boboko-core · competitive spec sheet
A feature-by-feature audit of Lunar's Discount engine against promotion tooling in Shopify, WooCommerce, and PrestaShop. Each row is graded against the underlying Lunar source, not the docs.
The two shipped discount types and the machinery that decides whether they fire.
Built in as Lunar\DiscountTypes\AmountOff. applyPercentage() and applyFixedValue() distribute the discount across eligible lines, tracking per-currency fixed values (data.fixed_values.{code}) so the amount is currency-aware, not a single converted number.
Built in as Lunar\DiscountTypes\BuyXGetY. Condition lines and reward lines are configured separately via discountableConditions/discountableRewards; getRewardQuantity() computes how many reward units a given condition quantity earns, with an optional max_reward_qty cap.
checkDiscountConditions() compares strtoupper($cart->coupon_code) against $discount->coupon; Discounts::validateCoupon() exposes a standalone check. Coupon is cast via CouponString on the model.
A blank coupon column makes a discount apply to every eligible cart with no code entered — DiscountManager::getDiscounts() queries whereNull('coupon')->orWhere('coupon', '') when the cart carries no coupon code.
checkDiscountConditions() reads data.min_prices.{currency} and compares it against $lines->sum('subTotal.value'). Configurable per-currency in the admin form's "Minimum cart amount" fieldset — but only enforced by AmountOff, see row below.
AmountOff::getEligibleLines() filters/rejects cart lines against discountableLimitations/discountableExclusions plus collections()/brands() pivot rows typed limitation or exclusion. Configured through five separate Filament relation managers on the discount record.
Whether a discount is currently live, and how hard its usage caps are enforced.
Discount::getStatusAttribute() derives active/pending/expired/scheduled from starts_at/ends_at; the Filament table badges this status column directly (green/gray/red/blue via DiscountResource::getTableColumns()).
Discount::scopeUsable() filters query-side (uses < max_uses OR max_uses IS NULL) before a discount is even fetched; checkDiscountConditions() re-checks it in AmountOff. markAsUsed() increments uses and attaches the user via discount_user.
checkDiscountConditions() calls usesByUser() only when $cart->user exists — a guest checkout cannot be capped per-customer since there's no user_id to key against, only customer_id. Wholesale/B2B carts often complete without a Laravel User attached, so the cap silently no-ops for them.
BuyXGetY::apply() never calls checkDiscountConditions() — grep the method body, it's absent. A coupon-gated, min-spend-gated, or max-uses-capped BOGO discount ignores all three conditions; only the min-quantity/reward math runs. AmountOff::apply() calls it correctly by contrast.
What happens when more than one discount could legally apply to the same cart.
stop field is dead code. It's a real column, cast as boolean on the model, and it's a live toggle in the Filament admin form (DiscountResource::getStopFormComponent()) — but a repo-wide grep of both lunarphp/core and lunarphp/lunar for reads of $discount->stop outside the model and the form turns up nothing. DiscountManager::apply() is a plain unconditional foreach over every fetched discount; nothing ever breaks the loop. Staff can toggle a setting that has zero runtime effect.
DiscountManager::getDiscounts() ends with orderBy('priority', 'desc')->orderBy('id'), and the admin form exposes low/medium/high (1/5/10) presets. This genuinely controls apply order.
See callout above — stop is unread at runtime. Every active, eligible discount is applied every time; there is no way to make one discount exclusive of the rest short of writing a custom AbstractDiscountType that inspects $cart->discounts itself.
Shopify models discounts as Product/Order/Shipping classes with an explicit "Combines with" toggle per pair. Lunar has no discount class concept at all — AmountOff and BuyXGetY are the only two types and neither declares a class or combination policy.
$cart->discountBreakdown (a collection of DiscountBreakdown value objects, one per applied discount with its affected lines) gives a storefront the raw data to render "2 promotions applied," but no UI ships to render it — it's a data structure a storefront app must build its own component against.
Both AmountOff::applyFixedValue() and applyPercentage() explicitly skip a line when $line->discountTotal->value > $amount — "if this line already has a greater discount value, don't add this one as they already have a better deal." This is a real per-line max-discount guard, just not a whole-cart exclusivity rule.
"Buy more, save more" mechanics — and the separate pricing layer that actually implements some of them in Lunar.
Discount. PricingManager::get() filters a purchasable's Price rows for min_quantity > 1 AND $this->qty >= $price->min_quantity and picks the cheapest matching price break. This is quantity-break pricing baked into the price table itself, resolved at Pricing::for($variant)->qty($n)->get() time — it never touches the Discount model, coupon system, or discount breakdown at all. A storefront gets the discounted unit price with no visible "discount applied" line.
Via the Price model's min_quantity/pricing pipeline described above, not Discount. Configured directly on product variant pricing in the admin, no separate promotion object needed.
AmountOff takes one flat percentage or fixed value per discount record; there is no multi-tier threshold structure in data. Reaching this today means creating several separate Discount rows, each with its own min_prices floor, and hoping only the intended one wins (compounded by the stop gap in section 03).
No bundle or kit concept anywhere in lunarphp/core's catalog or discount models. Shopify/WooCommerce/PrestaShop all support this via dedicated bundle apps or plugins layered on the same primitive Lunar lacks — a discount keyed to a co-purchased product set rather than any single line.
BuyXGetY's automatically_add_rewards flag drives processAutomaticRewards(), which inserts a brand-new CartLine for a randomly selected reward product and zeroes its price via discountTotal. $cart->freeItems tracks which purchasables were added this way.
Lunar has two genuinely different mechanisms here that solve overlapping-looking problems — conflating them is the easiest mistake to make.
CustomerGroup pricing and Discount customer-group scoping are not the same feature. Pricing::for($variant)->customerGroups($groups)->get() resolves a different base price per customer group directly from the Price table (wholesale sees $8, retail sees $10 — two rows, no discount object, no coupon, nothing to "apply"). Discount::customerGroups() is a separate pivot (customer_group_discount, via the HasCustomerGroups trait) that scopes whether a promotion is visible/enabled to a group at all, with its own starts_at/ends_at/enabled/visible per-pivot-row scheduling. One is differential pricing; the other is promotion eligibility. Both exist and both work, but they're wired into completely separate code paths.
PricingManager::get(): $potentialGroupPrice filters Price rows with a matching customer_group_id and picks the cheapest; falls back to $basePrice when no group price exists.
DiscountManager::getDiscounts() applies ->customerGroup($this->customerGroups) via the shared HasCustomerGroups trait's scopeCustomerGroup(), configured on the discount's own "Availability" sub-page (ManageDiscountAvailability) alongside channel restriction.
Discount::customers() pivot (customer_discount), checked in checkDiscountConditions(): if the discount has any tied customers, a cart without a matching customer_id fails eligibility outright. Managed via CustomerLimitationRelationManager in the admin.
Lunar does compute an order-level new_customer boolean (Jobs\Orders\MarkAsNewCustomer, ! $previousOrder) — but it's a post-order reporting flag surfaced only in the Filament order table/dashboard chart. Nothing reads it during ApplyDiscounts; there's no "is this customer's first order" condition available to a Discount at checkout time.
No referral concept anywhere in lunarphp/core or lunarphp/lunar — not a model, job, or config key. Common as a bolt-on in WooCommerce/Shopify via loyalty apps (e.g. WPLoyalty's referral-points module); would need to be built from scratch on top of Discount::customers() at best.
No points ledger, balance, or redemption model exists in Lunar core. A loyalty program (points-to-discount conversion, VIP-tier multipliers) is a third-party plugin layer in every researched competitor, not core commerce logic — same gap here, but Lunar offers no AbstractDiscountType hook obviously suited to "redeem N points" either, since discount eligibility has no notion of a spendable balance.
What it takes to reach a feature Lunar doesn't ship, without forking the package.
Discounts::addType(MyType::class) appends to DiscountManager::$types (seeded with just AmountOff::class, BuyXGetY::class). A new type extends AbstractDiscountType and implements apply(CartContract $cart) — the same contract the two built-ins use, so it participates in the same unconditional-foreach loop from section 03.
Requires additionally implementing Lunar\Admin\Base\LunarPanelDiscountInterface (lunarPanelSchema()/lunarPanelOnFill()/lunarPanelOnSave()) for DiscountResource::getDefaultForm() to render a config section for it. The interface exists and is wired in, but there is no shipped example implementation to copy from beyond AmountOff/BuyXGetY, which are hard-coded into the form rather than using the interface themselves.