Consumer: Rewards Payout Order Status Changed
Назначение
Consumer слушает статусные события платёжных поручений (pin.rewards.payout-order-status-changed.v1) и
при переходе поручения в Paid списывает выплаченные суммы с холдерских счетов получателей —
транзакции PayoutDebit по каждой позиции поручения. Счёт списания определяется видом поручения:
AgencyCommission (выплата долей агентам) дебетует счета AgentCommission, ReferralReward (выплата
партнёрам) — счета ReferralCommission (../../rewards/domain/payout-order.md).
После проведения баланс получателя показывает только невыплаченный остаток. Прочие переходы поручения
(PendingPayment, Rejected) денежного эффекта в кошельке не имеют.
Owning-сервис и триггер
| Поле | Значение |
|---|---|
| Сервис/контекст | wallet |
| Обрабатываемое событие | pin.rewards.payout-order-status-changed (PayoutOrderStatusChangedKafkaEvent) |
| Topic | pin.rewards.payout-order-status-changed.v1 (key = id поручения) |
| ContractVersion | 1; при несовпадении версии — warn-and-proceed |
| Consumer group | pin-wallet-rewards-payout-orders |
| Producer | rewards: отметка фактической выплаты поручения (MarkPaid — ручная, исполнение платежей отложено, решение №9 ТЗ) |
| Эффект (событие → что меняется) | по одной транзакции PayoutDebit на позицию поручения (../domain/ledger-transaction.md); балансы холдерских счетов получателей |
Контекст события
- Из метаданных доступны: идентификатор сообщения (дедупликация),
AggregateId= id поручения, время события; корреляция трейса — из метаданных. - Обработка — под системным актором вне tenant-контекста (счета кошелька — платформенные).
- Ключ бизнес-идемпотентности:
(OperationKind = PayoutDebit, SourceId = Id позиции поручения)— unique в журнале, по каждой позиции отдельно.
Входные данные / payload события
Контракт — ../../rewards/events/payout-order-status-changed.md.
Consumer ожидает от контракта построчную детализацию при Paid (ADR-0065, последствия для rewards):
позиции с их id, получателем и суммой. Ниже — читаемые поля.
| Поле payload | Тип | Обязательность | Как используется (маппинг) |
|---|---|---|---|
Id | Guid | Да | Id поручения — трассировка; = Kafka key |
Kind | string (enum PayoutOrderKind) | Да | Выбор вида дебетуемого счёта: AgencyCommission → AgentCommission; ReferralReward → ReferralCommission |
ToStatus | string (enum PayoutOrderStatus) | Да | Paid → проведение дебетов; прочие известные (PendingPayment/Rejected) → no-op (ack); неизвестное значение → DLQ + алерт |
Items[].PayoutOrderItemId | Guid | Да (при Paid ≥ 1 позиции) | Id позиции поручения → SourceId транзакции PayoutDebit |
Items[].RecipientUserId | Guid | Да | Холдер дебетуемого счёта (агент / партнёр) |
Items[].Amount | Money (Value + CurrencyId + CurrencySnapshot) | Да | Сумма дебета позиции |
PaidAt | DateTimeOffset? | Да при Paid | → OccurredAt транзакций |
Version | long | Да | Монотонный guard payload (перестановки страхует key + идемпотентность) |
Маппинг «позиция → проводка»
По каждой позиции — отдельная транзакция PayoutDebit (SourceId = Items[].PayoutOrderItemId):
Kind поручения | Дебет | Кредит | Сумма |
|---|---|---|---|
AgencyCommission | AgentCommission холдера RecipientUserId | PlatformSettlement (системный) | Items[].Amount |
ReferralReward | ReferralCommission холдера RecipientUserId | PlatformSettlement (системный) | Items[].Amount |
Дебет допустим только по существующему счёту с достаточным балансом: кредит по гейту получения денег
всегда предшествует выплате (ADR-0067 → ADR-0065), поэтому отсутствие счёта или недостаток баланса — не
бизнес-отказ, а детектор рассинхрона rewards ↔ wallet (ADR-0021): позиция не проводится,
сообщение уходит в DLQ + алерт, создаётся reconciliation-issue.
Алгоритм
- Контекст события. Дедупликация по идентификатору сообщения;
ContractVersion— warn-and-proceed. - Фильтр перехода.
ToStatus != Paid(PendingPayment/Rejected/ иное известное) → ack, no-op. НеизвестныйToStatus→ DLQ + алерт (non-retriable,WALLET_UNKNOWN_ENUM): молчаливый ack запрещён — проводка по неизвестному переходу не теряется (единообразно с consumer-амиcommission-money-received/referral-accrual-status-changed). НеизвестныйKindприPaid→ DLQ + алерт (WALLET_UNKNOWN_ENUM): счёт списания выбрать невозможно, списание по неизвестному виду не проводится.Itemsпуст/отсутствует приPaid→ DLQ (WALLET_UNKNOWN_ENUM— контракт нарушен, построчная детализация обязательна). - По каждой позиции
Items[].- Идемпотентность: транзакция
(PayoutDebit, Items[].PayoutOrderItemId)уже проведена → пропустить позицию (частично обработанное сообщение при повторе доводит только непроведённые позиции, INV-WLT-7). - Проверить
CurrencyId(RUB-only — иначе DLQWALLET_CURRENCY_MISMATCH, INV-WLT-4),Amount.Value > 0. - Найти счёт по
Kind+RecipientUserId: счёт отсутствует → DLQ + алерт (WALLET_BALANCE_NEGATIVE_FORBIDDEN— выплата без предшествующего кредита). - Guard no-negative (INV-WLT-3): баланс счёта −
Amount.Value< 0 → DLQ + алерт (WALLET_BALANCE_NEGATIVE_FORBIDDEN); уже проведённые позиции этого сообщения не откатываются — они корректны, задача разбора — непокрытая позиция. - Провести
PayoutDebit: дебет холдерского счёта, кредитPlatformSettlement,OccurredAt = PaidAt.
- Идемпотентность: транзакция
- Какие факты фиксируются. По каждой проведённой транзакции публикуется
pin.wallet.transaction-recorded.v1(../events/wallet-transaction-recorded.md) — потребительnotifications(«выплата проведена»). - Согласованность. Каждая позиция фиксируется отдельной атомарной транзакцией (проводки + балансы + исходящее событие); offset подтверждается только после обработки всех позиций сообщения (проведены либо распознаны как дубли). Отказ на любой позиции → сообщение целиком в retry/DLQ; идемпотентность позиций делает повтор безопасным.
- Результат. Успех → ack; контролируемая ошибка → retry/DLQ по политике ниже.
Диаграмма
Идемпотентность и replay
- Business key: unique
(PayoutDebit, PayoutOrderItemId позиции)— по каждой позиции (INV-WLT-7); дубль сообщения/позиции — no-op. - Частичная обработка: позиции независимы; повтор сообщения доводит только непроведённые позиции — двойного списания не бывает.
- Version-guard: warn-and-proceed по
ContractVersion; неизвестные additive-поля игнорируются. - Replay: да, безопасен — по unique-ключам позиций.
Retry и DLQ
| Сбой | Retry | DLQ | Ручное действие |
|---|---|---|---|
| Транзиентная ошибка хранилища | экспоненциальный retry | после исчерпания — да | replay после восстановления |
Счёт получателя отсутствует / баланс меньше суммы позиции (WALLET_BALANCE_NEGATIVE_FORBIDDEN, INV-WLT-3) | нет | Да + алерт | reconciliation-issue (ADR-0021): сверка событий кредитов (commission-money-received / referral-accruals), исправление командой rewards либо Adjustment; после устранения — replay из DLQ |
Неизвестный Kind / пустые Items при Paid (WALLET_UNKNOWN_ENUM) | нет | Да + алерт | разбор контракта с rewards |
Валюта вне допустимых (WALLET_CURRENCY_MISMATCH, INV-WLT-4) | нет | Да | разбор (ADR-0048) |
Неизвестный ToStatus → DLQ + алерт (non-retriable, WALLET_UNKNOWN_ENUM; см. Алгоритм, шаг 2) — единообразно с money-received/referral-status консюмерами; известные не-Paid статусы (PendingPayment/Rejected) — штатный no-op+ack.
Наблюдаемость и безопасность
- Logs: consumed / skipped-duplicate / posted-per-item / failed — id поручения,
Kind, id позиций и транзакций, без сумм. Metrics:wallet_transactions_recorded_total{kind="PayoutDebit"},wallet_debit_uncovered_total(алерт при > 0), DLQ, lag (now −PaidAt). - Traces: span приёма → span на позицию; корреляция из метаданных события.
- PII нет; суммы — Sensitive-класс.
Обратные ссылки
Автоссылки: где используется этот документ.
- Домен (CQRS) (1): Entity / Aggregate: LedgerTransactionEntity (+ LedgerEntryEntity)
- API (2): GET /api/v1/my/wallet — Мой кошелёк (балансы счетов), Wallet API — реестр операций и общие контрактные модели
- Use Cases (1): UC-WLT-002. Выплата поручения дебетует баланс получателя
- События (2): Event Contract: pin.rewards.payout-order-status-changed (PayoutOrderStatusChangedKafkaEvent), Event Contract: pin.wallet.transaction-recorded (WalletTransactionRecordedKafkaEvent)
- Консюмеры (1): Consumer: Rewards Referral Accrual Status Changed
- index.md (1): Wallet Service
Связанные документы
- Контракт входного события:
../../rewards/events/payout-order-status-changed.md;
сущность-источник: ../../rewards/domain/payout-order.md
(каскад
MarkPaid, INV-7). - Кредиты, предшествующие дебету: rewards-commission-money-received.md, rewards-referral-accrual-status-changed.md.
- Целевые сущности: ../domain/wallet-account.md, ../domain/ledger-transaction.md; исходящее событие: ../events/wallet-transaction-recorded.md.
- ADR-0065 (дебет по факту выплаты), ADR-0021 (детектор «дебет без покрытия»).