Архитектура данных

Счёт = инструмент = валюта + способ. EUR на Revolut и EUR наличными — это разные инструменты, разные счета, разные балансы.

1 Ключевая идея: Счёт = Инструмент
InstrumentEntity = валюта + способ

EUR-REVOLUT ≠ EUR-CASH. USDT-TRC20 ≠ USDT-ERC20. Один инструмент — один счёт у контрагента. Это снимает проблему «EUR на Revolut и EUR кэшем — это разные деньги». Да, разные — разные инструменты, разные счета.

Было

AccountEntity (старая модель)

Ключ: partner_id + location_id + currency_code
AccountEntity:
  partner_id    → PartnerEntity
  location_id   → LocationEntity  ← привязка к офису
  currency_code → "EUR"           ← не отличает способ
  balance       → 5000.00

Проблема: EUR cash и EUR Revolut
= один и тот же счёт (оба "EUR")
    
Стало

AccountEntity (новая модель)

Ключ: partner_id + instrument_id
AccountEntity:
  partner_id    → PartnerEntity
  instrument_id → InstrumentEntity ← содержит всё
  balance       → 5000.00
  hold          → 0.00

Решено: EUR-CASH и EUR-REVOLUT
= два разных счёта
    
2 InstrumentEntity — схема

Entity InstrumentEntity

idint, PK
codestring, uniqueEUR-REVOLUT, USDT-TRC20, RUB-CASH
currency_codestringEUR, USDT, RUB, BTC
namestring«Евро Revolut», «USDT Tron»
scopeenumsystem | community | private
created_byint, FK, null→ BrokerEntity (для private)
usage_countintСколько брокеров используют
is_activebool
// Примеры инструментов
EUR-CASH       → Евро, наличные (без привязки к локации)
EUR-REVOLUT    → Евро на Revolut
USDT-TRC20     → USDT в сети Tron
USDT-ERC20     → USDT в сети Ethereum (другой инструмент!)
USDT-BINANCE   → USDT на бирже Binance (отдельно от on-chain!)
RUB-CASH       → Рубли, наличные
RUB-SBER      → Рубли, Сбербанк
BTC-ONCHAIN    → Биткоин в блокчейне

// Пример счетов клиента Васи у Альфы:
Вася + EUR-REVOLUT  : EUR 5,000
Вася + EUR-CASH     : EUR 3,000     ← отдельно!
Вася + USDT-TRC20   : 10,000
Вася + USDT-BINANCE : 8,000        ← отдельно от on-chain!
3 LocationEntity убрана из финансовой модели
Жёсткий enum — попытка загнать реальность в коробку, а реальность не влезает

Раньше: LocationEntity = офис + валюты + балансы. Теперь: InstrumentEntity содержит всё что нужно. Если брокеру нужен «офис в Барселоне» — это тег #barcelona на инструментах. Фильтруешь по тегу — видишь «кассу Барселоны».

// РАНЬШЕ:
LocationEntity = офис + валюты + балансы + rate modifiers
AccountEntity  = partner × location × currency

// ТЕПЕРЬ:
InstrumentEntity = валюта + способ (всё что нужно)
AccountEntity    = partner × instrument
LocationEntity   → УДАЛЕНА из финансовой модели

// Если нужно понятие «офис» — это тег:
EUR-CASH + tag #barcelona → касса Барселоны
EUR-CASH + tag #vilnius   → касса Вильнюса

// "Касса Барселоны" = фильтр по тегу, а не сущность
4 Система тегов (TagEntity)

Entity TagEntity

idint, PK
slugstring, uniquecash, crypto, europe, fast
namestringНаличные, Криптовалюта, Европа, Быстрый
groupenumcategory | geo | property
iconstring, nullИконка для UI
is_systemboolСистемные нельзя удалить

Предустановленный каталог: 15 тегов в 3 группах

