This commit is contained in:
138
src/lib/db.ts
138
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 <table>_<column>_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<Array<{
|
||||
id: string;
|
||||
invoice_id: string;
|
||||
tenant_name: string;
|
||||
kind: string;
|
||||
amount_chf: number;
|
||||
description: string;
|
||||
}>> {
|
||||
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<boolean> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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]
|
||||
);
|
||||
}
|
||||
|
||||
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