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

Saga «Commission Settlement» (финансовый контур вознаграждений)

Назначение

Saga Commission Settlement координирует сквозной денежный процесс от завершения сделки CRM до дебетов кошельков получателей — без распределённой транзакции и без отдельного оркестратора (ADR-0056, хореография): каждый сервис реагирует на события соседей и мутирует только своё состояние. Процесс:

  1. pin.crm.deal-completed.v1rewards создаёт комиссионное начисление (gross + снапшот пунктов удержания ПИН, commission-accrual.md) и — параллельно, в той же локальной транзакции — реферальное начисление при активной связи инвестора (referral-accrual.md);
  2. менеджер застройщика подтверждает начисление (± корректировка суммы) — строится распределение тремя строками Platform/Agency/Agent (ADR-0066);
  3. финансовый оператор ПИН регистрирует акт поступления средств (platform-receipt.md) — гейт ADR-0067: позиции атомарно переходят в MoneyReceived, рефералки — в Approved;
  4. wallet кредитует счета получателей по событиям получения денег (ADR-0065);
  5. агентство и ПИН собирают платёжные поручения (payout-order.md) и отмечают фактическую выплату — начисления каскадно переходят в Paid;
  6. wallet дебетует счета получателей по событию выплаты поручения.

Продолжает deal-completion-saga (её ветка rewards) — от момента, когда факт завершения сделки уже терминален в crm. Ключевое свойство — однонаправленность после гейта денег: до MoneyReceived отмена сделки компенсируется автоотменой начислений; после — только ручными корректирующими начислениями (финансовая история неизменяема, Registered-акт не разворачивается).

Metadata

ПолеЗначение
Статусdraft
Owning teamPIN Platform (Rewards squad)
ТипChoreography (событийная цепочка + пользовательские операции-гейты); без отдельного оркестратора и без saga-state-таблицы — состояние процесса = статусы агрегатов rewards (ADR-0056)
Область действияот pin.crm.deal-completed.v1 до дебетов кошельков по pin.rewards.payout-order-status-changed.v1
Saga instance / correlationDealId (создание) → AccrualId / ReferralAccrualId (жизненный цикл) → ReceiptId (гейт денег) → PayoutOrderId (выплата); сквозной trace-id пробрасывается через все события
Assumptions / ограничениязавершение сделки терминально (INV-DEAL-9); начисления идемпотентны по DealId (INV-1/INV-RA-1); исполнение платежей отложено (решение №9 ТЗ) — MarkPaid вручную; частичные оплаты акта — вне scope v1; RUB-only (ADR-0048)
ЗависимостиPostgreSQL (rewards.*, wallet.*, outbox), Kafka; consumers crm-deal-completed, crm-deal-cancelled, referral-bond; контекст wallet (../../wallet/README.md — по конвенции)

Участники

ServiceResponsibilityOwned State
crmProducer терминальных фактов сделки (deal-completed / deal-cancelled)DealEntity (терминальные статусы)
referralProducer фактов связи «кто кого привёл» (pin.referral.bonds.v1)ReferralBondEntity
rewardsЦентр процесса: начисления (комиссионные и реферальные), условия платформы, ставки, акт поступления, поручения; все статусные события контураCommissionAccrualEntity + распределение, ReferralAccrualEntity, PlatformReceiptEntity, PayoutOrderEntity, реплика связей
walletБалансы получателей: кредиты по гейту денег, дебеты по выплате; идемпотентность по (OperationKind, SourceId)счета и ledger-проводки (ADR-0065)
notificationsУведомления сторонам на каждом значимом переходе

Стадии (state machine)

Состояние саги распределено по агрегатам rewards; ниже — сквозной срез. Дома переходов — commission-accrual.md, referral-accrual.md, platform-receipt.md, payout-order.md.

