diff --git a/src/app/[locale]/billing/page.tsx b/src/app/[locale]/billing/page.tsx index 22207db..343e86e 100644 --- a/src/app/[locale]/billing/page.tsx +++ b/src/app/[locale]/billing/page.tsx @@ -49,7 +49,9 @@ export default async function CustomerBillingPage() {

{t("currentPeriodHeading")}

- + {/* Phase 6: pass the owner flag so the no-config CTA shows + the right call-to-action vs the right hint. */} +
diff --git a/src/app/[locale]/settings/billing/page.tsx b/src/app/[locale]/settings/billing/page.tsx index 28877c8..6f01987 100644 --- a/src/app/[locale]/settings/billing/page.tsx +++ b/src/app/[locale]/settings/billing/page.tsx @@ -1,30 +1,31 @@ -import { getTranslations } from "next-intl/server"; 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 { 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 - * the client form. The form posts to PUT /api/billing on submit. + * Owner-only by visibility: non-owner members get a 404 (same + * 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` - * role redirects to /settings (which also wouldn't list billing for - * them). 403 here would be friendlier than redirect, but the most - * likely cause of a `user` landing on this URL is sharing a bookmark - * with their owner — silent redirect is gentle. + * First-time visitors see an empty form. Subsequent visits see + * the current values, editable. Save creates or updates via the + * shared upsert path; the row's existence drives whether the + * monthly issuance cron will pick this org up. */ export default async function BillingSettingsPage() { const user = await getSessionUser(); if (!user) redirect("/login"); - if (!canMutate(user)) { - redirect("/settings"); - } - const t = await getTranslations("settingsBilling"); + // Non-owners get a 404 — see comment above. + if (!user.roles.includes("owner")) notFound(); - const billing = await getOrgBilling(user.orgId); + const t = await getTranslations("settingsBilling"); + const existing = await getOrgBilling(user.orgId); return (
@@ -34,14 +35,9 @@ export default async function BillingSettingsPage() {

{t("subtitle")}

- - +
+ +
); } diff --git a/src/app/api/settings/billing/route.ts b/src/app/api/settings/billing/route.ts new file mode 100644 index 0000000..9bca7f1 --- /dev/null +++ b/src/app/api/settings/billing/route.ts @@ -0,0 +1,85 @@ +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), + 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, + 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 }); +} diff --git a/src/components/admin/cron/cron-controls.tsx b/src/components/admin/cron/cron-controls.tsx index b974874..d6b5653 100644 --- a/src/components/admin/cron/cron-controls.tsx +++ b/src/components/admin/cron/cron-controls.tsx @@ -116,8 +116,23 @@ export function CronControls({ initialRecent, initialLastSuccess }: Props) { }); }; + // 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 (
+ {hasRecentFailures && ( +
+

{t("failureBannerTitle")}

+

+ {t("failureBannerBody", { count: recentFailures.length })} +

+
+ )}

@@ -192,7 +207,12 @@ export function CronControls({ initialRecent, initialLastSuccess }: Props) { {recent.map((r) => ( - + 0 ? "bg-error/5" : "" + }`} + > {fmtRelative(r.startedAt)} diff --git a/src/components/billing/running-total-widget.tsx b/src/components/billing/running-total-widget.tsx index b897488..5b65d53 100644 --- a/src/components/billing/running-total-widget.tsx +++ b/src/components/billing/running-total-widget.tsx @@ -11,6 +11,17 @@ type CurrentResponse = | { draft: InvoiceDraft } | { 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. * @@ -28,7 +39,7 @@ type CurrentResponse = * No polling — the page is static enough that an explicit * "refresh" link is good enough if the user wants newer numbers. */ -export function RunningTotalWidget() { +export function RunningTotalWidget({ isOwner }: Props) { const t = useTranslations("customerBilling"); const fmt = useFormatter(); const [data, setData] = useState(null); @@ -62,13 +73,29 @@ export function RunningTotalWidget() { ); } if (!data || "error" in data) { + const noConfig = + data && "code" in data && data.code === "COMPUTE_FAILED"; return (

- {data && "code" in data && data.code === "COMPUTE_FAILED" - ? t("noBillingConfig") - : t("currentPeriodError")} + {noConfig ? t("noBillingConfig") : t("currentPeriodError")}

+ {/* 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 && ( + + {t("configureBillingCta")} + + )} + {noConfig && !isOwner && ( +

+ {t("noBillingConfigNonOwner")} +

+ )}
); } diff --git a/src/components/settings/billing-form.tsx b/src/components/settings/billing-form.tsx new file mode 100644 index 0000000..dccd1d7 --- /dev/null +++ b/src/components/settings/billing-form.tsx @@ -0,0 +1,229 @@ +"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; +} + +/** + * 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 }: Props) { + const t = useTranslations("settingsBilling"); + const router = useRouter(); + const [form, setForm] = useState({ + companyName: initial?.companyName ?? "", + 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(null); + const [savedFlash, setSavedFlash] = useState(false); + + const set = + (field: keyof typeof form) => + (e: React.ChangeEvent) => + 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(), + streetAddress: form.streetAddress.trim(), + postalCode: form.postalCode.trim(), + city: form.city.trim(), + country: form.country.trim().toUpperCase(), + vatNumber: 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 ( + +
+ + + + + + +
+ + + + + + + + + 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" + /> + +
+ + + + + + + +