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

POST /api/developer/crm/purchase-program/presets — Создать пресет рассрочки

Менеджер застройщика добавляет пресет рассрочки («Рассрочка 12 месяцев») в единственную программу покупки своего застройщика: первоначальный взнос, фиксированный ежемесячный платёж, срок, скидку и milestone-платежи (typed jsonb). Дом полей, инвариантов и формулы графика — purchase-program.md.

Назначение

Заводит новый пресет рассрочки внутри программы покупки застройщика (TenantId = DeveloperCompanyId): Name, InitialPaymentPercent, MonthlyFixedPayment, TermMonths, DiscountPercent, MilestonePayments (MilestonePaymentList, ContractVersion = 1). Вызывает менеджер застройщика; на выходе — карточка созданного пресета. График должен быть консистентен: один хвостовой remainder (INV-PPR-3) и полное погашение discountedPrice (INV-PPR-4).

Metadata

ПолеЗначение
Сервис/контекстcrm
API areadeveloper
Feature grouppurchase-program
Статусdraft
Документdocs/06-services/crm/api/developer/create-installment-preset.md

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

Доступ
ПроверкаЗначение
Actor typeUser (Менеджер застройщика в рамках своего TenantId = DeveloperCompanyId; администратор платформы — тот же permission)
Auth policy / Permissionscrm.purchase-programs.manage
Scope (RBAC)организация (застройщик): менеджер настраивает программу покупки своего застройщика
Record-levelпресет создаётся в программе застройщика PurchaseProgramEntity.TenantId == actor.DeveloperCompanyId
Tenant isolationTenantId = DeveloperCompanyId в каждом запросе (ADR-0052)
Auditcreate (CreatedByUserId = actor, инкремент Version); программа-родитель — version bump

HTTP-контракт

ПолеЗначение
MethodPOST
Route/api/developer/crm/purchase-program/presets (id программы не в маршруте — у застройщика ровно один живой агрегат, INV-PPR-1)
Success status201 (пресет создан; тело — карточка пресета InstallmentPresetModel)
Idempotency headerне применяется — создание не идемпотентно (повтор даёт дубль по Name(ci) → 409, INV уникальности)
Correlationсквозной trace через заголовок traceparent

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

ПолеИсточникТипОбязательностьВалидация / ОграниченияБезопасность
TenantIdidentity claimsGuidДаRequired; = DeveloperCompanyIdTenant isolation
ActorUserIdidentity claimsGuidДаJWT менеджера; record-level + CreatedByUserId
NamebodystringДа1..128, не пустое после trim; уникально по (PurchaseProgramId, lower(Name)) среди живых
Codebodystring?Нет0..64; display-код, не доменный ключ
InitialPaymentPercentbodydecimalДаnumeric(5,2), >= 0 and <= 100 (INV-PPR-2)
MonthlyFixedPaymentbodydecimalДаnumeric(18,2), >= 0 (RUB, как цена объекта недвижимости)
TermMonthsbodyintДа> 0 and <= 600 (INV-PPR-2)
DiscountPercentbodydecimalДаnumeric(5,2), >= 0 and <= 100, default 0
MilestonePaymentsbodyMilestonePaymentList (typed jsonb, ContractVersion = 1)Да (допустим пустой Items)каждый Item: Month ∈ [1, TermMonths]; Kind — enum-строкой (percent/fixed/remainder, Enum.IsDefined); Value — по Kind; INV-PPR-3 (см. шаг 2)
SortOrderbodyintНет>= 0, default 0 (порядок показа опции)

Модель MilestonePaymentList и enum MilestoneKind — дом purchase-program.md («Модели JSONB»); здесь не переописываются.

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

Проекция дочерней сущности — дом полей purchase-program.md; ниже только состав карточки, возвращаемой create/list. TenantId/PurchaseProgramId наружу не отдаются.

ПолеТипОбязательностьИсточникMaskingОписание
IdGuidДаInstallmentPresetEntity.IdИдентификатор пресета (Deal.InstallmentPresetId ссылается сюда).
NamestringДаInstallmentPresetEntity.NameНазвание («Рассрочка 12 месяцев»).
Codestring?НетInstallmentPresetEntity.CodeDisplay-код пресета.
InitialPaymentPercentdecimalДаInstallmentPresetEntity.InitialPaymentPercentПервоначальный взнос, % от цены.
MonthlyFixedPaymentdecimalДаInstallmentPresetEntity.MonthlyFixedPaymentФиксированный ежемесячный платёж (RUB).
TermMonthsintДаInstallmentPresetEntity.TermMonthsСрок рассрочки, месяцы.
DiscountPercentdecimalДаInstallmentPresetEntity.DiscountPercentСкидка пресета от цены объекта недвижимости.
MilestonePaymentsMilestonePaymentListДаInstallmentPresetEntity.MilestonePaymentsПромежуточные обязательные платежи (typed jsonb).
SortOrderintДаInstallmentPresetEntity.SortOrderПорядок показа опции.
VersionlongДаInstallmentPresetEntity.VersionОптимистическая версия (== 1 после создания).

