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

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 areadeveloper-admin
Feature groupdeveloper-accruals
Статусdraft
Документdocs/06-services/rewards/api/admin/create-manual-accrual.md

Актор и доступ

ПроверкаЗначение
Actor typeUser (менеджер компании-застройщика)
Auth policy / Permissionsrewards.accruals.manage (шире, чем confirm — создаёт финансовые обязательства)
Scope (RBAC)организация: компания Type = Developer
Record-levelDeveloperCompanyId начисления = CompanyId актора (жёстко на сервере)
Tenant isolationНачисление создаётся с TenantId = AgencyCompanyId (двусторонняя сущность, ADR-0052/решение №2); команда работает вне tenant-интерсептора актора
Auditcreate: CreatedByUserId = ActorUserId, Version = 1; финансовая операция — полный audit-trail через события

HTTP-контракт

ПолеЗначение
MethodPOST
Route/api/v1/developer/commission-accruals
Success status201 (+ Location: /api/v1/developer/commission-accruals/{id} в списке)
Idempotency headerIdempotency-Key (Guid); бизнес-идемпотентность — unique DealId (INV-1)
Correlationсквозной trace через заголовок traceparent

Входные данные / параметры запроса

ПолеИсточникТипОбязательностьВалидация / ОграниченияБезопасность
DeveloperCompanyIdidentity claims (tenant_id)GuidДаRequired; не из bodyrecord-level
ActorUserIdidentity claims (sub)GuidДаRequired
IdempotencyKeyheader Idempotency-KeyGuidДаRequired
DealIdbodyGuidДанепустой; уникальность — INV-1 (одно начисление на сделку)
AgencyCompanyIdbodyGuidДанепустой; компания-агентство получателя (станет TenantId)
AgentUserIdbodyGuidДанепустой; агент-исполнитель сделки
PropertyObjectIdbodyGuidДанепустой
DealCompletedAtbodyDateTimeOffsetДа<= UtcNow (сделка уже завершена)
CalculationBasebodyCommissionCalculationBaseДаenum BasePrice/FinalPrice
CommissionPercentbodydecimalДа> 0, <= 100, scale 2
BasePricebodyMoneyModelДаValue > 0, Currency = RUBсуммы не логируются
FinalPricebodyMoneyModelДаValue >= BasePrice.Value; Currency == BasePrice.Currency (INV-6)суммы не логируются
PropertyObjectPropertyTypebodyPropertyTypeДаenum; для snapshot-а
PropertyObjectLayoutId / PropertyObjectLayoutGroupIdbodyGuid?Нетдля snapshot-а
PropertyObjectRoomsbodyint?Нет0..20; для snapshot-а
PropertyObjectAreaTotalM2bodydecimalДа> 0, <= 10000, scale 2; для snapshot-а
Commentbodystring?Нет<= 1000; причина ручного начисления — в audit-log операции

Ссылочные проверки DealId/AgencyCompanyId/AgentUserId на стороне crm/identity не выполняются синхронно (assumption контекста, ../README.md) — ответственность актора; ошибка исправляется отменой ManualCorrection.

Модель ответа CreateManualAccrualCommandResult

ПолеТипОбязательностьИсточникMaskingОписание
IdGuidДаCommissionAccrualEntity.IdId начисления.
Statusstring (enum CommissionAccrualStatus)ДаStatusВсегда PendingConfirmation.
CommissionAmountMoneyModelДаCommissionAmountВычисленная сумма комиссии.
VersionlongДаVersion1.

Предусловия и постусловия

  • Предусловия. Актор — менеджер застройщика с расширенным правом 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).

Алгоритм

  1. Контекст и доступ. Операцию выполняет менеджер компании-застройщика с расширенным правом rewards.accruals.manage (создаёт финансовые обязательства); scope — организация застройщика (DeveloperCompanyId). Повторный вызов с тем же IdempotencyKey возвращает ранее сохранённый результат.
  2. Проверка входа и предусловий. Проценты, цены, валюты и даты — по таблице входных данных; нарушение диапазонов/формата → ValidationError, несовпадение валют → REWARDS_CURRENCY_MISMATCH. Уникальность сделки (INV-1): начисления по этому DealId ещё нет ни в одном статусе (включая Cancelled) — иначе REWARDS_ACCRUAL_ALREADY_EXISTS. Существование DealId/AgencyCompanyId/ AgentUserId в crm/identity синхронно не проверяется (ответственность актора, см. примечание к входным данным).
  3. Создание начисления и расчёт. Формируется начисление в статусе 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.
  4. 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 (гейт денег непроходим).
  5. Согласованность. Начисление, back-link рефералки-сироты (если найдена), запись идемпотентности и факт создания сохраняются в одном согласованном изменении. Гонка с consumer-ом deal-completed по тому же DealId (он мог параллельно создавать комиссионное начисление) ловится уникальным индексом на DealId (INV-1) и возвращает REWARDS_ACCRUAL_ALREADY_EXISTS. Гонка за саму рефералку-сироту (consumer deal-completed при создании родителя штатным путём мог бы одновременно проставлять ей CommissionAccrualId) разрешается блокировкой строки рефералки при чтении и уникальностью её DealId: ссылку выставляет захвативший строку первым, второй перечитывает и видит CommissionAccrualId != null → его шаг back-linking вырождается в no-op. Двойного родителя у рефералки возникнуть не может.
  6. События. Публикуется 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) на этом шаге не происходит — только проставление родителя; апрув наступит позднее, при регистрации акта поступления средств по этому начислению.
  7. Результат. Id, Status = PendingConfirmation, вычисленный CommissionAmount, Version = 1201.

