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

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

Перевод платёжного поручения Draft → Cancelled создателем: черновик, собранный ошибочно или потерявший актуальность, выводится из работы, а все привязанные к нему Agent-строки распределения освобождаются обратно в свободный пул (INV-8 ReleaseOnTerminate, domain/payout-order.md). Отмена разрешена только из Draft — после утверждения используется отклонение (reject-payout-order.md).

Назначение

Операция №27 реестра (README.md). Руководитель/бухгалтер агентства отменяет собственный черновик поручения из списка (list-payout-orders.md) до его утверждения, зеркально к отклонению утверждённого (reject-payout-order.md). Cancelled — терминальный статус; поручение сохраняется как элемент истории выплат (матрица «Агенство» 5.5), а подтверждённые доли агентов, которые в него входили, снова доступны для следующего поручения. Owning-контекст — rewards. Feature group — payouts.

Metadata

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

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

ПроверкаЗначение
Actor typeUser (руководитель/бухгалтер агентства — создатель поручения)
Auth policy / Permissionsrewards.payouts.manage
Scope (RBAC)организация: компания-агентство (TenantId)
Record-levelorder.CreatedByUserId == ActorUserId — отменить может только создатель; иначе 403 FORBIDDEN (общий код, README)
Tenant isolationTenantId из claims через tenant-интерсептор; чужой tenant → 404
Auditupdate: Status = Cancelled, CancelledAt, UpdatedByUserId = ActorUserId, Version + 1; отмена без интеграционного события

HTTP-контракт

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

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

ПолеИсточникТипОбязательностьВалидация / ОграниченияБезопасность
TenantIdidentity claims (tenant_id)GuidДаRequiredTenant isolation
ActorUserIdidentity claims (sub)GuidДаRequiredrecord-level (создатель)
OrderIdroute {orderId}GuidДаRequired, непустой
ExpectedVersionbodylongДа>= 1; сверка с Version порученияoptimistic concurrency (конкурент — Approve из Draft)

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

Полная проекция поручения — дом README.md. Дельта операции: Status всегда Cancelled, CancelledAt заполнен; Items возвращаются как замороженный снимок отменённого состава (TotalAmount, ItemsCount не пересчитываются — историческая фиксация того, что было отменено). Освобождённые строки распределения в ответ не входят (они принадлежат агрегату начисления).

ПолеТипОбязательностьИсточникMaskingОписание
IdGuidДаPayoutOrderEntity.IdId поручения.
Statusstring (enum PayoutOrderStatus)ДаStatusВсегда Cancelled.
CancelledAtDateTimeOffsetДаCancelledAtМомент отмены.
VersionlongДаVersionНовая версия.

Остальные поля (Number, PeriodFrom/To, TotalAmount, ItemsCount, Items, …) — по дому PayoutOrderModel; суммы Sensitive (не логируются), доступны роли с rewards.payouts.manage.

Алгоритм

  1. Контекст и доступ. Операцию выполняет руководитель/бухгалтер агентства — создатель поручения; scope — организация (TenantId из claims); требуется право rewards.payouts.manage.
  2. Проверка входа и предусловий.
    • формат: OrderId непустой, ExpectedVersion >= 1; иначе ошибка валидации;
    • существование: поручение с OrderId в tenant-е агентства существует; если нет (или чужой tenant) → REWARDS_PAYOUT_NOT_FOUND;
    • record-level: отменить может только создатель (CreatedByUserId = актор); иначе FORBIDDEN;
    • допустимость состояния: статус — Draft (после утверждения используется отклонение, reject-payout-order.md); иначе → REWARDS_PAYOUT_INVALID_STATUS_TRANSITION;
    • конкуренция: версия совпадает с ExpectedVersion; иначе REWARDS_CONCURRENCY_CONFLICT. Поручение читается под блокировкой (сериализация с параллельным утверждением, оба из Draft) вместе с позициями; дополнительно захватываются привязанные к позициям строки распределения — их предстоит освободить.
  3. Переход и освобождение долей. Поручение переходит Draft → Cancelled: проставляется CancelledAt, растёт версия; RejectReason не заполняется (это путь отмены, не отклонения). Позиции Items и суммы (TotalAmount, ItemsCount) не меняются — замороженный снимок отменённого состава для истории. По INV-8 ReleaseOnTerminate каждая привязанная строка распределения освобождается: её PayoutOrderItemId сбрасывается в null — доля возвращается в свободный пул (null = не включена в поручение, README.mdCommissionDistributionModel) и доступна следующему поручению. Cancelled — терминальный статус.
  4. Согласованность. Обновление поручения и освобождение строк распределения выполняются в одном согласованном изменении. Гонка с утверждением (оба из Draft) разрешается блокировкой шага 2 и сверкой версии. Интеграционных событий нет — событие статуса PayoutOrderStatusChangedKafkaEvent публикуется только на переходах Approve/MarkPaid/Reject (domain/payout-order.md); отмена черновика внешним потребителям не видна (поручение не выходило из Draft).
  5. Результат. Проекция поручения (Status = Cancelled, заполнен CancelledAt, снимок Items, новая версия) → HTTP 200.