Алгоритм

  1. Контекст и доступ. Выполняет менеджер застройщика (User, permission crm.purchase-programs.manage); tenant — TenantId = DeveloperCompanyId. Создание не идемпотентно.

  2. Проверка входа и предусловий.

    • Name не пусто после trim (1..128); Code ≤ 64; InitialPaymentPercent/DiscountPercent ∈ [0,100]; MonthlyFixedPayment >= 0; TermMonths ∈ (0,600] (INV-PPR-2); SortOrder >= 0; для каждого MilestonePayments.Items[i]: Month ∈ [1, TermMonths], Kind ∈ {percent, fixed, remainder} (unknown → ошибка), Value по Kind (percent → (0,100], fixed → > 0, remainder → игнорируется) — иначе VALIDATION_FAILED;
    • INV-PPR-3: среди Items не более одного remainder, и он — последний по Month; иначе CRM_INSTALLMENT_REMAINDER_INVALID;
    • существование родителя: у застройщика есть живой агрегат программы покупки (единственный, INV-PPR-1) — иначе CRM_PURCHASE_PROGRAM_NOT_FOUND;
    • уникальность имени (pre-check): живой пресет с таким Name (ci) в программе — если есть → CRM_INSTALLMENT_PRESET_NAME_DUPLICATE;
    • scope: программа принадлежит tenant актора (кросс-tenant недостижим).
  3. Что читаем. Программу покупки застройщика под блокировкой строки (с дочерними пресетами — единицы строк) — блокировка сериализует параллельные вставки пресетов и защищает version bump агрегата. Для проверки INV-PPR-4 используется эталонная цена калькулятора (константа проверки, дом формулы — purchase-program.md, «Серверный расчёт графика платежей»); расчёт от конкретного объекта недвижимости не зависит.

  4. Бизнес-правила и переходы. Собирается MilestonePaymentList { ContractVersion = 1, Items } и в программу добавляется пресет. Закрепляется:

    • INV-PPR-3: единственный remainder — хвостовой (дубль guard шага 2, инвариант в модели);
    • INV-PPR-4 (формула purchase-program.md): discountedPrice = referencePrice × (1 − DiscountPercent/100); месяц 0 = discountedPrice × InitialPaymentPercent/100; месяцы 1..max(TermMonths, maxMilestoneMonth) = MonthlyFixedPayment (пока месяц ≤ TermMonths) + milestone месяца (percent → от discountedPrice, fixedValue, remainder → непогашенный остаток); остаток по графику не уходит в минус (нет переплаты) и в конце равен 0 (в пределах эпсилон округления) — иначе CRM_INSTALLMENT_SCHEDULE_INCOMPLETE.

    Маппинг поле-в-поле: Id — новый идентификатор; PurchaseProgramId = Id программы; TenantId = DeveloperCompanyId; Name = trim(Name); Code; InitialPaymentPercent/MonthlyFixedPayment/TermMonths/DiscountPercent/SortOrder — из входа; MilestonePayments — собранный список; CreatedByUserId = ActorUserId; Version = 1. Фиксируется доменный факт PurchaseProgramChanged.

  5. Что меняется. Появляется новый пресет в графе программы; программа-родитель получает version bump. Гонку параллельного create того же имени ловит partial-unique ux_installment_presets_program_nameCRM_INSTALLMENT_PRESET_NAME_DUPLICATE (pre-check шага 2 — для дружелюбного ответа на неконкурентном пути).

  6. События. Интеграционных Kafka-событий нет: конфигурация покупки читается синхронно, топика pin.crm.purchase-program не существует (наружу PaymentMethod виден только через deal-stage-changed.md). Внутренний доменный факт PurchaseProgramChanged инвалидирует кэш конфигурации/калькулятора графика платежей.

  7. Согласованность. Вставка пресета и version bump программы фиксируются в одном согласованном изменении (атомарно). Нарушение unique-индекса откатывает изменение → duplicate-код (шаг 5).

  8. Результат. Карточка InstallmentPresetModel созданного пресета (Id, Name, Code, InitialPaymentPercent, MonthlyFixedPayment, TermMonths, DiscountPercent, MilestonePayments, SortOrder, Version = 1) → HTTP 201.

Диаграмма

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

ТипДетали
Integration eventsnone (программа покупки — внутренний справочник; kafka-топика нет, конфиг читается синхронно)
External callsnone
Cache / projections / searchдоменное PurchaseProgramChanged → инвалидация кэша конфигурации и серверного калькулятора графика платежей
Notificationsnone

Ошибки и failure modes

