Перейти к содержанию

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_PAYMENTinvoiceNote/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-ограничение продолжает действовать:

ALTER TABLE "pm"."tx_history" ALTER COLUMN "direction" SET DATA TYPE varchar(6);

Добавление колонки 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_idpm.intent.id. Один intent → 1..N строк tx_history. Существование строк — индикатор успешного settlement в TigerBeetle.
  • account_namepm.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):

SELECT i.id
FROM pm.intent i
LEFT JOIN pm.tx_history h
  ON h.intent_id = i.id AND h.direction = 'DEBIT'
WHERE i.status = 'SETTLED' AND h.id IS NULL;