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

pm.tb_account_map

Связующая таблица между человекочитаемыми именами аккаунтов PM и бинарными TigerBeetle-идентификаторами. Единственный источник истины для определения владельца аккаунта (user_id) и направления операции (DEBIT/CREDIT) в limit_rule.

Имя

pm.tb_account_map — таблица в схеме pm. Определена в src/shared/schema.ts как Drizzle-модель tbAccountMap.

Назначение

PM хранит все деньги в TigerBeetle (TB), где аккаунты идентифицируются 128-битным id. Внутри же кода и API мы оперируем человекочитаемыми именами вида user.42.THB или system.transit.IPPS.THB. tb_account_map — это и есть мост между этими двумя мирами:

  • даёт детерминированное отображение accountName ↔ tbAccountId (через UUID v5, см. ниже);
  • хранит метаданные, которых нет в TB (тип аккаунта, владелец, валюта, IPPS wallet ID);
  • служит источником данных для определения направления платежа в правиле лимита (limit_rule.direction = DEBIT|CREDIT|BOTH) — через колонку user_id.

КРИТИЧЕСКИЙ ИНВАРИАНТ. Колонка user_id в tb_account_mapединственный допустимый источник данных для определения направления (DEBIT/CREDIT) в limit_rule. НИКОГДА не использовать префикс account_name (user., merchant., agent., system.) для этой цели. Префикс — это вспомогательное человекочитаемое имя; правильная семантика владения формализована через user_id. Любая логика, читающая account_name LIKE 'user.%' для решения «это DEBIT или CREDIT» — баг, который ломается на merchant/agent/service-аккаунтах. См. memory feedback-limit-direction.

Детерминированные TB ID

TB account ID получается из имени по формуле accountId(name) = uuidv5(name, TB_NS), где TB_NS = '3e7b4a1c-9f2d-5e8a-b6c3-4d1f07e2a9b5' — зафиксированный namespace из src/ledger/accounts.ts. Из этого следует:

  1. Зная имя аккаунта, можно вычислить его TB ID без обращения к БД (полезно для верификации, тестов, миграций).
  2. Менять TB_NS категорически нельзя — это инвалидирует все существующие ID.
  3. Конфликтов имён быть не может: TB ID 0 и 2¹²⁸-1 запрещены TigerBeetle, остальное — 1:1 с именем.

То же самое для transfer ID: см. src/ledger/id-gen.ts (uuidv5TbTransfer(intentId, index) с собственным TRANSFER_NS).

DDL

Из drizzle/migrations/0000_init.sql:

CREATE TABLE "pm"."tb_account_map" (
    "id" serial NOT NULL,
    "account_name" varchar(100) PRIMARY KEY NOT NULL,
    "tb_account_id" uuid NOT NULL,
    "tb_ledger" integer NOT NULL,
    "account_type" varchar(50) NOT NULL,
    "user_id" integer,
    "currency" char(3) DEFAULT 'THB' NOT NULL,
    "created_at" timestamp with time zone DEFAULT now() NOT NULL,
    "ipps_wallet_id" text,
    CONSTRAINT "tb_account_map_tb_account_id_unique" UNIQUE("tb_account_id")
);

Индексы (там же):

CREATE INDEX "tb_account_map_user_idx"        ON "pm"."tb_account_map" ("user_id");
CREATE INDEX "tb_account_map_ipps_wallet_idx" ON "pm"."tb_account_map" ("ipps_wallet_id");
CREATE INDEX "tb_account_map_id_idx"          ON "pm"."tb_account_map" ("id");

Поля

