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

POST /api/v1/agency/payout-orders/{orderId}/reject — Отклонение поручения

Переход платёжного поручения PendingPayment → Rejected с обязательной причиной: руководитель/ бухгалтер агентства отказывается проводить утверждённое поручение (ошибка в составе, спор по суммам, сорванная выплата). Отклонение освобождает все привязанные строки распределения (CommissionDistributionEntity.PayoutOrderItemId → null) — они снова доступны для следующего поручения (INV-8). Терминальный статус: повторное действие невозможно.

Назначение

Операция №26 реестра (README.md). Ручной отказ по уже утверждённому (переданному в бухгалтерию) поручению, зеркальный к выплате (mark-payout-order-paid.md): вместо PaidRejected, а зарезервированные под него доли агентов возвращаются в пул свободных. Дом инвариантов и статусной модели — domain/payout-order.md (INV-8, enum PayoutOrderStatus). Feature group — payouts.

Metadata

ПолеЗначение
Сервис/контекстrewards
API areaagency
Feature grouppayouts
Статусdraft
Документdocs/06-services/rewards/api/agency/reject-payout-order.md

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

ПроверкаЗначение
Actor typeUser (руководитель/бухгалтер агентства)
Auth policy / Permissionsrewards.payouts.approve (каталог — permissions.md)
Scope (RBAC)организация: компания-агентство (Type ∈ {Agency, Broker, Pin})
Record-levelне применяется: поручение принадлежит TenantId актора (tenant-фильтр)
Tenant isolationorder.TenantId == CompanyId актора (стандартный tenant-интерсептор, ADR-0052); чужой tenant → 404 (fail closed)
Auditupdate: RejectedAt, RejectReason, UpdatedByUserId, Version + 1; PayoutOrderStatusChangedKafkaEvent

HTTP-контракт

ПолеЗначение
MethodPOST
Route/api/v1/agency/payout-orders/{orderId}/reject
Success status200
Idempotency headerне применяется (повтор из RejectedREWARDS_PAYOUT_INVALID_STATUS_TRANSITION)
Correlationсквозной trace через заголовок traceparent

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

ПолеИсточникТипОбязательностьВалидация / ОграниченияБезопасность
TenantIdidentity claims (tenant_id)GuidДаRequiredtenant isolation
ActorUserIdidentity claims (sub)GuidДаRequiredactor-stamping
OrderIdroute {orderId}GuidДаRequired, непустой
ExpectedVersionbodylongДа>= 1; сверяется с Version агрегатаoptimistic concurrency
RejectReasonbodystringДаобязателен; 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сохраняются (история поручения); строки распределения по ним уже освобождены

Алгоритм

  1. Контекст и доступ. Операцию выполняет руководитель/бухгалтер агентства (тип компании Agency/Broker/Pin); scope — организация (TenantId из claims); требуется право rewards.payouts.approve.
  2. Проверка входа и предусловий.
    • формат: 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) вместе с позициями.
  3. Переход и освобождение долей. Поручение переходит PendingPayment → Rejected: проставляются RejectedAt, RejectReason из запроса, растёт версия; TotalAmount/ItemsCount/Items не меняются (поручение остаётся финансовым документом истории). По INV-8 все привязанные к позициям строки распределения освобождаются: их PayoutOrderItemId сбрасывается в null (PaidAt уже null — поручение не выплачено) — доли снова доступны следующему поручению. Rejected — терминальный статус.
  4. Фиксация факта. Переход статуса, освобождение строк и публикация события выполняются в одном согласованном изменении. Событие 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 и сверкой версии.
  5. Результат. Проекция поручения (Status = Rejected, заполнены RejectedAt/RejectReason, Items включены, новая версия) → HTTP 200.

Диаграмма

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

ТипДетали
Integration eventsPayoutOrderStatusChangedKafkaEventpin.rewards.payout-order-status-changed.v1, key = OrderId
External callsnone
Cache / projections / searchnone; строки распределения возвращаются в пул свободных (PayoutOrderItemId = null) — доступны следующему поручению
Notificationsnotifications по событию: руководителю агентства «поручение {Number} отклонено» с RejectReason (агентам не рассылается)

Интеграционная последовательность. Переход статуса и освобождение строк распределения — синхронны и атомарны: сразу после 200 строки с PayoutOrderItemId = null доступны следующей автосборке поручения (create-payout-order.md) в том же tenant — окна eventual-consistency внутри rewards нет. Уведомление руководителю — асинхронно: PayoutOrderStatusChangedKafkaEventnotifications (окно — секунды); агентам об отклонении не сообщается, поэтому промежуточного состояния для агента не возникает. Событие несёт ключ партиционирования 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_REQUIRED400RejectReason пуст/whitespace (INV-8)«Укажите причину отклонения»no retry
REWARDS_PAYOUT_ORDER_NOT_FOUND404Поручение не существует / чужой TenantId«Поручение не найдено»no retry
REWARDS_PAYOUT_INVALID_STATUS_TRANSITION409Status != PendingPayment (уже Paid/Rejected/Cancelled/Draft)«Поручение нельзя отклонить в текущем статусе»no retry
REWARDS_CONCURRENCY_CONFLICT409конфликт версии (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/ (по конвенции).

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

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

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