Phase6: Customer Billing details
All checks were successful
Build and Push / build (push) Successful in 1m46s
All checks were successful
Build and Push / build (push) Successful in 1m46s
This commit is contained in:
@@ -49,7 +49,9 @@ export default async function CustomerBillingPage() {
|
||||
<h2 className="text-xs font-semibold uppercase tracking-wider text-text-muted mb-3">
|
||||
{t("currentPeriodHeading")}
|
||||
</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 className="animate-in animate-in-delay-2">
|
||||
|
||||
@@ -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 (
|
||||
<main className="max-w-3xl mx-auto px-6 py-8">
|
||||
@@ -34,14 +35,9 @@ export default async function BillingSettingsPage() {
|
||||
</h1>
|
||||
<p className="text-sm text-text-secondary mt-3">{t("subtitle")}</p>
|
||||
</div>
|
||||
|
||||
<BillingSettingsForm
|
||||
initial={billing}
|
||||
isPersonal={user.isPersonal}
|
||||
orgName={user.orgName}
|
||||
userName={user.name}
|
||||
userEmail={user.email}
|
||||
/>
|
||||
<div className="animate-in animate-in-delay-1">
|
||||
<BillingSettingsForm initial={existing} />
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
85
src/app/api/settings/billing/route.ts
Normal file
85
src/app/api/settings/billing/route.ts
Normal file
@@ -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 });
|
||||
}
|
||||
Reference in New Issue
Block a user