POST /api/v1/developer/commission-accruals/{accrualId}/reject — Отказ застройщика от начисления
Переход начисления PendingConfirmation → Cancelled с CancelReason = DeveloperRejected и
обязательным комментарием: застройщик не признаёт обязательство по комиссии (спорная сделка,
ошибочное начисление).
Назначение
Менеджер застройщика отклоняет начисление из очереди подтверждения
(list-developer-accruals.md). Cross-tenant команда (ADR-0052, решение №2
../README.md). Дом инвариантов — domain/commission-accrual.md
(INV-8, enum CommissionCancelReason). Feature group — developer-accruals.
| Поле | Значение |
|---|
| Сервис/контекст | rewards |
| API area | developer-admin |
| Feature group | developer-accruals |
| Статус | draft |
| Документ | docs/06-services/rewards/api/admin/reject-commission-accrual.md |
Актор и доступ
| Проверка | Значение |
|---|
| Actor type | User (менеджер компании-застройщика) |
| Auth policy / Permissions | rewards.accruals.confirm |
| Scope (RBAC) | организация: компания Type = Developer |
| Record-level | accrual.DeveloperCompanyId == CompanyId актора → иначе 404 |
| Tenant isolation | Cross-tenant path (как в confirm-commission-accrual.md) |
| Audit | update: CancelledAt, CancelReason, UpdatedByUserId, Version + 1; событие статуса |
HTTP-контракт
| Поле | Значение |
|---|
| Method | POST |
| Route | /api/v1/developer/commission-accruals/{accrualId}/reject |
| Success status | 200 |
| Idempotency header | не применяется (повтор из Cancelled → REWARDS_ACCRUAL_INVALID_STATUS_TRANSITION) |
| Correlation | сквозной trace через заголовок traceparent |
Входные данные / параметры запроса
| Поле | Источник | Тип | Обязательность | Валидация / Ограничения | Безопасность |
|---|
DeveloperCompanyId | identity claims (tenant_id) | Guid | Да | Required | record-level |
ActorUserId | identity claims (sub) | Guid | Да | Required | — |
AccrualId | route {accrualId} | Guid | Да | Required, непустой | — |
ExpectedVersion | body | long | Да | >= 1 | optimistic concurrency |
Reason | body | string | Да | 1..1000 — обязательный комментарий отказа (enum DeveloperRejected требует комментарий, дом CommissionCancelReason) | не содержит PII по договорённости UI; хранится в audit-логе операции |
Модель ответа RejectCommissionAccrualCommandResult
| Поле | Тип | Обязательность | Источник | Masking | Описание |
|---|
Id | Guid | Да | CommissionAccrualEntity.Id | — | Id начисления. |
Status | string (enum CommissionAccrualStatus) | Да | Status | — | Всегда Cancelled. |
CancelReason | string (enum CommissionCancelReason) | Да | CancelReason | — | Всегда DeveloperRejected. |
CancelledAt | DateTimeOffset | Да | CancelledAt | — | Момент отказа. |
Version | long | Да | Version | — | Новая версия. |
Предусловия и постусловия
- Предусловия. Актор — менеджер застройщика с
rewards.accruals.confirm; начисление существует, его
DeveloperCompanyId = компания актора и статус = PendingConfirmation; передан обязательный Reason
(1..1000) и актуальный ExpectedVersion. Строк распределения ещё нет (в PendingConfirmation, INV-4).
- Постусловия (успех). Начисление в статусе
Cancelled с CancelReason = DeveloperRejected;
проставлены CancelledAt, актор, новый Version; комментарий Reason записан в audit-лог операции
(в агрегат не добавляется); опубликовано CommissionAccrualStatusChangedKafkaEvent (PendingConfirmation → Cancelled).
- Постусловия (отказ). Статус и версия начисления не изменились; событие не публикуется.
Алгоритм
- Контекст и доступ. Операцию выполняет менеджер компании-застройщика; scope — организация
застройщика (
DeveloperCompanyId); требуется право rewards.accruals.confirm. Начисление живёт
в tenant-е агентства — cross-tenant доступ по явному DeveloperCompanyId.
- Проверка входа и предусловий.
AccrualId непустой, ExpectedVersion >= 1, Reason длиной
1..1000 (обязательный комментарий отказа) — иначе ValidationError. Начисление существует
(загружается под блокировкой на запись) и его DeveloperCompanyId совпадает с компанией актора —
иначе REWARDS_ACCRUAL_NOT_FOUND. Статус = PendingConfirmation (INV-8): отказ застройщика
допустим только до подтверждения (из Confirmed обязательство уже признано; отмена подтверждённого
— только агентская/ручная ManualCorrection) — иначе REWARDS_ACCRUAL_INVALID_STATUS_TRANSITION.
Version совпадает с ExpectedVersion — иначе REWARDS_CONCURRENCY_CONFLICT. Строк распределения
в PendingConfirmation нет (INV-4) — освобождать нечего.
- Переход и запись. Начисление переходит
PendingConfirmation → Cancelled с
CancelReason = DeveloperRejected; проставляются CancelledAt, актор, новая версия. Комментарий
Reason пишется в audit-лог операции (в агрегате поля нет — дом полей не расширяется).
- События. Публикуется
CommissionAccrualStatusChangedKafkaEvent
(топик pin.rewards.commission-accrual-status-changed.v1, ключ = AccrualId; payload: Id,
TenantId, DealId, FromStatus = PendingConfirmation, ToStatus = Cancelled,
CancelReason = DeveloperRejected, Version) — потребитель notifications уведомляет агента и
руководителя агентства.
- Результат.
Id, Status = Cancelled, CancelReason = DeveloperRejected, CancelledAt, новый
Version → 200.
Диаграмма
Побочные эффекты
| Тип | Детали |
|---|
| Integration events | CommissionAccrualStatusChangedKafkaEvent / pin.rewards.commission-accrual-status-changed.v1 / key = AccrualId |
| External calls | none |
| Cache / projections / search | none |
| Notifications | notifications по событию: агенту «начисление отклонено застройщиком» |
Окно eventual-consistency: переход PendingConfirmation → Cancelled фиксируется атомарно; агентство
синхронно (сразу после коммита) перестаёт видеть начисление в очереди ожидающих и в сводке
PendingConfirmation. CommissionAccrualStatusChangedKafkaEvent уходит через outbox (ключ = AccrualId,
порядок статусов по начислению сохраняется); notifications доставляет агенту уведомление об отказе
асинхронно (окно секунды). Распределения не строятся (в PendingConfirmation их нет, INV-4) —
освобождать/пересчитывать нечего; взамен отклонённого застройщик может создать ручное начисление
(create-manual-accrual.md).
Ошибки и failure modes
| Код | HTTP status | Условие | Сообщение | Retry |
|---|
REWARDS_ACCRUAL_NOT_FOUND | 404 | Не существует / чужой DeveloperCompanyId | «Начисление не найдено» | no retry |
REWARDS_ACCRUAL_INVALID_STATUS_TRANSITION | 409 | Status != PendingConfirmation | «Начисление уже обработано» | no retry |
REWARDS_CONCURRENCY_CONFLICT | 409 | ExpectedVersion/xmin конфликт | «Начисление изменено — обновите данные» | retry после refresh |
Acceptance-критерии
- Happy path. Дано: начисление
PendingConfirmation, DeveloperCompanyId = компания актора,
Reason задан, ExpectedVersion актуален. Когда: POST .../reject. Тогда: 200, Status = Cancelled, CancelReason = DeveloperRejected, CancelledAt проставлен; опубликовано
CommissionAccrualStatusChangedKafkaEvent; notifications уведомит агента.
- Пустой комментарий. Дано:
Reason пуст или длиннее 1000. Когда: POST .../reject. Тогда: 400
ValidationError (комментарий обязателен для DeveloperRejected).
- Уже обработано. Дано: начисление в
Confirmed/Paid/Cancelled. Когда: POST .../reject.
Тогда: 409 REWARDS_ACCRUAL_INVALID_STATUS_TRANSITION (отказ допустим только до подтверждения,
INV-8; отмена подтверждённого — только ManualCorrection).
- Чужой застройщик. Дано:
DeveloperCompanyId начисления ≠ компания актора. Когда: POST .../reject. Тогда: 404 REWARDS_ACCRUAL_NOT_FOUND.
- Гонка подтверждение/отказ. Дано: два менеджера одновременно подтверждают и отклоняют одно
начисление. Когда: параллельные
confirm и reject. Тогда: побеждает первый захвативший запись,
второй получает 409 REWARDS_CONCURRENCY_CONFLICT.
Совместимость и наблюдаемость
- Контракт additive-only; enum
CommissionCancelReason расширяется только additive.
- Logs:
AccrualId, DealId, DeveloperCompanyId, Reason (без сумм); metrics:
rewards_accrual_reject_total; traces: OTel; dashboard rewards-overview.
Обратные ссылки
Автоссылки: где используется этот документ.
Связанные документы