Entity / Aggregate: PayoutOrderEntity
Дом полей агрегата «Платёжное поручение» контекста rewards. Проекции (история выплат, сводки ЛК
агентства, payload событий) ссылаются сюда и описывают только дельту.
Назначение
PayoutOrderEntity — сводное платёжное поручение на выплату вознаграждений
(матрица «Агенство» 5.4: «генерация сводных отчётов для бухгалтерии для выплаты вознаграждений агентам»;
5.5: «история выплат — список всех выплат с датами, суммами, статусами: ожидается, выплачено, отклонено»).
Поручения бывают двух видов (Kind):
AgencyCommission— поручение агентства на выплату долей агентам: собирает свободныеAgent-строки распределения начислений в статусеMoneyReceived— деньги от застройщика получены, гейт ADR-0067 пройден (commission-accrual.md,CommissionDistributionEntity) — за период и группирует их по агентам в позиции (PayoutOrderItemEntity).ReferralReward— поручение платформы (tenant — компания ПИН) на выплату реферальных вознаграждений партнёрам: собирает реферальные начисления в статусеApproved(referral-accrual.md), не включённые в другое поручение, и группирует их по партнёрам в позиции;MarkPaidкаскадно переводит привязанные начисленияApproved → Paid.
Исполнение платежей (интеграция с банком/эквайрингом) — реализация отложена (решение №9 ТЗ, по
аналогии с crm): проектируется полная структура и статусная модель, а переход в Paid выполняется
вручную — руководителем/бухгалтером агентства (AgencyCommission) или оператором ПИН
(ReferralReward) после фактической выплаты вне платформы. История выплат = список поручений с их
статусами и датами.
Классификация
| Поле | Значение |
|---|---|
| Контекст | rewards |
| Kind | AggregateRoot (+ дочерняя Entity PayoutOrderItemEntity внутри границы агрегата) |
| Source of truth | rewards |
| Таблицы | rewards.payout_orders, rewards.payout_order_items |
| Tenant isolation | TenantId = CompanyId агентства (AgencyCommission) / компании ПИН (ReferralReward) — владелец процесса выплаты (ADR-0052) |
Поля (rewards.payout_orders)
| Поле | Тип | Обязательность | Ограничения (PK/FK/index/constraint) | Источник | Описание |
|---|---|---|---|---|---|
Id | Guid | Да | PK | app | Уникальный идентификатор поручения. |
TenantId | Guid | Да | index ix_payout_orders_tenant_status (btree: tenant_id, status) | identity (актор) | Владелец поручения: агентство (AgencyCommission) / компания ПИН (ReferralReward). |
Kind | PayoutOrderKind | Да | хранится как text, varchar(32); index ix_payout_orders_tenant_kind (tenant_id, kind) | app | Вид поручения (enum ниже): определяет источник строк (INV-1), tenant-владельца и каскад MarkPaid (INV-7). Задаётся при создании, неизменяем. |
Number | string | Да | varchar(32), unique ux_payout_orders_tenant_number (tenant_id, number); формат PO-{yyyy}-{seq}, seq — PostgreSQL sequence per tenant-year (генерация в команде создания) | app | Человекочитаемый номер для бухгалтерии («ПП-2026-0012»). |
Title | string? | Нет | varchar(256) | app | Название/назначение («Выплаты за июнь 2026»). |
PeriodFrom | DateTimeOffset | Да | check period_to > period_from | app | Начало периода: включаются строки-источники по сделкам с DealCompletedAt >= PeriodFrom. |
PeriodTo | DateTimeOffset | Да | index ix_payout_orders_period_to | app | Конец периода (исключительно): DealCompletedAt < PeriodTo. |
Status | PayoutOrderStatus | Да | хранится как text, varchar(32) | app | Статусная модель ниже. |
TotalAmount | Money (owned, VO — commission-accrual.md) | Да | total_amount_value numeric(18,2) >= 0, total_amount_currency_id smallint; check = Σ items (INV-3) | вычисление | Итог поручения = сумма всех позиций. |
ItemsCount | int | Да | >= 0; денормализация для списков | вычисление | Число позиций (получателей) в поручении. |
ApprovedAt | DateTimeOffset? | Нет | при Approve | app | Момент утверждения руководителем. |
ApprovedByUserId | Guid? | Нет | — | app | Утвердивший. |
PaidAt | DateTimeOffset? | Нет | при MarkPaid | app | Момент отметки о фактической выплате. |
PaidByUserId | Guid? | Нет | — | app | Кто отметил выплату. |
RejectedAt | DateTimeOffset? | Нет | при Reject | app | Момент отклонения. |
RejectReason | string? | Да при Rejected | varchar(1000) | app | Обязательный комментарий отклонения (матрица 5.5 «отклонено»). |
CancelledAt | DateTimeOffset? | Нет | при Cancel | app | Момент отмены черновика. |
CreatedAt | DateTimeOffset | Да | index ix_payout_orders_created_at | app | Момент создания (аудит). |
CreatedByUserId | Guid? | Нет | — | app | Создавший (руководитель/бухгалтер агентства). |
UpdatedAt | DateTimeOffset | Да | — | app | Момент изменения. |
UpdatedByUserId | Guid? | Нет | — | app | Последний изменивший. |
Version | long | Да | >= 1 | app | Доменная версия (инкремент при мутации). |
Enum PayoutOrderStatus
| Значение | Описание | Маппинг на статусы матрицы 5.5 |
|---|---|---|
Draft | Черновик: состав позиций редактируется (добавление/исключение распределений). | — |
PendingPayment | Утверждено руководителем: состав заморожен, передано в бухгалтерию; ожидает фактической выплаты. | «ожидается» |
Paid | Выплата произведена (отметка вручную — исполнение платежей отложено). Терминальный. | «выплачено» |
Rejected | Отклонено (руководителем/бухгалтерией) с обязательной причиной; строки-источники освобождены. Терминальный. | «отклонено» |
Cancelled | Черновик отменён создателем; строки-источники освобождены. Терминальный. | — |
Enum PayoutOrderKind
| Значение | Описание |
|---|---|
AgencyCommission | Выплата долей агентов. Tenant — агентство; позиции — по агентам; источник строк — свободные Agent-строки распределения начислений в статусе MoneyReceived (commission-accrual.md). Управляет руководитель/бухгалтер агентства. |
ReferralReward | Выплата реферальных вознаграждений. Tenant — компания ПИН; позиции — по партнёрам; источник строк — реферальные начисления в статусе Approved, не включённые в другое поручение (referral-accrual.md); MarkPaid каскадит Approved → Paid. Управляет оператор ПИН (платформенный scope, операции — область api/platform). |
Дочерняя сущность PayoutOrderItemEntity (rewards.payout_order_items)
Позиция поручения — сводная выплата одному получателю: агенту (AgencyCommission) или реферальному
партнёру (ReferralReward). Kind: Entity внутри границы агрегата PayoutOrderEntity; мутации только
через корень.
| Поле | Тип | Обязательность | Ограничения | Источник | Описание |
|---|---|---|---|---|---|
Id | Guid | Да | PK | app | Идентификатор позиции. |
PayoutOrderId | Guid | Да | FK fk_payout_order_items_order → payout_orders.id (cascade), index | app | Родительское поручение. |
TenantId | Guid | Да | index | родитель | Дублируется для изоляции по tenant. |
RecipientUserId | Guid | Да | unique ux_payout_order_items_order_recipient (payout_order_id, recipient_user_id) — один получатель = одна позиция (INV-2) | строки-источники | Получатель позиции: агент (AgencyCommission) / реферальный партнёр (ReferralReward). |
Amount | Money (owned) | Да | check amount_value > 0; = Σ привязанных строк-источников (INV-3) | вычисление | Сумма выплаты получателю по позиции. |
DistributionsCount | int | Да | >= 1 | вычисление | Число строк-источников в позиции: Agent-строк распределения (AgencyCommission, обратная ссылка commission_distributions.payout_order_item_id, commission-accrual.md) / реферальных начислений (ReferralReward, обратная ссылка referral_accruals.payout_order_item_id, referral-accrual.md). |
PaidAt | DateTimeOffset? | Нет | проставляется при MarkPaid поручения | app | Момент выплаты позиции. |
CreatedAt / UpdatedAt | DateTimeOffset | Да | — | app | Аудит позиции. |
Реквизиты получателя (счёт/карта) в rewards не хранятся — прохождение платежа вне платформы;
при включении интеграции платежей реквизиты будут запрашиваться из profile
(../../profile/domain/requisite-template.md — будущий путь по конвенции) в момент исполнения,
не копируясь в поручение.
Value objects
Money — единый VO контекста, дом — commission-accrual.md (колонки
total_amount_value/total_amount_currency_id, amount_value/amount_currency_id; CurrencyId short —
ссылка на валюту справочника, RUB-only по ADR-0048). CurrencyId всех позиций равен CurrencyId
поручения (INV-4).
Модели JSONB (versioned)
JSONB-модели не используются.
Связи
| Связь | Тип | Ключ | Delete behavior |
|---|---|---|---|
PayoutOrderItemEntity | owned (внутри агрегата) | PayoutOrderId | cascade |
CommissionDistributionEntity (AgencyCommission) | reference (обратная, из чужого агрегата CommissionAccrualEntity) | commission_distributions.payout_order_item_id → payout_order_items.id | set null (Reject/Cancel освобождает строки) |
ReferralAccrualEntity (ReferralReward) | reference (обратная, из чужого агрегата) | referral_accruals.payout_order_item_id → payout_order_items.id | set null (Reject/Cancel освобождает начисления) |
CompanyEntity (агентство/компания ПИН, identity) | reference (логическая, без FK) | TenantId | restrict |
Инварианты и переходы состояний
| Инвариант | Условие | Где проверяется | Ошибка |
|---|---|---|---|
| INV-1 OnlyFreeSourceRows | Источник строк — по Kind: AgencyCommission — только Agent-строки распределения начислений в статусе MoneyReceived (гейт получения денег, ADR-0067) с PayoutOrderItemId is null; ReferralReward — только реферальные начисления в статусе Approved с PayoutOrderItemId is null. Включение проставляет PayoutOrderItemId (строка занята ровно одним поручением) | сборка состава: свободные строки захватываются под блокировкой и помечаются в одном согласованном изменении | REWARDS_PAYOUT_DISTRIBUTION_UNAVAILABLE |
| INV-2 OneItemPerRecipient | Один получатель — одна позиция в поручении | unique index + агрегация при сборке | REWARDS_PAYOUT_DUPLICATE_RECIPIENT |
| INV-3 SumConsistency | Item.Amount = Σ Amount привязанных строк-источников; TotalAmount = Σ Item.Amount; ItemsCount = count(Items) | пересчёт в методах агрегата при каждой мутации состава | REWARDS_PAYOUT_SUM_MISMATCH |
| INV-4 SingleCurrency | CurrencyId всех позиций и TotalAmount един | метод сборки состава (VO-guard) | REWARDS_CURRENCY_MISMATCH |
| INV-5 ComposeOnlyInDraft | Изменение состава (добавить/исключить строки) — только в Draft | guard по Status в методах агрегата | REWARDS_PAYOUT_COMPOSITION_LOCKED |
| INV-6 ApproveGuard | Approve допустим только из Draft при ItemsCount >= 1; актор с permission rewards.payouts.approve | метод агрегата + policy | REWARDS_PAYOUT_INVALID_STATUS_TRANSITION |
| INV-7 MarkPaidCascade | MarkPaid (из PendingPayment): проставляет PaidAt поручению, позициям и привязанным строкам-источникам; каскад по Kind: AgencyCommission — каждое затронутое начисление, у которого все Agent-строки выплачены, переводится MoneyReceived → Paid; ReferralReward — каждое привязанное реферальное начисление переводится Approved → Paid — в одном согласованном изменении | переход MarkPaid | REWARDS_PAYOUT_INVALID_STATUS_TRANSITION |
| INV-8 ReleaseOnTerminate | Reject/Cancel: у всех привязанных строк-источников PayoutOrderItemId → null (строки снова доступны следующему поручению); Reject требует RejectReason | переходы Reject/Cancel | REWARDS_PAYOUT_REJECT_REASON_REQUIRED |
| INV-9 TenantOwnership | Все строки-источники принадлежат TenantId поручения; для ReferralReward tenant — компания ПИН (реферальные начисления — платформенные) | изоляция по tenant + проверка при сборке | fail closed |
| INV-10 KindHomogeneity | Все позиции и строки-источники поручения — одного вида, соответствующего Kind; смешение агентских долей и реферальных начислений в одном поручении запрещено | сборка состава | REWARDS_PAYOUT_KIND_MISMATCH |
Доменные и интеграционные события
| Событие | Тип | Когда | Snapshot/поля |
|---|---|---|---|
PayoutOrderCreatedEvent | доменное | Успешный Create | Id, TenantId, PeriodFrom/To |
PayoutOrderApprovedEvent | доменное | Draft → PendingPayment | Id, TotalAmount, ItemsCount |
PayoutOrderStatusChangedKafkaEvent | интеграционное (ContractVersion = 1, topic pin.rewards.payout-order-status-changed.v1, key = Id, AggregateId = Id) | Переходы Approve / MarkPaid / Reject | Id, TenantId, Kind, Number, FromStatus, ToStatus, TotalAmount (Value + CurrencyId + CurrencySnapshot — ../../handbook/domain/snapshots.md), ItemsCount, Items[] (PayoutOrderItemId, RecipientUserId, Amount, PaidAt? — при Paid строки дебета wallet), PaidAt?, RejectReason?, Version. Контракт — ../events/payout-order-status-changed.md. Потребители: notifications (уведомления получателям и владельцу поручения), wallet (дебет счетов при Paid, идемпотентность по PayoutOrderItemId) |
Интеграционные события публикуются атомарно со сменой состояния агрегата.
Дебет кошелька. По событию перехода в Paid контекст wallet списывает суммы позиций (Items[])
со счетов получателей (../../wallet/README.md): доля агента — со счёта
AgentCommission, реферальное вознаграждение — со счёта ReferralCommission (ADR-0065). Сам rewards
балансов не хранит и кошельком не управляет; кредит счетов происходит раньше — по событию получения денег
(commission-accrual.md, commission-money-received).
Конкурентность и восстановление
- Гонка за свободные строки-источники. Два поручения одного tenant-а, собираемых за пересекающийся
период, могут претендовать на одну свободную строку (
Agent-строку распределения или реферальное начисление). Строки захватываются под блокировкой и помечаютсяPayoutOrderItemIdв одном согласованном изменении (INV-1) — победитель занимает строку, проигравший получаетREWARDS_PAYOUT_DISTRIBUTION_UNAVAILABLEи пересобирает состав без уже занятых строк. - Гонка сборки vs отмена/корректировка начисления. Начисление в
MoneyReceivedне отменяется вовсе (commission-accrual.md, INV-8) — конфликт с отменой исключён; корректировка распределения (INV-11 начисления) допустима только пока строкаAgentне включена в поручение — обе операции мутируют агрегат начисления, оптимистическая блокировка пропускает одну. Реферальное начисление, включённое в поручение, не отменяется — освобождение только черезReject/Cancelпоручения (referral-accrual.md). - Атомарность
MarkPaid. Каскад INV-7 (поручение → позиции → строки-источники → начисленияMoneyReceived → Paid/ рефералкиApproved → Paid) выполняется в одном согласованном изменении: частичной выплаты не бывает — либо всё поручение помеченоPaidи все затронутые начисления перешли вPaid, либо ничего. - Уникальность номера.
Numberформируется из per-tenant-year sequence; конкурентные создания не дают коллизий, при гонке уникальный индексux_payout_orders_tenant_numberстрахует. - Освобождение при терминации.
Reject/Cancelвозвращают строкам-источникамPayoutOrderItemId = null(INV-8) — освобождённые строки снова доступны следующему поручению; связанные начисления остаются вMoneyReceived(рефералки — вApproved). - Оптимистическая блокировка переходов. Параллельные
Approve/Cancel/Reject/MarkPaidнад одним поручением разрешаются поVersion: проходит один, проигравший перечитывает актуальный статус, его повтор отклоняется guard-ом статуса (REWARDS_PAYOUT_INVALID_STATUS_TRANSITION).
Индексы, хранение, безопасность
| Аспект | Значение |
|---|---|
| Индексы | PK; ux_payout_orders_tenant_number unique btree (tenant_id, number); ix_payout_orders_tenant_status btree (tenant_id, status); ix_payout_orders_tenant_kind btree (tenant_id, kind); ix_payout_orders_period_to btree; ix_payout_orders_created_at btree; items: PK, ux_payout_order_items_order_recipient unique btree (payout_order_id, recipient_user_id), ix_payout_order_items_recipient btree (tenant_id, recipient_user_id) — история выплат получателя |
| Партиционирование / retention | none — единицы поручений на tenant в месяц; retention бессрочный (финансовая история) |
| Concurrency | оптимистическая блокировка на переходах; захват строк-источников под блокировкой при сборке состава (INV-1) |
| Permissions / PII | Создание/состав — rewards.payouts.manage; утверждение/отметка выплаты/отклонение — rewards.payouts.approve (для AgencyCommission — роль «Руководитель агентства»/бухгалтер, scope = организация; для ReferralReward — оператор ПИН, платформенный scope, операции — область api/platform); агент видит только свои позиции (rewards.payouts.view-own, record-level RecipientUserId), реферальный партнёр — свои начисления и выплаты по rewards.referral.view-own (record-level). Суммы — Sensitive: не логируются, в списках агентства видны только ролям с rewards.payouts.manage. Банковские реквизиты не хранятся (см. примечание к items) |
| Audit | Version + actor-stamping (ApprovedBy/PaidBy) + PayoutOrderStatusChangedKafkaEvent; история выплат = сами поручения (Paid/Rejected) — отдельная сущность истории не нужна |
Обратные ссылки
Автоссылки: где используется этот документ.
- Домен (CQRS) (7): Entity / Aggregate: AgencyCommissionPolicyEntity, Entity / Aggregate: CommissionAccrualEntity, Entity / Aggregate: LedgerTransactionEntity (+ LedgerEntryEntity), Entity / Aggregate: PlatformReceiptEntity, Entity / Aggregate: ReferralAccrualEntity, Entity / Aggregate: RewardRuleEntity, Entity / Aggregate: WalletAccountEntity
- API (17): GET /api/v1/agency/payout-orders — Список платёжных поручений (история выплат), GET /api/v1/agency/wallet/agent-balances — Балансы агентов агентства, GET /api/v1/my/payouts — Мои выплаты (агент), GET /api/v1/my/referral-accruals — Мои реферальные начисления (партнёр), GET /api/v1/platform/referral-payout-orders — Список реферальных платёжных поручений, POST /api/v1/agency/payout-orders — Создание платёжного поручения, POST /api/v1/agency/payout-orders/{orderId}/approve — Утверждение платёжного поручения, POST /api/v1/agency/payout-orders/{orderId}/cancel — Отмена черновика поручения, POST /api/v1/agency/payout-orders/{orderId}/mark-paid — Ручная отметка о выплате, POST /api/v1/agency/payout-orders/{orderId}/reject — Отклонение поручения, POST /api/v1/agency/rewards-exports — Постановка Excel-выгрузки вознаграждений агентства, POST /api/v1/platform/referral-payout-orders — Создание реферального платёжного поручения (+5)
- Use Cases (7): UC-RWD-005. Агентство настраивает распределение и формирует платёжное поручение, UC-RWD-009. Жизненный цикл реферального начисления (связь → начисление → гейт → выплата), UC-RWD-012. Владелец агентства выгружает отчёты по вознаграждениям в Excel, UC-WLT-002. Выплата поручения дебетует баланс получателя, US-реестр: Владелец агентства (US-OWN-AG), US-реестр: Реферальный партнёр (US-PRT), US-реестр: Финансовый блок ПИН (US-FIN)
- События (3): Event Contract: pin.rewards.commission-accrual-status-changed (CommissionAccrualStatusChangedKafkaEvent), Event Contract: pin.rewards.payout-order-status-changed (PayoutOrderStatusChangedKafkaEvent), Event Contract: pin.rewards.referral-accrual-status-changed (ReferralAccrualStatusChangedKafkaEvent)
- Консюмеры (1): Consumer: Rewards Payout Order Status Changed
- Саги (1): Saga «Commission Settlement» (финансовый контур вознаграждений)
- Отчёты (2): Отчет/Export: Вознаграждения агентства по сотрудникам и сделкам (agency-rewards-by-employee-per-deal), Отчет/Export: Реферальные вознаграждения по партнёрам (referral-rewards-by-partner)
- adr (3): ADR-0065 — контекст wallet (внутренние балансы и ledger), ADR-0066 — удержание ПИН и реферальные ставки, ADR-0067 — гейт получения денег (акт поступления средств)
- registries (2): Реестр инвариантов PIN, Реестр сущностей PIN
- index.md (2): Rewards Service, Wallet Service
Связанные документы
- Контракт производимого события: ../events/payout-order-status-changed.md.
- commission-accrual.md — источник
Agent-строк (MoneyReceived), каскадPaid(INV-7). - referral-accrual.md — источник строк вида
ReferralReward(Approved), каскадPaid. - agency-commission-policy.md — как формируются суммы долей (деление пула агентства).
- Кошелёк (дебет по
Paid, кредит поMoneyReceived): ../../wallet/README.md. - Реквизиты получателя (будущая интеграция платежей):
../../profile/domain/requisite-template.md(по конвенции). - Решение №9 ТЗ (technical-assignment.md) — «структуры проектируются; интеграция платежей — отложена».
- ADR-0052 (tenancy), ADR-0056 (simplicity-first — ручная отметка вместо платёжного шлюза), ADR-0065 (кошелёк), ADR-0067 (гейт получения денег — источники строк).
- Шаблон: entity-template.md; правила: cross-cutting-document-requirements.md.