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

POST /api/public/crm/payment-schedule — Серверный график платежей (витрина)

Назначение

Публичный серверный расчёт графика платежей по «Программам покупки» застройщика для карточки объекта недвижимости витрины. Формула считается на сервере по единому правилу — фронт запрашивает готовый график у CRM. По объекту и выбранному способу оплаты возвращает полный график: первый платёж, помесячные суммы, milestone-платежи, аннуитет ипотеки, остаток. Метод только читает: агрегаты PurchaseProgramEntity/InstallmentPresetEntity не изменяются, события не публикуются. Дом полей и дом формулыpurchase-program.md (§ «Серверный расчёт графика платежей»). POST (а не GET) — чтобы нести структурированное тело калькуляции; семантика при этом чистая read-only (безопасно ретраить).

Metadata

ПолеЗначение
Сервис/контекстcrm
API areapublic (гейт витрины: JWT инвестора или реферальная cookie)
Feature grouppurchase-program
Статусdraft
Документdocs/06-services/crm/api/public/get-payment-schedule.md

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

ПроверкаЗначение
Actor typeAnonymous (JWT инвестора/реф-cookie опциональны, на расчёт не влияют)
Auth policy / Permissionsnone (публичное чтение конфигурации в рамках карточки объекта); rate limit по IP
Scope (RBAC)не применяется (актор — вне tenant)
Record-levelне применяется
Tenant isolationTenantId = DeveloperCompanyId, вычисляется по объекту (chessboard-lookup, шаг 2), не из claims актора
Auditnone (read; PII отсутствует — только цена и конфигурация оплаты)
Доступ

Вызвать может любой аноним с витрины — JWT инвестора и реферальная cookie опциональны и на расчёт не влияют; permission не требуется, доступ ограничен только rate limit по IP. TenantId = DeveloperCompanyId вычисляется не из claims актора, а по объекту через chessboard-lookup (шаг 2 алгоритма).

HTTP-контракт

ПолеЗначение
MethodPOST
Success status200
Route/api/public/crm/payment-schedule
Idempotency headerне применяется (чистое чтение без побочных эффектов)
Correlationсквозной trace через заголовок traceparent

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

ПолеИсточникТипОбязательностьВалидация / ОграниченияБезопасность
PropertyObjectIdbodyGuidДа!= Guid.Empty; существующий объект chessboard (шаг 2) — из него DeveloperCompanyId (tenant программы) и базовая цена
BasePricebodydecimal?Нетесли задан — > 0 (numeric(18,2), RUB); override цены объекта для предпросмотра калькулятора
PaymentMethodbodyPaymentMethodДаenum-строка (FullPayment/Installment/Mortgage); unknown → VALIDATION_FAILED
InstallmentPresetIdbodyGuid?Условнообязателен и != Guid.Empty при PaymentMethod = Installment; иначе игнорируется
Важно

Программа застройщика анонимно резолвится только через объект (иначе — утечка конфигурации чужого tenant). BasePrice — лишь override суммы для «что если» на карточке.

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

ПолеТипОбязательностьИсточникMaskingОписание
PaymentMethodPaymentMethodДаecho запросаСпособ оплаты (enum-строка).
BasePricedecimalДаBasePrice ?? снапшот цены объектаБазовая цена (RUB).
DiscountPercentdecimalДавычисление (программа/пресет)Применённая скидка, %.
DiscountedPricedecimalДавычислениеЦена со скидкой.
InitialPaymentdecimalДавычислениеПервый платёж (месяц 0).
TermMonthsintДавычислениеГоризонт графика, месяцев (0 для FullPayment).
TotalAmountdecimalДавычислениеСумма всех платежей (FullPayment/Installment = DiscountedPrice; Mortgage = сумма аннуитетов).
OverpaymentAmountdecimalДавычислениеMortgage: TotalAmount − DiscountedPrice; иначе 0.
ItemsPaymentScheduleItemListДавычислениеПомесячный график (container-model ниже).

PaymentScheduleItemList (container-model, ContractVersion = 1)

Коллекция внутри именованной обёртки: { ContractVersion: int, Items: PaymentScheduleItem[] }. Это вычисляемая ограниченная (≤ TermMonths + 1) последовательность одного ответа, а не страничная выборка БД.

