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

POST /api/v1/agency/payout-orders/{orderId}/mark-paid — Ручная отметка о выплате

Перевод платёжного поручения PendingPayment → Paid вручную ролью руководителя/бухгалтера агентства после фактической выплаты вознаграждений агентам вне платформы. Исполнение платежей (интеграция с банком/эквайрингом) в rewards не реализовано — решение №9 ТЗ (см. ../README.md, «Принятые Решения» №5): проектируется полная структура поручений и статусная модель, а фактический платёж проводится вне системы, после чего бухгалтер фиксирует факт выплаты этой операцией.

Назначение

Отметка выплаты — терминальный финансовый переход поручения и точка каскадного проставления Paid затронутым начислениям (MoneyReceived → Paid, INV-7 MarkPaidCascade, ../domain/payout-order.md). После отметки поручение попадает в «историю выплат» ЛК агентства со статусом «выплачено» (матрица «Агенство» 5.5), агенты и агентство получают уведомления notifications, а кошельки получателей дебетуются по позициям события (wallet, ADR-0065). Операция покрывает поручения агентства (Kind = AgencyCommission); реферальные поручения (Kind = ReferralReward, tenant — ПИН) отмечаются одноимённой операцией platform-области (api/platform/mark-referral-payout-order-paid.md — по конвенции). Feature group — payouts.

Metadata

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

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

ПроверкаЗначение
Actor typeUser (руководитель / бухгалтер агентства)
Auth policy / Permissionsrewards.payouts.approve (op25 реестра README.md)
Scope (RBAC)организация: компания Type ∈ {Agency, Broker, Pin} — tenant актора
Record-levelне применяется (доступ на уровне tenant: order.TenantId == TenantId актора; чужой tenant отфильтрован интерсептором → 404)
Tenant isolationIn-tenant: TenantId из claims, загрузка поручения ограничена tenant-ом (fail-closed); строки распределения и начисления каскада принадлежат тому же TenantId (INV-9)
Auditupdate: PaidAt, PaidByUserId = ActorUserId, UpdatedByUserId, Version + 1; каскад — PaidAt позиций/строк распределения и переход начислений MoneyReceived → Paid; финансовые переходы фиксируются событиями статусов

HTTP-контракт

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

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

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

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

Полная проекция PayoutOrderEntity — дом модели: README.md («Общие контрактные модели»); дом полей: ../domain/payout-order.md. На успехе Status = Paid, PaidAt заполнено, Items[].PaidAt заполнено; TotalAmount/Items[].Amount — Sensitive (не логируются). PaidByUserId в проекцию не выводится (внутренний audit-stamp; наружу — только PaidAt).

