Compare commits

..

11 Commits

Author SHA1 Message Date
eea027b3b0 Phase6c: Optional Company contact name
All checks were successful
Build and Push / build (push) Successful in 1m38s
2026-05-25 13:14:36 +02:00
522246e386 Phase6c: Optional Company contact name
All checks were successful
Build and Push / build (push) Successful in 1m40s
2026-05-25 12:54:12 +02:00
b3131f7710 Phase6: Customer Billing details
All checks were successful
Build and Push / build (push) Successful in 1m43s
2026-05-25 12:15:48 +02:00
fadfdd3435 Phase6: Customer Billing details
All checks were successful
Build and Push / build (push) Successful in 1m46s
2026-05-25 11:47:14 +02:00
427c7c6204 Phase5: Automate bill creation
All checks were successful
Build and Push / build (push) Successful in 1m43s
2026-05-25 10:41:51 +02:00
6a8ad7b4be Phase4: Stripe
All checks were successful
Build and Push / build (push) Successful in 1m40s
2026-05-25 00:14:20 +02:00
875ade4351 Phase4: Stripe
All checks were successful
Build and Push / build (push) Successful in 1m40s
2026-05-24 23:59:05 +02:00
2a0bb10531 Phase4: Stripe
Some checks failed
Build and Push / build (push) Failing after 56s
2026-05-24 23:54:49 +02:00
262250564a Phase4: Stripe
Some checks failed
Build and Push / build (push) Failing after 53s
2026-05-24 23:48:39 +02:00
a680d6de9f Phase4: Stripe
Some checks failed
Build and Push / build (push) Failing after 38s
2026-05-24 23:37:48 +02:00
4a5ae0bb8b Phase3: Billing Customerpage/Mailings
All checks were successful
Build and Push / build (push) Successful in 1m37s
2026-05-24 22:21:26 +02:00
37 changed files with 3004 additions and 178 deletions

18
package-lock.json generated
View File

@@ -19,6 +19,7 @@
"pg": "^8.20.0", "pg": "^8.20.0",
"react": "^19.1.0", "react": "^19.1.0",
"react-dom": "^19.1.0", "react-dom": "^19.1.0",
"stripe": "^22.1.1",
"zod": "^3.24.0" "zod": "^3.24.0"
}, },
"devDependencies": { "devDependencies": {
@@ -7530,6 +7531,23 @@
"url": "https://github.com/sponsors/sindresorhus" "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": { "node_modules/styled-jsx": {
"version": "5.1.6", "version": "5.1.6",
"resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz", "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz",

View File

@@ -21,6 +21,7 @@
"pg": "^8.20.0", "pg": "^8.20.0",
"react": "^19.1.0", "react": "^19.1.0",
"react-dom": "^19.1.0", "react-dom": "^19.1.0",
"stripe": "^22.1.1",
"zod": "^3.24.0" "zod": "^3.24.0"
}, },
"devDependencies": { "devDependencies": {

View File

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

View File

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

View File

@@ -49,7 +49,9 @@ export default async function CustomerBillingPage() {
<h2 className="text-xs font-semibold uppercase tracking-wider text-text-muted mb-3"> <h2 className="text-xs font-semibold uppercase tracking-wider text-text-muted mb-3">
{t("currentPeriodHeading")} {t("currentPeriodHeading")}
</h2> </h2>
<RunningTotalWidget /> {/* Phase 6: pass the owner flag so the no-config CTA shows
the right call-to-action vs the right hint. */}
<RunningTotalWidget isOwner={user.roles.includes("owner")} />
</section> </section>
<section className="animate-in animate-in-delay-2"> <section className="animate-in animate-in-delay-2">

View File

@@ -76,6 +76,7 @@ export default async function NewInstancePage() {
userName={user.name} userName={user.name}
userEmail={user.email} userEmail={user.email}
hasOrgBilling={hasOrgBilling} hasOrgBilling={hasOrgBilling}
existingOrgBilling={orgBilling}
/> />
</div> </div>
</div> </div>

View File

@@ -317,6 +317,7 @@ export default async function DashboardPage() {
userName={user.name} userName={user.name}
userEmail={user.email} userEmail={user.email}
hasOrgBilling={hasOrgBilling} hasOrgBilling={hasOrgBilling}
existingOrgBilling={orgBilling}
/> />
</div> </div>
</div> </div>

View File

@@ -1,30 +1,31 @@
import { getTranslations } from "next-intl/server";
import { redirect, notFound } from "next/navigation"; import { redirect, notFound } from "next/navigation";
import { getSessionUser, canMutate } from "@/lib/session"; import { getTranslations } from "next-intl/server";
import { getSessionUser } from "@/lib/session";
import { getOrgBilling } from "@/lib/db"; import { getOrgBilling } from "@/lib/db";
import { BillingSettingsForm } from "@/components/settings/billing-settings-form"; import { BillingSettingsForm } from "@/components/settings/billing-form";
/** /**
* /settings/billing — view and edit org-scoped billing (Bug 34/35). * /settings/billing — customer-side billing details management.
* *
* Server-side fetches the existing record (if any) and passes it to * Owner-only by visibility: non-owner members get a 404 (same
* the client form. The form posts to PUT /api/billing on submit. * response as if the page didn't exist). The link to this page
* is also hidden from non-owners on /billing and elsewhere, but
* the page itself enforces too — a non-owner who learns the URL
* still gets 404, not 403, so the page's existence doesn't leak.
* *
* Access: same gate as the API — owners and platform admins. `user` * First-time visitors see an empty form. Subsequent visits see
* role redirects to /settings (which also wouldn't list billing for * the current values, editable. Save creates or updates via the
* them). 403 here would be friendlier than redirect, but the most * shared upsert path; the row's existence drives whether the
* likely cause of a `user` landing on this URL is sharing a bookmark * monthly issuance cron will pick this org up.
* with their owner — silent redirect is gentle.
*/ */
export default async function BillingSettingsPage() { export default async function BillingSettingsPage() {
const user = await getSessionUser(); const user = await getSessionUser();
if (!user) redirect("/login"); if (!user) redirect("/login");
if (!canMutate(user)) { // Non-owners get a 404 — see comment above.
redirect("/settings"); if (!user.roles.includes("owner")) notFound();
}
const t = await getTranslations("settingsBilling");
const billing = await getOrgBilling(user.orgId); const t = await getTranslations("settingsBilling");
const existing = await getOrgBilling(user.orgId);
return ( return (
<main className="max-w-3xl mx-auto px-6 py-8"> <main className="max-w-3xl mx-auto px-6 py-8">
@@ -32,16 +33,16 @@ export default async function BillingSettingsPage() {
<h1 className="font-display text-2xl font-semibold accent-rule"> <h1 className="font-display text-2xl font-semibold accent-rule">
{t("title")} {t("title")}
</h1> </h1>
<p className="text-sm text-text-secondary mt-3">{t("subtitle")}</p> <p className="text-sm text-text-secondary mt-3">
{user.isPersonal ? t("subtitlePersonal") : t("subtitle")}
</p>
</div>
<div className="animate-in animate-in-delay-1">
<BillingSettingsForm
initial={existing}
isPersonal={user.isPersonal}
/>
</div> </div>
<BillingSettingsForm
initial={billing}
isPersonal={user.isPersonal}
orgName={user.orgName}
userName={user.name}
userEmail={user.email}
/>
</main> </main>
); );
} }

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,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 }
);
}
}

View File

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

View File

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

View File

@@ -0,0 +1,90 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { getSessionUser } from "@/lib/session";
import { getOrgBilling, upsertOrgBilling } from "@/lib/db";
/**
* GET /api/settings/billing — read the caller's org_billing row.
* Returns null if the org hasn't configured billing yet — the
* form renders empty and the PUT will create on first save.
*
* PUT /api/settings/billing — upsert the row.
*
* Authorization: caller must have role "owner" in their org.
* Non-owners get 403 (they shouldn't have reached the page UI
* anyway, which hides the link, but the API enforces too — a
* non-owner who hits this directly with curl gets refused).
*
* Personal accounts are inherently their own owner (single-user
* org), so user.roles.includes("owner") returns true and they
* can manage their own billing.
*/
const upsertSchema = z.object({
companyName: z.string().trim().min(1).max(200),
// Phase 6 fix: optional "z.Hd." / "Attn:" line. Personal accounts
// never send this (the UI hides the field); orgs may set or leave
// it empty.
contactName: z.string().trim().max(200).optional().nullable(),
streetAddress: z.string().trim().min(1).max(200),
postalCode: z.string().trim().min(1).max(20),
city: z.string().trim().min(1).max(100),
// ISO 3166-1 alpha-2. We normalise to uppercase server-side.
country: z
.string()
.trim()
.length(2)
.regex(/^[A-Za-z]{2}$/, "Use a 2-letter ISO country code (CH, DE, …)"),
vatNumber: z.string().trim().max(40).optional().nullable(),
billingEmail: z.string().trim().email().max(200),
notes: z.string().trim().max(2000).optional().nullable(),
});
function requireOwner(user: { roles: string[] } | null) {
if (!user) return false;
return user.roles.includes("owner");
}
export async function GET() {
const user = await getSessionUser();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
if (!requireOwner(user as any)) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const billing = await getOrgBilling(user.orgId);
return NextResponse.json({ billing });
}
export async function PUT(request: Request) {
const user = await getSessionUser();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
if (!requireOwner(user as any)) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const body = await request.json().catch(() => ({}));
const parsed = upsertSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: "Invalid request", details: parsed.error.flatten() },
{ status: 400 }
);
}
const data = parsed.data;
const billing = await upsertOrgBilling({
zitadelOrgId: user.orgId,
companyName: data.companyName,
contactName: data.contactName ?? null,
streetAddress: data.streetAddress,
postalCode: data.postalCode,
city: data.city,
country: data.country.toUpperCase(),
vatNumber: data.vatNumber ?? null,
billingEmail: data.billingEmail,
notes: data.notes ?? null,
});
return NextResponse.json({ billing });
}

View 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)"
}`
);
}

View File

@@ -0,0 +1,249 @@
"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",
});
};
// Phase 6: surface failures prominently. Any run in the recent
// window with a non-zero failure_count drives a top-of-page
// banner — the row in the table is already red, but a banner
// means the admin doesn't have to scroll to notice.
const recentFailures = recent.filter((r) => r.failureCount > 0);
const hasRecentFailures = recentFailures.length > 0;
return (
<div className="space-y-8">
{hasRecentFailures && (
<div className="p-4 rounded-md border border-error bg-error/10 text-sm text-error">
<p className="font-medium mb-1">{t("failureBannerTitle")}</p>
<p className="text-xs">
{t("failureBannerBody", { count: recentFailures.length })}
</p>
</div>
)}
<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 ${
r.failureCount > 0 ? "bg-error/5" : ""
}`}
>
<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>
);
}

