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

Entity / Aggregate: CommissionAccrualEntity

Дом полей агрегата «Начисление комиссии» контекста rewards. Все проекции (сводки ЛК агента и агентства, payload событий, платёжные поручения) ссылаются сюда и описывают только дельту.

Назначение

CommissionAccrualEntity — начисление комиссионного вознаграждения по завершённой сделке CRM: фиксирует, сколько агент (и его агентство) заработали на сделке, по какому правилу застройщика посчитана сумма и в каком статусе подтверждения/выплаты она находится (матрица «Агенство» 5.1, 5.3, 5.6 — «какую комиссию мы получим/получили: подтверждено / ожидает подтверждения»).

Создаётся consumer-ом события pin.crm.deal-completed.v1 (../consumers/crm-deal-completed.md): подбирается правило застройщика (reward-rule.md, алгоритм подбора), считается сумма CommissionAmount = База × CommissionPercent / 100, снимается snapshot правила и снапшот условий платформы на момент завершения сделки — PlatformRetentionPercentagePoints / PlatformFeeAmount / AppliedPlatformTermsId (platform-commission-terms.md, ADR-0066); начисление создаётся в статусе PendingConfirmation. Подтверждает начисление застройщик (факт признания обязательства по комиссии; опционально — с корректировкой суммы, INV-11); в момент подтверждения строится распределение тремя строками CommissionDistributionEntity: Platform — удержание ПИН по снапшоту условий, Agency и Agent — деление пула агентства (CommissionAmount − PlatformFeeAmount) по активной политике (agency-commission-policy.md). Выплаты допускаются только после подтверждения получения денег оператором ПИН — регистрации акта поступления средств (platform-receipt.md, ADR-0067), переводящей начисление в MoneyReceived; далее — платёжные поручения (payout-order.md; исполнение платежей отложено, решение №9 ТЗ).

Если правило застройщика для объекта недвижимости не найдено — начисление не создаётся: consumer логирует warning с DealId, инкрементирует метрику rewards_accrual_no_rule_total; менеджер застройщика может создать начисление вручную (admin-API rewards, карта областей — ../README.md). Если удержание ПИН по применимым условиям платформы не меньше ставки применённого правила (пул агентства ≤ 0) — начисление также не создаётся: событие уходит в DLQ REWARDS_PLATFORM_RETENTION_EXCEEDS_RULE на ручной разбор (platform-commission-terms.md).

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

ПолеЗначение
Контекстrewards
KindAggregateRoot (+ дочерние Entity CommissionDistributionEntity, CommissionAccrualAdjustmentEntity и CommissionAdjustmentRequestEntity внутри границы агрегата)
Source of truthrewards (суммы/статусы комиссии); сделка — реплика-ссылка на crm
Таблицыrewards.commission_accruals, rewards.commission_distributions, rewards.commission_accrual_adjustments, rewards.commission_adjustment_requests
Tenant isolationTenantId = CompanyId агентства-получателя (ADR-0052: tenant = владелец процесса; процесс получения/распределения/выплаты комиссии ведёт агентство). Сторона застройщика — явное поле DeveloperCompanyId + cross-tenant доступ по permission (см. «Безопасность»)

Поля (rewards.commission_accruals)

