Promotions - Models Documentation
Location: api/nextango/apps/promotions/models.py
Last Updated: 2026-07-08
Overview
The Promotions domain has two discount mechanisms sharing the same discount vocabulary. PromoCode is a customer-entered code, standalone or grouped under a PromotionalCampaign. Promotion is an auto-apply discount evaluated against every cart with no code required. Both use the same discount_effect / applies_to JSON shapes, and both feed the shared clamp engine in services.py that bounds total discount to the cart subtotal.
Four immutable ledger models back the domain's usage counters and analytics: PromoUsageLedger, CampaignConversion, ABTestConversionLedger, and SaleTransactionAppliedPromotion. Row existence in a ledger is authoritative for whether the corresponding counter increment happened for a given transaction, which makes the increment safe to retry from Celery.
All models except SaleTransactionAppliedPromotion inherit from SanityIntegratedModel (bidirectional Sanity sync) and AuditableMixin (created_at, updated_at, created_by, updated_by). Fields on Sanity-synced models are snake_case in Django and camelCase in the corresponding Sanity schema; the sync layer translates between the two.
PromotionalCampaign
Purpose: Groups one or more PromoCode records (and ABTest records) under a shared marketing campaign with its own date range, store targeting, and performance metrics.
Base class: SanityIntegratedModel, AuditableMixin
Fields
| Field | Type | Description |
|---|---|---|
campaign_name | CharField(255) | Campaign display name |
campaign_type | CharField(50), choices | seasonal_sale, flash_sale, clearance, influencer, customer_loyalty |
date_range | JSONField | {"startDate": ..., "endDate": ...}. Sanity-facing keys stay camelCase inside JSON payloads. |
target_stores | JSONField (list) | Array of Sanity store _ref objects; empty means all stores |
target_products | JSONField | Product/collection targeting configuration |
markdown_schedule | JSONField (list) | Progressive discount schedule entries |
performance_metrics | JSONField | Impressions, conversions, revenue, conversion rate. See Services |
ab_test_id | CharField(255) | Sanity _id of an associated ABTest |
is_active | BooleanField | Master on/off switch |
start_date, end_date | property | Read-only, extracted from date_range |
Cross-links: PromotionalCampaignSerializer, PromotionService.get_active_campaigns_for_store
PromoCode
Purpose: Customer-entered discount code with a modular discount effect, application scope, usage limits, and store applicability.
Base class: SanityIntegratedModel, AuditableMixin
Fields
| Field | Type | Description |
|---|---|---|
name | CharField(255) | Internal descriptive name, not shown to customers |
code | CharField(20), unique | Customer-facing code, auto-uppercased on save |
discount_effect | JSONField | {"type": "percentage"|"fixedAmount"|"freeShipping"|"bogo"|"bogoDiscounted", ...} |
allow_combination | BooleanField | When True, stacks with other stackable discounts. When False, competes against other non-stackable discounts for the same cart |
applies_to | JSONField | {"scope": "order"|"products"|"collections"|"shipping", ...} |
date_range | JSONField | Valid date window |
usage_restrictions | JSONField | maxUses, maxUsesPerCustomer, customer-tier restrictions |
applicable_stores | JSONField (list) | Empty means valid at all stores |
current_usage_count | PositiveIntegerField | Compared against usage_restrictions.maxUses |
is_active | BooleanField | Master on/off switch, checked before all other validation |
campaigns | M2M(PromotionalCampaign) | related_name='promo_codes' |
discount_type, discount_value, usage_limit, times_used | property | Backward-compatible accessors into discount_effect / usage_restrictions / current_usage_count |
Usage increment: current_usage_count is incremented only after a matching row is written to PromoUsageLedger for the transaction. See PromoUsageLedger below. Applying a code to a cart does not increment the count.
Cross-links: PromoCodeSerializer, PromotionService._calculate_promo_discount
ABTest
Purpose: A/B testing framework tracking variant impressions, conversions, and statistical significance for campaign optimization.
Base class: SanityIntegratedModel, AuditableMixin
Fields
| Field | Type | Description |
|---|---|---|
name | CharField(255) | Test identifier |
campaigns | M2M(PromotionalCampaign) | related_name='ab_tests' |
metric_to_track | CharField(100) | e.g. conversion_rate, average_order_value |
test_configuration | JSONField | Variant definitions and traffic split |
variant_a_impressions, variant_a_conversions, variant_b_impressions, variant_b_conversions | PositiveIntegerField | Counters, updated only through record_impression() / record_conversion() |
performance_data | JSONField | Detailed per-variant analytics |
is_active | BooleanField | Controls whether the test is running |
test_duration | JSONField | Start/end/actual-end dates |
variant_a_conversion_rate, variant_b_conversion_rate | property | conversions / impressions * 100, 0 when no impressions |
statistical_significance | property | True once combined impressions reach 1000 |
Methods
record_impression(variant) and record_conversion(variant) increment the matching counter using a database-level F() expression (ABTest.objects.filter(pk=self.pk).update(...)), which avoids a read-modify-write race under concurrent requests. variant is 'a' or 'b', case-insensitive; any other value is logged as a warning and ignored.
Cross-links: ABTestSerializer, record-impression / record-conversion actions
Promotion
Purpose: Auto-apply discount evaluated against every cart at checkout time, with no customer-entered code. Authored in Sanity, synced to Django, evaluated against cart contents and inventory.
Base class: SanityIntegratedModel, AuditableMixin
Table: promotion
Fields
| Field | Type | Description |
|---|---|---|
name | CharField(255) | Display name |
discount_effect | JSONField | Same shape as PromoCode.discount_effect |
applies_to | JSONField | Same shape as PromoCode.applies_to |
allow_combination | BooleanField | Stacking rule, same semantics as PromoCode.allow_combination |
date_range | JSONField | Valid date window |
applicable_stores | JSONField (list) | Empty means all stores |
is_active | BooleanField | Inactive promotions are skipped during checkout evaluation |
images | JSONField (list) | Sanity image references; the first entry is the hero/banner image |
campaigns | M2M(PromotionalCampaign) | Campaign association |
Checkout evaluation
PromotionService._collect_fired_promotions() filters active Promotion records against a transaction: date range validity, store scope, and an inventory gate (stock must exceed zero for standard discounts, or exceed buy_quantity + get_quantity for BOGO types). Stackable promotions all apply; non-stackable promotions compete on post-clamp discount contribution and the best one wins. See the clamp engine for how the resulting discount amount is computed against the cart.
Cross-links: PromotionSerializer, PromotionViewSet
SaleTransactionAppliedPromotion
Purpose: Immutable snapshot of each auto-apply Promotion applied to a transaction. Preserves the promotion's state at the moment of checkout even if the original Promotion record is later modified or deleted.
Base class: plain Django model (not Sanity-synced)
Table: promotion_applied_transaction
Fields
| Field | Type | Description |
|---|---|---|
transaction | FK(SaleTransaction) | CASCADE, related_name='applied_promotions' |
promotion | FK(Promotion) | SET_NULL, null/blank. Goes NULL if the Promotion is later deleted |
promotion_snapshot_id | UUIDField | Django pk of the Promotion at time of sale, preserved after deletion |
promotion_name | CharField(255) | Name snapshot |
discount_effect | JSONField | Full discount effect snapshot |
discount_amount | DecimalField(10,2) | Concrete discount amount applied to this transaction |
Constraints: UniqueConstraint(transaction, promotion_snapshot_id), one row per promotion per transaction.
Rows are never updated after creation and are used by refund calculations (itemized refund amounts) and analytics (PromotionAnalyticsService.get_auto_promotion_performance, grouped by promotion_snapshot_id).
Cross-links: Analytics Service, Transactions Domain
PromoUsageLedger
Purpose: Audit-trail row per (promo_code, sale_transaction) usage increment. Row existence is authoritative for whether PromoCode.current_usage_count was incremented for a given transaction.
Base class: TimestampedModel
Table: promo_usage_ledger
| Field | Type | Description |
|---|---|---|
promo_code | FK(PromoCode) | CASCADE, related_name='usage_ledger' |
sale_transaction | FK(SaleTransaction) | CASCADE, related_name='promo_usage_ledger' |
applied_at | DateTimeField | auto_now_add |
Constraints: UniqueConstraint(promo_code, sale_transaction), the database-level dedup backstop that makes the Celery-driven usage increment safe to retry.
CampaignConversion
Purpose: Audit-trail row per (campaign, sale_transaction) conversion. Row existence is authoritative for whether PromotionService.track_campaign_conversion() was invoked for a given transaction.
Base class: TimestampedModel
Table: campaign_conversion
| Field | Type | Description |
|---|---|---|
campaign | FK(PromotionalCampaign) | CASCADE, related_name='conversions' |
sale_transaction | FK(SaleTransaction) | CASCADE, related_name='campaign_conversions' |
recorded_at | DateTimeField | auto_now_add |
is_new_customer | BooleanField | Captured at conversion time |
Constraints: UniqueConstraint(campaign, sale_transaction).
ABTestConversionLedger
Purpose: Audit-trail row per (ab_test, sale_transaction) conversion. Row existence is authoritative for whether ABTest.record_conversion() was invoked for a given test/transaction pair.
Base class: TimestampedModel
Table: ab_test_conversion_ledger
| Field | Type | Description |
|---|---|---|
ab_test | FK(ABTest) | CASCADE, related_name='conversion_ledger' |
sale_transaction | FK(SaleTransaction) | CASCADE, related_name='ab_test_conversion_ledger' |
variant | CharField(10) | The AB test variant (a/b) at conversion time |
recorded_at | DateTimeField | auto_now_add |
Constraints: UniqueConstraint(ab_test, sale_transaction).
Model Relationships
PromotionalCampaign ←──M2M──→ PromoCode ──FK──→ PromoUsageLedger ──FK──→ SaleTransaction
│
├──M2M──→ ABTest ──FK──→ ABTestConversionLedger ──FK──→ SaleTransaction
│
└──FK──→ CampaignConversion ──FK──→ SaleTransaction
Promotion ──M2M──→ PromotionalCampaign
Promotion ──FK──→ SaleTransactionAppliedPromotion ──FK──→ SaleTransaction