feat(payments): report income to «Мой налог»
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Failing after 24s
CI / ui (pull_request) Successful in 1m17s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Failing after 0s
CI / deploy (pull_request) Has been skipped

The direct rail runs on НПД, where the provider neither files with the tax
service nor issues a receipt — so nobody was doing it. This registers each
rouble purchase, annuls its receipt on a refund, and hands the buyer the
receipt by email.

Two properties of the (unofficial) lknpd API shape the design. Registering an
income takes no idempotency key, so an error does not mean nothing happened:
the service name is frozen before the call and carries a marker from the tail
of the order id, and after a failure the taxpayer's income list is searched
for that exact name. Found means filed; not found halts the queue for a human,
because declaring an income twice is as wrong as not declaring it. And faults
are classified rather than logged: a token is renewed silently, a throttle
backs off, an outage retries, but three unfixable rejections take the rail out
of service — a changed format must not become thousands of requests overnight.

The console button and the worker share one RunBatch. Automatic mode is armed
from the console, not from configuration, so the operator can watch a run go
through by hand first. A daily watchdog runs whether or not it is armed, since
the case it exists for is the export being off. An idle queue issues no call at
all — not even an authentication.

No payment path changed: the purchase letter rides the existing payment-event
outbox on its own cursor, the receipt and annulment letters ride the export
row. Decisions D53-D60.
This commit is contained in:
Ilia Denisov
2026-07-28 15:40:36 +02:00
parent c13f5cdb1e
commit e3c2e80a0a
35 changed files with 4926 additions and 13 deletions
@@ -0,0 +1,96 @@
-- «Мой налог» (НПД) export rail: the operator registers each rouble income with the tax service
-- and annuls its receipt on a refund. Two new tables plus one nullable column — strictly additive,
-- so it applies forward via goose with no data rewrite (no contour wipe) and an image rollback
-- simply ignores it. The ledger itself is untouched: it is append-only, so the export's working
-- state cannot live there.
-- +goose Up
-- The single stored «Мой налог» session and the rail's operating mode. The password is never
-- persisted: it is exchanged for a refresh token at login, and only that token is kept, encrypted
-- with BACKEND_MYNALOG_KEY. device_id pins the session to one synthetic "device" so repeated
-- logins do not look like a new one every time. Dropping the row logs the rail out and, by
-- design, also disarms the automatic mode.
CREATE TABLE payments.mynalog_session (
only_row boolean DEFAULT true NOT NULL,
inn text NOT NULL,
device_id text NOT NULL,
refresh_token_enc bytea NOT NULL,
auto_enabled boolean DEFAULT false NOT NULL,
paused_at timestamp with time zone,
pause_reason text DEFAULT '' NOT NULL,
last_ok_at timestamp with time zone,
last_alert_at timestamp with time zone,
last_alert_kind text DEFAULT '' NOT NULL,
updated_at timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT mynalog_session_pkey PRIMARY KEY (only_row),
CONSTRAINT mynalog_session_single_row_chk CHECK (only_row)
);
-- One row per income we act on, keyed on the ledger row it reports. The queue itself is NOT
-- materialised here — it is derived by joining the ledger against this table — so a row appears
-- only once we have decided something about that income.
--
-- receipt_name is the exact services[].name we sent, frozen BEFORE the request: the tax API has no
-- idempotency key, so after a timeout the only way to find out whether our receipt was created is
-- to search the tax service's own income list by that string. It carries the order id for exactly
-- that reason.
CREATE TABLE payments.mynalog_receipt (
ledger_id uuid NOT NULL,
order_id uuid NOT NULL,
account_id uuid NOT NULL,
status text NOT NULL,
receipt_uuid text,
receipt_name text NOT NULL,
operation_time timestamp with time zone NOT NULL,
amount_minor bigint NOT NULL,
currency text NOT NULL,
entered_manually boolean DEFAULT false NOT NULL,
attempts integer DEFAULT 0 NOT NULL,
last_error text DEFAULT '' NOT NULL,
sent_at timestamp with time zone,
receipt_mail_sent_at timestamp with time zone,
cancel_status text DEFAULT 'none' NOT NULL,
cancel_refund_ledger_id uuid,
cancelled_at timestamp with time zone,
cancel_mail_sent_at timestamp with time zone,
created_at timestamp with time zone DEFAULT now() NOT NULL,
updated_at timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT mynalog_receipt_pkey PRIMARY KEY (ledger_id),
CONSTRAINT mynalog_receipt_ledger_fkey FOREIGN KEY (ledger_id)
REFERENCES payments.ledger (ledger_id),
CONSTRAINT mynalog_receipt_order_fkey FOREIGN KEY (order_id)
REFERENCES payments.orders (order_id),
CONSTRAINT mynalog_receipt_refund_fkey FOREIGN KEY (cancel_refund_ledger_id)
REFERENCES payments.ledger (ledger_id),
CONSTRAINT mynalog_receipt_status_chk
CHECK ((status = ANY (ARRAY['sending'::text, 'sent'::text, 'unknown'::text,
'failed'::text, 'not_required'::text]))),
CONSTRAINT mynalog_receipt_cancel_status_chk
CHECK ((cancel_status = ANY (ARRAY['none'::text, 'cancelled'::text, 'failed'::text]))),
CONSTRAINT mynalog_receipt_amount_chk CHECK ((amount_minor >= 0)),
CONSTRAINT mynalog_receipt_attempts_chk CHECK ((attempts >= 0)),
-- A sent receipt must carry its uuid: without it the refund could never annul anything.
CONSTRAINT mynalog_receipt_sent_uuid_chk
CHECK ((status <> 'sent') OR (receipt_uuid IS NOT NULL AND receipt_uuid <> ''))
);
CREATE INDEX mynalog_receipt_status_idx ON payments.mynalog_receipt (status);
CREATE INDEX mynalog_receipt_cancel_idx ON payments.mynalog_receipt (cancel_status);
CREATE INDEX mynalog_receipt_order_idx ON payments.mynalog_receipt (order_id);
-- A second, independent cursor over the existing payment-event outbox: dispatched_at drives the
-- in-app wallet-refresh push, mailed_at drives the buyer's purchase-confirmation letter. The
-- column means "the mail decision has been made", not "a letter was sent" — an event that needs
-- no letter (a store rail, or an account with no confirmed address) is stamped and skipped.
ALTER TABLE payments.payment_events ADD COLUMN mailed_at timestamp with time zone;
CREATE INDEX payment_events_mail_idx
ON payments.payment_events (mailed_at)
WHERE mailed_at IS NULL;
-- +goose Down
DROP INDEX payments.payment_events_mail_idx;
ALTER TABLE payments.payment_events DROP COLUMN mailed_at;
DROP TABLE payments.mynalog_receipt;
DROP TABLE payments.mynalog_session;