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

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)
Topicpin.rewards.payout-order-status-changed.v1 (key = id поручения)
ContractVersion1; при несовпадении версии — warn-and-proceed
Consumer grouppin-wallet-rewards-payout-orders
Producerrewards: отметка фактической выплаты поручения (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ТипОбязательностьКак используется (маппинг)
IdGuidДаId поручения — трассировка; = Kafka key
Kindstring (enum PayoutOrderKind)ДаВыбор вида дебетуемого счёта: AgencyCommissionAgentCommission; ReferralRewardReferralCommission
ToStatusstring (enum PayoutOrderStatus)ДаPaid → проведение дебетов; прочие известные (PendingPayment/Rejected) → no-op (ack); неизвестное значение → DLQ + алерт
Items[].PayoutOrderItemIdGuidДа (при Paid ≥ 1 позиции)Id позиции поручения → SourceId транзакции PayoutDebit
Items[].RecipientUserIdGuidДаХолдер дебетуемого счёта (агент / партнёр)
Items[].AmountMoney (Value + CurrencyId + CurrencySnapshot)ДаСумма дебета позиции
PaidAtDateTimeOffset?Да при PaidOccurredAt транзакций
VersionlongДаМонотонный guard payload (перестановки страхует key + идемпотентность)

Маппинг «позиция → проводка»

По каждой позиции — отдельная транзакция PayoutDebit (SourceId = Items[].PayoutOrderItemId):

Kind порученияДебетКредитСумма
AgencyCommissionAgentCommission холдера RecipientUserIdPlatformSettlement (системный)Items[].Amount
ReferralRewardReferralCommission холдера RecipientUserIdPlatformSettlement (системный)Items[].Amount

Дебет допустим только по существующему счёту с достаточным балансом: кредит по гейту получения денег всегда предшествует выплате (ADR-0067 → ADR-0065), поэтому отсутствие счёта или недостаток баланса — не бизнес-отказ, а детектор рассинхрона rewardswallet (ADR-0021): позиция не проводится, сообщение уходит в DLQ + алерт, создаётся reconciliation-issue.

Алгоритм

  1. Контекст события. Дедупликация по идентификатору сообщения; ContractVersion — warn-and-proceed.
  2. Фильтр перехода. ToStatus != Paid (PendingPayment / Rejected / иное известное) → ack, no-op. Неизвестный ToStatusDLQ + алерт (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 — контракт нарушен, построчная детализация обязательна).
  3. По каждой позиции Items[].
    1. Идемпотентность: транзакция (PayoutDebit, Items[].PayoutOrderItemId) уже проведена → пропустить позицию (частично обработанное сообщение при повторе доводит только непроведённые позиции, INV-WLT-7).
    2. Проверить CurrencyId (RUB-only — иначе DLQ WALLET_CURRENCY_MISMATCH, INV-WLT-4), Amount.Value > 0.
    3. Найти счёт по Kind + RecipientUserId: счёт отсутствует → DLQ + алерт (WALLET_BALANCE_NEGATIVE_FORBIDDEN — выплата без предшествующего кредита).
    4. Guard no-negative (INV-WLT-3): баланс счёта − Amount.Value < 0 → DLQ + алерт (WALLET_BALANCE_NEGATIVE_FORBIDDEN); уже проведённые позиции этого сообщения не откатываются — они корректны, задача разбора — непокрытая позиция.
    5. Провести PayoutDebit: дебет холдерского счёта, кредит PlatformSettlement, OccurredAt = PaidAt.
  4. Какие факты фиксируются. По каждой проведённой транзакции публикуется pin.wallet.transaction-recorded.v1 (../events/wallet-transaction-recorded.md) — потребитель notifications («выплата проведена»).
  5. Согласованность. Каждая позиция фиксируется отдельной атомарной транзакцией (проводки + балансы + исходящее событие); offset подтверждается только после обработки всех позиций сообщения (проведены либо распознаны как дубли). Отказ на любой позиции → сообщение целиком в retry/DLQ; идемпотентность позиций делает повтор безопасным.
  6. Результат. Успех → 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

СбойRetryDLQРучное действие
Транзиентная ошибка хранилищаэкспоненциальный 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)

Неизвестный ToStatusDLQ + алерт (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-класс.

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

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

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