Entity / Aggregate: PromotionPurchase
Дом полей покупки пакета продвижения и её полиморфных целей (контекст promotion). Проекции (модели
ответов developers API, event payload) ссылаются сюда и перечисляют только дельту.
Назначение
PromotionPurchase — покупка платформенного пакета продвижения (promotion-package.md)
компанией (застройщик/агентство/брокер — ADR-0052). Агрегат владеет коллекцией целей
PromotionPurchaseTarget (1..N) и снапшотит PackageKind/BoostWeight/Price на момент покупки —
правки/архив пакета не трогают действующую покупку (INV-2 SnapshotImmutable).
По решению №10 (technical-assignment.md) оплата отложена: покупка активируется сразу без оплаты
(PaymentStatus = NotRequired); статус Pending и поля цены заложены под будущий эквайринг. Активная
покупка публикует событие PromotionPurchaseChangedKafkaEvent,
а денормализацию буста в целевые карточки выполняют потребители (catalog — карточки проекта/объекта,
news — Post.PromotionBoost), не сам промо-контекст (ADR-0060).
Классификация
| Поле | Значение |
|---|
| Контекст | promotion |
| Kind | AggregateRoot (PromotionPurchaseTarget — дочерняя Entity внутри агрегата) |
| Source of truth | этот контекст |
| Таблицы | promotion.promotion_purchases, promotion.promotion_purchase_targets |
| Tenant isolation | TenantId = CompanyId (ADR-0052; обобщение DeveloperId → CompanyId) |
| Поле | Тип | Обязательность | Ограничения (PK/FK/index/constraint) | Источник | Описание |
|---|
Id | Guid | Да | PK | app | Идентификатор покупки (= Kafka key события). |
TenantId | Guid | Да | index ix_promotion_purchases_tenant_id; = CompanyId | identity (актор) | Компания-покупатель (ADR-0052). |
PackageId | Guid | Да | FK fk_promotion_purchases_package → promotion_packages.id (restrict) | developer | Купленный пакет. |
PackageCode | string | Да | varchar(64) | snapshot пакета | Стабильный код пакета (для аналитики потребителей без lookup). |
PackageKind | PromotionPackageKind | Да | varchar(16) | snapshot пакета | Снапшот вида (INV-2). |
BoostWeight | int | Да | 1..100 | snapshot пакета | Снапшот веса — правки пакета не меняют покупку (INV-2). |
Price | PromotionPrice (owned) | Да | см. promotion-package.md | snapshot пакета | Снапшот цены для будущего биллинга. |
PaymentStatus | PromotionPaymentStatus | Да | varchar(16), default NotRequired | app | Сейчас всегда NotRequired (оплата отложена — решение №10); Pending/Paid/Refunded — заготовка эквайринга. |
StartsAt | DateTimeOffset | Да | — | app (= момент активации) | Начало действия. |
EndsAt | DateTimeOffset | Да | > StartsAt (check); index ix_promotion_purchases_ends_at (status, ends_at) where status = 'Active' | app (StartsAt + DurationDays) | Конец действия — job переводит в Expired. |
Status | PromotionPurchaseStatus | Да | varchar(16), index | app | См. stateDiagram. |
CancelReason | string? | Нет | varchar(512); обязателен при Status=Cancelled (check) | developer/admin | Причина отмены. |
CreatedAt / CreatedByUserId / UpdatedAt / UpdatedByUserId | — | стандарт | — | app | Аудит (без soft-delete — терминальные статусы). |
Version | long | Да | >= 1 | app | Доменная версия; монотонный guard у потребителей. |
| Значение | Описание |
|---|
Pending | Создана, ожидает активации. В режиме без оплаты — мгновенный переход в Active в той же операции; статус существует для будущего платёжного flow. |
Active | Действует: BoostWeight учитывается потребителями буста. |
Expired | Срок истёк (job expire-promotions); вклад снят. Терминальный. |
Cancelled | Отменена покупателем или админом до истечения; вклад снят. Терминальный. |
| Значение | Описание |
|---|
NotRequired | Текущий режим платформы: оплата не требуется (решение №10). |
Pending / Paid / Refunded | Будущий эквайринг; сейчас не присваиваются. |
Полиморфная цель продвижения. Заменяет прежние TargetProjectId + PromotionPurchaseLot (ADR-0060).
| Поле | Тип | Обязательность | Ограничения (PK/FK/index/constraint) | Источник | Описание |
|---|
Id | Guid | Да | PK | app | Идентификатор строки. |
PurchaseId | Guid | Да | FK fk_promotion_purchase_targets_purchase → promotion_purchases.id (cascade); unique ux_promotion_purchase_targets (purchase_id, target_kind, target_id) | app | Родительская покупка. |
Target | PromotionTarget (owned) | Да | см. VO; index ix_promotion_purchase_targets_target (target_kind, target_id) | developer | Полиморфная цель продвижения. |
Value objects
| Поле | Тип | Обязательность | Ограничения | Колонка |
|---|
TargetKind | PromotionTargetKind | Да | varchar(16) | target_kind |
TargetId | Guid | Да | != Guid.Empty; логическая ссылка (без FK — цель в чужом контексте) | target_id |
| Значение | Владелец цели | Consumer буста |
|---|
Project | catalog (CatalogProjectCard) | catalog |
PropertyObject | catalog (CatalogPropertyObjectCard) | catalog |
Post | news (Post) | news |
Дом — promotion-package.md (VO PromotionPrice). Снапшотится в покупку.
Модели JSONB
JSONB-модели не используются.
Связи
| Связь | Тип | Ключ | Правило загрузки | Delete behavior |
|---|
PromotionPackage → PromotionPurchase | reference | FK package_id | чтение пакета при покупке (снапшот) | restrict |
PromotionPurchase → PromotionPurchaseTarget | owned-коллекция агрегата | FK purchase_id | загружаются вместе с покупкой | cascade |
PromotionPurchaseTarget → (Project/PropertyObject/Post) | reference (логическая, без FK) | Target.TargetId | нет (цель в чужом контексте) | нет — потребитель снимает вклад по событию |
Инварианты и переходы состояний
| Инвариант | Условие | Где проверяется | Ошибка |
|---|
| INV-1 TargetsMatchKind | Набор TargetKind целей допустим для PackageKind (таблица «Kind → TargetKind» в promotion-package.md); ProjectBoost — ровно 1 цель, PropertyObjectBoost/PostBoost — 1..MaxTargetsPerPurchase; цели distinct | проверка при создании покупки | PROMOTION_TARGETS_INVALID |
| INV-2 SnapshotImmutable | BoostWeight/Price/PackageKind/PackageCode фиксируются при создании и не пересчитываются при правке пакета | поля неизменяемы после создания | нет |
| INV-3 PackagePurchasable | Пакет Status=Active на момент покупки | проверка при покупке | PROMOTION_PACKAGE_NOT_AVAILABLE |
| INV-4 TargetOwnership | Каждый TargetId принадлежит компании-покупателю (TenantId) — валидируется в контексте-владельце цели (purchase-promotion.md, fail-closed) | проверка при покупке | PROMOTION_TARGET_NOT_OWNED |
| INV-5 NoOverlapSameTarget | На (TenantId, PackageId, Target) — не более одной покупки в статусах Pending/Active (защита от задвоения буста) | проверка перед созданием | PROMOTION_ALREADY_ACTIVE |
| INV-6 TerminalFrozen | Из Expired/Cancelled переходов нет | проверка перехода | PROMOTION_INVALID_STATUS_TRANSITION |
Переходы статусов (из / в, условие, инициатор, эффект, событие)
Каждый переход (кроме создания [*] → Pending) публикует PromotionPurchaseChangedKafkaEvent в одном
согласованном изменении со сменой статуса. [*] → Pending в режиме без оплаты (решение №10) существует
лишь как промежуточная точка внутри операции покупки — самостоятельного события не даёт (первое
публикуемое состояние — Active).
| Из | В | Условие | Инициатор | Эффект | Событие (Status) |
|---|
[*] | Pending | создание покупки, пройдены INV-1 TargetsMatchKind, INV-3 PackagePurchasable, INV-4 TargetOwnership, INV-5 NoOverlapSameTarget | менеджер компании (purchase) | снапшот PackageKind/PackageCode/BoostWeight/Price (INV-2), цели зафиксированы; вклад ещё не начислен | — (промежуточный, без оплаты) |
Pending | Active | режим без оплаты — в той же операции покупки; будущий эквайринг — после PaymentStatus=Paid | менеджер компании (purchase) / (будущее) billing по pin.promotion.purchase-changed | StartsAt = now, EndsAt = now + DurationDays; потребители поднимают буст целей | Active |
Pending | Cancelled | отмена до активации | менеджер (cancel) / администратор (admin-cancel) | CancelReason задан; вклад не начислялся (снимать нечего) | Cancelled |
Active | Expired | now() >= EndsAt | System (job expire-promotions) | вклад снимают потребители по событию; переход терминален | Expired |
Active | Cancelled | досрочная отмена | менеджер (cancel) / администратор (admin-cancel) | CancelReason задан; потребители снимают вклад | Cancelled |
Expired / Cancelled | любой | терминально (INV-6 TerminalFrozen) | — | отказ PROMOTION_INVALID_STATUS_TRANSITION; в выборку job-а истечения не попадают | — |
Конкурентность отмены и истечения. Гонка Active → Cancelled (менеджер/админ) ↔ Active → Expired
(job) разрешается по версии покупки: побеждает зафиксировавший первым; проигравший видит покупку уже в
терминальном статусе (CONCURRENCY_CONFLICT в API-отмене — retry покажет Expired; в job-е строка уже
Cancelled и в выборку не попадает). По покупке публикуется ровно одно терминальное событие.
Доменные и интеграционные события
| Событие | Тип | Когда | Snapshot/поля |
|---|
PromotionPurchaseChangedKafkaEvent | интеграционное (ContractVersion = 1, topic pin.promotion.purchase-changed.v1, key = Id) | Каждый переход статуса покупки | PurchaseId, TenantId, PackageId, PackageCode, PackageKind, Targets[] {TargetKind, TargetId}, Status, PaymentStatus, BoostWeight, StartsAt, EndsAt, Price {Amount, CurrencyId, Currency: CurrencySnapshot}, Version — дом ../events/promotion-purchase-changed.md |
Интеграционное событие публикуется в одном согласованном изменении со сменой статуса покупки. Отдельных
доменных событий пересчёта буста нет — денормализация вынесена к потребителям (ADR-0060), промо-контекст
только публикует контракт.
Индексы, хранение, безопасность
| Аспект | Значение |
|---|
| Индексы | покупки: PK, ix_promotion_purchases_tenant_id, ix_promotion_purchases_ends_at (status, ends_at) where status = 'Active' (выборка job-а); цели: ux_promotion_purchase_targets (purchase_id, target_kind, target_id), ix_promotion_purchase_targets_target (target_kind, target_id) |
| Партиционирование / retention | none; покупки — бессрочно (финансовая история будущего биллинга) |
| Concurrency | оптимистическая блокировка; job expire-promotions выбирает кандидатов с блокировкой строк (FOR UPDATE SKIP LOCKED), идемпотентен (терминальные статусы не выбираются) — ADR-0056 п.3 |
| Permissions / PII | покупка/отмена — promotion.purchases.manage в scope организации; чтение своих — promotion.purchases.view; изоляция по TenantId. PII нет; Price — не секрет |
| Audit | отметка автора + Version; покупки не удаляются; переходы — публикуется событие |
Обратные ссылки
Автоссылки: где используется этот документ.
Связанные документы