Диаграмма

Побочные эффекты

ТипДетали
Integration eventsCommissionAccrualCreatedKafkaEvent / pin.rewards.commission-accrual-created.v1 / key = AccrualId
External callsnone
Cache / projections / searchnone
Data mutationback-link рефералки-сироты: ReferralAccrualEntity.CommissionAccrualId (той же DealId, Pending, ранее null) ← Id начисления (Version рефералки +1), в том же изменении. Смена статуса рефералки не выполняется — только проставление родителя
Notificationsnotifications по событию: агенту и руководителю агентства «начислена комиссия»

Окно eventual-consistency: начисление и запись идемпотентности фиксируются атомарно; агентство сразу (синхронно после коммита) видит новое начисление в своей сводке/очереди как PendingConfirmation, а застройщик — в очереди подтверждения. CommissionAccrualCreatedKafkaEvent уходит через outbox (ключ = AccrualId, порядок событий по начислению сохраняется); notifications доставляет уведомление и crm отмечает таймлайн сделки асинхронно (окно секунды). Дальнейшая судьба (распределение) наступает только после подтверждения застройщиком.

Ошибки и failure modes

КодHTTP statusУсловиеСообщениеRetry
REWARDS_ACCRUAL_ALREADY_EXISTS409Начисление по DealId уже существует (INV-1)«По этой сделке начисление уже создано»no retry
REWARDS_ACCRUAL_AMOUNT_MISMATCH400Вычисленная комиссия <= 0 (INV-2)«Сумма комиссии должна быть положительной»no retry
REWARDS_CURRENCY_MISMATCH400Валюты цен различаются / не 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; snapshot RuleName = "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. Дано: consumer deal-completed и ручной POST по одной DealId одновременно нацелены на родителя рефералки-сироты. Когда: параллельная обработка. Тогда: за комиссионное начисление борется уникальный индекс DealId (INV-1) — один создаётся, другой получает 409 REWARDS_ACCRUAL_ALREADY_EXISTS; строка рефералки читается под блокировкой, CommissionAccrualId проставляется ровно один раз (второй путь видит != null → no-op), двойного родителя не возникает.
  • Дубль по сделке. Дано: по этому DealId начисление уже существует (в любом статусе, включая Cancelled). Когда: POST. Тогда: 409 REWARDS_ACCRUAL_ALREADY_EXISTS (INV-1); второе начисление не создаётся.
  • Гонка с автоначислением. Дано: параллельно consumer deal-completed создаёт начисление по тому же DealId. Когда: одновременный ручной POST и обработка события. Тогда: побеждает захвативший уникальный ключ первым; второй получает 409 REWARDS_ACCRUAL_ALREADY_EXISTS.
  • Идемпотентный повтор. Дано: начисление уже создано с Idempotency-Key = K. Когда: повторный POST с тем же ключом. Тогда: тот же Id/Status/CommissionAmount/Version, второго начисления и второго события нет.
  • Комиссия вырождается в ноль. Дано: процент и база таковы, что после округления CommissionAmount ≤ 0. Когда: POST. Тогда: 400 REWARDS_ACCRUAL_AMOUNT_MISMATCH (INV-2); начисление не создаётся.
  • Несовпадение валют. Дано: FinalPrice.Currency != BasePrice.Currency или валюта не RUB. Когда: POST. Тогда: 400 REWARDS_CURRENCY_MISMATCH (INV-6).
  • Конечная цена ниже базовой. Дано: FinalPrice.Value < BasePrice.Value. Когда: POST. Тогда: 400 ValidationError.
  • Недостаточно прав. Дано: у актора есть только rewards.accruals.confirm, но не manage. Когда: POST. Тогда: 403 FORBIDDEN (ручное создание требует расширенного права).

Совместимость и наблюдаемость

  • Контракт 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; dashboard rewards-overview; runbook — runbooks/rewards-accrual-replay.md (по конвенции).

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

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

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