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

Entity / Aggregate: ReferralAccrualEntity

Дом полей агрегата «Реферальное начисление» контекста rewards. Проекции (реестр реферальных начислений platform-API, «мои реферальные начисления» партнёра, payload событий, позиции реферальных поручений) ссылаются сюда и описывают только дельту.

Назначение

ReferralAccrualEntity — начисление реферального вознаграждения партнёру по завершённой сделке приведённого им инвестора: фиксирует основание (активная реферальная связь — ../../referral/domain/referral-bond.md), применённую ставку (referral-rate-rule.md) и сумму Amount = FinalPrice × Percent / 100. Сумма корректируема first-class путём до выплаты (обязательная причина + append-only аудит-строка ReferralAccrualAdjustmentEntity, INV-RA-6, ADR-0067 п.4) — не отменой начисления и не правкой ставки на будущее.

Это отдельная сущность, а не kind-дискриминатор на комиссионном начислении (ADR-0066): другой получатель (партнёр-пользователь, не агент/агентство), нет распределения по политике агентства, собственный жизненный цикл. Создаётся consumer-ом события pin.crm.deal-completed.v1 (../consumers/crm-deal-completed.md) в статусе Pending; переходит в Approved автоматически при переходе родительского комиссионного начисления в MoneyReceived — регистрация акта поступления средств (platform-receipt.md, гейт ADR-0067); выплачивается через реферальное платёжное поручение (PayoutOrderKind = ReferralReward, tenant — ПИН, payout-order.md).

Классификация

ПолеЗначение
Контекстrewards
KindAggregateRoot (+ дочерняя Entity ReferralAccrualAdjustmentEntity внутри границы агрегата)
Source of truthrewards (суммы/статусы); связь — реплика referral, сделка — реплика-ссылка на crm
Таблицаrewards.referral_accruals
Tenant isolationвне tenant-модели — получатель PartnerUserId может не иметь компании; процесс выплаты ведёт ПИН (платформенный scope, ADR-0052). Партнёр — record-level по PartnerUserId

Поля

ПолеТипОбязательностьОграничения (PK/FK/index/constraint)ИсточникОписание
IdGuidДаPKappУникальный идентификатор начисления.
DealIdGuidДаunique ux_referral_accruals_deal_id — одно реферальное начисление на сделку, бизнес-идемпотентность consumer-а (INV-RA-1)payload deal-completedСсылка на сделку crm. Логическая, без FK.
BondIdGuidДаindex ix_referral_accruals_bond_id; логическая ссылка на реплику связей rewards.referral_bond_replicas (consumer ../consumers/referral-bond.md — по конвенции)реплика связейРеферальная связь — основание начисления.
PartnerUserIdGuidДаindex ix_referral_accruals_partner (partner_user_id, status) — record-level «свои начисления»реплика связей (ReferrerUserId)Партнёр — получатель вознаграждения.
InvestorUserIdGuidДаpayloadПриведённый инвестор — покупатель по сделке (= ReferredUserId связи); трассировка.
DeveloperCompanyIdGuidДаindex ix_referral_accruals_developerpayloadЗастройщик сделки — измерение резолюции ставки.
CommissionAccrualIdGuid?НетFK fk_referral_accruals_commission_accrual → rewards.commission_accruals.id (restrict), indexconsumerРодительское комиссионное начисление той же сделки (commission-accrual.md). null — родитель не создан (нет правила застройщика); заполняется при появлении родителя (ручное начисление по той же DealId) — без родителя гейт денег непроходим, начисление остаётся Pending.
AppliedRateSnapshotAppliedRateSnapshotDocument (jsonb)Даjsonb, typed, ContractVersionрезолюция ставкиСнапшот применённой ставки на момент CompletedAt — ставка может быть потом изменена/архивирована.
BaseAmountMoney (owned)Дасм. VOpayload (FinalPrice)База расчёта — конечная цена сделки.
AmountMoney (owned)Дасм. VO; check amount_value > 0вычислениеBaseAmount.Value × Percent / 100, округление banker's до 2 знаков (INV-RA-2). После first-class корректировки (INV-RA-6) — скорректированное значение (может расходиться с формулой); исходное — в OriginalAmount.
OriginalAmountMoney? (owned)Нетсм. VOappИсходная автосчитанная сумма до корректировок; фиксируется один раз — при первой корректировке (INV-RA-6). null — сумма не корректировалась.
StatusReferralAccrualStatusДахранится как text, varchar(32), index ix_referral_accruals_status (status)appСтатус начисления (stateDiagram ниже).
DealCompletedAtDateTimeOffsetДаindex ix_referral_accruals_deal_completed_atpayloadМомент завершения сделки; момент asOf резолюции ставки.
ApprovedAtDateTimeOffset?Нетзаполняется при ApproveappМомент авто-апрува (= момент регистрации акта родителя).
PaidAtDateTimeOffset?Нетзаполняется при выплате порученияappМомент выплаты.
CancelledAtDateTimeOffset?НетappМомент отмены.
CancelReasonReferralCancelReason?Нет (обязателен при Cancelled)хранится как text, varchar(32)app/consumerПричина отмены (enum ниже).
PayoutOrderItemIdGuid?НетFK fk_referral_accruals_payout_item → rewards.payout_order_items.id (set null), index where not nullpayoutПозиция реферального поручения; null — начисление свободно.
CreatedAtDateTimeOffsetДаindexappМомент создания (аудит).
UpdatedAtDateTimeOffsetДаappМомент изменения.
VersionlongДа>= 1appДоменная версия (инкремент при мутации, попадает в события).