ПолеТипОбязательностьОграничения (PK/FK/index/constraint)ИсточникОписание
IdGuidДаPKappУникальный идентификатор начисления.
TenantIdGuidДаindex ix_commission_accruals_tenant_status (btree: tenant_id, status)payload deal-completed (AgencyCompanyId)Агентство-получатель комиссии.
DealIdGuidДаunique ux_commission_accruals_deal_id — бизнес-идемпотентность consumer-а (одно начисление на сделку)payload deal-completedСсылка на сделку crm (../../crm/domain/deal.md — по конвенции). Логическая, без FK.
DeveloperCompanyIdGuidДаindex ix_commission_accruals_developer (btree: developer_company_id, status)payloadЗастройщик — плательщик комиссии; подтверждающая сторона.
AgentUserIdGuidДаindex ix_commission_accruals_agent (btree: tenant_id, agent_user_id)payloadПолучатель комиссии = агент-исполнитель (Executor) на момент завершения сделки; на него ссылается строка Agent распределения (record-level доступ «свои начисления»).
OriginAgentUserIdGuidДаindex ix_commission_accruals_origin_agent (btree: tenant_id, origin_agent_user_id)payload (deal-completed / fixation-approved)Атрибуция: иммутабельный снапшот агента-инициатора сделки (кто зафиксировал/привёл инвестора) на момент события. Не получатель, а источник — для воронки (кому засчитать лид/фиксацию) и антифрода (сверка инициатор vs получатель). Обычно = AgentUserId, но расходится при передаче/переназначении сделки другому исполнителю до завершения. Не меняется при смене Executor.
InvestorUserIdGuid?НетpayloadИнвестор-покупатель; только для трассировки, в ЛК не отдаётся агентству целиком (PII-правило ниже).
PropertyObjectIdGuidДаindex ix_commission_accruals_property_object_idpayloadОбъект недвижимости (chessboard).
DealCompletedAtDateTimeOffsetДаindex ix_commission_accruals_deal_completed_at — периодные сводки ЛКpayloadМомент завершения сделки (из события). Периоды дашбордов считаются по нему.
CalculationBaseCommissionCalculationBaseДахранится как text, varchar(32)snapshot правилаБаза расчёта на момент начисления (reward-rule.md).
CommissionPercentdecimalДаnumeric(5,2), check > 0 AND <= 100snapshot правилаПрименённый процент.
BasePriceAmountMoney (owned)Дасм. VOpayloadБазовая цена объекта недвижимости (до ремонта/скидки) на момент сделки.
FinalPriceAmountMoney (owned)Дасм. VOpayloadКонечная цена объекта недвижимости (базовая + ремонт) на момент сделки.
CommissionAmountMoney (owned)Дасм. VO; check commission_amount_value > 0вычисление(CalculationBase == BasePrice ? BasePriceAmount : FinalPriceAmount).Value × CommissionPercent / 100, округление banker's до 2 знаков. После корректировки gross (INV-11) — скорректированное значение; исходное — в OriginalCommissionAmount.
OriginalCommissionAmountMoney? (owned)Нетсм. VOappИсходная автосчитанная сумма до корректировок; фиксируется один раз — при первой корректировке GrossAmount (INV-11). null — сумма не корректировалась.
PlatformRetentionPercentagePointsdecimalДаnumeric(4,2), check >= 0 AND <= 1.50snapshot условий платформыУдержание ПИН в процентных пунктах ставки застройщика на момент DealCompletedAt (диапазон условий 0.25–1.50; 0 — fallback без условий, ADR-0066).
PlatformFeeAmountMoney (owned)Дасм. VO; check platform_fee_amount_value >= 0вычислениебаза × PlatformRetentionPercentagePoints / 100 (INV-10), banker's, 2 знака; 0 при fallback. Пул агентства = CommissionAmount − PlatformFeeAmount.
AppliedPlatformTermsIdGuid?Нет (null — fallback 0 п.п.)FK fk_commission_accruals_platform_terms → rewards.platform_commission_terms.id (restrict)резолверУсловия платформы (platform-commission-terms.md), применённые на момент DealCompletedAt.
AppliedRuleIdGuid?Нет (обязателен при автоначислении; null — ручное начисление)FK fk_commission_accruals_rule → rewards.reward_rules.id (restrict), indexрезолверПравило, по которому посчитана комиссия.
AppliedRuleSnapshotAppliedRuleSnapshotDocument (jsonb)Даjsonb, typed, ContractVersionрезолвер / ручной вводSnapshot условий расчёта на момент начисления — правило может быть потом изменено/архивировано.
AppliedPolicyIdGuid?Нет (заполняется при Confirm)FK fk_commission_accruals_policy → rewards.agency_commission_policies.id (restrict)ConfirmПолитика агентства, по которой построено распределение.
StatusCommissionAccrualStatusДахранится как text, varchar(32)appСтатус начисления (stateDiagram ниже).
ConfirmedAtDateTimeOffset?Нетзаполняется при ConfirmappМомент подтверждения застройщиком.
ConfirmedByUserIdGuid?НетappМенеджер застройщика, подтвердивший начисление.
MoneyReceivedAtDateTimeOffset?Нетзаполняется при регистрации актаактМомент подтверждения получения денег (переход Confirmed → MoneyReceived).
PlatformReceiptIdGuid?НетFK fk_commission_accruals_platform_receipt → rewards.platform_receipts.id (restrict), index ix_commission_accruals_platform_receipt where not nullактАкт поступления средств (platform-receipt.md), подтвердивший получение денег по начислению.
PaidAtDateTimeOffset?Нетзаполняется при MarkPaidappМомент выплаты (по платёжному поручению).
CancelledAtDateTimeOffset?НетappМомент отмены.
CancelReasonCommissionCancelReason?Нет (обязателен при Cancelled)хранится как text, varchar(32)app/consumerDealCancelled (событие pin.crm.deal-cancelled.v1) / DeveloperRejected / ManualCorrection.
CreatedAtDateTimeOffsetДаindexappМомент создания (аудит).
CreatedByUserIdGuid?НетappСистема (автоначисление) / менеджер застройщика (ручное).
UpdatedAtDateTimeOffsetДаappМомент изменения.
UpdatedByUserIdGuid?НетappПоследний изменивший.
VersionlongДа>= 1appДоменная версия (инкремент при мутации, попадает в события).

