feat(payments): report direct-rail income to «Мой налог» #295
@@ -292,6 +292,10 @@ The native Android app is a Capacitor 8 wrapper of the `ui` SPA, scaffolded unde
|
||||
millisecond clock — the first eight are shared by every order in the same ~65 seconds, which
|
||||
would make two purchases indistinguishable to the probe. An unresolved outcome halts the
|
||||
whole queue on purpose; a blind retry is the one thing that must never happen.
|
||||
- **The printable receipt link IS public** — `GET /api/v1/receipt/<ИНН>/<uuid>/print` opens with
|
||||
no authentication (verified on prod 2026-07-28; online sources contradict each other on this,
|
||||
so do not "fix" it back). It is the same URL the «Мой налог» app hands out under «копировать в
|
||||
буфер», which is why the buyer's receipt letter can simply carry it.
|
||||
- **The receipt's time zone decides the tax month.** The ledger is UTC; `BACKEND_MYNALOG_TZ`
|
||||
(default `Europe/Moscow`) is what a payment is filed under, so a 23:30 UTC payment on the last
|
||||
of the month belongs to the *next* one.
|
||||
|
||||
@@ -54,10 +54,10 @@ func TestRendererRendersEveryPage(t *testing.T) {
|
||||
SignedIn: true, INN: "770000000000", AutoEnabled: true, TimeZone: "Europe/Moscow",
|
||||
LastOK: "2026-07-28 12:00",
|
||||
Backlog: MyNalogBacklogRow{Count: 2, Amount: "300.00 RUB", Oldest: "2026-06-30", Overdue: true},
|
||||
Owed: []MyNalogRow{{LedgerID: "l1", AccountID: "a1", At: "2026-07-28 12:00",
|
||||
Owed: []MyNalogRow{{LedgerID: "l1", AccountID: "a1", AccountShort: "a1", At: "2026-07-28 12:00",
|
||||
Amount: "149.00 RUB", Name: `Внутриигровая валюта: "Фишка", 100 шт. (ID: 0198c1f2)`}},
|
||||
Cancels: []MyNalogCancelRow{{LedgerID: "l2", AccountID: "a2", ReceiptUUID: "20abcdef01", Failed: true}},
|
||||
Attention: []MyNalogRow{{LedgerID: "l3", AccountID: "a3", At: "2026-07-27 09:00",
|
||||
Cancels: []MyNalogCancelRow{{LedgerID: "l2", AccountID: "a2", AccountShort: "a2", ReceiptUUID: "20abcdef01", Failed: true}},
|
||||
Attention: []MyNalogRow{{LedgerID: "l3", AccountID: "a3", AccountShort: "a3", At: "2026-07-27 09:00",
|
||||
Amount: "99.00 RUB", Name: "x (ID: 0198c1f3)", Attempts: 2, LastError: "timeout"}},
|
||||
}, "Needs attention"},
|
||||
{"mynalog", MyNalogView{SignedIn: true, TimeZone: "Europe/Moscow", Paused: true,
|
||||
|
||||
@@ -40,7 +40,7 @@ Money in: {{range .Totals.MoneyIn}}<strong>{{.}}</strong> {{else}}<span class="n
|
||||
{{range .Rows}}
|
||||
<tr>
|
||||
<td>{{.At}}</td>
|
||||
<td><a href="/_gm/users/{{.AccountID}}">card</a></td>
|
||||
<td><a href="/_gm/users/{{.AccountID}}"><code>{{.AccountShort}}</code></a></td>
|
||||
<td>{{.Kind}}</td>
|
||||
<td>{{.Money}}</td>
|
||||
<td>{{.ChipsDelta}}</td>
|
||||
|
||||
@@ -79,7 +79,7 @@ created; entering a receipt number means you filed it by hand.</p>
|
||||
<tbody>
|
||||
{{range .Cancels}}
|
||||
<tr>
|
||||
<td><a href="/_gm/users/{{.AccountID}}">card</a></td>
|
||||
<td><a href="/_gm/users/{{.AccountID}}"><code>{{.AccountShort}}</code></a></td>
|
||||
<td><code>{{.ReceiptUUID}}</code></td>
|
||||
<td>{{if .Failed}}<span class="note">a previous attempt was refused</span>{{else}}awaiting{{end}}</td>
|
||||
</tr>
|
||||
@@ -96,7 +96,7 @@ created; entering a receipt number means you filed it by hand.</p>
|
||||
{{range .Owed}}
|
||||
<tr>
|
||||
<td>{{.At}}</td>
|
||||
<td><a href="/_gm/users/{{.AccountID}}">card</a></td>
|
||||
<td><a href="/_gm/users/{{.AccountID}}"><code>{{.AccountShort}}</code></a></td>
|
||||
<td class="num">{{.Amount}}</td>
|
||||
<td><code>{{.Name}}</code></td>
|
||||
<td class="num">{{.Attempts}}</td>
|
||||
|
||||
@@ -786,7 +786,10 @@ type LedgerView struct {
|
||||
// still reverse, which is what puts the Refund button on that row.
|
||||
type LedgerRow struct {
|
||||
At string
|
||||
// AccountID is the link target; AccountShort is what the cell shows, because a ledger row has
|
||||
// no room for a full uuid and the operator mostly needs to spot two rows from the same buyer.
|
||||
AccountID string
|
||||
AccountShort string
|
||||
Kind string
|
||||
Source string
|
||||
Origin string
|
||||
@@ -843,6 +846,7 @@ type MyNalogBacklogRow struct {
|
||||
type MyNalogRow struct {
|
||||
LedgerID string
|
||||
AccountID string
|
||||
AccountShort string
|
||||
At string
|
||||
Amount string
|
||||
Name string
|
||||
@@ -858,6 +862,7 @@ type MyNalogRow struct {
|
||||
type MyNalogCancelRow struct {
|
||||
LedgerID string
|
||||
AccountID string
|
||||
AccountShort string
|
||||
ReceiptUUID string
|
||||
Failed bool
|
||||
}
|
||||
|
||||
@@ -675,8 +675,22 @@ func TestMyNalogBuyerMails(t *testing.T) {
|
||||
if got := len(box.to(addr)); got != 2 {
|
||||
t.Fatalf("letters after filing = %d, want 2", got)
|
||||
}
|
||||
if !strings.Contains(box.to(addr)[1].Text, "/api/v1/receipt/770000000000/") {
|
||||
t.Errorf("the receipt letter carries no receipt link:\n%s", box.to(addr)[1].Text)
|
||||
receipt := box.to(addr)[1]
|
||||
if receipt.Subject != "Эрудит — Ваш чек покупки" {
|
||||
t.Errorf("receipt letter subject = %q", receipt.Subject)
|
||||
}
|
||||
if !strings.Contains(receipt.Text, "/api/v1/receipt/770000000000/") {
|
||||
t.Errorf("the receipt letter carries no receipt link:\n%s", receipt.Text)
|
||||
}
|
||||
// The amount carries its time-zone offset, because that offset is what decides the tax period
|
||||
// the receipt belongs to and the buyer should see the same moment the receipt states.
|
||||
if !strings.Contains(receipt.Text, "Покупка на сумму: 149.00 ₽ от ") ||
|
||||
!strings.Contains(receipt.Text, "+03:00") {
|
||||
t.Errorf("the receipt letter's amount line is wrong:\n%s", receipt.Text)
|
||||
}
|
||||
// Nothing beyond the amount, the moment and the link: the receipt is the document.
|
||||
if strings.Contains(receipt.Text, "test pack") {
|
||||
t.Errorf("the receipt letter repeats the catalog title; the receipt itself carries it:\n%s", receipt.Text)
|
||||
}
|
||||
|
||||
// 3. The annulment letter after a refund.
|
||||
|
||||
@@ -164,7 +164,7 @@ func (m *Mailer) sendReceiptMails(ctx context.Context, cancelled bool) {
|
||||
m.stampReceipt(ctx, mark, r.LedgerID)
|
||||
continue
|
||||
}
|
||||
subject, body := "Эрудит — чек от налоговой", receiptText(r, stored.INN, m.baseURL, m.loc)
|
||||
subject, body := "Эрудит — Ваш чек покупки", receiptText(r, stored.INN, m.baseURL, m.loc)
|
||||
if cancelled {
|
||||
subject, body = "Эрудит — чек аннулирован", cancelText(r, m.loc)
|
||||
}
|
||||
@@ -232,19 +232,19 @@ func purchaseText(p payments.PurchaseMail) string {
|
||||
|
||||
// receiptText is the fiscal receipt letter, which is how the receipt is actually handed to the
|
||||
// buyer as the professional-income tax regime requires.
|
||||
//
|
||||
// It is deliberately bare: the amount, the moment of payment with its offset, and the link. The
|
||||
// receipt itself is the document and it opens without authentication, so repeating its contents
|
||||
// here would only add something for the two to disagree about.
|
||||
func receiptText(r payments.ReceiptMail, inn, baseURL string, loc *time.Location) string {
|
||||
return fmt.Sprintf(`Здравствуйте!
|
||||
|
||||
По вашей покупке сформирован чек в налоговой службе.
|
||||
Покупка на сумму: %s от %s
|
||||
Ссылка на чек: %s
|
||||
|
||||
%s
|
||||
Дата: %s
|
||||
Чек: %s
|
||||
|
||||
Продавец применяет налог на профессиональный доход; чек сформирован в сервисе
|
||||
«Мой налог» Федеральной налоговой службы.
|
||||
`, describe(r.Title, r.AmountMinor, r.Currency),
|
||||
r.OperationTime.In(loc).Format("02.01.2006 15:04"),
|
||||
Спасибо, что играете в «Эрудит».
|
||||
`, formatAmount(r.AmountMinor, r.Currency),
|
||||
r.OperationTime.In(loc).Format("02.01.2006 15:04 -07:00"),
|
||||
mynalog.ReceiptURL(baseURL, inn, r.ReceiptUUID))
|
||||
}
|
||||
|
||||
|
||||
@@ -1375,6 +1375,17 @@ func normalizeComplaintStatus(s string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// shortID abbreviates an account id for a table cell: enough to recognise the same buyer across
|
||||
// rows at a glance, while the link still carries the whole id. Money tables have no room for a full
|
||||
// uuid, and a generic label there ("card") read as a payment card on the very pages about payments.
|
||||
func shortID(id string) string {
|
||||
const n = 8
|
||||
if len(id) <= n {
|
||||
return id
|
||||
}
|
||||
return id[:n]
|
||||
}
|
||||
|
||||
// fmtTime formats a timestamp for display, or "" when zero.
|
||||
func fmtTime(t time.Time) string {
|
||||
if t.IsZero() {
|
||||
|
||||
@@ -132,6 +132,7 @@ func ledgerRow(r payments.LedgerReportRow) adminconsole.LedgerRow {
|
||||
row := adminconsole.LedgerRow{
|
||||
At: fmtTime(r.CreatedAt),
|
||||
AccountID: r.AccountID.String(),
|
||||
AccountShort: shortID(r.AccountID.String()),
|
||||
Kind: r.Kind,
|
||||
Source: r.Source,
|
||||
Origin: r.Origin,
|
||||
|
||||
@@ -61,6 +61,7 @@ func (s *Server) consoleMyNalog(c *gin.Context) {
|
||||
view.Cancels = append(view.Cancels, adminconsole.MyNalogCancelRow{
|
||||
LedgerID: cn.LedgerID.String(),
|
||||
AccountID: cn.AccountID.String(),
|
||||
AccountShort: shortID(cn.AccountID.String()),
|
||||
ReceiptUUID: cn.ReceiptUUID,
|
||||
Failed: cn.CancelStatus == payments.MyNalogCancelFailed,
|
||||
})
|
||||
@@ -99,6 +100,7 @@ func (s *Server) myNalogRow(in payments.MyNalogIncome, inn string, loc *time.Loc
|
||||
row := adminconsole.MyNalogRow{
|
||||
LedgerID: in.LedgerID.String(),
|
||||
AccountID: in.AccountID.String(),
|
||||
AccountShort: shortID(in.AccountID.String()),
|
||||
At: in.OperationTime.In(loc).Format("2006-01-02 15:04"),
|
||||
Amount: in.Amount.String(),
|
||||
Name: s.mynalog.ReceiptName(in),
|
||||
|
||||
Reference in New Issue
Block a user