PUT /api/developer/crm/purchase-program — Настроить программу покупки
Менеджер застройщика задаёт общие настройки способов оплаты своей программы покупки: скидку за 100%
оплату, параметры ипотечного калькулятора (ставка/срок) и выключатели опций «ипотека»/«100% оплата».
Программа — один агрегат на застройщика (INV-PPR-1), поэтому операция построена как upsert по
TenantId: первый вызов создаёт агрегат, последующие правят его. Пресеты рассрочки этой командой не
трогаются — у них отдельные команды upsert/архива. Дом полей, инвариантов и формулы графика —
purchase-program.md.
Назначение
Upsert общих настроек программы покупки в TenantId = DeveloperCompanyId: FullPaymentDiscountPercent,
MortgageAnnualRatePercent, MortgageTermMonths, флаги IsMortgageEnabled / IsFullPaymentEnabled. Вызывает
менеджер застройщика; первый вызов создаёт агрегат, последующие правят его. На выходе — карточка настроек
программы.
Metadata
| Поле | Значение |
|---|---|
| Сервис/контекст | crm |
| API area | developer |
| Feature group | purchase-program |
| Статус | draft |
| Документ | docs/06-services/crm/api/developer/update-purchase-program.md |
Актор и доступ
| Проверка | Значение |
|---|---|
| Actor type | User (Менеджер застройщика в рамках своего TenantId = DeveloperCompanyId; Менеджер ПИН / администратор платформы — platform-scope, тот же permission) |
| Auth policy / Permissions | crm.purchase-programs.manage |
| Scope (RBAC) | организация (застройщик): менеджер настраивает свою программу покупки |
| Record-level | PurchaseProgramEntity.TenantId == actor.DeveloperCompanyId (агрегат принадлежит застройщику) |
| Tenant isolation | TenantId = DeveloperCompanyId в каждом запросе (ADR-0052) |
| Audit | create/update (CreatedByUserId/UpdatedByUserId = actor, инкремент Version) |
Record-level ограничивает доступ агрегатом своего застройщика: PurchaseProgramEntity.TenantId == actor.DeveloperCompanyId — менеджер видит и правит только программу своей компании. Менеджер ПИН и
администратор платформы обходят record-level (platform-scope) с тем же permission
crm.purchase-programs.manage.
HTTP-контракт
| Поле | Значение |
|---|---|
| Method | PUT |
| Route | /api/developer/crm/purchase-program |
| Success status | 200 (настройки обновлены) / 201 (агрегат создан первым вызовом) |
| Idempotency header | не применяется — идемпотентность естественная (upsert по TenantId; повтор с тем же телом и RowVersion даёт тот же агрегат) |
| Correlation | сквозной trace через заголовок traceparent |
Входные данные / параметры запроса
| Поле | Источник | Тип | Обязательность | Валидация / Ограничения | Безопасность |
|---|---|---|---|---|---|
TenantId | identity claims | Guid | Да | Required; = DeveloperCompanyId | Tenant isolation |
ActorUserId | identity claims | Guid | Да | JWT менеджера; пишется в CreatedByUserId/UpdatedByUserId | — |
FullPaymentDiscountPercent | body | decimal | Да | >= 0 and <= 100, шкала (5,2) (INV-PPR-2) | — |
MortgageAnnualRatePercent | body | decimal | Да | >= 0 and <= 100, шкала (5,2) (INV-PPR-2) | — |
MortgageTermMonths | body | int | Да | > 0 and <= 600 (INV-PPR-2) | — |
IsMortgageEnabled | body | bool | Да | выключатель опции «ипотека» | — |
IsFullPaymentEnabled | body | bool | Да | выключатель опции «100% оплата» | — |
RowVersion | body | uint? | Нет | intent-токен: null — намерение создать; задан — намерение обновить существующий (токен из query), несовпадение → 409 | — |
RowVersion намеренно nullable: агрегат один на застройщика, поэтому вместо route-id клиент выражает намерение
через наличие токена — null (первичная настройка) против конкретного токена (правка полученного из query
агрегата). Разбор ветвей — шаги 3–5 алгоритма.
Модель ответа PurchaseProgramModel
Проекция агрегата — дом полей purchase-program.md; ниже только
настроечная дельта, которую пишет/возвращает эта команда. TenantId наружу не отдаётся (внутренняя
изоляция); список пресетов рассрочки в ответ не входит — он проецируется отдельным query-методом программы
(пресеты правятся своими командами).
| Поле | Тип | Обязательность | Источник | Masking | Описание |
|---|---|---|---|---|---|
Id | Guid | Да | PurchaseProgramEntity.Id | — | Идентификатор программы застройщика. |
FullPaymentDiscountPercent | decimal | Да | PurchaseProgramEntity.FullPaymentDiscountPercent | — | Скидка при 100% оплате, %. |
MortgageAnnualRatePercent | decimal | Да | PurchaseProgramEntity.MortgageAnnualRatePercent | — | Годовая ставка ипотеки для калькулятора, %. |
MortgageTermMonths | int | Да | PurchaseProgramEntity.MortgageTermMonths | — | Срок ипотеки, месяцы. |
IsMortgageEnabled | bool | Да | PurchaseProgramEntity.IsMortgageEnabled | — | Доступна ли ипотека. |
IsFullPaymentEnabled | bool | Да | PurchaseProgramEntity.IsFullPaymentEnabled | — | Доступна ли 100% оплата. |
Version | long | Да | PurchaseProgramEntity.Version | — | Оптимистическая версия (== 1 после создания, иначе +1). |
RowVersion | uint | Да | PurchaseProgramEntity.RowVersion | — | Новый токен оптимистической блокировки для следующей правки клиента. |
UpdatedAt | DateTimeOffset? | Нет | PurchaseProgramEntity.UpdatedAt | — | Момент последней правки (null сразу после создания). |
Алгоритм
Программа — один агрегат на застройщика: намерение клиента (создать/обновить) выражается парой «есть ли
живой агрегат» × RowVersion. Отсутствующий агрегат + RowVersion == null → создание; существующий агрегат
- совпадающий
RowVersion→ обновление; любое другое сочетание — конфликт (CRM_PURCHASE_PROGRAM_EXISTSлибоCRM_PURCHASE_PROGRAM_CONCURRENCY_CONFLICT). Разбор веток — шаг 3.
- Контекст и доступ. Выполняет менеджер застройщика (
User, permissioncrm.purchase-programs.manage; менеджер ПИН / администратор платформы — platform-scope); tenant —TenantId = DeveloperCompanyId. Операция идемпотентна по природе upsert; защита от параллельной правки —RowVersion(шаги 3–5). - Проверка входа и предусловий.
- INV-PPR-2:
FullPaymentDiscountPercent ∈ [0..100],MortgageAnnualRatePercent ∈ [0..100],MortgageTermMonths ∈ (0..600]— нарушение →CRM_PURCHASE_PROGRAM_RANGE; структурно-невалидное тело (не число/нет обязательного поля) →VALIDATION_FAILED; - существование/состояние: агрегата по id нет (upsert по tenant); ветвление create/update — на шаге 3;
- scope: только tenant isolation (
TenantId = DeveloperCompanyId); родительских агрегатов и FK-ссылок у настроек нет; отсутствие permission →CRM_PURCHASE_PROGRAM_FORBIDDEN.
- INV-PPR-2:
- Что читаем. Живой агрегат программы покупки застройщика (nullable-путь, под блокировкой строки —
сериализует параллельные правки настроек одного застройщика). Пресеты рассрочки не читаются (команда их не
трогает). Разбор намерения по паре (
program,RowVersion):program == nullиRowVersion == null→ create (шаг 4a);program != nullиRowVersion == program.RowVersion→ update (шаг 4b);program != nullиRowVersion == null→CRM_PURCHASE_PROGRAM_EXISTS(INV-PPR-1: не затирать существующий агрегат «слепым» создающим вызовом);program == nullиRowVersion != null, либоprogram != nullиRowVersion != program.RowVersion→CRM_PURCHASE_PROGRAM_CONCURRENCY_CONFLICT(агрегат исчез/изменён параллельно).
- Бизнес-правила и переходы. Маппинг поле-в-поле из тела; INV-PPR-2 повторно закрепляется guard-ами.
- 4a (create): создаётся программа с проверкой диапазонов (INV-PPR-2 →
CRM_PURCHASE_PROGRAM_RANGE);Id— новый идентификатор,Version = 1,UpdatedAt = null; - 4b (update): обновляются настройки (
FullPaymentDiscountPercent,MortgageAnnualRatePercent,MortgageTermMonths,IsMortgageEnabled,IsFullPaymentEnabled),UpdatedAt = now,Version += 1;CreatedBy*и коллекция пресетов не трогаются; - обе ветви: фиксируется доменный факт
PurchaseProgramChanged.
- 4a (create): создаётся программа с проверкой диапазонов (INV-PPR-2 →
- Что меняется. Программа создаётся (create) или её настройки обновляются (update). Гонку двух первичных
upsert ловит partial-unique
ux_purchase_programs_tenant→CRM_PURCHASE_PROGRAM_EXISTS(INV-PPR-1). В update оптимистическая блокировка поRowVersion: рассинхрон →CRM_PURCHASE_PROGRAM_CONCURRENCY_CONFLICT. - События. Интеграционных Kafka-событий нет: конфигурация читается синхронно, топика
pin.crm.purchase-programне существует. Внутренний доменный фактPurchaseProgramChangedинвалидирует кэш публичной проекции конфигурации (график/условия витрины). - Согласованность. Вставка/обновление программы и audit/
Versionфиксируются в одном согласованном изменении (атомарно). Нарушениеux_purchase_programs_tenantоткатывает изменение →CRM_PURCHASE_PROGRAM_EXISTS(шаг 5). - Результат. Карточка
PurchaseProgramModel(настройки,Version, свежийRowVersion,UpdatedAt) → HTTP201для create-ветви,200для update-ветви.
Диаграмма
Побочные эффекты
| Тип | Детали |
|---|---|
| Integration events | none (конфигурация читается синхронно; kafka-топика программы покупки нет) |
| External calls | none |
| Cache / projections / search | доменное PurchaseProgramChanged → инвалидация кэша публичной проекции конфигурации (график/условия витрины) |
| Notifications | none (настроечная операция; участникам сделок ничего не шлётся) |
Ошибки и failure modes
Повторять запрос имеет смысл только при CRM_PURCHASE_PROGRAM_EXISTS и
CRM_PURCHASE_PROGRAM_CONCURRENCY_CONFLICT — оба требуют сначала перечитать актуальный RowVersion и
повторить как update. Остальные коды (VALIDATION_FAILED, CRM_PURCHASE_PROGRAM_RANGE,
CRM_PURCHASE_PROGRAM_FORBIDDEN) требуют сначала исправить тело запроса или права доступа.
| Код | HTTP status | Условие | Сообщение | Retry |
|---|---|---|---|---|
VALIDATION_FAILED | 400 | структурно-невалидное тело (не число/нет обязательного поля/флага) | «Проверьте корректность полей программы покупки» | no retry |
CRM_PURCHASE_PROGRAM_RANGE | 400 | INV-PPR-2: процент вне [0..100] или MortgageTermMonths вне (0..600] (валидатор либо check-констрейнт) | «Значения ставок, скидок и срока вне допустимого диапазона» | no retry |
CRM_PURCHASE_PROGRAM_FORBIDDEN | 403 | нет crm.purchase-programs.manage / чужой tenant | «Нет доступа к программе покупки» | no retry |
CRM_PURCHASE_PROGRAM_EXISTS | 409 | INV-PPR-1: create-намерение (RowVersion == null) при уже существующем живом агрегате либо гонка первичных upsert (ux_purchase_programs_tenant) | «Программа покупки уже настроена — обновите её» | retry (перечитать RowVersion и повторить как update) |
CRM_PURCHASE_PROGRAM_CONCURRENCY_CONFLICT | 409 | RowVersion устарел/агрегат изменён параллельно (update-намерение) | «Программа изменена параллельно, обновите данные» | retry (перечитать RowVersion) |
Инварианты пресетов (CRM_INSTALLMENT_REMAINDER_INVALID, CRM_INSTALLMENT_SCHEDULE_INCOMPLETE,
CRM_INSTALLMENT_PRESET_IN_USE, INV-PPR-3..5) к этой команде не относятся — см.
purchase-program.md и команды upsert/архива пресетов.
Acceptance Criteria
- Given у застройщика ещё нет живого агрегата программы и
RowVersion == null, When upsert валидных настроек, Then создаётся агрегат,Version = 1,UpdatedAt = null, ответ201(INV-PPR-1: один агрегат на застройщика). - Given агрегат существует и
RowVersion == program.RowVersion, When upsert, Then настройки обновлены,Version += 1,UpdatedAt = now, ответ200;CreatedBy*и коллекция пресетов не тронуты. - Given агрегат уже есть, но
RowVersion == null, When upsert, ThenCRM_PURCHASE_PROGRAM_EXISTS(409) — существующий агрегат не затирается (INV-PPR-1). - Given
RowVersion != program.RowVersionлибо заданRowVersionпри отсутствующем агрегате, When upsert, ThenCRM_PURCHASE_PROGRAM_CONCURRENCY_CONFLICT(409). - Given
FullPaymentDiscountPercent/MortgageAnnualRatePercentвне[0..100]илиMortgageTermMonths ∉ (0..600], When upsert, ThenCRM_PURCHASE_PROGRAM_RANGE(400) по INV-PPR-2; структурно-невалидное тело →VALIDATION_FAILED(400). - Given два параллельных первичных create одного застройщика, When оба коммитятся, Then один создаёт агрегат, второй нарушает
ux_purchase_programs_tenant→CRM_PURCHASE_PROGRAM_EXISTS(409). - Given нет
crm.purchase-programs.manageлибо чужой tenant, When upsert, ThenCRM_PURCHASE_PROGRAM_FORBIDDEN(403).
Совместимость и наблюдаемость
- Route/тело стабильны; добавление optional-полей в
PurchaseProgramModel— additive (без breaking); удаление/переименование/смена типа — breaking (migration + rollout). Проценты сериализуютсяdecimal(неfloat); нарушение диапазона —CRM_PURCHASE_PROGRAM_RANGE, а не молчаливое округление. - Logs:
purchase-program upsert requested/created/updated/conflict(TenantId,ProgramId,Version; PII отсутствует); metrics:crm_purchase_program_upsert_total{result}, latency p95; traces — сквозной trace черезtraceparent; dashboardpin-crm; runbook —docs/06-services/crm/runbooks/(будущий путь по конвенции).
Обратные ссылки
Автоссылки: где используется этот документ.
- registries (1): Реестр API-методов PIN
Связанные документы
- Сущность, инварианты, индексы и формула графика: purchase-program.md
(INV-PPR-1 один агрегат на застройщика, INV-PPR-2 диапазоны,
RowVersion/soft-delete, доменноеPurchaseProgramChanged,ux_purchase_programs_tenant); потребитель настроек — deal.md (PaymentMethod,InstallmentPresetId). - Область developer и карта событий: ../../README.md; публичное чтение конфигурации и
график платежей — API-документ
../get-payment-schedule.md(будущий путь по конвенции). - ADR: 0052 tenancy, 0056 simplicity-first.