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

UC-RWD-004. Застройщик подтверждает начисление комиссии

Назначение

Описывает сценарий подтверждения начисления застройщиком (разделение комиссии на «подтверждено» и «ожидает подтверждения»; матрица «Агенство» 5.3): менеджер застройщика признаёт обязательство по комиссии — опционально скорректировав сумму с обязательной причиной (INV-11, UC-RWD-011), — начисление переходит PendingConfirmation → Confirmed, и в этот момент строится распределение тремя строками (ADR-0066): Platform — удержание ПИН по снапшоту условий платформы, Agency и Agent — деление пула агентства (CommissionAmount − PlatformFeeAmount) по активной комиссионной политике. Выплаты после подтверждения ещё недоступны — гейт получения денег проходится регистрацией акта (UC-RWD-010). Связан с контекстом rewards.

Metadata

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

Актор

ПолеЗначение
РольДевелопер (менеджер застройщика)
Permissions / scoperewards.accruals.confirm в scope Organization(CompanyId застройщика); ручные корректировки — rewards.accruals.manage
TenantАктор в tenant застройщика, но начисление живёт в tenant агентства (TenantId = AgencyCompanyId, решение №2 README). Доступ — cross-tenant по явному DeveloperCompanyId = CompanyId актора через отдельный permission-фильтрованный query-path вне tenant-интерсептора (ADR-0052)
Record-levelне применяется (фильтр — по DeveloperCompanyId)

Предусловие

  • Начисление существует и находится в Status = PendingConfirmation (создано UC-RWD-003 или вручную, UC-RWD-003 A3); снапшот условий платформы (PlatformRetentionPercentagePoints/PlatformFeeAmount) снят при создании (UC-RWD-007).
  • accrual.DeveloperCompanyId = CompanyId актора.
  • Активная политика агентства (AgencyCommissionPolicyEntity, Status = Active) настроена руководителем агентства (UC-RWD-005) — иначе fallback A2 (весь пул агенту).

Триггер

