Skip to content

feat: promotions engine with codes, stacking and checkout integration - #104

Merged
roncodes merged 3 commits into
release/v0.4.22from
feat/promotions-engine
Sep 28, 2026
Merged

roncodes merged 3 commits into
release/v0.4.22from
feat/promotions-engine

Conversation

@roncodes

Copy link
Copy Markdown
Member

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

Types percentage, fixed_amount, free_delivery, bogo (buy X get Y at N% off; the cheapest units are discounted)
Trigger automatic, or code, with any number of codes per promotion (single-use or limited, customer-specific, expiring)
Targeting applies_to: products, categories, stores, plus excluded products and categories. Store-owned promotions only ever apply to that store's items
Conditions minimum subtotal and minimum item count (counted over the targeted items), first order only, currency (fixed amounts)
Schedule starts_at/ends_at, and weekly windows in the promotion's timezone (e.g. weekday happy hours; windows may cross midnight)
Limits total uses, uses per customer, uses per code, total budget, maximum discount per order
Stacking stackable promotions combine in priority order, 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 as not_combinable

How money flows

  1. When a checkout is created (beforeCheckout: cash, Stripe and QPay, plus updateStripePaymentIntent), PromotionEngine prices the cart. Codes come from promo_codes/promo_code/discount_code and from codes applied to the cart. The result is stored in checkouts.options.promotions, and calculateCheckoutAmount subtracts it. The Stripe PaymentIntent, the QPay invoice and the cash amount all come from that one function, so they always agree.
  2. Reservation: each use is reserved (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.
  3. Capture reuses the stored discount (so the amount matches what was paid), adds a discount transaction item plus discount/promotions order meta, and redeems the reservation. Multi-store (network) checkouts split the item discount across the child orders by store.
  4. Cleanup: storefront:release-promotion-reservations runs 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):

Method Path Description
GET promotions (?store=) live, public promotions ("deals") of the store, or of the network and its member stores
GET promotions/{id} one promotion
POST carts/{id}/promo-code { code }: validates the code against the cart and stores it. Returns { cart, promotions }, or an error with a reason
DELETE carts/{id}/promo-code/{code} removes a code
GET carts/{id}/promotions (?pickup=&service_quote=) previews { discount, discount_subtotal, discount_delivery, applied[], rejected[] }

Also: checkouts/before and checkouts/stripe-update accept promo_codes, and the Cart resource gains promo_codes.

Console (storefront/int/v1): fleetbaseRoutes('promotions') (with validation, company-scoped owner check, owner/status/type filters, and a stats field with redemptions and discount given), POST promotions/{id}/generate-codes (count, length, prefix, usage_limit, expires_at, or an exact code), and fleetbaseRoutes('promotion-codes') (times_used).

Permissions: new promotion (with a generate-codes action) and promotion-code resources, plus a PromotionsManager policy.

Related Issue

Part of the promotions / ads / notifications work. No tracking issue.

Type of Change

  • Bug fix
  • Feature
  • Refactor
  • Documentation
  • Test
  • Chore

Implementation Notes

  • New code in 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.
  • New models Promotion, PromotionCode, PromotionRedemption, and migration 2026_09_26_100000_create_promotions_tables. Money columns use the cart subtotal's minor-unit integers.
  • Promotion codes are stored on the existing, previously unused carts.discount_code column (comma separated), so no cart migration is needed.
  • Storefront::about() now returns null for an unknown storefront key instead of throwing on ->is_store.

Validation

  • Tests
  • Lint
  • Build (no frontend changes)
  • Manual validation (please try cash, Stripe and QPay checkouts with a percentage code and free delivery)
composer test:unit -> 505 tests, 3004 assertions, 0 failures (on current main)
composer test:lint -> Found 0 of 274 files that can be fixed

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

  • No documentation changes needed
  • Documentation updated in fleetbase/fleetbase.io
  • Documentation needed but not included

API Reference Impact

  • No API reference changes needed
  • Updated fleetbase/postman
  • API reference updates required but not included

API reference notes:

  • New public promotions endpoints, the new cart promo-code and promotions endpoints, and the promo_codes param on checkouts/before and checkouts/stripe-update.
  • New checkout error for a code that can't be applied (400, with promotions.rejected[]).
  • New order meta discount and promotions, and the discount transaction 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:

  1. Tips (percentage) are calculated on the pre-discount subtotal.
  2. The discount transaction item is stored as a positive amount with code: discount and meta.direction: credit. Core-api's Money cast strips minus signs, so a negative line isn't possible without changing that cast platform-wide. Anything that sums transaction items must subtract discount lines (the transaction's own amount is already net).
  3. QPay invoices get a negative "Discount" line (and negative VAT) so the invoice total matches. Please confirm eBarimt accepts negative lines. If not, the alternative is to spread the discount across the item lines.
  4. Uses are counted at checkout creation (reservation), not at payment. An abandoned checkout holds a use for up to 60 minutes.
  5. If a reservation expired before capture, the order still redeems the promotion, because the customer already paid the discounted amount. That can exceed a limit by the number of such late captures.

…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.
@roncodes roncodes added needs-docs Requires documentation updates needs-api-spec Requires API specification updates needs-human-review Requires human review before proceeding needs-product-decision Requires product decision or clarification type:feature Feature or enhancement labels Sep 26, 2026
@codecov

codecov Bot commented Sep 26, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 100.00%. Comparing base (490f9e5) to head (b6738c0).

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     
Flag Coverage Δ
backend 100.00% <100.00%> (ø)

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@roncodes roncodes mentioned this pull request Sep 26, 2026
6 of 16 tasks
after:starts_at failed validation whenever starts_at was empty.
@roncodes
roncodes merged commit c7fcf9d into release/v0.4.22 Sep 28, 2026
@roncodes
roncodes deleted the feat/promotions-engine branch September 28, 2026 04:11
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

needs-api-spec Requires API specification updates needs-docs Requires documentation updates needs-human-review Requires human review before proceeding needs-product-decision Requires product decision or clarification type:feature Feature or enhancement

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant