Перейти к основному содержимому

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
KindAggregateRoot (+ дочерняя Entity LedgerEntryEntity внутри границы агрегата)
Source of truthwallet (журнал); суммы-основания — факты rewards
Таблицыwallet.ledger_transactions, wallet.ledger_entries
Tenant isolationвне tenant-модели (как счета — wallet-account.md); видимость истории — record-level по холдеру счёта проводки

Поля (wallet.ledger_transactions)

ПолеТипОбязательностьОграничения (PK/FK/index/constraint)ИсточникОписание
IdGuidДаPKappУникальный идентификатор транзакции.
OperationKindWalletOperationKindДахранится как text, varchar(32); unique ux_ledger_transactions_operation_source (operation_kind, source_id) — идемпотентность (INV-WLT-7)appВид операции (enum ниже).
SourceIdGuidДачасть unique-ключа идемпотентностисобытие-источник / операцияИдентификатор основания — что именно проведено (карта проводок ниже: комиссионное начисление, реферальное начисление, позиция поручения, исходная транзакция, операция корректировки).
OccurredAtDateTimeOffsetДасобытие-источникМомент бизнес-факта (из события rewards: MoneyReceivedAt, PaidAt; для ручных операций — момент выполнения).
RecordedAtDateTimeOffsetДаindex ix_ledger_transactions_recorded_atappМомент проведения в журнале.
Commentstring?Да для Adjustment и Correction, иначе nullvarchar(1000), min 1 при обязательностиappПричина корректировки/компенсации (аудит): у Adjustment — обязательный Reason оператора, у авто-Correction — причина отмены источника (CancelReason).
ActorUserIdGuid?Да для Adjustment, null для системных операцийappПИН-админ, выполнивший ручную операцию.
CorrectedTransactionIdGuid?Да для Correction, иначе nullFK fk_ledger_transactions_corrected → ledger_transactions.id (restrict), index where not null; = SourceId у CorrectionappИсходная транзакция, компенсируемая этой (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; создаётся только при проведении транзакции, никогда не мутирует.

ПолеТипОбязательностьОграниченияИсточникОписание
IdGuidДаPKappИдентификатор проводки.
TransactionIdGuidДаFK fk_ledger_entries_transaction → ledger_transactions.id (restrict — журнал append-only, удалений нет), indexappРодительская транзакция.
AccountIdGuidДаFK fk_ledger_entries_account → wallet_accounts.id (restrict), index ix_ledger_entries_account_recorded (account_id, created_at) — история счётаappСчёт проводки (wallet-account.md).
DirectionLedgerEntryDirectionДахранится как text, varchar(8): Debit / CreditappНаправление: Credit увеличивает баланс счёта, Debit уменьшает (Balance = Σ кредитов − Σ дебетов).
AmountMoney (owned, VO — wallet-account.md)Даcheck amount_value > 0вычислениеСумма проводки (всегда положительная; знак задаёт Direction).
BalanceAfterMoney (owned)ДавычислениеБаланс счёта сразу после применения проводки — денормализация для истории ЛК и payload события.
CreatedAtDateTimeOffsetДа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 не создаёт): AgentAgentCommission агента (RecipientUserId); AgencyAgencyCommission агентства (TenantId события); PlatformPlatformRevenueДебет = CommissionAmount; кредиты = Amount строк (Σ строк = CommissionAmount, иначе WALLET_DISTRIBUTION_SUM_MISMATCH)
ReferralCreditРеферальное начисление (ReferralAccrualEntity.Id), перешедшее в Approved (../consumers/rewards-referral-accrual-status-changed.md)PlatformSettlementReferralCommission партнёра (PartnerUserId)Amount начисления
PayoutDebitПозиция поручения (PayoutOrderItemEntity.Id из Items[] события при Paid) — по транзакции на каждую позицию (../consumers/rewards-payout-order-status-changed.md)Холдерский счёт получателя позиции: AgentCommission (PayoutOrderKind = AgencyCommission) / ReferralCommission (PayoutOrderKind = ReferralReward)PlatformSettlementAmount позиции
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
LedgerEntryEntityowned (внутри агрегата)TransactionIdrestrict (append-only, удалений нет)
WalletAccountEntityreference (со стороны проводки)AccountIdrestrict
LedgerTransactionEntity (исходная)reference (self, nullable)CorrectedTransactionIdrestrict
Строка распределения / рефералка / позиция поручения (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_sourceWALLET_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_OPERATIONno-opТранзакция с той же парой (OperationKind, SourceId) уже проведена (INV-WLT-7); в т.ч. повторная компенсация исходной (INV-WLT-6). Consumer — идемпотентный skip; admin-API — возврат ранее проведённой транзакции.
WALLET_BALANCE_NEGATIVE_FORBIDDEN409 (admin-API) / DLQ (consumer)Проведение увело бы баланс холдерского счёта ниже нуля либо дебетуемый счёт отсутствует (INV-WLT-3): для дебета/компенсации по событию — детектор рассинхрона (DLQ + алерт + issue ADR-0021), для ручной корректировки — отказ.
WALLET_CURRENCY_MISMATCH400 (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_FOUND404Счёт не найден (admin-операции по явному AccountId; для кредитов consumer-ов не возникает — счёт создаётся lazy).
WALLET_ADJUSTMENT_REASON_REQUIRED400Adjustment без причины (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)
Партиционирование / retentionnone — единицы транзакций на сделку (десятки тысяч в год, 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 (сверка)

Обратные ссылки

Автоссылки: где используется этот документ.

Связанные документы