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

POST /api/v1/platform/referral-payout-orders/{orderId}/mark-paid — Отметка о выплате реферального поручения

Перевод реферального платёжного поручения (Kind = ReferralReward, tenant — компания ПИН) PendingPayment → Paid вручную оператором ПИН после фактической выплаты вознаграждений партнёрам вне платформы (исполнение платежей отложено — решение №9 ТЗ). Отметка каскадно переводит все привязанные реферальные начисления Approved → Paid (INV-7 MarkPaidCascade, domain/payout-order.md) и публикует событие статуса с позициями Items[] — по нему контекст wallet дебетует счета партнёров (ADR-0065). Агентский аналог — ../agency/mark-payout-order-paid.md.

Назначение

Терминальный финансовый переход реферального поручения: после отметки поручение попадает в историю выплат платформы со статусом «выплачено», партнёры получают уведомления, их начисления закрываются статусом Paid. Feature group — referral-payouts.

Metadata

ПолеЗначение
Сервис/контекстrewards
API areaplatform
Feature groupreferral-payouts
Статусdraft
Документdocs/06-services/rewards/api/platform/mark-referral-payout-order-paid.md

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

ПроверкаЗначение
Actor typeUser (оператор ПИН)
Auth policy / Permissionsrewards.payouts.approve (платформенный scope)
Scope (RBAC)платформа
Tenant isolationпоручение принадлежит tenant-у компании ПИН, Kind = ReferralReward; агентское поручение по этому route → REWARDS_PAYOUT_ORDER_NOT_FOUND
Auditupdate: PaidAt, PaidByUserId = ActorUserId, UpdatedByUserId, Version + 1; каскад — PaidAt позиций и переход рефералок Approved → Paid; финансовые переходы фиксируются событиями статусов

HTTP-контракт

ПолеЗначение
MethodPOST
Route/api/v1/platform/referral-payout-orders/{orderId}/mark-paid
Success status200
Idempotency headerне применяется (повтор из Paid — терминальный статус → REWARDS_PAYOUT_INVALID_STATUS_TRANSITION; двойного дебета кошельков нет)
Correlationсквозной trace через заголовок traceparent

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

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

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

Полная проекция агрегата — дом README.mdPayoutOrderModel»). Дельта операции: Status = Paid, заполнены PaidAt и Items[].PaidAt, новая Version. PaidByUserId наружу не выводится (внутренний audit-stamp).

Алгоритм

  1. Контекст и доступ. Операцию выполняет оператор ПИН в платформенном scope; требуется право rewards.payouts.approve.
  2. Проверка входа и предусловий.
    • формат: OrderId непустой, ExpectedVersion >= 1; иначе ValidationError;
    • существование: поручение с OrderId в tenant-е ПИН с Kind = ReferralReward существует; иначе → REWARDS_PAYOUT_ORDER_NOT_FOUND;
    • допустимость состояния (INV-7 guard): статус — PendingPayment; из Draft/Paid/Rejected/CancelledREWARDS_PAYOUT_INVALID_STATUS_TRANSITION;
    • конкуренция: версия совпадает с ExpectedVersion; иначе REWARDS_CONCURRENCY_CONFLICT. Поручение читается под блокировкой (защита от параллельного Reject) вместе с позициями.
  3. Что читаем для каскада. По позициям поручения определяются привязанные реферальные начисления (referral_accruals.payout_order_item_id); каждое читается под блокировкой и обязано быть в статусе Approved — иначе дефект целостности (REWARDS_REFERRAL_ACCRUAL_PAID_ONLY_VIA_PAYOUT, операция откатывается целиком).
  4. Переход и каскад (INV-7). Поручение переходит PendingPayment → Paid: проставляются PaidAt, внутренний PaidByUserId = актор, PaidAt каждой позиции, растёт версия. Каждое привязанное реферальное начисление переводится Approved → Paid (PaidAt начисления) — терминальный статус (INV-RA-5). Родительские комиссионные начисления не затрагиваются (их Paid — каскад агентских поручений).
  5. Фиксация фактов. Переход поручения, каскад по рефералкам и публикация событий — в одном согласованном изменении (частичной выплаты не бывает):
    • PayoutOrderStatusChangedKafkaEvent (топик pin.rewards.payout-order-status-changed.v1, ключ — OrderId, Kind = ReferralReward, FromStatus = PendingPayment, ToStatus = Paid, PaidAt, Items[] {RecipientUserId, Amount} — по позициям выплаты) — контракт ../../events/payout-order-status-changed.md;
    • по каждому переведённому начислению — ReferralAccrualStatusChangedKafkaEvent (топик pin.rewards.referral-accruals.v1, ключ — id начисления, Approved → Paid, PaidAt). Потребители: wallet — по Items[] события поручения дебетует счета ReferralCommission партнёров (ADR-0065; идемпотентность по операции и id источника — двойного дебета при повторной доставке нет); notifications — партнёрам «вознаграждение выплачено».
  6. Результат. Проекция поручения (Status = Paid, PaidAt, Items[].PaidAt, новая версия) → HTTP 200.

Диаграмма

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

