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

Entity / Aggregate: AgencyCommissionPolicyEntity

Дом полей агрегата «Комиссионная политика агентства» контекста rewards. Проекции (модели ответов API, snapshot применения в начислении) ссылаются сюда и описывают только дельту.

Назначение

AgencyCommissionPolicyEntity — настройка руководителя агентства, определяющая, как комиссия по завершённой сделке распределяется между агентом-исполнителем и самим агентством (матрица «Агенство» 5.2: «установка размера комиссии — в % или фиксированной суммой — которая удерживается из вознаграждения агента в пользу агентства»; 5.3: «автоматический расчёт: сколько заработал агент, сколько удержано агентством, сколько выплачено»).

Политика применяется в момент подтверждения начисления застройщиком (Confirm в commission-accrual.md, INV-4/INV-5/INV-10) и делит пул агентстваCommissionAmount − PlatformFeeAmount (удержание ПИН снимается до политики по снапшоту условий платформы, ADR-0066) — между строками Agency (удержание агентства) и Agent (остаток пула). Строка Platform трёхстрочного распределения формируется вне политики и от её настроек не зависит. По документу правил выбирается удержание для конкретного агента (персональный оверрайд или правило по умолчанию). У агентства одновременно одна активная политика (INV-1); смена политики не пересчитывает уже подтверждённые начисления — в начислении хранится AppliedPolicyId. Точечная правка долей конкретного начисления — per-deal корректировка распределения (INV-11 начисления), не через политику.

Политика поддерживает две ортогональные возможности поверх базовой retention-модели:

  • Прямое выражение доли агента. Правило распределения (AgencyRetentionRule) задаёт либо удержание агентства (Percent/FixedAmount — агенту достаётся остаток пула), либо долю агента напрямую (AgentPercentOfPool/AgentPercentOfPrice — агентство удерживает остаток). Обе формы делят один и тот же пул; результат всегда agencyShare + agentShare = пул.
  • Time-bounded agent-boost. Временная мотивационная кампания (AgentBoostCampaignEntity) поднимает долю набора агентов на период (типовой кейс: 2–4% → 6–7%). Boost — отдельный агрегат той же специфичности с периодом EffectiveFrom/EffectiveTo?; резолюция удержания на asOf = CompletedAt применяет активный boost вместо базового правила (ADR-0068).

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

ПолеЗначение
Контекстrewards
KindAggregateRoot
Source of truthrewards
Таблицаrewards.agency_commission_policies
Tenant isolationTenantId = CompanyId агентства (Type ∈ {Agency, Broker, Pin}, ADR-0052)

Поля

ПолеТипОбязательностьОграничения (PK/FK/index/constraint)ИсточникОписание
IdGuidДаPKappУникальный идентификатор политики.
TenantIdGuidДаindex; partial-unique ux_agency_policies_tenant_active (tenant_id) where status = 'Active' and deleted_at is null — единственность активной политики (INV-1)identity (актор)Агентство-владелец политики.
NamestringДаvarchar(256), min 1appНазвание политики (например «Стандарт 30/70»).
Descriptionstring?Нетvarchar(2000)appКомментарий руководителя.
StatusAgencyCommissionPolicyStatusДахранится как text, varchar(32), index (tenant_id, status)appDraft / Active / Archived (stateDiagram ниже).
EffectiveFromDateTimeOffset?Нетзаполняется при ActivateappМомент активации; информационный (распределение всегда считает текущая активная политика).
RulesAgencyDistributionRulesDocument (jsonb)Даjsonb, typed, ContractVersionappПравила распределения (см. JSONB ниже).
CreatedAtDateTimeOffsetДаindexappМомент создания (аудит).
CreatedByUserIdGuid?НетappРуководитель агентства.
UpdatedAtDateTimeOffsetДаappМомент изменения.
UpdatedByUserIdGuid?НетappПоследний изменивший.
DeletedAtDateTimeOffset?Нетpartial-filter на индексахappSoft-delete; вместе с Status = Archived.
DeletedByUserIdGuid?НетappКто архивировал.
VersionlongДа>= 1appДоменная версия (инкремент при мутации).

Enum AgencyCommissionPolicyStatus

