PUT /api/developer/crm/purchase-program/presets/{presetId} — Редактировать пресет рассрочки
Менеджер застройщика правит параметры одного пресета рассрочки своей программы покупки: название,
первоначальный взнос, ежемесячный платёж, срок, скидку и milestone-график. Операция сохраняет те же
инварианты, что и создание пресета — INV-PPR-2/3/4 (проценты 0–100 и сроки > 0, правило единственного
remainder, полнота графика). Дом полей, инвариантов, enum-ов и формулы расчёта —
purchase-program.md.
Назначение
Обновить конфигурацию одного пресета рассрочки застройщика с оптимистической блокировкой по RowVersion
(параллельная правка тем же/другим менеджером). Правка не ретроактивна: существующие сделки продолжают
ссылаться на пресет по Deal.InstallmentPresetId (deal.md); нетерминальные сделки при
следующем запросе графика пересчитаются по новым параметрам (расчёт серверный, дом формулы —
purchase-program.md), а терминальные (Completed/Cancelled) сделки
остаются read-only и уже согласованный график не пересчитывают.
| Поле | Значение |
|---|
| Сервис/контекст | crm |
| API area | developer |
| Feature group | purchase-program |
| Статус | draft |
| Документ | docs/06-services/crm/api/developer/update-installment-preset.md |
Актор и доступ
| Проверка | Значение |
|---|
| Actor type | User (Менеджер застройщика в рамках своего TenantId = DeveloperCompanyId; Менеджер ПИН / администратор платформы — platform-scope) |
| Auth policy / Permissions | crm.purchase-programs.manage |
| Scope (RBAC) | организация (застройщик): менеджер правит пресеты своей программы покупки |
| Record-level | InstallmentPresetEntity.TenantId == actor.DeveloperCompanyId (пресет принадлежит программе застройщика) |
| Tenant isolation | TenantId = DeveloperCompanyId в каждом запросе (ADR-0052) |
| Audit | update (UpdatedByUserId = actor, UpdatedAt, инкремент Version) |
Пресет правит менеджер застройщика — владелец программы покупки в своём TenantId = DeveloperCompanyId,
либо Менеджер ПИН / администратор платформы (platform-scope). Пресет чужого tenant → 403 CRM_INSTALLMENT_PRESET_FORBIDDEN; несуществующий или архивный пресет в своём tenant → 404 CRM_INSTALLMENT_PRESET_NOT_FOUND (правка архива запрещена).
HTTP-контракт
| Поле | Значение |
|---|
| Method | PUT |
| Route | /api/developer/crm/purchase-program/presets/{presetId} |
| Success status | 200 (пресет обновлён; тело — обновлённая проекция InstallmentPresetModel) |
| Idempotency header | не применяется — идемпотентность естественная (повтор с тем же телом и RowVersion даёт тот же результат; устаревший RowVersion → 409) |
| Correlation | сквозной trace через заголовок traceparent |
Входные данные / параметры запроса
PurchaseProgramId в теле не принимается: родитель пресета неизменен (перенос между программами не
поддерживается). Enum-ы сериализуются строкой; неизвестное значение Kind → VALIDATION_FAILED (строгая
типизация десериализатора).
| Поле | Источник | Тип | Обязательность | Валидация / Ограничения | Безопасность |
|---|
TenantId | identity claims | Guid | Да | Required; = DeveloperCompanyId | Tenant isolation |
ActorUserId | identity claims | Guid | Да | JWT менеджера; пишется в UpdatedByUserId | — |
PresetId | route | Guid | Да | != Guid.Empty; живой пресет в TenantId (шаг 2) | — |
Name | body | string | Да | trim, 1..128, не пустое; уникально по (PurchaseProgramId, lower(Name)) среди живых кроме себя | — |
Code | body | string? | Нет | 0..64; display-код, не доменный ключ | — |
InitialPaymentPercent | body | decimal | Да | numeric(5,2), >= 0 and <= 100 (INV-PPR-2) | — |
MonthlyFixedPayment | body | decimal | Да | numeric(18,2), >= 0 (RUB, как цена объекта недвижимости) | — |
TermMonths | body | int | Да | > 0 and <= 600 (INV-PPR-2) | — |
DiscountPercent | body | decimal | Да | numeric(5,2), >= 0 and <= 100 (INV-PPR-2) | — |
MilestonePayments | body | MilestonePaymentList | Да (список может быть пустым) | контейнер ниже; INV-PPR-3 (единственный remainder, последний по Month) | — |
SortOrder | body | int | Да | >= 0 | — |
RowVersion | body | uint | Да | оптимистический токен из get/list; несовпадение → 409 | — |
Контейнер MilestonePaymentList (вход = проекция jsonb-модели агрегата)
Дом модели — purchase-program.md (раздел «Модели JSONB»). Контейнер:
{ ContractVersion: int (=1), Items: MilestonePayment[] }; элемент MilestonePayment:
| Поле | Тип | Обязательность | Валидация |
|---|
Month | int | Да | >= 1 and <= TermMonths запроса |
Kind | string (enum MilestoneKind: percent / fixed / remainder) | Да | Enum.IsDefined; не более одного remainder, он максимален по Month (INV-PPR-3) |
Value | decimal | Да | percent: > 0 and <= 100; fixed: > 0 (RUB); remainder: игнорируется, нормализуется в 0 |
Модель ответа InstallmentPresetModel
Проекция child-сущности — дом полей purchase-program.md; ниже дельта,
значимая для правки. Та же модель отдаётся get/list пресетов застройщика. TenantId наружу не отдаётся.
| Поле | Тип | Обязательность | Источник | Masking | Описание |
|---|
Id | Guid | Да | InstallmentPresetEntity.Id | — | Идентификатор пресета (на него ссылается Deal.InstallmentPresetId). |
PurchaseProgramId | Guid | Да | InstallmentPresetEntity.PurchaseProgramId | — | Родительская программа (не изменилась операцией). |
Name | string | Да | InstallmentPresetEntity.Name | — | Новое название пресета. |
Code | string? | Нет | InstallmentPresetEntity.Code | — | Display-код. |
InitialPaymentPercent | decimal | Да | InstallmentPresetEntity.InitialPaymentPercent | — | Первоначальный взнос, %. |
MonthlyFixedPayment | decimal | Да | InstallmentPresetEntity.MonthlyFixedPayment | — | Ежемесячный платёж, RUB. |
TermMonths | int | Да | InstallmentPresetEntity.TermMonths | — | Срок рассрочки, месяцы. |
DiscountPercent | decimal | Да | InstallmentPresetEntity.DiscountPercent | — | Скидка пресета, %. |
MilestonePayments | MilestonePaymentList | Да | InstallmentPresetEntity.MilestonePayments | — | Обновлённый milestone-график. |
SortOrder | int | Да | InstallmentPresetEntity.SortOrder | — | Порядок показа опции. |
Version | long | Да | InstallmentPresetEntity.Version | — | Инкрементирована на 1. |
RowVersion | uint | Да | InstallmentPresetEntity.RowVersion | — | Новый токен оптимистической блокировки для следующей правки клиента. |
UpdatedAt | DateTimeOffset | Да | InstallmentPresetEntity.UpdatedAt | — | Момент правки. |
Алгоритм
- Контекст и доступ. Выполняет менеджер застройщика (
User, permission crm.purchase-programs.manage;
менеджер ПИН / администратор платформы — platform-scope); tenant — TenantId = DeveloperCompanyId.
Идемпотентность естественная — защита от параллельной правки строится на RowVersion (шаги 2, 5).
- Проверка входа и предусловий.
PresetId != Guid.Empty; Name после trim 1..128; Code 0..64; SortOrder >= 0; RowVersion задан
— иначе VALIDATION_FAILED;
- диапазоны (INV-PPR-2):
InitialPaymentPercent/DiscountPercent ∈ [0..100], MonthlyFixedPayment >= 0,
0 < TermMonths <= 600, MilestonePayment.Value по Kind — нарушение → CRM_PURCHASE_PROGRAM_RANGE;
- milestone-правило (INV-PPR-3): каждый
Month ∈ [1..TermMonths]; в Items не более одного remainder, и
его Month максимален — иначе CRM_INSTALLMENT_REMAINDER_INVALID;
- полнота графика (INV-PPR-4): прогон формулы расчёта
(purchase-program.md, «Серверный расчёт графика платежей») на
эталонной цене; остаток после последнего месяца ≠
0 (за вычетом округления) →
CRM_INSTALLMENT_SCHEDULE_INCOMPLETE (канонично гарантируется хвостовым remainder, но допустима и точная
раскладка без него);
- существование: пресет есть среди живых в
TenantId (архивный невидим) — иначе
CRM_INSTALLMENT_PRESET_NOT_FOUND (правка архива запрещена);
- scope:
preset.TenantId == TenantId — иначе CRM_INSTALLMENT_PRESET_FORBIDDEN (чужая программа);
- уникальность имени: живой пресет той же программы с таким
Name (ci), кроме себя — если есть →
CRM_INSTALLMENT_PRESET_NAME_DUPLICATE.
- Что читаем. Сам пресет и корень-агрегат — программу покупки: правка идёт через корень (инварианты и
доменный факт живут на программе, дом — purchase-program.md). Проверка
использования в сделках не делается: в отличие от архива (INV-PPR-5,
CRM_INSTALLMENT_PRESET_IN_USE),
правка допускается при живых ссылках deals.installment_preset_id — она не ретроактивна (см. Назначение).
Строчная блокировка не берётся — конкурентность точечной правки покрывает оптимистический RowVersion.
- Бизнес-правила и переходы. Обновление переносит поле-в-поле:
Name = trim(Name), Code,
InitialPaymentPercent, MonthlyFixedPayment, TermMonths, DiscountPercent, MilestonePayments = MilestonePaymentList(ContractVersion = 1, Items) (нормализация remainder.Value → 0), SortOrder,
UpdatedByUserId = ActorUserId, UpdatedAt = now, Version += 1; Id/PurchaseProgramId/TenantId/
CreatedBy* неизменны. Статусной машины нет (конфигурация: живой ↔ архив). Фиксируется доменный факт
PurchaseProgramChanged.
- Что меняется. Обновляются параметры пресета. Оптимистическая блокировка по
RowVersion: если между
чтением клиента и записью пресет был изменён — конфликт CRM_INSTALLMENT_PRESET_CONCURRENCY_CONFLICT; гонку
одинакового имени ловит unique-индекс ux_installment_presets_program_name →
CRM_INSTALLMENT_PRESET_NAME_DUPLICATE. Пресет не денормализован в deals — нетерминальные сделки
пересчитают график по новым параметрам при следующем запросе, терминальные (Completed/Cancelled) остаются
read-only.
- События. Интеграционных Kafka-событий нет: конфигурация покупки читается синхронно, топика
pin.crm.purchase-program не существует (карта событий — README). Внутренний доменный
факт PurchaseProgramChanged инвалидирует кэш калькулятора графика.
- Согласованность. Обновление пресета и audit/
Version фиксируются в одном согласованном изменении
(атомарно).
- Результат. Карточка
InstallmentPresetModel обновлённого пресета (новые параметры, инкрементированный
Version, свежий RowVersion) → HTTP 200.
Диаграмма
Побочные эффекты
| Тип | Детали |
|---|
| Integration events | none (конфигурация покупки читается синхронно; kafka-топика нет) |
| External calls | none |
| Cache / projections / search | доменное PurchaseProgramChanged инвалидирует кэш калькулятора графика; нетерминальные сделки пересчитают график по новым параметрам при следующем запросе (пресет не денормализован в deals) |
| Notifications | none (настроечная операция; уведомления сторонам сделок не шлются) |
Ошибки и failure modes
| Код | HTTP status | Условие | Сообщение | Retry |
|---|
VALIDATION_FAILED | 400 | PresetId = Guid.Empty, пустое/длинное Name/Code, SortOrder < 0, нет RowVersion, неизвестный Kind | «Проверьте корректность полей пресета» | no retry |
CRM_PURCHASE_PROGRAM_RANGE | 400 | проценты вне [0..100], TermMonths <= 0/> 600, MonthlyFixedPayment < 0, Value не по Kind (INV-PPR-2) | «Значения процентов и сроков вне допустимого диапазона» | no retry |
CRM_INSTALLMENT_REMAINDER_INVALID | 422 | более одного remainder либо remainder не последний по Month, либо Month вне [1..TermMonths] (INV-PPR-3) | «Правило платежа-остатка нарушено» | no retry |
CRM_INSTALLMENT_SCHEDULE_INCOMPLETE | 422 | график не погашает 100% эталонной цены (INV-PPR-4) | «График рассрочки не сходится к полной стоимости» | no retry |
CRM_INSTALLMENT_PRESET_NOT_FOUND | 404 | пресет не найден в TenantId либо архивирован | «Пресет рассрочки не найден» | no retry |
CRM_INSTALLMENT_PRESET_FORBIDDEN | 403 | чужой tenant / нет crm.purchase-programs.manage | «Нет доступа к пресету» | no retry |
CRM_INSTALLMENT_PRESET_NAME_DUPLICATE | 409 | имя занято живым пресетом программы (pre-check либо ux_installment_presets_program_name) | «Пресет с таким названием уже есть» | no retry (сменить имя) |
CRM_INSTALLMENT_PRESET_CONCURRENCY_CONFLICT | 409 | RowVersion устарел (параллельная правка) | «Пресет изменён параллельно, обновите данные» | retry (перечитать RowVersion) |
Acceptance Criteria
- Given живой пресет в
TenantId с актуальным RowVersion, When валидная правка параметров и milestone-графика, Then параметры обновлены, remainder.Value нормализован в 0, Version += 1, свежий RowVersion, ответ 200 с InstallmentPresetModel.
- Given
InitialPaymentPercent/DiscountPercent вне [0..100], TermMonths ∉ (0..600] или MonthlyFixedPayment < 0, When update, Then CRM_PURCHASE_PROGRAM_RANGE (400) по INV-PPR-2.
- Given более одного
remainder либо remainder не максимален по Month (или Month ∉ [1..TermMonths]), When update, Then CRM_INSTALLMENT_REMAINDER_INVALID (422) по INV-PPR-3.
- Given график не гасит 100% эталонной цены (остаток ≠ 0 сверх округления), When update, Then
CRM_INSTALLMENT_SCHEDULE_INCOMPLETE (422) по INV-PPR-4.
- Given имя занято другим живым пресетом той же программы (ci), When update, Then
CRM_INSTALLMENT_PRESET_NAME_DUPLICATE (409; pre-check или ux_installment_presets_program_name).
- Given
RowVersion устарел (параллельная правка), When update, Then CRM_INSTALLMENT_PRESET_CONCURRENCY_CONFLICT (409); клиент перечитывает RowVersion и повторяет.
- Given на пресет ссылаются нетерминальные и терминальные сделки, When параметры изменены, Then правка проходит (в отличие от архива INV-PPR-5 не блокируется живыми ссылками), нетерминальные сделки пересчитают график по новым параметрам при следующем запросе, а
Completed/Cancelled остаются read-only с уже согласованным графиком.
- Given пресет архивирован либо не существует в
TenantId, When update, Then CRM_INSTALLMENT_PRESET_NOT_FOUND (404); чужой tenant → CRM_INSTALLMENT_PRESET_FORBIDDEN (403).
Совместимость и наблюдаемость
- Route/тело стабильны; добавление optional-полей в
InstallmentPresetModel / MilestonePayment —
additive (без breaking); удаление/переименование/смена типа — breaking (migration + rollout). Enum-ы
MilestoneKind/PaymentMethod — строкой; unknown → VALIDATION_FAILED. MilestonePaymentList versioned
(ContractVersion = 1) — новые версии добавляются с upcast (дом — purchase-program.md).
- Logs:
installment-preset-update requested/succeeded/conflict (PresetId, PurchaseProgramId, TenantId;
PII нет); metrics: crm_installment_preset_update_total{result}, latency p95; traces — сквозной trace через
traceparent; dashboard pin-crm; runbook — docs/06-services/crm/runbooks/ (будущий путь).
Обратные ссылки
Автоссылки: где используется этот документ.
Связанные документы
- Сущность, инварианты, формула и индексы: purchase-program.md
(INV-PPR-2/3/4, enum
MilestoneKind, RowVersion/soft-delete, ux_installment_presets_program_name,
доменное PurchaseProgramChanged); потребитель пресета — deal.md
(InstallmentPresetId, PaymentMethod = Installment).
- Карта событий и область developer — ../../README.md (пресеты интеграционных событий не
публикуют; конфигурация читается синхронно).
- ADR: 0052 tenancy,
0056 simplicity-first.