Системные теги из коробки. Брокер может добавлять свои.
КАТЕГОРИЯ (group=category)
#cash — Наличные #crypto — Криптовалюта #bank — Банк #ewallet — Электронный кошелёк #exchange — Биржа #p2p — P2P
ГЕО (group=geo)
#europe — Европа #asia — Азия #cis — СНГ #spain — Испания #russia — Россия #turkey — Турция
СВОЙСТВА (group=property)
#fast — Быстрый #anonymous — Анонимный #verified — Верифицированный
Зачем теги работают для межброкерной сети

Альфа создал «EUR-REVOLUT» с тегами [#ewallet, #europe]. Бета нашла в каталоге, подключила. Оба говорят на одном языке. Тег «ewallet» или «безнал» — неважно для операции. Важно что код совпадает: оба понимают ЧТО это.

5 Автогенерация кодов инструментов

Конструктор инструмента

6 Три уровня: System → Community → Private
🌍
System
Стандартные, для всех брокеров по умолчанию
~15 инструментов
👥
Community
5+ брокеров используют, видны в каталоге
Растёт органически
🔒
Private
Один брокер создал под себя
Любое количество
7 Жизненный цикл инструмента
Private
Брокер создал
5+ брокеров
Органический рост
Community
Виден в каталоге
System
Платформа стандартизировала
// Пример жизненного цикла:

1. Брокер Альфа создаёт EUR-CASH-BCN (private, usage_count=1)
2. Бета находит в каталоге, подключает (usage_count=2)
3. Ещё 3 брокера подключают (usage_count=5)
4. Автоматически: scope = 'community' (виден всем в каталоге)
5. Платформа стандартизирует: scope = 'system' (предустановлен для новых)
8 Каталог инструментов (интерактивный)
Все
System
Community
Private
#cash #crypto #bank #ewallet #europe #cis
9 Полная модель сущностей
Как данные связаны друг с другом:
PartnerEntity
id, type, uid
+ instrument →
AccountEntity
partner_id + instrument_id
← записи
TransactionEntity
account_id, tx_hash
InstrumentEntity
code, scope, tags[]
← left/right
SwapEntity
left_instrument + right_instrument
→ orders
OrderEntity
cashier_id, instrument_id
HoldEntity
account_id, amount
 
ObligationEntity
debtor + creditor (межброкерка)
 
AnchorBatchEntity
merkle_root, btc_txid
10 Стратегия миграции
Самое опасное изменение — миграция AccountEntity

Ключ меняется с (partner_id, location_id, currency_code) на (partner_id, instrument_id). Стратегия: nullable instrument_id + dual-write. Текущий бизнес не должен сломаться.

Шаг 1 — Создать InstrumentEntity
Для каждой существующей комбинации location + currency автоматически создать InstrumentEntity. Код = CURRENCY-METHOD (определяется из контекста).
Шаг 2 — Добавить nullable поле
В AccountEntity добавить instrument_id (nullable FK). Старые поля (location_id, currency_code) остаются. Dual-write: при любой записи заполнять оба.
Шаг 3 — Заполнить instrument_id
Фоновый скрипт: для каждого Account без instrument_id найти/создать InstrumentEntity и проставить FK.
Шаг 4 — SwapEntity двойной формат
SwapEntity поддерживает оба: currency_code (legacy) и left_instrument_id / right_instrument_id (новый). Постепенный переход.
Шаг 5 — Переключение
Когда 100% записей имеют instrument_id → сделать поле NOT NULL, убрать location_id и currency_code из AccountEntity.
// Dual-write период (безопасность)

function createAccount($partnerId, $instrumentId) {
    $instrument = InstrumentEntity::find($instrumentId);

    $account = new AccountEntity();
    $account->partner_id    = $partnerId;
    $account->instrument_id = $instrumentId;         // ← новый путь
    $account->currency_code = $instrument->currency_code; // ← legacy (dual-write)
    $account->location_id   = null;                  // ← deprecated
    $account->balance       = 0;
    $account->hold          = 0;

    return $account->save();
}

// available = balance - hold (вычисляемое)
function getAvailable(): Decimal {
    return bcsub($this->balance, $this->hold);
}