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

POST /api/v1/developer/commission-accruals/{accrualId}/confirm — Подтверждение начисления застройщиком

Переход начисления PendingConfirmation → Confirmed: застройщик признаёт обязательство по комиссии («подтверждено / ожидает подтверждения»), опционально скорректировав сумму (INV-11). В момент подтверждения строится распределение комиссии тремя строками (INV-4/INV-5/INV-9/INV-10 domain/commission-accrual.md): Platform — удержание ПИН по снапшоту условий платформы (domain/platform-commission-terms.md, ADR-0066), Agency и Agent — деление пула агентства (CommissionAmount − PlatformFeeAmount) по активной политике агентства.

Назначение

Менеджер застройщика подтверждает начисление из очереди (list-developer-accruals.md) — при расхождении с договором передаёт скорректированную сумму с обязательной причиной (AdjustedAmount + AdjustmentReason, аудит-след корректировки). После подтверждения начисление ожидает получения денег: доля агента становится доступной платёжному поручению только после регистрации акта поступления средств (Confirmed → MoneyReceived, domain/platform-receipt.md, ADR-0067). Cross-tenant команда: начисление живёт в tenant-е агентства, застройщик действует по своему DeveloperCompanyId (ADR-0052, решение №2 ../README.md). Feature group — developer-accruals.

Metadata

ПолеЗначение
Сервис/контекстrewards
API areadeveloper-admin
Feature groupdeveloper-accruals
Статусdraft
Документdocs/06-services/rewards/api/admin/confirm-commission-accrual.md

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

ПроверкаЗначение
Actor typeUser (менеджер компании-застройщика)
Auth policy / Permissionsrewards.accruals.confirm
Scope (RBAC)организация: компания Type = Developer
Record-levelaccrual.DeveloperCompanyId == CompanyId актора — иначе 404 (не раскрываем существование чужих начислений)
Tenant isolationCross-tenant path: загрузка начисления вне tenant-интерсептора; изоляция — permission + record-level проверка DeveloperCompanyId
Auditupdate: ConfirmedAt, ConfirmedByUserId = ActorUserId, UpdatedByUserId, Version + 1; финансовый переход фиксируется событием статуса; корректировка суммы — обязательная append-only строка CommissionAccrualAdjustmentEntity (Kind = GrossAmount, OldAmount/NewAmount, Reason, актор)

HTTP-контракт

ПолеЗначение
MethodPOST
Route/api/v1/developer/commission-accruals/{accrualId}/confirm
Success status200
Idempotency headerне применяется (повтор из ConfirmedREWARDS_ACCRUAL_INVALID_STATUS_TRANSITION)
Correlationсквозной trace через заголовок traceparent

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

ПолеИсточникТипОбязательностьВалидация / ОграниченияБезопасность
DeveloperCompanyIdidentity claims (tenant_id)GuidДаRequiredrecord-level
ActorUserIdidentity claims (sub)GuidДаRequired
AccrualIdroute {accrualId}GuidДаRequired, непустой
ExpectedVersionbodylongДа>= 1; сверка с Version начисленияoptimistic concurrency (конкурент — отмена по deal-cancelled)
AdjustedAmountbodyMoneyModel?Нетесли задана — Value > 0, scale 2; CurrencyId = валюте начисления (INV-6); Value > PlatformFeeAmount.Value — пул агентства строго положителен (INV-11)Sensitive — не логируется
AdjustmentReasonbodystring?Да при заданном AdjustedAmount1..1000, не пусто после trim (INV-11)

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

Дельта от CommissionAccrualModel (README.md): застройщику распределение не отдаётся.

