POST /api/v1/agency/payout-orders/{orderId}/approve — Утверждение платёжного поручения
Переход платёжного поручения Draft → PendingPayment: руководитель агентства утверждает собранный
черновик, состав позиций замораживается и поручение передаётся в бухгалтерию как «ожидается»
(матрица «Агенство» 5.4/5.5; статусная модель — domain/payout-order.md,
INV-6 ApproveGuard). Исполнение платежа далее — вне платформы, ручной MarkPaid (решение №9 ТЗ,
../README.md).
Назначение
Утвердить черновик поручения, сформированный командами createPayoutOrder /
updatePayoutOrderItems (create-payout-order.md,
update-payout-order-items.md): проверить готовность (есть хотя бы одна
позиция), заморозить состав (после PendingPayment изменение позиций запрещено — INV-5
ComposeOnlyInDraft) и известить агентов и бухгалтерию событием статуса. После утверждения поручение
доступно к отметке фактической выплаты. Owning-контекст — rewards, feature group payouts.
| Поле | Значение |
|---|
| Сервис/контекст | rewards |
| API area | agency |
| Feature group | payouts |
| Статус | draft |
| Документ | docs/06-services/rewards/api/agency/approve-payout-order.md |
Актор и доступ
| Проверка | Значение |
|---|
| Actor type | User (руководитель / бухгалтер агентства) |
| Auth policy / Permissions | rewards.payouts.approve (op24 реестра README.md) |
| Scope (RBAC) | организация: компания Type = Agency (TenantId актора) |
| Record-level | order.TenantId == TenantId актора — обеспечивается tenant-интерсептором; чужой tenant → REWARDS_PAYOUT_ORDER_NOT_FOUND (без раскрытия) |
| Tenant isolation | TenantId из claims; выборка поручения ограничена tenant-ом агентства (ADR-0052) |
| Audit | update: ApprovedAt, ApprovedByUserId = ActorUserId, UpdatedByUserId, Version + 1; финансовый переход фиксируется PayoutOrderStatusChangedKafkaEvent |
HTTP-контракт
| Поле | Значение |
|---|
| Method | POST |
| Route | /api/v1/agency/payout-orders/{orderId}/approve |
| Success status | 200 |
| Idempotency header | не применяется (естественная идемпотентность: повтор из PendingPayment → 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, != Guid.Empty | — |
ExpectedVersion | body | long | Да | >= 1; сверка с PayoutOrderEntity.Version | optimistic concurrency (конкурент — Cancel/updateItems) |
Модель ответа PayoutOrderModel
Полная проекция агрегата — дом README.md (PayoutOrderModel). Дельта операции: после
успеха Status = PendingPayment, заполнен ApprovedAt; Items — замороженный состав. Поле
ApprovedByUserId проставляется в агрегате (audit), в проекцию не выносится (внутренние данные).
| Поле | Тип | Обязательность | Источник | Masking | Описание |
|---|
Status | string (enum PayoutOrderStatus) | Да | Status | — | Всегда PendingPayment. |
ApprovedAt | DateTimeOffset | Да | ApprovedAt | — | Момент утверждения (после операции — не null). |
TotalAmount | MoneyModel | Да | TotalAmount | Sensitive: не логируется | Итог поручения (Σ позиций, INV-3). |
ItemsCount | int | Да | ItemsCount | — | Число позиций (>= 1). |
Items | IReadOnlyList<PayoutOrderItemModel> | Да | child-коллекция | Sensitive (суммы) | Замороженные позиции по агентам. |
Version | long | Да | Version | — | Новая версия (ExpectedVersion + 1). |
Остальные поля PayoutOrderModel (Number, Title, PeriodFrom/To, CreatedAt, …) — без изменений,
см. дом.
Алгоритм
- Контекст и доступ. Операцию выполняет руководитель/бухгалтер агентства; scope — организация
(агентство
TenantId из claims); требуется право rewards.payouts.approve.
- Проверка входа и предусловий.
- формат:
OrderId непустой, ExpectedVersion >= 1; иначе ошибка валидации;
- существование: поручение с
OrderId в tenant-е агентства существует; если нет (или принадлежит
чужому tenant) → REWARDS_PAYOUT_ORDER_NOT_FOUND;
- допустимость состояния (INV-6 ApproveGuard): статус поручения —
Draft; иначе (в т.ч. повторное
утверждение из PendingPayment) → REWARDS_PAYOUT_INVALID_STATUS_TRANSITION;
- готовность (INV-6): в поручении есть хотя бы одна позиция (
ItemsCount >= 1); пустой черновик
утверждать нечего → REWARDS_PAYOUT_EMPTY;
- конкуренция: версия поручения совпадает с
ExpectedVersion; иначе REWARDS_CONCURRENCY_CONFLICT.
Поручение читается под блокировкой, чтобы сериализовать утверждение с конкурентными отменой и правкой
состава. Позиции загружаются вместе с поручением; строки распределения при утверждении не меняются
(их привязка проставлена на этапе Draft, INV-1).
- Переход и заморозка состава. Поручение переходит
Draft → PendingPayment (INV-6). Состав
позиций замораживается: после PendingPayment добавление/исключение позиций запрещено
(INV-5 ComposeOnlyInDraft). Проставляются ApprovedAt и внутренний ApprovedByUserId = актор,
растёт версия; TotalAmount и ItemsCount не пересчитываются (состав неизменен).
- Фиксация факта. Переход статуса и публикация события статуса выполняются в одном согласованном
изменении. Событие
PayoutOrderStatusChangedKafkaEvent (топик
pin.rewards.payout-order-status-changed.v1, ключ — OrderId, FromStatus = Draft,
ToStatus = PendingPayment, снимок позиций и итог) — контракт
../events/payout-order-status-changed.md; потребитель —
notifications (агентам позиций и руководителю).
- Результат. Обновлённая проекция поручения (
Status = PendingPayment, заполнен ApprovedAt,
новая версия, замороженные позиции) → HTTP 200. При отказах — ответ с соответствующим кодом.
Диаграмма
Побочные эффекты
| Тип | Детали |
|---|
| Integration events | PayoutOrderStatusChangedKafkaEvent → pin.rewards.payout-order-status-changed.v1 (key = OrderId); FromStatus=Draft, ToStatus=PendingPayment |
| External calls | none |
| Cache / projections / search | none (история выплат ЛК — SQL по поручениям, ADR-0056) |
| Notifications | опосредованно: notifications — консюмер payout-order-status-changed: агентам позиций «вам направлена выплата», руководителю «поручение {Number} утверждено» |
Ошибки и failure modes
Общие коды (ValidationError, FORBIDDEN, UNAUTHORIZED, REWARDS_CONCURRENCY_CONFLICT) — дом
README.md «Общие ошибки», здесь не дублируются. Специфичные для операции:
| Код | HTTP status | Условие | Сообщение | Retry |
|---|
REWARDS_PAYOUT_ORDER_NOT_FOUND | 404 | Поручение не существует или принадлежит чужому tenant | «Платёжное поручение не найдено» | no retry |
REWARDS_PAYOUT_INVALID_STATUS_TRANSITION | 409 | Status != Draft (INV-6; в т.ч. повторное утверждение из PendingPayment) | «Поручение нельзя утвердить в текущем статусе» | no retry |
REWARDS_PAYOUT_EMPTY | 409 | ItemsCount < 1 — черновик без позиций (INV-6 ApproveGuard) | «Нельзя утвердить поручение без позиций» | no retry (добавьте позиции) |
Acceptance criteria
- AC-1 (утверждение готового черновика). Given поручение
Draft с ItemsCount >= 1 (Version = N);
When руководитель утверждает его с ExpectedVersion = N; Then поручение переходит
Draft → PendingPayment, состав замораживается (INV-5), заполнен ApprovedAt, Version = N+1,
TotalAmount/ItemsCount не пересчитываются; публикуется PayoutOrderStatusChangedKafkaEvent
(FromStatus = Draft, ToStatus = PendingPayment); ответ 200. Постусловие: notifications
асинхронно уведомляет каждого агента позиций («вам направлена выплата») и руководителя
(«поручение {Number} утверждено») — окно доставки < 5 мин; до доставки ЛК показывает статус «ожидается»
из хранилища (история — SQL по поручениям, не по событиям).
- AC-2 (пустой черновик). Given поручение
Draft с ItemsCount = 0; When утверждение; Then
REWARDS_PAYOUT_EMPTY (409, INV-6 ApproveGuard) — сначала добавьте позиции; событие не публикуется.
- AC-3 (недопустимый статус / повтор). Given поручение уже
PendingPayment/Paid/Rejected/
Cancelled; When утверждение (в т.ч. повторное); Then REWARDS_PAYOUT_INVALID_STATUS_TRANSITION
(409, INV-6).
- AC-4 (гонка approve/cancel). Given одновременные утверждение и отмена того же черновика (оба из
Draft); When обе операции; Then одна побеждает по блокировке и сверке версии, проигравшая —
REWARDS_CONCURRENCY_CONFLICT (409).
- AC-5 (чужой tenant). Given поручение другого агентства; When утверждение; Then
REWARDS_PAYOUT_ORDER_NOT_FOUND (404, без раскрытия).
Совместимость и наблюдаемость
- Payload события — additive-only; breaking →
pin.rewards.payout-order-status-changed.v2 (dual-publish
- sunset). Новые optional-поля
PayoutOrderModel — без breaking change.
- Logs:
OrderId, Number, TenantId, Draft→PendingPayment, ItemsCount (суммы не логируются —
Sensitive); metrics: rewards_payout_approve_total{result}, rewards_payout_empty_total; traces —
OTel, correlation через traceparent; dashboard rewards-overview; runbook —
runbooks/rewards-payout-lifecycle.md (по конвенции).
- НФТ (ADR-0056): переход одного агрегата под блокировкой + одно событие статуса (состав не пересчитывается);
целевая латентность p95 < 400 мс; частота — единичные утверждения на агентство в месяц (back-office
руководителя).
Обратные ссылки
Автоссылки: где используется этот документ.
Связанные документы