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