Enum CommissionAccrualStatus

ЗначениеОписание
PendingConfirmationНачислено по завершённой сделке, ожидает подтверждения застройщиком («ожидает подтверждения» в ЛК агента/агентства).
ConfirmedЗастройщик подтвердил обязательство (опционально скорректировав сумму, INV-11); построено распределение тремя строками Platform/Agency/Agent («подтверждено»). Деньги от застройщика ещё не получены — выплаты недоступны.
MoneyReceivedОператор ПИН подтвердил получение денег от застройщика: начисление вошло в зарегистрированный акт поступления средств (platform-receipt.md, ADR-0067). Гейт всех денежных следствий: Agent-строки доступны платёжным поручениям, связанное реферальное начисление авто-переходит в Approved (referral-accrual.md), кошельки получателей кредитуются.
PaidВсе Agent-строки распределения выплачены (платёжное поручение в статусе Paid). Терминальный.
CancelledОтменено (отмена/расторжение сделки, отказ застройщика, ручная отмена ошибочного начисления). Терминальный.

Enum CommissionCancelReason

ЗначениеОписание
DealCancelledСделка отменена/расторгнута в CRM (consumer pin.crm.deal-cancelled.v1).
DeveloperRejectedЗастройщик отказал в подтверждении (обязательный комментарий в команде отказа).
ManualCorrectionРучная отмена ошибочного начисления целиком (admin-API, permission rewards.accruals.manage). Не способ поправить сумму: правка сумм/долей — first-class корректировками (INV-11, CommissionAccrualAdjustmentEntity).

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

Строка распределения подтверждённой комиссии между получателями. Создаётся при подтверждении (Confirm) — ровно три строки (INV-4/INV-9): Platform — удержание ПИН (= PlatformFeeAmount, по снапшоту условий платформы, ADR-0066), Agency — удержание агентства из пула, Agent — остаток пула; пул агентства = CommissionAmount − PlatformFeeAmount, деление пула — по активной политике агентства (agency-commission-policy.md). Сумма трёх строк равна CommissionAmount (INV-5). Kind: Entity внутри границы агрегата CommissionAccrualEntity — все мутации только через корень.

ПолеТипОбязательностьОграниченияИсточникОписание
IdGuidДаPKappИдентификатор строки.
AccrualIdGuidДаFK fk_commission_distributions_accrual → commission_accruals.id (cascade), index ix_commission_distributions_accrualappРодительское начисление.
TenantIdGuidДаindex (tenant_id, recipient_user_id)родительДублируется для изоляции по tenant.
RecipientTypeCommissionRecipientTypeДахранится как text, varchar(16): Platform / Agent / AgencyраспределениеТип получателя.
RecipientUserIdGuid?Да для Agent, null для Agency и Platformcheck (recipient_type = 'Agent') = (recipient_user_id is not null)родитель (AgentUserId)Пользователь-получатель доли (только строка Agent).
AmountMoney (owned)Даcheck amount_value >= 0вычисление (снапшот условий платформы / политика)Сумма доли.
AppliedRetentionKindAgencyRetentionKind?Только для строки Agencyхранится как text, varchar(16)политикаКак считалось удержание агентства: Percent / FixedAmount (см. политику).
PayoutOrderItemIdGuid?НетFK fk_commission_distributions_payout_item → rewards.payout_order_items.id (set null), index where not nullpayoutЗаполняется при включении строки в платёжное поручение (только строки Agent); null — строка свободна. Строки Platform/Agency в поручения не включаются.
PaidAtDateTimeOffset?НетpayoutМомент выплаты (проставляется при Paid поручения).
CreatedAt / UpdatedAtDateTimeOffsetДаappАудит строки.

Enum CommissionRecipientType

ЗначениеОписание
PlatformУдержание ПИН как площадки (= PlatformFeeAmount, ADR-0066). RecipientUserId = null — получатель не персона, а платформа. Неприкосновенна для агентства (INV-11): меняется только пересчётом при корректировке gross; в платёжные поручения не включается — по событию получения денег кредитуется счёт платформы в кошельке.
AgentДоля агента-исполнителя сделки из пула агентства (выплачивается по платёжному поручению после MoneyReceived).
AgencyУдержание в пользу агентства из пула (не выплачивается агенту; остаётся доходом агентства).

