Repository navigation
feat: promotions engine with codes, stacking and checkout integration - #104
Merged
Merged
Conversation
…ration Add promotions owned by a store or network: automatic or code based percentage, fixed amount, free delivery and buy X get Y discounts, with product/category/store targeting, minimum subtotal and item conditions, first order only, date ranges and weekly schedule windows, usage limits (total, per customer, per code), budgets, maximum discounts, stacking and priorities. Checkout prices the cart's promotions when a checkout is created, stores them on the checkout options and reserves each use under a row lock, so the amount charged (cash, Stripe, QPay) always matches the discount shown. Capture records a discount transaction item and order meta and redeems the reservations; multi-store orders split the discount per store. Stale reservations are released by a scheduled command. Cart endpoints apply, remove and preview codes; the public API lists the storefront's live deals; the console API manages promotions and generates codes.
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #104 +/- ##
============================================
Coverage 100.00% 100.00%
- Complexity 1772 2023 +251
============================================
Files 135 153 +18
Lines 7778 8557 +779
============================================
+ Hits 7778 8557 +779
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
6 of 16 tasks
after:starts_at failed validation whenever starts_at was empty.
This was referenced Sep 27, 2026
Merged
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds a promotions engine to Storefront: discounts owned by a store or network, applied automatically or through codes, priced into every checkout path (cash, Stripe, QPay) and recorded on the order.
This is the first part of the promotions work. It covers the data model, the engine, checkout and cart integration, the public deals API and the console CRUD API. Customer segments, campaigns (scheduled notification blasts) and the console UI come in follow-up PRs.
Promotion types and rules
percentage,fixed_amount,free_delivery,bogo(buy X get Y at N% off; the cheapest units are discounted)automatic, orcode, with any number of codes per promotion (single-use or limited, customer-specific, expiring)applies_to: products, categories, stores, plus excluded products and categories. Store-owned promotions only ever apply to that store's itemsstarts_at/ends_at, and weekly windows in the promotion's timezone (e.g. weekday happy hours; windows may cross midnight)stackablepromotions combine inpriorityorder, each on what the previous ones left. A non-stackable promotion applies alone, and whichever option saves the customer more wins. A code that loses out is reported asnot_combinableHow money flows
beforeCheckout: cash, Stripe and QPay, plusupdateStripePaymentIntent),PromotionEngineprices the cart. Codes come frompromo_codes/promo_code/discount_codeand from codes applied to the cart. The result is stored incheckouts.options.promotions, andcalculateCheckoutAmountsubtracts it. The Stripe PaymentIntent, the QPay invoice and the cash amount all come from that one function, so they always agree.promotion_redemptions) under a row lock that re-checks limits and budgets, so concurrent checkouts can't overspend a promotion. If a promotion ran out between pricing and reservation, the checkout is discarded and the customer gets an error.discounttransaction item plusdiscount/promotionsorder meta, and redeems the reservation. Multi-store (network) checkouts split the item discount across the child orders by store.storefront:release-promotion-reservationsruns every 15 minutes and releases uses held by checkouts that were never captured. Reservations older than 60 minutes already stop counting toward limits.A checkout fails if a code the customer entered can't be applied (invalid, expired, limit reached, conditions not met), so nobody is charged without a discount they expected.
API
Public (
storefront/v1, for the storefront app):promotions(?store=)promotions/{id}carts/{id}/promo-code{ code }: validates the code against the cart and stores it. Returns{ cart, promotions }, or an error with a reasoncarts/{id}/promo-code/{code}carts/{id}/promotions(?pickup=&service_quote=){ discount, discount_subtotal, discount_delivery, applied[], rejected[] }Also:
checkouts/beforeandcheckouts/stripe-updateacceptpromo_codes, and the Cart resource gainspromo_codes.Console (
storefront/int/v1):fleetbaseRoutes('promotions')(with validation, company-scoped owner check,owner/status/typefilters, and astatsfield with redemptions and discount given),POST promotions/{id}/generate-codes(count,length,prefix,usage_limit,expires_at, or an exactcode), andfleetbaseRoutes('promotion-codes')(times_used).Permissions: new
promotion(with agenerate-codesaction) andpromotion-coderesources, plus aPromotionsManagerpolicy.Related Issue
Part of the promotions / ads / notifications work. No tracking issue.
Type of Change
Implementation Notes
server/src/Promotions/:PromotionEngine: candidates, eligibility, limits, stacking.PromotionCalculator: per-type maths, largest-remainder allocation across lines.PromotionContext/PromotionLine: the cart as seen by the engine.PromotionResult: serializable result with per-store allocations.PromotionRedemptions: reserve, redeem, release.Promotion,PromotionCode,PromotionRedemption, and migration2026_09_26_100000_create_promotions_tables. Money columns use the cart subtotal's minor-unit integers.carts.discount_codecolumn (comma separated), so no cart migration is needed.Storefront::about()now returnsnullfor an unknown storefront key instead of throwing on->is_store.Validation
New tests in
server/tests/Unit/Promotions/(65): the calculator (every type, targeting, conditions, allocation), the engine (codes, all rejection reasons, customer rules, stacking, caps, budgets, schedules), checkout (amount calculation, pricing errors, reservation and capacity under lock, redemption, release command, discount transaction item, QPay line), and endpoints (cart codes, public deals, code generation, validation, filters, resources).Documentation Impact
fleetbase/fleetbase.ioAPI Reference Impact
fleetbase/postmanAPI reference notes:
promotionsendpoints, the new cartpromo-codeandpromotionsendpoints, and thepromo_codesparam oncheckouts/beforeandcheckouts/stripe-update.promotions.rejected[]).discountandpromotions, and thediscounttransaction item.Documentation Notes
fleetbase.io, Storefront → Promotions: types, conditions, stacking rules, codes, and how discounts appear on orders and transactions. Storefront API: the endpoints above.
Risk
Needs human review: this changes checkout amounts. With no promotions configured, every path behaves exactly as before; the new code only subtracts discounts that are stored on the checkout.
Decisions to confirm:
code: discountandmeta.direction: credit. Core-api'sMoneycast strips minus signs, so a negative line isn't possible without changing that cast platform-wide. Anything that sums transaction items must subtractdiscountlines (the transaction's ownamountis already net).