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

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.

Metadata

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

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

ПроверкаЗначение
Actor typeUser (руководитель / бухгалтер агентства)
Auth policy / Permissionsrewards.payouts.approve (op24 реестра README.md)
Scope (RBAC)организация: компания Type = Agency (TenantId актора)
Record-levelorder.TenantId == TenantId актора — обеспечивается tenant-интерсептором; чужой tenant → REWARDS_PAYOUT_ORDER_NOT_FOUND (без раскрытия)
Tenant isolationTenantId из claims; выборка поручения ограничена tenant-ом агентства (ADR-0052)
Auditupdate: ApprovedAt, ApprovedByUserId = ActorUserId, UpdatedByUserId, Version + 1; финансовый переход фиксируется PayoutOrderStatusChangedKafkaEvent

HTTP-контракт

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

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

ПолеИсточникТипОбязательностьВалидация / ОграниченияБезопасность
TenantIdidentity claims (tenant_id)GuidДаRequiredTenant isolation
ActorUserIdidentity claims (sub)GuidДаRequiredactor-stamping
OrderIdroute {orderId}GuidДаRequired, != Guid.Empty
ExpectedVersionbodylongДа>= 1; сверка с PayoutOrderEntity.Versionoptimistic concurrency (конкурент — Cancel/updateItems)

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

Полная проекция агрегата — дом README.md (PayoutOrderModel). Дельта операции: после успеха Status = PendingPayment, заполнен ApprovedAt; Items — замороженный состав. Поле ApprovedByUserId проставляется в агрегате (audit), в проекцию не выносится (внутренние данные).

ПолеТипОбязательностьИсточникMaskingОписание
Statusstring (enum PayoutOrderStatus)ДаStatusВсегда PendingPayment.
ApprovedAtDateTimeOffsetДаApprovedAtМомент утверждения (после операции — не null).
TotalAmountMoneyModelДаTotalAmountSensitive: не логируетсяИтог поручения (Σ позиций, INV-3).
ItemsCountintДаItemsCountЧисло позиций (>= 1).
ItemsIReadOnlyList<PayoutOrderItemModel>Даchild-коллекцияSensitive (суммы)Замороженные позиции по агентам.
VersionlongДаVersionНовая версия (ExpectedVersion + 1).

Остальные поля PayoutOrderModel (Number, Title, PeriodFrom/To, CreatedAt, …) — без изменений, см. дом.

Алгоритм

  1. Контекст и доступ. Операцию выполняет руководитель/бухгалтер агентства; scope — организация (агентство TenantId из claims); требуется право rewards.payouts.approve.
  2. Проверка входа и предусловий.
    • формат: 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).
  3. Переход и заморозка состава. Поручение переходит Draft → PendingPayment (INV-6). Состав позиций замораживается: после PendingPayment добавление/исключение позиций запрещено (INV-5 ComposeOnlyInDraft). Проставляются ApprovedAt и внутренний ApprovedByUserId = актор, растёт версия; TotalAmount и ItemsCount не пересчитываются (состав неизменен).
  4. Фиксация факта. Переход статуса и публикация события статуса выполняются в одном согласованном изменении. Событие PayoutOrderStatusChangedKafkaEvent (топик pin.rewards.payout-order-status-changed.v1, ключ — OrderId, FromStatus = Draft, ToStatus = PendingPayment, снимок позиций и итог) — контракт ../events/payout-order-status-changed.md; потребитель — notifications (агентам позиций и руководителю).
  5. Результат. Обновлённая проекция поручения (Status = PendingPayment, заполнен ApprovedAt, новая версия, замороженные позиции) → HTTP 200. При отказах — ответ с соответствующим кодом.

Диаграмма

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

ТипДетали
Integration eventsPayoutOrderStatusChangedKafkaEventpin.rewards.payout-order-status-changed.v1 (key = OrderId); FromStatus=Draft, ToStatus=PendingPayment
External callsnone
Cache / projections / searchnone (история выплат ЛК — 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_FOUND404Поручение не существует или принадлежит чужому tenant«Платёжное поручение не найдено»no retry
REWARDS_PAYOUT_INVALID_STATUS_TRANSITION409Status != Draft (INV-6; в т.ч. повторное утверждение из PendingPayment)«Поручение нельзя утвердить в текущем статусе»no retry
REWARDS_PAYOUT_EMPTY409ItemsCount < 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 руководителя).

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

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

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