PaymentScheduleItem

ПолеТипОбязательностьОписание
MonthintДа0 — первый платёж; 1..TermMonths — помесячные.
AmountdecimalДаПлатёж месяца (RUB).
MonthlyPartdecimal?НетЧасть MonthlyFixedPayment (Installment).
MilestonePartdecimal?НетСумма milestone-платежей месяца (Installment).
InterestPartdecimal?НетПроцентная часть аннуитета (Mortgage).
PrincipalPartdecimal?НетПогашение тела (Mortgage).
RemainingBalancedecimalДаНепогашенный остаток после платежа; на последнем месяце = 0 (INV-PPR-4).

Алгоритм

  1. Контекст и доступ. Публичный метод; актор Anonymous (JWT/реф-cookie опциональны и на расчёт не влияют). Tenant в контексте отсутствует — вычисляется на шаге 2 из объекта.
  2. Проверка входа и предусловий.
    • формат: PropertyObjectId != Guid.Empty; PaymentMethod — известное enum-значение; PaymentMethod = Installment ⇒ InstallmentPresetId задан и != Guid.Empty; BasePrice, если задан, > 0 → иначе VALIDATION_FAILED;
    • существование объекта: lookup в chessboard по PropertyObjectId → нет → CRM_PROPERTY_OBJECT_NOT_FOUND; из ответа берём DeveloperCompanyId и базовую цену объекта;
    • допустимость состояния: способ включён у застройщика — FullPayment ⇒ IsFullPaymentEnabled, Mortgage ⇒ IsMortgageEnabled (шаг 3); иначе CRM_PAYMENT_METHOD_DISABLED;
    • TenantId = DeveloperCompanyId (публичный метод — tenant из объекта, не из claims).
  3. Что читаем. Живую программу покупки застройщика (единственную, unique, INV-PPR-1) с её пресетами рассрочки → нет → CRM_PURCHASE_PROGRAM_NOT_FOUND. Для Installment — конкретный пресет по InstallmentPresetId (живой) → нет → CRM_INSTALLMENT_PRESET_NOT_FOUND.
  4. Бизнес-правила и расчёт (дом формулы — purchase-program.md § «Серверный расчёт»): basePrice = BasePrice ?? снапшот цены объекта. По способу:
    • FullPayment: DiscountPercent = FullPaymentDiscountPercent; discountedPrice = basePrice × (1 − DiscountPercent/100); Items = [{Month 0, Amount = discountedPrice, RemainingBalance = 0}]; InitialPayment = discountedPrice, TermMonths = 0.
    • Installment: DiscountPercent = preset.DiscountPercent; discountedPrice = basePrice × (1 − DiscountPercent/100); Month 0 = discountedPrice × InitialPaymentPercent/100; для Month 1..max(preset.TermMonths, max(Milestone.Month)): MonthlyPart = MonthlyFixedPayment (пока Month ≤ TermMonths) + MilestonePart = сумма milestone месяца (percent → % от discountedPrice; fixedValue; remainder → непогашенный остаток), с пересчётом RemainingBalance; TotalAmount = discountedPrice. Остаток последнего месяца обязан быть 0 (INV-PPR-4) — иначе конфигурация битая → CRM_INSTALLMENT_SCHEDULE_INCOMPLETE.
    • Mortgage: DiscountPercent = 0; discountedPrice = basePrice (первоначальный взнос — банковский процесс этапов сделки, в витринную оценку не входит); аннуитет по MortgageAnnualRatePercent/12 на MortgageTermMonths с разбиением InterestPart/PrincipalPart; TotalAmount = Σ Amount, OverpaymentAmount = TotalAmount − discountedPrice. Округление до копеек (numeric(18,2)), расхождение округления добирается последним платежом (остаток = 0).
  5. Результат. PaymentScheduleModel (поля из шага 4) → HTTP 200; при отказе — соответствующий код.

Диаграмма

Побочные эффекты

ТипДетали
Внешние вызовыchessboard lookup объекта по PropertyObjectId (DeveloperCompanyId + цена; timeout 2 c, retry 1)
Кэшдопустим edge-кэш ответа по ключу propertyObjectId+method+preset+basePrice (расчёт детерминирован по входу)