Co-broking (ADR-0069). В v1 у сделки один финансовый получатель — назначенный Executor (ADR-0068): распределение строго трёхстрочное, строка Agent ровно одна (INV-4/5/9), прочие агентские участники (CoAgent/Assistant/Lawyer) нефинансовы. Мультистрочное агентское распределение (несколько строк Agent с долями из AgentShares[] payload завершения сделки, обобщение INV-4/5/9 на 1..N строк Agent) — v-next, вне текущего контракта. Заявочная схема «агент запрашивает → руководитель одобряет» (CommissionAdjustmentRequestEntity) в v1 оперирует единственной Agent-строкой; при переходе на co-broking заявка обобщится на конкретную строку получателя.

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

Аудит-строка корректировки начисления (ADR-0067 п.4): кто, когда, что и почему изменил. Append-only — строки не редактируются и не удаляются; каждая корректировка суммы (GrossAmount) или долей (Distribution) обязана оставить ровно одну строку. Kind: Entity внутри границы агрегата CommissionAccrualEntity — создаётся только операциями корректировки корня (INV-11).

ПолеТипОбязательностьОграниченияИсточникОписание
IdGuidДаPKappИдентификатор строки корректировки.
AccrualIdGuidДаFK fk_commission_accrual_adjustments_accrual → commission_accruals.id (cascade), index ix_commission_accrual_adjustments_accrualappРодительское начисление.
TenantIdGuidДаindexродительДублируется для изоляции по tenant.
KindCommissionAdjustmentKindДахранится как text, varchar(16): GrossAmount / DistributionappЧто корректировалось (enum ниже).
OldAmountMoney (owned)Дасм. VOappЗначение до корректировки: для GrossAmountCommissionAmount, для Distribution — сумма строки Agent.
NewAmountMoney (owned)Даcheck new_amount_value >= 0appЗначение после корректировки (та же семантика, что у OldAmount).
ReasonstringДаvarchar(1000), min 1обязательная причинаappЧеловекочитаемая причина корректировки (аудит, отчётность по корректировкам).
ActorUserIdGuidДаappАктор: менеджер застройщика (корректировка при подтверждении) / ПИН-админ (gross) / руководитель агентства (distribution).
CreatedAtDateTimeOffsetДаindex ix_commission_accrual_adjustments_created_atappМомент корректировки.

Enum CommissionAdjustmentKind

ЗначениеОписание
GrossAmountКорректировка суммы начисления CommissionAmount (застройщик — в составе подтверждения; ПИН-админ — в PendingConfirmation/Confirmed, INV-11). При первой корректировке исходная сумма фиксируется в OriginalCommissionAmount; в Confirmed пересчитывается распределение: строка Platform — по снапшоту PlatformRetentionPercentagePoints, пул — по применённой политике.
DistributionPer-deal перераспределение пула агентства между строками Agency и Agent (руководитель агентства, INV-11). Строка Platform не меняется; CommissionAmount не меняется.

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

Заявка агента на корректировку своей доли (Agent-строки) в пуле сделки по схеме «агент запрашивает → руководитель одобряет». Агент не правит распределение напрямую (у него нет права распоряжаться пулом агентства) — он предлагает новую сумму своей доли ProposedAgentAmount с обоснованием; руководитель агентства одобряет (тогда предложение исполняется как Distribution-корректировка — те же строки Agency/ Agent, что и при прямой правке) либо отклоняет. Kind: Entity внутри границы агрегата CommissionAccrualEntity — создаётся и разрешается только операциями корня (INV-12); аудит-строку CommissionAccrualAdjustmentEntity (Kind = Distribution) создаёт именно исполнение при одобрении, а не сама заявка. На начисление одновременно допускается не более одной заявки в статусе Pending (INV-12).

Заявочные операции: ../api/agent/request-distribution-adjustment.md (агент создаёт Pending), ../api/agency/approve-distribution-adjustment.md (руководитель одобряет/отклоняет; одобрение исполняет корректировку той же логикой, что ../api/agency/adjust-accrual-distribution.md).