ПолеЗначение
ИсточникUI button
Триггерящий элементЛК застройщика → «Вознаграждения» → «Начисления на подтверждение» → строка начисления → «Подтвердить» / «Отклонить»

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

  1. Менеджер открывает очередь начислений: GET /rewards/developer/accruals?status=PendingConfirmation (permission rewards.accruals.confirm; выборка фильтрует DeveloperCompanyId = CompanyId актора по индексу ix_commission_accruals_developer; InvestorUserId в проекции замаскирован — отдаётся только DealId).
  2. Менеджер сверяет начисление со сделкой (ссылка на сделку crm по DealId, snapshot правила — AppliedRuleSnapshot: имя правила, база, процент, атрибуты объекта недвижимости) и нажимает «Подтвердить» (при расхождении с договором — «Подтвердить с корректировкой», A8).
  3. Фронт вызывает POST /rewards/developer/accruals/{accrualId}/confirm с телом ConfirmAccrualForm { rowVersion: uint, adjustedAmount?: MoneyModel, adjustmentReason?: string (обязателен при adjustedAmount) } (optimistic concurrency; confirm-commission-accrual.md).
  4. Система подтверждает начисление:
    • Доступ. rewards.accruals.confirm в scope Organization(actor.CompanyId). Начисление живёт в tenant агентства — доступ по явному фильтру DeveloperCompanyId = actor.CompanyId (cross-tenant read, ADR-0052).
    • Проверка. Начисление существует и accrual.DeveloperCompanyId == actor.CompanyId; иначе — 404 (fail closed, существование чужого начисления не раскрывается). Guard состояния (INV-3) — Status = PendingConfirmation, иначе REWARDS_ACCRUAL_INVALID_STATUS_TRANSITION; rowVersion совпадает с текущим (иначе 409); строк распределения ещё нет (INV-4, иначе REWARDS_ACCRUAL_DISTRIBUTION_EXISTS).
    • Корректировка суммы (опционально, INV-11, A8). При заданном adjustedAmount: обязательна непустая причина (REWARDS_ACCRUAL_ADJUSTMENT_REASON_REQUIRED), валюта совпадает с валютой начисления, adjustedAmount > PlatformFeeAmount — пул строго положителен (REWARDS_ACCRUAL_ADJUSTMENT_FORBIDDEN). Исходная сумма фиксируется в OriginalCommissionAmount (однократно), CommissionAmount заменяется, создаётся append-only аудит-строка (Kind = GrossAmount, UC-RWD-011). PlatformFeeAmount не пересчитывается — удержание ПИН считается от базы цены (INV-10).
    • Активная политика. Активная политика агентства-владельца (TenantId = accrual.TenantId, Status = Active) читается системно; её отсутствие допустимо (fallback A2).
    • Переход и распределение (три строки). Начисление переходит PendingConfirmation → Confirmed (ConfirmedAt = now, ConfirmedByUserId, Version++); AppliedPolicyId ← policy.Id (или null при fallback A2). Строки (INV-4/5/9/10):
      • Platform: RecipientUserId = null, Amount = PlatformFeeAmount (снапшот условий платформы, UC-RWD-007; 0 при fallback без условий — строка создаётся всегда);
      • пул агентства = CommissionAmount − PlatformFeeAmount (по итоговой, возможно скорректированной, сумме); деление пула (ResolveRetention по оверрайду агента или DefaultRule, agency-commission-policy.md): PercentagencyShare = round(пул × Value / 100, 2, banker's); FixedAmount → guard валюты (иначе REWARDS_CURRENCY_MISMATCH) + agencyShare = min(Value, пул); agentShare = пул − agencyShare;
      • Agency: RecipientUserId = null, Amount = agencyShare, AppliedRetentionKind ← rule.Kind; Agent: RecipientUserId ← accrual.AgentUserId, Amount = agentShare;
      • проверка INV-5: Platform + Agency + Agent == CommissionAmount, иначе REWARDS_ACCRUAL_DISTRIBUTION_SUM_INVALID. Поднимается доменное событие CommissionConfirmedEvent.
    • Событие и согласованность. В одном согласованном изменении (статус + корректировка + три строки распределения) публикуется CommissionAccrualStatusChangedKafkaEvent (topic pin.rewards.commission-accrual-status-changed.v1, key = Id; payload Id, TenantId, DealId, FromStatus = PendingConfirmation, ToStatus = Confirmed, CancelReason = null, ConfirmedAt, PaidAt = null, Version). Конкурентное подтверждение/отмена ловится оптимистической блокировкой.
    • Результат. { accrualId, status = Confirmed, confirmedAt, commissionAmount (итоговая), isAdjusted, originalCommissionAmount? } — распределение застройщику не отдаётся (внутреннее дело агентства и платформы, confirm-commission-accrual.md); InvestorUserId не возвращается.
  5. Фронт убирает начисление из очереди «на подтверждение»; notifications по событию уведомляет агента и руководителя агентства («комиссия N ₽ подтверждена застройщиком»).
  6. В ЛК агента и агентства начисление переходит из «ожидает подтверждения» в «подтверждено» с раскладкой Platform/Agency/Agent; выплата пока недоступна — Agent-строка откроется платёжному поручению после MoneyReceived (регистрация акта поступления средств, UC-RWD-010, ADR-0067).

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

A1. Отказ застройщика

  1. Менеджер нажимает «Отклонить» → POST /rewards/developer/accruals/{accrualId}/reject с телом RejectAccrualForm { reason: string (1..1000, обязателен), rowVersion }.
  2. Система отклоняет: те же доступ и проверка (guard PendingConfirmation); начисление отменяется (CancelReason = DeveloperRejected, комментарий = reason) → Status = Cancelled, CancelledAt = now; публикуется CommissionAccrualStatusChangedKafkaEvent (ToStatus = Cancelled, CancelReason = DeveloperRejected).
  3. notifications уведомляет агентство об отказе с причиной. Завершается (терминальный статус; спор — вне rewards, через support).

A2. У агентства нет активной политики

  1. Активной политики у агентства нет → fallback INV-6 (agency-commission-policy.md): удержание агентства = 0, весь пул — агенту: строка Agency создаётся с Amount = 0, AppliedRetentionKind = null; строка Agent = пул (CommissionAmount − PlatformFeeAmount); AppliedPolicyId = null. Строка Platform строится независимо от политики. Это не ошибка. Далее — как в основном потоке (переход и распределение).

A3. Fixed-удержание больше пула

  1. ResolveRetention при Kind = FixedAmount и Value >= пула срезает удержание: agencyShare = пул, agentShare = 0 — задокументированный edge-case (README «Известные Ограничения»), не ошибка. Строка Agent создаётся с Amount = 0 (check amount_value >= 0 допускает); строка Platform не затрагивается. Далее — как в основном потоке.

A4. Конкурентное изменение

  1. Между чтением списка и Confirm начисление отменено consumer-ом deal-cancelled (UC-RWD-003 A4) или другим менеджером: guard статуса → REWARDS_ACCRUAL_INVALID_STATUS_TRANSITION (409) либо несовпадение rowVersion → 409. Фронт перечитывает начисление и показывает актуальный статус.

A5. Смена политики агентства после подтверждения

  1. Руководитель агентства активирует новую политику (UC-RWD-005) — уже подтверждённые начисления не пересчитываются (README, решение по воспроизводимости: применённая политика зафиксирована AppliedPolicyId). Новая политика применится к следующим Confirm. Завершается.

A6. Ручная отмена ошибочного начисления

  1. Менеджер застройщика с rewards.accruals.manage вызывает POST /rewards/developer/accruals/{accrualId}/cancel { reason, rowVersion } → начисление отменяется (CancelReason = ManualCorrection); guard INV-8: из PendingConfirmation или Confirmed — если ни одна строка не в неотменённом поручении (PayoutOrderItemId is null у всех) и начисление не входит в не-отменённый акт поступления; из MoneyReceived/PaidREWARDS_ACCRUAL_CANCEL_FORBIDDEN (после получения денег — только компенсирующее ручное начисление, UC-RWD-003 A3, ADR-0067).

A7. Граничные доли удержания (0 % / 100 %)

  1. Активная политика задаёт удержание Percent = 0 (нижняя граница INV-2 policy) → agencyShare = 0, agentShare = пул. В отличие от A2 (политики нет), строка Agency создаётся с Amount = 0 и AppliedRetentionKind = Percent, а AppliedPolicyId заполнен — распределение воспроизводимо. Не ошибка.
  2. Удержание Percent = 100 (верхняя граница) или FixedAmount >= пула (A3) → agentShare = 0, строка Agent создаётся с Amount = 0 (check amount_value >= 0 допускает). В обоих случаях создаются ровно три строки (INV-9), их сумма равна CommissionAmount (INV-5). Сумма комиссии всегда > 0, а пул строго положителен (INV-PT-4 при создании, INV-11 при корректировке) — начисление с нулевым пулом на подтверждение не приходит.

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

  1. Менеджер передаёт adjustedAmount + adjustmentReason: сумма заменяется до построения распределения, исходная фиксируется в OriginalCommissionAmount, создаётся аудит-строка GrossAmount (полные guard-ы и виды корректировок — UC-RWD-011). Пул и строки Agency/Agent считаются от скорректированной суммы; PlatformFeeAmount неизменен (INV-10). Без причины — 400 REWARDS_ACCRUAL_ADJUSTMENT_REASON_REQUIRED; сумма ≤ PlatformFeeAmount409 REWARDS_ACCRUAL_ADJUSTMENT_FORBIDDEN. Далее — как в основном потоке.

Постусловие

  • Начисление в Status = Confirmed; ConfirmedAt/ConfirmedByUserId заполнены; AppliedPolicyId зафиксирован (или null при fallback A2). При корректировке (A8): CommissionAmount — итоговая сумма, OriginalCommissionAmount — исходная, создана аудит-строка.
  • Созданы ровно три строки CommissionDistributionEntity (Platform + Agency + Agent, INV-9), сумма строк равна CommissionAmount (INV-5), строка Platform = PlatformFeeAmount (INV-10); Agent-строка свободна (PayoutOrderItemId = null), но станет доступна платёжному поручению только после MoneyReceived — регистрации акта поступления средств (UC-RWD-010, ADR-0067).
  • CommissionAccrualStatusChangedKafkaEvent опубликован атомарно; в сводках ЛК начисление учитывается как «подтверждено» (деньги от застройщика ещё не получены).

Согласованность и межсервисное взаимодействие

  • Ожидает на входе. Начисление уже существует в Status = PendingConfirmation — его создал consumer pin.crm.deal-completed.v1 (UC-RWD-003) либо менеджер вручную (UC-RWD-003 A3). Поля DealId, DeveloperCompanyId, AgentUserId, AppliedRuleSnapshot и снапшот условий платформы (PlatformFeeAmount) на подтверждении иммутабельны; единственная допустимая правка застройщика — корректировка CommissionAmount в составе подтверждения с обязательной причиной (A8, UC-RWD-011). Активная политика агентства читается системно внутри того же контекста rewards (tenant = accrual.TenantId) — синхронно, в рамках операции; её отсутствие допустимо (fallback A2). Синхронных вызовов соседних сервисов операция не делает.
  • Предоставляет на выходе. Ровно одно CommissionAccrualStatusChangedKafkaEvent (topic pin.rewards.commission-accrual-status-changed.v1, key = Id, ContractVersion = 1) публикуется атомарно с мутацией. Ключ партиционирования = Id начисления — все переходы одного начисления (Confirmed/Cancelled/Paid) приходят потребителю в порядке. Потребители: notifications (уведомление агенту и руководителю агентства) и self-consume проекцией AgentPerformance. Переход → Confirmed метрики проекции не меняет — проекция реагирует только на → Paid (CommissionPaidTotal += …) и → Cancelled(DealCancelled) (реверс DealsWonCount/CommissionAccruedTotal, DealsLostCount += 1), см. agent-performance.md.
  • Окно eventual-consistency. Синхронно менеджер видит Confirmed, итоговую сумму и признак корректировки (без распределения); агентство сразу видит все три строки (одно согласованное изменение). Асинхронно (лаг доставки события < 1 мин штатно, alert > 5 мин): агент и агентство получают уведомление; в сводках ЛК (get-agency-commission-summary, get-my-commission-summary) сумма переезжает из «ожидает подтверждения» в «подтверждено» после применения события. В промежутке пользователь видит прежнюю сводку — это не ошибка, а лаг проекции.
  • Идемпотентность. Отдельный ключ не нужен — идемпотентность естественная: повтор Confirm того же начисления упирается в guard статуса (Status != PendingConfirmationREWARDS_ACCRUAL_INVALID_STATUS_TRANSITION) и в наличие уже созданных строк распределения (INV-4 → REWARDS_ACCRUAL_DISTRIBUTION_EXISTS); повторной публикации события и повторного распределения не происходит.
  • Конкурентность (кто побеждает). Confirm против отмены (deal-cancelled-consumer / ручная A6) или против второго менеджера разрешается оптимистической блокировкой: побеждает первая зафиксированная мутация; проигравший получает 409 (несовпадение rowVersion) либо REWARDS_ACCRUAL_INVALID_STATUS_TRANSITION (A4) и перечитывает актуальный статус.
  • Восстановление. Событие статуса доставляется at-least-once, потребители идемпотентны; публикация события и смена статуса неразделимы (одно согласованное изменение) — «подтвердили, но не уведомили» невозможно. Компенсация ошибочного подтверждения — ручная отмена (A6, только пока строки не включены в неотменённое поручение) либо компенсирующее ручное начисление-сторно застройщика (UC-RWD-003 A3).

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

КодHTTPУсловиеПоведение клиента
403 FORBIDDEN403Нет rewards.accruals.confirm (INV-3 permission-часть)Скрыть кнопки подтверждения
404 NOT FOUND404Начисление не существует или DeveloperCompanyId != actor.CompanyId (fail closed)Вернуться к списку
REWARDS_ACCRUAL_INVALID_STATUS_TRANSITION409Status != PendingConfirmation (уже подтверждено/отменено, A4)Перечитать начисление
REWARDS_ACCRUAL_DISTRIBUTION_EXISTS409Строки распределения уже существуют (INV-4, защита от повтора)Перечитать начисление
REWARDS_ACCRUAL_DISTRIBUTION_SUM_INVALID500Сумма трёх строк не сошлась с CommissionAmount (INV-5, инвариант-guard)Показать ошибку, алерт разработке
REWARDS_ACCRUAL_ADJUSTMENT_REASON_REQUIRED400adjustedAmount задан без непустой причины (INV-11, A8)Подсветить поле причины
REWARDS_ACCRUAL_ADJUSTMENT_FORBIDDEN409adjustedAmount <= PlatformFeeAmount — пул не положителен (INV-11, A8)Показать минимально допустимую сумму
REWARDS_CURRENCY_MISMATCH409Валюта FixedAmount-удержания или adjustedAmount не равна валюте комиссииПоказать ошибку, исправить вход/политику (UC-RWD-005)
REWARDS_ACCRUAL_CANCEL_FORBIDDEN409Отмена после MoneyReceived или со строками в активном поручении / в не-отменённом акте (INV-8, A6)Показать пояснение
ValidationError400Пустая причина отказа (A1)Подсветить поле причины
409 CONFLICT (RowVersion)409Конкурентная мутация (оптимистическая блокировка, A4)Перечитать и повторить

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

СобытиеКогда поднимаетсяSubscribers
CommissionConfirmedEventPendingConfirmation → Confirmed (шаг 4)in-proc аудит

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

СобытиеTopicКогда публикуетсяConsumers
CommissionAccrualStatusChangedKafkaEvent.v1pin.rewards.commission-accrual-status-changed.v1Подтверждение (шаг 4), отказ (A1), отмена (A6)notifications — уведомления агенту/агентству о подтверждении/отказе

Acceptance Criteria

#КритерийПроверка
AC1Подтвердить может только менеджер компании DeveloperCompanyId начислениятест чужого застройщика → 404
AC2При Confirm создаются ровно 3 строки (Platform/Agency/Agent), Σ = CommissionAmount; Platform = PlatformFeeAmount (INV-4/5/9/10)unit-тест Confirm + db check
AC3Персональный оверрайд агента в политике приоритетнее DefaultRule; политика делит пул, не grossтест ResolveRetention с оверрайдом
AC4Без активной политики весь пул — агенту, Agency-строка = 0, AppliedPolicyId = null; Platform-строка не зависит от политики (A2)тест fallback INV-6
AC5FixedAmount больше пула → удержание срезается до пула, доля агента = 0, Platform не тронута (A3)тест edge-case
AC6Повторный Confirm невозможен (идемпотентность через guard статуса)второй вызов → 409
AC7Смена политики не пересчитывает подтверждённые начисления (A5)активировать новую политику → распределение прежнее
AC8Отказ требует причину; статус — Cancelled/DeveloperRejected (A1)тест без причины → 400; с причиной → событие с CancelReason
AC9Событие статуса публикуется атомарно с мутациейконтракт-тест события + проверка атомарности
AC10В сводке агентства сумма переехала из «ожидает» в «подтверждено»; выплата недоступна до MoneyReceivedинтеграционный тест get-agency-commission-summary + автосборка поручения до акта
AC11Удержание Percent = 0Agency-строка Amount = 0, но AppliedPolicyId заполнен и AppliedRetentionKind = Percent (в отличие от fallback A2)тест граничной доли (A7)
AC12Подтверждение с корректировкой (A8): CommissionAmount заменён, OriginalCommissionAmount зафиксирован, аудит-строка GrossAmount создана, пул посчитан от новой суммы, PlatformFeeAmount неизменентест confirm с adjustedAmount
AC13Корректировка без причины / не покрывающая удержание ПИН отклоняется (A8)тесты 400 / 409

Наблюдаемость и НФТ

  • Логи (без секретов/PII). accrual-confirmed (accrualId, tenantId = агентство, developerCompanyId, dealId, fromStatus = PendingConfirmation, toStatus = Confirmed, appliedPolicyId?, distributionsCount = 3, isAdjusted, version); accrual-rejected (accrualId, cancelReason = DeveloperRejected, длина причины — не текст); accrual-manual-cancelled (A6: accrualId, cancelReason = ManualCorrection). Суммы (CommissionAmount, доли распределения) — класс Sensitive, в логи не пишутся; InvestorUserId не логируется и не возвращается.
  • Метрики. rewards_accrual_confirm_total{result=confirmed|rejected|manual_cancelled}; rewards_accrual_confirm_adjusted_total (доля подтверждений с корректировкой, A8); rewards_accrual_confirm_duration (p95, включая расчёт распределения); rewards_accrual_distribution_sum_invalid_total (guard INV-5 — в норме 0, рост = дефект расчёта, алерт разработке); rewards_currency_mismatch_total (INV-6 / FixedAmount-удержание в чужой валюте).
  • Трейсы. Span приёма → проверка доступа и guard статуса → чтение активной политики (в том же контексте) → расчёт распределения → публикация CommissionAccrualStatusChangedKafkaEvent; сквозной traceparent, в т.ч. через cross-tenant read-path застройщика (ADR-0052).
  • НФТ (нагрузка/латентность). API p95 < 300 мс (SLO сервиса); поток подтверждений низкий (единицы-десятки в день на застройщика, ADR-0056); штатный лаг доставки события статуса < 1 мин (alert > 5 мин) — до его применения сводки ЛК агента/агентства отстают (не ошибка).
  • Доступ/безопасность. rewards.accruals.confirm (ручная отмена — rewards.accruals.manage) в scope Organization(actor.CompanyId); доступ к чужому tenant — только через permission-фильтр по DeveloperCompanyId; чужое начисление отдаёт 404 (fail closed, существование не раскрывается).

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

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

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