POST /internal/v1/commission/calculate-property-object — Расчёт комиссии по объекту недвижимости (internal, s2s для catalog)
Назначение
Внутренний межсервисный метод контекста rewards для витрины catalog: отдаёт «сколько заработает агент на
объекте недвижимости» ещё до завершения сделки. По атрибутам объекта и ценам детерминированно подбирается
активное правило вознаграждения застройщика (алгоритм подбора — reward-rule.md,
раздел «Алгоритм подбора правила»), рассчитывается сумма комиссии (gross) и чистая экономика агентской
стороны: удержание платформы по условиям ПИН
(platform-commission-terms.md, ADR-0066) и пул агентства
(gross − удержание) — net-цифры для блока комиссии в витрине. Операция только читает: состояние не
меняется, событий нет. Тот же алгоритм подбора и резолюции условий применяет consumer завершения сделки
(crm-deal-completed.md) при создании начисления, а admin-dry-run —
test-reward-rule-calculation.md.
Комиссия — Sensitive-класс: инвестору не отдаётся (правило безопасности
reward-rule.md, «Индексы, хранение, безопасность»); catalog показывает блок
только агенту/менеджеру (rewards.commission.view-property-object) и скрывает его при HasRule = false.
| Поле | Значение |
|---|
| Сервис/контекст | rewards |
| API area | internal |
| Feature group | internal-commission |
| Статус | draft |
| Документ | docs/06-services/rewards/api/internal/calculate-property-object-commission.md |
Актор и доступ
| Проверка | Значение |
|---|
| Actor type | System — m2m-клиент catalog |
| Auth policy / Permissions | m2m client-credentials токен, audience pin.rewards.internal; неверный/отсутствующий audience → UNAUTHORIZED (401) до handler-а. Internal-область не экспонируется через публичный gateway |
| Scope (RBAC) | не применяется (платформенное system-чтение); ролевой гейтинг показа (rewards.commission.view-property-object, скрытие от инвестора) — обязанность catalog |
| Record-level | не применяется |
| Tenant isolation | правила читаются по явному DeveloperCompanyId из тела запроса (cross-tenant system-чтение, по образцу query-path начислений застройщика, ADR-0052) |
| Audit | none (read; вызовы логируются по client-id/audience, суммы — не логируются) |
HTTP-контракт
| Поле | Значение |
|---|
| Method | POST (read-only: тело несёт атрибуты объекта недвижимости и цены, состояние не мутирует) |
| Route | /internal/v1/commission/calculate-property-object |
| Success status | 200 (в т.ч. HasRule = false — валидный ответ, не 404) |
| Idempotency header | нет (безопасное чтение, повтор идемпотентен) |
| Correlation | сквозной trace через заголовок traceparent (от catalog) |
Входные данные / параметры запроса
Тело — CalculatePropertyObjectCommissionRequest (маппится 1:1 в CalculatePropertyObjectCommissionQuery).
| Поле | Источник | Тип | Обязательность | Валидация / Ограничения | Безопасность |
|---|
DeveloperCompanyId | body | Guid | Да | != Guid.Empty | tenant правил (cross-tenant m2m-чтение) |
AgencyCompanyId | body | Guid? | Нет | если задан — != Guid.Empty; агентство смотрящего агента — для резолюции per-agency условий платформы; null → резолюция только по глобальным условиям | — |
PropertyType | body | PropertyType (enum-string) | Да | Apartment/Parking/Commercial; unknown → правило с непустым PropertyTypes не подходит (fail-closed) | — |
LayoutId | body | Guid? | Нет | если задан — != Guid.Empty | — |
LayoutGroupId | body | Guid? | Нет | если задан — != Guid.Empty | — |
Rooms | body | int? | Нет | 0..20; null (парковка/коммерция) не проходит правило с заданным диапазоном комнат | — |
AreaTotalM2 | body | decimal | Да | > 0, <= 10000, scale 2 | — |
BasePrice | body | MoneyModel | Да | Value >= 0, scale 2; CurrencyId — RUB-only (ADR-0048) | — |
FinalPrice | body | MoneyModel | Да | Value >= 0, scale 2; CurrencyId == BasePrice.CurrencyId (иначе REWARDS_CURRENCY_MISMATCH) | — |
AsOf | body | DateTimeOffset? | Нет | null → серверный DateTimeOffset.UtcNow; момент действия правил (EffectiveFrom/EffectiveTo) и условий платформы | — |
Audience | m2m claims | string | Да (вычисляется) | = pin.rewards.internal (иначе 401) | логируется |
Модель ответа PropertyObjectCommissionResultModel
| Поле | Тип | Обязательность | Источник | Masking | Описание |
|---|
HasRule | bool | Да | computed | — | true — правило подобрано; false — catalog скрывает блок комиссии. |
ResolvedAsOf | DateTimeOffset | Да | AsOf ?? UtcNow | — | Момент подбора (echo для кэша/дебага витрины). |
AppliedRule | AppliedPropertyObjectCommissionModel? | Нет (null при HasRule = false) | вычисление | Sensitive | Подобранное правило и рассчитанная комиссия; null — правила нет. |
Вложенная модель AppliedPropertyObjectCommissionModel (проекция ../domain/reward-rule.md)
| Поле | Тип | Обязательность | Источник | Описание |
|---|
AppliedRuleId | Guid | Да | RewardRuleEntity.Id | Идентификатор применённого правила. |
RuleName | string | Да | RewardRuleEntity.Name | Название правила (витрина показывает «по правилу …»). |
CalculationBase | string (enum CommissionCalculationBase) | Да | CalculationBase: BasePrice/FinalPrice | База, от которой считается процент. |
CommissionPercent | decimal | Да | CommissionPercent (0 < x <= 100, scale 2) | Применённый процент. |
Priority | int | Да | Priority (0..100000) | Приоритет победившего правила (INV-3, детерминизм). |
CommissionBase | MoneyModel | Да | CalculationBase == BasePrice ? BasePrice : FinalPrice (из входа) | Сумма-база расчёта. |
CommissionAmount | MoneyModel | Да | вычисление | CommissionBase.Value × CommissionPercent / 100, banker's rounding до 2 знаков; CurrencyId = CommissionBase.CurrencyId. Gross-комиссия. |
PlatformRetentionPercentagePoints | decimal | Да | резолюция условий платформы на asOf (platform-commission-terms.md): агентские (по AgencyCompanyId) → глобальные → fallback 0 | Удержание ПИН в процентных пунктах ставки застройщика (диапазон условий 0.25–1.50; 0 — условий нет). |
PlatformFeeAmount | MoneyModel | Да | вычисление | CommissionBase.Value × PlatformRetentionPercentagePoints / 100, banker's, 2 знака (та же формула, что INV-10 начисления); 0 при fallback. |
AgencyPoolAmount | MoneyModel | Да | вычисление | CommissionAmount − PlatformFeeAmount — пул агентской стороны (net для витрины; деление пула агент/агентство — политика агентства, витрине не отдаётся). |
Алгоритм
- Контекст и доступ. Актор — системный межсервисный клиент
catalog: авторизация по токену с audience
pin.rewards.internal; неверный/отсутствующий audience → UNAUTHORIZED (401). Правила читаются
cross-tenant по явному DeveloperCompanyId из тела.
- Проверка входа.
DeveloperCompanyId != Guid.Empty; PropertyType — известный enum; AreaTotalM2 ∈ (0, 10000], Rooms ∈ [0, 20] (если задан); BasePrice/FinalPrice: Value >= 0,
BasePrice.CurrencyId == FinalPrice.CurrencyId и валюта допустима (RUB-only, ADR-0048) — иначе
REWARDS_CURRENCY_MISMATCH (400). Существование DeveloperCompanyId/AgencyCompanyId в identity не
проверяется (без синхронного вызова: несуществующий/без правил застройщик → пустая выборка →
HasRule = false; несуществующее агентство → глобальные условия). Момент подбора asOf = AsOf ?? UtcNow.
- Что читаем. Активные правила застройщика на момент
asOf: TenantId = DeveloperCompanyId,
Status = Active, не удалённые, действующие по периоду (EffectiveFrom/EffectiveTo). Правил у
застройщика десятки — выборка берётся целиком (ADR-0056; шаг 1 алгоритма подбора,
reward-rule.md).
- Бизнес-правила и маппинг. Выборку фильтруют по предикатам
Conditions (семантика И, «пусто = any»;
объект без Rooms не проходит правило с диапазоном комнат; unknown PropertyType — fail-closed). Из
подошедших выбирается правило с максимальным Priority (единственно — INV-3). Нет подошедших →
HasRule = false, AppliedRule = null. Иначе строится AppliedPropertyObjectCommissionModel:
CommissionBase = CalculationBase == BasePrice ? BasePrice : FinalPrice;
CommissionAmount.Value = CommissionBase.Value × CommissionPercent / 100, округление banker's до 2 знаков;
CommissionAmount.CurrencyId = CommissionBase.CurrencyId. Маскирование — на стороне catalog (инвестору
не показывать).
- Удержание платформы и пул агентства. Резолюция условий платформы на
asOf — тот же алгоритм, что
при создании начисления (platform-commission-terms.md):
условия агентства AgencyCompanyId (если передан) → глобальные условия → fallback
PlatformRetentionPercentagePoints = 0. Расчёт: PlatformFeeAmount = CommissionBase × PlatformRetentionPercentagePoints / 100 (banker's, 2 знака), AgencyPoolAmount = CommissionAmount − PlatformFeeAmount. Аномалия конфигурации (удержание ≥ ставки правила, INV-PT-4 — пул ≤ 0) не
маскируется: цифры отдаются как есть + warn-метрика; catalog обязан скрывать net-блок при
AgencyPoolAmount <= 0 (fail-closed отображения) — фактическое начисление по такой сделке не создастся
(DLQ REWARDS_PLATFORM_RETENTION_EXCEEDS_RULE) до исправления условий.
- Результат.
PropertyObjectCommissionResultModel → 200 (и при HasRule = true, и при false);
REWARDS_CURRENCY_MISMATCH/ValidationError → 400; неверный audience → 401.
Диаграмма
Ошибки и failure modes
Общие коды (UNAUTHORIZED 401 при неверном audience, ValidationError 400, FORBIDDEN 403) — в
api/README.md «Общие ошибки», здесь не дублируются. Операционно-специфичные:
| Код | HTTP status | Условие | Сообщение | Retry |
|---|
REWARDS_CURRENCY_MISMATCH | 400 | BasePrice.CurrencyId != FinalPrice.CurrencyId или валюта недопустима (RUB-only, ADR-0048) | «Валюта базовой и конечной цены должны совпадать» | no retry |
INTERNAL_ERROR | 500 | Инфраструктурный сбой (PostgreSQL) | generic | retry (backoff; при недоступности rewards — catalog скрывает блок комиссии, graceful degradation — ../README.md «Зависимости») |
Отсутствие подходящего правила не ошибка: 200 + HasRule = false (catalog скрывает блок комиссии).
Предусловия и постусловия
- Предусловия. Вызывающий — доверенный m2m-клиент
catalog с токеном audience pin.rewards.internal
(internal-область не доступна через публичный gateway). У застройщика DeveloperCompanyId могут быть
активные правила вознаграждения — либо ни одного. Ролевой гейтинг показа (скрыть от инвестора, показать
агенту/менеджеру по rewards.commission.view-property-object) выполняет catalog, а не rewards.
- Постусловия. Операция только читает: состояние
rewards не меняется, события не публикуются,
начисление не создаётся. Возвращён детерминированный результат подбора — либо HasRule = false (правило
не подобрано), либо HasRule = true c AppliedRule (правило максимального Priority, INV-3,
рассчитанная gross-комиссия, удержание платформы и пул агентства). Тот же алгоритм подбора правила и
резолюции условий платформы при завершении сделки
(crm-deal-completed.md) на тех же атрибутах, AgencyCompanyId
и asOf даст те же цифры (согласованность превью и фактического начисления).
Acceptance-критерии
- Happy path (правило подобрано). Дано: валидный m2m-токен (audience
pin.rewards.internal), у
застройщика есть активное правило, подходящее по Conditions, у агентства AgencyCompanyId — активные
условия платформы 1.00 п.п. Когда: POST /internal/v1/commission/calculate-property-object. Тогда:
200, HasRule = true, AppliedRule c AppliedRuleId/RuleName/CalculationBase/CommissionPercent/
Priority, CommissionBase = база по CalculationBase, CommissionAmount = база × % / 100 (banker's,
2 знака), PlatformRetentionPercentagePoints = 1.00, PlatformFeeAmount = база × 1.00 / 100,
AgencyPoolAmount = CommissionAmount − PlatformFeeAmount, ResolvedAsOf = AsOf ?? UtcNow.
- Условия платформы не заведены (fallback). Дано: ни агентских (или
AgencyCompanyId не передан), ни
глобальных активных условий на asOf. Когда: POST. Тогда: 200,
PlatformRetentionPercentagePoints = 0, PlatformFeeAmount = 0,
AgencyPoolAmount = CommissionAmount — витрина показывает полный пул.
- Аномалия конфигурации (пул ≤ 0). Дано: удержание платформы ≥
CommissionPercent применённого
правила (INV-PT-4). Когда: POST. Тогда: 200 с цифрами как есть (AgencyPoolAmount <= 0) +
warn-метрика; catalog скрывает net-блок (fail-closed отображения).
- Нет правила. Дано: под атрибуты не подходит ни одно активное правило (или правил нет вовсе / застройщик
неизвестен). Когда:
POST. Тогда: 200, HasRule = false, AppliedRule = null — не 404 (catalog
скрывает блок комиссии).
- Детерминизм приоритета. Дано: под атрибуты подходят несколько активных правил с разными
Priority.
Когда: POST. Тогда: выбирается ровно правило с максимальным Priority (INV-3), результат воспроизводим
при повторе.
- Несовпадение валют. Дано:
BasePrice.CurrencyId != FinalPrice.CurrencyId (или валюта недопустима —
RUB-only, ADR-0048). Когда: POST. Тогда: 400 REWARDS_CURRENCY_MISMATCH, расчёт не выполняется.
- Границы входа. Дано:
AreaTotalM2 <= 0 или > 10000, либо Rooms вне 0..20, либо пустой
DeveloperCompanyId. Когда: POST. Тогда: 400 ValidationError.
- Unknown PropertyType (fail-closed). Дано:
PropertyType вне известного enum. Когда: POST. Тогда:
правило с непустым PropertyTypes не подходит; если подходит только правило без ограничения по типу —
HasRule = true по нему, иначе HasRule = false; ошибки нет.
- Неверный audience. Дано: токен без audience
pin.rewards.internal (или без токена). Когда: POST.
Тогда: 401 UNAUTHORIZED — до обработки, правила не читаются.
- Деградация rewards. Дано:
rewards/PostgreSQL недоступны. Когда: POST. Тогда: 500
INTERNAL_ERROR (retry с backoff на стороне catalog); catalog временно скрывает блок комиссии
(graceful degradation), функция объекта в витрине не ломается.
Совместимость и наблюдаемость
- Контракт межсервисный (
rewards ↔ catalog): новые optional поля PropertyObjectCommissionResultModel/
AppliedPropertyObjectCommissionModel — additive; удаление/переименование/смена типа, смена маршрута — breaking
(координация с catalog, migration note).
- Unknown
PropertyType из витрины трактуется fail-closed (правило с непустым PropertyTypes не подходит) —
правило совместимо с добавлением новых типов объектов в chessboard.
- Logs:
clientId/audience, DeveloperCompanyId, HasRule, AppliedRuleId, latency — суммы не
логируются (Sensitive). Metrics: rewards_calculate_property_object_commission_total{result=has_rule|no_rule},
latency histogram (SLO p95 < 100 мс — ../README.md). Traces: OTel span
rewards.calculate-property-object-commission (сквозной trace). Dashboard/runbook — ../README.md.
Обратные ссылки
Автоссылки: где используется этот документ.
Связанные документы