315 lines
12 KiB
TypeScript
315 lines
12 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}`,
|
|
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<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",
|
|
};
|
|
}
|