@
All checks were successful
Deploy to Production / deploy (push) Successful in 1m22s

feat(billing): in-app embedded Stripe checkout + webhook hardening

Checkout previously used hosted ui_mode → window.location to checkout.stripe.com,
which pops out of the installed PWA into the system browser. Switch to embedded:

- API: ui_mode embedded_page (stripe-node v22 / API 2025-10 renamed the enum),
  return_url instead of success/cancel_url, returns client_secret.
- web: @stripe/react-stripe-js EmbeddedCheckout mounted in an in-app modal;
  NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY baked at build (Dockerfile arg + compose arg).
- .env.production.example: full Stripe section (was missing) + admin-email
  placeholder (INF-001).

Also bundled (same files): BILL-002 invoice.paid resets quota only on
subscription_cycle; BILL-003 webhook dedup rolled back on handler failure;
BILL-001 change-plan writes plan locally; BILL-004 webhook cross-checks
sub.customer before trusting metadata.orgId; INF-003 API routed off the raw
docker.sock through a locked-down tecnativa/docker-socket-proxy (CONTAINERS+POST).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@
This commit is contained in:
Marco Sadjadi
2026-05-29 20:56:40 +02:00
parent 9d5386ccba
commit cf423de3d5
8 changed files with 256 additions and 20 deletions

View File

