Consumer: CRM Deal Cancelled
Назначение
Consumer слушает событие отмены/расторжения сделки CRM (deal.md,
DealStatus.Cancelled) и отменяет начисления по этой сделке — оба эффекта consumer-а завершения
(crm-deal-completed.md):
- комиссионное начисление (
CommissionAccrualEntity): переходCancelсCancelReason = DealCancelled(матрица «Агенство» 5.6 — комиссия снимается при отзыве основания); - реферальное начисление (
ReferralAccrualEntity): переходCancelс тем жеCancelReason = DealCancelled.
Guard гейта денег (ADR-0067): начисление, прошедшее MoneyReceived (деньги от застройщика получены) или
Paid, не отменяется автоматически — warning + алерт, компенсация только ручным корректирующим
начислением (INV-8 commission-accrual.md). Переходы публикуются событиями
CommissionAccrualStatusChangedKafkaEvent и ReferralAccrualStatusChangedKafkaEvent
(ToStatus = Cancelled) для notifications и wallet.
Идемпотентность — по DealId: если начислений нет либо они уже терминальны (Cancelled/Paid),
выполняется идемпотентный skip без ошибки (ack). Единая группа и политика ошибок — как у
crm-deal-completed.md.
Owning-сервис и триггер
| Поле | Значение |
|---|---|
| Сервис/контекст | rewards (фоновый обработчик) |
| Обрабатываемое событие | pin.crm.deal-cancelled |
| Topic | pin.crm.deal-cancelled.v1 |
| Partitions / key | key = AggregateId = DealId — строгий порядок per-deal |
| ContractVersion | 1; при несовпадении версии — warn-and-proceed (метрика rewards_contract_mismatch_total) |
| Consumer group | pin-rewards-crm-deals (общая с crm-deal-completed.md; один DLQ-топик группы) |
| Producer | crm (отмена сделки Deal.Cancel, INV-DEAL-8) |
| Эффект (событие → что меняется) | отмена начислений по сделке: CommissionAccrualEntity и ReferralAccrualEntity → Cancelled (CancelReason = DealCancelled); после гейта денег (MoneyReceived/Paid) — не отменяются, warning + алерт |
Контекст события
- Из метаданных события доступны: идентификатор сообщения, время события,
Version, агрегат-источник (AggregateId = DealId,AggregateType = "Deal"). - Корреляция сквозного трейса восстанавливается из метаданных события и пробрасывается в исходящее событие
rewards(шаг 6). - Обработка — под системным актором вне tenant-контекста (platform-scope):
UpdatedByUserIdначисления при отмене штампуется системным актором (неpayload.CancelledByUserId— тот принадлежит контекстуcrm); начисление адресуется поDealId, а не по tenant актора (TenantIdначисления = агентство,payload.TenantId= застройщик =DeveloperCompanyId, ADR-0052). - Ключ идемпотентности —
rewards:crm-deal-cancelled:{MessageId}; бизнес-идемпотентность — уникальность/терминальность поDealId.
Входные данные / payload события
Контракт события — ../../crm/events/deal-cancelled.md (дом — контекст
crm). Consumer читает минимум полей: адресация по DealId, остальное — для трассировки и логов.
| Поле payload | Тип | Обязательность | Как используется (маппинг) |
|---|---|---|---|
DealId | Guid | Да | Бизнес-ключ адресации начисления (unique ux_commission_accruals_deal_id) |
TenantId | Guid | Да | DeveloperCompanyId застройщика; сверка с DeveloperCompanyId начисления (fail-closed при несоответствии) |
PropertyObjectId | Guid | Да | Только трассировка/лог (в поля начисления не пишется) |
CancelledByUserId | Guid? | Нет | Только трассировка; не пишется в UpdatedByUserId (actor начисления — system-actor) |
CancelReason | string (free text CRM) | Да | Не маппится в accrual.CancelReason: начислению всегда ставится enum CommissionCancelReason.DealCancelled. Free-text причина crm — PII, в лог только маскированной, в поля не сохраняется |
CancelledAt | DateTimeOffset | Да | occurredAt метода Cancel → accrual.CancelledAt |
Семантика потока. Событие
crmэмитится из нетерминальных статусов сделки (INV-DEAL-8/9), а начисления существуют только для завершённой сделки — поэтому в штатном потоке отмены начислений нет и доминирует ветка идемпотентного skip; ветки реальной отмены активируются при расторжении сделки после завершения (crmрасширяет отмену на post-Completed). До этого расширения веткиCancel— защитный инвариант, а не горячий путь.
Алгоритм
- Контекст события. Восстановить метаданные события (идентификатор сообщения,
DealId, время); сверитьContractVersion— при несовпадении warn-and-proceed. Проверить идемпотентность по ключуrewards:crm-deal-cancelled:{MessageId}— дубль доставки →skipped-duplicate, пропуск (ack). - Проверка входа.
DealId != Guid.Empty;TenantId != Guid.Empty;CancelledAtзадан. Нарушение —REWARDS_DEAL_EVENT_INVALID(non-retriable → DLQ: контракт producer-а нарушен). Отсутствие начисления здесь не ошибка — обрабатывается как идемпотентный skip (шаг 4). - Что читаем. Комиссионное начисление по
DealId(без tenant-фильтра — platform-scope) вместе с его строками распределения — они нужны guard-у INV-8 (проверкаPayoutOrderItemId, участие в не-отменённом акте поступления). Реферальное начисление поDealId(referral-accrual.md). Отсутствие любого из начислений допустимо. - Комиссионная ветка: guard-ы и отмена.
- Начисления нет: отменять нечего (сделка без завершения/агента/правила) — лог info
skipped-no-accrual, метрикаrewards_accrual_cancel_skipped_no_accrual_total; обработка продолжается реферальной веткой (шаг 5). - Сверка стороны:
DeveloperCompanyIdначисления ≠payload.TenantId→REWARDS_TENANT_MISMATCH(non-retriable → DLQ + алерт: адресация нарушена). - Уже отменено (
Status == Cancelled): целевое терминальное состояние достигнуто — идемпотентный skip, логskipped-already-cancelled. - Гейт денег пройден (
Status ∈ {MoneyReceived, Paid}): автоотмена запрещена (INV-8, ADR-0067) — деньги от застройщика получены (и, возможно, выплачены), разворот статусов недопустим. Warningcancel-forbidden-money-received/cancel-forbidden-paid, метрикаrewards_accrual_cancel_forbidden_total, алерт; компенсация — ручным корректирующим начислением (../api/admin/create-manual-accrual.md),Registered-акт не разворачивается. Ack — не DLQ (легитимное бизнес-состояние, не poison-сообщение). - Отмена (
Status ∈ {PendingConfirmation, Confirmed}): переходCancelставитStatus = Cancelled,CancelledAt = payload.CancelledAt,CancelReason = DealCancelled, инкрементируетVersionи порождает доменное событие смены статуса. Внутри — guard INV-8: изConfirmedотмена допустима, лишь если ни одна строка распределения не включена в неотменённое поручение (PayoutOrderItemId is nullу всех) и начисление не входит в не-отменённый акт поступления (platform-receipt.md); изPendingConfirmationстрок распределения ещё нет (INV-4) — guard тривиально проходит. - Guard INV-8 не прошёл (
Confirmed, строка в активном поручении / начисление вDraft-акте) →REWARDS_ACCRUAL_CANCEL_FORBIDDEN: warningcancel-forbidden-in-payout/cancel-forbidden-in-receipt, метрикаrewards_accrual_cancel_forbidden_total, алерт; ручное действие — сначала отменить/переоформить поручение (освобождает строки,PayoutOrderItemId → null) или исключить позицию изDraft-акта, затем ручное сторно. Ack — не DLQ.
- Начисления нет: отменять нечего (сделка без завершения/агента/правила) — лог info
- Реферальная ветка: guard-ы и отмена (INV-RA-5 referral-accrual.md).
- Начисления нет: штатный случай (сделка без реферала) — лог info
skipped-no-referral. - Уже отменено (
Status == Cancelled): идемпотентный skipskipped-referral-already-cancelled. - Уже выплачено (
Status == Paid): автоотмена запрещена (терминальный статус) — warningreferral-cancel-forbidden-paid, метрикаrewards_referral_cancel_forbidden_total, алерт; компенсация — вручную (регламентManualCorrection+ корректировка кошелька). - Занято поручением (
Status == Approved,PayoutOrderItemId != null): автоотмена запрещена (INV-RA-5) — warningreferral-cancel-forbidden-in-payout, метрикаrewards_referral_cancel_forbidden_total, алерт; ручное действие — отменить/переоформить реферальное поручение, затем отмена начисления оператором ПИН. - Отмена (
Status == Pending, либоStatus == ApprovedприPayoutOrderItemId is null): переходCancelставитStatus = Cancelled,CancelReason = DealCancelled,CancelledAt = payload.CancelledAt, инкрементируетVersion. Отмена изApprovedкомпенсируется кошельком партнёра по событию смены статуса (Correction, ADR-0065) — сам consumer кошелёк не трогает.
- Начисления нет: штатный случай (сделка без реферала) — лог info
- Что меняется. Только в ветках отмены: начисления переводятся в
Cancelled. Конкурентность — гонка с подтверждением застройщика / регистрацией акта / включением в поручение: конфликт → перечитать и повторить (см. Retry). Дочерние строки распределения не удаляются (финансовая история; приCancelledони свободны и в поручения не попадают). В skip-ветках мутаций агрегатов нет. - Какие факты/события фиксируются. В ветках отмены:
CommissionAccrualStatusChangedKafkaEvent(topicpin.rewards.commission-accrual-status-changed.v1,ContractVersion = 1, key =Idначисления); payload:FromStatus(PendingConfirmation/Confirmed),ToStatus = Cancelled,CancelReason = DealCancelled,CommissionAmount,Version(контракт — ../events/commission-accrual-status-changed.md). Потребитель —notifications(уведомление агенту и руководителю агентства об отмене).ReferralAccrualStatusChangedKafkaEvent(topicpin.rewards.referral-accruals.v1,ContractVersion = 1, key =Idначисления; контракт —../events/referral-accrual-status-changed.md, по конвенции); payload:FromStatus(Pending/Approved),ToStatus = Cancelled,CancelReason = DealCancelled,Amount,Version. Потребители —notifications(уведомление партнёру);wallet(приFromStatus = Approved— компенсирующаяCorrectionсчётаReferralCommission, ADR-0065).
- Согласованность. Переходы агрегатов, отметка идемпотентности и исходящие события фиксируются в одном согласованном изменении; offset подтверждается после фиксации. В skip-ветках фиксируется только отметка идемпотентности (и эффект второй ветки, если она выполнилась).
- Результат. Успех → ack; non-retriable коды (
REWARDS_DEAL_EVENT_INVALID,REWARDS_TENANT_MISMATCH) → DLQ + алерт; инфраструктурные сбои → retry (таблица ниже).
Диаграмма
Идемпотентность и replay
- Consumer-level: отметка по идентификатору сообщения (
rewards:crm-deal-cancelled:{MessageId}) — повторная доставка after-commit-crash не мутирует состояние. - Business-level: ключ =
DealIdв обеих ветках. Целевое состояние отмены — терминальныйCancelled; повтор события на ужеCancelledначисления → skip.MoneyReceived/Paid/in-payout/in-receipt— skip с warning (нужна ручная компенсация). Отсутствие начислений — штатный no-op. - Replay: да. Полный re-read топика с offset 0 безопасен:
Cancelидемпотентен (ужеCancelled→ skip), отсутствие начислений → skip,MoneyReceived/Paid→ skip. Runbook —../runbooks/rewards-accrual-replay.md(по конвенции, ../README.md).
Retry и DLQ
DLQ-топик группы: pin.rewards.crm-deals-dlq.v1 (общий с crm-deal-completed.md;
заголовки x-dlq-reason, x-dlq-error-code, x-source-topic, x-original-offset).
| Сбой | Код | Retry | DLQ | Ручное действие |
|---|---|---|---|---|
| Инфраструктурный (PostgreSQL/Kafka недоступны, timeout) | — (exception) | Бесконечный exponential backoff 1s→60s с паузой партиции | Нет | Runbook rewards-overview: восстановить зависимость |
Нарушение контракта payload (пустой DealId/TenantId/CancelledAt) | REWARDS_DEAL_EVENT_INVALID | Нет (non-retriable) | Да + алерт | Разбор с командой crm; после фикса producer-а — replay из DLQ |
Несоответствие стороны застройщика (DeveloperCompanyId != TenantId) | REWARDS_TENANT_MISMATCH | Нет | Да + алерт | Разбор адресации/данных сделки в crm |
| Гонка при подтверждении / регистрации акта / включении в поручение (оптимистическая блокировка) | — (конфликт) | 3 попытки немедленно (перечитать начисление) | После исчерпания | Расследование гонки |
Автоотмена комиссионного запрещена: MoneyReceived/Paid (гейт денег) или строка в активном поручении / Draft-акте (INV-8) | REWARDS_ACCRUAL_CANCEL_FORBIDDEN | — (не ошибка доставки) | Нет (ack) | Мониторинг rewards_accrual_cancel_forbidden_total + алерт; освобождение (отмена поручения / исключение из акта) → ручное сторно; после гейта — ручное корректирующее начисление |
Автоотмена реферального запрещена: Paid или занято поручением (INV-RA-5) | REWARDS_REFERRAL_ACCRUAL_CANCEL_FORBIDDEN | — (не ошибка доставки) | Нет (ack) | Мониторинг rewards_referral_cancel_forbidden_total + алерт; отмена реферального поручения → отмена оператором ПИН / ручная компенсация |
| Начислений нет | — (не ошибка) | — | Нет | Не требуется (штатный skip) |
Acceptance-критерии
- Отмена начислений (happy path). Дано: по
DealIdесть комиссионное начисление вPendingConfirmation/Confirmed(guard INV-8 проходит: строки свободны, начисление не в не-отменённом акте) и реферальное вPending. Когда: consumer обрабатываетpin.crm.deal-cancelled.v1. Тогда: оба начисления →Cancelled(CancelReason = DealCancelled,CancelledAt = payload.CancelledAt,Version + 1); опубликованыCommissionAccrualStatusChangedKafkaEventиReferralAccrualStatusChangedKafkaEvent(ToStatus = Cancelled); ack. - Начислений нет. Дано: по
DealIdначислений не существует (штатная отмена незавершённой сделки). Когда: обработка. Тогда:skipped-no-accrual+skipped-no-referral, ack (не ошибка). - Уже отменено. Дано: начисления уже
Cancelled. Когда: повторная/replay-доставка. Тогда:skipped-already-cancelled, ack (идемпотентность поDealId, целевое терминальное состояние достигнуто). - Гейт денег пройден (MoneyReceived). Дано: комиссионное начисление в
MoneyReceived(акт зарегистрирован). Когда: обработка. Тогда: автоотмена запрещена (INV-8, ADR-0067), warningcancel-forbidden-money-received, метрикаrewards_accrual_cancel_forbidden_total+ алерт, ack; компенсация — ручное корректирующее начисление (акт не разворачивается). - Уже выплачено. Дано: комиссионное начисление
Paid. Когда: обработка. Тогда: автооткат запрещён (INV-8), warningcancel-forbidden-paid, метрика + алерт, ack; компенсация — ручное сторно. - Строка в активном поручении / Draft-акте (INV-8). Дано:
Confirmed-начисление, его строка включена в неотменённое поручение либо начисление — позицияDraft-акта. Когда: обработка. Тогда:REWARDS_ACCRUAL_CANCEL_FORBIDDEN, warningcancel-forbidden-in-payout/cancel-forbidden-in-receipt- алерт, ack; сначала освободить (отменить поручение / исключить из акта), затем ручное сторно.
- Реферальное из Approved (свободно). Дано: рефералка
Approved,PayoutOrderItemId is null(акт был зарегистрирован, выплата ещё не собрана). Когда: обработка. Тогда: рефералка →Cancelled(DealCancelled); по событиюwalletвыполняет компенсирующуюCorrectionсчёта партнёра. - Реферальное занято поручением / выплачено (INV-RA-5). Дано: рефералка
Approvedв поручении либоPaid. Когда: обработка. Тогда: автоотмена запрещена, warningreferral-cancel-forbidden-in-payout/referral-cancel-forbidden-paid, метрикаrewards_referral_cancel_forbidden_total+ алерт, ack; ручная компенсация. - Несоответствие стороны. Дано:
DeveloperCompanyIdначисления ≠payload.TenantId. Когда: обработка. Тогда:REWARDS_TENANT_MISMATCH→ DLQ + алерт (fail-closed). - Нарушение контракта. Дано: пустой
DealId/TenantId/CancelledAt. Когда: обработка. Тогда:REWARDS_DEAL_EVENT_INVALID→ DLQ + алерт. - Дубль доставки. Дано: то же
MessageId. Когда: обработка. Тогда:skipped-duplicate, ack. - Гонка с подтверждением/актом/поручением. Дано: параллельно начисление подтверждают, включают в акт поступления или строку — в поручение (оптимистическая блокировка). Когда: обработка. Тогда: до 3 немедленных повторов с перечиткой; после исчерпания — DLQ (расследование гонки).
Наблюдаемость и безопасность
- Logs (structured):
consumed/skipped-duplicate/skipped-no-accrual/skipped-no-referral/skipped-already-cancelled/cancel-forbidden-money-received/cancel-forbidden-paid/cancel-forbidden-in-payout/cancel-forbidden-in-receipt/referral-cancel-forbidden-*/accrual-cancelled/referral-accrual-cancelled/dead-letteredс полямиMessageId,DealId,DeveloperCompanyId,AccrualId,ReferralAccrualId,FromStatus. Суммы в логи не пишутся (Sensitive, README «Безопасность»); free-textCancelReasoncrm— маскируется (ФИО,+7***1234),CancelledByUserIdв лог не пишется. - Metrics:
rewards_deal_cancelled_consume_duration_ms,rewards_accrual_cancelled_total,rewards_referral_accrual_cancelled_total,rewards_accrual_cancel_skipped_no_accrual_total,rewards_accrual_cancel_forbidden_total(алерт),rewards_referral_cancel_forbidden_total(алерт),rewards_crm_deals_lag_seconds,rewards_crm_deals_dlq_total,rewards_contract_mismatch_total. - Traces: span приёма события → span обработки → spans обращений к хранилищу; корреляция из метаданных события пробрасывается в исходящее событие.
- Alerts: lag consumer-group > 5 мин (SLO README); DLQ > 0; рост
rewards_accrual_cancel_forbidden_totalиrewards_referral_cancel_forbidden_total(расторжения после гейта денег/выплаты — требуют ручной компенсации). - Dashboard / Runbook: Grafana
rewards-overview; runbookrewards-accrual-replay.md. - Безопасность: system-actor (не пользовательский контекст), platform-scope (вне tenant-контекста);
адресация по
DealIdиз доверенного внутреннего топикаcrm+ сверкаDeveloperCompanyId(fail-closed при mismatch); payload не содержит untrusted-пользовательского ввода, кроме уже валидированныхcrmполей.
Обратные ссылки
Автоссылки: где используется этот документ.
- Домен (CQRS) (2): Entity / Aggregate: ReferralAccrualEntity, Entity / Projection: AgentPerformanceEntity
- API (1): POST /api/v1/platform/referral-accruals/{accrualId}/cancel — Ручная отмена реферального начисления
- Use Cases (3): UC-RWD-003. Сделка завершена — начисление комиссии (system), UC-RWD-009. Жизненный цикл реферального начисления (связь → начисление → гейт → выплата), US-реестр: Реферальный партнёр (US-PRT)
- События (3): Event Contract: pin.crm.deal-cancelled (DealCancelledEvent), Event Contract: pin.rewards.commission-accrual-status-changed (CommissionAccrualStatusChangedKafkaEvent), Event Contract: pin.rewards.referral-accrual-status-changed (ReferralAccrualStatusChangedKafkaEvent)
- Консюмеры (2): Consumer: Agent Performance Projection, Consumer: CRM Deal Completed
- Саги (1): Saga «Commission Settlement» (финансовый контур вознаграждений)
- index.md (1): Rewards Service
Связанные документы
- Сущности: commission-accrual.md (INV-8 CancelGuard, enum
CommissionCancelReason), referral-accrual.md (INV-RA-5, enumReferralCancelReason), platform-receipt.md (участие в акте — guard отмены). - Событие-источник: ../../crm/events/deal-cancelled.md; сделка — deal.md (INV-DEAL-8/9).
- Производимые события:
commission-accrual-status-changed.md;
../events/referral-accrual-status-changed.md(по конвенции, топикpin.rewards.referral-accruals.v1). - Соседний consumer группы: crm-deal-completed.md (создание начислений); ручная компенсация — ../api/admin/create-manual-accrual.md.
- Сквозной процесс и таблица компенсаций по стадиям: commission-settlement-saga.md.
- Шаблон: consumer-template.md; ADR-0052 (tenancy), ADR-0056
(simplicity-first), ADR-0067
(гейт денег — запрет автоотмены после
MoneyReceived).