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)