/** * Server-side Stripe client + helpers for Phase 4 (card payments). * * Architecture (see Phase 4 notes): * 1. Customer clicks "Pay with card" on /billing/. * 2. Server creates Stripe Checkout Session (mode='payment') with * the invoice total as a single line item. We pass `customer` * to reuse an existing Stripe Customer if the org already has * one, otherwise we create one and persist its id in * org_billing_config.stripe_customer_id. * 3. Returns session.url; the browser redirects there. * 4. Customer pays; Stripe redirects to success_url with the * session id appended. * 5. /api/stripe/webhook receives `checkout.session.completed`, * verifies signature, looks up the invoice id from metadata, * flips the invoice to 'paid'. * * Env vars: * STRIPE_SECRET_KEY (required) - sk_test_... in sandbox, sk_live_... in prod * STRIPE_WEBHOOK_SECRET (required for webhook) - whsec_... * APP_BASE_URL (required) - e.g. https://app.pieced.ch * * SDK: stripe@22.x (Node SDK v22), pinned API version 2026-03-25.dahlia. * Pinning the API version means a `npm update` of the SDK won't * silently change request/response shapes; we explicitly bump when * we want a new API version. */ import Stripe from "stripe"; import type { Invoice } from "@/types"; // Pinned API version. `as const` narrows this to a string-literal // type that the Stripe constructor's `apiVersion` field accepts // exactly. When the installed SDK bumps to a new pinned version, // TypeScript will surface the mismatch at the `new Stripe(...)` call // below — bump this string deliberately alongside the SDK upgrade // and review the API changelog before doing so. const STRIPE_API_VERSION = "2026-04-22.dahlia" as const; // Cache the client across hot reloads / serverless invocations. // We don't instantiate at module load because some build steps run // without runtime env vars set — only fail when actually used. let cachedClient: Stripe | null = null; export function getStripeClient(): Stripe { if (cachedClient) return cachedClient; const key = process.env.STRIPE_SECRET_KEY; if (!key) { throw new Error( "STRIPE_SECRET_KEY is not set. Configure it in your environment." ); } cachedClient = new Stripe(key, { apiVersion: STRIPE_API_VERSION, // Identify ourselves in Stripe's request logs so support can // distinguish PieCed traffic from other integrations on the // same account. appInfo: { name: "PieCed Portal", version: "1.0.0", url: "https://app.pieced.ch", }, }); return cachedClient; } /** * Return the configured webhook secret. Separated so the webhook * handler can fail fast with a clear error message rather than the * generic "STRIPE_SECRET_KEY missing" path above. */ export function getWebhookSecret(): string { const secret = process.env.STRIPE_WEBHOOK_SECRET; if (!secret) { throw new Error( "STRIPE_WEBHOOK_SECRET is not set. Get it from the webhook endpoint in your Stripe dashboard." ); } return secret; } /** * Convert a CHF decimal amount (e.g. 123.45) to integer rappen * (e.g. 12345). Stripe API requires integer amounts in the * currency's smallest unit. Centralised so we don't have rounding * drift between callers. */ export function chfToRappen(amountChf: number): number { // toFixed(2) avoids floating-point ugliness (0.1 + 0.2 = 0.30000000000000004). return Math.round(parseFloat(amountChf.toFixed(2)) * 100); } /** * Look up or create the Stripe Customer for a PieCed org. * * Lazy creation: orgs that only pay by invoice never get a Stripe * Customer. The first "Pay with card" click triggers creation; the * id is persisted in org_billing_config so subsequent invoices * reuse it. * * Returns the Stripe customer id (`cus_...`). */ export async function ensureStripeCustomerForOrg(params: { zitadelOrgId: string; // Snapshot taken at click-time, NOT at invoice issuance — the // org's current address goes on the Stripe customer object. // Stripe's address on file is independent of any one invoice. companyName: string; billingEmail: string; address: { line1: string; postalCode: string; city: string; country: string; // ISO 3166-1 alpha-2 (e.g. "CH") }; }): Promise { // Lazy import to avoid pulling pg into edge-runtime modules that // might import this file. Same pattern used elsewhere in lib/. const { getOrgBillingConfig, updateOrgBillingConfig } = await import("./db"); const existing = await getOrgBillingConfig(params.zitadelOrgId); if (existing.stripeCustomerId) { return existing.stripeCustomerId; } const stripe = getStripeClient(); const customer = await stripe.customers.create({ email: params.billingEmail, name: params.companyName, address: { line1: params.address.line1, postal_code: params.address.postalCode, city: params.address.city, country: params.address.country, }, metadata: { zitadel_org_id: params.zitadelOrgId, }, }); await updateOrgBillingConfig(params.zitadelOrgId, { stripeCustomerId: customer.id, }); return customer.id; } /** * Create a Checkout Session for paying a single invoice by card. * * Design notes: * * - Single line item with the invoice total (gross, VAT included). * Our own invoice PDF already breaks down lines + VAT; the Stripe * page is the checkout, not a duplicate of the invoice. * * - `automatic_tax` is disabled because the invoice already has * VAT computed by our pipeline. Letting Stripe re-calculate * would double-charge or contradict our PDF. * * - `payment_method_types` is NOT set, so Stripe surfaces dynamic * payment methods configured on the account (cards, TWINT for * Swiss customers, Apple Pay, Google Pay, etc.) automatically. * * - `metadata` and `payment_intent_data.metadata` BOTH carry the * invoice id. The session-level copy is enough for the * `checkout.session.completed` webhook; the intent-level copy * lets us correlate refunds and disputes which fire on the * PaymentIntent and don't include session metadata. * * - `client_reference_id` is set to our invoice id as a stable * reference. Visible in the Stripe dashboard, useful for support. * * - `locale` follows the invoice's locale so the customer sees * the Stripe page in their language (frozen at invoice issue * time; consistent with PDF + email). */ export async function createCheckoutSessionForInvoice(params: { invoice: Invoice; customerId: string; baseUrl: string; }): Promise<{ url: string; sessionId: string }> { const stripe = getStripeClient(); const { invoice, customerId, baseUrl } = params; // Stripe Checkout supports a limited set of locales; map our // four to Stripe's codes and fall back to 'auto' if anything // outside the set ever appears. // // We deliberately don't annotate this with // `Stripe.Checkout.SessionCreateParams.Locale` — stripe-node v22 // ships with a known type-export regression // (stripe/stripe-node#2662) where params types under namespaced // resources aren't re-exported from the resource barrel. The // `as const` literal narrowing gives the variable the union type // `"de" | "fr" | "it" | "en" | "auto"`, which `sessions.create` // accepts at the call site via its own inline parameter typing. // When the SDK fixes the re-export, we can put the annotation // back without touching the call site. const stripeLocale = invoice.locale === "de" ? ("de" as const) : invoice.locale === "fr" ? ("fr" as const) : invoice.locale === "it" ? ("it" as const) : invoice.locale === "en" ? ("en" as const) : ("auto" as const); const successUrl = `${baseUrl}/billing/${encodeURIComponent(invoice.invoiceNumber)}?paid=1&session_id={CHECKOUT_SESSION_ID}`; const cancelUrl = `${baseUrl}/billing/${encodeURIComponent(invoice.invoiceNumber)}?cancelled=1`; const session = await stripe.checkout.sessions.create({ mode: "payment", customer: customerId, client_reference_id: invoice.id, locale: stripeLocale, line_items: [ { quantity: 1, price_data: { currency: "chf", unit_amount: chfToRappen(invoice.totalChf), product_data: { name: `Invoice ${invoice.invoiceNumber}`, // Phase 8: custom invoices have no period — fall back // to a description that just references the invoice // number and due date. description: invoice.periodStart && invoice.periodEnd ? `PieCed IT — ${invoice.periodStart.slice(0, 10)} → ${invoice.periodEnd.slice(0, 10)}` : `PieCed IT — due ${invoice.dueAt.slice(0, 10)}`, }, }, }, ], metadata: { invoice_id: invoice.id, invoice_number: invoice.invoiceNumber, zitadel_org_id: invoice.zitadelOrgId, }, payment_intent_data: { // Mirror invoice id at the PaymentIntent level so refunds & // disputes (which fire on the PI, not the session) can be // correlated to our invoice without an extra lookup. metadata: { invoice_id: invoice.id, invoice_number: invoice.invoiceNumber, zitadel_org_id: invoice.zitadelOrgId, }, // Statement descriptor shown on the customer's card // statement. Limited to 22 chars total; we use the prefix // since Stripe will prepend the merchant name from the // account anyway. Keep it short and recognisable. description: `Invoice ${invoice.invoiceNumber}`, }, success_url: successUrl, cancel_url: cancelUrl, // VAT is already in invoice.totalChf — don't let Stripe touch tax. automatic_tax: { enabled: false }, }); if (!session.url) { throw new Error( `Stripe returned a session without a redirect URL (id=${session.id})` ); } return { url: session.url, sessionId: session.id }; } // --------------------------------------------------------------------------- // Phase 7 — refunds // --------------------------------------------------------------------------- /** * Create a Stripe Refund against an invoice's PaymentIntent. * * The amount is in CHF; we convert to rappen for Stripe's smallest- * currency-unit API. Pass 0 or undefined for `amountChf` to refund * the full charge. * * Returns the Stripe refund object so the caller can record the * refund id and final status. Stripe processes refunds asynchronously * for some payment methods, so the initial status may be 'pending' * — the charge.refunded webhook delivers the eventual succeeded / * failed transition. * * Throws on Stripe API errors (no charge, insufficient balance, * etc.). The caller surfaces these to the admin via the API * response — we don't swallow them because partial-refund logic * shouldn't be guessing about server state. */ export async function createInvoiceRefund(params: { paymentIntentId: string; amountChf?: number; reason?: "duplicate" | "fraudulent" | "requested_by_customer"; metadata?: Record; }): Promise<{ id: string; amountChf: number; status: string; }> { const stripe = getStripeClient(); const refundParams: Parameters[0] = { payment_intent: params.paymentIntentId, metadata: params.metadata, }; if (params.amountChf && params.amountChf > 0) { refundParams.amount = chfToRappen(params.amountChf); } if (params.reason) { refundParams.reason = params.reason; } const refund = await stripe.refunds.create(refundParams); // The amount on the response is in rappen; convert back. If no // amount was passed, Stripe defaults to the full remaining // charge, which is what we read back. return { id: refund.id, amountChf: refund.amount != null ? refund.amount / 100 : 0, status: refund.status ?? "unknown", }; } // --------------------------------------------------------------------------- // Phase 9 — saved cards (SetupIntent / Checkout setup mode) // --------------------------------------------------------------------------- /** * Create a Checkout session in setup mode — Stripe collects card * details and authorizes them for off-session future charges, * without charging anything now. On success, Stripe attaches the * resulting PaymentMethod to the customer object and fires * `checkout.session.completed` with mode='setup'. * * The webhook handler reads the session's setup_intent, extracts * the payment_method id, and persists the display fields * (brand/last4/exp) via setSavedPaymentMethod. From that moment * on, the customer has auto-charge wired up. * * Re-running this against a customer who already has a saved card * is supported — Stripe attaches the new PaymentMethod and the * webhook overwrites the old one in our DB. That's how "Update * card" works. */ export async function createSetupCheckoutSession(params: { customerId: string; baseUrl: string; locale?: "de" | "en" | "fr" | "it"; /** * Where to redirect after the customer completes / cancels the * setup. Defaults to /settings/billing — the natural landing * spot after saving a card. */ returnPath?: string; }): Promise<{ url: string; sessionId: string }> { const stripe = getStripeClient(); const { customerId, baseUrl, locale } = params; const returnPath = params.returnPath ?? "/settings/billing"; const stripeLocale = locale === "de" ? ("de" as const) : locale === "fr" ? ("fr" as const) : locale === "it" ? ("it" as const) : locale === "en" ? ("en" as const) : ("auto" as const); const successUrl = `${baseUrl}${returnPath}?card_setup=success&session_id={CHECKOUT_SESSION_ID}`; const cancelUrl = `${baseUrl}${returnPath}?card_setup=cancelled`; const session = await stripe.checkout.sessions.create({ mode: "setup", customer: customerId, locale: stripeLocale, payment_method_types: ["card"], success_url: successUrl, cancel_url: cancelUrl, // Stripe attaches the resulting PaymentMethod to the customer // and the webhook fires with session.setup_intent populated. // No extra setup_intent_data needed for the basic flow. }); if (!session.url) { throw new Error( `Stripe returned a setup session without a redirect URL (id=${session.id})` ); } return { url: session.url, sessionId: session.id }; } /** * Detach a PaymentMethod from its customer. Used when the customer * clicks "Remove card" — the PM is no longer usable for charges * once detached. The Stripe Customer object survives (so future * charges can still attach a new card to the same customer). * * Stripe permits detaching a PM that's already detached as a * no-op; safe to retry. */ export async function detachPaymentMethod( paymentMethodId: string ): Promise { const stripe = getStripeClient(); try { await stripe.paymentMethods.detach(paymentMethodId); } catch (e: any) { // Stripe returns 404 if the PM is already detached or doesn't // exist — treat as success since the intended end-state ("not // attached") is already reached. Re-throw anything else. if (e?.statusCode === 404) return; throw e; } } /** * Fetch the display fields for a PaymentMethod (brand, last4, * exp). Used by the webhook to read out what to persist after a * setup session completes; the session itself only carries the * PM id, not the card details. */ export async function getPaymentMethodDisplay( paymentMethodId: string ): Promise<{ brand: string | null; last4: string | null; expMonth: number | null; expYear: number | null; }> { const stripe = getStripeClient(); const pm = await stripe.paymentMethods.retrieve(paymentMethodId); // The card object is only present when type='card'. We don't // anticipate non-card PMs in this codebase yet, but defensive // null-handling avoids crashing if Stripe surfaces something // unexpected (Apple Pay, link, etc. — all of which still // resolve to a card under the hood). const card = (pm as any).card; if (!card) { return { brand: null, last4: null, expMonth: null, expYear: null }; } return { brand: card.brand ?? null, last4: card.last4 ?? null, expMonth: typeof card.exp_month === "number" ? card.exp_month : null, expYear: typeof card.exp_year === "number" ? card.exp_year : null, }; }