feat(payments): report direct-rail income to «Мой налог» #295
@@ -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.
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user