03 tx history
Журнал движения средств: одна или несколько строк на каждый успешно проведённый intent — основа для выписки пользователя.
Имя таблицы¶
pm.tx_history
Назначение¶
tx_history — это денормализованный журнал транзакций для UI-выписки и сверки. На каждый успешно проведённый платёж пишется минимум одна строка (DEBIT со стороны плательщика) и, как правило, дополнительные строки (CREDIT получателю и/или CREDIT-строки на fee-аккаунты). Записи формируются строго одним местом — функцией writeSettlement() из src/intent/settlement-writer.ts, вызываемой синхронно из intent/handler.ts, intent/confirm-handler.ts и intent/outbox-worker.ts после успешного post_pending в TigerBeetle. Никакой другой код в tx_history не пишет — это инвариант, на который опираются все читатели (UI Flutter-приложения, выписка Admin Panel, аналитика).
Колонка attributes (jsonb) — это PII-gate: каждый operationType сам решает, какие поля из intent.metadata безопасно проецируются в историю через хук projectHistory(metadata, direction). Например, NFC_CHARGE кладёт nfcTagId только в DEBIT-строку, INVOICE_PAYMENT — invoiceNote/posTerminalId. По колонке attributes->>'nfcTagId' построен функциональный индекс, обслуживающий аналитику NFC-операций.
DDL¶
Создание таблицы (миграция 0000_init.sql, строки 124–139):
CREATE TABLE "pm"."tx_history" (
"id" serial PRIMARY KEY NOT NULL,
"intent_id" uuid NOT NULL,
"user_id" integer NOT NULL,
"account_name" varchar(100) NOT NULL,
"operation_type" varchar(50) NOT NULL,
"direction" char(6) NOT NULL,
"amount" bigint NOT NULL,
"fee_amount" bigint DEFAULT 0 NOT NULL,
"currency" char(3) DEFAULT 'THB' NOT NULL,
"from_name" varchar(255),
"to_name" varchar(255),
"comment" varchar(500),
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "tx_history_direction_chk"
CHECK ("pm"."tx_history"."direction" IN ('DEBIT', 'CREDIT'))
);
CREATE INDEX "tx_history_user_id_created_at_idx"
ON "pm"."tx_history" USING btree ("user_id","created_at");
CREATE INDEX "tx_history_intent_id_idx"
ON "pm"."tx_history" USING btree ("intent_id");
Изменение типа direction с char(6) → varchar(6) (миграция 0004_fix_tx_history_direction_char.sql) — char(6) дополнял DEBIT пробелом справа, что ломало сравнения; CHECK-ограничение продолжает действовать:
Добавление колонки attributes и функционального индекса по nfcTagId (миграция 0006_rapid_millenium_guard.sql):
ALTER TABLE "pm"."tx_history" ADD COLUMN "attributes" jsonb;
CREATE INDEX "tx_history_nfc_tag_idx"
ON "pm"."tx_history"
USING btree ((("attributes" ->> 'nfcTagId')::int),"created_at");
Поля¶
| Колонка | Тип | NULL | Default | Назначение |
|---|---|---|---|---|
id |
serial |
NOT NULL | seq | PK строки. |
intent_id |
uuid |
NOT NULL | — | Связь со строкой pm.intent.id. Несколько строк на один intent_id — норма (DEBIT + CREDIT + fee). |
user_id |
integer |
NOT NULL | — | Владелец строки. Для DEBIT — плательщик; для CREDIT — получатель или владелец fee-аккаунта (для системных аккаунтов без владельца пишется 0). |
account_name |
varchar(100) |
NOT NULL | — | Имя TB-аккаунта (user.<id>.THB, merchant.<id>.settlement.THB, nostro.*, revenue.*). |
operation_type |
varchar(50) |
NOT NULL | — | Тип операции из реестра operation-types (P2P_TRANSFER, NFC_CHARGE, INVOICE_PAYMENT, WITHDRAWAL, ADMIN_TRANSFER, MINIAPP, SERVICE_TRANSFER, IPPS_*). |
direction |
varchar(6) |
NOT NULL | — | DEBIT (списание со счёта account_name) или CREDIT (зачисление). Гарантировано CHECK-ограничением. |
amount |
bigint |
NOT NULL | — | Сумма движения в минорных единицах (для CREDIT получателю — нетто после POST-fee, для DEBIT плательщика — gross). |
fee_amount |
bigint |
NOT NULL | 0 |
Сумма комиссий, относящихся к строке (PRE для DEBIT плательщика, POST для CREDIT получателя; на fee-CREDIT-строках всегда 0). |
currency |
char(3) |
NOT NULL | 'THB' |
ISO-валюта (на момент написания — только THB). |
from_name |
varchar(255) |
NULL | — | Отображаемое имя отправителя (для UI; берётся из intent.from_name). |
to_name |
varchar(255) |
NULL | — | Отображаемое имя получателя (для UI; берётся из intent.to_name). На fee-строках всегда NULL. |
comment |
varchar(500) |
NULL | — | Комментарий пользователя; для fee-строк подставляется "Fee: <operationType>". |
attributes |
jsonb |
NULL | — | Спроецированные operationType-специфичные поля из intent.metadata через хук projectHistory(). PII-gate: только разрешённые поля. |
created_at |
timestamptz |
NOT NULL | now() |
Момент записи (фактически совпадает с моментом успешного post_pending в TB). |
Индексы¶
| Индекс | Поля | Назначение |
|---|---|---|
tx_history_pkey |
(id) |
PK. |
tx_history_user_id_created_at_idx |
(user_id, created_at) |
Основной индекс выписки: запрос последних N операций пользователя. |
tx_history_intent_id_idx |
(intent_id) |
Сборка всех строк (DEBIT/CREDIT/fee) одной операции — для детального экрана и сверки. |
tx_history_nfc_tag_idx |
((attributes->>'nfcTagId')::int, created_at) |
Функциональный индекс для NFC-аналитики: поиск операций по nfcTagId. Используется только для NFC_CHARGE, в остальных типах attributes->>'nfcTagId' отсутствует. |
tx_history_direction_chk |
CHECK direction IN ('DEBIT','CREDIT') |
Доменное ограничение значений (других не бывает). |
FK к pm.intent.id намеренно не объявлен в БД (см. 01-schema-overview.md) — связь логическая, поддерживается приложением.
Связи¶
intent_id→pm.intent.id. Один intent → 1..N строкtx_history. Существование строк — индикатор успешного settlement в TigerBeetle.account_name→pm.tb_account_map.account_name. Не FK; разрешениеuserIdдля DEBIT-строки админских операций (intent.user_id = 0) выполняется через этот lookup (см.resolveSenderUserId()вsettlement-writer.ts).user_id(опосредованно) →public.serverpod_users_id(схема Auth Center). PM никогда не пишет вpublic.*, читает только при необходимости.
Связанный код¶
| Файл | Роль |
|---|---|
src/intent/settlement-writer.ts |
Единственный writer. writeSettlement(db, intent, feeSplits) формирует DEBIT/CREDIT/fee-строки одним батч-инсертом. |
src/intent/handler.ts |
Вызывает writeSettlement() для синхронных каналов (INTERNAL, fee-only-операции). |
src/intent/confirm-handler.ts |
Вызывает writeSettlement() при подтверждении 2-фазных операций. |
src/intent/outbox-worker.ts |
Вызывает writeSettlement() при асинхронном post_pending после успеха PSP. |
src/operation-types/nfc-charge.ts |
projectHistory() → проецирует nfcTagId (только DEBIT), lat/lon, businessCategoryCode. |
src/operation-types/invoice-payment.ts |
projectHistory() → проецирует invoiceNote, posTerminalId, qrIssuedAt, appScope. |
src/operation-types/*.ts |
Каждый operationType опционально реализует projectHistory(metadata, direction) — выход кладётся в attributes. Если хук не реализован — attributes = {}. |
Примеры запросов¶
Последние 50 операций пользователя (использует tx_history_user_id_created_at_idx):
SELECT id, intent_id, account_name, operation_type, direction,
amount, fee_amount, currency, from_name, to_name, comment, created_at
FROM pm.tx_history
WHERE user_id = $1
ORDER BY created_at DESC
LIMIT 50;
Все строки одного intent (DEBIT + CREDIT + все fee-строки) — используется на детальном экране операции:
SELECT account_name, direction, amount, fee_amount, comment, attributes
FROM pm.tx_history
WHERE intent_id = $1
ORDER BY id;
Поиск операций по NFC-метке (использует функциональный индекс tx_history_nfc_tag_idx):
SELECT id, intent_id, user_id, amount, created_at
FROM pm.tx_history
WHERE (attributes->>'nfcTagId')::int = $1
AND operation_type = 'NFC_CHARGE'
ORDER BY created_at DESC
LIMIT 100;
Свод по operation_type и направлению за период (для отчёта Admin Panel):
SELECT operation_type, direction,
COUNT(*) AS rows,
SUM(amount) AS gross,
SUM(fee_amount) AS fees
FROM pm.tx_history
WHERE created_at >= $1 AND created_at < $2
GROUP BY operation_type, direction
ORDER BY operation_type, direction;
Сверка: для каждого intent.id в статусе SETTLED должна быть минимум одна DEBIT-строка (диагностика рассинхрона settlement-writer):