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:
@@ -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
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user