diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index bfeeaaf..89e2961 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -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/<ИНН>//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. diff --git a/backend/internal/adminconsole/render_test.go b/backend/internal/adminconsole/render_test.go index 0bd8117..6808d28 100644 --- a/backend/internal/adminconsole/render_test.go +++ b/backend/internal/adminconsole/render_test.go @@ -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, diff --git a/backend/internal/adminconsole/templates/pages/ledger.gohtml b/backend/internal/adminconsole/templates/pages/ledger.gohtml index 5dcb79b..637da96 100644 --- a/backend/internal/adminconsole/templates/pages/ledger.gohtml +++ b/backend/internal/adminconsole/templates/pages/ledger.gohtml @@ -40,7 +40,7 @@ Money in: {{range .Totals.MoneyIn}}{{.}} {{else}}card +{{.AccountShort}} {{.Kind}} {{.Money}} {{.ChipsDelta}} diff --git a/backend/internal/adminconsole/templates/pages/mynalog.gohtml b/backend/internal/adminconsole/templates/pages/mynalog.gohtml index 5e5c243..2cf2bcc 100644 --- a/backend/internal/adminconsole/templates/pages/mynalog.gohtml +++ b/backend/internal/adminconsole/templates/pages/mynalog.gohtml @@ -79,7 +79,7 @@ created; entering a receipt number means you filed it by hand.

{{range .Cancels}} -card +{{.AccountShort}} {{.ReceiptUUID}} {{if .Failed}}a previous attempt was refused{{else}}awaiting{{end}} @@ -96,7 +96,7 @@ created; entering a receipt number means you filed it by hand.

{{range .Owed}} {{.At}} -card +{{.AccountShort}} {{.Amount}} {{.Name}} {{.Attempts}} diff --git a/backend/internal/adminconsole/views.go b/backend/internal/adminconsole/views.go index f6379db..3d1613e 100644 --- a/backend/internal/adminconsole/views.go +++ b/backend/internal/adminconsole/views.go @@ -785,18 +785,21 @@ type LedgerView struct { // spend, an admin grant, a rewarded-video credit). Refundable marks a funded order the operator may // still reverse, which is what puts the Refund button on that row. type LedgerRow struct { - At string - AccountID string - Kind string - Source string - Origin string - ChipsDelta int - Money string - Title string - Order string - Provider string - Shop string - Refundable bool + 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 + ChipsDelta int + Money string + Title string + Order string + Provider string + Shop string + Refundable bool } // LedgerTotalsRow is the summary above the table, over everything the filter matches. Money is @@ -841,23 +844,25 @@ type MyNalogBacklogRow struct { // sent, which is also what an operator must type when filing by hand — the two have to match, or a // later automatic recovery would not recognise the receipt. type MyNalogRow struct { - LedgerID string - AccountID string - At string - Amount string - Name string - Status string - Attempts int - LastError string - ReceiptUUID string - ReceiptURL string + LedgerID string + AccountID string + AccountShort string + At string + Amount string + Name string + Status string + Attempts int + LastError string + ReceiptUUID string + ReceiptURL string } // MyNalogCancelRow is one registered receipt whose income has been refunded and which is therefore // owed an annulment. type MyNalogCancelRow struct { - LedgerID string - AccountID string - ReceiptUUID string - Failed bool + LedgerID string + AccountID string + AccountShort string + ReceiptUUID string + Failed bool } diff --git a/backend/internal/inttest/mynalog_test.go b/backend/internal/inttest/mynalog_test.go index adff85e..8828bb8 100644 --- a/backend/internal/inttest/mynalog_test.go +++ b/backend/internal/inttest/mynalog_test.go @@ -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. diff --git a/backend/internal/mynalogsync/mailer.go b/backend/internal/mynalogsync/mailer.go index 085793f..688f5d0 100644 --- a/backend/internal/mynalogsync/mailer.go +++ b/backend/internal/mynalogsync/mailer.go @@ -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)) } diff --git a/backend/internal/server/handlers_admin_console.go b/backend/internal/server/handlers_admin_console.go index e308521..ab25b9a 100644 --- a/backend/internal/server/handlers_admin_console.go +++ b/backend/internal/server/handlers_admin_console.go @@ -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() { diff --git a/backend/internal/server/handlers_admin_ledger.go b/backend/internal/server/handlers_admin_ledger.go index de43173..b37d509 100644 --- a/backend/internal/server/handlers_admin_ledger.go +++ b/backend/internal/server/handlers_admin_ledger.go @@ -130,17 +130,18 @@ func (s *Server) consoleLedger(c *gin.Context) { // the template — only a funded order can be reversed. func ledgerRow(r payments.LedgerReportRow) adminconsole.LedgerRow { row := adminconsole.LedgerRow{ - At: fmtTime(r.CreatedAt), - AccountID: r.AccountID.String(), - Kind: r.Kind, - Source: r.Source, - Origin: r.Origin, - ChipsDelta: r.ChipsDelta, - Order: r.OrderID, - Provider: r.Provider, - Shop: r.Shop, - Refundable: r.Kind == "fund" && r.OrderID != "", - Title: snapshotTitle(r.Snapshot), + At: fmtTime(r.CreatedAt), + AccountID: r.AccountID.String(), + AccountShort: shortID(r.AccountID.String()), + Kind: r.Kind, + Source: r.Source, + Origin: r.Origin, + ChipsDelta: r.ChipsDelta, + Order: r.OrderID, + Provider: r.Provider, + Shop: r.Shop, + Refundable: r.Kind == "fund" && r.OrderID != "", + Title: snapshotTitle(r.Snapshot), } if r.HasMoney() { row.Money = fmtMoney(r.Money) diff --git a/backend/internal/server/handlers_admin_mynalog.go b/backend/internal/server/handlers_admin_mynalog.go index 79abeb2..bf9cb06 100644 --- a/backend/internal/server/handlers_admin_mynalog.go +++ b/backend/internal/server/handlers_admin_mynalog.go @@ -59,10 +59,11 @@ func (s *Server) consoleMyNalog(c *gin.Context) { } for _, cn := range queue.Cancels { view.Cancels = append(view.Cancels, adminconsole.MyNalogCancelRow{ - LedgerID: cn.LedgerID.String(), - AccountID: cn.AccountID.String(), - ReceiptUUID: cn.ReceiptUUID, - Failed: cn.CancelStatus == payments.MyNalogCancelFailed, + LedgerID: cn.LedgerID.String(), + AccountID: cn.AccountID.String(), + AccountShort: shortID(cn.AccountID.String()), + ReceiptUUID: cn.ReceiptUUID, + Failed: cn.CancelStatus == payments.MyNalogCancelFailed, }) } @@ -97,15 +98,16 @@ func fmtMinor(minor int64, cur payments.Currency) string { // the tax service has issued one. func (s *Server) myNalogRow(in payments.MyNalogIncome, inn string, loc *time.Location) adminconsole.MyNalogRow { row := adminconsole.MyNalogRow{ - LedgerID: in.LedgerID.String(), - AccountID: in.AccountID.String(), - At: in.OperationTime.In(loc).Format("2006-01-02 15:04"), - Amount: in.Amount.String(), - Name: s.mynalog.ReceiptName(in), - Status: in.Status, - Attempts: in.Attempts, - LastError: in.LastError, - ReceiptUUID: in.ReceiptUUID, + 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), + Status: in.Status, + Attempts: in.Attempts, + LastError: in.LastError, + ReceiptUUID: in.ReceiptUUID, } if in.ReceiptUUID != "" && inn != "" { row.ReceiptURL = s.mynalog.ReceiptURL(inn, in.ReceiptUUID)