Compare commits
49 Commits
v0.1.53
...
73f1af185f
| Author | SHA1 | Date | |
|---|---|---|---|
| 73f1af185f | |||
| c1833c1def | |||
| 521398b0fc | |||
| 74d276b656 | |||
| 3110b40cf9 | |||
| 08f28aeb93 | |||
| fb9c0ad25a | |||
| 322cfae824 | |||
| 7fac3c3aa8 | |||
| bff3aad1ca | |||
| f2a9637058 | |||
| bfc2194e24 | |||
| 6f8de14b4a | |||
| a6ed74b1be | |||
| 1741574eb2 | |||
| d78f9f2696 | |||
| 3fe3597553 | |||
| 9243beddd3 | |||
| a6c3c42ec9 | |||
| ee6bb89fb6 | |||
| ad4f614130 | |||
| 8e7691d38a | |||
| 9939f75c03 | |||
| e69b68b73c | |||
| 41c1553b1f | |||
| 38f4c3243e | |||
| ed915ec539 | |||
| 667617296b | |||
| 1c61111da3 | |||
| 6fed5b083b | |||
| 4f868d751e | |||
| e15a668f8e | |||
| 9cd9879a18 | |||
| 323786672f | |||
| a1769eeb00 | |||
| 002867850d | |||
| eea027b3b0 | |||
| 522246e386 | |||
| b3131f7710 | |||
| fadfdd3435 | |||
| 427c7c6204 | |||
| 6a8ad7b4be | |||
| 875ade4351 | |||
| 2a0bb10531 | |||
| 262250564a | |||
| a680d6de9f | |||
| 4a5ae0bb8b | |||
| c21b48c704 | |||
| cf190e5ac5 |
18
package-lock.json
generated
18
package-lock.json
generated
@@ -19,6 +19,7 @@
|
||||
"pg": "^8.20.0",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"stripe": "^22.1.1",
|
||||
"zod": "^3.24.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -7530,6 +7531,23 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/stripe": {
|
||||
"version": "22.1.1",
|
||||
"resolved": "https://registry.npmjs.org/stripe/-/stripe-22.1.1.tgz",
|
||||
"integrity": "sha512-cmodIYP27tBkJ8G7DuGgWw0PFuemlFZbuF3Wwr1TrjFjUa3T7NIgCe6TVwX8BO2ynu+xtTuDGfHafNDCPt9lXA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/node": ">=18"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/node": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/styled-jsx": {
|
||||
"version": "5.1.6",
|
||||
"resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz",
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
"pg": "^8.20.0",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"stripe": "^22.1.1",
|
||||
"zod": "^3.24.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
59
src/app/[locale]/admin/billing/invoice-drafts/[id]/page.tsx
Normal file
59
src/app/[locale]/admin/billing/invoice-drafts/[id]/page.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { getSessionUser } from "@/lib/session";
|
||||
import { getInvoiceDraftById, getOrgBilling } from "@/lib/db";
|
||||
import { BackLink } from "@/components/ui/back-link";
|
||||
import { CustomInvoiceEditor } from "@/components/admin/billing/custom-invoice-editor";
|
||||
|
||||
/**
|
||||
* /admin/billing/invoice-drafts/[id] — full editor for an
|
||||
* in-progress custom invoice.
|
||||
*
|
||||
* Phase 8. Server-loads the draft + the org's billing snapshot
|
||||
* (used to display the bill-to block preview), then hands off to
|
||||
* the client editor for the interactive line-management UI.
|
||||
*
|
||||
* The snapshot is loaded read-only for display. The actual VAT
|
||||
* computation happens server-side at issue time via
|
||||
* computeCustomInvoiceTotals, which re-reads the same snapshot.
|
||||
* That two-time read is intentional: the editor's preview math
|
||||
* is a hint, the issue-time read is authoritative — if the
|
||||
* customer updates their billing address between Draft and Issue,
|
||||
* the invoice reflects the new address.
|
||||
*/
|
||||
export default async function InvoiceDraftEditorPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const user = await getSessionUser();
|
||||
if (!user) redirect("/login");
|
||||
if (!user.isPlatform) redirect("/dashboard");
|
||||
const t = await getTranslations("adminBilling");
|
||||
|
||||
const { id } = await params;
|
||||
const draft = await getInvoiceDraftById(id);
|
||||
if (!draft) notFound();
|
||||
const orgBilling = await getOrgBilling(draft.zitadelOrgId).catch(() => null);
|
||||
|
||||
return (
|
||||
<main className="max-w-5xl mx-auto px-6 py-8">
|
||||
<BackLink
|
||||
href="/admin/billing/invoice-drafts"
|
||||
label={t("backToDrafts")}
|
||||
/>
|
||||
<div className="mb-6">
|
||||
<h1 className="font-display text-2xl font-semibold accent-rule">
|
||||
{t("editorPageTitle")}
|
||||
</h1>
|
||||
<p className="text-sm text-text-secondary mt-3">
|
||||
{orgBilling?.companyName ?? draft.zitadelOrgId}
|
||||
</p>
|
||||
</div>
|
||||
<CustomInvoiceEditor
|
||||
draft={draft}
|
||||
orgBilling={orgBilling}
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
72
src/app/[locale]/admin/billing/invoice-drafts/page.tsx
Normal file
72
src/app/[locale]/admin/billing/invoice-drafts/page.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { getSessionUser } from "@/lib/session";
|
||||
import { getOrgBilling, listAllInvoiceDrafts } from "@/lib/db";
|
||||
import { listTenants } from "@/lib/k8s";
|
||||
import { BackLink } from "@/components/ui/back-link";
|
||||
import { DraftList } from "@/components/admin/billing/draft-list";
|
||||
|
||||
/**
|
||||
* /admin/billing/invoice-drafts — list of all open custom-invoice
|
||||
* drafts across orgs.
|
||||
*
|
||||
* Phase 8. Each draft is a JSONB blob the admin is composing into
|
||||
* an invoice; visible only to platform admins. From here the admin
|
||||
* can resume editing or discard.
|
||||
*
|
||||
* Building an org-name map by reading tenant labels (for the set of
|
||||
* known orgs) + getOrgBilling per org (for the actual company name)
|
||||
* so the table can show "Customer X" instead of a raw ZITADEL org id.
|
||||
*/
|
||||
export default async function AdminInvoiceDraftsPage() {
|
||||
const user = await getSessionUser();
|
||||
if (!user) redirect("/login");
|
||||
if (!user.isPlatform) redirect("/dashboard");
|
||||
const t = await getTranslations("adminBilling");
|
||||
|
||||
const [drafts, tenants] = await Promise.all([
|
||||
listAllInvoiceDrafts(),
|
||||
listTenants().catch(() => []),
|
||||
]);
|
||||
|
||||
// Build the set of distinct ZITADEL org ids from tenant labels,
|
||||
// PLUS the set referenced by any current draft. Drafts may target
|
||||
// orgs that don't have tenants yet (rare but possible), so we
|
||||
// union both sources before fetching billing rows.
|
||||
const orgIds = new Set<string>();
|
||||
for (const tnt of tenants) {
|
||||
const oid = tnt.metadata.labels?.["pieced.ch/zitadel-org-id"];
|
||||
if (oid) orgIds.add(oid);
|
||||
}
|
||||
for (const d of drafts) {
|
||||
orgIds.add(d.zitadelOrgId);
|
||||
}
|
||||
// Look up billing in parallel — same pattern as
|
||||
// /api/admin/billing/orgs uses. Failure for any single org is
|
||||
// non-fatal (falls back to the raw id in the table).
|
||||
const orgNamePairs = await Promise.all(
|
||||
Array.from(orgIds).map(async (oid) => {
|
||||
const billing = await getOrgBilling(oid).catch(() => null);
|
||||
return [oid, billing?.companyName ?? null] as const;
|
||||
})
|
||||
);
|
||||
const orgNameMap: Record<string, string> = {};
|
||||
for (const [oid, name] of orgNamePairs) {
|
||||
if (name) orgNameMap[oid] = name;
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="max-w-5xl mx-auto px-6 py-8">
|
||||
<BackLink href="/admin/billing" label={t("backToBilling")} />
|
||||
<div className="mb-6">
|
||||
<h1 className="font-display text-2xl font-semibold accent-rule">
|
||||
{t("draftsPageTitle")}
|
||||
</h1>
|
||||
<p className="text-sm text-text-secondary mt-3">
|
||||
{t("draftsPageSubtitle")}
|
||||
</p>
|
||||
</div>
|
||||
<DraftList drafts={drafts} orgNameMap={orgNameMap} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { getSessionUser } from "@/lib/session";
|
||||
import { getInvoiceDetail } from "@/lib/db";
|
||||
import { getInvoiceDetail, listCreditNotesForInvoice } from "@/lib/db";
|
||||
import { BackLink } from "@/components/ui/back-link";
|
||||
import { InvoiceDetailView } from "@/components/admin/billing/invoice-detail-view";
|
||||
|
||||
@@ -9,8 +9,12 @@ import { InvoiceDetailView } from "@/components/admin/billing/invoice-detail-vie
|
||||
* /admin/billing/invoices/[id] — full detail of one invoice.
|
||||
*
|
||||
* Server-renders the static body (header, lines, totals, billing
|
||||
* snapshot); the action bar (mark-paid, delete, PDF download) is
|
||||
* a client component for the interactive bits.
|
||||
* snapshot); the action bar (mark-paid, void, refund, delete, PDF
|
||||
* download) is a client component for the interactive bits.
|
||||
*
|
||||
* Phase 7: also passes any linked credit notes so the detail view
|
||||
* can show the "this invoice was voided / partially refunded" panel
|
||||
* without an extra round-trip.
|
||||
*/
|
||||
export default async function AdminInvoiceDetailPage({
|
||||
params,
|
||||
@@ -25,11 +29,12 @@ export default async function AdminInvoiceDetailPage({
|
||||
const { id } = await params;
|
||||
const detail = await getInvoiceDetail(id);
|
||||
if (!detail) notFound();
|
||||
const creditNotes = await listCreditNotesForInvoice(id);
|
||||
|
||||
return (
|
||||
<main className="max-w-4xl mx-auto px-6 py-8">
|
||||
<BackLink href="/admin/billing/invoices" label={t("backToInvoices")} />
|
||||
<InvoiceDetailView detail={detail} />
|
||||
<InvoiceDetailView detail={detail} creditNotes={creditNotes} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
72
src/app/[locale]/admin/billing/invoices/new/page.tsx
Normal file
72
src/app/[locale]/admin/billing/invoices/new/page.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { getSessionUser } from "@/lib/session";
|
||||
import { listTenants } from "@/lib/k8s";
|
||||
import { getOrgBilling } from "@/lib/db";
|
||||
import { BackLink } from "@/components/ui/back-link";
|
||||
import { NewInvoiceForm } from "@/components/admin/billing/new-invoice-form";
|
||||
|
||||
/**
|
||||
* /admin/billing/invoices/new — entry point for the custom-invoice
|
||||
* flow. The admin picks an org, clicks Continue, and lands on the
|
||||
* editor at /admin/billing/invoice-drafts/<new-id>.
|
||||
*
|
||||
* Phase 8. Org list is built from tenant labels + each org's
|
||||
* billing config (we need the company name and the
|
||||
* has-billing-snapshot flag to gate the picker — orgs without a
|
||||
* snapshot can't be invoiced until they complete onboarding or
|
||||
* admin sets the billing info manually).
|
||||
*/
|
||||
export default async function NewInvoicePage() {
|
||||
const user = await getSessionUser();
|
||||
if (!user) redirect("/login");
|
||||
if (!user.isPlatform) redirect("/dashboard");
|
||||
const t = await getTranslations("adminBilling");
|
||||
|
||||
// Tenants give us org membership; getOrgBilling per org gives us
|
||||
// the snapshot status. We dedupe by org id since one org can own
|
||||
// many tenants.
|
||||
const tenants = await listTenants();
|
||||
const orgIds = new Set<string>();
|
||||
for (const tnt of tenants) {
|
||||
const oid = tnt.metadata.labels?.["pieced.ch/zitadel-org-id"];
|
||||
if (oid) orgIds.add(oid);
|
||||
}
|
||||
const orgs = await Promise.all(
|
||||
Array.from(orgIds).map(async (oid) => {
|
||||
const billing = await getOrgBilling(oid).catch(() => null);
|
||||
return {
|
||||
zitadelOrgId: oid,
|
||||
companyName: billing?.companyName ?? null,
|
||||
country: billing?.country ?? null,
|
||||
hasBillingAddress: !!billing && !!billing.companyName,
|
||||
};
|
||||
})
|
||||
);
|
||||
// Sort: orgs with billing first (admin's most likely target),
|
||||
// then alphabetically by company name.
|
||||
orgs.sort((a, b) => {
|
||||
if (a.hasBillingAddress !== b.hasBillingAddress) {
|
||||
return a.hasBillingAddress ? -1 : 1;
|
||||
}
|
||||
return (a.companyName ?? "").localeCompare(b.companyName ?? "");
|
||||
});
|
||||
|
||||
return (
|
||||
<main className="max-w-2xl mx-auto px-6 py-8">
|
||||
<BackLink
|
||||
href="/admin/billing/invoices"
|
||||
label={t("backToInvoices")}
|
||||
/>
|
||||
<div className="mb-6">
|
||||
<h1 className="font-display text-2xl font-semibold accent-rule">
|
||||
{t("newInvoicePageTitle")}
|
||||
</h1>
|
||||
<p className="text-sm text-text-secondary mt-3">
|
||||
{t("newInvoicePageSubtitle")}
|
||||
</p>
|
||||
</div>
|
||||
<NewInvoiceForm orgs={orgs} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
83
src/app/[locale]/admin/billing/orgs/page.tsx
Normal file
83
src/app/[locale]/admin/billing/orgs/page.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { getSessionUser } from "@/lib/session";
|
||||
import { getOrgBilling, getOrgBillingConfig } from "@/lib/db";
|
||||
import { listTenants } from "@/lib/k8s";
|
||||
import { BackLink } from "@/components/ui/back-link";
|
||||
import { OrgPaymentModeList } from "@/components/admin/billing/org-payment-mode-list";
|
||||
|
||||
/**
|
||||
* /admin/billing/orgs — list of orgs with their payment mode
|
||||
* settings.
|
||||
*
|
||||
* Phase 9b-2. The customer's /settings/billing only exposes the
|
||||
* saved-card flow (auto-pay). Bank-transfer mode is admin-only —
|
||||
* customer must contact support to request it, admin flips the
|
||||
* pay_by_invoice flag here. Also exposes the auto_charge_enabled
|
||||
* pause-switch for support situations.
|
||||
*
|
||||
* The page is intentionally minimal: org name, country, current
|
||||
* mode, has-saved-card indicator, and toggles. Detail-level work
|
||||
* (open balances, invoice list) is on the existing pages
|
||||
* (/admin/billing, /admin/billing/invoices).
|
||||
*/
|
||||
export default async function AdminOrgsPaymentModePage() {
|
||||
const user = await getSessionUser();
|
||||
if (!user) redirect("/login");
|
||||
if (!user.isPlatform) redirect("/dashboard");
|
||||
const t = await getTranslations("adminBilling");
|
||||
|
||||
// Same org-discovery pattern as /api/admin/billing/orgs: tenant
|
||||
// labels are the source of truth for org membership. We dedupe by
|
||||
// org id since one org can own many tenants.
|
||||
const tenants = await listTenants().catch(() => []);
|
||||
const orgIds = new Set<string>();
|
||||
for (const tnt of tenants) {
|
||||
const oid = tnt.metadata.labels?.["pieced.ch/zitadel-org-id"];
|
||||
if (oid) orgIds.add(oid);
|
||||
}
|
||||
const orgs = await Promise.all(
|
||||
Array.from(orgIds).map(async (oid) => {
|
||||
const [billing, cfg] = await Promise.all([
|
||||
getOrgBilling(oid).catch(() => null),
|
||||
getOrgBillingConfig(oid),
|
||||
]);
|
||||
return {
|
||||
zitadelOrgId: oid,
|
||||
companyName: billing?.companyName ?? null,
|
||||
country: billing?.country ?? null,
|
||||
hasSavedCard: !!cfg.stripeDefaultPaymentMethodId,
|
||||
cardLabel:
|
||||
cfg.stripePmBrand && cfg.stripePmLast4
|
||||
? `${cfg.stripePmBrand} •••• ${cfg.stripePmLast4}`
|
||||
: null,
|
||||
payByInvoice: !!cfg.payByInvoice,
|
||||
autoChargeEnabled: cfg.autoChargeEnabled !== false,
|
||||
};
|
||||
})
|
||||
);
|
||||
// Sort: orgs with billing first (most actionable), then by name.
|
||||
orgs.sort((a, b) => {
|
||||
if (!!a.companyName !== !!b.companyName) {
|
||||
return a.companyName ? -1 : 1;
|
||||
}
|
||||
return (a.companyName ?? a.zitadelOrgId).localeCompare(
|
||||
b.companyName ?? b.zitadelOrgId
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
<main className="max-w-6xl mx-auto px-6 py-8">
|
||||
<BackLink href="/admin/billing" label={t("backToBilling")} />
|
||||
<div className="mb-6">
|
||||
<h1 className="font-display text-2xl font-semibold accent-rule">
|
||||
{t("orgsPageTitle")}
|
||||
</h1>
|
||||
<p className="text-sm text-text-secondary mt-3">
|
||||
{t("orgsPageSubtitle")}
|
||||
</p>
|
||||
</div>
|
||||
<OrgPaymentModeList orgs={orgs} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -66,7 +66,7 @@ export default async function AdminBillingPage() {
|
||||
</div>
|
||||
|
||||
{/* Sub-tool cards */}
|
||||
<div className="grid grid-cols-3 gap-4 mb-8 animate-in animate-in-delay-2">
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8 animate-in animate-in-delay-2">
|
||||
<Link href="/admin/billing/pricing">
|
||||
<Card interactive>
|
||||
<div className="font-semibold mb-1">{t("pricingTitle")}</div>
|
||||
@@ -85,6 +85,12 @@ export default async function AdminBillingPage() {
|
||||
<div className="text-sm text-text-muted">{t("invoicesDesc")}</div>
|
||||
</Card>
|
||||
</Link>
|
||||
<Link href="/admin/billing/orgs">
|
||||
<Card interactive>
|
||||
<div className="font-semibold mb-1">{t("orgsTitle")}</div>
|
||||
<div className="text-sm text-text-muted">{t("orgsDesc")}</div>
|
||||
</Card>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Orgs with open balance */}
|
||||
@@ -92,6 +98,7 @@ export default async function AdminBillingPage() {
|
||||
<div className="animate-in animate-in-delay-3">
|
||||
<h2 className="text-lg font-semibold mb-3">{t("balancesTitle")}</h2>
|
||||
<Card>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="text-xs text-text-muted text-left">
|
||||
<tr>
|
||||
@@ -120,6 +127,7 @@ export default async function AdminBillingPage() {
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
44
src/app/[locale]/admin/cron/page.tsx
Normal file
44
src/app/[locale]/admin/cron/page.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { getSessionUser } from "@/lib/session";
|
||||
import {
|
||||
getLastSuccessfulCronRuns,
|
||||
listRecentCronRuns,
|
||||
} from "@/lib/db";
|
||||
import { CronControls } from "@/components/admin/cron/cron-controls";
|
||||
|
||||
/**
|
||||
* /admin/cron — automation dashboard.
|
||||
*
|
||||
* Shows:
|
||||
* - Last successful run of each kind, with relative time
|
||||
* - Two "Run now" buttons (admin-triggered manual sweeps)
|
||||
* - Recent runs table (last 30)
|
||||
*
|
||||
* Platform-admin gated server-side.
|
||||
*/
|
||||
export default async function AdminCronPage() {
|
||||
const user = await getSessionUser();
|
||||
if (!user || !user.isPlatform) redirect("/login");
|
||||
const t = await getTranslations("adminCron");
|
||||
|
||||
const [recent, lastSuccess] = await Promise.all([
|
||||
listRecentCronRuns(30),
|
||||
getLastSuccessfulCronRuns(),
|
||||
]);
|
||||
|
||||
return (
|
||||
<main className="max-w-5xl mx-auto px-6 py-8">
|
||||
<div className="mb-8 animate-in">
|
||||
<h1 className="font-display text-2xl font-semibold accent-rule">
|
||||
{t("title")}
|
||||
</h1>
|
||||
<p className="text-sm text-text-secondary mt-3">{t("subtitle")}</p>
|
||||
</div>
|
||||
<CronControls
|
||||
initialRecent={recent}
|
||||
initialLastSuccess={lastSuccess}
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,11 @@ import { listTenants } from "@/lib/k8s";
|
||||
import { countPendingSkillActivationRequests } from "@/lib/db";
|
||||
import { AdminPanel } from "@/components/admin/admin-panel";
|
||||
|
||||
export async function generateMetadata() {
|
||||
const t = await getTranslations("common");
|
||||
return { title: t("admin") };
|
||||
}
|
||||
|
||||
export default async function AdminPage() {
|
||||
const user = await getSessionUser();
|
||||
if (!user) redirect("/login");
|
||||
@@ -61,6 +66,12 @@ export default async function AdminPage() {
|
||||
>
|
||||
{t("billingTool")}
|
||||
</a>
|
||||
<a
|
||||
href="/admin/cron"
|
||||
className="text-sm px-4 py-2 rounded-lg border border-border text-text-secondary hover:text-text-primary hover:border-text-secondary transition-colors"
|
||||
>
|
||||
{t("cronTool")}
|
||||
</a>
|
||||
<a
|
||||
href="/admin/openclaw"
|
||||
className="text-sm px-4 py-2 rounded-lg border border-border text-text-secondary hover:text-text-primary hover:border-text-secondary transition-colors"
|
||||
|
||||
35
src/app/[locale]/billing/[invoiceNumber]/page.tsx
Normal file
35
src/app/[locale]/billing/[invoiceNumber]/page.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import { redirect, notFound } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { getSessionUser } from "@/lib/session";
|
||||
import { getInvoiceByNumberForOrg } from "@/lib/db";
|
||||
import { BackLink } from "@/components/ui/back-link";
|
||||
import { CustomerInvoiceDetail } from "@/components/billing/customer-invoice-detail";
|
||||
|
||||
/**
|
||||
* /billing/[invoiceNumber] — single-invoice view.
|
||||
*
|
||||
* Lookup is by the human-readable invoice number (the YYYY-NNNNN
|
||||
* format printed on the PDF and in the issuance email). Org
|
||||
* filter is enforced in the DB query — a customer trying another
|
||||
* org's number gets 404, not 403, to avoid leaking the existence
|
||||
* of other orgs' invoices.
|
||||
*/
|
||||
export default async function CustomerInvoiceDetailPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ invoiceNumber: string; locale: string }>;
|
||||
}) {
|
||||
const user = await getSessionUser();
|
||||
if (!user) redirect("/login");
|
||||
const { invoiceNumber } = await params;
|
||||
const t = await getTranslations("customerBilling");
|
||||
const detail = await getInvoiceByNumberForOrg(invoiceNumber, user.orgId);
|
||||
if (!detail) notFound();
|
||||
|
||||
return (
|
||||
<main className="max-w-3xl mx-auto px-6 py-8">
|
||||
<BackLink href="/billing" label={t("backToBilling")} />
|
||||
<CustomerInvoiceDetail invoice={detail.invoice} lines={detail.lines} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
90
src/app/[locale]/billing/page.tsx
Normal file
90
src/app/[locale]/billing/page.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { getSessionUser } from "@/lib/session";
|
||||
import {
|
||||
listCreditNotesForOrg,
|
||||
listInvoices,
|
||||
syncOverdueInvoices,
|
||||
} from "@/lib/db";
|
||||
import { CustomerInvoiceList } from "@/components/billing/customer-invoice-list";
|
||||
import { CustomerCreditNoteList } from "@/components/billing/customer-credit-note-list";
|
||||
import { RunningTotalWidget } from "@/components/billing/running-total-widget";
|
||||
|
||||
/**
|
||||
* /billing — customer's billing home.
|
||||
*
|
||||
* Shows three things:
|
||||
* 1. RunningTotalWidget — current calendar month's accruing cost
|
||||
* (or the already-issued invoice for the current month, if
|
||||
* that ran early).
|
||||
* 2. CustomerInvoiceList — every issued invoice for this org,
|
||||
* newest first. Status is reflected with a colored badge.
|
||||
* 3. CustomerCreditNoteList — Phase 7. Credit notes (voids and
|
||||
* refunds) for this org, with PDF download links. Hidden
|
||||
* entirely when there are none (the common case).
|
||||
*
|
||||
* Anyone signed in can view this. The data is org-scoped; even
|
||||
* non-owner team members see the same view.
|
||||
*/
|
||||
export async function generateMetadata() {
|
||||
const t = await getTranslations("common");
|
||||
return { title: t("billing") };
|
||||
}
|
||||
|
||||
export default async function CustomerBillingPage() {
|
||||
const user = await getSessionUser();
|
||||
if (!user) redirect("/login");
|
||||
const t = await getTranslations("customerBilling");
|
||||
|
||||
// Sync overdue status before listing — cheap, idempotent.
|
||||
try {
|
||||
await syncOverdueInvoices();
|
||||
} catch (e) {
|
||||
console.warn("syncOverdueInvoices failed in /billing:", e);
|
||||
}
|
||||
|
||||
// Parallel fetch — invoices + credit notes are independent.
|
||||
const [invoices, creditNotes] = await Promise.all([
|
||||
listInvoices({ zitadelOrgId: user.orgId, limit: 200 }),
|
||||
listCreditNotesForOrg(user.orgId, 200),
|
||||
]);
|
||||
|
||||
return (
|
||||
<main className="max-w-5xl mx-auto px-6 py-8">
|
||||
<div className="mb-8 animate-in">
|
||||
<h1 className="font-display text-2xl font-semibold accent-rule">
|
||||
{t("title")}
|
||||
</h1>
|
||||
<p className="text-sm text-text-secondary mt-3">{t("subtitle")}</p>
|
||||
</div>
|
||||
|
||||
<section className="mb-8 animate-in animate-in-delay-1">
|
||||
<h2 className="text-xs font-semibold uppercase tracking-wider text-text-muted mb-3">
|
||||
{t("currentPeriodHeading")}
|
||||
</h2>
|
||||
{/* 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 mb-8">
|
||||
<h2 className="text-xs font-semibold uppercase tracking-wider text-text-muted mb-3">
|
||||
{t("historyHeading")}
|
||||
</h2>
|
||||
<CustomerInvoiceList invoices={invoices} />
|
||||
</section>
|
||||
|
||||
{/* Phase 7: credit-note section. CustomerCreditNoteList itself
|
||||
returns null when there are no credit notes, so this whole
|
||||
section disappears for orgs in normal operation. */}
|
||||
{creditNotes.length > 0 && (
|
||||
<section className="animate-in animate-in-delay-3">
|
||||
<h2 className="text-xs font-semibold uppercase tracking-wider text-text-muted mb-3">
|
||||
{t("creditNotesHeading")}
|
||||
</h2>
|
||||
<CustomerCreditNoteList creditNotes={creditNotes} />
|
||||
</section>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { redirect } from "next/navigation";
|
||||
import { OnboardingFlow } from "@/components/onboarding/onboarding-flow";
|
||||
import { BackLink } from "@/components/ui/back-link";
|
||||
import { listTenants } from "@/lib/k8s";
|
||||
import { listActiveTenantRequestsByOrgId, getOrgBilling } from "@/lib/db";
|
||||
import { listActiveTenantRequestsByOrgId, getOrgBilling, getPlatformPricing } from "@/lib/db";
|
||||
import { personalAccountAtCapacity } from "@/lib/personal-org";
|
||||
|
||||
/**
|
||||
@@ -55,7 +55,10 @@ export default async function NewInstancePage() {
|
||||
}
|
||||
|
||||
const t = await getTranslations("dashboard");
|
||||
const orgBilling = await getOrgBilling(user.orgId);
|
||||
const [orgBilling, pricing] = await Promise.all([
|
||||
getOrgBilling(user.orgId),
|
||||
getPlatformPricing(),
|
||||
]);
|
||||
const hasOrgBilling = orgBilling !== null;
|
||||
|
||||
return (
|
||||
@@ -76,6 +79,9 @@ export default async function NewInstancePage() {
|
||||
userName={user.name}
|
||||
userEmail={user.email}
|
||||
hasOrgBilling={hasOrgBilling}
|
||||
existingOrgBilling={orgBilling}
|
||||
setupFeeChf={pricing.tenantSetupFeeChf}
|
||||
monthlyFeeChf={pricing.tenantMonthlyFeeChf}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
listActiveTenantRequestsByOrgId,
|
||||
syncProvisioningStatuses,
|
||||
getOrgBilling,
|
||||
getPlatformPricing,
|
||||
} from "@/lib/db";
|
||||
import {
|
||||
listVisibleTenants,
|
||||
@@ -21,6 +22,11 @@ import { ProvisioningStatus } from "@/components/onboarding/provisioning-status"
|
||||
import { formatDateTime } from "@/lib/format";
|
||||
import Link from "next/link";
|
||||
|
||||
export async function generateMetadata() {
|
||||
const t = await getTranslations("common");
|
||||
return { title: t("dashboard") };
|
||||
}
|
||||
|
||||
export default async function DashboardPage() {
|
||||
const user = await getSessionUser();
|
||||
if (!user) redirect("/login");
|
||||
@@ -192,6 +198,7 @@ export default async function DashboardPage() {
|
||||
// component.
|
||||
const orgBilling = await getOrgBilling(user.orgId);
|
||||
const hasOrgBilling = orgBilling !== null;
|
||||
const platformPricing = await getPlatformPricing();
|
||||
|
||||
// Pending requests that don't yet have a tenant CR. Once the CR
|
||||
// exists, the tenant card carries the live phase, so a separate
|
||||
@@ -317,6 +324,9 @@ export default async function DashboardPage() {
|
||||
userName={user.name}
|
||||
userEmail={user.email}
|
||||
hasOrgBilling={hasOrgBilling}
|
||||
existingOrgBilling={orgBilling}
|
||||
setupFeeChf={platformPricing.tenantSetupFeeChf}
|
||||
monthlyFeeChf={platformPricing.tenantMonthlyFeeChf}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -340,7 +350,7 @@ export default async function DashboardPage() {
|
||||
{canCreate && (
|
||||
<Link
|
||||
href="/dashboard/new"
|
||||
className="shrink-0 inline-flex items-center gap-1.5 py-2 px-4 bg-accent text-white text-xs font-medium rounded-lg hover:bg-accent-dim transition-colors"
|
||||
className="shrink-0 inline-flex items-center gap-1.5 py-2 px-4 bg-accent text-surface-0 text-xs font-medium rounded-lg hover:bg-accent-dim transition-colors"
|
||||
>
|
||||
<span>+</span> {t("createInstance")}
|
||||
</Link>
|
||||
|
||||
72
src/app/[locale]/error.tsx
Normal file
72
src/app/[locale]/error.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Link } from "@/i18n/navigation";
|
||||
|
||||
/**
|
||||
* Error boundary for the [locale] segment. Catches render/data errors
|
||||
* thrown by any page below the locale layout (which is where K8s, DB,
|
||||
* LiteLLM and Stripe calls happen). Renders inside NextIntlClientProvider,
|
||||
* so translations are available. Root-layout failures fall through to
|
||||
* global-error.tsx instead.
|
||||
*/
|
||||
export default function LocaleError({
|
||||
error,
|
||||
reset,
|
||||
}: {
|
||||
error: Error & { digest?: string };
|
||||
reset: () => void;
|
||||
}) {
|
||||
const t = useTranslations("errors");
|
||||
|
||||
useEffect(() => {
|
||||
// Surface the error for log scraping; the digest correlates with
|
||||
// the server-side stack in production.
|
||||
console.error("Portal error boundary:", error);
|
||||
}, [error]);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-[60vh] items-center justify-center px-5">
|
||||
<div className="w-full max-w-md text-center">
|
||||
<div className="mx-auto mb-5 flex h-14 w-14 items-center justify-center rounded-xl bg-error/10">
|
||||
<svg
|
||||
className="h-7 w-7 text-error"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.75}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M12 9v4M12 17h.01M10.3 3.86l-8.5 14.7A1.5 1.5 0 003.1 21h17.8a1.5 1.5 0 001.3-2.44l-8.5-14.7a1.5 1.5 0 00-2.6 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h1 className="font-display text-xl font-semibold text-text-primary mb-2">
|
||||
{t("title")}
|
||||
</h1>
|
||||
<p className="text-sm text-text-secondary mb-6">{t("description")}</p>
|
||||
{error?.digest && (
|
||||
<p className="text-[11px] font-mono text-text-muted mb-6">
|
||||
{error.digest}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-center justify-center gap-3">
|
||||
<button
|
||||
onClick={reset}
|
||||
className="py-2 px-4 rounded-lg bg-accent text-surface-0 text-sm font-medium hover:bg-accent-dim transition-colors cursor-pointer"
|
||||
>
|
||||
{t("retry")}
|
||||
</button>
|
||||
<Link
|
||||
href="/dashboard"
|
||||
className="py-2 px-4 rounded-lg border border-border text-sm font-medium text-text-secondary hover:text-text-primary hover:bg-surface-2 transition-colors"
|
||||
>
|
||||
{t("backToDashboard")}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +1,36 @@
|
||||
import type { Metadata, Viewport } from "next";
|
||||
import { NextIntlClientProvider } from "next-intl";
|
||||
import { getMessages } from "next-intl/server";
|
||||
import { getMessages, getTranslations } from "next-intl/server";
|
||||
import { routing } from "@/i18n/routing";
|
||||
import { notFound } from "next/navigation";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { NavShell } from "@/components/layout/nav-shell";
|
||||
|
||||
export function generateStaticParams() {
|
||||
return routing.locales.map((locale) => ({ locale }));
|
||||
}
|
||||
|
||||
// Metadata API (Next 15) instead of a hand-rolled <head>. The title
|
||||
// template lets each page export a short `title` (e.g. "Dashboard")
|
||||
// that renders as "Dashboard · PieCed". Pages that export no metadata
|
||||
// fall back to the default below.
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const t = await getTranslations("common");
|
||||
const appName = t("appName");
|
||||
return {
|
||||
title: {
|
||||
default: `${appName} Portal`,
|
||||
template: `%s · ${appName}`,
|
||||
},
|
||||
description: "PieCed IT — Multi-tenant AI assistant platform",
|
||||
};
|
||||
}
|
||||
|
||||
export const viewport: Viewport = {
|
||||
width: "device-width",
|
||||
initialScale: 1,
|
||||
};
|
||||
|
||||
export default async function LocaleLayout({
|
||||
children,
|
||||
params,
|
||||
@@ -22,20 +45,13 @@ export default async function LocaleLayout({
|
||||
}
|
||||
|
||||
const messages = await getMessages();
|
||||
const session = await auth();
|
||||
|
||||
return (
|
||||
<html lang={locale} className="dark">
|
||||
<head>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>PieCed Portal</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="PieCed IT — Multi-tenant AI assistant platform"
|
||||
/>
|
||||
</head>
|
||||
<body className="min-h-screen bg-surface-0 text-text-primary antialiased">
|
||||
<NextIntlClientProvider messages={messages}>
|
||||
<NavShell>{children}</NavShell>
|
||||
<NavShell session={session}>{children}</NavShell>
|
||||
</NextIntlClientProvider>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
25
src/app/[locale]/loading.tsx
Normal file
25
src/app/[locale]/loading.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* Loading skeleton for the [locale] segment. Shown during navigation
|
||||
* while a server component fetches (the dashboard, for instance, does
|
||||
* listTenants() + one K8s GET per provisioning row). Textless on
|
||||
* purpose so it needs no translations and adds no layout shift.
|
||||
*/
|
||||
export default function LocaleLoading() {
|
||||
return (
|
||||
<div className="animate-pulse" aria-hidden="true">
|
||||
<div className="mb-8">
|
||||
<div className="h-7 w-48 rounded-md bg-surface-2" />
|
||||
<div className="mt-4 h-4 w-72 rounded bg-surface-1" />
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="h-28 rounded-xl border border-border bg-surface-1"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<span className="sr-only">Loading…</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { signIn } from "next-auth/react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import Link from "next/link";
|
||||
import { useTranslations, useLocale } from "next-intl";
|
||||
import { Link, getPathname } from "@/i18n/navigation";
|
||||
|
||||
export default function LoginPage() {
|
||||
const t = useTranslations("login");
|
||||
const locale = useLocale();
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 flex items-center justify-center bg-surface-0">
|
||||
@@ -39,7 +40,14 @@ export default function LoginPage() {
|
||||
</p>
|
||||
|
||||
<button
|
||||
onClick={() => signIn("zitadel", { callbackUrl: "/dashboard" })}
|
||||
onClick={() =>
|
||||
signIn("zitadel", {
|
||||
// Preserve the active locale across the OIDC round-trip.
|
||||
// A bare "/dashboard" would resolve to the default (de)
|
||||
// locale on return; getPathname prefixes it as needed.
|
||||
callbackUrl: getPathname({ href: "/dashboard", locale }),
|
||||
})
|
||||
}
|
||||
className="
|
||||
w-full py-3 px-4 rounded-lg font-medium text-sm
|
||||
bg-accent text-surface-0 cursor-pointer
|
||||
|
||||
34
src/app/[locale]/not-found.tsx
Normal file
34
src/app/[locale]/not-found.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { Link } from "@/i18n/navigation";
|
||||
|
||||
/**
|
||||
* 404 for the [locale] segment. Triggered by notFound() calls in pages
|
||||
* below the locale layout. (A notFound() thrown by the locale layout
|
||||
* itself — e.g. an unknown locale — resolves to the framework default,
|
||||
* which is acceptable for that narrow case.)
|
||||
*/
|
||||
export default async function LocaleNotFound() {
|
||||
const t = await getTranslations("errors");
|
||||
|
||||
return (
|
||||
<div className="flex min-h-[60vh] items-center justify-center px-5">
|
||||
<div className="w-full max-w-md text-center">
|
||||
<div className="font-display text-5xl font-semibold text-accent mb-4 tabular-nums">
|
||||
404
|
||||
</div>
|
||||
<h1 className="font-display text-xl font-semibold text-text-primary mb-2">
|
||||
{t("notFoundTitle")}
|
||||
</h1>
|
||||
<p className="text-sm text-text-secondary mb-6">
|
||||
{t("notFoundDescription")}
|
||||
</p>
|
||||
<Link
|
||||
href="/dashboard"
|
||||
className="inline-flex py-2 px-4 rounded-lg bg-accent text-surface-0 text-sm font-medium hover:bg-accent-dim transition-colors"
|
||||
>
|
||||
{t("backToDashboard")}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,13 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { redirect } from "@/i18n/navigation";
|
||||
|
||||
export default function RootPage() {
|
||||
redirect("/dashboard");
|
||||
export default async function RootPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>;
|
||||
}) {
|
||||
// Locale-aware redirect: a bare next/navigation redirect("/dashboard")
|
||||
// drops the prefix and lands non-default-locale users on the German
|
||||
// dashboard. The i18n redirect prefixes per the active locale.
|
||||
const { locale } = await params;
|
||||
redirect({ href: "/dashboard", locale });
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useState, useRef, forwardRef } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useRouter, Link } from "@/i18n/navigation";
|
||||
import { Card } from "@/components/ui/card";
|
||||
|
||||
type FormState = "idle" | "submitting" | "success" | "error";
|
||||
@@ -50,6 +50,30 @@ export default function RegisterPage() {
|
||||
const [state, setState] = useState<FormState>("idle");
|
||||
const [error, setError] = useState("");
|
||||
|
||||
// Radiogroup keyboard support. `role="radio"` requires roving
|
||||
// tabindex (one tab stop) + arrow-key navigation between options —
|
||||
// native buttons don't move focus on arrows. The selected card is
|
||||
// the tab stop; when nothing is selected yet the first card is
|
||||
// focusable so keyboard users can enter the group.
|
||||
const TYPES: AccountType[] = ["personal", "company"];
|
||||
const cardRefs = useRef<(HTMLButtonElement | null)[]>([]);
|
||||
|
||||
const rovingTabIndex = (type: AccountType, index: number) =>
|
||||
accountType === type || (accountType === null && index === 0) ? 0 : -1;
|
||||
|
||||
const handleCardKeyDown = (e: React.KeyboardEvent, index: number) => {
|
||||
let next: number | null = null;
|
||||
if (e.key === "ArrowRight" || e.key === "ArrowDown") {
|
||||
next = (index + 1) % TYPES.length;
|
||||
} else if (e.key === "ArrowLeft" || e.key === "ArrowUp") {
|
||||
next = (index - 1 + TYPES.length) % TYPES.length;
|
||||
}
|
||||
if (next === null) return;
|
||||
e.preventDefault();
|
||||
setAccountType(TYPES[next]);
|
||||
cardRefs.current[next]?.focus();
|
||||
};
|
||||
|
||||
const isPersonal = accountType === "personal";
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
@@ -120,7 +144,7 @@ export default function RegisterPage() {
|
||||
</p>
|
||||
<button
|
||||
onClick={() => router.push("/login")}
|
||||
className="w-full py-2.5 px-4 bg-accent text-white text-sm font-medium rounded-lg hover:bg-accent-dim transition-colors"
|
||||
className="w-full py-2.5 px-4 bg-accent text-surface-0 text-sm font-medium rounded-lg hover:bg-accent-dim transition-colors"
|
||||
>
|
||||
{t("goToLogin")}
|
||||
</button>
|
||||
@@ -146,8 +170,13 @@ export default function RegisterPage() {
|
||||
className="grid grid-cols-2 gap-3 mb-6 animate-in animate-in-delay-1"
|
||||
>
|
||||
<AccountTypeCard
|
||||
ref={(el) => {
|
||||
cardRefs.current[0] = el;
|
||||
}}
|
||||
selected={accountType === "personal"}
|
||||
onClick={() => setAccountType("personal")}
|
||||
tabIndex={rovingTabIndex("personal", 0)}
|
||||
onKeyDown={(e) => handleCardKeyDown(e, 0)}
|
||||
label={t("personalCardTitle")}
|
||||
description={t("personalCardDescription")}
|
||||
icon={
|
||||
@@ -168,8 +197,13 @@ export default function RegisterPage() {
|
||||
}
|
||||
/>
|
||||
<AccountTypeCard
|
||||
ref={(el) => {
|
||||
cardRefs.current[1] = el;
|
||||
}}
|
||||
selected={accountType === "company"}
|
||||
onClick={() => setAccountType("company")}
|
||||
tabIndex={rovingTabIndex("company", 1)}
|
||||
onKeyDown={(e) => handleCardKeyDown(e, 1)}
|
||||
label={t("companyCardTitle")}
|
||||
description={t("companyCardDescription")}
|
||||
icon={
|
||||
@@ -270,7 +304,7 @@ export default function RegisterPage() {
|
||||
<button
|
||||
type="submit"
|
||||
disabled={state === "submitting"}
|
||||
className="w-full py-2.5 px-4 bg-accent text-white text-sm font-medium rounded-lg hover:bg-accent-dim transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
className="w-full py-2.5 px-4 bg-accent text-surface-0 text-sm font-medium rounded-lg hover:bg-accent-dim transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{state === "submitting" ? tCommon("loading") : t("submit")}
|
||||
</button>
|
||||
@@ -278,12 +312,12 @@ export default function RegisterPage() {
|
||||
|
||||
<p className="text-xs text-text-muted text-center mt-4">
|
||||
{t("hasAccount")}{" "}
|
||||
<a
|
||||
<Link
|
||||
href="/login"
|
||||
className="text-accent hover:text-accent-dim transition-colors"
|
||||
>
|
||||
{tCommon("login")}
|
||||
</a>
|
||||
</Link>
|
||||
</p>
|
||||
</Card>
|
||||
)}
|
||||
@@ -305,41 +339,42 @@ export default function RegisterPage() {
|
||||
* and text colours intensify when selected to give a clear "this one
|
||||
* is on" signal beyond just the border colour.
|
||||
*/
|
||||
function AccountTypeCard({
|
||||
selected,
|
||||
onClick,
|
||||
label,
|
||||
description,
|
||||
icon,
|
||||
}: {
|
||||
const AccountTypeCard = forwardRef<
|
||||
HTMLButtonElement,
|
||||
{
|
||||
selected: boolean;
|
||||
onClick: () => void;
|
||||
label: string;
|
||||
description: string;
|
||||
icon: React.ReactNode;
|
||||
}) {
|
||||
tabIndex: number;
|
||||
onKeyDown: (e: React.KeyboardEvent) => void;
|
||||
}
|
||||
>(function AccountTypeCard(
|
||||
{ selected, onClick, label, description, icon, tabIndex, onKeyDown },
|
||||
ref
|
||||
) {
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={selected}
|
||||
tabIndex={tabIndex}
|
||||
onClick={onClick}
|
||||
onKeyDown={onKeyDown}
|
||||
className={`text-left rounded-xl border p-4 transition-colors cursor-pointer focus:outline-none focus:ring-2 focus:ring-accent/40 ${
|
||||
selected
|
||||
? "border-accent bg-accent/10"
|
||||
: "border-border bg-surface-2 hover:border-accent/40 hover:bg-surface-3/30"
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`mb-2 ${
|
||||
selected ? "text-accent" : "text-text-muted"
|
||||
}`}
|
||||
>
|
||||
<div className={`mb-2 ${selected ? "text-accent" : "text-text-muted"}`}>
|
||||
{icon}
|
||||
</div>
|
||||
<div
|
||||
className={`text-sm font-semibold mb-0.5 ${
|
||||
selected ? "text-text-primary" : "text-text-primary"
|
||||
selected ? "text-text-primary" : "text-text-secondary"
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
@@ -347,4 +382,4 @@ function AccountTypeCard({
|
||||
<div className="text-xs text-text-muted leading-snug">{description}</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,30 +1,40 @@
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { redirect, notFound } from "next/navigation";
|
||||
import { getSessionUser, canMutate } from "@/lib/session";
|
||||
import { getOrgBilling } from "@/lib/db";
|
||||
import { BillingSettingsForm } from "@/components/settings/billing-settings-form";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { getSessionUser } from "@/lib/session";
|
||||
import { getOrgBilling, getOrgBillingConfig } from "@/lib/db";
|
||||
import { BillingSettingsForm } from "@/components/settings/billing-form";
|
||||
import { SavedCardSection } from "@/components/settings/saved-card-section";
|
||||
|
||||
/**
|
||||
* /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.
|
||||
*
|
||||
* Phase 9: also renders the saved-card section (Set up auto-pay /
|
||||
* Visa dot-dot-dot 4242, expires MM/YY / Update card / Disable
|
||||
* auto-pay / Remove card) when billing info is on file, plus a
|
||||
* footer note explaining that bank transfer is available on request.
|
||||
*/
|
||||
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, config] = await Promise.all([
|
||||
getOrgBilling(user.orgId),
|
||||
getOrgBillingConfig(user.orgId),
|
||||
]);
|
||||
|
||||
return (
|
||||
<main className="max-w-3xl mx-auto px-6 py-8">
|
||||
@@ -32,16 +42,30 @@ export default async function BillingSettingsPage() {
|
||||
<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>
|
||||
<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={billing}
|
||||
initial={existing}
|
||||
isPersonal={user.isPersonal}
|
||||
orgName={user.orgName}
|
||||
userName={user.name}
|
||||
userEmail={user.email}
|
||||
/>
|
||||
</div>
|
||||
{/* Phase 9: saved-card section. Only shown once billing info
|
||||
exists — without an address Stripe can't create the
|
||||
customer object, so the "Set up auto-pay" button would
|
||||
fail anyway. We give a clear hint up there if the form
|
||||
is empty (no need to surface the card UI). */}
|
||||
{existing && (
|
||||
<div className="animate-in animate-in-delay-2 mt-8">
|
||||
<SavedCardSection
|
||||
config={config}
|
||||
isPayByInvoice={!!config?.payByInvoice}
|
||||
isPersonal={user.isPersonal}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -14,14 +14,20 @@ import { Card } from "@/components/ui/card";
|
||||
* Access: any authenticated user (the cards themselves gate further;
|
||||
* non-owner users would not see "Billing" as actionable, etc.).
|
||||
*/
|
||||
export async function generateMetadata() {
|
||||
const t = await getTranslations("common");
|
||||
return { title: t("settings") };
|
||||
}
|
||||
|
||||
export default async function SettingsPage() {
|
||||
const user = await getSessionUser();
|
||||
if (!user) redirect("/login");
|
||||
const t = await getTranslations("settings");
|
||||
|
||||
// Build the list of settings cards. Each entry has a stable key, a
|
||||
// route, and a visibility predicate. Currently only billing; this
|
||||
// shape leaves headroom for adding more without restructuring.
|
||||
// route, and a visibility predicate. Phase 6 fix5: profile is
|
||||
// visible to every signed-in user (it's their own identity).
|
||||
// Billing stays gated behind canMutate.
|
||||
const sections: Array<{
|
||||
key: string;
|
||||
href: string;
|
||||
@@ -29,6 +35,14 @@ export default async function SettingsPage() {
|
||||
description: string;
|
||||
visible: boolean;
|
||||
}> = [
|
||||
{
|
||||
key: "profile",
|
||||
href: "/settings/profile",
|
||||
title: t("profileTitle"),
|
||||
description: t("profileDescription"),
|
||||
// Every signed-in user can edit their own first/last name.
|
||||
visible: true,
|
||||
},
|
||||
{
|
||||
key: "billing",
|
||||
href: "/settings/billing",
|
||||
|
||||
68
src/app/[locale]/settings/profile/page.tsx
Normal file
68
src/app/[locale]/settings/profile/page.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { getSessionUser } from "@/lib/session";
|
||||
import { getHumanUserDetail } from "@/lib/zitadel";
|
||||
import { ProfileSettingsForm } from "@/components/settings/profile-form";
|
||||
|
||||
/**
|
||||
* /settings/profile — every authenticated user can edit their own
|
||||
* first + last name. Email is shown read-only; changing it requires
|
||||
* verification and is left to ZITADEL's own self-service flow.
|
||||
*
|
||||
* Personal vs company accounts:
|
||||
* - Both can edit their first/last name in ZITADEL.
|
||||
* - Personal accounts get an extra hint: editing the ZITADEL name
|
||||
* does NOT change how the customer's name appears on invoices.
|
||||
* Invoice identity is in org_billing.company_name (the "Full
|
||||
* name" field on /settings/billing) and is intentionally
|
||||
* editable separately, because legal/billing identity may not
|
||||
* match preferred display identity.
|
||||
* - Company accounts see an org-membership hint instead.
|
||||
*
|
||||
* Server-fetches the current profile from ZITADEL via the
|
||||
* service-account PAT so the form starts with the canonical values
|
||||
* rather than whatever happens to be in the JWT (the JWT name might
|
||||
* be stale if the user updated their name in ZITADEL Console).
|
||||
*/
|
||||
export default async function ProfileSettingsPage() {
|
||||
const user = await getSessionUser();
|
||||
if (!user) redirect("/login");
|
||||
|
||||
const t = await getTranslations("settingsProfile");
|
||||
|
||||
let initial = { firstName: "", lastName: "", email: user.email };
|
||||
try {
|
||||
const profile = await getHumanUserDetail(user.id);
|
||||
initial = {
|
||||
firstName: profile.givenName,
|
||||
lastName: profile.familyName,
|
||||
email: profile.email || user.email,
|
||||
};
|
||||
} catch (e) {
|
||||
// Identity provider unreachable: render the form with whatever
|
||||
// we know from the session. The session has a combined `name`,
|
||||
// not split parts, so we leave first/last empty and let the user
|
||||
// re-enter. Server logs catch the underlying failure.
|
||||
console.error("ProfileSettingsPage: getHumanUserDetail failed:", e);
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="max-w-3xl 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">
|
||||
{user.isPersonal ? t("subtitlePersonal") : t("subtitle")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="animate-in animate-in-delay-1">
|
||||
<ProfileSettingsForm
|
||||
initial={initial}
|
||||
isPersonal={user.isPersonal}
|
||||
orgName={user.orgName}
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -24,6 +24,11 @@ import { TicketCategoryLabel } from "@/components/support/ticket-category-label"
|
||||
* having recent activity, but we don't sort by status; that's a
|
||||
* filter the admin can add later if the queue grows.
|
||||
*/
|
||||
export async function generateMetadata() {
|
||||
const t = await getTranslations("common");
|
||||
return { title: t("support") };
|
||||
}
|
||||
|
||||
export default async function SupportListPage() {
|
||||
const user = await getSessionUser();
|
||||
if (!user) redirect("/login");
|
||||
@@ -48,7 +53,7 @@ export default async function SupportListPage() {
|
||||
{!user.isPlatform && (
|
||||
<Link
|
||||
href="/support/new"
|
||||
className="text-sm font-medium px-4 py-2 rounded-lg bg-accent text-white hover:bg-accent/90 transition-colors"
|
||||
className="text-sm font-medium px-4 py-2 rounded-lg bg-accent text-surface-0 hover:bg-accent/90 transition-colors"
|
||||
>
|
||||
{t("newTicket")}
|
||||
</Link>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Card } from "@/components/ui/card";
|
||||
import { BackLink } from "@/components/ui/back-link";
|
||||
import { TeamList } from "@/components/team/team-list";
|
||||
import { InviteForm } from "@/components/team/invite-form";
|
||||
import { AccessOverview } from "@/components/team/access-overview";
|
||||
|
||||
/**
|
||||
* /team — manage org members.
|
||||
@@ -17,6 +18,11 @@ import { InviteForm } from "@/components/team/invite-form";
|
||||
* `<TeamList>` and `<InviteForm>` client components handle live
|
||||
* updates after invites and refreshes.
|
||||
*/
|
||||
export async function generateMetadata() {
|
||||
const t = await getTranslations("common");
|
||||
return { title: t("team") };
|
||||
}
|
||||
|
||||
export default async function TeamPage() {
|
||||
const user = await getSessionUser();
|
||||
if (!user) redirect("/login");
|
||||
@@ -65,6 +71,16 @@ export default async function TeamPage() {
|
||||
canEditRoles={isCustomerOwner(user)}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{/* Access overview — single place to see which member can reach
|
||||
which assistant, instead of checking each tenant page. */}
|
||||
<section className="mt-8 animate-in animate-in-delay-3">
|
||||
<h2 className="text-xs font-semibold uppercase tracking-wider text-text-muted mb-1">
|
||||
{t("accessTitle")}
|
||||
</h2>
|
||||
<p className="text-xs text-text-muted mb-3">{t("accessDescription")}</p>
|
||||
<AccessOverview />
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import { WorkspaceEditor } from "@/components/packages/workspace-editor";
|
||||
import { ChannelUsers } from "@/components/channel-users/channel-users";
|
||||
import { AssignedUsersPanel } from "@/components/tenants/assigned-users-panel";
|
||||
import { SubscriptionToggle } from "@/components/tenants/subscription-toggle";
|
||||
import { ConnectPanel } from "@/components/tenants/connect-panel";
|
||||
import { formatDateTime, formatRelative } from "@/lib/format";
|
||||
import { CHANNEL_PACKAGE_IDS } from "@/lib/packages";
|
||||
|
||||
@@ -216,6 +217,20 @@ export default async function TenantDetailPage({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Connect: how the customer actually reaches their assistant.
|
||||
The portal manages the assistant; the assistant lives in the
|
||||
customer's messaging app. This bridges that gap right at the
|
||||
top of the page (and calls out the case where no channel is
|
||||
enabled, which would otherwise leave a running assistant
|
||||
unreachable). */}
|
||||
<section className="mb-8 animate-in animate-in-delay-1">
|
||||
<ConnectPanel
|
||||
tenantName={name}
|
||||
enabledChannels={enabledChannels}
|
||||
phase={tenant.status?.phase ?? "Pending"}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{/* Usage */}
|
||||
<section className="mb-8 animate-in animate-in-delay-1">
|
||||
<h2 className="text-xs font-semibold uppercase tracking-wider text-text-muted mb-3">
|
||||
|
||||
64
src/app/api/admin/billing/invoice-drafts/[id]/issue/route.ts
Normal file
64
src/app/api/admin/billing/invoice-drafts/[id]/issue/route.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getSessionUser, requirePlatformRole } from "@/lib/session";
|
||||
import {
|
||||
CustomInvoiceValidationError,
|
||||
issueCustomInvoiceDraft,
|
||||
} from "@/lib/billing";
|
||||
import { safeError } from "@/lib/errors";
|
||||
|
||||
/**
|
||||
* POST /api/admin/billing/invoice-drafts/[id]/issue
|
||||
*
|
||||
* Phase 8. Convert a draft into a real invoice:
|
||||
* - Validate payload (must have lines, valid dates, billing snapshot)
|
||||
* - Allocate invoice number from the shared year-scoped counter
|
||||
* - Persist invoice with source='custom'
|
||||
* - Render PDF
|
||||
* - Email customer
|
||||
* - Delete the draft
|
||||
*
|
||||
* Returns the issued Invoice on success. Errors map cleanly to
|
||||
* HTTP codes:
|
||||
* 400 — validation failure (CustomInvoiceValidationError)
|
||||
* 404 — draft id doesn't exist (also CustomInvoiceValidationError
|
||||
* since the orchestrator can't tell apart "draft missing"
|
||||
* from "invalid input" — the message string discriminates)
|
||||
* 500 — anything else (DB error, Stripe error not applicable here)
|
||||
*
|
||||
* Idempotency: this endpoint is NOT idempotent. Issuing twice
|
||||
* allocates two invoice numbers. The admin UI disables the submit
|
||||
* button while in-flight, but for safety the backend handles
|
||||
* double-submit by failing on the second call (the draft was
|
||||
* deleted by the first).
|
||||
*/
|
||||
export async function POST(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
let user;
|
||||
try {
|
||||
await requirePlatformRole();
|
||||
user = await getSessionUser();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
const { id } = await params;
|
||||
try {
|
||||
const invoice = await issueCustomInvoiceDraft({
|
||||
draftId: id,
|
||||
issuedBy: user.id,
|
||||
});
|
||||
return NextResponse.json({ invoice });
|
||||
} catch (e) {
|
||||
if (e instanceof CustomInvoiceValidationError) {
|
||||
return NextResponse.json({ error: e.message }, { status: 400 });
|
||||
}
|
||||
return NextResponse.json(
|
||||
{ error: safeError(e, "Failed to issue custom invoice") },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { requirePlatformRole } from "@/lib/session";
|
||||
import {
|
||||
CustomInvoiceValidationError,
|
||||
renderCustomDraftPreview,
|
||||
} from "@/lib/billing";
|
||||
import { safeError } from "@/lib/errors";
|
||||
|
||||
/**
|
||||
* GET /api/admin/billing/invoice-drafts/[id]/preview
|
||||
*
|
||||
* Phase 8. Render the current draft as a PDF without persisting an
|
||||
* invoice. The bytes are returned inline so the browser displays
|
||||
* the document in a new tab. The invoice number on the rendered
|
||||
* PDF is the placeholder "DRAFT" — no real number is allocated.
|
||||
*
|
||||
* Useful for the admin's "Review" step in the draft → review →
|
||||
* issue flow.
|
||||
*/
|
||||
export async function GET(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
await requirePlatformRole();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
const { id } = await params;
|
||||
try {
|
||||
const pdf = await renderCustomDraftPreview(id);
|
||||
return new NextResponse(new Uint8Array(pdf), {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "application/pdf",
|
||||
// Inline so the browser displays the PDF immediately. The
|
||||
// filename is a guide — most browsers ignore it for inline
|
||||
// disposition but it shows on the "Save as" dialog.
|
||||
"Content-Disposition": `inline; filename="invoice-draft-${id}.pdf"`,
|
||||
"Cache-Control": "no-store",
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
if (e instanceof CustomInvoiceValidationError) {
|
||||
return NextResponse.json({ error: e.message }, { status: 400 });
|
||||
}
|
||||
return NextResponse.json(
|
||||
{ error: safeError(e, "Failed to render preview") },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
120
src/app/api/admin/billing/invoice-drafts/[id]/route.ts
Normal file
120
src/app/api/admin/billing/invoice-drafts/[id]/route.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { requirePlatformRole } from "@/lib/session";
|
||||
import {
|
||||
deleteInvoiceDraft,
|
||||
getInvoiceDraftById,
|
||||
updateInvoiceDraft,
|
||||
} from "@/lib/db";
|
||||
import { safeError } from "@/lib/errors";
|
||||
import type { CustomInvoiceDraftPayload } from "@/types";
|
||||
|
||||
/**
|
||||
* /api/admin/billing/invoice-drafts/[id]
|
||||
*
|
||||
* Phase 8.
|
||||
*
|
||||
* GET — fetch one draft
|
||||
* PUT — overwrite the payload (full replace, not patch)
|
||||
* DELETE — discard the draft
|
||||
*
|
||||
* All require platform admin. The org boundary is *not* enforced
|
||||
* here: a platform admin can edit any draft regardless of which
|
||||
* org it targets. If we ever introduce a per-org admin role,
|
||||
* scope filtering would go in this file.
|
||||
*/
|
||||
|
||||
const lineSchema = z.object({
|
||||
description: z.string().trim().min(1).max(500),
|
||||
quantity: z.number().finite(),
|
||||
unitPriceChf: z.number().finite(),
|
||||
});
|
||||
|
||||
const payloadSchema = z.object({
|
||||
issueDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
|
||||
dueDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
|
||||
locale: z.enum(["de", "en", "fr", "it"]),
|
||||
paymentMethod: z.enum(["invoice", "card"]),
|
||||
adminNotes: z.string().max(2000).optional(),
|
||||
lines: z.array(lineSchema).max(100),
|
||||
});
|
||||
|
||||
export async function GET(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
await requirePlatformRole();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
const { id } = await params;
|
||||
try {
|
||||
const draft = await getInvoiceDraftById(id);
|
||||
if (!draft) {
|
||||
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
}
|
||||
return NextResponse.json({ draft });
|
||||
} catch (e) {
|
||||
return NextResponse.json(
|
||||
{ error: safeError(e, "Failed to load draft") },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
await requirePlatformRole();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
const { id } = await params;
|
||||
const body = await request.json().catch(() => ({}));
|
||||
const parsed = payloadSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid request", details: parsed.error.flatten() },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
try {
|
||||
const updated = await updateInvoiceDraft(
|
||||
id,
|
||||
parsed.data as CustomInvoiceDraftPayload
|
||||
);
|
||||
if (!updated) {
|
||||
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
}
|
||||
return NextResponse.json({ draft: updated });
|
||||
} catch (e) {
|
||||
return NextResponse.json(
|
||||
{ error: safeError(e, "Failed to update draft") },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
await requirePlatformRole();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
const { id } = await params;
|
||||
try {
|
||||
const deleted = await deleteInvoiceDraft(id);
|
||||
return NextResponse.json({ deleted });
|
||||
} catch (e) {
|
||||
return NextResponse.json(
|
||||
{ error: safeError(e, "Failed to delete draft") },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
94
src/app/api/admin/billing/invoice-drafts/route.ts
Normal file
94
src/app/api/admin/billing/invoice-drafts/route.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { requirePlatformRole, getSessionUser } from "@/lib/session";
|
||||
import {
|
||||
createInvoiceDraft,
|
||||
listAllInvoiceDrafts,
|
||||
} from "@/lib/db";
|
||||
import { safeError } from "@/lib/errors";
|
||||
import type { CustomInvoiceDraftPayload } from "@/types";
|
||||
|
||||
/**
|
||||
* /api/admin/billing/invoice-drafts
|
||||
*
|
||||
* Phase 8. Drafts for the admin "New invoice" flow.
|
||||
*
|
||||
* GET — list all open drafts across all orgs, newest-touched first.
|
||||
* POST — create a new draft for an org with an initial (possibly
|
||||
* empty) payload. Returns the inserted draft.
|
||||
*
|
||||
* Both require platform admin. Drafts have no customer-facing
|
||||
* surface: they aren't reachable from /billing or any non-admin
|
||||
* route.
|
||||
*/
|
||||
|
||||
const lineSchema = z.object({
|
||||
description: z.string().trim().min(1).max(500),
|
||||
quantity: z.number().finite(),
|
||||
unitPriceChf: z.number().finite(),
|
||||
});
|
||||
|
||||
const payloadSchema = z.object({
|
||||
issueDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
|
||||
dueDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
|
||||
locale: z.enum(["de", "en", "fr", "it"]),
|
||||
paymentMethod: z.enum(["invoice", "card"]),
|
||||
adminNotes: z.string().max(2000).optional(),
|
||||
lines: z.array(lineSchema).max(100),
|
||||
});
|
||||
|
||||
const createSchema = z.object({
|
||||
zitadelOrgId: z.string().trim().min(1),
|
||||
payload: payloadSchema,
|
||||
});
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
await requirePlatformRole();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
try {
|
||||
const drafts = await listAllInvoiceDrafts();
|
||||
return NextResponse.json({ drafts });
|
||||
} catch (e) {
|
||||
return NextResponse.json(
|
||||
{ error: safeError(e, "Failed to list drafts") },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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 = createSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid request", details: parsed.error.flatten() },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
try {
|
||||
const draft = await createInvoiceDraft({
|
||||
zitadelOrgId: parsed.data.zitadelOrgId,
|
||||
createdBy: user.id,
|
||||
payload: parsed.data.payload as CustomInvoiceDraftPayload,
|
||||
});
|
||||
return NextResponse.json({ draft });
|
||||
} catch (e) {
|
||||
return NextResponse.json(
|
||||
{ error: safeError(e, "Failed to create draft") },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
88
src/app/api/admin/billing/invoices/[id]/refund/route.ts
Normal file
88
src/app/api/admin/billing/invoices/[id]/refund/route.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { requirePlatformRole, getSessionUser } from "@/lib/session";
|
||||
import { refundInvoice, RefundNotAllowedError } from "@/lib/billing";
|
||||
import { safeError } from "@/lib/errors";
|
||||
|
||||
/**
|
||||
* POST /api/admin/billing/invoices/[id]/refund
|
||||
*
|
||||
* Phase 7. Refunds a paid invoice (full or partial) and issues a
|
||||
* credit note. For Stripe-paid invoices, calls Stripe's Refund API
|
||||
* before any local recording. For invoice-paid customers (bank
|
||||
* transfer), records the refund locally and assumes the admin
|
||||
* handled the actual money movement out-of-band.
|
||||
*
|
||||
* Body:
|
||||
* {
|
||||
* amountChf: number, // positive, <= remaining refundable
|
||||
* reason: string // required, free-text, max 500
|
||||
* }
|
||||
*
|
||||
* Authorization: platform admin.
|
||||
*
|
||||
* Status codes:
|
||||
* 200 — refund issued, credit note returned
|
||||
* 400 — bad request (zero/negative amount, etc.)
|
||||
* 401 / 403 — not authenticated / not platform admin
|
||||
* 409 — invoice not in a refundable state, or amount exceeds remaining
|
||||
* 500 — Stripe call failed or another internal error
|
||||
*
|
||||
* Idempotency caveats: this endpoint is NOT idempotent against
|
||||
* client retries. Issuing two refunds quickly will result in two
|
||||
* Stripe refund calls (and two credit notes). The admin UI should
|
||||
* disable the submit button while the request is in flight to
|
||||
* prevent accidental double-clicks. The Stripe charge.refunded
|
||||
* webhook is idempotent and will not double-count if it fires
|
||||
* after this endpoint already recorded the refund.
|
||||
*/
|
||||
|
||||
const bodySchema = z.object({
|
||||
amountChf: z.number().positive().multipleOf(0.01),
|
||||
reason: z.string().trim().min(1).max(500),
|
||||
});
|
||||
|
||||
export async function POST(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
let user;
|
||||
try {
|
||||
await requirePlatformRole();
|
||||
user = await getSessionUser();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
const { id } = await params;
|
||||
const body = await request.json().catch(() => ({}));
|
||||
const parsed = bodySchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid request", details: parsed.error.flatten() },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
try {
|
||||
const creditNote = await refundInvoice({
|
||||
invoiceId: id,
|
||||
amountChf: parsed.data.amountChf,
|
||||
reason: parsed.data.reason,
|
||||
refundedBy: user.id,
|
||||
});
|
||||
return NextResponse.json({ creditNote });
|
||||
} catch (e) {
|
||||
if (e instanceof RefundNotAllowedError) {
|
||||
return NextResponse.json(
|
||||
{ error: e.message, currentStatus: e.currentStatus },
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
return NextResponse.json(
|
||||
{ error: safeError(e, "Refund failed") },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
77
src/app/api/admin/billing/invoices/[id]/void/route.ts
Normal file
77
src/app/api/admin/billing/invoices/[id]/void/route.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { requirePlatformRole, getSessionUser } from "@/lib/session";
|
||||
import { voidInvoice, VoidNotAllowedError } from "@/lib/billing";
|
||||
import { safeError } from "@/lib/errors";
|
||||
|
||||
/**
|
||||
* POST /api/admin/billing/invoices/[id]/void
|
||||
*
|
||||
* Phase 7. Voids an unpaid invoice and issues a credit note.
|
||||
*
|
||||
* Body:
|
||||
* {
|
||||
* reason: string // required, free-text, max 500
|
||||
* }
|
||||
*
|
||||
* Authorization: platform admin (same as mark-paid, generate, etc.).
|
||||
* The acting user's ID lands in invoices.voided_by and on the
|
||||
* credit_notes.issued_by audit columns.
|
||||
*
|
||||
* Status codes:
|
||||
* 200 — voided, credit note returned in body
|
||||
* 400 — bad request (missing reason etc.)
|
||||
* 401 / 403 — not authenticated / not platform admin
|
||||
* 409 — invoice not in a voidable state
|
||||
* 500 — anything else (Stripe shouldn't apply here, but if PDF
|
||||
* render fails the void still went through — see body
|
||||
* payload for the credit-note number to re-render later)
|
||||
*/
|
||||
|
||||
const bodySchema = z.object({
|
||||
reason: z.string().trim().min(1).max(500),
|
||||
});
|
||||
|
||||
export async function POST(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
let user;
|
||||
try {
|
||||
await requirePlatformRole();
|
||||
user = await getSessionUser();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
const { id } = await params;
|
||||
const body = await request.json().catch(() => ({}));
|
||||
const parsed = bodySchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid request", details: parsed.error.flatten() },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
try {
|
||||
const creditNote = await voidInvoice({
|
||||
invoiceId: id,
|
||||
reason: parsed.data.reason,
|
||||
voidedBy: user.id,
|
||||
});
|
||||
return NextResponse.json({ creditNote });
|
||||
} catch (e) {
|
||||
if (e instanceof VoidNotAllowedError) {
|
||||
return NextResponse.json(
|
||||
{ error: e.message, currentStatus: e.currentStatus },
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
return NextResponse.json(
|
||||
{ error: safeError(e, "Void failed") },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
72
src/app/api/admin/billing/orgs/[orgId]/payment-mode/route.ts
Normal file
72
src/app/api/admin/billing/orgs/[orgId]/payment-mode/route.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { requirePlatformRole } from "@/lib/session";
|
||||
import {
|
||||
getOrgBillingConfig,
|
||||
setAutoChargeEnabled,
|
||||
updateOrgBillingConfig,
|
||||
} from "@/lib/db";
|
||||
import { safeError } from "@/lib/errors";
|
||||
|
||||
/**
|
||||
* POST /api/admin/billing/orgs/[orgId]/payment-mode
|
||||
*
|
||||
* Phase 9b-2. Admin-only override of an org's billing mode:
|
||||
* - payByInvoice (boolean) — flip the customer's account to
|
||||
* bank-transfer billing. Auto-charge is skipped entirely for
|
||||
* these orgs; they receive the regular issued-invoice email
|
||||
* and pay manually. Switching ON also implicitly stops
|
||||
* attempting card charges even if a saved card exists.
|
||||
* - autoChargeEnabled (boolean) — pause auto-charge without
|
||||
* committing to pay-by-invoice. Useful during disputes or
|
||||
* billing investigations.
|
||||
*
|
||||
* Either flag may be omitted; the endpoint only writes what's
|
||||
* provided. Returns the updated config.
|
||||
*/
|
||||
const bodySchema = z.object({
|
||||
payByInvoice: z.boolean().optional(),
|
||||
autoChargeEnabled: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export async function POST(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ orgId: string }> }
|
||||
) {
|
||||
try {
|
||||
await requirePlatformRole();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
const { orgId } = await params;
|
||||
const body = await request.json().catch(() => ({}));
|
||||
const parsed = bodySchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid request", details: parsed.error.flatten() },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
const { payByInvoice, autoChargeEnabled } = parsed.data;
|
||||
if (payByInvoice === undefined && autoChargeEnabled === undefined) {
|
||||
return NextResponse.json(
|
||||
{ error: "Provide at least one of payByInvoice or autoChargeEnabled" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
try {
|
||||
if (payByInvoice !== undefined) {
|
||||
await updateOrgBillingConfig(orgId, { payByInvoice });
|
||||
}
|
||||
if (autoChargeEnabled !== undefined) {
|
||||
await setAutoChargeEnabled(orgId, autoChargeEnabled);
|
||||
}
|
||||
const cfg = await getOrgBillingConfig(orgId);
|
||||
return NextResponse.json({ config: cfg });
|
||||
} catch (e) {
|
||||
return NextResponse.json(
|
||||
{ error: safeError(e, "Failed to update payment mode") },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
68
src/app/api/admin/cron/issue-monthly/route.ts
Normal file
68
src/app/api/admin/cron/issue-monthly/route.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { getSessionUser, requirePlatformRole } from "@/lib/session";
|
||||
import { runMonthlyIssuance } from "@/lib/cron";
|
||||
import { safeError } from "@/lib/errors";
|
||||
|
||||
/**
|
||||
* POST /api/admin/cron/issue-monthly
|
||||
*
|
||||
* Admin-side manual trigger for the issuance sweep — same business
|
||||
* logic as /api/cron/issue-monthly, different auth (session-based
|
||||
* platform role check) and the option to override the target
|
||||
* year/month from the request body.
|
||||
*
|
||||
* Body (all optional):
|
||||
* { year?: number, month?: number }
|
||||
*
|
||||
* Default target is the previous local month — matching what the
|
||||
* automated cron would do. Override is useful for catching up after
|
||||
* a failed run or re-billing a past month after fixing data.
|
||||
*/
|
||||
const bodySchema = z.object({
|
||||
year: z.number().int().min(2000).max(3000).optional(),
|
||||
month: z.number().int().min(1).max(12).optional(),
|
||||
});
|
||||
|
||||
export async function POST(request: Request) {
|
||||
let user;
|
||||
try {
|
||||
await requirePlatformRole();
|
||||
user = await getSessionUser();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
const body = await request.json().catch(() => ({}));
|
||||
const parsed = bodySchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid request", details: parsed.error.flatten() },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
if (
|
||||
(parsed.data.year && !parsed.data.month) ||
|
||||
(parsed.data.month && !parsed.data.year)
|
||||
) {
|
||||
return NextResponse.json(
|
||||
{ error: "year and month must both be provided, or neither" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
try {
|
||||
const { runId, summary } = await runMonthlyIssuance({
|
||||
triggeredBy: user.id,
|
||||
year: parsed.data.year,
|
||||
month: parsed.data.month,
|
||||
});
|
||||
return NextResponse.json({ runId, ...summary });
|
||||
} catch (e) {
|
||||
return NextResponse.json(
|
||||
{ error: safeError(e, "Issuance sweep failed.") },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
27
src/app/api/admin/cron/runs/route.ts
Normal file
27
src/app/api/admin/cron/runs/route.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { requirePlatformRole } from "@/lib/session";
|
||||
import {
|
||||
getLastSuccessfulCronRuns,
|
||||
listRecentCronRuns,
|
||||
} from "@/lib/db";
|
||||
|
||||
/**
|
||||
* GET /api/admin/cron/runs
|
||||
*
|
||||
* Returns recent cron run history plus per-kind "last successful"
|
||||
* summary for the admin /admin/cron dashboard.
|
||||
*
|
||||
* Response: { recent: CronRun[]; lastSuccess: { monthlyIssue, reminders } }
|
||||
*/
|
||||
export async function GET() {
|
||||
try {
|
||||
await requirePlatformRole();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
const [recent, lastSuccess] = await Promise.all([
|
||||
listRecentCronRuns(30),
|
||||
getLastSuccessfulCronRuns(),
|
||||
]);
|
||||
return NextResponse.json({ recent, lastSuccess });
|
||||
}
|
||||
34
src/app/api/admin/cron/send-reminders/route.ts
Normal file
34
src/app/api/admin/cron/send-reminders/route.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getSessionUser, requirePlatformRole } from "@/lib/session";
|
||||
import { runReminderSweep } from "@/lib/cron";
|
||||
import { safeError } from "@/lib/errors";
|
||||
|
||||
/**
|
||||
* POST /api/admin/cron/send-reminders
|
||||
*
|
||||
* Admin-side manual trigger for the reminder sweep. Same logic
|
||||
* as the machine path; session-based platform-role auth.
|
||||
*/
|
||||
export async function POST() {
|
||||
let user;
|
||||
try {
|
||||
await requirePlatformRole();
|
||||
user = await getSessionUser();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
try {
|
||||
const { runId, summary } = await runReminderSweep({
|
||||
triggeredBy: user.id,
|
||||
});
|
||||
return NextResponse.json({ runId, ...summary });
|
||||
} catch (e) {
|
||||
return NextResponse.json(
|
||||
{ error: safeError(e, "Reminder sweep failed.") },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -4,14 +4,12 @@ import {
|
||||
getTenantRequestById,
|
||||
updateTenantRequestStatus,
|
||||
clearEncryptedSecrets,
|
||||
recordTenantCreated,
|
||||
recordSkillEvents,
|
||||
recordSuspensionEvent,
|
||||
} from "@/lib/db";
|
||||
import { createTenant, patchTenantSpec, setTenantAnnotation } from "@/lib/k8s";
|
||||
import { sendApprovalEmail, sendResumeApprovalEmail } from "@/lib/email";
|
||||
import { decryptSecrets } from "@/lib/crypto";
|
||||
import { writePackageSecrets } from "@/lib/openbao";
|
||||
import { createRoute as createRelayRoute } from "@/lib/threema-relay";
|
||||
import {
|
||||
getDefaultSoulMd,
|
||||
getDefaultAgentsMd,
|
||||
@@ -88,23 +86,6 @@ export async function POST(
|
||||
}
|
||||
try {
|
||||
await patchTenantSpec(tenantRequest.tenantName, { suspend: false });
|
||||
|
||||
// Billing — Phase 1: record the resume so monthly proration
|
||||
// counts the suspended segment correctly. Best-effort; if
|
||||
// logging fails, the approval still succeeds.
|
||||
try {
|
||||
await recordSuspensionEvent(
|
||||
tenantRequest.tenantName,
|
||||
tenantRequest.zitadelOrgId,
|
||||
"resumed"
|
||||
);
|
||||
} catch (e) {
|
||||
console.error(
|
||||
"billing: failed to record resumed suspension event:",
|
||||
e
|
||||
);
|
||||
}
|
||||
|
||||
// Clear the annotation that pauses the operator's 60-day TTL.
|
||||
// Best-effort — annotation cleanup is also done by the operator
|
||||
// when it sees suspend=false on the next reconcile (it clears
|
||||
@@ -197,6 +178,29 @@ export async function POST(
|
||||
? tenantRequest.contactName || "Assistant"
|
||||
: tenantRequest.companyName;
|
||||
|
||||
// Phase 9b: split the customer's initial channel-user ids into
|
||||
// (a) ids the operator needs in spec.channelUsers (telegram,
|
||||
// discord, …) — passed straight into createTenant
|
||||
// (b) Threema ids that ALSO need a relay route registered so
|
||||
// inbound messages reach this tenant. Threema is in (a)
|
||||
// AND (b): spec.channelUsers tells the operator the id is
|
||||
// authorized; the relay's route maps inbound traffic from
|
||||
// that id to this tenant.
|
||||
const initialChannelUsers = tenantRequest.channelUsers ?? {};
|
||||
// Strip channels the customer didn't actually enable (defensive
|
||||
// — the wizard already filters this, but the row could carry
|
||||
// stale data if the customer edited their request post-submit).
|
||||
const filteredChannelUsers: Record<string, string[]> = {};
|
||||
for (const [channel, ids] of Object.entries(initialChannelUsers)) {
|
||||
if (!packages.includes(channel)) continue;
|
||||
const cleaned = (ids ?? [])
|
||||
.map((s) => (s ?? "").trim())
|
||||
.filter((s) => s.length > 0);
|
||||
if (cleaned.length > 0) {
|
||||
filteredChannelUsers[channel] = cleaned;
|
||||
}
|
||||
}
|
||||
|
||||
await createTenant(
|
||||
tenantName,
|
||||
{
|
||||
@@ -204,6 +208,9 @@ export async function POST(
|
||||
agentName: tenantRequest.agentName,
|
||||
packages,
|
||||
workspaceFiles,
|
||||
...(Object.keys(filteredChannelUsers).length > 0
|
||||
? { channelUsers: filteredChannelUsers }
|
||||
: {}),
|
||||
},
|
||||
{
|
||||
"pieced.ch/zitadel-org-id": tenantRequest.zitadelOrgId,
|
||||
@@ -219,34 +226,34 @@ export async function POST(
|
||||
}
|
||||
);
|
||||
|
||||
// Billing — Phase 1: record the tenant's creation and initial
|
||||
// package state. Anchored at "now" rather than the CR's
|
||||
// creationTimestamp because we don't get the timestamp back from
|
||||
// createTenant — the few-millisecond skew vs the CR's actual
|
||||
// creationTimestamp is irrelevant for monthly billing.
|
||||
//
|
||||
// Best-effort: tracking failures must never block provisioning.
|
||||
// The backfill helper can repair any gaps later if needed.
|
||||
const billingAnchor = new Date();
|
||||
// Threema: register relay routes for each id the customer
|
||||
// entered. Best-effort — a route failure doesn't unwind the
|
||||
// tenant creation (admin can retry from the tenant page later).
|
||||
// The Threema package itself isn't enabled on the tenant until
|
||||
// the customer toggles it from the tenant detail page (which
|
||||
// also mints the per-tenant token); the routes here pre-warm
|
||||
// the relay so the first toggle works without re-typing the id.
|
||||
if (
|
||||
packages.includes("threema") &&
|
||||
filteredChannelUsers.threema &&
|
||||
filteredChannelUsers.threema.length > 0
|
||||
) {
|
||||
for (const tid of filteredChannelUsers.threema) {
|
||||
try {
|
||||
await recordTenantCreated(
|
||||
tenantName,
|
||||
tenantRequest.zitadelOrgId,
|
||||
billingAnchor
|
||||
);
|
||||
await recordSkillEvents(
|
||||
tenantName,
|
||||
tenantRequest.zitadelOrgId,
|
||||
packages,
|
||||
[],
|
||||
billingAnchor
|
||||
const res = await createRelayRoute(tenantName, tid);
|
||||
if (!res.ok) {
|
||||
console.warn(
|
||||
`[approve] Threema route create for tenant=${tenantName} id=${tid} returned not-ok: ${res.message}`
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(
|
||||
"billing: failed to record tenant creation / initial skill events:",
|
||||
`[approve] Threema route create threw for tenant=${tenantName} id=${tid}:`,
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Step 5: Update request status — clear admin notes on re-approval
|
||||
const updated = await updateTenantRequestStatus(id, "provisioning", {
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { requirePlatformRole } from "@/lib/session";
|
||||
import { getTenantRequestById, updateTenantRequestStatus } from "@/lib/db";
|
||||
import {
|
||||
getInvoiceById,
|
||||
getTenantRequestById,
|
||||
updateTenantRequestStatus,
|
||||
} from "@/lib/db";
|
||||
import { setTenantAnnotation } from "@/lib/k8s";
|
||||
import { sendRejectionEmail, sendResumeRejectionEmail } from "@/lib/email";
|
||||
import { refundInvoice, RefundNotAllowedError } from "@/lib/billing";
|
||||
import type { SessionUser } from "@/types";
|
||||
|
||||
/**
|
||||
* POST /api/admin/requests/[id]/reject
|
||||
@@ -14,13 +20,23 @@ import { sendRejectionEmail, sendResumeRejectionEmail } from "@/lib/email";
|
||||
* suspendedAt — rejection doesn't reset it. The customer can submit
|
||||
* a fresh resume request later if circumstances change, but that
|
||||
* starts a new pending row and re-stamps the annotation.
|
||||
*
|
||||
* Phase 9b: provision rejections that have a linked paid setup
|
||||
* invoice (setup_invoice_id) trigger an automatic full refund via
|
||||
* the existing refundInvoice flow. The refund creates a credit
|
||||
* note + Stripe refund + customer email — same paper trail any
|
||||
* post-payment refund would have. Best-effort: a refund failure
|
||||
* does NOT block the rejection (admin can re-refund manually via
|
||||
* the invoice detail page if needed), but it's logged and surfaced
|
||||
* in the response so admin sees what happened.
|
||||
*/
|
||||
export async function POST(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
let user: SessionUser;
|
||||
try {
|
||||
await requirePlatformRole();
|
||||
user = await requirePlatformRole();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
@@ -65,6 +81,63 @@ export async function POST(
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 9b: refund the setup-fee invoice if one is linked. Only
|
||||
// applies to provision rejections; resume requests never have a
|
||||
// setup_invoice_id. Skip silently if no invoice is linked (e.g.
|
||||
// the request was created before Phase 9b shipped, or the setup
|
||||
// fee was 0).
|
||||
const refundSummary: {
|
||||
attempted: boolean;
|
||||
succeeded: boolean;
|
||||
error?: string;
|
||||
} = { attempted: false, succeeded: false };
|
||||
if (
|
||||
tenantRequest.requestType === "provision" &&
|
||||
tenantRequest.setupInvoiceId
|
||||
) {
|
||||
refundSummary.attempted = true;
|
||||
try {
|
||||
// refundInvoice expects an explicit CHF amount (no "full"
|
||||
// sentinel). Compute the remaining refundable amount as
|
||||
// total minus what's already been refunded. For a fresh
|
||||
// setup-fee invoice this is just totalChf, but the formula
|
||||
// is robust if admin had partially refunded earlier (rare
|
||||
// but possible — same invoice could in theory get a manual
|
||||
// partial refund, then a rejection).
|
||||
const inv = await getInvoiceById(tenantRequest.setupInvoiceId);
|
||||
if (!inv) {
|
||||
throw new Error(
|
||||
`Linked setup invoice ${tenantRequest.setupInvoiceId} not found`
|
||||
);
|
||||
}
|
||||
const remaining = Math.round(
|
||||
(inv.totalChf - (inv.refundedTotalChf ?? 0)) * 100
|
||||
) / 100;
|
||||
if (remaining <= 0) {
|
||||
refundSummary.succeeded = true; // nothing to refund — treat as success
|
||||
} else {
|
||||
await refundInvoice({
|
||||
invoiceId: tenantRequest.setupInvoiceId,
|
||||
amountChf: remaining,
|
||||
reason: adminNotes
|
||||
? `Tenant request rejected: ${adminNotes}`
|
||||
: "Tenant request rejected",
|
||||
refundedBy: user.id,
|
||||
});
|
||||
refundSummary.succeeded = true;
|
||||
}
|
||||
} catch (e: any) {
|
||||
refundSummary.error =
|
||||
e instanceof RefundNotAllowedError
|
||||
? e.message
|
||||
: (e?.message ?? "refund failed");
|
||||
console.error(
|
||||
`Setup-fee refund failed for request ${id} (invoice ${tenantRequest.setupInvoiceId}):`,
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Notify customer. Resume requests get a different email — the
|
||||
// tenant already exists; copy needs to mention "stays suspended" and
|
||||
// the 60-day retention deadline. Provision rejections use the
|
||||
@@ -88,5 +161,6 @@ export async function POST(
|
||||
return NextResponse.json({
|
||||
message: "Request rejected.",
|
||||
request: updated,
|
||||
refund: refundSummary,
|
||||
});
|
||||
}
|
||||
|
||||
27
src/app/api/billing/auto-charge/route.ts
Normal file
27
src/app/api/billing/auto-charge/route.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
/**
|
||||
* POST /api/billing/auto-charge — RETIRED.
|
||||
*
|
||||
* Auto-pay is no longer a customer-toggleable setting. A saved
|
||||
* card on file is the consent to auto-bill; customers manage their
|
||||
* card via update/remove on /settings/billing, nothing else. The
|
||||
* auto_charge_enabled flag is now an admin-only pause used during
|
||||
* disputes, set from /admin/billing/orgs.
|
||||
*
|
||||
* This route is kept as an explicit 410 (Gone) so any stale client
|
||||
* that still POSTs here fails loudly rather than silently toggling
|
||||
* a flag the customer shouldn't control. The old behaviour lived
|
||||
* here through Phase 9b-2.
|
||||
*/
|
||||
export async function POST() {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
"Auto-pay can no longer be disabled. A saved card is required for service. " +
|
||||
"Contact support if you need to switch to bank-transfer billing.",
|
||||
code: "auto_pay_not_toggleable",
|
||||
},
|
||||
{ status: 410 }
|
||||
);
|
||||
}
|
||||
75
src/app/api/billing/current/route.ts
Normal file
75
src/app/api/billing/current/route.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getSessionUser } from "@/lib/session";
|
||||
import { computeInvoiceDraft } from "@/lib/billing";
|
||||
import { listInvoices } from "@/lib/db";
|
||||
|
||||
/**
|
||||
* GET /api/billing/current
|
||||
*
|
||||
* Running total for the current calendar month — what the
|
||||
* customer will be billed if no further activity happens. Uses
|
||||
* the same compute pipeline as the final invoice (LiteLLM spend,
|
||||
* Threema usage, skill day-counting, proration) so the number
|
||||
* the customer sees matches what they'll eventually receive
|
||||
* within the limits of intra-month drift.
|
||||
*
|
||||
* If an invoice has ALREADY been issued for the current month
|
||||
* (e.g. cron ran early, admin manually generated), we return
|
||||
* that issued invoice instead — no point showing a draft that
|
||||
* duplicates a real invoice.
|
||||
*
|
||||
* Returns:
|
||||
* { issued: Invoice } // current-month invoice exists
|
||||
* { draft: InvoiceDraft } // still accruing
|
||||
* { error: ... } // org missing billing config
|
||||
*
|
||||
* Cost: 1 LiteLLM HTTP call + 1 Threema HTTP call + a handful of
|
||||
* DB queries per skill. Sub-second typically. No caching; called
|
||||
* on demand from the customer billing page.
|
||||
*/
|
||||
export async function GET() {
|
||||
const user = await getSessionUser();
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
// Resolve current calendar month from UTC. Billing is UTC-day
|
||||
// based throughout (see billing.ts iterDays comment), so the
|
||||
// running total inherits that same semantics.
|
||||
const now = new Date();
|
||||
const year = now.getUTCFullYear();
|
||||
const month = now.getUTCMonth() + 1; // 1-12
|
||||
const periodMonth = `${year}-${String(month).padStart(2, "0")}`;
|
||||
|
||||
// 1. Has the current month already been invoiced?
|
||||
const existing = await listInvoices({
|
||||
zitadelOrgId: user.orgId,
|
||||
periodMonth,
|
||||
limit: 1,
|
||||
});
|
||||
if (existing.length > 0) {
|
||||
return NextResponse.json({ issued: existing[0] });
|
||||
}
|
||||
|
||||
// 2. Otherwise compute the draft. Falls through to error if the
|
||||
// org doesn't have a billing config yet (no Address on file).
|
||||
try {
|
||||
const draft = await computeInvoiceDraft({
|
||||
zitadelOrgId: user.orgId,
|
||||
year,
|
||||
month,
|
||||
});
|
||||
return NextResponse.json({ draft });
|
||||
} catch (e: any) {
|
||||
// Most likely: org_billing row missing. We surface a 200 with a
|
||||
// soft error code rather than 500 — the customer-side widget
|
||||
// displays a helpful "complete your billing details" message
|
||||
// instead of a stack trace.
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: e?.message ?? "Could not compute running total.",
|
||||
code: e?.code ?? "COMPUTE_FAILED",
|
||||
},
|
||||
{ status: 200 }
|
||||
);
|
||||
}
|
||||
}
|
||||
105
src/app/api/billing/invoices/[invoiceNumber]/pay/route.ts
Normal file
105
src/app/api/billing/invoices/[invoiceNumber]/pay/route.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getSessionUser } from "@/lib/session";
|
||||
import {
|
||||
getInvoiceByNumberForOrg,
|
||||
getOrgBilling,
|
||||
} from "@/lib/db";
|
||||
import {
|
||||
createCheckoutSessionForInvoice,
|
||||
ensureStripeCustomerForOrg,
|
||||
} from "@/lib/stripe";
|
||||
import { safeError } from "@/lib/errors";
|
||||
|
||||
/**
|
||||
* POST /api/billing/invoices/[invoiceNumber]/pay
|
||||
*
|
||||
* Initiates a Stripe Checkout Session for an open invoice. Returns
|
||||
* `{ url }` — the browser is expected to navigate to that URL,
|
||||
* where Stripe hosts the payment UI.
|
||||
*
|
||||
* Authorization: caller must belong to the invoice's org (the DB
|
||||
* query enforces this — wrong-org returns 404, indistinguishable
|
||||
* from a non-existent invoice).
|
||||
*
|
||||
* Preconditions enforced server-side:
|
||||
* - Invoice exists for caller's org
|
||||
* - Invoice status is 'open' or 'overdue' (paid/void/draft/uncollectible
|
||||
* all reject — already-paid invoices in particular must not
|
||||
* create a second Checkout Session, even though Stripe would
|
||||
* deduplicate the actual charge)
|
||||
*
|
||||
* The Stripe Customer for the org is lazily ensured here — first
|
||||
* card click on an org creates the customer; subsequent clicks
|
||||
* reuse the persisted stripe_customer_id.
|
||||
*/
|
||||
export async function POST(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ invoiceNumber: string }> }
|
||||
) {
|
||||
const user = await getSessionUser();
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
const { invoiceNumber } = await params;
|
||||
|
||||
const detail = await getInvoiceByNumberForOrg(invoiceNumber, user.orgId);
|
||||
if (!detail) {
|
||||
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
}
|
||||
const inv = detail.invoice;
|
||||
if (inv.status !== "open" && inv.status !== "overdue") {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
inv.status === "paid"
|
||||
? "This invoice has already been paid."
|
||||
: `This invoice cannot be paid online (status: ${inv.status}).`,
|
||||
},
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
|
||||
// We need org_billing for the customer creation address. The
|
||||
// invoice has a SNAPSHOT but that's frozen at issue time; for
|
||||
// creating/updating the Stripe customer we want the current
|
||||
// address (which may have been corrected since the invoice).
|
||||
// Snapshot is still authoritative on the invoice PDF and total.
|
||||
const orgBilling = await getOrgBilling(user.orgId);
|
||||
if (!orgBilling) {
|
||||
return NextResponse.json(
|
||||
{ error: "Billing details are not configured for your organization." },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const customerId = await ensureStripeCustomerForOrg({
|
||||
zitadelOrgId: user.orgId,
|
||||
companyName: orgBilling.companyName,
|
||||
billingEmail: orgBilling.billingEmail,
|
||||
address: {
|
||||
line1: orgBilling.streetAddress,
|
||||
postalCode: orgBilling.postalCode,
|
||||
city: orgBilling.city,
|
||||
country: orgBilling.country,
|
||||
},
|
||||
});
|
||||
const baseUrl =
|
||||
process.env.APP_BASE_URL ?? "https://app.pieced.ch";
|
||||
const { url } = await createCheckoutSessionForInvoice({
|
||||
invoice: inv,
|
||||
customerId,
|
||||
baseUrl,
|
||||
});
|
||||
return NextResponse.json({ url });
|
||||
} catch (e) {
|
||||
console.error(
|
||||
`Failed to create Checkout Session for invoice ${invoiceNumber}:`,
|
||||
e
|
||||
);
|
||||
return NextResponse.json(
|
||||
{ error: safeError(e, "Failed to start card payment.") },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
43
src/app/api/billing/invoices/[invoiceNumber]/pdf/route.ts
Normal file
43
src/app/api/billing/invoices/[invoiceNumber]/pdf/route.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getSessionUser } from "@/lib/session";
|
||||
import { getInvoiceByNumberForOrg, getInvoicePdf } from "@/lib/db";
|
||||
|
||||
/**
|
||||
* GET /api/billing/invoices/[invoiceNumber]/pdf
|
||||
*
|
||||
* Customer-facing PDF download. Same Uint8Array.from() variance
|
||||
* fix as the admin route — see /api/admin/billing/invoices/[id]/pdf
|
||||
* for the rationale.
|
||||
*
|
||||
* Authorization: looks up the invoice by number with org scope
|
||||
* baked into the query, then re-fetches the PDF blob by id. A
|
||||
* customer can't probe another org's invoice numbers — they get
|
||||
* 404 either way.
|
||||
*/
|
||||
export async function GET(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ invoiceNumber: string }> }
|
||||
) {
|
||||
const user = await getSessionUser();
|
||||
if (!user) {
|
||||
return new NextResponse("Unauthorized", { status: 401 });
|
||||
}
|
||||
const { invoiceNumber } = await params;
|
||||
const detail = await getInvoiceByNumberForOrg(invoiceNumber, user.orgId);
|
||||
if (!detail) {
|
||||
return new NextResponse("Not found", { status: 404 });
|
||||
}
|
||||
const pdf = await getInvoicePdf(detail.invoice.id);
|
||||
if (!pdf) {
|
||||
return new NextResponse("PDF not available", { status: 404 });
|
||||
}
|
||||
const body = Uint8Array.from(pdf.data);
|
||||
return new NextResponse(body, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "application/pdf",
|
||||
"Content-Disposition": `inline; filename="${pdf.filename}"`,
|
||||
"Cache-Control": "private, max-age=0, must-revalidate",
|
||||
},
|
||||
});
|
||||
}
|
||||
27
src/app/api/billing/invoices/[invoiceNumber]/route.ts
Normal file
27
src/app/api/billing/invoices/[invoiceNumber]/route.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getSessionUser } from "@/lib/session";
|
||||
import { getInvoiceByNumberForOrg } from "@/lib/db";
|
||||
|
||||
/**
|
||||
* GET /api/billing/invoices/[invoiceNumber]
|
||||
*
|
||||
* Customer-scoped detail lookup by invoice number (the human-
|
||||
* readable YYYY-NNNNN format the customer sees on the PDF). The
|
||||
* org filter is part of the DB query — a customer probing another
|
||||
* org's invoice number gets the same 404 as a non-existent one.
|
||||
*/
|
||||
export async function GET(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ invoiceNumber: string }> }
|
||||
) {
|
||||
const user = await getSessionUser();
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
const { invoiceNumber } = await params;
|
||||
const detail = await getInvoiceByNumberForOrg(invoiceNumber, user.orgId);
|
||||
if (!detail) {
|
||||
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
}
|
||||
return NextResponse.json(detail);
|
||||
}
|
||||
39
src/app/api/billing/invoices/route.ts
Normal file
39
src/app/api/billing/invoices/route.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getSessionUser } from "@/lib/session";
|
||||
import { listInvoices, syncOverdueInvoices } from "@/lib/db";
|
||||
|
||||
/**
|
||||
* GET /api/billing/invoices
|
||||
*
|
||||
* Customer-scoped list of invoices for the caller's org. Returns
|
||||
* a flat array of Invoice headers (no line items — those are
|
||||
* fetched separately by /[invoiceNumber]).
|
||||
*
|
||||
* Status filter is implicit: we return every invoice the
|
||||
* customer's org has, all statuses (issued/paid/overdue/void)
|
||||
* because the customer wants a single billing-history view.
|
||||
*
|
||||
* Before returning we run syncOverdueInvoices() so the displayed
|
||||
* status reflects the current date — issued invoices past their
|
||||
* due_at flip to 'overdue'. Cheap, idempotent, and avoids needing
|
||||
* a separate cron for this transition.
|
||||
*/
|
||||
export async function GET() {
|
||||
const user = await getSessionUser();
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
// Personal accounts have an org too — they share the same shape;
|
||||
// their invoices show up under that synthetic org id.
|
||||
try {
|
||||
await syncOverdueInvoices();
|
||||
} catch (e) {
|
||||
// Non-fatal — display stale status rather than 500.
|
||||
console.warn("syncOverdueInvoices failed in /api/billing/invoices:", e);
|
||||
}
|
||||
const invoices = await listInvoices({
|
||||
zitadelOrgId: user.orgId,
|
||||
limit: 200,
|
||||
});
|
||||
return NextResponse.json(invoices);
|
||||
}
|
||||
46
src/app/api/billing/saved-card/route.ts
Normal file
46
src/app/api/billing/saved-card/route.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getSessionUser } from "@/lib/session";
|
||||
import { clearSavedPaymentMethod, getOrgBillingConfig } from "@/lib/db";
|
||||
import { detachPaymentMethod } from "@/lib/stripe";
|
||||
import { safeError } from "@/lib/errors";
|
||||
|
||||
/**
|
||||
* DELETE /api/billing/saved-card
|
||||
*
|
||||
* Phase 9. Remove the saved card for the caller's org. Detaches
|
||||
* the PaymentMethod in Stripe (so it can't be charged again) and
|
||||
* clears the four display columns + the pm_id reference locally.
|
||||
*
|
||||
* Idempotent: calling on an org with no saved card returns 200
|
||||
* (the desired end-state is already reached).
|
||||
*
|
||||
* Auth: any signed-in member of the org. Same reasoning as the
|
||||
* setup endpoint — card removal is a customer-visible action; it
|
||||
* doesn't leak anything, and a non-owner needing to remove a
|
||||
* stolen-card-on-file shouldn't be blocked by role gating.
|
||||
*/
|
||||
export async function DELETE() {
|
||||
const user = await getSessionUser();
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
try {
|
||||
const cfg = await getOrgBillingConfig(user.orgId);
|
||||
if (!cfg || !cfg.stripeDefaultPaymentMethodId) {
|
||||
// Already empty — no-op, return success.
|
||||
return NextResponse.json({ removed: false });
|
||||
}
|
||||
// Stripe detach first. If it fails for a real reason (network,
|
||||
// 500 from Stripe), we don't clear the DB — admin can retry.
|
||||
// 404 is treated as success by detachPaymentMethod (PM already
|
||||
// gone), so we proceed to clear the DB regardless.
|
||||
await detachPaymentMethod(cfg.stripeDefaultPaymentMethodId);
|
||||
await clearSavedPaymentMethod(user.orgId);
|
||||
return NextResponse.json({ removed: true });
|
||||
} catch (e) {
|
||||
return NextResponse.json(
|
||||
{ error: safeError(e, "Failed to remove card") },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
75
src/app/api/billing/setup-card/route.ts
Normal file
75
src/app/api/billing/setup-card/route.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getSessionUser } from "@/lib/session";
|
||||
import { getOrgBilling } from "@/lib/db";
|
||||
import {
|
||||
createSetupCheckoutSession,
|
||||
ensureStripeCustomerForOrg,
|
||||
} from "@/lib/stripe";
|
||||
import { safeError } from "@/lib/errors";
|
||||
|
||||
/**
|
||||
* POST /api/billing/setup-card
|
||||
*
|
||||
* Phase 9. Customer-initiated "Set up auto-pay" / "Update card"
|
||||
* flow. Creates a Checkout session in setup mode and returns its
|
||||
* URL — the caller redirects the browser. On completion, the
|
||||
* webhook handler saves the resulting PaymentMethod's display
|
||||
* fields against this org's billing config.
|
||||
*
|
||||
* Auth: any signed-in member of the org. We don't owner-gate this
|
||||
* because non-owners might legitimately need to update payment
|
||||
* (e.g., for a team they administer). The actual card data is
|
||||
* collected by Stripe, not us — there's nothing to leak from
|
||||
* misuse here.
|
||||
*
|
||||
* Requires an existing billing snapshot (org_billing row). If
|
||||
* absent, returns 400 — the customer hasn't set their billing
|
||||
* address yet, and Stripe needs the address for the customer
|
||||
* object.
|
||||
*/
|
||||
export async function POST(request: Request) {
|
||||
const user = await getSessionUser();
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
const orgBilling = await getOrgBilling(user.orgId);
|
||||
if (!orgBilling) {
|
||||
return NextResponse.json(
|
||||
{ error: "Billing address required before saving a card." },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
try {
|
||||
// Ensure the Stripe customer exists. Idempotent — if we
|
||||
// already created one for this org (e.g. from a prior
|
||||
// "Pay by Card" Checkout), it's reused.
|
||||
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,
|
||||
},
|
||||
});
|
||||
// Base URL for redirect targets — must be the public-facing
|
||||
// origin since Stripe redirects the browser back. Behind an
|
||||
// ingress (Cedric's setup) request.url is the internal pod
|
||||
// address ("0.0.0.0:3000" / cluster.svc), useless for the
|
||||
// browser. Same env-var pattern as the invoice pay endpoint.
|
||||
const baseUrl =
|
||||
process.env.APP_BASE_URL ?? "https://app.pieced.ch";
|
||||
const session = await createSetupCheckoutSession({
|
||||
customerId,
|
||||
baseUrl,
|
||||
});
|
||||
return NextResponse.json({ url: session.url });
|
||||
} catch (e) {
|
||||
return NextResponse.json(
|
||||
{ error: safeError(e, "Failed to start card setup") },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
64
src/app/api/credit-notes/[number]/pdf/route.ts
Normal file
64
src/app/api/credit-notes/[number]/pdf/route.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getSessionUser } from "@/lib/session";
|
||||
import {
|
||||
getCreditNoteByNumber,
|
||||
getCreditNoteByNumberForOrg,
|
||||
getCreditNotePdf,
|
||||
} from "@/lib/db";
|
||||
|
||||
/**
|
||||
* GET /api/credit-notes/[number]/pdf
|
||||
*
|
||||
* Phase 7. Customer-facing PDF download for a credit note. Returns
|
||||
* the binary PDF with Content-Disposition: inline so the browser
|
||||
* renders it in-tab (matching the invoice download behaviour). The
|
||||
* customer's email links here.
|
||||
*
|
||||
* Authorization:
|
||||
* - The caller must be authenticated.
|
||||
* - For customer-org callers, the credit note must belong to their
|
||||
* org (orgId-scoped lookup).
|
||||
* - Platform admins can fetch any credit note (cross-org lookup).
|
||||
*
|
||||
* Returns 404 in both "doesn't exist" and "exists but not yours"
|
||||
* cases — leak-safe identical to invoice lookup.
|
||||
*/
|
||||
export async function GET(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ number: string }> }
|
||||
) {
|
||||
const user = await getSessionUser();
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
const { number } = await params;
|
||||
// URL-decoded number — the route param comes URL-encoded.
|
||||
const decodedNumber = decodeURIComponent(number);
|
||||
const cn = user.isPlatform
|
||||
? await getCreditNoteByNumber(decodedNumber)
|
||||
: await getCreditNoteByNumberForOrg(decodedNumber, user.orgId);
|
||||
if (!cn) {
|
||||
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
}
|
||||
const pdf = await getCreditNotePdf(cn.id);
|
||||
if (!pdf) {
|
||||
// The credit note exists but the PDF was never attached. Most
|
||||
// likely a render failure during issuance — the credit note
|
||||
// row is still authoritative, the PDF needs re-rendering.
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
"Credit note exists but its PDF has not been rendered. Please contact support.",
|
||||
},
|
||||
{ status: 502 }
|
||||
);
|
||||
}
|
||||
return new NextResponse(new Uint8Array(pdf.data), {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "application/pdf",
|
||||
"Content-Disposition": `inline; filename="${pdf.filename}"`,
|
||||
"Cache-Control": "private, no-cache",
|
||||
},
|
||||
});
|
||||
}
|
||||
42
src/app/api/cron/issue-monthly/route.ts
Normal file
42
src/app/api/cron/issue-monthly/route.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { runMonthlyIssuance, verifyCronBearer } from "@/lib/cron";
|
||||
import { safeError } from "@/lib/errors";
|
||||
|
||||
/**
|
||||
* POST /api/cron/issue-monthly
|
||||
*
|
||||
* Machine entry point for the monthly issuance sweep. Authentication
|
||||
* is the shared bearer token in CRON_BEARER_TOKEN, injected from
|
||||
* OpenBao via the portal-cron K8s Secret. The K8s CronJob sends:
|
||||
*
|
||||
* curl -X POST -H "Authorization: Bearer $CRON_BEARER_TOKEN" \
|
||||
* https://app.pieced.ch/api/cron/issue-monthly
|
||||
*
|
||||
* The sweep targets the calendar month that ended just before
|
||||
* "now" in Europe/Zurich. Running it on June 1st at 00:30 Swiss
|
||||
* time bills May; running it on July 5th bills June; etc. The
|
||||
* uniqueness constraint on (org, period_start) makes re-runs
|
||||
* harmless — already-issued orgs are counted as skipped.
|
||||
*
|
||||
* Returns the summary {success, failure, skipped} JSON. The
|
||||
* CronJob doesn't look at the response body (just the status
|
||||
* code) but having a useful one helps debugging via curl.
|
||||
*/
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
if (!verifyCronBearer(request.headers.get("authorization"))) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
try {
|
||||
const { runId, summary } = await runMonthlyIssuance({
|
||||
triggeredBy: "cron",
|
||||
});
|
||||
return NextResponse.json({ runId, ...summary });
|
||||
} catch (e) {
|
||||
return NextResponse.json(
|
||||
{ error: safeError(e, "Issuance sweep failed.") },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
33
src/app/api/cron/send-reminders/route.ts
Normal file
33
src/app/api/cron/send-reminders/route.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { runReminderSweep, verifyCronBearer } from "@/lib/cron";
|
||||
import { safeError } from "@/lib/errors";
|
||||
|
||||
/**
|
||||
* POST /api/cron/send-reminders
|
||||
*
|
||||
* Machine entry point for the daily reminder sweep. Same auth
|
||||
* (bearer token in CRON_BEARER_TOKEN) and the same response
|
||||
* contract as /api/cron/issue-monthly.
|
||||
*
|
||||
* Schedule: 09:00 Europe/Zurich daily. Picks invoices that are
|
||||
* past their due date and haven't received the corresponding
|
||||
* reminder level yet; sends one email per invoice per run.
|
||||
*/
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
if (!verifyCronBearer(request.headers.get("authorization"))) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
try {
|
||||
const { runId, summary } = await runReminderSweep({
|
||||
triggeredBy: "cron",
|
||||
});
|
||||
return NextResponse.json({ runId, ...summary });
|
||||
} catch (e) {
|
||||
return NextResponse.json(
|
||||
{ error: safeError(e, "Reminder sweep failed.") },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getSessionUser, canMutate } from "@/lib/session";
|
||||
import {
|
||||
getInvoiceById,
|
||||
getTenantRequestById,
|
||||
updateTenantRequestStatus,
|
||||
updateTenantRequestEditableFields,
|
||||
@@ -9,6 +10,8 @@ import { encryptSecrets } from "@/lib/crypto";
|
||||
import { setTenantAnnotation } from "@/lib/k8s";
|
||||
import { onboardingSchema } from "@/lib/validation";
|
||||
import { safeError } from "@/lib/errors";
|
||||
import { refundInvoice, RefundNotAllowedError } from "@/lib/billing";
|
||||
import type { SessionUser, TenantRequest } from "@/types";
|
||||
|
||||
/**
|
||||
* Customer-side controls for a single tenant_request row.
|
||||
@@ -29,7 +32,7 @@ async function loadAuthorized(
|
||||
id: string
|
||||
): Promise<
|
||||
| { error: NextResponse }
|
||||
| { req: Awaited<ReturnType<typeof getTenantRequestById>>; }
|
||||
| { req: TenantRequest; user: SessionUser }
|
||||
> {
|
||||
const user = await getSessionUser();
|
||||
if (!user) {
|
||||
@@ -55,7 +58,7 @@ async function loadAuthorized(
|
||||
error: NextResponse.json({ error: "Not found" }, { status: 404 }),
|
||||
};
|
||||
}
|
||||
return { req: tr };
|
||||
return { req: tr, user };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -93,6 +96,50 @@ export async function DELETE(
|
||||
try {
|
||||
await updateTenantRequestStatus(id, "cancelled");
|
||||
|
||||
// Phase 9b: a 'pending' provision request has already had its
|
||||
// setup fee charged (the order-time Checkout completed before
|
||||
// the webhook flipped it to 'pending'). Cancelling it must
|
||||
// refund that payment, exactly as an admin rejection does.
|
||||
// Resume requests never carry a setup_invoice_id, so this only
|
||||
// fires for provision orders. Best-effort: a refund failure is
|
||||
// logged + surfaced but doesn't block the cancellation (admin
|
||||
// can refund manually from the invoice page).
|
||||
let refund: { attempted: boolean; succeeded: boolean; error?: string } = {
|
||||
attempted: false,
|
||||
succeeded: false,
|
||||
};
|
||||
if (tr.requestType === "provision" && tr.setupInvoiceId) {
|
||||
refund.attempted = true;
|
||||
try {
|
||||
const inv = await getInvoiceById(tr.setupInvoiceId);
|
||||
if (!inv) {
|
||||
throw new Error(`Linked setup invoice ${tr.setupInvoiceId} not found`);
|
||||
}
|
||||
const remaining =
|
||||
Math.round((inv.totalChf - (inv.refundedTotalChf ?? 0)) * 100) / 100;
|
||||
if (remaining <= 0) {
|
||||
refund.succeeded = true; // nothing left to refund
|
||||
} else {
|
||||
await refundInvoice({
|
||||
invoiceId: tr.setupInvoiceId,
|
||||
amountChf: remaining,
|
||||
reason: "Order cancelled by customer",
|
||||
refundedBy: loaded.user!.id,
|
||||
});
|
||||
refund.succeeded = true;
|
||||
}
|
||||
} catch (e: any) {
|
||||
refund.error =
|
||||
e instanceof RefundNotAllowedError
|
||||
? e.message
|
||||
: (e?.message ?? "refund failed");
|
||||
console.error(
|
||||
`Setup-fee refund failed for cancelled request ${id} (invoice ${tr.setupInvoiceId}):`,
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Customer cancels their own pending resume request: clear the
|
||||
// operator-side annotation so the 60-day TTL resumes counting.
|
||||
// Best-effort — the operator handles missing annotation gracefully.
|
||||
@@ -111,7 +158,7 @@ export async function DELETE(
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ message: "Request cancelled.", id });
|
||||
return NextResponse.json({ message: "Request cancelled.", id, refund });
|
||||
} catch (e: any) {
|
||||
console.error("Failed to cancel request:", e);
|
||||
return NextResponse.json(
|
||||
|
||||
@@ -2,11 +2,14 @@ import { NextRequest, NextResponse } from "next/server";
|
||||
import { getSessionUser, canMutate } from "@/lib/session";
|
||||
import {
|
||||
createTenantRequest,
|
||||
createTenantRequestPendingPayment,
|
||||
deletePendingPaymentRequest,
|
||||
getTenantRequestById,
|
||||
listTenantRequestsByOrgId,
|
||||
listActiveTenantRequestsByOrgId,
|
||||
getMostRecentApprovedRequestForOrg,
|
||||
getOrgBilling,
|
||||
getPlatformPricing,
|
||||
upsertOrgBilling,
|
||||
} from "@/lib/db";
|
||||
import { getTenant, listTenants } from "@/lib/k8s";
|
||||
@@ -19,7 +22,18 @@ import { sendAdminNotificationEmail } from "@/lib/email";
|
||||
import { encryptSecrets } from "@/lib/crypto";
|
||||
import { isPersonalOrgName } from "@/lib/personal-org";
|
||||
import { onboardingSchema, billingAddressSchema } from "@/lib/validation";
|
||||
import type { OnboardingInput, PiecedTenant, TenantRequest } from "@/types";
|
||||
import {
|
||||
createSetupFeeCheckoutSession,
|
||||
ensureStripeCustomerForOrg,
|
||||
} from "@/lib/stripe";
|
||||
import { createTenantSetupFeeInvoice, voidInvoice } from "@/lib/billing";
|
||||
import { deriveTenantName } from "@/lib/tenant-naming";
|
||||
import type {
|
||||
InvoiceBillingSnapshot,
|
||||
OnboardingInput,
|
||||
PiecedTenant,
|
||||
TenantRequest,
|
||||
} from "@/types";
|
||||
import { z } from "zod";
|
||||
|
||||
/**
|
||||
@@ -194,6 +208,7 @@ export async function POST(request: Request) {
|
||||
|
||||
const input: OnboardingInput & {
|
||||
packageSecrets?: Record<string, Record<string, string>>;
|
||||
channelUsers?: Record<string, string[]>;
|
||||
} = parsed.data;
|
||||
|
||||
// Look up an existing approved request for this org to inherit
|
||||
@@ -252,11 +267,24 @@ export async function POST(request: Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// For follow-up instances, prefer the on-file company name and contact
|
||||
// details; the user can't change those by re-typing them in the wizard.
|
||||
// The audit copy of company name on this request stays inherited
|
||||
// from the first request in the org — it's a historical snapshot
|
||||
// of the company name at the time the request was created, and
|
||||
// org_billing is now the canonical source for current values.
|
||||
//
|
||||
// Phase 6 fix4: contactName and contactEmail are NOT inherited.
|
||||
// They identify whoever submitted THIS specific request (drives
|
||||
// admin display, support ticket routing, and email greetings).
|
||||
// The previous "prior?.contactName ?? user.name" pattern locked
|
||||
// the contact to whoever first onboarded the org, which broke for
|
||||
// any subsequent submission by a different user — admin saw the
|
||||
// wrong name, support emails went to the wrong person, and the
|
||||
// actual submitter had no way to correct it because the wizard
|
||||
// doesn't expose a contact-name input. The fix is simply to use
|
||||
// the current session user every time.
|
||||
const companyName = prior?.companyName ?? user.orgName;
|
||||
const contactName = prior?.contactName ?? user.name;
|
||||
const contactEmail = prior?.contactEmail ?? user.email;
|
||||
const contactName = user.name;
|
||||
const contactEmail = user.email;
|
||||
|
||||
// Bug 35: org-scoped billing.
|
||||
//
|
||||
@@ -389,6 +417,18 @@ export async function POST(request: Request) {
|
||||
);
|
||||
}
|
||||
|
||||
// Look up the setup fee. If it's 0 we skip the Checkout flow
|
||||
// entirely and create a normal pending request (same as the
|
||||
// pre-Phase-9b behaviour).
|
||||
const platformPricing = await getPlatformPricing();
|
||||
const setupFeeChf = platformPricing.tenantSetupFeeChf;
|
||||
|
||||
// ZERO-FEE PATH ---------------------------------------------------
|
||||
// No payment to collect. Create the request directly in 'pending'
|
||||
// status (same as the pre-Phase-9b flow) and notify admin. The
|
||||
// wizard treats this response identically to its previous
|
||||
// success path.
|
||||
if (setupFeeChf <= 0) {
|
||||
const tenantRequest = await createTenantRequest({
|
||||
zitadelOrgId: user.orgId,
|
||||
zitadelUserId: user.id,
|
||||
@@ -404,11 +444,8 @@ export async function POST(request: Request) {
|
||||
billingNotes,
|
||||
encryptedSecrets,
|
||||
isPersonal,
|
||||
channelUsers: input.channelUsers ?? {},
|
||||
});
|
||||
|
||||
// Notify admin about the new request. For follow-up instances, include
|
||||
// the instance name in the notification so the admin sees what's
|
||||
// being requested without opening the panel.
|
||||
try {
|
||||
await sendAdminNotificationEmail(
|
||||
tenantRequest.contactEmail,
|
||||
@@ -420,11 +457,7 @@ export async function POST(request: Request) {
|
||||
} catch (e) {
|
||||
console.error("Failed to send admin notification:", e);
|
||||
}
|
||||
|
||||
// For diagnostics: how many other in-flight requests does this org
|
||||
// already have? Useful for the admin queue.
|
||||
const allRequests = await listTenantRequestsByOrgId(user.orgId);
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: "Request submitted.",
|
||||
@@ -433,4 +466,164 @@ export async function POST(request: Request) {
|
||||
},
|
||||
{ status: 201 }
|
||||
);
|
||||
}
|
||||
|
||||
// PAID-FEE PATH ---------------------------------------------------
|
||||
// Insert as 'pending_payment' (tenant_name stays NULL so abandoned
|
||||
// Checkout sessions don't block retries). Build the setup-fee
|
||||
// invoice, then start a Checkout session. The wizard follows the
|
||||
// returned URL; on completion the webhook flips the row to
|
||||
// 'pending' and admin sees it in their queue.
|
||||
const tenantRequest = await createTenantRequestPendingPayment({
|
||||
zitadelOrgId: user.orgId,
|
||||
zitadelUserId: user.id,
|
||||
companyName,
|
||||
instanceName: input.instanceName,
|
||||
contactName,
|
||||
contactEmail,
|
||||
agentName: input.agentName,
|
||||
soulMd: input.soulMd,
|
||||
agentsMd: input.agentsMd,
|
||||
packages: input.packages ?? [],
|
||||
billingAddress,
|
||||
billingNotes,
|
||||
encryptedSecrets,
|
||||
isPersonal,
|
||||
channelUsers: input.channelUsers ?? {},
|
||||
});
|
||||
|
||||
// Derive the future tenant_name — needed on the invoice line so
|
||||
// tenantHasSetupFeeBilled() in the monthly cron dedup finds the
|
||||
// already-paid setup fee once the K8s tenant exists. The name is
|
||||
// request-id-suffix-derived, so abandoned Checkout retries each
|
||||
// get unique names.
|
||||
const derivedTenantName = deriveTenantName(
|
||||
isPersonal ? "personal" : "company",
|
||||
companyName,
|
||||
tenantRequest.id
|
||||
);
|
||||
|
||||
// Re-fetch orgBilling here: the variable at the top of POST was
|
||||
// captured BEFORE the upsertOrgBilling call upstream (which fires
|
||||
// when the wizard collected the address on first onboarding). For
|
||||
// a brand-new user that initial fetch returned null; only by
|
||||
// re-fetching now do we get the row we just wrote. Existing
|
||||
// customers get the same orgBilling back either way.
|
||||
const billingForOrder = await getOrgBilling(user.orgId);
|
||||
if (!billingForOrder) {
|
||||
console.error(
|
||||
`Paid-fee onboarding path: no org_billing for org ${user.orgId} even after upsert — wizard did not collect address?`
|
||||
);
|
||||
await deletePendingPaymentRequest(tenantRequest.id).catch(() => undefined);
|
||||
return NextResponse.json(
|
||||
{ error: "Billing record missing. Please re-save your billing details." },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
const billingSnapshot: InvoiceBillingSnapshot = {
|
||||
companyName: billingForOrder.companyName,
|
||||
contactName: billingForOrder.contactName ?? null,
|
||||
streetAddress: billingForOrder.streetAddress,
|
||||
postalCode: billingForOrder.postalCode,
|
||||
city: billingForOrder.city,
|
||||
country: billingForOrder.country,
|
||||
vatNumber: billingForOrder.vatNumber ?? null,
|
||||
billingEmail: billingForOrder.billingEmail,
|
||||
notes: billingForOrder.notes ?? null,
|
||||
};
|
||||
|
||||
// Locale for the invoice + PDF — pick from the org's country
|
||||
// using the same heuristic the auto-cron uses.
|
||||
const c = (billingSnapshot.country ?? "").toUpperCase();
|
||||
const invoiceLocale: "de" | "en" | "fr" | "it" = ["CH", "LI", "AT", "DE"].includes(c)
|
||||
? "de"
|
||||
: ["FR", "BE", "LU"].includes(c)
|
||||
? "fr"
|
||||
: c === "IT"
|
||||
? "it"
|
||||
: "en";
|
||||
|
||||
let setupInvoice;
|
||||
try {
|
||||
setupInvoice = await createTenantSetupFeeInvoice({
|
||||
zitadelOrgId: user.orgId,
|
||||
tenantName: derivedTenantName,
|
||||
billingSnapshot,
|
||||
locale: invoiceLocale,
|
||||
paymentMethod: "card",
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("Failed to create setup-fee invoice:", e);
|
||||
// Roll back the pending_payment row so the customer can retry
|
||||
// without an orphan record.
|
||||
await deletePendingPaymentRequest(tenantRequest.id).catch(() => undefined);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to prepare setup-fee invoice. Please try again." },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
// Create the Checkout session. The Stripe customer must exist
|
||||
// before this — ensureStripeCustomerForOrg returns the existing
|
||||
// one (idempotent) since the saved-card setup already created it.
|
||||
let checkoutUrl: string;
|
||||
try {
|
||||
const stripeCustomerId = await ensureStripeCustomerForOrg({
|
||||
zitadelOrgId: user.orgId,
|
||||
companyName: billingSnapshot.companyName,
|
||||
billingEmail: billingSnapshot.billingEmail,
|
||||
address: {
|
||||
line1: billingSnapshot.streetAddress,
|
||||
postalCode: billingSnapshot.postalCode,
|
||||
city: billingSnapshot.city,
|
||||
country: billingSnapshot.country,
|
||||
},
|
||||
});
|
||||
const baseUrl =
|
||||
process.env.APP_BASE_URL ?? "https://app.pieced.ch";
|
||||
const { url } = await createSetupFeeCheckoutSession({
|
||||
invoice: setupInvoice,
|
||||
customerId: stripeCustomerId,
|
||||
baseUrl,
|
||||
tenantRequestId: tenantRequest.id,
|
||||
});
|
||||
checkoutUrl = url;
|
||||
} catch (e) {
|
||||
console.error("Failed to create setup-fee Checkout session:", e);
|
||||
// Roll back BOTH the pending_payment row and the setup invoice
|
||||
// we already created. The invoice was issued in 'open' status
|
||||
// but no payment will ever arrive (Checkout never started), so
|
||||
// void it to keep the ledger clean — an open invoice with no
|
||||
// route to payment would otherwise linger and show up in
|
||||
// arrears reports. Void (not delete) preserves the audit trail
|
||||
// and the void reason. Best-effort: a void failure is logged
|
||||
// but doesn't change the 500 we return.
|
||||
await voidInvoice({
|
||||
invoiceId: setupInvoice.id,
|
||||
reason: "Order abandoned before payment (Checkout could not be started)",
|
||||
voidedBy: user.id,
|
||||
}).catch((ve) =>
|
||||
console.error(
|
||||
`Failed to void orphaned setup invoice ${setupInvoice.id}:`,
|
||||
ve
|
||||
)
|
||||
);
|
||||
await deletePendingPaymentRequest(tenantRequest.id).catch(() => undefined);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to start payment. Please try again." },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
// Don't notify admin yet — the request is invisible to admin
|
||||
// until the webhook flips it to 'pending'. Notification happens
|
||||
// there.
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: "Redirecting to payment.",
|
||||
request: publicRequestShape(tenantRequest),
|
||||
checkoutUrl,
|
||||
},
|
||||
{ status: 201 }
|
||||
);
|
||||
}
|
||||
|
||||
90
src/app/api/settings/billing/route.ts
Normal file
90
src/app/api/settings/billing/route.ts
Normal 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 });
|
||||
}
|
||||
81
src/app/api/settings/profile/route.ts
Normal file
81
src/app/api/settings/profile/route.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { getSessionUser } from "@/lib/session";
|
||||
import {
|
||||
getHumanUserDetail,
|
||||
updateHumanUserProfile,
|
||||
} from "@/lib/zitadel";
|
||||
|
||||
/**
|
||||
* GET /api/settings/profile — read the caller's ZITADEL profile.
|
||||
* Returns first/last/display name and email. Used by the settings
|
||||
* page server component to populate the form.
|
||||
*
|
||||
* PUT /api/settings/profile — update first + last name. Email is
|
||||
* NOT mutable here — changing email needs verification flow that
|
||||
* ZITADEL's own self-service UI already provides; we don't
|
||||
* duplicate that.
|
||||
*
|
||||
* Authorization: any authenticated user can edit their own profile.
|
||||
* The PAT (ZITADEL_SA_PAT) is used to call the ZITADEL v2 user
|
||||
* service, but only against the caller's own userId. There is no
|
||||
* userId field on the request — it's always derived from the
|
||||
* session, so the route can't be abused to edit other users.
|
||||
*/
|
||||
|
||||
const updateSchema = z.object({
|
||||
firstName: z.string().trim().min(1).max(100),
|
||||
lastName: z.string().trim().min(1).max(100),
|
||||
});
|
||||
|
||||
export async function GET() {
|
||||
const user = await getSessionUser();
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
try {
|
||||
const profile = await getHumanUserDetail(user.id);
|
||||
return NextResponse.json({ profile });
|
||||
} catch (e: any) {
|
||||
// Surface ZITADEL-side failures (e.g. user not found, PAT expired)
|
||||
// as 502 — the portal couldn't reach its identity provider, which
|
||||
// is operationally different from a 4xx on the caller's input.
|
||||
console.error("getHumanUserDetail failed:", e);
|
||||
return NextResponse.json(
|
||||
{ error: "Could not load profile from identity provider" },
|
||||
{ status: 502 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(request: Request) {
|
||||
const user = await getSessionUser();
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
const body = await request.json().catch(() => ({}));
|
||||
const parsed = updateSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid request", details: parsed.error.flatten() },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
try {
|
||||
const result = await updateHumanUserProfile({
|
||||
userId: user.id,
|
||||
givenName: parsed.data.firstName,
|
||||
familyName: parsed.data.lastName,
|
||||
});
|
||||
return NextResponse.json({
|
||||
displayName: result.displayName,
|
||||
changeDate: result.changeDate,
|
||||
});
|
||||
} catch (e: any) {
|
||||
console.error("updateHumanUserProfile failed:", e);
|
||||
return NextResponse.json(
|
||||
{ error: "Could not update profile in identity provider" },
|
||||
{ status: 502 }
|
||||
);
|
||||
}
|
||||
}
|
||||
557
src/app/api/stripe/webhook/route.ts
Normal file
557
src/app/api/stripe/webhook/route.ts
Normal file
@@ -0,0 +1,557 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import type Stripe from "stripe";
|
||||
import {
|
||||
getPaymentMethodDisplay,
|
||||
getStripeClient,
|
||||
getWebhookSecret,
|
||||
} from "@/lib/stripe";
|
||||
import {
|
||||
getInvoiceByStripePaymentIntent,
|
||||
getInvoiceDetail,
|
||||
getOrgIdByStripeCustomerId,
|
||||
getTenantRequestForSetupFlow,
|
||||
isStripeRefundRecorded,
|
||||
linkTenantRequestSetupPayment,
|
||||
markInvoicePaid,
|
||||
markStripeEventProcessed,
|
||||
setInvoiceStripePaymentIntent,
|
||||
setSavedPaymentMethod,
|
||||
tryRecordStripeEvent,
|
||||
} from "@/lib/db";
|
||||
import { sendAdminNotificationEmail } from "@/lib/email";
|
||||
import { refundInvoice, RefundNotAllowedError } from "@/lib/billing";
|
||||
|
||||
/**
|
||||
* 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> {
|
||||
// Phase 9: setup-mode sessions don't pay anything — they
|
||||
// authorize a card for off-session future charges. The
|
||||
// PaymentMethod is attached to the customer and the session's
|
||||
// setup_intent.payment_method holds the id we save.
|
||||
if (session.mode === "setup") {
|
||||
await handleSetupCompleted(session);
|
||||
return;
|
||||
}
|
||||
// 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}).`
|
||||
);
|
||||
|
||||
// Phase 9b: if this Checkout was the setup-fee flow for a tenant
|
||||
// order, flip the linked tenant_request row from 'pending_payment'
|
||||
// to 'pending' so admin sees it in the queue. The invoice line's
|
||||
// tenant_name has the derived name; we also stamp it on the
|
||||
// request row so admin can act on it. linkTenantRequestSetupPayment
|
||||
// is idempotent (no-op if status already advanced).
|
||||
const flow = session.metadata?.flow;
|
||||
const tenantRequestId = session.metadata?.tenant_request_id;
|
||||
if (flow === "setup_fee" && tenantRequestId) {
|
||||
try {
|
||||
// The derived tenant_name lives on the invoice line we just
|
||||
// marked paid. Fetch via getInvoiceDetail (existing helper).
|
||||
const detail = await getInvoiceDetail(invoiceId);
|
||||
const setupLine = detail?.lines.find(
|
||||
(l) => l.kind === "tenant_setup" && l.tenantName
|
||||
);
|
||||
if (!setupLine || !setupLine.tenantName) {
|
||||
console.error(
|
||||
`Setup-fee webhook for invoice ${invoiceId} has no tenant_setup line with tenant_name; cannot link request ${tenantRequestId}.`
|
||||
);
|
||||
} else {
|
||||
const linked = await linkTenantRequestSetupPayment({
|
||||
requestId: tenantRequestId,
|
||||
tenantName: setupLine.tenantName,
|
||||
setupInvoiceId: invoiceId,
|
||||
});
|
||||
if (linked) {
|
||||
console.log(
|
||||
`Tenant request ${tenantRequestId} flipped to 'pending' (tenant=${setupLine.tenantName}, setup invoice=${invoiceId}).`
|
||||
);
|
||||
// Notify admin now that the payment cleared. Best-effort —
|
||||
// a failure here doesn't undo the linkage.
|
||||
try {
|
||||
const req = await getTenantRequestForSetupFlow(tenantRequestId);
|
||||
if (req) {
|
||||
await sendAdminNotificationEmail(
|
||||
req.contactEmail,
|
||||
req.contactName,
|
||||
req.instanceName
|
||||
? `${req.companyName} (${req.instanceName})`
|
||||
: req.companyName
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(
|
||||
`Failed to send admin notification for tenant request ${tenantRequestId}:`,
|
||||
e
|
||||
);
|
||||
}
|
||||
} else {
|
||||
console.log(
|
||||
`Tenant request ${tenantRequestId} not in 'pending_payment' (likely already advanced); webhook is a no-op.`
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(
|
||||
`Setup-fee webhook for invoice ${invoiceId} failed to link tenant request ${tenantRequestId}:`,
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 9b: any payment-mode Checkout that set setup_future_usage
|
||||
// attaches the resulting PaymentMethod to the customer. Read it
|
||||
// back and save the display fields against the org's config —
|
||||
// same behaviour as the setup-mode webhook does. This is what
|
||||
// makes the setup-fee Checkout also "refresh saved card" without
|
||||
// an extra step, and it's also what Phase 9b-2's manual-pay
|
||||
// with setup_future_usage will rely on.
|
||||
try {
|
||||
if (paymentIntentId) {
|
||||
const stripe = getStripeClient();
|
||||
const pi = await stripe.paymentIntents.retrieve(paymentIntentId);
|
||||
const pmId =
|
||||
typeof pi.payment_method === "string"
|
||||
? pi.payment_method
|
||||
: pi.payment_method?.id;
|
||||
const customerId =
|
||||
typeof pi.customer === "string"
|
||||
? pi.customer
|
||||
: pi.customer?.id;
|
||||
// setup_future_usage on the PI tells us this payment also
|
||||
// saved the card. If it's not set, this was a one-off pay
|
||||
// and we shouldn't overwrite anything.
|
||||
if (pmId && customerId && pi.setup_future_usage === "off_session") {
|
||||
const orgId = await getOrgIdByStripeCustomerId(customerId);
|
||||
if (orgId) {
|
||||
const display = await getPaymentMethodDisplay(pmId);
|
||||
await setSavedPaymentMethod({
|
||||
zitadelOrgId: orgId,
|
||||
stripeCustomerId: customerId,
|
||||
paymentMethodId: pmId,
|
||||
brand: display.brand,
|
||||
last4: display.last4,
|
||||
expMonth: display.expMonth,
|
||||
expYear: display.expYear,
|
||||
});
|
||||
// Also tell Stripe this PM is the customer's default for
|
||||
// future invoice charges. Best-effort.
|
||||
try {
|
||||
await stripe.customers.update(customerId, {
|
||||
invoice_settings: { default_payment_method: pmId },
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn(
|
||||
`Failed to set default_payment_method on customer ${customerId}:`,
|
||||
e
|
||||
);
|
||||
}
|
||||
console.log(
|
||||
`Saved PaymentMethod ${pmId} (${display.brand} ${display.last4}) for org ${orgId} via payment-mode Checkout.`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(
|
||||
`Failed to save PaymentMethod from payment-mode Checkout (session ${session.id}):`,
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Phase 9: handle setup-mode Checkout completion. The customer
|
||||
* authorized a card for future off-session charges; persist the
|
||||
* display fields against their org so the portal can show the
|
||||
* saved card and use it for auto-charge.
|
||||
*
|
||||
* The session carries:
|
||||
* - mode: 'setup'
|
||||
* - customer: 'cus_xxx' (the Stripe customer id we created)
|
||||
* - setup_intent: 'seti_xxx' (the SetupIntent — has payment_method)
|
||||
*
|
||||
* We look up which org owns the customer (via
|
||||
* org_billing_config.stripe_customer_id), fetch the SetupIntent
|
||||
* to find the resulting PaymentMethod id, then fetch the PM for
|
||||
* its display fields. Three Stripe round-trips total — acceptable
|
||||
* for a one-off setup event.
|
||||
*/
|
||||
async function handleSetupCompleted(
|
||||
session: Stripe.Checkout.Session
|
||||
): Promise<void> {
|
||||
const customerId =
|
||||
typeof session.customer === "string"
|
||||
? session.customer
|
||||
: session.customer?.id;
|
||||
if (!customerId) {
|
||||
console.error(
|
||||
`Setup session ${session.id} completed without a customer; cannot link to org.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
const orgId = await getOrgIdByStripeCustomerId(customerId);
|
||||
if (!orgId) {
|
||||
console.error(
|
||||
`Setup session ${session.id} for customer ${customerId} has no matching org.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
const setupIntentId =
|
||||
typeof session.setup_intent === "string"
|
||||
? session.setup_intent
|
||||
: session.setup_intent?.id;
|
||||
if (!setupIntentId) {
|
||||
console.error(
|
||||
`Setup session ${session.id} completed without a setup_intent id.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
// Read the SetupIntent for the resulting PaymentMethod id.
|
||||
const stripe = getStripeClient();
|
||||
const setupIntent = await stripe.setupIntents.retrieve(setupIntentId);
|
||||
const paymentMethodId =
|
||||
typeof setupIntent.payment_method === "string"
|
||||
? setupIntent.payment_method
|
||||
: setupIntent.payment_method?.id;
|
||||
if (!paymentMethodId) {
|
||||
console.error(
|
||||
`Setup session ${session.id}: setup_intent ${setupIntentId} has no payment_method.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
// Fetch the PM details for display columns.
|
||||
const display = await getPaymentMethodDisplay(paymentMethodId);
|
||||
await setSavedPaymentMethod({
|
||||
zitadelOrgId: orgId,
|
||||
stripeCustomerId: customerId,
|
||||
paymentMethodId,
|
||||
brand: display.brand,
|
||||
last4: display.last4,
|
||||
expMonth: display.expMonth,
|
||||
expYear: display.expYear,
|
||||
});
|
||||
// Also tell Stripe this PM is the customer's default for invoice
|
||||
// payments — so a future stripe.paymentIntents.create against
|
||||
// this customer without an explicit payment_method picks it up.
|
||||
// Best-effort: a failure here doesn't undo the save (we have the
|
||||
// pm id, we can pass it explicitly when charging in Phase 9b).
|
||||
try {
|
||||
await stripe.customers.update(customerId, {
|
||||
invoice_settings: { default_payment_method: paymentMethodId },
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn(
|
||||
`Setup session ${session.id}: failed to set default_payment_method on customer ${customerId}; will pass pm id explicitly on charges.`,
|
||||
e
|
||||
);
|
||||
}
|
||||
console.log(
|
||||
`Saved PaymentMethod ${paymentMethodId} (${display.brand} ${display.last4}) for org ${orgId}.`
|
||||
);
|
||||
}
|
||||
|
||||
async function handleChargeRefunded(charge: Stripe.Charge): Promise<void> {
|
||||
// Phase 7: mirror Stripe refunds into the portal so credit notes
|
||||
// are issued for refunds initiated in the Stripe Dashboard. For
|
||||
// refunds initiated via /api/admin/.../refund, this handler is a
|
||||
// no-op (each refund's stripe_refund_id is already recorded
|
||||
// before the webhook lands — refundInvoice records it
|
||||
// synchronously after the Stripe API call).
|
||||
//
|
||||
// A charge can have multiple refund objects (multiple partial
|
||||
// refunds against the same charge accumulate here). We iterate
|
||||
// and process any that aren't yet recorded in our DB.
|
||||
const paymentIntentId =
|
||||
typeof charge.payment_intent === "string"
|
||||
? charge.payment_intent
|
||||
: charge.payment_intent?.id;
|
||||
if (!paymentIntentId) {
|
||||
console.error(
|
||||
`charge.refunded for charge ${charge.id} has no payment_intent; cannot link to invoice.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
const invoice = await getInvoiceByStripePaymentIntent(paymentIntentId);
|
||||
if (!invoice) {
|
||||
console.error(
|
||||
`charge.refunded for payment_intent ${paymentIntentId} has no matching invoice; ignoring.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
const refundsList = charge.refunds?.data ?? [];
|
||||
if (refundsList.length === 0) {
|
||||
// Some charge.refunded events fire with the refunds list
|
||||
// collapsed (the object includes the aggregated amount_refunded
|
||||
// but the data array can be omitted depending on Stripe's
|
||||
// expansion choices). In that case there's nothing for us to
|
||||
// iterate over here; the actual `refund.created` /
|
||||
// `refund.updated` events carry per-refund detail and we'd need
|
||||
// to enable those in Stripe to handle them. For v1 we log and
|
||||
// rely on the in-portal admin path (refundInvoice) being the
|
||||
// only refund initiator.
|
||||
console.log(
|
||||
`charge.refunded for charge ${charge.id} arrived without refund objects in data; in-portal flow assumed.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
for (const refund of refundsList) {
|
||||
try {
|
||||
// Idempotency: skip refunds we already recorded (either via
|
||||
// portal admin action or a prior webhook delivery).
|
||||
if (await isStripeRefundRecorded(refund.id)) {
|
||||
continue;
|
||||
}
|
||||
const amountChf = (refund.amount ?? 0) / 100;
|
||||
if (amountChf <= 0) continue;
|
||||
// Map Stripe refund status to ours. Anything other than the
|
||||
// canonical four falls through to 'pending' so we don't lose
|
||||
// the record entirely.
|
||||
let status: "pending" | "succeeded" | "failed" | "canceled" = "pending";
|
||||
if (refund.status === "succeeded") status = "succeeded";
|
||||
else if (refund.status === "failed") status = "failed";
|
||||
else if (refund.status === "canceled") status = "canceled";
|
||||
// For refunds that originated in Stripe Dashboard we don't
|
||||
// have a reason to display. Use a sentinel string so the
|
||||
// credit note PDF has something to print. Admin can edit
|
||||
// post-hoc if needed (no UI for that today, but the DB row
|
||||
// is reachable).
|
||||
const reason = refund.reason
|
||||
? `Stripe Dashboard: ${refund.reason}`
|
||||
: "Refund issued via Stripe Dashboard";
|
||||
// refundInvoice with existingStripeRefund: don't call Stripe
|
||||
// again (we'd error since the refund already exists), just
|
||||
// mirror the record into our DB and issue the credit note.
|
||||
await refundInvoice({
|
||||
invoiceId: invoice.id,
|
||||
amountChf,
|
||||
reason,
|
||||
refundedBy: "stripe-webhook",
|
||||
existingStripeRefund: { id: refund.id, status },
|
||||
});
|
||||
console.log(
|
||||
`Mirrored Stripe refund ${refund.id} for invoice ${invoice.invoiceNumber} (CHF ${amountChf.toFixed(2)}).`
|
||||
);
|
||||
} catch (e) {
|
||||
if (e instanceof RefundNotAllowedError) {
|
||||
// The invoice was already fully refunded by an earlier
|
||||
// webhook delivery or by an in-portal action. That's fine.
|
||||
console.log(
|
||||
`Stripe refund ${refund.id}: ${e.message} (already accounted for).`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
// For any other error, log but continue to the next refund —
|
||||
// we don't want one bad refund to block the rest.
|
||||
console.error(
|
||||
`Failed to mirror Stripe refund ${refund.id} for invoice ${invoice.invoiceNumber}:`,
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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)"
|
||||
}`
|
||||
);
|
||||
}
|
||||
78
src/app/global-error.tsx
Normal file
78
src/app/global-error.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
|
||||
/**
|
||||
* Last-resort boundary for errors thrown in the root layout itself
|
||||
* (before the locale layout / intl provider mount). It replaces the
|
||||
* entire document, so it must render its own <html>/<body> and cannot
|
||||
* use translations or rely on the app stylesheet being applied — styles
|
||||
* are inlined with the palette's hex values so it renders correctly in
|
||||
* isolation. Everything below the locale layout is handled by
|
||||
* [locale]/error.tsx instead; this should almost never be seen.
|
||||
*/
|
||||
export default function GlobalError({
|
||||
error,
|
||||
reset,
|
||||
}: {
|
||||
error: Error & { digest?: string };
|
||||
reset: () => void;
|
||||
}) {
|
||||
useEffect(() => {
|
||||
console.error("Portal global error:", error);
|
||||
}, [error]);
|
||||
|
||||
return (
|
||||
<html lang="en" className="dark">
|
||||
<body
|
||||
style={{
|
||||
margin: 0,
|
||||
minHeight: "100vh",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
background: "#0a0c10",
|
||||
color: "#e8ecf4",
|
||||
fontFamily: "system-ui, sans-serif",
|
||||
padding: "20px",
|
||||
}}
|
||||
>
|
||||
<div style={{ maxWidth: "28rem", textAlign: "center" }}>
|
||||
<h1 style={{ fontSize: "1.25rem", fontWeight: 600, margin: "0 0 0.5rem" }}>
|
||||
Something went wrong
|
||||
</h1>
|
||||
<p style={{ fontSize: "0.875rem", color: "#8892a4", margin: "0 0 1.5rem" }}>
|
||||
An unexpected error occurred. Please try again.
|
||||
</p>
|
||||
{error?.digest && (
|
||||
<p
|
||||
style={{
|
||||
fontSize: "11px",
|
||||
fontFamily: "monospace",
|
||||
color: "#565e6e",
|
||||
margin: "0 0 1.5rem",
|
||||
}}
|
||||
>
|
||||
{error.digest}
|
||||
</p>
|
||||
)}
|
||||
<button
|
||||
onClick={reset}
|
||||
style={{
|
||||
padding: "0.5rem 1rem",
|
||||
borderRadius: "0.5rem",
|
||||
border: "none",
|
||||
background: "#00d4aa",
|
||||
color: "#0a0c10",
|
||||
fontSize: "0.875rem",
|
||||
fontWeight: 500,
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
Try again
|
||||
</button>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { useState, useEffect, useCallback } from "react";
|
||||
import { useTranslations, useFormatter } from "next-intl";
|
||||
import type { PiecedTenant, TenantRequest } from "@/types";
|
||||
import { StatusBadge } from "@/components/ui/status-badge";
|
||||
import { Modal } from "@/components/ui/modal";
|
||||
import { formatDateTime, formatRelative } from "@/lib/format";
|
||||
import Link from "next/link";
|
||||
|
||||
@@ -35,6 +36,11 @@ export function AdminPanel({ initialTenants }: AdminPanelProps) {
|
||||
const [actionLoading, setActionLoading] = useState<string | null>(null);
|
||||
const [rejectModal, setRejectModal] = useState<string | null>(null);
|
||||
const [rejectNotes, setRejectNotes] = useState("");
|
||||
// Approve is the highest-consequence request action — it provisions
|
||||
// real infrastructure and triggers the billable setup fee — so it now
|
||||
// goes through a confirmation modal like reject/delete, instead of
|
||||
// firing on a single click.
|
||||
const [approveModal, setApproveModal] = useState<string | null>(null);
|
||||
|
||||
// Tenants state
|
||||
const [tenants, setTenants] = useState<PiecedTenant[]>(initialTenants);
|
||||
@@ -47,6 +53,11 @@ export function AdminPanel({ initialTenants }: AdminPanelProps) {
|
||||
|
||||
// Shared
|
||||
const [error, setError] = useState("");
|
||||
// Action-scoped error — shown inside the active confirmation modal so
|
||||
// a failed approve/reject/delete surfaces next to the action that
|
||||
// caused it (and keeps the modal open), rather than as a detached
|
||||
// panel-level banner that isn't tied to any row.
|
||||
const [actionError, setActionError] = useState("");
|
||||
|
||||
// ─── Requests fetching ───
|
||||
const fetchRequests = useCallback(async () => {
|
||||
@@ -125,18 +136,21 @@ export function AdminPanel({ initialTenants }: AdminPanelProps) {
|
||||
// ─── Request actions ───
|
||||
const handleApprove = async (id: string) => {
|
||||
setActionLoading(id);
|
||||
setError("");
|
||||
setActionError("");
|
||||
try {
|
||||
const res = await fetch(`/api/admin/requests/${id}/approve`, {
|
||||
method: "POST",
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json();
|
||||
const data = await res.json().catch(() => ({}));
|
||||
throw new Error(data.error || "Approve failed");
|
||||
}
|
||||
setApproveModal(null);
|
||||
await fetchRequests();
|
||||
} catch (e: any) {
|
||||
setError(e.message);
|
||||
// Keep the modal open so the admin sees why provisioning didn't
|
||||
// start; the error renders inside the dialog next to the action.
|
||||
setActionError(e.message);
|
||||
} finally {
|
||||
setActionLoading(null);
|
||||
}
|
||||
@@ -144,7 +158,7 @@ export function AdminPanel({ initialTenants }: AdminPanelProps) {
|
||||
|
||||
const handleReject = async (id: string) => {
|
||||
setActionLoading(id);
|
||||
setError("");
|
||||
setActionError("");
|
||||
try {
|
||||
const res = await fetch(`/api/admin/requests/${id}/reject`, {
|
||||
method: "POST",
|
||||
@@ -152,14 +166,14 @@ export function AdminPanel({ initialTenants }: AdminPanelProps) {
|
||||
body: JSON.stringify({ adminNotes: rejectNotes || undefined }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json();
|
||||
const data = await res.json().catch(() => ({}));
|
||||
throw new Error(data.error || "Reject failed");
|
||||
}
|
||||
setRejectModal(null);
|
||||
setRejectNotes("");
|
||||
await fetchRequests();
|
||||
} catch (e: any) {
|
||||
setError(e.message);
|
||||
setActionError(e.message);
|
||||
} finally {
|
||||
setActionLoading(null);
|
||||
}
|
||||
@@ -189,7 +203,7 @@ export function AdminPanel({ initialTenants }: AdminPanelProps) {
|
||||
|
||||
const handleDelete = async (name: string) => {
|
||||
setActionLoading(name);
|
||||
setError("");
|
||||
setActionError("");
|
||||
try {
|
||||
const res = await fetch(`/api/admin/tenants/${name}/delete`, {
|
||||
method: "POST",
|
||||
@@ -216,7 +230,7 @@ export function AdminPanel({ initialTenants }: AdminPanelProps) {
|
||||
fetchTenants();
|
||||
setTimeout(() => fetchTenants(), 1500);
|
||||
} catch (e: any) {
|
||||
setError(e.message);
|
||||
setActionError(e.message);
|
||||
} finally {
|
||||
setActionLoading(null);
|
||||
}
|
||||
@@ -246,7 +260,7 @@ export function AdminPanel({ initialTenants }: AdminPanelProps) {
|
||||
>
|
||||
{t("requests")}
|
||||
{pendingCount > 0 && tab !== "requests" && (
|
||||
<span className="ml-1.5 inline-flex items-center justify-center h-4 min-w-[16px] px-1 text-[10px] font-bold bg-accent text-white rounded-full">
|
||||
<span className="ml-1.5 inline-flex items-center justify-center h-4 min-w-[16px] px-1 text-[10px] font-bold bg-accent text-surface-0 rounded-full">
|
||||
{pendingCount}
|
||||
</span>
|
||||
)}
|
||||
@@ -308,7 +322,7 @@ export function AdminPanel({ initialTenants }: AdminPanelProps) {
|
||||
onClick={() => setFilter(f)}
|
||||
className={`px-3 py-1 text-xs rounded-full transition-colors ${
|
||||
filter === f
|
||||
? "bg-accent text-white"
|
||||
? "bg-accent text-surface-0"
|
||||
: "bg-surface-2 text-text-muted hover:text-text-secondary border border-border"
|
||||
}`}
|
||||
>
|
||||
@@ -436,16 +450,20 @@ export function AdminPanel({ initialTenants }: AdminPanelProps) {
|
||||
{req.status === "pending" && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => handleApprove(req.id)}
|
||||
onClick={() => {
|
||||
setActionError("");
|
||||
setApproveModal(req.id);
|
||||
}}
|
||||
disabled={actionLoading === req.id}
|
||||
className="px-2.5 py-1 text-xs font-medium bg-emerald-500/15 text-emerald-400 rounded-md hover:bg-emerald-500/25 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{actionLoading === req.id
|
||||
? "…"
|
||||
: t("approve")}
|
||||
{t("approve")}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setRejectModal(req.id)}
|
||||
onClick={() => {
|
||||
setActionError("");
|
||||
setRejectModal(req.id);
|
||||
}}
|
||||
disabled={actionLoading === req.id}
|
||||
className="px-2.5 py-1 text-xs font-medium bg-red-500/15 text-red-400 rounded-md hover:bg-red-500/25 transition-colors disabled:opacity-50"
|
||||
>
|
||||
@@ -466,7 +484,10 @@ export function AdminPanel({ initialTenants }: AdminPanelProps) {
|
||||
)}
|
||||
{req.status === "rejected" && (
|
||||
<button
|
||||
onClick={() => handleApprove(req.id)}
|
||||
onClick={() => {
|
||||
setActionError("");
|
||||
setApproveModal(req.id);
|
||||
}}
|
||||
disabled={actionLoading === req.id}
|
||||
className="px-2.5 py-1 text-xs font-medium bg-amber-500/15 text-amber-400 rounded-md hover:bg-amber-500/25 transition-colors disabled:opacity-50"
|
||||
>
|
||||
@@ -642,9 +663,10 @@ export function AdminPanel({ initialTenants }: AdminPanelProps) {
|
||||
: t("suspend")}
|
||||
</button>
|
||||
<button
|
||||
onClick={() =>
|
||||
setDeleteModal(tenant.metadata.name)
|
||||
}
|
||||
onClick={() => {
|
||||
setActionError("");
|
||||
setDeleteModal(tenant.metadata.name);
|
||||
}}
|
||||
disabled={actionLoading === tenant.metadata.name}
|
||||
className="px-2.5 py-1 text-xs font-medium bg-red-500/15 text-red-400 rounded-md hover:bg-red-500/25 transition-colors disabled:opacity-50"
|
||||
>
|
||||
@@ -772,10 +794,75 @@ export function AdminPanel({ initialTenants }: AdminPanelProps) {
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ───── APPROVE MODAL ───── */}
|
||||
<Modal
|
||||
open={!!approveModal}
|
||||
onClose={() => {
|
||||
setApproveModal(null);
|
||||
setActionError("");
|
||||
}}
|
||||
ariaLabel={t("approveTitle")}
|
||||
>
|
||||
{approveModal &&
|
||||
(() => {
|
||||
const req = requests.find((r) => r.id === approveModal);
|
||||
const isReapprove = req?.status === "rejected";
|
||||
return (
|
||||
<>
|
||||
<h3 className="font-display text-lg font-semibold text-text-primary mb-2">
|
||||
{t("approveTitle")}
|
||||
</h3>
|
||||
<p className="text-sm text-text-secondary mb-2">
|
||||
{isReapprove
|
||||
? t("approveReapproveWarning")
|
||||
: t("approveWarning")}
|
||||
</p>
|
||||
{req && (
|
||||
<p className="text-xs font-mono text-accent bg-surface-2 border border-border rounded-lg px-3 py-2 mb-4">
|
||||
{req.companyName}
|
||||
{req.agentName ? ` · ${req.agentName}` : ""}
|
||||
</p>
|
||||
)}
|
||||
{actionError && (
|
||||
<p className="text-xs text-red-400 bg-red-400/10 border border-red-400/20 rounded-lg px-3 py-2 mb-4">
|
||||
{actionError}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex gap-2 justify-end">
|
||||
<button
|
||||
onClick={() => {
|
||||
setApproveModal(null);
|
||||
setActionError("");
|
||||
}}
|
||||
className="px-4 py-2 text-sm text-text-secondary hover:text-text-primary transition-colors"
|
||||
>
|
||||
{t("cancelAction")}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleApprove(approveModal)}
|
||||
disabled={actionLoading === approveModal}
|
||||
className="px-4 py-2 text-sm font-medium bg-emerald-500/15 text-emerald-400 rounded-lg hover:bg-emerald-500/25 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{actionLoading === approveModal ? "…" : t("confirmApprove")}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</Modal>
|
||||
|
||||
{/* ───── REJECT MODAL ───── */}
|
||||
<Modal
|
||||
open={!!rejectModal}
|
||||
onClose={() => {
|
||||
setRejectModal(null);
|
||||
setRejectNotes("");
|
||||
setActionError("");
|
||||
}}
|
||||
ariaLabel={t("rejectTitle")}
|
||||
>
|
||||
{rejectModal && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
|
||||
<div className="bg-surface-1 border border-border rounded-xl p-6 max-w-md w-full mx-4 shadow-2xl">
|
||||
<>
|
||||
<h3 className="font-display text-lg font-semibold text-text-primary mb-4">
|
||||
{t("rejectTitle")}
|
||||
</h3>
|
||||
@@ -789,11 +876,17 @@ export function AdminPanel({ initialTenants }: AdminPanelProps) {
|
||||
rows={3}
|
||||
className="w-full px-3 py-2 bg-surface-2 border border-border rounded-lg text-sm text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-1 focus:ring-accent focus:border-accent transition-colors resize-none mb-4"
|
||||
/>
|
||||
{actionError && (
|
||||
<p className="text-xs text-red-400 bg-red-400/10 border border-red-400/20 rounded-lg px-3 py-2 mb-4">
|
||||
{actionError}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex gap-2 justify-end">
|
||||
<button
|
||||
onClick={() => {
|
||||
setRejectModal(null);
|
||||
setRejectNotes("");
|
||||
setActionError("");
|
||||
}}
|
||||
className="px-4 py-2 text-sm text-text-secondary hover:text-text-primary transition-colors"
|
||||
>
|
||||
@@ -807,14 +900,21 @@ export function AdminPanel({ initialTenants }: AdminPanelProps) {
|
||||
{actionLoading === rejectModal ? "…" : t("confirmReject")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
{/* ───── DELETE MODAL ───── */}
|
||||
<Modal
|
||||
open={!!deleteModal}
|
||||
onClose={() => {
|
||||
setDeleteModal(null);
|
||||
setActionError("");
|
||||
}}
|
||||
ariaLabel={t("deleteTitle")}
|
||||
>
|
||||
{deleteModal && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
|
||||
<div className="bg-surface-1 border border-border rounded-xl p-6 max-w-md w-full mx-4 shadow-2xl">
|
||||
<>
|
||||
<h3 className="font-display text-lg font-semibold text-text-primary mb-2">
|
||||
{t("deleteTitle")}
|
||||
</h3>
|
||||
@@ -824,9 +924,17 @@ export function AdminPanel({ initialTenants }: AdminPanelProps) {
|
||||
<p className="text-xs font-mono text-accent bg-surface-2 border border-border rounded-lg px-3 py-2 mb-4">
|
||||
{deleteModal}
|
||||
</p>
|
||||
{actionError && (
|
||||
<p className="text-xs text-red-400 bg-red-400/10 border border-red-400/20 rounded-lg px-3 py-2 mb-4">
|
||||
{actionError}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex gap-2 justify-end">
|
||||
<button
|
||||
onClick={() => setDeleteModal(null)}
|
||||
onClick={() => {
|
||||
setDeleteModal(null);
|
||||
setActionError("");
|
||||
}}
|
||||
className="px-4 py-2 text-sm text-text-secondary hover:text-text-primary transition-colors"
|
||||
>
|
||||
{t("cancelAction")}
|
||||
@@ -839,9 +947,9 @@ export function AdminPanel({ initialTenants }: AdminPanelProps) {
|
||||
{actionLoading === deleteModal ? "…" : t("confirmDelete")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
539
src/components/admin/billing/custom-invoice-editor.tsx
Normal file
539
src/components/admin/billing/custom-invoice-editor.tsx
Normal file
@@ -0,0 +1,539 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useMemo, useCallback } from "react";
|
||||
import { useRouter } from "@/i18n/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Card, CardHeader } from "@/components/ui/card";
|
||||
import type {
|
||||
CustomInvoiceDraftLine,
|
||||
CustomInvoiceDraftPayload,
|
||||
InvoiceDraftRecord,
|
||||
OrgBilling,
|
||||
} from "@/types";
|
||||
|
||||
interface Props {
|
||||
draft: InvoiceDraftRecord;
|
||||
orgBilling: OrgBilling | null;
|
||||
}
|
||||
|
||||
const LOCALE_OPTIONS = [
|
||||
{ value: "de", label: "Deutsch" },
|
||||
{ value: "en", label: "English" },
|
||||
{ value: "fr", label: "Français" },
|
||||
{ value: "it", label: "Italiano" },
|
||||
];
|
||||
|
||||
/**
|
||||
* Custom invoice editor — Phase 8.
|
||||
*
|
||||
* Local state mirrors the persisted payload. Save persists the
|
||||
* current state via PUT. Preview re-renders the PDF in-memory (no
|
||||
* persistence). Issue allocates the invoice number and emails the
|
||||
* customer.
|
||||
*
|
||||
* VAT preview is computed client-side from the country in the org
|
||||
* billing snapshot — it's an estimate for the admin's eye, not
|
||||
* authoritative. The server recomputes at issue time using the
|
||||
* same vatRateForAddress() helper to ensure consistency.
|
||||
*
|
||||
* Discount/Rabatt is supported via a row with a negative
|
||||
* unitPriceChf. The "Add discount" button seeds a new row with
|
||||
* quantity 1 and a -50 placeholder to nudge the admin toward the
|
||||
* intended sign.
|
||||
*/
|
||||
export function CustomInvoiceEditor({ draft, orgBilling }: Props) {
|
||||
const t = useTranslations("adminBilling");
|
||||
const router = useRouter();
|
||||
|
||||
// Editable state — initialized from the draft payload.
|
||||
const [issueDate, setIssueDate] = useState(draft.payload.issueDate);
|
||||
const [dueDate, setDueDate] = useState(draft.payload.dueDate);
|
||||
const [locale, setLocale] = useState<"de" | "en" | "fr" | "it">(
|
||||
draft.payload.locale
|
||||
);
|
||||
const [paymentMethod, setPaymentMethod] = useState<"invoice" | "card">(
|
||||
draft.payload.paymentMethod
|
||||
);
|
||||
const [adminNotes, setAdminNotes] = useState(draft.payload.adminNotes ?? "");
|
||||
const [lines, setLines] = useState<CustomInvoiceDraftLine[]>(
|
||||
draft.payload.lines.length > 0
|
||||
? draft.payload.lines
|
||||
: [{ description: "", quantity: 1, unitPriceChf: 0 }]
|
||||
);
|
||||
|
||||
const [busy, setBusy] = useState<null | "save" | "preview" | "issue" | "delete">(
|
||||
null
|
||||
);
|
||||
const [error, setError] = useState("");
|
||||
const [dirty, setDirty] = useState(false);
|
||||
|
||||
// Build current payload — used by every action.
|
||||
const buildPayload = useCallback((): CustomInvoiceDraftPayload => {
|
||||
return {
|
||||
issueDate,
|
||||
dueDate,
|
||||
locale,
|
||||
paymentMethod,
|
||||
adminNotes: adminNotes.trim() ? adminNotes.trim() : undefined,
|
||||
lines: lines.map((ln) => ({
|
||||
description: ln.description,
|
||||
quantity: Number(ln.quantity) || 0,
|
||||
unitPriceChf: Number(ln.unitPriceChf) || 0,
|
||||
})),
|
||||
};
|
||||
}, [issueDate, dueDate, locale, paymentMethod, adminNotes, lines]);
|
||||
|
||||
// Client-side VAT estimate. The auth-of-truth math runs server-side
|
||||
// at issue time; this is just to show the admin what they're about
|
||||
// to commit to.
|
||||
const totals = useMemo(() => {
|
||||
const subtotal = Math.round(
|
||||
lines.reduce(
|
||||
(s, ln) => s + (Number(ln.quantity) || 0) * (Number(ln.unitPriceChf) || 0),
|
||||
0
|
||||
) * 100
|
||||
) / 100;
|
||||
// Country-based VAT estimate. Mirrors vatRateForAddress() —
|
||||
// simplified because the editor doesn't know the platform
|
||||
// pricing config. Defaults to 8.1 for CH/LI; 0 otherwise.
|
||||
const country = (orgBilling?.country ?? "").toUpperCase();
|
||||
let vatRate = 0;
|
||||
if (country === "CH" || country === "LI") {
|
||||
vatRate = 8.1;
|
||||
} else if (orgBilling?.vatNumber) {
|
||||
vatRate = 0; // reverse charge
|
||||
} else {
|
||||
vatRate = 0; // out of scope OR consumer (server will fix)
|
||||
}
|
||||
const vatAmount = Math.round(subtotal * (vatRate / 100) * 100) / 100;
|
||||
const total = Math.round((subtotal + vatAmount) * 100) / 100;
|
||||
return { subtotal, vatRate, vatAmount, total };
|
||||
}, [lines, orgBilling]);
|
||||
|
||||
// Line management
|
||||
const updateLine = (idx: number, patch: Partial<CustomInvoiceDraftLine>) => {
|
||||
setLines((prev) =>
|
||||
prev.map((ln, i) => (i === idx ? { ...ln, ...patch } : ln))
|
||||
);
|
||||
setDirty(true);
|
||||
};
|
||||
const addLine = () => {
|
||||
setLines((prev) => [
|
||||
...prev,
|
||||
{ description: "", quantity: 1, unitPriceChf: 0 },
|
||||
]);
|
||||
setDirty(true);
|
||||
};
|
||||
const addDiscountLine = () => {
|
||||
setLines((prev) => [
|
||||
...prev,
|
||||
{ description: t("editorRabattDefaultDescription"), quantity: 1, unitPriceChf: -50 },
|
||||
]);
|
||||
setDirty(true);
|
||||
};
|
||||
const removeLine = (idx: number) => {
|
||||
setLines((prev) => prev.filter((_, i) => i !== idx));
|
||||
setDirty(true);
|
||||
};
|
||||
|
||||
// Actions
|
||||
const save = async (): Promise<boolean> => {
|
||||
setError("");
|
||||
setBusy("save");
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/admin/billing/invoice-drafts/${draft.id}`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(buildPayload()),
|
||||
}
|
||||
);
|
||||
const j = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(j.error || `HTTP ${res.status}`);
|
||||
setDirty(false);
|
||||
return true;
|
||||
} catch (e: any) {
|
||||
setError(e.message);
|
||||
return false;
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
const preview = async () => {
|
||||
// Save first if there are unsaved changes — otherwise the
|
||||
// preview reflects stale data.
|
||||
if (dirty) {
|
||||
const ok = await save();
|
||||
if (!ok) return;
|
||||
}
|
||||
// Open the preview in a new tab. The browser handles the PDF
|
||||
// download/render natively; we don't need to fetch the bytes
|
||||
// ourselves.
|
||||
window.open(
|
||||
`/api/admin/billing/invoice-drafts/${draft.id}/preview`,
|
||||
"_blank",
|
||||
"noopener"
|
||||
);
|
||||
};
|
||||
|
||||
const issue = async () => {
|
||||
if (!confirm(t("editorIssueConfirm"))) return;
|
||||
if (dirty) {
|
||||
const ok = await save();
|
||||
if (!ok) return;
|
||||
}
|
||||
setError("");
|
||||
setBusy("issue");
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/admin/billing/invoice-drafts/${draft.id}/issue`,
|
||||
{ method: "POST" }
|
||||
);
|
||||
const j = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(j.error || `HTTP ${res.status}`);
|
||||
// The draft was deleted server-side; go look at the new invoice.
|
||||
router.push(`/admin/billing/invoices/${j.invoice.id}`);
|
||||
} catch (e: any) {
|
||||
setError(e.message);
|
||||
setBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteDraft = async () => {
|
||||
if (!confirm(t("editorDeleteConfirm"))) return;
|
||||
setError("");
|
||||
setBusy("delete");
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/admin/billing/invoice-drafts/${draft.id}`,
|
||||
{ method: "DELETE" }
|
||||
);
|
||||
if (!res.ok) {
|
||||
const j = await res.json().catch(() => ({}));
|
||||
throw new Error(j.error || `HTTP ${res.status}`);
|
||||
}
|
||||
router.push("/admin/billing/invoice-drafts");
|
||||
} catch (e: any) {
|
||||
setError(e.message);
|
||||
setBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
// No billing snapshot = can't issue. Save still works so admin
|
||||
// can come back once the customer has completed onboarding.
|
||||
const canIssue =
|
||||
!!orgBilling &&
|
||||
lines.length > 0 &&
|
||||
lines.every((ln) => ln.description.trim().length > 0);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
{/* Bill-to preview — read-only, sourced from the org's billing
|
||||
snapshot. Issued at issue time. */}
|
||||
<Card>
|
||||
<CardHeader>{t("editorBillToHeading")}</CardHeader>
|
||||
<div className="p-4 text-sm">
|
||||
{orgBilling ? (
|
||||
<>
|
||||
<p className="font-medium">{orgBilling.companyName}</p>
|
||||
{orgBilling.contactName && (
|
||||
<p className="text-text-secondary text-xs">
|
||||
{orgBilling.contactName}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-text-secondary text-xs">
|
||||
{orgBilling.streetAddress}, {orgBilling.postalCode}{" "}
|
||||
{orgBilling.city}, {orgBilling.country}
|
||||
</p>
|
||||
{orgBilling.vatNumber && (
|
||||
<p className="text-text-muted text-xs mt-1">
|
||||
MWST/VAT: {orgBilling.vatNumber}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-text-muted text-xs">
|
||||
{orgBilling.billingEmail}
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-error">{t("editorNoBillingSnapshot")}</p>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Dates + locale + payment method */}
|
||||
<Card>
|
||||
<CardHeader>{t("editorMetadataHeading")}</CardHeader>
|
||||
<div className="p-4 grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs uppercase tracking-wider text-text-muted">
|
||||
{t("editorIssueDateLabel")}
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={issueDate}
|
||||
onChange={(e) => {
|
||||
setIssueDate(e.target.value);
|
||||
setDirty(true);
|
||||
}}
|
||||
className="px-3 py-2 rounded-md border border-border bg-surface-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs uppercase tracking-wider text-text-muted">
|
||||
{t("editorDueDateLabel")}
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={dueDate}
|
||||
onChange={(e) => {
|
||||
setDueDate(e.target.value);
|
||||
setDirty(true);
|
||||
}}
|
||||
className="px-3 py-2 rounded-md border border-border bg-surface-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs uppercase tracking-wider text-text-muted">
|
||||
{t("editorLocaleLabel")}
|
||||
</label>
|
||||
<select
|
||||
value={locale}
|
||||
onChange={(e) => {
|
||||
setLocale(e.target.value as "de" | "en" | "fr" | "it");
|
||||
setDirty(true);
|
||||
}}
|
||||
className="px-3 py-2 rounded-md border border-border bg-surface-2 text-sm"
|
||||
>
|
||||
{LOCALE_OPTIONS.map((o) => (
|
||||
<option key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs uppercase tracking-wider text-text-muted">
|
||||
{t("editorPaymentMethodLabel")}
|
||||
</label>
|
||||
<select
|
||||
value={paymentMethod}
|
||||
onChange={(e) => {
|
||||
setPaymentMethod(e.target.value as "invoice" | "card");
|
||||
setDirty(true);
|
||||
}}
|
||||
className="px-3 py-2 rounded-md border border-border bg-surface-2 text-sm"
|
||||
>
|
||||
<option value="invoice">{t("editorPaymentInvoice")}</option>
|
||||
<option value="card">{t("editorPaymentCard")}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Line editor */}
|
||||
<Card>
|
||||
<CardHeader>{t("editorLinesHeading")}</CardHeader>
|
||||
<div className="p-4">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="text-xs text-text-muted text-left">
|
||||
<tr>
|
||||
<th className="pb-2 pr-3">{t("editorLineDescription")}</th>
|
||||
<th className="pb-2 pr-3 w-20 text-right">
|
||||
{t("editorLineQty")}
|
||||
</th>
|
||||
<th className="pb-2 pr-3 w-32 text-right">
|
||||
{t("editorLineUnitPrice")}
|
||||
</th>
|
||||
<th className="pb-2 pr-3 w-32 text-right">
|
||||
{t("editorLineAmount")}
|
||||
</th>
|
||||
<th className="pb-2 w-12"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{lines.map((ln, idx) => {
|
||||
const amount =
|
||||
Math.round(
|
||||
(Number(ln.quantity) || 0) *
|
||||
(Number(ln.unitPriceChf) || 0) *
|
||||
100
|
||||
) / 100;
|
||||
return (
|
||||
<tr key={idx} className="border-t border-border">
|
||||
<td className="py-2 pr-3">
|
||||
<input
|
||||
type="text"
|
||||
value={ln.description}
|
||||
onChange={(e) =>
|
||||
updateLine(idx, { description: e.target.value })
|
||||
}
|
||||
placeholder={t("editorLineDescriptionPlaceholder")}
|
||||
className="w-full px-2 py-1.5 rounded border border-border bg-surface-2 text-sm"
|
||||
maxLength={500}
|
||||
/>
|
||||
</td>
|
||||
<td className="py-2 pr-3">
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={ln.quantity}
|
||||
onChange={(e) =>
|
||||
updateLine(idx, {
|
||||
quantity: parseFloat(e.target.value) || 0,
|
||||
})
|
||||
}
|
||||
className="w-full px-2 py-1.5 rounded border border-border bg-surface-2 text-sm font-mono text-right"
|
||||
/>
|
||||
</td>
|
||||
<td className="py-2 pr-3">
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={ln.unitPriceChf}
|
||||
onChange={(e) =>
|
||||
updateLine(idx, {
|
||||
unitPriceChf: parseFloat(e.target.value) || 0,
|
||||
})
|
||||
}
|
||||
className="w-full px-2 py-1.5 rounded border border-border bg-surface-2 text-sm font-mono text-right"
|
||||
/>
|
||||
</td>
|
||||
<td className="py-2 pr-3 text-right font-mono text-sm whitespace-nowrap">
|
||||
<span className={amount < 0 ? "text-error" : ""}>
|
||||
CHF {amount.toFixed(2)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-2 text-right">
|
||||
<button
|
||||
onClick={() => removeLine(idx)}
|
||||
className="text-text-muted hover:text-error text-lg leading-none"
|
||||
title={t("editorLineRemove")}
|
||||
type="button"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="flex gap-2 mt-3">
|
||||
<button
|
||||
onClick={addLine}
|
||||
type="button"
|
||||
className="px-3 py-1.5 rounded-md border border-border text-sm hover:bg-surface-3"
|
||||
>
|
||||
+ {t("editorAddLine")}
|
||||
</button>
|
||||
<button
|
||||
onClick={addDiscountLine}
|
||||
type="button"
|
||||
className="px-3 py-1.5 rounded-md border border-border text-sm hover:bg-surface-3 text-text-secondary"
|
||||
title={t("editorAddDiscountHint")}
|
||||
>
|
||||
− {t("editorAddDiscount")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Admin notes */}
|
||||
<Card>
|
||||
<CardHeader>{t("editorNotesHeading")}</CardHeader>
|
||||
<div className="p-4">
|
||||
<textarea
|
||||
value={adminNotes}
|
||||
onChange={(e) => {
|
||||
setAdminNotes(e.target.value);
|
||||
setDirty(true);
|
||||
}}
|
||||
placeholder={t("editorNotesPlaceholder")}
|
||||
rows={2}
|
||||
maxLength={2000}
|
||||
className="w-full px-3 py-2 rounded-md border border-border bg-surface-2 text-sm"
|
||||
/>
|
||||
<p className="text-xs text-text-muted mt-1">
|
||||
{t("editorNotesHint")}
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Totals preview */}
|
||||
<Card>
|
||||
<CardHeader>{t("editorTotalsHeading")}</CardHeader>
|
||||
<div className="p-4 max-w-sm ml-auto text-sm">
|
||||
<div className="flex justify-between py-1">
|
||||
<span className="text-text-muted">{t("editorSubtotal")}</span>
|
||||
<span className="font-mono">CHF {totals.subtotal.toFixed(2)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between py-1">
|
||||
<span className="text-text-muted">
|
||||
{t("editorVat")} ({totals.vatRate.toFixed(1)}%)
|
||||
</span>
|
||||
<span className="font-mono">CHF {totals.vatAmount.toFixed(2)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between py-2 border-t border-border mt-1 font-medium">
|
||||
<span>{t("editorTotal")}</span>
|
||||
<span className="font-mono">CHF {totals.total.toFixed(2)}</span>
|
||||
</div>
|
||||
<p className="text-xs text-text-muted mt-2 italic">
|
||||
{t("editorTotalsEstimateNote")}
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Error + actions */}
|
||||
{error && (
|
||||
<div className="text-sm text-error border border-error/30 bg-error/10 rounded-md px-4 py-2">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap gap-2 justify-between items-center">
|
||||
<button
|
||||
onClick={deleteDraft}
|
||||
disabled={busy !== null}
|
||||
className="px-4 py-2 rounded-md border border-error text-error text-sm disabled:opacity-50 hover:bg-error/10"
|
||||
type="button"
|
||||
>
|
||||
{busy === "delete" ? t("deleting") : t("editorDeleteBtn")}
|
||||
</button>
|
||||
<div className="flex gap-2 ml-auto">
|
||||
<button
|
||||
onClick={save}
|
||||
disabled={busy !== null || !dirty}
|
||||
className="px-4 py-2 rounded-md border border-border text-sm disabled:opacity-50"
|
||||
type="button"
|
||||
>
|
||||
{busy === "save"
|
||||
? t("saving")
|
||||
: dirty
|
||||
? t("editorSaveBtn")
|
||||
: t("editorSavedBtn")}
|
||||
</button>
|
||||
<button
|
||||
onClick={preview}
|
||||
disabled={busy !== null || lines.length === 0}
|
||||
className="px-4 py-2 rounded-md border border-border text-sm disabled:opacity-50"
|
||||
type="button"
|
||||
>
|
||||
{busy === "preview" ? t("previewing") : t("editorPreviewBtn")}
|
||||
</button>
|
||||
<button
|
||||
onClick={issue}
|
||||
disabled={busy !== null || !canIssue}
|
||||
className="px-4 py-2 rounded-md bg-accent text-surface-0 text-sm disabled:opacity-50"
|
||||
type="button"
|
||||
>
|
||||
{busy === "issue" ? t("issuing") : t("editorIssueBtn")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
147
src/components/admin/billing/draft-list.tsx
Normal file
147
src/components/admin/billing/draft-list.tsx
Normal file
@@ -0,0 +1,147 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useTranslations, useFormatter } from "next-intl";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import type { InvoiceDraftRecord } from "@/types";
|
||||
|
||||
interface Props {
|
||||
drafts: InvoiceDraftRecord[];
|
||||
/** Map ZITADEL org id → company name for friendlier display. */
|
||||
orgNameMap: Record<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the drafts table with per-row Edit / Delete actions.
|
||||
*
|
||||
* The total preview is the algebraic sum of line amounts (the same
|
||||
* formula billing.computeCustomInvoiceTotals uses for the subtotal,
|
||||
* minus VAT — which we don't know without the org's billing
|
||||
* snapshot). It's a hint, not authoritative; the real total
|
||||
* appears when the draft is issued.
|
||||
*
|
||||
* Empty state shows a clear CTA so a fresh admin knows where to
|
||||
* start.
|
||||
*/
|
||||
export function DraftList({ drafts, orgNameMap }: Props) {
|
||||
const t = useTranslations("adminBilling");
|
||||
const fmt = useFormatter();
|
||||
const router = useRouter();
|
||||
const [busyId, setBusyId] = useState<string | null>(null);
|
||||
|
||||
const onDelete = async (id: string) => {
|
||||
if (!confirm(t("draftDeleteConfirm"))) return;
|
||||
setBusyId(id);
|
||||
try {
|
||||
const res = await fetch(`/api/admin/billing/invoice-drafts/${id}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
if (!res.ok) {
|
||||
const j = await res.json().catch(() => ({}));
|
||||
throw new Error(j.error || `HTTP ${res.status}`);
|
||||
}
|
||||
router.refresh();
|
||||
} catch (e: any) {
|
||||
alert(e.message);
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
};
|
||||
|
||||
if (drafts.length === 0) {
|
||||
return (
|
||||
<Card>
|
||||
<div className="p-6 text-center">
|
||||
<p className="text-text-secondary mb-4">{t("draftsEmpty")}</p>
|
||||
<Link
|
||||
href="/admin/billing/invoices/new"
|
||||
className="inline-block px-4 py-2 rounded-md bg-accent text-surface-0 text-sm"
|
||||
>
|
||||
{t("newInvoiceBtn")}
|
||||
</Link>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<div className="flex justify-end p-3 border-b border-border">
|
||||
<Link
|
||||
href="/admin/billing/invoices/new"
|
||||
className="inline-block px-3 py-1.5 rounded-md bg-accent text-surface-0 text-sm"
|
||||
>
|
||||
{t("newInvoiceBtn")}
|
||||
</Link>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="text-xs text-text-muted text-left">
|
||||
<tr>
|
||||
<th className="pb-2 pl-3 pr-4">{t("draftOrgCol")}</th>
|
||||
<th className="pb-2 pr-4">{t("draftIssueDateCol")}</th>
|
||||
<th className="pb-2 pr-4 text-center">{t("draftLinesCol")}</th>
|
||||
<th className="pb-2 pr-4 text-right">{t("draftSubtotalCol")}</th>
|
||||
<th className="pb-2 pr-4">{t("draftUpdatedCol")}</th>
|
||||
<th className="pb-2 pr-3 text-right">{t("draftActionsCol")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{drafts.map((d) => {
|
||||
const subtotal = d.payload.lines.reduce(
|
||||
(s, ln) =>
|
||||
s +
|
||||
Math.round(ln.quantity * ln.unitPriceChf * 100) / 100,
|
||||
0
|
||||
);
|
||||
return (
|
||||
<tr key={d.id} className="border-t border-border">
|
||||
<td className="py-2 pl-3 pr-4">
|
||||
<Link
|
||||
href={`/admin/billing/invoice-drafts/${d.id}`}
|
||||
className="hover:underline"
|
||||
>
|
||||
{orgNameMap[d.zitadelOrgId] ?? d.zitadelOrgId}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="py-2 pr-4 text-xs font-mono text-text-secondary whitespace-nowrap">
|
||||
{d.payload.issueDate}
|
||||
</td>
|
||||
<td className="py-2 pr-4 text-center text-xs">
|
||||
{d.payload.lines.length}
|
||||
</td>
|
||||
<td className="py-2 pr-4 text-right font-mono text-xs whitespace-nowrap">
|
||||
CHF {subtotal.toFixed(2)}
|
||||
</td>
|
||||
<td className="py-2 pr-4 text-xs text-text-muted whitespace-nowrap">
|
||||
{fmt.dateTime(new Date(d.updatedAt), {
|
||||
dateStyle: "medium",
|
||||
timeStyle: "short",
|
||||
})}
|
||||
</td>
|
||||
<td className="py-2 pr-3 text-right">
|
||||
<Link
|
||||
href={`/admin/billing/invoice-drafts/${d.id}`}
|
||||
className="text-accent hover:underline text-xs mr-3"
|
||||
>
|
||||
{t("editBtn")}
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => onDelete(d.id)}
|
||||
disabled={busyId === d.id}
|
||||
className="text-error hover:underline text-xs disabled:opacity-50"
|
||||
>
|
||||
{busyId === d.id ? t("deleting") : t("deleteBtn")}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -216,7 +216,7 @@ export function GenerateForm({ orgs }: Props) {
|
||||
<button
|
||||
onClick={commit}
|
||||
disabled={busy}
|
||||
className="px-4 py-2 rounded-md bg-accent text-white text-sm disabled:opacity-50"
|
||||
className="px-4 py-2 rounded-md bg-accent text-surface-0 text-sm disabled:opacity-50"
|
||||
>
|
||||
{busy ? t("saving") : t("commitBtn")}
|
||||
</button>
|
||||
@@ -265,6 +265,7 @@ function DraftPreview({ draft }: { draft: InvoiceDraft }) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="text-xs text-text-muted text-left">
|
||||
<tr>
|
||||
@@ -323,6 +324,7 @@ function DraftPreview({ draft }: { draft: InvoiceDraft }) {
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 pt-3 border-t border-border space-y-1 text-sm">
|
||||
<div className="flex justify-between">
|
||||
|
||||
@@ -1,36 +1,64 @@
|
||||
"use client";
|
||||
|
||||
import { useState, Fragment } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useRouter } from "@/i18n/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Card, CardHeader } from "@/components/ui/card";
|
||||
import type { InvoiceDetail, InvoiceStatus } from "@/types";
|
||||
import type { CreditNote, InvoiceDetail, InvoiceStatus } from "@/types";
|
||||
|
||||
interface Props {
|
||||
detail: InvoiceDetail;
|
||||
/**
|
||||
* Phase 7: credit notes linked to this invoice (voids + refunds).
|
||||
* Empty array when none. Passed from the server page; client
|
||||
* doesn't re-fetch — router.refresh() rebuilds after actions.
|
||||
*/
|
||||
creditNotes?: CreditNote[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the invoice header (status, totals, action bar) then
|
||||
* line items grouped by tenant, then billing snapshot. Actions are
|
||||
* mark-paid (POST), delete (DELETE), PDF download (link to /pdf).
|
||||
* mark-paid (POST), void (POST), refund (POST), delete (DELETE),
|
||||
* PDF download (link to /pdf).
|
||||
*
|
||||
* Phase 7 adds void + refund. The action bar shows:
|
||||
* - status open/overdue → Mark paid, Void, Delete
|
||||
* - status paid → Refund, Delete
|
||||
* - status partially_refunded → Refund (for remainder), Delete
|
||||
* - status fully_refunded / void → Delete only (read-only otherwise)
|
||||
*
|
||||
* On successful action we router.refresh() — the server-side page
|
||||
* re-renders against the new DB state. For delete we navigate
|
||||
* away first.
|
||||
* re-renders against the new DB state, including any new credit
|
||||
* notes.
|
||||
*/
|
||||
export function InvoiceDetailView({ detail }: Props) {
|
||||
export function InvoiceDetailView({ detail, creditNotes = [] }: Props) {
|
||||
const t = useTranslations("adminBilling");
|
||||
const router = useRouter();
|
||||
const { invoice, lines } = detail;
|
||||
|
||||
const [busyAction, setBusyAction] = useState<null | "mark-paid" | "delete">(
|
||||
null
|
||||
);
|
||||
const [busyAction, setBusyAction] = useState<
|
||||
null | "mark-paid" | "delete" | "void" | "refund"
|
||||
>(null);
|
||||
const [actionError, setActionError] = useState("");
|
||||
const [noteInput, setNoteInput] = useState("");
|
||||
const [noteOpen, setNoteOpen] = useState(false);
|
||||
|
||||
// Phase 7 — void modal state
|
||||
const [voidOpen, setVoidOpen] = useState(false);
|
||||
const [voidReason, setVoidReason] = useState("");
|
||||
|
||||
// Phase 7 — refund modal state. Amount defaults to the full
|
||||
// remaining refundable on open.
|
||||
const [refundOpen, setRefundOpen] = useState(false);
|
||||
const [refundAmount, setRefundAmount] = useState("");
|
||||
const [refundReason, setRefundReason] = useState("");
|
||||
|
||||
const remainingRefundable =
|
||||
Math.round(
|
||||
(invoice.totalChf - invoice.refundedTotalChf) * 100
|
||||
) / 100;
|
||||
|
||||
const markPaid = async () => {
|
||||
setActionError("");
|
||||
setBusyAction("mark-paid");
|
||||
@@ -75,6 +103,84 @@ export function InvoiceDetailView({ detail }: Props) {
|
||||
}
|
||||
};
|
||||
|
||||
// Phase 7 — void: marks an unpaid invoice as cancelled and issues
|
||||
// a credit note. Backend rejects if the invoice is paid (use
|
||||
// refund) or already voided/refunded.
|
||||
const voidInvoice = async () => {
|
||||
if (!voidReason.trim()) {
|
||||
setActionError(t("voidReasonRequired"));
|
||||
return;
|
||||
}
|
||||
setActionError("");
|
||||
setBusyAction("void");
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/admin/billing/invoices/${invoice.id}/void`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ reason: voidReason }),
|
||||
}
|
||||
);
|
||||
const j = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(j.error || `HTTP ${res.status}`);
|
||||
setVoidOpen(false);
|
||||
setVoidReason("");
|
||||
router.refresh();
|
||||
} catch (e: any) {
|
||||
setActionError(e.message);
|
||||
} finally {
|
||||
setBusyAction(null);
|
||||
}
|
||||
};
|
||||
|
||||
// Phase 7 — refund: paid invoices only. Amount may be partial;
|
||||
// backend caps at remaining refundable.
|
||||
const refundInvoice = async () => {
|
||||
const amt = parseFloat(refundAmount);
|
||||
if (!isFinite(amt) || amt <= 0) {
|
||||
setActionError(t("refundAmountInvalid"));
|
||||
return;
|
||||
}
|
||||
if (amt - remainingRefundable > 0.005) {
|
||||
setActionError(
|
||||
t("refundAmountExceeds", {
|
||||
max: remainingRefundable.toFixed(2),
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!refundReason.trim()) {
|
||||
setActionError(t("refundReasonRequired"));
|
||||
return;
|
||||
}
|
||||
setActionError("");
|
||||
setBusyAction("refund");
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/admin/billing/invoices/${invoice.id}/refund`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
amountChf: Math.round(amt * 100) / 100,
|
||||
reason: refundReason,
|
||||
}),
|
||||
}
|
||||
);
|
||||
const j = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(j.error || `HTTP ${res.status}`);
|
||||
setRefundOpen(false);
|
||||
setRefundAmount("");
|
||||
setRefundReason("");
|
||||
router.refresh();
|
||||
} catch (e: any) {
|
||||
setActionError(e.message);
|
||||
} finally {
|
||||
setBusyAction(null);
|
||||
}
|
||||
};
|
||||
|
||||
// Group lines by tenant for display (matches PDF layout).
|
||||
const linesByTenant = new Map<string | null, typeof lines>();
|
||||
for (const ln of lines) {
|
||||
@@ -97,10 +203,14 @@ export function InvoiceDetailView({ detail }: Props) {
|
||||
</h1>
|
||||
<div className="flex items-center gap-3 mt-3 text-sm">
|
||||
<StatusPill status={invoice.status} />
|
||||
{invoice.periodStart && invoice.periodEnd && (
|
||||
<>
|
||||
<span className="text-text-muted">
|
||||
{invoice.periodStart} → {invoice.periodEnd}
|
||||
</span>
|
||||
<span className="text-text-muted">·</span>
|
||||
</>
|
||||
)}
|
||||
<span className="text-text-muted">
|
||||
{t("dueOnLabel")}: {invoice.dueAt}
|
||||
</span>
|
||||
@@ -137,7 +247,7 @@ export function InvoiceDetailView({ detail }: Props) {
|
||||
<button
|
||||
onClick={() => setNoteOpen(true)}
|
||||
disabled={busyAction !== null}
|
||||
className="px-4 py-2 rounded-md bg-accent text-white text-sm disabled:opacity-50"
|
||||
className="px-4 py-2 rounded-md bg-accent text-surface-0 text-sm disabled:opacity-50"
|
||||
>
|
||||
{t("markPaidBtn")}
|
||||
</button>
|
||||
@@ -154,7 +264,7 @@ export function InvoiceDetailView({ detail }: Props) {
|
||||
<button
|
||||
onClick={markPaid}
|
||||
disabled={busyAction !== null}
|
||||
className="px-3 py-1.5 rounded-md bg-accent text-white text-sm disabled:opacity-50"
|
||||
className="px-3 py-1.5 rounded-md bg-accent text-surface-0 text-sm disabled:opacity-50"
|
||||
>
|
||||
{busyAction === "mark-paid" ? t("saving") : t("confirm")}
|
||||
</button>
|
||||
@@ -171,6 +281,144 @@ export function InvoiceDetailView({ detail }: Props) {
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{/* Phase 7 — Void: visible only for open/overdue invoices.
|
||||
Same gating as Mark Paid but mutually exclusive with it
|
||||
via the chosen action. Opens a small inline form so
|
||||
the admin can enter a reason; reason is required and
|
||||
lands on the credit-note PDF. */}
|
||||
{(invoice.status === "open" || invoice.status === "overdue") && (
|
||||
<>
|
||||
{!voidOpen ? (
|
||||
<button
|
||||
onClick={() => {
|
||||
setVoidOpen(true);
|
||||
setNoteOpen(false);
|
||||
setRefundOpen(false);
|
||||
}}
|
||||
disabled={busyAction !== null}
|
||||
className="px-4 py-2 rounded-md border border-error text-error text-sm disabled:opacity-50 hover:bg-error/10"
|
||||
>
|
||||
{t("voidBtn")}
|
||||
</button>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 flex-grow">
|
||||
<input
|
||||
type="text"
|
||||
placeholder={t("voidReasonPlaceholder")}
|
||||
value={voidReason}
|
||||
onChange={(e) => setVoidReason(e.target.value)}
|
||||
maxLength={500}
|
||||
className="flex-grow px-3 py-1.5 rounded-md border border-border bg-surface-2 text-sm"
|
||||
autoFocus
|
||||
/>
|
||||
<button
|
||||
onClick={voidInvoice}
|
||||
disabled={busyAction !== null}
|
||||
className="px-3 py-1.5 rounded-md bg-error text-white text-sm disabled:opacity-50"
|
||||
>
|
||||
{busyAction === "void" ? t("saving") : t("confirmVoid")}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setVoidOpen(false);
|
||||
setVoidReason("");
|
||||
}}
|
||||
className="px-3 py-1.5 rounded-md border border-border text-sm"
|
||||
>
|
||||
{t("cancel")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{/* Phase 7 — Refund: paid invoices, including ones already
|
||||
partially refunded (as long as some refundable amount
|
||||
remains). Opens an inline form with amount + reason.
|
||||
The remaining-refundable hint helps admin pick the
|
||||
right number. */}
|
||||
{(invoice.status === "paid" ||
|
||||
invoice.status === "partially_refunded") &&
|
||||
remainingRefundable > 0 && (
|
||||
<>
|
||||
{!refundOpen ? (
|
||||
<button
|
||||
onClick={() => {
|
||||
setRefundOpen(true);
|
||||
setNoteOpen(false);
|
||||
setVoidOpen(false);
|
||||
setRefundAmount(remainingRefundable.toFixed(2));
|
||||
}}
|
||||
disabled={busyAction !== null}
|
||||
className="px-4 py-2 rounded-md border border-error text-error text-sm disabled:opacity-50 hover:bg-error/10"
|
||||
>
|
||||
{t("refundBtn")}
|
||||
</button>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2 flex-grow">
|
||||
<div className="text-xs text-text-muted">
|
||||
{t("refundRemainingHint", {
|
||||
max: remainingRefundable.toFixed(2),
|
||||
})}
|
||||
</div>
|
||||
<div className="flex items-center gap-4 flex-wrap">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[10px] uppercase tracking-wider text-text-muted">
|
||||
{t("refundAmountLabel")}
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0.01"
|
||||
max={remainingRefundable}
|
||||
placeholder="CHF"
|
||||
value={refundAmount}
|
||||
onChange={(e) => setRefundAmount(e.target.value)}
|
||||
className="w-32 px-3 py-1.5 rounded-md border border-border bg-surface-2 text-sm font-mono"
|
||||
autoFocus
|
||||
/>
|
||||
<span className="text-[10px] text-text-muted italic">
|
||||
{t("refundAmountInclVatHint")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 flex-grow min-w-[200px]">
|
||||
<label className="text-[10px] uppercase tracking-wider text-text-muted">
|
||||
{t("refundReasonLabel")}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder={t("refundReasonPlaceholder")}
|
||||
value={refundReason}
|
||||
onChange={(e) => setRefundReason(e.target.value)}
|
||||
maxLength={500}
|
||||
className="w-full px-3 py-1.5 rounded-md border border-border bg-surface-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 self-end">
|
||||
<button
|
||||
onClick={refundInvoice}
|
||||
disabled={busyAction !== null}
|
||||
className="px-3 py-1.5 rounded-md bg-error text-white text-sm disabled:opacity-50"
|
||||
>
|
||||
{busyAction === "refund"
|
||||
? t("saving")
|
||||
: t("confirmRefund")}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setRefundOpen(false);
|
||||
setRefundAmount("");
|
||||
setRefundReason("");
|
||||
}}
|
||||
className="px-3 py-1.5 rounded-md border border-border text-sm"
|
||||
>
|
||||
{t("cancel")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
onClick={deleteInvoice}
|
||||
disabled={busyAction !== null}
|
||||
@@ -189,11 +437,96 @@ export function InvoiceDetailView({ detail }: Props) {
|
||||
{invoice.paidMethodDetail}
|
||||
</div>
|
||||
)}
|
||||
{/* Phase 7 — void/refund summary lines, shown when applicable.
|
||||
Surfaces the auditing context that the columns alone don't
|
||||
(who voided, what the reason was, how much has been
|
||||
refunded vs how much remains). */}
|
||||
{invoice.voidedAt && (
|
||||
<div className="mt-3 text-xs text-text-muted">
|
||||
{t("voidedOnLabel")}: {invoice.voidedAt} · {invoice.voidedBy}
|
||||
{invoice.voidReason ? ` · ${invoice.voidReason}` : ""}
|
||||
</div>
|
||||
)}
|
||||
{invoice.refundedTotalChf > 0 && (
|
||||
<div className="mt-3 text-xs text-text-muted">
|
||||
{t("refundedTotalLabel")}: CHF{" "}
|
||||
{invoice.refundedTotalChf.toFixed(2)} ·{" "}
|
||||
{t("refundedRemainingLabel")}: CHF{" "}
|
||||
{remainingRefundable.toFixed(2)}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Phase 7 — linked credit notes panel. Hidden when there are
|
||||
none (most invoices). When present, lists each credit note
|
||||
with kind, amount, reason, issued date, and PDF download. */}
|
||||
{creditNotes.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>{t("creditNotesPanelTitle")}</CardHeader>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="text-xs text-text-muted text-left">
|
||||
<tr>
|
||||
<th className="pb-2 pr-4">{t("creditNoteNumberHeader")}</th>
|
||||
<th className="pb-2 pr-4">{t("creditNoteKindHeader")}</th>
|
||||
<th className="pb-2 pr-4 text-right">
|
||||
{t("creditNoteAmountHeader")}
|
||||
</th>
|
||||
<th className="pb-2 pr-4">{t("creditNoteReasonHeader")}</th>
|
||||
<th className="pb-2 pr-4">{t("creditNoteIssuedHeader")}</th>
|
||||
<th className="pb-2 text-right">{t("creditNotePdfHeader")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{creditNotes.map((cn) => (
|
||||
<tr key={cn.id} className="border-t border-border">
|
||||
<td className="py-2 pr-4 font-mono text-xs">
|
||||
{cn.creditNoteNumber}
|
||||
</td>
|
||||
<td className="py-2 pr-4">
|
||||
<span className="px-2 py-0.5 rounded text-xs text-error bg-error/10">
|
||||
{t(`creditNoteKind_${cn.kind}` as any)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-2 pr-4 text-right font-mono whitespace-nowrap">
|
||||
CHF {cn.amountChf.toFixed(2)}
|
||||
</td>
|
||||
<td className="py-2 pr-4 text-text-secondary text-xs">
|
||||
{cn.reason ?? "—"}
|
||||
</td>
|
||||
<td className="py-2 pr-4 text-xs text-text-muted whitespace-nowrap">
|
||||
{cn.issuedAt.slice(0, 10)}
|
||||
</td>
|
||||
<td className="py-2 text-right">
|
||||
{cn.hasPdf ? (
|
||||
<a
|
||||
href={`/api/credit-notes/${encodeURIComponent(
|
||||
cn.creditNoteNumber
|
||||
)}/pdf`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-accent hover:underline text-xs"
|
||||
>
|
||||
{t("downloadPdfBtn")}
|
||||
</a>
|
||||
) : (
|
||||
<span className="text-text-muted text-xs italic">
|
||||
{t("creditNoteNoPdf")}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Lines */}
|
||||
<Card>
|
||||
<CardHeader>{t("lineItemsTitle")}</CardHeader>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="text-xs text-text-muted text-left">
|
||||
<tr>
|
||||
@@ -242,6 +575,7 @@ export function InvoiceDetailView({ detail }: Props) {
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="mt-4 pt-3 border-t border-border space-y-1 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-text-muted">{t("subtotal")}</span>
|
||||
@@ -296,6 +630,8 @@ function StatusPill({ status }: { status: InvoiceStatus }) {
|
||||
? "bg-error/15 text-error"
|
||||
: status === "void" || status === "uncollectible"
|
||||
? "bg-text-muted/15 text-text-muted"
|
||||
: status === "partially_refunded" || status === "fully_refunded"
|
||||
? "bg-error/15 text-error"
|
||||
: "bg-accent/15 text-accent";
|
||||
return (
|
||||
<span
|
||||
|
||||
@@ -100,6 +100,23 @@ export function InvoicesTable({ initialInvoices }: Props) {
|
||||
{t("loading")}
|
||||
</span>
|
||||
)}
|
||||
{/* Phase 8: shortcuts to the custom-invoice flow. The
|
||||
Drafts link is muted because most of the time it's
|
||||
empty; New invoice is the prominent CTA. */}
|
||||
<div className={`flex items-center gap-3 ${busy ? "" : "ml-auto"}`}>
|
||||
<Link
|
||||
href="/admin/billing/invoice-drafts"
|
||||
className="text-xs text-text-muted hover:underline"
|
||||
>
|
||||
{t("draftsLink")}
|
||||
</Link>
|
||||
<Link
|
||||
href="/admin/billing/invoices/new"
|
||||
className="px-3 py-1.5 rounded-md bg-accent text-surface-0 text-sm"
|
||||
>
|
||||
+ {t("newInvoiceBtn")}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
@@ -109,6 +126,7 @@ export function InvoicesTable({ initialInvoices }: Props) {
|
||||
{t("noInvoicesFound")}
|
||||
</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="text-xs text-text-muted text-left">
|
||||
<tr>
|
||||
@@ -142,7 +160,11 @@ export function InvoicesTable({ initialInvoices }: Props) {
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-2 text-xs font-mono">
|
||||
{inv.periodStart.slice(0, 7)}
|
||||
{inv.periodStart
|
||||
? inv.periodStart.slice(0, 7)
|
||||
: inv.source === "custom"
|
||||
? "—"
|
||||
: ""}
|
||||
</td>
|
||||
<td className="py-2">
|
||||
<StatusPill status={inv.status} />
|
||||
@@ -157,6 +179,7 @@ export function InvoicesTable({ initialInvoices }: Props) {
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
166
src/components/admin/billing/new-invoice-form.tsx
Normal file
166
src/components/admin/billing/new-invoice-form.tsx
Normal file
@@ -0,0 +1,166 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Card } from "@/components/ui/card";
|
||||
|
||||
interface OrgEntry {
|
||||
zitadelOrgId: string;
|
||||
companyName: string | null;
|
||||
country: string | null;
|
||||
hasBillingAddress: boolean;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
orgs: OrgEntry[];
|
||||
}
|
||||
|
||||
const LOCALE_OPTIONS = [
|
||||
{ value: "de", label: "Deutsch" },
|
||||
{ value: "en", label: "English" },
|
||||
{ value: "fr", label: "Français" },
|
||||
{ value: "it", label: "Italiano" },
|
||||
];
|
||||
|
||||
/**
|
||||
* Step 1 of the custom-invoice flow: pick an org. Creating the
|
||||
* draft on the backend allocates an id we redirect to; the editor
|
||||
* page then loads the draft and lets the admin add lines.
|
||||
*
|
||||
* The dropdown shows the company name when known, falling back to
|
||||
* the raw org id. Orgs without a billing snapshot are visually
|
||||
* marked and warn the admin — they can still create the draft but
|
||||
* won't be able to issue until billing info is set.
|
||||
*
|
||||
* Default issue date = today; due date = today + 30 days. These
|
||||
* are sensible defaults the editor can override.
|
||||
*/
|
||||
export function NewInvoiceForm({ orgs }: Props) {
|
||||
const t = useTranslations("adminBilling");
|
||||
const router = useRouter();
|
||||
const [orgId, setOrgId] = useState(
|
||||
orgs.find((o) => o.hasBillingAddress)?.zitadelOrgId ??
|
||||
orgs[0]?.zitadelOrgId ??
|
||||
""
|
||||
);
|
||||
const [locale, setLocale] = useState<"de" | "en" | "fr" | "it">("de");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const selected = orgs.find((o) => o.zitadelOrgId === orgId);
|
||||
|
||||
// Pick a locale default from the org's country if admin hasn't
|
||||
// overridden — same heuristic the auto cron uses.
|
||||
const onOrgChange = (newOrgId: string) => {
|
||||
setOrgId(newOrgId);
|
||||
const o = orgs.find((x) => x.zitadelOrgId === newOrgId);
|
||||
const c = (o?.country ?? "").toUpperCase();
|
||||
if (["CH", "LI", "AT", "DE"].includes(c)) setLocale("de");
|
||||
else if (["FR", "BE", "LU"].includes(c)) setLocale("fr");
|
||||
else if (c === "IT") setLocale("it");
|
||||
else setLocale("en");
|
||||
};
|
||||
|
||||
const onSubmit = async () => {
|
||||
if (!orgId) {
|
||||
setError(t("newInvoiceOrgRequired"));
|
||||
return;
|
||||
}
|
||||
setError("");
|
||||
setBusy(true);
|
||||
try {
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const due = new Date();
|
||||
due.setDate(due.getDate() + 30);
|
||||
const dueIso = due.toISOString().slice(0, 10);
|
||||
const res = await fetch("/api/admin/billing/invoice-drafts", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
zitadelOrgId: orgId,
|
||||
payload: {
|
||||
issueDate: today,
|
||||
dueDate: dueIso,
|
||||
locale,
|
||||
paymentMethod: "invoice",
|
||||
lines: [],
|
||||
},
|
||||
}),
|
||||
});
|
||||
const j = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(j.error || `HTTP ${res.status}`);
|
||||
router.push(`/admin/billing/invoice-drafts/${j.draft.id}`);
|
||||
} catch (e: any) {
|
||||
setError(e.message);
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<div className="p-5 flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs uppercase tracking-wider text-text-muted">
|
||||
{t("newInvoiceOrgLabel")}
|
||||
</label>
|
||||
<select
|
||||
value={orgId}
|
||||
onChange={(e) => onOrgChange(e.target.value)}
|
||||
className="px-3 py-2 rounded-md border border-border bg-surface-2 text-sm"
|
||||
>
|
||||
<option value="">{t("newInvoiceOrgPlaceholder")}</option>
|
||||
{orgs.map((o) => (
|
||||
<option
|
||||
key={o.zitadelOrgId}
|
||||
value={o.zitadelOrgId}
|
||||
disabled={!o.hasBillingAddress}
|
||||
>
|
||||
{o.companyName ?? o.zitadelOrgId}
|
||||
{!o.hasBillingAddress
|
||||
? ` (${t("newInvoiceOrgNoBilling")})`
|
||||
: ""}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{selected && !selected.hasBillingAddress && (
|
||||
<p className="text-xs text-error mt-1">
|
||||
{t("newInvoiceOrgBillingMissing")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs uppercase tracking-wider text-text-muted">
|
||||
{t("newInvoiceLocaleLabel")}
|
||||
</label>
|
||||
<select
|
||||
value={locale}
|
||||
onChange={(e) =>
|
||||
setLocale(e.target.value as "de" | "en" | "fr" | "it")
|
||||
}
|
||||
className="px-3 py-2 rounded-md border border-border bg-surface-2 text-sm"
|
||||
>
|
||||
{LOCALE_OPTIONS.map((o) => (
|
||||
<option key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{error && <div className="text-sm text-error">{error}</div>}
|
||||
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<button
|
||||
onClick={onSubmit}
|
||||
disabled={busy || !orgId || !selected?.hasBillingAddress}
|
||||
className="px-4 py-2 rounded-md bg-accent text-surface-0 text-sm disabled:opacity-50"
|
||||
>
|
||||
{busy ? t("creating") : t("newInvoiceContinueBtn")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
160
src/components/admin/billing/org-payment-mode-list.tsx
Normal file
160
src/components/admin/billing/org-payment-mode-list.tsx
Normal file
@@ -0,0 +1,160 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Card } from "@/components/ui/card";
|
||||
|
||||
interface OrgEntry {
|
||||
zitadelOrgId: string;
|
||||
companyName: string | null;
|
||||
country: string | null;
|
||||
hasSavedCard: boolean;
|
||||
cardLabel: string | null;
|
||||
payByInvoice: boolean;
|
||||
autoChargeEnabled: boolean;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
orgs: OrgEntry[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Inline toggles for pay_by_invoice and auto_charge_enabled per
|
||||
* org. Each toggle round-trips to /api/admin/billing/orgs/[orgId]
|
||||
* /payment-mode and then router.refresh() so the server-fetched
|
||||
* state stays canonical (avoids drift between optimistic UI and
|
||||
* the DB).
|
||||
*
|
||||
* Phase 9b-2.
|
||||
*/
|
||||
export function OrgPaymentModeList({ orgs }: Props) {
|
||||
const t = useTranslations("adminBilling");
|
||||
const router = useRouter();
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const toggle = async (
|
||||
orgId: string,
|
||||
patch: { payByInvoice?: boolean; autoChargeEnabled?: boolean }
|
||||
) => {
|
||||
setError("");
|
||||
setBusy(orgId);
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/admin/billing/orgs/${encodeURIComponent(orgId)}/payment-mode`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(patch),
|
||||
}
|
||||
);
|
||||
const j = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(j.error || `HTTP ${res.status}`);
|
||||
router.refresh();
|
||||
} catch (e: any) {
|
||||
setError(e.message);
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
if (orgs.length === 0) {
|
||||
return (
|
||||
<Card>
|
||||
<div className="p-6 text-center text-text-secondary text-sm">
|
||||
{t("orgsEmpty")}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
{error && (
|
||||
<div className="text-sm text-error border-b border-error/30 bg-error/10 px-4 py-2">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="text-xs text-text-muted text-left">
|
||||
<tr>
|
||||
<th className="pb-2 pl-3 pr-4">{t("orgsColCustomer")}</th>
|
||||
<th className="pb-2 pr-4">{t("orgsColCard")}</th>
|
||||
<th className="pb-2 pr-4 text-center">
|
||||
{t("orgsColPayByInvoice")}
|
||||
</th>
|
||||
<th className="pb-2 pr-4 text-center">
|
||||
{t("orgsColAutoCharge")}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{orgs.map((o) => (
|
||||
<tr key={o.zitadelOrgId} className="border-t border-border">
|
||||
<td className="py-2 pl-3 pr-4">
|
||||
<div className="font-medium">
|
||||
{o.companyName ?? (
|
||||
<span className="font-mono text-xs">{o.zitadelOrgId}</span>
|
||||
)}
|
||||
</div>
|
||||
{o.country && (
|
||||
<div className="text-xs text-text-muted">{o.country}</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-2 pr-4 text-xs">
|
||||
{o.hasSavedCard ? (
|
||||
<span className="font-mono">{o.cardLabel}</span>
|
||||
) : (
|
||||
<span className="text-text-muted">
|
||||
{t("orgsNoSavedCard")}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-2 pr-4 text-center">
|
||||
<label className="inline-flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={o.payByInvoice}
|
||||
disabled={busy === o.zitadelOrgId}
|
||||
onChange={(e) =>
|
||||
toggle(o.zitadelOrgId, {
|
||||
payByInvoice: e.target.checked,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<span className="text-xs">
|
||||
{o.payByInvoice
|
||||
? t("orgsPayByInvoiceOn")
|
||||
: t("orgsPayByInvoiceOff")}
|
||||
</span>
|
||||
</label>
|
||||
</td>
|
||||
<td className="py-2 pr-4 text-center">
|
||||
<label className="inline-flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={o.autoChargeEnabled}
|
||||
disabled={busy === o.zitadelOrgId || o.payByInvoice}
|
||||
onChange={(e) =>
|
||||
toggle(o.zitadelOrgId, {
|
||||
autoChargeEnabled: e.target.checked,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<span className="text-xs">
|
||||
{o.autoChargeEnabled
|
||||
? t("orgsAutoChargeOn")
|
||||
: t("orgsAutoChargeOff")}
|
||||
</span>
|
||||
</label>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -236,7 +236,7 @@ export function PricingEditor({
|
||||
<button
|
||||
type="submit"
|
||||
disabled={savingPricing}
|
||||
className="px-4 py-2 rounded-md bg-accent text-white text-sm disabled:opacity-50"
|
||||
className="px-4 py-2 rounded-md bg-accent text-surface-0 text-sm disabled:opacity-50"
|
||||
>
|
||||
{savingPricing ? t("saving") : t("save")}
|
||||
</button>
|
||||
@@ -255,6 +255,7 @@ export function PricingEditor({
|
||||
<p className="text-sm text-text-muted mb-4">{t("skillPricingDesc")}</p>
|
||||
|
||||
{initialSkillPricing.length > 0 ? (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm mb-6">
|
||||
<thead className="text-xs text-text-muted text-left">
|
||||
<tr>
|
||||
@@ -319,6 +320,7 @@ export function PricingEditor({
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-text-muted italic mb-4">{t("noSkillsPriced")}</p>
|
||||
)}
|
||||
@@ -401,7 +403,7 @@ export function PricingEditor({
|
||||
<button
|
||||
type="submit"
|
||||
disabled={addingSkill || !newSkillId}
|
||||
className="px-4 py-2 rounded-md bg-accent text-white text-sm disabled:opacity-50"
|
||||
className="px-4 py-2 rounded-md bg-accent text-surface-0 text-sm disabled:opacity-50"
|
||||
>
|
||||
{addingSkill ? t("saving") : t("add")}
|
||||
</button>
|
||||
@@ -473,7 +475,7 @@ function InlinePriceEditor({
|
||||
}
|
||||
}}
|
||||
disabled={busy}
|
||||
className="text-xs px-2 py-1 bg-accent text-white rounded"
|
||||
className="text-xs px-2 py-1 bg-accent text-surface-0 rounded"
|
||||
>
|
||||
{busy ? "…" : "✓"}
|
||||
</button>
|
||||
|
||||
251
src/components/admin/cron/cron-controls.tsx
Normal file
251
src/components/admin/cron/cron-controls.tsx
Normal file
@@ -0,0 +1,251 @@
|
||||
"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-surface-0 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-surface-0 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>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<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>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -107,7 +107,7 @@ export function OpenClawAdminPanel({ initialDefaults, tenants }: Props) {
|
||||
<button
|
||||
type="submit"
|
||||
disabled={savingDefault}
|
||||
className="text-sm font-medium px-4 py-2 rounded-lg bg-accent text-white hover:bg-accent/90 transition-colors disabled:opacity-50"
|
||||
className="text-sm font-medium px-4 py-2 rounded-lg bg-accent text-surface-0 hover:bg-accent/90 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{savingDefault ? tCommon("loading") : t("saveDefault")}
|
||||
</button>
|
||||
@@ -265,7 +265,7 @@ function TenantOverrideRow({
|
||||
type="button"
|
||||
onClick={() => submit(false)}
|
||||
disabled={saving || !tag.trim()}
|
||||
className="text-xs px-3 py-1.5 rounded-lg bg-accent text-white hover:bg-accent/90 transition-colors disabled:opacity-50"
|
||||
className="text-xs px-3 py-1.5 rounded-lg bg-accent text-surface-0 hover:bg-accent/90 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{saving ? tCommon("loading") : t("saveOverride")}
|
||||
</button>
|
||||
|
||||
@@ -99,6 +99,7 @@ export function PendingSkillRequests({ initialRows }: Props) {
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="text-xs text-text-muted text-left">
|
||||
<tr>
|
||||
@@ -146,7 +147,7 @@ export function PendingSkillRequests({ initialRows }: Props) {
|
||||
<button
|
||||
onClick={() => approve(row.id)}
|
||||
disabled={busyId !== null}
|
||||
className="text-xs px-3 py-1.5 rounded-md bg-accent text-white disabled:opacity-50"
|
||||
className="text-xs px-3 py-1.5 rounded-md bg-accent text-surface-0 disabled:opacity-50"
|
||||
>
|
||||
{busyId === row.id ? t("working") : t("approveBtn")}
|
||||
</button>
|
||||
@@ -199,6 +200,7 @@ export function PendingSkillRequests({ initialRows }: Props) {
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
103
src/components/billing/customer-credit-note-list.tsx
Normal file
103
src/components/billing/customer-credit-note-list.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
import { useTranslations, useFormatter } from "next-intl";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import type { CreditNote } from "@/types";
|
||||
|
||||
interface Props {
|
||||
creditNotes: CreditNote[];
|
||||
}
|
||||
|
||||
const kindColors: Record<string, string> = {
|
||||
// Voids = the invoice was cancelled; gentle red.
|
||||
void: "text-error bg-error/10",
|
||||
// Refunds = money returned; also red but slightly differentiated.
|
||||
refund: "text-error bg-error/10",
|
||||
};
|
||||
|
||||
/**
|
||||
* Phase 7 — customer's credit-note history below the invoice list.
|
||||
*
|
||||
* Hidden entirely when the org has zero credit notes (most orgs in
|
||||
* normal operation). When present, each row shows the credit-note
|
||||
* number, the invoice it relates to, kind (void / refund), amount,
|
||||
* and a download link to the PDF.
|
||||
*
|
||||
* No detail page — clicking the PDF link opens the document inline
|
||||
* (browser PDF viewer), which IS the credit-note detail view. A
|
||||
* separate per-credit-note web page would duplicate what's in the
|
||||
* PDF and add no value.
|
||||
*/
|
||||
export function CustomerCreditNoteList({ creditNotes }: Props) {
|
||||
const t = useTranslations("customerBilling");
|
||||
const fmt = useFormatter();
|
||||
|
||||
if (creditNotes.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="text-xs text-text-muted text-left">
|
||||
<tr>
|
||||
<th className="pb-2 pr-4">{t("creditNoteNumberCol")}</th>
|
||||
<th className="pb-2 pr-4">{t("creditNoteInvoiceCol")}</th>
|
||||
<th className="pb-2 pr-4">{t("creditNoteIssuedCol")}</th>
|
||||
<th className="pb-2 pr-4 text-right">{t("creditNoteAmountCol")}</th>
|
||||
<th className="pb-2 pr-4 text-right">{t("creditNoteKindCol")}</th>
|
||||
<th className="pb-2 text-right">{t("creditNotePdfCol")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{creditNotes.map((cn) => (
|
||||
<tr
|
||||
key={cn.id}
|
||||
className="border-t border-border align-middle"
|
||||
>
|
||||
<td className="py-2 pr-4 font-mono text-xs">
|
||||
{cn.creditNoteNumber}
|
||||
</td>
|
||||
<td className="py-2 pr-4 font-mono text-xs text-text-secondary">
|
||||
{cn.invoiceNumber}
|
||||
</td>
|
||||
<td className="py-2 pr-4 text-text-secondary whitespace-nowrap">
|
||||
{fmt.dateTime(new Date(cn.issuedAt), { dateStyle: "medium" })}
|
||||
</td>
|
||||
<td className="py-2 pr-4 text-right font-mono whitespace-nowrap">
|
||||
CHF {cn.amountChf.toFixed(2)}
|
||||
</td>
|
||||
<td className="py-2 pr-4 text-right">
|
||||
<span
|
||||
className={`px-2 py-0.5 rounded text-xs ${
|
||||
kindColors[cn.kind] ?? ""
|
||||
}`}
|
||||
>
|
||||
{t(`creditNoteKind_${cn.kind}` as any)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-2 text-right">
|
||||
{cn.hasPdf ? (
|
||||
<a
|
||||
href={`/api/credit-notes/${encodeURIComponent(
|
||||
cn.creditNoteNumber
|
||||
)}/pdf`}
|
||||
className="text-accent hover:underline text-xs"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{t("downloadPdf")}
|
||||
</a>
|
||||
) : (
|
||||
<span className="text-text-muted text-xs italic">
|
||||
{t("creditNoteNoPdf")}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
168
src/components/billing/customer-invoice-detail.tsx
Normal file
168
src/components/billing/customer-invoice-detail.tsx
Normal file
@@ -0,0 +1,168 @@
|
||||
import { useTranslations, useFormatter } from "next-intl";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import type { Invoice, InvoiceLine } from "@/types";
|
||||
import { PayInvoiceButton } from "./pay-invoice-button";
|
||||
import { PaymentStatusBanner } from "./payment-status-banner";
|
||||
|
||||
interface Props {
|
||||
invoice: Invoice;
|
||||
lines: InvoiceLine[];
|
||||
}
|
||||
|
||||
const statusColors: Record<string, string> = {
|
||||
open: "text-text-secondary bg-surface-3",
|
||||
paid: "text-success bg-success/10",
|
||||
overdue: "text-error bg-error/10",
|
||||
void: "text-text-muted bg-surface-3",
|
||||
};
|
||||
|
||||
/**
|
||||
* Read-only invoice detail. Flat list of lines — no per-tenant
|
||||
* grouping (one invoice per customer; the tenant context is
|
||||
* already embedded in each line description).
|
||||
*
|
||||
* The download link points at /api/billing/invoices/[n]/pdf
|
||||
* which serves the stored PDF blob inline. Customers using a
|
||||
* link from their email will hit the same route via this page.
|
||||
*/
|
||||
export function CustomerInvoiceDetail({ invoice, lines }: Props) {
|
||||
const t = useTranslations("customerBilling");
|
||||
const fmt = useFormatter();
|
||||
|
||||
return (
|
||||
<div className="space-y-6 animate-in">
|
||||
<PaymentStatusBanner />
|
||||
<div className="flex items-start justify-between gap-4 flex-wrap">
|
||||
<div>
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<h1 className="font-display text-2xl font-semibold">
|
||||
{invoice.invoiceNumber}
|
||||
</h1>
|
||||
<span
|
||||
className={`text-[10px] uppercase tracking-wider px-2 py-1 rounded-md font-semibold ${
|
||||
statusColors[invoice.status] ?? "text-text-muted bg-surface-3"
|
||||
}`}
|
||||
>
|
||||
{t(`status.${invoice.status}` as any)}
|
||||
</span>
|
||||
</div>
|
||||
{invoice.periodStart && invoice.periodEnd && (
|
||||
<p className="text-sm text-text-secondary">
|
||||
{fmt.dateTime(new Date(invoice.periodStart), {
|
||||
dateStyle: "long",
|
||||
})}
|
||||
<span className="text-text-muted mx-1">→</span>
|
||||
{fmt.dateTime(new Date(invoice.periodEnd), {
|
||||
dateStyle: "long",
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-start gap-2 flex-wrap">
|
||||
{/* Phase 4: Pay-with-card available for open + overdue.
|
||||
Paid/void/draft/uncollectible hide the button — the
|
||||
API also enforces this, so client-side hiding is just
|
||||
for the visible affordance. */}
|
||||
{(invoice.status === "open" || invoice.status === "overdue") && (
|
||||
<PayInvoiceButton invoiceNumber={invoice.invoiceNumber} />
|
||||
)}
|
||||
<a
|
||||
href={`/api/billing/invoices/${encodeURIComponent(invoice.invoiceNumber)}/pdf`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="px-4 py-2 rounded-md bg-surface-3 hover:bg-surface-2 border border-border text-sm font-medium transition-colors"
|
||||
>
|
||||
{t("downloadPdf")}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<div className="space-y-2 mb-4">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-text-muted">{t("billedToLabel")}</span>
|
||||
<span>{invoice.billingSnapshot.companyName}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-text-muted">{t("issuedAtLabel")}</span>
|
||||
<span>
|
||||
{fmt.dateTime(new Date(invoice.issuedAt), { dateStyle: "medium" })}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-text-muted">{t("dueAtLabel")}</span>
|
||||
<span>
|
||||
{fmt.dateTime(new Date(invoice.dueAt), { dateStyle: "medium" })}
|
||||
</span>
|
||||
</div>
|
||||
{invoice.status === "paid" && invoice.paidAt && (
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-text-muted">{t("paidAtLabel")}</span>
|
||||
<span>
|
||||
{fmt.dateTime(new Date(invoice.paidAt), { dateStyle: "medium" })}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="text-xs text-text-muted text-left">
|
||||
<tr>
|
||||
<th className="pb-2">{t("descriptionCol")}</th>
|
||||
<th className="pb-2 text-right">{t("qtyCol")}</th>
|
||||
<th className="pb-2 text-right">{t("unitCol")}</th>
|
||||
<th className="pb-2 text-right">{t("amountCol")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{lines.map((ln) => (
|
||||
<tr key={ln.id} className="border-t border-border align-top">
|
||||
<td className="py-2">{ln.description}</td>
|
||||
<td className="py-2 text-right font-mono text-xs">
|
||||
{ln.quantity}
|
||||
{ln.unitLabel ? ` ${ln.unitLabel}` : ""}
|
||||
</td>
|
||||
<td className="py-2 text-right font-mono text-xs">
|
||||
{ln.unitPriceChf.toFixed(2)}
|
||||
</td>
|
||||
<td className="py-2 text-right font-mono">
|
||||
{ln.amountChf.toFixed(2)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr className="border-t border-border">
|
||||
<td colSpan={3} className="pt-3 text-right text-text-muted">
|
||||
{t("subtotalLabel")}
|
||||
</td>
|
||||
<td className="pt-3 text-right font-mono">
|
||||
{invoice.subtotalChf.toFixed(2)}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colSpan={3} className="pt-1 text-right text-text-muted">
|
||||
{t("vatLabel", { rate: invoice.vatRate.toFixed(2) })}
|
||||
</td>
|
||||
<td className="pt-1 text-right font-mono">
|
||||
{invoice.vatAmountChf.toFixed(2)}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colSpan={3} className="pt-2 text-right font-semibold">
|
||||
{t("totalLabel")}
|
||||
</td>
|
||||
<td className="pt-2 text-right font-mono font-semibold text-base">
|
||||
CHF {invoice.totalChf.toFixed(2)}
|
||||
</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
111
src/components/billing/customer-invoice-list.tsx
Normal file
111
src/components/billing/customer-invoice-list.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
import { useTranslations, useFormatter } from "next-intl";
|
||||
import { Link } from "@/i18n/navigation";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import type { Invoice } from "@/types";
|
||||
|
||||
interface Props {
|
||||
invoices: Invoice[];
|
||||
}
|
||||
|
||||
const statusColors: Record<string, string> = {
|
||||
open: "text-text-secondary bg-surface-3",
|
||||
paid: "text-success bg-success/10",
|
||||
overdue: "text-error bg-error/10",
|
||||
void: "text-text-muted bg-surface-3 line-through",
|
||||
// Phase 7: refund states. Red tinting matches the credit-note
|
||||
// PDF accent so customers reading the table get a visual cue
|
||||
// that something was credited back. partially_refunded reads
|
||||
// as a partial state (mixed colour), fully_refunded reads as
|
||||
// closed (line-through like void).
|
||||
partially_refunded: "text-error bg-error/10",
|
||||
fully_refunded: "text-text-muted bg-error/10 line-through",
|
||||
};
|
||||
|
||||
/**
|
||||
* Customer's invoice history table. Server component — gets a
|
||||
* pre-fetched Invoice[] from /billing/page.tsx. Each row links
|
||||
* to /billing/<invoice-number> for the full detail view.
|
||||
*
|
||||
* Columns: number, period, due date, total, status. Status is
|
||||
* displayed with a colored badge so the customer can scan for
|
||||
* outstanding ones at a glance.
|
||||
*/
|
||||
export function CustomerInvoiceList({ invoices }: Props) {
|
||||
const t = useTranslations("customerBilling");
|
||||
const fmt = useFormatter();
|
||||
|
||||
if (invoices.length === 0) {
|
||||
return (
|
||||
<Card>
|
||||
<p className="text-sm text-text-muted italic text-center py-8">
|
||||
{t("emptyHistory")}
|
||||
</p>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="text-xs text-text-muted text-left">
|
||||
<tr>
|
||||
<th className="pb-2">{t("numberCol")}</th>
|
||||
<th className="pb-2">{t("periodCol")}</th>
|
||||
<th className="pb-2">{t("dueCol")}</th>
|
||||
<th className="pb-2 text-right">{t("totalCol")}</th>
|
||||
<th className="pb-2 text-right">{t("statusCol")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{invoices.map((inv) => (
|
||||
<tr
|
||||
key={inv.id}
|
||||
className="border-t border-border hover:bg-surface-2 transition-colors"
|
||||
>
|
||||
<td className="py-2">
|
||||
<Link
|
||||
href={`/billing/${inv.invoiceNumber}`}
|
||||
className="font-mono text-xs text-accent hover:underline"
|
||||
>
|
||||
{inv.invoiceNumber}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="py-2 text-xs text-text-secondary">
|
||||
{inv.periodStart && inv.periodEnd ? (
|
||||
<>
|
||||
{fmt.dateTime(new Date(inv.periodStart), {
|
||||
dateStyle: "medium",
|
||||
})}
|
||||
<span className="text-text-muted mx-1">→</span>
|
||||
{fmt.dateTime(new Date(inv.periodEnd), {
|
||||
dateStyle: "medium",
|
||||
})}
|
||||
</>
|
||||
) : (
|
||||
<span className="text-text-muted">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-2 text-xs text-text-secondary">
|
||||
{fmt.dateTime(new Date(inv.dueAt), { dateStyle: "medium" })}
|
||||
</td>
|
||||
<td className="py-2 text-right font-mono">
|
||||
CHF {inv.totalChf.toFixed(2)}
|
||||
</td>
|
||||
<td className="py-2 text-right">
|
||||
<span
|
||||
className={`text-[10px] uppercase tracking-wider px-2 py-1 rounded-md font-semibold ${
|
||||
statusColors[inv.status] ?? "text-text-muted bg-surface-3"
|
||||
}`}
|
||||
>
|
||||
{t(`status.${inv.status}` as any)}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
64
src/components/billing/pay-invoice-button.tsx
Normal file
64
src/components/billing/pay-invoice-button.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
interface Props {
|
||||
invoiceNumber: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pay-with-card button. Posts to /api/billing/invoices/[n]/pay,
|
||||
* which returns a Stripe Checkout Session URL; we redirect the
|
||||
* browser there.
|
||||
*
|
||||
* The button is rendered only by the parent for status='open' or
|
||||
* 'overdue' invoices — the API enforces this too, but pre-filtering
|
||||
* UI-side keeps the dead state out of the customer's face.
|
||||
*/
|
||||
export function PayInvoiceButton({ invoiceNumber }: Props) {
|
||||
const t = useTranslations("customerBilling");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const onClick = async () => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/billing/invoices/${encodeURIComponent(invoiceNumber)}/pay`,
|
||||
{ method: "POST" }
|
||||
);
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
throw new Error(data.error ?? `HTTP ${res.status}`);
|
||||
}
|
||||
if (!data.url) {
|
||||
throw new Error("Payment session URL missing from response.");
|
||||
}
|
||||
// Hard navigation, not Next.js router — Stripe Checkout is a
|
||||
// separate origin and the browser needs to fully leave our app.
|
||||
window.location.href = data.url;
|
||||
} catch (e: any) {
|
||||
setError(e?.message ?? String(e));
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-end gap-1">
|
||||
<button
|
||||
onClick={onClick}
|
||||
disabled={busy}
|
||||
className="px-4 py-2 rounded-md bg-accent text-surface-0 text-sm font-medium hover:bg-accent-dim transition-colors disabled:opacity-50 cursor-pointer"
|
||||
>
|
||||
{busy ? t("redirectingToStripe") : t("payWithCard")}
|
||||
</button>
|
||||
{error && (
|
||||
<span className="text-xs text-error max-w-[260px] text-right">
|
||||
{error}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
67
src/components/billing/payment-status-banner.tsx
Normal file
67
src/components/billing/payment-status-banner.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
/**
|
||||
* Banner shown after a return from Stripe Checkout.
|
||||
*
|
||||
* ?paid=1 → green success banner. The webhook may or may
|
||||
* not have processed yet, so we phrase the message
|
||||
* as "Payment received, status will update shortly"
|
||||
* and don't claim the status is already paid. A
|
||||
* light auto-refresh after a few seconds nudges
|
||||
* the page to pick up the new status badge.
|
||||
*
|
||||
* ?cancelled=1 → neutral grey banner: "Payment cancelled". The
|
||||
* invoice stays in 'open' state.
|
||||
*
|
||||
* The banner cleans up the query params from the URL so a page
|
||||
* reload doesn't repeat the message. We use router.replace() to
|
||||
* keep history clean.
|
||||
*/
|
||||
export function PaymentStatusBanner() {
|
||||
const router = useRouter();
|
||||
const t = useTranslations("customerBilling");
|
||||
const [state, setState] = useState<"paid" | "cancelled" | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
if (params.has("paid")) {
|
||||
setState("paid");
|
||||
// The webhook usually arrives before the browser redirect
|
||||
// completes, so the page often renders with status='paid'
|
||||
// on first load and this refresh is a no-op. In the rare
|
||||
// case where it arrives slightly after, a short refresh
|
||||
// picks up the status flip. 1.5s is comfortable for both.
|
||||
const timer = setTimeout(() => {
|
||||
router.refresh();
|
||||
}, 1500);
|
||||
// Strip the query string out of the URL.
|
||||
const cleanUrl = window.location.pathname;
|
||||
window.history.replaceState({}, "", cleanUrl);
|
||||
return () => clearTimeout(timer);
|
||||
} else if (params.has("cancelled")) {
|
||||
setState("cancelled");
|
||||
const cleanUrl = window.location.pathname;
|
||||
window.history.replaceState({}, "", cleanUrl);
|
||||
}
|
||||
}, [router]);
|
||||
|
||||
if (state === "paid") {
|
||||
return (
|
||||
<div className="mb-4 p-3 rounded-md border border-success bg-success/10 text-sm text-success">
|
||||
{t("paymentReceived")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (state === "cancelled") {
|
||||
return (
|
||||
<div className="mb-4 p-3 rounded-md border border-border bg-surface-2 text-sm text-text-secondary">
|
||||
{t("paymentCancelled")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
198
src/components/billing/running-total-widget.tsx
Normal file
198
src/components/billing/running-total-widget.tsx
Normal file
@@ -0,0 +1,198 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslations, useFormatter } from "next-intl";
|
||||
import { Link } from "@/i18n/navigation";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import type { Invoice, InvoiceDraft } from "@/types";
|
||||
|
||||
type CurrentResponse =
|
||||
| { issued: Invoice }
|
||||
| { draft: InvoiceDraft }
|
||||
| { error: string; code?: string };
|
||||
|
||||
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.
|
||||
*
|
||||
* Loads /api/billing/current on mount. Three result shapes:
|
||||
*
|
||||
* - { issued } — current-month invoice already exists; we
|
||||
* link to it instead of showing a draft total.
|
||||
* - { draft } — still accruing; show subtotal+VAT+total and
|
||||
* a small line breakdown.
|
||||
* - { error } — most likely the org has no billing config
|
||||
* yet; show a friendly hint, not a stack trace.
|
||||
*
|
||||
* Client-side because the compute can take a second or two
|
||||
* (LiteLLM + Threema HTTP calls) and we want a loading spinner.
|
||||
* No polling — the page is static enough that an explicit
|
||||
* "refresh" link is good enough if the user wants newer numbers.
|
||||
*/
|
||||
export function RunningTotalWidget({ isOwner }: Props) {
|
||||
const t = useTranslations("customerBilling");
|
||||
const fmt = useFormatter();
|
||||
const [data, setData] = useState<CurrentResponse | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [refreshCounter, setRefreshCounter] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
fetch("/api/billing/current")
|
||||
.then(async (res) => {
|
||||
const j = (await res.json()) as CurrentResponse;
|
||||
if (!cancelled) setData(j);
|
||||
})
|
||||
.catch((e) => {
|
||||
if (!cancelled) setData({ error: String(e), code: "FETCH_FAILED" });
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [refreshCounter]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card>
|
||||
<p className="text-sm text-text-muted italic py-4">{t("computing")}</p>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
if (!data || "error" in data) {
|
||||
const noConfig =
|
||||
data && "code" in data && data.code === "COMPUTE_FAILED";
|
||||
return (
|
||||
<Card>
|
||||
<p className="text-sm text-text-secondary py-2">
|
||||
{noConfig ? t("noBillingConfig") : t("currentPeriodError")}
|
||||
</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-surface-0 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>
|
||||
);
|
||||
}
|
||||
if ("issued" in data) {
|
||||
const inv = data.issued;
|
||||
return (
|
||||
<Card>
|
||||
<div className="flex items-start justify-between gap-4 flex-wrap">
|
||||
<div>
|
||||
<p className="text-xs text-text-muted">{t("currentInvoiceIssued")}</p>
|
||||
<Link
|
||||
href={`/billing/${inv.invoiceNumber}`}
|
||||
className="font-mono text-sm text-accent hover:underline"
|
||||
>
|
||||
{inv.invoiceNumber}
|
||||
</Link>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-xs text-text-muted">{t("totalLabel")}</p>
|
||||
<p className="font-mono text-lg font-semibold">
|
||||
CHF {inv.totalChf.toFixed(2)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
// draft
|
||||
const draft = data.draft;
|
||||
// Phase 8: InvoiceDraft.periodStart/End became nullable for the
|
||||
// custom-invoice flow. The running-total widget only renders the
|
||||
// auto-cron draft (always has a period), so the null branch is
|
||||
// defensive — if we ever did hit it the label just collapses.
|
||||
const periodLabel =
|
||||
draft.periodStart && draft.periodEnd
|
||||
? `${fmt.dateTime(new Date(draft.periodStart), {
|
||||
dateStyle: "long",
|
||||
})} → ${fmt.dateTime(new Date(draft.periodEnd), { dateStyle: "long" })}`
|
||||
: "";
|
||||
return (
|
||||
<Card>
|
||||
<div className="flex items-start justify-between gap-4 flex-wrap mb-3">
|
||||
<div>
|
||||
<p className="text-xs text-text-muted">{t("accruedSoFar")}</p>
|
||||
<p className="text-xs text-text-secondary">{periodLabel}</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-xs text-text-muted">{t("estimatedTotal")}</p>
|
||||
<p className="font-mono text-2xl font-semibold text-accent">
|
||||
CHF {draft.totalChf.toFixed(2)}
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setRefreshCounter((n) => n + 1)}
|
||||
className="text-[10px] text-text-muted hover:text-text-secondary underline mt-1 cursor-pointer"
|
||||
>
|
||||
{t("refresh")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{draft.lines.length > 0 && (
|
||||
<details className="text-xs">
|
||||
<summary className="cursor-pointer text-text-muted hover:text-text-secondary">
|
||||
{t("breakdownToggle", { count: draft.lines.length })}
|
||||
</summary>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full mt-2 text-xs">
|
||||
<tbody>
|
||||
{draft.lines.map((ln, i) => (
|
||||
<tr key={i} className="border-t border-border">
|
||||
<td className="py-1 pr-2">{ln.description}</td>
|
||||
<td className="py-1 text-right font-mono">
|
||||
{ln.amountChf.toFixed(2)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
<tr className="border-t border-border">
|
||||
<td className="py-1 pr-2 text-text-muted text-right">
|
||||
{t("subtotalLabel")}
|
||||
</td>
|
||||
<td className="py-1 text-right font-mono">
|
||||
{draft.subtotalChf.toFixed(2)}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="py-1 pr-2 text-text-muted text-right">
|
||||
{t("vatLabel", { rate: draft.vatRate.toFixed(2) })}
|
||||
</td>
|
||||
<td className="py-1 text-right font-mono">
|
||||
{draft.vatAmountChf.toFixed(2)}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
<p className="text-[10px] text-text-muted mt-3 italic">{t("draftNote")}</p>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -328,7 +328,7 @@ export function ChannelUsers({
|
||||
<button
|
||||
onClick={() => handleAdd(channel)}
|
||||
disabled={saving || !inputValues[channel]?.trim()}
|
||||
className="px-4 py-2 text-sm font-medium bg-accent text-white rounded-lg hover:bg-accent-dim transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
className="px-4 py-2 text-sm font-medium bg-accent text-surface-0 rounded-lg hover:bg-accent-dim transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{saving ? "…" : t("add")}
|
||||
</button>
|
||||
|
||||
@@ -263,7 +263,7 @@ export function BudgetEditableCard({
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving}
|
||||
className="text-sm px-4 py-2 rounded-lg bg-accent text-white hover:bg-accent/90 transition-colors disabled:opacity-50"
|
||||
className="text-sm px-4 py-2 rounded-lg bg-accent text-surface-0 hover:bg-accent/90 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{saving ? tCommon("loading") : tCommon("save")}
|
||||
</button>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useTranslations, useLocale } from "next-intl";
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { BudgetEditableCard } from "@/components/dashboard/budget-editable-card";
|
||||
|
||||
@@ -84,42 +84,149 @@ function formatMonth(month: string, locale: string): string {
|
||||
}
|
||||
|
||||
function UsageChart({ data }: { data: DailyUsage[] }) {
|
||||
const t = useTranslations("usage");
|
||||
const locale = useLocale();
|
||||
// Which day's detail is shown in the readout. Defaults to the most
|
||||
// recent day; hover (mouse), tap (touch) or focus (keyboard) all
|
||||
// update it. The previous version put per-day numbers only in SVG
|
||||
// <title> hover tooltips, which are unreachable on touch devices and
|
||||
// invisible to keyboard users — this readout fixes both.
|
||||
const [selected, setSelected] = useState<number | null>(null);
|
||||
|
||||
if (!data.length) return null;
|
||||
const maxTokens = Math.max(...data.map((d) => d.inputTokens + d.outputTokens), 1);
|
||||
|
||||
const maxTokens = Math.max(
|
||||
...data.map((d) => d.inputTokens + d.outputTokens),
|
||||
1
|
||||
);
|
||||
const barW = Math.max(4, Math.floor(600 / data.length) - 2);
|
||||
const h = 120;
|
||||
|
||||
const activeIndex = selected ?? data.length - 1;
|
||||
const active = data[activeIndex];
|
||||
|
||||
const dayLabel = (iso: string) => {
|
||||
const [y, m, dd] = iso.split("-").map(Number);
|
||||
return new Date(y, m - 1, dd).toLocaleDateString(locale, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
};
|
||||
|
||||
const barAria = (d: DailyUsage) =>
|
||||
`${dayLabel(d.date)}: ${fmt(d.inputTokens)} ${t("inputTokens")}, ${fmt(
|
||||
d.outputTokens
|
||||
)} ${t("outputTokens")}, ${chf(d.spend)}`;
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Readout — the touch/keyboard-accessible equivalent of the old
|
||||
hover-only tooltip. Always reflects the active day. */}
|
||||
<div className="flex flex-wrap items-baseline gap-x-3 gap-y-1 mb-2 text-xs">
|
||||
<span className="font-medium text-text-primary">
|
||||
{dayLabel(active.date)}
|
||||
</span>
|
||||
<span className="text-text-secondary tabular-nums">
|
||||
{fmt(active.inputTokens)} {t("inputTokens")}
|
||||
</span>
|
||||
<span className="text-text-secondary tabular-nums">
|
||||
{fmt(active.outputTokens)} {t("outputTokens")}
|
||||
</span>
|
||||
<span className="text-accent tabular-nums">{chf(active.spend)}</span>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<svg
|
||||
viewBox={`0 0 ${Math.max(data.length * (barW + 2), 600)} ${h + 24}`}
|
||||
className="w-full h-36"
|
||||
preserveAspectRatio="xMinYMid meet"
|
||||
role="group"
|
||||
aria-label={t("dailyBreakdown")}
|
||||
>
|
||||
{data.map((d, i) => {
|
||||
const total = d.inputTokens + d.outputTokens;
|
||||
const totalH = (total / maxTokens) * h;
|
||||
const inputH = (d.inputTokens / maxTokens) * h;
|
||||
const x = i * (barW + 2);
|
||||
const isActive = i === activeIndex;
|
||||
return (
|
||||
<g key={d.date}>
|
||||
<title>{d.date}: {fmt(d.inputTokens)} in / {fmt(d.outputTokens)} out — {chf(d.spend)}</title>
|
||||
<rect x={x} y={h - totalH} width={barW} height={totalH - inputH} rx={1} fill="var(--color-accent)" opacity={0.3} />
|
||||
<rect x={x} y={h - inputH} width={barW} height={inputH} rx={1} fill="var(--color-accent)" opacity={0.7} />
|
||||
<g
|
||||
key={d.date}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={barAria(d)}
|
||||
aria-pressed={isActive}
|
||||
className="cursor-pointer focus:outline-none"
|
||||
onClick={() => setSelected(i)}
|
||||
onMouseEnter={() => setSelected(i)}
|
||||
onFocus={() => setSelected(i)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
setSelected(i);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<title>{barAria(d)}</title>
|
||||
{/* Full-height transparent hit area so thin bars stay
|
||||
easy to tap on touch screens. */}
|
||||
<rect x={x} y={0} width={barW} height={h} fill="transparent" />
|
||||
<rect
|
||||
x={x}
|
||||
y={h - totalH}
|
||||
width={barW}
|
||||
height={Math.max(0, totalH - inputH)}
|
||||
rx={1}
|
||||
fill="var(--color-accent)"
|
||||
opacity={isActive ? 0.5 : 0.3}
|
||||
/>
|
||||
<rect
|
||||
x={x}
|
||||
y={h - inputH}
|
||||
width={barW}
|
||||
height={inputH}
|
||||
rx={1}
|
||||
fill="var(--color-accent)"
|
||||
opacity={isActive ? 1 : 0.7}
|
||||
/>
|
||||
{isActive && (
|
||||
<rect
|
||||
x={x - 1}
|
||||
y={Math.max(0, h - totalH) - 1}
|
||||
width={barW + 2}
|
||||
height={Math.max(2, totalH) + 1}
|
||||
rx={1.5}
|
||||
fill="none"
|
||||
stroke="var(--color-accent)"
|
||||
strokeWidth={1}
|
||||
/>
|
||||
)}
|
||||
{i % 7 === 0 && (
|
||||
<text x={x + barW / 2} y={h + 14} textAnchor="middle" fill="var(--color-text-muted)" fontSize="8">{d.date.slice(8)}</text>
|
||||
<text
|
||||
x={x + barW / 2}
|
||||
y={h + 14}
|
||||
textAnchor="middle"
|
||||
fill="var(--color-text-muted)"
|
||||
fontSize="8"
|
||||
>
|
||||
{d.date.slice(8)}
|
||||
</text>
|
||||
)}
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-xs text-text-muted mt-1">
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="inline-block h-2 w-2 rounded-sm bg-accent opacity-70" /> Input
|
||||
<span className="inline-block h-2 w-2 rounded-sm bg-accent opacity-70" />{" "}
|
||||
{t("legendInput")}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="inline-block h-2 w-2 rounded-sm bg-accent opacity-30" /> Output
|
||||
<span className="inline-block h-2 w-2 rounded-sm bg-accent opacity-30" />{" "}
|
||||
{t("legendOutput")}
|
||||
</span>
|
||||
<span className="ml-auto text-text-muted/70">{t("chartHint")}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -161,6 +268,7 @@ export function UsageDisplay({
|
||||
canEditBudget?: boolean;
|
||||
}) {
|
||||
const t = useTranslations("usage");
|
||||
const locale = useLocale();
|
||||
const [month, setMonth] = useState(getCurrentMonth);
|
||||
const [data, setData] = useState<UsageData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -202,7 +310,7 @@ export function UsageDisplay({
|
||||
←
|
||||
</button>
|
||||
<span className="font-display text-sm font-medium text-text-primary">
|
||||
{formatMonth(month, "en")}
|
||||
{formatMonth(month, locale)}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setMonth((m) => shiftMonth(m, 1))}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { signOut, useSession } from "next-auth/react";
|
||||
import { usePathname } from "@/i18n/navigation";
|
||||
import { Link } from "@/i18n/navigation";
|
||||
import { SessionProvider } from "next-auth/react";
|
||||
import type { Session } from "next-auth";
|
||||
import { LanguageSwitcher } from "@/components/ui/language-switcher";
|
||||
|
||||
function NavBar() {
|
||||
@@ -13,6 +15,15 @@ function NavBar() {
|
||||
const pathname = usePathname();
|
||||
const user = (session as any)?.platformUser;
|
||||
|
||||
const [mobileOpen, setMobileOpen] = useState(false);
|
||||
|
||||
// Close the mobile menu on any navigation. Without this the panel
|
||||
// would stay open across route changes (the component doesn't
|
||||
// unmount — it lives in the layout).
|
||||
useEffect(() => {
|
||||
setMobileOpen(false);
|
||||
}, [pathname]);
|
||||
|
||||
// Hide the nav entirely on auth-only routes. These pages have no
|
||||
// session yet — showing "Dashboard" / "Sign Out" is misleading at
|
||||
// best (the buttons would 401 or redirect-loop). Keep this list
|
||||
@@ -21,6 +32,47 @@ function NavBar() {
|
||||
const isAuthRoute = pathname === "/login" || pathname === "/register";
|
||||
if (isAuthRoute) return null;
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Visibility gates — computed once, shared by the desktop nav and the
|
||||
// mobile panel so the two can never diverge.
|
||||
//
|
||||
// - team: owner+platform only AND not a personal account (Bug 8 —
|
||||
// personal accounts have no team). Matches `canMutate` /
|
||||
// `user.isPersonal === false` server-side.
|
||||
// - settings: anyone who can mutate org-level state (owners + platform).
|
||||
// `user`-role customers don't see it (canMutate is false).
|
||||
// - billing / support: any signed-in user (org-scoped server-side).
|
||||
// - admin: platform only.
|
||||
// ------------------------------------------------------------------
|
||||
const isOwner =
|
||||
user && Array.isArray(user.roles) && user.roles.includes("owner");
|
||||
const showTeam = !!user && !user.isPersonal && (user.isPlatform || isOwner);
|
||||
const showSettings = !!user && (user.isPlatform || isOwner);
|
||||
const showBilling = !!user;
|
||||
const showSupport = !!user;
|
||||
const showAdmin = !!user?.isPlatform;
|
||||
|
||||
// Active-state helper. Dashboard/Admin previously used exact `===`,
|
||||
// so sub-routes (/dashboard/new, /admin/billing, …) showed no active
|
||||
// item. startsWith keeps the parent lit on its children too.
|
||||
const isActive = (href: string) =>
|
||||
pathname === href || pathname.startsWith(`${href}/`);
|
||||
|
||||
const links = [
|
||||
{ href: "/dashboard", label: t("dashboard"), show: !!user },
|
||||
{ href: "/team", label: t("team"), show: showTeam },
|
||||
{ href: "/settings", label: t("settings"), show: showSettings },
|
||||
{ href: "/billing", label: t("billing"), show: showBilling },
|
||||
{ href: "/support", label: t("support"), show: showSupport },
|
||||
{ href: "/admin", label: t("admin"), show: showAdmin },
|
||||
].filter((l) => l.show);
|
||||
|
||||
const displayName = user
|
||||
? user.isPersonal
|
||||
? user.name || (user.email ? user.email.split("@")[0] : user.orgName)
|
||||
: user.orgName
|
||||
: "";
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-50 border-b border-border bg-surface-1/80 backdrop-blur-md">
|
||||
<div className="mx-auto flex h-14 max-w-6xl items-center justify-between px-5">
|
||||
@@ -40,84 +92,96 @@ function NavBar() {
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
{/* Nav links */}
|
||||
{/* Desktop nav links */}
|
||||
<nav className="hidden sm:flex items-center gap-1 ml-2">
|
||||
<NavLink href="/dashboard" active={pathname === "/dashboard"}>
|
||||
{t("dashboard")}
|
||||
{links.map((l) => (
|
||||
<NavLink key={l.href} href={l.href} active={isActive(l.href)}>
|
||||
{l.label}
|
||||
</NavLink>
|
||||
{/* Slice 7: /team is owner+platform only AND personal
|
||||
accounts are excluded — they have no team to manage
|
||||
(Bug 8). Match server-side gates (`canMutate`,
|
||||
`user.isPersonal === false`). The roles array carries
|
||||
either "owner" or "user" for customer sessions;
|
||||
isPlatform covers the platform side. */}
|
||||
{user &&
|
||||
!user.isPersonal &&
|
||||
(user.isPlatform ||
|
||||
(Array.isArray(user.roles) && user.roles.includes("owner"))) && (
|
||||
<NavLink href="/team" active={pathname === "/team"}>
|
||||
{t("team")}
|
||||
</NavLink>
|
||||
)}
|
||||
{/* Bug 35: /settings is shown to anyone who can mutate org-level
|
||||
state — owners and platform admins. Personal accounts also
|
||||
see it; their billing page is optional but the entry point
|
||||
exists for consistency. `user`-role customers don't see it
|
||||
(canMutate is false). */}
|
||||
{user &&
|
||||
(user.isPlatform ||
|
||||
(Array.isArray(user.roles) && user.roles.includes("owner"))) && (
|
||||
<NavLink
|
||||
href="/settings"
|
||||
active={pathname.startsWith("/settings")}
|
||||
>
|
||||
{t("settings")}
|
||||
</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 && (
|
||||
<NavLink href="/admin" active={pathname === "/admin"}>
|
||||
{t("admin")}
|
||||
</NavLink>
|
||||
)}
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* Right side */}
|
||||
<div className="flex items-center gap-4">
|
||||
{user && (
|
||||
// For personal accounts the orgName is opaque
|
||||
// ("personal-3f2a8b1c") or a synthetic legacy
|
||||
// "Name (Personal)" — neither is what we want in the nav.
|
||||
// Show the user's display name instead. The detection logic
|
||||
// and fallback chain live in `lib/personal-org.ts`; keeping
|
||||
// a thin inline branch here avoids importing a server-only
|
||||
// helper into a client component.
|
||||
<span className="hidden md:inline text-xs text-text-secondary font-mono">
|
||||
{user.isPersonal
|
||||
? user.name || (user.email ? user.email.split("@")[0] : user.orgName)
|
||||
: user.orgName}
|
||||
{displayName}
|
||||
</span>
|
||||
)}
|
||||
<LanguageSwitcher />
|
||||
<button
|
||||
onClick={() => signOut({ callbackUrl: "/login" })}
|
||||
className="text-xs font-medium text-text-secondary hover:text-error transition-colors cursor-pointer"
|
||||
className="hidden sm:inline text-xs font-medium text-text-secondary hover:text-error transition-colors cursor-pointer"
|
||||
>
|
||||
{t("logout")}
|
||||
</button>
|
||||
|
||||
{/* Mobile menu toggle — only shown below the `sm` breakpoint,
|
||||
where the desktop nav and logout button are hidden. */}
|
||||
{user && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMobileOpen((v) => !v)}
|
||||
aria-expanded={mobileOpen}
|
||||
aria-controls="mobile-nav"
|
||||
aria-label={t("menu")}
|
||||
className="sm:hidden inline-flex items-center justify-center h-8 w-8 -mr-1 rounded-md text-text-secondary hover:text-text-primary hover:bg-surface-2 transition-colors cursor-pointer"
|
||||
>
|
||||
<svg
|
||||
className="h-5 w-5"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.75"
|
||||
strokeLinecap="round"
|
||||
>
|
||||
{mobileOpen ? (
|
||||
<path d="M6 6l12 12M18 6L6 18" />
|
||||
) : (
|
||||
<path d="M4 7h16M4 12h16M4 17h16" />
|
||||
)}
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile panel */}
|
||||
{user && mobileOpen && (
|
||||
<nav
|
||||
id="mobile-nav"
|
||||
className="sm:hidden border-t border-border bg-surface-1 px-3 py-3"
|
||||
>
|
||||
<div className="flex flex-col gap-1">
|
||||
{links.map((l) => (
|
||||
<Link
|
||||
key={l.href}
|
||||
href={l.href}
|
||||
className={`px-3 py-2.5 rounded-md text-sm font-medium transition-colors ${
|
||||
isActive(l.href)
|
||||
? "bg-surface-3 text-text-primary"
|
||||
: "text-text-secondary hover:text-text-primary hover:bg-surface-2"
|
||||
}`}
|
||||
>
|
||||
{l.label}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-3 pt-3 border-t border-border flex items-center justify-between px-3">
|
||||
<span className="text-xs text-text-secondary font-mono truncate">
|
||||
{displayName}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => signOut({ callbackUrl: "/login" })}
|
||||
className="text-xs font-medium text-text-secondary hover:text-error transition-colors cursor-pointer shrink-0 ml-3"
|
||||
>
|
||||
{t("logout")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
)}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -148,9 +212,19 @@ function NavLink({
|
||||
);
|
||||
}
|
||||
|
||||
export function NavShell({ children }: { children: React.ReactNode }) {
|
||||
export function NavShell({
|
||||
children,
|
||||
session,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
// Server-resolved session passed down from the locale layout. Seeding
|
||||
// SessionProvider with it means useSession() is populated on the first
|
||||
// client render, so the nav links render immediately instead of
|
||||
// popping in after the client-side session fetch (CLS / flash).
|
||||
session: Session | null;
|
||||
}) {
|
||||
return (
|
||||
<SessionProvider>
|
||||
<SessionProvider session={session}>
|
||||
<NavBar />
|
||||
<main className="mx-auto max-w-6xl px-5 py-8">{children}</main>
|
||||
</SessionProvider>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useRouter } from "@/i18n/navigation";
|
||||
import { OnboardingWizard } from "./wizard";
|
||||
import type { OrgBilling } from "@/types";
|
||||
|
||||
interface OnboardingFlowProps {
|
||||
orgName: string;
|
||||
@@ -19,6 +20,23 @@ interface OnboardingFlowProps {
|
||||
* /settings/billing.
|
||||
*/
|
||||
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;
|
||||
/**
|
||||
* Phase 9b: platform setup fee (net CHF) shown on the review
|
||||
* step. Forwarded straight to the wizard.
|
||||
*/
|
||||
setupFeeChf?: number | null;
|
||||
/**
|
||||
* Recurring per-tenant monthly fee (net CHF). Forwarded to the
|
||||
* wizard's review-step cost summary so the customer sees the ongoing
|
||||
* commitment, not just the one-time setup fee.
|
||||
*/
|
||||
monthlyFeeChf?: number | null;
|
||||
/**
|
||||
* Bug 6: when present, the wizard is rendered in edit mode against
|
||||
* the given pending request. See `OnboardingWizard` for the full
|
||||
@@ -45,6 +63,9 @@ export function OnboardingFlow({
|
||||
userName,
|
||||
userEmail,
|
||||
hasOrgBilling,
|
||||
existingOrgBilling,
|
||||
setupFeeChf,
|
||||
monthlyFeeChf,
|
||||
editingRequest,
|
||||
}: OnboardingFlowProps) {
|
||||
const router = useRouter();
|
||||
@@ -55,6 +76,9 @@ export function OnboardingFlow({
|
||||
userName={userName}
|
||||
userEmail={userEmail}
|
||||
hasOrgBilling={hasOrgBilling}
|
||||
existingOrgBilling={existingOrgBilling}
|
||||
setupFeeChf={setupFeeChf}
|
||||
monthlyFeeChf={monthlyFeeChf}
|
||||
editingRequest={editingRequest}
|
||||
onComplete={() => {
|
||||
// Navigate back to /dashboard and re-fetch on the server. The
|
||||
|
||||
@@ -432,25 +432,35 @@ export function ProvisioningStatus({ requestId, canAct }: Props) {
|
||||
<span className="text-xs text-text-muted">{t("phase")}</span>
|
||||
<StatusBadge phase={phase} />
|
||||
</div>
|
||||
{conditions.map((c, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex items-center justify-between bg-surface-2 border border-border rounded-lg px-4 py-2"
|
||||
>
|
||||
<span className="text-xs text-text-muted">{c.type}</span>
|
||||
<span
|
||||
className={`text-xs font-mono ${
|
||||
c.status === "True"
|
||||
? "text-emerald-400"
|
||||
: c.status === "False"
|
||||
? "text-red-400"
|
||||
: "text-text-muted"
|
||||
}`}
|
||||
>
|
||||
{c.reason || c.status}
|
||||
{/* Setup progress. The operator reports readiness as a list of
|
||||
internal K8s conditions (OpenBao policy, LiteLLM key, network
|
||||
policy, …) — meaningful to operators, jargon to customers.
|
||||
We surface the *shape* of that progress (how many steps are
|
||||
done) without leaking the internal names. */}
|
||||
{conditions.length > 0 &&
|
||||
(() => {
|
||||
const done = conditions.filter((c) => c.status === "True").length;
|
||||
const total = conditions.length;
|
||||
const pct = Math.round((done / total) * 100);
|
||||
return (
|
||||
<div className="bg-surface-2 border border-border rounded-lg px-4 py-3">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-xs text-text-muted">
|
||||
{t("setupProgress")}
|
||||
</span>
|
||||
<span className="text-xs font-medium text-text-secondary tabular-nums">
|
||||
{t("setupStepsComplete", { done, total })}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
<div className="h-1.5 w-full rounded-full bg-surface-3 overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-accent transition-all duration-500"
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
@@ -487,12 +497,27 @@ export function ProvisioningStatus({ requestId, canAct }: Props) {
|
||||
<p className="text-sm text-text-secondary max-w-sm mx-auto mb-4">
|
||||
{t("readyDescription")}
|
||||
</p>
|
||||
{(() => {
|
||||
// Prefer deep-linking straight to the tenant page, where the
|
||||
// ConnectPanel shows how to start chatting. Fall back to a
|
||||
// reload only if we somehow don't have a tenant name yet.
|
||||
const tenantName = data.tenant?.name || data.request.tenantName;
|
||||
return tenantName ? (
|
||||
<Link
|
||||
href={`/tenants/${tenantName}`}
|
||||
className="inline-block py-2 px-6 bg-accent text-surface-0 text-sm font-medium rounded-lg hover:bg-accent-dim transition-colors"
|
||||
>
|
||||
{t("connectCta")}
|
||||
</Link>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
className="py-2 px-6 bg-accent text-white text-sm font-medium rounded-lg hover:bg-accent-dim transition-colors"
|
||||
className="py-2 px-6 bg-accent text-surface-0 text-sm font-medium rounded-lg hover:bg-accent-dim transition-colors"
|
||||
>
|
||||
{t("goToDashboard")}
|
||||
</button>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useTranslations } from "next-intl";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { PACKAGE_CATALOG, DEFAULT_PACKAGE_IDS, type PackageDef } from "@/lib/packages";
|
||||
import { isPersonalOrgName, displayOrgNameFor } from "@/lib/personal-org";
|
||||
import { THREEMA_GATEWAY } from "@/lib/threema-gateway-config";
|
||||
import {
|
||||
configureStepSchema,
|
||||
billingStepSchema,
|
||||
@@ -13,6 +14,7 @@ import {
|
||||
SUPPORTED_COUNTRIES,
|
||||
type SupportedCountry,
|
||||
} from "@/lib/validation";
|
||||
import type { OrgBilling } from "@/types";
|
||||
|
||||
type Step = "welcome" | "configure" | "billing" | "confirm";
|
||||
|
||||
@@ -96,6 +98,32 @@ interface WizardProps {
|
||||
* fix it before admin approves.
|
||||
*/
|
||||
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;
|
||||
/**
|
||||
* Phase 9b: the platform's current tenant setup fee (net CHF,
|
||||
* before VAT). Shown on the review step so the customer sees how
|
||||
* much they're about to be charged before being sent to Stripe.
|
||||
* Null/0 means no setup fee — the review notice is suppressed and
|
||||
* the order skips the Checkout redirect (handled server-side).
|
||||
*/
|
||||
setupFeeChf?: number | null;
|
||||
/**
|
||||
* The platform's recurring per-tenant monthly fee (net CHF, before
|
||||
* VAT). Shown on the review step alongside the setup fee so the
|
||||
* customer sees the ongoing commitment — not just the one-time
|
||||
* charge — before submitting. Null/0 hides the monthly line.
|
||||
*/
|
||||
monthlyFeeChf?: number | null;
|
||||
/**
|
||||
* Bug 6: when present, the wizard renders in "edit" mode — fields
|
||||
* are pre-populated from the request, the SOUL.md auto-fetch is
|
||||
@@ -134,6 +162,9 @@ export function OnboardingWizard({
|
||||
userName,
|
||||
userEmail,
|
||||
hasOrgBilling,
|
||||
existingOrgBilling,
|
||||
setupFeeChf,
|
||||
monthlyFeeChf,
|
||||
editingRequest,
|
||||
onComplete,
|
||||
}: WizardProps) {
|
||||
@@ -232,6 +263,14 @@ export function OnboardingWizard({
|
||||
const [disclaimerAccepted, setDisclaimerAccepted] = useState<
|
||||
Record<string, boolean>
|
||||
>({});
|
||||
// Phase 9b: per-channel customer user id collected at onboarding.
|
||||
// Keyed by package id (e.g. "telegram" → "1234567"). Applied on
|
||||
// admin approval — see /api/admin/requests/[id]/approve. Optional
|
||||
// per channel; the customer can also leave it blank and add their
|
||||
// id later from the tenant's channel-users page.
|
||||
const [channelUserIds, setChannelUserIds] = useState<Record<string, string>>(
|
||||
{}
|
||||
);
|
||||
|
||||
// Fetch DB-stored defaults on mount
|
||||
useEffect(() => {
|
||||
@@ -319,7 +358,23 @@ export function OnboardingWizard({
|
||||
}
|
||||
// confirm: validate the union (defence in depth — submit handler
|
||||
// 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) {
|
||||
setErrors({});
|
||||
return true;
|
||||
@@ -373,18 +428,51 @@ export function OnboardingWizard({
|
||||
[]
|
||||
);
|
||||
|
||||
// Validate that all secret-requiring enabled packages have complete credentials
|
||||
const packageCredentialsValid = (): boolean => {
|
||||
// Enabled packages that still need something from the user before the
|
||||
// configure step can advance — a missing credential field or an
|
||||
// unaccepted disclaimer. Returns the package defs so the UI can name
|
||||
// exactly what's blocking the (otherwise silently disabled) Next
|
||||
// button instead of greying it out with no explanation.
|
||||
const incompletePackages = (): PackageDef[] => {
|
||||
const out: PackageDef[] = [];
|
||||
for (const pkgId of config.packages) {
|
||||
const def = PACKAGE_CATALOG.find((p) => p.id === pkgId);
|
||||
if (!def?.requiresSecrets) continue;
|
||||
if (!def) continue;
|
||||
let incomplete = false;
|
||||
if (def.requiresSecrets) {
|
||||
const secrets = packageSecrets[pkgId] || {};
|
||||
for (const field of def.secrets || []) {
|
||||
if (!secrets[field.key]?.trim()) return false;
|
||||
if (!secrets[field.key]?.trim()) {
|
||||
incomplete = true;
|
||||
break;
|
||||
}
|
||||
if (def.disclaimerKey && !disclaimerAccepted[pkgId]) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (def.disclaimerKey && !disclaimerAccepted[pkgId]) incomplete = true;
|
||||
if (incomplete) out.push(def);
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
const packageCredentialsValid = (): boolean =>
|
||||
incompletePackages().length === 0;
|
||||
|
||||
// Map zod field paths to human labels for the confirm-step error
|
||||
// summary, so a stray validation failure reads "Postal code" rather
|
||||
// than "billingAddress.postalCode". Unknown paths fall back to the
|
||||
// raw path (this defence-in-depth list should rarely render at all).
|
||||
const fieldLabel = (path: string): string => {
|
||||
const map: Record<string, string> = {
|
||||
instanceName: t("instanceName"),
|
||||
agentName: t("agentName"),
|
||||
"billingAddress.company": t("billingCompany"),
|
||||
"billingAddress.street": t("billingStreet"),
|
||||
"billingAddress.postalCode": t("billingPostalCode"),
|
||||
"billingAddress.city": t("billingCity"),
|
||||
"billingAddress.country": t("billingCountry"),
|
||||
"billingAddress.vatNumber": t("billingVatNumber"),
|
||||
};
|
||||
return map[path] ?? path;
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
@@ -435,6 +523,20 @@ export function OnboardingWizard({
|
||||
})()
|
||||
: config;
|
||||
|
||||
// Phase 9b: build the channelUsers payload from the per-package
|
||||
// ids collected during onboarding. Only include channels that
|
||||
// (a) are enabled in the wizard's packages list AND
|
||||
// (b) have a non-empty id entered.
|
||||
// Shape matches PiecedTenantSpec.channelUsers — { channel: [id] }
|
||||
// — so the approve handler can pass it straight through.
|
||||
const channelUsersPayload: Record<string, string[]> = {};
|
||||
for (const [pkgId, rawId] of Object.entries(channelUserIds)) {
|
||||
const trimmed = (rawId ?? "").trim();
|
||||
if (!trimmed) continue;
|
||||
if (!config.packages.includes(pkgId)) continue;
|
||||
channelUsersPayload[pkgId] = [trimmed];
|
||||
}
|
||||
|
||||
const res = await fetch(url, {
|
||||
method,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
@@ -444,6 +546,10 @@ export function OnboardingWizard({
|
||||
Object.keys(secretsPayload).length > 0
|
||||
? secretsPayload
|
||||
: undefined,
|
||||
channelUsers:
|
||||
Object.keys(channelUsersPayload).length > 0
|
||||
? channelUsersPayload
|
||||
: undefined,
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -452,6 +558,22 @@ export function OnboardingWizard({
|
||||
throw new Error(data.error || "Submission failed");
|
||||
}
|
||||
|
||||
// Phase 9b: if the server initiated a setup-fee Checkout, the
|
||||
// response carries a `checkoutUrl`. Redirect the browser
|
||||
// directly — Stripe Checkout is the next step. The
|
||||
// tenant_requests row is already inserted in 'pending_payment'
|
||||
// status; on successful Checkout, the webhook flips it to
|
||||
// 'pending' and admin sees it.
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (data?.checkoutUrl) {
|
||||
// Don't reset submitting=false — let the redirect happen
|
||||
// with the spinner still active so the button stays
|
||||
// disabled.
|
||||
window.location.href = data.checkoutUrl;
|
||||
return;
|
||||
}
|
||||
|
||||
// Zero-fee path or PATCH edit — same behaviour as before.
|
||||
onComplete();
|
||||
} catch (err: any) {
|
||||
setError(err.message);
|
||||
@@ -525,7 +647,7 @@ export function OnboardingWizard({
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
onClick={goNext}
|
||||
className="py-2 px-6 bg-accent text-white text-sm font-medium rounded-lg hover:bg-accent-dim transition-colors"
|
||||
className="py-2 px-6 bg-accent text-surface-0 text-sm font-medium rounded-lg hover:bg-accent-dim transition-colors"
|
||||
>
|
||||
{t("getStarted")}
|
||||
</button>
|
||||
@@ -691,6 +813,8 @@ export function OnboardingWizard({
|
||||
className={`border rounded-lg overflow-hidden transition-colors ${
|
||||
isSelected
|
||||
? "border-accent bg-accent/5"
|
||||
: pkg.recommended
|
||||
? "border-accent/40 bg-accent/[0.02]"
|
||||
: "border-border bg-surface-2"
|
||||
}`}
|
||||
>
|
||||
@@ -710,6 +834,11 @@ export function OnboardingWizard({
|
||||
>
|
||||
{pkg.name}
|
||||
</span>
|
||||
{pkg.recommended && (
|
||||
<span className="ml-2 text-[10px] font-semibold uppercase tracking-wide text-accent bg-accent/10 border border-accent/30 rounded-full px-1.5 py-0.5">
|
||||
{tPkg("recommended")}
|
||||
</span>
|
||||
)}
|
||||
{pkg.requiresSecrets && (
|
||||
<span className="ml-1.5 text-[10px] text-text-muted">
|
||||
({tPkg("requiresApiKey")})
|
||||
@@ -731,8 +860,16 @@ export function OnboardingWizard({
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Inline credential inputs — expand when selected + requires secrets */}
|
||||
{isSelected && pkg.requiresSecrets && (
|
||||
{/* Inline expansion when selected — shows
|
||||
instructions (if any), credential inputs
|
||||
(if requiresSecrets), and the disclaimer
|
||||
checkbox (if any). Threema for example
|
||||
has no customer-entered secrets but has
|
||||
instructions + a disclaimer to accept. */}
|
||||
{isSelected &&
|
||||
(pkg.requiresSecrets ||
|
||||
pkg.instructionsKey ||
|
||||
pkg.disclaimerKey) && (
|
||||
<div className="border-t border-border px-3 py-3 space-y-3 bg-surface-1/50">
|
||||
{pkg.instructionsKey && (
|
||||
<div className="bg-surface-2 border border-border rounded-lg p-3 text-xs text-text-secondary leading-relaxed whitespace-pre-line">
|
||||
@@ -745,6 +882,40 @@ export function OnboardingWizard({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Threema: show the bot's Threema ID
|
||||
and QR right here in the wizard. The
|
||||
instructions text refers to a QR
|
||||
that isn't visible until after
|
||||
provisioning — without this block
|
||||
the message is confusing. The QR is
|
||||
the platform's shared gateway QR
|
||||
(*AIAGENT), identical for every
|
||||
tenant, so we can render it before
|
||||
the tenant even exists. */}
|
||||
{pkg.id === "threema" && (
|
||||
<div className="rounded-lg border border-accent/30 bg-surface-1 p-3 flex items-start gap-3">
|
||||
<div className="bg-white p-1.5 rounded-md shrink-0">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={THREEMA_GATEWAY.qrCodePath}
|
||||
alt={`QR code for ${THREEMA_GATEWAY.displayName}`}
|
||||
width={96}
|
||||
height={96}
|
||||
style={{ display: "block" }}
|
||||
/>
|
||||
</div>
|
||||
<div className="text-xs text-text-secondary leading-relaxed">
|
||||
<div className="text-text-primary font-medium mb-1">
|
||||
{tPkg("threemaBotIdHeading")}
|
||||
</div>
|
||||
<div className="font-mono text-sm text-accent mb-2">
|
||||
{THREEMA_GATEWAY.displayName}
|
||||
</div>
|
||||
<div>{tPkg("threemaBotIdHint")}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(pkg.secrets || []).map((field) => (
|
||||
<label key={field.key} className="block">
|
||||
<span className="text-xs text-text-secondary mb-1 block">
|
||||
@@ -773,6 +944,46 @@ export function OnboardingWizard({
|
||||
</label>
|
||||
))}
|
||||
|
||||
{/* Phase 9b: channel-user-id capture
|
||||
during onboarding. For channels
|
||||
where the customer's own user id
|
||||
is needed for routing (Telegram,
|
||||
Discord, Threema), collect it here
|
||||
so the assistant is usable
|
||||
immediately on provisioning. The
|
||||
help text comes from the existing
|
||||
channelUsers.<id>IdHelp keys
|
||||
(same copy as the post-provisioning
|
||||
page uses). Field is optional —
|
||||
blank means "I'll add it later". */}
|
||||
{pkg.collectsChannelUserId && (
|
||||
<label className="block">
|
||||
<span className="text-xs text-text-secondary mb-1 block">
|
||||
{t(`yourChannelIdLabel.${pkg.id}`)}{" "}
|
||||
<span className="text-text-muted normal-case">
|
||||
({t("optional")})
|
||||
</span>
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
placeholder={t(
|
||||
`yourChannelIdPlaceholder.${pkg.id}`
|
||||
)}
|
||||
value={channelUserIds[pkg.id] ?? ""}
|
||||
onChange={(e) =>
|
||||
setChannelUserIds((prev) => ({
|
||||
...prev,
|
||||
[pkg.id]: e.target.value,
|
||||
}))
|
||||
}
|
||||
className="w-full px-3 py-2 bg-surface-2 border border-border rounded-lg text-sm text-text-primary placeholder:text-text-muted font-mono focus:outline-none focus:ring-1 focus:ring-accent focus:border-accent transition-colors"
|
||||
/>
|
||||
<p className="text-[11px] text-text-muted mt-1 leading-relaxed whitespace-pre-line">
|
||||
{t(`yourChannelIdHelp.${pkg.id}`)}
|
||||
</p>
|
||||
</label>
|
||||
)}
|
||||
|
||||
{pkg.disclaimerKey && (
|
||||
<label className="flex items-start gap-2 text-xs text-text-secondary">
|
||||
<input
|
||||
@@ -814,7 +1025,19 @@ export function OnboardingWizard({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between mt-6">
|
||||
<div className="mt-6">
|
||||
{(() => {
|
||||
const blocking = incompletePackages();
|
||||
if (blocking.length === 0) return null;
|
||||
return (
|
||||
<p className="text-xs text-amber-400/90 mb-3 text-right">
|
||||
{t("packagesIncompleteHint", {
|
||||
packages: blocking.map((p) => p.name).join(", "),
|
||||
})}
|
||||
</p>
|
||||
);
|
||||
})()}
|
||||
<div className="flex justify-between">
|
||||
<button
|
||||
onClick={goBack}
|
||||
className="py-2 px-4 text-sm text-text-secondary hover:text-text-primary transition-colors"
|
||||
@@ -824,11 +1047,12 @@ export function OnboardingWizard({
|
||||
<button
|
||||
onClick={goNext}
|
||||
disabled={!packageCredentialsValid()}
|
||||
className="py-2 px-6 bg-accent text-white text-sm font-medium rounded-lg hover:bg-accent-dim transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
className="py-2 px-6 bg-accent text-surface-0 text-sm font-medium rounded-lg hover:bg-accent-dim transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{t("next")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
@@ -1001,28 +1225,6 @@ export function OnboardingWizard({
|
||||
</p>
|
||||
</FieldWithError>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-semibold uppercase tracking-wider text-text-muted mb-1.5">
|
||||
{t("billingNotes")}
|
||||
</label>
|
||||
<textarea
|
||||
value={config.billingNotes}
|
||||
onChange={(e) =>
|
||||
setConfig((prev) => ({
|
||||
...prev,
|
||||
billingNotes: e.target.value,
|
||||
}))
|
||||
}
|
||||
rows={3}
|
||||
placeholder={t(
|
||||
isPersonal
|
||||
? "billingNotesPlaceholderPersonal"
|
||||
: "billingNotesPlaceholder"
|
||||
)}
|
||||
className="w-full px-3 py-2 bg-surface-2 border border-border rounded-lg text-sm text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-1 focus:ring-accent focus:border-accent transition-colors resize-y"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between mt-6">
|
||||
@@ -1034,7 +1236,7 @@ export function OnboardingWizard({
|
||||
</button>
|
||||
<button
|
||||
onClick={goNext}
|
||||
className="py-2 px-6 bg-accent text-white text-sm font-medium rounded-lg hover:bg-accent-dim transition-colors"
|
||||
className="py-2 px-6 bg-accent text-surface-0 text-sm font-medium rounded-lg hover:bg-accent-dim transition-colors"
|
||||
>
|
||||
{t("next")}
|
||||
</button>
|
||||
@@ -1101,60 +1303,135 @@ export function OnboardingWizard({
|
||||
<ReviewRow
|
||||
label={t("reviewBillingTo")}
|
||||
value={
|
||||
(() => {
|
||||
// Phase 6 fix3: when the org has billing on file
|
||||
// and we're not editing, render the saved
|
||||
// org_billing record (the authoritative source)
|
||||
// rather than config.billingAddress, which is the
|
||||
// wizard's empty default state because the billing
|
||||
// step was skipped. In edit mode, fall back to
|
||||
// config.billingAddress, which is pre-populated
|
||||
// from the request being edited.
|
||||
const useSaved =
|
||||
hasOrgBilling && !isEditing && existingOrgBilling;
|
||||
const company = useSaved
|
||||
? existingOrgBilling!.companyName
|
||||
: config.billingAddress.company;
|
||||
const street = useSaved
|
||||
? existingOrgBilling!.streetAddress
|
||||
: config.billingAddress.street;
|
||||
const postalCode = useSaved
|
||||
? existingOrgBilling!.postalCode
|
||||
: config.billingAddress.postalCode;
|
||||
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 &&
|
||||
config.billingAddress.company &&
|
||||
config.billingAddress.company.trim().length > 0 && (
|
||||
<div>{config.billingAddress.company}</div>
|
||||
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>{config.billingAddress.street}</div>
|
||||
<div>{street}</div>
|
||||
<div>
|
||||
{config.billingAddress.postalCode}{" "}
|
||||
{config.billingAddress.city}
|
||||
{postalCode} {city}
|
||||
</div>
|
||||
<div className="text-text-muted">
|
||||
{tCountries(
|
||||
config.billingAddress.country as SupportedCountry
|
||||
)}
|
||||
{tCountries(country as SupportedCountry)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()
|
||||
}
|
||||
/>
|
||||
{/* Bug 35: VAT review row. Company customers see this so
|
||||
they can verify the VAT id they typed before submitting.
|
||||
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 &&
|
||||
config.billingAddress.vatNumber &&
|
||||
config.billingAddress.vatNumber.trim().length > 0 && (
|
||||
(() => {
|
||||
const vat =
|
||||
hasOrgBilling && !isEditing && existingOrgBilling
|
||||
? existingOrgBilling.vatNumber
|
||||
: config.billingAddress.vatNumber;
|
||||
return vat && vat.trim().length > 0 ? (
|
||||
<ReviewRow
|
||||
label={t("billingVatNumber")}
|
||||
value={config.billingAddress.vatNumber}
|
||||
value={vat}
|
||||
mono
|
||||
/>
|
||||
)}
|
||||
) : null;
|
||||
})()}
|
||||
<ReviewRow
|
||||
label={t("reviewContactEmail")}
|
||||
value={userEmail || ""}
|
||||
mono
|
||||
/>
|
||||
{config.billingNotes.trim().length > 0 && (
|
||||
<ReviewRow
|
||||
label={t("billingNotes")}
|
||||
value={
|
||||
<span className="text-text-primary whitespace-pre-wrap text-right">
|
||||
{config.billingNotes}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-text-muted">{t("confirmNote")}</p>
|
||||
|
||||
{/* Cost summary. Surfaces the full commitment before
|
||||
submitting — not just the one-time setup fee but the
|
||||
recurring monthly per-assistant fee and the fact that
|
||||
AI usage is billed by consumption (with the budget-cap
|
||||
control as the reassurance). All figures are net (before
|
||||
VAT); VAT is added server-side per billing country, so
|
||||
we show "+ VAT" rather than a country-dependent gross.
|
||||
The block is suppressed only when there are no fixed
|
||||
fees at all. */}
|
||||
{((typeof setupFeeChf === "number" && setupFeeChf > 0) ||
|
||||
(typeof monthlyFeeChf === "number" && monthlyFeeChf > 0)) && (
|
||||
<div className="text-xs rounded-md border border-accent/30 bg-accent/10 text-text-secondary px-3 py-3 mt-4">
|
||||
<strong className="block text-text-primary mb-2">
|
||||
{t("costSummaryHeading")}
|
||||
</strong>
|
||||
{typeof setupFeeChf === "number" && setupFeeChf > 0 && (
|
||||
<div className="flex items-baseline justify-between mb-1.5">
|
||||
<span>{t("costSetupLabel")}</span>
|
||||
<span className="text-sm font-semibold text-text-primary">
|
||||
CHF {setupFeeChf.toFixed(2)}{" "}
|
||||
<span className="text-[10px] font-normal text-text-muted">
|
||||
{t("setupFeePlusVat")}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{typeof monthlyFeeChf === "number" && monthlyFeeChf > 0 && (
|
||||
<div className="flex items-baseline justify-between mb-1.5">
|
||||
<span>{t("costMonthlyLabel")}</span>
|
||||
<span className="text-sm font-semibold text-text-primary">
|
||||
CHF {monthlyFeeChf.toFixed(2)}{" "}
|
||||
<span className="text-[10px] font-normal text-text-muted">
|
||||
{t("setupFeePlusVat")}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-2 pt-2 border-t border-accent/20 leading-relaxed">
|
||||
{t("costUsageNote")}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
@@ -1175,7 +1452,8 @@ export function OnboardingWizard({
|
||||
<ul className="list-disc list-inside space-y-0.5">
|
||||
{Object.entries(errors).map(([path, msg]) => (
|
||||
<li key={path}>
|
||||
<span className="font-mono">{path}</span>: {msg}
|
||||
<span className="font-medium">{fieldLabel(path)}</span>:{" "}
|
||||
{msg}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
@@ -1192,7 +1470,7 @@ export function OnboardingWizard({
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={submitting}
|
||||
className="py-2.5 px-6 bg-accent text-white text-sm font-medium rounded-lg hover:bg-accent-dim transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
className="py-2.5 px-6 bg-accent text-surface-0 text-sm font-medium rounded-lg hover:bg-accent-dim transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{submitting
|
||||
? tCommon("loading")
|
||||
|
||||
@@ -9,6 +9,7 @@ import type {
|
||||
SkillPricing,
|
||||
} from "@/types";
|
||||
import { SkillCostDialog } from "./skill-cost-dialog";
|
||||
import { ThreemaQrModal } from "@/components/channel-users/threema-qr-modal";
|
||||
|
||||
interface Props {
|
||||
pkg: PackageDef;
|
||||
@@ -51,6 +52,11 @@ export function PackageCard({
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
// Phase 2.5: cost-disclosure flow + activation-request flow.
|
||||
const [showCostDialog, setShowCostDialog] = useState(false);
|
||||
// Threema: after a successful enable on customProvisioning, surface
|
||||
// the gateway QR + bot Threema ID so the customer immediately knows
|
||||
// how to add the assistant to their Threema contacts. Without this,
|
||||
// the toggle just flips silently with no actionable info.
|
||||
const [showThreemaInfo, setShowThreemaInfo] = useState(false);
|
||||
const isPriced =
|
||||
(pricing?.dailyPriceChf ?? 0) > 0 || (pricing?.setupFeeChf ?? 0) > 0;
|
||||
|
||||
@@ -79,6 +85,14 @@ export function PackageCard({
|
||||
throw new Error(err.error || `Provisioning failed (HTTP ${provRes.status})`);
|
||||
}
|
||||
await togglePackage(true);
|
||||
// For Threema specifically: now that the relay's minted the
|
||||
// per-tenant token and the package is enabled, show the
|
||||
// gateway QR + bot Threema ID so the customer can add the
|
||||
// assistant to their Threema contacts straight away. Other
|
||||
// customProvisioning packages don't need this confirmation.
|
||||
if (pkg.id === "threema") {
|
||||
setShowThreemaInfo(true);
|
||||
}
|
||||
} catch (e: any) {
|
||||
setError(e.message);
|
||||
} finally {
|
||||
@@ -283,10 +297,25 @@ export function PackageCard({
|
||||
</button>
|
||||
</div>
|
||||
) : canEdit ? (
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
{/* Phase 9b: re-open the Threema info popup at any time
|
||||
while Threema is enabled. The popup auto-opens after
|
||||
a fresh enable; this button lets the customer see the
|
||||
QR + bot ID again without having to disable + re-enable. */}
|
||||
{pkg.id === "threema" && enabled && (
|
||||
<button
|
||||
onClick={() => setShowThreemaInfo(true)}
|
||||
className="rounded-lg px-2 py-1.5 text-xs font-medium bg-surface-3 text-text-secondary hover:text-text-primary hover:bg-surface-2 transition-colors cursor-pointer"
|
||||
title={t("packages.showInfoTitle")}
|
||||
aria-label={t("packages.showInfoTitle")}
|
||||
>
|
||||
ⓘ {t("packages.showInfo")}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={enabled ? handleDisable : handleEnable}
|
||||
disabled={saving}
|
||||
className={`ml-auto rounded-lg px-3 py-1.5 text-xs font-medium transition-all cursor-pointer ${
|
||||
className={`rounded-lg px-3 py-1.5 text-xs font-medium transition-all cursor-pointer ${
|
||||
enabled
|
||||
? "bg-surface-3 text-text-secondary hover:text-text-primary hover:bg-surface-2"
|
||||
: "bg-accent text-surface-0 hover:bg-accent-dim shadow-lg shadow-accent/20"
|
||||
@@ -294,6 +323,7 @@ export function PackageCard({
|
||||
>
|
||||
{saving ? "…" : enabled ? t("packages.disable") : t("packages.enable")}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
// Slice 5: read-only viewers see a static badge instead of a
|
||||
// toggle. The status badge above the divider already conveys
|
||||
@@ -320,6 +350,16 @@ export function PackageCard({
|
||||
busy={saving}
|
||||
/>
|
||||
|
||||
{/* Threema: post-enable confirmation showing the gateway QR
|
||||
and bot Threema ID. Only rendered for the threema package
|
||||
and only after a successful enable. The same modal is also
|
||||
reachable later on the channel-users page. */}
|
||||
{pkg.id === "threema" && (
|
||||
<ThreemaQrModal
|
||||
open={showThreemaInfo}
|
||||
onClose={() => setShowThreemaInfo(false)}
|
||||
/>
|
||||
)}
|
||||
{showModal && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4">
|
||||
<div className="w-full max-w-md bg-surface-1 border border-border rounded-2xl p-6 space-y-4 shadow-2xl shadow-black/40">
|
||||
|
||||
@@ -104,7 +104,7 @@ export function SkillCostDialog({
|
||||
<button
|
||||
onClick={onConfirm}
|
||||
disabled={busy}
|
||||
className="px-4 py-2 rounded-md bg-accent text-white text-sm disabled:opacity-50"
|
||||
className="px-4 py-2 rounded-md bg-accent text-surface-0 text-sm disabled:opacity-50"
|
||||
>
|
||||
{busy ? t("confirming") : t("confirm")}
|
||||
</button>
|
||||
|
||||
263
src/components/settings/billing-form.tsx
Normal file
263
src/components/settings/billing-form.tsx
Normal 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-surface-0 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>
|
||||
);
|
||||
}
|
||||
@@ -268,7 +268,7 @@ export function BillingSettingsForm({
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
className="ml-auto text-sm font-medium px-4 py-2 rounded-lg bg-accent text-white hover:bg-accent/90 transition-colors disabled:opacity-50"
|
||||
className="ml-auto text-sm font-medium px-4 py-2 rounded-lg bg-accent text-surface-0 hover:bg-accent/90 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{submitting ? tCommon("loading") : t("save")}
|
||||
</button>
|
||||
|
||||
187
src/components/settings/profile-form.tsx
Normal file
187
src/components/settings/profile-form.tsx
Normal file
@@ -0,0 +1,187 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Card } from "@/components/ui/card";
|
||||
|
||||
interface Props {
|
||||
initial: {
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
email: string;
|
||||
};
|
||||
/**
|
||||
* Personal-account flag. Drives a small hint about how the ZITADEL
|
||||
* name relates (or doesn't) to invoice identity — see the page
|
||||
* server component for the long explanation.
|
||||
*/
|
||||
isPersonal: boolean;
|
||||
/**
|
||||
* For company accounts: the display org name. Shown in a small
|
||||
* read-only "Member of <org>" hint so the user understands which
|
||||
* identity they're editing. Ignored for personals (orgName is an
|
||||
* opaque "personal-XXXX" string in that case).
|
||||
*/
|
||||
orgName: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Edits first/last name in ZITADEL via PUT /api/settings/profile.
|
||||
* Email is shown read-only — changing email requires verification
|
||||
* flow that ZITADEL's own self-service UI handles.
|
||||
*
|
||||
* On save, we trigger NextAuth's `update()` from useSession() with
|
||||
* the new display name. That routes through our jwt callback
|
||||
* (trigger='update' branch) which overlays token.name without a
|
||||
* logout/login. After the cookie is updated we trigger a full page
|
||||
* reload — every server-rendered surface (nav-shell, dashboard
|
||||
* welcome, instance cards) re-reads the cookie on the next request
|
||||
* and renders with the new name. router.refresh() alone wasn't
|
||||
* enough: it re-runs only the current route's server components,
|
||||
* leaving outer-tree segments stale until the user navigates.
|
||||
*/
|
||||
export function ProfileSettingsForm({ initial, isPersonal, orgName }: Props) {
|
||||
const t = useTranslations("settingsProfile");
|
||||
const { update } = useSession();
|
||||
const [form, setForm] = useState({
|
||||
firstName: initial.firstName,
|
||||
lastName: initial.lastName,
|
||||
});
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [savedFlash, setSavedFlash] = useState(false);
|
||||
|
||||
const submit = async () => {
|
||||
setError(null);
|
||||
setSavedFlash(false);
|
||||
if (!form.firstName.trim() || !form.lastName.trim()) {
|
||||
setError(t("missingRequired"));
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
try {
|
||||
const res = await fetch("/api/settings/profile", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
firstName: form.firstName.trim(),
|
||||
lastName: form.lastName.trim(),
|
||||
}),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
throw new Error(data.error ?? `HTTP ${res.status}`);
|
||||
}
|
||||
// Phase 6 fix5: push the new display name into the session
|
||||
// token. The jwt callback handles trigger='update' and overlays
|
||||
// token.name; the next session callback maps token.name back
|
||||
// to session.user.name. No re-login needed.
|
||||
await update({ name: data.displayName });
|
||||
setSavedFlash(true);
|
||||
// Force a full reload so EVERY server-rendered component picks
|
||||
// up the new session cookie immediately — router.refresh() only
|
||||
// re-runs the current route's server components, leaving the
|
||||
// nav-shell (rendered higher in the tree) and other cached
|
||||
// segments showing the old name until the user navigates.
|
||||
// The 800ms delay lets the "Saved" flash render briefly before
|
||||
// the page reloads, so the user gets visible feedback.
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 800);
|
||||
} catch (e: any) {
|
||||
setError(e?.message ?? String(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<Field label={t("firstNameLabel")} required>
|
||||
<input
|
||||
type="text"
|
||||
value={form.firstName}
|
||||
onChange={(e) =>
|
||||
setForm((f) => ({ ...f, firstName: e.target.value }))
|
||||
}
|
||||
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("lastNameLabel")} required>
|
||||
<input
|
||||
type="text"
|
||||
value={form.lastName}
|
||||
onChange={(e) =>
|
||||
setForm((f) => ({ ...f, lastName: e.target.value }))
|
||||
}
|
||||
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>
|
||||
</div>
|
||||
<Field label={t("emailLabel")} hint={t("emailReadOnlyHint")}>
|
||||
<input
|
||||
type="email"
|
||||
value={initial.email}
|
||||
readOnly
|
||||
disabled
|
||||
className="w-full px-3 py-2 rounded-md bg-surface-2 border border-border text-sm text-text-muted cursor-not-allowed"
|
||||
/>
|
||||
</Field>
|
||||
{/* Personal vs company hint. Personals get the
|
||||
"this won't change your invoice name" warning since their
|
||||
ZITADEL name and their invoice identity are intentionally
|
||||
decoupled. Company accounts get a benign "member of"
|
||||
context line so they know which org's identity they're
|
||||
editing. */}
|
||||
{isPersonal ? (
|
||||
<p className="text-xs text-text-muted italic">
|
||||
{t("personalAccountHint")}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-xs text-text-muted italic">
|
||||
{t("companyAccountHint", { orgName })}
|
||||
</p>
|
||||
)}
|
||||
{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-surface-0 text-sm font-medium hover:bg-accent-dim transition-colors disabled:opacity-50 cursor-pointer"
|
||||
>
|
||||
{busy ? t("saving") : t("saveChanges")}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
273
src/components/settings/saved-card-section.tsx
Normal file
273
src/components/settings/saved-card-section.tsx
Normal file
@@ -0,0 +1,273 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useRouter } from "@/i18n/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Card, CardHeader } from "@/components/ui/card";
|
||||
import type { OrgBillingConfig } from "@/types";
|
||||
|
||||
interface Props {
|
||||
config: OrgBillingConfig | null;
|
||||
/**
|
||||
* True when this org has been flipped to pay-by-invoice by admin.
|
||||
* The card UI still renders (admin-set customers might also have
|
||||
* a saved card as backup), but with an info note that auto-charge
|
||||
* is disabled by their billing mode.
|
||||
*/
|
||||
isPayByInvoice: boolean;
|
||||
/**
|
||||
* Personal-account flag from the session. Personal accounts are
|
||||
* single-user B2C tenants and don't have the bank-transfer
|
||||
* affordance — they pay by card or not at all. We hide the
|
||||
* "Bank transfer is available on request" hint for these accounts
|
||||
* to keep the messaging unambiguous.
|
||||
*/
|
||||
isPersonal: boolean;
|
||||
}
|
||||
|
||||
const BRAND_LABELS: Record<string, string> = {
|
||||
visa: "Visa",
|
||||
mastercard: "Mastercard",
|
||||
amex: "American Express",
|
||||
discover: "Discover",
|
||||
jcb: "JCB",
|
||||
diners: "Diners Club",
|
||||
unionpay: "UnionPay",
|
||||
};
|
||||
|
||||
/**
|
||||
* Saved-card management — Phase 9.
|
||||
*
|
||||
* State derives entirely from the OrgBillingConfig the server
|
||||
* sends down. Actions are: set up (no card → Checkout setup
|
||||
* mode), update (existing card → same Checkout flow, replaces),
|
||||
* remove (DELETE the PM in Stripe + clear local fields), toggle
|
||||
* auto-charge.
|
||||
*
|
||||
* The component watches for ?card_setup=success on mount and
|
||||
* fires a router.refresh() — the success redirect from Stripe
|
||||
* lands here and the new card info needs to load. We also strip
|
||||
* the query param so a page reload doesn't re-trigger.
|
||||
*/
|
||||
export function SavedCardSection({
|
||||
config,
|
||||
isPayByInvoice,
|
||||
isPersonal,
|
||||
}: Props) {
|
||||
const t = useTranslations("settingsBilling");
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const [busy, setBusy] = useState<null | "setup" | "remove">(null);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
// Refresh + clean the URL when Stripe redirects back. Stripe's
|
||||
// webhook is what actually persists the card; the refresh just
|
||||
// re-fetches the server-side config so the new fields appear.
|
||||
useEffect(() => {
|
||||
const status = searchParams.get("card_setup");
|
||||
if (status === "success") {
|
||||
router.replace("/settings/billing");
|
||||
router.refresh();
|
||||
} else if (status === "cancelled") {
|
||||
// Just clean the URL. No-op otherwise.
|
||||
router.replace("/settings/billing");
|
||||
}
|
||||
}, [searchParams, router]);
|
||||
|
||||
const hasCard = !!config?.stripeDefaultPaymentMethodId;
|
||||
const autoChargeOn = config?.autoChargeEnabled !== false;
|
||||
|
||||
const startSetup = async () => {
|
||||
setError("");
|
||||
setBusy("setup");
|
||||
try {
|
||||
const res = await fetch("/api/billing/setup-card", { method: "POST" });
|
||||
const j = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(j.error || `HTTP ${res.status}`);
|
||||
if (!j.url) throw new Error("No redirect URL returned");
|
||||
// Hard-redirect — Stripe Checkout doesn't run inside the SPA.
|
||||
window.location.href = j.url;
|
||||
} catch (e: any) {
|
||||
setError(e.message);
|
||||
setBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
const removeCard = async () => {
|
||||
if (!confirm(t("savedCardRemoveConfirm"))) return;
|
||||
setError("");
|
||||
setBusy("remove");
|
||||
try {
|
||||
const res = await fetch("/api/billing/saved-card", { method: "DELETE" });
|
||||
const j = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(j.error || `HTTP ${res.status}`);
|
||||
router.refresh();
|
||||
} catch (e: any) {
|
||||
setError(e.message);
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
// Empty state — no card on file.
|
||||
if (!hasCard) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>{t("savedCardHeading")}</CardHeader>
|
||||
<div className="p-5">
|
||||
<p className="text-sm text-text-secondary mb-4">
|
||||
{t("savedCardEmptyBody")}
|
||||
</p>
|
||||
{/* Phase 9: prominent policy notice. Auto-pay is the
|
||||
expected default — emphasise that failure to keep a
|
||||
chargeable card on file may result in tenant suspension.
|
||||
Sits above the CTA so it's seen before the click. */}
|
||||
<div className="text-sm rounded-md border border-warning/40 bg-warning/10 text-warning px-4 py-3 mb-4">
|
||||
<strong className="block mb-1">
|
||||
{t("savedCardAutoPayRequiredHeading")}
|
||||
</strong>
|
||||
<span className="text-text-secondary">
|
||||
{t("savedCardAutoPayRequiredBody")}
|
||||
</span>
|
||||
</div>
|
||||
{error && (
|
||||
<div className="text-sm text-error mb-3">{error}</div>
|
||||
)}
|
||||
<button
|
||||
onClick={startSetup}
|
||||
disabled={busy !== null}
|
||||
className="px-4 py-2 rounded-md bg-accent text-surface-0 text-sm disabled:opacity-50"
|
||||
>
|
||||
{busy === "setup" ? t("savedCardRedirecting") : t("savedCardSetupBtn")}
|
||||
</button>
|
||||
{/* Bank-transfer hint shown only for company accounts.
|
||||
Personal (B2C) accounts pay by card only — surfacing
|
||||
the alternative would only confuse. */}
|
||||
{!isPersonal && (
|
||||
<p className="text-xs text-text-muted mt-4">
|
||||
{t("savedCardBankTransferHint")}{" "}
|
||||
<a
|
||||
href="/support"
|
||||
className="text-accent hover:underline"
|
||||
>
|
||||
{t("savedCardBankTransferLink")}
|
||||
</a>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// Card on file.
|
||||
const brandLabel =
|
||||
config?.stripePmBrand
|
||||
? BRAND_LABELS[config.stripePmBrand] ?? config.stripePmBrand
|
||||
: t("savedCardBrandUnknown");
|
||||
const last4 = config?.stripePmLast4 ?? "????";
|
||||
const expMonth = config?.stripePmExpMonth;
|
||||
const expYear = config?.stripePmExpYear;
|
||||
const expLabel =
|
||||
expMonth && expYear
|
||||
? `${String(expMonth).padStart(2, "0")}/${String(expYear).slice(-2)}`
|
||||
: "";
|
||||
// Heuristic for "expiring soon" — if the card expires this calendar
|
||||
// month or next. Stripe's pre-expiration emails handle the real
|
||||
// notification, but a portal hint is friendly too.
|
||||
const now = new Date();
|
||||
const expiringSoon =
|
||||
expMonth &&
|
||||
expYear &&
|
||||
(expYear < now.getFullYear() ||
|
||||
(expYear === now.getFullYear() && expMonth <= now.getMonth() + 2));
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>{t("savedCardHeading")}</CardHeader>
|
||||
<div className="p-5">
|
||||
<div className="flex items-center justify-between mb-4 flex-wrap gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="font-mono text-sm">
|
||||
{brandLabel} •••• {last4}
|
||||
</span>
|
||||
{expLabel && (
|
||||
<span
|
||||
className={`text-xs ${
|
||||
expiringSoon ? "text-warning" : "text-text-muted"
|
||||
}`}
|
||||
>
|
||||
{t("savedCardExpires", { date: expLabel })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-xs">
|
||||
<span
|
||||
className={`px-2 py-0.5 rounded text-xs ${
|
||||
autoChargeOn
|
||||
? "bg-success/15 text-success"
|
||||
: "bg-text-muted/15 text-text-muted"
|
||||
}`}
|
||||
>
|
||||
{autoChargeOn
|
||||
? t("savedCardAutoChargeOn")
|
||||
: t("savedCardAutoChargeOff")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isPayByInvoice && (
|
||||
<div className="text-xs text-text-muted bg-surface-3 rounded-md px-3 py-2 mb-3">
|
||||
{t("savedCardPayByInvoiceNote")}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* If the card is on file but the customer has actively
|
||||
disabled auto-pay, surface the suspension-risk reminder.
|
||||
Not shown when admin has flipped them to pay-by-invoice —
|
||||
that's a different deal and the note above explains it. */}
|
||||
{!isPayByInvoice && !autoChargeOn && (
|
||||
<div className="text-xs rounded-md border border-warning/40 bg-warning/10 text-warning px-3 py-2 mb-3">
|
||||
{t("savedCardAutoPayDisabledNote")}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && <div className="text-sm text-error mb-3">{error}</div>}
|
||||
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<button
|
||||
onClick={startSetup}
|
||||
disabled={busy !== null}
|
||||
className="px-3 py-1.5 rounded-md border border-border text-sm disabled:opacity-50 hover:bg-surface-3"
|
||||
>
|
||||
{busy === "setup"
|
||||
? t("savedCardRedirecting")
|
||||
: t("savedCardUpdateBtn")}
|
||||
</button>
|
||||
<button
|
||||
onClick={removeCard}
|
||||
disabled={busy !== null}
|
||||
className="px-3 py-1.5 rounded-md border border-error text-error text-sm disabled:opacity-50 hover:bg-error/10 ml-auto"
|
||||
>
|
||||
{busy === "remove"
|
||||
? t("savedCardRemoving")
|
||||
: t("savedCardRemoveBtn")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Bank-transfer hint shown only for company accounts. */}
|
||||
{!isPersonal && (
|
||||
<p className="text-xs text-text-muted mt-4">
|
||||
{t("savedCardBankTransferHint")}{" "}
|
||||
<a
|
||||
href="/support"
|
||||
className="text-accent hover:underline"
|
||||
>
|
||||
{t("savedCardBankTransferLink")}
|
||||
</a>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -119,7 +119,7 @@ export function TicketCreateForm() {
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
className="text-sm font-medium px-4 py-2 rounded-lg bg-accent text-white hover:bg-accent/90 transition-colors disabled:opacity-50"
|
||||
className="text-sm font-medium px-4 py-2 rounded-lg bg-accent text-surface-0 hover:bg-accent/90 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{submitting ? tCommon("loading") : t("submitTicket")}
|
||||
</button>
|
||||
|
||||
@@ -186,7 +186,7 @@ export function TicketThread({
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting || closing || body.trim().length === 0}
|
||||
className="text-sm font-medium px-4 py-2 rounded-lg bg-accent text-white hover:bg-accent/90 transition-colors disabled:opacity-50"
|
||||
className="text-sm font-medium px-4 py-2 rounded-lg bg-accent text-surface-0 hover:bg-accent/90 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{submitting ? tCommon("loading") : t("sendReply")}
|
||||
</button>
|
||||
|
||||
219
src/components/team/access-overview.tsx
Normal file
219
src/components/team/access-overview.tsx
Normal file
@@ -0,0 +1,219 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
/**
|
||||
* AccessOverview
|
||||
*
|
||||
* Read-only "who can reach which assistant" matrix for owners. Access
|
||||
* was previously only visible per-tenant (the AssignedUsersPanel on each
|
||||
* tenant page) and per-member (the team roster) — with no single place
|
||||
* to see the whole picture, which made it easy to lose track across
|
||||
* several tenants and members.
|
||||
*
|
||||
* This composes existing endpoints only (no new API surface):
|
||||
* - GET /api/team → org members
|
||||
* - GET /api/tenants → the org's tenants
|
||||
* - GET /api/tenants/{name}/assignments → per-tenant assignees
|
||||
*
|
||||
* Owners implicitly see every tenant, so their row is marked
|
||||
* "all assistants" rather than per-cell.
|
||||
*/
|
||||
|
||||
interface Member {
|
||||
userId: string;
|
||||
email: string;
|
||||
displayName?: string;
|
||||
roles: string[];
|
||||
}
|
||||
|
||||
interface TenantLite {
|
||||
name: string;
|
||||
displayName: string;
|
||||
}
|
||||
|
||||
export function AccessOverview() {
|
||||
const t = useTranslations("team");
|
||||
|
||||
const [members, setMembers] = useState<Member[] | null>(null);
|
||||
const [tenants, setTenants] = useState<TenantLite[] | null>(null);
|
||||
// tenant name → set of assigned userIds
|
||||
const [assignments, setAssignments] = useState<Record<string, Set<string>>>(
|
||||
{}
|
||||
);
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const [teamRes, tenantsRes] = await Promise.all([
|
||||
fetch("/api/team"),
|
||||
fetch("/api/tenants"),
|
||||
]);
|
||||
if (!teamRes.ok || !tenantsRes.ok) throw new Error("load");
|
||||
|
||||
const teamData = await teamRes.json();
|
||||
const tenantsData = await tenantsRes.json();
|
||||
|
||||
const mem: Member[] = teamData.members ?? [];
|
||||
const ten: TenantLite[] = (tenantsData ?? []).map((x: any) => ({
|
||||
name: x.metadata.name,
|
||||
displayName: x.spec?.displayName || x.metadata.name,
|
||||
}));
|
||||
|
||||
// Per-tenant assignment lookups in parallel. A failed lookup
|
||||
// degrades to "no assignees" for that tenant rather than
|
||||
// failing the whole view.
|
||||
const entries = await Promise.all(
|
||||
ten.map(async (tn) => {
|
||||
try {
|
||||
const r = await fetch(
|
||||
`/api/tenants/${encodeURIComponent(tn.name)}/assignments`
|
||||
);
|
||||
if (!r.ok) return [tn.name, new Set<string>()] as const;
|
||||
const data = await r.json();
|
||||
const ids = new Set<string>(
|
||||
(data.assignments ?? data ?? []).map((a: any) => a.userId)
|
||||
);
|
||||
return [tn.name, ids] as const;
|
||||
} catch {
|
||||
return [tn.name, new Set<string>()] as const;
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
if (cancelled) return;
|
||||
setMembers(mem);
|
||||
setTenants(ten);
|
||||
setAssignments(Object.fromEntries(entries));
|
||||
} catch {
|
||||
if (!cancelled) setError(t("accessLoadFailed"));
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [t]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="bg-surface-1 border border-border rounded-xl p-6 animate-pulse">
|
||||
<div className="h-4 w-40 bg-surface-3 rounded mb-4" />
|
||||
<div className="h-24 bg-surface-2 rounded" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="bg-surface-1 border border-border rounded-xl p-6">
|
||||
<p className="text-sm text-text-secondary">{error}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!tenants || tenants.length === 0) {
|
||||
return (
|
||||
<div className="bg-surface-1 border border-border rounded-xl p-6">
|
||||
<p className="text-sm text-text-secondary">{t("accessNoTenants")}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const isOwner = (m: Member) => m.roles?.includes("owner");
|
||||
|
||||
return (
|
||||
<div className="bg-surface-1 border border-border rounded-xl overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm border-collapse">
|
||||
<thead>
|
||||
<tr className="border-b border-border">
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-text-muted sticky left-0 bg-surface-1">
|
||||
{t("accessMemberCol")}
|
||||
</th>
|
||||
{tenants.map((tn) => (
|
||||
<th
|
||||
key={tn.name}
|
||||
className="px-3 py-3 text-center text-xs font-semibold text-text-secondary min-w-[7rem]"
|
||||
title={tn.name}
|
||||
>
|
||||
{tn.displayName}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(members ?? []).map((m) => (
|
||||
<tr
|
||||
key={m.userId}
|
||||
className="border-b border-border last:border-0 hover:bg-surface-2/50 transition-colors"
|
||||
>
|
||||
<td className="px-4 py-3 sticky left-0 bg-surface-1">
|
||||
<div className="text-sm text-text-primary truncate max-w-[14rem]">
|
||||
{m.displayName || m.email}
|
||||
</div>
|
||||
<div className="text-xs text-text-muted truncate max-w-[14rem]">
|
||||
{m.email}
|
||||
</div>
|
||||
</td>
|
||||
{tenants.map((tn) => {
|
||||
const owner = isOwner(m);
|
||||
const has = owner || assignments[tn.name]?.has(m.userId);
|
||||
const label = owner
|
||||
? t("accessOwnerAll")
|
||||
: has
|
||||
? t("accessHasLabel")
|
||||
: t("accessHasNotLabel");
|
||||
return (
|
||||
<td
|
||||
key={tn.name}
|
||||
className="px-3 py-3 text-center"
|
||||
title={label}
|
||||
>
|
||||
<span className="sr-only">{label}</span>
|
||||
{owner ? (
|
||||
<span aria-hidden="true" className="text-accent">
|
||||
●
|
||||
</span>
|
||||
) : has ? (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="text-emerald-400 font-semibold"
|
||||
>
|
||||
✓
|
||||
</span>
|
||||
) : (
|
||||
<span aria-hidden="true" className="text-text-muted/50">
|
||||
–
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="px-4 py-2.5 border-t border-border flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-text-muted">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="text-accent">●</span> {t("accessOwnerAll")}
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="text-emerald-400 font-semibold">✓</span>{" "}
|
||||
{t("accessHasLabel")}
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="text-text-muted/50">–</span> {t("accessHasNotLabel")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -141,7 +141,7 @@ export function InviteForm() {
|
||||
<button
|
||||
type="submit"
|
||||
disabled={state === "submitting"}
|
||||
className="w-full py-2.5 px-4 bg-accent text-white text-sm font-medium rounded-lg hover:bg-accent-dim transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
className="w-full py-2.5 px-4 bg-accent text-surface-0 text-sm font-medium rounded-lg hover:bg-accent-dim transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{state === "submitting" ? tCommon("loading") : t("inviteButton")}
|
||||
</button>
|
||||
|
||||
@@ -179,7 +179,7 @@ export function TeamList({
|
||||
type="button"
|
||||
onClick={() => saveEdit(m)}
|
||||
disabled={submitting || !m.authorizationId}
|
||||
className="text-xs px-2.5 py-1 rounded-md bg-accent text-white hover:bg-accent-dim transition-colors disabled:opacity-50"
|
||||
className="text-xs px-2.5 py-1 rounded-md bg-accent text-surface-0 hover:bg-accent-dim transition-colors disabled:opacity-50"
|
||||
>
|
||||
{t("save")}
|
||||
</button>
|
||||
|
||||
@@ -218,7 +218,7 @@ export function AssignedUsersPanel({ tenantName, canEdit }: Props) {
|
||||
<button
|
||||
onClick={handleAssign}
|
||||
disabled={busy || !pickedUserId}
|
||||
className="px-4 py-2 text-sm font-medium bg-accent text-white rounded-lg hover:bg-accent-dim transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
className="px-4 py-2 text-sm font-medium bg-accent text-surface-0 rounded-lg hover:bg-accent-dim transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{busy ? "…" : t("assign")}
|
||||
</button>
|
||||
|
||||
234
src/components/tenants/connect-panel.tsx
Normal file
234
src/components/tenants/connect-panel.tsx
Normal file
@@ -0,0 +1,234 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { THREEMA_GATEWAY } from "@/lib/threema-gateway-config";
|
||||
|
||||
/**
|
||||
* ConnectPanel
|
||||
*
|
||||
* The portal is a *management* console — config, billing, usage — but
|
||||
* the assistant itself lives in the customer's messaging app. Nothing
|
||||
* previously told the customer how to actually start talking to the
|
||||
* thing they just provisioned ("Your assistant is ready… now what?").
|
||||
*
|
||||
* This panel closes that gap on the tenant-detail page: for each
|
||||
* enabled channel it shows the concrete first-contact steps, and when
|
||||
* NO channel is enabled it says so explicitly (a running assistant with
|
||||
* no channel is unreachable).
|
||||
*
|
||||
* Once a customer has connected they don't need the steps every visit,
|
||||
* so the panel is dismissible: clicking "I've connected" collapses it
|
||||
* to a slim row and remembers that per-tenant (localStorage). The slim
|
||||
* row keeps a "Show connection details" toggle so it's never lost.
|
||||
* The no-channel warning is NOT dismissible — it's an actionable alert,
|
||||
* not reference material.
|
||||
*
|
||||
* It is intentionally complementary to ChannelUsers below it:
|
||||
* - ConnectPanel → "how do *I* reach the assistant"
|
||||
* - ChannelUsers → "*who* is allowed to reach it"
|
||||
*/
|
||||
|
||||
// Render order is fixed (not the order packages happen to appear in
|
||||
// spec.packages) so the panel layout is stable across tenants.
|
||||
const CHANNEL_ORDER = ["threema", "telegram", "discord"] as const;
|
||||
|
||||
const CHANNEL_NAMES: Record<string, string> = {
|
||||
threema: "Threema",
|
||||
telegram: "Telegram",
|
||||
discord: "Discord",
|
||||
};
|
||||
|
||||
// Per-channel instruction key in the `connect` message namespace.
|
||||
const CHANNEL_STEPS_KEY: Record<string, string> = {
|
||||
threema: "threemaSteps",
|
||||
telegram: "telegramSteps",
|
||||
discord: "discordSteps",
|
||||
};
|
||||
|
||||
const dismissKey = (tenantName: string) =>
|
||||
`pieced:connect-hidden:${tenantName}`;
|
||||
|
||||
export function ConnectPanel({
|
||||
tenantName,
|
||||
enabledChannels,
|
||||
phase,
|
||||
}: {
|
||||
tenantName: string;
|
||||
enabledChannels: string[];
|
||||
/** Tenant phase — connection details only "work" once it's Ready. */
|
||||
phase: string;
|
||||
}) {
|
||||
const t = useTranslations("connect");
|
||||
|
||||
const channels = CHANNEL_ORDER.filter((c) => enabledChannels.includes(c));
|
||||
const ready = phase === "Ready" || phase === "Running" || phase === "Active";
|
||||
|
||||
// Dismissed state is read from localStorage after mount to avoid a
|
||||
// hydration mismatch (server has no localStorage). `hydrated` gates
|
||||
// the collapsed view so the first paint matches the server output.
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const [hydrated, setHydrated] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
setCollapsed(localStorage.getItem(dismissKey(tenantName)) === "1");
|
||||
} catch {
|
||||
/* private mode / storage disabled — just stay expanded */
|
||||
}
|
||||
setHydrated(true);
|
||||
}, [tenantName]);
|
||||
|
||||
const dismiss = () => {
|
||||
setCollapsed(true);
|
||||
try {
|
||||
localStorage.setItem(dismissKey(tenantName), "1");
|
||||
} catch {
|
||||
/* no-op */
|
||||
}
|
||||
};
|
||||
|
||||
const reopen = () => {
|
||||
setCollapsed(false);
|
||||
try {
|
||||
localStorage.removeItem(dismissKey(tenantName));
|
||||
} catch {
|
||||
/* no-op */
|
||||
}
|
||||
};
|
||||
|
||||
// No channel at all → the assistant is unreachable. Make it loud and
|
||||
// keep it non-dismissible (it's an alert, not reference material).
|
||||
if (channels.length === 0) {
|
||||
return (
|
||||
<div className="rounded-xl border border-amber-500/30 bg-amber-500/10 p-5">
|
||||
<div className="flex items-start gap-3">
|
||||
<svg
|
||||
className="h-5 w-5 text-amber-400 shrink-0 mt-0.5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zM12 15.75h.008v.008H12v-.008z"
|
||||
/>
|
||||
</svg>
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-semibold text-amber-300">
|
||||
{t("noChannelsTitle")}
|
||||
</div>
|
||||
<p className="text-xs text-text-secondary mt-1 leading-relaxed">
|
||||
{t("noChannelsBody")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Collapsed: a slim, unobtrusive row with a toggle to bring the full
|
||||
// panel back. Only shown once hydrated so SSR/CSR agree.
|
||||
if (hydrated && collapsed) {
|
||||
return (
|
||||
<div className="flex items-center justify-between rounded-lg border border-border bg-surface-1 px-4 py-2">
|
||||
<span className="text-xs text-text-muted">{t("title")}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={reopen}
|
||||
className="text-xs font-medium text-accent hover:text-accent-dim transition-colors cursor-pointer"
|
||||
>
|
||||
{t("show")}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-accent/30 bg-accent/5 p-5">
|
||||
<div className="flex items-start justify-between gap-3 mb-1">
|
||||
<h2 className="font-display text-base font-semibold text-text-primary">
|
||||
{t("title")}
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={dismiss}
|
||||
className="shrink-0 inline-flex items-center gap-1 text-xs font-medium text-text-muted hover:text-text-secondary transition-colors cursor-pointer"
|
||||
>
|
||||
<svg
|
||||
className="h-3.5 w-3.5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M4.5 12.75l6 6 9-13.5"
|
||||
/>
|
||||
</svg>
|
||||
{t("dismiss")}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-text-secondary mb-4 leading-relaxed">
|
||||
{t("description")}
|
||||
</p>
|
||||
|
||||
{!ready && (
|
||||
<p className="text-xs text-amber-300 bg-amber-500/10 border border-amber-500/20 rounded-lg px-3 py-2 mb-4 leading-relaxed">
|
||||
{t("notReadyNote")}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="space-y-3">
|
||||
{channels.map((c) => (
|
||||
<div
|
||||
key={c}
|
||||
className="rounded-lg border border-border bg-surface-1 p-3"
|
||||
>
|
||||
<div className="text-sm font-medium text-text-primary mb-1.5">
|
||||
{CHANNEL_NAMES[c]}
|
||||
</div>
|
||||
|
||||
{c === "threema" ? (
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="bg-white p-1.5 rounded-md shrink-0">
|
||||
{/* Shared gateway QR — identical for every tenant, so
|
||||
it can render before/after provisioning alike.
|
||||
eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={THREEMA_GATEWAY.qrCodePath}
|
||||
alt={`QR code for ${THREEMA_GATEWAY.displayName}`}
|
||||
width={88}
|
||||
height={88}
|
||||
style={{ display: "block" }}
|
||||
/>
|
||||
</div>
|
||||
<div className="text-xs text-text-secondary leading-relaxed">
|
||||
<div className="mb-1.5">
|
||||
<span className="text-text-muted">
|
||||
{t("threemaBotIdLabel")}:{" "}
|
||||
</span>
|
||||
<span className="font-mono text-sm text-accent">
|
||||
{THREEMA_GATEWAY.displayName}
|
||||
</span>
|
||||
</div>
|
||||
<div className="whitespace-pre-line">{t("threemaSteps")}</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-text-secondary leading-relaxed whitespace-pre-line">
|
||||
{t(CHANNEL_STEPS_KEY[c])}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
58
src/components/ui/button.tsx
Normal file
58
src/components/ui/button.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import { forwardRef } from "react";
|
||||
|
||||
/**
|
||||
* Shared button primitive.
|
||||
*
|
||||
* Why this exists
|
||||
* ---------------
|
||||
* The accent fill (#00d4aa) is bright; white text on it measures ~1.9:1,
|
||||
* which fails WCAG even for large/UI text. Dark text (surface-0) on the
|
||||
* same accent is ~10:1. The codebase had ~40 hand-rolled accent buttons,
|
||||
* most using `text-white`. This component centralises the correct token
|
||||
* (`text-surface-0` on accent) so the contrast can't drift again — reach
|
||||
* for `<Button>` instead of re-deriving the class string.
|
||||
*/
|
||||
|
||||
type Variant = "primary" | "secondary" | "ghost" | "danger";
|
||||
type Size = "sm" | "md";
|
||||
|
||||
const BASE =
|
||||
"inline-flex items-center justify-center gap-1.5 font-medium rounded-lg " +
|
||||
"transition-colors cursor-pointer focus:outline-none focus-visible:ring-2 " +
|
||||
"focus-visible:ring-accent/50 disabled:opacity-50 disabled:cursor-not-allowed";
|
||||
|
||||
const VARIANTS: Record<Variant, string> = {
|
||||
// surface-0 (dark) text — the contrast-correct pairing for the accent.
|
||||
primary: "bg-accent text-surface-0 hover:bg-accent-dim shadow-sm shadow-accent/20",
|
||||
secondary:
|
||||
"bg-surface-2 text-text-primary border border-border hover:bg-surface-3 hover:border-border-active",
|
||||
ghost: "text-text-secondary hover:text-text-primary hover:bg-surface-2",
|
||||
danger: "bg-error text-surface-0 hover:opacity-90",
|
||||
};
|
||||
|
||||
const SIZES: Record<Size, string> = {
|
||||
sm: "text-xs px-3 py-1.5",
|
||||
md: "text-sm px-4 py-2",
|
||||
};
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: Variant;
|
||||
size?: Size;
|
||||
}
|
||||
|
||||
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
function Button(
|
||||
{ variant = "primary", size = "md", className = "", type = "button", ...rest },
|
||||
ref
|
||||
) {
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
type={type}
|
||||
className={`${BASE} ${VARIANTS[variant]} ${SIZES[size]} ${className}`}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
@@ -16,6 +16,9 @@ interface Props {
|
||||
ariaLabel?: string;
|
||||
}
|
||||
|
||||
const FOCUSABLE =
|
||||
'a[href],button:not([disabled]),textarea:not([disabled]),input:not([disabled]),select:not([disabled]),[tabindex]:not([tabindex="-1"])';
|
||||
|
||||
/**
|
||||
* Portal-based modal.
|
||||
*
|
||||
@@ -25,45 +28,86 @@ interface Props {
|
||||
* ancestor's containing block, not the viewport, when ANY ancestor
|
||||
* has a `transform`, `perspective`, or `filter` applied. Our
|
||||
* `animate-in` utility sets `transform: translateY(0)` on a lot of
|
||||
* dashboard/tenant-detail containers (because of the fade-up
|
||||
* animation, which uses `animation-fill-mode: both` to keep the
|
||||
* transform on after the animation finishes). That broke modals
|
||||
* rendered as in-place children — they centred to the panel they
|
||||
* lived in, not to the page.
|
||||
* dashboard/tenant-detail containers, which broke modals rendered as
|
||||
* in-place children — they centred to the panel they lived in, not to
|
||||
* the page. Rendering at `document.body` via `createPortal` escapes
|
||||
* every containing-block ancestor and gives us true viewport coords.
|
||||
*
|
||||
* Rendering at `document.body` via `createPortal` escapes every
|
||||
* containing-block ancestor and gives us true viewport coordinates.
|
||||
*
|
||||
* UX details
|
||||
* ----------
|
||||
* - Backdrop click triggers `onClose`. (Bubbling check: only fires
|
||||
* when the click target IS the backdrop, not the panel inside.)
|
||||
* - Escape key triggers `onClose`. Standard modal expectation.
|
||||
* - `body` overflow is locked while open so background content
|
||||
* doesn't scroll behind the modal.
|
||||
* - Renders nothing on first paint server-side, then mounts on
|
||||
* client. `useEffect` gating ensures `document.body` is available;
|
||||
* without it Next.js SSR would throw on `document` reference.
|
||||
* UX / a11y details
|
||||
* -----------------
|
||||
* - Backdrop click triggers `onClose` (only when the click target IS
|
||||
* the backdrop, not the panel inside).
|
||||
* - Escape triggers `onClose`.
|
||||
* - `body` overflow is locked while open so background content doesn't
|
||||
* scroll behind the modal.
|
||||
* - Focus is moved into the panel on open, trapped within it while open
|
||||
* (Tab / Shift+Tab cycle), and restored to the previously focused
|
||||
* element on close — so keyboard and screen-reader users can't tab
|
||||
* out to the inert page behind the dialog.
|
||||
*/
|
||||
export function Modal({ open, onClose, children, ariaLabel }: Props) {
|
||||
const closeRef = useRef(onClose);
|
||||
closeRef.current = onClose;
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
// Lock background scroll. Restore on unmount/close.
|
||||
// Remember what had focus so we can restore it on close.
|
||||
const previouslyFocused = document.activeElement as HTMLElement | null;
|
||||
|
||||
// Lock background scroll.
|
||||
const previousOverflow = document.body.style.overflow;
|
||||
document.body.style.overflow = "hidden";
|
||||
|
||||
// Move focus into the dialog — first focusable element, else the
|
||||
// panel itself (it carries tabIndex={-1}).
|
||||
const panel = panelRef.current;
|
||||
const focusables = panel
|
||||
? Array.from(panel.querySelectorAll<HTMLElement>(FOCUSABLE))
|
||||
: [];
|
||||
(focusables[0] ?? panel)?.focus();
|
||||
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") closeRef.current();
|
||||
if (e.key === "Escape") {
|
||||
closeRef.current();
|
||||
return;
|
||||
}
|
||||
if (e.key !== "Tab" || !panel) return;
|
||||
|
||||
// Re-query each time — modal content can change between tabs.
|
||||
const items = Array.from(
|
||||
panel.querySelectorAll<HTMLElement>(FOCUSABLE)
|
||||
).filter((el) => el.offsetParent !== null || el === document.activeElement);
|
||||
if (items.length === 0) {
|
||||
e.preventDefault();
|
||||
panel.focus();
|
||||
return;
|
||||
}
|
||||
const first = items[0];
|
||||
const last = items[items.length - 1];
|
||||
const active = document.activeElement;
|
||||
|
||||
if (e.shiftKey) {
|
||||
if (active === first || active === panel) {
|
||||
e.preventDefault();
|
||||
last.focus();
|
||||
}
|
||||
} else if (active === last) {
|
||||
e.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", onKey);
|
||||
|
||||
return () => {
|
||||
document.body.style.overflow = previousOverflow;
|
||||
window.removeEventListener("keydown", onKey);
|
||||
// Restore focus to the trigger (if it's still in the document).
|
||||
if (previouslyFocused && document.contains(previouslyFocused)) {
|
||||
previouslyFocused.focus();
|
||||
}
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
@@ -72,15 +116,19 @@ export function Modal({ open, onClose, children, ariaLabel }: Props) {
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={ariaLabel}
|
||||
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm"
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) onClose();
|
||||
}}
|
||||
>
|
||||
<div className="bg-surface-1 border border-border rounded-xl p-6 max-w-md w-full max-h-[90vh] overflow-y-auto">
|
||||
<div
|
||||
ref={panelRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={ariaLabel}
|
||||
tabIndex={-1}
|
||||
className="bg-surface-1 border border-border rounded-xl p-6 max-w-md w-full max-h-[90vh] overflow-y-auto focus:outline-none"
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>,
|
||||
|
||||
@@ -49,7 +49,31 @@ export const authConfig: NextAuthConfig = {
|
||||
},
|
||||
],
|
||||
callbacks: {
|
||||
async jwt({ token, account, profile }) {
|
||||
async jwt({ token, account, profile, trigger, session }) {
|
||||
// Phase 6 fix5: client-side `useSession().update({ name })` calls
|
||||
// route through this branch. We trust the new value because the
|
||||
// PUT /api/settings/profile route already wrote it to ZITADEL
|
||||
// and re-fetched the canonical displayName before returning.
|
||||
// The session callback reads token.name directly (see below) so
|
||||
// the update propagates without depending on auth.js's implicit
|
||||
// token→session.user mapping, which is flaky for the name claim
|
||||
// in the v5 OIDC provider configuration.
|
||||
//
|
||||
// Defensive: only the `name` field is accepted from the update
|
||||
// payload, even if the client passes additional keys. Other
|
||||
// identity claims (orgId, roles, sub) come from ZITADEL at
|
||||
// sign-in time and are not user-mutable from a settings page.
|
||||
//
|
||||
// Returns a NEW token object (spread) rather than mutating, so
|
||||
// there is no ambiguity for auth.js about whether the token
|
||||
// changed and needs re-encoding into the session cookie.
|
||||
if (trigger === "update" && session) {
|
||||
const update = session as { name?: unknown };
|
||||
if (typeof update.name === "string") {
|
||||
return { ...token, name: update.name };
|
||||
}
|
||||
return token;
|
||||
}
|
||||
if (account && profile) {
|
||||
const claims = profile as unknown as ZitadelClaims;
|
||||
token.orgId = claims["urn:zitadel:iam:user:resourceowner:id"];
|
||||
@@ -58,6 +82,19 @@ export const authConfig: NextAuthConfig = {
|
||||
claims["urn:zitadel:iam:org:project:roles"]
|
||||
);
|
||||
token.accessToken = account.access_token;
|
||||
// Phase 6 fix5: explicitly pin the standard name/email claims
|
||||
// onto the token from the OIDC profile. Previously these came
|
||||
// through auth.js's implicit mapping, which works on first
|
||||
// sign-in but isn't reliable after update() — once the update
|
||||
// path overrides token.name, the read-back path needs token
|
||||
// to be the authoritative source. Setting them explicitly
|
||||
// here keeps sign-in and update on the same path.
|
||||
if (typeof profile.name === "string") {
|
||||
token.name = profile.name;
|
||||
}
|
||||
if (typeof profile.email === "string") {
|
||||
token.email = profile.email;
|
||||
}
|
||||
// Pin token.sub to the OIDC subject. Auth.js v5 otherwise puts a
|
||||
// freshly generated UUID in token.sub on initial sign-in,
|
||||
// ignoring what profile() returns for `id`. That UUID then
|
||||
@@ -80,10 +117,19 @@ export const authConfig: NextAuthConfig = {
|
||||
async session({ session, token }) {
|
||||
const roles = (token.roles as Role[]) ?? [];
|
||||
const orgName = (token.orgName as string) ?? "";
|
||||
// Phase 6 fix5: read name and email directly from the token.
|
||||
// Previously this code relied on `session.user?.name`, expecting
|
||||
// auth.js to map token.name → session.user.name automatically.
|
||||
// That mapping is brittle: it works on first sign-in (because
|
||||
// OIDC profile() populates session.user) but not after update()
|
||||
// overrides token.name. Reading from token is the canonical
|
||||
// path regardless of how the token was last written.
|
||||
const tokenName = (token.name as string | undefined) ?? "";
|
||||
const tokenEmail = (token.email as string | undefined) ?? "";
|
||||
const sessionUser: SessionUser = {
|
||||
id: token.sub!,
|
||||
name: session.user?.name ?? "",
|
||||
email: session.user?.email ?? "",
|
||||
name: tokenName || session.user?.name || "",
|
||||
email: tokenEmail || session.user?.email || "",
|
||||
orgId: token.orgId as string,
|
||||
orgName,
|
||||
roles,
|
||||
@@ -96,6 +142,14 @@ export const authConfig: NextAuthConfig = {
|
||||
isPersonal: isPersonalOrgName(orgName),
|
||||
};
|
||||
(session as any).platformUser = sessionUser;
|
||||
// Also overwrite session.user so any client-side code that uses
|
||||
// the standard NextAuth shape (session.user.name) sees the new
|
||||
// value. Pre-fix5 code paths read from session.user.name; this
|
||||
// keeps them working without per-component changes.
|
||||
if (session.user) {
|
||||
session.user.name = sessionUser.name;
|
||||
session.user.email = sessionUser.email;
|
||||
}
|
||||
return session;
|
||||
},
|
||||
},
|
||||
|
||||
@@ -153,5 +153,21 @@ export function formatLineDescription(
|
||||
}[L];
|
||||
return reason ? `${base}: ${reason}` : base;
|
||||
}
|
||||
|
||||
// Phase 8: custom invoice lines. The description is what the
|
||||
// admin typed in the editor — return it verbatim (no template,
|
||||
// no locale-specific formatting). billing.ts persists the
|
||||
// already-trimmed admin input into invoice_lines.description.
|
||||
case "custom_line": {
|
||||
const dRaw = (m as Record<string, unknown>)["description"];
|
||||
if (typeof dRaw === "string" && dRaw.trim().length > 0) return dRaw;
|
||||
// Fallback: the description column on the row itself. The
|
||||
// PDF renderer hands us the line so it can read it directly
|
||||
// — see how billing-pdf invokes formatLineDescription.
|
||||
const onRow = (line as unknown as { description?: string }).description;
|
||||
return onRow && onRow.trim().length > 0
|
||||
? onRow
|
||||
: { de: "Leistung", en: "Service", fr: "Service", it: "Servizio" }[L];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,44 +31,18 @@ import {
|
||||
Text,
|
||||
View,
|
||||
StyleSheet,
|
||||
Svg,
|
||||
Polygon,
|
||||
Polyline,
|
||||
renderToBuffer,
|
||||
} from "@react-pdf/renderer";
|
||||
import type { Invoice, InvoiceLine, InvoiceLineKind } from "@/types";
|
||||
import { BRAND, Logo } from "./pdf-brand";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Brand constants — edit here to tweak look without touching layout
|
||||
// Brand: imported from lib/pdf-brand. Edit there to change issuer
|
||||
// info, colours, or the logo. Both billing-pdf.tsx and credit-note-pdf.tsx
|
||||
// share the same source of truth so a brand change applies to every
|
||||
// PDF the portal produces.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const BRAND = {
|
||||
name: "PieCed IT",
|
||||
// Primary emerald — matches the logo SVG fill (#10B981).
|
||||
primary: "#10B981",
|
||||
// Slightly darker emerald for headings.
|
||||
primaryDark: "#0a8060",
|
||||
textColor: "#1a1a1a",
|
||||
mutedColor: "#666",
|
||||
borderColor: "#d4d4d4",
|
||||
// Issuer block — change these to your real legal info.
|
||||
issuer: {
|
||||
legalName: "PieCed IT",
|
||||
addressLine1: "Cedric Mosimann",
|
||||
addressLine2: "[Strasse Nr.]",
|
||||
postalCity: "[PLZ] Basel",
|
||||
country: "Switzerland",
|
||||
email: "billing@pieced.ch",
|
||||
web: "pieced.ch",
|
||||
// Show "MWST-Nr. ..." on PDF when set.
|
||||
vatNumber: null as string | null,
|
||||
// Bank instructions — Phase 7 replaces with QR-bill.
|
||||
bankName: "[Bank name]",
|
||||
bankIban: "[CHxx xxxx xxxx xxxx xxxx x]",
|
||||
bankBic: "[BIC]",
|
||||
},
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Localized strings
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -80,6 +54,11 @@ interface PdfStrings {
|
||||
dueDate: string;
|
||||
period: 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;
|
||||
quantity: string;
|
||||
unitPrice: string;
|
||||
@@ -107,6 +86,7 @@ const MESSAGES: Record<string, PdfStrings> = {
|
||||
dueDate: "Zahlbar bis",
|
||||
period: "Abrechnungsperiode",
|
||||
billTo: "Rechnungsempfänger",
|
||||
attentionPrefix: "z.Hd.",
|
||||
description: "Beschreibung",
|
||||
quantity: "Menge",
|
||||
unitPrice: "Einzelpreis",
|
||||
@@ -127,6 +107,7 @@ const MESSAGES: Record<string, PdfStrings> = {
|
||||
skill_usage: "Skill-Nutzung",
|
||||
skill_setup: "Einrichtungsgebühr Skill",
|
||||
adjustment: "Anpassung",
|
||||
custom_line: "Leistungen",
|
||||
},
|
||||
reverseCharge:
|
||||
"Steuerschuldnerschaft des Leistungsempfängers (Reverse Charge).",
|
||||
@@ -139,6 +120,7 @@ const MESSAGES: Record<string, PdfStrings> = {
|
||||
dueDate: "Due date",
|
||||
period: "Billing period",
|
||||
billTo: "Bill to",
|
||||
attentionPrefix: "Attn:",
|
||||
description: "Description",
|
||||
quantity: "Qty",
|
||||
unitPrice: "Unit price",
|
||||
@@ -159,6 +141,7 @@ const MESSAGES: Record<string, PdfStrings> = {
|
||||
skill_usage: "Skill usage",
|
||||
skill_setup: "Skill setup fee",
|
||||
adjustment: "Adjustment",
|
||||
custom_line: "Services",
|
||||
},
|
||||
reverseCharge:
|
||||
"Reverse charge — VAT to be accounted for by the recipient.",
|
||||
@@ -171,6 +154,7 @@ const MESSAGES: Record<string, PdfStrings> = {
|
||||
dueDate: "Échéance",
|
||||
period: "Période de facturation",
|
||||
billTo: "Destinataire",
|
||||
attentionPrefix: "À l'attention de",
|
||||
description: "Description",
|
||||
quantity: "Qté",
|
||||
unitPrice: "Prix unitaire",
|
||||
@@ -191,6 +175,7 @@ const MESSAGES: Record<string, PdfStrings> = {
|
||||
skill_usage: "Utilisation Skill",
|
||||
skill_setup: "Frais de configuration skill",
|
||||
adjustment: "Ajustement",
|
||||
custom_line: "Services",
|
||||
},
|
||||
reverseCharge:
|
||||
"Autoliquidation — TVA à acquitter par le destinataire.",
|
||||
@@ -203,6 +188,7 @@ const MESSAGES: Record<string, PdfStrings> = {
|
||||
dueDate: "Scadenza",
|
||||
period: "Periodo di fatturazione",
|
||||
billTo: "Destinatario",
|
||||
attentionPrefix: "c.a.",
|
||||
description: "Descrizione",
|
||||
quantity: "Qtà",
|
||||
unitPrice: "Prezzo unitario",
|
||||
@@ -223,6 +209,7 @@ const MESSAGES: Record<string, PdfStrings> = {
|
||||
skill_usage: "Utilizzo Skill",
|
||||
skill_setup: "Spese di attivazione skill",
|
||||
adjustment: "Rettifica",
|
||||
custom_line: "Servizi",
|
||||
},
|
||||
reverseCharge:
|
||||
"Inversione contabile — IVA a carico del destinatario.",
|
||||
@@ -349,62 +336,6 @@ const styles = StyleSheet.create({
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Logo — inlined SVG primitives
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* PieCed honeycomb logo. Re-renders the same 6-hex glyph as the
|
||||
* portal's `public/pieced-logo.svg` using React-PDF's SVG support.
|
||||
* Width/height are independent of the original viewBox so we can
|
||||
* scale it without losing stroke quality.
|
||||
*/
|
||||
const Logo = ({ size = 60 }: { size?: number }) => (
|
||||
<Svg width={size} height={size * (106 / 70)} viewBox="0 0 70 106">
|
||||
{/* H1 solid */}
|
||||
<Polygon
|
||||
points="38.5,22.69 31.5,10.566 17.5,10.566 10.5,22.69 17.5,34.814 31.5,34.814"
|
||||
fill="#10B981"
|
||||
stroke="#10B981"
|
||||
strokeWidth={1.6}
|
||||
/>
|
||||
{/* H2 outline */}
|
||||
<Polygon
|
||||
points="59.5,34.814 52.5,22.69 38.5,22.69 31.5,34.814 38.5,46.938 52.5,46.938"
|
||||
fill="none"
|
||||
stroke="#10B981"
|
||||
strokeWidth={1.8}
|
||||
/>
|
||||
{/* H3 outline */}
|
||||
<Polygon
|
||||
points="38.5,46.938 31.5,34.814 17.5,34.814 10.5,46.938 17.5,59.062 31.5,59.062"
|
||||
fill="none"
|
||||
stroke="#10B981"
|
||||
strokeWidth={1.8}
|
||||
/>
|
||||
{/* H4 solid */}
|
||||
<Polygon
|
||||
points="59.5,59.062 52.5,46.938 38.5,46.938 31.5,59.062 38.5,71.186 52.5,71.186"
|
||||
fill="#10B981"
|
||||
stroke="#10B981"
|
||||
strokeWidth={1.6}
|
||||
/>
|
||||
{/* H5 partial */}
|
||||
<Polyline
|
||||
points="31.5,83.31 38.5,71.186 31.5,59.062 17.5,59.062 10.5,71.186"
|
||||
fill="none"
|
||||
stroke="#10B981"
|
||||
strokeWidth={1.8}
|
||||
/>
|
||||
{/* H6 partial */}
|
||||
<Polyline
|
||||
points="59.5,83.31 52.5,71.186 38.5,71.186 31.5,83.31 38.5,95.434"
|
||||
fill="none"
|
||||
stroke="#10B981"
|
||||
strokeWidth={1.8}
|
||||
/>
|
||||
</Svg>
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -508,11 +439,18 @@ const InvoicePdf: React.FC<InvoicePdfProps> = ({ invoice, lines }) => {
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.metaCol}>
|
||||
{/* Phase 8: skip the billing-period block on custom
|
||||
invoices (which aren't tied to a period). Due date
|
||||
still renders. */}
|
||||
{invoice.periodStart && invoice.periodEnd && (
|
||||
<>
|
||||
<Text style={styles.metaLabel}>{s.period}</Text>
|
||||
<Text style={styles.metaValue}>
|
||||
{fmtDate(invoice.periodStart, invoice.locale)} —{" "}
|
||||
{fmtDate(invoice.periodEnd, invoice.locale)}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
<Text style={styles.metaLabel}>{s.dueDate}</Text>
|
||||
<Text style={styles.metaValue}>
|
||||
{fmtDate(invoice.dueAt, invoice.locale)}
|
||||
@@ -524,6 +462,15 @@ const InvoicePdf: React.FC<InvoicePdfProps> = ({ invoice, lines }) => {
|
||||
<View style={styles.billToBlock}>
|
||||
<Text style={styles.billToLabel}>{s.billTo}</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.postalCode} {snap.city}
|
||||
|
||||
1074
src/lib/billing.ts
1074
src/lib/billing.ts
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user