From 69bddaeb9ab32e7f9263f714147716987444dcf4 Mon Sep 17 00:00:00 2001
From: Ilia Denisov
Date: Tue, 28 Jul 2026 16:44:44 +0200
Subject: [PATCH] fix(admin): shorten the receipt letter, show the account id
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The receipt letter repeated what the receipt itself states. It now carries the
amount, the moment of payment with its offset, and the link — the receipt is
the document, and it opens without authentication, so there is nothing for the
two to disagree about. Subject reworded to name what it is.
The ledger and tax-export tables labelled the account link "card", meaning the
user card. On pages about payments that reads as a bank card, which is exactly
how it was misread. Both now show the first eight characters of the account id,
so the column carries data — two rows from the same buyer are visible at a
glance — while the link still holds the whole id.
---
.claude/CLAUDE.md | 4 ++
backend/internal/adminconsole/render_test.go | 6 +-
.../templates/pages/ledger.gohtml | 2 +-
.../templates/pages/mynalog.gohtml | 4 +-
backend/internal/adminconsole/views.go | 57 ++++++++++---------
backend/internal/inttest/mynalog_test.go | 18 +++++-
backend/internal/mynalogsync/mailer.go | 20 +++----
.../internal/server/handlers_admin_console.go | 11 ++++
.../internal/server/handlers_admin_ledger.go | 23 ++++----
.../internal/server/handlers_admin_mynalog.go | 28 ++++-----
10 files changed, 105 insertions(+), 68 deletions(-)
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)