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

POST /api/v1/agency/payout-orders — Создание платёжного поручения

Создание сводного платёжного поручения агентства (Kind = AgencyCommission) на выплату вознаграждений агентам за период (матрица «Агенство» 5.4, domain/payout-order.md). Поручение создаётся в статусе Draft и в этой же команде автособирает свободные Agent-строки распределения начислений в статусе MoneyReceived — деньги от застройщика получены, гейт ADR-0067 пройден (domain/platform-receipt.md) — группируя их по агентам в позиции. Далее состав можно править (update-payout-order-items.md) и утвердить (approve-payout-order.md).

Назначение

Руководитель/бухгалтер агентства формирует поручение «за июнь 2026»: система захватывает все ещё не выплаченные доли агентов по начислениям с полученными деньгами (MoneyReceived) с DealCompletedAt в периоде, группирует по агенту и считает итог для передачи в бухгалтерию. Подтверждённые, но не оплаченные застройщиком начисления (Confirmed) в поручение не попадают — выплаты до получения денег запрещены (ADR-0067). Исполнение платежа отложено (решение №5/№9 ТЗ) — поручение лишь фиксирует состав. Feature group — payouts.

Metadata

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

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

ПроверкаЗначение
Actor typeUser (руководитель/бухгалтер агентства)
Auth policy / Permissionsrewards.payouts.manage (op21 реестра README.md)
Scope (RBAC)организация: компания Type ∈ {Agency, Broker, Pin}
Record-levelне применяется (агрегатов ещё нет; состав ограничен tenant-ом)
Tenant isolationTenantId = CompanyId агентства из claims; строки распределения захватываются только в этом tenant (INV-9)
Auditcreate: CreatedByUserId = ActorUserId, Version = 1; финансовый документ, полный audit-trail через Version + события статусов

HTTP-контракт

ПолеЗначение
MethodPOST
Route/api/v1/agency/payout-orders
Success status201 (+ Location: /api/v1/agency/payout-orders/{id})
Idempotency headerне применяется (повтор создаёт новый черновик; уже захваченные строки во второй вызов не попадут — вернётся REWARDS_PAYOUT_DISTRIBUTION_UNAVAILABLE)
Correlationсквозной trace через заголовок traceparent

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

ПолеИсточникТипОбязательностьВалидация / ОграниченияБезопасность
TenantIdidentity claims (tenant_id)GuidДаRequired; не из bodyTenant isolation
ActorUserIdidentity claims (sub)GuidДаRequired
PeriodFrombodyDateTimeOffsetДаRequired; включается DealCompletedAt >= PeriodFrom
PeriodTobodyDateTimeOffsetДаRequired; PeriodTo > PeriodFrom (check period_to > period_from); исключительно: DealCompletedAt < PeriodTo
Titlebodystring?Нет<= 256; назначение поручения («Выплаты за июнь 2026»)

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

Полная проекция — PayoutOrderModel, дом README.md (проекция domain/payout-order.md). Особенности этого ответа:

ПолеТипОбязательностьИсточникMaskingОписание
IdGuidДаPayoutOrderEntity.IdId созданного поручения.
NumberstringДаNumberPO-{yyyy}-{seq} — присвоен в команде (шаг 3).
Statusstring (enum PayoutOrderStatus)ДаStatusВсегда Draft.
TotalAmountMoneyModelДаTotalAmountSensitiveИтог = Σ позиций (INV-3).
ItemsCountintДаItemsCountЧисло агентов; >= 1 (иначе создание не состоится).
ItemsIReadOnlyList<PayoutOrderItemModel>Даchild-коллекцияSensitive-суммыПозиции по агентам (не пустой).
VersionlongДаVersion1.