@@ -63,6 +63,17 @@ export async function isDuplicateEvent(eventId: string): Promise<boolean> {
return set === null;
}
/**
* Roll back the idempotency marker for an event whose handler FAILED, so
* Stripe's retry re-processes it. Without this, the marker set by the failed
* first attempt makes every retry look like a duplicate and the event is lost
* forever (e.g. a paid org that never gets upgraded). (BILL-003)
*/
export async function clearProcessedEvent(eventId: string): Promise<void> {
const redis = getRedis();
await redis.del(`stripe:event:${eventId}`);
}
/**
* Sanity-check that price-id env vars actually contain price ids — a common
* setup mistake is to paste the product id (prod_…) instead. Logs loudly on

View File

@@ -6,6 +6,7 @@ import { config } from '../config.js';
import { audit } from '../lib/audit.js';
import {
type PriceTier,
clearProcessedEvent,
isDuplicateEvent,
planFromPriceId,
priceIdForTier,
@@ -41,6 +42,14 @@ export async function billingRoutes(app: FastifyInstance): Promise<void> {
try {
const session = await stripe.checkout.sessions.create({
// Embedded UI: the payment form mounts INSIDE our dashboard via Stripe.js
// instead of redirecting to checkout.stripe.com. Keeps the flow in-app
// (critical for the installed PWA, which otherwise pops out to the
// system browser). Embedded mode uses return_url, not success/cancel_url.
// NOTE: stripe-node v22 / API 2025-10 renamed this enum 'embedded' →
// 'embedded_page'; it returns a client_secret for @stripe/react-stripe-js
// EmbeddedCheckout. ('hosted' is now 'hosted_page'.)
ui_mode: 'embedded_page',
mode: 'subscription',
payment_method_types: ['card', 'sepa_debit'],
line_items: [{ price: priceId, quantity: 1 }],
@@ -54,8 +63,7 @@ export async function billingRoutes(app: FastifyInstance): Promise<void> {
subscription_data: {
metadata: { orgId: user.orgId, userId: user.userId },
},
success_url: `${config.NEXT_PUBLIC_APP_URL}/settings/billing?success=true`,
cancel_url: `${config.NEXT_PUBLIC_APP_URL}/settings/billing?cancelled=true`,
return_url: `${config.NEXT_PUBLIC_APP_URL}/settings/billing?success=true&session_id={CHECKOUT_SESSION_ID}`,
automatic_tax: { enabled: true },
tax_id_collection: { enabled: true },
billing_address_collection: 'required',
@@ -71,7 +79,8 @@ export async function billingRoutes(app: FastifyInstance): Promise<void> {
ipAddress: req.ip,
});
return reply.send({ url: session.url, sessionId: session.id });
// client_secret drives the embedded form; sessionId for optional verification.
return reply.send({ clientSecret: session.client_secret, sessionId: session.id });
} catch (err) {
app.log.error({ err }, 'checkout session create failed');
const msg = err instanceof Error ? err.message : 'unknown_error';
@@ -272,6 +281,14 @@ export async function billingRoutes(app: FastifyInstance): Promise<void> {
items: [{ id: itemId, price: newPriceId }],
proration_behavior: 'create_prorations',
});
// Reconcile the local plan immediately instead of waiting for the
// customer.subscription.updated webhook — otherwise quota enforcement
// reads a stale tier in the gap between this call and webhook delivery.
// Idempotent: the webhook will set the same value. (BILL-001)
await db
.update(organizations)
.set({ plan: planFromPriceId(newPriceId) })
.where(eq(organizations.id, user.orgId));
await audit({
orgId: user.orgId,
userId: user.userId,
@@ -328,6 +345,11 @@ export async function billingRoutes(app: FastifyInstance): Promise<void> {
await handleStripeEvent(app, event);
return reply.send({ ok: true });
} catch (err) {
// Roll back the idempotency marker so the retry actually re-runs the
// handler instead of being skipped as a duplicate. Handlers are
// idempotent (they SET state, not increment), so a rare double-process
// on concurrent retries is safe. (BILL-003)
await clearProcessedEvent(event.id);
// Return 5xx so Stripe retries with exponential backoff.
app.log.error(
{ err, eventId: event.id, type: event.type },
@@ -364,11 +386,26 @@ async function handleStripeEvent(app: FastifyInstance, event: Stripe.Event): Pro
}
async function findOrgIdForSubscription(sub: Stripe.Subscription): Promise<string | null> {
// Prefer the metadata we set at checkout — it's the most reliable mapping.
// Fallback: look the org up by stored customer id.
const metaOrgId = sub.metadata?.orgId;
if (typeof metaOrgId === 'string' && metaOrgId.length > 0) return metaOrgId;
// Prefer the metadata we set at checkout — but DON'T blindly trust it. A
// webhook signature proves the event came from Stripe, not that
// sub.metadata.orgId is honest (metadata is editable in the dashboard/portal).
// Only honour the metadata orgId if the subscription's customer actually
// matches that org's stored stripeCustomerId; otherwise fall back to the
// customer lookup. This prevents a sub with a forged metadata.orgId from
// re-planning a victim org. (BILL-004)
const customerId = typeof sub.customer === 'string' ? sub.customer : sub.customer.id;
const metaOrgId = sub.metadata?.orgId;
if (typeof metaOrgId === 'string' && metaOrgId.length > 0) {
const [byMeta] = await db
.select({ id: organizations.id, customer: organizations.stripeCustomerId })
.from(organizations)
.where(eq(organizations.id, metaOrgId))
.limit(1);
if (byMeta && (byMeta.customer === null || byMeta.customer === customerId)) {
return byMeta.id;
}
// metadata orgId does not own this customer — ignore it and fall through.
}
const [row] = await db
.select({ id: organizations.id })
.from(organizations)
@@ -474,15 +511,18 @@ async function handleSubscriptionDeleted(
async function handleInvoicePaid(_app: FastifyInstance, invoice: Stripe.Invoice): Promise<void> {
const orgId = await findOrgIdForInvoice(invoice);
if (!orgId) return;
// Successful renewal — clear any past-due suspension and reset the usage
// period (so the new month's call quota starts fresh).
// Only the actual monthly renewal (`subscription_cycle`) resets the usage
// counter. Stripe also sends `invoice.paid` for proration/manual/one-off
// invoices (e.g. every plan up/downgrade); resetting on those would let a
// user zero their call quota on demand by churning plan changes. For
// non-cycle invoices we only clear a past-due suspension. (BILL-002)
const isRenewal = invoice.billing_reason === 'subscription_cycle';
await db
.update(organizations)
.set({
suspended: false,
suspendedReason: null,
callsThisPeriod: 0,
periodStartsAt: new Date(),
...(isRenewal ? { callsThisPeriod: 0, periodStartsAt: new Date() } : {}),
})
.where(eq(organizations.id, orgId));
await audit({

View File

@@ -24,6 +24,11 @@ RUN pnpm install --frozen-lockfile
FROM deps AS build
ARG NEXT_PUBLIC_API_URL=http://localhost:4000
ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL
# Stripe publishable key — inlined into the client bundle so the embedded
# checkout can initialise. Safe to expose (publishable, not secret). Empty
# build = embedded checkout shows a "not configured" message until set.
ARG NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=
ENV NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=$NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY
ENV NEXT_TELEMETRY_DISABLED=1
COPY . .
RUN pnpm --filter @bmm/web build
@@ -34,4 +39,9 @@ ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
WORKDIR /app/apps/web
EXPOSE 3001
# NOTE (INF-003): non-root `USER node` was reverted — `pnpm start` via corepack
# can't reach its root-owned cache as the node user and the deploy health-check
# doesn't cover web, so a broken web would deploy "green" but take the site down.
# Re-enable only after switching the runtime CMD to invoke next directly
# (node_modules/.bin/next) and smoke-testing the image locally.
CMD ["pnpm", "start"]

View File

@@ -2,11 +2,19 @@
import { Button } from '@/components/ui/button';
import { apiFetch } from '@/lib/api';
import { Loader2 } from 'lucide-react';
import { EmbeddedCheckout, EmbeddedCheckoutProvider } from '@stripe/react-stripe-js';
import { loadStripe } from '@stripe/stripe-js';
import { Loader2, X } from 'lucide-react';
import Link from 'next/link';
import { useRouter, useSearchParams } from 'next/navigation';
import { Suspense, useCallback, useEffect, useState } from 'react';
// Load Stripe.js once at module scope (Stripe's recommendation). Null when the
// publishable key isn't baked into the build — the modal then shows a clear
// "not configured" message instead of throwing.
const STRIPE_PK = process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY;
const stripePromise = STRIPE_PK ? loadStripe(STRIPE_PK) : null;
type Plan = 'hobby' | 'pro' | 'team' | 'enterprise';
type Tier = 'pro_monthly' | 'pro_yearly' | 'team_monthly' | 'team_yearly';
@@ -68,6 +76,8 @@ function BillingInner() {
const [status, setStatus] = useState<BillingStatus | null>(null);
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState<string | null>(null);
// When set, the in-app embedded Stripe checkout modal is open.
const [clientSecret, setClientSecret] = useState<string | null>(null);
const loadStatus = useCallback(() => {
apiFetch<BillingStatus>('/v1/billing/status')
@@ -98,15 +108,17 @@ function BillingInner() {
setBusy(tier);
setError(null);
try {
const res = await apiFetch<{ url: string }>('/v1/billing/checkout-session', {
const res = await apiFetch<{ clientSecret: string }>('/v1/billing/checkout-session', {
method: 'POST',
body: JSON.stringify({ tier }),
});
window.location.href = res.url;
// Open the embedded checkout in-app instead of redirecting to Stripe.
setClientSecret(res.clientSecret);
} catch (e) {
setBusy(null);
const detail = (e as { detail?: { detail?: string; error?: string } }).detail;
setError(detail?.detail ?? detail?.error ?? (e as Error).message);
} finally {
setBusy(null);
}
}, []);
@@ -183,6 +195,15 @@ function BillingInner() {
return (
<div className="mx-auto max-w-3xl px-6 py-10">
{clientSecret && (
<CheckoutModal
clientSecret={clientSecret}
onClose={() => {
setClientSecret(null);
setBusy(null);
}}
/>
)}
<div>
<h1 className="text-[22px] font-semibold tracking-tight">Billing</h1>
<p className="mt-1 text-[13px] text-[--color-fg-muted]">
@@ -421,6 +442,44 @@ function BillingInner() {
);
}
function CheckoutModal({
clientSecret,
onClose,
}: {
clientSecret: string;
onClose: () => void;
}) {
return (
<div
className="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-black/60 p-4 backdrop-blur-sm sm:p-8"
role="dialog"
aria-modal="true"
>
<div className="relative my-auto w-full max-w-xl rounded-lg border border-[--color-border] bg-[--color-bg] p-1 shadow-xl">
<button
type="button"
onClick={onClose}
aria-label="Close checkout"
className="absolute right-2 top-2 z-10 rounded-md p-1.5 text-[--color-fg-muted] hover:bg-[--color-bg-subtle] hover:text-[--color-fg]"
>
<X size={18} />
</button>
{stripePromise ? (
<EmbeddedCheckoutProvider stripe={stripePromise} options={{ clientSecret }}>
<EmbeddedCheckout />
</EmbeddedCheckoutProvider>
) : (
<div className="p-6">
<Alert tone="error">
Payments arent configured (missing Stripe publishable key). Please contact support.
</Alert>
</div>
)}
</div>
</div>
);
}
function Alert({
tone,
children,
@@ -485,7 +544,7 @@ function TierCard({
onClick={() => onSubscribe(monthlyTier)}
disabled={Boolean(busy)}
>
{busy === monthlyTier ? 'Redirecting…' : `Subscribe — €${monthly}/mo`}
{busy === monthlyTier ? 'Loading…' : `Subscribe — €${monthly}/mo`}
</Button>
<Button
variant="ghost"
@@ -493,7 +552,7 @@ function TierCard({
onClick={() => onSubscribe(yearlyTier)}
disabled={Boolean(busy)}
>
{busy === yearlyTier ? 'Redirecting…' : `Or €${yearly}/year — 2 months free`}
{busy === yearlyTier ? 'Loading…' : `Or €${yearly}/year — 2 months free`}
</Button>
</div>
</div>

View File

@@ -11,6 +11,8 @@
},
"dependencies": {
"@bmm/types": "workspace:*",
"@stripe/react-stripe-js": "^6.4.0",
"@stripe/stripe-js": "^9.7.0",
"clsx": "2.1.1",
"framer-motion": "11.18.2",
"geist": "1.3.1",