ПолеТипОбязательностьОграниченияИсточникОписание
IdGuidДаPKappИдентификатор заявки.
AccrualIdGuidДаFK fk_commission_adjustment_requests_accrual → commission_accruals.id (cascade), index ix_commission_adjustment_requests_accrual; partial unique ux_commission_adjustment_requests_one_pending (accrual_id) where status = 'Pending' — одна открытая заявка на начисление (INV-12)appРодительское начисление.
TenantIdGuidДаindex (tenant_id, status)родительДублируется для изоляции по tenant.
RequestedByAgentUserIdGuidДаindex ix_commission_adjustment_requests_agent (tenant_id, requested_by_agent_user_id)claims (sub)Агент-заявитель; на момент создания = AgentUserId начисления (получатель Agent-строки, record-level).
ProposedAgentAmountMoney (owned)Дасм. VO; check proposed_agent_amount_value >= 0заявкаПредлагаемая сумма доли агента (Agent-строки). При исполнении доля Agency = пул − ProposedAgentAmount.
ReasonstringДаvarchar(1000), min 1обязательное обоснованиезаявкаЧеловекочитаемое обоснование предложения (переносится в Reason аудит-строки Distribution при одобрении).
StatusCommissionAdjustmentRequestStatusДахранится как text, varchar(16)appСтатус заявки (enum ниже).
ResolvedByUserIdGuid?Нет (обязателен при Approved/Rejected)appРуководитель агентства, разрешивший заявку.
ResolvedAtDateTimeOffset?Нет (заполняется при разрешении)appМомент одобрения/отклонения.
ResolutionCommentstring?Нетvarchar(1000)appКомментарий руководителя (обоснование отклонения; опционально при одобрении).
AppliedAdjustmentIdGuid?Нет (заполняется при Approved)FK fk_commission_adjustment_requests_adjustment → commission_accrual_adjustments.id (set null)appАудит-строка Distribution, созданная исполнением одобренной заявки (трассировка «заявка → корректировка»).
CreatedAtDateTimeOffsetДаindex ix_commission_adjustment_requests_created_atappМомент подачи заявки.
UpdatedAtDateTimeOffsetДаappМомент изменения.

Enum CommissionAdjustmentRequestStatus

ЗначениеОписание
PendingЗаявка подана агентом, ожидает решения руководителя. Единственная открытая заявка на начисление (partial unique).
ApprovedРуководитель одобрил: предложение исполнено как Distribution-корректировка (Agent = ProposedAgentAmount, Agency = пул − ProposedAgentAmount); создана аудит-строка CommissionAccrualAdjustmentEntity (AppliedAdjustmentId). Терминальный.
RejectedРуководитель отклонил: распределение не изменено, ResolutionComment — причина отказа. Терминальный.

Value objects

Money (единый VO контекста rewards)

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

Инвариант VO: операции сложения/вычитания допустимы только при равном CurrencyId (REWARDS_CURRENCY_MISMATCH). Все Money-поля одного начисления — с одним CurrencyId (INV-6). В payload событий денежные суммы передаются как Value + CurrencyId + CurrencySnapshot — см. ../../handbook/domain/snapshots.md (справочник валют ≠ мультивалютность).

Модели JSONB (versioned)

AppliedRuleSnapshotDocument (jsonb, колонка applied_rule_snapshot, ContractVersion = 1)

ПолеТипОбязательностьОграниченияSince version
ContractVersionintДа= 1v1
RuleIdGuid?Нет (null — ручное начисление)v1
RuleNamestringДаvarchar(256); для ручного — "manual"v1
RuleVersionlong?НетVersion правила на момент примененияv1
CalculationBaseCommissionCalculationBase (enum-string)Даv1
CommissionPercentdecimalДа> 0, <= 100, scale 2v1
Priorityint?Нетприоритет применённого правилаv1
MatchedConditionsRewardRuleConditionsDocumentДакопия условий правила на момент применения (reward-rule.md)v1
PropertyObjectPropertyTypePropertyType (enum-string)Даатрибут объекта недвижимости из событияv1
PropertyObjectLayoutIdGuid?Нетv1
PropertyObjectLayoutGroupIdGuid?Нетv1
PropertyObjectRoomsint?Нет0..20v1
PropertyObjectAreaTotalM2decimalДа> 0, scale 2v1

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

Связи

СвязьТипКлючDelete behavior
CommissionDistributionEntityowned (внутри агрегата)AccrualIdcascade
CommissionAccrualAdjustmentEntityowned (внутри агрегата)AccrualIdcascade
CommissionAdjustmentRequestEntityowned (внутри агрегата)AccrualIdcascade
RewardRuleEntityreference (nullable)AppliedRuleIdrestrict
AgencyCommissionPolicyEntityreference (nullable)AppliedPolicyIdrestrict
PlatformCommissionTermsEntityreference (nullable)AppliedPlatformTermsIdrestrict
PlatformReceiptEntityreference (nullable)PlatformReceiptIdrestrict
PayoutOrderItemEntityreference (со стороны строки распределения)PayoutOrderItemIdset null (отмена поручения освобождает строки)
Сделка crmexternal reference (без FK)DealId— (только хранение ссылки; данные — из payload события)
Объект недвижимости chessboardexternal reference (без FK)PropertyObjectId— (только хранение ссылки)

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

