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
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.
+3 -3
View File
@@ -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>
+5
View File
@@ -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
}
+16 -2
View File
@@ -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.
+10 -10
View File
@@ -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),