Compare commits
51 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| a3b080f542 | |||
| 229bfea263 | |||
| 49b085e59e |
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>
|
||||
);
|
||||
}
|
||||
@@ -2,8 +2,14 @@ import { getSessionUser } from "@/lib/session";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { redirect } from "next/navigation";
|
||||
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");
|
||||
@@ -19,6 +25,12 @@ export default async function AdminPage() {
|
||||
}
|
||||
|
||||
const tenants = await listTenants();
|
||||
// Phase 2.5: badge counter for the skill-activation admin queue.
|
||||
// Cheap COUNT(*) on a partial-indexed status='pending' column —
|
||||
// bounded by request volume and never expected to be high.
|
||||
const pendingSkillCount = await countPendingSkillActivationRequests().catch(
|
||||
() => 0
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -33,12 +45,33 @@ export default async function AdminPage() {
|
||||
than nav-shell entries — these are platform-team utilities,
|
||||
not main navigation. */}
|
||||
<div className="flex items-center gap-2">
|
||||
<a
|
||||
href="/admin/skills/pending"
|
||||
className={`text-sm px-4 py-2 rounded-lg border transition-colors flex items-center gap-2 ${
|
||||
pendingSkillCount > 0
|
||||
? "border-warning text-warning hover:bg-warning/10"
|
||||
: "border-border text-text-secondary hover:text-text-primary hover:border-text-secondary"
|
||||
}`}
|
||||
>
|
||||
<span>{t("skillsQueueTool")}</span>
|
||||
{pendingSkillCount > 0 && (
|
||||
<span className="text-xs px-1.5 py-0.5 rounded bg-warning text-surface-0 font-semibold">
|
||||
{pendingSkillCount}
|
||||
</span>
|
||||
)}
|
||||
</a>
|
||||
<a
|
||||
href="/admin/billing"
|
||||
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("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"
|
||||
|
||||
59
src/app/[locale]/admin/skills/pending/page.tsx
Normal file
59
src/app/[locale]/admin/skills/pending/page.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { getSessionUser } from "@/lib/session";
|
||||
import { listPendingSkillActivationRequests, getOrgBilling } from "@/lib/db";
|
||||
import { getPackageDef } from "@/lib/packages";
|
||||
import { BackLink } from "@/components/ui/back-link";
|
||||
import { PendingSkillRequests } from "@/components/admin/skills/pending-skill-requests";
|
||||
|
||||
/**
|
||||
* /admin/skills/pending — admin queue for manual-setup skill
|
||||
* activation requests. Each row shows tenant, skill, requester
|
||||
* info, and offers Approve / Reject actions.
|
||||
*
|
||||
* Server-renders the initial list. Approval/rejection trigger a
|
||||
* client-side fetch + router.refresh() so the row disappears and
|
||||
* the count updates without a hard reload.
|
||||
*/
|
||||
export default async function AdminPendingSkillRequestsPage() {
|
||||
const user = await getSessionUser();
|
||||
if (!user) redirect("/login");
|
||||
if (!user.isPlatform) redirect("/dashboard");
|
||||
const t = await getTranslations("adminSkills");
|
||||
|
||||
const pending = await listPendingSkillActivationRequests();
|
||||
|
||||
// Hydrate display fields: skill name from catalog, org company name
|
||||
// from billing. Skill name fallback to skillId for off-catalog
|
||||
// entries (shouldn't happen but defensive). Company name is
|
||||
// looked up lazily per row; dedup'd via a Map so we don't issue
|
||||
// duplicate getOrgBilling calls for the same org.
|
||||
const seenOrg = new Map<string, string | null>();
|
||||
const rows = await Promise.all(
|
||||
pending.map(async (r) => {
|
||||
if (!seenOrg.has(r.zitadelOrgId)) {
|
||||
const billing = await getOrgBilling(r.zitadelOrgId).catch(() => null);
|
||||
seenOrg.set(r.zitadelOrgId, billing?.companyName ?? null);
|
||||
}
|
||||
const def = getPackageDef(r.skillId);
|
||||
return {
|
||||
...r,
|
||||
skillName: def?.name ?? r.skillId,
|
||||
companyName: seenOrg.get(r.zitadelOrgId) ?? null,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
return (
|
||||
<main className="max-w-5xl mx-auto px-6 py-8">
|
||||
<BackLink href="/admin" label={t("backToAdmin")} />
|
||||
<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>
|
||||
<PendingSkillRequests initialRows={rows} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
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,
|
||||
}: {
|
||||
selected: boolean;
|
||||
onClick: () => void;
|
||||
label: string;
|
||||
description: string;
|
||||
icon: React.ReactNode;
|
||||
}) {
|
||||
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>
|
||||
|
||||
<BillingSettingsForm
|
||||
initial={billing}
|
||||
isPersonal={user.isPersonal}
|
||||
orgName={user.orgName}
|
||||
userName={user.name}
|
||||
userEmail={user.email}
|
||||
/>
|
||||
<div className="animate-in animate-in-delay-1">
|
||||
<BillingSettingsForm
|
||||
initial={existing}
|
||||
isPersonal={user.isPersonal}
|
||||
/>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,11 @@ import { getTranslations, getFormatter } from "next-intl/server";
|
||||
import { redirect, notFound } from "next/navigation";
|
||||
import { getTenant } from "@/lib/k8s";
|
||||
import { canUserSeeTenant } from "@/lib/visibility";
|
||||
import { getPendingResumeRequestForTenant } from "@/lib/db";
|
||||
import {
|
||||
getPendingResumeRequestForTenant,
|
||||
listSkillActivationRequestsForTenant,
|
||||
listSkillPricing,
|
||||
} from "@/lib/db";
|
||||
import { StatusBadge } from "@/components/ui/status-badge";
|
||||
import { WarningBadge } from "@/components/ui/warning-badge";
|
||||
import { UsageDisplay } from "@/components/dashboard/usage-display";
|
||||
@@ -12,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";
|
||||
|
||||
@@ -82,6 +87,17 @@ export default async function TenantDetailPage({
|
||||
);
|
||||
const channelUsers = tenant.spec.channelUsers || {};
|
||||
|
||||
// Phase 2.5: surface pending and most-recently-rejected skill
|
||||
// activation requests so PackageCard can render the inline
|
||||
// "Manual review pending" / "Activation rejected" states.
|
||||
// Pricing drives the cost-disclosure dialog before enable.
|
||||
// Both fetches are best-effort — an empty list is the safe
|
||||
// fallback if the DB call fails (cards just show normal toggles).
|
||||
const [activationRequests, skillPricing] = await Promise.all([
|
||||
listSkillActivationRequestsForTenant(name).catch(() => []),
|
||||
listSkillPricing().catch(() => []),
|
||||
]);
|
||||
|
||||
// Bug 19 fix: every viewer (customer or admin) passes the tenant
|
||||
// name to UsageDisplay. The /api/usage route resolves team+alias
|
||||
// from the tenant CR's status and applies the visibility check, so
|
||||
@@ -201,6 +217,19 @@ 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
|
||||
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">
|
||||
@@ -219,6 +248,8 @@ export default async function TenantDetailPage({
|
||||
enabledPackages={enabledPackages}
|
||||
conditions={tenant.status?.conditions}
|
||||
canEdit={canEdit}
|
||||
activationRequests={activationRequests}
|
||||
skillPricing={skillPricing}
|
||||
/>
|
||||
</section>
|
||||
|
||||
|
||||
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,33 +226,33 @@ 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();
|
||||
try {
|
||||
await recordTenantCreated(
|
||||
tenantName,
|
||||
tenantRequest.zitadelOrgId,
|
||||
billingAnchor
|
||||
);
|
||||
await recordSkillEvents(
|
||||
tenantName,
|
||||
tenantRequest.zitadelOrgId,
|
||||
packages,
|
||||
[],
|
||||
billingAnchor
|
||||
);
|
||||
} catch (e) {
|
||||
console.error(
|
||||
"billing: failed to record tenant creation / initial skill events:",
|
||||
e
|
||||
);
|
||||
// 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 {
|
||||
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(
|
||||
`[approve] Threema route create threw for tenant=${tenantName} id=${tid}:`,
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Step 5: Update request status — clear admin notes on re-approval
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
155
src/app/api/admin/skills/pending/[id]/approve/route.ts
Normal file
155
src/app/api/admin/skills/pending/[id]/approve/route.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getSessionUser, requirePlatformRole } from "@/lib/session";
|
||||
import {
|
||||
getSkillActivationRequestById,
|
||||
recordSkillEvents,
|
||||
updateSkillActivationRequestStatus,
|
||||
} from "@/lib/db";
|
||||
import { getTenant, patchTenantSpec } from "@/lib/k8s";
|
||||
import { getPackageDef } from "@/lib/packages";
|
||||
import { listOrgUsers } from "@/lib/zitadel";
|
||||
import { sendSkillActivationApprovalEmail } from "@/lib/email";
|
||||
import { safeError } from "@/lib/errors";
|
||||
|
||||
/**
|
||||
* POST /api/admin/skills/pending/[id]/approve
|
||||
*
|
||||
* Atomic-ish approval. Ordering:
|
||||
* 1. Load + sanity-check the request (must be pending).
|
||||
* 2. Patch the tenant CR to include the skill in spec.packages.
|
||||
* 3. Record the skill_event (kind=enabled) for billing.
|
||||
* 4. Flip the request row to 'approved'.
|
||||
* 5. Best-effort approval email to the requester.
|
||||
*
|
||||
* Step 2 is the irreversible one — if it succeeds but step 4 fails
|
||||
* we end up with a skill enabled in K8s but a still-pending request
|
||||
* row. That's a manual cleanup task; we log loudly so admin notices
|
||||
* via the queue page (the request would reappear there).
|
||||
*
|
||||
* The request must be in 'pending' status. Approving an already-
|
||||
* approved/rejected request returns 409.
|
||||
*
|
||||
* Body (optional): { adminNotes?: string }
|
||||
*/
|
||||
export async function POST(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
let admin;
|
||||
try {
|
||||
await requirePlatformRole();
|
||||
admin = await getSessionUser();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
if (!admin) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
const { id } = await params;
|
||||
const body = await request.json().catch(() => ({}));
|
||||
const adminNotes =
|
||||
typeof body.adminNotes === "string" && body.adminNotes.length <= 1000
|
||||
? body.adminNotes
|
||||
: null;
|
||||
|
||||
// 1. Load + sanity-check.
|
||||
const req = await getSkillActivationRequestById(id);
|
||||
if (!req) {
|
||||
return NextResponse.json({ error: "Request not found" }, { status: 404 });
|
||||
}
|
||||
if (req.status !== "pending") {
|
||||
return NextResponse.json(
|
||||
{ error: `Request is already ${req.status}` },
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
|
||||
// 2. Patch the tenant CR — add the skill if not already present.
|
||||
// Defensive: if the tenant was deleted or the skill was somehow
|
||||
// added by another path, we still proceed without duplicate.
|
||||
let tenant;
|
||||
try {
|
||||
tenant = await getTenant(req.tenantName);
|
||||
} catch (e) {
|
||||
return NextResponse.json(
|
||||
{ error: `Tenant ${req.tenantName} not found: ${safeError(e, "")}` },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
if (!tenant) {
|
||||
return NextResponse.json(
|
||||
{ error: `Tenant ${req.tenantName} not found` },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
const currentPackages = new Set<string>(tenant.spec.packages ?? []);
|
||||
const alreadyEnabled = currentPackages.has(req.skillId);
|
||||
if (!alreadyEnabled) {
|
||||
currentPackages.add(req.skillId);
|
||||
try {
|
||||
await patchTenantSpec(req.tenantName, {
|
||||
packages: [...currentPackages],
|
||||
});
|
||||
} catch (e) {
|
||||
return NextResponse.json(
|
||||
{ error: `Failed to enable skill on tenant: ${safeError(e, "")}` },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Record skill event (only if we actually added it — re-adding
|
||||
// would skew the day-count). Best-effort.
|
||||
if (!alreadyEnabled) {
|
||||
try {
|
||||
await recordSkillEvents(req.tenantName, req.zitadelOrgId, [req.skillId], []);
|
||||
} catch (e) {
|
||||
console.error(
|
||||
`Failed to record skill_event after approve (request ${id}):`,
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Flip request to approved.
|
||||
const updated = await updateSkillActivationRequestStatus(id, "approved", {
|
||||
reviewedBy: admin.id,
|
||||
adminNotes,
|
||||
});
|
||||
if (!updated) {
|
||||
// Race: another admin tab flipped it between our read and now.
|
||||
// The K8s patch already happened so we don't roll back; log so
|
||||
// the human notices.
|
||||
console.error(
|
||||
`Request ${id} was no longer pending when we tried to mark approved; K8s patch already applied.`
|
||||
);
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
"Request status changed during approval; the skill may have been enabled. Check the queue.",
|
||||
},
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
|
||||
// 5. Email the requester (best-effort). Look up their email via
|
||||
// ZITADEL since we only stored the userId on the request.
|
||||
try {
|
||||
const orgUsers = await listOrgUsers(req.zitadelOrgId);
|
||||
const requester = orgUsers.find((u) => u.userId === req.zitadelUserId);
|
||||
if (requester?.email) {
|
||||
const def = getPackageDef(req.skillId);
|
||||
await sendSkillActivationApprovalEmail({
|
||||
to: requester.email,
|
||||
contactName: requester.displayName || requester.email,
|
||||
skillName: def?.name ?? req.skillId,
|
||||
tenantName: req.tenantName,
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`Failed to send approval email for request ${id}:`, e);
|
||||
}
|
||||
|
||||
return NextResponse.json(updated);
|
||||
}
|
||||
129
src/app/api/admin/skills/pending/[id]/reject/route.ts
Normal file
129
src/app/api/admin/skills/pending/[id]/reject/route.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { getSessionUser, requirePlatformRole } from "@/lib/session";
|
||||
import {
|
||||
getSkillActivationRequestById,
|
||||
updateSkillActivationRequestStatus,
|
||||
} from "@/lib/db";
|
||||
import { getPackageDef } from "@/lib/packages";
|
||||
import { listOrgUsers } from "@/lib/zitadel";
|
||||
import { sendSkillActivationRejectionEmail } from "@/lib/email";
|
||||
import { deletePackageSecrets } from "@/lib/openbao";
|
||||
|
||||
/**
|
||||
* POST /api/admin/skills/pending/[id]/reject
|
||||
*
|
||||
* Reject a pending activation request with a required reason that
|
||||
* is shown to the customer (mirroring the tenant-request rejection
|
||||
* flow). The skill is NOT added to the tenant spec — it was never
|
||||
* there in the first place — so the customer's enable attempt is
|
||||
* effectively cancelled. They can try again from their tenant
|
||||
* settings after seeing the reason (a new pending row will be
|
||||
* created by their next toggle).
|
||||
*
|
||||
* Body:
|
||||
* {
|
||||
* reason: string (1..1000 chars, required),
|
||||
* adminNotes?: string (optional, not shown to customer)
|
||||
* }
|
||||
*/
|
||||
|
||||
const bodySchema = z.object({
|
||||
reason: z.string().min(1).max(1000),
|
||||
adminNotes: z.string().max(1000).optional(),
|
||||
});
|
||||
|
||||
export async function POST(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
let admin;
|
||||
try {
|
||||
await requirePlatformRole();
|
||||
admin = await getSessionUser();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
if (!admin) {
|
||||
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 }
|
||||
);
|
||||
}
|
||||
|
||||
const req = await getSkillActivationRequestById(id);
|
||||
if (!req) {
|
||||
return NextResponse.json({ error: "Request not found" }, { status: 404 });
|
||||
}
|
||||
if (req.status !== "pending") {
|
||||
return NextResponse.json(
|
||||
{ error: `Request is already ${req.status}` },
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
|
||||
const updated = await updateSkillActivationRequestStatus(id, "rejected", {
|
||||
reviewedBy: admin.id,
|
||||
rejectionReason: parsed.data.reason,
|
||||
adminNotes: parsed.data.adminNotes ?? null,
|
||||
});
|
||||
if (!updated) {
|
||||
return NextResponse.json(
|
||||
{ error: "Request status changed during rejection." },
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
|
||||
// Cleanup: if the package needed customer-provided secrets, the
|
||||
// user submitted them BEFORE the gate fired (handleSubmitSecrets
|
||||
// in PackageCard writes to OpenBao then PATCHes). Those secrets
|
||||
// are now orphaned — the package never made it into spec, won't
|
||||
// be re-attempted unless the user retries with fresh credentials.
|
||||
// Best-effort delete: keep the OpenBao path clean, avoid stale
|
||||
// creds lurking. Idempotent (404 is fine). Failure is logged but
|
||||
// not propagated — the rejection itself already succeeded.
|
||||
//
|
||||
// We deliberately skip customProvisioning packages here. Those
|
||||
// mint platform-side credentials via a dedicated endpoint and
|
||||
// need symmetric deprovisioning (POST /[pkg.id] → DELETE
|
||||
// /[pkg.id]). Calling deletePackageSecrets wouldn't revoke them
|
||||
// — admin handles that path manually if the rejected request had
|
||||
// already minted resources.
|
||||
const def = getPackageDef(req.skillId);
|
||||
if (def?.requiresSecrets && !def.customProvisioning) {
|
||||
try {
|
||||
await deletePackageSecrets(req.tenantName, req.skillId);
|
||||
} catch (e) {
|
||||
console.error(
|
||||
`Failed to delete orphan secrets for ${req.tenantName}/${req.skillId} after reject:`,
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Email the requester with the reason — best-effort.
|
||||
try {
|
||||
const orgUsers = await listOrgUsers(req.zitadelOrgId);
|
||||
const requester = orgUsers.find((u) => u.userId === req.zitadelUserId);
|
||||
if (requester?.email) {
|
||||
const def = getPackageDef(req.skillId);
|
||||
await sendSkillActivationRejectionEmail({
|
||||
to: requester.email,
|
||||
contactName: requester.displayName || requester.email,
|
||||
skillName: def?.name ?? req.skillId,
|
||||
tenantName: req.tenantName,
|
||||
reason: parsed.data.reason,
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`Failed to send rejection email for request ${id}:`, e);
|
||||
}
|
||||
|
||||
return NextResponse.json(updated);
|
||||
}
|
||||
22
src/app/api/admin/skills/pending/route.ts
Normal file
22
src/app/api/admin/skills/pending/route.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { requirePlatformRole } from "@/lib/session";
|
||||
import { listPendingSkillActivationRequests } from "@/lib/db";
|
||||
|
||||
/**
|
||||
* GET /api/admin/skills/pending
|
||||
*
|
||||
* List all pending skill-activation requests across all tenants
|
||||
* and orgs. Powers the admin queue at /admin/skills/pending.
|
||||
*
|
||||
* Platform-role only. Returns up to 500 rows oldest-first so the
|
||||
* queue UI shows the oldest requests at the top (FIFO).
|
||||
*/
|
||||
export async function GET() {
|
||||
try {
|
||||
await requirePlatformRole();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
const rows = await listPendingSkillActivationRequests();
|
||||
return NextResponse.json(rows);
|
||||
}
|
||||
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,7 +417,64 @@ export async function POST(request: Request) {
|
||||
);
|
||||
}
|
||||
|
||||
const tenantRequest = await createTenantRequest({
|
||||
// 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,
|
||||
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 ?? {},
|
||||
});
|
||||
try {
|
||||
await sendAdminNotificationEmail(
|
||||
tenantRequest.contactEmail,
|
||||
tenantRequest.contactName,
|
||||
tenantRequest.instanceName
|
||||
? `${tenantRequest.companyName} (${tenantRequest.instanceName})`
|
||||
: tenantRequest.companyName
|
||||
);
|
||||
} catch (e) {
|
||||
console.error("Failed to send admin notification:", e);
|
||||
}
|
||||
const allRequests = await listTenantRequestsByOrgId(user.orgId);
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: "Request submitted.",
|
||||
request: publicRequestShape(tenantRequest),
|
||||
orgRequestCount: allRequests.length,
|
||||
},
|
||||
{ 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,
|
||||
@@ -404,32 +489,140 @@ 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,
|
||||
tenantRequest.contactName,
|
||||
tenantRequest.instanceName
|
||||
? `${tenantRequest.companyName} (${tenantRequest.instanceName})`
|
||||
: tenantRequest.companyName
|
||||
// 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 send admin notification:", 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 }
|
||||
);
|
||||
}
|
||||
|
||||
// For diagnostics: how many other in-flight requests does this org
|
||||
// already have? Useful for the admin queue.
|
||||
const allRequests = await listTenantRequestsByOrgId(user.orgId);
|
||||
// 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: "Request submitted.",
|
||||
message: "Redirecting to payment.",
|
||||
request: publicRequestShape(tenantRequest),
|
||||
orgRequestCount: allRequests.length,
|
||||
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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
23
src/app/api/skills/pricing/route.ts
Normal file
23
src/app/api/skills/pricing/route.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getSessionUser } from "@/lib/session";
|
||||
import { listSkillPricing } from "@/lib/db";
|
||||
|
||||
/**
|
||||
* GET /api/skills/pricing
|
||||
*
|
||||
* Returns the platform-wide skill pricing (daily price + setup fee
|
||||
* per skill) for display in the customer's cost-disclosure dialog
|
||||
* before they enable a priced skill. Any logged-in user can read
|
||||
* this — pricing isn't org-specific and is effectively public
|
||||
* information for anyone who'd be considering activation.
|
||||
*
|
||||
* Empty array means no skill is currently priced.
|
||||
*/
|
||||
export async function GET() {
|
||||
const user = await getSessionUser();
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
const rows = await listSkillPricing();
|
||||
return NextResponse.json(rows);
|
||||
}
|
||||
74
src/app/api/skills/requests/[id]/withdraw/route.ts
Normal file
74
src/app/api/skills/requests/[id]/withdraw/route.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getSessionUser } from "@/lib/session";
|
||||
import {
|
||||
getSkillActivationRequestById,
|
||||
updateSkillActivationRequestStatus,
|
||||
} from "@/lib/db";
|
||||
import { getPackageDef } from "@/lib/packages";
|
||||
import { deletePackageSecrets } from "@/lib/openbao";
|
||||
|
||||
/**
|
||||
* POST /api/skills/requests/[id]/withdraw
|
||||
*
|
||||
* The owner of a pending activation request can cancel it. This
|
||||
* doesn't touch K8s (the skill was never enabled) — it just flips
|
||||
* the row to 'withdrawn' so the user's UI clears the pending
|
||||
* state and they can try a different skill or retry later.
|
||||
*
|
||||
* Authorization: only the original requester OR a platform admin
|
||||
* can withdraw a request. We deliberately don't allow other org
|
||||
* members to cancel each other's requests in v1 — the partial
|
||||
* unique index would let one user repeatedly cancel another's
|
||||
* pending request.
|
||||
*/
|
||||
export async function POST(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const user = await getSessionUser();
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
const { id } = await params;
|
||||
const req = await getSkillActivationRequestById(id);
|
||||
if (!req) {
|
||||
return NextResponse.json({ error: "Request not found" }, { status: 404 });
|
||||
}
|
||||
if (!user.isPlatform && req.zitadelUserId !== user.id) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
if (req.status !== "pending") {
|
||||
return NextResponse.json(
|
||||
{ error: `Request is already ${req.status}` },
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
const updated = await updateSkillActivationRequestStatus(id, "withdrawn", {
|
||||
reviewedBy: user.id,
|
||||
});
|
||||
if (!updated) {
|
||||
return NextResponse.json(
|
||||
{ error: "Request status changed during withdraw." },
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
|
||||
// Cleanup: same logic as reject — the user submitted secrets
|
||||
// before the gate fired, and those are now orphaned in OpenBao.
|
||||
// Best-effort delete; failure logged but not propagated. Skip
|
||||
// customProvisioning packages (their deprovisioning is a
|
||||
// separate, dedicated endpoint).
|
||||
const def = getPackageDef(req.skillId);
|
||||
if (def?.requiresSecrets && !def.customProvisioning) {
|
||||
try {
|
||||
await deletePackageSecrets(req.tenantName, req.skillId);
|
||||
} catch (e) {
|
||||
console.error(
|
||||
`Failed to delete orphan secrets for ${req.tenantName}/${req.skillId} after withdraw:`,
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json(updated);
|
||||
}
|
||||
40
src/app/api/skills/requests/route.ts
Normal file
40
src/app/api/skills/requests/route.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getSessionUser } from "@/lib/session";
|
||||
import { listSkillActivationRequestsForTenant } from "@/lib/db";
|
||||
import { canUserSeeTenant } from "@/lib/visibility";
|
||||
import { getTenant } from "@/lib/k8s";
|
||||
|
||||
/**
|
||||
* GET /api/skills/requests?tenant=<name>
|
||||
*
|
||||
* Returns pending and most-recent-rejected skill activation
|
||||
* requests for the named tenant. Used by the tenant settings page
|
||||
* to render the "Manual review pending" or "Activation rejected"
|
||||
* inline states on PackageCard.
|
||||
*
|
||||
* Authorization: the caller must be able to see the tenant (owner
|
||||
* of its org, assigned user, or platform admin).
|
||||
*/
|
||||
export async function GET(request: Request) {
|
||||
const user = await getSessionUser();
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
const { searchParams } = new URL(request.url);
|
||||
const tenantName = searchParams.get("tenant");
|
||||
if (!tenantName) {
|
||||
return NextResponse.json(
|
||||
{ error: "Missing tenant parameter" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
const tenant = await getTenant(tenantName).catch(() => null);
|
||||
if (!tenant) {
|
||||
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
}
|
||||
if (!canUserSeeTenant(user, tenant)) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
const requests = await listSkillActivationRequestsForTenant(tenantName);
|
||||
return NextResponse.json(requests);
|
||||
}
|
||||
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)"
|
||||
}`
|
||||
);
|
||||
}
|
||||
@@ -3,7 +3,12 @@ import { getSessionUser, canMutate } from "@/lib/session";
|
||||
import { canUserSeeTenant } from "@/lib/visibility";
|
||||
import { getTenant, patchTenantSpec } from "@/lib/k8s";
|
||||
import { getPackageDef } from "@/lib/packages";
|
||||
import { recordSkillEvents } from "@/lib/db";
|
||||
import {
|
||||
createSkillActivationRequest,
|
||||
getOrgBilling,
|
||||
recordSkillEvents,
|
||||
} from "@/lib/db";
|
||||
import { sendSkillActivationAdminNotification } from "@/lib/email";
|
||||
import { safeError } from "@/lib/errors";
|
||||
|
||||
const ALLOWED_WORKSPACE_FILES = ["SOUL.md", "AGENTS.md", "TOOLS.md"];
|
||||
@@ -69,6 +74,17 @@ export async function PATCH(
|
||||
|
||||
const specPatch: Record<string, any> = {};
|
||||
|
||||
// Track manual-setup gate activations created during this PATCH.
|
||||
// We push to the K8s spec only the non-gated skills; the gated
|
||||
// ones live in skill_activation_requests until admin approves
|
||||
// and adds them via the admin endpoint. Platform admins bypass
|
||||
// the gate (direct enable from /admin still applies immediately).
|
||||
let gatedRequests: Array<{
|
||||
skillId: string;
|
||||
requestId: string;
|
||||
skillName: string;
|
||||
}> = [];
|
||||
|
||||
// ── Validate packages against catalog ──
|
||||
if (body.packages !== undefined) {
|
||||
if (!Array.isArray(body.packages) || body.packages.length > 10) {
|
||||
@@ -85,7 +101,63 @@ export async function PATCH(
|
||||
);
|
||||
}
|
||||
}
|
||||
specPatch.packages = body.packages;
|
||||
// Compute the to-be-added set against the existing spec.
|
||||
const existingPackages = new Set<string>(existing.spec.packages ?? []);
|
||||
const desiredPackages: string[] = body.packages;
|
||||
const newlyAdded = desiredPackages.filter(
|
||||
(p) => !existingPackages.has(p)
|
||||
);
|
||||
// Manual-setup gate. Customer adds get routed to the queue;
|
||||
// platform admins go straight through.
|
||||
if (!user.isPlatform && newlyAdded.length > 0) {
|
||||
const orgIdForGate =
|
||||
existing.metadata.labels?.["pieced.ch/zitadel-org-id"];
|
||||
if (!orgIdForGate) {
|
||||
// Defensive: every customer-visible tenant should have the
|
||||
// org label. Without it we can't attribute the request.
|
||||
return NextResponse.json(
|
||||
{ error: "Tenant missing org binding; contact support." },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
const gatedSet = new Set<string>();
|
||||
for (const skillId of newlyAdded) {
|
||||
const def = getPackageDef(skillId);
|
||||
if (!def?.requiresManualSetup) continue;
|
||||
gatedSet.add(skillId);
|
||||
try {
|
||||
const req = await createSkillActivationRequest({
|
||||
tenantName: name,
|
||||
zitadelOrgId: orgIdForGate,
|
||||
zitadelUserId: user.id,
|
||||
skillId,
|
||||
});
|
||||
gatedRequests.push({
|
||||
skillId,
|
||||
requestId: req.id,
|
||||
skillName: def.name,
|
||||
});
|
||||
} catch (e: any) {
|
||||
if (e?.code === "REQUEST_ALREADY_PENDING") {
|
||||
// Idempotent: a pending row already exists; just keep
|
||||
// the skill out of the K8s spec and surface it as
|
||||
// gated without creating a duplicate.
|
||||
gatedRequests.push({
|
||||
skillId,
|
||||
requestId: "",
|
||||
skillName: def.name,
|
||||
});
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Strip gated skills from the desired spec — they must not
|
||||
// reach K8s until approved.
|
||||
specPatch.packages = desiredPackages.filter((p) => !gatedSet.has(p));
|
||||
} else {
|
||||
specPatch.packages = desiredPackages;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Validate workspaceFiles ──
|
||||
@@ -232,7 +304,49 @@ export async function PATCH(
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json(updated);
|
||||
// Phase 2.5: notify admin of newly created activation requests.
|
||||
// Best-effort — email failure must not poison the PATCH response.
|
||||
// requestId === "" means an existing-pending row was reused, so
|
||||
// skip the email in that case (admin already knows).
|
||||
if (gatedRequests.length > 0) {
|
||||
const orgIdForEmail =
|
||||
existing.metadata.labels?.["pieced.ch/zitadel-org-id"] ?? null;
|
||||
const companyName = orgIdForEmail
|
||||
? await getOrgBilling(orgIdForEmail)
|
||||
.then((b) => b?.companyName ?? null)
|
||||
.catch(() => null)
|
||||
: null;
|
||||
for (const g of gatedRequests) {
|
||||
if (!g.requestId) continue;
|
||||
try {
|
||||
await sendSkillActivationAdminNotification({
|
||||
tenantName: name,
|
||||
skillId: g.skillId,
|
||||
skillName: g.skillName,
|
||||
requesterEmail: user.email,
|
||||
requesterName: user.name,
|
||||
companyName,
|
||||
});
|
||||
} catch (e) {
|
||||
console.error(
|
||||
`Failed to send admin notification for skill activation request:`,
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
...updated,
|
||||
// Phase 2.5: tells the client which requested-to-enable skills
|
||||
// didn't actually land in the spec because they're awaiting
|
||||
// admin approval. UI uses this to render the "pending review"
|
||||
// state on those skill cards.
|
||||
pendingActivationRequests: gatedRequests.map((g) => ({
|
||||
skillId: g.skillId,
|
||||
skillName: g.skillName,
|
||||
})),
|
||||
});
|
||||
} catch (e: any) {
|
||||
return NextResponse.json(
|
||||
{ error: safeError(e, "Failed to update tenant") },
|
||||
|
||||
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 ───── */}
|
||||
{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">
|
||||
<Modal
|
||||
open={!!rejectModal}
|
||||
onClose={() => {
|
||||
setRejectModal(null);
|
||||
setRejectNotes("");
|
||||
setActionError("");
|
||||
}}
|
||||
ariaLabel={t("rejectTitle")}
|
||||
>
|
||||
{rejectModal && (
|
||||
<>
|
||||
<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 ───── */}
|
||||
{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">
|
||||
<Modal
|
||||
open={!!deleteModal}
|
||||
onClose={() => {
|
||||
setDeleteModal(null);
|
||||
setActionError("");
|
||||
}}
|
||||
ariaLabel={t("deleteTitle")}
|
||||
>
|
||||
{deleteModal && (
|
||||
<>
|
||||
<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} />
|
||||
<span className="text-text-muted">
|
||||
{invoice.periodStart} → {invoice.periodEnd}
|
||||
</span>
|
||||
<span className="text-text-muted">·</span>
|
||||
{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,7 +630,9 @@ function StatusPill({ status }: { status: InvoiceStatus }) {
|
||||
? "bg-error/15 text-error"
|
||||
: status === "void" || status === "uncollectible"
|
||||
? "bg-text-muted/15 text-text-muted"
|
||||
: "bg-accent/15 text-accent";
|
||||
: status === "partially_refunded" || status === "fully_refunded"
|
||||
? "bg-error/15 text-error"
|
||||
: "bg-accent/15 text-accent";
|
||||
return (
|
||||
<span
|
||||
className={`inline-block px-2 py-0.5 rounded text-xs font-medium ${color}`}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -34,6 +34,7 @@ export function PricingEditor({
|
||||
catalog,
|
||||
}: Props) {
|
||||
const t = useTranslations("adminBilling");
|
||||
const tPackages = useTranslations("packages");
|
||||
const router = useRouter();
|
||||
|
||||
// -- Platform pricing form ----------------------------------------------
|
||||
@@ -78,12 +79,19 @@ export function PricingEditor({
|
||||
}
|
||||
};
|
||||
|
||||
// -- Skill pricing ------------------------------------------------------
|
||||
// -- Package pricing ----------------------------------------------------
|
||||
// Server is authoritative — we don't keep an editable local copy of the
|
||||
// table; instead each action posts to the API and we router.refresh().
|
||||
const [newSkillId, setNewSkillId] = useState(
|
||||
catalog.find((c) => c.category === "skill")?.id ?? ""
|
||||
);
|
||||
//
|
||||
// Naming carry-over: the underlying DB table is `skill_pricing` and the
|
||||
// column is `skill_id`, dating from when only skills were priced. The
|
||||
// model now applies to any PackageDef in the catalog regardless of
|
||||
// category — core, channel, or skill. The state variable names below
|
||||
// (newSkill*, addingSkill, etc.) retain the legacy "skill" prefix
|
||||
// because renaming the entire surface for purely cosmetic reasons
|
||||
// would create churn for no functional gain. Treat "skill" here as
|
||||
// shorthand for "priced package".
|
||||
const [newSkillId, setNewSkillId] = useState(catalog[0]?.id ?? "");
|
||||
const [newSkillPrice, setNewSkillPrice] = useState("0.10");
|
||||
const [newSkillSetupFee, setNewSkillSetupFee] = useState("0");
|
||||
const [addingSkill, setAddingSkill] = useState(false);
|
||||
@@ -147,9 +155,16 @@ export function PricingEditor({
|
||||
}
|
||||
};
|
||||
|
||||
// Catalog filtered to skill-kind entries for the picker, but keeping
|
||||
// existing pricing rows even if they reference non-skill packages.
|
||||
const skillCatalogOptions = catalog.filter((c) => c.category === "skill");
|
||||
// Pricing applies to any catalog entry regardless of category. Grouped
|
||||
// dropdown sorts options by category for visual scanning — core,
|
||||
// channel, and skill in a single picker.
|
||||
const skillCatalogOptions = [...catalog].sort((a, b) => {
|
||||
const order = { core: 0, channel: 1, skill: 2 } as Record<string, number>;
|
||||
const ca = order[a.category] ?? 99;
|
||||
const cb = order[b.category] ?? 99;
|
||||
if (ca !== cb) return ca - cb;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
const catalogIndex = new Map(catalog.map((c) => [c.id, c]));
|
||||
const pricedIds = new Set(initialSkillPricing.map((s) => s.skillId));
|
||||
|
||||
@@ -221,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>
|
||||
@@ -240,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>
|
||||
@@ -260,7 +276,12 @@ export function PricingEditor({
|
||||
<td className="py-2">
|
||||
<div className="font-mono text-xs">{sp.skillId}</div>
|
||||
{entry && (
|
||||
<div className="text-xs text-text-muted">{entry.name}</div>
|
||||
<div className="text-xs text-text-muted flex items-center gap-2">
|
||||
<span>{entry.name}</span>
|
||||
<span className="text-[10px] uppercase tracking-wider bg-surface-3 px-1.5 py-0.5 rounded">
|
||||
{entry.category}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-2 text-right">
|
||||
@@ -299,6 +320,7 @@ export function PricingEditor({
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-text-muted italic mb-4">{t("noSkillsPriced")}</p>
|
||||
)}
|
||||
@@ -311,13 +333,45 @@ export function PricingEditor({
|
||||
onChange={(e) => setNewSkillId(e.target.value)}
|
||||
className="mt-1 w-full px-3 py-2 rounded-md border border-border bg-surface-2 text-sm"
|
||||
>
|
||||
{skillCatalogOptions
|
||||
.filter((c) => !pricedIds.has(c.id))
|
||||
.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.name} ({c.id})
|
||||
</option>
|
||||
))}
|
||||
{(() => {
|
||||
// Group available options by category for the picker.
|
||||
// Already-priced packages are filtered out (admin
|
||||
// edits those inline above).
|
||||
const available = skillCatalogOptions.filter(
|
||||
(c) => !pricedIds.has(c.id)
|
||||
);
|
||||
const byCat = new Map<string, typeof available>();
|
||||
for (const c of available) {
|
||||
if (!byCat.has(c.category)) byCat.set(c.category, []);
|
||||
byCat.get(c.category)!.push(c);
|
||||
}
|
||||
// Labels for the optgroups. Reuse the existing
|
||||
// packages.categories.* scope which already has
|
||||
// translations in all four locales.
|
||||
const labels: Record<string, string> = {
|
||||
core: tPackages("categories.core"),
|
||||
channel: tPackages("categories.channels"),
|
||||
skill: tPackages("categories.skills"),
|
||||
};
|
||||
const order: Array<"core" | "channel" | "skill"> = [
|
||||
"core",
|
||||
"channel",
|
||||
"skill",
|
||||
];
|
||||
return order.map((cat) => {
|
||||
const items = byCat.get(cat);
|
||||
if (!items || items.length === 0) return null;
|
||||
return (
|
||||
<optgroup key={cat} label={labels[cat] ?? cat}>
|
||||
{items.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.name} ({c.id})
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
);
|
||||
});
|
||||
})()}
|
||||
</select>
|
||||
</label>
|
||||
<label className="w-28">
|
||||
@@ -349,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>
|
||||
@@ -421,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>
|
||||
|
||||
206
src/components/admin/skills/pending-skill-requests.tsx
Normal file
206
src/components/admin/skills/pending-skill-requests.tsx
Normal file
@@ -0,0 +1,206 @@
|
||||
"use client";
|
||||
|
||||
import { useState, Fragment } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import type { SkillActivationRequest } from "@/types";
|
||||
|
||||
interface RowData extends SkillActivationRequest {
|
||||
skillName: string;
|
||||
companyName: string | null;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
initialRows: RowData[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Admin queue table. Each row has Approve and Reject buttons.
|
||||
* Reject opens an inline reason input that must be filled before
|
||||
* the call goes through (the API also enforces this — empty
|
||||
* reasons are 400'd server-side).
|
||||
*
|
||||
* Actions hit the admin API endpoints, then router.refresh() to
|
||||
* re-render the server component with the new state (the row
|
||||
* disappears once flipped to approved/rejected).
|
||||
*/
|
||||
export function PendingSkillRequests({ initialRows }: Props) {
|
||||
const t = useTranslations("adminSkills");
|
||||
const router = useRouter();
|
||||
const [busyId, setBusyId] = useState<string | null>(null);
|
||||
const [error, setError] = useState("");
|
||||
// Per-row open-reject-input state. Key = request id.
|
||||
const [rejectingId, setRejectingId] = useState<string | null>(null);
|
||||
const [reasonText, setReasonText] = useState("");
|
||||
|
||||
const approve = async (id: string) => {
|
||||
setError("");
|
||||
setBusyId(id);
|
||||
try {
|
||||
const res = await fetch(`/api/admin/skills/pending/${id}/approve`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const j = await res.json().catch(() => ({}));
|
||||
throw new Error(j.error || `HTTP ${res.status}`);
|
||||
}
|
||||
router.refresh();
|
||||
} catch (e: any) {
|
||||
setError(e.message);
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const reject = async (id: string) => {
|
||||
if (!reasonText.trim()) {
|
||||
setError(t("reasonRequired"));
|
||||
return;
|
||||
}
|
||||
setError("");
|
||||
setBusyId(id);
|
||||
try {
|
||||
const res = await fetch(`/api/admin/skills/pending/${id}/reject`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ reason: reasonText }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const j = await res.json().catch(() => ({}));
|
||||
throw new Error(j.error || `HTTP ${res.status}`);
|
||||
}
|
||||
setRejectingId(null);
|
||||
setReasonText("");
|
||||
router.refresh();
|
||||
} catch (e: any) {
|
||||
setError(e.message);
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
};
|
||||
|
||||
if (initialRows.length === 0) {
|
||||
return (
|
||||
<Card>
|
||||
<p className="text-sm text-text-muted italic text-center py-6">
|
||||
{t("emptyQueue")}
|
||||
</p>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
{error && (
|
||||
<div className="mb-3 p-3 rounded-md border border-error bg-error/10 text-sm text-error">
|
||||
{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">{t("requestedAtCol")}</th>
|
||||
<th className="pb-2">{t("skillCol")}</th>
|
||||
<th className="pb-2">{t("tenantCol")}</th>
|
||||
<th className="pb-2">{t("orgCol")}</th>
|
||||
<th className="pb-2 text-right">{t("actionsCol")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{initialRows.map((row) => (
|
||||
<Fragment key={row.id}>
|
||||
<tr className="border-t border-border align-top">
|
||||
<td className="py-2 text-xs text-text-muted font-mono">
|
||||
{row.requestedAt.slice(0, 16).replace("T", " ")}
|
||||
</td>
|
||||
<td className="py-2">
|
||||
<div className="font-medium">{row.skillName}</div>
|
||||
<div className="text-xs text-text-muted font-mono">
|
||||
{row.skillId}
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-2 font-mono text-xs">{row.tenantName}</td>
|
||||
<td className="py-2">
|
||||
<div className="text-xs">{row.companyName ?? "—"}</div>
|
||||
<div className="text-xs text-text-muted font-mono">
|
||||
{row.zitadelOrgId.slice(0, 16)}…
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-2 text-right">
|
||||
{rejectingId !== row.id && (
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
setRejectingId(row.id);
|
||||
setReasonText("");
|
||||
setError("");
|
||||
}}
|
||||
disabled={busyId !== null}
|
||||
className="text-xs px-3 py-1.5 rounded-md border border-error text-error hover:bg-error/10 disabled:opacity-50"
|
||||
>
|
||||
{t("rejectBtn")}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => approve(row.id)}
|
||||
disabled={busyId !== null}
|
||||
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>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
{rejectingId === row.id && (
|
||||
<tr className="border-t border-border bg-surface-2">
|
||||
<td colSpan={5} className="py-3 px-3">
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-xs text-text-muted">
|
||||
{t("reasonLabel")}
|
||||
</label>
|
||||
<textarea
|
||||
value={reasonText}
|
||||
onChange={(e) => setReasonText(e.target.value)}
|
||||
rows={3}
|
||||
maxLength={1000}
|
||||
placeholder={t("reasonPlaceholder")}
|
||||
className="w-full px-3 py-2 rounded-md border border-border bg-surface-1 text-sm"
|
||||
autoFocus
|
||||
/>
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
setRejectingId(null);
|
||||
setReasonText("");
|
||||
}}
|
||||
disabled={busyId !== null}
|
||||
className="text-xs px-3 py-1.5 rounded-md border border-border disabled:opacity-50"
|
||||
>
|
||||
{t("cancel")}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => reject(row.id)}
|
||||
disabled={busyId !== null || !reasonText.trim()}
|
||||
className="text-xs px-3 py-1.5 rounded-md bg-error text-white disabled:opacity-50"
|
||||
>
|
||||
{busyId === row.id
|
||||
? t("working")
|
||||
: t("confirmRejectBtn")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</Fragment>
|
||||
))}
|
||||
</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 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"
|
||||
>
|
||||
{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);
|
||||
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} />
|
||||
{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>
|
||||
)}
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
<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}
|
||||
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>
|
||||
)}
|
||||
</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")}
|
||||
</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")}
|
||||
{links.map((l) => (
|
||||
<NavLink key={l.href} href={l.href} active={isActive(l.href)}>
|
||||
{l.label}
|
||||
</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>
|
||||
</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}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{/* 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>
|
||||
<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"
|
||||
>
|
||||
{t("goToDashboard")}
|
||||
</button>
|
||||
{(() => {
|
||||
// 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-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;
|
||||
const secrets = packageSecrets[pkgId] || {};
|
||||
for (const field of def.secrets || []) {
|
||||
if (!secrets[field.key]?.trim()) return false;
|
||||
if (!def) continue;
|
||||
let incomplete = false;
|
||||
if (def.requiresSecrets) {
|
||||
const secrets = packageSecrets[pkgId] || {};
|
||||
for (const field of def.secrets || []) {
|
||||
if (!secrets[field.key]?.trim()) {
|
||||
incomplete = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (def.disclaimerKey && !disclaimerAccepted[pkgId]) return false;
|
||||
if (def.disclaimerKey && !disclaimerAccepted[pkgId]) incomplete = true;
|
||||
if (incomplete) out.push(def);
|
||||
}
|
||||
return true;
|
||||
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,7 +813,9 @@ export function OnboardingWizard({
|
||||
className={`border rounded-lg overflow-hidden transition-colors ${
|
||||
isSelected
|
||||
? "border-accent bg-accent/5"
|
||||
: "border-border bg-surface-2"
|
||||
: pkg.recommended
|
||||
? "border-accent/40 bg-accent/[0.02]"
|
||||
: "border-border bg-surface-2"
|
||||
}`}
|
||||
>
|
||||
{/* Toggle row */}
|
||||
@@ -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,20 +1025,33 @@ export function OnboardingWizard({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between mt-6">
|
||||
<button
|
||||
onClick={goBack}
|
||||
className="py-2 px-4 text-sm text-text-secondary hover:text-text-primary transition-colors"
|
||||
>
|
||||
{t("back")}
|
||||
</button>
|
||||
<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"
|
||||
>
|
||||
{t("next")}
|
||||
</button>
|
||||
<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"
|
||||
>
|
||||
{t("back")}
|
||||
</button>
|
||||
<button
|
||||
onClick={goNext}
|
||||
disabled={!packageCredentialsValid()}
|
||||
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={
|
||||
<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>
|
||||
)}
|
||||
<div>{config.billingAddress.street}</div>
|
||||
<div>
|
||||
{config.billingAddress.postalCode}{" "}
|
||||
{config.billingAddress.city}
|
||||
</div>
|
||||
<div className="text-text-muted">
|
||||
{tCountries(
|
||||
config.billingAddress.country as SupportedCountry
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
(() => {
|
||||
// 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 &&
|
||||
company &&
|
||||
company.trim().length > 0 && <div>{company}</div>}
|
||||
{/* Phase 6 fix2: optional contact-person line
|
||||
("z.Hd. <name>") only present when the saved
|
||||
org_billing has it set. */}
|
||||
{contactName && contactName.trim().length > 0 && (
|
||||
<div className="text-text-muted">
|
||||
{t("reviewContactPersonPrefix")} {contactName}
|
||||
</div>
|
||||
)}
|
||||
<div>{street}</div>
|
||||
<div>
|
||||
{postalCode} {city}
|
||||
</div>
|
||||
<div className="text-text-muted">
|
||||
{tCountries(country as SupportedCountry)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()
|
||||
}
|
||||
/>
|
||||
{/* Bug 35: VAT review row. Company customers see this so
|
||||
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 && (
|
||||
<ReviewRow
|
||||
label={t("billingVatNumber")}
|
||||
value={config.billingAddress.vatNumber}
|
||||
mono
|
||||
/>
|
||||
)}
|
||||
(() => {
|
||||
const vat =
|
||||
hasOrgBilling && !isEditing && existingOrgBilling
|
||||
? existingOrgBilling.vatNumber
|
||||
: config.billingAddress.vatNumber;
|
||||
return vat && vat.trim().length > 0 ? (
|
||||
<ReviewRow
|
||||
label={t("billingVatNumber")}
|
||||
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")
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import type { PackageDef } from "@/lib/packages";
|
||||
import type {
|
||||
SkillActivationRequest,
|
||||
SkillPricing,
|
||||
} from "@/types";
|
||||
import { SkillCostDialog } from "./skill-cost-dialog";
|
||||
import { ThreemaQrModal } from "@/components/channel-users/threema-qr-modal";
|
||||
|
||||
interface Props {
|
||||
pkg: PackageDef;
|
||||
@@ -12,6 +19,18 @@ interface Props {
|
||||
onToggled: () => void;
|
||||
/** Slice 5: when false, the enable/disable button is hidden. */
|
||||
canEdit?: boolean;
|
||||
/**
|
||||
* Phase 2.5 — most recent non-terminal activation request for this
|
||||
* skill on this tenant, if any. Drives the "Manual review pending"
|
||||
* and "Activation rejected" inline states. Approved/withdrawn rows
|
||||
* never reach the client side.
|
||||
*/
|
||||
activationRequest?: SkillActivationRequest | null;
|
||||
/**
|
||||
* Phase 2.5 — pricing for this skill if it has any. Triggers the
|
||||
* cost-disclosure dialog before enable.
|
||||
*/
|
||||
pricing?: SkillPricing | null;
|
||||
}
|
||||
|
||||
export function PackageCard({
|
||||
@@ -21,15 +40,38 @@ export function PackageCard({
|
||||
tenantName,
|
||||
onToggled,
|
||||
canEdit = true,
|
||||
activationRequest = null,
|
||||
pricing = null,
|
||||
}: Props) {
|
||||
const t = useTranslations();
|
||||
const router = useRouter();
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [secrets, setSecrets] = useState<Record<string, string>>({});
|
||||
const [accepted, setAccepted] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
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;
|
||||
|
||||
async function handleEnable() {
|
||||
function handleEnable() {
|
||||
// Phase 2.5: gate priced skills behind the cost-disclosure dialog.
|
||||
// Confirm → proceedWithEnable. Cancel → bail.
|
||||
if (isPriced) {
|
||||
setError(null);
|
||||
setShowCostDialog(true);
|
||||
return;
|
||||
}
|
||||
void proceedWithEnable();
|
||||
}
|
||||
|
||||
async function proceedWithEnable() {
|
||||
if (pkg.customProvisioning) {
|
||||
// Platform-side provisioning, then add to packages list.
|
||||
setSaving(true);
|
||||
@@ -43,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 {
|
||||
@@ -112,6 +162,39 @@ export function PackageCard({
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 2.5: withdraw a still-pending activation request. The
|
||||
// request row flips to 'withdrawn' (server-side); router.refresh()
|
||||
// re-renders the tenant page without the pending state, leaving
|
||||
// the toggle re-enabled if the user wants to retry.
|
||||
async function withdrawRequest() {
|
||||
if (!activationRequest || activationRequest.status !== "pending") return;
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/skills/requests/${activationRequest.id}/withdraw`,
|
||||
{ method: "POST" }
|
||||
);
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
throw new Error(err.error || `HTTP ${res.status}`);
|
||||
}
|
||||
router.refresh();
|
||||
} catch (e: any) {
|
||||
setError(e.message);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 2.5: retry after a rejection. Same flow as a fresh
|
||||
// enable; the rejected row stays in the DB as audit trail but a
|
||||
// new pending row will be created by the PATCH.
|
||||
function tryAgainAfterRejection() {
|
||||
setError(null);
|
||||
handleEnable();
|
||||
}
|
||||
|
||||
async function handleSubmitSecrets() {
|
||||
if (pkg.disclaimerKey && !accepted) return;
|
||||
|
||||
@@ -170,18 +253,77 @@ export function PackageCard({
|
||||
{pkg.requiresSecrets && (
|
||||
<span className="text-[10px] text-text-muted">{t("packages.requiresApiKey")}</span>
|
||||
)}
|
||||
{canEdit ? (
|
||||
<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 ${
|
||||
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"
|
||||
} disabled:opacity-50`}
|
||||
>
|
||||
{saving ? "…" : enabled ? t("packages.disable") : t("packages.enable")}
|
||||
</button>
|
||||
{/* Phase 2.5: pending or rejected request takes precedence
|
||||
over the toggle. Approved/withdrawn never reach here.
|
||||
For packages that needed secrets, surface that they're
|
||||
safely stored — the user might otherwise worry the
|
||||
credentials they typed got lost when the activation
|
||||
was deferred. */}
|
||||
{canEdit && activationRequest?.status === "pending" ? (
|
||||
<div className="ml-auto flex flex-col items-end gap-1">
|
||||
<span
|
||||
className="text-[10px] text-warning italic"
|
||||
title={pkg.requiresSecrets ? t("packages.credentialsSavedTip") : undefined}
|
||||
>
|
||||
{t("packages.manualReviewPending")}
|
||||
{pkg.requiresSecrets && (
|
||||
<span className="text-text-muted ml-1 not-italic">
|
||||
· {t("packages.credentialsSaved")}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<button
|
||||
onClick={withdrawRequest}
|
||||
disabled={saving}
|
||||
className="rounded-lg px-3 py-1.5 text-xs font-medium text-text-secondary hover:text-text-primary bg-surface-3 hover:bg-surface-2 disabled:opacity-50 cursor-pointer"
|
||||
>
|
||||
{saving ? "…" : t("packages.withdraw")}
|
||||
</button>
|
||||
</div>
|
||||
) : canEdit && activationRequest?.status === "rejected" ? (
|
||||
<div className="ml-auto flex flex-col items-end gap-1">
|
||||
<span
|
||||
className="text-[10px] text-error italic max-w-[220px] truncate"
|
||||
title={activationRequest.rejectionReason ?? ""}
|
||||
>
|
||||
{t("packages.activationRejected")}: {activationRequest.rejectionReason}
|
||||
</span>
|
||||
<button
|
||||
onClick={tryAgainAfterRejection}
|
||||
disabled={saving}
|
||||
className="rounded-lg px-3 py-1.5 text-xs font-medium bg-accent text-surface-0 hover:bg-accent-dim disabled:opacity-50 cursor-pointer shadow-lg shadow-accent/20"
|
||||
>
|
||||
{saving ? "…" : t("packages.tryAgain")}
|
||||
</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={`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"
|
||||
} disabled:opacity-50`}
|
||||
>
|
||||
{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
|
||||
@@ -194,6 +336,30 @@ export function PackageCard({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Phase 2.5: cost-disclosure modal for priced skills. */}
|
||||
<SkillCostDialog
|
||||
open={showCostDialog}
|
||||
onClose={() => setShowCostDialog(false)}
|
||||
onConfirm={() => {
|
||||
setShowCostDialog(false);
|
||||
void proceedWithEnable();
|
||||
}}
|
||||
skillName={pkg.name}
|
||||
dailyPriceChf={pricing?.dailyPriceChf ?? 0}
|
||||
setupFeeChf={pricing?.setupFeeChf ?? 0}
|
||||
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">
|
||||
|
||||
@@ -3,6 +3,10 @@
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { PACKAGE_CATALOG } from "@/lib/packages";
|
||||
import type {
|
||||
SkillActivationRequest,
|
||||
SkillPricing,
|
||||
} from "@/types";
|
||||
import { PackageCard } from "./package-card";
|
||||
|
||||
interface Props {
|
||||
@@ -12,6 +16,17 @@ interface Props {
|
||||
onRefresh?: () => void;
|
||||
/** Slice 5: when false, package toggles and edit affordances are hidden. */
|
||||
canEdit?: boolean;
|
||||
/**
|
||||
* Phase 2.5 — non-terminal activation requests for this tenant.
|
||||
* Each PackageCard looks up its skill in this array to render the
|
||||
* pending/rejected inline state. Most recent first.
|
||||
*/
|
||||
activationRequests?: SkillActivationRequest[];
|
||||
/**
|
||||
* Phase 2.5 — skill pricing keyed by skillId. Drives the cost
|
||||
* disclosure dialog.
|
||||
*/
|
||||
skillPricing?: SkillPricing[];
|
||||
}
|
||||
|
||||
const CATEGORIES = [
|
||||
@@ -39,11 +54,29 @@ export function PackageList({
|
||||
conditions,
|
||||
onRefresh,
|
||||
canEdit = true,
|
||||
activationRequests = [],
|
||||
skillPricing = [],
|
||||
}: Props) {
|
||||
const t = useTranslations("packages");
|
||||
const router = useRouter();
|
||||
const handleRefresh = onRefresh || (() => router.refresh());
|
||||
|
||||
// Build per-skill lookups once so each card render is O(1) rather
|
||||
// than O(N) over the requests array. `activationRequests` already
|
||||
// arrives filtered to non-terminal rows (most-recent per
|
||||
// (skill, status) pair from the server).
|
||||
const requestBySkill = new Map<string, SkillActivationRequest>();
|
||||
for (const req of activationRequests) {
|
||||
// Pending takes precedence over rejected — if both exist for
|
||||
// the same skill (race or after-rejection-retry), show pending.
|
||||
const existing = requestBySkill.get(req.skillId);
|
||||
if (!existing || (existing.status === "rejected" && req.status === "pending")) {
|
||||
requestBySkill.set(req.skillId, req);
|
||||
}
|
||||
}
|
||||
const pricingBySkill = new Map<string, SkillPricing>();
|
||||
for (const p of skillPricing) pricingBySkill.set(p.skillId, p);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{CATEGORIES.map(({ key, labelKey }) => {
|
||||
@@ -65,6 +98,8 @@ export function PackageList({
|
||||
tenantName={tenantName}
|
||||
onToggled={handleRefresh}
|
||||
canEdit={canEdit}
|
||||
activationRequest={requestBySkill.get(pkg.id) ?? null}
|
||||
pricing={pricingBySkill.get(pkg.id) ?? null}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
115
src/components/packages/skill-cost-dialog.tsx
Normal file
115
src/components/packages/skill-cost-dialog.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Modal } from "@/components/ui/modal";
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: () => void;
|
||||
skillName: string;
|
||||
dailyPriceChf: number;
|
||||
setupFeeChf: number;
|
||||
busy?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cost-disclosure modal shown before activating a priced skill.
|
||||
*
|
||||
* Shows the daily rate and setup fee (each only if > 0) and
|
||||
* requires an explicit Confirm before the activation request goes
|
||||
* through. Rendered every time the user toggles on a priced skill,
|
||||
* not once-and-remember — this is recurring-charge consent, not a
|
||||
* one-time terms agreement.
|
||||
*
|
||||
* The setup fee is always shown when configured, with a note
|
||||
* clarifying it's "one-time, charged on first activation". The
|
||||
* backend (billing.ts tenantSkillHasBeenBilled) is the authority
|
||||
* on whether the fee actually fires — we don't second-guess from
|
||||
* the client. If you've previously activated this skill on this
|
||||
* tenant, the fee won't appear on the next invoice even though
|
||||
* the dialog mentions it.
|
||||
*/
|
||||
export function SkillCostDialog({
|
||||
open,
|
||||
onClose,
|
||||
onConfirm,
|
||||
skillName,
|
||||
dailyPriceChf,
|
||||
setupFeeChf,
|
||||
busy = false,
|
||||
}: Props) {
|
||||
const t = useTranslations("skillCostDialog");
|
||||
const showSetupFee = setupFeeChf > 0;
|
||||
const showDaily = dailyPriceChf > 0;
|
||||
// Nothing to disclose? Bail to confirm immediately — shouldn't
|
||||
// normally be shown in this case but guard anyway.
|
||||
if (!showSetupFee && !showDaily) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<Modal open={open} onClose={onClose} ariaLabel={t("title")}>
|
||||
<div className="bg-surface-1 rounded-lg border border-border p-6 max-w-md w-full">
|
||||
<h2 className="text-lg font-semibold mb-2">{t("title")}</h2>
|
||||
<p className="text-sm text-text-secondary mb-4">
|
||||
{t("intro", { skill: skillName })}
|
||||
</p>
|
||||
|
||||
<div className="rounded-md bg-surface-2 border border-border p-4 mb-4 space-y-2">
|
||||
{showSetupFee && (
|
||||
<div className="flex justify-between items-baseline">
|
||||
<div>
|
||||
<div className="text-sm">{t("setupFeeLabel")}</div>
|
||||
<div className="text-xs text-text-muted">
|
||||
{t("setupFeeNote")}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-sm font-mono">
|
||||
CHF {setupFeeChf.toFixed(2)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{showDaily && (
|
||||
/* Display reference monthly cost (daily × 30) plus the
|
||||
actual daily rate as a sub-note. Billing is always
|
||||
per UTC day enabled — partial months prorate to that
|
||||
same daily rate, full months land at roughly the
|
||||
figure shown (varies ±~3% by month length). */
|
||||
<div className="flex justify-between items-baseline">
|
||||
<div>
|
||||
<div className="text-sm">{t("monthlyPriceLabel")}</div>
|
||||
<div className="text-xs text-text-muted">
|
||||
{t("monthlyPriceNote", {
|
||||
daily: dailyPriceChf.toFixed(2),
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-sm font-mono">
|
||||
CHF {(dailyPriceChf * 30).toFixed(2)} / {t("monthUnit")}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-text-muted mb-4">{t("disclaimer")}</p>
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
onClick={onClose}
|
||||
disabled={busy}
|
||||
className="px-4 py-2 rounded-md border border-border text-sm disabled:opacity-50"
|
||||
>
|
||||
{t("cancel")}
|
||||
</button>
|
||||
<button
|
||||
onClick={onConfirm}
|
||||
disabled={busy}
|
||||
className="px-4 py-2 rounded-md bg-accent text-surface-0 text-sm disabled:opacity-50"
|
||||
>
|
||||
{busy ? t("confirming") : t("confirm")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
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>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user