ИнвариантУсловиеГде проверяетсяОшибка
INV-1 OneAccrualPerDealНа DealId существует не более одного начисления (идемпотентность consumer-а)unique index + проверка существующего начисления до созданиядубль события — идемпотентный skip
INV-2 AmountFormulaCommissionAmount = база × CommissionPercent / 100 (banker's rounding, 2 знака); база выбирается по CalculationBaseметод Create/CreateManual агрегатаREWARDS_ACCRUAL_AMOUNT_MISMATCH
INV-3 ConfirmGuardConfirm допустим только из PendingConfirmation; актор — менеджер компании DeveloperCompanyIdметод агрегата + permission-policyREWARDS_ACCRUAL_INVALID_STATUS_TRANSITION
INV-4 DistributionOnConfirmСтроки распределения создаются ровно при Confirm: Platform — по снапшоту условий платформы, Agency/Agent — делением пула по активной политике агентства; до подтверждения строк нетметод ConfirmREWARDS_ACCRUAL_DISTRIBUTION_EXISTS
INV-5 DistributionSumΣ Distribution.Amount.Value = CommissionAmount.Value по трём строкам: Platform = PlatformFeeAmount; Agency + Agent = пул (CommissionAmount − PlatformFeeAmount); при FixedAmount-удержании больше пула — удержание агентства срезается до пула, доля агента = 0метод Confirm (расчёт) + проверка суммыREWARDS_ACCRUAL_DISTRIBUTION_SUM_INVALID
INV-6 SingleCurrencyBasePriceAmount.CurrencyId = FinalPriceAmount.CurrencyId = CommissionAmount.CurrencyId = CurrencyId всех строк распределения и корректировокметод Create + VO-guardREWARDS_CURRENCY_MISMATCH
INV-7 PaidOnlyViaPayoutPaid достижим только из MoneyReceived и выставляется только обработчиком перехода поручения в Paid (payout-order.md), когда у всех строк Agent-распределения PaidAt != nullметод MarkPaid (internal)REWARDS_ACCRUAL_PAID_ONLY_VIA_PAYOUT
INV-8 CancelGuardCancel допустим из PendingConfirmation и из Confirmed — если ни одна строка не включена в неотменённое поручение (PayoutOrderItemId is null у всех) и начисление не входит в не-отменённый акт поступления (platform-receipt.md). Из MoneyReceived и Paid — запрещён: после получения денег ошибки лечатся только компенсирующим ручным начислением, не разворотом статусов (ADR-0067)метод CancelREWARDS_ACCRUAL_CANCEL_FORBIDDEN
INV-9 RecipientConsistencyСтрок ровно три — по одной каждого типа Platform/Agency/Agent; строка Agent ссылается на AgentUserId начисления; у Platform и Agency RecipientUserId = nullметод Confirm + db checkREWARDS_ACCRUAL_DISTRIBUTION_INVALID
INV-10 PlatformFeeFormulaPlatformFeeAmount = база × PlatformRetentionPercentagePoints / 100 (banker's rounding, 2 знака; база — по CalculationBase, та же, что в INV-2); пул агентства = CommissionAmount − PlatformFeeAmount; политика агентства делит только пул (agency-commission-policy.md). При корректировке gross PlatformFeeAmount не меняется (считается от базы, а не от комиссии); пересчитываются пул и его делениеметод Create + пересчёт распределения при корректировке grossREWARDS_ACCRUAL_PLATFORM_FEE_MISMATCH
INV-11 AdjustmentGuardsGrossAmount: застройщик — только в составе подтверждения (AdjustedAmount + причина в команде confirm); ПИН-админ (permission rewards.accruals.adjust) — в PendingConfirmation | Confirmed, пока ни одна строка не включена в неотменённое поручение и начисление не входит в не-отменённый акт; скорректированная сумма > PlatformFeeAmount (пул > 0); после MoneyReceived gross неизменяем — только компенсирующее начисление. Distribution: перераспределяется только пул AgencyAgent, строка Platform неприкосновенна. Инициатор Distribution — один из: руководитель агентства (permission rewards.commission.adjust-approve — прямая правка или одобрение заявки) в Confirmed | MoneyReceived, пока строка Agent не включена в поручение; агент (permission rewards.commission.adjust-request) — только предложение через CommissionAdjustmentRequestEntity (Pending), не прямая правка: агент не распоряжается пулом, изменение строк выполняется лишь одобрением руководителя (исполнение — INV-12). Каждая исполненная корректировка создаёт строку CommissionAccrualAdjustmentEntity с обязательной Reason (для одобренной заявки Reason берётся из заявки, ActorUserId = одобривший руководитель)операции корректировки + permission-policyREWARDS_ACCRUAL_ADJUSTMENT_FORBIDDEN; пустая причина — REWARDS_ACCRUAL_ADJUSTMENT_REASON_REQUIRED
INV-12 AdjustmentRequestLifecycleЗаявка агента (CommissionAdjustmentRequestEntity): создаётся агентом-получателем (RequestedByAgentUserId = AgentUserId начисления) только когда начисление ∈ {Confirmed, MoneyReceived}, строка Agent свободна (PayoutOrderItemId is null) и по начислению нет другой Pending-заявки (partial unique); ProposedAgentAmount в [0; пул] той же валюты (INV-6). Разрешает заявку руководитель: одобрение допустимо только из Pending и исполняет Distribution-корректировку по актуальному пулу (Agent = ProposedAgentAmount, Agency = пул − ProposedAgentAmount, INV-5/10) под теми же guard-ами, что INV-11 (Agent-строка всё ещё свободна, статус ∈ {Confirmed, MoneyReceived}); при изменившемся пуле ProposedAgentAmount > пул — отказ. Отклонение из Pending не меняет распределение. Approved/Rejected — терминальнызаявочные операции + permission-policyREWARDS_ADJUSTMENT_REQUEST_FORBIDDEN; активная заявка уже есть — REWARDS_ADJUSTMENT_REQUEST_ALREADY_PENDING; заявка не в PendingREWARDS_ADJUSTMENT_REQUEST_INVALID_STATUS

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

СобытиеТипКогдаSnapshot/поля
CommissionAccruedEventдоменноеУспешный CreateId, DealId, CommissionAmount
CommissionConfirmedEventдоменноеPendingConfirmation → ConfirmedId, распределение
CommissionAccrualCreatedKafkaEventинтеграционное (ContractVersion = 1, topic pin.rewards.commission-accrual-created.v1, key = Id, AggregateId = Id)После CreateId, TenantId, DealId, DeveloperCompanyId, AgentUserId, PropertyObjectId, CommissionAmount (Value + CurrencyId + CurrencySnapshot), PlatformFeeAmount, PlatformRetentionPercentagePoints, Status, DealCompletedAt. Контракт — ../events/commission-accrual-created.md. Потребители: notifications (уведомление агенту/агентству), crm (отметка в таймлайне сделки)
CommissionAccrualStatusChangedKafkaEventинтеграционное (ContractVersion = 1, topic pin.rewards.commission-accrual-status-changed.v1, key = Id)Каждый переход StatusId, TenantId, DealId, FromStatus, ToStatus, CancelReason?, ConfirmedAt?, MoneyReceivedAt?, PaidAt?, Version. Потребители: notifications
CommissionMoneyReceivedKafkaEventинтеграционное (ContractVersion = 1, topic pin.rewards.commission-money-received.v1, key = Id)Confirmed → MoneyReceived (регистрация акта, platform-receipt.md)Id, TenantId, DealId, DeveloperCompanyId, PlatformReceiptId, MoneyReceivedAt, CommissionAmount (Value + CurrencyId + CurrencySnapshot), Distributions[] (RecipientType, RecipientUserId?, Amount), Version. Контракт — ../events/commission-money-received.md. Потребители: wallet (кредиты счетов получателей), notifications
CommissionAdjustmentRequestedEventдоменноеАгент создал заявку (CommissionAdjustmentRequestEntityPending)RequestId, AccrualId, RequestedByAgentUserId, ProposedAgentAmount
CommissionAdjustmentRequestResolvedEventдоменноеРуководитель одобрил/отклонил заявку (Pending → Approved/Rejected)RequestId, AccrualId, Status, ResolvedByUserId, AppliedAdjustmentId?
CommissionAdjustmentRequestStatusChangedKafkaEventинтеграционное (ContractVersion = 1, topic pin.rewards.commission-adjustment-request-status-changed.v1, key = AccrualId)Создание заявки и её разрешениеRequestId, AccrualId, TenantId, RequestedByAgentUserId, Status (Pending/Approved/Rejected), ResolvedByUserId?, ResolutionComment?, CreatedAt/ResolvedAt?. Контракт — ../events/commission-adjustment-request-status-changed.md (будущий путь по конвенции). Потребители: notifications (уведомление руководителю о новой заявке / агенту о решении)

Исполнение одобренной заявки публикует также pin.rewards.commission-distribution-adjusted.v1 (при MoneyReceived — компенсация кошелька, ../events/commission-distribution-adjusted.md), как и прямая Distribution-корректировка. Интеграционные события публикуются атомарно со сменой состояния агрегата.

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

  • Идемпотентность создания. На DealId — не более одного начисления (INV-1): повтор события deal-completed/гонка двух инстансов consumer-а упирается в unique ux_commission_accruals_deal_id и трактуется как идемпотентный skip (перечитать → запись есть). Ручное начисление использует ту же защиту по DealId.
  • Гонка Confirm vs Cancel. Оба перехода стартуют из PendingConfirmation; оптимистическая блокировка (Version) пропускает ровно один — проигравший получает конфликт версии и перечитывает уже изменённый статус; повтор его перехода отклоняется guard-ом (INV-3/INV-8, REWARDS_ACCRUAL_INVALID_STATUS_TRANSITION / REWARDS_ACCRUAL_CANCEL_FORBIDDEN).
  • Гонка Cancel vs включение в поручение. Cancel из Confirmed допустим только пока все Agent-строки свободны (PayoutOrderItemId is null, INV-8); захват строк поручением (payout-order.md, INV-1) идёт под блокировкой строк — победитель первым проставляет PayoutOrderItemId, после чего Cancel уже недоступен (проигравший видит REWARDS_ACCRUAL_CANCEL_FORBIDDEN).
  • Гонка Cancel vs включение в акт поступления. Cancel из Confirmed также недоступен, пока начисление входит в не-отменённый акт (platform-receipt.md); включение позиции в акт и отмена мутируют один агрегат — оптимистическая блокировка пропускает ровно одну операцию, проигравший перечитывает состояние.
  • Переход в MoneyReceived. Выполняется только регистрацией акта поступления средств — атомарно для всех позиций акта вместе с авто-Approve связанных реферальных начислений и публикацией commission-money-received; прямой перевод статуса извне не предусмотрен.
  • Переход в Paid. Достижим только из MoneyReceived и выставляется только каскадом поручения (INV-7), когда у всех Agent-строк PaidAt != null; прямой MarkPaid извне запрещён (REWARDS_ACCRUAL_PAID_ONLY_VIA_PAYOUT).
  • Гонка «корректировка распределения ↔ сборка поручения». Guard INV-11 «строка Agent не включена в поручение» + оптимистическая блокировка начисления: обе операции мутируют один агрегат, проходит одна; проигравший перечитывает актуальное состояние и повторяет решение.
  • Компенсации после получения денег. Из MoneyReceived и Paid отмена и правка gross запрещены (INV-8, INV-11); ошибочно подтверждённое/выплаченное начисление компенсируется отдельным ручным корректирующим начислением, а не правкой существующего — финансовая история неизменяема, Registered-акт не разворачивается (ADR-0067).

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

АспектЗначение
ИндексыPK; ux_commission_accruals_deal_id unique btree; ix_commission_accruals_tenant_status btree (tenant_id, status); ix_commission_accruals_agent btree (tenant_id, agent_user_id); ix_commission_accruals_origin_agent btree (tenant_id, origin_agent_user_id); ix_commission_accruals_developer btree (developer_company_id, status); ix_commission_accruals_deal_completed_at btree; ix_commission_accruals_property_object_id btree; ix_commission_accruals_platform_receipt btree where not null; distributions: PK, ix_commission_distributions_accrual btree, ix_commission_distributions_payout_item btree where not null, ix_commission_distributions_recipient btree (tenant_id, recipient_user_id); adjustments: PK, ix_commission_accrual_adjustments_accrual btree, ix_commission_accrual_adjustments_created_at btree (отчётность по корректировкам); adjustment_requests: PK, ix_commission_adjustment_requests_accrual btree, ux_commission_adjustment_requests_one_pending partial unique (accrual_id) where status='Pending', ix_commission_adjustment_requests_agent btree (tenant_id, requested_by_agent_user_id), ix_commission_adjustment_requests_created_at btree
Партиционирование / retentionnone — объём = число завершённых сделок (десятки тысяч в год, ADR-0056); retention бессрочный (финансовая история)
Concurrencyоптимистическая блокировка (правка не должна затирать чужую): подтверждение застройщика vs отмена vs включение в поручение
Permissions / PIIАгент — только свои начисления (record-level AgentUserId, permission rewards.commission.view-own) и заявка на корректировку своей доли (rewards.commission.adjust-request — только предложение, INV-12); руководитель агентства — все начисления tenant-а (rewards.commission.view-all, scope = организация), прямая правка долей и одобрение заявок (rewards.commission.adjust-approve); застройщик — начисления с его DeveloperCompanyId (cross-tenant чтение/подтверждение по permission rewards.accruals.confirm, фильтр по DeveloperCompanyId — вне обычной изоляции по tenant, отдельный путь чтения); Менеджер ПИН — платформенный scope. InvestorUserId в проекции агентства маскируется (отдаётся только DealId); суммы — Sensitive-класс, в логи не пишутся
AuditVersion + actor-stamping + события статусов; корректировки сумм и долей — first-class строками CommissionAccrualAdjustmentEntity (обязательная причина + актор, append-only); заявки агентов на корректировку доли — CommissionAdjustmentRequestEntity (заявитель + резолвер + AppliedAdjustmentId, трассировка «заявка → корректировка»); остальная история — событиями

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

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

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