Gift Cards & Store Credit adds two closely related balances to the shop: gift cards — bearer codes anyone can buy or be issued and redeem at checkout — and store credit — a balance on a member's account the store can grant (goodwill, refunds, promotions) and the member can spend. Gift cards are also sellable as normal shop products, with the codes minted and emailed automatically the moment the order pays.
The problem
Gift cards are the classic "sell money now, ship nothing" product — and the usual answer is a third-party gift-card service with its own fees, or a pile of manual coupon codes that don't track balances. Store credit is even more awkward: refunding goodwill as real money costs the store the sale, but there's nowhere native to park "you have $15 with us."
The fix
A self-contained feature module under app/Features/GiftCards/ with two ledgers:
gift_cards— each card has a unique 16-character code (unambiguous alphabet, shown asXXXX-XXXX-XXXX-XXXX), an initial value, a live balance, optional recipient/message/expiry, and a full transaction history.store_credit_accounts— one balance per member, again with a complete transaction ledger (every grant, spend, claim, and refund names its cause).
Both plug into the existing shop checkout: applied balances ride into the embedded Stripe checkout as a single one-off coupon, and the actual balance is only captured when the order really pays — an abandoned checkout never touches anyone's card.
Reservation across concurrent checkouts. Because the discount is baked into the Stripe session up front, a pending order's stamped amounts reserve that portion of the balance — a second checkout applying the same card (two tabs, a shared code) is only offered whatever remains unreserved, so one balance can never fund two full discounts. Reservations release the moment the credits are captured, the attempt is canceled/expired, or after 24 hours (Stripe's maximum session lifetime). If a balance still can't cover a paid order's discount (say, an admin adjusted the card mid-checkout), the gap is recorded on the order as a credit shortfall and shown in red on the dashboard order page instead of disappearing into a log file. The same reservation and shortfall tracking covers store credit and loyalty-point redemptions.
What customers can do
- Buy a gift card. Mark any product as a gift card (Product editor → Fulfillment → "This product is a gift card"). Each purchased unit becomes a code worth the price paid, emailed to the buyer right after payment and shown on the order confirmation page. Use variants for denominations ($25 / $50 / $100). Gift-card products are always digital — nothing ships, and buying one never earns loyalty points or accepts other credits as payment (no card-for-card laundering).
- Send it as a gift. The gift-card product page has a "Send as a gift to someone else" option: recipient name + email, a personal message, and an optional delivery date up to a year out. Gifted codes email the recipient directly (subject names the sender), immediately after payment or on the chosen date (
gift-cards:deliver-scheduled, hourly). Different recipients get their own cart lines, so one checkout can gift several people. - View & print the card. Every delivery email links to a print-friendly card page behind an opaque token (
/gift-card/{token}) — amount, code, message, sender — with a Print button and a PDF download (dompdf). - Check a balance. A public
/gift-card/balancepage (rate-limited, honeypot-guarded) shows any code's remaining balance, status, and expiry — no login needed. - Redeem at checkout. A "Gift card code" field on the cart page (guests included — codes are bearer instruments). The applied amount shows as its own line, and Stripe's checkout shows it as a named discount.
- Claim to account. A logged-in member can add a gift card's remaining balance to their store credit from the member dashboard — after that it's spendable with one click instead of retyping the code.
- Spend store credit. Members with a balance see "You have $X in store credit — Use it" on the cart page.
What the store can do
Dashboard → Shop → Gift Cards (manager and up):
- Issue cards by hand — amount, optional recipient email/name, gift message, and expiry date, with a toggle to email the code immediately. Re-send the email any time.
- Bulk issue — mint up to 500 identical cards in one labeled batch (corporate orders, promos); the codes download as a CSV on creation, and the batch stays filterable/exportable from the cards list.
- Import codes — upload a CSV (
code,amount+ optionalrecipient_email,recipient_name,message,expires_at) to bring physical card stock or codes from another platform into the ledger. Existing codes are skipped, never overwritten; each import gets its own batch id. - Refund to store credit — the order screen's refund modal offers "Store credit" as a destination whenever the buyer maps to a member account. No Stripe refund happens; the amount lands on the member's credit ledger, and the order's status/refundable math tracks both channels combined. This also un-sticks orders paid entirely by credits (no Stripe payment intent) — they can now be refunded properly.
- See every card — code, balance vs. initial value, recipient (and gift sender), scheduled-delivery state, status (Active / Used / Disabled / Expired), and a per-card activity ledger.
- Disable / enable cards — a disabled card keeps its balance but can't redeem (lost codes, fraud).
- The Store Credit tab lists member balances, lets you grant or remove credit (negative amounts deduct) with a note, and shows each account's history.
The gift card delivery emails are editable like every other shop email (Shop → Settings → Emails): "Gift card delivery" (buyer-kept codes, dashboard-issued cards) and "Gift card — sent to a recipient" (gifted codes; adds a {{sender_name}} token). Both are sent through the same mailer the Marketing feature uses — no extra configuration — and link to the printable card page.
A dashboard widget ("Gift cards") shows the outstanding gift-card liability, active card count, and total store credit at a glance.
How the money flows (mechanics)
- Planning. When a checkout starts, the applied credits are resolved against the live cart: gift card first, then store credit, then loyalty points, capped at the cart subtotal. On physical carts a 50¢ minimum stays card-payable so Stripe always has something to charge; a digital-only cart fully covered by credits skips Stripe entirely and completes on the spot.
- One discount slot. Stripe's checkout accepts a single discount, so credits ride in as one one-off
amount_offcoupon — which also means credits and promo codes are mutually exclusive on the same order (the cart says so when you try). Because the credit behaves like a discount, tax is computed on the reduced base — the same treatment a Stripe coupon gets. - Capture on payment. Balances are deducted (with ledger rows naming the order) only by the same atomic claim that marks the order paid — the webhook/return-page race can't double-charge a card, and abandoned checkouts never hold funds.
- Full refunds give everything back. A full refund returns the gift-card portion to the card (or to the member's store credit if the card row was deleted) and the store-credit portion to the account. Partial refunds deliberately don't touch balances — they usually cover shipping goodwill.
- Two refund channels, one pool. Stripe refunds (
amount_refunded_cents) and store-credit refunds (refunded_to_credit_cents) draw from the same refundable balance; the order flips torefunded(and runs the full-refund side effects — commission void, download expiry, credit release, points revoke) when the two combined reach the total.
Redemption outside the shop (the shared engine)
Gift cards, store credit and loyalty points are one shared balance, spendable at every enabled checkout — not scoped to whichever module sold the card. Cards are minted from a single place (a shop gift-card product, the dashboard, or a CSV import), so there is no issuing module to scope them to.
The shop's own path (Ecommerce\Support\CheckoutCredits, amounts stamped onto columns on orders) predates this and is unchanged. Every other checkout uses App\Support\Checkout\Credits, which stamps a polymorphic checkout_credit_holds row against the checkout instead — so a new checkout can adopt redemption without a migration of its own. The hold row is three things at once: the reservation, the idempotency stamp, and the record of what was actually spent.
- Credit is tender, not a discount. Outside the shop it comes off the grand total being charged; tax and tip are computed on the full pre-credit amounts. That is how a real gift card behaves, and it is also what Stripe's one-off
amount_offcoupon does to an inline-priced session. - One shared reservation pool. Availability subtracts both outstanding hold rows and in-flight shop orders, so the same card can't ride into a shop cart and a restaurant order at once. Holds age out after 24h (Stripe's maximum session lifetime), so an abandoned checkout can never strand a balance.
- A 50¢ floor stays card-payable. None of the non-shop checkouts has the zero-total finalize path the shop has for a fully-covered digital cart, so credits stop just short of the full amount rather than eliminating the payment.
- Guests can spend a gift card, but not store credit or points. A card's code is its own credential; the other two are account balances and need a signed-in member. The member is recorded on the hold, so the capture side (a Stripe webhook, with no session) still knows whose balance to deduct.
- Capture and release are each claimed once, with a conditional update — a replayed webhook or a double refund can't deduct or refill twice.
Where it appears
| Checkout | Where the input sits | Measured against |
|---|---|---|
Restaurant /order |
Details step, above the totals — online payment only | The grand total (items + delivery + tax + tip) |
| Event tickets | Under the promo-code field on the ticket picker | The selected tickets' total |
Online booking /book |
Step 4, under the questions | The amount due now — the deposit on a deposit-priced service, else the full price |
Each shows the applied card / credit / points with the amount it covers, a Remove link, and the reduced total. A pay-at-store restaurant order deliberately has no credit UI: nothing would capture the balance. Staff see what was spent on the module's order/appointment detail page (<x-checkout.credit-summary>), which matters because the Stripe payout is net of the credits — without it an order total and its settlement don't reconcile.
Limitations
- Credits can't pay for gift-card products, product subscriptions (
/shop/subscribeis a separate Stripe flow), memberships or donations. - One gift card per checkout. A member can stack a gift card and store credit and points on the same order, but not two gift cards (claim one to account credit first).
- Currency follows the shop's single configured currency.
- Store-credit refunds need a resolvable member (the order's member, or a member matching the order email) — guests without accounts still refund through Stripe only.
- Scheduled gift delivery dates are day-granular (delivered by the hourly cron from the recipient's date at server midnight).
Files
| Piece | Where |
|---|---|
| Module (models, ledgers, minting, dashboard) | app/Features/GiftCards/ |
| Checkout orchestration, shop (plan/capture/release) | app/Features/Ecommerce/Support/CheckoutCredits.php |
| Checkout orchestration, every other module | app/Support/Checkout/Credits.php + App\Models\CheckoutCreditHold |
| Apply/remove affordances (shared by the three checkouts) | app/Concerns/AppliesCheckoutCredits.php |
| Shared UI | resources/views/components/checkout/{credits,credit-lines,credit-summary}.blade.php |
| Zero-total finalize | OrderFinalizer::finalizeZeroTotal() |
| Store-credit refunds | OrderFinalizer::refundToStoreCredit() |
| Delivery emails | OrderEmails types gift_card + gift_card_recipient |
| Gift details on cart lines / order items | Cart::add(..., $gift) → order_items.gift_meta |
| Scheduled delivery cron | gift-cards:deliver-scheduled (LazyCron, hourly) |
| Public pages (balance, printable card, PDF) | app/Features/GiftCards/routes/web.php |
| CSV import | app/Features/GiftCards/Support/GiftCardImporter.php |
| Tests | tests/Feature/ShopGiftCardsTest.php, tests/Feature/GiftCardsV2Test.php, tests/Feature/CheckoutCreditsSharedTest.php, tests/Feature/CheckoutCreditsFlowTest.php |