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

UC-RWD-003. Сделка завершена — начисление комиссии (system)

Назначение

Описывает системный сценарий создания начисления комиссии: CRM завершает сделку и публикует pin.crm.deal-completed.v1; consumer rewards резолвит правило вознаграждения застройщика, считает gross-сумму комиссии, резолвит условия платформы на момент завершения сделки и снимает снапшот удержания ПИН (PlatformRetentionPercentagePoints/PlatformFeeAmount/AppliedPlatformTermsId, ADR-0066), снимает snapshot правила и создаёт CommissionAccrualEntity в статусе PendingConfirmation («ожидает подтверждения» в ЛК агента/агентства). Тем же событием — независимой веткой — при активной реферальной связи инвестора создаётся реферальное начисление (ReferralAccrualEntity в Pending, детально — UC-RWD-009). Связан с контекстом rewards.

Metadata

ПолеЗначение
IDUC-RWD-003
Контекстrewards (см. docs/06-services/rewards/)
Типосновной
Статусdraft

Актор

ПолеЗначение
РольSystem (Kafka consumer rewards, system-actor)
Permissions / scopeНе применяется (внутренний system-actor; CreatedByUserId = системный идентификатор consumer-а)
TenantTenantId начисления = AgencyCompanyId из payload события (агентство — владелец процесса, решение №2 ../README.md, ADR-0052); consumer работает вне пользовательского tenant-контекста
Record-levelAgentUserId из payload — для последующего record-level доступа агента «мои начисления»

Предусловие

  • crm публикует pin.crm.deal-completed.v1 с обязательными полями контракта (../../crm/events/deal-completed.md; требование — README «Известные Ограничения»): DealId, DeveloperCompanyId, AgencyCompanyId, AgentUserId?, InvestorUserId, PropertyObjectId, PropertyType, LayoutId?, LayoutGroupId?, Rooms?, AreaTotalM2, BasePrice (Amount + CurrencyId + CurrencySnapshot../../handbook/domain/snapshots.md), FinalPrice (аналогично), CompletedAt.
  • У застройщика настроено активное правило, покрывающее объект недвижимости (UC-RWD-001) — иначе поток A3.
  • Действуют условия платформы (агентские или глобальные, UC-RWD-007) — иначе fallback 0 п.п. (A6); конфликт «удержание ≥ ставки правила» ведёт в DLQ (A7).
  • Для реферальной ветки: у инвестора активная реферальная связь в реплике (../consumers/referral-bond.md) и применимая ставка (UC-RWD-008) — иначе ветка штатно пропускается (UC-RWD-009 A1–A3).
  • Consumer-group pin-rewards-crm-deals (../consumers/crm-deal-completed.md) подключена к топику; outbox-процессор работает.

Триггер

ПолеЗначение
ИсточникKafka event
Триггерящий элементConsumer rewards, topic pin.crm.deal-completed.v1, key = DealId