FromToTriggerConditionSide Effects
[*]Accrual: PendingConfirmationReferral: Pendingconsume deal-completed (crm-deal-completed)first-seen DealId; правило застройщика найдено (иначе ветка skip); guard INV-PT-4 (иначе DLQ); активная связь + ставка (иначе реферальная ветка skip)снапшоты правила и условий платформы (RetentionPP/PlatformFee); события commission-accrual-created, referral-accrual-creatednotifications
Accrual: PendingConfirmationAccrual: ConfirmedConfirm менеджера застройщика (confirm-commission-accrual)актор компании DeveloperCompanyId; опц. корректировка суммы (AdjustedAmount + причина, INV-11)три строки распределения Platform/Agency/Agent (INV-4/5/9/10); commission-accrual-status-changednotifications
Accrual: ConfirmedAccrual: MoneyReceived и Referral: Pending → Approvedрегистрация акта Draft → Registered (оператор ПИН, ../api/platform/register-platform-receipt.md — по конвенции)INV-RC-1..3: позиции Confirmed одного застройщика, Σ позиций = сумме акта, суммы не устарелиатомарный каскад для всех позиций акта (INV-RC-4); события commission-money-received (по каждому начислению), commission-accrual-status-changed (→ MoneyReceived), referral-accruals (→ Approved)
wallet: —wallet: счета кредитованыconsume commission-money-received / referral-accruals (Approved)идемпотентность (OperationKind, SourceId)кредиты AgentCommission/AgencyCommission/PlatformRevenue по Distributions[]; кредит ReferralCommission партнёра; wallet-transaction-recordednotifications
Payout: DraftPayout: PendingPaymentCreate+Approve поручения (руководитель агентства — create-payout-order, approve; оператор ПИН — ../api/platform/create-referral-payout-order.md, по конвенции)источники строк по Kind (INV-1 payout-order): Agent-строки начислений MoneyReceived / рефералки Approved, свободныезахват строк (PayoutOrderItemId); состав заморожен
Payout: PendingPaymentPayout: Paid + Accrual: MoneyReceived → Paid / Referral: Approved → PaidMarkPaid после фактической выплаты вне платформы (mark-payout-order-paid)все позиции выплачены одним актом (INV-7 payout-order — атомарный каскад)payout-order-status-changed (Paid, Items[]) → wallet, notifications; commission-accrual-status-changed (→ Paid)
wallet: счета кредитованыwallet: дебеты проведеныconsume payout-order-status-changed (Paid)идемпотентность (PayoutDebit, ItemId)дебеты счетов получателей по Items[]; недостаток баланса → DLQ + алерт

Терминальные состояния процесса: Accrual: Paid + Referral: Paid + дебеты wallet (успех) либо Accrual: Cancelled / Referral: Cancelled (компенсация до гейта денег) либо ручная компенсация (после гейта, см. «Компенсации»).

Сквозной пример (числа)

Сделка 10 000 000 ₽ (FinalPrice, CalculationBase = FinalPrice), правило застройщика 10%, условия платформы 1.00 п.п., политика агентства удерживает 30% пула, ставка рефералки 0.5% (Global):

ВеличинаФормулаЗначение
CommissionAmount (gross)10 000 000 × 10 / 1001 000 000 ₽
PlatformFeeAmount (строка Platform)10 000 000 × 1.00 / 100100 000 ₽
Пул агентства1 000 000 − 100 000900 000 ₽
Строка Agency900 000 × 30 / 100270 000 ₽
Строка Agent900 000 − 270 000630 000 ₽
Σ распределения (INV-5)100 000 + 270 000 + 630 0001 000 000 ₽
ReferralAccrual.Amount10 000 000 × 0.5 / 10050 000 ₽ (сверх комиссии, платит ПИН)

Commands И Events

Направление — относительно rewards (центр процесса). Контракты не-rewards топиков — у их владельцев.

DirectionContractOwnerIdempotency
Consumepin.crm.deal-completed.v1crmMessageId + DealId (INV-1 / INV-RA-1)
Consumepin.crm.deal-cancelled.v1crmMessageId + DealId (терминальный Cancelled)
Consumepin.referral.bonds.v1 (created/closed)referralMessageId + BondId + Version-guard (referral-bond)
Sendpin.rewards.commission-accrual-created.v1rewardskey = AccrualId; потребители — notifications, crm, проекция agent-performance
Sendpin.rewards.commission-accrual-status-changed.v1rewardskey = AccrualId; факт AccrualId:{ToStatus} у потребителей
Sendpin.rewards.commission-money-received.v1 (../events/commission-money-received.md — по конвенции)rewardskey = AccrualId; wallet(CommissionCredit, AccrualId) per строка
Send (условно)pin.rewards.commission-distribution-adjusted.v1rewardskey = AccrualId; публикуется только при корректировке распределения в MoneyReceived; wallet(Adjustment, AccrualId + Version), перенос дельты Agent↔Agency
Sendpin.rewards.referral-accruals.v1 (../events/referral-accrual-created.md, ../events/referral-accrual-status-changed.md — по конвенции)rewardskey = ReferralAccrualId; wallet(ReferralCredit, ReferralAccrualId)
Sendpin.rewards.payout-order-status-changed.v1rewardskey = PayoutOrderId; wallet(PayoutDebit, ItemId) per позиция

