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

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
Topicpin.crm.deal-cancelled.v1
Partitions / keykey = AggregateId = DealId — строгий порядок per-deal
ContractVersion1; при несовпадении версии — warn-and-proceed (метрика rewards_contract_mismatch_total)
Consumer grouppin-rewards-crm-deals (общая с crm-deal-completed.md; один DLQ-топик группы)
Producercrm (отмена сделки 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ТипОбязательностьКак используется (маппинг)
DealIdGuidДаБизнес-ключ адресации начисления (unique ux_commission_accruals_deal_id)
TenantIdGuidДаDeveloperCompanyId застройщика; сверка с DeveloperCompanyId начисления (fail-closed при несоответствии)
PropertyObjectIdGuidДаТолько трассировка/лог (в поля начисления не пишется)
CancelledByUserIdGuid?НетТолько трассировка; не пишется в UpdatedByUserId (actor начисления — system-actor)
CancelReasonstring (free text CRM)ДаНе маппится в accrual.CancelReason: начислению всегда ставится enum CommissionCancelReason.DealCancelled. Free-text причина crm — PII, в лог только маскированной, в поля не сохраняется
CancelledAtDateTimeOffsetДаoccurredAt метода Cancelaccrual.CancelledAt

Семантика потока. Событие crm эмитится из нетерминальных статусов сделки (INV-DEAL-8/9), а начисления существуют только для завершённой сделки — поэтому в штатном потоке отмены начислений нет и доминирует ветка идемпотентного skip; ветки реальной отмены активируются при расторжении сделки после завершения (crm расширяет отмену на post-Completed). До этого расширения ветки Cancel — защитный инвариант, а не горячий путь.

Алгоритм

  1. Контекст события. Восстановить метаданные события (идентификатор сообщения, DealId, время); сверить ContractVersion — при несовпадении warn-and-proceed. Проверить идемпотентность по ключу rewards:crm-deal-cancelled:{MessageId} — дубль доставки → skipped-duplicate, пропуск (ack).
  2. Проверка входа. DealId != Guid.Empty; TenantId != Guid.Empty; CancelledAt задан. Нарушение — REWARDS_DEAL_EVENT_INVALID (non-retriable → DLQ: контракт producer-а нарушен). Отсутствие начисления здесь не ошибка — обрабатывается как идемпотентный skip (шаг 4).
  3. Что читаем. Комиссионное начисление по DealId (без tenant-фильтра — platform-scope) вместе с его строками распределения — они нужны guard-у INV-8 (проверка PayoutOrderItemId, участие в не-отменённом акте поступления). Реферальное начисление по DealId (referral-accrual.md). Отсутствие любого из начислений допустимо.
  4. Комиссионная ветка: guard-ы и отмена.
    • Начисления нет: отменять нечего (сделка без завершения/агента/правила) — лог info skipped-no-accrual, метрика rewards_accrual_cancel_skipped_no_accrual_total; обработка продолжается реферальной веткой (шаг 5).
    • Сверка стороны: DeveloperCompanyId начисления ≠ payload.TenantIdREWARDS_TENANT_MISMATCH (non-retriable → DLQ + алерт: адресация нарушена).
    • Уже отменено (Status == Cancelled): целевое терминальное состояние достигнуто — идемпотентный skip, лог skipped-already-cancelled.
    • Гейт денег пройден (Status ∈ {MoneyReceived, Paid}): автоотмена запрещена (INV-8, ADR-0067) — деньги от застройщика получены (и, возможно, выплачены), разворот статусов недопустим. Warning cancel-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: warning cancel-forbidden-in-payout / cancel-forbidden-in-receipt, метрика rewards_accrual_cancel_forbidden_total, алерт; ручное действие — сначала отменить/переоформить поручение (освобождает строки, PayoutOrderItemId → null) или исключить позицию из Draft-акта, затем ручное сторно. Ack — не DLQ.
  5. Реферальная ветка: guard-ы и отмена (INV-RA-5 referral-accrual.md).
    • Начисления нет: штатный случай (сделка без реферала) — лог info skipped-no-referral.
    • Уже отменено (Status == Cancelled): идемпотентный skip skipped-referral-already-cancelled.
    • Уже выплачено (Status == Paid): автоотмена запрещена (терминальный статус) — warning referral-cancel-forbidden-paid, метрика rewards_referral_cancel_forbidden_total, алерт; компенсация — вручную (регламент ManualCorrection + корректировка кошелька).
    • Занято поручением (Status == Approved, PayoutOrderItemId != null): автоотмена запрещена (INV-RA-5) — warning referral-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 кошелёк не трогает.
  6. Что меняется. Только в ветках отмены: начисления переводятся в Cancelled. Конкурентность — гонка с подтверждением застройщика / регистрацией акта / включением в поручение: конфликт → перечитать и повторить (см. Retry). Дочерние строки распределения не удаляются (финансовая история; при Cancelled они свободны и в поручения не попадают). В skip-ветках мутаций агрегатов нет.
  7. Какие факты/события фиксируются. В ветках отмены:
    • CommissionAccrualStatusChangedKafkaEvent (topic pin.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 (topic pin.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).
  8. Согласованность. Переходы агрегатов, отметка идемпотентности и исходящие события фиксируются в одном согласованном изменении; offset подтверждается после фиксации. В skip-ветках фиксируется только отметка идемпотентности (и эффект второй ветки, если она выполнилась).
  9. Результат. Успех → 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).

СбойКодRetryDLQРучное действие
Инфраструктурный (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), warning cancel-forbidden-money-received, метрика rewards_accrual_cancel_forbidden_total + алерт, ack; компенсация — ручное корректирующее начисление (акт не разворачивается).
  • Уже выплачено. Дано: комиссионное начисление Paid. Когда: обработка. Тогда: автооткат запрещён (INV-8), warning cancel-forbidden-paid, метрика + алерт, ack; компенсация — ручное сторно.
  • Строка в активном поручении / Draft-акте (INV-8). Дано: Confirmed-начисление, его строка включена в неотменённое поручение либо начисление — позиция Draft-акта. Когда: обработка. Тогда: REWARDS_ACCRUAL_CANCEL_FORBIDDEN, warning cancel-forbidden-in-payout / cancel-forbidden-in-receipt
    • алерт, ack; сначала освободить (отменить поручение / исключить из акта), затем ручное сторно.
  • Реферальное из Approved (свободно). Дано: рефералка Approved, PayoutOrderItemId is null (акт был зарегистрирован, выплата ещё не собрана). Когда: обработка. Тогда: рефералка → Cancelled (DealCancelled); по событию wallet выполняет компенсирующую Correction счёта партнёра.
  • Реферальное занято поручением / выплачено (INV-RA-5). Дано: рефералка Approved в поручении либо Paid. Когда: обработка. Тогда: автоотмена запрещена, warning referral-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-text CancelReason crm — маскируется (ФИО, +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; runbook rewards-accrual-replay.md.
  • Безопасность: system-actor (не пользовательский контекст), platform-scope (вне tenant-контекста); адресация по DealId из доверенного внутреннего топика crm + сверка DeveloperCompanyId (fail-closed при mismatch); payload не содержит untrusted-пользовательского ввода, кроме уже валидированных crm полей.

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

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

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