Диаграмма

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

ТипДетали
Integration eventsnone — Kafka не публикуется (событие статуса только на Approve/MarkPaid/Reject)
External callsnone
Cache / projections / searchОсвобождённые Agent-строки распределения снова попадают в выборку свободных при сборке следующего поручения (create-payout-order.md)
Notificationsnone (черновик внешним ролям не анонсировался)

Ошибки и failure modes

КодHTTP statusУсловиеСообщениеRetry
REWARDS_PAYOUT_NOT_FOUND404Поручение не существует / чужой tenant«Поручение не найдено»no retry
REWARDS_PAYOUT_INVALID_STATUS_TRANSITION409Status != Draft (уже утверждено/выплачено/отклонено/отменено)«Отменить можно только черновик»no retry
REWARDS_CONCURRENCY_CONFLICT409конфликт версии (ExpectedVersion, параллельный Approve)«Поручение изменено — обновите данные»retry после refresh

Общие коды (ValidationError 400, FORBIDDEN 403 — не создатель/нет permission, UNAUTHORIZED 401) — дом README.md, в таблице не дублируются.

Acceptance criteria

  • AC-1 (отмена своего черновика). Given поручение Draft, созданное актором (Version = N); When создатель отменяет его с ExpectedVersion = N; Then поручение переходит Draft → Cancelled, заполнен CancelledAt, Version = N+1, Items/суммы сохранены снимком, RejectReason не заполняется; ответ 200. Постусловие: все привязанные Agent-строки распределения освобождены (PayoutOrderItemId → null, INV-8) — снова доступны следующему поручению (create-payout-order.md); интеграционных событий нет (черновик наружу не выходил).
  • AC-2 (не создатель). Given поручение Draft, созданное другим сотрудником; When отмену инициирует не создатель; Then FORBIDDEN (403, record-level: CreatedByUserId != ActorUserId).
  • AC-3 (недопустимый статус). Given поручение уже PendingPayment/Paid/Rejected/Cancelled; When отмена; Then REWARDS_PAYOUT_INVALID_STATUS_TRANSITION (409) — после утверждения используется отклонение (reject-payout-order.md).
  • AC-4 (гонка cancel/approve). Given одновременные отмена и утверждение того же черновика (оба из Draft); When обе операции; Then одна побеждает по блокировке и версии, проигравшая — REWARDS_CONCURRENCY_CONFLICT (409, retry после refresh).
  • AC-5 (чужой tenant). Given поручение другого агентства; When отмена; Then REWARDS_PAYOUT_NOT_FOUND (404, без раскрытия).

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

  • Cancelled терминален — восстановление не проектируется (собирается новый черновик create-payout-order.md). Модель ответа additive-only.
  • Logs: TenantId, OrderId, Number, FromStatus = Draft → ToStatus = Cancelled, число освобождённых строк (суммы не логируются); metrics: rewards_payout_cancel_total{result}, rewards_payout_released_distributions_total; traces: OTel, correlation через traceparent; dashboard rewards-overview; runbook — не применяется.
  • НФТ (ADR-0056): переход одного агрегата под блокировкой + освобождение привязанных строк распределения (десятки-сотни) в одном изменении; целевая латентность p95 < 400 мс; частота — редкая (отмена ошибочного черновика, back-office).

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

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

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