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
| Поле | Значение |
|---|---|
| ID | UC-RWD-003 |
| Контекст | rewards (см. docs/06-services/rewards/) |
| Тип | основной |
| Статус | draft |
Актор
| Поле | Значение |
|---|---|
| Роль | System (Kafka consumer rewards, system-actor) |
| Permissions / scope | Не применяется (внутренний system-actor; CreatedByUserId = системный идентификатор consumer-а) |
| Tenant | TenantId начисления = AgencyCompanyId из payload события (агентство — владелец процесса, решение №2 ../README.md, ADR-0052); consumer работает вне пользовательского tenant-контекста |
| Record-level | AgentUserId из 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 |
Основной Поток
crmзавершает сделку (переход в терминальный успешный статус) и публикуетDealCompletedEventвpin.crm.deal-completed.v1.- 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-messageREWARDS_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→ единственное правило с maxPriority(илиnull→ A3). - Резолюция условий платформы (asOf =
CompletedAt). Агентские условия (AgencyCompanyIdсделки) → глобальный дефолт → fallback 0 п.п. (A6) — алгоритм platform-commission-terms.md. Guard INV-PT-4: пункты удержания ≥CommissionPercentприменённого правила (пул агентства ≤ 0) → событие целиком в DLQREWARDS_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, guardCommissionAmount.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(topicpin.rewards.commission-accrual-created.v1, key =Id; payloadId, TenantId, DealId, DeveloperCompanyId, AgentUserId, PropertyObjectId, CommissionAmount (Value + CurrencyId + CurrencySnapshot), PlatformFeeAmount, PlatformRetentionPercentagePoints, Status = PendingConfirmation, DealCompletedAt) и — при созданной рефералке —ReferralAccrualCreatedKafkaEvent(topicpin.rewards.referral-accruals.v1). Offset коммитится после успешного сохранения (at-least-once + идемпотентность = effectively-once). Ошибок наружу нет — HTTP-ответа не существует.
- Проверка контракта. Обязательные поля payload заполнены (
notificationsполучаетpin.rewards.commission-accrual-created.v1и уведомляет агента и руководителя агентства («начислена комиссия N ₽, ожидает подтверждения застройщиком»);crmотмечает начисление в таймлайне сделки.- Агент видит начисление в ЛК (
GET /rewards/agent/my-accruals, permissionrewards.commission.view-own, record-levelAgentUserId); руководитель агентства — в сводке «получим / ожидает подтверждения» (GET /rewards/agency/commission-summary); менеджер застройщика — в списке на подтверждение (UC-RWD-004).
Альтернативные Потоки
A1. Повторная доставка события (дубль)
- Начисление по
DealIdуже существует (INV-1) → идемпотентный skip: логdebug, commit offset. Дубли из гонки инстансов ловитux_commission_accruals_deal_id— конфликт уникальности обрабатывается как тот же skip (перечитать → запись есть). Завершается.
A2. Сделка без агента
AgentUserId == null(инвестор купил напрямую, без агента) → начисление не порождается (assumption README): info-лог сDealId, метрикаrewards_accrual_skipped_no_agent_total, commit offset. Завершается.
A3. Правило не найдено
ResolveRuleвернулnull(у застройщика нет активных правил, покрывающих объект недвижимости, на моментCompletedAt) → комиссионное начисление не создаётся (решение №3 README): warning-лог сDealId,DeveloperCompanyId, атрибутами объекта недвижимости; инкремент метрикиrewards_accrual_no_rule_total(алерт на рост — README «Runtime»); реферальная ветка выполняется (еёCommissionAccrualId = null— гейт денег непроходим до появления родителя, UC-RWD-009 A4); commit offset.- Менеджер застройщика может создать начисление вручную:
POST /rewards/developer/accruals(CreateManualAccrualForm { dealId, agencyCompanyId, agentUserId, propertyObjectId, calculationBase, commissionPercent, basePrice, finalPrice, dealCompletedAt, comment }, permissionrewards.accruals.manage) — снапшот строится сRuleId = null, RuleName = "manual",AppliedRuleId = null; та же идемпотентность поDealId. Возвращается к шагу 3 основного потока.
A4. Сделка отменена после начисления
crmпубликуетpin.crm.deal-cancelled.v1; consumerrewards(../consumers/crm-deal-cancelled.md) находит начисление поDealIdи отменяет его (CancelReason = DealCancelled) — guard INV-8: допустимо изPendingConfirmationи изConfirmed(если ни одна строка распределения не в неотменённом поручении и начисление не входит в не-отменённый акт); изMoneyReceived/Paid— запрещено (компенсация — только ручным корректирующим начислением, ADR-0067).- Переход
→ CancelledпубликуетCommissionAccrualStatusChangedKafkaEvent→notifications. Завершается. - Нет начисления по
DealId(сделка без правила/без агента — начисление не создавалось; либоdeal-completedещё не обработан из-за отсутствия сквозного порядка между топиками): consumer отмены трактует как идемпотентный no-op (компенсировать нечего), commit offset. Так как оба события долговечны иdeal-completedобрабатывается быстро, устойчивого рассогласования не возникает; редкий случай «отмена обработана раньше создания» разбирается по runbook (начисление не должно оставаться по отменённой сделке).
A5. Poison message (невалидный контракт)
- Payload не проходит валидацию контракта (нет обязательного поля,
AreaTotalM2 <= 0, валюты цен различаются — INV-6) → ошибка с кодом; consumer после исчерпания retry-политики (3 попытки с backoff — транзиентные ошибки БД отсеиваются) отправляет сообщение в DLQpin.crm.deal-completed.v1.dlq, пишет error-лог сDealIdи кодом. Разбор — runbook../runbooks/rewards-accrual-replay.md(по конвенции): исправление данных вcrm→ republish.
A6. Условий платформы нет (fallback 0 п.п.)
- На
asOf = CompletedAtнет ни агентских, ни глобальных действующих условий → начисление создаётся без удержания:PlatformRetentionPercentagePoints = 0,PlatformFeeAmount = 0,AppliedPlatformTermsId = null; метрикаrewards_platform_terms_missing_total+ алерт (платформа теряет доход — UC-RWD-007 A2). Не ошибка. Далее — как в основном потоке.
A7. Удержание съедает ставку правила (INV-PT-4)
- Пункты удержания применимых условий ≥
CommissionPercentправила (пул агентства ≤ 0) → событие целиком уходит в DLQREWARDS_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) — атомарно с комиссионным. CommissionAccrualCreatedKafkaEvent(иReferralAccrualCreatedKafkaEvent, если ветка сработала) опубликованы в одном согласованном изменении с созданием.- Начисление видно: агенту («мои комиссии», ожидает подтверждения), агентству (сводка «получим»),
застройщику (очередь на подтверждение),
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_MISMATCH | — | CurrencyId цен BasePrice/FinalPrice различаются (INV-6, A5) | Retry не поможет → DLQ + error-лог |
REWARDS_ACCRUAL_AMOUNT_MISMATCH | — | Guard формулы суммы (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.v1 | pin.rewards.commission-accrual-created.v1 | Создание начисления (шаг 2), атомарно с сохранением | notifications (уведомление агенту/агентству), crm (таймлайн сделки) |
ReferralAccrualCreatedKafkaEvent.v1 | pin.rewards.referral-accruals.v1 | Создание реферального начисления (реферальная ветка, UC-RWD-009) | notifications (уведомление партнёру) |
CommissionAccrualStatusChangedKafkaEvent.v1 | pin.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 → применяется |
| AC4 | Snapshot правила фиксирует все поля расчёта; последующая правка правила не меняет начисление | изменить правило после начисления → перечитать accrual, суммы/snapshot неизменны |
| AC5 | Нет правила → начисление не создаётся, метрика rewards_accrual_no_rule_total растёт (A3) | тест без активных правил → warning + метрика |
| AC6 | Сделка без AgentUserId пропускается (A2) | тест payload без агента → нет записи |
| AC7 | CommissionAccrualCreatedKafkaEvent публикуется атомарно с созданием начисления | контракт-тест события + проверка атомарности |
| 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) | тест обеих веток одного события |
Обратные ссылки
Автоссылки: где используется этот документ.
- Use Cases (1): UC-RWD-016. Застройщик подтверждает начисление после смены исполнителя
- scenarios (1): UC-OVR-010. От завершённой сделки до выплаты комиссии и реферального вознаграждения
- registries (1): Реестр use cases и user stories PIN
Связанные Документы
- Consumers: ../consumers/crm-deal-completed.md, ../consumers/crm-deal-cancelled.md, ../consumers/referral-bond.md (реплика связей).
- Domain entities: ../domain/commission-accrual.md (поля, INV-1..11,
snapshot, stateDiagram), ../domain/reward-rule.md (
ResolveRule), ../domain/platform-commission-terms.md (резолюция условий, INV-PT-4), ../domain/referral-accrual.md (реферальная ветка). - API ручного начисления: ../api/admin/create-manual-accrual.md.
- Входящие контракты: ../../crm/events/deal-completed.md, ../../crm/events/deal-cancelled.md (требования к полям — ../README.md, «Известные Ограничения»).
- Сквозной процесс: ../sagas/commission-settlement-saga.md.
- Связанные UC: UC-RWD-001 (правила), UC-RWD-002 (та же формула в каталоге), UC-RWD-004 (подтверждение), UC-RWD-007 (условия платформы), UC-RWD-009 (реферальная ветка).