Phase5: Automate bill creation
All checks were successful
Build and Push / build (push) Successful in 1m43s
All checks were successful
Build and Push / build (push) Successful in 1m43s
This commit is contained in:
214
src/lib/db.ts
214
src/lib/db.ts
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user