Promotions - Services Documentation
Location: api/nextango/apps/promotions/services.py
Last Updated: 2026-07-08
Overview
PromotionService is a stateless service class, every method is static, that evaluates transactions against promo codes and auto-apply promotions and calculates the resulting discount. It backs three surfaces: checkout code validation, the auto-apply promotion evaluation that runs on every cart, and the recording-stage reconciliation that writes the final discount to a completed transaction.
Two discount sources exist side by side: PromoCode (customer-entered) and Promotion (auto-apply, no code). Both share the same discount_effect and applies_to JSON shapes and both flow through the same clamp engine described below, so a code and an auto-apply promotion on the same line item cannot jointly discount that line below zero.
DiscountResult
Result container for a discount calculation.
| Field | Type | Description |
|---|---|---|
promo_code | PromoCode or None | The code applied, if any |
campaign | PromotionalCampaign or None | The campaign attributed, if any |
discount_amount | Decimal | Total discount, always >= 0 |
discount_type | str | percentage, fixedAmount, freeShipping, bogo, mixed, or none |
applied_to | str | order, line_items, or shipping |
line_item_discounts | dict | Per-line-item discount breakdown |
error_message | str | Populated when the discount could not be calculated |
success | bool (computed) | discount_amount > 0 and no error message |
to_dict() converts the result to the shape returned by the checkout API.
ApplyResult
Result of the basePromoCode candidate-application pipeline, used by the two-tier default-promo-code hierarchy (see below). States: not_applicable (no default code resolved), scope_rejected (a code resolved but failed applies_to validation, carries a reason), candidate (resolved and valid, carries the promo_code).
Public methods
validate_promo_code_for_checkout
validate_promo_code_for_checkout(promo_code, transaction=None, subtotal=None, store_id=None) -> dict
Validates a code during checkout without incrementing usage. Accepts either an existing transaction or a raw subtotal and store_id for checkouts where no transaction row exists yet. Returns {'valid': bool, 'error': str, 'discount_info': dict | None}. discount_info includes code, name, discount_type, discount_amount, and a formatted message.
evaluate_transaction
evaluate_transaction(transaction, promo_code=None, store_id=None) -> DiscountResult
The entry point called from SaleTransaction.recalculate_totals(). Two paths:
- Explicit single-code application ("apply this code" API): if
promo_codeis passed and the transaction has no codes applied yet, delegates to_apply_promo_code(). - Recording path (the common path, used by
recalculate_totals()): callsclamp_transaction_discounts(), which mirrors the checkout-time selection of codes, campaign-linked codes, and auto-apply promotions, then clamps the selected set through the shared per-line engine. The returnedDiscountResult.discount_amountis the subtotal-bounded total,discount_typeis'mixed'.
apply_discount_to_transaction
apply_discount_to_transaction(sale_transaction, discount_result) -> SaleTransaction
Applies a calculated discount: increments promo_code.current_usage_count if a code was used, and calls track_campaign_conversion() for the attributed campaign (non-fatal on failure, logged as a warning). Raises PromotionServiceError if discount_result.success is False.
validate_promo_code_only
validate_promo_code_only(code, transaction=None, store_id=None) -> dict
Validates a code without calculating a discount. With a transaction, runs the full _validate_promo_code() check (store, minimum order, product/collection scope). Without one, checks only is_valid_for_use().
get_active_campaigns_for_store
get_active_campaigns_for_store(store_id) -> list[PromotionalCampaign]
Returns active campaigns applicable to a store. store_id may be a Django UUID or a Sanity sanity_id; it is normalized to the canonical sanity_id before comparing against target_stores _ref values.
track_campaign_impression / track_campaign_conversion
track_campaign_impression(campaign, context=None) increments campaign.performance_metrics['impressions'] and recalculates conversionRate. track_campaign_conversion(campaign, conversion_value, context=None, new_customer=False) increments totalSales, adds conversion_value to totalRevenue, recalculates averageOrderValue and conversionRate, and increments newCustomers when new_customer=True.
Calling points: track_campaign_impression() is called from web_commerce/services/checkout_service.py after a promo code is added to the cart, once per active campaign linked to that code (non-fatal on failure). track_campaign_conversion() is called from apply_discount_to_transaction() on the completed-transaction path.
get_applied_auto_promotions
get_applied_auto_promotions(transaction) -> list[tuple[Promotion, Decimal]]
Public wrapper around _collect_fired_promotions(). Returns each fired Promotion and its calculated discount amount for the given transaction.
preview_from_cart_items
preview_from_cart_items(items_json, store_id) -> dict
Computes the active-promotion preview for a cart that has no SaleTransaction yet, the storefront cart-page path. Takes cart line items as {sku, unit_price, quantity} dicts and a Django store UUID (or None to skip store filtering). Returns active_promotions (banner metadata for every eligible promotion, including non-winning non-stackable ones), promotion_savings (the clamped total), per_sku_discounts (per-unit discount by SKU), and free_shipping_active.
Two-tier basePromoCode hierarchy
get_effective_default_promo_code_id(variant), resolve_promo_code_for_display(variant), and apply_default_promo_to_cart_candidate(variant, transaction) support product-level default promo codes: a variant can carry its own default code, falling back to the parent product's default if the variant has none. resolve_promo_code_for_display() is identity-only, no scope validation, intended for "suggested code" UI. apply_default_promo_to_cart_candidate() runs both identity resolution and applies_to scope validation, returning an ApplyResult; per the design, it never auto-applies, the caller decides whether to add the resolved code to the cart.
Discount calculation
_calculate_promo_discount(promo_code, transaction) dispatches on discount_effect['type'] to one of four calculators, all order-level (subtotal-based), used for the checkout-preview and single-code-application paths:
_calculate_percentage_discount:subtotal * percentage / 100, rounded half-up to the cent._calculate_fixed_amount_discount: value in cents converted to dollars, capped at the transaction subtotal._calculate_free_shipping_discount: returns the transaction's actualfulfillment.total_shipping, not a placeholder value._calculate_bogo_discount: expands every line item into individual per-unit prices, sorts descending, and within each group ofbuy_quantity + get_quantityunits discounts the lastget_quantityunits byget_discount_percentage. This is a real computation over line items, not a fixed placeholder amount.
The clamp engine
The order-level calculators above answer "what would this promo code alone discount an order by." The clamp engine in _clamp_candidates(), _build_candidate_spec(), and clamp_transaction_discounts() answers the harder question: when multiple promo codes and auto-apply promotions are eligible on the same cart at once, what is the combined discount, bounded so that no single unit's stacked discount can exceed its own price.
Each candidate (a PromoCode or a Promotion) is converted to a spec carrying its eligible SKUs and a per-unit discount amount for each unit in the cart. Stackable candidates apply in oldest-first order against a shared per-unit headroom pool per SKU; a non-stackable candidate competes against the other non-stackable candidates for the single winning slot, chosen by post-clamp contribution. The invariant this guarantees: each unit's stacked discount is bounded by its own price, so a line's total discount cannot exceed its extended price, and the transaction's total discount cannot exceed its subtotal.
clamp_transaction_discounts() is the recording-stage entry point (used by recalculate_totals()): it mirrors the same code-plus-auto-promotion selection used at checkout preview time, then re-applies the clamp so the recorded orderLevelDiscount and per-line lineDiscount values on the transaction are the subtotal-bounded truth rather than the raw nominal sum of independently-calculated discounts. preview_from_cart_items() is the cart-preview entry point, used before a transaction exists.
Free shipping is a separate money dimension, it reduces the shipping cost, not an item line, so it is captured once outside the per-line clamp and added to the total, bounded by the actual shipping amount.
Automatic promotion evaluation
_evaluate_automatic_promotions(transaction, store_id=None) is used by the older evaluate_transaction() fallback path (campaign-linked codes plus fired auto-apply Promotions, summed) and by service-level callers that want the order-level (non-clamped) evaluation directly. _collect_fired_promotions(transaction) is the shared eligibility pipeline behind both this method and get_applied_auto_promotions(): it filters active Promotion records by date validity, store scope, and the inventory gate (_promotion_passes_inventory_gate(), stock must exceed zero, or exceed buy_quantity + get_quantity for BOGO), then splits the survivors into stackable (all applied) and non-stackable (best one wins by discount amount).
Related Documentation
- Models - PromoCode, Promotion, PromotionalCampaign, ABTest, and the ledger models
- Views - REST API endpoints using PromotionService
- Signals - Sanity sync
- Transactions Domain - SaleTransaction.recalculate_totals integration
- README - Domain overview