Ошибки
КодHTTP statusУсловиеСообщениеRetry
VALIDATION_FAILED400Name пусто / проценты вне [0,100] / TermMonths вне (0,600] / Month вне [1,TermMonths] / неизвестный Kind / Value не по Kind«Проверьте корректность полей пресета»no retry
CRM_INSTALLMENT_REMAINDER_INVALID422более одного remainder либо remainder не последний по Month (INV-PPR-3)«Остаток (remainder) должен быть один и последним в графике»no retry
CRM_INSTALLMENT_SCHEDULE_INCOMPLETE422график не погашает 100% discountedPrice на эталонной цене (INV-PPR-4)«График платежей не покрывает полную стоимость»no retry
CRM_PURCHASE_PROGRAM_NOT_FOUND404у застройщика нет живого агрегата PurchaseProgramEntity в TenantId«Программа покупки не настроена»no retry
CRM_INSTALLMENT_PRESET_NAME_DUPLICATE409живой пресет с таким Name(ci) уже есть в программе (pre-check либо ux_installment_presets_program_name)«Пресет с таким названием уже существует»no retry (сменить название)

Инвариант архива CRM_INSTALLMENT_PRESET_IN_USE (INV-PPR-5) к create не относится — см. purchase-program.md и команду архива пресета.

Предусловия и постусловия

Предусловия (истинны до сценария):

  • актор — менеджер застройщика (или администратор платформы) с permission crm.purchase-programs.manage;
  • у застройщика есть единственный живой агрегат программы покупки (INV-PPR-1) в TenantId = DeveloperCompanyId;
  • нет живого пресета с тем же именем (ci) в этой программе;
  • график консистентен: не более одного хвостового remainder (INV-PPR-3) и полное погашение discountedPrice на эталонной цене (INV-PPR-4).

Постусловия (гарантированы после успеха):

  • в программу добавлен новый живой пресет (Version = 1, CreatedByUserId); программа-родитель получает version bump;
  • график платежей пресета проверен как полный (INV-PPR-4) и с единственным хвостовым remainder (INV-PPR-3);
  • интеграционных Kafka-событий нет; внутренний факт PurchaseProgramChanged инвалидирует кэш конфигурации и серверного калькулятора графика — пресет предлагается при конвертации лида в сделку при следующем чтении (синхронный источник, окна eventual-consistency наружу нет);
  • существующие сделки со ссылками на другие пресеты не затронуты (держат snapshot).

Acceptance Criteria

  • Happy path. Дано: живая программа покупки, уникальное имя, консистентный график. Когда: создание. Тогда: 201, пресет создан (Version = 1), программа получает version bump.
  • Несколько remainder / не последний. Дано: два remainder или remainder не последний по Month. Когда: создание. Тогда: 422 CRM_INSTALLMENT_REMAINDER_INVALID (INV-PPR-3).
  • Неполный график. Дано: график не погашает 100% discountedPrice на эталонной цене. Когда: создание. Тогда: 422 CRM_INSTALLMENT_SCHEDULE_INCOMPLETE (INV-PPR-4).
  • Нет программы. Дано: у застройщика нет живого агрегата программы покупки. Когда: создание. Тогда: 404 CRM_PURCHASE_PROGRAM_NOT_FOUND.
  • Дубль имени. Дано: живой пресет с таким именем (ci) уже есть. Когда: создание (в т.ч. гонка). Тогда: 409 CRM_INSTALLMENT_PRESET_NAME_DUPLICATE.
  • Некорректный milestone. Дано: Month вне [1, TermMonths], неизвестный Kind или Value не по Kind. Когда: создание. Тогда: 400 VALIDATION_FAILED.

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

  • Route/тело стабильны; добавление optional-полей в InstallmentPresetModel / MilestonePayment — additive (без breaking); удаление/переименование/смена типа — breaking (migration + rollout). Kind сериализуется строкой; на входе принимаются только определённые значения (Enum.IsDefined) — unknown → VALIDATION_FAILED. MilestonePaymentList.ContractVersion = 1 — рост версии при смене схемы jsonb.
  • Logs: installment-preset create requested/created/duplicate/schedule-invalid (TenantId, PurchaseProgramId, PresetId, TermMonths; PII нет); metrics: crm_installment_preset_create_total{result}; traces — сквозной trace через traceparent; dashboard pin-crm; runbook — docs/06-services/crm/runbooks/ (будущий путь по конвенции).
Гонка параллельного создания с одинаковым именем

Уникальность Name(ci) проверяется дважды: pre-check на шаге 2 (для дружелюбного 409 на неконкурентном пути) и partial-unique-индекс ux_installment_presets_program_name на сохранении (шаг 5) — он ловит гонку параллельных create с одним именем и тоже отдаёт CRM_INSTALLMENT_PRESET_NAME_DUPLICATE.

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

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

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

  • Сущность, инварианты, формула графика: purchase-program.md (INV-PPR-1 единственный агрегат, INV-PPR-2 диапазоны, INV-PPR-3 хвостовой remainder, INV-PPR-4 полнота графика, модели MilestonePaymentList/MilestoneKind, индекс ux_installment_presets_program_name); потребитель пресета: deal.md (PaymentMethod, InstallmentPresetId); карта событий и область developer — ../../README.md.
  • События агрегата: домен PurchaseProgramChanged (IDomainEvent, внутренний; kafka-канала нет — см. карту событий ../../README.md); наружу PaymentMethod виден только через deal-stage-changed.md.
  • ADR: 0052 tenancy, 0056 simplicity-first.