goldnead/statamic-offers
Statamic Offers is a Laravel package that adds offer management to Statamic CMS. Editors can create and organize offers in the control panel, while developers can surface them in Antlers templates for promotions, deals, or listings.
This package covers two related but separable areas, and it's worth knowing which one you're in before you start:
Basket that holds lines, coupons, setup fees and bumps, plus Subscriptions::start() and Checkout::start() which actually book the payment.type: offer, id: <offer handle> and number: <seats accepted in the period>.The one thing to internalise before you write any UI is still that the basket total and the amount actually charged are two different numbers.
Minimal path:
statamic-brand-context resolves to ^1.17 — the funnels-map offer support depends on it, and if your lockfile pins an older brand context the feature will be missing or half-wired.Basket class first — specifically the difference between netCent() and firstPaymentCent(). Everything else (coupons, bumps, setup fees) hangs off those two.Subscriptions::start() and Checkout::start() once, end to end, to see exactly which basket components they consume. firstPaymentCent() is defined as mirroring them, so those two methods are your source of truth.netCent() call in that slot with firstPaymentCent() and you immediately fix the class of bug described in v1.15.0 (a paid-trial subscription showing 65 EUR while 45 EUR was booked).The v1.15.0 release was additive — firstPaymentCent() is new, netCent() is unchanged — so there was no migration work, only a swap in the places where you were displaying "what will be charged now". v1.18.0 is likewise additive to the money layer; it adds a new target type to the funnels map plus a dependency floor.
Two numbers, two labels. Treat netCent() as "order value" and firstPaymentCent() as "charged today". Keep them as separate view-model properties rather than deriving one from the other in Blade; that's how you avoid regressing back to the staging bug.
$basket = Basket::for($customer)->withLines($lines)->withCoupon($coupon);
return view('checkout.summary', [
'dueTodayCent' => $basket->firstPaymentCent(),
'orderTotalCent' => $basket->netCent(),
]);
Paid trials are a price replacement, not a discount. When the trial period is paid, the trial price replaces the main line price. Note the consequence: the coupon does not apply in that case, because the trial consumes the payment's single discount slot. So your summary should not render a struck-through coupon line when a paid trial is active — it will read as a lie to the customer and to support.
Order of operations for the non-trial path. Basket subtotal, minus coupon, plus setup fee, plus ticked bumps. Implement any preview/JS-side calculator against that same sequence, and add a test asserting your preview equals firstPaymentCent(); duplicating pricing logic in the frontend is the most common source of drift.
Reconcile against the booking, not against the basket. The strongest pattern is an assertion in your feature tests that the amount recorded by Subscriptions::start() / Checkout::start() equals the firstPaymentCent() you showed the user:
$shown = $basket->firstPaymentCent();
$payment = Subscriptions::start($basket, $payload);
$this->assertSame($shown, $payment->amount_cent);
That single test covers coupons, trials, setup fees and bumps in combination, and it's what would have caught the 65-vs-45 and 37-vs-27 discrepancies before staging.
Wire offers into funnels by handle, and keep the handle stable. An offer is now an addressable object in the funnels map. Add the entry with type => 'offer', id => $offer->handle, number => $seats, and treat the handle as a public identifier — the same way you'd treat a route name or a field handle in Statamic content. Everything that resolves the map (funnel previews, seat-counters, reporting) reads through that handle.
// illustrative: the shape of the entry, not the exact builder API
$map->addStep($step, [
'type' => 'offer',
'id' => $offer->handle, // handle, NOT $offer->id
'number' => $offer->seats_accepted_for_period,
]);
Keep "seats accepted in the period" out of the money layer. The number on an offer entry is a capacity/quota, not a price and not a basket quantity. Do not feed it into firstPaymentCent(), do not render it as a line total, and do not derive line quantity from it. If you need both on one screen (e.g. a funnel landing page with a price and a "12 seats left this month" badge), pull them from their respective owners and label them separately.
Test the map as data, not as UI. Because an offer target is just (type, id, number), you can assert the map's contents directly: that every offer handle referenced in the map resolves to a real offer, and that the numbers on offer entries match your source of truth. That's a cheap test that catches handle typos and stale entries long before a campaign does.
Use it in emails and invoices too. The "due today" figure belongs in the order confirmation and the payment receipt header; the netCent() figure belongs in the order-detail table. Mixing them across those two artefacts generates refund requests.
netCent() is unchanged, and that's deliberate. Don't "fix" it to match what's charged. If you have downstream code (reporting, exports, accounting hand-off) keyed on netCent(), verify it is genuinely meant to be order value and not due-today.firstPaymentCent() disagrees with what got booked, print each component (main line, trial price, coupon, setup fee, each bump) individually rather than diffing totals. The two failure modes in the 1.15.0 release notes were each a single misapplied component: trial price not replacing the main line, and coupon applied where the trial had taken the discount slot.firstPaymentCent() is specified as mirroring start(), any future release that changes trial or coupon semantics can silently desync the two. A parity test is cheap insurance. The same logic applies to offers: assert map entries resolve, and assert the resolved offers are the ones you expect, so a data change in the map surfaces as a failing test rather than a broken funnel.id is the offer handle, not the model id (v1.18.0). Passing a numeric id will look plausible and fail to resolve. This is the single most likely mistake when hand-writing or importing map entries.^1.17 brand-context floor is a hard gate (v1.18.0). If your app pins statamic-brand-context below 1.17 — common in older Statamic projects — Composer will refuse the update or you'll end up on a partial feature set. Check composer why statamic-brand-context before you plan the upgrade, and budget for upgrading that alongside.firstPaymentCent() the same way setup fees do. If they don't, they'll be missing from "due today" while still being booked — the exact asymmetry the 1.15.0 work was created to fix.How can I help you explore Laravel packages today?