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

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.

Metadata

ПолеЗначение
Сервис/контекстrewards
API areainternal
Feature groupinternal-commission
Статусdraft
Документdocs/06-services/rewards/api/internal/calculate-property-object-commission.md

Актор и доступ

ПроверкаЗначение
Actor typeSystem — m2m-клиент catalog
Auth policy / Permissionsm2m 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)
Auditnone (read; вызовы логируются по client-id/audience, суммы — не логируются)

HTTP-контракт

ПолеЗначение
MethodPOST (read-only: тело несёт атрибуты объекта недвижимости и цены, состояние не мутирует)
Route/internal/v1/commission/calculate-property-object
Success status200 (в т.ч. HasRule = false — валидный ответ, не 404)
Idempotency headerнет (безопасное чтение, повтор идемпотентен)
Correlationсквозной trace через заголовок traceparent (от catalog)

Входные данные / параметры запроса

Тело — CalculatePropertyObjectCommissionRequest (маппится 1:1 в CalculatePropertyObjectCommissionQuery).

ПолеИсточникТипОбязательностьВалидация / ОграниченияБезопасность
DeveloperCompanyIdbodyGuidДа!= Guid.Emptytenant правил (cross-tenant m2m-чтение)
AgencyCompanyIdbodyGuid?Нетесли задан — != Guid.Empty; агентство смотрящего агента — для резолюции per-agency условий платформы; null → резолюция только по глобальным условиям
PropertyTypebodyPropertyType (enum-string)ДаApartment/Parking/Commercial; unknown → правило с непустым PropertyTypes не подходит (fail-closed)
LayoutIdbodyGuid?Нетесли задан — != Guid.Empty
LayoutGroupIdbodyGuid?Нетесли задан — != Guid.Empty
Roomsbodyint?Нет0..20; null (парковка/коммерция) не проходит правило с заданным диапазоном комнат
AreaTotalM2bodydecimalДа> 0, <= 10000, scale 2
BasePricebodyMoneyModelДаValue >= 0, scale 2; CurrencyId — RUB-only (ADR-0048)
FinalPricebodyMoneyModelДаValue >= 0, scale 2; CurrencyId == BasePrice.CurrencyId (иначе REWARDS_CURRENCY_MISMATCH)
AsOfbodyDateTimeOffset?Нетnull → серверный DateTimeOffset.UtcNow; момент действия правил (EffectiveFrom/EffectiveTo) и условий платформы
Audiencem2m claimsstringДа (вычисляется)= pin.rewards.internal (иначе 401)логируется

Модель ответа PropertyObjectCommissionResultModel

ПолеТипОбязательностьИсточникMaskingОписание
HasRuleboolДаcomputedtrue — правило подобрано; falsecatalog скрывает блок комиссии.
ResolvedAsOfDateTimeOffsetДаAsOf ?? UtcNowМомент подбора (echo для кэша/дебага витрины).
AppliedRuleAppliedPropertyObjectCommissionModel?Нет (null при HasRule = false)вычислениеSensitiveПодобранное правило и рассчитанная комиссия; null — правила нет.

Вложенная модель AppliedPropertyObjectCommissionModel (проекция ../domain/reward-rule.md)

ПолеТипОбязательностьИсточникОписание
AppliedRuleIdGuidДаRewardRuleEntity.IdИдентификатор применённого правила.
RuleNamestringДаRewardRuleEntity.NameНазвание правила (витрина показывает «по правилу …»).
CalculationBasestring (enum CommissionCalculationBase)ДаCalculationBase: BasePrice/FinalPriceБаза, от которой считается процент.
CommissionPercentdecimalДаCommissionPercent (0 < x <= 100, scale 2)Применённый процент.
PriorityintДаPriority (0..100000)Приоритет победившего правила (INV-3, детерминизм).
CommissionBaseMoneyModelДаCalculationBase == BasePrice ? BasePrice : FinalPrice (из входа)Сумма-база расчёта.
CommissionAmountMoneyModelДавычислениеCommissionBase.Value × CommissionPercent / 100, banker's rounding до 2 знаков; CurrencyId = CommissionBase.CurrencyId. Gross-комиссия.
PlatformRetentionPercentagePointsdecimalДарезолюция условий платформы на asOf (platform-commission-terms.md): агентские (по AgencyCompanyId) → глобальные → fallback 0Удержание ПИН в процентных пунктах ставки застройщика (диапазон условий 0.25–1.50; 0 — условий нет).
PlatformFeeAmountMoneyModelДавычислениеCommissionBase.Value × PlatformRetentionPercentagePoints / 100, banker's, 2 знака (та же формула, что INV-10 начисления); 0 при fallback.
AgencyPoolAmountMoneyModelДавычислениеCommissionAmount − PlatformFeeAmount — пул агентской стороны (net для витрины; деление пула агент/агентство — политика агентства, витрине не отдаётся).

Алгоритм

  1. Контекст и доступ. Актор — системный межсервисный клиент catalog: авторизация по токену с audience pin.rewards.internal; неверный/отсутствующий audience → UNAUTHORIZED (401). Правила читаются cross-tenant по явному DeveloperCompanyId из тела.
  2. Проверка входа. 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.
  3. Что читаем. Активные правила застройщика на момент asOf: TenantId = DeveloperCompanyId, Status = Active, не удалённые, действующие по периоду (EffectiveFrom/EffectiveTo). Правил у застройщика десятки — выборка берётся целиком (ADR-0056; шаг 1 алгоритма подбора, reward-rule.md).
  4. Бизнес-правила и маппинг. Выборку фильтруют по предикатам 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 (инвестору не показывать).
  5. Удержание платформы и пул агентства. Резолюция условий платформы на 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) до исправления условий.
  6. Результат. PropertyObjectCommissionResultModel200 (и при HasRule = true, и при false); REWARDS_CURRENCY_MISMATCH/ValidationError400; неверный audience → 401.

Диаграмма

Ошибки и failure modes

Общие коды (UNAUTHORIZED 401 при неверном audience, ValidationError 400, FORBIDDEN 403) — в api/README.md «Общие ошибки», здесь не дублируются. Операционно-специфичные:

КодHTTP statusУсловиеСообщениеRetry
REWARDS_CURRENCY_MISMATCH400BasePrice.CurrencyId != FinalPrice.CurrencyId или валюта недопустима (RUB-only, ADR-0048)«Валюта базовой и конечной цены должны совпадать»no retry
INTERNAL_ERROR500Инфраструктурный сбой (PostgreSQL)genericretry (backoff; при недоступности rewardscatalog скрывает блок комиссии, 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), функция объекта в витрине не ломается.

Совместимость и наблюдаемость

  • Контракт межсервисный (rewardscatalog): новые 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.

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

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

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