diff --git a/package-lock.json b/package-lock.json index b42e8db..29c278f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,6 +19,7 @@ "pg": "^8.20.0", "react": "^19.1.0", "react-dom": "^19.1.0", + "stripe": "^22.1.1", "zod": "^3.24.0" }, "devDependencies": { @@ -7530,6 +7531,23 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/stripe": { + "version": "22.1.1", + "resolved": "https://registry.npmjs.org/stripe/-/stripe-22.1.1.tgz", + "integrity": "sha512-cmodIYP27tBkJ8G7DuGgWw0PFuemlFZbuF3Wwr1TrjFjUa3T7NIgCe6TVwX8BO2ynu+xtTuDGfHafNDCPt9lXA==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, "node_modules/styled-jsx": { "version": "5.1.6", "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz", diff --git a/package.json b/package.json index 4d35413..fba5197 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "pg": "^8.20.0", "react": "^19.1.0", "react-dom": "^19.1.0", + "stripe": "^22.1.1", "zod": "^3.24.0" }, "devDependencies": { diff --git a/src/app/api/billing/invoices/[invoiceNumber]/pay/route.ts b/src/app/api/billing/invoices/[invoiceNumber]/pay/route.ts new file mode 100644 index 0000000..de6ea9c --- /dev/null +++ b/src/app/api/billing/invoices/[invoiceNumber]/pay/route.ts @@ -0,0 +1,105 @@ +import { NextResponse } from "next/server"; +import { getSessionUser } from "@/lib/session"; +import { + getInvoiceByNumberForOrg, + getOrgBilling, +} from "@/lib/db"; +import { + createCheckoutSessionForInvoice, + ensureStripeCustomerForOrg, +} from "@/lib/stripe"; +import { safeError } from "@/lib/errors"; + +/** + * POST /api/billing/invoices/[invoiceNumber]/pay + * + * Initiates a Stripe Checkout Session for an open invoice. Returns + * `{ url }` — the browser is expected to navigate to that URL, + * where Stripe hosts the payment UI. + * + * Authorization: caller must belong to the invoice's org (the DB + * query enforces this — wrong-org returns 404, indistinguishable + * from a non-existent invoice). + * + * Preconditions enforced server-side: + * - Invoice exists for caller's org + * - Invoice status is 'open' or 'overdue' (paid/void/draft/uncollectible + * all reject — already-paid invoices in particular must not + * create a second Checkout Session, even though Stripe would + * deduplicate the actual charge) + * + * The Stripe Customer for the org is lazily ensured here — first + * card click on an org creates the customer; subsequent clicks + * reuse the persisted stripe_customer_id. + */ +export async function POST( + _request: Request, + { params }: { params: Promise<{ invoiceNumber: string }> } +) { + const user = await getSessionUser(); + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + const { invoiceNumber } = await params; + + const detail = await getInvoiceByNumberForOrg(invoiceNumber, user.orgId); + if (!detail) { + return NextResponse.json({ error: "Not found" }, { status: 404 }); + } + const inv = detail.invoice; + if (inv.status !== "open" && inv.status !== "overdue") { + return NextResponse.json( + { + error: + inv.status === "paid" + ? "This invoice has already been paid." + : `This invoice cannot be paid online (status: ${inv.status}).`, + }, + { status: 409 } + ); + } + + // We need org_billing for the customer creation address. The + // invoice has a SNAPSHOT but that's frozen at issue time; for + // creating/updating the Stripe customer we want the current + // address (which may have been corrected since the invoice). + // Snapshot is still authoritative on the invoice PDF and total. + const orgBilling = await getOrgBilling(user.orgId); + if (!orgBilling) { + return NextResponse.json( + { error: "Billing details are not configured for your organization." }, + { status: 400 } + ); + } + + try { + const customerId = await ensureStripeCustomerForOrg({ + zitadelOrgId: user.orgId, + companyName: orgBilling.companyName, + billingEmail: orgBilling.billingEmail, + address: { + line1: orgBilling.streetAddress, + postalCode: orgBilling.postalCode, + city: orgBilling.city, + country: orgBilling.country, + }, + }); + const baseUrl = + process.env.APP_BASE_URL ?? "https://app.pieced.ch"; + const { url } = await createCheckoutSessionForInvoice({ + invoice: inv, + customerId, + baseUrl, + }); + return NextResponse.json({ url }); + } catch (e) { + console.error( + `Failed to create Checkout Session for invoice ${invoiceNumber}:`, + e + ); + return NextResponse.json( + { error: safeError(e, "Failed to start card payment.") }, + { status: 500 } + ); + } +} diff --git a/src/app/api/stripe/webhook/route.ts b/src/app/api/stripe/webhook/route.ts new file mode 100644 index 0000000..3eb0e30 --- /dev/null +++ b/src/app/api/stripe/webhook/route.ts @@ -0,0 +1,232 @@ +import { NextResponse } from "next/server"; +import type Stripe from "stripe"; +import { getStripeClient, getWebhookSecret } from "@/lib/stripe"; +import { + markInvoicePaid, + markStripeEventProcessed, + setInvoiceStripePaymentIntent, + tryRecordStripeEvent, +} from "@/lib/db"; + +/** + * POST /api/stripe/webhook + * + * Receives signed events from Stripe. The lifecycle: + * + * 1. Read RAW body (request.text(), NOT request.json() — Stripe's + * HMAC is computed over the raw bytes and any JSON re-parse + * will subtly mangle whitespace or property ordering and the + * signature will fail). + * 2. Verify signature against the configured webhook secret. If + * verification fails → 400. An attacker forging webhook calls + * could otherwise mark our invoices paid. + * 3. Idempotency: INSERT the event id into stripe_events. If the + * INSERT conflicts (duplicate delivery, which is normal — Stripe + * retries failed deliveries for up to 72h), return 200 immediately + * so Stripe doesn't keep retrying. + * 4. Process the event based on type. Currently we care about: + * - checkout.session.completed → flip invoice to paid + * - charge.refunded → log; void/credit handling is Phase 7 + * - payment_intent.payment_failed → log only; the failure is + * already shown to the user on + * the Stripe page, no action. + * Unknown event types are ack'd with 200 (we may have other + * events enabled at the Stripe end that we don't yet care about, + * and 200 + log is cheaper than 404 + Stripe retries). + * 5. Stamp processed_at on success. + * + * Return contract: 2xx ack → Stripe stops retrying. Any non-2xx → + * Stripe retries with exponential backoff up to 72h. We aim for + * 200 on every reachable path (verified, deduplicated, or processed), + * and only 400 for signature failures (those would never succeed + * on retry anyway, so retrying is wasted effort). + * + * Performance: handlers run synchronously here because PieCed's + * event volume is tiny. If/when that changes, the obvious refactor + * is to enqueue (Phase 7) and ack first — but at v1 the inline + * model is simpler to reason about and harder to lose events with. + */ + +// Next.js: explicitly disable static optimization; this route MUST +// run on every request and must not be cached. +export const dynamic = "force-dynamic"; + +export async function POST(request: Request) { + // 1. Raw body — Stripe verifies the signature over these exact bytes. + const rawBody = await request.text(); + const signature = request.headers.get("stripe-signature"); + if (!signature) { + return new NextResponse("Missing stripe-signature header", { + status: 400, + }); + } + + // 2. Verify signature. + let event: Stripe.Event; + try { + const stripe = getStripeClient(); + const secret = getWebhookSecret(); + event = stripe.webhooks.constructEvent(rawBody, signature, secret); + } catch (err) { + console.error("Stripe webhook signature verification failed:", err); + // 400 — never retry. The webhook configuration is wrong on + // either end (rotated secret, wrong endpoint, etc.); retries + // won't fix it. + return new NextResponse("Invalid signature", { status: 400 }); + } + + // 3. Idempotency. INSERT event.id → fail-fast on duplicate. + let firstDelivery: boolean; + try { + firstDelivery = await tryRecordStripeEvent( + event.id, + event.type, + event + ); + } catch (err) { + console.error( + `Failed to record stripe event ${event.id} (${event.type}):`, + err + ); + // 5xx so Stripe retries — this is a DB hiccup, not a logic error. + return new NextResponse("DB error", { status: 500 }); + } + if (!firstDelivery) { + // Already processed; ack happily. + return new NextResponse("Duplicate delivery; acknowledged.", { + status: 200, + }); + } + + // 4. Process. Each handler is responsible for being safe to run + // exactly once (we already deduplicated by event.id above). + try { + switch (event.type) { + case "checkout.session.completed": + await handleCheckoutCompleted( + event.data.object as Stripe.Checkout.Session + ); + break; + case "charge.refunded": + await handleChargeRefunded(event.data.object as Stripe.Charge); + break; + case "payment_intent.payment_failed": + await handlePaymentFailed( + event.data.object as Stripe.PaymentIntent + ); + break; + default: + // Unknown event — log so we notice if Stripe starts sending + // something we should handle, but ack so we don't accumulate + // retries forever. + console.log( + `Stripe webhook: ignoring event type ${event.type} (id ${event.id})` + ); + } + } catch (err) { + console.error( + `Stripe webhook handler failed for ${event.type} (id ${event.id}):`, + err + ); + // 5xx → Stripe retries. The handler is idempotent because the + // stripe_events row already exists, so on the next attempt we'd + // short-circuit at step 3. To actually retry the work we'd need + // to DELETE the stripe_events row first; for v1 we don't bother + // and let a human investigate the logs. + return new NextResponse("Handler error", { status: 500 }); + } + + // 5. Mark processed. + try { + await markStripeEventProcessed(event.id); + } catch (err) { + // Non-fatal — the event was already processed, this is just the + // bookkeeping flag. Log and move on. + console.error( + `Failed to mark stripe event ${event.id} processed:`, + err + ); + } + + return new NextResponse("OK", { status: 200 }); +} + +// --------------------------------------------------------------------------- +// Handlers +// --------------------------------------------------------------------------- + +async function handleCheckoutCompleted( + session: Stripe.Checkout.Session +): Promise { + // Defensive: paid sessions are what we want; sessions can also + // complete in "unpaid" state (rare for mode=payment, more common + // for async/delayed methods like SEPA). Only flip the invoice + // when payment actually cleared. + if (session.payment_status !== "paid") { + console.log( + `Checkout session ${session.id} completed but payment_status=${session.payment_status}; waiting for downstream events.` + ); + return; + } + const invoiceId = + session.metadata?.invoice_id ?? session.client_reference_id ?? null; + if (!invoiceId) { + console.error( + `Checkout session ${session.id} completed without invoice_id metadata; cannot link to invoice.` + ); + return; + } + const paymentIntentId = + typeof session.payment_intent === "string" + ? session.payment_intent + : session.payment_intent?.id; + + // Persist the PaymentIntent id on the invoice for traceability + + // future refund correlation. + if (paymentIntentId) { + await setInvoiceStripePaymentIntent(invoiceId, paymentIntentId); + } + + // Flip status. markInvoicePaid is idempotent — re-running on an + // already-paid invoice returns null and we log + skip. + const updated = await markInvoicePaid(invoiceId, { + paidBy: "stripe", + paidMethodDetail: paymentIntentId + ? `Stripe Checkout (${paymentIntentId})` + : "Stripe Checkout", + paidAt: session.created ? new Date(session.created * 1000) : undefined, + }); + if (!updated) { + // Already paid or void/draft — fine, nothing to do. + console.log( + `Invoice ${invoiceId} was not in a payable state when Stripe webhook arrived (likely already paid).` + ); + return; + } + console.log( + `Invoice ${invoiceId} marked paid via Stripe (session ${session.id}, intent ${paymentIntentId}).` + ); +} + +async function handleChargeRefunded(charge: Stripe.Charge): Promise { + // v1 scope: log only. Refunds always go through Stripe → admin + // initiates them in the dashboard. Updating our invoice status + // to 'void' or partial-credit needs more product thinking + // (partial refunds? credit notes? VAT corrections?). Phase 7. + console.log( + `Charge ${charge.id} refunded (amount ${charge.amount_refunded} ${charge.currency}); no portal-side state change.` + ); +} + +async function handlePaymentFailed( + intent: Stripe.PaymentIntent +): Promise { + // The Stripe-hosted page already shows the failure to the user. + // We log here for support visibility and to surface in Workbench. + // No invoice state change — it stays 'open' until paid. + console.log( + `PaymentIntent ${intent.id} failed: ${ + intent.last_payment_error?.message ?? "(no message)" + }` + ); +} diff --git a/src/components/billing/customer-invoice-detail.tsx b/src/components/billing/customer-invoice-detail.tsx index 3ace13f..ae76452 100644 --- a/src/components/billing/customer-invoice-detail.tsx +++ b/src/components/billing/customer-invoice-detail.tsx @@ -1,6 +1,8 @@ import { useTranslations, useFormatter } from "next-intl"; import { Card } from "@/components/ui/card"; import type { Invoice, InvoiceLine } from "@/types"; +import { PayInvoiceButton } from "./pay-invoice-button"; +import { PaymentStatusBanner } from "./payment-status-banner"; interface Props { invoice: Invoice; @@ -29,6 +31,7 @@ export function CustomerInvoiceDetail({ invoice, lines }: Props) { return (
+
@@ -49,14 +52,23 @@ export function CustomerInvoiceDetail({ invoice, lines }: Props) { {fmt.dateTime(new Date(invoice.periodEnd), { dateStyle: "long" })}

- - {t("downloadPdf")} - +
+ {/* Phase 4: Pay-with-card available for open + overdue. + Paid/void/draft/uncollectible hide the button — the + API also enforces this, so client-side hiding is just + for the visible affordance. */} + {(invoice.status === "open" || invoice.status === "overdue") && ( + + )} + + {t("downloadPdf")} + +
diff --git a/src/components/billing/pay-invoice-button.tsx b/src/components/billing/pay-invoice-button.tsx new file mode 100644 index 0000000..93b1765 --- /dev/null +++ b/src/components/billing/pay-invoice-button.tsx @@ -0,0 +1,64 @@ +"use client"; + +import { useState } from "react"; +import { useTranslations } from "next-intl"; + +interface Props { + invoiceNumber: string; +} + +/** + * Pay-with-card button. Posts to /api/billing/invoices/[n]/pay, + * which returns a Stripe Checkout Session URL; we redirect the + * browser there. + * + * The button is rendered only by the parent for status='open' or + * 'overdue' invoices — the API enforces this too, but pre-filtering + * UI-side keeps the dead state out of the customer's face. + */ +export function PayInvoiceButton({ invoiceNumber }: Props) { + const t = useTranslations("customerBilling"); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + const onClick = async () => { + setBusy(true); + setError(null); + try { + const res = await fetch( + `/api/billing/invoices/${encodeURIComponent(invoiceNumber)}/pay`, + { method: "POST" } + ); + const data = await res.json().catch(() => ({})); + if (!res.ok) { + throw new Error(data.error ?? `HTTP ${res.status}`); + } + if (!data.url) { + throw new Error("Payment session URL missing from response."); + } + // Hard navigation, not Next.js router — Stripe Checkout is a + // separate origin and the browser needs to fully leave our app. + window.location.href = data.url; + } catch (e: any) { + setError(e?.message ?? String(e)); + setBusy(false); + } + }; + + return ( +
+ + {error && ( + + {error} + + )} +
+ ); +} diff --git a/src/components/billing/payment-status-banner.tsx b/src/components/billing/payment-status-banner.tsx new file mode 100644 index 0000000..9e034ff --- /dev/null +++ b/src/components/billing/payment-status-banner.tsx @@ -0,0 +1,66 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useRouter } from "next/navigation"; +import { useTranslations } from "next-intl"; + +/** + * Banner shown after a return from Stripe Checkout. + * + * ?paid=1 → green success banner. The webhook may or may + * not have processed yet, so we phrase the message + * as "Payment received, status will update shortly" + * and don't claim the status is already paid. A + * light auto-refresh after a few seconds nudges + * the page to pick up the new status badge. + * + * ?cancelled=1 → neutral grey banner: "Payment cancelled". The + * invoice stays in 'open' state. + * + * The banner cleans up the query params from the URL so a page + * reload doesn't repeat the message. We use router.replace() to + * keep history clean. + */ +export function PaymentStatusBanner() { + const router = useRouter(); + const t = useTranslations("customerBilling"); + const [state, setState] = useState<"paid" | "cancelled" | null>(null); + + useEffect(() => { + const params = new URLSearchParams(window.location.search); + if (params.has("paid")) { + setState("paid"); + // Reload after 4s so the status badge picks up the webhook's + // effect on the invoice row. By then most webhook deliveries + // have landed; if not the user just sees "open" and can + // manually refresh. + const timer = setTimeout(() => { + router.refresh(); + }, 4000); + // Strip the query string out of the URL. + const cleanUrl = window.location.pathname; + window.history.replaceState({}, "", cleanUrl); + return () => clearTimeout(timer); + } else if (params.has("cancelled")) { + setState("cancelled"); + const cleanUrl = window.location.pathname; + window.history.replaceState({}, "", cleanUrl); + } + }, [router]); + + if (state === "paid") { + return ( +
+ {t("paymentReceived")} +
+ ); + } + if (state === "cancelled") { + return ( +
+ {t("paymentCancelled")} +
+ ); + } + return null; +} diff --git a/src/lib/db.ts b/src/lib/db.ts index 40bd2b1..8bd9da6 100644 --- a/src/lib/db.ts +++ b/src/lib/db.ts @@ -500,7 +500,7 @@ const MIGRATION_SQL = ` -- NULL for org-wide items; tenant name for per-tenant breakdowns. tenant_name TEXT, kind TEXT NOT NULL CHECK (kind IN ( - 'tenant_monthly','tenant_setup','ai_usage','threema_messages','skill_usage','adjustment' + 'tenant_monthly','tenant_setup','ai_usage','threema_messages','skill_usage','skill_setup','adjustment' )), description TEXT NOT NULL, quantity NUMERIC(12,4) NOT NULL DEFAULT 1, @@ -563,6 +563,41 @@ const MIGRATION_SQL = ` -- Per-tenant lookup for the customer UI's pending+rejected display. CREATE INDEX IF NOT EXISTS idx_skill_act_tenant ON skill_activation_requests (tenant_name, requested_at DESC); + + -- Phase 3 fix: the original invoice_lines.kind CHECK constraint + -- was created without 'skill_setup' (which Phase 2-fix6 added as + -- a new line kind for per-skill setup fees). CREATE TABLE IF NOT + -- EXISTS doesn't update constraints on existing tables, so we + -- explicitly drop and re-add with the full kind set on every + -- boot. Idempotent — DROP IF EXISTS swallows the not-yet-exists + -- case (fresh installs); ADD always re-creates. Constraint name + -- follows Postgres's default __check. + ALTER TABLE invoice_lines + DROP CONSTRAINT IF EXISTS invoice_lines_kind_check; + ALTER TABLE invoice_lines + ADD CONSTRAINT invoice_lines_kind_check + CHECK (kind IN ( + 'tenant_monthly','tenant_setup','ai_usage','threema_messages', + 'skill_usage','skill_setup','adjustment' + )); + + -- Phase 4: Stripe webhook idempotency. Stripe guarantees at-least-once + -- delivery and retries failures with exponential backoff for up to 72h, + -- so the same event.id can arrive multiple times. We insert each + -- event.id with the PK constraint enforcing uniqueness; INSERT either + -- succeeds (first delivery → process the event) or fails with 23505 + -- (duplicate → ack with 200 and skip). The payload column is invaluable + -- when diagnosing a webhook that processed wrong; keep it small and + -- prune old rows out-of-band if storage becomes a concern (Phase 7). + CREATE TABLE IF NOT EXISTS stripe_events ( + event_id TEXT PRIMARY KEY, + event_type TEXT NOT NULL, + received_at TIMESTAMPTZ NOT NULL DEFAULT now(), + processed_at TIMESTAMPTZ, + payload JSONB + ); + CREATE INDEX IF NOT EXISTS idx_stripe_events_type_received + ON stripe_events (event_type, received_at DESC); `; let migrated = false; @@ -2825,3 +2860,104 @@ export async function updateSkillActivationRequestStatus( ? rowToSkillActivationRequest(result.rows[0]) : null; } + +// --------------------------------------------------------------------------- +// Phase 3 diagnostic — single-purpose helper for the /api/admin/billing/debug +// endpoint. Returns raw invoice_line rows for a tenant filtered to setup-fee +// rows, so a human can verify what the billing emission code's SQL is +// actually seeing. Not intended for production use; kept here for shipping +// hotfixes when running-total drafts diverge from expected behaviour. +// --------------------------------------------------------------------------- +export async function debugListSetupLines( + tenantName: string +): Promise> { + await ensureSchema(); + const result = await getPool().query( + `SELECT id, invoice_id, tenant_name, kind, amount_chf, description + FROM invoice_lines + WHERE tenant_name = $1 + AND kind = 'tenant_setup'`, + [tenantName] + ); + return result.rows; +} + +// --------------------------------------------------------------------------- +// Stripe — Phase 4 +// --------------------------------------------------------------------------- + +/** + * Attempt to record receipt of a Stripe webhook event. Returns true + * when this is the first time we've seen the event (caller should + * process it), false when the event_id was already present + * (caller should ack with 200 and skip — Stripe retries are + * normal and we must be idempotent). + * + * The whole-payload JSONB is stored so a misbehaving event can be + * diagnosed after the fact without re-fetching from Stripe. + */ +export async function tryRecordStripeEvent( + eventId: string, + eventType: string, + payload: unknown +): Promise { + await ensureSchema(); + try { + await getPool().query( + `INSERT INTO stripe_events (event_id, event_type, payload) + VALUES ($1, $2, $3::jsonb)`, + [eventId, eventType, JSON.stringify(payload)] + ); + return true; + } catch (e: any) { + // 23505 = unique_violation; the row already exists, meaning we've + // seen this event before. That's the normal duplicate-delivery + // case — return false so the caller short-circuits. + if (e?.code === "23505") return false; + throw e; + } +} + +/** + * Stamp processed_at on a stripe_events row once the handler has + * finished its work successfully. Lets us spot stuck events + * (received but not processed) for diagnosis. + */ +export async function markStripeEventProcessed(eventId: string): Promise { + await ensureSchema(); + await getPool().query( + "UPDATE stripe_events SET processed_at = now() WHERE event_id = $1", + [eventId] + ); +} + +/** + * Persist the Stripe PaymentIntent id on an invoice. Used by the + * webhook handler once the Checkout Session completes — at that + * point Stripe has minted the PaymentIntent and we want to be + * able to find the Stripe-side record from the invoice (and vice + * versa via metadata). + * + * Idempotent: re-running with the same value is a no-op. The + * column was added in Phase 2 schema; this helper was missing. + */ +export async function setInvoiceStripePaymentIntent( + invoiceId: string, + paymentIntentId: string +): Promise { + await ensureSchema(); + await getPool().query( + `UPDATE invoices + SET stripe_payment_intent_id = $2 + WHERE id = $1 + AND (stripe_payment_intent_id IS NULL OR stripe_payment_intent_id = $2)`, + [invoiceId, paymentIntentId] + ); +} diff --git a/src/lib/stripe.ts b/src/lib/stripe.ts new file mode 100644 index 0000000..c2f27a5 --- /dev/null +++ b/src/lib/stripe.ts @@ -0,0 +1,245 @@ +/** + * 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. Bump deliberately, not via dependency update. +// Keep this matched to the stripe-node major version in package.json. +const STRIPE_API_VERSION = "2026-03-25.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. + const stripeLocale: Stripe.Checkout.SessionCreateParams.Locale = + invoice.locale === "de" + ? "de" + : invoice.locale === "fr" + ? "fr" + : invoice.locale === "it" + ? "it" + : invoice.locale === "en" + ? "en" + : "auto"; + + 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 }; +} diff --git a/src/messages/de.json b/src/messages/de.json index 9e04583..2ee632a 100644 --- a/src/messages/de.json +++ b/src/messages/de.json @@ -737,6 +737,10 @@ "overdue": "Überfällig", "void": "Storniert", "uncollectible": "Uneinbringlich" - } + }, + "payWithCard": "Mit Karte bezahlen", + "redirectingToStripe": "Weiterleitung…", + "paymentReceived": "Zahlung erhalten — vielen Dank. Der Status wird aktualisiert, sobald Stripe bestätigt (wenige Sekunden).", + "paymentCancelled": "Zahlung wurde abgebrochen. Die Rechnung ist weiterhin offen; Sie können es jederzeit erneut versuchen." } } diff --git a/src/messages/en.json b/src/messages/en.json index b73c391..874c285 100644 --- a/src/messages/en.json +++ b/src/messages/en.json @@ -737,6 +737,10 @@ "overdue": "Overdue", "void": "Void", "uncollectible": "Uncollectible" - } + }, + "payWithCard": "Pay with card", + "redirectingToStripe": "Redirecting…", + "paymentReceived": "Payment received — thank you. The status will update once Stripe confirms (a few seconds).", + "paymentCancelled": "Payment was cancelled. The invoice is still open; you can try again whenever." } } diff --git a/src/messages/fr.json b/src/messages/fr.json index 468f7cf..df74522 100644 --- a/src/messages/fr.json +++ b/src/messages/fr.json @@ -737,6 +737,10 @@ "overdue": "En retard", "void": "Annulée", "uncollectible": "Irrécouvrable" - } + }, + "payWithCard": "Payer par carte", + "redirectingToStripe": "Redirection…", + "paymentReceived": "Paiement reçu — merci. Le statut sera mis à jour dès la confirmation de Stripe (quelques secondes).", + "paymentCancelled": "Le paiement a été annulé. La facture reste ouverte ; vous pouvez réessayer à tout moment." } } diff --git a/src/messages/it.json b/src/messages/it.json index ef5b1a9..5169af2 100644 --- a/src/messages/it.json +++ b/src/messages/it.json @@ -737,6 +737,10 @@ "overdue": "In ritardo", "void": "Annullata", "uncollectible": "Inesigibile" - } + }, + "payWithCard": "Paga con carta", + "redirectingToStripe": "Reindirizzamento…", + "paymentReceived": "Pagamento ricevuto — grazie. Lo stato si aggiornerà alla conferma di Stripe (pochi secondi).", + "paymentCancelled": "Il pagamento è stato annullato. La fattura rimane aperta; puoi riprovare in qualsiasi momento." } }