ЗначениеОписание
DraftЧерновик: редактируется, в расчёте распределения не участвует.
ActiveДействующая политика — единственная на агентство; применяется ко всем подтверждениям начислений. Правила иммутабельны (INV-4).
ArchivedАрхив (soft-delete); ссылки AppliedPolicyId из начислений живы.

Enum AgencyRetentionKind (вид правила распределения пула)

Правило задаёт либо удержание агентства (Percent/FixedAmount — историческая retention-модель, агенту достаётся остаток), либо долю агента напрямую (AgentPercentOfPool/AgentPercentOfPrice — агентство удерживает остаток). Обе формы делят один и тот же пул агентства (CommissionAmount − PlatformFeeAmount); выбор Kind — вопрос удобства выражения, результат всегда agencyShare + agentShare = пул. Значения Percent/FixedAmount полностью совместимы с прежней моделью.

ЗначениеВыражаетОписание
Percentудержание агентстваАгентство удерживает Value процентов от пула агентства начисления (CommissionAmount − PlatformFeeAmount); агенту — остаток.
FixedAmountудержание агентстваАгентство удерживает фиксированную сумму Value (в валюте CurrencyId); если сумма удержания больше пула — удержание срезается до пула, доля агента = 0 (commission-accrual.md, INV-5).
AgentPercentOfPoolдоля агентаАгент получает Value процентов от пула агентства; агентство удерживает остаток (пул − доля агента). Прямая форма записи доли агента (в т.ч. для agent-boost).
AgentPercentOfPriceдоля агентаАгент получает Value процентов от конечной цены сделки (FinalPrice, из снимка сделки в начислении); если результат превышает пул — доля агента срезается до пула (агентство удерживает 0). Позволяет мотивировать агента в терминах цены объекта.

Value objects

Отдельных value objects нет — денежные значения политики живут внутри типизированного jsonb (AgencyRetentionRule), потому что это конфигурация правил, а не транзакционные суммы (транзакционные Money — в начислении).

Модели JSONB (versioned)

AgencyDistributionRulesDocument (jsonb, колонка rules, ContractVersion = 1)

ПолеТипОбязательностьОграниченияSince version
ContractVersionintДа= 1v1
DefaultRuleAgencyRetentionRuleДаправило по умолчанию для всех агентов агентстваv1
AgentOverridesIReadOnlyList<AgentRetentionOverride>Нет (пусто = нет персональных условий)≤ 1000 элементов; AgentUserId уникален в спискеv1

Вложенная модель AgencyRetentionRule

ПолеТипОбязательностьОграниченияSince version
KindAgencyRetentionKind (enum-string)ДаPercent / FixedAmount / AgentPercentOfPool / AgentPercentOfPricev1
ValuedecimalДаPercent/AgentPercentOfPool/AgentPercentOfPrice: >= 0, <= 100, scale 2; FixedAmount: >= 0, <= 100000000, scale 2v1
CurrencyIdshort?Да при Kind = FixedAmount, null при остальных Kind (для AgentPercentOfPrice база — цена сделки, валюта берётся из начисления)ссылка на валюту справочника (дефолт RUB; RUB-only — ADR-0048); валидируется при сохранении/активации через internal resolve справочника (../../handbook/api/internal/resolve-references.md)v1

Вложенная модель AgentRetentionOverride

ПолеТипОбязательностьОграниченияSince version
AgentUserIdGuidДапользователь компании TenantId (проверка при активации через identity-реплику пользователей агентства)v1
RuleAgencyRetentionRuleДате же ограниченияv1

Правила версии: новые поля — additive (nullable/default); смена семантики — ContractVersion + 1 + migration-reader. Секреты/PII в jsonb запрещены (UserId — не PII, ФИО не хранится).

Алгоритм применения политики (расчёт распределения при подтверждении начисления)

