UC-RWD-004. Застройщик подтверждает начисление комиссии
Назначение
Описывает сценарий подтверждения начисления застройщиком (разделение комиссии на «подтверждено» и
«ожидает подтверждения»; матрица «Агенство» 5.3): менеджер застройщика признаёт обязательство по
комиссии — опционально скорректировав сумму с обязательной причиной (INV-11, UC-RWD-011), — начисление
переходит PendingConfirmation → Confirmed, и в этот момент строится распределение тремя строками
(ADR-0066): Platform — удержание ПИН по снапшоту условий платформы, Agency и Agent — деление
пула агентства (CommissionAmount − PlatformFeeAmount) по активной комиссионной политике. Выплаты после
подтверждения ещё недоступны — гейт получения денег проходится регистрацией акта (UC-RWD-010).
Связан с контекстом rewards.
Metadata
| Поле | Значение |
|---|---|
| ID | UC-RWD-004 |
| Контекст | rewards (см. docs/06-services/rewards/) |
| Тип | основной |
| Статус | draft |
Актор
| Поле | Значение |
|---|---|
| Роль | Девелопер (менеджер застройщика) |
| Permissions / scope | rewards.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 |
| Триггерящий элемент | ЛК застройщика → «Вознаграждения» → «Начисления на подтверждение» → строка начисления → «Подтвердить» / «Отклонить» |
Основной Поток
- Менеджер открывает очередь начислений:
GET /rewards/developer/accruals?status=PendingConfirmation(permissionrewards.accruals.confirm; выборка фильтруетDeveloperCompanyId = CompanyIdактора по индексуix_commission_accruals_developer;InvestorUserIdв проекции замаскирован — отдаётся толькоDealId). - Менеджер сверяет начисление со сделкой (ссылка на сделку
crmпоDealId, snapshot правила —AppliedRuleSnapshot: имя правила, база, процент, атрибуты объекта недвижимости) и нажимает «Подтвердить» (при расхождении с договором — «Подтвердить с корректировкой», A8). - Фронт вызывает
POST /rewards/developer/accruals/{accrualId}/confirmс теломConfirmAccrualForm { rowVersion: uint, adjustedAmount?: MoneyModel, adjustmentReason?: string (обязателен при adjustedAmount) }(optimistic concurrency; confirm-commission-accrual.md). - Система подтверждает начисление:
- Доступ.
rewards.accruals.confirmв scopeOrganization(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):Percent→agencyShare = 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(topicpin.rewards.commission-accrual-status-changed.v1, key =Id; payloadId, TenantId, DealId, FromStatus = PendingConfirmation, ToStatus = Confirmed, CancelReason = null, ConfirmedAt, PaidAt = null, Version). Конкурентное подтверждение/отмена ловится оптимистической блокировкой. - Результат.
{ accrualId, status = Confirmed, confirmedAt, commissionAmount (итоговая), isAdjusted, originalCommissionAmount? }— распределение застройщику не отдаётся (внутреннее дело агентства и платформы, confirm-commission-accrual.md);InvestorUserIdне возвращается.
- Доступ.
- Фронт убирает начисление из очереди «на подтверждение»;
notificationsпо событию уведомляет агента и руководителя агентства («комиссия N ₽ подтверждена застройщиком»). - В ЛК агента и агентства начисление переходит из «ожидает подтверждения» в «подтверждено» с
раскладкой
Platform/Agency/Agent; выплата пока недоступна —Agent-строка откроется платёжному поручению послеMoneyReceived(регистрация акта поступления средств, UC-RWD-010, ADR-0067).
Альтернативные Потоки
A1. Отказ застройщика
- Менеджер нажимает «Отклонить» →
POST /rewards/developer/accruals/{accrualId}/rejectс теломRejectAccrualForm { reason: string (1..1000, обязателен), rowVersion }. - Система отклоняет: те же доступ и проверка (guard
PendingConfirmation); начисление отменяется (CancelReason = DeveloperRejected, комментарий =reason) →Status = Cancelled,CancelledAt = now; публикуетсяCommissionAccrualStatusChangedKafkaEvent (ToStatus = Cancelled, CancelReason = DeveloperRejected). notificationsуведомляет агентство об отказе с причиной. Завершается (терминальный статус; спор — внеrewards, черезsupport).
A2. У агентства нет активной политики
- Активной политики у агентства нет → fallback INV-6
(agency-commission-policy.md): удержание агентства = 0,
весь пул — агенту: строка
Agencyсоздаётся сAmount = 0,AppliedRetentionKind = null; строкаAgent= пул (CommissionAmount − PlatformFeeAmount);AppliedPolicyId = null. СтрокаPlatformстроится независимо от политики. Это не ошибка. Далее — как в основном потоке (переход и распределение).
A3. Fixed-удержание больше пула
ResolveRetentionприKind = FixedAmountиValue >=пула срезает удержание:agencyShare = пул,agentShare = 0— задокументированный edge-case (README «Известные Ограничения»), не ошибка. СтрокаAgentсоздаётся сAmount = 0(checkamount_value >= 0допускает); строкаPlatformне затрагивается. Далее — как в основном потоке.
A4. Конкурентное изменение
- Между чтением списка и
Confirmначисление отменено consumer-омdeal-cancelled(UC-RWD-003 A4) или другим менеджером: guard статуса →REWARDS_ACCRUAL_INVALID_STATUS_TRANSITION(409) либо несовпадениеrowVersion→ 409. Фронт перечитывает начисление и показывает актуальный статус.
A5. Смена политики агентства после подтверждения
- Руководитель агентства активирует новую политику (UC-RWD-005) — уже подтверждённые начисления
не пересчитываются (README, решение по воспроизводимости: применённая политика зафиксирована
AppliedPolicyId). Новая политика применится к следующимConfirm. Завершается.
A6. Ручная отмена ошибочного начисления
- Менеджер застройщика с
rewards.accruals.manageвызываетPOST /rewards/developer/accruals/{accrualId}/cancel { reason, rowVersion }→ начисление отменяется (CancelReason = ManualCorrection); guard INV-8: изPendingConfirmationилиConfirmed— если ни одна строка не в неотменённом поручении (PayoutOrderItemId is nullу всех) и начисление не входит в не-отменённый акт поступления; изMoneyReceived/Paid—REWARDS_ACCRUAL_CANCEL_FORBIDDEN(после получения денег — только компенсирующее ручное начисление, UC-RWD-003 A3, ADR-0067).
A7. Граничные доли удержания (0 % / 100 %)
- Активная политика задаёт удержание
Percent = 0(нижняя граница INV-2 policy) →agencyShare = 0,agentShare = пул. В отличие от A2 (политики нет), строкаAgencyсоздаётся сAmount = 0иAppliedRetentionKind = Percent, аAppliedPolicyIdзаполнен — распределение воспроизводимо. Не ошибка. - Удержание
Percent = 100(верхняя граница) илиFixedAmount >=пула (A3) →agentShare = 0, строкаAgentсоздаётся сAmount = 0(checkamount_value >= 0допускает). В обоих случаях создаются ровно три строки (INV-9), их сумма равнаCommissionAmount(INV-5). Сумма комиссии всегда> 0, а пул строго положителен (INV-PT-4 при создании, INV-11 при корректировке) — начисление с нулевым пулом на подтверждение не приходит.
A8. Подтверждение с корректировкой суммы
- Менеджер передаёт
adjustedAmount+adjustmentReason: сумма заменяется до построения распределения, исходная фиксируется вOriginalCommissionAmount, создаётся аудит-строкаGrossAmount(полные guard-ы и виды корректировок — UC-RWD-011). Пул и строкиAgency/Agentсчитаются от скорректированной суммы;PlatformFeeAmountнеизменен (INV-10). Без причины —400REWARDS_ACCRUAL_ADJUSTMENT_REASON_REQUIRED; сумма ≤PlatformFeeAmount—409REWARDS_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— его создал consumerpin.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(topicpin.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 != PendingConfirmation→REWARDS_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 FORBIDDEN | 403 | Нет rewards.accruals.confirm (INV-3 permission-часть) | Скрыть кнопки подтверждения |
404 NOT FOUND | 404 | Начисление не существует или DeveloperCompanyId != actor.CompanyId (fail closed) | Вернуться к списку |
REWARDS_ACCRUAL_INVALID_STATUS_TRANSITION | 409 | Status != PendingConfirmation (уже подтверждено/отменено, A4) | Перечитать начисление |
REWARDS_ACCRUAL_DISTRIBUTION_EXISTS | 409 | Строки распределения уже существуют (INV-4, защита от повтора) | Перечитать начисление |
REWARDS_ACCRUAL_DISTRIBUTION_SUM_INVALID | 500 | Сумма трёх строк не сошлась с CommissionAmount (INV-5, инвариант-guard) | Показать ошибку, алерт разработке |
REWARDS_ACCRUAL_ADJUSTMENT_REASON_REQUIRED | 400 | adjustedAmount задан без непустой причины (INV-11, A8) | Подсветить поле причины |
REWARDS_ACCRUAL_ADJUSTMENT_FORBIDDEN | 409 | adjustedAmount <= PlatformFeeAmount — пул не положителен (INV-11, A8) | Показать минимально допустимую сумму |
REWARDS_CURRENCY_MISMATCH | 409 | Валюта FixedAmount-удержания или adjustedAmount не равна валюте комиссии | Показать ошибку, исправить вход/политику (UC-RWD-005) |
REWARDS_ACCRUAL_CANCEL_FORBIDDEN | 409 | Отмена после MoneyReceived или со строками в активном поручении / в не-отменённом акте (INV-8, A6) | Показать пояснение |
ValidationError | 400 | Пустая причина отказа (A1) | Подсветить поле причины |
409 CONFLICT (RowVersion) | 409 | Конкурентная мутация (оптимистическая блокировка, A4) | Перечитать и повторить |
Доменные События
| Событие | Когда поднимается | Subscribers |
|---|---|---|
CommissionConfirmedEvent | PendingConfirmation → Confirmed (шаг 4) | in-proc аудит |
Kafka Интеграционные События
| Событие | Topic | Когда публикуется | Consumers |
|---|---|---|---|
CommissionAccrualStatusChangedKafkaEvent.v1 | pin.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 |
| AC5 | FixedAmount больше пула → удержание срезается до пула, доля агента = 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 = 0 → Agency-строка 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) в scopeOrganization(actor.CompanyId); доступ к чужому tenant — только через permission-фильтр поDeveloperCompanyId; чужое начисление отдаёт404(fail closed, существование не раскрывается).
Обратные ссылки
Автоссылки: где используется этот документ.
- Use Cases (2): UC-RWD-016. Застройщик подтверждает начисление после смены исполнителя, UC-RWD-017. Уволенный агент: судьба заработанных комиссий
- scenarios (1): UC-OVR-010. От завершённой сделки до выплаты комиссии и реферального вознаграждения
- registries (1): Реестр use cases и user stories PIN
Связанные Документы
- API методы: ../api/admin/confirm-commission-accrual.md, ../api/admin/reject-commission-accrual.md, ../api/admin/list-developer-accruals.md.
- Domain entities: ../domain/commission-accrual.md (INV-3..11,
stateDiagram, три строки), ../domain/agency-commission-policy.md
(
ResolveRetentionпо пулу, INV-6 fallback), ../domain/platform-commission-terms.md (строкаPlatform). - Cross-tenant доступ: ADR
../../../02-architecture/adr/0052-pin-tenancy-model.md(two-sided сущности, решение №2 ../README.md). - Связанные UC: UC-RWD-003 (создание начисления со снапшотами), UC-RWD-005 (выплата долей после гейта), UC-RWD-010 (гейт получения денег), UC-RWD-011 (корректировки).
- ADR: 0066 (три строки распределения), 0067 (гейт
MoneyReceived, корректировки).