Transaction Boundaries

Каждый сервис — только своя локальная транзакция; согласованность eventual, публикации — атомарно с мутацией агрегата (outbox), offset подтверждается после фиксации.

  • crm: терминальный переход сделки + outbox deal-completed/deal-cancelled — один коммит.
  • rewards, consumer завершения: комиссионное начисление + реферальное начисление + отметка идемпотентности + outbox (commission-accrual-created, referral-accrual-created) — один коммит (обе ветки одного события не расходятся между собой).
  • rewards, Confirm: переход + три строки распределения + (опц.) строка корректировки + outbox status-changed — один коммит.
  • rewards, регистрация акта: акт → Registered + все позиции Confirmed → MoneyReceived + связанные рефералки Pending → Approved + outbox (commission-money-received × N, status-changed × N, referral-accruals × M) — один коммит (INV-RC-4: либо всё, либо ничего).
  • rewards, MarkPaid поручения: поручение → Paid + позиции + строки-источники + каскад начислений → Paid + outbox payout-order-status-changed — один коммит (INV-7 payout-order).
  • wallet: каждая ledger-транзакция (проводки Σдебет=Σкредит) + отметка идемпотентности (OperationKind, SourceId) — один коммит per обработанное событие.

Промежуточные состояния и видимость (eventual consistency)

  • Агент/агентство видят начисление сразу в PendingConfirmation (< 5 мин после завершения сделки), затем Confirmed — суммы долей известны, но выплата недоступна (гейт денег не пройден); MoneyReceived — доля доступна поручению; баланс кошелька пополняется с лагом consumer-а wallet (< 5 мин) — в окне начисление уже MoneyReceived, а баланс ещё старый: это ожидаемо, не ошибка.
  • Партнёр видит рефералку Pending («ожидает поступления денег»), затем Approved и кредит кошелька.
  • Оператор ПИН видит Confirmed-начисления как кандидатов в акт; возраст старейшего без акта — метрика rewards_accrual_awaiting_receipt_max_age_minutes (узкое место процесса, ADR-0067).
  • Обрыв цепочки (consumer в DLQ): уже применённые стадии не откатываются; восстановление — replay (все consumers идемпотентны), сверки — раздел «Timeout и Reconciliation».

Компенсации (по стадиям)

Компенсаций-откатов зарегистрированных финансовых фактов нет — только автоотмена до гейта денег и ручные корректирующие начисления после (ADR-0067). Автоотмену выполняет consumer crm-deal-cancelled.

Стадия на момент сбоя/отменыТриггерКомпенсацияOwnerAudit
Accrual: PendingConfirmation (+ Referral: Pending)deal-cancelledАвто-Cancel обоих начислений (CancelReason = DealCancelled); кошельки не затронуты (кредитов ещё не было)rewardsда (события status-changed)
Accrual: Confirmed, строки свободны, не в актеdeal-cancelledАвто-Cancel обоих (INV-8 проходит); строки распределения остаются историейrewardsда
Accrual: Confirmed, строка в поручении / позиция Draft-актаdeal-cancelledАвтоотмена запрещена (INV-8) → warning + алерт; вручную: освободить (Reject/Cancel поручения, исключение позиции из Draft-акта) → отмена/сторноrewards (оператор/агентство)да (rewards_accrual_cancel_forbidden_total)
Accrual: MoneyReceiveddeal-cancelledКомиссионное не отменяется (гейт денег): warning + алерт; компенсация — ручное корректирующее начисление (create-manual-accrual); рефералка Approved (свободна) авто-отменяется — wallet компенсирует кредит партнёра проводкой Correction по событиюrewards + walletда
Accrual: Paid / Referral: Paiddeal-cancelledНичего не отменяется автоматически: warning + алерт; возврат денег — вне платформы + ручное корректирующее начисление; ledger не разворачивается (append-only)rewards (ручной регламент)да
Любая стадияbond-closed (Replaced/Revoked)Новые реферальные начисления по BondId прекращаются (реплика неактивна); созданные остаются; при фроде — ручная отмена BondRevoked оператором ПИН (+ Correction кошелька, если была Approved/кредитована)rewardsда
Акт Registered ошибочен (не те позиции/сумма)обнаружение операторомАкт не разворачивается (INV-RC-4) — компенсирующее ручное начисление / корректировка следующим периодом; двухшаговость Draft → Registered с предпросмотром Σ снижает рискrewards (оператор ПИН)да (акт неизменяем — сам себе след)
Gross скорректирован после включения в Draft-актрегистрация актаREWARDS_RECEIPT_ITEM_STALE — регистрация отклоняется, оператор пересобирает состав/суммуrewardsда (строки корректировок)
Поручение отклонено/отменено (Rejected/Cancelled)решение агентства/оператораСтроки-источники освобождаются (PayoutOrderItemId → null, INV-8 payout-order) — доступны следующему поручению; начисления остаются MoneyReceived/Approved; кошельки не затронуты (дебет только по Paid)rewardsда
Дебет wallet невозможен (недостаток баланса — расхождение ledger)consume payout-order-status-changedНе ретраится бесконечно: DLQ + алерт (ADR-0021), ручная сверка ledger против rewards (Items[] события — источник истины по суммам)walletда