Вход: AgentUserId, AgencyPoolAmount : Moneyпул агентства = CommissionAmount − PlatformFeeAmount (commission-accrual.md, INV-10), DealFinalPrice : Money — конечная цена сделки (нужна только для Kind = AgentPercentOfPrice; берётся из снимка сделки в начислении), asOf = CompletedAt — момент завершения сделки. Политика делит пул, а не gross-комиссию: удержание ПИН (строка Platform) снято до применения политики и от её настроек не зависит. Применяется при подтверждении начисления (Confirm). round — банковское округление до scale 2.

  1. Если у агентства нет активной политики в момент Confirm — fallback INV-6: agencyShare = 0, весь пул — агенту, AppliedPolicyId = null, AppliedBoostCampaignId = null; шаги 2–5 не выполняются (строка Platform создаётся независимо от политики). Boost поверх отсутствующей политики не применяется — агент и так получает весь пул.
  2. Базовое правило. Найти в Rules.AgentOverrides элемент с AgentUserId — если найден, взять его Rule, иначе DefaultRule. Оверрайд применяется по AgentUserId независимо от того, состоит ли агент в агентстве на момент подтверждения: политика иммутабельна в Active (INV-4), состав пользователей сверялся при Activate (INV-3); последующий выход агента распределение не меняет.
  3. Активный boost (time-bounded). Отобрать AgentBoostCampaign со Status = Active, чей период покрывает asOf (EffectiveFrom <= asOf и (EffectiveTo is null или EffectiveTo > asOf)) и в чьём наборе AgentUserIds есть AgentUserId. Такой кампании не больше одной (INV-9 — периоды boost одного агента не пересекаются), поэтому резолюция детерминирована. Если найдена — effectiveRule = campaign.BoostRule, AppliedBoostCampaignId = campaign.Id; иначе effectiveRule = базовое правило из шага 2, AppliedBoostCampaignId = null. Boost всегда перекрывает базовое правило (его назначение — временно поднять долю агента).
  4. Расчёт по effectiveRule.Kind:
    • PercentagencyShare = round(AgencyPoolAmount.Value × Value / 100); agentShare = пул − agencyShare. Граничные значения: Value = 0 → удержание 0, весь пул агенту; Value = 100 → удержание = весь пул, доля агента 0.
    • FixedAmount → проверить CurrencyId == AgencyPoolAmount.CurrencyId (REWARDS_CURRENCY_MISMATCH); agencyShare = min(Value, AgencyPoolAmount.Value) — если фиксированное удержание превышает пул, оно срезается до пула (доля агента 0, INV-5 commission-accrual.md); agentShare = пул − agencyShare.
    • AgentPercentOfPoolagentShare = round(AgencyPoolAmount.Value × Value / 100); agencyShare = пул − agentShare. Граничные значения: Value = 0 → доля агента 0, весь пул агентству; Value = 100 → агенту весь пул (удержание 0).
    • AgentPercentOfPrice → проверить DealFinalPrice.CurrencyId == AgencyPoolAmount.CurrencyId (REWARDS_CURRENCY_MISMATCH); agentShare = min(round(DealFinalPrice.Value × Value / 100), AgencyPoolAmount.Value) — доля агента не может превысить пул (при срезке agencyShare = 0); agencyShare = пул − agentShare.
  5. Одна из долей вычислена округлением, вторая — остаток без отдельного округления, поэтому agencyShare + agentShare = AgencyPoolAmount.Value точно; вместе со строкой Platform: PlatformFeeAmount + agencyShare + agentShare = CommissionAmount (INV-5). Вернуть пары (Agency, agencyShare, AppliedRetentionKind = effectiveRule.Kind, AppliedBoostCampaignId?) и (Agent, agentShare). Снапшот применённого правила (политика — AppliedPolicyId; boost — AppliedBoostCampaignId + период EffectiveFrom/EffectiveTo + Kind/Value) фиксируется в начислении и не пересчитывается при последующей смене политики или архивации кампании (ADR-0068 п.4).

Под-агрегат AgentBoostCampaignEntity (time-bounded agent-boost)

Agent-boost — временная мотивационная кампания агентства: на ограниченный период доля агентов из заданного набора поднимается (типовой кейс: 2–4% → 6–7%). Boost вынесен в отдельный агрегат, а не в документ политики, потому что активная политика иммутабельна и единственна (INV-1/INV-4): постоянная конфигурация и её временные надстройки живут раздельно, кампанию можно завести и свернуть, не трогая базовую политику (ADR-0068 п.3 — акции и boost как отдельные time-bounded записи, без промо-флагов в основной записи). Резолюция удержания при Confirm начисления учитывает активный на asOf = CompletedAt boost и применяет его правило вместо базового (см. «Алгоритм применения политики», шаг 3). Кампания не пересчитывает уже подтверждённые начисления: применённый boost зафиксирован снапшотом AppliedBoostCampaignId в начислении.

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

