Skip to content

Promotions

Location: dg_smart_pos_api/modules/Promotions. Owns discount/promotion campaigns for POS pricing: scheduling a promotion window, pricing the items in it (one at a time or via a bulk CSV import), and resolving the single currently-eligible promotion at checkout. It does not own stock or catalog pricing itself, it calls App\Services\Pricing\PricingResolverService to resolve each promoted stock/variant to a concrete price.

Gated per store by the enable_promotions_module system config, enforced by the system.config:enable_promotions_module route middleware on the whole route group, confirmed in modules/Promotions/Routes/promotions.php.

  • Promotion - the campaign: name, banner text (max 4 words, enforced server-side), type, status (draft/scheduled/ended, no stored “active” state, eligibility is always derived), start/end window, activation audit fields.
  • PromotionItem - one priced stock or product variant within a promotion: promo_price/percent_off/baseline_price, keyed by stock_id and a product_variant_id sentinel (0 means “whole product, no variant”, translated to null via an accessor).
  • PromotionStore - which stores a promotion applies to (a promotion can target more than one store).

A promotion_activation_lock table backs a single-row mutex (see Flows) rather than a model of its own.

A promotion is created as draft (PromotionController::store). Activating it (activate) moves it to scheduled after checking for an overlapping scheduled window tenant-wide (not just this promotion’s own stores); ending it early (end) moves it to ended; returnToDraft reverts a scheduled-but-not-yet-started promotion back to draft. All three go through PromotionActivationService, and all three take the same promotion_activation_lock row (lockForUpdate) as the first statement of their transaction, a deliberate single mutex chosen over per-row locking to avoid both deadlocks and a time-of-check/time-of-use gap between reading and locking the conflicting set.

An item can be priced two ways, both going through the same validation (PromotionMatrixService::addOrUpdateItem) and the same draft-only restriction: one at a time through PromotionItemController (list/search-catalog/add/remove, for an “items in this promotion” picker modal), or in bulk through PromotionMatrixController’s CSV template/import. An import at or under config('promotions.matrix_async_row_threshold') rows (default 500) applies synchronously; above that it’s queued as ProcessPromotionMatrixImportJob and its progress polled via importStatus against a cache key.

PromotionController::active is the only POS-facing endpoint gated merely by the module being enabled (no promotions:* permission needed, since every cashier needs it at checkout). It resolves the one scheduled promotion whose window covers now for the given store, resolves every promoted item’s server-side price via PricingResolverService, and returns each as a flat row_id (s-{stock_id} for a plain product, v-{stock_id}-{variant_id} for a variant) with a promo_price. The client does no percentage math itself, it just matches row_ids at the register.

All under auth:sanctum + system.config:enable_promotions_module, prefix promotions/stores/{store}, registered via modules/Promotions/Routes/promotions.php (required once from routes/tenant.php):

Method Path Controller method Permission
GET active PromotionController::active none beyond module enabled
GET / PromotionController::index promotions:view
GET {promotion} PromotionController::show promotions:view
GET {promotion}/items PromotionItemController::index promotions:view
GET {promotion}/items/search PromotionItemController::search promotions:view
POST / PromotionController::store promotions:manage
PUT {promotion} PromotionController::update promotions:manage
DELETE {promotion} PromotionController::destroy promotions:manage
POST {promotion}/items PromotionItemController::store promotions:manage
DELETE {promotion}/items/{item} PromotionItemController::destroy promotions:manage
POST {promotion}/activate PromotionController::activate promotions:activate
POST {promotion}/end PromotionController::end promotions:activate
POST {promotion}/return-to-draft PromotionController::returnToDraft promotions:activate
GET {promotion}/matrix/template PromotionMatrixController::template promotions:import
POST {promotion}/matrix/import PromotionMatrixController::import promotions:import
GET {promotion}/matrix/import/{importId} PromotionMatrixController::importStatus promotions:import
  • Inventory - stock/variant pricing resolution
  • Sales - POS checkout consumes active