POST /api/v1/developer/commission-accruals — Ручное создание начисления застройщиком
Ручное начисление комиссии по завершённой сделке, для которой автоматическое начисление не создалось
(правило не найдено — решение №3 ../README.md, метрика rewards_accrual_no_rule_total)
или параметры согласованы индивидуально. Создаётся в статусе PendingConfirmation (stateDiagram
domain/commission-accrual.md) и подтверждается обычным
confirm-commission-accrual.md — распределение по политике агентства
строится в единственной точке.
Назначение
Менеджер застройщика с расширенным правом rewards.accruals.manage вводит начисление вручную:
процент и базу задаёт сам, snapshot фиксируется с RuleName = "manual", AppliedRuleId = null.
Feature group — developer-accruals.
Metadata
| Поле | Значение |
|---|---|
| Сервис/контекст | rewards |
| API area | developer-admin |
| Feature group | developer-accruals |
| Статус | draft |
| Документ | docs/06-services/rewards/api/admin/create-manual-accrual.md |
Актор и доступ
| Проверка | Значение |
|---|---|
| Actor type | User (менеджер компании-застройщика) |
| Auth policy / Permissions | rewards.accruals.manage (шире, чем confirm — создаёт финансовые обязательства) |
| Scope (RBAC) | организация: компания Type = Developer |
| Record-level | DeveloperCompanyId начисления = CompanyId актора (жёстко на сервере) |
| Tenant isolation | Начисление создаётся с TenantId = AgencyCompanyId (двусторонняя сущность, ADR-0052/решение №2); команда работает вне tenant-интерсептора актора |
| Audit | create: CreatedByUserId = ActorUserId, Version = 1; финансовая операция — полный audit-trail через события |
HTTP-контракт
| Поле | Значение |
|---|---|
| Method | POST |
| Route | /api/v1/developer/commission-accruals |
| Success status | 201 (+ Location: /api/v1/developer/commission-accruals/{id} в списке) |
| Idempotency header | Idempotency-Key (Guid); бизнес-идемпотентность — unique DealId (INV-1) |
| Correlation | сквозной trace через заголовок traceparent |
Входные данные / параметры запроса
| Поле | Источник | Тип | Обязательность | Валидация / Ограничения | Безопасность |
|---|---|---|---|---|---|
DeveloperCompanyId | identity claims (tenant_id) | Guid | Да | Required; не из body | record-level |
ActorUserId | identity claims (sub) | Guid | Да | Required | — |
IdempotencyKey | header Idempotency-Key | Guid | Да | Required | — |
DealId | body | Guid | Да | непустой; уникальность — INV-1 (одно начисление на сделку) | — |
AgencyCompanyId | body | Guid | Да | непустой; компания-агентство получателя (станет TenantId) | — |
AgentUserId | body | Guid | Да | непустой; агент-исполнитель сделки | — |
PropertyObjectId | body | Guid | Да | непустой | — |
DealCompletedAt | body | DateTimeOffset | Да | <= UtcNow (сделка уже завершена) | — |
CalculationBase | body | CommissionCalculationBase | Да | enum BasePrice/FinalPrice | — |
CommissionPercent | body | decimal | Да | > 0, <= 100, scale 2 | — |
BasePrice | body | MoneyModel | Да | Value > 0, Currency = RUB | суммы не логируются |
FinalPrice | body | MoneyModel | Да | Value >= BasePrice.Value; Currency == BasePrice.Currency (INV-6) | суммы не логируются |
PropertyObjectPropertyType | body | PropertyType | Да | enum; для snapshot-а | — |
PropertyObjectLayoutId / PropertyObjectLayoutGroupId | body | Guid? | Нет | для snapshot-а | — |
PropertyObjectRooms | body | int? | Нет | 0..20; для snapshot-а | — |
PropertyObjectAreaTotalM2 | body | decimal | Да | > 0, <= 10000, scale 2; для snapshot-а | — |
Comment | body | string? | Нет | <= 1000; причина ручного начисления — в audit-log операции | — |
Ссылочные проверки DealId/AgencyCompanyId/AgentUserId на стороне crm/identity не выполняются
синхронно (assumption контекста, ../README.md) — ответственность актора; ошибка
исправляется отменой ManualCorrection.
Модель ответа CreateManualAccrualCommandResult
| Поле | Тип | Обязательность | Источник | Masking | Описание |
|---|---|---|---|---|---|
Id | Guid | Да | CommissionAccrualEntity.Id | — | Id начисления. |
Status | string (enum CommissionAccrualStatus) | Да | Status | — | Всегда PendingConfirmation. |
CommissionAmount | MoneyModel | Да | CommissionAmount | — | Вычисленная сумма комиссии. |
Version | long | Да | Version | — | 1. |
Предусловия и постусловия
- Предусловия. Актор — менеджер застройщика с расширенным правом
rewards.accruals.manage; его компания — застройщик (Type = Developer). Сделка уже завершена (DealCompletedAt <= UtcNow) и по еёDealIdещё нет начисления ни в одном статусе (включаяCancelled). Цены и процент валидны, обе цены — в одной валютеRUB(INV-6). Типичный запуск — застройщик увидел в очереди сделку без автоначисления (правило не найдено, метрикаrewards_accrual_no_rule_total) либо согласовал параметры индивидуально. - Постусловия (успех). В tenant-е агентства (
TenantId = AgencyCompanyId, двусторонняя сущность) появилось ровно одно начисление в статусеPendingConfirmationсVersion = 1, зафиксированным автором, вычисленнымCommissionAmount(INV-2) и snapshot-омRuleName = "manual"/AppliedRuleId = null(расчёт воспроизводим). Строк распределения нет — они появятся только при подтверждении (confirm-commission-accrual.md, INV-4). ОпубликованоCommissionAccrualCreatedKafkaEvent; запись идемпотентности сохранена. Начисление видно в очереди застройщика (list-developer-accruals.md) и в сводке агентства как «ожидает подтверждения». Если по этой сделке было реферальное начисление-сирота (Pending,CommissionAccrualId = null), в том же изменении ему проставленCommissionAccrualIdэтого начисления (Versionрефералки +1) — рефералка обрела родителя и теперь способна пройти вApprovedпо гейтуMoneyReceived(до этого была застопорена вPending); её статус на этом шаге не меняется. Рефералки на сделку могло не быть или её родитель уже был проставлен — тогда back-link не выполняется. - Постусловия (отказ). Ни одного начисления не создано; back-link рефералки-сироты откачен вместе с
изменением (её
CommissionAccrualIdостаётсяnull,Versionне менялся); событие не публикуется. Повтор с тем жеIdempotency-Keyпосле успеха возвращает исходный результат и второго начисления не создаёт (рефералка при повторе уже связана — back-link no-op).
Алгоритм
- Контекст и доступ. Операцию выполняет менеджер компании-застройщика с расширенным правом
rewards.accruals.manage(создаёт финансовые обязательства); scope — организация застройщика (DeveloperCompanyId). Повторный вызов с тем жеIdempotencyKeyвозвращает ранее сохранённый результат. - Проверка входа и предусловий. Проценты, цены, валюты и даты — по таблице входных данных;
нарушение диапазонов/формата →
ValidationError, несовпадение валют →REWARDS_CURRENCY_MISMATCH. Уникальность сделки (INV-1): начисления по этомуDealIdещё нет ни в одном статусе (включаяCancelled) — иначеREWARDS_ACCRUAL_ALREADY_EXISTS. СуществованиеDealId/AgencyCompanyId/AgentUserIdвcrm/identityсинхронно не проверяется (ответственность актора, см. примечание к входным данным). - Создание начисления и расчёт. Формируется начисление в статусе
PendingConfirmation:TenantId=AgencyCompanyId(двусторонняя сущность),DeveloperCompanyIdиз claims,AgentUserId,PropertyObjectId,DealCompletedAt— из тела;InvestorUserId = null;- сумма комиссии (INV-2): база =
BasePriceилиFinalPriceпоCalculationBase;CommissionAmount= процент от базы (округление banker's, scale 2), валюта базы; сумма должна быть положительной — иначеREWARDS_ACCRUAL_AMOUNT_MISMATCH; - snapshot применённого правила фиксируется с
AppliedRuleId = null,RuleName = "manual", пустыми условиями и атрибутами объекта из тела запроса — расчёт воспроизводим; - фиксируются актор,
CreatedAt,Version = 1.
- Back-linking реферального начисления-сироты. В том же согласованном изменении ищется
реферальное начисление той же сделки, оставшееся без родителя:
DealIdсовпадает, статусPending,CommissionAccrualId = null(сирота — создана consumer-омdeal-completed, но правила застройщика на тот момент не было; см. domain/referral-accrual.md, INV-RA-3). Если такая найдена — ей проставляетсяCommissionAccrualId = Idтолько что созданного начисления (Versionрефералки инкрементируется). Рефералок на сделку — не более одной (INV-RA-1), поэтому связывается либо ноль, либо ровно одна. Операция идемпотентна: рефералки нет (сделка без реферала), либо у неё уже проставленCommissionAccrualId(неnull) — тогда шаг no-op, ручное начисление создаётся штатно. Эффект: рефералка получает родителя и теперь сможет пройти вApprovedпо гейтуMoneyReceivedэтого начисления (регистрация акта поступления средств — ../consumers/crm-deal-completed.md, INV-RA-3); до появления родителя она была застопорена вPending(гейт денег непроходим). - Согласованность. Начисление, back-link рефералки-сироты (если найдена), запись идемпотентности и
факт создания сохраняются в одном согласованном изменении. Гонка с consumer-ом
deal-completedпо тому жеDealId(он мог параллельно создавать комиссионное начисление) ловится уникальным индексом наDealId(INV-1) и возвращаетREWARDS_ACCRUAL_ALREADY_EXISTS. Гонка за саму рефералку-сироту (consumerdeal-completedпри создании родителя штатным путём мог бы одновременно проставлять ейCommissionAccrualId) разрешается блокировкой строки рефералки при чтении и уникальностью еёDealId: ссылку выставляет захвативший строку первым, второй перечитывает и видитCommissionAccrualId != null→ его шаг back-linking вырождается в no-op. Двойного родителя у рефералки возникнуть не может. - События. Публикуется
CommissionAccrualCreatedKafkaEvent(топикpin.rewards.commission-accrual-created.v1, ключ =AccrualId; payload:Id,TenantId,DealId,DeveloperCompanyId,AgentUserId,PropertyObjectId,CommissionAmount,Status = PendingConfirmation,DealCompletedAt— контракт ../events/commission-accrual-created.md) — потребители:notifications(агент/агентство),crm(таймлайн сделки). Смена статуса самой рефералки (Pending → Approved) на этом шаге не происходит — только проставление родителя; апрув наступит позднее, при регистрации акта поступления средств по этому начислению. - Результат.
Id,Status = PendingConfirmation, вычисленныйCommissionAmount,Version = 1→201.
Диаграмма
Побочные эффекты
| Тип | Детали |
|---|---|
| Integration events | CommissionAccrualCreatedKafkaEvent / pin.rewards.commission-accrual-created.v1 / key = AccrualId |
| External calls | none |
| Cache / projections / search | none |
| Data mutation | back-link рефералки-сироты: ReferralAccrualEntity.CommissionAccrualId (той же DealId, Pending, ранее null) ← Id начисления (Version рефералки +1), в том же изменении. Смена статуса рефералки не выполняется — только проставление родителя |
| Notifications | notifications по событию: агенту и руководителю агентства «начислена комиссия» |
Окно eventual-consistency: начисление и запись идемпотентности фиксируются атомарно; агентство сразу
(синхронно после коммита) видит новое начисление в своей сводке/очереди как PendingConfirmation, а
застройщик — в очереди подтверждения. CommissionAccrualCreatedKafkaEvent уходит через outbox (ключ =
AccrualId, порядок событий по начислению сохраняется); notifications доставляет уведомление и crm
отмечает таймлайн сделки асинхронно (окно секунды). Дальнейшая судьба (распределение) наступает только
после подтверждения застройщиком.
Ошибки и failure modes
| Код | HTTP status | Условие | Сообщение | Retry |
|---|---|---|---|---|
REWARDS_ACCRUAL_ALREADY_EXISTS | 409 | Начисление по DealId уже существует (INV-1) | «По этой сделке начисление уже создано» | no retry |
REWARDS_ACCRUAL_AMOUNT_MISMATCH | 400 | Вычисленная комиссия <= 0 (INV-2) | «Сумма комиссии должна быть положительной» | no retry |
REWARDS_CURRENCY_MISMATCH | 400 | Валюты цен различаются / не RUB (INV-6) | «Валюты должны совпадать» | no retry |
Back-linking рефералки-сироты собственных кодов не добавляет: отсутствие рефералки или уже проставленный
CommissionAccrualId — штатный no-op, а гонка за родителя разрешается уникальным индексом DealId (INV-1,
код REWARDS_ACCRUAL_ALREADY_EXISTS).
Общие коды — README.md.
Acceptance-критерии
- Happy path (сделка без автоначисления). Дано: сделка завершена, автоначисление не создалось
(
rewards_accrual_no_rule_total), актор сrewards.accruals.manage, валидное тело. Когда:POST /api/v1/developer/commission-accruals. Тогда:201+Location,Status = PendingConfirmation,CommissionAmount= процент от выбранной базы (banker's, scale 2, валюта базы),Version = 1; snapshotRuleName = "manual",AppliedRuleId = null; опубликованоCommissionAccrualCreatedKafkaEvent; начисление далее подтверждается обычным confirm-commission-accrual.md. - Back-link рефералки-сироты. Дано: по этой
DealIdесть реферальное начисление в статусеPendingсCommissionAccrualId = null(создано consumer-омdeal-completed, застряло без родителя, INV-RA-3), правила застройщика не было. Когда:POSTручного начисления по той жеDealId. Тогда: в том же изменении рефералке проставленCommissionAccrualId = Idсозданного начисления, еёVersion+1, статус остаётсяPending; после будущей регистрации акта поступления средств по этому начислению (MoneyReceived) рефералка автоматически переходит вApproved(INV-RA-3). - Сироты нет / уже связана (no-op). Дано: по
DealIdреферального начисления нет, либо оно есть, но егоCommissionAccrualIdуже неnull. Когда:POST. Тогда: ручное начисление создаётся штатно (201), шаг back-linking не меняет ни одной рефералки; повторныйPOSTс тем жеIdempotency-Keyтакже оставляет рефералку неизменной. - Гонка back-link с consumer
deal-completed. Дано: consumerdeal-completedи ручнойPOSTпо однойDealIdодновременно нацелены на родителя рефералки-сироты. Когда: параллельная обработка. Тогда: за комиссионное начисление борется уникальный индексDealId(INV-1) — один создаётся, другой получает409REWARDS_ACCRUAL_ALREADY_EXISTS; строка рефералки читается под блокировкой,CommissionAccrualIdпроставляется ровно один раз (второй путь видит!= null→ no-op), двойного родителя не возникает. - Дубль по сделке. Дано: по этому
DealIdначисление уже существует (в любом статусе, включаяCancelled). Когда:POST. Тогда:409REWARDS_ACCRUAL_ALREADY_EXISTS(INV-1); второе начисление не создаётся. - Гонка с автоначислением. Дано: параллельно consumer
deal-completedсоздаёт начисление по тому жеDealId. Когда: одновременный ручнойPOSTи обработка события. Тогда: побеждает захвативший уникальный ключ первым; второй получает409REWARDS_ACCRUAL_ALREADY_EXISTS. - Идемпотентный повтор. Дано: начисление уже создано с
Idempotency-Key = K. Когда: повторныйPOSTс тем же ключом. Тогда: тот жеId/Status/CommissionAmount/Version, второго начисления и второго события нет. - Комиссия вырождается в ноль. Дано: процент и база таковы, что после округления
CommissionAmount≤ 0. Когда:POST. Тогда:400REWARDS_ACCRUAL_AMOUNT_MISMATCH(INV-2); начисление не создаётся. - Несовпадение валют. Дано:
FinalPrice.Currency != BasePrice.Currencyили валюта неRUB. Когда:POST. Тогда:400REWARDS_CURRENCY_MISMATCH(INV-6). - Конечная цена ниже базовой. Дано:
FinalPrice.Value < BasePrice.Value. Когда:POST. Тогда:400ValidationError. - Недостаточно прав. Дано: у актора есть только
rewards.accruals.confirm, но неmanage. Когда:POST. Тогда:403FORBIDDEN(ручное создание требует расширенного права).
Совместимость и наблюдаемость
- Контракт additive-only; payload события — по дому контракта.
- Logs:
DealId,AccrualId,DeveloperCompanyId,AgencyCompanyId(суммы не логируются); при back-link дополнительноReferralAccrualIdсвязанной рефералки; metrics:rewards_accrual_manual_create_total{result},rewards_referral_backlink_total{result}(linked/absent) — сколько сирот подхвачено ручным начислением; traces: OTel; dashboardrewards-overview; runbook —runbooks/rewards-accrual-replay.md(по конвенции).
Обратные ссылки
Автоссылки: где используется этот документ.
- API (2): POST /api/v1/developer/commission-accruals/{accrualId}/reject — Отказ застройщика от начисления, Rewards API — реестр операций и общие контрактные модели
- Use Cases (4): UC-RWD-003. Сделка завершена — начисление комиссии (system), UC-RWD-009. Жизненный цикл реферального начисления (связь → начисление → гейт → выплата), UC-RWD-011. Корректировки начисления комиссии, UC-SUP-004. Ручное вмешательство через permissions
- Консюмеры (1): Consumer: CRM Deal Cancelled
- Саги (1): Saga «Commission Settlement» (финансовый контур вознаграждений)
- registries (1): Реестр API-методов PIN
Связанные документы
- Сущность: domain/commission-accrual.md (INV-1/2/6, snapshot).
- Автоначисление: ../consumers/crm-deal-completed.md; событие: ../events/commission-accrual-created.md.
- Реферальное начисление (back-link сироты, гейт
MoneyReceived → Approved): domain/referral-accrual.md (CommissionAccrualId, INV-RA-3). - Подтверждение: confirm-commission-accrual.md.