Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 427c7c6204 | |||
| 6a8ad7b4be | |||
| 875ade4351 | |||
| 2a0bb10531 | |||
| 262250564a | |||
| a680d6de9f | |||
| 4a5ae0bb8b | |||
| c21b48c704 | |||
| cf190e5ac5 |
18
package-lock.json
generated
18
package-lock.json
generated
@@ -19,6 +19,7 @@
|
||||
"pg": "^8.20.0",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"stripe": "^22.1.1",
|
||||
"zod": "^3.24.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -7530,6 +7531,23 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/stripe": {
|
||||
"version": "22.1.1",
|
||||
"resolved": "https://registry.npmjs.org/stripe/-/stripe-22.1.1.tgz",
|
||||
"integrity": "sha512-cmodIYP27tBkJ8G7DuGgWw0PFuemlFZbuF3Wwr1TrjFjUa3T7NIgCe6TVwX8BO2ynu+xtTuDGfHafNDCPt9lXA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/node": ">=18"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/node": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/styled-jsx": {
|
||||
"version": "5.1.6",
|
||||
"resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz",
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
"pg": "^8.20.0",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"stripe": "^22.1.1",
|
||||
"zod": "^3.24.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
44
src/app/[locale]/admin/cron/page.tsx
Normal file
44
src/app/[locale]/admin/cron/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
35
src/app/[locale]/billing/[invoiceNumber]/page.tsx
Normal file
35
src/app/[locale]/billing/[invoiceNumber]/page.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import { redirect, notFound } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { getSessionUser } from "@/lib/session";
|
||||
import { getInvoiceByNumberForOrg } from "@/lib/db";
|
||||
import { BackLink } from "@/components/ui/back-link";
|
||||
import { CustomerInvoiceDetail } from "@/components/billing/customer-invoice-detail";
|
||||
|
||||
/**
|
||||
* /billing/[invoiceNumber] — single-invoice view.
|
||||
*
|
||||
* Lookup is by the human-readable invoice number (the YYYY-NNNNN
|
||||
* format printed on the PDF and in the issuance email). Org
|
||||
* filter is enforced in the DB query — a customer trying another
|
||||
* org's number gets 404, not 403, to avoid leaking the existence
|
||||
* of other orgs' invoices.
|
||||
*/
|
||||
export default async function CustomerInvoiceDetailPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ invoiceNumber: string; locale: string }>;
|
||||
}) {
|
||||
const user = await getSessionUser();
|
||||
if (!user) redirect("/login");
|
||||
const { invoiceNumber } = await params;
|
||||
const t = await getTranslations("customerBilling");
|
||||
const detail = await getInvoiceByNumberForOrg(invoiceNumber, user.orgId);
|
||||
if (!detail) notFound();
|
||||
|
||||
return (
|
||||
<main className="max-w-3xl mx-auto px-6 py-8">
|
||||
<BackLink href="/billing" label={t("backToBilling")} />
|
||||
<CustomerInvoiceDetail invoice={detail.invoice} lines={detail.lines} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
63
src/app/[locale]/billing/page.tsx
Normal file
63
src/app/[locale]/billing/page.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { getSessionUser } from "@/lib/session";
|
||||
import { listInvoices, syncOverdueInvoices } from "@/lib/db";
|
||||
import { CustomerInvoiceList } from "@/components/billing/customer-invoice-list";
|
||||
import { RunningTotalWidget } from "@/components/billing/running-total-widget";
|
||||
|
||||
/**
|
||||
* /billing — customer's billing home.
|
||||
*
|
||||
* Shows two things:
|
||||
* 1. RunningTotalWidget — current calendar month's accruing cost
|
||||
* (or the already-issued invoice for the current month, if
|
||||
* that ran early).
|
||||
* 2. CustomerInvoiceList — every issued invoice for this org,
|
||||
* newest first. Status is reflected with a colored badge.
|
||||
*
|
||||
* Anyone signed in can view this. The data is org-scoped; even
|
||||
* non-owner team members see the same view. Phase 4 will add a
|
||||
* "settings.payByInvoice" toggle visibility-gated to owners only.
|
||||
*/
|
||||
export default async function CustomerBillingPage() {
|
||||
const user = await getSessionUser();
|
||||
if (!user) redirect("/login");
|
||||
const t = await getTranslations("customerBilling");
|
||||
|
||||
// Sync overdue status before listing — cheap, idempotent.
|
||||
try {
|
||||
await syncOverdueInvoices();
|
||||
} catch (e) {
|
||||
console.warn("syncOverdueInvoices failed in /billing:", e);
|
||||
}
|
||||
|
||||
const invoices = await listInvoices({
|
||||
zitadelOrgId: user.orgId,
|
||||
limit: 200,
|
||||
});
|
||||
|
||||
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>
|
||||
|
||||
<section className="mb-8 animate-in animate-in-delay-1">
|
||||
<h2 className="text-xs font-semibold uppercase tracking-wider text-text-muted mb-3">
|
||||
{t("currentPeriodHeading")}
|
||||
</h2>
|
||||
<RunningTotalWidget />
|
||||
</section>
|
||||
|
||||
<section className="animate-in animate-in-delay-2">
|
||||
<h2 className="text-xs font-semibold uppercase tracking-wider text-text-muted mb-3">
|
||||
{t("historyHeading")}
|
||||
</h2>
|
||||
<CustomerInvoiceList invoices={invoices} />
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
68
src/app/api/admin/cron/issue-monthly/route.ts
Normal file
68
src/app/api/admin/cron/issue-monthly/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
27
src/app/api/admin/cron/runs/route.ts
Normal file
27
src/app/api/admin/cron/runs/route.ts
Normal 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 });
|
||||
}
|
||||
34
src/app/api/admin/cron/send-reminders/route.ts
Normal file
34
src/app/api/admin/cron/send-reminders/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
75
src/app/api/billing/current/route.ts
Normal file
75
src/app/api/billing/current/route.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getSessionUser } from "@/lib/session";
|
||||
import { computeInvoiceDraft } from "@/lib/billing";
|
||||
import { listInvoices } from "@/lib/db";
|
||||
|
||||
/**
|
||||
* GET /api/billing/current
|
||||
*
|
||||
* Running total for the current calendar month — what the
|
||||
* customer will be billed if no further activity happens. Uses
|
||||
* the same compute pipeline as the final invoice (LiteLLM spend,
|
||||
* Threema usage, skill day-counting, proration) so the number
|
||||
* the customer sees matches what they'll eventually receive
|
||||
* within the limits of intra-month drift.
|
||||
*
|
||||
* If an invoice has ALREADY been issued for the current month
|
||||
* (e.g. cron ran early, admin manually generated), we return
|
||||
* that issued invoice instead — no point showing a draft that
|
||||
* duplicates a real invoice.
|
||||
*
|
||||
* Returns:
|
||||
* { issued: Invoice } // current-month invoice exists
|
||||
* { draft: InvoiceDraft } // still accruing
|
||||
* { error: ... } // org missing billing config
|
||||
*
|
||||
* Cost: 1 LiteLLM HTTP call + 1 Threema HTTP call + a handful of
|
||||
* DB queries per skill. Sub-second typically. No caching; called
|
||||
* on demand from the customer billing page.
|
||||
*/
|
||||
export async function GET() {
|
||||
const user = await getSessionUser();
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
// Resolve current calendar month from UTC. Billing is UTC-day
|
||||
// based throughout (see billing.ts iterDays comment), so the
|
||||
// running total inherits that same semantics.
|
||||
const now = new Date();
|
||||
const year = now.getUTCFullYear();
|
||||
const month = now.getUTCMonth() + 1; // 1-12
|
||||
const periodMonth = `${year}-${String(month).padStart(2, "0")}`;
|
||||
|
||||
// 1. Has the current month already been invoiced?
|
||||
const existing = await listInvoices({
|
||||
zitadelOrgId: user.orgId,
|
||||
periodMonth,
|
||||
limit: 1,
|
||||
});
|
||||
if (existing.length > 0) {
|
||||
return NextResponse.json({ issued: existing[0] });
|
||||
}
|
||||
|
||||
// 2. Otherwise compute the draft. Falls through to error if the
|
||||
// org doesn't have a billing config yet (no Address on file).
|
||||
try {
|
||||
const draft = await computeInvoiceDraft({
|
||||
zitadelOrgId: user.orgId,
|
||||
year,
|
||||
month,
|
||||
});
|
||||
return NextResponse.json({ draft });
|
||||
} catch (e: any) {
|
||||
// Most likely: org_billing row missing. We surface a 200 with a
|
||||
// soft error code rather than 500 — the customer-side widget
|
||||
// displays a helpful "complete your billing details" message
|
||||
// instead of a stack trace.
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: e?.message ?? "Could not compute running total.",
|
||||
code: e?.code ?? "COMPUTE_FAILED",
|
||||
},
|
||||
{ status: 200 }
|
||||
);
|
||||
}
|
||||
}
|
||||
105
src/app/api/billing/invoices/[invoiceNumber]/pay/route.ts
Normal file
105
src/app/api/billing/invoices/[invoiceNumber]/pay/route.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getSessionUser } from "@/lib/session";
|
||||
import {
|
||||
getInvoiceByNumberForOrg,
|
||||
getOrgBilling,
|
||||
} from "@/lib/db";
|
||||
import {
|
||||
createCheckoutSessionForInvoice,
|
||||
ensureStripeCustomerForOrg,
|
||||
} from "@/lib/stripe";
|
||||
import { safeError } from "@/lib/errors";
|
||||
|
||||
/**
|
||||
* POST /api/billing/invoices/[invoiceNumber]/pay
|
||||
*
|
||||
* Initiates a Stripe Checkout Session for an open invoice. Returns
|
||||
* `{ url }` — the browser is expected to navigate to that URL,
|
||||
* where Stripe hosts the payment UI.
|
||||
*
|
||||
* Authorization: caller must belong to the invoice's org (the DB
|
||||
* query enforces this — wrong-org returns 404, indistinguishable
|
||||
* from a non-existent invoice).
|
||||
*
|
||||
* Preconditions enforced server-side:
|
||||
* - Invoice exists for caller's org
|
||||
* - Invoice status is 'open' or 'overdue' (paid/void/draft/uncollectible
|
||||
* all reject — already-paid invoices in particular must not
|
||||
* create a second Checkout Session, even though Stripe would
|
||||
* deduplicate the actual charge)
|
||||
*
|
||||
* The Stripe Customer for the org is lazily ensured here — first
|
||||
* card click on an org creates the customer; subsequent clicks
|
||||
* reuse the persisted stripe_customer_id.
|
||||
*/
|
||||
export async function POST(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ invoiceNumber: string }> }
|
||||
) {
|
||||
const user = await getSessionUser();
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
const { invoiceNumber } = await params;
|
||||
|
||||
const detail = await getInvoiceByNumberForOrg(invoiceNumber, user.orgId);
|
||||
if (!detail) {
|
||||
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
}
|
||||
const inv = detail.invoice;
|
||||
if (inv.status !== "open" && inv.status !== "overdue") {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
inv.status === "paid"
|
||||
? "This invoice has already been paid."
|
||||
: `This invoice cannot be paid online (status: ${inv.status}).`,
|
||||
},
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
|
||||
// We need org_billing for the customer creation address. The
|
||||
// invoice has a SNAPSHOT but that's frozen at issue time; for
|
||||
// creating/updating the Stripe customer we want the current
|
||||
// address (which may have been corrected since the invoice).
|
||||
// Snapshot is still authoritative on the invoice PDF and total.
|
||||
const orgBilling = await getOrgBilling(user.orgId);
|
||||
if (!orgBilling) {
|
||||
return NextResponse.json(
|
||||
{ error: "Billing details are not configured for your organization." },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const customerId = await ensureStripeCustomerForOrg({
|
||||
zitadelOrgId: user.orgId,
|
||||
companyName: orgBilling.companyName,
|
||||
billingEmail: orgBilling.billingEmail,
|
||||
address: {
|
||||
line1: orgBilling.streetAddress,
|
||||
postalCode: orgBilling.postalCode,
|
||||
city: orgBilling.city,
|
||||
country: orgBilling.country,
|
||||
},
|
||||
});
|
||||
const baseUrl =
|
||||
process.env.APP_BASE_URL ?? "https://app.pieced.ch";
|
||||
const { url } = await createCheckoutSessionForInvoice({
|
||||
invoice: inv,
|
||||
customerId,
|
||||
baseUrl,
|
||||
});
|
||||
return NextResponse.json({ url });
|
||||
} catch (e) {
|
||||
console.error(
|
||||
`Failed to create Checkout Session for invoice ${invoiceNumber}:`,
|
||||
e
|
||||
);
|
||||
return NextResponse.json(
|
||||
{ error: safeError(e, "Failed to start card payment.") },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
43
src/app/api/billing/invoices/[invoiceNumber]/pdf/route.ts
Normal file
43
src/app/api/billing/invoices/[invoiceNumber]/pdf/route.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getSessionUser } from "@/lib/session";
|
||||
import { getInvoiceByNumberForOrg, getInvoicePdf } from "@/lib/db";
|
||||
|
||||
/**
|
||||
* GET /api/billing/invoices/[invoiceNumber]/pdf
|
||||
*
|
||||
* Customer-facing PDF download. Same Uint8Array.from() variance
|
||||
* fix as the admin route — see /api/admin/billing/invoices/[id]/pdf
|
||||
* for the rationale.
|
||||
*
|
||||
* Authorization: looks up the invoice by number with org scope
|
||||
* baked into the query, then re-fetches the PDF blob by id. A
|
||||
* customer can't probe another org's invoice numbers — they get
|
||||
* 404 either way.
|
||||
*/
|
||||
export async function GET(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ invoiceNumber: string }> }
|
||||
) {
|
||||
const user = await getSessionUser();
|
||||
if (!user) {
|
||||
return new NextResponse("Unauthorized", { status: 401 });
|
||||
}
|
||||
const { invoiceNumber } = await params;
|
||||
const detail = await getInvoiceByNumberForOrg(invoiceNumber, user.orgId);
|
||||
if (!detail) {
|
||||
return new NextResponse("Not found", { status: 404 });
|
||||
}
|
||||
const pdf = await getInvoicePdf(detail.invoice.id);
|
||||
if (!pdf) {
|
||||
return new NextResponse("PDF not available", { status: 404 });
|
||||
}
|
||||
const body = Uint8Array.from(pdf.data);
|
||||
return new NextResponse(body, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "application/pdf",
|
||||
"Content-Disposition": `inline; filename="${pdf.filename}"`,
|
||||
"Cache-Control": "private, max-age=0, must-revalidate",
|
||||
},
|
||||
});
|
||||
}
|
||||
27
src/app/api/billing/invoices/[invoiceNumber]/route.ts
Normal file
27
src/app/api/billing/invoices/[invoiceNumber]/route.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getSessionUser } from "@/lib/session";
|
||||
import { getInvoiceByNumberForOrg } from "@/lib/db";
|
||||
|
||||
/**
|
||||
* GET /api/billing/invoices/[invoiceNumber]
|
||||
*
|
||||
* Customer-scoped detail lookup by invoice number (the human-
|
||||
* readable YYYY-NNNNN format the customer sees on the PDF). The
|
||||
* org filter is part of the DB query — a customer probing another
|
||||
* org's invoice number gets the same 404 as a non-existent one.
|
||||
*/
|
||||
export async function GET(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ invoiceNumber: string }> }
|
||||
) {
|
||||
const user = await getSessionUser();
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
const { invoiceNumber } = await params;
|
||||
const detail = await getInvoiceByNumberForOrg(invoiceNumber, user.orgId);
|
||||
if (!detail) {
|
||||
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
}
|
||||
return NextResponse.json(detail);
|
||||
}
|
||||
39
src/app/api/billing/invoices/route.ts
Normal file
39
src/app/api/billing/invoices/route.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getSessionUser } from "@/lib/session";
|
||||
import { listInvoices, syncOverdueInvoices } from "@/lib/db";
|
||||
|
||||
/**
|
||||
* GET /api/billing/invoices
|
||||
*
|
||||
* Customer-scoped list of invoices for the caller's org. Returns
|
||||
* a flat array of Invoice headers (no line items — those are
|
||||
* fetched separately by /[invoiceNumber]).
|
||||
*
|
||||
* Status filter is implicit: we return every invoice the
|
||||
* customer's org has, all statuses (issued/paid/overdue/void)
|
||||
* because the customer wants a single billing-history view.
|
||||
*
|
||||
* Before returning we run syncOverdueInvoices() so the displayed
|
||||
* status reflects the current date — issued invoices past their
|
||||
* due_at flip to 'overdue'. Cheap, idempotent, and avoids needing
|
||||
* a separate cron for this transition.
|
||||
*/
|
||||
export async function GET() {
|
||||
const user = await getSessionUser();
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
// Personal accounts have an org too — they share the same shape;
|
||||
// their invoices show up under that synthetic org id.
|
||||
try {
|
||||
await syncOverdueInvoices();
|
||||
} catch (e) {
|
||||
// Non-fatal — display stale status rather than 500.
|
||||
console.warn("syncOverdueInvoices failed in /api/billing/invoices:", e);
|
||||
}
|
||||
const invoices = await listInvoices({
|
||||
zitadelOrgId: user.orgId,
|
||||
limit: 200,
|
||||
});
|
||||
return NextResponse.json(invoices);
|
||||
}
|
||||
42
src/app/api/cron/issue-monthly/route.ts
Normal file
42
src/app/api/cron/issue-monthly/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
33
src/app/api/cron/send-reminders/route.ts
Normal file
33
src/app/api/cron/send-reminders/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
232
src/app/api/stripe/webhook/route.ts
Normal file
232
src/app/api/stripe/webhook/route.ts
Normal file
@@ -0,0 +1,232 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import type Stripe from "stripe";
|
||||
import { getStripeClient, getWebhookSecret } from "@/lib/stripe";
|
||||
import {
|
||||
markInvoicePaid,
|
||||
markStripeEventProcessed,
|
||||
setInvoiceStripePaymentIntent,
|
||||
tryRecordStripeEvent,
|
||||
} from "@/lib/db";
|
||||
|
||||
/**
|
||||
* POST /api/stripe/webhook
|
||||
*
|
||||
* Receives signed events from Stripe. The lifecycle:
|
||||
*
|
||||
* 1. Read RAW body (request.text(), NOT request.json() — Stripe's
|
||||
* HMAC is computed over the raw bytes and any JSON re-parse
|
||||
* will subtly mangle whitespace or property ordering and the
|
||||
* signature will fail).
|
||||
* 2. Verify signature against the configured webhook secret. If
|
||||
* verification fails → 400. An attacker forging webhook calls
|
||||
* could otherwise mark our invoices paid.
|
||||
* 3. Idempotency: INSERT the event id into stripe_events. If the
|
||||
* INSERT conflicts (duplicate delivery, which is normal — Stripe
|
||||
* retries failed deliveries for up to 72h), return 200 immediately
|
||||
* so Stripe doesn't keep retrying.
|
||||
* 4. Process the event based on type. Currently we care about:
|
||||
* - checkout.session.completed → flip invoice to paid
|
||||
* - charge.refunded → log; void/credit handling is Phase 7
|
||||
* - payment_intent.payment_failed → log only; the failure is
|
||||
* already shown to the user on
|
||||
* the Stripe page, no action.
|
||||
* Unknown event types are ack'd with 200 (we may have other
|
||||
* events enabled at the Stripe end that we don't yet care about,
|
||||
* and 200 + log is cheaper than 404 + Stripe retries).
|
||||
* 5. Stamp processed_at on success.
|
||||
*
|
||||
* Return contract: 2xx ack → Stripe stops retrying. Any non-2xx →
|
||||
* Stripe retries with exponential backoff up to 72h. We aim for
|
||||
* 200 on every reachable path (verified, deduplicated, or processed),
|
||||
* and only 400 for signature failures (those would never succeed
|
||||
* on retry anyway, so retrying is wasted effort).
|
||||
*
|
||||
* Performance: handlers run synchronously here because PieCed's
|
||||
* event volume is tiny. If/when that changes, the obvious refactor
|
||||
* is to enqueue (Phase 7) and ack first — but at v1 the inline
|
||||
* model is simpler to reason about and harder to lose events with.
|
||||
*/
|
||||
|
||||
// Next.js: explicitly disable static optimization; this route MUST
|
||||
// run on every request and must not be cached.
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
// 1. Raw body — Stripe verifies the signature over these exact bytes.
|
||||
const rawBody = await request.text();
|
||||
const signature = request.headers.get("stripe-signature");
|
||||
if (!signature) {
|
||||
return new NextResponse("Missing stripe-signature header", {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
// 2. Verify signature.
|
||||
let event: Stripe.Event;
|
||||
try {
|
||||
const stripe = getStripeClient();
|
||||
const secret = getWebhookSecret();
|
||||
event = stripe.webhooks.constructEvent(rawBody, signature, secret);
|
||||
} catch (err) {
|
||||
console.error("Stripe webhook signature verification failed:", err);
|
||||
// 400 — never retry. The webhook configuration is wrong on
|
||||
// either end (rotated secret, wrong endpoint, etc.); retries
|
||||
// won't fix it.
|
||||
return new NextResponse("Invalid signature", { status: 400 });
|
||||
}
|
||||
|
||||
// 3. Idempotency. INSERT event.id → fail-fast on duplicate.
|
||||
let firstDelivery: boolean;
|
||||
try {
|
||||
firstDelivery = await tryRecordStripeEvent(
|
||||
event.id,
|
||||
event.type,
|
||||
event
|
||||
);
|
||||
} catch (err) {
|
||||
console.error(
|
||||
`Failed to record stripe event ${event.id} (${event.type}):`,
|
||||
err
|
||||
);
|
||||
// 5xx so Stripe retries — this is a DB hiccup, not a logic error.
|
||||
return new NextResponse("DB error", { status: 500 });
|
||||
}
|
||||
if (!firstDelivery) {
|
||||
// Already processed; ack happily.
|
||||
return new NextResponse("Duplicate delivery; acknowledged.", {
|
||||
status: 200,
|
||||
});
|
||||
}
|
||||
|
||||
// 4. Process. Each handler is responsible for being safe to run
|
||||
// exactly once (we already deduplicated by event.id above).
|
||||
try {
|
||||
switch (event.type) {
|
||||
case "checkout.session.completed":
|
||||
await handleCheckoutCompleted(
|
||||
event.data.object as Stripe.Checkout.Session
|
||||
);
|
||||
break;
|
||||
case "charge.refunded":
|
||||
await handleChargeRefunded(event.data.object as Stripe.Charge);
|
||||
break;
|
||||
case "payment_intent.payment_failed":
|
||||
await handlePaymentFailed(
|
||||
event.data.object as Stripe.PaymentIntent
|
||||
);
|
||||
break;
|
||||
default:
|
||||
// Unknown event — log so we notice if Stripe starts sending
|
||||
// something we should handle, but ack so we don't accumulate
|
||||
// retries forever.
|
||||
console.log(
|
||||
`Stripe webhook: ignoring event type ${event.type} (id ${event.id})`
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(
|
||||
`Stripe webhook handler failed for ${event.type} (id ${event.id}):`,
|
||||
err
|
||||
);
|
||||
// 5xx → Stripe retries. The handler is idempotent because the
|
||||
// stripe_events row already exists, so on the next attempt we'd
|
||||
// short-circuit at step 3. To actually retry the work we'd need
|
||||
// to DELETE the stripe_events row first; for v1 we don't bother
|
||||
// and let a human investigate the logs.
|
||||
return new NextResponse("Handler error", { status: 500 });
|
||||
}
|
||||
|
||||
// 5. Mark processed.
|
||||
try {
|
||||
await markStripeEventProcessed(event.id);
|
||||
} catch (err) {
|
||||
// Non-fatal — the event was already processed, this is just the
|
||||
// bookkeeping flag. Log and move on.
|
||||
console.error(
|
||||
`Failed to mark stripe event ${event.id} processed:`,
|
||||
err
|
||||
);
|
||||
}
|
||||
|
||||
return new NextResponse("OK", { status: 200 });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Handlers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function handleCheckoutCompleted(
|
||||
session: Stripe.Checkout.Session
|
||||
): Promise<void> {
|
||||
// Defensive: paid sessions are what we want; sessions can also
|
||||
// complete in "unpaid" state (rare for mode=payment, more common
|
||||
// for async/delayed methods like SEPA). Only flip the invoice
|
||||
// when payment actually cleared.
|
||||
if (session.payment_status !== "paid") {
|
||||
console.log(
|
||||
`Checkout session ${session.id} completed but payment_status=${session.payment_status}; waiting for downstream events.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
const invoiceId =
|
||||
session.metadata?.invoice_id ?? session.client_reference_id ?? null;
|
||||
if (!invoiceId) {
|
||||
console.error(
|
||||
`Checkout session ${session.id} completed without invoice_id metadata; cannot link to invoice.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
const paymentIntentId =
|
||||
typeof session.payment_intent === "string"
|
||||
? session.payment_intent
|
||||
: session.payment_intent?.id;
|
||||
|
||||
// Persist the PaymentIntent id on the invoice for traceability +
|
||||
// future refund correlation.
|
||||
if (paymentIntentId) {
|
||||
await setInvoiceStripePaymentIntent(invoiceId, paymentIntentId);
|
||||
}
|
||||
|
||||
// Flip status. markInvoicePaid is idempotent — re-running on an
|
||||
// already-paid invoice returns null and we log + skip.
|
||||
const updated = await markInvoicePaid(invoiceId, {
|
||||
paidBy: "stripe",
|
||||
paidMethodDetail: paymentIntentId
|
||||
? `Stripe Checkout (${paymentIntentId})`
|
||||
: "Stripe Checkout",
|
||||
paidAt: session.created ? new Date(session.created * 1000) : undefined,
|
||||
});
|
||||
if (!updated) {
|
||||
// Already paid or void/draft — fine, nothing to do.
|
||||
console.log(
|
||||
`Invoice ${invoiceId} was not in a payable state when Stripe webhook arrived (likely already paid).`
|
||||
);
|
||||
return;
|
||||
}
|
||||
console.log(
|
||||
`Invoice ${invoiceId} marked paid via Stripe (session ${session.id}, intent ${paymentIntentId}).`
|
||||
);
|
||||
}
|
||||
|
||||
async function handleChargeRefunded(charge: Stripe.Charge): Promise<void> {
|
||||
// v1 scope: log only. Refunds always go through Stripe → admin
|
||||
// initiates them in the dashboard. Updating our invoice status
|
||||
// to 'void' or partial-credit needs more product thinking
|
||||
// (partial refunds? credit notes? VAT corrections?). Phase 7.
|
||||
console.log(
|
||||
`Charge ${charge.id} refunded (amount ${charge.amount_refunded} ${charge.currency}); no portal-side state change.`
|
||||
);
|
||||
}
|
||||
|
||||
async function handlePaymentFailed(
|
||||
intent: Stripe.PaymentIntent
|
||||
): Promise<void> {
|
||||
// The Stripe-hosted page already shows the failure to the user.
|
||||
// We log here for support visibility and to surface in Workbench.
|
||||
// No invoice state change — it stays 'open' until paid.
|
||||
console.log(
|
||||
`PaymentIntent ${intent.id} failed: ${
|
||||
intent.last_payment_error?.message ?? "(no message)"
|
||||
}`
|
||||
);
|
||||
}
|
||||
229
src/components/admin/cron/cron-controls.tsx
Normal file
229
src/components/admin/cron/cron-controls.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
160
src/components/billing/customer-invoice-detail.tsx
Normal file
160
src/components/billing/customer-invoice-detail.tsx
Normal file
@@ -0,0 +1,160 @@
|
||||
import { useTranslations, useFormatter } from "next-intl";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import type { Invoice, InvoiceLine } from "@/types";
|
||||
import { PayInvoiceButton } from "./pay-invoice-button";
|
||||
import { PaymentStatusBanner } from "./payment-status-banner";
|
||||
|
||||
interface Props {
|
||||
invoice: Invoice;
|
||||
lines: InvoiceLine[];
|
||||
}
|
||||
|
||||
const statusColors: Record<string, string> = {
|
||||
open: "text-text-secondary bg-surface-3",
|
||||
paid: "text-success bg-success/10",
|
||||
overdue: "text-error bg-error/10",
|
||||
void: "text-text-muted bg-surface-3",
|
||||
};
|
||||
|
||||
/**
|
||||
* Read-only invoice detail. Flat list of lines — no per-tenant
|
||||
* grouping (one invoice per customer; the tenant context is
|
||||
* already embedded in each line description).
|
||||
*
|
||||
* The download link points at /api/billing/invoices/[n]/pdf
|
||||
* which serves the stored PDF blob inline. Customers using a
|
||||
* link from their email will hit the same route via this page.
|
||||
*/
|
||||
export function CustomerInvoiceDetail({ invoice, lines }: Props) {
|
||||
const t = useTranslations("customerBilling");
|
||||
const fmt = useFormatter();
|
||||
|
||||
return (
|
||||
<div className="space-y-6 animate-in">
|
||||
<PaymentStatusBanner />
|
||||
<div className="flex items-start justify-between gap-4 flex-wrap">
|
||||
<div>
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<h1 className="font-display text-2xl font-semibold">
|
||||
{invoice.invoiceNumber}
|
||||
</h1>
|
||||
<span
|
||||
className={`text-[10px] uppercase tracking-wider px-2 py-1 rounded-md font-semibold ${
|
||||
statusColors[invoice.status] ?? "text-text-muted bg-surface-3"
|
||||
}`}
|
||||
>
|
||||
{t(`status.${invoice.status}` as any)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-text-secondary">
|
||||
{fmt.dateTime(new Date(invoice.periodStart), { dateStyle: "long" })}
|
||||
<span className="text-text-muted mx-1">→</span>
|
||||
{fmt.dateTime(new Date(invoice.periodEnd), { dateStyle: "long" })}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-start gap-2 flex-wrap">
|
||||
{/* Phase 4: Pay-with-card available for open + overdue.
|
||||
Paid/void/draft/uncollectible hide the button — the
|
||||
API also enforces this, so client-side hiding is just
|
||||
for the visible affordance. */}
|
||||
{(invoice.status === "open" || invoice.status === "overdue") && (
|
||||
<PayInvoiceButton invoiceNumber={invoice.invoiceNumber} />
|
||||
)}
|
||||
<a
|
||||
href={`/api/billing/invoices/${encodeURIComponent(invoice.invoiceNumber)}/pdf`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="px-4 py-2 rounded-md bg-surface-3 hover:bg-surface-2 border border-border text-sm font-medium transition-colors"
|
||||
>
|
||||
{t("downloadPdf")}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<div className="space-y-2 mb-4">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-text-muted">{t("billedToLabel")}</span>
|
||||
<span>{invoice.billingSnapshot.companyName}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-text-muted">{t("issuedAtLabel")}</span>
|
||||
<span>
|
||||
{fmt.dateTime(new Date(invoice.issuedAt), { dateStyle: "medium" })}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-text-muted">{t("dueAtLabel")}</span>
|
||||
<span>
|
||||
{fmt.dateTime(new Date(invoice.dueAt), { dateStyle: "medium" })}
|
||||
</span>
|
||||
</div>
|
||||
{invoice.status === "paid" && invoice.paidAt && (
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-text-muted">{t("paidAtLabel")}</span>
|
||||
<span>
|
||||
{fmt.dateTime(new Date(invoice.paidAt), { dateStyle: "medium" })}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<table className="w-full text-sm">
|
||||
<thead className="text-xs text-text-muted text-left">
|
||||
<tr>
|
||||
<th className="pb-2">{t("descriptionCol")}</th>
|
||||
<th className="pb-2 text-right">{t("qtyCol")}</th>
|
||||
<th className="pb-2 text-right">{t("unitCol")}</th>
|
||||
<th className="pb-2 text-right">{t("amountCol")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{lines.map((ln) => (
|
||||
<tr key={ln.id} className="border-t border-border align-top">
|
||||
<td className="py-2">{ln.description}</td>
|
||||
<td className="py-2 text-right font-mono text-xs">
|
||||
{ln.quantity}
|
||||
{ln.unitLabel ? ` ${ln.unitLabel}` : ""}
|
||||
</td>
|
||||
<td className="py-2 text-right font-mono text-xs">
|
||||
{ln.unitPriceChf.toFixed(2)}
|
||||
</td>
|
||||
<td className="py-2 text-right font-mono">
|
||||
{ln.amountChf.toFixed(2)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr className="border-t border-border">
|
||||
<td colSpan={3} className="pt-3 text-right text-text-muted">
|
||||
{t("subtotalLabel")}
|
||||
</td>
|
||||
<td className="pt-3 text-right font-mono">
|
||||
{invoice.subtotalChf.toFixed(2)}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colSpan={3} className="pt-1 text-right text-text-muted">
|
||||
{t("vatLabel", { rate: invoice.vatRate.toFixed(2) })}
|
||||
</td>
|
||||
<td className="pt-1 text-right font-mono">
|
||||
{invoice.vatAmountChf.toFixed(2)}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colSpan={3} className="pt-2 text-right font-semibold">
|
||||
{t("totalLabel")}
|
||||
</td>
|
||||
<td className="pt-2 text-right font-mono font-semibold text-base">
|
||||
CHF {invoice.totalChf.toFixed(2)}
|
||||
</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
92
src/components/billing/customer-invoice-list.tsx
Normal file
92
src/components/billing/customer-invoice-list.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import { useTranslations, useFormatter } from "next-intl";
|
||||
import { Link } from "@/i18n/navigation";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import type { Invoice } from "@/types";
|
||||
|
||||
interface Props {
|
||||
invoices: Invoice[];
|
||||
}
|
||||
|
||||
const statusColors: Record<string, string> = {
|
||||
open: "text-text-secondary bg-surface-3",
|
||||
paid: "text-success bg-success/10",
|
||||
overdue: "text-error bg-error/10",
|
||||
void: "text-text-muted bg-surface-3 line-through",
|
||||
};
|
||||
|
||||
/**
|
||||
* Customer's invoice history table. Server component — gets a
|
||||
* pre-fetched Invoice[] from /billing/page.tsx. Each row links
|
||||
* to /billing/<invoice-number> for the full detail view.
|
||||
*
|
||||
* Columns: number, period, due date, total, status. Status is
|
||||
* displayed with a colored badge so the customer can scan for
|
||||
* outstanding ones at a glance.
|
||||
*/
|
||||
export function CustomerInvoiceList({ invoices }: Props) {
|
||||
const t = useTranslations("customerBilling");
|
||||
const fmt = useFormatter();
|
||||
|
||||
if (invoices.length === 0) {
|
||||
return (
|
||||
<Card>
|
||||
<p className="text-sm text-text-muted italic text-center py-8">
|
||||
{t("emptyHistory")}
|
||||
</p>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<table className="w-full text-sm">
|
||||
<thead className="text-xs text-text-muted text-left">
|
||||
<tr>
|
||||
<th className="pb-2">{t("numberCol")}</th>
|
||||
<th className="pb-2">{t("periodCol")}</th>
|
||||
<th className="pb-2">{t("dueCol")}</th>
|
||||
<th className="pb-2 text-right">{t("totalCol")}</th>
|
||||
<th className="pb-2 text-right">{t("statusCol")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{invoices.map((inv) => (
|
||||
<tr
|
||||
key={inv.id}
|
||||
className="border-t border-border hover:bg-surface-2 transition-colors"
|
||||
>
|
||||
<td className="py-2">
|
||||
<Link
|
||||
href={`/billing/${inv.invoiceNumber}`}
|
||||
className="font-mono text-xs text-accent hover:underline"
|
||||
>
|
||||
{inv.invoiceNumber}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="py-2 text-xs text-text-secondary">
|
||||
{fmt.dateTime(new Date(inv.periodStart), { dateStyle: "medium" })}
|
||||
<span className="text-text-muted mx-1">→</span>
|
||||
{fmt.dateTime(new Date(inv.periodEnd), { dateStyle: "medium" })}
|
||||
</td>
|
||||
<td className="py-2 text-xs text-text-secondary">
|
||||
{fmt.dateTime(new Date(inv.dueAt), { dateStyle: "medium" })}
|
||||
</td>
|
||||
<td className="py-2 text-right font-mono">
|
||||
CHF {inv.totalChf.toFixed(2)}
|
||||
</td>
|
||||
<td className="py-2 text-right">
|
||||
<span
|
||||
className={`text-[10px] uppercase tracking-wider px-2 py-1 rounded-md font-semibold ${
|
||||
statusColors[inv.status] ?? "text-text-muted bg-surface-3"
|
||||
}`}
|
||||
>
|
||||
{t(`status.${inv.status}` as any)}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
64
src/components/billing/pay-invoice-button.tsx
Normal file
64
src/components/billing/pay-invoice-button.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
interface Props {
|
||||
invoiceNumber: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pay-with-card button. Posts to /api/billing/invoices/[n]/pay,
|
||||
* which returns a Stripe Checkout Session URL; we redirect the
|
||||
* browser there.
|
||||
*
|
||||
* The button is rendered only by the parent for status='open' or
|
||||
* 'overdue' invoices — the API enforces this too, but pre-filtering
|
||||
* UI-side keeps the dead state out of the customer's face.
|
||||
*/
|
||||
export function PayInvoiceButton({ invoiceNumber }: Props) {
|
||||
const t = useTranslations("customerBilling");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const onClick = async () => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/billing/invoices/${encodeURIComponent(invoiceNumber)}/pay`,
|
||||
{ method: "POST" }
|
||||
);
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
throw new Error(data.error ?? `HTTP ${res.status}`);
|
||||
}
|
||||
if (!data.url) {
|
||||
throw new Error("Payment session URL missing from response.");
|
||||
}
|
||||
// Hard navigation, not Next.js router — Stripe Checkout is a
|
||||
// separate origin and the browser needs to fully leave our app.
|
||||
window.location.href = data.url;
|
||||
} catch (e: any) {
|
||||
setError(e?.message ?? String(e));
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-end gap-1">
|
||||
<button
|
||||
onClick={onClick}
|
||||
disabled={busy}
|
||||
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 ? t("redirectingToStripe") : t("payWithCard")}
|
||||
</button>
|
||||
{error && (
|
||||
<span className="text-xs text-error max-w-[260px] text-right">
|
||||
{error}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
67
src/components/billing/payment-status-banner.tsx
Normal file
67
src/components/billing/payment-status-banner.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
/**
|
||||
* Banner shown after a return from Stripe Checkout.
|
||||
*
|
||||
* ?paid=1 → green success banner. The webhook may or may
|
||||
* not have processed yet, so we phrase the message
|
||||
* as "Payment received, status will update shortly"
|
||||
* and don't claim the status is already paid. A
|
||||
* light auto-refresh after a few seconds nudges
|
||||
* the page to pick up the new status badge.
|
||||
*
|
||||
* ?cancelled=1 → neutral grey banner: "Payment cancelled". The
|
||||
* invoice stays in 'open' state.
|
||||
*
|
||||
* The banner cleans up the query params from the URL so a page
|
||||
* reload doesn't repeat the message. We use router.replace() to
|
||||
* keep history clean.
|
||||
*/
|
||||
export function PaymentStatusBanner() {
|
||||
const router = useRouter();
|
||||
const t = useTranslations("customerBilling");
|
||||
const [state, setState] = useState<"paid" | "cancelled" | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
if (params.has("paid")) {
|
||||
setState("paid");
|
||||
// The webhook usually arrives before the browser redirect
|
||||
// completes, so the page often renders with status='paid'
|
||||
// on first load and this refresh is a no-op. In the rare
|
||||
// case where it arrives slightly after, a short refresh
|
||||
// picks up the status flip. 1.5s is comfortable for both.
|
||||
const timer = setTimeout(() => {
|
||||
router.refresh();
|
||||
}, 1500);
|
||||
// Strip the query string out of the URL.
|
||||
const cleanUrl = window.location.pathname;
|
||||
window.history.replaceState({}, "", cleanUrl);
|
||||
return () => clearTimeout(timer);
|
||||
} else if (params.has("cancelled")) {
|
||||
setState("cancelled");
|
||||
const cleanUrl = window.location.pathname;
|
||||
window.history.replaceState({}, "", cleanUrl);
|
||||
}
|
||||
}, [router]);
|
||||
|
||||
if (state === "paid") {
|
||||
return (
|
||||
<div className="mb-4 p-3 rounded-md border border-success bg-success/10 text-sm text-success">
|
||||
{t("paymentReceived")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (state === "cancelled") {
|
||||
return (
|
||||
<div className="mb-4 p-3 rounded-md border border-border bg-surface-2 text-sm text-text-secondary">
|
||||
{t("paymentCancelled")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
162
src/components/billing/running-total-widget.tsx
Normal file
162
src/components/billing/running-total-widget.tsx
Normal file
@@ -0,0 +1,162 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslations, useFormatter } from "next-intl";
|
||||
import { Link } from "@/i18n/navigation";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import type { Invoice, InvoiceDraft } from "@/types";
|
||||
|
||||
type CurrentResponse =
|
||||
| { issued: Invoice }
|
||||
| { draft: InvoiceDraft }
|
||||
| { error: string; code?: string };
|
||||
|
||||
/**
|
||||
* Live running total for the current calendar month.
|
||||
*
|
||||
* Loads /api/billing/current on mount. Three result shapes:
|
||||
*
|
||||
* - { issued } — current-month invoice already exists; we
|
||||
* link to it instead of showing a draft total.
|
||||
* - { draft } — still accruing; show subtotal+VAT+total and
|
||||
* a small line breakdown.
|
||||
* - { error } — most likely the org has no billing config
|
||||
* yet; show a friendly hint, not a stack trace.
|
||||
*
|
||||
* Client-side because the compute can take a second or two
|
||||
* (LiteLLM + Threema HTTP calls) and we want a loading spinner.
|
||||
* No polling — the page is static enough that an explicit
|
||||
* "refresh" link is good enough if the user wants newer numbers.
|
||||
*/
|
||||
export function RunningTotalWidget() {
|
||||
const t = useTranslations("customerBilling");
|
||||
const fmt = useFormatter();
|
||||
const [data, setData] = useState<CurrentResponse | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [refreshCounter, setRefreshCounter] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
fetch("/api/billing/current")
|
||||
.then(async (res) => {
|
||||
const j = (await res.json()) as CurrentResponse;
|
||||
if (!cancelled) setData(j);
|
||||
})
|
||||
.catch((e) => {
|
||||
if (!cancelled) setData({ error: String(e), code: "FETCH_FAILED" });
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [refreshCounter]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card>
|
||||
<p className="text-sm text-text-muted italic py-4">{t("computing")}</p>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
if (!data || "error" in data) {
|
||||
return (
|
||||
<Card>
|
||||
<p className="text-sm text-text-secondary py-2">
|
||||
{data && "code" in data && data.code === "COMPUTE_FAILED"
|
||||
? t("noBillingConfig")
|
||||
: t("currentPeriodError")}
|
||||
</p>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
if ("issued" in data) {
|
||||
const inv = data.issued;
|
||||
return (
|
||||
<Card>
|
||||
<div className="flex items-start justify-between gap-4 flex-wrap">
|
||||
<div>
|
||||
<p className="text-xs text-text-muted">{t("currentInvoiceIssued")}</p>
|
||||
<Link
|
||||
href={`/billing/${inv.invoiceNumber}`}
|
||||
className="font-mono text-sm text-accent hover:underline"
|
||||
>
|
||||
{inv.invoiceNumber}
|
||||
</Link>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-xs text-text-muted">{t("totalLabel")}</p>
|
||||
<p className="font-mono text-lg font-semibold">
|
||||
CHF {inv.totalChf.toFixed(2)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
// draft
|
||||
const draft = data.draft;
|
||||
const periodLabel = `${fmt.dateTime(new Date(draft.periodStart), {
|
||||
dateStyle: "long",
|
||||
})} → ${fmt.dateTime(new Date(draft.periodEnd), { dateStyle: "long" })}`;
|
||||
return (
|
||||
<Card>
|
||||
<div className="flex items-start justify-between gap-4 flex-wrap mb-3">
|
||||
<div>
|
||||
<p className="text-xs text-text-muted">{t("accruedSoFar")}</p>
|
||||
<p className="text-xs text-text-secondary">{periodLabel}</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-xs text-text-muted">{t("estimatedTotal")}</p>
|
||||
<p className="font-mono text-2xl font-semibold text-accent">
|
||||
CHF {draft.totalChf.toFixed(2)}
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setRefreshCounter((n) => n + 1)}
|
||||
className="text-[10px] text-text-muted hover:text-text-secondary underline mt-1 cursor-pointer"
|
||||
>
|
||||
{t("refresh")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{draft.lines.length > 0 && (
|
||||
<details className="text-xs">
|
||||
<summary className="cursor-pointer text-text-muted hover:text-text-secondary">
|
||||
{t("breakdownToggle", { count: draft.lines.length })}
|
||||
</summary>
|
||||
<table className="w-full mt-2 text-xs">
|
||||
<tbody>
|
||||
{draft.lines.map((ln, i) => (
|
||||
<tr key={i} className="border-t border-border">
|
||||
<td className="py-1 pr-2">{ln.description}</td>
|
||||
<td className="py-1 text-right font-mono">
|
||||
{ln.amountChf.toFixed(2)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
<tr className="border-t border-border">
|
||||
<td className="py-1 pr-2 text-text-muted text-right">
|
||||
{t("subtotalLabel")}
|
||||
</td>
|
||||
<td className="py-1 text-right font-mono">
|
||||
{draft.subtotalChf.toFixed(2)}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="py-1 pr-2 text-text-muted text-right">
|
||||
{t("vatLabel", { rate: draft.vatRate.toFixed(2) })}
|
||||
</td>
|
||||
<td className="py-1 text-right font-mono">
|
||||
{draft.vatAmountChf.toFixed(2)}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</details>
|
||||
)}
|
||||
<p className="text-[10px] text-text-muted mt-3 italic">{t("draftNote")}</p>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -74,6 +74,20 @@ function NavBar() {
|
||||
{t("settings")}
|
||||
</NavLink>
|
||||
)}
|
||||
{/* Phase 3: Billing visible to anyone signed in. The
|
||||
page is org-scoped server-side — non-owner members
|
||||
see the same invoice history their owner does, but
|
||||
actions like "configure billing details" are gated
|
||||
separately on the settings page. Personal accounts
|
||||
see their own (single-tenant) invoices. */}
|
||||
{user && (
|
||||
<NavLink
|
||||
href="/billing"
|
||||
active={pathname.startsWith("/billing")}
|
||||
>
|
||||
{t("billing")}
|
||||
</NavLink>
|
||||
)}
|
||||
{/* Feature 5: Support is available to every signed-in
|
||||
user. Customers see their own tickets only; platform
|
||||
admins see the queue. */}
|
||||
|
||||
@@ -61,6 +61,7 @@ import { listTenants } from "./k8s";
|
||||
import { getTeamSpendLogsV2 } from "./litellm";
|
||||
import { getUsage as getThreemaUsage } from "./threema-relay";
|
||||
import { renderInvoicePdf } from "./billing-pdf";
|
||||
import { sendInvoiceIssuedEmail } from "./email";
|
||||
import { formatLineDescription } from "./billing-i18n";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -779,6 +780,50 @@ export async function generateInvoice(opts: {
|
||||
// Pass 2: store the PDF bytes.
|
||||
await updateInvoicePdf(placeholder.id, pdfBuffer, filename);
|
||||
const finalInvoice = await getInvoiceById(placeholder.id);
|
||||
|
||||
// Phase 3: best-effort notification to the billing contact.
|
||||
// We send AFTER the PDF is fully persisted (so the deep link
|
||||
// in the email immediately resolves to a downloadable PDF) but
|
||||
// BEFORE returning, since the cron caller doesn't otherwise
|
||||
// know to trigger this. Failure is logged, never thrown — a
|
||||
// mail-server hiccup must not roll back an issued invoice.
|
||||
// The recipient is the billing email captured in the invoice
|
||||
// snapshot (immutable; reflects who was on file at issue time).
|
||||
try {
|
||||
const settled = finalInvoice ?? placeholder;
|
||||
const snapshot = settled.billingSnapshot;
|
||||
if (snapshot.billingEmail) {
|
||||
const supportedLocales: Array<"en" | "de" | "fr" | "it"> = [
|
||||
"en", "de", "fr", "it",
|
||||
];
|
||||
const locale = supportedLocales.includes(settled.locale as any)
|
||||
? (settled.locale as "en" | "de" | "fr" | "it")
|
||||
: "de";
|
||||
await sendInvoiceIssuedEmail({
|
||||
to: snapshot.billingEmail,
|
||||
contactName: snapshot.companyName, // no separate contact-name field
|
||||
companyName: snapshot.companyName,
|
||||
invoiceNumber: settled.invoiceNumber,
|
||||
totalChf: settled.totalChf,
|
||||
currency: "CHF",
|
||||
dueAt: settled.dueAt,
|
||||
lineCount: draft.lines.length,
|
||||
periodStart: settled.periodStart,
|
||||
periodEnd: settled.periodEnd,
|
||||
locale,
|
||||
});
|
||||
} else {
|
||||
console.warn(
|
||||
`Invoice ${settled.invoiceNumber} issued but billing snapshot has no email — notification skipped.`
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(
|
||||
`Invoice ${placeholder.invoiceNumber} issued; notification email failed:`,
|
||||
e
|
||||
);
|
||||
}
|
||||
|
||||
return { draft, invoice: finalInvoice ?? placeholder };
|
||||
} catch (e) {
|
||||
// Render failed — leave the persisted row in place so admin can
|
||||
|
||||
360
src/lib/cron.ts
Normal file
360
src/lib/cron.ts
Normal 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 };
|
||||
384
src/lib/db.ts
384
src/lib/db.ts
@@ -500,7 +500,7 @@ const MIGRATION_SQL = `
|
||||
-- NULL for org-wide items; tenant name for per-tenant breakdowns.
|
||||
tenant_name TEXT,
|
||||
kind TEXT NOT NULL CHECK (kind IN (
|
||||
'tenant_monthly','tenant_setup','ai_usage','threema_messages','skill_usage','adjustment'
|
||||
'tenant_monthly','tenant_setup','ai_usage','threema_messages','skill_usage','skill_setup','adjustment'
|
||||
)),
|
||||
description TEXT NOT NULL,
|
||||
quantity NUMERIC(12,4) NOT NULL DEFAULT 1,
|
||||
@@ -563,6 +563,61 @@ const MIGRATION_SQL = `
|
||||
-- Per-tenant lookup for the customer UI's pending+rejected display.
|
||||
CREATE INDEX IF NOT EXISTS idx_skill_act_tenant
|
||||
ON skill_activation_requests (tenant_name, requested_at DESC);
|
||||
|
||||
-- Phase 3 fix: the original invoice_lines.kind CHECK constraint
|
||||
-- was created without 'skill_setup' (which Phase 2-fix6 added as
|
||||
-- a new line kind for per-skill setup fees). CREATE TABLE IF NOT
|
||||
-- EXISTS doesn't update constraints on existing tables, so we
|
||||
-- explicitly drop and re-add with the full kind set on every
|
||||
-- boot. Idempotent — DROP IF EXISTS swallows the not-yet-exists
|
||||
-- case (fresh installs); ADD always re-creates. Constraint name
|
||||
-- follows Postgres's default <table>_<column>_check.
|
||||
ALTER TABLE invoice_lines
|
||||
DROP CONSTRAINT IF EXISTS invoice_lines_kind_check;
|
||||
ALTER TABLE invoice_lines
|
||||
ADD CONSTRAINT invoice_lines_kind_check
|
||||
CHECK (kind IN (
|
||||
'tenant_monthly','tenant_setup','ai_usage','threema_messages',
|
||||
'skill_usage','skill_setup','adjustment'
|
||||
));
|
||||
|
||||
-- Phase 4: Stripe webhook idempotency. Stripe guarantees at-least-once
|
||||
-- delivery and retries failures with exponential backoff for up to 72h,
|
||||
-- so the same event.id can arrive multiple times. We insert each
|
||||
-- event.id with the PK constraint enforcing uniqueness; INSERT either
|
||||
-- succeeds (first delivery → process the event) or fails with 23505
|
||||
-- (duplicate → ack with 200 and skip). The payload column is invaluable
|
||||
-- when diagnosing a webhook that processed wrong; keep it small and
|
||||
-- prune old rows out-of-band if storage becomes a concern (Phase 7).
|
||||
CREATE TABLE IF NOT EXISTS stripe_events (
|
||||
event_id TEXT PRIMARY KEY,
|
||||
event_type TEXT NOT NULL,
|
||||
received_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
processed_at TIMESTAMPTZ,
|
||||
payload JSONB
|
||||
);
|
||||
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;
|
||||
@@ -2407,6 +2462,38 @@ export async function getInvoiceDetail(
|
||||
return { invoice, lines: lines.rows.map(rowToInvoiceLine) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Phase 3 — customer-scoped lookup by human-readable invoice
|
||||
* number with ownership enforcement in a single query. The org
|
||||
* filter is part of the WHERE clause so a customer can't probe
|
||||
* another org's invoice numbers (which are sequential and easy
|
||||
* to guess) and get a different status code (404 vs 403) than
|
||||
* for their own — both miss-and-not-yours return null.
|
||||
*
|
||||
* Used by /api/billing/invoices/[invoiceNumber] and the
|
||||
* /billing/[invoiceNumber] customer page.
|
||||
*/
|
||||
export async function getInvoiceByNumberForOrg(
|
||||
invoiceNumber: string,
|
||||
zitadelOrgId: string
|
||||
): Promise<InvoiceDetail | null> {
|
||||
await ensureSchema();
|
||||
const head = await getPool().query(
|
||||
`SELECT ${INVOICE_LIST_COLUMNS} FROM invoices
|
||||
WHERE invoice_number = $1 AND zitadel_org_id = $2
|
||||
LIMIT 1`,
|
||||
[invoiceNumber, zitadelOrgId]
|
||||
);
|
||||
if (head.rows.length === 0) return null;
|
||||
const invoice = rowToInvoice(head.rows[0]);
|
||||
const lines = await getPool().query(
|
||||
`SELECT * FROM invoice_lines WHERE invoice_id = $1
|
||||
ORDER BY display_order, id`,
|
||||
[invoice.id]
|
||||
);
|
||||
return { invoice, lines: lines.rows.map(rowToInvoiceLine) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the PDF bytes for an invoice. Returns null if no PDF was
|
||||
* stored (shouldn't happen in v1; defensive against partial state).
|
||||
@@ -2793,3 +2880,298 @@ export async function updateSkillActivationRequestStatus(
|
||||
? rowToSkillActivationRequest(result.rows[0])
|
||||
: null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Phase 3 diagnostic — single-purpose helper for the /api/admin/billing/debug
|
||||
// endpoint. Returns raw invoice_line rows for a tenant filtered to setup-fee
|
||||
// rows, so a human can verify what the billing emission code's SQL is
|
||||
// actually seeing. Not intended for production use; kept here for shipping
|
||||
// hotfixes when running-total drafts diverge from expected behaviour.
|
||||
// ---------------------------------------------------------------------------
|
||||
export async function debugListSetupLines(
|
||||
tenantName: string
|
||||
): Promise<Array<{
|
||||
id: string;
|
||||
invoice_id: string;
|
||||
tenant_name: string;
|
||||
kind: string;
|
||||
amount_chf: number;
|
||||
description: string;
|
||||
}>> {
|
||||
await ensureSchema();
|
||||
const result = await getPool().query(
|
||||
`SELECT id, invoice_id, tenant_name, kind, amount_chf, description
|
||||
FROM invoice_lines
|
||||
WHERE tenant_name = $1
|
||||
AND kind = 'tenant_setup'`,
|
||||
[tenantName]
|
||||
);
|
||||
return result.rows;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Stripe — Phase 4
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Attempt to record receipt of a Stripe webhook event. Returns true
|
||||
* when this is the first time we've seen the event (caller should
|
||||
* process it), false when the event_id was already present
|
||||
* (caller should ack with 200 and skip — Stripe retries are
|
||||
* normal and we must be idempotent).
|
||||
*
|
||||
* The whole-payload JSONB is stored so a misbehaving event can be
|
||||
* diagnosed after the fact without re-fetching from Stripe.
|
||||
*/
|
||||
export async function tryRecordStripeEvent(
|
||||
eventId: string,
|
||||
eventType: string,
|
||||
payload: unknown
|
||||
): Promise<boolean> {
|
||||
await ensureSchema();
|
||||
try {
|
||||
await getPool().query(
|
||||
`INSERT INTO stripe_events (event_id, event_type, payload)
|
||||
VALUES ($1, $2, $3::jsonb)`,
|
||||
[eventId, eventType, JSON.stringify(payload)]
|
||||
);
|
||||
return true;
|
||||
} catch (e: any) {
|
||||
// 23505 = unique_violation; the row already exists, meaning we've
|
||||
// seen this event before. That's the normal duplicate-delivery
|
||||
// case — return false so the caller short-circuits.
|
||||
if (e?.code === "23505") return false;
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stamp processed_at on a stripe_events row once the handler has
|
||||
* finished its work successfully. Lets us spot stuck events
|
||||
* (received but not processed) for diagnosis.
|
||||
*/
|
||||
export async function markStripeEventProcessed(eventId: string): Promise<void> {
|
||||
await ensureSchema();
|
||||
await getPool().query(
|
||||
"UPDATE stripe_events SET processed_at = now() WHERE event_id = $1",
|
||||
[eventId]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist the Stripe PaymentIntent id on an invoice. Used by the
|
||||
* webhook handler once the Checkout Session completes — at that
|
||||
* point Stripe has minted the PaymentIntent and we want to be
|
||||
* able to find the Stripe-side record from the invoice (and vice
|
||||
* versa via metadata).
|
||||
*
|
||||
* Idempotent: re-running with the same value is a no-op. The
|
||||
* column was added in Phase 2 schema; this helper was missing.
|
||||
*/
|
||||
export async function setInvoiceStripePaymentIntent(
|
||||
invoiceId: string,
|
||||
paymentIntentId: string
|
||||
): Promise<void> {
|
||||
await ensureSchema();
|
||||
await getPool().query(
|
||||
`UPDATE invoices
|
||||
SET stripe_payment_intent_id = $2
|
||||
WHERE id = $1
|
||||
AND (stripe_payment_intent_id IS NULL OR stripe_payment_intent_id = $2)`,
|
||||
[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;
|
||||
}
|
||||
|
||||
258
src/lib/email.ts
258
src/lib/email.ts
@@ -900,3 +900,261 @@ export async function sendSkillActivationRejectionEmail(params: {
|
||||
console.error("Failed to send skill activation rejection email:", err);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Invoice issuance — Phase 3
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Notify the billing contact when a new invoice has been issued.
|
||||
* Includes a brief summary (total + due date + line count) so the
|
||||
* recipient can triage without opening the portal, plus a deep
|
||||
* link to /billing/<invoice number> where they can download the
|
||||
* PDF. The PDF itself is NOT attached — it lives in the portal,
|
||||
* keeps mail payloads small, and avoids the audit-trail headache
|
||||
* of "which copy is authoritative".
|
||||
*/
|
||||
export async function sendInvoiceIssuedEmail(params: {
|
||||
to: string;
|
||||
contactName: string;
|
||||
companyName: string;
|
||||
invoiceNumber: string;
|
||||
totalChf: number;
|
||||
currency: string; // "CHF" — passed for future-proofing
|
||||
dueAt: string; // ISO date
|
||||
lineCount: number;
|
||||
periodStart: string; // ISO date
|
||||
periodEnd: string; // ISO date
|
||||
locale: "de" | "en" | "fr" | "it";
|
||||
}): Promise<void> {
|
||||
// All four locales — the email is sent in the invoice's locale,
|
||||
// which was frozen at issue time. No fallback to admin's locale.
|
||||
const L = params.locale;
|
||||
const subjectsByLocale: Record<typeof L, string> = {
|
||||
en: `New invoice ${params.invoiceNumber} from PieCed IT — ${params.currency} ${params.totalChf.toFixed(2)}`,
|
||||
de: `Neue Rechnung ${params.invoiceNumber} von PieCed IT — ${params.currency} ${params.totalChf.toFixed(2)}`,
|
||||
fr: `Nouvelle facture ${params.invoiceNumber} de PieCed IT — ${params.currency} ${params.totalChf.toFixed(2)}`,
|
||||
it: `Nuova fattura ${params.invoiceNumber} da PieCed IT — ${params.currency} ${params.totalChf.toFixed(2)}`,
|
||||
};
|
||||
const greetingsByLocale: Record<typeof L, string> = {
|
||||
en: `Hello ${params.contactName},`,
|
||||
de: `Sehr geehrte/r ${params.contactName},`,
|
||||
fr: `Bonjour ${params.contactName},`,
|
||||
it: `Gentile ${params.contactName},`,
|
||||
};
|
||||
const introByLocale: Record<typeof L, string> = {
|
||||
en: `A new invoice has been issued for ${params.companyName}.`,
|
||||
de: `Für ${params.companyName} wurde eine neue Rechnung ausgestellt.`,
|
||||
fr: `Une nouvelle facture a été émise pour ${params.companyName}.`,
|
||||
it: `È stata emessa una nuova fattura per ${params.companyName}.`,
|
||||
};
|
||||
const labels: Record<typeof L, Record<string, string>> = {
|
||||
en: { number: "Invoice", period: "Period", total: "Total", due: "Due by", lines: "Line items", cta: "View invoice & download PDF", signoff: "Best regards", brand: "PieCed IT" },
|
||||
de: { number: "Rechnung", period: "Zeitraum", total: "Gesamt", due: "Zahlbar bis", lines: "Positionen", cta: "Rechnung ansehen & PDF herunterladen", signoff: "Mit freundlichen Grüssen", brand: "PieCed IT" },
|
||||
fr: { number: "Facture", period: "Période", total: "Total", due: "À régler avant", lines: "Lignes", cta: "Voir la facture & télécharger le PDF", signoff: "Cordialement", brand: "PieCed IT" },
|
||||
it: { number: "Fattura", period: "Periodo", total: "Totale", due: "Scadenza", lines: "Voci", cta: "Visualizza fattura & scarica PDF", signoff: "Cordiali saluti", brand: "PieCed IT" },
|
||||
};
|
||||
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 periodFmt = `${params.periodStart.slice(0, 10)} → ${params.periodEnd.slice(0, 10)}`;
|
||||
const dueFmt = params.dueAt.slice(0, 10);
|
||||
|
||||
// Both bodies built in the invoice's locale.
|
||||
const link = `https://app.pieced.ch/billing/${encodeURIComponent(params.invoiceNumber)}`;
|
||||
|
||||
try {
|
||||
await getTransporter().sendMail({
|
||||
from: getFrom(),
|
||||
to: params.to,
|
||||
subject: subjectsByLocale[L],
|
||||
text: [
|
||||
greetingsByLocale[L],
|
||||
"",
|
||||
introByLocale[L],
|
||||
"",
|
||||
`${l.number}: ${params.invoiceNumber}`,
|
||||
`${l.period}: ${periodFmt}`,
|
||||
`${l.total}: ${totalFmt}`,
|
||||
`${l.due}: ${dueFmt}`,
|
||||
`${l.lines}: ${params.lineCount}`,
|
||||
"",
|
||||
`${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: #10B981;">${escapeHtml(introByLocale[L])}</h2>
|
||||
<p>${escapeHtml(greetingsByLocale[L])}</p>
|
||||
<p>${escapeHtml(introByLocale[L])}</p>
|
||||
<table style="width:100%; border-collapse:collapse; margin:16px 0; font-size:14px;">
|
||||
<tr><td style="color:#888; padding:6px 0; width:120px;">${l.number}</td><td><strong>${safeNumber}</strong></td></tr>
|
||||
<tr><td style="color:#888; padding:6px 0;">${l.period}</td><td>${escapeHtml(periodFmt)}</td></tr>
|
||||
<tr><td style="color:#888; padding:6px 0;">${l.total}</td><td style="color:#10B981; 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.lines}</td><td>${params.lineCount}</td></tr>
|
||||
</table>
|
||||
<p>
|
||||
<a href="${link}" style="display:inline-block; padding:10px 24px; background:#10B981; 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 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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
260
src/lib/stripe.ts
Normal file
260
src/lib/stripe.ts
Normal file
@@ -0,0 +1,260 @@
|
||||
/**
|
||||
* Server-side Stripe client + helpers for Phase 4 (card payments).
|
||||
*
|
||||
* Architecture (see Phase 4 notes):
|
||||
* 1. Customer clicks "Pay with card" on /billing/<number>.
|
||||
* 2. Server creates Stripe Checkout Session (mode='payment') with
|
||||
* the invoice total as a single line item. We pass `customer`
|
||||
* to reuse an existing Stripe Customer if the org already has
|
||||
* one, otherwise we create one and persist its id in
|
||||
* org_billing_config.stripe_customer_id.
|
||||
* 3. Returns session.url; the browser redirects there.
|
||||
* 4. Customer pays; Stripe redirects to success_url with the
|
||||
* session id appended.
|
||||
* 5. /api/stripe/webhook receives `checkout.session.completed`,
|
||||
* verifies signature, looks up the invoice id from metadata,
|
||||
* flips the invoice to 'paid'.
|
||||
*
|
||||
* Env vars:
|
||||
* STRIPE_SECRET_KEY (required) - sk_test_... in sandbox, sk_live_... in prod
|
||||
* STRIPE_WEBHOOK_SECRET (required for webhook) - whsec_...
|
||||
* APP_BASE_URL (required) - e.g. https://app.pieced.ch
|
||||
*
|
||||
* SDK: stripe@22.x (Node SDK v22), pinned API version 2026-03-25.dahlia.
|
||||
* Pinning the API version means a `npm update` of the SDK won't
|
||||
* silently change request/response shapes; we explicitly bump when
|
||||
* we want a new API version.
|
||||
*/
|
||||
|
||||
import Stripe from "stripe";
|
||||
import type { Invoice } from "@/types";
|
||||
|
||||
// Pinned API version. `as const` narrows this to a string-literal
|
||||
// type that the Stripe constructor's `apiVersion` field accepts
|
||||
// exactly. When the installed SDK bumps to a new pinned version,
|
||||
// TypeScript will surface the mismatch at the `new Stripe(...)` call
|
||||
// below — bump this string deliberately alongside the SDK upgrade
|
||||
// and review the API changelog before doing so.
|
||||
const STRIPE_API_VERSION = "2026-04-22.dahlia" as const;
|
||||
|
||||
// Cache the client across hot reloads / serverless invocations.
|
||||
// We don't instantiate at module load because some build steps run
|
||||
// without runtime env vars set — only fail when actually used.
|
||||
let cachedClient: Stripe | null = null;
|
||||
|
||||
export function getStripeClient(): Stripe {
|
||||
if (cachedClient) return cachedClient;
|
||||
const key = process.env.STRIPE_SECRET_KEY;
|
||||
if (!key) {
|
||||
throw new Error(
|
||||
"STRIPE_SECRET_KEY is not set. Configure it in your environment."
|
||||
);
|
||||
}
|
||||
cachedClient = new Stripe(key, {
|
||||
apiVersion: STRIPE_API_VERSION,
|
||||
// Identify ourselves in Stripe's request logs so support can
|
||||
// distinguish PieCed traffic from other integrations on the
|
||||
// same account.
|
||||
appInfo: {
|
||||
name: "PieCed Portal",
|
||||
version: "1.0.0",
|
||||
url: "https://app.pieced.ch",
|
||||
},
|
||||
});
|
||||
return cachedClient;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the configured webhook secret. Separated so the webhook
|
||||
* handler can fail fast with a clear error message rather than the
|
||||
* generic "STRIPE_SECRET_KEY missing" path above.
|
||||
*/
|
||||
export function getWebhookSecret(): string {
|
||||
const secret = process.env.STRIPE_WEBHOOK_SECRET;
|
||||
if (!secret) {
|
||||
throw new Error(
|
||||
"STRIPE_WEBHOOK_SECRET is not set. Get it from the webhook endpoint in your Stripe dashboard."
|
||||
);
|
||||
}
|
||||
return secret;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a CHF decimal amount (e.g. 123.45) to integer rappen
|
||||
* (e.g. 12345). Stripe API requires integer amounts in the
|
||||
* currency's smallest unit. Centralised so we don't have rounding
|
||||
* drift between callers.
|
||||
*/
|
||||
export function chfToRappen(amountChf: number): number {
|
||||
// toFixed(2) avoids floating-point ugliness (0.1 + 0.2 = 0.30000000000000004).
|
||||
return Math.round(parseFloat(amountChf.toFixed(2)) * 100);
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up or create the Stripe Customer for a PieCed org.
|
||||
*
|
||||
* Lazy creation: orgs that only pay by invoice never get a Stripe
|
||||
* Customer. The first "Pay with card" click triggers creation; the
|
||||
* id is persisted in org_billing_config so subsequent invoices
|
||||
* reuse it.
|
||||
*
|
||||
* Returns the Stripe customer id (`cus_...`).
|
||||
*/
|
||||
export async function ensureStripeCustomerForOrg(params: {
|
||||
zitadelOrgId: string;
|
||||
// Snapshot taken at click-time, NOT at invoice issuance — the
|
||||
// org's current address goes on the Stripe customer object.
|
||||
// Stripe's address on file is independent of any one invoice.
|
||||
companyName: string;
|
||||
billingEmail: string;
|
||||
address: {
|
||||
line1: string;
|
||||
postalCode: string;
|
||||
city: string;
|
||||
country: string; // ISO 3166-1 alpha-2 (e.g. "CH")
|
||||
};
|
||||
}): Promise<string> {
|
||||
// Lazy import to avoid pulling pg into edge-runtime modules that
|
||||
// might import this file. Same pattern used elsewhere in lib/.
|
||||
const { getOrgBillingConfig, updateOrgBillingConfig } = await import("./db");
|
||||
const existing = await getOrgBillingConfig(params.zitadelOrgId);
|
||||
if (existing.stripeCustomerId) {
|
||||
return existing.stripeCustomerId;
|
||||
}
|
||||
const stripe = getStripeClient();
|
||||
const customer = await stripe.customers.create({
|
||||
email: params.billingEmail,
|
||||
name: params.companyName,
|
||||
address: {
|
||||
line1: params.address.line1,
|
||||
postal_code: params.address.postalCode,
|
||||
city: params.address.city,
|
||||
country: params.address.country,
|
||||
},
|
||||
metadata: {
|
||||
zitadel_org_id: params.zitadelOrgId,
|
||||
},
|
||||
});
|
||||
await updateOrgBillingConfig(params.zitadelOrgId, {
|
||||
stripeCustomerId: customer.id,
|
||||
});
|
||||
return customer.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a Checkout Session for paying a single invoice by card.
|
||||
*
|
||||
* Design notes:
|
||||
*
|
||||
* - Single line item with the invoice total (gross, VAT included).
|
||||
* Our own invoice PDF already breaks down lines + VAT; the Stripe
|
||||
* page is the checkout, not a duplicate of the invoice.
|
||||
*
|
||||
* - `automatic_tax` is disabled because the invoice already has
|
||||
* VAT computed by our pipeline. Letting Stripe re-calculate
|
||||
* would double-charge or contradict our PDF.
|
||||
*
|
||||
* - `payment_method_types` is NOT set, so Stripe surfaces dynamic
|
||||
* payment methods configured on the account (cards, TWINT for
|
||||
* Swiss customers, Apple Pay, Google Pay, etc.) automatically.
|
||||
*
|
||||
* - `metadata` and `payment_intent_data.metadata` BOTH carry the
|
||||
* invoice id. The session-level copy is enough for the
|
||||
* `checkout.session.completed` webhook; the intent-level copy
|
||||
* lets us correlate refunds and disputes which fire on the
|
||||
* PaymentIntent and don't include session metadata.
|
||||
*
|
||||
* - `client_reference_id` is set to our invoice id as a stable
|
||||
* reference. Visible in the Stripe dashboard, useful for support.
|
||||
*
|
||||
* - `locale` follows the invoice's locale so the customer sees
|
||||
* the Stripe page in their language (frozen at invoice issue
|
||||
* time; consistent with PDF + email).
|
||||
*/
|
||||
export async function createCheckoutSessionForInvoice(params: {
|
||||
invoice: Invoice;
|
||||
customerId: string;
|
||||
baseUrl: string;
|
||||
}): Promise<{ url: string; sessionId: string }> {
|
||||
const stripe = getStripeClient();
|
||||
const { invoice, customerId, baseUrl } = params;
|
||||
|
||||
// Stripe Checkout supports a limited set of locales; map our
|
||||
// four to Stripe's codes and fall back to 'auto' if anything
|
||||
// outside the set ever appears.
|
||||
//
|
||||
// We deliberately don't annotate this with
|
||||
// `Stripe.Checkout.SessionCreateParams.Locale` — stripe-node v22
|
||||
// ships with a known type-export regression
|
||||
// (stripe/stripe-node#2662) where params types under namespaced
|
||||
// resources aren't re-exported from the resource barrel. The
|
||||
// `as const` literal narrowing gives the variable the union type
|
||||
// `"de" | "fr" | "it" | "en" | "auto"`, which `sessions.create`
|
||||
// accepts at the call site via its own inline parameter typing.
|
||||
// When the SDK fixes the re-export, we can put the annotation
|
||||
// back without touching the call site.
|
||||
const stripeLocale =
|
||||
invoice.locale === "de"
|
||||
? ("de" as const)
|
||||
: invoice.locale === "fr"
|
||||
? ("fr" as const)
|
||||
: invoice.locale === "it"
|
||||
? ("it" as const)
|
||||
: invoice.locale === "en"
|
||||
? ("en" as const)
|
||||
: ("auto" as const);
|
||||
|
||||
const successUrl = `${baseUrl}/billing/${encodeURIComponent(invoice.invoiceNumber)}?paid=1&session_id={CHECKOUT_SESSION_ID}`;
|
||||
const cancelUrl = `${baseUrl}/billing/${encodeURIComponent(invoice.invoiceNumber)}?cancelled=1`;
|
||||
|
||||
const session = await stripe.checkout.sessions.create({
|
||||
mode: "payment",
|
||||
customer: customerId,
|
||||
client_reference_id: invoice.id,
|
||||
locale: stripeLocale,
|
||||
line_items: [
|
||||
{
|
||||
quantity: 1,
|
||||
price_data: {
|
||||
currency: "chf",
|
||||
unit_amount: chfToRappen(invoice.totalChf),
|
||||
product_data: {
|
||||
name: `Invoice ${invoice.invoiceNumber}`,
|
||||
description: `PieCed IT — ${invoice.periodStart.slice(0, 10)} → ${invoice.periodEnd.slice(0, 10)}`,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
metadata: {
|
||||
invoice_id: invoice.id,
|
||||
invoice_number: invoice.invoiceNumber,
|
||||
zitadel_org_id: invoice.zitadelOrgId,
|
||||
},
|
||||
payment_intent_data: {
|
||||
// Mirror invoice id at the PaymentIntent level so refunds &
|
||||
// disputes (which fire on the PI, not the session) can be
|
||||
// correlated to our invoice without an extra lookup.
|
||||
metadata: {
|
||||
invoice_id: invoice.id,
|
||||
invoice_number: invoice.invoiceNumber,
|
||||
zitadel_org_id: invoice.zitadelOrgId,
|
||||
},
|
||||
// Statement descriptor shown on the customer's card
|
||||
// statement. Limited to 22 chars total; we use the prefix
|
||||
// since Stripe will prepend the merchant name from the
|
||||
// account anyway. Keep it short and recognisable.
|
||||
description: `Invoice ${invoice.invoiceNumber}`,
|
||||
},
|
||||
success_url: successUrl,
|
||||
cancel_url: cancelUrl,
|
||||
// VAT is already in invoice.totalChf — don't let Stripe touch tax.
|
||||
automatic_tax: { enabled: false },
|
||||
});
|
||||
|
||||
if (!session.url) {
|
||||
throw new Error(
|
||||
`Stripe returned a session without a redirect URL (id=${session.id})`
|
||||
);
|
||||
}
|
||||
return { url: session.url, sessionId: session.id };
|
||||
}
|
||||
@@ -15,7 +15,8 @@
|
||||
"team": "Team",
|
||||
"settings": "Einstellungen",
|
||||
"optional": "optional",
|
||||
"support": "Support"
|
||||
"support": "Support",
|
||||
"billing": "Abrechnung"
|
||||
},
|
||||
"login": {
|
||||
"title": "PieCed Portal",
|
||||
@@ -392,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",
|
||||
@@ -695,5 +697,81 @@
|
||||
"reasonLabel": "Grund (wird dem Kunden angezeigt)",
|
||||
"reasonPlaceholder": "Erklären Sie, warum die Aktivierung nicht erfolgen kann — z. B. fehlende Kundendaten, Hardware nicht verfügbar usw.",
|
||||
"reasonRequired": "Ein Grund ist für die Ablehnung erforderlich."
|
||||
},
|
||||
"customerBilling": {
|
||||
"title": "Abrechnung",
|
||||
"subtitle": "Aktueller Zeitraum und Rechnungshistorie. Ausgestellte Rechnungen stehen als PDF-Download bereit.",
|
||||
"backToBilling": "Zurück zur Abrechnung",
|
||||
"currentPeriodHeading": "Aktueller Zeitraum",
|
||||
"historyHeading": "Rechnungshistorie",
|
||||
"computing": "Berechne aktuellen Periodenbetrag…",
|
||||
"currentPeriodError": "Aktueller Periodenbetrag konnte nicht geladen werden. Bitte später erneut versuchen.",
|
||||
"noBillingConfig": "Abrechnungsdaten sind noch nicht hinterlegt. Sobald die Rechnungsadresse Ihrer Organisation eingetragen ist, erscheint hier der laufende Betrag.",
|
||||
"accruedSoFar": "Bisher in diesem Monat",
|
||||
"estimatedTotal": "Geschätzter Gesamtbetrag",
|
||||
"currentInvoiceIssued": "Aktueller Monat bereits abgerechnet",
|
||||
"refresh": "aktualisieren",
|
||||
"breakdownToggle": "Aufschlüsselung anzeigen ({count} Positionen)",
|
||||
"draftNote": "Live-Schätzung. Die endgültige Rechnung kann durch Monatsendrundung, nachgemeldete Nutzungsdaten oder manuelle Anpassungen leicht abweichen.",
|
||||
"emptyHistory": "Noch keine Rechnungen ausgestellt. Nach Abschluss Ihres ersten Monats erscheinen sie hier.",
|
||||
"numberCol": "Nummer",
|
||||
"periodCol": "Zeitraum",
|
||||
"dueCol": "Fällig",
|
||||
"totalCol": "Gesamt",
|
||||
"statusCol": "Status",
|
||||
"descriptionCol": "Beschreibung",
|
||||
"qtyCol": "Menge",
|
||||
"unitCol": "Einzelpreis",
|
||||
"amountCol": "Betrag",
|
||||
"billedToLabel": "Rechnungsempfänger",
|
||||
"issuedAtLabel": "Ausgestellt",
|
||||
"dueAtLabel": "Zahlbar bis",
|
||||
"paidAtLabel": "Bezahlt am",
|
||||
"subtotalLabel": "Zwischensumme",
|
||||
"vatLabel": "MWST ({rate}%)",
|
||||
"totalLabel": "Gesamt",
|
||||
"downloadPdf": "PDF herunterladen",
|
||||
"status": {
|
||||
"draft": "Entwurf",
|
||||
"open": "Offen",
|
||||
"paid": "Bezahlt",
|
||||
"overdue": "Überfällig",
|
||||
"void": "Storniert",
|
||||
"uncollectible": "Uneinbringlich"
|
||||
},
|
||||
"payWithCard": "Mit Karte bezahlen",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,8 @@
|
||||
"team": "Team",
|
||||
"settings": "Settings",
|
||||
"optional": "optional",
|
||||
"support": "Support"
|
||||
"support": "Support",
|
||||
"billing": "Billing"
|
||||
},
|
||||
"login": {
|
||||
"title": "PieCed Portal",
|
||||
@@ -392,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",
|
||||
@@ -695,5 +697,81 @@
|
||||
"reasonLabel": "Reason (shown to the customer)",
|
||||
"reasonPlaceholder": "Explain why this can't be activated — e.g. missing customer data, hardware unavailable, etc.",
|
||||
"reasonRequired": "A reason is required to reject."
|
||||
},
|
||||
"customerBilling": {
|
||||
"title": "Billing",
|
||||
"subtitle": "Your current period and invoice history. Issued invoices are available as PDF downloads.",
|
||||
"backToBilling": "Back to billing",
|
||||
"currentPeriodHeading": "Current period",
|
||||
"historyHeading": "Invoice history",
|
||||
"computing": "Computing current period total…",
|
||||
"currentPeriodError": "Could not load the current period total. Please try again later.",
|
||||
"noBillingConfig": "Billing details haven't been configured yet. Once your organization's billing address is on file, this widget will show the running total.",
|
||||
"accruedSoFar": "Accrued this month",
|
||||
"estimatedTotal": "Estimated total",
|
||||
"currentInvoiceIssued": "Current month already invoiced",
|
||||
"refresh": "refresh",
|
||||
"breakdownToggle": "Show breakdown ({count} line items)",
|
||||
"draftNote": "Live estimate. The final invoice may differ slightly due to end-of-month rounding, late-arriving usage data, or manual adjustments.",
|
||||
"emptyHistory": "No invoices issued yet. Once your first month closes, you'll see it here.",
|
||||
"numberCol": "Number",
|
||||
"periodCol": "Period",
|
||||
"dueCol": "Due",
|
||||
"totalCol": "Total",
|
||||
"statusCol": "Status",
|
||||
"descriptionCol": "Description",
|
||||
"qtyCol": "Qty",
|
||||
"unitCol": "Unit",
|
||||
"amountCol": "Amount",
|
||||
"billedToLabel": "Billed to",
|
||||
"issuedAtLabel": "Issued",
|
||||
"dueAtLabel": "Due by",
|
||||
"paidAtLabel": "Paid on",
|
||||
"subtotalLabel": "Subtotal",
|
||||
"vatLabel": "VAT ({rate}%)",
|
||||
"totalLabel": "Total",
|
||||
"downloadPdf": "Download PDF",
|
||||
"status": {
|
||||
"draft": "Draft",
|
||||
"open": "Open",
|
||||
"paid": "Paid",
|
||||
"overdue": "Overdue",
|
||||
"void": "Void",
|
||||
"uncollectible": "Uncollectible"
|
||||
},
|
||||
"payWithCard": "Pay with card",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,8 @@
|
||||
"team": "Équipe",
|
||||
"settings": "Paramètres",
|
||||
"optional": "facultatif",
|
||||
"support": "Support"
|
||||
"support": "Support",
|
||||
"billing": "Facturation"
|
||||
},
|
||||
"login": {
|
||||
"title": "Portail PieCed",
|
||||
@@ -392,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",
|
||||
@@ -695,5 +697,81 @@
|
||||
"reasonLabel": "Motif (visible par le client)",
|
||||
"reasonPlaceholder": "Expliquez pourquoi l'activation ne peut pas se faire — ex. données client manquantes, matériel indisponible, etc.",
|
||||
"reasonRequired": "Un motif est requis pour refuser."
|
||||
},
|
||||
"customerBilling": {
|
||||
"title": "Facturation",
|
||||
"subtitle": "Période en cours et historique des factures. Les factures émises sont disponibles en téléchargement PDF.",
|
||||
"backToBilling": "Retour à la facturation",
|
||||
"currentPeriodHeading": "Période en cours",
|
||||
"historyHeading": "Historique des factures",
|
||||
"computing": "Calcul du total de la période en cours…",
|
||||
"currentPeriodError": "Impossible de charger le total de la période en cours. Veuillez réessayer plus tard.",
|
||||
"noBillingConfig": "Les informations de facturation ne sont pas encore configurées. Une fois l'adresse de facturation de votre organisation enregistrée, le total en cours apparaîtra ici.",
|
||||
"accruedSoFar": "Cumulé ce mois",
|
||||
"estimatedTotal": "Total estimé",
|
||||
"currentInvoiceIssued": "Mois en cours déjà facturé",
|
||||
"refresh": "actualiser",
|
||||
"breakdownToggle": "Afficher le détail ({count} lignes)",
|
||||
"draftNote": "Estimation en direct. La facture finale peut légèrement varier en raison d'arrondis de fin de mois, de données d'utilisation tardives ou d'ajustements manuels.",
|
||||
"emptyHistory": "Aucune facture émise pour le moment. Après la clôture de votre premier mois, elles apparaîtront ici.",
|
||||
"numberCol": "Numéro",
|
||||
"periodCol": "Période",
|
||||
"dueCol": "Échéance",
|
||||
"totalCol": "Total",
|
||||
"statusCol": "Statut",
|
||||
"descriptionCol": "Description",
|
||||
"qtyCol": "Qté",
|
||||
"unitCol": "Prix unitaire",
|
||||
"amountCol": "Montant",
|
||||
"billedToLabel": "Facturé à",
|
||||
"issuedAtLabel": "Émise le",
|
||||
"dueAtLabel": "À régler avant",
|
||||
"paidAtLabel": "Payée le",
|
||||
"subtotalLabel": "Sous-total",
|
||||
"vatLabel": "TVA ({rate}%)",
|
||||
"totalLabel": "Total",
|
||||
"downloadPdf": "Télécharger le PDF",
|
||||
"status": {
|
||||
"draft": "Brouillon",
|
||||
"open": "Ouverte",
|
||||
"paid": "Payée",
|
||||
"overdue": "En retard",
|
||||
"void": "Annulée",
|
||||
"uncollectible": "Irrécouvrable"
|
||||
},
|
||||
"payWithCard": "Payer par carte",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,8 @@
|
||||
"team": "Team",
|
||||
"settings": "Impostazioni",
|
||||
"optional": "facoltativo",
|
||||
"support": "Supporto"
|
||||
"support": "Supporto",
|
||||
"billing": "Fatturazione"
|
||||
},
|
||||
"login": {
|
||||
"title": "Portale PieCed",
|
||||
@@ -392,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",
|
||||
@@ -695,5 +697,81 @@
|
||||
"reasonLabel": "Motivo (mostrato al cliente)",
|
||||
"reasonPlaceholder": "Spiega perché l'attivazione non può procedere — es. dati cliente mancanti, hardware non disponibile, ecc.",
|
||||
"reasonRequired": "Un motivo è necessario per rifiutare."
|
||||
},
|
||||
"customerBilling": {
|
||||
"title": "Fatturazione",
|
||||
"subtitle": "Periodo corrente e cronologia delle fatture. Le fatture emesse sono disponibili come download PDF.",
|
||||
"backToBilling": "Torna alla fatturazione",
|
||||
"currentPeriodHeading": "Periodo corrente",
|
||||
"historyHeading": "Cronologia fatture",
|
||||
"computing": "Calcolo del totale del periodo corrente…",
|
||||
"currentPeriodError": "Impossibile caricare il totale del periodo corrente. Riprova più tardi.",
|
||||
"noBillingConfig": "I dati di fatturazione non sono ancora configurati. Una volta registrato l'indirizzo di fatturazione della tua organizzazione, il totale corrente apparirà qui.",
|
||||
"accruedSoFar": "Accumulato questo mese",
|
||||
"estimatedTotal": "Totale stimato",
|
||||
"currentInvoiceIssued": "Mese corrente già fatturato",
|
||||
"refresh": "aggiorna",
|
||||
"breakdownToggle": "Mostra dettaglio ({count} voci)",
|
||||
"draftNote": "Stima in tempo reale. La fattura finale può variare leggermente per arrotondamenti di fine mese, dati di utilizzo in ritardo o aggiustamenti manuali.",
|
||||
"emptyHistory": "Nessuna fattura emessa ancora. Dopo la chiusura del primo mese, appariranno qui.",
|
||||
"numberCol": "Numero",
|
||||
"periodCol": "Periodo",
|
||||
"dueCol": "Scadenza",
|
||||
"totalCol": "Totale",
|
||||
"statusCol": "Stato",
|
||||
"descriptionCol": "Descrizione",
|
||||
"qtyCol": "Qtà",
|
||||
"unitCol": "Prezzo unitario",
|
||||
"amountCol": "Importo",
|
||||
"billedToLabel": "Fatturato a",
|
||||
"issuedAtLabel": "Emessa il",
|
||||
"dueAtLabel": "Scadenza",
|
||||
"paidAtLabel": "Pagata il",
|
||||
"subtotalLabel": "Subtotale",
|
||||
"vatLabel": "IVA ({rate}%)",
|
||||
"totalLabel": "Totale",
|
||||
"downloadPdf": "Scarica PDF",
|
||||
"status": {
|
||||
"draft": "Bozza",
|
||||
"open": "Aperta",
|
||||
"paid": "Pagata",
|
||||
"overdue": "In ritardo",
|
||||
"void": "Annullata",
|
||||
"uncollectible": "Inesigibile"
|
||||
},
|
||||
"payWithCard": "Paga con carta",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user