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

02 intent

Корневая таблица оркестрации платежей: одно платёжное намерение = одна строка pm.intent.

Имя таблицы

pm.intent

Назначение

Хранит каждое платёжное намерение от его создания (CREATED) до финального состояния (SETTLED / FAILED / CANCELED / EXPIRED). Это корневая сущность всей платёжной саги: вокруг intent.id (UUID) собираются записи аудита (intent_event), денежной истории (tx_history), очереди двухфазных TB-трансферов (outbox_event) и PSP-state-машины (psp_tx_map).

  • Пишут: только Payment Manager:
  • src/intent/handler.tsPOST /intents (создание и однофазный flow).
  • src/intent/confirm-handler.tsPOST /intents/:id/confirm (двухфазный flow MERCHANT_INVOICE).
  • src/intent/cancel-handler.tsPOST /intents/:id/cancel (отмена инвойса мерчантом / админом).
  • воркеры в src/saga/ и src/psp/ — обновляют status, tb_transfer_ids, failure_reason.
  • Читают: PM (idempotency-replay, GET /intents/:id, GET /intents/:id/events), Admin Panel (через прямой read-only коннект к pm.*), сами воркеры PM.
  • Не пишет никто кроме PM. Внешние сервисы дёргают POST /intents с HMAC — но в БД попадают только через handler-ы PM.

DDL

DDL ниже — дословное склеивание миграций, в которых pm.intent создаётся / изменяется. Источник — каталог drizzle/migrations/.

Базовое создание — 0000_init.sql

CREATE TABLE "pm"."intent" (
    "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
    "idempotency_key" uuid NOT NULL,
    "service_id" varchar(50) NOT NULL,
    "user_id" integer NOT NULL,
    "operation_type" varchar(50) NOT NULL,
    "channel" varchar(50) NOT NULL,
    "from_account_name" varchar(100) NOT NULL,
    "to_account_name" varchar(100) NOT NULL,
    "external_ref" varchar(100),
    "amount" bigint NOT NULL,
    "currency" char(3) DEFAULT 'THB' NOT NULL,
    "tb_ledger" integer NOT NULL,
    "tb_transfer_ids" uuid[] DEFAULT '{}'::uuid[] NOT NULL,
    "status" varchar(20) DEFAULT 'CREATED' NOT NULL,
    "pre_fee_amount" bigint DEFAULT 0 NOT NULL,
    "post_fee_amount" bigint DEFAULT 0 NOT NULL,
    "failure_reason" text,
    "metadata" jsonb DEFAULT '{}'::jsonb,
    "from_name" varchar(255),
    "to_name" varchar(255),
    "comment" varchar(500),
    "settlement_date" varchar(8),
    "created_at" timestamp with time zone DEFAULT now() NOT NULL,
    "updated_at" timestamp with time zone DEFAULT now() NOT NULL,
    CONSTRAINT "intent_idempotency_key_service_id_uniq" UNIQUE("idempotency_key","service_id")
);

CREATE INDEX "intent_user_id_created_at_idx" ON "pm"."intent" USING btree ("user_id","created_at");
CREATE INDEX "intent_status_active_idx" ON "pm"."intent" USING btree ("status");

Invoice-поля — 0007_intents_invoice_fields.sql

ALTER TABLE "pm"."intent"
  ALTER COLUMN "from_account_name" DROP NOT NULL,
  ADD COLUMN "version" integer NOT NULL DEFAULT 0,
  ADD COLUMN "expires_at" timestamp with time zone,
  ADD COLUMN "reserved_at" timestamp with time zone,
  ADD COLUMN "canceled_at" timestamp with time zone,
  ADD COLUMN "redeemed_at" timestamp with time zone,
  ADD COLUMN "cancel_reason" varchar(50),
  ADD COLUMN "qr_signature" bytea,
  ADD COLUMN "issued_by_user_id" integer,
  ADD COLUMN "redeemed_by_user_id" integer;

-- Partial index for active invoices (quick lookup by expiry)
CREATE INDEX IF NOT EXISTS "intent_invoice_expiry_idx"
  ON "pm"."intent" ("expires_at")
  WHERE "status" = 'CREATED' AND "operation_type" = 'INVOICE_PAYMENT';

-- Partial index for invoice issuance tracking
CREATE INDEX IF NOT EXISTS "intent_issued_by_idx"
  ON "pm"."intent" ("issued_by_user_id", "created_at" DESC)
  WHERE "operation_type" = 'INVOICE_PAYMENT';

