Phase2: Invoicecomputation/AdminpricingUI/Ainvoicemgnt
Some checks failed
Build and Push / build (push) Failing after 28s
Some checks failed
Build and Push / build (push) Failing after 28s
This commit is contained in:
71
src/app/[locale]/admin/billing/generate/page.tsx
Normal file
71
src/app/[locale]/admin/billing/generate/page.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { getSessionUser } from "@/lib/session";
|
||||
import { listTenants } from "@/lib/k8s";
|
||||
import { getOrgBilling } from "@/lib/db";
|
||||
import { BackLink } from "@/components/ui/back-link";
|
||||
import { GenerateForm } from "@/components/admin/billing/generate-form";
|
||||
|
||||
/**
|
||||
* /admin/billing/generate — testing tool to compute & commit an
|
||||
* invoice for a given (org, period).
|
||||
*
|
||||
* Workflow:
|
||||
* 1. Admin picks org + year/month + locale (default auto-detected
|
||||
* from country).
|
||||
* 2. "Preview" runs computeInvoiceDraft (dryRun) — shows lines,
|
||||
* totals, warnings.
|
||||
* 3. "Commit" persists + renders the PDF.
|
||||
*
|
||||
* The org dropdown is hydrated server-side here so the page loads
|
||||
* with the list pre-populated. Per-org billing status (address
|
||||
* present / open balance) is fetched on demand from /api/admin/
|
||||
* billing/orgs since it can change as admin edits.
|
||||
*/
|
||||
export default async function AdminBillingGeneratePage() {
|
||||
const user = await getSessionUser();
|
||||
if (!user) redirect("/login");
|
||||
if (!user.isPlatform) redirect("/dashboard");
|
||||
const t = await getTranslations("adminBilling");
|
||||
|
||||
// Build initial org list from tenant labels.
|
||||
const tenants = await listTenants();
|
||||
const orgMap = new Map<string, string[]>();
|
||||
for (const t of tenants) {
|
||||
const oid = t.metadata.labels?.["pieced.ch/zitadel-org-id"];
|
||||
if (!oid) continue;
|
||||
if (!orgMap.has(oid)) orgMap.set(oid, []);
|
||||
orgMap.get(oid)!.push(t.metadata.name);
|
||||
}
|
||||
// Hydrate company name + country in parallel.
|
||||
const orgList = await Promise.all(
|
||||
[...orgMap.entries()].map(async ([orgId, tenantNames]) => {
|
||||
const billing = await getOrgBilling(orgId).catch(() => null);
|
||||
return {
|
||||
zitadelOrgId: orgId,
|
||||
tenantNames,
|
||||
companyName: billing?.companyName ?? null,
|
||||
country: billing?.country ?? null,
|
||||
hasBillingAddress: !!billing,
|
||||
};
|
||||
})
|
||||
);
|
||||
orgList.sort((a, b) =>
|
||||
(a.companyName ?? a.zitadelOrgId).localeCompare(
|
||||
b.companyName ?? b.zitadelOrgId
|
||||
)
|
||||
);
|
||||
|
||||
return (
|
||||
<main className="max-w-4xl mx-auto px-6 py-8">
|
||||
<BackLink href="/admin/billing" label={t("backToBilling")} />
|
||||
<div className="mb-8 animate-in">
|
||||
<h1 className="font-display text-2xl font-semibold accent-rule">
|
||||
{t("generateTitle")}
|
||||
</h1>
|
||||
<p className="text-sm text-text-secondary mt-3">{t("generatePageDesc")}</p>
|
||||
</div>
|
||||
<GenerateForm orgs={orgList} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
35
src/app/[locale]/admin/billing/invoices/[id]/page.tsx
Normal file
35
src/app/[locale]/admin/billing/invoices/[id]/page.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { getSessionUser } from "@/lib/session";
|
||||
import { getInvoiceDetail } from "@/lib/db";
|
||||
import { BackLink } from "@/components/ui/back-link";
|
||||
import { InvoiceDetailView } from "@/components/admin/billing/invoice-detail-view";
|
||||
|
||||
/**
|
||||
* /admin/billing/invoices/[id] — full detail of one invoice.
|
||||
*
|
||||
* Server-renders the static body (header, lines, totals, billing
|
||||
* snapshot); the action bar (mark-paid, delete, PDF download) is
|
||||
* a client component for the interactive bits.
|
||||
*/
|
||||
export default async function AdminInvoiceDetailPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const user = await getSessionUser();
|
||||
if (!user) redirect("/login");
|
||||
if (!user.isPlatform) redirect("/dashboard");
|
||||
const t = await getTranslations("adminBilling");
|
||||
|
||||
const { id } = await params;
|
||||
const detail = await getInvoiceDetail(id);
|
||||
if (!detail) notFound();
|
||||
|
||||
return (
|
||||
<main className="max-w-4xl mx-auto px-6 py-8">
|
||||
<BackLink href="/admin/billing/invoices" label={t("backToInvoices")} />
|
||||
<InvoiceDetailView detail={detail} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
39
src/app/[locale]/admin/billing/invoices/page.tsx
Normal file
39
src/app/[locale]/admin/billing/invoices/page.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { getSessionUser } from "@/lib/session";
|
||||
import { listInvoices, syncOverdueInvoices } from "@/lib/db";
|
||||
import { BackLink } from "@/components/ui/back-link";
|
||||
import { InvoicesTable } from "@/components/admin/billing/invoices-table";
|
||||
|
||||
/**
|
||||
* /admin/billing/invoices — list of all issued invoices, filterable
|
||||
* by status and month. Click a row to drill into detail.
|
||||
*
|
||||
* Server-renders the initial table with no filters applied (showing
|
||||
* the most recent 200). Client filters trigger a fetch with query
|
||||
* params and re-render in place.
|
||||
*/
|
||||
export default async function AdminInvoicesListPage() {
|
||||
const user = await getSessionUser();
|
||||
if (!user) redirect("/login");
|
||||
if (!user.isPlatform) redirect("/dashboard");
|
||||
const t = await getTranslations("adminBilling");
|
||||
|
||||
await syncOverdueInvoices().catch((e) =>
|
||||
console.error("syncOverdueInvoices failed:", e)
|
||||
);
|
||||
const invoices = await listInvoices({ limit: 200 });
|
||||
|
||||
return (
|
||||
<main className="max-w-5xl mx-auto px-6 py-8">
|
||||
<BackLink href="/admin/billing" label={t("backToBilling")} />
|
||||
<div className="mb-8 animate-in">
|
||||
<h1 className="font-display text-2xl font-semibold accent-rule">
|
||||
{t("invoicesTitle")}
|
||||
</h1>
|
||||
<p className="text-sm text-text-secondary mt-3">{t("invoicesPageDesc")}</p>
|
||||
</div>
|
||||
<InvoicesTable initialInvoices={invoices} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
128
src/app/[locale]/admin/billing/page.tsx
Normal file
128
src/app/[locale]/admin/billing/page.tsx
Normal file
@@ -0,0 +1,128 @@
|
||||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { getSessionUser } from "@/lib/session";
|
||||
import { getOrgOpenBalances, syncOverdueInvoices } from "@/lib/db";
|
||||
import { Card } from "@/components/ui/card";
|
||||
|
||||
/**
|
||||
* /admin/billing — landing page with sub-section links and a
|
||||
* quick overview of orgs in arrears.
|
||||
*
|
||||
* Sub-pages:
|
||||
* - /admin/billing/pricing — platform + skill prices
|
||||
* - /admin/billing/generate — manual invoice generator (testing)
|
||||
* - /admin/billing/invoices — invoice list/detail
|
||||
*
|
||||
* The Phase 2 customer-side /billing landing page is added in
|
||||
* Phase 3.
|
||||
*/
|
||||
export default async function AdminBillingPage() {
|
||||
const user = await getSessionUser();
|
||||
if (!user) redirect("/login");
|
||||
if (!user.isPlatform) redirect("/dashboard");
|
||||
const t = await getTranslations("adminBilling");
|
||||
|
||||
// Sweep open invoices past due → 'overdue' so the counters below
|
||||
// reflect reality without needing a cron.
|
||||
await syncOverdueInvoices().catch((e) =>
|
||||
console.error("syncOverdueInvoices failed:", e)
|
||||
);
|
||||
const balances = await getOrgOpenBalances().catch(() => []);
|
||||
const totalOpen = balances.reduce((acc, b) => acc + b.totalOpenChf, 0);
|
||||
const totalOverdue = balances.reduce((acc, b) => acc + b.overdueCount, 0);
|
||||
|
||||
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>
|
||||
|
||||
{/* Stats strip */}
|
||||
<div className="grid grid-cols-3 gap-4 mb-8 animate-in animate-in-delay-1">
|
||||
<Card>
|
||||
<div className="text-xs text-text-muted">{t("totalOpenBalance")}</div>
|
||||
<div className="text-2xl font-semibold mt-1">
|
||||
CHF {totalOpen.toFixed(2)}
|
||||
</div>
|
||||
</Card>
|
||||
<Card>
|
||||
<div className="text-xs text-text-muted">{t("orgsWithBalance")}</div>
|
||||
<div className="text-2xl font-semibold mt-1">{balances.length}</div>
|
||||
</Card>
|
||||
<Card>
|
||||
<div className="text-xs text-text-muted">{t("overdueInvoices")}</div>
|
||||
<div className="text-2xl font-semibold mt-1">
|
||||
{totalOverdue > 0 ? (
|
||||
<span className="text-error">{totalOverdue}</span>
|
||||
) : (
|
||||
totalOverdue
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Sub-tool cards */}
|
||||
<div className="grid grid-cols-3 gap-4 mb-8 animate-in animate-in-delay-2">
|
||||
<Link href="/admin/billing/pricing">
|
||||
<Card interactive>
|
||||
<div className="font-semibold mb-1">{t("pricingTitle")}</div>
|
||||
<div className="text-sm text-text-muted">{t("pricingDesc")}</div>
|
||||
</Card>
|
||||
</Link>
|
||||
<Link href="/admin/billing/generate">
|
||||
<Card interactive>
|
||||
<div className="font-semibold mb-1">{t("generateTitle")}</div>
|
||||
<div className="text-sm text-text-muted">{t("generateDesc")}</div>
|
||||
</Card>
|
||||
</Link>
|
||||
<Link href="/admin/billing/invoices">
|
||||
<Card interactive>
|
||||
<div className="font-semibold mb-1">{t("invoicesTitle")}</div>
|
||||
<div className="text-sm text-text-muted">{t("invoicesDesc")}</div>
|
||||
</Card>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Orgs with open balance */}
|
||||
{balances.length > 0 && (
|
||||
<div className="animate-in animate-in-delay-3">
|
||||
<h2 className="text-lg font-semibold mb-3">{t("balancesTitle")}</h2>
|
||||
<Card>
|
||||
<table className="w-full text-sm">
|
||||
<thead className="text-xs text-text-muted text-left">
|
||||
<tr>
|
||||
<th className="pb-2">{t("orgIdCol")}</th>
|
||||
<th className="pb-2 text-right">{t("openCountCol")}</th>
|
||||
<th className="pb-2 text-right">{t("overdueCountCol")}</th>
|
||||
<th className="pb-2 text-right">{t("totalOpenCol")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{balances.map((b) => (
|
||||
<tr key={b.zitadelOrgId} className="border-t border-border">
|
||||
<td className="py-2 font-mono text-xs">{b.zitadelOrgId}</td>
|
||||
<td className="py-2 text-right">{b.openCount}</td>
|
||||
<td className="py-2 text-right">
|
||||
{b.overdueCount > 0 ? (
|
||||
<span className="text-error">{b.overdueCount}</span>
|
||||
) : (
|
||||
<span className="text-text-muted">0</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-2 text-right">
|
||||
CHF {b.totalOpenChf.toFixed(2)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
55
src/app/[locale]/admin/billing/pricing/page.tsx
Normal file
55
src/app/[locale]/admin/billing/pricing/page.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { getSessionUser } from "@/lib/session";
|
||||
import { getPlatformPricing, listSkillPricing } from "@/lib/db";
|
||||
import { PACKAGE_CATALOG } from "@/lib/packages";
|
||||
import { BackLink } from "@/components/ui/back-link";
|
||||
import { PricingEditor } from "@/components/admin/billing/pricing-editor";
|
||||
|
||||
/**
|
||||
* /admin/billing/pricing — edit platform-wide pricing config
|
||||
* (monthly fee, setup fee, Threema per-message, VAT rate for
|
||||
* CH/LI) and per-skill daily prices.
|
||||
*
|
||||
* Single-row platform_pricing semantics: one global pricing
|
||||
* config applies to every tenant. No per-tenant overrides in
|
||||
* v1.
|
||||
*/
|
||||
export default async function AdminBillingPricingPage() {
|
||||
const user = await getSessionUser();
|
||||
if (!user) redirect("/login");
|
||||
if (!user.isPlatform) redirect("/dashboard");
|
||||
const t = await getTranslations("adminBilling");
|
||||
|
||||
const [pricing, skillPricing] = await Promise.all([
|
||||
getPlatformPricing(),
|
||||
listSkillPricing(),
|
||||
]);
|
||||
|
||||
// Surface every package in the catalog so admin can price any of
|
||||
// them — UI defaults the picker to skill-kind entries but doesn't
|
||||
// hard-block other kinds (a future scenario where a non-skill
|
||||
// package gets a per-day price shouldn't need a code change).
|
||||
const catalog = Object.values(PACKAGE_CATALOG).map((p) => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
kind: p.kind,
|
||||
}));
|
||||
|
||||
return (
|
||||
<main className="max-w-4xl mx-auto px-6 py-8">
|
||||
<BackLink href="/admin/billing" label={t("backToBilling")} />
|
||||
<div className="mb-8 animate-in">
|
||||
<h1 className="font-display text-2xl font-semibold accent-rule">
|
||||
{t("pricingTitle")}
|
||||
</h1>
|
||||
<p className="text-sm text-text-secondary mt-3">{t("pricingPageDesc")}</p>
|
||||
</div>
|
||||
<PricingEditor
|
||||
initialPricing={pricing}
|
||||
initialSkillPricing={skillPricing}
|
||||
catalog={catalog}
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -32,12 +32,20 @@ export default async function AdminPage() {
|
||||
{/* Sub-tools: links to other admin pages. Plain links rather
|
||||
than nav-shell entries — these are platform-team utilities,
|
||||
not main navigation. */}
|
||||
<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"
|
||||
>
|
||||
{t("openclawTool")}
|
||||
</a>
|
||||
<div className="flex items-center gap-2">
|
||||
<a
|
||||
href="/admin/billing"
|
||||
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("billingTool")}
|
||||
</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"
|
||||
>
|
||||
{t("openclawTool")}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="animate-in animate-in-delay-1">
|
||||
|
||||
66
src/app/api/admin/billing/generate/route.ts
Normal file
66
src/app/api/admin/billing/generate/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
81
src/app/api/admin/billing/invoices/[id]/mark-paid/route.ts
Normal file
81
src/app/api/admin/billing/invoices/[id]/mark-paid/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
39
src/app/api/admin/billing/invoices/[id]/pdf/route.ts
Normal file
39
src/app/api/admin/billing/invoices/[id]/pdf/route.ts
Normal 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",
|
||||
},
|
||||
});
|
||||
}
|
||||
55
src/app/api/admin/billing/invoices/[id]/route.ts
Normal file
55
src/app/api/admin/billing/invoices/[id]/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
44
src/app/api/admin/billing/invoices/route.ts
Normal file
44
src/app/api/admin/billing/invoices/route.ts
Normal 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);
|
||||
}
|
||||
80
src/app/api/admin/billing/orgs/route.ts
Normal file
80
src/app/api/admin/billing/orgs/route.ts
Normal 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);
|
||||
}
|
||||
59
src/app/api/admin/billing/pricing/route.ts
Normal file
59
src/app/api/admin/billing/pricing/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
33
src/app/api/admin/billing/skill-pricing/[skill]/route.ts
Normal file
33
src/app/api/admin/billing/skill-pricing/[skill]/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
76
src/app/api/admin/billing/skill-pricing/route.ts
Normal file
76
src/app/api/admin/billing/skill-pricing/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user