Saga «Commission Settlement» (финансовый контур вознаграждений)
Назначение
Saga Commission Settlement координирует сквозной денежный процесс от завершения сделки CRM до
дебетов кошельков получателей — без распределённой транзакции и без отдельного оркестратора (ADR-0056,
хореография): каждый сервис реагирует на события соседей и мутирует только своё состояние. Процесс:
pin.crm.deal-completed.v1→rewardsсоздаёт комиссионное начисление (gross + снапшот пунктов удержания ПИН, commission-accrual.md) и — параллельно, в той же локальной транзакции — реферальное начисление при активной связи инвестора (referral-accrual.md);- менеджер застройщика подтверждает начисление (± корректировка суммы) — строится распределение
тремя строками
Platform/Agency/Agent(ADR-0066); - финансовый оператор ПИН регистрирует акт поступления средств (platform-receipt.md)
— гейт ADR-0067: позиции атомарно переходят в
MoneyReceived, рефералки — вApproved; walletкредитует счета получателей по событиям получения денег (ADR-0065);- агентство и ПИН собирают платёжные поручения (payout-order.md) и
отмечают фактическую выплату — начисления каскадно переходят в
Paid; walletдебетует счета получателей по событию выплаты поручения.
Продолжает deal-completion-saga (её ветка rewards) — от
момента, когда факт завершения сделки уже терминален в crm. Ключевое свойство — однонаправленность
после гейта денег: до MoneyReceived отмена сделки компенсируется автоотменой начислений; после —
только ручными корректирующими начислениями (финансовая история неизменяема, Registered-акт не
разворачивается).
Metadata
| Поле | Значение |
|---|---|
| Статус | draft |
| Owning team | PIN Platform (Rewards squad) |
| Тип | Choreography (событийная цепочка + пользовательские операции-гейты); без отдельного оркестратора и без saga-state-таблицы — состояние процесса = статусы агрегатов rewards (ADR-0056) |
| Область действия | от pin.crm.deal-completed.v1 до дебетов кошельков по pin.rewards.payout-order-status-changed.v1 |
| Saga instance / correlation | DealId (создание) → 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 — по конвенции) |
Участники
| Service | Responsibility | Owned State |
|---|---|---|
crm | Producer терминальных фактов сделки (deal-completed / deal-cancelled) | DealEntity (терминальные статусы) |
referral | Producer фактов связи «кто кого привёл» (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.
| From | To | Trigger | Condition | Side Effects |
|---|---|---|---|---|
[*] | Accrual: PendingConfirmation ‖ Referral: Pending | consume deal-completed (crm-deal-completed) | first-seen DealId; правило застройщика найдено (иначе ветка skip); guard INV-PT-4 (иначе DLQ); активная связь + ставка (иначе реферальная ветка skip) | снапшоты правила и условий платформы (RetentionPP/PlatformFee); события commission-accrual-created, referral-accrual-created → notifications |
Accrual: PendingConfirmation | Accrual: Confirmed | Confirm менеджера застройщика (confirm-commission-accrual) | актор компании DeveloperCompanyId; опц. корректировка суммы (AdjustedAmount + причина, INV-11) | три строки распределения Platform/Agency/Agent (INV-4/5/9/10); commission-accrual-status-changed → notifications |
Accrual: Confirmed | Accrual: 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-recorded → notifications |
Payout: Draft | Payout: PendingPayment | Create+Approve поручения (руководитель агентства — create-payout-order, approve; оператор ПИН — ../api/platform/create-referral-payout-order.md, по конвенции) | источники строк по Kind (INV-1 payout-order): Agent-строки начислений MoneyReceived / рефералки Approved, свободные | захват строк (PayoutOrderItemId); состав заморожен |
Payout: PendingPayment | Payout: Paid + Accrual: MoneyReceived → Paid / Referral: Approved → Paid | MarkPaid после фактической выплаты вне платформы (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 / 100 | 1 000 000 ₽ |
PlatformFeeAmount (строка Platform) | 10 000 000 × 1.00 / 100 | 100 000 ₽ |
| Пул агентства | 1 000 000 − 100 000 | 900 000 ₽ |
Строка Agency | 900 000 × 30 / 100 | 270 000 ₽ |
Строка Agent | 900 000 − 270 000 | 630 000 ₽ |
| Σ распределения (INV-5) | 100 000 + 270 000 + 630 000 | 1 000 000 ₽ |
ReferralAccrual.Amount | 10 000 000 × 0.5 / 100 | 50 000 ₽ (сверх комиссии, платит ПИН) |
Commands И Events
Направление — относительно rewards (центр процесса). Контракты не-rewards топиков — у их владельцев.
| Direction | Contract | Owner | Idempotency |
|---|---|---|---|
| Consume | pin.crm.deal-completed.v1 | crm | MessageId + DealId (INV-1 / INV-RA-1) |
| Consume | pin.crm.deal-cancelled.v1 | crm | MessageId + DealId (терминальный Cancelled) |
| Consume | pin.referral.bonds.v1 (created/closed) | referral | MessageId + BondId + Version-guard (referral-bond) |
| Send | pin.rewards.commission-accrual-created.v1 | rewards | key = AccrualId; потребители — notifications, crm, проекция agent-performance |
| Send | pin.rewards.commission-accrual-status-changed.v1 | rewards | key = AccrualId; факт AccrualId:{ToStatus} у потребителей |
| Send | pin.rewards.commission-money-received.v1 (../events/commission-money-received.md — по конвенции) | rewards | key = AccrualId; wallet — (CommissionCredit, AccrualId) per строка |
| Send (условно) | pin.rewards.commission-distribution-adjusted.v1 | rewards | key = AccrualId; публикуется только при корректировке распределения в MoneyReceived; wallet — (Adjustment, AccrualId + Version), перенос дельты Agent↔Agency |
| Send | pin.rewards.referral-accruals.v1 (../events/referral-accrual-created.md, ../events/referral-accrual-status-changed.md — по конвенции) | rewards | key = ReferralAccrualId; wallet — (ReferralCredit, ReferralAccrualId) |
| Send | pin.rewards.payout-order-status-changed.v1 | rewards | key = PayoutOrderId; wallet — (PayoutDebit, ItemId) per позиция |
Transaction Boundaries
Каждый сервис — только своя локальная транзакция; согласованность eventual, публикации — атомарно с мутацией агрегата (outbox), offset подтверждается после фиксации.
crm: терминальный переход сделки + outboxdeal-completed/deal-cancelled— один коммит.rewards, consumer завершения: комиссионное начисление + реферальное начисление + отметка идемпотентности + outbox (commission-accrual-created,referral-accrual-created) — один коммит (обе ветки одного события не расходятся между собой).rewards,Confirm: переход + три строки распределения + (опц.) строка корректировки + outboxstatus-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+ outboxpayout-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.
| Стадия на момент сбоя/отмены | Триггер | Компенсация | Owner | Audit |
|---|---|---|---|---|
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: MoneyReceived | deal-cancelled | Комиссионное не отменяется (гейт денег): warning + алерт; компенсация — ручное корректирующее начисление (create-manual-accrual); рефералка Approved (свободна) авто-отменяется — wallet компенсирует кредит партнёра проводкой Correction по событию | rewards + wallet | да |
Accrual: Paid / Referral: Paid | deal-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-adjusted—walletпроводкой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 топиков контура), сверка ledgerwallet(по конвенции).
Обратные ссылки
Автоссылки: где используется этот документ.
- API (1): POST /api/v1/agency/commission-accruals/{accrualId}/adjust-distribution — Per-deal корректировка долей внутри пула
- Use Cases (3): UC-RWD-003. Сделка завершена — начисление комиссии (system), UC-RWD-009. Жизненный цикл реферального начисления (связь → начисление → гейт → выплата), UC-RWD-010. Акт поступления средств — гейт получения денег
- События (1): Event Contract: pin.rewards.commission-distribution-adjusted (CommissionDistributionAdjustedKafkaEvent)
- Консюмеры (3): Consumer: CRM Deal Cancelled, Consumer: CRM Deal Completed, Consumer: Rewards Commission Distribution Adjusted
- Саги (1): Saga «Deal Completion» (fan-out завершения сделки)
- scenarios (1): UC-OVR-010. От завершённой сделки до выплаты комиссии и реферального вознаграждения
- index.md (2): CRM Service, Rewards Service
Связанные документы
- Агрегаты контура: commission-accrual.md (статус-машина, INV-7/8/11), referral-accrual.md (INV-RA-3/5), platform-receipt.md (атомарный каскад INV-RC-4), payout-order.md (источники строк INV-1, каскад INV-7), platform-commission-terms.md (резолюция удержания), referral-rate-rule.md (резолюция ставки), agency-commission-policy.md (деление пула).
- Consumers: crm-deal-completed.md,
crm-deal-cancelled.md,
referral-bond.md,
agent-performance-projection.md (
MoneyReceived— no-op для метрик агента). - Операции-гейты: confirm-commission-accrual.md;
акт —
../api/platform/create-platform-receipt.md,../api/platform/register-platform-receipt.md(по конвенции); поручения — create-payout-order.md, approve-payout-order.md, mark-payout-order-paid.md. - Предшествующая сага: deal-completion-saga.md (fan-out завершения сделки); путь отмены — deal-cancelled.md.
- Кошелёк:
../../wallet/README.md(по конвенции) — счета, ledger, идемпотентность(OperationKind, SourceId). - ADR: 0066
(удержание ПИН, три строки, реферальные ставки),
0067 (гейт
MoneyReceived, акт, корректировки), 0065 (кошелёк), ADR-0056 (хореография без оркестратора), ADR-0048 (RUB-only), ADR-0052 (tenancy). - Шаблон: saga-template.md.