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]
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user