Алгоритм

  1. Контекст и доступ. Операцию выполняет руководитель/бухгалтер агентства (тип компании Agency/Broker/Pin); scope — организация (TenantId из claims); требуется право rewards.payouts.manage.
  2. Проверка входа. Формат: PeriodFrom и PeriodTo заданы, PeriodTo > PeriodFrom, Title.Length <= 256; иначе ошибка валидации. TenantId берётся только из claims (не из body); период — граница выборки, не сущность.
  3. Захват состава за период. Захватить свободные доли агентов за период (INV-1): Agent-строки распределения начислений в статусе MoneyReceived (гейт получения денег пройден, ADR-0067), у которых DealCompletedAt в [PeriodFrom, PeriodTo) и PayoutOrderItemId = null (ещё не в поручении), в tenant-е агентства (INV-9). Строки захватываются под блокировкой с пропуском уже занятых параллельным поручением. Если свободных строк нет, но за период есть начисления в Confirmed с невыплаченными Agent-строками → REWARDS_PAYOUT_MONEY_NOT_RECEIVED (422 — деньги от застройщика ещё не получены, акт не зарегистрирован; выплата станет доступна после MoneyReceived). Если нет вообще ничего → REWARDS_PAYOUT_DISTRIBUTION_UNAVAILABLE (собирать нечего). Номер поручения Number присваивается из последовательности per (tenant, year): Number = "PO-{yyyy}-{seq}".
  4. Группировка и расчёт. Создаётся поручение Status = Draft, Version = 1, CreatedByUserId = актор; состав собирается из захваченных строк:
    • валюта (INV-4): все суммы строк в одной валюте; иначе REWARDS_CURRENCY_MISMATCH;
    • группировка по агенту (INV-2 «один агент = одна позиция»): на каждого агента — позиция с суммой Σ его строк и числом строк; каждая строка помечается ссылкой на позицию (PayoutOrderItemId, INV-1);
    • пересчёт (INV-3): Item.Amount = Σ строк, TotalAmount = Σ Items[].Amount, ItemsCount = count; требуется ItemsCount >= 1.
  5. Фиксация факта. Создание сопровождается доменным событием PayoutOrderCreatedEvent внутри rewards. Интеграционное событие статуса PayoutOrderStatusChangedKafkaEvent (pin.rewards.payout-order-status-changed.v1) на создании не публикуется — оно шлётся только на переходах Approve/MarkPaid/Reject (domain/payout-order.md); черновик наружу не анонсируется. Поручение, позиции и захват строк распределения фиксируются в одном согласованном изменении; захват под блокировкой с пропуском занятых исключает двойной захват тех же строк параллельным поручением, сверка версии начислений — гонку с per-deal корректировкой распределения (adjust-accrual-distribution.md, INV-11) и MarkPaid; отмена из MoneyReceived невозможна вовсе (INV-8 начисления); коллизия Number разрешается повтором с новым номером.
  6. Результат. Полная проекция созданного поручения с позициями (Status = Draft, Number, Version = 1) → HTTP 201 + Location.

Диаграмма

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

ТипДетали
Integration eventsnone на создании (Kafka payout-order-status-changed — только на Approve/MarkPaid/Reject); доменное PayoutOrderCreatedEvent — in-process
External callsnone
Cache / projections / searchnone
Notificationsnone (уведомления агентам — по payout-order-status-changed на утверждении/выплате)

Ошибки и failure modes

Общие коды (ValidationError при PeriodTo <= PeriodFrom/Title.Length > 256, FORBIDDEN, UNAUTHORIZED, REWARDS_CONCURRENCY_CONFLICT) — дом README.md «Общие ошибки», здесь не дублируются. REWARDS_CONCURRENCY_CONFLICT покрывает конфликт версии начисления (параллельный Cancel/MarkPaid) и гонку по Number (уникальность номера в tenant-е) — повтор с новым номером. Специфичные для операции:

КодHTTP statusУсловиеСообщениеRetry
REWARDS_PAYOUT_MONEY_NOT_RECEIVED422Свободных строк в MoneyReceived за период нет, но есть Confirmed-начисления с невыплаченными долями — деньги от застройщика ещё не получены (гейт ADR-0067)«Деньги от застройщика ещё не получены — выплата будет доступна после регистрации акта поступления»retry после регистрации акта
REWARDS_PAYOUT_DISTRIBUTION_UNAVAILABLE409Нет свободных Agent-строк MoneyReceived за период (все выплачены/захвачены другим поручением) и нет ожидающих Confirmed — INV-1«За период нет комиссий, готовых к выплате»no retry (сменить период)
REWARDS_CURRENCY_MISMATCH409Валюты захваченных долей различаются (INV-4)«Строки выплаты в разных валютах»no retry

