Phase5: Automate bill creation
All checks were successful
Build and Push / build (push) Successful in 1m43s

This commit is contained in:
2026-05-25 10:41:51 +02:00
parent 6a8ad7b4be
commit 427c7c6204
16 changed files with 1343 additions and 4 deletions

View File

@@ -598,6 +598,26 @@ const MIGRATION_SQL = `
);
CREATE INDEX IF NOT EXISTS idx_stripe_events_type_received
ON stripe_events (event_type, received_at DESC);
-- Phase 5: Cron run history. One row per invocation of either the
-- monthly issuance sweep or the daily reminder sweep, regardless of
-- whether it ran from K8s CronJob or an admin's manual trigger.
-- The summary counters let the admin UI render "last run: 12 issued,
-- 0 failed" without joining against invoices/reminders. Detail rows
-- live in the JSONB error_details on failure for diagnosis.
CREATE TABLE IF NOT EXISTS cron_run_history (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
run_kind TEXT NOT NULL CHECK (run_kind IN ('monthly_issue','reminders')),
triggered_by TEXT NOT NULL, -- 'cron' or '<admin-user-id>'
started_at TIMESTAMPTZ NOT NULL DEFAULT now(),
finished_at TIMESTAMPTZ,
success_count INT NOT NULL DEFAULT 0,
failure_count INT NOT NULL DEFAULT 0,
skipped_count INT NOT NULL DEFAULT 0,
error_details JSONB
);
CREATE INDEX IF NOT EXISTS idx_cron_run_history_kind_started
ON cron_run_history (run_kind, started_at DESC);
`;
let migrated = false;
@@ -2961,3 +2981,197 @@ export async function setInvoiceStripePaymentIntent(
[invoiceId, paymentIntentId]
);
}
// ---------------------------------------------------------------------------
// Phase 5 — Cron run history + reminder helpers
// ---------------------------------------------------------------------------
import type { CronRun, CronRunKind } from "@/types";
function rowToCronRun(row: any): CronRun {
return {
id: row.id,
runKind: row.run_kind,
triggeredBy: row.triggered_by,
startedAt:
row.started_at?.toISOString?.() ?? String(row.started_at),
finishedAt: row.finished_at
? row.finished_at.toISOString?.() ?? String(row.finished_at)
: null,
successCount: Number(row.success_count ?? 0),
failureCount: Number(row.failure_count ?? 0),
skippedCount: Number(row.skipped_count ?? 0),
errorDetails: row.error_details ?? null,
};
}
/**
* Open a new cron-run row in 'started' state. Returns the row's
* id which the caller passes to finishCronRun() with the summary
* stats once the sweep completes.
*
* Separating start/finish lets the admin UI distinguish an in-
* progress run from a finished one, and lets a crashed pod leave
* a forensic trace ("started but never finished — investigate").
*/
export async function startCronRun(
runKind: CronRunKind,
triggeredBy: string
): Promise<string> {
await ensureSchema();
const result = await getPool().query(
`INSERT INTO cron_run_history (run_kind, triggered_by)
VALUES ($1, $2)
RETURNING id`,
[runKind, triggeredBy]
);
return result.rows[0].id;
}
export async function finishCronRun(
id: string,
summary: {
successCount: number;
failureCount: number;
skippedCount: number;
errorDetails?: unknown;
}
): Promise<void> {
await ensureSchema();
await getPool().query(
`UPDATE cron_run_history
SET finished_at = now(),
success_count = $2,
failure_count = $3,
skipped_count = $4,
error_details = $5::jsonb
WHERE id = $1`,
[
id,
summary.successCount,
summary.failureCount,
summary.skippedCount,
summary.errorDetails ? JSON.stringify(summary.errorDetails) : null,
]
);
}
export async function listRecentCronRuns(
limit = 30
): Promise<CronRun[]> {
await ensureSchema();
const result = await getPool().query(
`SELECT * FROM cron_run_history
ORDER BY started_at DESC
LIMIT $1`,
[limit]
);
return result.rows.map(rowToCronRun);
}
/**
* Most recent successful run of each kind. Drives the admin
* dashboard's "last issuance: N days ago" indicator. Returns
* null for a kind that has never run successfully.
*/
export async function getLastSuccessfulCronRuns(): Promise<{
monthlyIssue: CronRun | null;
reminders: CronRun | null;
}> {
await ensureSchema();
const result = await getPool().query(
`SELECT DISTINCT ON (run_kind) *
FROM cron_run_history
WHERE finished_at IS NOT NULL AND failure_count = 0
ORDER BY run_kind, started_at DESC`
);
const map: Record<string, CronRun> = {};
for (const row of result.rows) {
map[row.run_kind] = rowToCronRun(row);
}
return {
monthlyIssue: map["monthly_issue"] ?? null,
reminders: map["reminders"] ?? null,
};
}
/**
* IDs of all orgs with auto-issue enabled. Drives the monthly
* issuance sweep. Returns just the zitadel_org_id strings — the
* caller fetches OrgBilling per-org during the sweep so a bad
* row doesn't poison the whole list at SELECT time.
*/
export async function listAutoIssueOrgIds(): Promise<string[]> {
await ensureSchema();
const result = await getPool().query(
`SELECT zitadel_org_id FROM org_billing_config
WHERE auto_invoice_enabled = TRUE`
);
return result.rows.map((r) => r.zitadel_org_id as string);
}
/**
* Open or overdue invoices whose org has auto-reminders enabled
* and whose due_at is at least 7 days in the past. The reminder
* sweep takes this list and picks the right level (1/2/3) per
* invoice based on days-past-due AND which levels have already
* been sent.
*
* We don't filter by "needs reminder X yet" in SQL because the
* level logic is more readable in TypeScript and the candidate
* set is small (only past-due invoices for opted-in orgs).
*/
export async function listInvoicesPendingReminders(): Promise<Invoice[]> {
await ensureSchema();
const result = await getPool().query(
`SELECT ${INVOICE_LIST_COLUMNS}
FROM invoices i
JOIN org_billing_config c
ON c.zitadel_org_id = i.zitadel_org_id
AND c.auto_reminders_enabled = TRUE
WHERE i.status IN ('open','overdue')
AND i.due_at < now() - INTERVAL '7 days'
ORDER BY i.due_at ASC`
);
return result.rows.map(rowToInvoice);
}
/**
* Which reminder levels have already been sent for this invoice?
* Returns a Set of {1, 2, 3} subset. Drives the "send the next
* level only" logic in the reminder sweep.
*/
export async function getReminderLevelsSent(
invoiceId: string
): Promise<Set<number>> {
await ensureSchema();
const result = await getPool().query(
`SELECT level FROM invoice_reminders WHERE invoice_id = $1`,
[invoiceId]
);
return new Set(result.rows.map((r) => Number(r.level)));
}
/**
* Mark a reminder as sent. Wrapped in an INSERT ... ON CONFLICT
* DO NOTHING so a retry of the same level after a partial failure
* is a no-op rather than a 23505 explosion. Returns true if a row
* was inserted (first send), false on conflict (already sent).
*/
export async function recordReminderSent(params: {
invoiceId: string;
level: 1 | 2 | 3;
sentBy: string;
emailSentTo: string;
}): Promise<boolean> {
await ensureSchema();
const result = await getPool().query(
`INSERT INTO invoice_reminders
(invoice_id, level, sent_by, email_sent_to)
VALUES ($1, $2, $3, $4)
ON CONFLICT (invoice_id, level) DO NOTHING
RETURNING id`,
[params.invoiceId, params.level, params.sentBy, params.emailSentTo]
);
return result.rowCount === 1;
}