View File

@@ -1,6 +1,8 @@
import { useTranslations, useFormatter } from "next-intl"; import { useTranslations, useFormatter } from "next-intl";
import { Card } from "@/components/ui/card"; import { Card } from "@/components/ui/card";
import type { Invoice, InvoiceLine } from "@/types"; import type { Invoice, InvoiceLine } from "@/types";
import { PayInvoiceButton } from "./pay-invoice-button";
import { PaymentStatusBanner } from "./payment-status-banner";
interface Props { interface Props {
invoice: Invoice; invoice: Invoice;
@@ -8,7 +10,7 @@ interface Props {
} }
const statusColors: Record<string, string> = { const statusColors: Record<string, string> = {
issued: "text-text-secondary bg-surface-3", open: "text-text-secondary bg-surface-3",
paid: "text-success bg-success/10", paid: "text-success bg-success/10",
overdue: "text-error bg-error/10", overdue: "text-error bg-error/10",
void: "text-text-muted bg-surface-3", void: "text-text-muted bg-surface-3",
@@ -29,6 +31,7 @@ export function CustomerInvoiceDetail({ invoice, lines }: Props) {
return ( return (
<div className="space-y-6 animate-in"> <div className="space-y-6 animate-in">
<PaymentStatusBanner />
<div className="flex items-start justify-between gap-4 flex-wrap"> <div className="flex items-start justify-between gap-4 flex-wrap">
<div> <div>
<div className="flex items-center gap-3 mb-2"> <div className="flex items-center gap-3 mb-2">
@@ -49,14 +52,23 @@ export function CustomerInvoiceDetail({ invoice, lines }: Props) {
{fmt.dateTime(new Date(invoice.periodEnd), { dateStyle: "long" })} {fmt.dateTime(new Date(invoice.periodEnd), { dateStyle: "long" })}
</p> </p>
</div> </div>
<a <div className="flex items-start gap-2 flex-wrap">
href={`/api/billing/invoices/${encodeURIComponent(invoice.invoiceNumber)}/pdf`} {/* Phase 4: Pay-with-card available for open + overdue.
target="_blank" Paid/void/draft/uncollectible hide the button — the
rel="noopener noreferrer" API also enforces this, so client-side hiding is just
className="px-4 py-2 rounded-md bg-accent text-white text-sm font-medium hover:bg-accent-dim transition-colors" for the visible affordance. */}
> {(invoice.status === "open" || invoice.status === "overdue") && (
{t("downloadPdf")} <PayInvoiceButton invoiceNumber={invoice.invoiceNumber} />
</a> )}
<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> </div>
<Card> <Card>

View File

@@ -8,7 +8,7 @@ interface Props {
} }
const statusColors: Record<string, string> = { const statusColors: Record<string, string> = {
issued: "text-text-secondary bg-surface-3", open: "text-text-secondary bg-surface-3",
paid: "text-success bg-success/10", paid: "text-success bg-success/10",
overdue: "text-error bg-error/10", overdue: "text-error bg-error/10",
void: "text-text-muted bg-surface-3 line-through", void: "text-text-muted bg-surface-3 line-through",

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

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

View File

@@ -11,6 +11,17 @@ type CurrentResponse =
| { draft: InvoiceDraft } | { draft: InvoiceDraft }
| { error: string; code?: string }; | { error: string; code?: string };
interface Props {
/**
* Whether the viewing user has org-owner role. Drives the
* "complete your billing details" CTA — only owners can edit
* billing settings, so non-owners see a softer message asking
* them to contact their org owner instead. The flag is computed
* server-side and passed in to avoid a second API round-trip.
*/
isOwner: boolean;
}
/** /**
* Live running total for the current calendar month. * Live running total for the current calendar month.
* *
@@ -28,7 +39,7 @@ type CurrentResponse =
* No polling — the page is static enough that an explicit * No polling — the page is static enough that an explicit
* "refresh" link is good enough if the user wants newer numbers. * "refresh" link is good enough if the user wants newer numbers.
*/ */
export function RunningTotalWidget() { export function RunningTotalWidget({ isOwner }: Props) {
const t = useTranslations("customerBilling"); const t = useTranslations("customerBilling");
const fmt = useFormatter(); const fmt = useFormatter();
const [data, setData] = useState<CurrentResponse | null>(null); const [data, setData] = useState<CurrentResponse | null>(null);
@@ -62,13 +73,29 @@ export function RunningTotalWidget() {
); );
} }
if (!data || "error" in data) { if (!data || "error" in data) {
const noConfig =
data && "code" in data && data.code === "COMPUTE_FAILED";
return ( return (
<Card> <Card>
<p className="text-sm text-text-secondary py-2"> <p className="text-sm text-text-secondary py-2">
{data && "code" in data && data.code === "COMPUTE_FAILED" {noConfig ? t("noBillingConfig") : t("currentPeriodError")}
? t("noBillingConfig")
: t("currentPeriodError")}
</p> </p>
{/* Phase 6: owner-only CTA. Non-owners can't edit billing
settings, so we show them a "contact owner" hint instead
— that's gentler than a button that 404s on click. */}
{noConfig && isOwner && (
<Link
href="/settings/billing"
className="inline-block mt-2 px-4 py-2 rounded-md bg-accent text-white text-sm font-medium hover:bg-accent-dim transition-colors"
>
{t("configureBillingCta")}
</Link>
)}
{noConfig && !isOwner && (
<p className="text-xs text-text-muted italic mt-2">
{t("noBillingConfigNonOwner")}
</p>
)}
</Card> </Card>
); );
} }

View File

@@ -74,17 +74,6 @@ function NavBar() {
{t("settings")} {t("settings")}
</NavLink> </NavLink>
)} )}
{/* Feature 5: Support is available to every signed-in
user. Customers see their own tickets only; platform
admins see the queue. */}
{user && (
<NavLink
href="/support"
active={pathname.startsWith("/support")}
>
{t("support")}
</NavLink>
)}
{/* Phase 3: Billing visible to anyone signed in. The {/* Phase 3: Billing visible to anyone signed in. The
page is org-scoped server-side — non-owner members page is org-scoped server-side — non-owner members
see the same invoice history their owner does, but see the same invoice history their owner does, but
@@ -99,6 +88,17 @@ function NavBar() {
{t("billing")} {t("billing")}
</NavLink> </NavLink>
)} )}
{/* Feature 5: Support is available to every signed-in
user. Customers see their own tickets only; platform
admins see the queue. */}
{user && (
<NavLink
href="/support"
active={pathname.startsWith("/support")}
>
{t("support")}
</NavLink>
)}
{user?.isPlatform && ( {user?.isPlatform && (
<NavLink href="/admin" active={pathname === "/admin"}> <NavLink href="/admin" active={pathname === "/admin"}>
{t("admin")} {t("admin")}

View File

@@ -2,6 +2,7 @@
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { OnboardingWizard } from "./wizard"; import { OnboardingWizard } from "./wizard";
import type { OrgBilling } from "@/types";
interface OnboardingFlowProps { interface OnboardingFlowProps {
orgName: string; orgName: string;
@@ -19,6 +20,12 @@ interface OnboardingFlowProps {
* /settings/billing. * /settings/billing.
*/ */
hasOrgBilling?: boolean; hasOrgBilling?: boolean;
/**
* Phase 6 fix3: the actual org_billing record (or null). Drives
* the review-step "Billing to" rendering AND the confirm-step
* validation skip when the billing step was skipped.
*/
existingOrgBilling?: OrgBilling | null;
/** /**
* Bug 6: when present, the wizard is rendered in edit mode against * Bug 6: when present, the wizard is rendered in edit mode against
* the given pending request. See `OnboardingWizard` for the full * the given pending request. See `OnboardingWizard` for the full
@@ -45,6 +52,7 @@ export function OnboardingFlow({
userName, userName,
userEmail, userEmail,
hasOrgBilling, hasOrgBilling,
existingOrgBilling,
editingRequest, editingRequest,
}: OnboardingFlowProps) { }: OnboardingFlowProps) {
const router = useRouter(); const router = useRouter();
@@ -55,6 +63,7 @@ export function OnboardingFlow({
userName={userName} userName={userName}
userEmail={userEmail} userEmail={userEmail}
hasOrgBilling={hasOrgBilling} hasOrgBilling={hasOrgBilling}
existingOrgBilling={existingOrgBilling}
editingRequest={editingRequest} editingRequest={editingRequest}
onComplete={() => { onComplete={() => {
// Navigate back to /dashboard and re-fetch on the server. The // Navigate back to /dashboard and re-fetch on the server. The

View File

@@ -13,6 +13,7 @@ import {
SUPPORTED_COUNTRIES, SUPPORTED_COUNTRIES,
type SupportedCountry, type SupportedCountry,
} from "@/lib/validation"; } from "@/lib/validation";
import type { OrgBilling } from "@/types";
type Step = "welcome" | "configure" | "billing" | "confirm"; type Step = "welcome" | "configure" | "billing" | "confirm";
@@ -96,6 +97,17 @@ interface WizardProps {
* fix it before admin approves. * fix it before admin approves.
*/ */
hasOrgBilling?: boolean; hasOrgBilling?: boolean;
/**
* Phase 6 fix3: the actual org_billing record when one exists.
* Used to render real values on the review-step "Billing to" block
* (rather than the wizard's empty default config.billingAddress)
* AND to skip the confirm-step's client-side validation of
* billingAddress — same logic that already strips billingAddress
* at submit time. Null when no org_billing row exists yet.
* Ignored in edit mode (the editingRequest carries its own
* billingAddress snapshot).
*/
existingOrgBilling?: OrgBilling | null;
/** /**
* Bug 6: when present, the wizard renders in "edit" mode — fields * Bug 6: when present, the wizard renders in "edit" mode — fields
* are pre-populated from the request, the SOUL.md auto-fetch is * are pre-populated from the request, the SOUL.md auto-fetch is
@@ -134,6 +146,7 @@ export function OnboardingWizard({
userName, userName,
userEmail, userEmail,
hasOrgBilling, hasOrgBilling,
existingOrgBilling,
editingRequest, editingRequest,
onComplete, onComplete,
}: WizardProps) { }: WizardProps) {
@@ -319,7 +332,23 @@ export function OnboardingWizard({
} }
// confirm: validate the union (defence in depth — submit handler // confirm: validate the union (defence in depth — submit handler
// also runs onboardingSchema before POST). // also runs onboardingSchema before POST).
const r = onboardingSchema.safeParse(config); //
// Phase 6 fix3: when hasOrgBilling=true AND not editing, the
// billing step was skipped and config.billingAddress is the
// empty default. zod's .optional() doesn't help here because the
// field IS present (empty object), so billingAddressSchema
// validates it and fails with required-field errors that the
// user has no way to fix — the form to enter the values was
// skipped on purpose. Strip the field for validation, matching
// the same strip we already do at submit time.
const configForValidation =
hasOrgBilling && !isEditing
? (() => {
const { billingAddress: _b, ...rest } = config;
return rest;
})()
: config;
const r = onboardingSchema.safeParse(configForValidation);
if (r.success) { if (r.success) {
setErrors({}); setErrors({});
return true; return true;
@@ -1101,42 +1130,84 @@ export function OnboardingWizard({
<ReviewRow <ReviewRow
label={t("reviewBillingTo")} label={t("reviewBillingTo")}
value={ value={
<div className="text-text-primary text-right"> (() => {
{/* For personal: skip the company line so the // Phase 6 fix3: when the org has billing on file
invoice rendering matches what the user actually // and we're not editing, render the saved
entered. For company: include it as the first // org_billing record (the authoritative source)
line. */} // rather than config.billingAddress, which is the
{!isPersonal && // wizard's empty default state because the billing
config.billingAddress.company && // step was skipped. In edit mode, fall back to
config.billingAddress.company.trim().length > 0 && ( // config.billingAddress, which is pre-populated
<div>{config.billingAddress.company}</div> // from the request being edited.
)} const useSaved =
<div>{config.billingAddress.street}</div> hasOrgBilling && !isEditing && existingOrgBilling;
<div> const company = useSaved
{config.billingAddress.postalCode}{" "} ? existingOrgBilling!.companyName
{config.billingAddress.city} : config.billingAddress.company;
</div> const street = useSaved
<div className="text-text-muted"> ? existingOrgBilling!.streetAddress
{tCountries( : config.billingAddress.street;
config.billingAddress.country as SupportedCountry const postalCode = useSaved
)} ? existingOrgBilling!.postalCode
</div> : config.billingAddress.postalCode;
</div> const city = useSaved
? existingOrgBilling!.city
: config.billingAddress.city;
const country = useSaved
? existingOrgBilling!.country
: config.billingAddress.country;
const contactName = useSaved
? existingOrgBilling!.contactName
: null;
return (
<div className="text-text-primary text-right">
{/* For personal: skip the company line so the
invoice rendering matches what the user actually
entered. For company: include it as the first
line. */}
{!isPersonal &&
company &&
company.trim().length > 0 && <div>{company}</div>}
{/* Phase 6 fix2: optional contact-person line
("z.Hd. <name>") only present when the saved
org_billing has it set. */}
{contactName && contactName.trim().length > 0 && (
<div className="text-text-muted">
{t("reviewContactPersonPrefix")} {contactName}
</div>
)}
<div>{street}</div>
<div>
{postalCode} {city}
</div>
<div className="text-text-muted">
{tCountries(country as SupportedCountry)}
</div>
</div>
);
})()
} }
/> />
{/* Bug 35: VAT review row. Company customers see this so {/* Bug 35: VAT review row. Company customers see this so
they can verify the VAT id they typed before submitting. they can verify the VAT id they typed before submitting.
Personal customers never see it — they don't have a Personal customers never see it — they don't have a
VAT number, the form didn't ask, the review hides it. */} VAT number, the form didn't ask, the review hides it.
Phase 6 fix3: when reading from existingOrgBilling,
the value comes from there too. */}
{!isPersonal && {!isPersonal &&
config.billingAddress.vatNumber && (() => {
config.billingAddress.vatNumber.trim().length > 0 && ( const vat =
<ReviewRow hasOrgBilling && !isEditing && existingOrgBilling
label={t("billingVatNumber")} ? existingOrgBilling.vatNumber
value={config.billingAddress.vatNumber} : config.billingAddress.vatNumber;
mono return vat && vat.trim().length > 0 ? (
/> <ReviewRow
)} label={t("billingVatNumber")}
value={vat}
mono
/>
) : null;
})()}
<ReviewRow <ReviewRow
label={t("reviewContactEmail")} label={t("reviewContactEmail")}
value={userEmail || ""} value={userEmail || ""}

View File

@@ -0,0 +1,263 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { Card } from "@/components/ui/card";
import type { OrgBilling } from "@/types";
interface Props {
initial: OrgBilling | null;
/**
* Personal-account (individual customer) flag from the session.
* Individuals get a "Full name" field instead of "Company name",
* and the VAT input is hidden entirely — they don't have one and
* showing the field would only confuse. The underlying column is
* still `company_name` in the DB and the invoice PDF; for an
* individual that field carries their full name, which is
* exactly what should print on the invoice.
*/
isPersonal: boolean;
}
/**
* Customer billing settings form. Drives PUT /api/settings/billing
* which upserts org_billing for the caller's org.
*
* Validation is the same regex as the server-side zod schema for
* the country field (ISO 3166-1 alpha-2). Other fields are checked
* for required + max-length client-side; the server is the
* authority and re-validates everything.
*
* On success we router.refresh() the page so the server component
* re-fetches and any "create now" -> "edit" wording flips.
*/
export function BillingSettingsForm({ initial, isPersonal }: Props) {
const t = useTranslations("settingsBilling");
const router = useRouter();
const [form, setForm] = useState({
companyName: initial?.companyName ?? "",
contactName: initial?.contactName ?? "",
streetAddress: initial?.streetAddress ?? "",
postalCode: initial?.postalCode ?? "",
city: initial?.city ?? "",
country: initial?.country ?? "CH",
vatNumber: initial?.vatNumber ?? "",
billingEmail: initial?.billingEmail ?? "",
notes: initial?.notes ?? "",
});
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [savedFlash, setSavedFlash] = useState(false);
const set =
(field: keyof typeof form) =>
(e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) =>
setForm((f) => ({ ...f, [field]: e.target.value }));
const submit = async () => {
setError(null);
setSavedFlash(false);
// Client-side gate on required fields — the server re-validates.
if (
!form.companyName.trim() ||
!form.streetAddress.trim() ||
!form.postalCode.trim() ||
!form.city.trim() ||
!form.country.trim() ||
!form.billingEmail.trim()
) {
setError(t("missingRequired"));
return;
}
if (!/^[A-Za-z]{2}$/.test(form.country.trim())) {
setError(t("invalidCountry"));
return;
}
if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(form.billingEmail.trim())) {
setError(t("invalidEmail"));
return;
}
setBusy(true);
try {
const res = await fetch("/api/settings/billing", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
companyName: form.companyName.trim(),
// Personal accounts don't have a contact-name field
// (companyName IS their name); force null so stale state
// from a previously-org-flagged account can't carry over.
contactName: isPersonal ? null : form.contactName.trim() || null,
streetAddress: form.streetAddress.trim(),
postalCode: form.postalCode.trim(),
city: form.city.trim(),
country: form.country.trim().toUpperCase(),
// Personal accounts never have a VAT number — force null
// regardless of stale state, in case a value was stored
// before the account got flagged as personal.
vatNumber: isPersonal ? null : form.vatNumber.trim() || null,
billingEmail: form.billingEmail.trim(),
notes: form.notes.trim() || null,
}),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
throw new Error(data.error ?? `HTTP ${res.status}`);
}
setSavedFlash(true);
router.refresh();
} catch (e: any) {
setError(e?.message ?? String(e));
} finally {
setBusy(false);
}
};
return (
<Card>
<div className="space-y-4">
<Field
label={isPersonal ? t("fullNameLabel") : t("companyNameLabel")}
required
>
<input
type="text"
value={form.companyName}
onChange={set("companyName")}
maxLength={200}
className="w-full px-3 py-2 rounded-md bg-surface-2 border border-border focus:border-accent focus:outline-none text-sm"
/>
</Field>
{!isPersonal && (
<Field label={t("contactNameLabel")} hint={t("contactNameHint")}>
<input
type="text"
value={form.contactName}
onChange={set("contactName")}
maxLength={200}
className="w-full px-3 py-2 rounded-md bg-surface-2 border border-border focus:border-accent focus:outline-none text-sm"
/>
</Field>
)}
<Field label={t("streetAddressLabel")} required>
<input
type="text"
value={form.streetAddress}
onChange={set("streetAddress")}
maxLength={200}
className="w-full px-3 py-2 rounded-md bg-surface-2 border border-border focus:border-accent focus:outline-none text-sm"
/>
</Field>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<Field label={t("postalCodeLabel")} required>
<input
type="text"
value={form.postalCode}
onChange={set("postalCode")}
maxLength={20}
className="w-full px-3 py-2 rounded-md bg-surface-2 border border-border focus:border-accent focus:outline-none text-sm"
/>
</Field>
<Field label={t("cityLabel")} required>
<input
type="text"
value={form.city}
onChange={set("city")}
maxLength={100}
className="w-full px-3 py-2 rounded-md bg-surface-2 border border-border focus:border-accent focus:outline-none text-sm"
/>
</Field>
<Field
label={t("countryLabel")}
required
hint={t("countryHint")}
>
<input
type="text"
value={form.country}
onChange={(e) =>
setForm((f) => ({
...f,
country: e.target.value.toUpperCase().slice(0, 2),
}))
}
maxLength={2}
className="w-full px-3 py-2 rounded-md bg-surface-2 border border-border focus:border-accent focus:outline-none text-sm uppercase font-mono"
/>
</Field>
</div>
{!isPersonal && (
<Field label={t("vatNumberLabel")} hint={t("vatNumberHint")}>
<input
type="text"
value={form.vatNumber}
onChange={set("vatNumber")}
maxLength={40}
placeholder="CHE-123.456.789 MWST"
className="w-full px-3 py-2 rounded-md bg-surface-2 border border-border focus:border-accent focus:outline-none text-sm font-mono"
/>
</Field>
)}
<Field label={t("billingEmailLabel")} required hint={t("billingEmailHint")}>
<input
type="email"
value={form.billingEmail}
onChange={set("billingEmail")}
maxLength={200}
className="w-full px-3 py-2 rounded-md bg-surface-2 border border-border focus:border-accent focus:outline-none text-sm"
/>
</Field>
<Field label={t("notesLabel")} hint={t("notesHint")}>
<textarea
value={form.notes}
onChange={set("notes")}
maxLength={2000}
rows={3}
className="w-full px-3 py-2 rounded-md bg-surface-2 border border-border focus:border-accent focus:outline-none text-sm"
/>
</Field>
{error && (
<p className="text-sm text-error">{error}</p>
)}
{savedFlash && (
<p className="text-sm text-success">{t("saved")}</p>
)}
<div className="flex justify-end">
<button
onClick={submit}
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("saving") : initial ? t("saveChanges") : t("createBilling")}
</button>
</div>
</div>
</Card>
);
}
function Field({
label,
required,
hint,
children,
}: {
label: string;
required?: boolean;
hint?: string;
children: React.ReactNode;
}) {
return (
<div>
<label className="block text-xs uppercase tracking-wider text-text-muted mb-1">
{label}
{required && <span className="text-error ml-1">*</span>}
</label>
{children}
{hint && (
<p className="text-xs text-text-muted mt-1 italic">{hint}</p>
)}
</div>
);
}

View File

@@ -80,6 +80,11 @@ interface PdfStrings {
dueDate: string; dueDate: string;
period: string; period: string;
billTo: string; billTo: string;
// Phase 6 fix: prefix shown before the optional contact-person
// name on the bill-to block. "z.Hd." (DE) / "Attn:" (EN) /
// "À l'attention de" (FR) / "c.a." (IT). Empty/unused when the
// invoice has no contactName on its snapshot.
attentionPrefix: string;
description: string; description: string;
quantity: string; quantity: string;
unitPrice: string; unitPrice: string;
@@ -107,6 +112,7 @@ const MESSAGES: Record<string, PdfStrings> = {
dueDate: "Zahlbar bis", dueDate: "Zahlbar bis",
period: "Abrechnungsperiode", period: "Abrechnungsperiode",
billTo: "Rechnungsempfänger", billTo: "Rechnungsempfänger",
attentionPrefix: "z.Hd.",
description: "Beschreibung", description: "Beschreibung",
quantity: "Menge", quantity: "Menge",
unitPrice: "Einzelpreis", unitPrice: "Einzelpreis",
@@ -139,6 +145,7 @@ const MESSAGES: Record<string, PdfStrings> = {
dueDate: "Due date", dueDate: "Due date",
period: "Billing period", period: "Billing period",
billTo: "Bill to", billTo: "Bill to",
attentionPrefix: "Attn:",
description: "Description", description: "Description",
quantity: "Qty", quantity: "Qty",
unitPrice: "Unit price", unitPrice: "Unit price",
@@ -171,6 +178,7 @@ const MESSAGES: Record<string, PdfStrings> = {
dueDate: "Échéance", dueDate: "Échéance",
period: "Période de facturation", period: "Période de facturation",
billTo: "Destinataire", billTo: "Destinataire",
attentionPrefix: "À l'attention de",
description: "Description", description: "Description",
quantity: "Qté", quantity: "Qté",
unitPrice: "Prix unitaire", unitPrice: "Prix unitaire",
@@ -203,6 +211,7 @@ const MESSAGES: Record<string, PdfStrings> = {
dueDate: "Scadenza", dueDate: "Scadenza",
period: "Periodo di fatturazione", period: "Periodo di fatturazione",
billTo: "Destinatario", billTo: "Destinatario",
attentionPrefix: "c.a.",
description: "Descrizione", description: "Descrizione",
quantity: "Qtà", quantity: "Qtà",
unitPrice: "Prezzo unitario", unitPrice: "Prezzo unitario",
@@ -524,6 +533,15 @@ const InvoicePdf: React.FC<InvoicePdfProps> = ({ invoice, lines }) => {
<View style={styles.billToBlock}> <View style={styles.billToBlock}>
<Text style={styles.billToLabel}>{s.billTo}</Text> <Text style={styles.billToLabel}>{s.billTo}</Text>
<Text style={styles.billToName}>{snap.companyName}</Text> <Text style={styles.billToName}>{snap.companyName}</Text>
{/* Phase 6 fix: optional "z.Hd." / "Attn:" line for routing
the printed invoice internally at the customer. Prints
between the company name and street address, in the
invoice's locale (frozen at issue time). */}
{snap.contactName && (
<Text>
{s.attentionPrefix} {snap.contactName}
</Text>
)}
<Text>{snap.streetAddress}</Text> <Text>{snap.streetAddress}</Text>
<Text> <Text>
{snap.postalCode} {snap.city} {snap.postalCode} {snap.city}

View File

@@ -645,6 +645,7 @@ export async function computeInvoiceDraft(opts: {
} }
const snapshot: InvoiceBillingSnapshot = { const snapshot: InvoiceBillingSnapshot = {
companyName: orgBilling.companyName, companyName: orgBilling.companyName,
contactName: orgBilling.contactName ?? null,
streetAddress: orgBilling.streetAddress, streetAddress: orgBilling.streetAddress,
postalCode: orgBilling.postalCode, postalCode: orgBilling.postalCode,
city: orgBilling.city, city: orgBilling.city,

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

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

View File

@@ -198,6 +198,12 @@ const MIGRATION_SQL = `
CREATE TABLE IF NOT EXISTS org_billing ( CREATE TABLE IF NOT EXISTS org_billing (
zitadel_org_id TEXT PRIMARY KEY, zitadel_org_id TEXT PRIMARY KEY,
company_name TEXT NOT NULL, company_name TEXT NOT NULL,
-- Phase 6 fix: optional contact-person line shown on the
-- invoice PDF below the company name (e.g. "z.Hd. Herr Müller").
-- Not normally needed since invoices are delivered by email
-- link, but useful when customers forward the PDF internally
-- for AP routing in larger organizations.
contact_name TEXT,
street_address TEXT NOT NULL, street_address TEXT NOT NULL,
postal_code TEXT NOT NULL, postal_code TEXT NOT NULL,
city TEXT NOT NULL, city TEXT NOT NULL,
@@ -208,6 +214,10 @@ const MIGRATION_SQL = `
created_at TIMESTAMPTZ NOT NULL DEFAULT now(), created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now() updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
); );
-- Phase 6 fix: ensure the column exists on databases that were
-- created before contact_name was added to the base schema above.
-- IF NOT EXISTS makes this safe to run repeatedly via ensureSchema.
ALTER TABLE org_billing ADD COLUMN IF NOT EXISTS contact_name TEXT;
-- Feature 5: lightweight customer support / feedback tickets. -- Feature 5: lightweight customer support / feedback tickets.
-- Scoped strictly per-user (zitadel_user_id), not per-org — -- Scoped strictly per-user (zitadel_user_id), not per-org —
@@ -500,7 +510,7 @@ const MIGRATION_SQL = `
-- NULL for org-wide items; tenant name for per-tenant breakdowns. -- NULL for org-wide items; tenant name for per-tenant breakdowns.
tenant_name TEXT, tenant_name TEXT,
kind TEXT NOT NULL CHECK (kind IN ( 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, description TEXT NOT NULL,
quantity NUMERIC(12,4) NOT NULL DEFAULT 1, quantity NUMERIC(12,4) NOT NULL DEFAULT 1,
@@ -563,6 +573,61 @@ const MIGRATION_SQL = `
-- Per-tenant lookup for the customer UI's pending+rejected display. -- Per-tenant lookup for the customer UI's pending+rejected display.
CREATE INDEX IF NOT EXISTS idx_skill_act_tenant CREATE INDEX IF NOT EXISTS idx_skill_act_tenant
ON skill_activation_requests (tenant_name, requested_at DESC); 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; let migrated = false;
@@ -1207,6 +1272,7 @@ function rowToOrgBilling(row: any): OrgBilling {
return { return {
zitadelOrgId: row.zitadel_org_id, zitadelOrgId: row.zitadel_org_id,
companyName: row.company_name, companyName: row.company_name,
contactName: row.contact_name ?? null,
streetAddress: row.street_address, streetAddress: row.street_address,
postalCode: row.postal_code, postalCode: row.postal_code,
city: row.city, city: row.city,
@@ -1251,12 +1317,13 @@ export async function upsertOrgBilling(
await ensureSchema(); await ensureSchema();
const result = await getPool().query( const result = await getPool().query(
`INSERT INTO org_billing ( `INSERT INTO org_billing (
zitadel_org_id, company_name, street_address, postal_code, zitadel_org_id, company_name, contact_name, street_address,
city, country, vat_number, billing_email, notes postal_code, city, country, vat_number, billing_email, notes
) )
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
ON CONFLICT (zitadel_org_id) DO UPDATE SET ON CONFLICT (zitadel_org_id) DO UPDATE SET
company_name = EXCLUDED.company_name, company_name = EXCLUDED.company_name,
contact_name = EXCLUDED.contact_name,
street_address = EXCLUDED.street_address, street_address = EXCLUDED.street_address,
postal_code = EXCLUDED.postal_code, postal_code = EXCLUDED.postal_code,
city = EXCLUDED.city, city = EXCLUDED.city,
@@ -1269,6 +1336,7 @@ export async function upsertOrgBilling(
[ [
data.zitadelOrgId, data.zitadelOrgId,
data.companyName, data.companyName,
data.contactName ?? null,
data.streetAddress, data.streetAddress,
data.postalCode, data.postalCode,
data.city, data.city,
@@ -2825,3 +2893,298 @@ export async function updateSkillActivationRequestStatus(
? rowToSkillActivationRequest(result.rows[0]) ? rowToSkillActivationRequest(result.rows[0])
: null; : 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;
}

View File

@@ -1014,3 +1014,147 @@ export async function sendInvoiceIssuedEmail(params: {
console.error("Failed to send invoice issued email:", 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
View 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 };
}

View File

@@ -121,7 +121,8 @@
"saveChanges": "Änderungen speichern", "saveChanges": "Änderungen speichern",
"billingVatNumber": "MWST-Nummer", "billingVatNumber": "MWST-Nummer",
"billingVatHelp": "Ihre registrierte MWST-Nummer. Falls Ihre Firma von der MWST befreit ist, leer lassen und in den Notizen erläutern.", "billingVatHelp": "Ihre registrierte MWST-Nummer. Falls Ihre Firma von der MWST befreit ist, leer lassen und in den Notizen erläutern.",
"billingNotesPlaceholderPersonal": "Was wir wissen sollten — bevorzugte Zahlungsart, Rechnungsreferenz, etc." "billingNotesPlaceholderPersonal": "Was wir wissen sollten — bevorzugte Zahlungsart, Rechnungsreferenz, etc.",
"reviewContactPersonPrefix": "z.Hd."
}, },
"dashboard": { "dashboard": {
"title": "Dashboard", "title": "Dashboard",
@@ -393,7 +394,8 @@
"resumeRequestTooltip": "Reaktivierungsanfrage für einen bestehenden Tenant. Bei Genehmigung wird der Tenant wieder aktiviert; keine Provisionierung läuft.", "resumeRequestTooltip": "Reaktivierungsanfrage für einen bestehenden Tenant. Bei Genehmigung wird der Tenant wieder aktiviert; keine Provisionierung läuft.",
"openclawTool": "OpenClaw-Versionen", "openclawTool": "OpenClaw-Versionen",
"billingTool": "Abrechnung →", "billingTool": "Abrechnung →",
"skillsQueueTool": "Aktivierungs-Warteschlange" "skillsQueueTool": "Aktivierungs-Warteschlange",
"cronTool": "Automatisierung"
}, },
"channelUsers": { "channelUsers": {
"title": "Autorisierte Benutzer", "title": "Autorisierte Benutzer",
@@ -481,25 +483,31 @@
"billingDescriptionPersonal": "Adresse und Rechnungs-E-Mail für alle Ihre Tenants." "billingDescriptionPersonal": "Adresse und Rechnungs-E-Mail für alle Ihre Tenants."
}, },
"settingsBilling": { "settingsBilling": {
"title": "Abrechnung", "title": "Rechnungsdaten",
"subtitle": "Wird beim ersten Onboarding einmalig erfasst und für jeden Tenant Ihrer Organisation wiederverwendet. Aktualisieren Sie hier, wenn sich Ihre Abrechnungsdaten ändern.", "subtitle": "Rechnungsadresse, MWST-Nummer und Rechnungskontakt Ihres Unternehmens. Erforderlich, bevor Rechnungen für Ihre Organisation ausgestellt werden können.",
"companyName": "Firmenname", "companyNameLabel": "Firmenname",
"streetAddress": "Strasse", "streetAddressLabel": "Strasse und Hausnummer",
"postalCode": "PLZ", "postalCodeLabel": "PLZ",
"city": "Ort", "cityLabel": "Ort",
"country": "Land", "countryLabel": "Ländercode",
"vatNumber": "MWST-Nummer", "countryHint": "ISO 3166-1 alpha-2 — z.B. CH, DE, AT, FR, IT, GB, US",
"vatHelp": "Ihre registrierte MWST-Nummer (z. B. CHE-123.456.789 MWST für die Schweiz).", "vatNumberLabel": "MWST-Nummer (optional)",
"billingEmail": "Rechnungs-E-Mail", "vatNumberHint": "Für Schweizer Kunden: CHE-XXX.XXX.XXX MWST. EU-Kunden mit USt-IdNr. erhalten eine Reverse-Charge-Rechnung (0% MWST).",
"billingEmailHelp": "An diese Adresse werden Rechnungen und Abrechnungskommunikation gesendet.", "billingEmailLabel": "Rechnungs-E-Mail",
"notes": "Notizen", "billingEmailHint": "Rechnungen und Zahlungserinnerungen werden an diese Adresse gesendet. Kann von Ihrer Konto-E-Mail abweichen.",
"notesPlaceholder": "Alles, was die Buchhaltung wissen muss MWST-Befreiung, besondere Rechnungsstellung usw.", "notesLabel": "Bemerkungen (optional)",
"save": "Speichern", "notesHint": "Referenznummern, Bestellnummern oder andere Angaben, die auf der Rechnung erscheinen sollen.",
"saveChanges": "Änderungen speichern",
"createBilling": "Rechnungsdaten speichern",
"saving": "Speichern…",
"saved": "Gespeichert.", "saved": "Gespeichert.",
"saveFailed": "Konnte nicht gespeichert werden. Bitte erneut versuchen.", "missingRequired": "Bitte alle Pflichtfelder ausfüllen.",
"lastUpdated": "Zuletzt aktualisiert {when}", "invalidCountry": "Ländercode muss aus 2 Buchstaben bestehen (z.B. CH).",
"fullName": "Voller Name", "invalidEmail": "Bitte eine gültige E-Mail-Adresse eingeben.",
"notesPlaceholderPersonal": "Was wir wissen sollten — bevorzugte Zahlungsart, Rechnungsreferenz, etc." "fullNameLabel": "Vor- und Nachname",
"subtitlePersonal": "Ihre Rechnungsadresse und Rechnungskontakt. Erforderlich, bevor Rechnungen ausgestellt werden können.",
"contactNameLabel": "Ansprechperson (optional)",
"contactNameHint": "Erscheint als 'z.Hd. <Name>' auf der Rechnung unter dem Firmennamen. Hilfreich für die Zuordnung in der Buchhaltung grösserer Firmen."
}, },
"support": { "support": {
"title": "Support", "title": "Support",
@@ -731,10 +739,50 @@
"totalLabel": "Gesamt", "totalLabel": "Gesamt",
"downloadPdf": "PDF herunterladen", "downloadPdf": "PDF herunterladen",
"status": { "status": {
"issued": "Ausgestellt", "draft": "Entwurf",
"open": "Offen",
"paid": "Bezahlt", "paid": "Bezahlt",
"overdue": "Überfällig", "overdue": "Überfällig",
"void": "Storniert" "void": "Storniert",
} "uncollectible": "Uneinbringlich"
},
"payWithCard": "Mit Karte bezahlen",
"redirectingToStripe": "Weiterleitung…",
"paymentReceived": "Zahlung erhalten — vielen Dank!",
"paymentCancelled": "Zahlung abgebrochen.",
"configureBillingCta": "Rechnungsdaten einrichten",
"noBillingConfigNonOwner": "Nur der Organisations-Owner kann die Rechnungsdaten einrichten. Bitte wenden Sie sich an diese Person, um diesen Schritt abzuschliessen."
},
"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"
},
"failureBannerTitle": "Fehler in jüngsten Automatisierungsläufen",
"failureBannerBody": "{count} Lauf/Läufe im aktuellen Fenster haben mindestens einen Fehler gemeldet. Bitte die Tabelle unten prüfen — betroffene Zeilen sind rot hervorgehoben."
} }
} }

View File

@@ -121,7 +121,8 @@
"saveChanges": "Save changes", "saveChanges": "Save changes",
"billingVatNumber": "VAT number", "billingVatNumber": "VAT number",
"billingVatHelp": "Your registered VAT identifier. If your company is VAT-exempt, leave blank and explain in the notes field.", "billingVatHelp": "Your registered VAT identifier. If your company is VAT-exempt, leave blank and explain in the notes field.",
"billingNotesPlaceholderPersonal": "Anything we should know — preferred payment method, billing reference, etc." "billingNotesPlaceholderPersonal": "Anything we should know — preferred payment method, billing reference, etc.",
"reviewContactPersonPrefix": "Attn:"
}, },
"dashboard": { "dashboard": {
"title": "Dashboard", "title": "Dashboard",
@@ -393,7 +394,8 @@
"resumeRequestTooltip": "Reactivation request for an existing tenant. Approving will un-suspend the tenant; no provisioning runs.", "resumeRequestTooltip": "Reactivation request for an existing tenant. Approving will un-suspend the tenant; no provisioning runs.",
"openclawTool": "OpenClaw versions", "openclawTool": "OpenClaw versions",
"billingTool": "Billing →", "billingTool": "Billing →",
"skillsQueueTool": "Activation Queue" "skillsQueueTool": "Activation Queue",
"cronTool": "Automation"
}, },
"channelUsers": { "channelUsers": {
"title": "Authorized Users", "title": "Authorized Users",
@@ -481,25 +483,31 @@
"billingDescriptionPersonal": "Address and invoice email used for all your tenants." "billingDescriptionPersonal": "Address and invoice email used for all your tenants."
}, },
"settingsBilling": { "settingsBilling": {
"title": "Billing", "title": "Billing details",
"subtitle": "Captured once at first onboarding and reused for every tenant in your organization. Update here whenever your billing details change.", "subtitle": "Your company's billing address, VAT number, and invoice contact. Required before invoices can be issued for your organization.",
"companyName": "Company name", "companyNameLabel": "Company name",
"streetAddress": "Street address", "streetAddressLabel": "Street address",
"postalCode": "Postal code", "postalCodeLabel": "Postal code",
"city": "City", "cityLabel": "City",
"country": "Country", "countryLabel": "Country code",
"vatNumber": "VAT number", "countryHint": "ISO 3166-1 alpha-2 — e.g. CH, DE, AT, FR, IT, GB, US",
"vatHelp": "Your registered VAT identifier (e.g. CHE-123.456.789 MWST for Switzerland).", "vatNumberLabel": "VAT number (optional)",
"billingEmail": "Billing email", "vatNumberHint": "For Swiss customers: CHE-XXX.XXX.XXX MWST. EU customers with a VAT number get a 0% reverse-charge invoice.",
"billingEmailHelp": "Where invoices and billing communication will be sent.", "billingEmailLabel": "Billing email",
"notes": "Notes", "billingEmailHint": "Invoices and payment reminders are sent here. Can differ from your account email.",
"notesPlaceholder": "Anything else accounting needs to know — VAT exemption, special invoicing arrangements, etc.", "notesLabel": "Notes (optional)",
"save": "Save", "notesHint": "Reference numbers, purchase order tags, or anything else you'd like printed on invoices.",
"saveChanges": "Save changes",
"createBilling": "Save billing details",
"saving": "Saving…",
"saved": "Saved.", "saved": "Saved.",
"saveFailed": "Could not save. Please try again.", "missingRequired": "Please fill in all required fields.",
"lastUpdated": "Last updated {when}", "invalidCountry": "Country code must be 2 letters (e.g. CH).",
"fullName": "Full name", "invalidEmail": "Please enter a valid email address.",
"notesPlaceholderPersonal": "Anything we should know — preferred payment method, billing reference, etc." "fullNameLabel": "Full name",
"subtitlePersonal": "Your billing address and invoice contact. Required before invoices can be issued.",
"contactNameLabel": "Contact person (optional)",
"contactNameHint": "Prints as 'Attn: <name>' on the invoice below the company name. Useful for AP routing in larger organizations."
}, },
"support": { "support": {
"title": "Support", "title": "Support",
@@ -731,10 +739,50 @@
"totalLabel": "Total", "totalLabel": "Total",
"downloadPdf": "Download PDF", "downloadPdf": "Download PDF",
"status": { "status": {
"issued": "Issued", "draft": "Draft",
"open": "Open",
"paid": "Paid", "paid": "Paid",
"overdue": "Overdue", "overdue": "Overdue",
"void": "Void" "void": "Void",
} "uncollectible": "Uncollectible"
},
"payWithCard": "Pay with card",
"redirectingToStripe": "Redirecting…",
"paymentReceived": "Payment received — thank you!",
"paymentCancelled": "Payment cancelled.",
"configureBillingCta": "Configure billing details",
"noBillingConfigNonOwner": "Only the organization owner can configure billing details. Please contact them to complete this step."
},
"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"
},
"failureBannerTitle": "Recent automation failures detected",
"failureBannerBody": "{count} run(s) in the recent window reported at least one failure. Review the table below — the affected rows are highlighted in red."
} }
} }

View File

@@ -121,7 +121,8 @@
"saveChanges": "Enregistrer les modifications", "saveChanges": "Enregistrer les modifications",
"billingVatNumber": "Numéro de TVA", "billingVatNumber": "Numéro de TVA",
"billingVatHelp": "Votre identifiant TVA enregistré. Si votre entreprise est exonérée de TVA, laissez vide et précisez dans les notes.", "billingVatHelp": "Votre identifiant TVA enregistré. Si votre entreprise est exonérée de TVA, laissez vide et précisez dans les notes.",
"billingNotesPlaceholderPersonal": "Tout ce que nous devons savoir — moyen de paiement préféré, référence de facturation, etc." "billingNotesPlaceholderPersonal": "Tout ce que nous devons savoir — moyen de paiement préféré, référence de facturation, etc.",
"reviewContactPersonPrefix": "À l'attention de"
}, },
"dashboard": { "dashboard": {
"title": "Tableau de bord", "title": "Tableau de bord",
@@ -393,7 +394,8 @@
"resumeRequestTooltip": "Demande de réactivation d'un locataire existant. L'approbation le réactivera ; aucun provisionnement ne s'exécute.", "resumeRequestTooltip": "Demande de réactivation d'un locataire existant. L'approbation le réactivera ; aucun provisionnement ne s'exécute.",
"openclawTool": "Versions OpenClaw", "openclawTool": "Versions OpenClaw",
"billingTool": "Facturation →", "billingTool": "Facturation →",
"skillsQueueTool": "File d'activation" "skillsQueueTool": "File d'activation",
"cronTool": "Automatisation"
}, },
"channelUsers": { "channelUsers": {
"title": "Utilisateurs autorisés", "title": "Utilisateurs autorisés",
@@ -481,25 +483,31 @@
"billingDescriptionPersonal": "Adresse et e-mail de facturation utilisés pour tous vos locataires." "billingDescriptionPersonal": "Adresse et e-mail de facturation utilisés pour tous vos locataires."
}, },
"settingsBilling": { "settingsBilling": {
"title": "Facturation", "title": "Informations de facturation",
"subtitle": "Saisie une fois lors de l'inscription et réutilisée pour chaque locataire de votre organisation. Mettez à jour ici dès que vos coordonnées de facturation changent.", "subtitle": "Adresse de facturation, numéro de TVA et contact pour les factures. Requis avant l'émission de toute facture pour votre organisation.",
"companyName": "Nom de l'entreprise", "companyNameLabel": "Nom de l'entreprise",
"streetAddress": "Adresse", "streetAddressLabel": "Adresse",
"postalCode": "Code postal", "postalCodeLabel": "Code postal",
"city": "Ville", "cityLabel": "Ville",
"country": "Pays", "countryLabel": "Code pays",
"vatNumber": "Numéro de TVA", "countryHint": "ISO 3166-1 alpha-2 — p. ex. CH, DE, AT, FR, IT, GB, US",
"vatHelp": "Votre identifiant TVA enregistré (par ex. CHE-123.456.789 TVA pour la Suisse).", "vatNumberLabel": "Numéro de TVA (facultatif)",
"billingEmail": "E-mail de facturation", "vatNumberHint": "Pour les clients suisses : CHE-XXX.XXX.XXX TVA. Les clients UE avec un n° de TVA reçoivent une facture à 0% (autoliquidation).",
"billingEmailHelp": "Adresse à laquelle les factures et la communication de facturation seront envoyées.", "billingEmailLabel": "E-mail de facturation",
"notes": "Notes", "billingEmailHint": "Les factures et rappels de paiement sont envoyés à cette adresse. Peut différer de l'e-mail du compte.",
"notesPlaceholder": "Tout ce que la comptabilité doit savoir exonération de TVA, modalités de facturation particulières, etc.", "notesLabel": "Notes (facultatif)",
"save": "Enregistrer", "notesHint": "Numéros de référence, bons de commande, ou toute autre information à imprimer sur les factures.",
"saveChanges": "Enregistrer les modifications",
"createBilling": "Enregistrer les informations",
"saving": "Enregistrement…",
"saved": "Enregistré.", "saved": "Enregistré.",
"saveFailed": "Impossible d'enregistrer. Veuillez réessayer.", "missingRequired": "Veuillez remplir tous les champs obligatoires.",
"lastUpdated": "Dernière mise à jour {when}", "invalidCountry": "Le code pays doit comporter 2 lettres (p. ex. CH).",
"fullName": "Nom complet", "invalidEmail": "Veuillez saisir une adresse e-mail valide.",
"notesPlaceholderPersonal": "Tout ce que nous devons savoir — moyen de paiement préféré, référence de facturation, etc." "fullNameLabel": "Nom et prénom",
"subtitlePersonal": "Votre adresse de facturation et votre contact. Requis avant l'émission de toute facture.",
"contactNameLabel": "Personne à contacter (facultatif)",
"contactNameHint": "S'imprime « À l'attention de <nom> » sur la facture, sous le nom de l'entreprise. Utile pour le routage en comptabilité dans les grandes organisations."
}, },
"support": { "support": {
"title": "Support", "title": "Support",
@@ -731,10 +739,50 @@
"totalLabel": "Total", "totalLabel": "Total",
"downloadPdf": "Télécharger le PDF", "downloadPdf": "Télécharger le PDF",
"status": { "status": {
"issued": "Émise", "draft": "Brouillon",
"open": "Ouverte",
"paid": "Payée", "paid": "Payée",
"overdue": "En retard", "overdue": "En retard",
"void": "Annulée" "void": "Annulée",
} "uncollectible": "Irrécouvrable"
},
"payWithCard": "Payer par carte",
"redirectingToStripe": "Redirection…",
"paymentReceived": "Paiement reçu — merci !",
"paymentCancelled": "Paiement annulé.",
"configureBillingCta": "Configurer les informations de facturation",
"noBillingConfigNonOwner": "Seul le propriétaire de l'organisation peut configurer les informations de facturation. Veuillez le contacter pour terminer cette étape."
},
"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"
},
"failureBannerTitle": "Échecs récents détectés",
"failureBannerBody": "{count} lancement(s) récent(s) ont signalé au moins un échec. Consultez le tableau ci-dessous — les lignes concernées sont en rouge."
} }
} }