ПолеЗначение
Контекстrewards
KindAggregateRoot
Source of truthrewards
Таблицаrewards.agent_boost_campaigns
Tenant isolationTenantId = CompanyId агентства (Type ∈ {Agency, Broker, Pin}, ADR-0052)

Поля

ПолеТипОбязательностьОграничения (PK/FK/index/constraint)ИсточникОписание
IdGuidДаPKappИдентификатор кампании; попадает в снапшот начисления как AppliedBoostCampaignId.
TenantIdGuidДаindex (tenant_id, status)identity (актор)Агентство-владелец кампании.
NamestringДаvarchar(256), min 1appНазвание кампании (например «Весенний boost 7%»).
Descriptionstring?Нетvarchar(2000)appОснование/условия кампании.
TargetsAgentBoostTargetsDocument (jsonb)Даjsonb, typed, ContractVersion; набор агентов кампанииappАгенты, на которых распространяется boost (см. JSONB ниже).
BoostRuleAgencyRetentionRule (jsonb)Даjsonb, typed; Kind ∈ {AgentPercentOfPool, AgentPercentOfPrice} (boost выражает долю агента напрямую); те же ограничения ValueappПовышенная доля агента на период кампании.
EffectiveFromDateTimeOffsetДаcheck effective_to is null or effective_to > effective_fromappНачало периода boost.
EffectiveToDateTimeOffset?НетappКонец периода (исключительно); null — бессрочно.
StatusAgentBoostCampaignStatusДаtext, varchar(32), index (tenant_id, status)appDraft / Active / Archived (stateDiagram ниже).
CreatedAtDateTimeOffsetДаindexappМомент создания (аудит).
CreatedByUserIdGuid?НетappРуководитель агентства.
UpdatedAtDateTimeOffsetДаappМомент изменения.
UpdatedByUserIdGuid?НетappПоследний изменивший.
DeletedAtDateTimeOffset?Нетpartial-filter на индексахappSoft-delete (вместе с Status = Archived).
VersionlongДа>= 1appДоменная версия (инкремент при мутации).

Enum AgentBoostCampaignStatus

ЗначениеОписание
DraftЧерновик: редактируется, в резолюции boost не участвует.
ActiveДействует: участвует в резолюции с учётом периода EffectiveFrom/EffectiveTo. Иммутабелен, кроме сокращения EffectiveTo (INV-10).
ArchivedАрхив (soft-delete): выведен из резолюции навсегда; снапшоты AppliedBoostCampaignId в начислениях живы.

Модель JSONB AgentBoostTargetsDocument (колонка targets, ContractVersion = 1)

ПолеТипОбязательностьОграниченияSince version
ContractVersionintДа= 1v1
AgentUserIdsIReadOnlyList<Guid>Да1..1000 элементов, уникальны; каждый — активный пользователь компании TenantId (сверка при Activate через identity-реплику пользователей агентства)v1

BoostRule хранит Kind/Value в тех же ограничениях, что AgencyRetentionRule; для AgentPercentOfPrice CurrencyId не задаётся — база берётся из цены сделки. Разные кампании могут пересекаться по набору агентов только если их периоды не пересекаются для общих агентов (INV-9).

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

ИнвариантУсловиеГде проверяетсяОшибка
INV-8 BoostRuleKindBoostRule.Kind ∈ {AgentPercentOfPool, AgentPercentOfPrice} и 0 <= Value <= 100 (boost выражает долю агента; удержание агентства через boost не задаётся)проверка документаREWARDS_AGENT_BOOST_RULE_INVALID
INV-9 NoBoostOverlapPerAgentДля каждого агента периоды [EffectiveFrom, EffectiveTo|∞) активных кампаний, содержащих этого агента, не пересекаются — на любой asOf к агенту применима не более одной кампании (детерминизм резолюции)Activate: активные кампании, пересекающиеся по агентам, читаются под блокировкой, пересечение периодов отклоняетсяREWARDS_AGENCY_POLICY_OVERLAP
INV-10 BoostImmutableWhenActiveВ Active меняется только EffectiveTo — досрочное завершение (только сокращение периода, не раньше текущего момента; сокращение пересечений не создаёт); остальные поля и набор агентов — только в Draftguard по Status в операции измененияREWARDS_AGENT_BOOST_ACTIVE_IMMUTABLE
INV-11 BoostTargetsValidAgentUserIds непуст, ≤ 1000, без повторов, каждый — активный пользователь TenantIdпроверка документа + Activate (сверка по identity)REWARDS_AGENT_BOOST_TARGETS_INVALID
INV-12 BoostPeriodValidEffectiveTo is null или EffectiveTo > EffectiveFromпроверка входа + db checkREWARDS_AGENT_BOOST_PERIOD_INVALID

