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-аккаунтах. См. memoryfeedback-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. Из этого следует:
- Зная имя аккаунта, можно вычислить его TB ID без обращения к БД (полезно для верификации, тестов, миграций).
- Менять
TB_NSкатегорически нельзя — это инвалидирует все существующие ID. - Конфликтов имён быть не может: 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):
- Проверка прав сервиса (
IPPS_WITHDRAWALилиTHAI_QR_PAYвservice_key.permissions.allowedOperationTypes). - Поиск
USER_WALLETпользователя вtb_account_map. - Если
ipps_wallet_idуже заполнен — возврат 200 (idempotent replay). - Иначе — вызов 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() это обнаружит и залогирует.