Phase2: Invoicecomputation/AdminpricingUI/Ainvoicemgnt
Some checks failed
Build and Push / build (push) Failing after 28s

This commit is contained in:
2026-05-24 13:51:38 +02:00
parent 6baca1a459
commit c8ed27157f
29 changed files with 4465 additions and 11 deletions

View File

@@ -0,0 +1,66 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { requirePlatformRole } from "@/lib/session";
import { generateInvoice } from "@/lib/billing";
import { safeError } from "@/lib/errors";
/**
* POST /api/admin/billing/generate
*
* Compute (and optionally commit) an invoice for an (org, year,
* month). Platform-only — this is the testing/admin tool.
*
* Body:
* {
* zitadelOrgId: string,
* year: number (e.g. 2026),
* month: number (1-12),
* locale?: 'de' | 'en' | 'fr' | 'it', // default: from country
* dryRun?: boolean // default: false
* }
*
* Response on success:
* {
* draft: InvoiceDraft, // line breakdown + warnings
* invoice: Invoice | null, // null when dryRun=true
* }
*
* If an invoice for that (org, period) already exists, returns
* 409 with a clear message. Use the delete endpoint first to
* regenerate.
*/
const bodySchema = z.object({
zitadelOrgId: z.string().min(1),
year: z.number().int().min(2020).max(2100),
month: z.number().int().min(1).max(12),
locale: z.enum(["de", "en", "fr", "it"]).optional(),
dryRun: z.boolean().optional().default(false),
});
export async function POST(request: Request) {
try {
await requirePlatformRole();
} catch {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
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 }
);
}
try {
const result = await generateInvoice(parsed.data);
return NextResponse.json(result);
} catch (e: any) {
console.error("Invoice generation failed:", e);
const msg = safeError(e, "Generation failed");
// Specific 409 for the "already exists" case so the UI can
// show a "delete first" link.
const status = /already exists/i.test(msg) ? 409 : 500;
return NextResponse.json({ error: msg }, { status });
}
}

View File

