Skip to content

feat(discounts): promo codes at checkout and in the admin - #23

Merged
kris1027 merged 17 commits into
mainfrom
feat/discounts-promo-codes
Oct 17, 2026
Merged

kris1027 merged 17 commits into
mainfrom
feat/discounts-promo-codes

Conversation

@kris1027

Copy link
Copy Markdown
Owner

What

Adds promo codes end to end. Admins create and manage codes (percent or fixed amount off the items, with optional dates, use limits and a minimum spend) on new /admin/discounts pages. Customers type one code on /checkout, see the new total right away, and Stripe charges exactly that total through a one off coupon made per order.

Why

Implements spec 0014 (Slice 8 in the scope). It also settles two follow ups left open by spec 0002: the discounted total never drops below Stripe's minimum charge (AC-2), and the rule for splitting an order discount across its lines (AC-6). It amends spec 0011 AC-4 and spec 0012 AC-12 so the email and account order page name the code.

Changes

Discount rules (pure, no I/O)

  • src/lib/discounts/rule.ts: the raw discount, both caps (keep free delivery when that is cheaper for the customer, stay at or above Stripe's minimum), the six refusal reasons in their fixed order, the line split (rounded down, leftover cents to the largest remainders), the derived code status and the label. One evaluateDiscount feeds Apply, the prefill and Pay.
  • src/lib/discounts/schema.ts: code format and the shared normalizeEmail, which the checkout email schema now uses too.
  • snapshotOrder takes the applied code and writes the split onto the lines.

Checkout

  • New applyDiscountCode action (read only, public) and a DiscountField in the summary: Apply, Remove, Enter runs Apply, a polite live region, and the refusal message linked with aria-describedby.
  • startCheckout takes discountCode. Inside the order transaction it locks the code row after the customer and cart locks, checks the code again against database prices, live settings and the database clock, then writes the order snapshot, the line split and one discount_redemptions row. A refusal returns discount_refused, creates no order and keeps the form.
  • After the order commits, it creates a Stripe coupon order_<orderId> with our exact amount_off (idempotency key coupon:<orderId>) and passes it as the session's discounts. If coupon creation fails, the order is expired and the use is freed.
  • checkoutPrefill brings the code back when the customer returns from Stripe within 24 hours.

Use accounting

  • markExpired and markCancelled (pending path) delete the order's redemption, so every way out of pending_payment frees the held use. Paid orders keep their use through cancel, refund and undo cancel. A pending order older than 35 minutes stops counting.

Admin

  • /admin/discounts list (search, status filter, uses, discount given, newest 200), plus new and edit pages, with a Discounts entry in the admin nav.
  • updateDiscountCode and deleteDiscountCode take the same code row lock. Code, type and value lock after the first use, a stale form is refused, and a used code cannot be deleted (deactivate instead). Every page and action calls requireAdmin().
  • /admin/orders?discount=<id> filter with a removable chip; the admin order page shows "Discount (CODE, 10%)" linking to the code.
  • src/lib/dates.ts: datetime-local helpers that read and show dates in STORE_TIMEZONE.

Customer facing rows

  • /checkout/complete, /account/orders/[number] and the order confirmation email show "Discount (CODE)" with the amount.

Logs

  • discount.created, discount.updated, discount.deleted, checkout.discount_refused, checkout.coupon_failed; checkout.started gains discountCents and discountCodeId. No email, and never the typed text of a code that does not exist.

How to test / verify

  • pnpm test: rule edges, schemas, dates, snapshot, session params, list params, email render.
  • pnpm db:start, then pnpm test:db tests/db/discounts-checkout.db.test.ts tests/db/discounts-admin.db.test.ts tests/db/orders.db.test.ts: last use race for total and per email limits, refusal order, lifecycle (expire, cancel, paid kept, restart with a limit of 1), prefill, Pay reading prices, delivery fee, account email and the clock from the database, coupon failure and reuse, refund suggestions, admin create, update, lock, stale and delete.
  • pnpm test:e2e tests/e2e/checkout/discounts.spec.ts tests/e2e/admin/discounts.spec.ts: apply, remove, each visible refusal, Enter runs Apply, Pay refused after deactivation, admin create, edit lock, delete refusal, list and filter, axe and keyboard.
  • By hand: the steps in verify.md. A Pay in Stripe test mode was checked: the hosted page shows the coupon under the code's name with the same total as our summary.

Risk & rollout

  • Migration 20261017152429_discount_code_admin: adds note, created_by_admin_id (NOT NULL, no default) and updated_by_admin_id to discount_codes, two FK indexes, and CHECKs on the code format and note length. Safe only because the table is empty before this ships (no code could be created until now). No data change.
  • Checkout now writes to discount_redemptions and calls coupons.create when a code is applied. Orders without a code follow the same path as before (no coupon, no discounts param).
  • No flags. Rolling back means reverting the PR; the migration only adds columns and constraints.

Residual risks accepted from the review (docs/reviews/2026-10-18-feat-discounts-promo-codes.md), no blockers or majors:

  • A popular code serialises Pays on its row lock inside the default 5 s Prisma interactive transaction. Under heavy campaign load, later Pays could time out as a 500. Fine for the current store size; worth a timeout raise if campaigns grow.
  • The "Stripe amount equals our total" invariant is asserted against mocks in CI; the real hosted page check was done by hand, not on every run.
  • clearPendingOrder runs before the code check, so any refused code on Pay already ended the customer's earlier open Stripe session.

Notes for reviewers

  • Worth extra eyes: the lock order in start-checkout.ts (customer, cart, then code) and the same code lock taken first by admin update and delete, so an edit cannot race a redemption.
  • The caps in AC-2 are the subtle part: a code may be trimmed so delivery stays free, or so the total stays at Stripe's minimum. A code never makes the total higher than the same cart without one.
  • Stripe never computes the discount itself; the webhook's existing amount check stays a real alarm.
  • Two items for /sync to update in the spec: the orders.discount_code_id index already existed, and the admin queries file is admin-queries.ts.
  • Open review nits, not fixed here: discountCode: z.unknown() in the checkout schema (validated later by parseCodeText), and zonedDateTimeToUtc shifting a wall time inside a spring forward gap instead of returning null.

🤖 Generated with Claude Code

kris1027 and others added 7 commits October 17, 2026 17:24
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ne off Stripe coupon

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ter by code

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…r and email

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ild ticked

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@kris1027 kris1027 added feature New user-facing functionality ui Layouts, styling, and shared interface components database Schemas, migrations, queries, and row-level security labels Oct 17, 2026
@kris1027 kris1027 self-assigned this Oct 17, 2026
@github-project-automation github-project-automation Bot moved this to Backlog in BeStore Oct 17, 2026
@kris1027 kris1027 moved this from Backlog to In review in BeStore Oct 17, 2026
kris1027 and others added 10 commits October 18, 2026 00:42
A code that came back applied from an earlier Pay (AC-9) started with an
empty text state, so a Pay refusal (AC-4) blanked the field under its
message.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
log.ts owns the checkout.* events; this was the one written inline.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…abel

The confirmation page, the account order and the email already use it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…lper

The refusal shape was declared in ApplyDiscountError, StartCheckoutError,
the preview and the evaluation, and the optional minimumGapCents spread
was copied three times.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e reads

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
zonedDayStart and zonedDateTimeToUtc carried the same two-pass offset
steps.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…scount.ts

applyLockedCode, wantsCode and the Stripe coupon create leave
start-checkout.ts, which keeps the expiry on a failed coupon. AppliedCode
now uses DiscountType, the undiscounted snapshot is computed once, and
the applied code is a const.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… recompute

The status filter already runs on the SQL CASE; deriving it again in
toSummary was a second copy that could drift from the filter.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…elpers, no let

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Adds the discounts context file and its CLAUDE.md pointer, links it from
the root AGENTS.md, notes the promo code in the checkout and orders
context, and marks spec 0014 accepted and scope item 14 done.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@kris1027
kris1027 merged commit 478e6ec into main Oct 17, 2026
7 checks passed
@github-project-automation github-project-automation Bot moved this from In review to Done in BeStore Oct 17, 2026
@kris1027
kris1027 deleted the feat/discounts-promo-codes branch October 17, 2026 23:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

database Schemas, migrations, queries, and row-level security feature New user-facing functionality ui Layouts, styling, and shared interface components

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

1 participant