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

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) — сохраняется с причиной.

Metadata

ПолеЗначение
Сервис/контекстpromotion
API areadevelopers
Feature grouppromotions
Статусdraft
Документdocs/06-services/promotion/api/developers/cancel-promotion.md

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

ПроверкаЗначение
Actor typeUser (менеджер компании-покупателя — JWT identity)
Auth policy / Permissionspermission promotion.purchases.manage в scope организации (владелец жизненного цикла покупки)
Scope (RBAC)организация; покупка обязана принадлежать tenant-компании актора
Record-levelне применяется (владение решается tenant-изоляцией)
Tenant isolationTenantId = CompanyId (= PromotionPurchase.TenantId, ADR-0052); tenant-интерсептор SDK скоупит выборку — чужая покупка неотличима от несуществующей (404)
Auditотметка автора изменения (UpdatedByUserId) + инкремент Version; покупка не удаляется (терминальный статус)

Отмена той же покупки администратором платформы — отдельная admin-операция (promotion.purchases.admin, ADR-0053; будущий путь ../admin/cancel-promotion.md): та же отмена, но без tenant-скоупа и от системного актора. Настоящий документ описывает путь менеджера компании.

HTTP-контракт

ПолеЗначение
MethodPOST
Route/api/developers/v1/promotions/{purchaseId}/cancel
Success status200
Idempotency headerне применяется (повтор по уже отменённой покупке упрётся в INV-6 PROMOTION_INVALID_STATUS_TRANSITION)
Correlationсквозной trace через заголовок traceparent

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

ПолеИсточникТипОбязательностьВалидация / ОграниченияБезопасность
TenantIdidentity claimsGuidДаRequired (= CompanyId)tenant isolation
PurchaseIdrouteGuidДа!= Guid.Empty; существование и владение — шаг 2
CancelReasonbodystringДа1..512 после trim (обязателен при Status=Cancelled)не PII (свободный текст менеджера); в логи — усечённо

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

ПолеТипОбязательностьИсточникMaskingОписание
IdGuidДаPromotionPurchase.IdОтменённая покупка.
Statusstring (enum PromotionPurchaseStatus)ДаPromotionPurchase.StatusВсегда Cancelled.
CancelReasonstringДаPromotionPurchase.CancelReasonСохранённая причина отмены.
VersionlongДаPromotionPurchase.VersionНовая версия агрегата после отмены.

PromotionPurchaseResult переиспользуется покупкой (purchase-promotion.md) — здесь заполнены поля, релевантные отмене.

Алгоритм

  1. Контекст и доступ. Операцию выполняет менеджер компании; tenant — его компания (TenantId = CompanyId); требуется permission promotion.purchases.manage.
  2. Проверка входа. PurchaseId не пуст; CancelReason непустой после trim и ≤ 512 → иначе ValidationError.
  3. Существование и предусловия. Найти покупку по PurchaseId в рамках компании актора; чужая покупка неотличима от несуществующей → PROMOTION_PURCHASE_NOT_FOUND (404). Допустимость состояния (INV-6 TerminalFrozen): статус ∈ {Active, Pending}; из Expired/Cancelled переходов нет → PROMOTION_INVALID_STATUS_TRANSITION (409). Цели покупки нужны для снапшота события; целевые карточки в чужих контекстах не читаются — снятие вклада выполняют потребители по событию (ADR-0060).
  4. Отмена. Перевести покупку Active → Cancelled либо Pending → Cancelled (guard INV-6), записать CancelReason, Status = Cancelled, автор изменения (UpdatedByUserId), Version++. Пересчёта буста в промо-контексте нет — денормализация у потребителей.
  5. Сохранение и событие. Сохранить покупку и в одном согласованном изменении зафиксировать событие PromotionPurchaseChangedKafkaEvent (топик pin.promotion.purchase-changed.v1, key = PurchaseId) со Status = Cancelled; payload — из полей покупки (CancelReason в payload не входит), контракт — promotion-purchase-changed.md. Конкурентность: гонка с job-ом expire-promotions (отмена ↔ истечение) фиксируется по версии покупки → CONCURRENCY_CONFLICT (retry).
  6. Результат. Вернуть Id, Status = Cancelled, CancelReason, Version200; ошибки → коды таблицы ниже.

Диаграмма

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

ТипДетали
Integration eventsPromotionPurchaseChangedKafkaEventpin.promotion.purchase-changed.v1 (Status=Cancelled), публикуется атомарно со сменой статуса
External callsnone
Cache / projections / searchnone в этом контексте — снятие вклада 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
ValidationError400Пустой/> 512 CancelReason или пустой PurchaseIdОшибка валидации запросаno retry
UNAUTHORIZED401Нет/просрочен JWTТребуется авторизацияno retry
FORBIDDEN403Нет permission promotion.purchases.manageДоступ запрещёнno retry
PROMOTION_PURCHASE_NOT_FOUND404Покупка не существует или принадлежит другому tenantПокупка продвижения не найденаno retry
PROMOTION_INVALID_STATUS_TRANSITION409Покупка в терминальном Expired/Cancelled (INV-6)Покупку в текущем статусе нельзя отменитьno retry
CONCURRENCY_CONFLICT409Гонка 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.

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

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

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