cartwright
Features

Checkout (Stripe)

Stripe Payment Element, webhook reconciliation, and the no-key mock-fallback.

Cartwright integrates Stripe via the Payment Element on /checkout, a webhook handler at /api/webhook/stripe, and a nightly reconciliation cron at /api/cron/reconcile-stripe. Keys can live in env or — preferred — in the database via /admin/integrations.

DB-first key strategy

Stripe secrets are stored AES-256-GCM-encrypted in IntegrationSettings (Prisma). The runtime looks there first and falls back to STRIPE_SECRET_KEY, STRIPE_PUBLISHABLE_KEY, STRIPE_WEBHOOK_SECRET env vars.

The benefit: rotating keys, switching between test and live mode, and onboarding a new admin do not require an env update + redeploy. The admin UI is the source of truth.

Mock fallback

If no Stripe keys are configured at all, checkout still works. The order is created with status paid and paymentMethod: "mock", no charge happens, and the customer sees a success page that explicitly says this is a dev order. This is the default experience right after pnpm dev against a fresh fork — you can validate the full order flow before touching real Stripe.

The mock fallback is intentionally permissive in development. Production deploys must have real Stripe keys configured (env or DB) — otherwise every order would be a free order. Cartwright does not block deploy without keys; it is your responsibility to verify.

Test mode quick check

Card:     4242 4242 4242 4242
Expiry:   any future date
CVC:      any 3 digits
ZIP:      any
Card:     4000 0025 0000 3155
Expiry:   any future date
CVC:      any 3 digits
ZIP:      any

Stripe forces a 3-D Secure challenge on this card. Useful for verifying the redirect flow handles authentication correctly.

Webhook flow

app/api/webhook/stripe/route.ts validates the Stripe signature against the configured webhook secret (DB-first, STRIPE_WEBHOOK_SECRET as fallback), then dispatches by event type:

  • payment_intent.succeeded → check the charged amount and currency against the order (a mismatch sets flagged_review and stops), mark the order paid, send the receipt, the optional owner e-mail and the orderPlaced marketing event. The webhook touches no cookie — the cart was already emptied when the order was created.
  • payment_intent.payment_failed → logged only. The order stays pending_payment so the customer can retry with another card; no e-mail.
  • payment_intent.canceled → if the order is still pending_payment, it is set to cancelled and its items go back in stock in the same transaction (below). An order that has moved on — already cancelled, paid, needing review, shipped or refunded — is left as it is: a cancelled intent says nothing about money already captured. Engines up to v0.60.0 set any order to cancelled here and returned no stock.
  • charge.refunded → order set to refunded, or partial_refund when the refunded amount is below the charge, with refundedAt and an audit row. No e-mail is sent.
  • charge.dispute.created → order set to disputed, with disputedAt and an audit row.
  • Everything else is acknowledged with 200 and ignored — no audit row is written. (Subscription events are handled only when the subscriptions feature is enabled.)

Idempotency is keyed off the Stripe event.id, recorded in ProcessedWebhookEvent: a retried event is acknowledged with 200 and does nothing. The receipt has a second guard, confirmationEmailSentAt, so a success event re-sent under a new id still produces one e-mail.

A payment_intent.succeeded that arrives for an order no longer awaiting payment — one an admin has already cancelled, or moved on to processing, shipped or completed — is logged for manual review (an audit row, orders.payment_not_applied, visible in /admin/audit) and leaves the order untouched: it is not flipped back to paid, and no receipt, owner e-mail or marketing event is re-sent.

Reconciliation cron

/api/cron/reconcile-stripe runs nightly (Vercel Cron). It walks orders still in pending_payment (older than five minutes, with a Stripe PaymentIntent attached), asks Stripe what actually happened, and corrects the order state — paid with the receipt sent, cancelled with its stock back (the rule below), or flagged_review on an amount mismatch. This catches webhook delivery failures and edge cases where the Payment Element succeeded but the network dropped before the success page rendered.

The cron is protected by CRON_SECRET — generated with openssl rand -hex 32 and set in env. Vercel Cron sends the secret automatically when invoking your route. Set it before you take payments: from the next engine release, a production deploy without CRON_SECRET refuses the call (503), so reconciliation never runs.

Cancelling an order that was never paid

Checkout takes the stock when it creates the order, so an order cancelled while it is still pending_payment gives that stock back — exactly once, whichever of these gets there first:

  • an operator cancel: the admin status form, the bulk status action or the orders.update_status tool;
  • the payment_intent.canceled webhook;
  • the reconciliation cron, when Stripe reports the intent cancelled and the webhook was missed;
  • checkout's own rollback, when the PaymentIntent could not be created after the order was written.

All four of these unpaid cancels go through one helper, lib/orders/cancel-unpaid.ts. The status write is conditional on pending_payment, and the restock runs in the same transaction only when that write landed, so the second path to arrive finds the order cancelled and leaves the order and its stock unchanged (an operator's refused cancel can still leave a failed entry in the audit log). A variant line's quantity goes back to the variant, the inverse of checkout's decrement. The admin status form, the bulk action and orders.update_status write the status change to the order timeline with "items returned to stock". The tool's result also carries restocked: true.

An operator cancel reads the order's status when it runs, not when you opened the order, and acts on what it finds then. If the customer paid in the meantime, it is an ordinary cancel of a paid order and goes through, with no stock back (next paragraph). If the webhook or the cron cancelled the order first, it is refused as an illegal transition (cancelled → cancelled) and the order stays as it is. Every operator status change — the admin status form, the bulk action and orders.update_status — is now conditional on the order still being in the status it was read in, so a change racing a cancel, payment or another operator is refused without writing anything, with "it changed while it was being updated — reload the order and try again". An unpaid cancel words the same refusal as "no longer awaiting payment" when the change that beat it was a payment or a cancel landing in the instant between its own read and its write; it too leaves the order unchanged. The bulk action lists an order refused either way as skipped, with the reason.

An order cancelled after it was paid (paid, processing, needing review, disputed) gets no stock back here: a refund moves money, not stock, and a return is how paid stock comes back.

Cancelling an order with a Stripe payment also cancels its PaymentIntent, right after the status is written, so a customer still on the payment page can no longer pay for it. A payment the customer already made is never cancelled — the timeline note says to refund it on the admin order page, since cancelling an order returns no money. If Stripe cannot be reached the order is cancelled anyway, and the note says to cancel the payment in the Stripe Dashboard. Each attempt is audited as orders.cancel_payment_intent, and the tool's result reports it as paymentIntent. That cancelled intent is what fires payment_intent.canceled, which then finds the order already cancelled and restocked and leaves it alone.

Engines up to v0.60.0 give stock back only from checkout's rollback and the reconciliation cron: an operator cancel or the webhook changed the status, and the items stayed out of stock. The PaymentIntent cancel arrived in v0.60.0; engines up to v0.59.1 leave the payment open.

Elements appearance

Stripe Elements render in a same-origin iframe and cannot read your CSS variables. brand.config.ts:stripeAppearance mirrors a small subset of the palette — primary color, background, text, danger, border-radius. Sync it manually with themes/<slug>.css after a palette change.

On this page