ПолеТипОбязательностьИсточникMaskingОписание
IdGuidДаCommissionAccrualEntity.IdId начисления.
Statusstring (enum CommissionAccrualStatus)ДаStatusВсегда Confirmed.
ConfirmedAtDateTimeOffsetДаConfirmedAtМомент подтверждения.
CommissionAmountMoneyModelДаCommissionAmountИтоговая подтверждённая сумма (gross): скорректированная, если корректировка выполнялась, иначе исходная.
IsAdjustedboolДаcomputed (OriginalCommissionAmount != null)Признак: сумма корректировалась (в этой команде или ранее ПИН-админом).
OriginalCommissionAmountMoneyModel?Нет (null — не корректировалась)OriginalCommissionAmountИсходная автосчитанная сумма до корректировок (INV-11).
VersionlongДаVersionНовая версия.

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

  • Предусловия. Актор — менеджер застройщика с rewards.accruals.confirm; начисление существует, его DeveloperCompanyId = компания актора и статус = PendingConfirmation; клиент передаёт актуальный ExpectedVersion. Снапшот условий платформы (PlatformRetentionPercentagePoints/PlatformFeeAmount) снят при создании начисления. Активная политика агентства-получателя либо существует, либо отсутствует (допустимо — fallback INV-6 политики).
  • Постусловия (успех). Начисление в статусе Confirmed; проставлены ConfirmedAt, AppliedPolicyId (или null при fallback), актор, новый Version. При корректировке: CommissionAmount заменён на AdjustedAmount, исходная сумма зафиксирована в OriginalCommissionAmount (однократно, при первой корректировке), создана аудит-строка Kind = GrossAmount с причиной. Созданы ровно три строки распределения (Platform + Agency + Agent, INV-9), сумма которых равна CommissionAmount (INV-5); строка Platform = PlatformFeeAmount (не пересчитывается при корректировке gross — считается от базы цены, INV-10). Выплаты пока недоступны — доля агента откроется поручению после MoneyReceived (регистрация акта, ADR-0067). Опубликовано CommissionAccrualStatusChangedKafkaEvent (PendingConfirmation → Confirmed).
  • Постусловия (отказ). Статус, сумма, версия, распределение и аудит-история начисления не изменились; событие не публикуется.

