Entity / Aggregate: LedgerTransactionEntity (+ LedgerEntryEntity)
Дом полей журнала кошелька контекста wallet: транзакция (LedgerTransactionEntity) и проводки
(LedgerEntryEntity). Проекции (история операций ЛК, payload событий, admin-журнал) ссылаются сюда и
описывают только дельту.
Назначение
LedgerTransactionEntity — одна бизнес-операция над балансами кошелька: кредит по полученной комиссии,
кредит по реферальному вознаграждению, дебет по выплате, ручная корректировка или компенсация. Каждая
транзакция состоит из двух и более проводок LedgerEntryEntity по принципу двойной записи: сумма
дебетов равна сумме кредитов
(ADR-0005 — этот
журнал является его первой реализацией,
ADR-0065).
Журнал append-only: транзакции и проводки не изменяются и не удаляются; любое исправление — новая
компенсирующая транзакция Correction со ссылкой на исходную и обязательной причиной. Балансы счетов
(wallet-account.md) — производная от журнала величина.
Транзакции создаются consumer-ами событий rewards (кредиты — только после гейта получения денег
MoneyReceived, дебеты — по факту выплаты поручения) и admin-операциями корректировки; сам wallet
денежных обязательств не порождает и контексту rewards не командует.
Классификация
| Поле | Значение |
|---|
| Контекст | wallet |
| Kind | AggregateRoot (+ дочерняя Entity LedgerEntryEntity внутри границы агрегата) |
| Source of truth | wallet (журнал); суммы-основания — факты rewards |
| Таблицы | wallet.ledger_transactions, wallet.ledger_entries |
| Tenant isolation | вне tenant-модели (как счета — wallet-account.md); видимость истории — record-level по холдеру счёта проводки |
Поля (wallet.ledger_transactions)
| Поле | Тип | Обязательность | Ограничения (PK/FK/index/constraint) | Источник | Описание |
|---|
Id | Guid | Да | PK | app | Уникальный идентификатор транзакции. |
OperationKind | WalletOperationKind | Да | хранится как text, varchar(32); unique ux_ledger_transactions_operation_source (operation_kind, source_id) — идемпотентность (INV-WLT-7) | app | Вид операции (enum ниже). |
SourceId | Guid | Да | часть unique-ключа идемпотентности | событие-источник / операция | Идентификатор основания — что именно проведено (карта проводок ниже: комиссионное начисление, реферальное начисление, позиция поручения, исходная транзакция, операция корректировки). |
OccurredAt | DateTimeOffset | Да | — | событие-источник | Момент бизнес-факта (из события rewards: MoneyReceivedAt, PaidAt; для ручных операций — момент выполнения). |
RecordedAt | DateTimeOffset | Да | index ix_ledger_transactions_recorded_at | app | Момент проведения в журнале. |
Comment | string? | Да для Adjustment и Correction, иначе null | varchar(1000), min 1 при обязательности | app | Причина корректировки/компенсации (аудит): у Adjustment — обязательный Reason оператора, у авто-Correction — причина отмены источника (CancelReason). |
ActorUserId | Guid? | Да для Adjustment, null для системных операций | — | app | ПИН-админ, выполнивший ручную операцию. |
CorrectedTransactionId | Guid? | Да для Correction, иначе null | FK fk_ledger_transactions_corrected → ledger_transactions.id (restrict), index where not null; = SourceId у Correction | app | Исходная транзакция, компенсируемая этой (INV-WLT-6). |
Enum WalletOperationKind
| Значение | Описание |
|---|
CommissionCredit | Кредиты получателям по строкам распределения полученной комиссии — одна транзакция на начисление (событие pin.rewards.commission-money-received.v1, гейт MoneyReceived пройден). |
ReferralCredit | Кредит партнёру по реферальному начислению, перешедшему в Approved (топик pin.rewards.referral-accruals.v1). |
PayoutDebit | Дебет холдерского счёта по позиции платёжного поручения при переходе поручения в Paid (событие pin.rewards.payout-order-status-changed.v1). |
Adjustment | Ручная корректировка баланса ПИН-админом (обязательная причина, аудит актора). |
Correction | Компенсация ранее проведённой транзакции — зеркальные проводки со ссылкой на исходную. В v1 проводится системно (отмена реферального начисления после Approved); ручной компенсации нет — админ пользуется Adjustment. |
Дочерняя сущность LedgerEntryEntity (wallet.ledger_entries)
Проводка — изменение одного счёта внутри транзакции. Kind: Entity внутри границы агрегата
LedgerTransactionEntity; создаётся только при проведении транзакции, никогда не мутирует.
| Поле | Тип | Обязательность | Ограничения | Источник | Описание |
|---|
Id | Guid | Да | PK | app | Идентификатор проводки. |
TransactionId | Guid | Да | FK fk_ledger_entries_transaction → ledger_transactions.id (restrict — журнал append-only, удалений нет), index | app | Родительская транзакция. |
AccountId | Guid | Да | FK fk_ledger_entries_account → wallet_accounts.id (restrict), index ix_ledger_entries_account_recorded (account_id, created_at) — история счёта | app | Счёт проводки (wallet-account.md). |
Direction | LedgerEntryDirection | Да | хранится как text, varchar(8): Debit / Credit | app | Направление: Credit увеличивает баланс счёта, Debit уменьшает (Balance = Σ кредитов − Σ дебетов). |
Amount | Money (owned, VO — wallet-account.md) | Да | check amount_value > 0 | вычисление | Сумма проводки (всегда положительная; знак задаёт Direction). |
BalanceAfter | Money (owned) | Да | — | вычисление | Баланс счёта сразу после применения проводки — денормализация для истории ЛК и payload события. |
CreatedAt | DateTimeOffset | Да | — | app | Момент записи (= RecordedAt транзакции). |
Enum LedgerEntryDirection
| Значение | Описание |
|---|
Debit | Списание со счёта (баланс уменьшается). |
Credit | Зачисление на счёт (баланс увеличивается). |
Карта проводок по видам операций
Каждая транзакция несёт пару (OperationKind, SourceId); повторная доставка события-основания — no-op по
уникальному ключу (INV-WLT-7). Счета получателей создаются lazy при первом кредите
(wallet-account.md, INV-WLT-2).
| OperationKind | Основание (SourceId) | Дебет | Кредит | Сумма |
|---|
CommissionCredit | Комиссионное начисление (CommissionAccrualEntity.Id события commission-money-received) — одна транзакция на событие (../consumers/rewards-commission-money-received.md) | PlatformSettlement — на CommissionAmount | По кредиту на каждую ненулевую строку Distributions[] (нулевая строка проводку не порождает и счёт lazy не создаёт): Agent → AgentCommission агента (RecipientUserId); Agency → AgencyCommission агентства (TenantId события); Platform → PlatformRevenue | Дебет = CommissionAmount; кредиты = Amount строк (Σ строк = CommissionAmount, иначе WALLET_DISTRIBUTION_SUM_MISMATCH) |
ReferralCredit | Реферальное начисление (ReferralAccrualEntity.Id), перешедшее в Approved (../consumers/rewards-referral-accrual-status-changed.md) | PlatformSettlement | ReferralCommission партнёра (PartnerUserId) | Amount начисления |
PayoutDebit | Позиция поручения (PayoutOrderItemEntity.Id из Items[] события при Paid) — по транзакции на каждую позицию (../consumers/rewards-payout-order-status-changed.md) | Холдерский счёт получателя позиции: AgentCommission (PayoutOrderKind = AgencyCommission) / ReferralCommission (PayoutOrderKind = ReferralReward) | PlatformSettlement | Amount позиции |
Adjustment | Операция корректировки (Idempotency-Key admin-операции — ../api/platform/create-wallet-adjustment.md) | Увеличение баланса счёта: дебет PlatformSettlement, кредит целевого; уменьшение — зеркально (дебет целевого, кредит PlatformSettlement) | — (направление задаётся знаком суммы операции) | Сумма корректировки |
Correction | Исходная транзакция (LedgerTransactionEntity.Id = CorrectedTransactionId); в v1 проводится системно — отмена реферального начисления после Approved; ручного API-пути нет (админ пользуется Adjustment) | Зеркально исходной: каждая проводка повторяется с противоположным Direction по тем же счетам и суммам | — | Суммы проводок исходной транзакции |
Кросс-контрактное требование: события rewards обязаны нести построчную детализацию — событие получения
денег несёт все три строки распределения (RecipientType, RecipientUserId?, Amount), событие выплаты
при Paid — позиции Items[] с их id — иначе wallet не сможет разнести суммы по счетам и построить
идемпотентный SourceId дебетов (последствия ADR-0065).
Связи
| Связь | Тип | Ключ | Delete behavior |
|---|
LedgerEntryEntity | owned (внутри агрегата) | TransactionId | restrict (append-only, удалений нет) |
WalletAccountEntity | reference (со стороны проводки) | AccountId | restrict |
LedgerTransactionEntity (исходная) | reference (self, nullable) | CorrectedTransactionId | restrict |
Строка распределения / рефералка / позиция поручения (rewards) | external reference (без FK) | SourceId | — (данные — из payload события) |
Инварианты
| Инвариант | Условие | Когда проверяется | Ошибка |
|---|
| INV-WLT-5 BalancedEntries | В каждой транзакции ≥ 2 проводок и Σ Amount дебетов = Σ Amount кредитов; все проводки — в одной валюте (INV-WLT-4 wallet-account.md) | проведение транзакции (до применения к балансам) | WALLET_ENTRIES_UNBALANCED |
| INV-WLT-6 AppendOnly | Транзакции и проводки не изменяются и не удаляются; исправление — только новая транзакция Correction с CorrectedTransactionId и обязательной причиной. Одна исходная транзакция компенсируется не более одного раза — unique (OperationKind = Correction, SourceId = исходная) структурно исключает двойную компенсацию | модель операций (update/delete не предусмотрены) + unique-ключ | повторная компенсация → WALLET_DUPLICATE_OPERATION |
| INV-WLT-7 IdempotentSource | На пару (OperationKind, SourceId) — не более одной транзакции: повторная доставка события / повтор admin-операции — no-op (существующая транзакция перечитывается, балансы не меняются) | unique ux_ledger_transactions_operation_source | WALLET_DUPLICATE_OPERATION (для consumer — идемпотентный skip без ошибки; повтор admin-операции с тем же Idempotency-Key возвращает ранее проведённую транзакцию) |
Инварианты счетов, действующие на каждое проведение: INV-WLT-3 NoNegativeHolderBalance и INV-WLT-4
SingleCurrency — wallet-account.md.
Коды ошибок
| Код | HTTP | Условие |
|---|
WALLET_ENTRIES_UNBALANCED | — (guard проведения) | Σ дебетов ≠ Σ кредитов транзакции (INV-WLT-5) — дефект расчёта, транзакция не проводится. |
WALLET_DUPLICATE_OPERATION | no-op | Транзакция с той же парой (OperationKind, SourceId) уже проведена (INV-WLT-7); в т.ч. повторная компенсация исходной (INV-WLT-6). Consumer — идемпотентный skip; admin-API — возврат ранее проведённой транзакции. |
WALLET_BALANCE_NEGATIVE_FORBIDDEN | 409 (admin-API) / DLQ (consumer) | Проведение увело бы баланс холдерского счёта ниже нуля либо дебетуемый счёт отсутствует (INV-WLT-3): для дебета/компенсации по событию — детектор рассинхрона (DLQ + алерт + issue ADR-0021), для ручной корректировки — отказ. |
WALLET_CURRENCY_MISMATCH | 400 (admin-API) / DLQ (consumer) | Валюта проводки не совпадает с валютой счёта / внутри транзакции смешаны валюты / валюта вне допустимых платформой (INV-WLT-4, RUB-only ADR-0048). |
WALLET_DISTRIBUTION_SUM_MISMATCH | — (consumer, DLQ + алерт) | Σ сумм строк Distributions[] события получения денег ≠ CommissionAmount — контракт producer-а нарушен, проводить нечего (../consumers/rewards-commission-money-received.md). |
WALLET_UNKNOWN_ENUM | — (consumer, DLQ) | Событие-основание не разносится по счетам: неизвестный RecipientType/Kind, не три строки распределения, нет RecipientUserId у Agent-строки, пустые Items[] при Paid. |
WALLET_ACCOUNT_NOT_FOUND | 404 | Счёт не найден (admin-операции по явному AccountId; для кредитов consumer-ов не возникает — счёт создаётся lazy). |
WALLET_ADJUSTMENT_REASON_REQUIRED | 400 | Adjustment без причины (Reason). |
Доменные и интеграционные события
| Событие | Тип | Когда | Snapshot/поля |
|---|
WalletTransactionRecordedKafkaEvent | интеграционное (ContractVersion = 1, topic pin.wallet.transaction-recorded.v1, key = AccountId) | Проведение транзакции — по одному событию на каждую проводку холдерского счёта (проводки системных счетов не публикуются — подписчиков нет) | TransactionId, EntryId, AccountId, AccountKind, HolderUserId?, HolderCompanyId?, OperationKind, Direction, Amount, BalanceAfter, CurrencyId + CurrencySnapshot, SourceId, OccurredAt, AccountVersion. Контракт — ../events/wallet-transaction-recorded.md. Потребители: notifications |
Интеграционные события публикуются атомарно с проведением транзакции.
Идемпотентность, конкурентность и восстановление
- Идемпотентность. Ключ —
(OperationKind, SourceId) (INV-WLT-7): повторная доставка любого события
rewards и повтор admin-операции упираются в уникальный индекс и трактуются как no-op; событие
transaction-recorded при no-op повторно не публикуется.
- Атомарность проведения. Транзакция, все её проводки, обновление балансов затронутых счетов
(
Balance, Version, BalanceAfter) и факт публикации событий фиксируются в одном согласованном
изменении: частично проведённых транзакций не бывает.
- Дебет без покрытия.
PayoutDebit, не покрытый кредитами (INV-WLT-3), — детектор рассинхрона между
rewards и wallet, а не бизнес-отказ: операция не проводится, сообщение уходит в DLQ + алерт,
создаётся issue Reconciliation Center (ADR-0021);
исправление — командой владельца данных (rewards) либо компенсирующей транзакцией, никогда — правкой
журнала. После исправления сообщение реплеится из DLQ: уже проведённые операции — no-op.
- Порядок событий. Кредиты предшествуют дебетам по бизнес-процессу (
MoneyReceived раньше Paid),
но сквозной порядок между топиками rewards не гарантирован; страховка — INV-WLT-3 + DLQ + replay.
- Компенсация
Correction. Зеркальная транзакция обязана пройти те же guard-ы (INV-WLT-3/4/5):
компенсация кредита, уже «потраченного» выплатой, упрётся в no-negative и уйдёт на ручной разбор —
расхождение решается на стороне rewards (компенсирующее начисление), а не силовой правкой баланса.
Индексы, хранение, безопасность
| Аспект | Значение |
|---|
| Индексы | PK; ux_ledger_transactions_operation_source unique btree (operation_kind, source_id); ix_ledger_transactions_recorded_at btree; ix_ledger_transactions_corrected btree where not null; entries: PK, ix_ledger_entries_transaction btree, ix_ledger_entries_account_recorded btree (account_id, created_at) |
| Партиционирование / retention | none — единицы транзакций на сделку (десятки тысяч в год, ADR-0056); retention бессрочный (финансовая история, append-only) |
| Concurrency | уникальный ключ идемпотентности + блокировка строк счетов при применении проводок (wallet-account.md); оптимистических конфликтов на транзакциях нет — они не мутируют |
| Permissions / PII | История своего счёта — wallet.transactions.view-own (record-level по холдеру счёта проводки); руководитель агентства — история счёта агентства и проводки счетов агентов (wallet.team.view, scope = организация); полный журнал и корректировки — wallet.accounts.view-all / wallet.adjustments.manage (платформенный scope). Суммы и балансы — Sensitive-класс: в логи не пишутся |
| Audit | Журнал сам является audit trail (append-only, причина и актор у ручных операций); наблюдаемость: wallet_transactions_recorded_total (по OperationKind), wallet_duplicate_operation_total (повторы), wallet_debit_uncovered_total (дебеты без покрытия → DLQ, алерт при > 0), wallet_balance_drift_total (сверка) |
Обратные ссылки
Автоссылки: где используется этот документ.
- Домен (CQRS) (1): Entity / Aggregate: WalletAccountEntity
- API (8): GET /api/v1/my/wallet/transactions — История транзакций моего кошелька, GET /api/v1/platform/wallet/accounts — Реестр счетов кошелька, GET /api/v1/platform/wallet/accounts/{accountId}/ledger — Журнал проводок счёта, POST /api/v1/agency/wallet/statement-exports — Постановка выписки по счёту агентства/агента (XLSX), POST /api/v1/my/wallet/statement-exports — Постановка выписки по моему счёту (XLSX), POST /api/v1/platform/wallet/adjustments — Ручная корректировка счёта, POST /api/v1/platform/wallet/statement-exports — Постановка выписки по любому счёту (XLSX), Wallet API — реестр операций и общие контрактные модели
- Use Cases (7): UC-WLT-001. Получение денег кредитует балансы получателей, UC-WLT-002. Выплата поручения дебетует баланс получателя, UC-WLT-003. ПИН-админ вручную корректирует баланс, UC-WLT-004. Холдер смотрит баланс и историю операций, UC-WLT-005. Оператор ПИН смотрит журнал проводок счёта (сверка), UC-WLT-006. Компенсирующее начисление отражается в балансе после MoneyReceived, US-реестр: Реферальный партнёр (US-PRT)
- События (2): Event Contract: pin.rewards.commission-distribution-adjusted (CommissionDistributionAdjustedKafkaEvent), Event Contract: pin.wallet.transaction-recorded (WalletTransactionRecordedKafkaEvent)
- Консюмеры (5): Consumer: Rewards Commission Distribution Adjusted, Consumer: Rewards Commission Money Received, Consumer: Rewards Payout Order Status Changed, Consumer: Rewards Referral Accrual Status Changed, Consumer: Wallet Events → Notifications (notifications-wallet-events)
- Отчёты (1): Отчет/Export: Выписка по счёту кошелька (wallet-statement)
- registries (2): Реестр инвариантов PIN, Реестр сущностей PIN
- index.md (1): Wallet Service
Связанные документы
- Счета и балансы: wallet-account.md (INV-WLT-1..4, VO
Money).
- Событие: ../events/wallet-transaction-recorded.md.
- Consumer-ы событий
rewards:
../consumers/rewards-commission-money-received.md,
../consumers/rewards-referral-accrual-status-changed.md,
../consumers/rewards-payout-order-status-changed.md.
- Admin-операция: ../api/platform/create-wallet-adjustment.md.
- Источники фактов: ../../rewards/domain/commission-accrual.md,
../../rewards/domain/referral-accrual.md,
../../rewards/domain/payout-order.md,
../../rewards/domain/platform-receipt.md (гейт
MoneyReceived).
- ADR-0005,
ADR-0065,
ADR-0021, ADR-0048 (RUB-only),
ADR-0056 (simplicity-first).