Поле Тип Default Назначение
id serial Суррогатный sequential ID. Внутри PM не используется; нужен только Serverpod ORM (public.v_user_tb_accounts view требует id int).
account_name varchar(100) PK Натуральный первичный ключ. Человекочитаемое имя (user.42.THB, system.transit.IPPS.THB). Все JOIN'ы внутри PM идут по нему.
tb_account_id uuid (BYTEA / 16 bytes) TigerBeetle account ID в виде UUID. На уровне Postgres хранится как uuid (бинарно — 16 байт). UNIQUE — гарантирует 1:1 с account_name.
tb_ledger integer Номер TigerBeetle ledger (Phase 1 всегда 1 — THB). См. примечание ниже про multi-currency.
account_type varchar(50) (AccountType) Класс аккаунта: USER_WALLET, TRANSIT, REVENUE, NOSTRO, MERCHANT_WALLET, MERCHANT_SETTLEMENT, AGENT_WALLET, AGENT_SETTLEMENT, SERVICE_ACCOUNT, EQUITY.
user_id integer NULL Владелец аккаунта. NOT NULL для всех "пользовательских" типов (USER_WALLET, MERCHANT_, AGENT_, SERVICE_ACCOUNT). NULL для системных (TRANSIT, REVENUE, NOSTRO, EQUITY). Единственный валидный источник direction для limit_rule.
currency char(3) 'THB' Валюта аккаунта. Phase 1 — только THB.
created_at timestamptz now() Когда аккаунт зарегистрирован в PM (не путать с timestamp в TB — там это поле выставляет сам TB-сервер).
ipps_wallet_id text NULL Идентификатор PromptPay-кошелька, выданный IPPS PSP'ом. NULL для всех неактивированных USER_WALLET и для всех системных аккаунтов. Заполняется через POST /accounts/register-ipps.

Заготовка на будущее (multi-currency). Колонка tb_ledger — это явная подготовка к Phase 2C+. TigerBeetle разносит валюты по разным ledger (один ledger = одна валюта, без cross-ledger transfers). Сейчас всегда 1 (THB), но колонка зарезервирована, чтобы при добавлении USD / EUR не пришлось менять схему. Логика seedAccounts() пока сидит только THB-аккаунты.

Индексы

Индекс Поля Зачем
PRIMARY KEY (account_name) account_name Автоматический. Главный точечный lookup в getAccountByName().
UNIQUE (tb_account_id) tb_account_id tb_account_map_tb_account_id_unique. Гарантирует 1:1 с TB ID; используется в getAccountByTbId().
tb_account_map_user_idx user_id Найти все аккаунты пользователя (USER_WALLET + возможные merchant/agent в будущем). Используется в limit_rule и register-ipps.
tb_account_map_ipps_wallet_idx ipps_wallet_id Обратный lookup при входящих IPPS-вебхуках («какому пользователю принадлежит этот wallet?»).
tb_account_map_id_idx id Только для Serverpod-view public.v_user_tb_accounts (Serverpod выбирает по id).

