Files
pieced-portal/src/lib/stripe.ts
admin 8e7691d38a
Some checks failed
Build and Push / build (push) Failing after 43s
Phase8: Auto bill credit card
2026-05-27 20:41:17 +02:00

446 lines
16 KiB
TypeScript

/**
* Server-side Stripe client + helpers for Phase 4 (card payments).
*
* Architecture (see Phase 4 notes):
* 1. Customer clicks "Pay with card" on /billing/<number>.
* 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<string> {
// 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<string, string>;
}): Promise<{
id: string;
amountChf: number;
status: string;
}> {
const stripe = getStripeClient();
const refundParams: Parameters<typeof stripe.refunds.create>[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<void> {
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,
};
}