POST /api/v1/agency/payout-orders/{orderId}/reject — Отклонение поручения
Переход платёжного поручения PendingPayment → Rejected с обязательной причиной: руководитель/
бухгалтер агентства отказывается проводить утверждённое поручение (ошибка в составе, спор по суммам,
сорванная выплата). Отклонение освобождает все привязанные строки распределения
(CommissionDistributionEntity.PayoutOrderItemId → null) — они снова доступны для следующего поручения
(INV-8). Терминальный статус: повторное действие невозможно.
Назначение
Операция №26 реестра (README.md). Ручной отказ по уже утверждённому (переданному в
бухгалтерию) поручению, зеркальный к выплате (mark-payout-order-paid.md):
вместо Paid — Rejected, а зарезервированные под него доли агентов возвращаются в пул свободных.
Дом инвариантов и статусной модели — domain/payout-order.md (INV-8,
enum PayoutOrderStatus). Feature group — payouts.
| Поле | Значение |
|---|
| Сервис/контекст | rewards |
| API area | agency |
| Feature group | payouts |
| Статус | draft |
| Документ | docs/06-services/rewards/api/agency/reject-payout-order.md |
Актор и доступ
| Проверка | Значение |
|---|
| Actor type | User (руководитель/бухгалтер агентства) |
| Auth policy / Permissions | rewards.payouts.approve (каталог — permissions.md) |
| Scope (RBAC) | организация: компания-агентство (Type ∈ {Agency, Broker, Pin}) |
| Record-level | не применяется: поручение принадлежит TenantId актора (tenant-фильтр) |
| Tenant isolation | order.TenantId == CompanyId актора (стандартный tenant-интерсептор, ADR-0052); чужой tenant → 404 (fail closed) |
| Audit | update: RejectedAt, RejectReason, UpdatedByUserId, Version + 1; PayoutOrderStatusChangedKafkaEvent |
HTTP-контракт
| Поле | Значение |
|---|
| Method | POST |
| Route | /api/v1/agency/payout-orders/{orderId}/reject |
| Success status | 200 |
| Idempotency header | не применяется (повтор из Rejected → REWARDS_PAYOUT_INVALID_STATUS_TRANSITION) |
| Correlation | сквозной trace через заголовок traceparent |
Входные данные / параметры запроса
| Поле | Источник | Тип | Обязательность | Валидация / Ограничения | Безопасность |
|---|
TenantId | identity claims (tenant_id) | Guid | Да | Required | tenant isolation |
ActorUserId | identity claims (sub) | Guid | Да | Required | actor-stamping |
OrderId | route {orderId} | Guid | Да | Required, непустой | — |
ExpectedVersion | body | long | Да | >= 1; сверяется с Version агрегата | optimistic concurrency |
RejectReason | body | string | Да | обязателен; trim, 1..1000 (varchar(1000)); пустой/whitespace → REWARDS_PAYOUT_REJECT_REASON_REQUIRED (INV-8) | не содержит PII (договорённость UI); хранится в агрегате, рассылается только руководителю |
Модель ответа PayoutOrderModel
Полная проекция агрегата — дом: README.md (раздел «PayoutOrderModel»); дублировать
таблицу полей запрещено (правило «один дом»). Дельта этой операции — заполняемые/изменяемые поля:
| Поле | Значение после операции |
|---|
Status | всегда Rejected |
RejectedAt | момент отклонения (now) |
RejectReason | значение из запроса |
Version | новая версия (+1) |
Items | сохраняются (история поручения); строки распределения по ним уже освобождены |
Алгоритм
- Контекст и доступ. Операцию выполняет руководитель/бухгалтер агентства (тип компании
Agency/Broker/Pin); scope — организация (TenantId из claims); требуется право
rewards.payouts.approve.
- Проверка входа и предусловий.
- формат:
OrderId непустой, ExpectedVersion >= 1; RejectReason обязателен, не пуст после trim
(иначе REWARDS_PAYOUT_REJECT_REASON_REQUIRED, INV-8), длина <= 1000 (превышение — ошибка
валидации);
- существование: поручение с
OrderId в tenant-е агентства существует; если нет (или чужой tenant) →
REWARDS_PAYOUT_ORDER_NOT_FOUND (fail closed);
- допустимость состояния: отклонение возможно только из
PendingPayment (из Draft — отмена, из
Paid/Rejected/Cancelled — терминально) → REWARDS_PAYOUT_INVALID_STATUS_TRANSITION;
- конкуренция: версия совпадает с
ExpectedVersion; иначе REWARDS_CONCURRENCY_CONFLICT.
Поручение читается под блокировкой (защита от параллельного MarkPaid) вместе с позициями.
- Переход и освобождение долей. Поручение переходит
PendingPayment → Rejected: проставляются
RejectedAt, RejectReason из запроса, растёт версия; TotalAmount/ItemsCount/Items не меняются
(поручение остаётся финансовым документом истории). По INV-8 все привязанные к позициям строки
распределения освобождаются: их PayoutOrderItemId сбрасывается в null (PaidAt уже null —
поручение не выплачено) — доли снова доступны следующему поручению. Rejected — терминальный статус.
- Фиксация факта. Переход статуса, освобождение строк и публикация события выполняются в одном
согласованном изменении. Событие
PayoutOrderStatusChangedKafkaEvent (топик
pin.rewards.payout-order-status-changed.v1, ключ — OrderId, FromStatus = PendingPayment,
ToStatus = Rejected, PaidAt = null, RejectReason, позиции) — контракт
../events/payout-order-status-changed.md; потребитель —
notifications (уведомление только руководителю с RejectReason; агентам об отклонении не
рассылается). Гонка с MarkPaid разрешается блокировкой шага 2 и сверкой версии.
- Результат. Проекция поручения (
Status = Rejected, заполнены RejectedAt/RejectReason, Items
включены, новая версия) → HTTP 200.
Диаграмма
Побочные эффекты
| Тип | Детали |
|---|
| Integration events | PayoutOrderStatusChangedKafkaEvent → pin.rewards.payout-order-status-changed.v1, key = OrderId |
| External calls | none |
| Cache / projections / search | none; строки распределения возвращаются в пул свободных (PayoutOrderItemId = null) — доступны следующему поручению |
| Notifications | notifications по событию: руководителю агентства «поручение {Number} отклонено» с RejectReason (агентам не рассылается) |
Интеграционная последовательность. Переход статуса и освобождение строк распределения — синхронны и
атомарны: сразу после 200 строки с PayoutOrderItemId = null доступны следующей автосборке поручения
(create-payout-order.md) в том же tenant — окна eventual-consistency внутри
rewards нет. Уведомление руководителю — асинхронно: PayoutOrderStatusChangedKafkaEvent →
notifications (окно — секунды); агентам об отклонении не сообщается, поэтому промежуточного состояния для
агента не возникает. Событие несёт ключ партиционирования OrderId, ContractVersion = 1, поля
FromStatus = PendingPayment/ToStatus = Rejected/PaidAt = null/RejectReason/Items; единственный
внешний потребитель — notifications. Проекция производительности агента при Reject не затрагивается:
статус начислений остаётся Confirmed (событий смены статуса начислений не публикуется), меняется только
привязка строк к поручению.
Ошибки и failure modes
Общие коды (ValidationError 400, FORBIDDEN 403, UNAUTHORIZED 401, REWARDS_CONCURRENCY_CONFLICT
409) — дом README.md; ниже — дельта операции.
| Код | HTTP status | Условие | Сообщение | Retry |
|---|
REWARDS_PAYOUT_REJECT_REASON_REQUIRED | 400 | RejectReason пуст/whitespace (INV-8) | «Укажите причину отклонения» | no retry |
REWARDS_PAYOUT_ORDER_NOT_FOUND | 404 | Поручение не существует / чужой TenantId | «Поручение не найдено» | no retry |
REWARDS_PAYOUT_INVALID_STATUS_TRANSITION | 409 | Status != PendingPayment (уже Paid/Rejected/Cancelled/Draft) | «Поручение нельзя отклонить в текущем статусе» | no retry |
REWARDS_CONCURRENCY_CONFLICT | 409 | конфликт версии (ExpectedVersion) | «Поручение изменено — обновите данные» | retry после refresh |
Предусловия и постусловия
- Предусловия. Поручение существует в tenant-е агентства и находится в статусе
PendingPayment
(утверждено и передано в бухгалтерию, но ещё не выплачено). Актор — руководитель/бухгалтер с правом
rewards.payouts.approve. К позициям поручения привязаны строки распределения (PayoutOrderItemId != null),
ни одна ещё не выплачена (PaidAt = null).
- Постусловия (успех). Поручение в терминальном статусе
Rejected; заполнены RejectedAt/RejectReason;
Version увеличен на 1; Items, TotalAmount, ItemsCount сохранены как история. Все строки распределения,
входившие в позиции, освобождены (PayoutOrderItemId → null) и снова доступны следующему поручению (INV-8);
их начисления сохраняют статус Confirmed. Отправлено PayoutOrderStatusChangedKafkaEvent
(PendingPayment → Rejected); notifications уведомляет только руководителя. Повторное отклонение того
же поручения более невозможно.
Acceptance-критерии
- Happy path. Дано: поручение в
PendingPayment с 3 привязанными строками, актор с
rewards.payouts.approve, корректный ExpectedVersion и непустой RejectReason. Когда: POST .../reject. Тогда: 200, Status = Rejected, заполнены RejectedAt/RejectReason, Version + 1; все 3
строки распределения освобождены (PayoutOrderItemId = null) и видны как свободные при следующей автосборке
(create-payout-order.md); событие PayoutOrderStatusChangedKafkaEvent
(PendingPayment → Rejected) опубликовано; руководитель получает уведомление с причиной, агенты — нет.
- Пустая причина. Дано:
RejectReason пуст/whitespace. Когда: POST. Тогда: 400
REWARDS_PAYOUT_REJECT_REASON_REQUIRED (INV-8), статус поручения не меняется, строки не освобождаются.
- Недопустимый статус. Дано: поручение уже
Paid (или Rejected/Cancelled/Draft). Когда: POST.
Тогда: 409 REWARDS_PAYOUT_INVALID_STATUS_TRANSITION, эффектов нет (по Paid — деньги уже выплачены,
автооткат недопустим).
- Повторное отклонение. Дано: поручение уже
Rejected. Когда: повторный POST .../reject. Тогда: 409
REWARDS_PAYOUT_INVALID_STATUS_TRANSITION (естественная идемпотентность — двойного освобождения строк нет).
- Конкурентный конфликт. Дано:
ExpectedVersion не совпадает с текущей версией (параллельный MarkPaid
успел перевести поручение). Когда: POST. Тогда: 409 REWARDS_CONCURRENCY_CONFLICT; клиент перечитывает
поручение и видит его актуальный статус.
- Чужой tenant / не найдено. Дано:
OrderId принадлежит другому агентству или не существует. Когда:
POST. Тогда: 404 REWARDS_PAYOUT_ORDER_NOT_FOUND (fail closed, чужой tenant неотличим от отсутствующего).
- Гонка reject vs mark-paid. Дано: два параллельных запроса —
reject и mark-paid по одному поручению.
Когда: оба стартуют. Тогда: побеждает захвативший блокировку первым; проигравший получает
REWARDS_CONCURRENCY_CONFLICT либо REWARDS_PAYOUT_INVALID_STATUS_TRANSITION (в зависимости от того, чей
переход применился), двойного эффекта нет.
Совместимость и наблюдаемость
- Контракт additive-only; enum
PayoutOrderStatus расширяется только additive; смена типа/удаление
полей PayoutOrderModel — breaking (дом README, migration + rollout).
- Logs:
OrderId, Number, TenantId, FromStatus/ToStatus, RejectReason, число освобождённых
строк (суммы — Sensitive, не логируются); metrics: rewards_payout_reject_total; traces: OTel,
correlation через traceparent; dashboard rewards-overview; runbook — runbooks/ (по конвенции).
Обратные ссылки
Автоссылки: где используется этот документ.
Связанные документы