POST /api/v1/platform/referral-payout-orders/{orderId}/approve — Утверждение реферального поручения
Переход реферального платёжного поручения (Kind = ReferralReward, tenant — компания ПИН)
Draft → PendingPayment: оператор ПИН утверждает собранный черновик, состав позиций замораживается и
поручение передаётся в бухгалтерию ПИН как «ожидается» (статусная модель и INV-6 ApproveGuard —
domain/payout-order.md). Агентский аналог —
../agency/approve-payout-order.md.
Назначение
Утвердить черновик, сформированный командой создания
(create-referral-payout-order.md): проверить готовность (есть хотя бы
одна позиция), заморозить состав (после PendingPayment изменение позиций запрещено — INV-5
ComposeOnlyInDraft) и известить партнёров событием статуса. После утверждения поручение доступно к отметке
фактической выплаты. Feature group — referral-payouts.
| Поле | Значение |
|---|
| Сервис/контекст | rewards |
| API area | platform |
| Feature group | referral-payouts |
| Статус | draft |
| Документ | docs/06-services/rewards/api/platform/approve-referral-payout-order.md |
Актор и доступ
| Проверка | Значение |
|---|
| Actor type | User (оператор ПИН) |
| Auth policy / Permissions | rewards.payouts.approve (платформенный scope) |
| Scope (RBAC) | платформа |
| Tenant isolation | поручение принадлежит tenant-у компании ПИН и имеет Kind = ReferralReward; агентское поручение по этому route → REWARDS_PAYOUT_ORDER_NOT_FOUND (без раскрытия) |
| Audit | update: ApprovedAt, ApprovedByUserId = ActorUserId, UpdatedByUserId, Version + 1; финансовый переход фиксируется PayoutOrderStatusChangedKafkaEvent |
HTTP-контракт
| Поле | Значение |
|---|
| Method | POST |
| Route | /api/v1/platform/referral-payout-orders/{orderId}/approve |
| Success status | 200 |
| Idempotency header | не применяется (естественная идемпотентность: повтор из PendingPayment → REWARDS_PAYOUT_INVALID_STATUS_TRANSITION) |
| Correlation | сквозной trace через заголовок traceparent |
Входные данные / параметры запроса
| Поле | Источник | Тип | Обязательность | Валидация / Ограничения | Безопасность |
|---|
ActorUserId | identity claims (sub) | Guid | Да | Required | actor-stamping |
OrderId | route {orderId} | Guid | Да | Required, непустой | — |
ExpectedVersion | body | long | Да | >= 1; сверка с Version поручения (конкурент — Cancel) | optimistic concurrency |
Модель ответа PayoutOrderModel
Полная проекция агрегата — дом README.md («PayoutOrderModel»). Дельта операции:
Status = PendingPayment, заполнен ApprovedAt, Items — замороженный состав позиций по партнёрам,
новая Version. ApprovedByUserId наружу не выводится (внутренний audit-stamp).
Алгоритм
- Контекст и доступ. Операцию выполняет оператор ПИН в платформенном scope; требуется право
rewards.payouts.approve.
- Проверка входа и предусловий.
- формат:
OrderId непустой, ExpectedVersion >= 1; иначе ValidationError;
- существование: поручение с
OrderId в tenant-е ПИН с Kind = ReferralReward существует; иначе (нет,
агентское поручение, чужой tenant) → REWARDS_PAYOUT_ORDER_NOT_FOUND;
- допустимость состояния (INV-6): статус —
Draft; иначе (в т.ч. повторное утверждение) →
REWARDS_PAYOUT_INVALID_STATUS_TRANSITION;
- готовность (INV-6):
ItemsCount >= 1; пустой черновик → REWARDS_PAYOUT_EMPTY;
- конкуренция: версия совпадает с
ExpectedVersion; иначе REWARDS_CONCURRENCY_CONFLICT.
Поручение читается под блокировкой с позициями (сериализация с конкурентной отменой).
- Переход и заморозка состава. Поручение переходит
Draft → PendingPayment (INV-6); состав
замораживается (INV-5): добавление/исключение начислений после утверждения запрещено; привязанные
рефералки остаются Approved и защищены от отмены захватом (PayoutOrderItemId != null, INV-RA-5).
Проставляются ApprovedAt и внутренний ApprovedByUserId, растёт версия; TotalAmount/ItemsCount
не пересчитываются.
- Фиксация факта. Переход и публикация события — в одном согласованном изменении:
PayoutOrderStatusChangedKafkaEvent (топик pin.rewards.payout-order-status-changed.v1, ключ —
OrderId, Kind = ReferralReward, 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); Kind = ReferralReward, FromStatus = Draft, ToStatus = PendingPayment |
| External calls | none |
| Cache / projections / search | none |
| 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 | Поручение не существует / не ReferralReward / чужой tenant | «Платёжное поручение не найдено» | no retry |
REWARDS_PAYOUT_INVALID_STATUS_TRANSITION | 409 | Status != Draft (INV-6; в т.ч. повторное утверждение) | «Поручение нельзя утвердить в текущем статусе» | no retry |
REWARDS_PAYOUT_EMPTY | 409 | ItemsCount < 1 — черновик без позиций (INV-6) | «Нельзя утвердить поручение без позиций» | no retry |
Acceptance criteria
- AC-1 (утверждение готового черновика). Given реферальное поручение
Draft с ItemsCount >= 1
(Version = N); When оператор утверждает с ExpectedVersion = N; Then поручение переходит
Draft → PendingPayment, состав заморожен (INV-5), ApprovedAt заполнен, Version = N+1; опубликовано
PayoutOrderStatusChangedKafkaEvent (Draft → PendingPayment, Kind = ReferralReward); ответ 200.
Постусловие: notifications асинхронно уведомляет партнёров позиций (окно доставки — минуты); рефералки
состава остаются Approved и не могут быть отменены, пока привязаны.
- AC-2 (пустой черновик). Given поручение
Draft с ItemsCount = 0 (все начисления исключены
правкой до заморозки); When утверждение; Then REWARDS_PAYOUT_EMPTY (409); событие не
публикуется.
- AC-3 (недопустимый статус / повтор). Given поручение уже
PendingPayment/Paid/Rejected/Cancelled; When утверждение (в т.ч. повторное); Then
REWARDS_PAYOUT_INVALID_STATUS_TRANSITION (409).
- AC-4 (гонка approve/cancel). Given одновременные утверждение и отмена того же черновика; When
обе операции; Then одна побеждает по блокировке и сверке версии, проигравшая —
REWARDS_CONCURRENCY_CONFLICT (409); итоговое состояние согласовано (либо утверждено, либо отменено с
освобождением рефералок).
- AC-5 (не реферальное / чужое). Given
OrderId агентского поручения (Kind = AgencyCommission)
или несуществующий; When утверждение по этому route; Then REWARDS_PAYOUT_ORDER_NOT_FOUND
(404, без раскрытия).
- AC-6 (доступ). Given актор с
rewards.payouts.approve только в scope организации-агентства (не
платформы); When утверждение; Then FORBIDDEN (403).
Совместимость и наблюдаемость
- Payload события — additive-only; breaking →
pin.rewards.payout-order-status-changed.v2 (dual-publish
- Logs:
OrderId, Number, Kind, Draft→PendingPayment, ItemsCount (суммы не логируются —
Sensitive); metrics: rewards_payout_approve_total{result, kind}; traces: OTel, correlation через
traceparent; dashboard rewards-overview.
- НФТ (ADR-0056): переход одного агрегата под блокировкой + одно событие; целевая латентность p95 < 400 мс;
частота — единичные утверждения в месяц.
Обратные ссылки
Автоссылки: где используется этот документ.
Связанные документы