This commit is contained in:
245
src/lib/stripe.ts
Normal file
245
src/lib/stripe.ts
Normal file
@@ -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/<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. 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<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.
|
||||
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 };
|
||||
}
|
||||
Reference in New Issue
Block a user