/** * 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}`, description: `PieCed IT — ${invoice.periodStart.slice(0, 10)} → ${invoice.periodEnd.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", }; }