Коды ошибок boost

КодHTTPУсловие
REWARDS_AGENT_BOOST_RULE_INVALID400BoostRule не выражает долю агента или Value вне диапазона (INV-8).
REWARDS_AGENT_BOOST_TARGETS_INVALID400Пустой/дублирующий набор агентов или неизвестный пользователь агентства (INV-11).
REWARDS_AGENT_BOOST_PERIOD_INVALID400EffectiveTo <= EffectiveFrom (INV-12).
REWARDS_AGENCY_POLICY_OVERLAP409Пересечение периодов активных кампаний по общему агенту при активации (INV-9).
REWARDS_AGENT_BOOST_ACTIVE_IMMUTABLE409Попытка изменить Active-кампанию сверх сокращения EffectiveTo (INV-10).
REWARDS_AGENT_BOOST_INVALID_STATUS_TRANSITION409Недопустимый переход статуса: активация не из Draft либо архивирование уже Archived-кампании.
REWARDS_AGENT_BOOST_NOT_FOUND404Кампания не найдена (операции управления).

Связи

СвязьТипКлючПравило загрузкиDelete behavior
CompanyEntity (агентство, identity)reference (логическая, без FK)TenantIdtenant-контекст актораrestrict
CommissionAccrualEntity (политика)reference (обратная)commission_accruals.applied_policy_id → agency_commission_policies.idFilterrestrict — политика с применениями только архивируется
CommissionAccrualEntity (boost)reference (обратная)commission_accruals.applied_boost_campaign_id → agent_boost_campaigns.idFilterrestrict — кампания с применениями только архивируется
AgentBoostCampaignEntity (под-агрегат)reference (в рамках агентства)TenantId (то же агентство)Filter по tenantrestrict
Пользователи агентства (identity)external referenceRules.AgentOverrides[].AgentUserId, Targets.AgentUserIds[]валидация при Activate (запрос списка пользователей компании)

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

ИнвариантУсловиеГде проверяетсяОшибка
INV-1 SingleActivePolicyНа агентство — не более одной Active политики; Activate новой атомарно переводит предыдущую в Archivedpartial-unique index + команда Activate (текущая активная политика читается под блокировкой)REWARDS_POLICY_ACTIVE_EXISTS (гонка — конфликт индекса → retry)
INV-2 ValidRuleValuesPercent/AgentPercentOfPool/AgentPercentOfPrice: 0 <= Value <= 100; FixedAmount: Value >= 0 и CurrencyId задан (резолвится в справочнике); для не-FixedAmount Kind CurrencyId = nullпроверка документа + internal resolveREWARDS_POLICY_RULE_INVALID
INV-3 UniqueOverridesAgentUserId уникален в AgentOverrides; каждый — активный пользователь компании TenantIdпроверка документа + Activate (сверка по identity)REWARDS_POLICY_OVERRIDE_INVALID
INV-4 ImmutableWhenActiveВ Active документ Rules и Name не меняются — правка идёт через новый Draft + Activate (сохраняет воспроизводимость применённого распределения)guard по Status в операции измененияREWARDS_POLICY_ACTIVE_IMMUTABLE
INV-5 ArchiveConsistencyStatus = ArchivedDeletedAt != nullоперация архивированияREWARDS_POLICY_INVALID_ARCHIVE_STATE
INV-6 NoPolicyFallbackЕсли у агентства нет активной политики в момент Confirm начисления — удержание агентства = 0, весь пул агентства (CommissionAmount − PlatformFeeAmount) — агенту (Agency-строка с Amount = 0, AppliedPolicyId = null); удержание ПИН (строка Platform) применяется независимо от наличия политикиподтверждение начисления (Confirm)нет ошибки — задокументированный fallback
INV-7 TenantOwnershipАктор — руководитель/администратор компании TenantId с permission rewards.commission-policy.manageизоляция по tenant + permission-policy403

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

