One-time payments
The template handles receiving and reversing a one-time payment.
Creating the mode: 'payment' Checkout Session is yours to write. There
is no one-time checkout endpoint and no product catalog here, because a
checkout endpoint that takes a price id from the client lets the client
name its own price. The id has to be resolved server-side from something
the client cannot choose - a product key mapped to price ids in your
config, a row in your own products table, a plan the user already owns -
and that resolution is the part only you can write.
The receiving side is all here: the hook, the per-event dedupe, the
payment_status guard for asynchronous methods, and the reversal half.
The session you create
Two things the webhook will need. Both have to be set here, at session-creation time, because neither can be recovered later:
// Yours to write. The price id comes from your catalog, never from the
// request body.
const session = await stripe.checkout.sessions.create({
mode: 'payment',
line_items: [{ price: priceIdForProduct(productKey), quantity: 1 }],
client_reference_id: userId,
// The webhook Session carries no expanded line_items, so whatever the
// hook needs to know about WHAT was bought has to be stamped here.
metadata: { userId, productKey },
customer_email: email,
success_url: `${frontendUrl}/purchase/success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${frontendUrl}/pricing`,
});
The service that receives it
// purchases/purchases.service.ts
import { Injectable } from '@nestjs/common';
import { BillingPaymentHook } from '../billing/billing-payment-hook';
import { StripeCheckoutSession } from '../billing/stripe-types';
@Injectable()
export class PurchasesService implements BillingPaymentHook {
async onPaymentCompleted(session: StripeCheckoutSession): Promise<void> {
const { userId, productKey } = session.metadata ?? {};
if (!userId || !productKey) return; // not one of ours
// UNIQUE (stripe_checkout_session_id) is what makes this idempotent.
// See "Belt-and-braces" below.
await this.grants.grant({ userId, productKey, sessionId: session.id });
}
// Optional. Fires on refunds and chargebacks; see the reversal section.
async onPaymentReversed(
session: StripeCheckoutSession,
reason: 'refund' | 'dispute',
): Promise<void> {
await this.grants.revokeBySessionId(session.id, reason);
}
}
BillingPaymentHook lives in backend/src/billing/billing-payment-hook.ts
and StripeCheckoutSession in backend/src/billing/stripe-types.ts.
Binding it
Bind it in app.module.ts:
// app.module.ts: replaces the plain BillingModule entry
BillingModule.withPaymentHook(PurchasesModule, PurchasesService),
PurchasesModule must export PurchasesService, which implements
BillingPaymentHook. Don't declare the BILLING_PAYMENT_HOOK token in
your own module: Nest provider scopes are per-module, so the webhook
service (which lives in BillingModule) would never see it and the
@Optional injection silently resolves to nothing.
Never import BillingModule anywhere else. It is @Global(), so
inject BillingService and you have it. An import creates a second
module instance - Nest keys instances by class plus dynamic metadata,
so the static import and withPaymentHook(...) are two different
modules, each with its own StripeWebhookController, and the one that
wins POST /webhooks/stripe may be the one with no hook bound. The
failure it produces is the quiet kind: a real payment answers 200 and is
fulfilled by nobody. The module now throws at boot if it is registered
twice rather than letting you find out from the audit log.
PurchasesService.onPaymentCompleted(session) owns what a purchase
means: create a record, grant access, send an email. Throw to make Stripe
retry.
The hook runs at most once per Stripe event id, and only once the money has landed. A process crash between claiming the event and the hook finishing skips the retry, so monitor for stuck claim rows if your grant is irreversible. Webhook internals has the dedupe and the asynchronous-payment guard.
Belt-and-braces: key on session.id, not just the event. Dedupe by
Stripe event id protects the hook's own invocation count, but it's still
your app's job to make the grant itself idempotent - put a UNIQUE
constraint on the stripe_checkout_session_id column of whatever table
records the grant. If your payment hook ever runs twice for the same
session (a backfill, an admin replay, a future refactor of this dedupe),
that unique constraint turns a double-grant into a caught conflict
instead of a silent double-credit.
Undoing a one-time payment
Refunds and chargebacks resolve a buyer through billing_subscriptions
by Stripe customer id. A guest one-time checkout never creates that
row, so without a seam here a refunded or charged-back buyer keeps
whatever the grant gave them, and nothing says so. Implement the optional
other half:
async onPaymentReversed(
session: StripeCheckoutSession,
reason: 'refund' | 'dispute',
): Promise<void> {
await this.purchases.revokeBySessionId(session.id, reason);
}
It fires on charge.refunded (full refunds only) and
charge.dispute.funds_withdrawn, in addition to what those events do to a
subscription rather than instead of it: a subscriber's refunded
subscription charge is still set to cancel at period end, and a chargeback
still locks the row. A refunded one-time purchase reaches only this hook,
even when the buyer also subscribes. The webhook resolves the Session
from the charge's payment intent via checkout.sessions.list; a charge
that was not a Checkout sale resolves to nothing and is skipped quietly.
Three choices worth knowing:
- It hands you the Session, not the PaymentIntent, so you can key off
session.id- the same id the belt-and-braces advice above tells you to store. A payment intent would force a nullable column, an index and a migration purely to correlate. - It is deliberately NOT deduped per event id, the opposite of
onPaymentCompleted. Reversal is expected to be idempotent, so a redelivery costs nothing, while a missed one leaves live access that should have ended. Make your revoke idempotent and let it run twice. - Throwing makes Stripe retry, same as the granting half. The webhook records only that the hook ran, since only your app knows what "revoked" means; log your own outcome.
A reversal can arrive before the grant it reverses, and this one is
yours to handle. Delivery order is not guaranteed. If the completion
delivery failed once and is backing off while the refund is delivered
fresh, onPaymentReversed is handed a session you have never granted.
Returning quietly because you found nothing leaves the door open: the
completion lands afterwards under a different event id, so the dedupe
claim succeeds and onPaymentCompleted grants a purchase that was
already refunded.
Neither dedupe by event id nor keying both halves on session.id helps,
because the problem is ordering, not duplication. Nor does having the
grant check for a reversal first: if (await isReversed(id)) return;
before grant(id) is check-then-act, and a reversal landing between the
two lines walks straight through the gap.
Record both facts and let the read decide. One row per session, two independent columns, each write touching only its own:
// Either handler may run first. Neither reads the other's column.
onPaymentCompleted: INSERT (session_id, granted_at)
ON CONFLICT (session_id) DO UPDATE SET granted_at = now()
onPaymentReversed: INSERT (session_id, reversed_at)
ON CONFLICT (session_id) DO UPDATE SET reversed_at = now()
and the entitlement check the rest of your app calls:
SELECT granted_at IS NOT NULL AND reversed_at IS NULL AS has_access
FROM purchases WHERE session_id = $1
Ordering stops mattering: whichever delivery lands first, the final row
holds both facts and the read resolves them the same way. No window to
race, no lock to hold, and the redelivery safety of the
UNIQUE (stripe_checkout_session_id) constraint is unchanged.
The template deliberately leaves this to you. The grant lives in your table and only you know what access means, so a tombstone kept here would have to be consulted by your code anyway and would become a second source of truth about the same purchase.