release: «Мой налог» tax export (v1.27.0) #296

Merged
developer merged 7 commits from development into master 2026-07-28 15:07:27 +00:00
10 changed files with 105 additions and 68 deletions
Showing only changes of commit 69bddaeb9a - Show all commits
+4
View File
@@ -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 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 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. 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` - **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 (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. of the month belongs to the *next* one.
+3 -3
View File
@@ -54,10 +54,10 @@ func TestRendererRendersEveryPage(t *testing.T) {
SignedIn: true, INN: "770000000000", AutoEnabled: true, TimeZone: "Europe/Moscow", SignedIn: true, INN: "770000000000", AutoEnabled: true, TimeZone: "Europe/Moscow",
LastOK: "2026-07-28 12:00", LastOK: "2026-07-28 12:00",
Backlog: MyNalogBacklogRow{Count: 2, Amount: "300.00 RUB", Oldest: "2026-06-30", Overdue: true}, 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)`}}, Amount: "149.00 RUB", Name: `Внутриигровая валюта: "Фишка", 100 шт. (ID: 0198c1f2)`}},
Cancels: []MyNalogCancelRow{{LedgerID: "l2", AccountID: "a2", ReceiptUUID: "20abcdef01", Failed: true}}, Cancels: []MyNalogCancelRow{{LedgerID: "l2", AccountID: "a2", AccountShort: "a2", ReceiptUUID: "20abcdef01", Failed: true}},
Attention: []MyNalogRow{{LedgerID: "l3", AccountID: "a3", At: "2026-07-27 09:00", 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"}}, Amount: "99.00 RUB", Name: "x (ID: 0198c1f3)", Attempts: 2, LastError: "timeout"}},
}, "Needs attention"}, }, "Needs attention"},
{"mynalog", MyNalogView{SignedIn: true, TimeZone: "Europe/Moscow", Paused: true, {"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}} {{range .Rows}}
<tr> <tr>
<td>{{.At}}</td> <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>{{.Kind}}</td>
<td>{{.Money}}</td> <td>{{.Money}}</td>
<td>{{.ChipsDelta}}</td> <td>{{.ChipsDelta}}</td>
@@ -79,7 +79,7 @@ created; entering a receipt number means you filed it by hand.</p>
<tbody> <tbody>
{{range .Cancels}} {{range .Cancels}}
<tr> <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><code>{{.ReceiptUUID}}</code></td>
<td>{{if .Failed}}<span class="note">a previous attempt was refused</span>{{else}}awaiting{{end}}</td> <td>{{if .Failed}}<span class="note">a previous attempt was refused</span>{{else}}awaiting{{end}}</td>
</tr> </tr>
@@ -96,7 +96,7 @@ created; entering a receipt number means you filed it by hand.</p>
{{range .Owed}} {{range .Owed}}
<tr> <tr>
<td>{{.At}}</td> <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 class="num">{{.Amount}}</td>
<td><code>{{.Name}}</code></td> <td><code>{{.Name}}</code></td>
<td class="num">{{.Attempts}}</td> <td class="num">{{.Attempts}}</td>
+31 -26
View File
@@ -785,18 +785,21 @@ type LedgerView struct {
// spend, an admin grant, a rewarded-video credit). Refundable marks a funded order the operator may // 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. // still reverse, which is what puts the Refund button on that row.
type LedgerRow struct { type LedgerRow struct {
At string At string
AccountID string // AccountID is the link target; AccountShort is what the cell shows, because a ledger row has
Kind string // no room for a full uuid and the operator mostly needs to spot two rows from the same buyer.
Source string AccountID string
Origin string AccountShort string
ChipsDelta int Kind string
Money string Source string
Title string Origin string
Order string ChipsDelta int
Provider string Money string
Shop string Title string
Refundable bool Order string
Provider string
Shop string
Refundable bool
} }
// LedgerTotalsRow is the summary above the table, over everything the filter matches. Money is // 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 // 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. // later automatic recovery would not recognise the receipt.
type MyNalogRow struct { type MyNalogRow struct {
LedgerID string LedgerID string
AccountID string AccountID string
At string AccountShort string
Amount string At string
Name string Amount string
Status string Name string
Attempts int Status string
LastError string Attempts int
ReceiptUUID string LastError string
ReceiptURL string ReceiptUUID string
ReceiptURL string
} }
// MyNalogCancelRow is one registered receipt whose income has been refunded and which is therefore // MyNalogCancelRow is one registered receipt whose income has been refunded and which is therefore
// owed an annulment. // owed an annulment.
type MyNalogCancelRow struct { type MyNalogCancelRow struct {
LedgerID string LedgerID string
AccountID string AccountID string
ReceiptUUID string AccountShort string
Failed bool ReceiptUUID string
Failed bool
} }
+16 -2
View File
@@ -675,8 +675,22 @@ func TestMyNalogBuyerMails(t *testing.T) {
if got := len(box.to(addr)); got != 2 { if got := len(box.to(addr)); got != 2 {
t.Fatalf("letters after filing = %d, want 2", got) t.Fatalf("letters after filing = %d, want 2", got)
} }
if !strings.Contains(box.to(addr)[1].Text, "/api/v1/receipt/770000000000/") { receipt := box.to(addr)[1]
t.Errorf("the receipt letter carries no receipt link:\n%s", box.to(addr)[1].Text) 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. // 3. The annulment letter after a refund.
+10 -10
View File
@@ -164,7 +164,7 @@ func (m *Mailer) sendReceiptMails(ctx context.Context, cancelled bool) {
m.stampReceipt(ctx, mark, r.LedgerID) m.stampReceipt(ctx, mark, r.LedgerID)
continue continue
} }
subject, body := "Эрудит — чек от налоговой", receiptText(r, stored.INN, m.baseURL, m.loc) subject, body := "Эрудит — Ваш чек покупки", receiptText(r, stored.INN, m.baseURL, m.loc)
if cancelled { if cancelled {
subject, body = "Эрудит — чек аннулирован", cancelText(r, m.loc) 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 // receiptText is the fiscal receipt letter, which is how the receipt is actually handed to the
// buyer as the professional-income tax regime requires. // 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 { func receiptText(r payments.ReceiptMail, inn, baseURL string, loc *time.Location) string {
return fmt.Sprintf(`Здравствуйте! return fmt.Sprintf(`Здравствуйте!
По вашей покупке сформирован чек в налоговой службе. Покупка на сумму: %s от %s
Ссылка на чек: %s
%s Спасибо, что играете в «Эрудит».
Дата: %s `, formatAmount(r.AmountMinor, r.Currency),
Чек: %s r.OperationTime.In(loc).Format("02.01.2006 15:04 -07:00"),
Продавец применяет налог на профессиональный доход; чек сформирован в сервисе
«Мой налог» Федеральной налоговой службы.
`, describe(r.Title, r.AmountMinor, r.Currency),
r.OperationTime.In(loc).Format("02.01.2006 15:04"),
mynalog.ReceiptURL(baseURL, inn, r.ReceiptUUID)) mynalog.ReceiptURL(baseURL, inn, r.ReceiptUUID))
} }
@@ -1375,6 +1375,17 @@ func normalizeComplaintStatus(s string) string {
return "" 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. // fmtTime formats a timestamp for display, or "" when zero.
func fmtTime(t time.Time) string { func fmtTime(t time.Time) string {
if t.IsZero() { if t.IsZero() {
@@ -130,17 +130,18 @@ func (s *Server) consoleLedger(c *gin.Context) {
// the template — only a funded order can be reversed. // the template — only a funded order can be reversed.
func ledgerRow(r payments.LedgerReportRow) adminconsole.LedgerRow { func ledgerRow(r payments.LedgerReportRow) adminconsole.LedgerRow {
row := adminconsole.LedgerRow{ row := adminconsole.LedgerRow{
At: fmtTime(r.CreatedAt), At: fmtTime(r.CreatedAt),
AccountID: r.AccountID.String(), AccountID: r.AccountID.String(),
Kind: r.Kind, AccountShort: shortID(r.AccountID.String()),
Source: r.Source, Kind: r.Kind,
Origin: r.Origin, Source: r.Source,
ChipsDelta: r.ChipsDelta, Origin: r.Origin,
Order: r.OrderID, ChipsDelta: r.ChipsDelta,
Provider: r.Provider, Order: r.OrderID,
Shop: r.Shop, Provider: r.Provider,
Refundable: r.Kind == "fund" && r.OrderID != "", Shop: r.Shop,
Title: snapshotTitle(r.Snapshot), Refundable: r.Kind == "fund" && r.OrderID != "",
Title: snapshotTitle(r.Snapshot),
} }
if r.HasMoney() { if r.HasMoney() {
row.Money = fmtMoney(r.Money) row.Money = fmtMoney(r.Money)
@@ -59,10 +59,11 @@ func (s *Server) consoleMyNalog(c *gin.Context) {
} }
for _, cn := range queue.Cancels { for _, cn := range queue.Cancels {
view.Cancels = append(view.Cancels, adminconsole.MyNalogCancelRow{ view.Cancels = append(view.Cancels, adminconsole.MyNalogCancelRow{
LedgerID: cn.LedgerID.String(), LedgerID: cn.LedgerID.String(),
AccountID: cn.AccountID.String(), AccountID: cn.AccountID.String(),
ReceiptUUID: cn.ReceiptUUID, AccountShort: shortID(cn.AccountID.String()),
Failed: cn.CancelStatus == payments.MyNalogCancelFailed, 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. // the tax service has issued one.
func (s *Server) myNalogRow(in payments.MyNalogIncome, inn string, loc *time.Location) adminconsole.MyNalogRow { func (s *Server) myNalogRow(in payments.MyNalogIncome, inn string, loc *time.Location) adminconsole.MyNalogRow {
row := adminconsole.MyNalogRow{ row := adminconsole.MyNalogRow{
LedgerID: in.LedgerID.String(), LedgerID: in.LedgerID.String(),
AccountID: in.AccountID.String(), AccountID: in.AccountID.String(),
At: in.OperationTime.In(loc).Format("2006-01-02 15:04"), AccountShort: shortID(in.AccountID.String()),
Amount: in.Amount.String(), At: in.OperationTime.In(loc).Format("2006-01-02 15:04"),
Name: s.mynalog.ReceiptName(in), Amount: in.Amount.String(),
Status: in.Status, Name: s.mynalog.ReceiptName(in),
Attempts: in.Attempts, Status: in.Status,
LastError: in.LastError, Attempts: in.Attempts,
ReceiptUUID: in.ReceiptUUID, LastError: in.LastError,
ReceiptUUID: in.ReceiptUUID,
} }
if in.ReceiptUUID != "" && inn != "" { if in.ReceiptUUID != "" && inn != "" {
row.ReceiptURL = s.mynalog.ReceiptURL(inn, in.ReceiptUUID) row.ReceiptURL = s.mynalog.ReceiptURL(inn, in.ReceiptUUID)