СобытиеТипКогдаSnapshot/поля
AgencyPolicyCreatedEventдоменноеУспешный CreateId, TenantId
AgencyPolicyActivatedEventдоменноеDraft → ActiveId, PreviousPolicyId?
AgencyPolicyArchivedEventдоменное* → ArchivedId, actor
AgentBoostCampaignCreatedEventдоменноеУспешный Create кампанииId, TenantId
AgentBoostCampaignActivatedEventдоменноеDraft → ActiveId, EffectiveFrom/EffectiveTo, число агентов
AgentBoostCampaignArchivedEventдоменное* → ArchivedId, actor

Интеграционные Kafka-события не публикуются — политика и boost применяются только внутри контекста rewards (при Confirm начисления); внешним потребителям их изменения не нужны. Наблюдаемость и инвалидация кэша резолвера ставок обеспечиваются общим топиком слоёв конфигурации pin.rewards.commission-rules.v1 (created/updated/archived записи слоёв, ADR-0068 п.6) — если он будет включён для агентского слоя, изменения политики и кампаний boost публикуются через additive-процедуру событийного реестра.

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

АспектЗначение
ИндексыПолитика: PK; ux_agency_policies_tenant_active unique btree (tenant_id) where status = 'Active' and deleted_at is null; ix_agency_policies_tenant_status btree (tenant_id, status) where deleted_at is null; ix_agency_policies_created_at btree. Boost: PK; ix_agent_boost_campaigns_tenant_status btree (tenant_id, status) where deleted_at is null; ix_agent_boost_campaigns_created_at btree. Непересечение периодов boost по агенту (INV-9) — app-level при Activate (кампаний единицы, ADR-0056)
Партиционирование / retentionnone — единицы записей на агентство (политики и кампании boost); retention бессрочный (soft-delete)
Concurrencyоптимистическая блокировка (правка не должна затирать чужую) + блокировка текущей активной политики при её смене (INV-1) и активных кампаний, пересекающихся по агентам, при Activate boost (INV-9)
Permissions / PIIУправление политикой и кампаниями boost — rewards.commission-policy.manage (scope = организация-агентство: роль «Руководитель»/«Администратор агентства»); чтение — rewards.commission-policy.view. Агенту своя ставка удержания и действующий boost видны в проекции «моя комиссия» (только собственный оверрайд/дефолт и относящиеся к нему кампании), полный документ политики и списки агентов кампаний агенту не отдаются. PII нет
AuditVersion + actor-stamping; история версий политики = цепочка архивированных записей (Draft→Active→Archived), применённая версия зафиксирована AppliedPolicyId в начислении; применённый boost зафиксирован AppliedBoostCampaignId + период в снапшоте начисления и не пересчитывается ретроспективно (ADR-0068 п.4)

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

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

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

  • commission-accrual.md — точка применения (Confirm, INV-4/5/10: пул = CommissionAmount − PlatformFeeAmount), трёхстрочное распределение Platform/Agency/Agent, fallback без политики и per-deal корректировка долей (INV-11); хранит снапшот применённого правила (AppliedPolicyId, AppliedBoostCampaignId? + период) и DealFinalPrice для AgentPercentOfPrice.
  • platform-commission-terms.md — удержание ПИН, формирующее пул.
  • payout-order.md — выплата агентских долей.
  • referral-rate-rule.md — эталон паттерна scoped+time-bounded (период, «специфичное побеждает», непересечение периодов внутри специфичности, asOf = CompletedAt).
  • Роли агентства: ../../identity/domain/role.md; пользователи: ../../identity/domain/user.md.
  • ADR-0052 (tenancy), ADR-0056 (simplicity-first — jsonb-конфигурация вместо отдельных таблиц правил), ADR-0066 (пул вместо gross, строка Platform), ADR-0068 (единый паттерн scoped+time-bounded: agent-boost как time-bounded запись, снапшот области/периода в начисление, непересечение периодов внутри специфичности).
  • Шаблон: entity-template.md; правила: cross-cutting-document-requirements.md.