Основной Поток

  1. crm завершает сделку (переход в терминальный успешный статус) и публикует DealCompletedEvent в pin.crm.deal-completed.v1.
  2. Consumer rewards (группа pin-rewards-crm-deals, топик pin.crm.deal-completed.v1, key = DealId) получает событие и создаёт начисление:
    • Проверка контракта. Обязательные поля payload заполнены (DealId, DeveloperCompanyId, AgencyCompanyId, PropertyObjectId, PropertyType, AreaTotalM2 > 0, BasePrice, FinalPrice, CompletedAt) — нарушение → poison-message (A5).
    • Сделка без агента. AgentUserId == null (инвестор напрямую) → комиссионное начисление не создаётся, идемпотентный skip с info-логом (A2); реферальная ветка выполняется.
    • Валюты (INV-6). BasePrice.CurrencyId == FinalPrice.CurrencyId; иначе poison-message REWARDS_CURRENCY_MISMATCH (A5).
    • Идемпотентность (INV-1). Если начисление по DealId уже существует → идемпотентный skip, commit offset (A1); гонка двух инстансов ловится уникальным индексом ux_commission_accruals_deal_id. Дедупликация доставки — по ключу сообщения.
    • Подбор правила. Активные правила застройщика на момент завершения сделки: TenantId = DeveloperCompanyId, Status = Active, не архивированы, период по asOf = CompletedAt (та же выборка, что в UC-RWD-002). ResolveRule → единственное правило с max Priority (или null → A3).
    • Резолюция условий платформы (asOf = CompletedAt). Агентские условия (AgencyCompanyId сделки) → глобальный дефолт → fallback 0 п.п. (A6) — алгоритм platform-commission-terms.md. Guard INV-PT-4: пункты удержания ≥ CommissionPercent применённого правила (пул агентства ≤ 0) → событие целиком в DLQ REWARDS_PLATFORM_RETENTION_EXCEEDS_RULE, ни одно начисление не создаётся (A7).
    • Расчёт и снапшот. Создаётся CommissionAccrualEntity в статусе PendingConfirmation (Version = 1, CreatedByUserId = системный): TenantId ← AgencyCompanyId, DealId, DeveloperCompanyId, AgentUserId, InvestorUserId, PropertyObjectId, DealCompletedAt ← CompletedAt; CalculationBase/CommissionPercent — из правила; BasePriceAmount ← BasePrice, FinalPriceAmount ← FinalPrice; CommissionAmount.Value = round((CalculationBase == BasePrice ? BasePriceAmount : FinalPriceAmount).Value × CommissionPercent / 100, 2, banker's) (INV-2), CommissionAmount.CurrencyId ← BasePriceAmount.CurrencyId, guard CommissionAmount.Value > 0 (иначе REWARDS_ACCRUAL_AMOUNT_MISMATCH); снапшот условий платформы (INV-10): PlatformRetentionPercentagePoints, PlatformFeeAmount = база × п.п. / 100 (banker's, 2 знака; база — та же, что в INV-2), AppliedPlatformTermsId — последующая смена условий начисление не пересчитывает; AppliedRuleId ← rule.Id; AppliedRuleSnapshot ← AppliedRuleSnapshotDocument { RuleId, RuleName, RuleVersion, CalculationBase, CommissionPercent, Priority, MatchedConditions ← rule.Conditions, тип/планировка/группа/комнаты/площадь объекта недвижимости — из payload } — воспроизводимость расчёта (решение №4 README). Строк распределения нет — появятся при Confirm (INV-4). Поднимается доменное событие CommissionAccruedEvent.
    • Реферальная ветка (независимая). По InvestorUserId ищется активная связь в реплике связей; при найденной связи, пройденном guard-е самореферала (INV-RA-4) и применимой ставке (UC-RWD-008) создаётся ReferralAccrualEntity в Pending (Amount = FinalPrice × Percent / 100, CommissionAccrualId — ссылка на созданное комиссионное начисление либо null); детально — UC-RWD-009. Обе ветки фиксируются в одном согласованном изменении.
    • Событие и согласованность. В одном согласованном изменении с созданием начислений публикуются CommissionAccrualCreatedKafkaEvent (topic pin.rewards.commission-accrual-created.v1, key = Id; payload Id, TenantId, DealId, DeveloperCompanyId, AgentUserId, PropertyObjectId, CommissionAmount (Value + CurrencyId + CurrencySnapshot), PlatformFeeAmount, PlatformRetentionPercentagePoints, Status = PendingConfirmation, DealCompletedAt) и — при созданной рефералке — ReferralAccrualCreatedKafkaEvent (topic pin.rewards.referral-accruals.v1). Offset коммитится после успешного сохранения (at-least-once + идемпотентность = effectively-once). Ошибок наружу нет — HTTP-ответа не существует.
  3. notifications получает pin.rewards.commission-accrual-created.v1 и уведомляет агента и руководителя агентства («начислена комиссия N ₽, ожидает подтверждения застройщиком»); crm отмечает начисление в таймлайне сделки.
  4. Агент видит начисление в ЛК (GET /rewards/agent/my-accruals, permission rewards.commission.view-own, record-level AgentUserId); руководитель агентства — в сводке «получим / ожидает подтверждения» (GET /rewards/agency/commission-summary); менеджер застройщика — в списке на подтверждение (UC-RWD-004).

Альтернативные Потоки

A1. Повторная доставка события (дубль)

  1. Начисление по DealId уже существует (INV-1) → идемпотентный skip: лог debug, commit offset. Дубли из гонки инстансов ловит ux_commission_accruals_deal_id — конфликт уникальности обрабатывается как тот же skip (перечитать → запись есть). Завершается.

A2. Сделка без агента

  1. AgentUserId == null (инвестор купил напрямую, без агента) → начисление не порождается (assumption README): info-лог с DealId, метрика rewards_accrual_skipped_no_agent_total, commit offset. Завершается.