ТипДетали
Integration eventsPayoutOrderStatusChangedKafkaEventpin.rewards.payout-order-status-changed.v1, key = OrderId (Kind = ReferralReward, PendingPayment → Paid, Items[] {RecipientUserId, Amount}; деньги — Value + CurrencyId + CurrencySnapshot, ../../../handbook/domain/snapshots.md); ReferralAccrualStatusChangedKafkaEventpin.rewards.referral-accruals.v1, key = id начисления (Approved → Paid) — по каждому привязанному начислению; контракт — ../../events/referral-accrual-status-changed.md
External callsnone (исполнение платежей вне платформы — решение №9 ТЗ)
Cache / projections / searchnone
Notificationsnotifications по событиям: партнёрам «вознаграждение выплачено»; wallet — дебет счетов ReferralCommission по Items[] (ADR-0065, асинхронно — окно секунды)

Ошибки и failure modes

Общие коды (ValidationError, FORBIDDEN, UNAUTHORIZED, REWARDS_CONCURRENCY_CONFLICT) — дом README.md «Общие ошибки». Специфичные для операции:

КодHTTP statusУсловиеСообщениеRetry
REWARDS_PAYOUT_ORDER_NOT_FOUND404Поручение не существует / не ReferralReward / чужой tenant«Платёжное поручение не найдено»no retry
REWARDS_PAYOUT_INVALID_STATUS_TRANSITION409Status != PendingPayment (в т.ч. повтор из Paid)«Поручение нельзя отметить выплаченным в текущем статусе»no retry
REWARDS_REFERRAL_ACCRUAL_PAID_ONLY_VIA_PAYOUT500Каскад: привязанное начисление не в Approved (дефект целостности INV-7/INV-RA-5)«Внутренняя ошибка перехода начисления»retry после fix

Acceptance criteria

  • AC-1 (отметка выплаты с каскадом). Given реферальное поручение PendingPayment (Version = N) с позициями по трём партнёрам (5 привязанных рефералок в Approved); When оператор отмечает выплату с ExpectedVersion = N; Then поручение → Paid (PaidAt, Items[].PaidAt, Version = N+1); все 5 рефералок → Paid (INV-7); опубликованы PayoutOrderStatusChangedKafkaEvent с Items[] и 5× ReferralAccrualStatusChangedKafkaEvent (Approved → Paid); ответ 200. Постусловие: wallet асинхронно дебетует счета ReferralCommission трёх партнёров на суммы позиций; notifications уведомляет партнёров (окно — минуты).
  • AC-2 (атомарность каскада). Given поручение с N привязанными рефералками; When отметка; Then переход поручения, все PaidAt, все переходы рефералок и публикация всех событий происходят в одном согласованном изменении — состояния «поручение Paid, а рефералки ещё Approved» наружу не наблюдается.
  • AC-3 (недопустимый статус / повтор). Given поручение в Draft/Paid/Rejected/Cancelled; When отметка (в т.ч. повторная из Paid); Then REWARDS_PAYOUT_INVALID_STATUS_TRANSITION (409) — каскад не выполняется, события не публикуются, двойного дебета кошельков нет.
  • AC-4 (гонка mark-paid / reject). Given одновременные MarkPaid и Reject того же поручения; When обе операции; Then одна побеждает по блокировке и сверке версии, проигравшая — REWARDS_CONCURRENCY_CONFLICT (409); итог согласован: либо всё выплачено, либо всё отклонено с освобождением рефералок.
  • AC-5 (дефект целостности). Given привязанная рефералка оказалась не в Approved; When каскад; Then REWARDS_REFERRAL_ACCRUAL_PAID_ONLY_VIA_PAYOUT (500) — операция откатывается целиком.
  • AC-6 (не реферальное / чужое). Given OrderId агентского поручения или несуществующий; When отметка по этому route; Then REWARDS_PAYOUT_ORDER_NOT_FOUND (404, без раскрытия).
  • AC-7 (доступ). Given актор с rewards.payouts.approve только в scope агентства; When отметка; Then FORBIDDEN (403).

Смежные операции (тонкая дельта): отклонение

rejectReferralPayoutOrderPOST /api/v1/platform/referral-payout-orders/{orderId}/reject (rewards.payouts.approve, платформенный scope): PendingPayment → Rejected с обязательной причиной RejectReason (trim, 1..1000; пустая → REWARDS_PAYOUT_REJECT_REASON_REQUIRED 400, INV-8); все привязанные рефералки освобождаются (PayoutOrderItemId → null) и остаются в Approved — доступны следующему поручению; публикуется PayoutOrderStatusChangedKafkaEvent (PendingPayment → Rejected, RejectReason); статусы рефералок не меняются — событий по ним и движений кошелька нет. Поведение зеркально агентскому аналогу — ../agency/reject-payout-order.md, с дельтой: tenant — компания ПИН, источники строк — реферальные начисления, уведомление — только оператору ПИН.

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

  • Payload событий — additive-only; breaking → новая версия топика (dual-publish + sunset). Потребители дедуплицируют повторную доставку по id агрегата + переходу (конвенция README.md «Согласованность и порядок событий»).
  • Logs: OrderId, Number, Kind, FromStatus→ToStatus, число переведённых рефералок (суммы не логируются — Sensitive); metrics: rewards_payout_mark_paid_total{result, kind}, rewards_referral_accruals_paid_total; traces: OTel, correlation через traceparent; dashboard rewards-overview; runbook — runbooks/rewards-payout-lifecycle.md (по конвенции).
  • НФТ (ADR-0056): каскад — позиции + рефералки (десятки-сотни строк) в одной транзакции под блокировкой, публикация N+1 событий; целевая латентность p95 < 700 мс; частота — единичные отметки в месяц.

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

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

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