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

@@ -0,0 +1,44 @@
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { getSessionUser } from "@/lib/session";
import {
getLastSuccessfulCronRuns,
listRecentCronRuns,
} from "@/lib/db";
import { CronControls } from "@/components/admin/cron/cron-controls";
/**
* /admin/cron — automation dashboard.
*
* Shows:
* - Last successful run of each kind, with relative time
* - Two "Run now" buttons (admin-triggered manual sweeps)
* - Recent runs table (last 30)
*
* Platform-admin gated server-side.
*/
export default async function AdminCronPage() {
const user = await getSessionUser();
if (!user || !user.isPlatform) redirect("/login");
const t = await getTranslations("adminCron");
const [recent, lastSuccess] = await Promise.all([
listRecentCronRuns(30),
getLastSuccessfulCronRuns(),
]);
return (
<main className="max-w-5xl mx-auto px-6 py-8">
<div className="mb-8 animate-in">
<h1 className="font-display text-2xl font-semibold accent-rule">
{t("title")}
</h1>
<p className="text-sm text-text-secondary mt-3">{t("subtitle")}</p>
</div>
<CronControls
initialRecent={recent}
initialLastSuccess={lastSuccess}
/>
</main>
);
}

View File

@@ -61,6 +61,12 @@ export default async function AdminPage() {
>
{t("billingTool")}
</a>
<a
href="/admin/cron"
className="text-sm px-4 py-2 rounded-lg border border-border text-text-secondary hover:text-text-primary hover:border-text-secondary transition-colors"
>
{t("cronTool")}
</a>
<a
href="/admin/openclaw"
className="text-sm px-4 py-2 rounded-lg border border-border text-text-secondary hover:text-text-primary hover:border-text-secondary transition-colors"

View File

@@ -0,0 +1,68 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { getSessionUser, requirePlatformRole } from "@/lib/session";
import { runMonthlyIssuance } from "@/lib/cron";
import { safeError } from "@/lib/errors";
/**
* POST /api/admin/cron/issue-monthly
*
* Admin-side manual trigger for the issuance sweep — same business
* logic as /api/cron/issue-monthly, different auth (session-based
* platform role check) and the option to override the target
* year/month from the request body.
*
* Body (all optional):
* { year?: number, month?: number }
*
* Default target is the previous local month — matching what the
* automated cron would do. Override is useful for catching up after
* a failed run or re-billing a past month after fixing data.
*/
const bodySchema = z.object({
year: z.number().int().min(2000).max(3000).optional(),
month: z.number().int().min(1).max(12).optional(),
});
export async function POST(request: Request) {
let user;
try {
await requirePlatformRole();
user = await getSessionUser();
} catch {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const body = await request.json().catch(() => ({}));
const parsed = bodySchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: "Invalid request", details: parsed.error.flatten() },
{ status: 400 }
);
}
if (
(parsed.data.year && !parsed.data.month) ||
(parsed.data.month && !parsed.data.year)
) {
return NextResponse.json(
{ error: "year and month must both be provided, or neither" },
{ status: 400 }
);
}
try {
const { runId, summary } = await runMonthlyIssuance({
triggeredBy: user.id,
year: parsed.data.year,
month: parsed.data.month,
});
return NextResponse.json({ runId, ...summary });
} catch (e) {
return NextResponse.json(
{ error: safeError(e, "Issuance sweep failed.") },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,27 @@
import { NextResponse } from "next/server";
import { requirePlatformRole } from "@/lib/session";
import {
getLastSuccessfulCronRuns,
listRecentCronRuns,
} from "@/lib/db";
/**
* GET /api/admin/cron/runs
*
* Returns recent cron run history plus per-kind "last successful"
* summary for the admin /admin/cron dashboard.
*
* Response: { recent: CronRun[]; lastSuccess: { monthlyIssue, reminders } }
*/
export async function GET() {
try {
await requirePlatformRole();
} catch {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const [recent, lastSuccess] = await Promise.all([
listRecentCronRuns(30),
getLastSuccessfulCronRuns(),
]);
return NextResponse.json({ recent, lastSuccess });
}

View File

@@ -0,0 +1,34 @@
import { NextResponse } from "next/server";
import { getSessionUser, requirePlatformRole } from "@/lib/session";
import { runReminderSweep } from "@/lib/cron";
import { safeError } from "@/lib/errors";
/**
* POST /api/admin/cron/send-reminders
*
* Admin-side manual trigger for the reminder sweep. Same logic
* as the machine path; session-based platform-role auth.
*/
export async function POST() {
let user;
try {
await requirePlatformRole();
user = await getSessionUser();
} catch {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
try {
const { runId, summary } = await runReminderSweep({
triggeredBy: user.id,
});
return NextResponse.json({ runId, ...summary });
} catch (e) {
return NextResponse.json(
{ error: safeError(e, "Reminder sweep failed.") },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,42 @@
import { NextResponse } from "next/server";
import { runMonthlyIssuance, verifyCronBearer } from "@/lib/cron";
import { safeError } from "@/lib/errors";
/**
* POST /api/cron/issue-monthly
*
* Machine entry point for the monthly issuance sweep. Authentication
* is the shared bearer token in CRON_BEARER_TOKEN, injected from
* OpenBao via the portal-cron K8s Secret. The K8s CronJob sends:
*
* curl -X POST -H "Authorization: Bearer $CRON_BEARER_TOKEN" \
* https://app.pieced.ch/api/cron/issue-monthly
*
* The sweep targets the calendar month that ended just before
* "now" in Europe/Zurich. Running it on June 1st at 00:30 Swiss
* time bills May; running it on July 5th bills June; etc. The
* uniqueness constraint on (org, period_start) makes re-runs
* harmless — already-issued orgs are counted as skipped.
*
* Returns the summary {success, failure, skipped} JSON. The
* CronJob doesn't look at the response body (just the status
* code) but having a useful one helps debugging via curl.
*/
export const dynamic = "force-dynamic";
export async function POST(request: Request) {
if (!verifyCronBearer(request.headers.get("authorization"))) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
try {
const { runId, summary } = await runMonthlyIssuance({
triggeredBy: "cron",
});
return NextResponse.json({ runId, ...summary });
} catch (e) {
return NextResponse.json(
{ error: safeError(e, "Issuance sweep failed.") },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,33 @@
import { NextResponse } from "next/server";
import { runReminderSweep, verifyCronBearer } from "@/lib/cron";
import { safeError } from "@/lib/errors";
/**
* POST /api/cron/send-reminders
*
* Machine entry point for the daily reminder sweep. Same auth
* (bearer token in CRON_BEARER_TOKEN) and the same response
* contract as /api/cron/issue-monthly.
*
* Schedule: 09:00 Europe/Zurich daily. Picks invoices that are
* past their due date and haven't received the corresponding
* reminder level yet; sends one email per invoice per run.
*/
export const dynamic = "force-dynamic";
export async function POST(request: Request) {
if (!verifyCronBearer(request.headers.get("authorization"))) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
try {
const { runId, summary } = await runReminderSweep({
triggeredBy: "cron",
});
return NextResponse.json({ runId, ...summary });
} catch (e) {
return NextResponse.json(
{ error: safeError(e, "Reminder sweep failed.") },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,229 @@
"use client";
import { useState } from "react";
import { useTranslations, useFormatter } from "next-intl";
import { Card } from "@/components/ui/card";
import type { CronRun } from "@/types";
interface Props {
initialRecent: CronRun[];
initialLastSuccess: {
monthlyIssue: CronRun | null;
reminders: CronRun | null;
};
}
/**
* Admin cron dashboard. Server pre-loads `initialRecent` and
* `initialLastSuccess`; "Run now" clicks POST to the admin
* endpoints, then re-fetch the history via GET /api/admin/cron/runs.
*
* The trigger buttons disable while busy and surface the resulting
* counters inline so the admin gets immediate feedback without
* needing to scroll to the history table.
*/
export function CronControls({ initialRecent, initialLastSuccess }: Props) {
const t = useTranslations("adminCron");
const fmt = useFormatter();
const [recent, setRecent] = useState(initialRecent);
const [lastSuccess, setLastSuccess] = useState(initialLastSuccess);
const [busy, setBusy] = useState<null | "issue" | "reminders">(null);
const [flash, setFlash] = useState<null | {
kind: "issue" | "reminders";
ok: boolean;
summary: string;
}>(null);
const refresh = async () => {
try {
const res = await fetch("/api/admin/cron/runs");
if (!res.ok) return;
const data = await res.json();
setRecent(data.recent);
setLastSuccess(data.lastSuccess);
} catch {
// swallow — refresh is opportunistic
}
};
const triggerIssue = async () => {
setBusy("issue");
setFlash(null);
try {
const res = await fetch("/api/admin/cron/issue-monthly", {
method: "POST",
});
const j = await res.json();
if (!res.ok) {
setFlash({
kind: "issue",
ok: false,
summary: j.error ?? `HTTP ${res.status}`,
});
} else {
setFlash({
kind: "issue",
ok: true,
summary: t("flashIssueOk", {
success: j.successCount,
skipped: j.skippedCount,
failure: j.failureCount,
}),
});
}
await refresh();
} finally {
setBusy(null);
}
};
const triggerReminders = async () => {
setBusy("reminders");
setFlash(null);
try {
const res = await fetch("/api/admin/cron/send-reminders", {
method: "POST",
});
const j = await res.json();
if (!res.ok) {
setFlash({
kind: "reminders",
ok: false,
summary: j.error ?? `HTTP ${res.status}`,
});
} else {
setFlash({
kind: "reminders",
ok: true,
summary: t("flashRemindersOk", {
success: j.successCount,
skipped: j.skippedCount,
failure: j.failureCount,
}),
});
}
await refresh();
} finally {
setBusy(null);
}
};
const fmtRelative = (iso: string | null) => {
if (!iso) return t("never");
return fmt.dateTime(new Date(iso), {
dateStyle: "medium",
timeStyle: "short",
});
};
return (
<div className="space-y-8">
<section className="grid gap-4 md:grid-cols-2">
<Card>
<h2 className="text-xs uppercase tracking-wider text-text-muted mb-2">
{t("monthlyIssue")}
</h2>
<p className="text-xs text-text-secondary mb-1">
{t("scheduleIssueLabel")}: <span className="font-mono">{t("scheduleIssueValue")}</span>
</p>
<p className="text-xs text-text-secondary mb-3">
{t("lastSuccess")}: <span className="font-mono">{fmtRelative(lastSuccess.monthlyIssue?.startedAt ?? null)}</span>
</p>
<button
onClick={triggerIssue}
disabled={busy !== null}
className="px-4 py-2 rounded-md bg-accent text-white text-sm font-medium hover:bg-accent-dim transition-colors disabled:opacity-50 cursor-pointer"
>
{busy === "issue" ? t("running") : t("runIssueNow")}
</button>
</Card>
<Card>
<h2 className="text-xs uppercase tracking-wider text-text-muted mb-2">
{t("reminders")}
</h2>
<p className="text-xs text-text-secondary mb-1">
{t("scheduleReminderLabel")}: <span className="font-mono">{t("scheduleReminderValue")}</span>
</p>
<p className="text-xs text-text-secondary mb-3">
{t("lastSuccess")}: <span className="font-mono">{fmtRelative(lastSuccess.reminders?.startedAt ?? null)}</span>
</p>
<button
onClick={triggerReminders}
disabled={busy !== null}
className="px-4 py-2 rounded-md bg-accent text-white text-sm font-medium hover:bg-accent-dim transition-colors disabled:opacity-50 cursor-pointer"
>
{busy === "reminders" ? t("running") : t("runRemindersNow")}
</button>
</Card>
</section>
{flash && (
<div
className={`p-3 rounded-md border text-sm ${
flash.ok
? "border-success bg-success/10 text-success"
: "border-error bg-error/10 text-error"
}`}
>
{flash.summary}
</div>
)}
<section>
<h2 className="text-xs uppercase tracking-wider text-text-muted mb-3">
{t("recentRuns")}
</h2>
<Card>
{recent.length === 0 ? (
<p className="text-sm text-text-muted italic py-4">
{t("noRunsYet")}
</p>
) : (
<table className="w-full text-sm">
<thead className="text-xs text-text-muted text-left">
<tr>
<th className="pb-2">{t("startedCol")}</th>
<th className="pb-2">{t("kindCol")}</th>
<th className="pb-2">{t("triggeredByCol")}</th>
<th className="pb-2 text-right">{t("okCol")}</th>
<th className="pb-2 text-right">{t("skipCol")}</th>
<th className="pb-2 text-right">{t("failCol")}</th>
</tr>
</thead>
<tbody>
{recent.map((r) => (
<tr key={r.id} className="border-t border-border align-top">
<td className="py-2 text-xs font-mono">
{fmtRelative(r.startedAt)}
</td>
<td className="py-2 text-xs">
{t(`kind.${r.runKind}` as any)}
</td>
<td className="py-2 text-xs text-text-secondary font-mono">
{r.triggeredBy === "cron"
? t("triggeredByCron")
: r.triggeredBy.slice(0, 8) + "…"}
</td>
<td className="py-2 text-right font-mono text-xs text-success">
{r.successCount}
</td>
<td className="py-2 text-right font-mono text-xs text-text-secondary">
{r.skippedCount}
</td>
<td
className={`py-2 text-right font-mono text-xs ${
r.failureCount > 0 ? "text-error" : "text-text-muted"
}`}
>
{r.failureCount}
</td>
</tr>
))}
</tbody>
</table>
)}
</Card>
</section>
</div>
);
}

360
src/lib/cron.ts Normal file
View File

@@ -0,0 +1,360 @@
/**
* Phase 5 — Automated billing cron logic.
*
* This module hosts the two sweeps:
* - runMonthlyIssuance() — invoked monthly to generate invoices
* for orgs opted into auto-issuance. Idempotent via the
* uniq_invoices_org_period constraint on invoices: a re-run
* for an org that's already been billed for the target period
* gets caught as a duplicate and counted as a skip, not a
* failure.
* - runReminderSweep() — invoked daily. Walks open/overdue
* invoices, sends the appropriate reminder level (1/2/3) once
* per invoice via the invoice_reminders unique-key constraint.
*
* Both entry points return a summary {success, failure, skipped}
* that the caller persists via finishCronRun(). The shared
* structure means the HTTP routes (machine + admin variants) are
* trivial wrappers.
*
* Time-of-month math is timezone-aware: we read the calendar in
* Europe/Zurich rather than UTC, because the K8s CronJob schedules
* at 00:30 local time on the 1st — UTC at that moment is still in
* the previous month, and a naive `getUTCMonth() - 1` would bill
* the wrong period.
*/
import {
finishCronRun,
getLastSuccessfulCronRuns,
getOrgBilling,
getReminderLevelsSent,
listAutoIssueOrgIds,
listInvoicesPendingReminders,
recordReminderSent,
startCronRun,
syncOverdueInvoices,
} from "./db";
import { generateInvoice } from "./billing";
import { sendInvoiceReminderEmail } from "./email";
// The org_billing snapshot's company_name field doubles as the
// recipient name when no separate "billing contact" exists in
// our schema. Same convention as Phase 3's issuance email.
// All cron timing assumes Switzerland's calendar — the operator,
// the customers, and the legal basis (Swiss MWST) are all here.
const TZ = "Europe/Zurich";
export type CronSummary = {
successCount: number;
failureCount: number;
skippedCount: number;
errorDetails: Array<{
orgId?: string;
invoiceId?: string;
reason: string;
}>;
};
// ---------------------------------------------------------------------------
// Monthly issuance
// ---------------------------------------------------------------------------
/**
* The (year, month) of the calendar month that ended JUST BEFORE
* `now` in the configured timezone. This is what the issuance
* sweep bills.
*
* Reading the local-time calendar avoids a UTC-vs-local off-by-one
* when the sweep runs at 00:30 Zurich and UTC is still in the
* previous month.
*/
export function previousLocalMonth(
now: Date = new Date()
): { year: number; month: number } {
const fmt = new Intl.DateTimeFormat("en-CA", {
timeZone: TZ,
year: "numeric",
month: "2-digit",
});
const parts = fmt.formatToParts(now);
const year = Number(parts.find((p) => p.type === "year")!.value);
const month = Number(parts.find((p) => p.type === "month")!.value);
if (month === 1) return { year: year - 1, month: 12 };
return { year, month: month - 1 };
}
export async function runMonthlyIssuance(opts: {
triggeredBy: string;
/** Override target year/month — defaults to previous local month. */
year?: number;
month?: number;
}): Promise<{ runId: string; summary: CronSummary }> {
const target =
opts.year && opts.month
? { year: opts.year, month: opts.month }
: previousLocalMonth();
const runId = await startCronRun("monthly_issue", opts.triggeredBy);
const summary: CronSummary = {
successCount: 0,
failureCount: 0,
skippedCount: 0,
errorDetails: [],
};
try {
const orgIds = await listAutoIssueOrgIds();
for (const orgId of orgIds) {
try {
const orgBilling = await getOrgBilling(orgId);
if (!orgBilling) {
// Auto-issue is enabled but billing details are missing.
// Skip rather than fail — the admin needs to complete the
// address before invoicing can succeed.
summary.skippedCount += 1;
summary.errorDetails.push({
orgId,
reason: "org_billing not configured",
});
continue;
}
// Derive invoice locale from the org's country. PieCed is
// Swiss-default; CH/LI/AT/DE customers get the German PDF,
// FR/BE/LU customers get French, IT customers get Italian,
// anything else falls through to English. Customers needing
// a different locale can still trigger a manual issuance
// with an explicit override from the admin UI.
const locale = pickLocaleForCountry(orgBilling.country);
const { invoice } = await generateInvoice({
zitadelOrgId: orgId,
year: target.year,
month: target.month,
locale,
});
if (invoice) {
summary.successCount += 1;
} else {
// dryRun path — shouldn't happen in production. Defensive.
summary.skippedCount += 1;
}
} catch (e: any) {
// The uniqueness constraint on (zitadel_org_id, period_start)
// surfaces as "An invoice already exists for this org and
// billing period" from createInvoice. Re-running the cron
// mid-month or after a partial completion is therefore safe:
// already-billed orgs end up as skipped, not failed.
const msg = String(e?.message ?? e);
const isAlreadyIssued = /already exists for this org and billing period/i.test(
msg
);
if (isAlreadyIssued) {
summary.skippedCount += 1;
} else {
summary.failureCount += 1;
summary.errorDetails.push({ orgId, reason: msg });
console.error(
`runMonthlyIssuance: org ${orgId} failed:`,
e
);
}
}
}
await finishCronRun(runId, summary);
return { runId, summary };
} catch (e) {
// Catastrophic — the sweep itself failed (DB down, etc).
summary.failureCount += 1;
summary.errorDetails.push({
reason: `sweep aborted: ${e instanceof Error ? e.message : String(e)}`,
});
await finishCronRun(runId, summary).catch(() => undefined);
throw e;
}
}
// ---------------------------------------------------------------------------
// Reminder sweep
// ---------------------------------------------------------------------------
/**
* Which reminder level (if any) is due now for this invoice?
*
* Logic:
* - days_past_due >= 30 AND level 3 not yet sent → 3 (final)
* - else days_past_due >= 14 AND level 2 not yet sent → 2
* - else days_past_due >= 7 AND level 1 not yet sent → 1
* - else → null (nothing to do this run)
*
* One reminder per cron run per invoice — highest applicable
* un-sent level wins. If a customer fell behind quickly and is
* already 35 days past due without ever having received levels
* 1 or 2 (e.g. the cron was broken for a while), they get level
* 3 directly. We don't backfill lower levels.
*/
function nextReminderLevel(
daysPastDue: number,
sent: Set<number>
): 1 | 2 | 3 | null {
if (daysPastDue >= 30 && !sent.has(3)) return 3;
if (daysPastDue >= 14 && !sent.has(2)) return 2;
if (daysPastDue >= 7 && !sent.has(1)) return 1;
return null;
}
function daysBetween(later: Date, earlier: Date): number {
const ms = later.getTime() - earlier.getTime();
return Math.floor(ms / (1000 * 60 * 60 * 24));
}
/**
* Pick a default invoice locale based on the org's country
* (ISO 3166-1 alpha-2 code from org_billing.country). PieCed is
* primarily a Swiss-German operator; CH/LI/AT/DE get German,
* FR/BE/LU get French, IT gets Italian, anything else falls
* through to English.
*
* This only drives the automated issuance default. Manual
* issuance from the admin UI takes an explicit override.
*/
function pickLocaleForCountry(country: string): "de" | "en" | "fr" | "it" {
const c = country.toUpperCase();
if (["CH", "LI", "AT", "DE"].includes(c)) return "de";
if (["FR", "BE", "LU"].includes(c)) return "fr";
if (c === "IT") return "it";
return "en";
}
export async function runReminderSweep(opts: {
triggeredBy: string;
}): Promise<{ runId: string; summary: CronSummary }> {
const runId = await startCronRun("reminders", opts.triggeredBy);
const summary: CronSummary = {
successCount: 0,
failureCount: 0,
skippedCount: 0,
errorDetails: [],
};
try {
// Flip stale 'open' → 'overdue' first so the listing reflects
// current status, and audit trails stay accurate.
await syncOverdueInvoices().catch((e) => {
console.warn("syncOverdueInvoices failed during reminder sweep:", e);
});
const candidates = await listInvoicesPendingReminders();
const now = new Date();
for (const inv of candidates) {
try {
const sent = await getReminderLevelsSent(inv.id);
const dueAt = new Date(inv.dueAt);
const days = daysBetween(now, dueAt);
const level = nextReminderLevel(days, sent);
if (level === null) {
summary.skippedCount += 1;
continue;
}
const billing = inv.billingSnapshot;
if (!billing.billingEmail) {
summary.skippedCount += 1;
summary.errorDetails.push({
invoiceId: inv.id,
reason: "no billing email on snapshot",
});
continue;
}
const supportedLocales: Array<"de" | "en" | "fr" | "it"> = [
"de", "en", "fr", "it",
];
const locale = supportedLocales.includes(inv.locale as any)
? (inv.locale as "de" | "en" | "fr" | "it")
: "de";
await sendInvoiceReminderEmail({
to: billing.billingEmail,
contactName: billing.companyName,
companyName: billing.companyName,
invoiceNumber: inv.invoiceNumber,
totalChf: inv.totalChf,
currency: "CHF",
dueAt: inv.dueAt,
daysPastDue: days,
level,
locale,
});
// Record AFTER the send. If the SMTP send fails the email
// helper logs and doesn't throw, so we'd still record — but
// that's a tradeoff we accept: at-least-once delivery semantics
// with logged warnings is better than at-most-once where a
// transient failure stops the customer from ever getting
// reminded. If duplicate-reminder fatigue becomes a real
// problem in production, switch to: send first, only record
// on confirmed transporter success.
await recordReminderSent({
invoiceId: inv.id,
level,
sentBy: opts.triggeredBy,
emailSentTo: billing.billingEmail,
});
summary.successCount += 1;
} catch (e: any) {
summary.failureCount += 1;
summary.errorDetails.push({
invoiceId: inv.id,
reason: String(e?.message ?? e),
});
console.error(
`runReminderSweep: invoice ${inv.id} failed:`,
e
);
}
}
await finishCronRun(runId, summary);
return { runId, summary };
} catch (e) {
summary.failureCount += 1;
summary.errorDetails.push({
reason: `sweep aborted: ${e instanceof Error ? e.message : String(e)}`,
});
await finishCronRun(runId, summary).catch(() => undefined);
throw e;
}
}
// ---------------------------------------------------------------------------
// Auth — bearer token for the machine endpoints
// ---------------------------------------------------------------------------
/**
* Constant-time bearer token check. The CRON_BEARER_TOKEN env var
* is injected from OpenBao via the portal-cron K8s Secret. Both
* the CronJob and the portal Deployment reference it; the
* CronJob sends it in the Authorization header, the portal checks
* with timing-safe equals to defeat character-by-character probing.
*/
export function verifyCronBearer(authHeader: string | null): boolean {
if (!authHeader) return false;
const expected = process.env.CRON_BEARER_TOKEN;
if (!expected || expected.length < 16) {
// Treat misconfiguration as a hard refusal so a missing/
// accidentally-empty token doesn't silently grant access.
return false;
}
if (!authHeader.startsWith("Bearer ")) return false;
const got = authHeader.slice("Bearer ".length).trim();
if (got.length !== expected.length) return false;
// Constant-time byte compare. Node's Buffer.compare and the
// crypto.timingSafeEqual function both work, but the latter
// throws on length mismatch; the length pre-check above
// protects against that.
let diff = 0;
for (let i = 0; i < got.length; i++) {
diff |= got.charCodeAt(i) ^ expected.charCodeAt(i);
}
return diff === 0;
}
// Re-export for the admin UI to render "last run X ago" indicators.
export { getLastSuccessfulCronRuns };

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;
}

View File

@@ -1014,3 +1014,147 @@ export async function sendInvoiceIssuedEmail(params: {
console.error("Failed to send invoice issued email:", err);
}
}
// ---------------------------------------------------------------------------
// Reminder emails — Phase 5
// ---------------------------------------------------------------------------
/**
* Send a payment reminder for an open/overdue invoice.
*
* Three escalation levels:
* 1 — Gentle nudge: ~7 days past due. Friendly tone, "in case
* you missed it".
* 2 — Firmer reminder: ~14 days past due. Clear that payment is
* outstanding, please pay.
* 3 — Final notice: ~30 days past due. Explicit consequences
* (service may be suspended). Last automated touch — beyond
* this, admin involvement is expected.
*
* Failure is logged, never thrown — the cron sweep must continue
* past a single failed send.
*/
export async function sendInvoiceReminderEmail(params: {
to: string;
contactName: string;
companyName: string;
invoiceNumber: string;
totalChf: number;
currency: string;
dueAt: string;
daysPastDue: number;
level: 1 | 2 | 3;
locale: "de" | "en" | "fr" | "it";
}): Promise<void> {
const L = params.locale;
// Per-locale strings keyed by the three escalation levels.
// Kept inline (rather than the next-intl message files) because
// the email layer doesn't import from React's i18n context.
const SUBJECTS: Record<typeof L, Record<1 | 2 | 3, string>> = {
en: {
1: `Friendly reminder: invoice ${params.invoiceNumber} is overdue`,
2: `Second reminder: invoice ${params.invoiceNumber} is still unpaid`,
3: `Final notice: invoice ${params.invoiceNumber} requires immediate payment`,
},
de: {
1: `Freundliche Erinnerung: Rechnung ${params.invoiceNumber} ist überfällig`,
2: `Zweite Mahnung: Rechnung ${params.invoiceNumber} ist weiterhin unbezahlt`,
3: `Letzte Mahnung: Rechnung ${params.invoiceNumber} erfordert sofortige Zahlung`,
},
fr: {
1: `Rappel amical : la facture ${params.invoiceNumber} est en retard`,
2: `Deuxième rappel : la facture ${params.invoiceNumber} reste impayée`,
3: `Dernier avis : la facture ${params.invoiceNumber} doit être réglée sans délai`,
},
it: {
1: `Promemoria amichevole: la fattura ${params.invoiceNumber} è scaduta`,
2: `Secondo sollecito: la fattura ${params.invoiceNumber} è ancora insoluta`,
3: `Avviso finale: la fattura ${params.invoiceNumber} richiede pagamento immediato`,
},
};
const INTROS: Record<typeof L, Record<1 | 2 | 3, string>> = {
en: {
1: "We noticed this invoice hasn't been settled yet — in case it slipped through.",
2: "This invoice remains unpaid. Please arrange payment at your earliest convenience.",
3: "This invoice is significantly overdue. Service may be suspended if payment is not received promptly.",
},
de: {
1: "Diese Rechnung scheint noch nicht beglichen — falls sie übersehen wurde, möchten wir freundlich daran erinnern.",
2: "Diese Rechnung ist weiterhin unbezahlt. Bitte veranlassen Sie die Zahlung umgehend.",
3: "Diese Rechnung ist erheblich überfällig. Bei nicht zeitnaher Zahlung kann der Dienst ausgesetzt werden.",
},
fr: {
1: "Cette facture n'a pas encore été réglée — au cas où elle vous aurait échappé.",
2: "Cette facture reste impayée. Merci d'effectuer le paiement dans les meilleurs délais.",
3: "Cette facture est en grand retard. Le service pourra être suspendu en l'absence de paiement rapide.",
},
it: {
1: "Questa fattura non risulta ancora saldata — nel caso vi fosse sfuggita.",
2: "Questa fattura risulta ancora insoluta. Si prega di provvedere al pagamento al più presto.",
3: "Questa fattura è significativamente in ritardo. In assenza di pagamento tempestivo il servizio potrà essere sospeso.",
},
};
const LABELS: Record<typeof L, Record<string, string>> = {
en: { num: "Invoice", total: "Total", due: "Due date", days: "Days past due", cta: "View invoice & pay", signoff: "Best regards", brand: "PieCed IT", greeting: "Hello" },
de: { num: "Rechnung", total: "Gesamt", due: "Fälligkeitsdatum", days: "Tage überfällig", cta: "Rechnung ansehen & bezahlen", signoff: "Mit freundlichen Grüssen", brand: "PieCed IT", greeting: "Sehr geehrte/r" },
fr: { num: "Facture", total: "Total", due: "Échéance", days: "Jours de retard", cta: "Voir la facture & payer", signoff: "Cordialement", brand: "PieCed IT", greeting: "Bonjour" },
it: { num: "Fattura", total: "Totale", due: "Scadenza", days: "Giorni di ritardo", cta: "Vedi fattura & paga", signoff: "Cordiali saluti", brand: "PieCed IT", greeting: "Gentile" },
};
const l = LABELS[L];
const safeName = escapeHtml(params.contactName);
const safeCompany = escapeHtml(params.companyName);
const safeNumber = escapeHtml(params.invoiceNumber);
const totalFmt = `${params.currency} ${params.totalChf.toFixed(2)}`;
const dueFmt = params.dueAt.slice(0, 10);
const link = `https://app.pieced.ch/billing/${encodeURIComponent(params.invoiceNumber)}`;
// Final-notice gets red accent; earlier levels keep the brand green.
const accent = params.level === 3 ? "#dc2626" : "#10B981";
try {
await getTransporter().sendMail({
from: getFrom(),
to: params.to,
subject: SUBJECTS[L][params.level],
text: [
`${l.greeting} ${params.contactName},`,
"",
INTROS[L][params.level],
"",
`${l.num}: ${params.invoiceNumber}`,
`${l.total}: ${totalFmt}`,
`${l.due}: ${dueFmt}`,
`${l.days}: ${params.daysPastDue}`,
"",
`${l.cta}: ${link}`,
"",
`${l.signoff},`,
l.brand,
].join("\n"),
html: `
<div style="font-family:-apple-system,BlinkMacSystemFont,sans-serif;max-width:560px;padding:24px;background:#1a1a1a;color:#e5e5e5;">
<h2 style="margin:0 0 16px;color:${accent};">${escapeHtml(SUBJECTS[L][params.level])}</h2>
<p>${l.greeting} ${safeName},</p>
<p>${escapeHtml(INTROS[L][params.level])}</p>
<table style="width:100%;border-collapse:collapse;margin:16px 0;font-size:14px;">
<tr><td style="color:#888;padding:6px 0;width:140px;">${l.num}</td><td><strong>${safeNumber}</strong></td></tr>
<tr><td style="color:#888;padding:6px 0;">${l.total}</td><td style="color:${accent};font-weight:600;">${escapeHtml(totalFmt)}</td></tr>
<tr><td style="color:#888;padding:6px 0;">${l.due}</td><td>${escapeHtml(dueFmt)}</td></tr>
<tr><td style="color:#888;padding:6px 0;">${l.days}</td><td>${params.daysPastDue}</td></tr>
</table>
<p>
<a href="${link}" style="display:inline-block;padding:10px 24px;background:${accent};color:#fff;text-decoration:none;border-radius:8px;font-weight:500;">
${l.cta}
</a>
</p>
<hr style="border:none;border-top:1px solid #333;margin:24px 0;" />
<p style="color:#666;font-size:12px;">${l.brand}</p>
</div>
`,
});
} catch (err) {
console.error(
`Failed to send reminder L${params.level} for invoice ${params.invoiceNumber}:`,
err
);
}
}

View File

@@ -393,7 +393,8 @@
"resumeRequestTooltip": "Reaktivierungsanfrage für einen bestehenden Tenant. Bei Genehmigung wird der Tenant wieder aktiviert; keine Provisionierung läuft.",
"openclawTool": "OpenClaw-Versionen",
"billingTool": "Abrechnung →",
"skillsQueueTool": "Aktivierungs-Warteschlange"
"skillsQueueTool": "Aktivierungs-Warteschlange",
"cronTool": "Automatisierung"
},
"channelUsers": {
"title": "Autorisierte Benutzer",
@@ -742,5 +743,35 @@
"redirectingToStripe": "Weiterleitung…",
"paymentReceived": "Zahlung erhalten — vielen Dank!",
"paymentCancelled": "Zahlung abgebrochen."
},
"adminCron": {
"title": "Abrechnungsautomatisierung",
"subtitle": "Monatliche Rechnungsstellung und tägliche Mahnungsläufe. Beides läuft automatisch; mit den Schaltflächen unten können Sie einen Lauf manuell auslösen.",
"monthlyIssue": "Monatliche Rechnungsstellung",
"reminders": "Mahnungen",
"scheduleIssueLabel": "Zeitplan",
"scheduleIssueValue": "00:30 Europe/Zurich am 1.",
"scheduleReminderLabel": "Zeitplan",
"scheduleReminderValue": "09:00 Europe/Zurich täglich",
"lastSuccess": "Letzter Erfolg",
"never": "nie",
"runIssueNow": "Letzten Monat jetzt abrechnen",
"runRemindersNow": "Mahnungslauf jetzt starten",
"running": "Läuft…",
"flashIssueOk": "Rechnungsstellung abgeschlossen: {success} Rechnungen erstellt, {skipped} übersprungen, {failure} fehlgeschlagen.",
"flashRemindersOk": "Mahnungen versendet: {success} erfolgreich, {skipped} übersprungen, {failure} fehlgeschlagen.",
"recentRuns": "Letzte Läufe (max. 30)",
"noRunsYet": "Noch keine Automatisierungsläufe erfasst.",
"startedCol": "Gestartet",
"kindCol": "Art",
"triggeredByCol": "Ausgelöst von",
"okCol": "OK",
"skipCol": "Übersprungen",
"failCol": "Fehler",
"triggeredByCron": "Cron",
"kind": {
"monthly_issue": "Rechnungsstellung",
"reminders": "Mahnungen"
}
}
}

View File

@@ -393,7 +393,8 @@
"resumeRequestTooltip": "Reactivation request for an existing tenant. Approving will un-suspend the tenant; no provisioning runs.",
"openclawTool": "OpenClaw versions",
"billingTool": "Billing →",
"skillsQueueTool": "Activation Queue"
"skillsQueueTool": "Activation Queue",
"cronTool": "Automation"
},
"channelUsers": {
"title": "Authorized Users",
@@ -742,5 +743,35 @@
"redirectingToStripe": "Redirecting…",
"paymentReceived": "Payment received — thank you!",
"paymentCancelled": "Payment cancelled."
},
"adminCron": {
"title": "Billing automation",
"subtitle": "Monthly issuance and daily reminder sweeps. Both run automatically; use the buttons below to trigger a sweep on demand.",
"monthlyIssue": "Monthly issuance",
"reminders": "Reminders",
"scheduleIssueLabel": "Schedule",
"scheduleIssueValue": "00:30 Europe/Zurich on the 1st",
"scheduleReminderLabel": "Schedule",
"scheduleReminderValue": "09:00 Europe/Zurich daily",
"lastSuccess": "Last success",
"never": "never",
"runIssueNow": "Run last month's issuance now",
"runRemindersNow": "Run reminder sweep now",
"running": "Running…",
"flashIssueOk": "Issuance complete: {success} invoices issued, {skipped} skipped, {failure} failed.",
"flashRemindersOk": "Reminders sent: {success} succeeded, {skipped} skipped, {failure} failed.",
"recentRuns": "Recent runs (last 30)",
"noRunsYet": "No automation runs recorded yet.",
"startedCol": "Started",
"kindCol": "Kind",
"triggeredByCol": "Triggered by",
"okCol": "OK",
"skipCol": "Skipped",
"failCol": "Failed",
"triggeredByCron": "cron",
"kind": {
"monthly_issue": "Issuance",
"reminders": "Reminders"
}
}
}

View File

@@ -393,7 +393,8 @@
"resumeRequestTooltip": "Demande de réactivation d'un locataire existant. L'approbation le réactivera ; aucun provisionnement ne s'exécute.",
"openclawTool": "Versions OpenClaw",
"billingTool": "Facturation →",
"skillsQueueTool": "File d'activation"
"skillsQueueTool": "File d'activation",
"cronTool": "Automatisation"
},
"channelUsers": {
"title": "Utilisateurs autorisés",
@@ -742,5 +743,35 @@
"redirectingToStripe": "Redirection…",
"paymentReceived": "Paiement reçu — merci !",
"paymentCancelled": "Paiement annulé."
},
"adminCron": {
"title": "Automatisation de la facturation",
"subtitle": "Émission mensuelle et balayage quotidien des rappels. Les deux s'exécutent automatiquement ; utilisez les boutons ci-dessous pour déclencher un lancement à la demande.",
"monthlyIssue": "Émission mensuelle",
"reminders": "Rappels",
"scheduleIssueLabel": "Planning",
"scheduleIssueValue": "00:30 Europe/Zurich le 1er",
"scheduleReminderLabel": "Planning",
"scheduleReminderValue": "09:00 Europe/Zurich quotidien",
"lastSuccess": "Dernière réussite",
"never": "jamais",
"runIssueNow": "Facturer le mois dernier maintenant",
"runRemindersNow": "Lancer les rappels maintenant",
"running": "En cours…",
"flashIssueOk": "Émission terminée : {success} factures émises, {skipped} ignorées, {failure} échouées.",
"flashRemindersOk": "Rappels envoyés : {success} réussis, {skipped} ignorés, {failure} échoués.",
"recentRuns": "Lancements récents (30 derniers)",
"noRunsYet": "Aucun lancement automatique enregistré pour le moment.",
"startedCol": "Démarré",
"kindCol": "Type",
"triggeredByCol": "Déclenché par",
"okCol": "OK",
"skipCol": "Ignorés",
"failCol": "Échoués",
"triggeredByCron": "cron",
"kind": {
"monthly_issue": "Émission",
"reminders": "Rappels"
}
}
}

View File

@@ -393,7 +393,8 @@
"resumeRequestTooltip": "Richiesta di riattivazione di un tenant esistente. L'approvazione lo riattiverà; non viene eseguito alcun provisioning.",
"openclawTool": "Versioni OpenClaw",
"billingTool": "Fatturazione →",
"skillsQueueTool": "Coda di attivazione"
"skillsQueueTool": "Coda di attivazione",
"cronTool": "Automazione"
},
"channelUsers": {
"title": "Utenti autorizzati",
@@ -742,5 +743,35 @@
"redirectingToStripe": "Reindirizzamento…",
"paymentReceived": "Pagamento ricevuto — grazie!",
"paymentCancelled": "Pagamento annullato."
},
"adminCron": {
"title": "Automazione fatturazione",
"subtitle": "Emissione mensile e invio quotidiano dei solleciti. Entrambi vengono eseguiti automaticamente; usa i pulsanti sotto per avviare un'esecuzione su richiesta.",
"monthlyIssue": "Emissione mensile",
"reminders": "Solleciti",
"scheduleIssueLabel": "Pianificazione",
"scheduleIssueValue": "00:30 Europe/Zurich il 1°",
"scheduleReminderLabel": "Pianificazione",
"scheduleReminderValue": "09:00 Europe/Zurich quotidianamente",
"lastSuccess": "Ultimo successo",
"never": "mai",
"runIssueNow": "Fattura il mese scorso ora",
"runRemindersNow": "Avvia solleciti ora",
"running": "In corso…",
"flashIssueOk": "Emissione completata: {success} fatture emesse, {skipped} ignorate, {failure} fallite.",
"flashRemindersOk": "Solleciti inviati: {success} riusciti, {skipped} ignorati, {failure} falliti.",
"recentRuns": "Esecuzioni recenti (ultime 30)",
"noRunsYet": "Nessuna esecuzione automatica registrata.",
"startedCol": "Avviata",
"kindCol": "Tipo",
"triggeredByCol": "Avviata da",
"okCol": "OK",
"skipCol": "Ignorati",
"failCol": "Falliti",
"triggeredByCron": "cron",
"kind": {
"monthly_issue": "Emissione",
"reminders": "Solleciti"
}
}
}

View File

@@ -542,6 +542,20 @@ export type InvoiceStatus =
export type InvoicePaymentMethod = "invoice" | "card";
// Phase 5 — Cron run history rows for the admin /admin/cron page.
export type CronRunKind = "monthly_issue" | "reminders";
export interface CronRun {
id: string;
runKind: CronRunKind;
triggeredBy: string;
startedAt: string;
finishedAt: string | null;
successCount: number;
failureCount: number;
skippedCount: number;
errorDetails: unknown | null;
}
export type InvoiceLineKind =
| "tenant_monthly"
| "tenant_setup"