A3. Правило не найдено

  1. ResolveRule вернул null (у застройщика нет активных правил, покрывающих объект недвижимости, на момент CompletedAt) → комиссионное начисление не создаётся (решение №3 README): warning-лог с DealId, DeveloperCompanyId, атрибутами объекта недвижимости; инкремент метрики rewards_accrual_no_rule_total (алерт на рост — README «Runtime»); реферальная ветка выполняется (её CommissionAccrualId = null — гейт денег непроходим до появления родителя, UC-RWD-009 A4); commit offset.
  2. Менеджер застройщика может создать начисление вручную: POST /rewards/developer/accruals (CreateManualAccrualForm { dealId, agencyCompanyId, agentUserId, propertyObjectId, calculationBase, commissionPercent, basePrice, finalPrice, dealCompletedAt, comment }, permission rewards.accruals.manage) — снапшот строится с RuleId = null, RuleName = "manual", AppliedRuleId = null; та же идемпотентность по DealId. Возвращается к шагу 3 основного потока.

A4. Сделка отменена после начисления

  1. crm публикует pin.crm.deal-cancelled.v1; consumer rewards (../consumers/crm-deal-cancelled.md) находит начисление по DealId и отменяет его (CancelReason = DealCancelled) — guard INV-8: допустимо из PendingConfirmation и из Confirmed (если ни одна строка распределения не в неотменённом поручении и начисление не входит в не-отменённый акт); из MoneyReceived/Paid — запрещено (компенсация — только ручным корректирующим начислением, ADR-0067).
  2. Переход → Cancelled публикует CommissionAccrualStatusChangedKafkaEventnotifications. Завершается.
  3. Нет начисления по DealId (сделка без правила/без агента — начисление не создавалось; либо deal-completed ещё не обработан из-за отсутствия сквозного порядка между топиками): consumer отмены трактует как идемпотентный no-op (компенсировать нечего), commit offset. Так как оба события долговечны и deal-completed обрабатывается быстро, устойчивого рассогласования не возникает; редкий случай «отмена обработана раньше создания» разбирается по runbook (начисление не должно оставаться по отменённой сделке).

A5. Poison message (невалидный контракт)

  1. Payload не проходит валидацию контракта (нет обязательного поля, AreaTotalM2 <= 0, валюты цен различаются — INV-6) → ошибка с кодом; consumer после исчерпания retry-политики (3 попытки с backoff — транзиентные ошибки БД отсеиваются) отправляет сообщение в DLQ pin.crm.deal-completed.v1.dlq, пишет error-лог с DealId и кодом. Разбор — runbook ../runbooks/rewards-accrual-replay.md (по конвенции): исправление данных в crm → republish.

A6. Условий платформы нет (fallback 0 п.п.)

  1. На asOf = CompletedAt нет ни агентских, ни глобальных действующих условий → начисление создаётся без удержания: PlatformRetentionPercentagePoints = 0, PlatformFeeAmount = 0, AppliedPlatformTermsId = null; метрика rewards_platform_terms_missing_total + алерт (платформа теряет доход — UC-RWD-007 A2). Не ошибка. Далее — как в основном потоке.

A7. Удержание съедает ставку правила (INV-PT-4)

  1. Пункты удержания применимых условий ≥ CommissionPercent правила (пул агентства ≤ 0) → событие целиком уходит в DLQ REWARDS_PLATFORM_RETENTION_EXCEEDS_RULE + алерт: ни одно начисление (включая реферальное) не создаётся. Менеджер ПИН правит условия или правило (UC-RWD-007 A5), после исправления событие переигрывается из DLQ.

Постусловие

  • Создан CommissionAccrualEntity (TenantId = агентство, Status = PendingConfirmation) с суммой, посчитанной по правилу застройщика на момент CompletedAt, полным snapshot-ом правила (AppliedRuleSnapshotDocument) и снапшотом условий платформы (PlatformRetentionPercentagePoints/PlatformFeeAmount/AppliedPlatformTermsId) — расчёт воспроизводим при любых последующих изменениях правил и условий.
  • Строк распределения ещё нет (появятся при подтверждении — UC-RWD-004: три строки Platform/Agency/Agent).
  • При активной связи и применимой ставке создано реферальное начисление Pending (UC-RWD-009) — атомарно с комиссионным.
  • CommissionAccrualCreatedKafkaEventReferralAccrualCreatedKafkaEvent, если ветка сработала) опубликованы в одном согласованном изменении с созданием.
  • Начисление видно: агенту («мои комиссии», ожидает подтверждения), агентству (сводка «получим»), застройщику (очередь на подтверждение), crm (таймлайн сделки). Видимость — асинхронная: запись в rewards фиксируется при обработке события (лаг consumer-group обычно секунды), уведомление notifications и отметка в таймлайне crm приходят в пределах SLA потребителей (< 5 мин); до этого агент/агентство ещё не видят начисление — расхождение самокорректируется.