Важно: с миграции 0007 from_account_name стал NULLable — для INVOICE_PAYMENT плательщик неизвестен на момент CREATED (выпуск инвойса мерчантом) и заполняется только в confirm-handler.ts после скана QR покупателем.

Миграции 0001/0002/0005/0006 таблицу pm.intent не трогают (0001 — perf-tuning для psp_tx_map/outbox_event, 0002 — создание limit_rule, 0005DROP COLUMN secret_hash у service_key, 0006attributes JSONB у tx_history).

Поля

Поле Тип NULL Default Описание
id uuid no gen_random_uuid() PK; используется как trace_id во всех событиях и в Redis-канале intent.{id} для live-подписки клиента.
idempotency_key uuid no Идемпотентность вместе с service_id. Повторный POST с тем же ключом не создаёт новую строку — handler возвращает существующую (200 OK).
service_id varchar(50) no Идентификатор вызывающего сервиса из HMAC-заголовка X-Service-Id (соответствует pm.service_key). Используется для авторизации GET /intents/:id (сервис видит только свои интенты).
user_id integer no Инициатор операции (X-User-Id). Для INVOICE_PAYMENT сначала равен мерчанту (выпустившему инвойс), затем в confirm-handler.ts переписывается на payerUserId. Для admin-операций (channel=ADMIN) равен 0.
operation_type varchar(50) no Бизнес-смысл операции (P2P, MERCHANT_PAYMENT, INVOICE_PAYMENT, BANK_DEPOSIT, BANK_WITHDRAWAL, MINIAPP_CHARGE, MINIAPP_REFUND, ADMIN_TRANSFER …). Реестр — src/intent/operation-registry.ts.
channel varchar(50) no Канал исполнения, выбранный роутером по (operation_type, amount) через pm.payment_route. Значения из src/channels/*.ts: INTERNAL_P2P, IPPS_TRANSFER, SERVICE_TRANSFER, ADMIN, MERCHANT_INVOICE.
from_account_name varchar(100) yes Источник в pm.tb_account_map. NULL у INVOICE_PAYMENT в статусе CREATED — заполняется при confirm (см. миграцию 0007).
to_account_name varchar(100) no Получатель в pm.tb_account_map. Может быть резолвлен из to_tb_account_id (только для сервисов с allowToTbAccountId в permissions).
external_ref varchar(100) yes Внешний идентификатор (например, IPPS rqUID, ID банковской транзакции, миниапп-orderId).
amount bigint no Сумма операции в satang (1 THB = 100 satang). Никаких numeric/float — финансовый инвариант (см. 01-schema-overview.md, «Конвенции»).
currency char(3) no 'THB' Валюта; на 0.x всегда THB.
tb_ledger integer no ID TigerBeetle-ledger (например, 1 для THB). Берётся из tb_account_map стороны from (или to, если from ещё неизвестен).
tb_transfer_ids uuid[] no '{}'::uuid[] Массив TigerBeetle-transfer ID, созданных в рамках этого интента (включая комиссии и void-операции).
status varchar(20) no 'CREATED' Один из 9 статусов (см. ниже). Реализован как VARCHAR(20), не PG enum.
pre_fee_amount bigint no 0 Сумма PRE-комиссий (списываются с отправителя дополнительно к amount). Считается через src/rule-engine/fee-calculator.ts.
post_fee_amount bigint no 0 Сумма POST-комиссий (вычитаются из приходящей суммы у получателя).
failure_reason text yes Заполняется при переходе в FAILED (err.message либо строка из канала).
metadata jsonb yes '{}'::jsonb Routing-метаданные интента: recipientUserId, merchantId, tags, appScope, ttlSeconds, miniapp-payload и прочее. Используется в правилах комиссий и лимитов.
from_name varchar(255) yes Человекочитаемое имя отправителя (для UI/чеков).
to_name varchar(255) yes Человекочитаемое имя получателя.
comment varchar(500) yes Комментарий к платежу.
settlement_date varchar(8) yes Дата settlement в формате YYYYMMDD, полученная от PSP (актуально для IPPS_TRANSFER). Для не-PSP каналов NULL.
created_at timestamptz no now() Момент создания.
updated_at timestamptz no now() Обновляется handler-ами и воркерами при каждом переходе статуса.
version integer no 0 Оптимистичный конкурент-контроль для двухфазных каналов. confirm-handler.ts использует If-Match заголовок и инкремент version + 1 в UPDATE.
expires_at timestamptz yes Срок жизни инвойса (для MERCHANT_INVOICE). После наступления — инвойс может быть переведён в EXPIRED. Используется в partial-индексе intent_invoice_expiry_idx.
reserved_at timestamptz yes Момент reserve() двухфазным каналом (фиксация QR-подписи и expires_at).
canceled_at timestamptz yes Момент отмены инвойса (заполняется в cancel-handler.ts через TwoPhaseChannel.cancel()).
redeemed_at timestamptz yes Момент подтверждения инвойса покупателем (confirm-handler.ts).
cancel_reason varchar(50) yes Причина отмены (merchant_canceled, expired, admin_override…).
qr_signature bytea yes HMAC-SHA256-подпись QR-кода инвойса (16 байт). Отдаётся клиенту в base64url для генерации QR. Подробнее — в channel-реализации MERCHANT_INVOICE.
issued_by_user_id integer yes userId мерчанта, выпустившего инвойс. Используется для авторизации отмены: только эмитент (или actorUserId=0 admin bypass) может вызвать cancel.
redeemed_by_user_id integer yes userId покупателя, оплатившего инвойс. Заполняется в confirm-handler.ts.

Статусы (intent.status)

Все 9 значений из типа IntentStatus (см. src/shared/schema.ts строка 90):

Статус Когда устанавливается
CREATED Сразу после INSERT в handler-е (а также состояние свежевыпущенного инвойса до confirm).
VALIDATED После успешного расчёта комиссий и преvalidate-проверок.
AUTHORIZED После TigerBeetle-pending transfer (channel.steps[0]) — деньги «заморожены», но не списаны.
SETTLING Внутреннее промежуточное состояние при асинхронной обработке PSP (используется воркерами).
SETTLED Финальный успех — TB-transfer post-нут (или INTERNAL_P2P завершён синхронно).
FAILED Финальная ошибка; failure_reason заполнен.
MANUAL_REVIEW Требует разбора оператором (например, orphan-rqUID после краха в IPPS confirm-окне; см. PspState в схеме).
CANCELED Инвойс отменён мерчантом или админом (см. cancel-handler.ts).
EXPIRED Инвойс не был оплачен до expires_at.

Состояний RESERVED, INIT, DONE не существует — только перечисленные 9.

Индексы

Индекс Тип Колонки Назначение
intent_pkey btree, PK id Поиск по UUID интента.
intent_idempotency_key_service_id_uniq btree, UNIQUE (idempotency_key, service_id) Идемпотентность POST /intents — повтор с тем же ключом возвращает существующий интент (см. handler.ts шаг 2).
intent_user_id_created_at_idx btree (user_id, created_at) История операций пользователя (Admin Panel, выписки, лимиты DAILY/MONTHLY).
intent_status_active_idx btree (status) Сканирование активных интентов воркерами (SETTLING, AUTHORIZED, …).
intent_invoice_expiry_idx btree, partial (expires_at) WHERE status='CREATED' AND operation_type='INVOICE_PAYMENT' Быстрый поиск активных инвойсов с истекающим сроком (для job-а перевода в EXPIRED).
intent_issued_by_idx btree, partial (issued_by_user_id, created_at DESC) WHERE operation_type='INVOICE_PAYMENT' История выпущенных инвойсов мерчанта.

Оба partial-индекса добавлены миграцией 0007_intents_invoice_fields.sql и существуют только для строк INVOICE_PAYMENT — это даёт минимальный размер индекса и максимальную скорость на профиле «мало инвойсов от общего числа интентов».

Связи

Логические (по intent_id UUID, без FK на уровне БД)

FK не объявлены, чтобы не блокировать запись событий concurrent-обновлениями. Целостность держится прикладным кодом + индексами на колонке intent_id в дочерних таблицах.

Связь Дочерняя таблица Кто пишет
1 : N pm.intent_event — аудит-лог статусов writeIntentEvent() из src/intent/intent-events.ts
1 : N pm.tx_history — денежная история по аккаунтам writeSettlement() из src/intent/settlement-writer.ts
1 : N pm.outbox_event — очередь post_pending/void_pending для TB каналы и OutboxWorker
1 : 0..1 pm.psp_tx_map — state-machine PSP (UNIQUE на intent_id) psp-worker (IPPS)

Внешние логические зависимости (без FK)

Колонка Куда логически ссылается
service_id pm.service_key.service_id
from_account_name, to_account_name pm.tb_account_map.account_name
operation_type + channel определяется правилом из pm.payment_route; влияет на правила комиссий pm.fee_rule и лимиты pm.limit_rule

Связанный код

Модуль Роль
src/intent/handler.ts POST /intentsINSERT строки, шаги CREATED → VALIDATED → AUTHORIZED → SETTLED (или FAILED) для однофазных каналов; для MERCHANT_INVOICE вызывает channel.reserve() и оставляет интент в CREATED с заполненным qr_signature/expires_at.
src/intent/confirm-handler.ts POST /intents/:id/confirm — двухфазный flow MERCHANT_INVOICE. Атомарный UPDATE CREATED → VALIDATED с проверкой expires_at > now(), If-Match-версии, заполнением from_account_name/user_id/redeemed_by_user_id/redeemed_at. Далее запускает channel.redeem() и переводит в AUTHORIZED → SETTLED.
src/intent/cancel-handler.ts POST /intents/:id/cancel — авторизация по issued_by_user_id (либо admin userId=0), вызов TwoPhaseChannel.cancel() (атомарный UPDATE status='CANCELED' WHERE status='CREATED').
src/intent/intent-events.ts writeIntentEvent() — пишет в pm.intent_event при каждом переходе статуса; publishIntentStatus() — публикует в Redis-канал intent.{id} для live-подписки клиента.
src/intent/settlement-writer.ts writeSettlement() — после SETTLED создаёт строки в pm.tx_history по каждому участвующему аккаунту.
src/intent/router.ts resolveChannel() — выбор channel по operation_type + amount через pm.payment_route.
src/saga/ Воркеры асинхронной обработки (PSP, outbox) — обновляют status, tb_transfer_ids, failure_reason.

Примеры запросов

Получить интент по ID для конкретного сервиса

SELECT id, status, channel, operation_type, amount, currency,
       pre_fee_amount, post_fee_amount, from_account_name, to_account_name,
       created_at, updated_at
FROM   pm.intent
WHERE  id = $1
  AND  service_id = $2
LIMIT  1;
(используется в GET /intents/:idhandler.ts)

Idempotency-replay при повторном POST /intents

SELECT id, status, channel, amount, currency,
       pre_fee_amount, post_fee_amount, created_at,
       expires_at, qr_signature, version
FROM   pm.intent
WHERE  idempotency_key = $1
  AND  service_id      = $2
LIMIT  1;
(шаг 2 в handler.ts)

Атомарное подтверждение инвойса (confirm)

UPDATE pm.intent
SET    status              = 'VALIDATED',
       from_account_name   = $1,
       user_id             = $2,
       redeemed_by_user_id = $2,
       redeemed_at         = now(),
       version             = version + 1,
       updated_at          = now()
WHERE  id             = $3
  AND  status         = 'CREATED'
  AND  operation_type = 'INVOICE_PAYMENT'
  AND  expires_at     > now()
  AND  version        = $4   -- опционально (If-Match)
RETURNING *;
(confirm-handler.ts; если 0 строк — handler разбирает причину: ALREADY_PROCESSED / EXPIRED / VERSION_MISMATCH)

Активные инвойсы, истекающие в ближайшие 60 секунд

SELECT id, expires_at, to_account_name, amount
FROM   pm.intent
WHERE  status         = 'CREATED'
  AND  operation_type = 'INVOICE_PAYMENT'
  AND  expires_at     < now() + interval '60 seconds'
ORDER  BY expires_at;
(индекс intent_invoice_expiry_idx обслуживает этот запрос целиком; partial — поэтому минимального размера)

История выпущенных мерчантом инвойсов

SELECT id, status, amount, created_at, expires_at, redeemed_at, canceled_at
FROM   pm.intent
WHERE  operation_type     = 'INVOICE_PAYMENT'
  AND  issued_by_user_id  = $1
ORDER  BY created_at DESC
LIMIT  50;
(индекс intent_issued_by_idx — partial, отсортирован created_at DESC)

Отладка: текущее распределение по статусам

SELECT status, count(*)
FROM   pm.intent
WHERE  created_at > now() - interval '24 hours'
GROUP  BY status
ORDER  BY count(*) DESC;

Поиск «зависших» в AUTHORIZED (для эскалации в MANUAL_REVIEW)

SELECT id, channel, operation_type, amount, updated_at
FROM   pm.intent
WHERE  status     = 'AUTHORIZED'
  AND  updated_at < now() - interval '10 minutes'
ORDER  BY updated_at;
(индекс intent_status_active_idx сужает по status, затем фильтр по updated_at)