View File

@@ -121,7 +121,8 @@
"saveChanges": "Salva modifiche", "saveChanges": "Salva modifiche",
"billingVatNumber": "Partita IVA", "billingVatNumber": "Partita IVA",
"billingVatHelp": "Il tuo identificativo IVA registrato. Se la tua azienda è esente IVA, lascia vuoto e spiega nelle note.", "billingVatHelp": "Il tuo identificativo IVA registrato. Se la tua azienda è esente IVA, lascia vuoto e spiega nelle note.",
"billingNotesPlaceholderPersonal": "Qualsiasi cosa dovremmo sapere — metodo di pagamento preferito, riferimento per fatturazione, ecc." "billingNotesPlaceholderPersonal": "Qualsiasi cosa dovremmo sapere — metodo di pagamento preferito, riferimento per fatturazione, ecc.",
"reviewContactPersonPrefix": "c.a."
}, },
"dashboard": { "dashboard": {
"title": "Dashboard", "title": "Dashboard",
@@ -393,7 +394,8 @@
"resumeRequestTooltip": "Richiesta di riattivazione di un tenant esistente. L'approvazione lo riattiverà; non viene eseguito alcun provisioning.", "resumeRequestTooltip": "Richiesta di riattivazione di un tenant esistente. L'approvazione lo riattiverà; non viene eseguito alcun provisioning.",
"openclawTool": "Versioni OpenClaw", "openclawTool": "Versioni OpenClaw",
"billingTool": "Fatturazione →", "billingTool": "Fatturazione →",
"skillsQueueTool": "Coda di attivazione" "skillsQueueTool": "Coda di attivazione",
"cronTool": "Automazione"
}, },
"channelUsers": { "channelUsers": {
"title": "Utenti autorizzati", "title": "Utenti autorizzati",
@@ -481,25 +483,31 @@
"billingDescriptionPersonal": "Indirizzo ed e-mail di fatturazione usati per tutti i tuoi tenant." "billingDescriptionPersonal": "Indirizzo ed e-mail di fatturazione usati per tutti i tuoi tenant."
}, },
"settingsBilling": { "settingsBilling": {
"title": "Fatturazione", "title": "Dati di fatturazione",
"subtitle": "Acquisita una sola volta al primo onboarding e riutilizzata per ogni tenant della tua organizzazione. Aggiorna qui ogni volta che i dati di fatturazione cambiano.", "subtitle": "Indirizzo di fatturazione, partita IVA e contatto fatture della tua azienda. Necessari prima che possano essere emesse fatture per la tua organizzazione.",
"companyName": "Ragione sociale", "companyNameLabel": "Nome azienda",
"streetAddress": "Indirizzo", "streetAddressLabel": "Indirizzo",
"postalCode": "CAP", "postalCodeLabel": "CAP",
"city": "Città", "cityLabel": "Città",
"country": "Paese", "countryLabel": "Codice paese",
"vatNumber": "Partita IVA", "countryHint": "ISO 3166-1 alpha-2 — es. CH, DE, AT, FR, IT, GB, US",
"vatHelp": "Il tuo identificativo IVA registrato (es. CHE-123.456.789 IVA per la Svizzera).", "vatNumberLabel": "Partita IVA (facoltativa)",
"billingEmail": "E-mail di fatturazione", "vatNumberHint": "Per clienti svizzeri: CHE-XXX.XXX.XXX IVA. Clienti UE con partita IVA ricevono fattura in reverse charge (0% IVA).",
"billingEmailHelp": "Indirizzo a cui verranno inviate le fatture e le comunicazioni di fatturazione.", "billingEmailLabel": "E-mail di fatturazione",
"notes": "Note", "billingEmailHint": "Le fatture e i solleciti vengono inviati a questo indirizzo. Può differire dall'e-mail dell'account.",
"notesPlaceholder": "Qualsiasi cosa la contabilità debba sapere — esenzione IVA, modalità di fatturazione particolari, ecc.", "notesLabel": "Note (facoltative)",
"save": "Salva", "notesHint": "Numeri di riferimento, ordini d'acquisto o altre informazioni da riportare in fattura.",
"saveChanges": "Salva modifiche",
"createBilling": "Salva dati di fatturazione",
"saving": "Salvataggio…",
"saved": "Salvato.", "saved": "Salvato.",
"saveFailed": "Impossibile salvare. Riprova.", "missingRequired": "Compila tutti i campi obbligatori.",
"lastUpdated": "Ultimo aggiornamento {when}", "invalidCountry": "Il codice paese deve essere di 2 lettere (es. CH).",
"fullName": "Nome completo", "invalidEmail": "Inserisci un indirizzo e-mail valido.",
"notesPlaceholderPersonal": "Qualsiasi cosa dovremmo sapere — metodo di pagamento preferito, riferimento per fatturazione, ecc." "fullNameLabel": "Nome e cognome",
"subtitlePersonal": "Il tuo indirizzo di fatturazione e contatto. Necessari prima che possano essere emesse fatture.",
"contactNameLabel": "Persona di contatto (facoltativa)",
"contactNameHint": "Stampato come 'c.a. <nome>' sulla fattura, sotto il nome dell'azienda. Utile per l'instradamento contabile in grandi organizzazioni."
}, },
"support": { "support": {
"title": "Supporto", "title": "Supporto",
@@ -731,10 +739,50 @@
"totalLabel": "Totale", "totalLabel": "Totale",
"downloadPdf": "Scarica PDF", "downloadPdf": "Scarica PDF",
"status": { "status": {
"issued": "Emessa", "draft": "Bozza",
"open": "Aperta",
"paid": "Pagata", "paid": "Pagata",
"overdue": "In ritardo", "overdue": "In ritardo",
"void": "Annullata" "void": "Annullata",
} "uncollectible": "Inesigibile"
},
"payWithCard": "Paga con carta",
"redirectingToStripe": "Reindirizzamento…",
"paymentReceived": "Pagamento ricevuto — grazie!",
"paymentCancelled": "Pagamento annullato.",
"configureBillingCta": "Configura dati di fatturazione",
"noBillingConfigNonOwner": "Solo il proprietario dell'organizzazione può configurare i dati di fatturazione. Contattalo per completare questo passaggio."
},
"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"
},
"failureBannerTitle": "Fallimenti recenti rilevati",
"failureBannerBody": "{count} esecuzione/i recente/i hanno segnalato almeno un fallimento. Controlla la tabella sotto — le righe interessate sono in rosso."
} }
} }

View File

@@ -234,6 +234,12 @@ export interface BillingAddress {
export interface OrgBilling { export interface OrgBilling {
zitadelOrgId: string; zitadelOrgId: string;
companyName: string; companyName: string;
// Optional contact-person line ("z.Hd. / Attn:") shown on the
// invoice PDF below the company name. Useful when invoicing
// larger companies where the mailroom needs a name to route
// the document. Personal accounts don't expose this in the UI —
// their "Full name" already lives in companyName.
contactName?: string | null;
streetAddress: string; streetAddress: string;
postalCode: string; postalCode: string;
city: string; city: string;
@@ -542,6 +548,20 @@ export type InvoiceStatus =
export type InvoicePaymentMethod = "invoice" | "card"; 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 = export type InvoiceLineKind =
| "tenant_monthly" | "tenant_monthly"
| "tenant_setup" | "tenant_setup"
@@ -561,6 +581,7 @@ export type InvoiceLineKind =
*/ */
export interface InvoiceBillingSnapshot { export interface InvoiceBillingSnapshot {
companyName: string; companyName: string;
contactName: string | null;
streetAddress: string; streetAddress: string;
postalCode: string; postalCode: string;
city: string; city: string;