Возможные Ошибки

КодHTTPУсловиеПоведение (consumer)
— (идемпотентный skip)Начисление по DealId уже существует (A1, INV-1)Идемпотентный skip, commit offset, debug-лог
— (skip без агента)AgentUserId == null (A2)Skip, commit offset, метрика skipped
— (нет правила)ResolveRule = null (A3, решение №3)Warning + rewards_accrual_no_rule_total, commit offset
— (fallback условий)Нет условий платформы на asOf (A6)rewards_platform_terms_missing_total + алерт, начисление создано с 0 п.п.
REWARDS_PLATFORM_RETENTION_EXCEEDS_RULEУдержание ≥ ставки применённого правила, пул ≤ 0 (INV-PT-4, A7)DLQ + алерт; replay после исправления условий/правила
REWARDS_CURRENCY_MISMATCHCurrencyId цен BasePrice/FinalPrice различаются (INV-6, A5)Retry не поможет → DLQ + error-лог
REWARDS_ACCRUAL_AMOUNT_MISMATCHGuard формулы суммы (INV-2) при созданииDLQ + error-лог (дефект кода/данных)
ValidationError (контракт)Отсутствуют обязательные поля payload (A5)DLQ + error-лог, runbook replay
Транзиентная ошибка БД/сетиPostgreSQL/Kafka временно недоступныRetry с backoff; offset не коммитится, лаг растёт (алерт > 5 мин)

Доменные События

СобытиеКогда поднимаетсяSubscribers
CommissionAccruedEventУспешное создание начисления (шаг 2)in-proc аудит

Kafka Интеграционные События

СобытиеTopicКогда публикуетсяConsumers
CommissionAccrualCreatedKafkaEvent.v1pin.rewards.commission-accrual-created.v1Создание начисления (шаг 2), атомарно с сохранениемnotifications (уведомление агенту/агентству), crm (таймлайн сделки)
ReferralAccrualCreatedKafkaEvent.v1pin.rewards.referral-accruals.v1Создание реферального начисления (реферальная ветка, UC-RWD-009)notifications (уведомление партнёру)
CommissionAccrualStatusChangedKafkaEvent.v1pin.rewards.commission-accrual-status-changed.v1Отмена по deal-cancelled (A4)notifications

Consumed: pin.crm.deal-completed.v1 (триггер), pin.crm.deal-cancelled.v1 (A4) — контракты ../../crm/events/deal-completed.md, ../../crm/events/deal-cancelled.md.

Acceptance Criteria

#КритерийПроверка
AC1Повторная доставка deal-completed не создаёт второе начисление (INV-1)тест двойной доставки + гонка двух инстансов (unique index)
AC2Сумма = база × процент / 100, banker's rounding, база — по CalculationBase правила (INV-2)параметризованный тест обеих баз и округления (x.xx5)
AC3Правило подбирается на момент CompletedAt, а не на момент обработкитест: правило с EffectiveTo между CompletedAt и now → применяется
AC4Snapshot правила фиксирует все поля расчёта; последующая правка правила не меняет начислениеизменить правило после начисления → перечитать accrual, суммы/snapshot неизменны
AC5Нет правила → начисление не создаётся, метрика rewards_accrual_no_rule_total растёт (A3)тест без активных правил → warning + метрика
AC6Сделка без AgentUserId пропускается (A2)тест payload без агента → нет записи
AC7CommissionAccrualCreatedKafkaEvent публикуется атомарно с созданием начисленияконтракт-тест события + проверка атомарности
AC8Начисление сразу видно агенту как «ожидает подтверждения» (PendingConfirmation)интеграционный тест list-my-accruals
AC9Снапшот условий платформы снят на CompletedAt: PlatformFeeAmount = база × п.п. / 100 (INV-10); смена условий не меняет начислениетест со сменой условий после начисления
AC10Без условий платформы — fallback 0 п.п. + метрика (A6)тест без активных условий
AC11Удержание ≥ ставки правила → событие в DLQ, оба начисления не созданы (INV-PT-4, A7)тест конфликта условий и правила
AC12При активной связи и ставке атомарно создаётся рефералка Pending (UC-RWD-009)тест обеих веток одного события

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

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

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