Ошибки и failure modes

Ошибки
КодHTTP statusУсловиеСообщениеRetry
VALIDATION_FAILED400неизвестный PaymentMethod, отсутствует InstallmentPresetId для рассрочки, BasePrice ≤ 0«Проверьте параметры расчёта»no retry
CRM_PROPERTY_OBJECT_NOT_FOUND404объект не найден в chessboard«Объект не найден»no retry
CRM_PURCHASE_PROGRAM_NOT_FOUND404у застройщика объекта нет живой программы покупки«Условия оплаты недоступны»no retry
CRM_INSTALLMENT_PRESET_NOT_FOUND404пресет не найден/архивирован в программе«Выбранная рассрочка недоступна»no retry
CRM_PAYMENT_METHOD_DISABLED409способ выключен (IsFullPaymentEnabled/IsMortgageEnabled = false)«Способ оплаты недоступен»no retry
CRM_INSTALLMENT_SCHEDULE_INCOMPLETE422пресет не погашает 100% discountedPrice (битая конфигурация, INV-PPR-4)«Ошибка конфигурации рассрочки»no retry
CHESSBOARD_UNAVAILABLE503chessboard недоступен на шаге 2«Сервис временно недоступен»retry

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

  • Формула — по дому purchase-program.md. Новые optional-поля ответа (OverpaymentAmount, разбиение *Part) — additive; удаление/переименование/смена типа полей или неизвестное значение PaymentMethod — breaking (migration + rollout). PaymentScheduleItemList.ContractVersion версионирует форму графика.
  • Logs: payment-schedule computed/failed (PropertyObjectId, PaymentMethod, PresetId, TenantId; PII нет); metrics crm_payment_schedule_total{method,result}, latency p95 < 500 мс; traces — сквозной trace через traceparent; dashboard Grafana pin-crm.

Acceptance Criteria

  • AC-1 (100% оплата). Дано: у застройщика объекта IsFullPaymentEnabled = true. Когда: PaymentMethod = FullPayment. Тогда: DiscountPercent = FullPaymentDiscountPercent, единственный Items[Month=0] с Amount = DiscountedPrice и RemainingBalance = 0, TermMonths = 0, 200.
  • AC-2 (рассрочка). Дано: живой пресет по InstallmentPresetId, погашающий 100% (INV-PPR-4). Когда: PaymentMethod = Installment. Тогда: Items[0] = первоначальный взнос, помесячные MonthlyPart + MilestonePart, остаток последнего месяца = 0, TotalAmount = DiscountedPrice, 200.
  • AC-3 (ипотека). Дано: IsMortgageEnabled = true. Когда: PaymentMethod = Mortgage. Тогда: аннуитет по MortgageAnnualRatePercent/MortgageTermMonths с разбиением InterestPart/PrincipalPart, OverpaymentAmount = TotalAmount − DiscountedPrice > 0, RemainingBalance последнего месяца = 0, 200.
  • AC-4 (рассрочка без пресета). Дано: PaymentMethod = Installment, InstallmentPresetId не задан. Когда: запрос. Тогда: отказ VALIDATION_FAILED (400).
  • AC-5 (способ выключен). Дано: выбранный способ выключен у застройщика. Когда: запрос. Тогда: отказ CRM_PAYMENT_METHOD_DISABLED (409).
  • AC-6 (битая конфигурация рассрочки). Дано: пресет не погашает 100% DiscountedPrice. Когда: запрос. Тогда: отказ CRM_INSTALLMENT_SCHEDULE_INCOMPLETE (422).
  • AC-7 (объект/программа не найдены). Дано: PropertyObjectId отсутствует в chessboard (или у застройщика нет живой программы). Когда: запрос. Тогда: отказ CRM_PROPERTY_OBJECT_NOT_FOUND (404) / CRM_PURCHASE_PROGRAM_NOT_FOUND (404).
  • AC-8 (chessboard недоступен). Дано: chessboard не отвечает на резолве объекта. Когда: запрос. Тогда: отказ CHESSBOARD_UNAVAILABLE (503, retriable). Расчёт не изменяет состояние ни при каком исходе (метод read-only, безопасно ретраить).

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

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

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