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.
| Поле | Значение |
|---|
| Сервис/контекст | rewards |
| API area | agency |
| Feature group | payouts |
| Статус | draft |
| Документ | docs/06-services/rewards/api/agency/mark-payout-order-paid.md |
Актор и доступ
| Проверка | Значение |
|---|
| Actor type | User (руководитель / бухгалтер агентства) |
| Auth policy / Permissions | rewards.payouts.approve (op25 реестра README.md) |
| Scope (RBAC) | организация: компания Type ∈ {Agency, Broker, Pin} — tenant актора |
| Record-level | не применяется (доступ на уровне tenant: order.TenantId == TenantId актора; чужой tenant отфильтрован интерсептором → 404) |
| Tenant isolation | In-tenant: TenantId из claims, загрузка поручения ограничена tenant-ом (fail-closed); строки распределения и начисления каскада принадлежат тому же TenantId (INV-9) |
| Audit | update: PaidAt, PaidByUserId = ActorUserId, UpdatedByUserId, Version + 1; каскад — PaidAt позиций/строк распределения и переход начислений MoneyReceived → Paid; финансовые переходы фиксируются событиями статусов |
HTTP-контракт
| Поле | Значение |
|---|
| Method | POST |
| Route | /api/v1/agency/payout-orders/{orderId}/mark-paid |
| Success status | 200 |
| Idempotency header | не применяется (повтор из Paid — терминальный статус → 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 (PaidByUserId) |
OrderId | route {orderId} | Guid | Да | Required, непустой | — |
ExpectedVersion | body | long | Да | >= 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).
Алгоритм
- Контекст и доступ. Операцию выполняет руководитель/бухгалтер агентства (тип компании
Agency/Broker/Pin); scope — организация (TenantId из claims); требуется право
rewards.payouts.approve.
- Проверка входа и предусловий.
- формат:
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) вместе с позициями.
- Что читаем для каскада. По позициям поручения определяются затронутые начисления:
Agent-строки
распределения, привязанные к позициям, дают набор начислений; эти начисления читаются под блокировкой
вместе со своими строками распределения — по каждому проверяется полнота выплаты Agent-строк
(INV-7 PaidOnlyViaPayout). По INV-9 у начисления ровно одна Agent-строка (проверка обобщена).
- Переход и каскад (INV-7). Поручение переходит
PendingPayment → Paid: проставляются PaidAt,
внутренний PaidByUserId = актор, PaidAt каждой позиции, растёт версия. Затем по каждому затронутому
начислению проставляется PaidAt его Agent-строк, попавших в поручение; если все Agent-строки
начисления оплачены — начисление переходит MoneyReceived → Paid (PaidAt, версия). Если при полной
оплате строк начисление не в статусе MoneyReceived — это дефект целостности →
REWARDS_ACCRUAL_PAID_ONLY_VIA_PAYOUT (в MoneyReceived начисления попадают только регистрацией акта
поступления средств, ADR-0067, и из него не отменяются).
- Фиксация фактов. Переход поручения, каскад по начислениям и публикация событий выполняются в одном
согласованном изменении (граница каскада 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 и сверкой версии.
- Результат. Проекция поручения (
Status = Paid, PaidAt, Items[].PaidAt, новая версия) →
HTTP 200.
Диаграмма
Побочные эффекты
| Тип | Детали |
|---|
| Integration events | PayoutOrderStatusChangedKafkaEvent / pin.rewards.payout-order-status-changed.v1 / key = OrderId (Kind, Items[] при Paid); CommissionAccrualStatusChangedKafkaEvent / pin.rewards.commission-accrual-status-changed.v1 / key = AccrualId (по каждому переведённому в Paid начислению) |
| External calls | none (исполнение платежей вне платформы — решение №9 ТЗ) |
| Cache / projections / search | none (сводки ЛК — SQL-агрегации по PostgreSQL, ADR-0056) |
| Notifications | notifications по событиям: агентам «выплата произведена», руководителю агентства — сводное по поручению |
| Кошелёк | 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_FOUND | 404 | Поручение не существует или принадлежит чужому tenant | «Платёжное поручение не найдено» | no retry |
REWARDS_PAYOUT_INVALID_STATUS_TRANSITION | 409 | Status != PendingPayment (INV-7): отметка из Draft/Paid/Rejected/Cancelled | «Поручение нельзя отметить выплаченным в текущем статусе» | no retry |
REWARDS_ACCRUAL_PAID_ONLY_VIA_PAYOUT | 500 | Каскад: затронутое начисление не в 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 бухгалтерии).
Обратные ссылки
Автоссылки: где используется этот документ.
Связанные документы