Алгоритм

  1. Контекст и доступ. Операцию выполняет руководитель/бухгалтер агентства (тип компании Agency/Broker/Pin); scope — организация (TenantId из claims); требуется право rewards.payouts.approve.
  2. Проверка входа и предусловий.
    • формат: OrderId непустой, ExpectedVersion >= 1; иначе ошибка валидации;
    • существование: поручение с OrderId в tenant-е агентства существует; если нет (или чужой tenant) → REWARDS_PAYOUT_ORDER_NOT_FOUND;
    • допустимость состояния (INV-7 guard): статус — PendingPayment; из Draft/Paid/Rejected/Cancelled отметка невозможна → REWARDS_PAYOUT_INVALID_STATUS_TRANSITION;
    • конкуренция: версия совпадает с ExpectedVersion; иначе REWARDS_CONCURRENCY_CONFLICT. Поручение читается под блокировкой (защита от параллельного Reject) вместе с позициями.
  3. Что читаем для каскада. По позициям поручения определяются затронутые начисления: Agent-строки распределения, привязанные к позициям, дают набор начислений; эти начисления читаются под блокировкой вместе со своими строками распределения — по каждому проверяется полнота выплаты Agent-строк (INV-7 PaidOnlyViaPayout). По INV-9 у начисления ровно одна Agent-строка (проверка обобщена).
  4. Переход и каскад (INV-7). Поручение переходит PendingPayment → Paid: проставляются PaidAt, внутренний PaidByUserId = актор, PaidAt каждой позиции, растёт версия. Затем по каждому затронутому начислению проставляется PaidAt его Agent-строк, попавших в поручение; если все Agent-строки начисления оплачены — начисление переходит MoneyReceived → Paid (PaidAt, версия). Если при полной оплате строк начисление не в статусе MoneyReceived — это дефект целостности → REWARDS_ACCRUAL_PAID_ONLY_VIA_PAYOUTMoneyReceived начисления попадают только регистрацией акта поступления средств, ADR-0067, и из него не отменяются).
  5. Фиксация фактов. Переход поручения, каскад по начислениям и публикация событий выполняются в одном согласованном изменении (граница каскада INV-7). Публикуются:
    • PayoutOrderStatusChangedKafkaEvent (топик pin.rewards.payout-order-status-changed.v1, ключ — OrderId, Kind = AgencyCommission, FromStatus = PendingPayment, ToStatus = Paid, PaidAt, Items[] — позиции выплаты (RecipientUserId, Amount c CurrencyId + CurrencySnapshot, PaidAt); Items[] заполняется только при ToStatus = Paid — по нему wallet дебетует счета получателей) — контракт ../events/payout-order-status-changed.md;
    • по каждому переведённому в Paid начислению — CommissionAccrualStatusChangedKafkaEvent (топик pin.rewards.commission-accrual-status-changed.v1, ключ — AccrualId, FromStatus = MoneyReceived, ToStatus = Paid, PaidAt) — контракт ../events/commission-accrual-status-changed.md. Потребители — notifications (агентам «выплата произведена», руководителю — сводно) и wallet (дебет счетов AgentCommission получателей по Items[], ADR-0065). Гонка с Reject разрешается блокировкой шага 2 и сверкой версии.
  6. Результат. Проекция поручения (Status = Paid, PaidAt, Items[].PaidAt, новая версия) → HTTP 200.

Диаграмма

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

ТипДетали
Integration eventsPayoutOrderStatusChangedKafkaEvent / pin.rewards.payout-order-status-changed.v1 / key = OrderId (Kind, Items[] при Paid); CommissionAccrualStatusChangedKafkaEvent / pin.rewards.commission-accrual-status-changed.v1 / key = AccrualId (по каждому переведённому в Paid начислению)
External callsnone (исполнение платежей вне платформы — решение №9 ТЗ)
Cache / projections / searchnone (сводки ЛК — SQL-агрегации по PostgreSQL, ADR-0056)
Notificationsnotifications по событиям: агентам «выплата произведена», руководителю агентства — сводное по поручению
Кошелёкwallet по payout-order-status-changed (ToStatus = Paid) списывает суммы Items[] со счетов AgentCommission получателей (дебет по факту выплаты, ADR-0065); кредит счетов происходил раньше — по событию получения денег (../domain/commission-accrual.md, commission-money-received). rewards балансов не хранит

Ошибки и 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 != PendingPayment (INV-7): отметка из Draft/Paid/Rejected/Cancelled«Поручение нельзя отметить выплаченным в текущем статусе»no retry
REWARDS_ACCRUAL_PAID_ONLY_VIA_PAYOUT500Каскад: затронутое начисление не в MoneyReceived при полной оплате строк (дефект целостности INV-7)«Внутренняя ошибка перехода начисления»retry после fix

Acceptance criteria

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

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

  • Payload событий — additive-only; breaking → ...-status-changed.v2 (dual-publish + sunset). Новые optional-поля PayoutOrderModel — без breaking change; удаление/переименование — breaking.
  • Logs: OrderId, Number, TenantId, FromStatus→ToStatus, число затронутых начислений (суммы не логируются — Sensitive); metrics: rewards_payout_mark_paid_total{result}, rewards_payout_accruals_paid_total (каскадно переведённые начисления); traces — OTel, correlation через traceparent; dashboard rewards-overview; runbook — runbooks/rewards-accrual-replay.md (по конвенции).
  • НФТ (ADR-0056): каскад затрагивает позиции поручения и по одному начислению на позицию (десятки-сотни строк) в одной транзакции под блокировкой; целевая латентность p95 < 700 мс (учитывая публикацию N событий статусов); частота — единичные отметки на агентство в месяц (back-office бухгалтерии).

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

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

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