release: «Мой налог» tax export (v1.27.0) #296
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user