Алгоритм

  1. Контекст и доступ. Операцию выполняет менеджер компании-застройщика; scope — организация застройщика (DeveloperCompanyId); требуется право rewards.accruals.confirm. Начисление живёт в tenant-е агентства — cross-tenant доступ по явному DeveloperCompanyId.
  2. Проверка входа и предусловий. AccrualId непустой, ExpectedVersion >= 1. Если задан AdjustedAmountValue > 0, scale 2, и обязателен непустой AdjustmentReason (INV-11) — иначе REWARDS_ACCRUAL_ADJUSTMENT_REASON_REQUIRED. Начисление существует (загружается вне tenant-фильтра под блокировкой на запись — защита от параллельной отмены consumer-ом deal-cancelled) — иначе REWARDS_ACCRUAL_NOT_FOUND. Record-level: DeveloperCompanyId начисления совпадает с компанией актора — иначе REWARDS_ACCRUAL_NOT_FOUND (как not-found, без раскрытия чужих начислений). Статус = PendingConfirmation (INV-3) — иначе REWARDS_ACCRUAL_INVALID_STATUS_TRANSITION. Version совпадает с ExpectedVersion — иначе REWARDS_CONCURRENCY_CONFLICT.
  3. Корректировка суммы (опционально, INV-11). Если задан AdjustedAmount: CurrencyId совпадает с валютой начисления (INV-6) — иначе REWARDS_CURRENCY_MISMATCH; AdjustedAmount.Value > PlatformFeeAmount.Value — пул агентства строго положителен, иначе REWARDS_ACCRUAL_ADJUSTMENT_FORBIDDEN. Исходная сумма фиксируется в OriginalCommissionAmount (однократно — при первой корректировке), CommissionAmount заменяется на AdjustedAmount, создаётся аудит-строка CommissionAccrualAdjustmentEntity (Kind = GrossAmount, OldAmount = прежняя сумма, NewAmount = AdjustedAmount, Reason = AdjustmentReason, актор). PlatformFeeAmount не пересчитывается — удержание ПИН считается от базы цены, а не от комиссии (INV-10).
  4. Активная политика агентства. Читается действующая (Active) комиссионная политика агентства-получателя. Отсутствие политики допустимо — fallback INV-6 (domain/agency-commission-policy.md).
  5. Расчёт распределения и переход. Строится трёхстрочное распределение (INV-4/5/9/10):
    • строк распределения ещё нет (INV-4) — иначе REWARDS_ACCRUAL_DISTRIBUTION_EXISTS;
    • строка Platform: RecipientUserId = null, Amount = PlatformFeeAmount (снапшот условий платформы, снятый при создании; 0 при fallback без условий — строка создаётся всегда);
    • пул агентства = CommissionAmount − PlatformFeeAmount (по итоговой, возможно скорректированной, сумме); деление пула — по политике (ResolveRetention, дом domain/agency-commission-policy.md): персональный оверрайд по AgentUserId или правило по умолчанию; Percent → удержание агентства = процент от пула (banker's, scale 2); FixedAmount → валюта удержания совпадает с валютой комиссии (иначе REWARDS_CURRENCY_MISMATCH), удержание = min(сумма удержания, пул) — fixed больше пула срезается, доля агента = 0 (задокументированный edge-case); доля агента = пул − удержание агентства;
    • без политики (INV-6 NoPolicyFallback): удержание агентства = 0, весь пул — агенту, AppliedPolicyId = null, AppliedRetentionKind = null (строка Platform — независимо от политики);
    • создаются строки Agency (RecipientUserId = null, AppliedRetentionKind = вид применённого правила) и Agent (RecipientUserId = AgentUserId);
    • сумма трёх строк равна CommissionAmount (INV-5) — иначе REWARDS_ACCRUAL_DISTRIBUTION_SUM_INVALID;
    • начисление переходит PendingConfirmation → Confirmed; проставляются AppliedPolicyId, ConfirmedAt, актор, новая версия.
  6. Согласованность. Начисление, корректировка (если была) и три строки распределения сохраняются в одном согласованном изменении. Гонка с consumer-ом deal-cancelled разрешается блокировкой начисления на запись и оптимистичной версией.
  7. События. Публикуется CommissionAccrualStatusChangedKafkaEvent (топик pin.rewards.commission-accrual-status-changed.v1, ключ = AccrualId; payload: Id, TenantId, DealId, FromStatus = PendingConfirmation, ToStatus = Confirmed, ConfirmedAt, Version) — потребитель notifications уведомляет агента и руководителя агентства.
  8. Результат. Id, Status = Confirmed, ConfirmedAt, CommissionAmount (итоговая сумма), IsAdjusted, OriginalCommissionAmount?, новый Version200.

Диаграмма

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

ТипДетали
Integration eventsCommissionAccrualStatusChangedKafkaEvent / pin.rewards.commission-accrual-status-changed.v1 / key = AccrualId
External callsnone
Cache / projections / searchnone
Notificationsnotifications по событию: агенту «комиссия подтверждена», руководителю агентства — сводное

Окно eventual-consistency: начисление, корректировка и три строки распределения фиксируются атомарно; агентство сразу видит подтверждённую комиссию с раскладкой Platform/Agency/Agent. Доля агента становится доступной платёжному поручению (create-payout-order.md) только после MoneyReceived — регистрации акта поступления средств (domain/platform-receipt.md, ADR-0067). CommissionAccrualStatusChangedKafkaEvent уходит через outbox (ключ = AccrualId, порядок статусов сохраняется); notifications доставляет агенту/руководителю уведомление асинхронно (окно секунды). Синхронный ответ застройщику отдаёт итоговую CommissionAmount и признак корректировки без распределения (распределение — внутреннее дело агентства и платформы).

Ошибки и failure modes

КодHTTP statusУсловиеСообщениеRetry
REWARDS_ACCRUAL_NOT_FOUND404Начисление не существует или DeveloperCompanyId чужой«Начисление не найдено»no retry
REWARDS_ACCRUAL_INVALID_STATUS_TRANSITION409Status != PendingConfirmation (INV-3)«Начисление уже обработано»no retry
REWARDS_ACCRUAL_DISTRIBUTION_EXISTS409Строки распределения уже существуют (INV-4)«Распределение уже построено»no retry
REWARDS_ACCRUAL_DISTRIBUTION_SUM_INVALID500Сумма трёх строк ≠ комиссии (INV-5, дефект расчёта)«Внутренняя ошибка расчёта»retry после fix
REWARDS_ACCRUAL_ADJUSTMENT_REASON_REQUIRED400AdjustedAmount задан без непустого AdjustmentReason (INV-11)«Укажите причину корректировки суммы»no retry
REWARDS_ACCRUAL_ADJUSTMENT_FORBIDDEN409AdjustedAmount <= PlatformFeeAmount — пул агентства не положителен (INV-11)«Скорректированная сумма не покрывает удержание платформы»no retry
REWARDS_CURRENCY_MISMATCH409FixedAmount-правило политики или AdjustedAmount в другой валюте (INV-6 VO)«Валюта не совпадает с валютой комиссии»no retry
REWARDS_CONCURRENCY_CONFLICT409Конфликт версии ExpectedVersion (параллельная отмена)«Начисление изменено — обновите данные»retry после refresh

Acceptance-критерии

  • Подтверждение с политикой (Percent). Дано: начисление PendingConfirmation (CommissionAmount = 100 000, PlatformFeeAmount = 10 000), у агентства активная политика с процентным удержанием 30%. Когда: POST .../confirm. Тогда: 200, Status = Confirmed; созданы три строки: Platform = 10 000, Agency = 27 000 (30% от пула 90 000, banker's, scale 2), Agent = 63 000; сумма строк = CommissionAmount (INV-5); AppliedPolicyId заполнен.
  • Подтверждение с корректировкой суммы. Дано: начисление PendingConfirmation с CommissionAmount = 100 000; застройщик передаёт AdjustedAmount = 90 000 и AdjustmentReason. Когда: POST .../confirm. Тогда: 200; CommissionAmount = 90 000, OriginalCommissionAmount = 100 000, IsAdjusted = true; создана аудит-строка GrossAmount (OldAmount = 100 000, NewAmount = 90 000, причина, актор); PlatformFeeAmount не изменился (INV-10), пул и строки Agency/Agent посчитаны от скорректированной суммы.
  • Корректировка без причины. Дано: AdjustedAmount задан, AdjustmentReason пуст. Когда: POST .../confirm. Тогда: 400 REWARDS_ACCRUAL_ADJUSTMENT_REASON_REQUIRED; статус и сумма прежние.
  • Корректировка не покрывает удержание ПИН. Дано: AdjustedAmount <= PlatformFeeAmount. Когда: POST .../confirm. Тогда: 409 REWARDS_ACCRUAL_ADJUSTMENT_FORBIDDEN (пул должен быть строго положителен, INV-11); подтверждение не выполняется.
  • Подтверждение без политики (fallback). Дано: у агентства нет активной политики. Когда: POST .../confirm. Тогда: 200; строка Platform = PlatformFeeAmount, удержание агентства = 0, весь пул — агенту; AppliedPolicyId = null, AppliedRetentionKind = null (INV-6 политики); инкремент rewards_accrual_confirm_no_policy_total.
  • Fixed больше пула. Дано: политика с FixedAmount > пула агентства в той же валюте. Когда: POST .../confirm. Тогда: 200; удержание агентства = пул, доля агента = 0, строка Platform не затронута (срез — задокументированный edge-case, не ошибка).
  • Несовпадение валют. Дано: FixedAmount-правило политики (или AdjustedAmount) в валюте, отличной от комиссии. Когда: POST .../confirm. Тогда: 409 REWARDS_CURRENCY_MISMATCH; распределение не построено, статус прежний.
  • Повторное подтверждение. Дано: начисление уже Confirmed (или MoneyReceived/Paid/Cancelled). Когда: POST .../confirm. Тогда: 409 REWARDS_ACCRUAL_INVALID_STATUS_TRANSITION; второй набор строк не создаётся.
  • Чужой застройщик. Дано: DeveloperCompanyId начисления ≠ компания актора. Когда: POST .../confirm. Тогда: 404 REWARDS_ACCRUAL_NOT_FOUND (чужое начисление не раскрывается).
  • Гонка с отменой сделки. Дано: параллельно приходит deal-cancelled по той же сделке. Когда: одновременные подтверждение и отмена. Тогда: побеждает захвативший запись первым; проигравший получает 409 REWARDS_CONCURRENCY_CONFLICT и перечитывает актуальный статус.

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

  • Payload события — additive-only; breaking → ...status-changed.v2. AdjustedAmount/AdjustmentReason — optional-поля body (additive для клиентов).
  • Logs: AccrualId, DealId, DeveloperCompanyId, FromStatus→ToStatus, IsAdjusted (суммы не логируются); metrics: rewards_accrual_confirm_total{result}, rewards_accrual_confirm_no_policy_total (fallback INV-6 политики), rewards_accrual_confirm_adjusted_total (доля подтверждений с корректировкой); traces: OTel; dashboard rewards-overview; runbook — runbooks/rewards-accrual-replay.md (по конвенции).

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

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

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