Phase4: Stripe
Some checks failed
Build and Push / build (push) Failing after 38s

This commit is contained in:
2026-05-24 23:37:48 +02:00
parent 4a5ae0bb8b
commit a680d6de9f
13 changed files with 908 additions and 13 deletions

View File

@@ -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]
);
}