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.
| Поле | Значение |
|---|
| Сервис/контекст | rewards |
| API area | agency |
| Feature group | payouts |
| Статус | draft |
| Документ | docs/06-services/rewards/api/agency/cancel-payout-order.md |
Актор и доступ
| Проверка | Значение |
|---|
| Actor type | User (руководитель/бухгалтер агентства — создатель поручения) |
| Auth policy / Permissions | rewards.payouts.manage |
| Scope (RBAC) | организация: компания-агентство (TenantId) |
| Record-level | order.CreatedByUserId == ActorUserId — отменить может только создатель; иначе 403 FORBIDDEN (общий код, README) |
| Tenant isolation | TenantId из claims через tenant-интерсептор; чужой tenant → 404 |
| Audit | update: Status = Cancelled, CancelledAt, UpdatedByUserId = ActorUserId, Version + 1; отмена без интеграционного события |
HTTP-контракт
| Поле | Значение |
|---|
| Method | POST |
| Route | /api/v1/agency/payout-orders/{orderId}/cancel |
| Success status | 200 |
| Idempotency header | не применяется (повтор из Cancelled → REWARDS_PAYOUT_INVALID_STATUS_TRANSITION) |
| Correlation | сквозной trace через заголовок traceparent |
Входные данные / параметры запроса
| Поле | Источник | Тип | Обязательность | Валидация / Ограничения | Безопасность |
|---|
TenantId | identity claims (tenant_id) | Guid | Да | Required | Tenant isolation |
ActorUserId | identity claims (sub) | Guid | Да | Required | record-level (создатель) |
OrderId | route {orderId} | Guid | Да | Required, непустой | — |
ExpectedVersion | body | long | Да | >= 1; сверка с Version поручения | optimistic concurrency (конкурент — Approve из Draft) |
Модель ответа PayoutOrderModel
Полная проекция поручения — дом README.md. Дельта операции: Status всегда Cancelled,
CancelledAt заполнен; Items возвращаются как замороженный снимок отменённого состава (TotalAmount,
ItemsCount не пересчитываются — историческая фиксация того, что было отменено). Освобождённые строки
распределения в ответ не входят (они принадлежат агрегату начисления).
| Поле | Тип | Обязательность | Источник | Masking | Описание |
|---|
Id | Guid | Да | PayoutOrderEntity.Id | — | Id поручения. |
Status | string (enum PayoutOrderStatus) | Да | Status | — | Всегда Cancelled. |
CancelledAt | DateTimeOffset | Да | CancelledAt | — | Момент отмены. |
Version | long | Да | Version | — | Новая версия. |
Остальные поля (Number, PeriodFrom/To, TotalAmount, ItemsCount, Items, …) — по дому PayoutOrderModel;
суммы Sensitive (не логируются), доступны роли с rewards.payouts.manage.
Алгоритм
- Контекст и доступ. Операцию выполняет руководитель/бухгалтер агентства — создатель поручения;
scope — организация (
TenantId из claims); требуется право rewards.payouts.manage.
- Проверка входа и предусловий.
- формат:
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) вместе с
позициями; дополнительно захватываются привязанные к позициям строки распределения — их предстоит
освободить.
- Переход и освобождение долей. Поручение переходит
Draft → Cancelled: проставляется CancelledAt,
растёт версия; RejectReason не заполняется (это путь отмены, не отклонения). Позиции Items и суммы
(TotalAmount, ItemsCount) не меняются — замороженный снимок отменённого состава для истории. По
INV-8 ReleaseOnTerminate каждая привязанная строка распределения освобождается: её PayoutOrderItemId
сбрасывается в null — доля возвращается в свободный пул (null = не включена в поручение,
README.md → CommissionDistributionModel) и доступна следующему поручению. Cancelled —
терминальный статус.
- Согласованность. Обновление поручения и освобождение строк распределения выполняются в одном
согласованном изменении. Гонка с утверждением (оба из
Draft) разрешается блокировкой шага 2 и сверкой
версии. Интеграционных событий нет — событие статуса PayoutOrderStatusChangedKafkaEvent публикуется
только на переходах Approve/MarkPaid/Reject
(domain/payout-order.md); отмена черновика внешним потребителям не видна
(поручение не выходило из Draft).
- Результат. Проекция поручения (
Status = Cancelled, заполнен CancelledAt, снимок Items, новая
версия) → HTTP 200.
Диаграмма
Побочные эффекты
| Тип | Детали |
|---|
| Integration events | none — Kafka не публикуется (событие статуса только на Approve/MarkPaid/Reject) |
| External calls | none |
| Cache / projections / search | Освобождённые Agent-строки распределения снова попадают в выборку свободных при сборке следующего поручения (create-payout-order.md) |
| Notifications | none (черновик внешним ролям не анонсировался) |
Ошибки и failure modes
| Код | HTTP status | Условие | Сообщение | Retry |
|---|
REWARDS_PAYOUT_NOT_FOUND | 404 | Поручение не существует / чужой tenant | «Поручение не найдено» | no retry |
REWARDS_PAYOUT_INVALID_STATUS_TRANSITION | 409 | Status != Draft (уже утверждено/выплачено/отклонено/отменено) | «Отменить можно только черновик» | no retry |
REWARDS_CONCURRENCY_CONFLICT | 409 | конфликт версии (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).
Обратные ссылки
Автоссылки: где используется этот документ.
Связанные документы