Связи

  • Без FK на уровне БД. tb_account_map ссылается на user_id (формально — из public.user_info, schema Serverpod'а), но PM никогда не пишет в public.*, поэтому FK поставить нельзя (см. 01-schema-overview.md). Целостность поддерживается на уровне приложения.
  • pm.intent.from_account_name / to_account_name — ссылаются на account_name логически (без FK), но валидация идёт через getAccountByName().
  • pm.tx_history.account_name — то же самое, плюс при записи берётся userId из tb_account_map и кладётся в tx_history.user_id (для быстрого выбора истории).
  • pm.limit_rule.direction — определяется по tb_account_map.user_id (см. инвариант выше).
  • Serverpod view public.v_user_tb_accounts — read-only представление поверх tb_account_map для отображения баланса в Auth Center / Flutter App.

Классы аккаунтов и их имена

Правила формирования имён фиксируются функцией accountName(type, opts) в src/ledger/accounts.ts:

Класс (account_type) Шаблон имени Пример user_id
USER_WALLET user.<userId>.<currency> user.42.THB NOT NULL
TRANSIT system.transit.<channel>.<currency> system.transit.IPPS.THB NULL
REVENUE system.revenue.<currency> system.revenue.THB NULL
NOSTRO system.nostro.<provider>.<currency> system.nostro.ipps.THB NULL
EQUITY system.equity.<currency> system.equity.THB NULL
MERCHANT_WALLET merchant.<userId>.<currency> merchant.1001.THB NOT NULL
MERCHANT_SETTLEMENT merchant.<userId>.settlement.<currency> merchant.1001.settlement.THB NOT NULL
AGENT_WALLET agent.<userId>.<currency> agent.2002.THB NOT NULL
AGENT_SETTLEMENT agent.<userId>.settlement.<currency> agent.2002.settlement.THB NOT NULL
SERVICE_ACCOUNT service.<userId>.<currency> service.3003.THB NOT NULL

Каналы для TRANSIT: INTERNAL, INTERNAL_P2P, IPPS, MERCHANT, MERCHANT_INVOICE, SERVICE_TRANSFER. Провайдеры для NOSTRO: ipps (default), bank, любой партнёрский идентификатор.

Важно. Префикс имени — для людей и логов; не используйте его для бизнес-логики направления/владельца. Эту роль выполняет account_type (что за аккаунт) и user_id (чей он).

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

Файл / модуль Роль
src/shared/schema.ts Drizzle-определение tbAccountMap (типы TbAccountMap, NewTbAccountMap, AccountType).
src/ledger/accounts.ts Формирование имён (accountName), создание аккаунтов в TB + строки в tb_account_map (createAccount), сид и верификация системных аккаунтов (seedAccounts, verifySystemAccounts), lookup-функции getAccountByName(name) и getAccountByTbId(tbAccountId).
src/ledger/id-gen.ts UUID v5 для transfer ID (uuidv5TbTransfer) — родственный механизм детерминированных ID. Account ID живёт в ledger/accounts.ts::accountId().
src/accounts/register-ipps.ts HTTP-роут POST /accounts/register-ipps. Auth Center вызывает его после успешного KYC; роут регистрирует PromptPay-кошелёк у IPPS PSP и сохраняет ippsWalletId в tb_account_map (idempotent).
src/limits/evaluate-policy.ts Читает user_id из tb_account_map для определения направления при оценке limit_rule.

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

Lookup по имени (горячий путь)

SELECT tb_account_id, tb_ledger, account_type, user_id
FROM   pm.tb_account_map
WHERE  account_name = 'user.42.THB';

Эквивалент в коде — getAccountByName('user.42.THB', db) (src/ledger/accounts.ts).

Обратный lookup по TB ID (используется в обработке webhook'ов и в diagnostics)

SELECT account_name, tb_ledger, user_id
FROM   pm.tb_account_map
WHERE  tb_account_id = '<uuid из TB>';

Эквивалент — getAccountByTbId(uuid, db).

Все аккаунты одного пользователя

SELECT account_name, account_type, currency, ipps_wallet_id
FROM   pm.tb_account_map
WHERE  user_id = 42
ORDER  BY account_type, currency;

Найти пользователя по IPPS wallet ID (обработка входящих PSP-вебхуков)

SELECT user_id, account_name
FROM   pm.tb_account_map
WHERE  ipps_wallet_id = 'PP-XXXXXXXX'
  AND  account_type   = 'USER_WALLET';

Системные аккаунты для проверки целостности

SELECT account_name, account_type, tb_account_id
FROM   pm.tb_account_map
WHERE  user_id IS NULL
ORDER  BY account_type, account_name;

После старта PM здесь должны быть все строки, перечисленные в SYSTEM_ACCOUNTS (src/ledger/accounts.ts): 6 × TRANSIT, REVENUE, NOSTRO×2 (ipps, bank), EQUITY — всего 10 для Phase 1. Расхождение → ошибка в логе verifySystemAccounts().

Регистрация IPPS-кошелька (внешний API)

Кошелёк регистрируется не SQL'ом, а HTTP-вызовом из Auth Center:

POST /accounts/register-ipps
X-Service-Id: auth-center
X-Timestamp:  <unix>
X-Signature:  <hmac>
X-User-Id:    42
Content-Type: application/json

{ "externalUserId": "...", "customerEnglishName": "...", ... }

Поведение (см. src/accounts/register-ipps.ts):

  1. Проверка прав сервиса (IPPS_WITHDRAWAL или THAI_QR_PAY в service_key.permissions.allowedOperationTypes).
  2. Поиск USER_WALLET пользователя в tb_account_map.
  3. Если ipps_wallet_id уже заполнен — возврат 200 (idempotent replay).
  4. Иначе — вызов IPPS-драйвера, запись ipps_wallet_id в tb_account_map, возврат 201.

Эквивалент создания аккаунта в коде

tb_account_map напрямую руками не наполняется — всегда через createAccount(), который атомарно создаёт и запись в TigerBeetle, и строку в этой таблице:

// src/ledger/accounts.ts
await createAccount('USER_WALLET', { userId: 42, currency: 'THB' }, db)
// → создаёт TB-аккаунт с id = uuidv5('user.42.THB', TB_NS)
// → INSERT INTO pm.tb_account_map (...) ON CONFLICT DO NOTHING

Прямой INSERT в tb_account_map без соответствующего TB-аккаунта приведёт к рассинхрону — pre-flight verifySystemAccounts() это обнаружит и залогирует.