Enum ReferralAccrualStatus

ЗначениеОписание
PendingНачислено по завершённой сделке; ожидает получения денег от застройщика по родительскому комиссионному начислению.
ApprovedРодительское начисление перешло в MoneyReceived (акт зарегистрирован) — вознаграждение доступно для включения в реферальное поручение; кошелёк партнёра кредитуется (ADR-0065).
PaidВыплачено по реферальному поручению. Терминальный.
CancelledОтменено (причина — CancelReason). Терминальный.

Enum ReferralCancelReason

ЗначениеОписание
DealCancelledСделка отменена/расторгнута в CRM (consumer pin.crm.deal-cancelled.v1../consumers/crm-deal-cancelled.md).
BondRevokedСвязь отозвана в referral (фрод, ошибка привязки) — ручная отмена оператором ПИН по итогам разбора; автоматической отмены по bond-closed нет (созданные начисления остаются, ../../referral/events/bond-closed.md).
ManualCorrectionРучная отмена ошибочного начисления целиком оператором ПИН (platform-API, permission rewards.accruals.manage). Не способ поправить сумму: правка суммы — first-class корректировкой (INV-RA-6, ReferralAccrualAdjustmentEntity), не отменой.

Создание (ветка consumer-а завершения сделки)

  1. По InvestorUserId сделки найти активную связь в реплике связей (rewards.referral_bond_replicas, consumer ../consumers/referral-bond.md — по конвенции). Активной связи нет → реферальное начисление не создаётся (штатный случай — сделка без реферала).
  2. Guard самореферала (INV-RA-4): ReferrerUserId связи совпадает с AgentUserId сделки → начисление не создаётся, skip + метрика rewards_referral_self_skip_total (самому себе партнёрка не платится; обход через вторую учётку — осознанное ограничение v1, инструмент разбора — admin-коррекции связей в referral).
  3. Резолвить ставку на asOf = CompletedAt (referral-rate-rule.md). Ставки нет → начисление не создаётся, warn-метрика rewards_referral_accrual_no_rate_total.
  4. Посчитать Amount = FinalPrice × Percent / 100 (banker's, 2 знака), снять AppliedRateSnapshot, привязать родительское комиссионное начисление той же сделки (CommissionAccrualId), создать начисление в Pending. Повтор события упирается в unique DealId → идемпотентный skip (INV-RA-1).

Дочерняя сущность ReferralAccrualAdjustmentEntity (rewards.referral_accrual_adjustments)

Аудит-строка корректировки суммы реферального начисления (ADR-0067 п.4): кто, когда, что и почему изменил. Append-only — строки не редактируются и не удаляются; каждая корректировка суммы обязана оставить ровно одну строку. Kind: Entity внутри границы агрегата ReferralAccrualEntity — создаётся только операцией корректировки корня (../api/platform/adjust-referral-accrual.md, INV-RA-6).

ПолеТипОбязательностьОграниченияИсточникОписание
IdGuidДаPKappИдентификатор строки корректировки.
AccrualIdGuidДаFK fk_referral_accrual_adjustments_accrual → referral_accruals.id (cascade), index ix_referral_accrual_adjustments_accrualappРодительское начисление.
OldAmountMoney (owned)Дасм. VOappСумма Amount до корректировки.
NewAmountMoney (owned)Даcheck new_amount_value > 0appСумма Amount после корректировки.
ReasonstringДаvarchar(1000), min 1обязательная причинаappЧеловекочитаемая причина корректировки (аудит, отчётность).
ActorUserIdGuidДаappАктор — Менеджер ПИН, выполнивший корректировку.
CreatedAtDateTimeOffsetДаindex ix_referral_accrual_adjustments_created_atappМомент корректировки.

Value objects

Money (единый VO контекста rewards, дом — commission-accrual.md)

ПолеТипОбязательностьОграниченияКолонка (пример для Amount)
ValuedecimalДаnumeric(18,2), >= 0amount_value
CurrencyIdshortДассылка на валюту справочника (../../handbook/domain/snapshots.md); RUB-only (ADR-0048)amount_currency_id

BaseAmount и Amount — в одной валюте (CurrencyId), совпадающей с валютой FinalPrice события (INV-RA-2); операции над Money c разными CurrencyId запрещены (REWARDS_CURRENCY_MISMATCH).

Модели JSONB (versioned)

AppliedRateSnapshotDocument (jsonb, колонка applied_rate_snapshot, ContractVersion = 1)

ПолеТипОбязательностьОграниченияSince version
ContractVersionintДа= 1v1
RateRuleIdGuidДаставка на момент применения (referral-rate-rule.md)v1
ScopeReferralRateScope (enum-string)ДаPartnerDeveloper / Partner / Developer / Globalv1
PercentdecimalДа> 0, <= 100, scale 2v1
EffectiveFromDateTimeOffsetДапериод ставки на момент примененияv1
EffectiveToDateTimeOffset?Нетv1
RuleVersionlongДаVersion ставки на момент примененияv1

Правила версии: additive-поля — той же версией (nullable); смена семантики — ContractVersion + 1.

Связи

СвязьТипКлючDelete behavior
ReferralAccrualAdjustmentEntityowned (внутри агрегата)AccrualIdcascade
CommissionAccrualEntityreference (nullable)CommissionAccrualIdrestrict
PayoutOrderItemEntityreference (nullable)PayoutOrderItemIdset null (отмена поручения освобождает начисление)
Реплика связей referralreplica-referenceBondId
Сделка crmexternal reference (без FK)DealId— (данные — из payload события)
Пользователи (identity)external referencePartnerUserId / InvestorUserId

Инварианты и переходы состояний

ИнвариантУсловиеГде проверяетсяОшибка
INV-RA-1 OneAccrualPerDealНа DealId — не более одного реферального начисления (идемпотентность consumer-а)unique index + проверка существующего до созданиядубль события — идемпотентный skip
INV-RA-2 AmountFormulaAmount = BaseAmount × Percent / 100 (banker's, 2 знака); BaseAmount = FinalPrice сделки; валюта базы и суммы едина (CurrencyId) — на момент создания начисления. Later Amount может быть скорректирован first-class путём (INV-RA-6) и тогда с формулой расходится (исходное — в OriginalAmount); валюта корректировки обязана совпадать (CurrencyId)создание начисления; корректировка суммыREWARDS_REFERRAL_ACCRUAL_AMOUNT_MISMATCH / REWARDS_CURRENCY_MISMATCH
INV-RA-3 ApprovedOnlyViaMoneyReceivedPending → Approved выполняется только автоматически при переходе родительского начисления (CommissionAccrualId) в MoneyReceived — атомарно с регистрацией акта (platform-receipt.md, INV-RC-4); прямого API-перевода в Approved нет; без родителя начисление остаётся Pendingрегистрация акта (каскад) + guard переходовREWARDS_REFERRAL_ACCRUAL_INVALID_STATUS_TRANSITION
INV-RA-4 NoSelfReferralReferrerUserId связи ≠ AgentUserId сделки — иначе начисление не создаётся (skip + метрика rewards_referral_self_skip_total)создание начисления (consumer, шаг 2)нет ошибки — задокументированный skip
INV-RA-5 TerminalDisciplinePaid — только каскадом реферального поручения (PayoutOrderKind = ReferralReward) при выплате позиции; Cancel — из Pending, из Approved только пока начисление свободно (PayoutOrderItemId is null); из Paid переходов нет — ошибка компенсируется вручную (ManualCorrection-регламент)каскад выплаты поручения + guard переходовREWARDS_REFERRAL_ACCRUAL_PAID_ONLY_VIA_PAYOUT / REWARDS_REFERRAL_ACCRUAL_CANCEL_FORBIDDEN
INV-RA-6 AmountAdjustableСумма Amount корректируется first-class путём (обязательная Reason + актор + append-only строка ReferralAccrualAdjustmentEntity), статус не меняется; допустимо только пока начисление свободно и не выплаченоPending или Approved при PayoutOrderItemId is null (из Paid/Cancelled и из включённого в поручение — запрет); при первой корректировке исходная сумма фиксируется в OriginalAmount; из Approved (кошелёк уже кредитован) дельта компенсируется проводкой wallet Correction; NewAmount.Value > 0, валюта совпадаеткорректировка суммы (../api/platform/adjust-referral-accrual.md)REWARDS_REFERRAL_ACCRUAL_ADJUSTMENT_FORBIDDEN / REWARDS_REFERRAL_ACCRUAL_ADJUSTMENT_REASON_REQUIRED / REWARDS_CURRENCY_MISMATCH

Коды ошибок

КодHTTPУсловие
REWARDS_REFERRAL_ACCRUAL_NOT_FOUND404Начисление не найдено (операции platform/partner-API).
REWARDS_REFERRAL_ACCRUAL_AMOUNT_MISMATCH— (consumer)Сумма не сходится с формулой INV-RA-2 (защита расчёта).
REWARDS_REFERRAL_ACCRUAL_INVALID_STATUS_TRANSITION409Недопустимый переход статуса (в т.ч. попытка прямого Approve).
REWARDS_REFERRAL_ACCRUAL_CANCEL_FORBIDDEN409Отмена из Approved при занятом поручением начислении или из терминального статуса (INV-RA-5).
REWARDS_REFERRAL_ACCRUAL_PAID_ONLY_VIA_PAYOUT409Попытка выставить Paid вне каскада реферального поручения (INV-RA-5).
REWARDS_REFERRAL_ACCRUAL_ADJUSTMENT_FORBIDDEN409Корректировка суммы вне Pending/Approved или при занятом поручением начислении (INV-RA-6).
REWARDS_REFERRAL_ACCRUAL_ADJUSTMENT_REASON_REQUIRED400Пустая причина корректировки суммы (INV-RA-6).
REWARDS_CURRENCY_MISMATCH400Валюты Money-полей не совпадают (INV-RA-2).

Доменные и интеграционные события

СобытиеТипКогдаSnapshot/поля
ReferralAccruedEventдоменноеУспешный CreateId, DealId, PartnerUserId, Amount
ReferralAccrualCreatedKafkaEventинтеграционное (ContractVersion = 1, topic pin.rewards.referral-accruals.v1, key = Id)После CreateId, DealId, BondId, PartnerUserId, DeveloperCompanyId, Amount (Value + CurrencyId + CurrencySnapshot../../handbook/domain/snapshots.md), Status, DealCompletedAt, Version. Контракт — ../events/referral-accrual-created.md. Потребители: notifications (уведомление партнёру)
ReferralAccrualStatusChangedKafkaEventинтеграционное (ContractVersion = 1, тот же topic pin.rewards.referral-accruals.v1, key = Id — строгий порядок per-начисление)Каждый переход StatusId, DealId, PartnerUserId, FromStatus, ToStatus, Amount (Value + CurrencyId + CurrencySnapshot), CancelReason?, ApprovedAt?, PaidAt?, Version. Контракт — ../events/referral-accrual-status-changed.md. Потребители: notifications; wallet (Approved → кредит счёта ReferralCommission партнёра; Cancelled после Approved → компенсирующая Correction, ADR-0065)
ReferralAccrualAmountAdjustedKafkaEventинтеграционное (ContractVersion = 1, тот же topic pin.rewards.referral-accruals.v1, key = Id) — контракт по конвенцииКорректировка суммы из Approved (из Pending не публикуется — кошелёк ещё не кредитован), INV-RA-6Id, DealId, PartnerUserId, OldAmount, NewAmount (Value + CurrencyId + CurrencySnapshot), Version. Потребители: wallet (Correction на дельту NewAmount − OldAmount, ADR-0065); notifications (партнёру — сумма изменена)

Интеграционные события публикуются атомарно со сменой состояния агрегата.

Конкурентность, идемпотентность и восстановление

  • Идемпотентность создания. Повтор deal-completed/гонка инстансов consumer-а упирается в unique ux_referral_accruals_deal_id — идемпотентный skip (INV-RA-1).
  • Гонка авто-апрува и отмены. Approve (каскад регистрации акта) и Cancel стартуют из Pending; оптимистическая блокировка (Version) пропускает один переход — проигравший перечитывает статус, повтор отклоняется guard-ом. Отменённое начисление при регистрации акта не апрувится (каскад берёт только Pending-рефералки родителя); зарегистрированный акт при последующей отмене сделки рефералку уже не спасает — отмена из Approved идёт с компенсацией кошелька (Correction).
  • Гонка отмены и включения в поручение. Cancel из Approved допустим только пока PayoutOrderItemId is null; захват начисления реферальным поручением идёт под блокировкой — победитель первым проставляет PayoutOrderItemId, проигравший получает REWARDS_REFERRAL_ACCRUAL_CANCEL_FORBIDDEN.
  • Гонка корректировки суммы и включения в поручение. Adjust (INV-RA-6) допустим только пока PayoutOrderItemId is null; захват начисления реферальным поручением идёт под блокировкой — если поручение успело раньше, Adjust получает REWARDS_REFERRAL_ACCRUAL_ADJUSTMENT_FORBIDDEN; сверка ExpectedVersion отсекает применение к устаревшему снапшоту (REWARDS_CONCURRENCY_CONFLICT).
  • Bond-closed не отменяет. Закрытие связи (bond-closed) прекращает создание новых начислений (реплика связей помечает связь неактивной); созданные начисления остаются — отзыв денег по фроду выполняется явной ручной отменой (BondRevoked).
  • Компенсация терминального Paid. Из Paid переходов нет; ошибочно выплаченное вознаграждение компенсируется вне статусной машины (регламент ManualCorrection + корректировка кошелька), финансовая история неизменяема.

Индексы, хранение, безопасность

АспектЗначение
ИндексыPK; ux_referral_accruals_deal_id unique btree; ix_referral_accruals_partner btree (partner_user_id, status); ix_referral_accruals_bond_id btree; ix_referral_accruals_developer btree; ix_referral_accruals_status btree; ix_referral_accruals_deal_completed_at btree; ix_referral_accruals_payout_item btree where not null. Аудит-строки: ix_referral_accrual_adjustments_accrual, ix_referral_accrual_adjustments_created_at
Партиционирование / retentionnone — доли завершённых сделок (десятки тысяч в год, ADR-0056); retention бессрочный (финансовая история, включая аудит-строки корректировок)
Concurrencyоптимистическая блокировка (правка не должна затирать чужую): авто-апрув vs отмена vs корректировка суммы vs включение в поручение
Permissions / PIIПартнёр — только свои начисления (record-level PartnerUserId, permission rewards.referral.view-own); реестр и ручная отмена — Менеджер ПИН (rewards.accruals.manage, платформенный scope); корректировка суммы — Менеджер ПИН (rewards.referral-rates.manage / rewards.accruals.adjust); агентству и застройщику реферальные начисления не отдаются. InvestorUserId партнёру не отдаётся (PII приведённого инвестора); суммы — Sensitive-класс, в логи не пишутся
AuditVersion + события статусов; причина отмены обязательна (CancelReason); корректировки суммы — append-only строки ReferralAccrualAdjustmentEntity (обязательная Reason + актор)

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

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

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