Files
core/docs/scratch/discounts-feature-survey.html
T

528 lines
26 KiB
HTML

<title>Discounts Feature Survey</title>
<style>
@import url('https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,400;9..144,500;9..144,600&family=IBM+Plex+Sans:wght@400;500;600&family=IBM+Plex+Mono:wght@400;500&display=swap');
:root {
--paper: #FAFAF7;
--ink: #1C1C1A;
--muted: #6B6B63;
--accent: #2F5D50;
--accent-soft: #E4EDE9;
--good: #3F7A5C;
--good-soft: #E6F0EA;
--warn: #B8863B;
--warn-soft: #F5ECDC;
--miss: #A14B3B;
--miss-soft: #F5E5E0;
--hairline: #E4E2DB;
--card: #FFFFFF;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--paper: #17181A;
--ink: #EDEBE4;
--muted: #9C9A90;
--accent: #6FAE97;
--accent-soft: #1E2D28;
--good: #6FAE97;
--good-soft: #1C2B22;
--warn: #D9AD6B;
--warn-soft: #2E2618;
--miss: #DE8A76;
--miss-soft: #2E1F1B;
--hairline: #302F2B;
--card: #1D1E20;
}
}
:root[data-theme="dark"] {
--paper: #17181A;
--ink: #EDEBE4;
--muted: #9C9A90;
--accent: #6FAE97;
--accent-soft: #1E2D28;
--good: #6FAE97;
--good-soft: #1C2B22;
--warn: #D9AD6B;
--warn-soft: #2E2618;
--miss: #DE8A76;
--miss-soft: #2E1F1B;
--hairline: #302F2B;
--card: #1D1E20;
}
* { box-sizing: border-box; }
body {
background: var(--paper);
color: var(--ink);
font-family: "IBM Plex Sans", ui-sans-serif, system-ui, sans-serif;
font-size: 16px;
line-height: 1.6;
margin: 0;
padding: 0;
}
.sheet {
max-width: 780px;
margin: 0 auto;
padding: 72px 24px 56px;
}
header.title-block {
margin-bottom: 56px;
padding-bottom: 32px;
border-bottom: 1px solid var(--hairline);
}
.eyebrow {
font-family: "IBM Plex Mono", ui-monospace, monospace;
font-size: 12px;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--accent);
margin: 0 0 16px;
}
h1 {
font-family: "Fraunces", Georgia, serif;
font-weight: 500;
font-size: 2.15rem;
line-height: 1.18;
letter-spacing: -0.01em;
text-wrap: balance;
margin: 0 0 18px;
color: var(--ink);
}
.lede {
font-size: 1rem;
color: var(--muted);
max-width: 62ch;
margin: 0 0 20px;
}
.legend {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-top: 8px;
}
.chip {
display: inline-flex;
align-items: center;
gap: 6px;
font-family: "IBM Plex Mono", ui-monospace, monospace;
font-size: 11.5px;
letter-spacing: 0.03em;
padding: 3px 9px;
border-radius: 3px;
text-transform: uppercase;
white-space: nowrap;
}
.chip.have { background: var(--good-soft); color: var(--good); }
.chip.partial { background: var(--warn-soft); color: var(--warn); }
.chip.missing { background: var(--miss-soft); color: var(--miss); }
section.category {
margin-bottom: 48px;
}
.cat-head {
display: flex;
align-items: baseline;
gap: 14px;
margin-bottom: 6px;
}
.cat-num {
font-family: "Fraunces", Georgia, serif;
font-weight: 500;
font-size: 1rem;
color: var(--accent);
min-width: 26px;
}
.cat-title {
font-family: "Fraunces", Georgia, serif;
font-weight: 500;
font-size: 1.3rem;
letter-spacing: -0.005em;
margin: 0;
}
.cat-note {
font-size: 0.92rem;
color: var(--muted);
margin: 0 0 22px 40px;
max-width: 58ch;
}
.rows {
display: flex;
flex-direction: column;
border-top: 1px solid var(--hairline);
margin-left: 40px;
}
.row {
padding: 15px 0;
border-bottom: 1px solid var(--hairline);
}
.row-head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 16px;
margin-bottom: 6px;
}
.feat-name {
font-weight: 600;
font-size: 0.98rem;
color: var(--ink);
}
.ground {
font-size: 0.87rem;
color: var(--muted);
max-width: 66ch;
}
.ground code {
font-family: "IBM Plex Mono", ui-monospace, monospace;
font-size: 0.83em;
background: var(--accent-soft);
color: var(--accent);
padding: 1px 5px;
border-radius: 3px;
}
.callout {
background: var(--card);
border: 1px solid var(--hairline);
border-left: 3px solid var(--accent);
border-radius: 4px;
padding: 16px 18px;
margin: 0 0 22px 40px;
font-size: 0.9rem;
color: var(--ink);
}
.callout strong {
color: var(--accent);
}
footer {
margin-top: 64px;
padding-top: 24px;
border-top: 1px solid var(--hairline);
font-size: 0.82rem;
color: var(--muted);
font-family: "IBM Plex Mono", ui-monospace, monospace;
}
footer p {
margin: 0 0 8px;
line-height: 1.6;
}
footer p:last-child { margin-bottom: 0; }
@media (max-width: 560px) {
.cat-note, .rows, .callout { margin-left: 0; }
.row-head { flex-direction: column; gap: 4px; }
}
</style>
<div class="sheet">
<header class="title-block">
<p class="eyebrow">boboko-core &middot; competitive spec sheet</p>
<h1>What discounts &amp; promotions elsewhere can do that boboko can&rsquo;t yet</h1>
<p class="lede">A feature-by-feature audit of Lunar's <code style="font-family:'IBM Plex Mono',monospace;background:var(--accent-soft);color:var(--accent);padding:1px 5px;border-radius:3px;font-size:0.85em;">Discount</code> engine against promotion tooling in Shopify, WooCommerce, and PrestaShop. Each row is graded against the underlying Lunar source, not the docs.</p>
<div class="legend">
<span class="chip have">have</span>
<span class="chip partial">partial</span>
<span class="chip missing">missing</span>
</div>
</header>
<section class="category">
<div class="cat-head">
<span class="cat-num">01</span>
<h2 class="cat-title">Core discount mechanics</h2>
</div>
<p class="cat-note">The two shipped discount types and the machinery that decides whether they fire.</p>
<div class="rows">
<div class="row">
<div class="row-head">
<span class="feat-name">Percentage / fixed-amount off cart or line items</span>
<span class="chip have">have</span>
</div>
<p class="ground">Built in as <code>Lunar\DiscountTypes\AmountOff</code>. <code>applyPercentage()</code> and <code>applyFixedValue()</code> distribute the discount across eligible lines, tracking per-currency fixed values (<code>data.fixed_values.{code}</code>) so the amount is currency-aware, not a single converted number.</p>
</div>
<div class="row">
<div class="row-head">
<span class="feat-name">Buy X get Y (free or discounted)</span>
<span class="chip have">have</span>
</div>
<p class="ground">Built in as <code>Lunar\DiscountTypes\BuyXGetY</code>. Condition lines and reward lines are configured separately via <code>discountableConditions</code>/<code>discountableRewards</code>; <code>getRewardQuantity()</code> computes how many reward units a given condition quantity earns, with an optional <code>max_reward_qty</code> cap.</p>
</div>
<div class="row">
<div class="row-head">
<span class="feat-name">Coupon-code discounts</span>
<span class="chip have">have</span>
</div>
<p class="ground"><code>checkDiscountConditions()</code> compares <code>strtoupper($cart-&gt;coupon_code)</code> against <code>$discount-&gt;coupon</code>; <code>Discounts::validateCoupon()</code> exposes a standalone check. Coupon is cast via <code>CouponString</code> on the model.</p>
</div>
<div class="row">
<div class="row-head">
<span class="feat-name">Automatic (no-code) discounts</span>
<span class="chip have">have</span>
</div>
<p class="ground">A blank <code>coupon</code> column makes a discount apply to every eligible cart with no code entered &mdash; <code>DiscountManager::getDiscounts()</code> queries <code>whereNull('coupon')-&gt;orWhere('coupon', '')</code> when the cart carries no coupon code.</p>
</div>
<div class="row">
<div class="row-head">
<span class="feat-name">Minimum cart spend condition</span>
<span class="chip have">have</span>
</div>
<p class="ground"><code>checkDiscountConditions()</code> reads <code>data.min_prices.{currency}</code> and compares it against <code>$lines-&gt;sum('subTotal.value')</code>. Configurable per-currency in the admin form's "Minimum cart amount" fieldset &mdash; but only enforced by <code>AmountOff</code>, see row below.</p>
</div>
<div class="row">
<div class="row-head">
<span class="feat-name">Scoping to products, variants, collections, brands (incl. exclusions)</span>
<span class="chip have">have</span>
</div>
<p class="ground"><code>AmountOff::getEligibleLines()</code> filters/rejects cart lines against <code>discountableLimitations</code>/<code>discountableExclusions</code> plus <code>collections()</code>/<code>brands()</code> pivot rows typed <code>limitation</code> or <code>exclusion</code>. Configured through five separate Filament relation managers on the discount record.</p>
</div>
</div>
</section>
<section class="category">
<div class="cat-head">
<span class="cat-num">02</span>
<h2 class="cat-title">Timing, status, and usage limits</h2>
</div>
<p class="cat-note">Whether a discount is currently live, and how hard its usage caps are enforced.</p>
<div class="rows">
<div class="row">
<div class="row-head">
<span class="feat-name">Scheduled / expiring discount windows</span>
<span class="chip have">have</span>
</div>
<p class="ground"><code>Discount::getStatusAttribute()</code> derives <code>active</code>/<code>pending</code>/<code>expired</code>/<code>scheduled</code> from <code>starts_at</code>/<code>ends_at</code>; the Filament table badges this status column directly (green/gray/red/blue via <code>DiscountResource::getTableColumns()</code>).</p>
</div>
<div class="row">
<div class="row-head">
<span class="feat-name">Global max-uses cap</span>
<span class="chip have">have</span>
</div>
<p class="ground"><code>Discount::scopeUsable()</code> filters query-side (<code>uses &lt; max_uses OR max_uses IS NULL</code>) before a discount is even fetched; <code>checkDiscountConditions()</code> re-checks it in <code>AmountOff</code>. <code>markAsUsed()</code> increments <code>uses</code> and attaches the user via <code>discount_user</code>.</p>
</div>
<div class="row">
<div class="row-head">
<span class="feat-name">Per-user max-uses cap</span>
<span class="chip partial">partial</span>
</div>
<p class="ground"><code>checkDiscountConditions()</code> calls <code>usesByUser()</code> only when <code>$cart-&gt;user</code> exists &mdash; a guest checkout cannot be capped per-customer since there's no <code>user_id</code> to key against, only <code>customer_id</code>. Wholesale/B2B carts often complete without a Laravel <code>User</code> attached, so the cap silently no-ops for them.</p>
</div>
<div class="row">
<div class="row-head">
<span class="feat-name">Usage/eligibility checks on Buy X Get Y</span>
<span class="chip missing">missing</span>
</div>
<p class="ground"><code>BuyXGetY::apply()</code> never calls <code>checkDiscountConditions()</code> &mdash; 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. <code>AmountOff::apply()</code> calls it correctly by contrast.</p>
</div>
</div>
</section>
<section class="category">
<div class="cat-head">
<span class="cat-num">03</span>
<h2 class="cat-title">Multiple discounts, priority, and stacking</h2>
</div>
<p class="cat-note">What happens when more than one discount could legally apply to the same cart.</p>
<div class="callout">
<strong>The <code>stop</code> field is dead code.</strong> It's a real column, cast as boolean on the model, and it's a live toggle in the Filament admin form (<code>DiscountResource::getStopFormComponent()</code>) &mdash; but a repo-wide grep of both <code>lunarphp/core</code> and <code>lunarphp/lunar</code> for reads of <code>$discount-&gt;stop</code> outside the model and the form turns up nothing. <code>DiscountManager::apply()</code> is a plain unconditional <code>foreach</code> over every fetched discount; nothing ever breaks the loop. Staff can toggle a setting that has zero runtime effect.
</div>
<div class="rows">
<div class="row">
<div class="row-head">
<span class="feat-name">Priority ordering between discounts</span>
<span class="chip have">have</span>
</div>
<p class="ground"><code>DiscountManager::getDiscounts()</code> ends with <code>orderBy('priority', 'desc')-&gt;orderBy('id')</code>, and the admin form exposes low/medium/high (1/5/10) presets. This genuinely controls apply order.</p>
</div>
<div class="row">
<div class="row-head">
<span class="feat-name">Stopping further discounts once one applies ("exclusive" discount)</span>
<span class="chip missing">missing</span>
</div>
<p class="ground">See callout above &mdash; <code>stop</code> 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 <code>AbstractDiscountType</code> that inspects <code>$cart-&gt;discounts</code> itself.</p>
</div>
<div class="row">
<div class="row-head">
<span class="feat-name">Per-class combination rules (product vs. order vs. shipping discounts)</span>
<span class="chip missing">missing</span>
</div>
<p class="ground">Shopify models discounts as Product/Order/Shipping classes with an explicit "Combines with" toggle per pair. Lunar has no discount class concept at all &mdash; <code>AmountOff</code> and <code>BuyXGetY</code> are the only two types and neither declares a class or combination policy.</p>
</div>
<div class="row">
<div class="row-head">
<span class="feat-name">Customer-facing stacking transparency (which discounts combined, and why)</span>
<span class="chip partial">partial</span>
</div>
<p class="ground"><code>$cart-&gt;discountBreakdown</code> (a collection of <code>DiscountBreakdown</code> 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 &mdash; it's a data structure a storefront app must build its own component against.</p>
</div>
<div class="row">
<div class="row-head">
<span class="feat-name">"Best deal wins" line-level conflict resolution</span>
<span class="chip have">have</span>
</div>
<p class="ground">Both <code>AmountOff::applyFixedValue()</code> and <code>applyPercentage()</code> explicitly skip a line when <code>$line-&gt;discountTotal-&gt;value &gt; $amount</code> &mdash; "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.</p>
</div>
</div>
</section>
<section class="category">
<div class="cat-head">
<span class="cat-num">04</span>
<h2 class="cat-title">Volume, tiers, and bundles</h2>
</div>
<p class="cat-note">"Buy more, save more" mechanics &mdash; and the separate pricing layer that actually implements some of them in Lunar.</p>
<div class="callout">
<strong>Tiered/volume pricing exists &mdash; but it's not a <code>Discount</code>.</strong> <code>PricingManager::get()</code> filters a purchasable's <code>Price</code> rows for <code>min_quantity &gt; 1 AND $this-&gt;qty &gt;= $price-&gt;min_quantity</code> and picks the cheapest matching price break. This is quantity-break pricing baked into the price table itself, resolved at <code>Pricing::for($variant)-&gt;qty($n)-&gt;get()</code> time &mdash; it never touches the <code>Discount</code> model, coupon system, or discount breakdown at all. A storefront gets the discounted unit price with no visible "discount applied" line.
</div>
<div class="rows">
<div class="row">
<div class="row-head">
<span class="feat-name">Per-SKU quantity price breaks</span>
<span class="chip have">have</span>
</div>
<p class="ground">Via the <code>Price</code> model's <code>min_quantity</code>/pricing pipeline described above, not <code>Discount</code>. Configured directly on product variant pricing in the admin, no separate promotion object needed.</p>
</div>
<div class="row">
<div class="row-head">
<span class="feat-name">Cart-wide tiered discount ("spend $100, save 10%; spend $200, save 20%")</span>
<span class="chip missing">missing</span>
</div>
<p class="ground"><code>AmountOff</code> takes one flat percentage or fixed value per discount record; there is no multi-tier threshold structure in <code>data</code>. Reaching this today means creating several separate <code>Discount</code> rows, each with its own <code>min_prices</code> floor, and hoping only the intended one wins (compounded by the <code>stop</code> gap in section 03).</p>
</div>
<div class="row">
<div class="row-head">
<span class="feat-name">Bundle / kit discount (buy this set, get a fixed bundle price)</span>
<span class="chip missing">missing</span>
</div>
<p class="ground">No bundle or kit concept anywhere in <code>lunarphp/core</code>'s catalog or discount models. Shopify/WooCommerce/PrestaShop all support this via dedicated bundle apps or plugins layered on the same primitive Lunar lacks &mdash; a discount keyed to a co-purchased product set rather than any single line.</p>
</div>
<div class="row">
<div class="row-head">
<span class="feat-name">Free-gift-with-purchase (a distinct SKU added free, not a percentage off an existing line)</span>
<span class="chip have">have</span>
</div>
<p class="ground"><code>BuyXGetY</code>'s <code>automatically_add_rewards</code> flag drives <code>processAutomaticRewards()</code>, which inserts a brand-new <code>CartLine</code> for a randomly selected reward product and zeroes its price via <code>discountTotal</code>. <code>$cart-&gt;freeItems</code> tracks which purchasables were added this way.</p>
</div>
</div>
</section>
<section class="category">
<div class="cat-head">
<span class="cat-num">05</span>
<h2 class="cat-title">Customer targeting</h2>
</div>
<p class="cat-note">Lunar has two genuinely different mechanisms here that solve overlapping-looking problems &mdash; conflating them is the easiest mistake to make.</p>
<div class="callout">
<strong><code>CustomerGroup</code> pricing and <code>Discount</code> customer-group scoping are not the same feature.</strong> <code>Pricing::for($variant)-&gt;customerGroups($groups)-&gt;get()</code> resolves a <em>different base price</em> per customer group directly from the <code>Price</code> table (wholesale sees $8, retail sees $10 &mdash; two rows, no discount object, no coupon, nothing to "apply"). <code>Discount::customerGroups()</code> is a separate pivot (<code>customer_group_discount</code>, via the <code>HasCustomerGroups</code> trait) that scopes whether a <em>promotion</em> is visible/enabled to a group at all, with its own <code>starts_at</code>/<code>ends_at</code>/<code>enabled</code>/<code>visible</code> 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.
</div>
<div class="rows">
<div class="row">
<div class="row-head">
<span class="feat-name">Differential pricing per customer group (wholesale/VIP base price)</span>
<span class="chip have">have</span>
</div>
<p class="ground"><code>PricingManager::get()</code>: <code>$potentialGroupPrice</code> filters <code>Price</code> rows with a matching <code>customer_group_id</code> and picks the cheapest; falls back to <code>$basePrice</code> when no group price exists.</p>
</div>
<div class="row">
<div class="row-head">
<span class="feat-name">Restricting a discount/coupon to specific customer groups</span>
<span class="chip have">have</span>
</div>
<p class="ground"><code>DiscountManager::getDiscounts()</code> applies <code>-&gt;customerGroup($this-&gt;customerGroups)</code> via the shared <code>HasCustomerGroups</code> trait's <code>scopeCustomerGroup()</code>, configured on the discount's own "Availability" sub-page (<code>ManageDiscountAvailability</code>) alongside channel restriction.</p>
</div>
<div class="row">
<div class="row-head">
<span class="feat-name">Restricting a discount to specific named customers</span>
<span class="chip have">have</span>
</div>
<p class="ground"><code>Discount::customers()</code> pivot (<code>customer_discount</code>), checked in <code>checkDiscountConditions()</code>: if the discount has any tied customers, a cart without a matching <code>customer_id</code> fails eligibility outright. Managed via <code>CustomerLimitationRelationManager</code> in the admin.</p>
</div>
<div class="row">
<div class="row-head">
<span class="feat-name">First-purchase / welcome discount</span>
<span class="chip missing">missing</span>
</div>
<p class="ground">Lunar does compute an order-level <code>new_customer</code> boolean (<code>Jobs\Orders\MarkAsNewCustomer</code>, <code>! $previousOrder</code>) &mdash; but it's a post-order reporting flag surfaced only in the Filament order table/dashboard chart. Nothing reads it during <code>ApplyDiscounts</code>; there's no "is this customer's first order" condition available to a <code>Discount</code> at checkout time.</p>
</div>
<div class="row">
<div class="row-head">
<span class="feat-name">Referral discounts (reward both referrer and referee)</span>
<span class="chip missing">missing</span>
</div>
<p class="ground">No referral concept anywhere in <code>lunarphp/core</code> or <code>lunarphp/lunar</code> &mdash; 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 <code>Discount::customers()</code> at best.</p>
</div>
<div class="row">
<div class="row-head">
<span class="feat-name">Loyalty points redeemable as a discount</span>
<span class="chip missing">missing</span>
</div>
<p class="ground">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 &mdash; same gap here, but Lunar offers no <code>AbstractDiscountType</code> hook obviously suited to "redeem N points" either, since discount eligibility has no notion of a spendable balance.</p>
</div>
</div>
</section>
<section class="category">
<div class="cat-head">
<span class="cat-num">06</span>
<h2 class="cat-title">Extensibility</h2>
</div>
<p class="cat-note">What it takes to reach a feature Lunar doesn't ship, without forking the package.</p>
<div class="rows">
<div class="row">
<div class="row-head">
<span class="feat-name">Registering a custom discount type</span>
<span class="chip have">have</span>
</div>
<p class="ground"><code>Discounts::addType(MyType::class)</code> appends to <code>DiscountManager::$types</code> (seeded with just <code>AmountOff::class, BuyXGetY::class</code>). A new type extends <code>AbstractDiscountType</code> and implements <code>apply(CartContract $cart)</code> &mdash; the same contract the two built-ins use, so it participates in the same unconditional-foreach loop from section 03.</p>
</div>
<div class="row">
<div class="row-head">
<span class="feat-name">Admin UI for a custom discount type</span>
<span class="chip partial">partial</span>
</div>
<p class="ground">Requires additionally implementing <code>Lunar\Admin\Base\LunarPanelDiscountInterface</code> (<code>lunarPanelSchema()</code>/<code>lunarPanelOnFill()</code>/<code>lunarPanelOnSave()</code>) for <code>DiscountResource::getDefaultForm()</code> 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 <code>AmountOff</code>/<code>BuyXGetY</code>, which are hard-coded into the form rather than using the interface themselves.</p>
</div>
</div>
</section>
<footer>
<p>Compiled 2026-08-28 &middot; boboko-core / docs</p>
<p>Section 01&ndash;03 and 05&ndash;06 rows are grounded directly in <code>vendor/lunarphp/core/src</code> and <code>vendor/lunarphp/lunar/src</code> source reads (file/method citations inline). Section 02's per-user cap and section 04's pricing-vs-discount distinction are likewise direct source reads. Comparative claims about Shopify, WooCommerce, and PrestaShop feature sets and terminology (discount classes, cart-rule compatibility, loyalty/referral plugins) are sourced from current public documentation and app-store listings via web research, not from reading those platforms' source.</p>
</footer>
</div>