@@ -0,0 +1,81 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { requirePlatformRole, getSessionUser } from "@/lib/session";
import { markInvoicePaid } from "@/lib/db";
import { safeError } from "@/lib/errors";
/**
* POST /api/admin/billing/invoices/[id]/mark-paid
*
* Manually mark an open/overdue invoice as paid. Used for the
* "pay by invoice" flow where the customer transfers money to
* the bank account printed on the PDF and the admin reconciles
* by hand.
*
* Body (all optional):
* {
* paidAt?: ISO timestamp, // defaults to now
* note?: string // free-form, stored in paid_method_detail
* }
*
* paid_by is set to the admin user's id automatically.
* Idempotent: trying to mark an already-paid invoice returns 409.
*
* Phase 4 will introduce a parallel auto-paid path triggered by
* Stripe webhooks; for Phase 2 this is the only way to flip the
* status.
*/
const bodySchema = z.object({
paidAt: z.string().datetime().optional(),
note: z.string().max(500).optional(),
});
export async function POST(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
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 { id } = await params;
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 }
);
}
try {
const detail = parsed.data.note
? `${user.id}: ${parsed.data.note}`
: user.id;
const invoice = await markInvoicePaid(id, {
paidBy: "manual",
paidMethodDetail: detail,
paidAt: parsed.data.paidAt ? new Date(parsed.data.paidAt) : undefined,
});
if (!invoice) {
// Either not found or status not in {open, overdue}.
return NextResponse.json(
{ error: "Invoice not found, or already paid/void." },
{ status: 409 }
);
}
return NextResponse.json(invoice);
} catch (e) {
console.error("Failed to mark invoice paid:", e);
return NextResponse.json(
{ error: safeError(e, "Mark-paid failed") },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,39 @@
import { NextResponse } from "next/server";
import { requirePlatformRole } from "@/lib/session";
import { getInvoicePdf } from "@/lib/db";
/**
* GET /api/admin/billing/invoices/[id]/pdf
*
* Streams the stored PDF bytes for an invoice. The bytea column is
* read once and returned as an octet stream; no on-the-fly
* re-rendering — PDFs are immutable once issued.
*
* Phase 3 will add a parallel customer-facing route at
* /api/billing/invoices/[id]/pdf with org-scoped authorization.
*/
export async function GET(
_request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
await requirePlatformRole();
} catch {
return new NextResponse("Forbidden", { status: 403 });
}
const { id } = await params;
const pdf = await getInvoicePdf(id);
if (!pdf) {
return new NextResponse("Not found", { status: 404 });
}
// Construct a response that the browser will render inline (PDF
// viewer) but also offer to download with the right filename.
return new NextResponse(pdf.data, {
status: 200,
headers: {
"Content-Type": "application/pdf",
"Content-Disposition": `inline; filename="${pdf.filename}"`,
"Cache-Control": "private, max-age=0, must-revalidate",
},
});
}

View File

@@ -0,0 +1,55 @@
import { NextResponse } from "next/server";
import { requirePlatformRole } from "@/lib/session";
import { deleteInvoice, getInvoiceDetail } from "@/lib/db";
import { safeError } from "@/lib/errors";
/**
* GET /api/admin/billing/invoices/[id]
* Detail view: invoice + lines.
*
* DELETE /api/admin/billing/invoices/[id]
* Hard delete (testing tool). Invoice number is consumed — gaps
* in the sequence are intentional and documented. Reminders
* (and their PDFs) cascade-delete via the FK.
*/
export async function GET(
_request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
await requirePlatformRole();
} catch {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const { id } = await params;
const detail = await getInvoiceDetail(id);
if (!detail) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
return NextResponse.json(detail);
}
export async function DELETE(
_request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
await requirePlatformRole();
} catch {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const { id } = await params;
try {
const ok = await deleteInvoice(id);
if (!ok) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
return NextResponse.json({ message: "Deleted." });
} catch (e) {
console.error("Failed to delete invoice:", e);
return NextResponse.json(
{ error: safeError(e, "Delete failed") },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,44 @@
import { NextResponse } from "next/server";
import { requirePlatformRole } from "@/lib/session";
import { listInvoices, syncOverdueInvoices } from "@/lib/db";
import type { InvoiceStatus } from "@/types";
/**
* GET /api/admin/billing/invoices
*
* List invoices for admin. Optional filters:
* ?status=open|paid|overdue|void|uncollectible
* ?orgId=...
* ?month=YYYY-MM
* ?limit=200
*
* Refreshes overdue status on each call (cheap UPDATE), so the
* admin list always reflects the latest due-date math without
* needing a cron.
*/
export async function GET(request: Request) {
try {
await requirePlatformRole();
} catch {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
await syncOverdueInvoices().catch((e) =>
console.error("syncOverdueInvoices failed:", e)
);
const { searchParams } = new URL(request.url);
const status = searchParams.get("status") as InvoiceStatus | null;
const orgId = searchParams.get("orgId");
const month = searchParams.get("month");
const limitParam = searchParams.get("limit");
const limit = limitParam ? Math.max(1, Math.min(1000, parseInt(limitParam, 10))) : 200;
const invoices = await listInvoices({
status: status ?? undefined,
zitadelOrgId: orgId ?? undefined,
periodMonth: month ?? undefined,
limit,
});
return NextResponse.json(invoices);
}

View File

@@ -0,0 +1,80 @@
import { NextResponse } from "next/server";
import { requirePlatformRole } from "@/lib/session";
import { listTenants } from "@/lib/k8s";
import { getOrgBilling, getOrgOpenBalances } from "@/lib/db";
/**
* GET /api/admin/billing/orgs
*
* Returns the orgs known to the platform via tenant labels, with
* their billing-address-on-file status and open balance summary.
* Powers the generate form's org dropdown and the billing landing
* page's open-balance table.
*
* Each entry:
* {
* zitadelOrgId: string,
* tenantCount: number,
* hasBillingAddress: boolean,
* companyName: string | null,
* openCount: number,
* overdueCount: number,
* totalOpenChf: number
* }
*/
export async function GET() {
try {
await requirePlatformRole();
} catch {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
// Org membership is derived from tenant labels — there's no
// separate "orgs" table on the portal. listTenants reads from
// K8s, which is the source of truth.
const tenants = await listTenants();
const orgIdToTenants = new Map<string, string[]>();
for (const t of tenants) {
const oid = t.metadata.labels?.["pieced.ch/zitadel-org-id"];
if (!oid) continue;
if (!orgIdToTenants.has(oid)) orgIdToTenants.set(oid, []);
orgIdToTenants.get(oid)!.push(t.metadata.name);
}
const balances = await getOrgOpenBalances();
const balanceMap = new Map(balances.map((b) => [b.zitadelOrgId, b]));
// Hydrate billing-address presence + company name per org.
const results = await Promise.all(
[...orgIdToTenants.entries()].map(async ([orgId, tenantNames]) => {
const billing = await getOrgBilling(orgId).catch(() => null);
const bal = balanceMap.get(orgId);
return {
zitadelOrgId: orgId,
tenantCount: tenantNames.length,
tenantNames,
hasBillingAddress: !!billing,
companyName: billing?.companyName ?? null,
country: billing?.country ?? null,
openCount: bal?.openCount ?? 0,
overdueCount: bal?.overdueCount ?? 0,
totalOpenChf: bal?.totalOpenChf ?? 0,
};
})
);
// Sort: orgs with overdue first, then open, then by name.
results.sort((a, b) => {
if (a.overdueCount !== b.overdueCount) {
return b.overdueCount - a.overdueCount;
}
if (a.openCount !== b.openCount) {
return b.openCount - a.openCount;
}
return (a.companyName ?? a.zitadelOrgId).localeCompare(
b.companyName ?? b.zitadelOrgId
);
});
return NextResponse.json(results);
}

View File

@@ -0,0 +1,59 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { requirePlatformRole } from "@/lib/session";
import { getPlatformPricing, updatePlatformPricing } from "@/lib/db";
import { safeError } from "@/lib/errors";
/**
* GET /api/admin/billing/pricing
* Returns the single-row platform pricing config.
*
* PUT /api/admin/billing/pricing
* Updates one or more pricing fields. Missing fields are left
* unchanged.
*
* Both endpoints are platform-role only.
*/
const updateSchema = z.object({
tenantMonthlyFeeChf: z.number().min(0).max(99_999_999).optional(),
tenantSetupFeeChf: z.number().min(0).max(99_999_999).optional(),
threemaMessageChf: z.number().min(0).max(1000).optional(),
vatRateChli: z.number().min(0).max(100).optional(),
});
export async function GET() {
try {
await requirePlatformRole();
} catch {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const pricing = await getPlatformPricing();
return NextResponse.json(pricing);
}
export async function PUT(request: Request) {
try {
await requirePlatformRole();
} catch {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const body = await request.json().catch(() => ({}));
const parsed = updateSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: "Invalid pricing payload", details: parsed.error.flatten() },
{ status: 400 }
);
}
try {
const updated = await updatePlatformPricing(parsed.data);
return NextResponse.json(updated);
} catch (e) {
console.error("Failed to update platform pricing:", e);
return NextResponse.json(
{ error: safeError(e, "Update failed") },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,33 @@
import { NextResponse } from "next/server";
import { requirePlatformRole } from "@/lib/session";
import { removeSkillPricing } from "@/lib/db";
import { safeError } from "@/lib/errors";
/**
* DELETE /api/admin/billing/skill-pricing/[skill]
* Remove pricing for a skill. Toggle events continue to be
* recorded; the skill simply becomes free starting from the next
* generated invoice. Historical invoices already issued are
* unaffected (they carry frozen line amounts).
*/
export async function DELETE(
_request: Request,
{ params }: { params: Promise<{ skill: string }> }
) {
try {
await requirePlatformRole();
} catch {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const { skill } = await params;
try {
await removeSkillPricing(skill);
return NextResponse.json({ message: "Removed." });
} catch (e) {
console.error("Failed to remove skill pricing:", e);
return NextResponse.json(
{ error: safeError(e, "Remove failed") },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,76 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { requirePlatformRole } from "@/lib/session";
import { listSkillPricing, setSkillPricing } from "@/lib/db";
import { getPackageDef } from "@/lib/packages";
import { safeError } from "@/lib/errors";
/**
* GET /api/admin/billing/skill-pricing
* List all configured skill prices.
*
* PUT /api/admin/billing/skill-pricing
* Upsert a daily price for a single skill. Body:
* { skillId: string, dailyPriceChf: number }
*
* Both endpoints are platform-only.
*
* Note on skillId validation: we accept any package id that exists
* in PACKAGE_CATALOG. The PIN to "skills only" is enforced at the
* UI layer, not here, so admins can price a non-skill package in
* an emergency without code changes.
*/
const upsertSchema = z.object({
skillId: z.string().min(1).max(100),
dailyPriceChf: z.number().min(0).max(1_000_000),
});
export async function GET() {
try {
await requirePlatformRole();
} catch {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const rows = await listSkillPricing();
return NextResponse.json(rows);
}
export async function PUT(request: Request) {
try {
await requirePlatformRole();
} catch {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const body = await request.json().catch(() => ({}));
const parsed = upsertSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: "Invalid payload", details: parsed.error.flatten() },
{ status: 400 }
);
}
// Validate the skill id exists in PACKAGE_CATALOG. Returns null
// for unknown ids; we reject those rather than persist a row that
// would never match a real toggle event.
const pkg = getPackageDef(parsed.data.skillId);
if (!pkg) {
return NextResponse.json(
{ error: `Unknown package id: ${parsed.data.skillId}` },
{ status: 400 }
);
}
try {
const row = await setSkillPricing(
parsed.data.skillId,
parsed.data.dailyPriceChf
);
return NextResponse.json(row);
} catch (e) {
console.error("Failed to upsert skill pricing:", e);
return NextResponse.json(
{ error: safeError(e, "Upsert failed") },
{ status: 500 }
);
}
}