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.
Data model
Section titled “Data model”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 bystock_idand aproduct_variant_idsentinel (0means “whole product, no variant”, translated tonullvia 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.
Lifecycle: draft, schedule, end
Section titled “Lifecycle: draft, schedule, end”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.
Pricing items: picker vs. CSV import
Section titled “Pricing items: picker vs. CSV import”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.
Checkout resolution
Section titled “Checkout resolution”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.
Route surface
Section titled “Route surface”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 |