Acceptance criteria

  • AC-1 (автосборка за период). Given есть свободные Agent-доли начислений, прошедших гейт денег (Status = MoneyReceived, PayoutOrderItemId = null) с DealCompletedAt в [PeriodFrom, PeriodTo); When руководитель создаёт поручение за период; Then заводится Draft с присвоенным Number (PO-{yyyy}-{seq}), доли сгруппированы по агенту в позиции (INV-2), захваченные строки помечены PayoutOrderItemId (INV-1), TotalAmount = Σ Items, ItemsCount >= 1, Version = 1; ответ 201 + Location. Постусловие: захваченные доли исключены из свободного пула — повторный вызов за тот же период их не подберёт.
  • AC-2 (нет свободных долей). Given за период нет свободных MoneyReceived Agent-строк (все уже выплачены/захвачены другим поручением) и нет ожидающих Confirmed; When создание; Then REWARDS_PAYOUT_DISTRIBUTION_UNAVAILABLE (409) — собирать нечего, поручение не создаётся; клиент меняет период.
  • AC-2a (деньги ещё не получены). Given за период есть только Confirmed-начисления с невыплаченными Agent-долями (акт поступления средств не зарегистрирован); When создание; Then REWARDS_PAYOUT_MONEY_NOT_RECEIVED (422) — поручение не создаётся; выплата станет доступна после перехода начислений в MoneyReceived (domain/platform-receipt.md).
  • AC-3 (границы периода включ./исключ.). Given доля с DealCompletedAt == PeriodFrom и доля с DealCompletedAt == PeriodTo; When создание; Then первая попадает в поручение (нижняя граница включительна), вторая — нет (верхняя исключительна).
  • AC-4 (валидация периода). Given PeriodTo <= PeriodFrom или Title.Length > 256; When создание; Then ValidationError (400).
  • AC-5 (группировка одного агента). Given у одного агента несколько свободных долей за период; When создание; Then на агента ровно одна позиция с суммой Σ его долей и DistributionsCount = N (INV-2), а не N позиций.
  • AC-6 (конкурентный захват). Given два параллельных создания поручений за пересекающийся период; When оба захватывают строки под блокировкой с пропуском занятых; Then каждая свободная доля попадает ровно в одно поручение (двойного захвата нет); при исчерпании свободных строк второй вызов получает REWARDS_PAYOUT_DISTRIBUTION_UNAVAILABLE.
  • AC-7 (коллизия номера). Given гонка присвоения Number в пределах (tenant, year); When конфликт уникальности номера; Then REWARDS_CONCURRENCY_CONFLICT (409) — повтор с новым номером из последовательности.
  • AC-8 (разные валюты). Given захваченные доли за период в разных валютах (нарушение INV-4); When сборка; Then REWARDS_CURRENCY_MISMATCH (409).
  • AC-9 (доступ). Given актор без rewards.payouts.manage или не роль агентства; When создание; Then FORBIDDEN (403); строки захватываются только в tenant-е актора (INV-9).

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

  • Контракт additive-only (Title — optional). PayoutOrderModel/PayoutOrderItemModel — дом README.md; breaking-изменение проекции — версия route.
  • Logs: PayoutOrderId, Number, TenantId, ItemsCount, DistributionsCount (суммы не логируются — Sensitive); metrics: rewards_payout_order_create_total{result}, rewards_payout_order_items_count (гистограмма); traces: OTel, correlation через traceparent; dashboard rewards-overview; runbook — runbooks/rewards-payout-compose.md (по конвенции).
  • НФТ (ADR-0056): захват идёт по ix_commission_distributions_recipient/ix_commission_accruals_deal_completed_at под блокировкой с пропуском занятых строк; объём периода — сотни-тысячи долей на агентство; целевая латентность p95 < 500 мс; частота — единицы поручений на агентство в месяц (back-office бухгалтерии).

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

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

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