POST /api/developers/v1/promotions/{purchaseId}/cancel — Отмена покупки продвижения
Назначение
Менеджер компании досрочно отменяет свою покупку пакета продвижения PromotionPurchase до истечения
срока (promotion-purchase.md, stateDiagram Active → Cancelled /
Pending → Cancelled). Отмена снимает буст: покупка переводится в терминальный Cancelled и
публикует событие PromotionPurchaseChangedKafkaEvent
(Status = Cancelled) — консюмеры-потребители (catalog/news) убирают вклад покупки из
денормализованного буста целей у себя (ADR-0060). Покупка не удаляется (финансовая история будущего
биллинга — решение №10) — сохраняется с причиной.
| Поле | Значение |
|---|
| Сервис/контекст | promotion |
| API area | developers |
| Feature group | promotions |
| Статус | draft |
| Документ | docs/06-services/promotion/api/developers/cancel-promotion.md |
Актор и доступ
| Проверка | Значение |
|---|
| Actor type | User (менеджер компании-покупателя — JWT identity) |
| Auth policy / Permissions | permission promotion.purchases.manage в scope организации (владелец жизненного цикла покупки) |
| Scope (RBAC) | организация; покупка обязана принадлежать tenant-компании актора |
| Record-level | не применяется (владение решается tenant-изоляцией) |
| Tenant isolation | TenantId = CompanyId (= PromotionPurchase.TenantId, ADR-0052); tenant-интерсептор SDK скоупит выборку — чужая покупка неотличима от несуществующей (404) |
| Audit | отметка автора изменения (UpdatedByUserId) + инкремент Version; покупка не удаляется (терминальный статус) |
Отмена той же покупки администратором платформы — отдельная admin-операция
(promotion.purchases.admin, ADR-0053; будущий путь ../admin/cancel-promotion.md): та же отмена, но без
tenant-скоупа и от системного актора. Настоящий документ описывает путь менеджера компании.
HTTP-контракт
| Поле | Значение |
|---|
| Method | POST |
| Route | /api/developers/v1/promotions/{purchaseId}/cancel |
| Success status | 200 |
| Idempotency header | не применяется (повтор по уже отменённой покупке упрётся в INV-6 PROMOTION_INVALID_STATUS_TRANSITION) |
| Correlation | сквозной trace через заголовок traceparent |
Входные данные / параметры запроса
| Поле | Источник | Тип | Обязательность | Валидация / Ограничения | Безопасность |
|---|
TenantId | identity claims | Guid | Да | Required (= CompanyId) | tenant isolation |
PurchaseId | route | Guid | Да | != Guid.Empty; существование и владение — шаг 2 | — |
CancelReason | body | string | Да | 1..512 после trim (обязателен при Status=Cancelled) | не PII (свободный текст менеджера); в логи — усечённо |
| Поле | Тип | Обязательность | Источник | Masking | Описание |
|---|
Id | Guid | Да | PromotionPurchase.Id | — | Отменённая покупка. |
Status | string (enum PromotionPurchaseStatus) | Да | PromotionPurchase.Status | — | Всегда Cancelled. |
CancelReason | string | Да | PromotionPurchase.CancelReason | — | Сохранённая причина отмены. |
Version | long | Да | PromotionPurchase.Version | — | Новая версия агрегата после отмены. |
PromotionPurchaseResult переиспользуется покупкой (purchase-promotion.md) —
здесь заполнены поля, релевантные отмене.
Алгоритм
- Контекст и доступ. Операцию выполняет менеджер компании; tenant — его компания (
TenantId = CompanyId); требуется permission promotion.purchases.manage.
- Проверка входа.
PurchaseId не пуст; CancelReason непустой после trim и ≤ 512 → иначе
ValidationError.
- Существование и предусловия. Найти покупку по
PurchaseId в рамках компании актора; чужая покупка
неотличима от несуществующей → PROMOTION_PURCHASE_NOT_FOUND (404). Допустимость состояния (INV-6
TerminalFrozen): статус ∈ {Active, Pending}; из Expired/Cancelled переходов нет →
PROMOTION_INVALID_STATUS_TRANSITION (409). Цели покупки нужны для снапшота события; целевые карточки в
чужих контекстах не читаются — снятие вклада выполняют потребители по событию (ADR-0060).
- Отмена. Перевести покупку
Active → Cancelled либо Pending → Cancelled (guard INV-6), записать
CancelReason, Status = Cancelled, автор изменения (UpdatedByUserId), Version++. Пересчёта буста
в промо-контексте нет — денормализация у потребителей.
- Сохранение и событие. Сохранить покупку и в одном согласованном изменении зафиксировать событие
PromotionPurchaseChangedKafkaEvent (топик pin.promotion.purchase-changed.v1, key = PurchaseId) со
Status = Cancelled; payload — из полей покупки (CancelReason в payload не входит), контракт —
promotion-purchase-changed.md. Конкурентность: гонка с
job-ом expire-promotions (отмена ↔ истечение) фиксируется по версии покупки →
CONCURRENCY_CONFLICT (retry).
- Результат. Вернуть
Id, Status = Cancelled, CancelReason, Version → 200; ошибки → коды
таблицы ниже.
Диаграмма
Побочные эффекты
| Тип | Детали |
|---|
| Integration events | PromotionPurchaseChangedKafkaEvent → pin.promotion.purchase-changed.v1 (Status=Cancelled), публикуется атомарно со сменой статуса |
| External calls | none |
| Cache / projections / search | none в этом контексте — снятие вклада PromotionBoost выполняют консюмеры catalog/news (ADR-0060) |
| Notifications | опосредованно: notifications — консюмер pin.promotion.purchase-changed.v1 (уведомление об отмене) |
Окно консистентности снятия буста
Покупка становится Cancelled и видна такой в «Моих покупках» сразу (source of truth — этот контекст).
Вклад покупки в PromotionBoost целей снимают потребители catalog/news асинхронно по событию
(Status = Cancelled, key = PurchaseId) — целевой SLA < 1 мин. В промежутке карточка/пост ещё может
показывать прежний буст; это ожидаемое промежуточное состояние. Порядок относительно других переходов той
же покупки гарантирован key-ом PurchaseId и монотонным Version-guard-ом у потребителей: отмена не может
«обогнать» более раннюю активацию.
Ошибки и failure modes
| Код | HTTP status | Условие | Сообщение | Retry |
|---|
ValidationError | 400 | Пустой/> 512 CancelReason или пустой PurchaseId | Ошибка валидации запроса | no retry |
UNAUTHORIZED | 401 | Нет/просрочен JWT | Требуется авторизация | no retry |
FORBIDDEN | 403 | Нет permission promotion.purchases.manage | Доступ запрещён | no retry |
PROMOTION_PURCHASE_NOT_FOUND | 404 | Покупка не существует или принадлежит другому tenant | Покупка продвижения не найдена | no retry |
PROMOTION_INVALID_STATUS_TRANSITION | 409 | Покупка в терминальном Expired/Cancelled (INV-6) | Покупку в текущем статусе нельзя отменить | no retry |
CONCURRENCY_CONFLICT | 409 | Гонка RowVersion покупки с job expire-promotions | Повторите операцию | retry |
Совместимость и наблюдаемость
- Новые optional поля
PromotionPurchaseResult — additive; удаление/переименование/смена типа —
breaking. Контракт события (ContractVersion, payload, версионирование) —
promotion-purchase-changed.md.
- Logs:
promotion-cancelled (purchaseId, tenantId, packageKind, prevStatus; cancelReason —
усечённо до 64 симв.); metrics: promotion_operations_total{operation="cancel-promotion"}, duration;
traces — сквозной trace через traceparent. Dashboard promotion-overview.
Обратные ссылки
Автоссылки: где используется этот документ.
Связанные документы