Timeout И Reconciliation

  • Timeout: саговых таймаутов нет — процесс гейтится пользовательскими операциями (подтверждение застройщика, регистрация акта, выплата). Узкие места мониторятся возрастом: старейшее PendingConfirmation без решения застройщика; старейшее Confirmed без акта (rewards_accrual_awaiting_receipt_max_age_minutes + алерт); старейшая MoneyReceived-доля без поручения.
  • Retry policy: per-consumer exponential backoff (1s→60s, пауза партиции) для инфраструктурных сбоев; контрактные/бизнес-ошибки — non-retriable → DLQ (+ алерт); конфликты оптимистической блокировки — немедленные повторы с перечиткой.
  • Reconciliation: (1) Σ кредитов wallet по (CommissionCredit|ReferralCredit) с учётом переносов Adjustment = Σ Distributions начислений в MoneyReceived+ и Σ Approved+ рефералок: per-deal корректировка распределения в MoneyReceived меняет доли Agency/Agent, но пул сохраняется (INV-11), поэтому она сопровождается компенсирующим событием commission-distribution-adjustedwallet проводкой Adjustment переносит дельту между счетами получателей, и Σ кредитов остаётся равной Σ распределений (перенос внутрипуловой, строка Platform не затронута); (2) Σ дебетов = Σ позиций Paid-поручений; (3) начисления MoneyReceived старше SLA без кредита кошелька / Paid без дебета — replay соответствующего топика (идемпотентность (OperationKind, SourceId) делает его безопасным); (4) сверка реплики связей с referral (referral-bond).
  • Manual review trigger: DLQ > 0 любого consumer-а контура; рост rewards_accrual_cancel_forbidden_total / rewards_referral_cancel_forbidden_total (расторжения после гейта — нужны ручные компенсации); REWARDS_PLATFORM_RETENTION_EXCEEDS_RULE в DLQ (конфликт условий платформы и правила застройщика).

Диаграмма

Путь компенсации (deal-cancelled, bond-closed) — таблица «Компенсации» и crm-deal-cancelled / referral-bond.

Observability

  • Metrics (полнота контура): rewards_accrual_created_total и rewards_referral_accrual_created_total сверяются с crm_deals_completed_total; воронка статусов — счётчики переходов Confirmed/MoneyReceived/Paid; возраст узких мест (см. Reconciliation); лаги consumer-групп (rewards_crm_deals_lag_seconds, rewards_referral_bonds_lag_seconds, лаг wallet); DLQ — *_dlq_total.
  • Logs/traces: сквозной correlation id от crm через события rewards до проводок wallet; saga id = DealId (до гейта) / ReceiptId, PayoutOrderId (после); causation id = MessageId. Суммы — Sensitive, в логи не пишутся.
  • Alerts: DLQ > 0 у любого участника; rewards_accrual_awaiting_receipt_max_age_minutes выше порога; расхождение сверок wallet↔rewards; rewards_platform_terms_missing_total (fallback 0 п.п. — платформа теряет доход).
  • Runbook: rewards-accrual-replay (replay топиков контура), сверка ledger wallet (по конвенции).

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

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

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