Compare commits
45 Commits
v0.1.56
...
ca1a014c01
| Author | SHA1 | Date | |
|---|---|---|---|
| ca1a014c01 | |||
| d01ab85cbb | |||
| 610572eafe | |||
| 73f1af185f | |||
| c1833c1def | |||
| 521398b0fc | |||
| 74d276b656 | |||
| 3110b40cf9 | |||
| 08f28aeb93 | |||
| fb9c0ad25a | |||
| 322cfae824 | |||
| 7fac3c3aa8 | |||
| bff3aad1ca | |||
| f2a9637058 | |||
| bfc2194e24 | |||
| 6f8de14b4a | |||
| a6ed74b1be | |||
| 1741574eb2 | |||
| d78f9f2696 | |||
| 3fe3597553 | |||
| 9243beddd3 | |||
| a6c3c42ec9 | |||
| ee6bb89fb6 | |||
| ad4f614130 | |||
| 8e7691d38a | |||
| 9939f75c03 | |||
| e69b68b73c | |||
| 41c1553b1f | |||
| 38f4c3243e | |||
| ed915ec539 | |||
| 667617296b | |||
| 1c61111da3 | |||
| 6fed5b083b | |||
| 4f868d751e | |||
| e15a668f8e | |||
| 9cd9879a18 | |||
| 323786672f | |||
| a1769eeb00 | |||
| 002867850d | |||
| eea027b3b0 | |||
| 522246e386 | |||
| b3131f7710 | |||
| fadfdd3435 | |||
| 427c7c6204 | |||
| 6a8ad7b4be |
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 { notFound, redirect } from "next/navigation";
|
||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
import { getSessionUser } from "@/lib/session";
|
import { getSessionUser } from "@/lib/session";
|
||||||
import { getInvoiceDetail } from "@/lib/db";
|
import { getInvoiceDetail, listCreditNotesForInvoice } from "@/lib/db";
|
||||||
import { BackLink } from "@/components/ui/back-link";
|
import { BackLink } from "@/components/ui/back-link";
|
||||||
import { InvoiceDetailView } from "@/components/admin/billing/invoice-detail-view";
|
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.
|
* /admin/billing/invoices/[id] — full detail of one invoice.
|
||||||
*
|
*
|
||||||
* Server-renders the static body (header, lines, totals, billing
|
* Server-renders the static body (header, lines, totals, billing
|
||||||
* snapshot); the action bar (mark-paid, delete, PDF download) is
|
* snapshot); the action bar (mark-paid, void, refund, delete, PDF
|
||||||
* a client component for the interactive bits.
|
* 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({
|
export default async function AdminInvoiceDetailPage({
|
||||||
params,
|
params,
|
||||||
@@ -25,11 +29,12 @@ export default async function AdminInvoiceDetailPage({
|
|||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const detail = await getInvoiceDetail(id);
|
const detail = await getInvoiceDetail(id);
|
||||||
if (!detail) notFound();
|
if (!detail) notFound();
|
||||||
|
const creditNotes = await listCreditNotesForInvoice(id);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="max-w-4xl mx-auto px-6 py-8">
|
<main className="max-w-4xl mx-auto px-6 py-8">
|
||||||
<BackLink href="/admin/billing/invoices" label={t("backToInvoices")} />
|
<BackLink href="/admin/billing/invoices" label={t("backToInvoices")} />
|
||||||
<InvoiceDetailView detail={detail} />
|
<InvoiceDetailView detail={detail} creditNotes={creditNotes} />
|
||||||
</main>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Sub-tool cards */}
|
{/* 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">
|
<Link href="/admin/billing/pricing">
|
||||||
<Card interactive>
|
<Card interactive>
|
||||||
<div className="font-semibold mb-1">{t("pricingTitle")}</div>
|
<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>
|
<div className="text-sm text-text-muted">{t("invoicesDesc")}</div>
|
||||||
</Card>
|
</Card>
|
||||||
</Link>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Orgs with open balance */}
|
{/* Orgs with open balance */}
|
||||||
@@ -92,6 +98,7 @@ export default async function AdminBillingPage() {
|
|||||||
<div className="animate-in animate-in-delay-3">
|
<div className="animate-in animate-in-delay-3">
|
||||||
<h2 className="text-lg font-semibold mb-3">{t("balancesTitle")}</h2>
|
<h2 className="text-lg font-semibold mb-3">{t("balancesTitle")}</h2>
|
||||||
<Card>
|
<Card>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead className="text-xs text-text-muted text-left">
|
<thead className="text-xs text-text-muted text-left">
|
||||||
<tr>
|
<tr>
|
||||||
@@ -120,6 +127,7 @@ export default async function AdminBillingPage() {
|
|||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
44
src/app/[locale]/admin/cron/page.tsx
Normal file
44
src/app/[locale]/admin/cron/page.tsx
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { getTranslations } from "next-intl/server";
|
||||||
|
import { getSessionUser } from "@/lib/session";
|
||||||
|
import {
|
||||||
|
getLastSuccessfulCronRuns,
|
||||||
|
listRecentCronRuns,
|
||||||
|
} from "@/lib/db";
|
||||||
|
import { CronControls } from "@/components/admin/cron/cron-controls";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* /admin/cron — automation dashboard.
|
||||||
|
*
|
||||||
|
* Shows:
|
||||||
|
* - Last successful run of each kind, with relative time
|
||||||
|
* - Two "Run now" buttons (admin-triggered manual sweeps)
|
||||||
|
* - Recent runs table (last 30)
|
||||||
|
*
|
||||||
|
* Platform-admin gated server-side.
|
||||||
|
*/
|
||||||
|
export default async function AdminCronPage() {
|
||||||
|
const user = await getSessionUser();
|
||||||
|
if (!user || !user.isPlatform) redirect("/login");
|
||||||
|
const t = await getTranslations("adminCron");
|
||||||
|
|
||||||
|
const [recent, lastSuccess] = await Promise.all([
|
||||||
|
listRecentCronRuns(30),
|
||||||
|
getLastSuccessfulCronRuns(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="max-w-5xl mx-auto px-6 py-8">
|
||||||
|
<div className="mb-8 animate-in">
|
||||||
|
<h1 className="font-display text-2xl font-semibold accent-rule">
|
||||||
|
{t("title")}
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-text-secondary mt-3">{t("subtitle")}</p>
|
||||||
|
</div>
|
||||||
|
<CronControls
|
||||||
|
initialRecent={recent}
|
||||||
|
initialLastSuccess={lastSuccess}
|
||||||
|
/>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,6 +5,11 @@ import { listTenants } from "@/lib/k8s";
|
|||||||
import { countPendingSkillActivationRequests } from "@/lib/db";
|
import { countPendingSkillActivationRequests } from "@/lib/db";
|
||||||
import { AdminPanel } from "@/components/admin/admin-panel";
|
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() {
|
export default async function AdminPage() {
|
||||||
const user = await getSessionUser();
|
const user = await getSessionUser();
|
||||||
if (!user) redirect("/login");
|
if (!user) redirect("/login");
|
||||||
@@ -61,6 +66,12 @@ export default async function AdminPage() {
|
|||||||
>
|
>
|
||||||
{t("billingTool")}
|
{t("billingTool")}
|
||||||
</a>
|
</a>
|
||||||
|
<a
|
||||||
|
href="/admin/cron"
|
||||||
|
className="text-sm px-4 py-2 rounded-lg border border-border text-text-secondary hover:text-text-primary hover:border-text-secondary transition-colors"
|
||||||
|
>
|
||||||
|
{t("cronTool")}
|
||||||
|
</a>
|
||||||
<a
|
<a
|
||||||
href="/admin/openclaw"
|
href="/admin/openclaw"
|
||||||
className="text-sm px-4 py-2 rounded-lg border border-border text-text-secondary hover:text-text-primary hover:border-text-secondary transition-colors"
|
className="text-sm px-4 py-2 rounded-lg border border-border text-text-secondary hover:text-text-primary hover:border-text-secondary transition-colors"
|
||||||
|
|||||||
@@ -1,24 +1,36 @@
|
|||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
import { getSessionUser } from "@/lib/session";
|
import { getSessionUser } from "@/lib/session";
|
||||||
import { listInvoices, syncOverdueInvoices } from "@/lib/db";
|
import {
|
||||||
|
listCreditNotesForOrg,
|
||||||
|
listInvoices,
|
||||||
|
syncOverdueInvoices,
|
||||||
|
} from "@/lib/db";
|
||||||
import { CustomerInvoiceList } from "@/components/billing/customer-invoice-list";
|
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";
|
import { RunningTotalWidget } from "@/components/billing/running-total-widget";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* /billing — customer's billing home.
|
* /billing — customer's billing home.
|
||||||
*
|
*
|
||||||
* Shows two things:
|
* Shows three things:
|
||||||
* 1. RunningTotalWidget — current calendar month's accruing cost
|
* 1. RunningTotalWidget — current calendar month's accruing cost
|
||||||
* (or the already-issued invoice for the current month, if
|
* (or the already-issued invoice for the current month, if
|
||||||
* that ran early).
|
* that ran early).
|
||||||
* 2. CustomerInvoiceList — every issued invoice for this org,
|
* 2. CustomerInvoiceList — every issued invoice for this org,
|
||||||
* newest first. Status is reflected with a colored badge.
|
* 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
|
* Anyone signed in can view this. The data is org-scoped; even
|
||||||
* non-owner team members see the same view. Phase 4 will add a
|
* non-owner team members see the same view.
|
||||||
* "settings.payByInvoice" toggle visibility-gated to owners only.
|
|
||||||
*/
|
*/
|
||||||
|
export async function generateMetadata() {
|
||||||
|
const t = await getTranslations("common");
|
||||||
|
return { title: t("billing") };
|
||||||
|
}
|
||||||
|
|
||||||
export default async function CustomerBillingPage() {
|
export default async function CustomerBillingPage() {
|
||||||
const user = await getSessionUser();
|
const user = await getSessionUser();
|
||||||
if (!user) redirect("/login");
|
if (!user) redirect("/login");
|
||||||
@@ -31,10 +43,11 @@ export default async function CustomerBillingPage() {
|
|||||||
console.warn("syncOverdueInvoices failed in /billing:", e);
|
console.warn("syncOverdueInvoices failed in /billing:", e);
|
||||||
}
|
}
|
||||||
|
|
||||||
const invoices = await listInvoices({
|
// Parallel fetch — invoices + credit notes are independent.
|
||||||
zitadelOrgId: user.orgId,
|
const [invoices, creditNotes] = await Promise.all([
|
||||||
limit: 200,
|
listInvoices({ zitadelOrgId: user.orgId, limit: 200 }),
|
||||||
});
|
listCreditNotesForOrg(user.orgId, 200),
|
||||||
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="max-w-5xl mx-auto px-6 py-8">
|
<main className="max-w-5xl mx-auto px-6 py-8">
|
||||||
@@ -49,15 +62,29 @@ export default async function CustomerBillingPage() {
|
|||||||
<h2 className="text-xs font-semibold uppercase tracking-wider text-text-muted mb-3">
|
<h2 className="text-xs font-semibold uppercase tracking-wider text-text-muted mb-3">
|
||||||
{t("currentPeriodHeading")}
|
{t("currentPeriodHeading")}
|
||||||
</h2>
|
</h2>
|
||||||
<RunningTotalWidget />
|
{/* Phase 6: pass the owner flag so the no-config CTA shows
|
||||||
|
the right call-to-action vs the right hint. */}
|
||||||
|
<RunningTotalWidget isOwner={user.roles.includes("owner")} />
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="animate-in animate-in-delay-2">
|
<section className="animate-in animate-in-delay-2 mb-8">
|
||||||
<h2 className="text-xs font-semibold uppercase tracking-wider text-text-muted mb-3">
|
<h2 className="text-xs font-semibold uppercase tracking-wider text-text-muted mb-3">
|
||||||
{t("historyHeading")}
|
{t("historyHeading")}
|
||||||
</h2>
|
</h2>
|
||||||
<CustomerInvoiceList invoices={invoices} />
|
<CustomerInvoiceList invoices={invoices} />
|
||||||
</section>
|
</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>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { redirect } from "next/navigation";
|
|||||||
import { OnboardingFlow } from "@/components/onboarding/onboarding-flow";
|
import { OnboardingFlow } from "@/components/onboarding/onboarding-flow";
|
||||||
import { BackLink } from "@/components/ui/back-link";
|
import { BackLink } from "@/components/ui/back-link";
|
||||||
import { listTenants } from "@/lib/k8s";
|
import { listTenants } from "@/lib/k8s";
|
||||||
import { listActiveTenantRequestsByOrgId, getOrgBilling } from "@/lib/db";
|
import { listActiveTenantRequestsByOrgId, getOrgBilling, getPlatformPricing } from "@/lib/db";
|
||||||
import { personalAccountAtCapacity } from "@/lib/personal-org";
|
import { personalAccountAtCapacity } from "@/lib/personal-org";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -55,7 +55,10 @@ export default async function NewInstancePage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const t = await getTranslations("dashboard");
|
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;
|
const hasOrgBilling = orgBilling !== null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -76,6 +79,9 @@ export default async function NewInstancePage() {
|
|||||||
userName={user.name}
|
userName={user.name}
|
||||||
userEmail={user.email}
|
userEmail={user.email}
|
||||||
hasOrgBilling={hasOrgBilling}
|
hasOrgBilling={hasOrgBilling}
|
||||||
|
existingOrgBilling={orgBilling}
|
||||||
|
setupFeeChf={pricing.tenantSetupFeeChf}
|
||||||
|
monthlyFeeChf={pricing.tenantMonthlyFeeChf}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
listActiveTenantRequestsByOrgId,
|
listActiveTenantRequestsByOrgId,
|
||||||
syncProvisioningStatuses,
|
syncProvisioningStatuses,
|
||||||
getOrgBilling,
|
getOrgBilling,
|
||||||
|
getPlatformPricing,
|
||||||
} from "@/lib/db";
|
} from "@/lib/db";
|
||||||
import {
|
import {
|
||||||
listVisibleTenants,
|
listVisibleTenants,
|
||||||
@@ -21,6 +22,11 @@ import { ProvisioningStatus } from "@/components/onboarding/provisioning-status"
|
|||||||
import { formatDateTime } from "@/lib/format";
|
import { formatDateTime } from "@/lib/format";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
|
||||||
|
export async function generateMetadata() {
|
||||||
|
const t = await getTranslations("common");
|
||||||
|
return { title: t("dashboard") };
|
||||||
|
}
|
||||||
|
|
||||||
export default async function DashboardPage() {
|
export default async function DashboardPage() {
|
||||||
const user = await getSessionUser();
|
const user = await getSessionUser();
|
||||||
if (!user) redirect("/login");
|
if (!user) redirect("/login");
|
||||||
@@ -192,6 +198,7 @@ export default async function DashboardPage() {
|
|||||||
// component.
|
// component.
|
||||||
const orgBilling = await getOrgBilling(user.orgId);
|
const orgBilling = await getOrgBilling(user.orgId);
|
||||||
const hasOrgBilling = orgBilling !== null;
|
const hasOrgBilling = orgBilling !== null;
|
||||||
|
const platformPricing = await getPlatformPricing();
|
||||||
|
|
||||||
// Pending requests that don't yet have a tenant CR. Once the CR
|
// Pending requests that don't yet have a tenant CR. Once the CR
|
||||||
// exists, the tenant card carries the live phase, so a separate
|
// exists, the tenant card carries the live phase, so a separate
|
||||||
@@ -317,6 +324,9 @@ export default async function DashboardPage() {
|
|||||||
userName={user.name}
|
userName={user.name}
|
||||||
userEmail={user.email}
|
userEmail={user.email}
|
||||||
hasOrgBilling={hasOrgBilling}
|
hasOrgBilling={hasOrgBilling}
|
||||||
|
existingOrgBilling={orgBilling}
|
||||||
|
setupFeeChf={platformPricing.tenantSetupFeeChf}
|
||||||
|
monthlyFeeChf={platformPricing.tenantMonthlyFeeChf}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -340,7 +350,7 @@ export default async function DashboardPage() {
|
|||||||
{canCreate && (
|
{canCreate && (
|
||||||
<Link
|
<Link
|
||||||
href="/dashboard/new"
|
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")}
|
<span>+</span> {t("createInstance")}
|
||||||
</Link>
|
</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 { NextIntlClientProvider } from "next-intl";
|
||||||
import { getMessages } from "next-intl/server";
|
import { getMessages, getTranslations } from "next-intl/server";
|
||||||
import { routing } from "@/i18n/routing";
|
import { routing } from "@/i18n/routing";
|
||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
import { NavShell } from "@/components/layout/nav-shell";
|
import { NavShell } from "@/components/layout/nav-shell";
|
||||||
|
|
||||||
export function generateStaticParams() {
|
export function generateStaticParams() {
|
||||||
return routing.locales.map((locale) => ({ locale }));
|
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({
|
export default async function LocaleLayout({
|
||||||
children,
|
children,
|
||||||
params,
|
params,
|
||||||
@@ -22,20 +45,13 @@ export default async function LocaleLayout({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const messages = await getMessages();
|
const messages = await getMessages();
|
||||||
|
const session = await auth();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<html lang={locale} className="dark">
|
<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">
|
<body className="min-h-screen bg-surface-0 text-text-primary antialiased">
|
||||||
<NextIntlClientProvider messages={messages}>
|
<NextIntlClientProvider messages={messages}>
|
||||||
<NavShell>{children}</NavShell>
|
<NavShell session={session}>{children}</NavShell>
|
||||||
</NextIntlClientProvider>
|
</NextIntlClientProvider>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</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,13 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { signIn } from "next-auth/react";
|
import { signIn } from "next-auth/react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations, useLocale } from "next-intl";
|
||||||
import Link from "next/link";
|
import { Link, getPathname } from "@/i18n/navigation";
|
||||||
|
import { Logo } from "@/components/ui/logo";
|
||||||
|
|
||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
const t = useTranslations("login");
|
const t = useTranslations("login");
|
||||||
|
const locale = useLocale();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 flex items-center justify-center bg-surface-0">
|
<div className="fixed inset-0 flex items-center justify-center bg-surface-0">
|
||||||
@@ -24,10 +26,7 @@ export default function LoginPage() {
|
|||||||
<div className="relative z-10 w-full max-w-sm px-5 animate-in">
|
<div className="relative z-10 w-full max-w-sm px-5 animate-in">
|
||||||
{/* Logo mark */}
|
{/* Logo mark */}
|
||||||
<div className="flex justify-center mb-8">
|
<div className="flex justify-center mb-8">
|
||||||
<div className="relative h-12 w-12">
|
<Logo className="h-14 w-auto text-accent" />
|
||||||
<div className="absolute inset-0 rounded-lg bg-accent/15" />
|
|
||||||
<div className="absolute inset-[5px] rounded-md bg-accent" />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-surface-1 rounded-2xl border border-border p-8 shadow-2xl shadow-black/40">
|
<div className="bg-surface-1 rounded-2xl border border-border p-8 shadow-2xl shadow-black/40">
|
||||||
@@ -39,7 +38,14 @@ export default function LoginPage() {
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<button
|
<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="
|
className="
|
||||||
w-full py-3 px-4 rounded-lg font-medium text-sm
|
w-full py-3 px-4 rounded-lg font-medium text-sm
|
||||||
bg-accent text-surface-0 cursor-pointer
|
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() {
|
export default async function RootPage({
|
||||||
redirect("/dashboard");
|
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";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState, useRef, forwardRef } from "react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter, Link } from "@/i18n/navigation";
|
||||||
import { Card } from "@/components/ui/card";
|
import { Card } from "@/components/ui/card";
|
||||||
|
|
||||||
type FormState = "idle" | "submitting" | "success" | "error";
|
type FormState = "idle" | "submitting" | "success" | "error";
|
||||||
@@ -50,6 +50,30 @@ export default function RegisterPage() {
|
|||||||
const [state, setState] = useState<FormState>("idle");
|
const [state, setState] = useState<FormState>("idle");
|
||||||
const [error, setError] = useState("");
|
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 isPersonal = accountType === "personal";
|
||||||
|
|
||||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
@@ -120,7 +144,7 @@ export default function RegisterPage() {
|
|||||||
</p>
|
</p>
|
||||||
<button
|
<button
|
||||||
onClick={() => router.push("/login")}
|
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")}
|
{t("goToLogin")}
|
||||||
</button>
|
</button>
|
||||||
@@ -146,8 +170,13 @@ export default function RegisterPage() {
|
|||||||
className="grid grid-cols-2 gap-3 mb-6 animate-in animate-in-delay-1"
|
className="grid grid-cols-2 gap-3 mb-6 animate-in animate-in-delay-1"
|
||||||
>
|
>
|
||||||
<AccountTypeCard
|
<AccountTypeCard
|
||||||
|
ref={(el) => {
|
||||||
|
cardRefs.current[0] = el;
|
||||||
|
}}
|
||||||
selected={accountType === "personal"}
|
selected={accountType === "personal"}
|
||||||
onClick={() => setAccountType("personal")}
|
onClick={() => setAccountType("personal")}
|
||||||
|
tabIndex={rovingTabIndex("personal", 0)}
|
||||||
|
onKeyDown={(e) => handleCardKeyDown(e, 0)}
|
||||||
label={t("personalCardTitle")}
|
label={t("personalCardTitle")}
|
||||||
description={t("personalCardDescription")}
|
description={t("personalCardDescription")}
|
||||||
icon={
|
icon={
|
||||||
@@ -168,8 +197,13 @@ export default function RegisterPage() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<AccountTypeCard
|
<AccountTypeCard
|
||||||
|
ref={(el) => {
|
||||||
|
cardRefs.current[1] = el;
|
||||||
|
}}
|
||||||
selected={accountType === "company"}
|
selected={accountType === "company"}
|
||||||
onClick={() => setAccountType("company")}
|
onClick={() => setAccountType("company")}
|
||||||
|
tabIndex={rovingTabIndex("company", 1)}
|
||||||
|
onKeyDown={(e) => handleCardKeyDown(e, 1)}
|
||||||
label={t("companyCardTitle")}
|
label={t("companyCardTitle")}
|
||||||
description={t("companyCardDescription")}
|
description={t("companyCardDescription")}
|
||||||
icon={
|
icon={
|
||||||
@@ -270,7 +304,7 @@ export default function RegisterPage() {
|
|||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={state === "submitting"}
|
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")}
|
{state === "submitting" ? tCommon("loading") : t("submit")}
|
||||||
</button>
|
</button>
|
||||||
@@ -278,12 +312,12 @@ export default function RegisterPage() {
|
|||||||
|
|
||||||
<p className="text-xs text-text-muted text-center mt-4">
|
<p className="text-xs text-text-muted text-center mt-4">
|
||||||
{t("hasAccount")}{" "}
|
{t("hasAccount")}{" "}
|
||||||
<a
|
<Link
|
||||||
href="/login"
|
href="/login"
|
||||||
className="text-accent hover:text-accent-dim transition-colors"
|
className="text-accent hover:text-accent-dim transition-colors"
|
||||||
>
|
>
|
||||||
{tCommon("login")}
|
{tCommon("login")}
|
||||||
</a>
|
</Link>
|
||||||
</p>
|
</p>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
@@ -305,41 +339,42 @@ export default function RegisterPage() {
|
|||||||
* and text colours intensify when selected to give a clear "this one
|
* and text colours intensify when selected to give a clear "this one
|
||||||
* is on" signal beyond just the border colour.
|
* is on" signal beyond just the border colour.
|
||||||
*/
|
*/
|
||||||
function AccountTypeCard({
|
const AccountTypeCard = forwardRef<
|
||||||
selected,
|
HTMLButtonElement,
|
||||||
onClick,
|
{
|
||||||
label,
|
selected: boolean;
|
||||||
description,
|
onClick: () => void;
|
||||||
icon,
|
label: string;
|
||||||
}: {
|
description: string;
|
||||||
selected: boolean;
|
icon: React.ReactNode;
|
||||||
onClick: () => void;
|
tabIndex: number;
|
||||||
label: string;
|
onKeyDown: (e: React.KeyboardEvent) => void;
|
||||||
description: string;
|
}
|
||||||
icon: React.ReactNode;
|
>(function AccountTypeCard(
|
||||||
}) {
|
{ selected, onClick, label, description, icon, tabIndex, onKeyDown },
|
||||||
|
ref
|
||||||
|
) {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
|
ref={ref}
|
||||||
type="button"
|
type="button"
|
||||||
role="radio"
|
role="radio"
|
||||||
aria-checked={selected}
|
aria-checked={selected}
|
||||||
|
tabIndex={tabIndex}
|
||||||
onClick={onClick}
|
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 ${
|
className={`text-left rounded-xl border p-4 transition-colors cursor-pointer focus:outline-none focus:ring-2 focus:ring-accent/40 ${
|
||||||
selected
|
selected
|
||||||
? "border-accent bg-accent/10"
|
? "border-accent bg-accent/10"
|
||||||
: "border-border bg-surface-2 hover:border-accent/40 hover:bg-surface-3/30"
|
: "border-border bg-surface-2 hover:border-accent/40 hover:bg-surface-3/30"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div
|
<div className={`mb-2 ${selected ? "text-accent" : "text-text-muted"}`}>
|
||||||
className={`mb-2 ${
|
|
||||||
selected ? "text-accent" : "text-text-muted"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{icon}
|
{icon}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className={`text-sm font-semibold mb-0.5 ${
|
className={`text-sm font-semibold mb-0.5 ${
|
||||||
selected ? "text-text-primary" : "text-text-primary"
|
selected ? "text-text-primary" : "text-text-secondary"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
@@ -347,4 +382,4 @@ function AccountTypeCard({
|
|||||||
<div className="text-xs text-text-muted leading-snug">{description}</div>
|
<div className="text-xs text-text-muted leading-snug">{description}</div>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|||||||
@@ -1,30 +1,40 @@
|
|||||||
import { getTranslations } from "next-intl/server";
|
|
||||||
import { redirect, notFound } from "next/navigation";
|
import { redirect, notFound } from "next/navigation";
|
||||||
import { getSessionUser, canMutate } from "@/lib/session";
|
import { getTranslations } from "next-intl/server";
|
||||||
import { getOrgBilling } from "@/lib/db";
|
import { getSessionUser } from "@/lib/session";
|
||||||
import { BillingSettingsForm } from "@/components/settings/billing-settings-form";
|
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
|
* Owner-only by visibility: non-owner members get a 404 (same
|
||||||
* the client form. The form posts to PUT /api/billing on submit.
|
* response as if the page didn't exist). The link to this page
|
||||||
|
* is also hidden from non-owners on /billing and elsewhere, but
|
||||||
|
* the page itself enforces too — a non-owner who learns the URL
|
||||||
|
* still gets 404, not 403, so the page's existence doesn't leak.
|
||||||
*
|
*
|
||||||
* Access: same gate as the API — owners and platform admins. `user`
|
* First-time visitors see an empty form. Subsequent visits see
|
||||||
* role redirects to /settings (which also wouldn't list billing for
|
* the current values, editable. Save creates or updates via the
|
||||||
* them). 403 here would be friendlier than redirect, but the most
|
* shared upsert path; the row's existence drives whether the
|
||||||
* likely cause of a `user` landing on this URL is sharing a bookmark
|
* monthly issuance cron will pick this org up.
|
||||||
* with their owner — silent redirect is gentle.
|
*
|
||||||
|
* 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() {
|
export default async function BillingSettingsPage() {
|
||||||
const user = await getSessionUser();
|
const user = await getSessionUser();
|
||||||
if (!user) redirect("/login");
|
if (!user) redirect("/login");
|
||||||
if (!canMutate(user)) {
|
// Non-owners get a 404 — see comment above.
|
||||||
redirect("/settings");
|
if (!user.roles.includes("owner")) notFound();
|
||||||
}
|
|
||||||
const t = await getTranslations("settingsBilling");
|
|
||||||
|
|
||||||
const billing = await getOrgBilling(user.orgId);
|
const t = await getTranslations("settingsBilling");
|
||||||
|
const [existing, config] = await Promise.all([
|
||||||
|
getOrgBilling(user.orgId),
|
||||||
|
getOrgBillingConfig(user.orgId),
|
||||||
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="max-w-3xl mx-auto px-6 py-8">
|
<main className="max-w-3xl mx-auto px-6 py-8">
|
||||||
@@ -32,16 +42,30 @@ export default async function BillingSettingsPage() {
|
|||||||
<h1 className="font-display text-2xl font-semibold accent-rule">
|
<h1 className="font-display text-2xl font-semibold accent-rule">
|
||||||
{t("title")}
|
{t("title")}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-sm text-text-secondary mt-3">{t("subtitle")}</p>
|
<p className="text-sm text-text-secondary mt-3">
|
||||||
|
{user.isPersonal ? t("subtitlePersonal") : t("subtitle")}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="animate-in animate-in-delay-1">
|
||||||
<BillingSettingsForm
|
<BillingSettingsForm
|
||||||
initial={billing}
|
initial={existing}
|
||||||
isPersonal={user.isPersonal}
|
isPersonal={user.isPersonal}
|
||||||
orgName={user.orgName}
|
/>
|
||||||
userName={user.name}
|
</div>
|
||||||
userEmail={user.email}
|
{/* 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>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,14 +14,20 @@ import { Card } from "@/components/ui/card";
|
|||||||
* Access: any authenticated user (the cards themselves gate further;
|
* Access: any authenticated user (the cards themselves gate further;
|
||||||
* non-owner users would not see "Billing" as actionable, etc.).
|
* 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() {
|
export default async function SettingsPage() {
|
||||||
const user = await getSessionUser();
|
const user = await getSessionUser();
|
||||||
if (!user) redirect("/login");
|
if (!user) redirect("/login");
|
||||||
const t = await getTranslations("settings");
|
const t = await getTranslations("settings");
|
||||||
|
|
||||||
// Build the list of settings cards. Each entry has a stable key, a
|
// Build the list of settings cards. Each entry has a stable key, a
|
||||||
// route, and a visibility predicate. Currently only billing; this
|
// route, and a visibility predicate. Phase 6 fix5: profile is
|
||||||
// shape leaves headroom for adding more without restructuring.
|
// visible to every signed-in user (it's their own identity).
|
||||||
|
// Billing stays gated behind canMutate.
|
||||||
const sections: Array<{
|
const sections: Array<{
|
||||||
key: string;
|
key: string;
|
||||||
href: string;
|
href: string;
|
||||||
@@ -29,6 +35,14 @@ export default async function SettingsPage() {
|
|||||||
description: string;
|
description: string;
|
||||||
visible: boolean;
|
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",
|
key: "billing",
|
||||||
href: "/settings/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
|
* having recent activity, but we don't sort by status; that's a
|
||||||
* filter the admin can add later if the queue grows.
|
* 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() {
|
export default async function SupportListPage() {
|
||||||
const user = await getSessionUser();
|
const user = await getSessionUser();
|
||||||
if (!user) redirect("/login");
|
if (!user) redirect("/login");
|
||||||
@@ -48,7 +53,7 @@ export default async function SupportListPage() {
|
|||||||
{!user.isPlatform && (
|
{!user.isPlatform && (
|
||||||
<Link
|
<Link
|
||||||
href="/support/new"
|
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")}
|
{t("newTicket")}
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { Card } from "@/components/ui/card";
|
|||||||
import { BackLink } from "@/components/ui/back-link";
|
import { BackLink } from "@/components/ui/back-link";
|
||||||
import { TeamList } from "@/components/team/team-list";
|
import { TeamList } from "@/components/team/team-list";
|
||||||
import { InviteForm } from "@/components/team/invite-form";
|
import { InviteForm } from "@/components/team/invite-form";
|
||||||
|
import { AccessOverview } from "@/components/team/access-overview";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* /team — manage org members.
|
* /team — manage org members.
|
||||||
@@ -17,6 +18,11 @@ import { InviteForm } from "@/components/team/invite-form";
|
|||||||
* `<TeamList>` and `<InviteForm>` client components handle live
|
* `<TeamList>` and `<InviteForm>` client components handle live
|
||||||
* updates after invites and refreshes.
|
* updates after invites and refreshes.
|
||||||
*/
|
*/
|
||||||
|
export async function generateMetadata() {
|
||||||
|
const t = await getTranslations("common");
|
||||||
|
return { title: t("team") };
|
||||||
|
}
|
||||||
|
|
||||||
export default async function TeamPage() {
|
export default async function TeamPage() {
|
||||||
const user = await getSessionUser();
|
const user = await getSessionUser();
|
||||||
if (!user) redirect("/login");
|
if (!user) redirect("/login");
|
||||||
@@ -65,6 +71,16 @@ export default async function TeamPage() {
|
|||||||
canEditRoles={isCustomerOwner(user)}
|
canEditRoles={isCustomerOwner(user)}
|
||||||
/>
|
/>
|
||||||
</section>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { WorkspaceEditor } from "@/components/packages/workspace-editor";
|
|||||||
import { ChannelUsers } from "@/components/channel-users/channel-users";
|
import { ChannelUsers } from "@/components/channel-users/channel-users";
|
||||||
import { AssignedUsersPanel } from "@/components/tenants/assigned-users-panel";
|
import { AssignedUsersPanel } from "@/components/tenants/assigned-users-panel";
|
||||||
import { SubscriptionToggle } from "@/components/tenants/subscription-toggle";
|
import { SubscriptionToggle } from "@/components/tenants/subscription-toggle";
|
||||||
|
import { ConnectPanel } from "@/components/tenants/connect-panel";
|
||||||
import { formatDateTime, formatRelative } from "@/lib/format";
|
import { formatDateTime, formatRelative } from "@/lib/format";
|
||||||
import { CHANNEL_PACKAGE_IDS } from "@/lib/packages";
|
import { CHANNEL_PACKAGE_IDS } from "@/lib/packages";
|
||||||
|
|
||||||
@@ -216,6 +217,20 @@ export default async function TenantDetailPage({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Connect: how the customer actually reaches their assistant.
|
||||||
|
The portal manages the assistant; the assistant lives in the
|
||||||
|
customer's messaging app. This bridges that gap right at the
|
||||||
|
top of the page (and calls out the case where no channel is
|
||||||
|
enabled, which would otherwise leave a running assistant
|
||||||
|
unreachable). */}
|
||||||
|
<section className="mb-8 animate-in animate-in-delay-1">
|
||||||
|
<ConnectPanel
|
||||||
|
tenantName={name}
|
||||||
|
enabledChannels={enabledChannels}
|
||||||
|
phase={tenant.status?.phase ?? "Pending"}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
|
||||||
{/* Usage */}
|
{/* Usage */}
|
||||||
<section className="mb-8 animate-in animate-in-delay-1">
|
<section className="mb-8 animate-in animate-in-delay-1">
|
||||||
<h2 className="text-xs font-semibold uppercase tracking-wider text-text-muted mb-3">
|
<h2 className="text-xs font-semibold uppercase tracking-wider text-text-muted mb-3">
|
||||||
|
|||||||
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,
|
getTenantRequestById,
|
||||||
updateTenantRequestStatus,
|
updateTenantRequestStatus,
|
||||||
clearEncryptedSecrets,
|
clearEncryptedSecrets,
|
||||||
recordTenantCreated,
|
|
||||||
recordSkillEvents,
|
|
||||||
recordSuspensionEvent,
|
|
||||||
} from "@/lib/db";
|
} from "@/lib/db";
|
||||||
import { createTenant, patchTenantSpec, setTenantAnnotation } from "@/lib/k8s";
|
import { createTenant, patchTenantSpec, setTenantAnnotation } from "@/lib/k8s";
|
||||||
import { sendApprovalEmail, sendResumeApprovalEmail } from "@/lib/email";
|
import { sendApprovalEmail, sendResumeApprovalEmail } from "@/lib/email";
|
||||||
import { decryptSecrets } from "@/lib/crypto";
|
import { decryptSecrets } from "@/lib/crypto";
|
||||||
import { writePackageSecrets } from "@/lib/openbao";
|
import { writePackageSecrets } from "@/lib/openbao";
|
||||||
|
import { createRoute as createRelayRoute } from "@/lib/threema-relay";
|
||||||
import {
|
import {
|
||||||
getDefaultSoulMd,
|
getDefaultSoulMd,
|
||||||
getDefaultAgentsMd,
|
getDefaultAgentsMd,
|
||||||
@@ -88,23 +86,6 @@ export async function POST(
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await patchTenantSpec(tenantRequest.tenantName, { suspend: false });
|
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.
|
// Clear the annotation that pauses the operator's 60-day TTL.
|
||||||
// Best-effort — annotation cleanup is also done by the operator
|
// Best-effort — annotation cleanup is also done by the operator
|
||||||
// when it sees suspend=false on the next reconcile (it clears
|
// when it sees suspend=false on the next reconcile (it clears
|
||||||
@@ -197,6 +178,29 @@ export async function POST(
|
|||||||
? tenantRequest.contactName || "Assistant"
|
? tenantRequest.contactName || "Assistant"
|
||||||
: tenantRequest.companyName;
|
: 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(
|
await createTenant(
|
||||||
tenantName,
|
tenantName,
|
||||||
{
|
{
|
||||||
@@ -204,6 +208,9 @@ export async function POST(
|
|||||||
agentName: tenantRequest.agentName,
|
agentName: tenantRequest.agentName,
|
||||||
packages,
|
packages,
|
||||||
workspaceFiles,
|
workspaceFiles,
|
||||||
|
...(Object.keys(filteredChannelUsers).length > 0
|
||||||
|
? { channelUsers: filteredChannelUsers }
|
||||||
|
: {}),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"pieced.ch/zitadel-org-id": tenantRequest.zitadelOrgId,
|
"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
|
// Threema: register relay routes for each id the customer
|
||||||
// package state. Anchored at "now" rather than the CR's
|
// entered. Best-effort — a route failure doesn't unwind the
|
||||||
// creationTimestamp because we don't get the timestamp back from
|
// tenant creation (admin can retry from the tenant page later).
|
||||||
// createTenant — the few-millisecond skew vs the CR's actual
|
// The Threema package itself isn't enabled on the tenant until
|
||||||
// creationTimestamp is irrelevant for monthly billing.
|
// the customer toggles it from the tenant detail page (which
|
||||||
//
|
// also mints the per-tenant token); the routes here pre-warm
|
||||||
// Best-effort: tracking failures must never block provisioning.
|
// the relay so the first toggle works without re-typing the id.
|
||||||
// The backfill helper can repair any gaps later if needed.
|
if (
|
||||||
const billingAnchor = new Date();
|
packages.includes("threema") &&
|
||||||
try {
|
filteredChannelUsers.threema &&
|
||||||
await recordTenantCreated(
|
filteredChannelUsers.threema.length > 0
|
||||||
tenantName,
|
) {
|
||||||
tenantRequest.zitadelOrgId,
|
for (const tid of filteredChannelUsers.threema) {
|
||||||
billingAnchor
|
try {
|
||||||
);
|
const res = await createRelayRoute(tenantName, tid);
|
||||||
await recordSkillEvents(
|
if (!res.ok) {
|
||||||
tenantName,
|
console.warn(
|
||||||
tenantRequest.zitadelOrgId,
|
`[approve] Threema route create for tenant=${tenantName} id=${tid} returned not-ok: ${res.message}`
|
||||||
packages,
|
);
|
||||||
[],
|
}
|
||||||
billingAnchor
|
} catch (e) {
|
||||||
);
|
console.error(
|
||||||
} catch (e) {
|
`[approve] Threema route create threw for tenant=${tenantName} id=${tid}:`,
|
||||||
console.error(
|
e
|
||||||
"billing: failed to record tenant creation / initial skill events:",
|
);
|
||||||
e
|
}
|
||||||
);
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 5: Update request status — clear admin notes on re-approval
|
// Step 5: Update request status — clear admin notes on re-approval
|
||||||
|
|||||||
@@ -1,8 +1,14 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { requirePlatformRole } from "@/lib/session";
|
import { requirePlatformRole } from "@/lib/session";
|
||||||
import { getTenantRequestById, updateTenantRequestStatus } from "@/lib/db";
|
import {
|
||||||
|
getInvoiceById,
|
||||||
|
getTenantRequestById,
|
||||||
|
updateTenantRequestStatus,
|
||||||
|
} from "@/lib/db";
|
||||||
import { setTenantAnnotation } from "@/lib/k8s";
|
import { setTenantAnnotation } from "@/lib/k8s";
|
||||||
import { sendRejectionEmail, sendResumeRejectionEmail } from "@/lib/email";
|
import { sendRejectionEmail, sendResumeRejectionEmail } from "@/lib/email";
|
||||||
|
import { refundInvoice, RefundNotAllowedError } from "@/lib/billing";
|
||||||
|
import type { SessionUser } from "@/types";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /api/admin/requests/[id]/reject
|
* 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
|
* suspendedAt — rejection doesn't reset it. The customer can submit
|
||||||
* a fresh resume request later if circumstances change, but that
|
* a fresh resume request later if circumstances change, but that
|
||||||
* starts a new pending row and re-stamps the annotation.
|
* 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(
|
export async function POST(
|
||||||
request: Request,
|
request: Request,
|
||||||
{ params }: { params: Promise<{ id: string }> }
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
) {
|
) {
|
||||||
|
let user: SessionUser;
|
||||||
try {
|
try {
|
||||||
await requirePlatformRole();
|
user = await requirePlatformRole();
|
||||||
} catch {
|
} catch {
|
||||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
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
|
// Notify customer. Resume requests get a different email — the
|
||||||
// tenant already exists; copy needs to mention "stays suspended" and
|
// tenant already exists; copy needs to mention "stays suspended" and
|
||||||
// the 60-day retention deadline. Provision rejections use the
|
// the 60-day retention deadline. Provision rejections use the
|
||||||
@@ -88,5 +161,6 @@ export async function POST(
|
|||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
message: "Request rejected.",
|
message: "Request rejected.",
|
||||||
request: updated,
|
request: updated,
|
||||||
|
refund: refundSummary,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
27
src/app/api/billing/auto-charge/route.ts
Normal file
27
src/app/api/billing/auto-charge/route.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/billing/auto-charge — RETIRED.
|
||||||
|
*
|
||||||
|
* Auto-pay is no longer a customer-toggleable setting. A saved
|
||||||
|
* card on file is the consent to auto-bill; customers manage their
|
||||||
|
* card via update/remove on /settings/billing, nothing else. The
|
||||||
|
* auto_charge_enabled flag is now an admin-only pause used during
|
||||||
|
* disputes, set from /admin/billing/orgs.
|
||||||
|
*
|
||||||
|
* This route is kept as an explicit 410 (Gone) so any stale client
|
||||||
|
* that still POSTs here fails loudly rather than silently toggling
|
||||||
|
* a flag the customer shouldn't control. The old behaviour lived
|
||||||
|
* here through Phase 9b-2.
|
||||||
|
*/
|
||||||
|
export async function POST() {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error:
|
||||||
|
"Auto-pay can no longer be disabled. A saved card is required for service. " +
|
||||||
|
"Contact support if you need to switch to bank-transfer billing.",
|
||||||
|
code: "auto_pay_not_toggleable",
|
||||||
|
},
|
||||||
|
{ status: 410 }
|
||||||
|
);
|
||||||
|
}
|
||||||
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 { NextRequest, NextResponse } from "next/server";
|
||||||
import { getSessionUser, canMutate } from "@/lib/session";
|
import { getSessionUser, canMutate } from "@/lib/session";
|
||||||
import {
|
import {
|
||||||
|
getInvoiceById,
|
||||||
getTenantRequestById,
|
getTenantRequestById,
|
||||||
updateTenantRequestStatus,
|
updateTenantRequestStatus,
|
||||||
updateTenantRequestEditableFields,
|
updateTenantRequestEditableFields,
|
||||||
@@ -9,6 +10,8 @@ import { encryptSecrets } from "@/lib/crypto";
|
|||||||
import { setTenantAnnotation } from "@/lib/k8s";
|
import { setTenantAnnotation } from "@/lib/k8s";
|
||||||
import { onboardingSchema } from "@/lib/validation";
|
import { onboardingSchema } from "@/lib/validation";
|
||||||
import { safeError } from "@/lib/errors";
|
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.
|
* Customer-side controls for a single tenant_request row.
|
||||||
@@ -29,7 +32,7 @@ async function loadAuthorized(
|
|||||||
id: string
|
id: string
|
||||||
): Promise<
|
): Promise<
|
||||||
| { error: NextResponse }
|
| { error: NextResponse }
|
||||||
| { req: Awaited<ReturnType<typeof getTenantRequestById>>; }
|
| { req: TenantRequest; user: SessionUser }
|
||||||
> {
|
> {
|
||||||
const user = await getSessionUser();
|
const user = await getSessionUser();
|
||||||
if (!user) {
|
if (!user) {
|
||||||
@@ -55,7 +58,7 @@ async function loadAuthorized(
|
|||||||
error: NextResponse.json({ error: "Not found" }, { status: 404 }),
|
error: NextResponse.json({ error: "Not found" }, { status: 404 }),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return { req: tr };
|
return { req: tr, user };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -93,6 +96,50 @@ export async function DELETE(
|
|||||||
try {
|
try {
|
||||||
await updateTenantRequestStatus(id, "cancelled");
|
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
|
// Customer cancels their own pending resume request: clear the
|
||||||
// operator-side annotation so the 60-day TTL resumes counting.
|
// operator-side annotation so the 60-day TTL resumes counting.
|
||||||
// Best-effort — the operator handles missing annotation gracefully.
|
// 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) {
|
} catch (e: any) {
|
||||||
console.error("Failed to cancel request:", e);
|
console.error("Failed to cancel request:", e);
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
|
|||||||
@@ -2,11 +2,14 @@ import { NextRequest, NextResponse } from "next/server";
|
|||||||
import { getSessionUser, canMutate } from "@/lib/session";
|
import { getSessionUser, canMutate } from "@/lib/session";
|
||||||
import {
|
import {
|
||||||
createTenantRequest,
|
createTenantRequest,
|
||||||
|
createTenantRequestPendingPayment,
|
||||||
|
deletePendingPaymentRequest,
|
||||||
getTenantRequestById,
|
getTenantRequestById,
|
||||||
listTenantRequestsByOrgId,
|
listTenantRequestsByOrgId,
|
||||||
listActiveTenantRequestsByOrgId,
|
listActiveTenantRequestsByOrgId,
|
||||||
getMostRecentApprovedRequestForOrg,
|
getMostRecentApprovedRequestForOrg,
|
||||||
getOrgBilling,
|
getOrgBilling,
|
||||||
|
getPlatformPricing,
|
||||||
upsertOrgBilling,
|
upsertOrgBilling,
|
||||||
} from "@/lib/db";
|
} from "@/lib/db";
|
||||||
import { getTenant, listTenants } from "@/lib/k8s";
|
import { getTenant, listTenants } from "@/lib/k8s";
|
||||||
@@ -19,7 +22,18 @@ import { sendAdminNotificationEmail } from "@/lib/email";
|
|||||||
import { encryptSecrets } from "@/lib/crypto";
|
import { encryptSecrets } from "@/lib/crypto";
|
||||||
import { isPersonalOrgName } from "@/lib/personal-org";
|
import { isPersonalOrgName } from "@/lib/personal-org";
|
||||||
import { onboardingSchema, billingAddressSchema } from "@/lib/validation";
|
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";
|
import { z } from "zod";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -194,6 +208,7 @@ export async function POST(request: Request) {
|
|||||||
|
|
||||||
const input: OnboardingInput & {
|
const input: OnboardingInput & {
|
||||||
packageSecrets?: Record<string, Record<string, string>>;
|
packageSecrets?: Record<string, Record<string, string>>;
|
||||||
|
channelUsers?: Record<string, string[]>;
|
||||||
} = parsed.data;
|
} = parsed.data;
|
||||||
|
|
||||||
// Look up an existing approved request for this org to inherit
|
// 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
|
// The audit copy of company name on this request stays inherited
|
||||||
// details; the user can't change those by re-typing them in the wizard.
|
// 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 companyName = prior?.companyName ?? user.orgName;
|
||||||
const contactName = prior?.contactName ?? user.name;
|
const contactName = user.name;
|
||||||
const contactEmail = prior?.contactEmail ?? user.email;
|
const contactEmail = user.email;
|
||||||
|
|
||||||
// Bug 35: org-scoped billing.
|
// 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,
|
zitadelOrgId: user.orgId,
|
||||||
zitadelUserId: user.id,
|
zitadelUserId: user.id,
|
||||||
companyName,
|
companyName,
|
||||||
@@ -404,32 +489,140 @@ export async function POST(request: Request) {
|
|||||||
billingNotes,
|
billingNotes,
|
||||||
encryptedSecrets,
|
encryptedSecrets,
|
||||||
isPersonal,
|
isPersonal,
|
||||||
|
channelUsers: input.channelUsers ?? {},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Notify admin about the new request. For follow-up instances, include
|
// Derive the future tenant_name — needed on the invoice line so
|
||||||
// the instance name in the notification so the admin sees what's
|
// tenantHasSetupFeeBilled() in the monthly cron dedup finds the
|
||||||
// being requested without opening the panel.
|
// already-paid setup fee once the K8s tenant exists. The name is
|
||||||
try {
|
// request-id-suffix-derived, so abandoned Checkout retries each
|
||||||
await sendAdminNotificationEmail(
|
// get unique names.
|
||||||
tenantRequest.contactEmail,
|
const derivedTenantName = deriveTenantName(
|
||||||
tenantRequest.contactName,
|
isPersonal ? "personal" : "company",
|
||||||
tenantRequest.instanceName
|
companyName,
|
||||||
? `${tenantRequest.companyName} (${tenantRequest.instanceName})`
|
tenantRequest.id
|
||||||
: tenantRequest.companyName
|
);
|
||||||
|
|
||||||
|
// 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) {
|
} 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
|
// Create the Checkout session. The Stripe customer must exist
|
||||||
// already have? Useful for the admin queue.
|
// before this — ensureStripeCustomerForOrg returns the existing
|
||||||
const allRequests = await listTenantRequestsByOrgId(user.orgId);
|
// 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(
|
return NextResponse.json(
|
||||||
{
|
{
|
||||||
message: "Request submitted.",
|
message: "Redirecting to payment.",
|
||||||
request: publicRequestShape(tenantRequest),
|
request: publicRequestShape(tenantRequest),
|
||||||
orgRequestCount: allRequests.length,
|
checkoutUrl,
|
||||||
},
|
},
|
||||||
{ status: 201 }
|
{ 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,12 +1,25 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import type Stripe from "stripe";
|
import type Stripe from "stripe";
|
||||||
import { getStripeClient, getWebhookSecret } from "@/lib/stripe";
|
|
||||||
import {
|
import {
|
||||||
|
getPaymentMethodDisplay,
|
||||||
|
getStripeClient,
|
||||||
|
getWebhookSecret,
|
||||||
|
} from "@/lib/stripe";
|
||||||
|
import {
|
||||||
|
getInvoiceByStripePaymentIntent,
|
||||||
|
getInvoiceDetail,
|
||||||
|
getOrgIdByStripeCustomerId,
|
||||||
|
getTenantRequestForSetupFlow,
|
||||||
|
isStripeRefundRecorded,
|
||||||
|
linkTenantRequestSetupPayment,
|
||||||
markInvoicePaid,
|
markInvoicePaid,
|
||||||
markStripeEventProcessed,
|
markStripeEventProcessed,
|
||||||
setInvoiceStripePaymentIntent,
|
setInvoiceStripePaymentIntent,
|
||||||
|
setSavedPaymentMethod,
|
||||||
tryRecordStripeEvent,
|
tryRecordStripeEvent,
|
||||||
} from "@/lib/db";
|
} from "@/lib/db";
|
||||||
|
import { sendAdminNotificationEmail } from "@/lib/email";
|
||||||
|
import { refundInvoice, RefundNotAllowedError } from "@/lib/billing";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /api/stripe/webhook
|
* POST /api/stripe/webhook
|
||||||
@@ -158,6 +171,14 @@ export async function POST(request: Request) {
|
|||||||
async function handleCheckoutCompleted(
|
async function handleCheckoutCompleted(
|
||||||
session: Stripe.Checkout.Session
|
session: Stripe.Checkout.Session
|
||||||
): Promise<void> {
|
): 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
|
// Defensive: paid sessions are what we want; sessions can also
|
||||||
// complete in "unpaid" state (rare for mode=payment, more common
|
// complete in "unpaid" state (rare for mode=payment, more common
|
||||||
// for async/delayed methods like SEPA). Only flip the invoice
|
// for async/delayed methods like SEPA). Only flip the invoice
|
||||||
@@ -206,16 +227,320 @@ async function handleCheckoutCompleted(
|
|||||||
console.log(
|
console.log(
|
||||||
`Invoice ${invoiceId} marked paid via Stripe (session ${session.id}, intent ${paymentIntentId}).`
|
`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> {
|
async function handleChargeRefunded(charge: Stripe.Charge): Promise<void> {
|
||||||
// v1 scope: log only. Refunds always go through Stripe → admin
|
// Phase 7: mirror Stripe refunds into the portal so credit notes
|
||||||
// initiates them in the dashboard. Updating our invoice status
|
// are issued for refunds initiated in the Stripe Dashboard. For
|
||||||
// to 'void' or partial-credit needs more product thinking
|
// refunds initiated via /api/admin/.../refund, this handler is a
|
||||||
// (partial refunds? credit notes? VAT corrections?). Phase 7.
|
// no-op (each refund's stripe_refund_id is already recorded
|
||||||
console.log(
|
// before the webhook lands — refundInvoice records it
|
||||||
`Charge ${charge.id} refunded (amount ${charge.amount_refunded} ${charge.currency}); no portal-side state change.`
|
// 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(
|
async function handlePaymentFailed(
|
||||||
|
|||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
5
src/app/icon.svg
Normal file
5
src/app/icon.svg
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="5.5 3.69 38 38" role="img" aria-label="PieCed">
|
||||||
|
<rect x="5.5" y="3.69" width="38" height="38" rx="7" fill="#0B0F0E"/>
|
||||||
|
<polygon points="38.5,22.69 31.5,10.566 17.5,10.566 10.5,22.69 17.5,34.814 31.5,34.814"
|
||||||
|
fill="#10B981" stroke="#10B981" stroke-width="1.6" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 354 B |
@@ -4,6 +4,15 @@ import { useState, useEffect, useCallback } from "react";
|
|||||||
import { useTranslations, useFormatter } from "next-intl";
|
import { useTranslations, useFormatter } from "next-intl";
|
||||||
import type { PiecedTenant, TenantRequest } from "@/types";
|
import type { PiecedTenant, TenantRequest } from "@/types";
|
||||||
import { StatusBadge } from "@/components/ui/status-badge";
|
import { StatusBadge } from "@/components/ui/status-badge";
|
||||||
|
import { Modal } from "@/components/ui/modal";
|
||||||
|
import {
|
||||||
|
applyTableView,
|
||||||
|
nextSort,
|
||||||
|
SearchInput,
|
||||||
|
SortableTh,
|
||||||
|
Pagination,
|
||||||
|
type SortState,
|
||||||
|
} from "@/components/admin/table-controls";
|
||||||
import { formatDateTime, formatRelative } from "@/lib/format";
|
import { formatDateTime, formatRelative } from "@/lib/format";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
|
||||||
@@ -35,6 +44,11 @@ export function AdminPanel({ initialTenants }: AdminPanelProps) {
|
|||||||
const [actionLoading, setActionLoading] = useState<string | null>(null);
|
const [actionLoading, setActionLoading] = useState<string | null>(null);
|
||||||
const [rejectModal, setRejectModal] = useState<string | null>(null);
|
const [rejectModal, setRejectModal] = useState<string | null>(null);
|
||||||
const [rejectNotes, setRejectNotes] = useState("");
|
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
|
// Tenants state
|
||||||
const [tenants, setTenants] = useState<PiecedTenant[]>(initialTenants);
|
const [tenants, setTenants] = useState<PiecedTenant[]>(initialTenants);
|
||||||
@@ -48,6 +62,26 @@ export function AdminPanel({ initialTenants }: AdminPanelProps) {
|
|||||||
// Shared
|
// Shared
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
// Client-side table view state (search / sort / page) for each tab.
|
||||||
|
const [reqSearch, setReqSearch] = useState("");
|
||||||
|
const [reqSort, setReqSort] = useState<SortState>({
|
||||||
|
key: "created",
|
||||||
|
dir: "desc",
|
||||||
|
});
|
||||||
|
const [reqPage, setReqPage] = useState(1);
|
||||||
|
|
||||||
|
const [tenSearch, setTenSearch] = useState("");
|
||||||
|
const [tenSort, setTenSort] = useState<SortState>({
|
||||||
|
key: "created",
|
||||||
|
dir: "desc",
|
||||||
|
});
|
||||||
|
const [tenPage, setTenPage] = useState(1);
|
||||||
|
// 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 ───
|
// ─── Requests fetching ───
|
||||||
const fetchRequests = useCallback(async () => {
|
const fetchRequests = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -125,18 +159,21 @@ export function AdminPanel({ initialTenants }: AdminPanelProps) {
|
|||||||
// ─── Request actions ───
|
// ─── Request actions ───
|
||||||
const handleApprove = async (id: string) => {
|
const handleApprove = async (id: string) => {
|
||||||
setActionLoading(id);
|
setActionLoading(id);
|
||||||
setError("");
|
setActionError("");
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/admin/requests/${id}/approve`, {
|
const res = await fetch(`/api/admin/requests/${id}/approve`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const data = await res.json();
|
const data = await res.json().catch(() => ({}));
|
||||||
throw new Error(data.error || "Approve failed");
|
throw new Error(data.error || "Approve failed");
|
||||||
}
|
}
|
||||||
|
setApproveModal(null);
|
||||||
await fetchRequests();
|
await fetchRequests();
|
||||||
} catch (e: any) {
|
} 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 {
|
} finally {
|
||||||
setActionLoading(null);
|
setActionLoading(null);
|
||||||
}
|
}
|
||||||
@@ -144,7 +181,7 @@ export function AdminPanel({ initialTenants }: AdminPanelProps) {
|
|||||||
|
|
||||||
const handleReject = async (id: string) => {
|
const handleReject = async (id: string) => {
|
||||||
setActionLoading(id);
|
setActionLoading(id);
|
||||||
setError("");
|
setActionError("");
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/admin/requests/${id}/reject`, {
|
const res = await fetch(`/api/admin/requests/${id}/reject`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -152,14 +189,14 @@ export function AdminPanel({ initialTenants }: AdminPanelProps) {
|
|||||||
body: JSON.stringify({ adminNotes: rejectNotes || undefined }),
|
body: JSON.stringify({ adminNotes: rejectNotes || undefined }),
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const data = await res.json();
|
const data = await res.json().catch(() => ({}));
|
||||||
throw new Error(data.error || "Reject failed");
|
throw new Error(data.error || "Reject failed");
|
||||||
}
|
}
|
||||||
setRejectModal(null);
|
setRejectModal(null);
|
||||||
setRejectNotes("");
|
setRejectNotes("");
|
||||||
await fetchRequests();
|
await fetchRequests();
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
setError(e.message);
|
setActionError(e.message);
|
||||||
} finally {
|
} finally {
|
||||||
setActionLoading(null);
|
setActionLoading(null);
|
||||||
}
|
}
|
||||||
@@ -189,7 +226,7 @@ export function AdminPanel({ initialTenants }: AdminPanelProps) {
|
|||||||
|
|
||||||
const handleDelete = async (name: string) => {
|
const handleDelete = async (name: string) => {
|
||||||
setActionLoading(name);
|
setActionLoading(name);
|
||||||
setError("");
|
setActionError("");
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/admin/tenants/${name}/delete`, {
|
const res = await fetch(`/api/admin/tenants/${name}/delete`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -216,7 +253,7 @@ export function AdminPanel({ initialTenants }: AdminPanelProps) {
|
|||||||
fetchTenants();
|
fetchTenants();
|
||||||
setTimeout(() => fetchTenants(), 1500);
|
setTimeout(() => fetchTenants(), 1500);
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
setError(e.message);
|
setActionError(e.message);
|
||||||
} finally {
|
} finally {
|
||||||
setActionLoading(null);
|
setActionLoading(null);
|
||||||
}
|
}
|
||||||
@@ -232,6 +269,53 @@ export function AdminPanel({ initialTenants }: AdminPanelProps) {
|
|||||||
|
|
||||||
const pendingCount = requests.filter((r) => r.status === "pending").length;
|
const pendingCount = requests.filter((r) => r.status === "pending").length;
|
||||||
|
|
||||||
|
// Derived table views: search → sort → paginate, applied client-side
|
||||||
|
// on top of the already-fetched lists.
|
||||||
|
const reqView = applyTableView(requests, {
|
||||||
|
search: reqSearch,
|
||||||
|
searchOf: (r) => [
|
||||||
|
r.companyName,
|
||||||
|
r.contactName,
|
||||||
|
r.contactEmail,
|
||||||
|
r.agentName,
|
||||||
|
r.tenantName,
|
||||||
|
],
|
||||||
|
sort: reqSort,
|
||||||
|
sortOf: (r, key) =>
|
||||||
|
key === "company"
|
||||||
|
? r.companyName || ""
|
||||||
|
: key === "status"
|
||||||
|
? r.status || ""
|
||||||
|
: r.createdAt || "",
|
||||||
|
page: reqPage,
|
||||||
|
});
|
||||||
|
|
||||||
|
const tenView = applyTableView(tenants, {
|
||||||
|
search: tenSearch,
|
||||||
|
searchOf: (tn) => [
|
||||||
|
tn.metadata.name,
|
||||||
|
tn.spec.displayName,
|
||||||
|
tn.spec.agentName,
|
||||||
|
],
|
||||||
|
sort: tenSort,
|
||||||
|
sortOf: (tn, key) =>
|
||||||
|
key === "name"
|
||||||
|
? tn.spec.displayName || tn.metadata.name
|
||||||
|
: key === "phase"
|
||||||
|
? tn.status?.phase || "Pending"
|
||||||
|
: tn.metadata.creationTimestamp || "",
|
||||||
|
page: tenPage,
|
||||||
|
});
|
||||||
|
|
||||||
|
const onReqSort = (key: string) => {
|
||||||
|
setReqSort((s) => nextSort(s, key));
|
||||||
|
setReqPage(1);
|
||||||
|
};
|
||||||
|
const onTenSort = (key: string) => {
|
||||||
|
setTenSort((s) => nextSort(s, key));
|
||||||
|
setTenPage(1);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Tab bar */}
|
{/* Tab bar */}
|
||||||
@@ -246,7 +330,7 @@ export function AdminPanel({ initialTenants }: AdminPanelProps) {
|
|||||||
>
|
>
|
||||||
{t("requests")}
|
{t("requests")}
|
||||||
{pendingCount > 0 && tab !== "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}
|
{pendingCount}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -301,20 +385,33 @@ export function AdminPanel({ initialTenants }: AdminPanelProps) {
|
|||||||
{/* ───── REQUESTS TAB ───── */}
|
{/* ───── REQUESTS TAB ───── */}
|
||||||
{tab === "requests" && (
|
{tab === "requests" && (
|
||||||
<>
|
<>
|
||||||
<div className="flex gap-1.5 mb-4 flex-wrap">
|
<div className="flex items-center justify-between gap-3 mb-4 flex-wrap">
|
||||||
{FILTERS.map((f) => (
|
<div className="flex gap-1.5 flex-wrap">
|
||||||
<button
|
{FILTERS.map((f) => (
|
||||||
key={f}
|
<button
|
||||||
onClick={() => setFilter(f)}
|
key={f}
|
||||||
className={`px-3 py-1 text-xs rounded-full transition-colors ${
|
onClick={() => {
|
||||||
filter === f
|
setFilter(f);
|
||||||
? "bg-accent text-white"
|
setReqPage(1);
|
||||||
: "bg-surface-2 text-text-muted hover:text-text-secondary border border-border"
|
}}
|
||||||
}`}
|
className={`px-3 py-1 text-xs rounded-full transition-colors ${
|
||||||
>
|
filter === f
|
||||||
{t(`filter_${f}`)}
|
? "bg-accent text-surface-0"
|
||||||
</button>
|
: "bg-surface-2 text-text-muted hover:text-text-secondary border border-border"
|
||||||
))}
|
}`}
|
||||||
|
>
|
||||||
|
{t(`filter_${f}`)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<SearchInput
|
||||||
|
value={reqSearch}
|
||||||
|
onChange={(v) => {
|
||||||
|
setReqSearch(v);
|
||||||
|
setReqPage(1);
|
||||||
|
}}
|
||||||
|
placeholder={t("searchRequestsPlaceholder")}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{loadingRequests ? (
|
{loadingRequests ? (
|
||||||
@@ -326,15 +423,22 @@ export function AdminPanel({ initialTenants }: AdminPanelProps) {
|
|||||||
<div className="bg-surface-1 border border-border rounded-xl p-12 text-center">
|
<div className="bg-surface-1 border border-border rounded-xl p-12 text-center">
|
||||||
<p className="text-text-secondary text-sm">{t("noRequests")}</p>
|
<p className="text-text-secondary text-sm">{t("noRequests")}</p>
|
||||||
</div>
|
</div>
|
||||||
|
) : reqView.total === 0 ? (
|
||||||
|
<div className="bg-surface-1 border border-border rounded-xl p-12 text-center">
|
||||||
|
<p className="text-text-secondary text-sm">{t("noMatches")}</p>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="bg-surface-1 border border-border rounded-xl overflow-hidden">
|
<div className="bg-surface-1 border border-border rounded-xl overflow-hidden">
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-border text-left">
|
<tr className="border-b border-border text-left">
|
||||||
<th className="px-4 py-3 text-xs font-semibold uppercase tracking-wider text-text-muted">
|
<SortableTh
|
||||||
{t("company")}
|
label={t("company")}
|
||||||
</th>
|
sortKey="company"
|
||||||
|
sort={reqSort}
|
||||||
|
onSort={onReqSort}
|
||||||
|
/>
|
||||||
<th className="px-4 py-3 text-xs font-semibold uppercase tracking-wider text-text-muted">
|
<th className="px-4 py-3 text-xs font-semibold uppercase tracking-wider text-text-muted">
|
||||||
{t("contact")}
|
{t("contact")}
|
||||||
</th>
|
</th>
|
||||||
@@ -344,19 +448,26 @@ export function AdminPanel({ initialTenants }: AdminPanelProps) {
|
|||||||
<th className="px-4 py-3 text-xs font-semibold uppercase tracking-wider text-text-muted hidden lg:table-cell">
|
<th className="px-4 py-3 text-xs font-semibold uppercase tracking-wider text-text-muted hidden lg:table-cell">
|
||||||
{t("packages")}
|
{t("packages")}
|
||||||
</th>
|
</th>
|
||||||
<th className="px-4 py-3 text-xs font-semibold uppercase tracking-wider text-text-muted">
|
<SortableTh
|
||||||
{t("status")}
|
label={t("status")}
|
||||||
</th>
|
sortKey="status"
|
||||||
<th className="px-4 py-3 text-xs font-semibold uppercase tracking-wider text-text-muted hidden md:table-cell">
|
sort={reqSort}
|
||||||
{t("submitted")}
|
onSort={onReqSort}
|
||||||
</th>
|
/>
|
||||||
|
<SortableTh
|
||||||
|
label={t("submitted")}
|
||||||
|
sortKey="created"
|
||||||
|
sort={reqSort}
|
||||||
|
onSort={onReqSort}
|
||||||
|
className="hidden md:table-cell"
|
||||||
|
/>
|
||||||
<th className="px-4 py-3 text-xs font-semibold uppercase tracking-wider text-text-muted">
|
<th className="px-4 py-3 text-xs font-semibold uppercase tracking-wider text-text-muted">
|
||||||
{t("actions")}
|
{t("actions")}
|
||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{requests.map((req) => (
|
{reqView.paged.map((req) => (
|
||||||
<tr
|
<tr
|
||||||
key={req.id}
|
key={req.id}
|
||||||
className="border-b border-border last:border-0 hover:bg-surface-2/50 transition-colors"
|
className="border-b border-border last:border-0 hover:bg-surface-2/50 transition-colors"
|
||||||
@@ -436,16 +547,20 @@ export function AdminPanel({ initialTenants }: AdminPanelProps) {
|
|||||||
{req.status === "pending" && (
|
{req.status === "pending" && (
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleApprove(req.id)}
|
onClick={() => {
|
||||||
|
setActionError("");
|
||||||
|
setApproveModal(req.id);
|
||||||
|
}}
|
||||||
disabled={actionLoading === 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"
|
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>
|
||||||
<button
|
<button
|
||||||
onClick={() => setRejectModal(req.id)}
|
onClick={() => {
|
||||||
|
setActionError("");
|
||||||
|
setRejectModal(req.id);
|
||||||
|
}}
|
||||||
disabled={actionLoading === 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"
|
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 +581,10 @@ export function AdminPanel({ initialTenants }: AdminPanelProps) {
|
|||||||
)}
|
)}
|
||||||
{req.status === "rejected" && (
|
{req.status === "rejected" && (
|
||||||
<button
|
<button
|
||||||
onClick={() => handleApprove(req.id)}
|
onClick={() => {
|
||||||
|
setActionError("");
|
||||||
|
setApproveModal(req.id);
|
||||||
|
}}
|
||||||
disabled={actionLoading === 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"
|
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"
|
||||||
>
|
>
|
||||||
@@ -485,6 +603,12 @@ export function AdminPanel({ initialTenants }: AdminPanelProps) {
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
<Pagination
|
||||||
|
page={reqView.page}
|
||||||
|
totalPages={reqView.totalPages}
|
||||||
|
total={reqView.total}
|
||||||
|
onPage={setReqPage}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
@@ -522,6 +646,17 @@ export function AdminPanel({ initialTenants }: AdminPanelProps) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end mb-4">
|
||||||
|
<SearchInput
|
||||||
|
value={tenSearch}
|
||||||
|
onChange={(v) => {
|
||||||
|
setTenSearch(v);
|
||||||
|
setTenPage(1);
|
||||||
|
}}
|
||||||
|
placeholder={t("searchTenantsPlaceholder")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{loadingTenants ? (
|
{loadingTenants ? (
|
||||||
<div className="bg-surface-1 border border-border rounded-xl p-12 text-center">
|
<div className="bg-surface-1 border border-border rounded-xl p-12 text-center">
|
||||||
<div className="h-5 w-5 border-2 border-accent border-t-transparent rounded-full animate-spin mx-auto mb-2" />
|
<div className="h-5 w-5 border-2 border-accent border-t-transparent rounded-full animate-spin mx-auto mb-2" />
|
||||||
@@ -531,37 +666,51 @@ export function AdminPanel({ initialTenants }: AdminPanelProps) {
|
|||||||
<div className="bg-surface-1 border border-border rounded-xl p-12 text-center">
|
<div className="bg-surface-1 border border-border rounded-xl p-12 text-center">
|
||||||
<p className="text-text-secondary text-sm">{t("noTenants")}</p>
|
<p className="text-text-secondary text-sm">{t("noTenants")}</p>
|
||||||
</div>
|
</div>
|
||||||
|
) : tenView.total === 0 ? (
|
||||||
|
<div className="bg-surface-1 border border-border rounded-xl p-12 text-center">
|
||||||
|
<p className="text-text-secondary text-sm">{t("noMatches")}</p>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="bg-surface-1 border border-border rounded-xl overflow-hidden">
|
<div className="bg-surface-1 border border-border rounded-xl overflow-hidden">
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-border text-left">
|
<tr className="border-b border-border text-left">
|
||||||
<th className="px-4 py-3 text-xs font-semibold uppercase tracking-wider text-text-muted">
|
<SortableTh
|
||||||
{t("name")}
|
label={t("name")}
|
||||||
</th>
|
sortKey="name"
|
||||||
|
sort={tenSort}
|
||||||
|
onSort={onTenSort}
|
||||||
|
/>
|
||||||
<th className="px-4 py-3 text-xs font-semibold uppercase tracking-wider text-text-muted">
|
<th className="px-4 py-3 text-xs font-semibold uppercase tracking-wider text-text-muted">
|
||||||
{t("displayName")}
|
{t("displayName")}
|
||||||
</th>
|
</th>
|
||||||
<th className="px-4 py-3 text-xs font-semibold uppercase tracking-wider text-text-muted">
|
<SortableTh
|
||||||
{t("phase")}
|
label={t("phase")}
|
||||||
</th>
|
sortKey="phase"
|
||||||
|
sort={tenSort}
|
||||||
|
onSort={onTenSort}
|
||||||
|
/>
|
||||||
<th className="px-4 py-3 text-xs font-semibold uppercase tracking-wider text-text-muted hidden md:table-cell">
|
<th className="px-4 py-3 text-xs font-semibold uppercase tracking-wider text-text-muted hidden md:table-cell">
|
||||||
{t("packages")}
|
{t("packages")}
|
||||||
</th>
|
</th>
|
||||||
<th className="px-4 py-3 text-xs font-semibold uppercase tracking-wider text-text-muted hidden md:table-cell">
|
<th className="px-4 py-3 text-xs font-semibold uppercase tracking-wider text-text-muted hidden md:table-cell">
|
||||||
{t("spendChf")}
|
{t("spendChf")}
|
||||||
</th>
|
</th>
|
||||||
<th className="px-4 py-3 text-xs font-semibold uppercase tracking-wider text-text-muted hidden md:table-cell">
|
<SortableTh
|
||||||
{t("created")}
|
label={t("created")}
|
||||||
</th>
|
sortKey="created"
|
||||||
|
sort={tenSort}
|
||||||
|
onSort={onTenSort}
|
||||||
|
className="hidden md:table-cell"
|
||||||
|
/>
|
||||||
<th className="px-4 py-3 text-xs font-semibold uppercase tracking-wider text-text-muted">
|
<th className="px-4 py-3 text-xs font-semibold uppercase tracking-wider text-text-muted">
|
||||||
{t("actions")}
|
{t("actions")}
|
||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{tenants.map((tenant) => {
|
{tenView.paged.map((tenant) => {
|
||||||
const tenantSpend =
|
const tenantSpend =
|
||||||
health?.spend?.perTenant?.[tenant.metadata.name];
|
health?.spend?.perTenant?.[tenant.metadata.name];
|
||||||
return (
|
return (
|
||||||
@@ -642,9 +791,10 @@ export function AdminPanel({ initialTenants }: AdminPanelProps) {
|
|||||||
: t("suspend")}
|
: t("suspend")}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() =>
|
onClick={() => {
|
||||||
setDeleteModal(tenant.metadata.name)
|
setActionError("");
|
||||||
}
|
setDeleteModal(tenant.metadata.name);
|
||||||
|
}}
|
||||||
disabled={actionLoading === 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"
|
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"
|
||||||
>
|
>
|
||||||
@@ -658,6 +808,12 @@ export function AdminPanel({ initialTenants }: AdminPanelProps) {
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
<Pagination
|
||||||
|
page={tenView.page}
|
||||||
|
totalPages={tenView.totalPages}
|
||||||
|
total={tenView.total}
|
||||||
|
onPage={setTenPage}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
@@ -772,10 +928,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 ───── */}
|
{/* ───── REJECT MODAL ───── */}
|
||||||
{rejectModal && (
|
<Modal
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
|
open={!!rejectModal}
|
||||||
<div className="bg-surface-1 border border-border rounded-xl p-6 max-w-md w-full mx-4 shadow-2xl">
|
onClose={() => {
|
||||||
|
setRejectModal(null);
|
||||||
|
setRejectNotes("");
|
||||||
|
setActionError("");
|
||||||
|
}}
|
||||||
|
ariaLabel={t("rejectTitle")}
|
||||||
|
>
|
||||||
|
{rejectModal && (
|
||||||
|
<>
|
||||||
<h3 className="font-display text-lg font-semibold text-text-primary mb-4">
|
<h3 className="font-display text-lg font-semibold text-text-primary mb-4">
|
||||||
{t("rejectTitle")}
|
{t("rejectTitle")}
|
||||||
</h3>
|
</h3>
|
||||||
@@ -789,11 +1010,17 @@ export function AdminPanel({ initialTenants }: AdminPanelProps) {
|
|||||||
rows={3}
|
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"
|
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">
|
<div className="flex gap-2 justify-end">
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setRejectModal(null);
|
setRejectModal(null);
|
||||||
setRejectNotes("");
|
setRejectNotes("");
|
||||||
|
setActionError("");
|
||||||
}}
|
}}
|
||||||
className="px-4 py-2 text-sm text-text-secondary hover:text-text-primary transition-colors"
|
className="px-4 py-2 text-sm text-text-secondary hover:text-text-primary transition-colors"
|
||||||
>
|
>
|
||||||
@@ -807,14 +1034,21 @@ export function AdminPanel({ initialTenants }: AdminPanelProps) {
|
|||||||
{actionLoading === rejectModal ? "…" : t("confirmReject")}
|
{actionLoading === rejectModal ? "…" : t("confirmReject")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</>
|
||||||
</div>
|
)}
|
||||||
)}
|
</Modal>
|
||||||
|
|
||||||
{/* ───── DELETE MODAL ───── */}
|
{/* ───── DELETE MODAL ───── */}
|
||||||
{deleteModal && (
|
<Modal
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
|
open={!!deleteModal}
|
||||||
<div className="bg-surface-1 border border-border rounded-xl p-6 max-w-md w-full mx-4 shadow-2xl">
|
onClose={() => {
|
||||||
|
setDeleteModal(null);
|
||||||
|
setActionError("");
|
||||||
|
}}
|
||||||
|
ariaLabel={t("deleteTitle")}
|
||||||
|
>
|
||||||
|
{deleteModal && (
|
||||||
|
<>
|
||||||
<h3 className="font-display text-lg font-semibold text-text-primary mb-2">
|
<h3 className="font-display text-lg font-semibold text-text-primary mb-2">
|
||||||
{t("deleteTitle")}
|
{t("deleteTitle")}
|
||||||
</h3>
|
</h3>
|
||||||
@@ -824,9 +1058,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">
|
<p className="text-xs font-mono text-accent bg-surface-2 border border-border rounded-lg px-3 py-2 mb-4">
|
||||||
{deleteModal}
|
{deleteModal}
|
||||||
</p>
|
</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">
|
<div className="flex gap-2 justify-end">
|
||||||
<button
|
<button
|
||||||
onClick={() => setDeleteModal(null)}
|
onClick={() => {
|
||||||
|
setDeleteModal(null);
|
||||||
|
setActionError("");
|
||||||
|
}}
|
||||||
className="px-4 py-2 text-sm text-text-secondary hover:text-text-primary transition-colors"
|
className="px-4 py-2 text-sm text-text-secondary hover:text-text-primary transition-colors"
|
||||||
>
|
>
|
||||||
{t("cancelAction")}
|
{t("cancelAction")}
|
||||||
@@ -839,9 +1081,9 @@ export function AdminPanel({ initialTenants }: AdminPanelProps) {
|
|||||||
{actionLoading === deleteModal ? "…" : t("confirmDelete")}
|
{actionLoading === deleteModal ? "…" : t("confirmDelete")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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
|
<button
|
||||||
onClick={commit}
|
onClick={commit}
|
||||||
disabled={busy}
|
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")}
|
{busy ? t("saving") : t("commitBtn")}
|
||||||
</button>
|
</button>
|
||||||
@@ -265,6 +265,7 @@ function DraftPreview({ draft }: { draft: InvoiceDraft }) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead className="text-xs text-text-muted text-left">
|
<thead className="text-xs text-text-muted text-left">
|
||||||
<tr>
|
<tr>
|
||||||
@@ -323,6 +324,7 @@ function DraftPreview({ draft }: { draft: InvoiceDraft }) {
|
|||||||
)}
|
)}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="mt-4 pt-3 border-t border-border space-y-1 text-sm">
|
<div className="mt-4 pt-3 border-t border-border space-y-1 text-sm">
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
|
|||||||
@@ -1,36 +1,64 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, Fragment } from "react";
|
import { useState, Fragment } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "@/i18n/navigation";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { Card, CardHeader } from "@/components/ui/card";
|
import { Card, CardHeader } from "@/components/ui/card";
|
||||||
import type { InvoiceDetail, InvoiceStatus } from "@/types";
|
import type { CreditNote, InvoiceDetail, InvoiceStatus } from "@/types";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
detail: InvoiceDetail;
|
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
|
* Renders the invoice header (status, totals, action bar) then
|
||||||
* line items grouped by tenant, then billing snapshot. Actions are
|
* 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
|
* On successful action we router.refresh() — the server-side page
|
||||||
* re-renders against the new DB state. For delete we navigate
|
* re-renders against the new DB state, including any new credit
|
||||||
* away first.
|
* notes.
|
||||||
*/
|
*/
|
||||||
export function InvoiceDetailView({ detail }: Props) {
|
export function InvoiceDetailView({ detail, creditNotes = [] }: Props) {
|
||||||
const t = useTranslations("adminBilling");
|
const t = useTranslations("adminBilling");
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { invoice, lines } = detail;
|
const { invoice, lines } = detail;
|
||||||
|
|
||||||
const [busyAction, setBusyAction] = useState<null | "mark-paid" | "delete">(
|
const [busyAction, setBusyAction] = useState<
|
||||||
null
|
null | "mark-paid" | "delete" | "void" | "refund"
|
||||||
);
|
>(null);
|
||||||
const [actionError, setActionError] = useState("");
|
const [actionError, setActionError] = useState("");
|
||||||
const [noteInput, setNoteInput] = useState("");
|
const [noteInput, setNoteInput] = useState("");
|
||||||
const [noteOpen, setNoteOpen] = useState(false);
|
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 () => {
|
const markPaid = async () => {
|
||||||
setActionError("");
|
setActionError("");
|
||||||
setBusyAction("mark-paid");
|
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).
|
// Group lines by tenant for display (matches PDF layout).
|
||||||
const linesByTenant = new Map<string | null, typeof lines>();
|
const linesByTenant = new Map<string | null, typeof lines>();
|
||||||
for (const ln of lines) {
|
for (const ln of lines) {
|
||||||
@@ -97,10 +203,14 @@ export function InvoiceDetailView({ detail }: Props) {
|
|||||||
</h1>
|
</h1>
|
||||||
<div className="flex items-center gap-3 mt-3 text-sm">
|
<div className="flex items-center gap-3 mt-3 text-sm">
|
||||||
<StatusPill status={invoice.status} />
|
<StatusPill status={invoice.status} />
|
||||||
<span className="text-text-muted">
|
{invoice.periodStart && invoice.periodEnd && (
|
||||||
{invoice.periodStart} → {invoice.periodEnd}
|
<>
|
||||||
</span>
|
<span className="text-text-muted">
|
||||||
<span className="text-text-muted">·</span>
|
{invoice.periodStart} → {invoice.periodEnd}
|
||||||
|
</span>
|
||||||
|
<span className="text-text-muted">·</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<span className="text-text-muted">
|
<span className="text-text-muted">
|
||||||
{t("dueOnLabel")}: {invoice.dueAt}
|
{t("dueOnLabel")}: {invoice.dueAt}
|
||||||
</span>
|
</span>
|
||||||
@@ -137,7 +247,7 @@ export function InvoiceDetailView({ detail }: Props) {
|
|||||||
<button
|
<button
|
||||||
onClick={() => setNoteOpen(true)}
|
onClick={() => setNoteOpen(true)}
|
||||||
disabled={busyAction !== null}
|
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")}
|
{t("markPaidBtn")}
|
||||||
</button>
|
</button>
|
||||||
@@ -154,7 +264,7 @@ export function InvoiceDetailView({ detail }: Props) {
|
|||||||
<button
|
<button
|
||||||
onClick={markPaid}
|
onClick={markPaid}
|
||||||
disabled={busyAction !== null}
|
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")}
|
{busyAction === "mark-paid" ? t("saving") : t("confirm")}
|
||||||
</button>
|
</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
|
<button
|
||||||
onClick={deleteInvoice}
|
onClick={deleteInvoice}
|
||||||
disabled={busyAction !== null}
|
disabled={busyAction !== null}
|
||||||
@@ -189,11 +437,96 @@ export function InvoiceDetailView({ detail }: Props) {
|
|||||||
{invoice.paidMethodDetail}
|
{invoice.paidMethodDetail}
|
||||||
</div>
|
</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>
|
</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 */}
|
{/* Lines */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>{t("lineItemsTitle")}</CardHeader>
|
<CardHeader>{t("lineItemsTitle")}</CardHeader>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead className="text-xs text-text-muted text-left">
|
<thead className="text-xs text-text-muted text-left">
|
||||||
<tr>
|
<tr>
|
||||||
@@ -242,6 +575,7 @@ export function InvoiceDetailView({ detail }: Props) {
|
|||||||
})}
|
})}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
</div>
|
||||||
<div className="mt-4 pt-3 border-t border-border space-y-1 text-sm">
|
<div className="mt-4 pt-3 border-t border-border space-y-1 text-sm">
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span className="text-text-muted">{t("subtotal")}</span>
|
<span className="text-text-muted">{t("subtotal")}</span>
|
||||||
@@ -296,7 +630,9 @@ function StatusPill({ status }: { status: InvoiceStatus }) {
|
|||||||
? "bg-error/15 text-error"
|
? "bg-error/15 text-error"
|
||||||
: status === "void" || status === "uncollectible"
|
: status === "void" || status === "uncollectible"
|
||||||
? "bg-text-muted/15 text-text-muted"
|
? "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 (
|
return (
|
||||||
<span
|
<span
|
||||||
className={`inline-block px-2 py-0.5 rounded text-xs font-medium ${color}`}
|
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")}
|
{t("loading")}
|
||||||
</span>
|
</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>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
@@ -109,6 +126,7 @@ export function InvoicesTable({ initialInvoices }: Props) {
|
|||||||
{t("noInvoicesFound")}
|
{t("noInvoicesFound")}
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead className="text-xs text-text-muted text-left">
|
<thead className="text-xs text-text-muted text-left">
|
||||||
<tr>
|
<tr>
|
||||||
@@ -142,7 +160,11 @@ export function InvoicesTable({ initialInvoices }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="py-2 text-xs font-mono">
|
<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>
|
||||||
<td className="py-2">
|
<td className="py-2">
|
||||||
<StatusPill status={inv.status} />
|
<StatusPill status={inv.status} />
|
||||||
@@ -157,6 +179,7 @@ export function InvoicesTable({ initialInvoices }: Props) {
|
|||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
290
src/components/admin/billing/new-invoice-form.tsx
Normal file
290
src/components/admin/billing/new-invoice-form.tsx
Normal file
@@ -0,0 +1,290 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef, 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>
|
||||||
|
<OrgCombobox
|
||||||
|
orgs={orgs}
|
||||||
|
value={orgId}
|
||||||
|
onChange={onOrgChange}
|
||||||
|
placeholder={t("newInvoiceOrgPlaceholder")}
|
||||||
|
noBillingLabel={t("newInvoiceOrgNoBilling")}
|
||||||
|
noMatchesLabel={t("newInvoiceOrgNoMatches")}
|
||||||
|
/>
|
||||||
|
{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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Searchable single-select for the billing org. Replaces a plain
|
||||||
|
* <select> that would become unusable once the customer list grows:
|
||||||
|
* type to filter by company name or org id, arrow keys to move, Enter
|
||||||
|
* to pick. Orgs without a billing snapshot stay selectable but are
|
||||||
|
* flagged — selecting one surfaces the existing "billing missing"
|
||||||
|
* warning and keeps the submit button disabled.
|
||||||
|
*/
|
||||||
|
function OrgCombobox({
|
||||||
|
orgs,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
placeholder,
|
||||||
|
noBillingLabel,
|
||||||
|
noMatchesLabel,
|
||||||
|
}: {
|
||||||
|
orgs: OrgEntry[];
|
||||||
|
value: string;
|
||||||
|
onChange: (orgId: string) => void;
|
||||||
|
placeholder: string;
|
||||||
|
noBillingLabel: string;
|
||||||
|
noMatchesLabel: string;
|
||||||
|
}) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const [hi, setHi] = useState(0);
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const selected = orgs.find((o) => o.zitadelOrgId === value) || null;
|
||||||
|
const display = selected ? selected.companyName ?? selected.zitadelOrgId : "";
|
||||||
|
|
||||||
|
// Close on outside click so the dropdown doesn't linger.
|
||||||
|
useEffect(() => {
|
||||||
|
const onDoc = (e: MouseEvent) => {
|
||||||
|
if (ref.current && !ref.current.contains(e.target as Node)) {
|
||||||
|
setOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener("mousedown", onDoc);
|
||||||
|
return () => document.removeEventListener("mousedown", onDoc);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const q = query.trim().toLowerCase();
|
||||||
|
const filtered = q
|
||||||
|
? orgs.filter(
|
||||||
|
(o) =>
|
||||||
|
(o.companyName ?? "").toLowerCase().includes(q) ||
|
||||||
|
o.zitadelOrgId.toLowerCase().includes(q)
|
||||||
|
)
|
||||||
|
: orgs;
|
||||||
|
|
||||||
|
const choose = (o: OrgEntry) => {
|
||||||
|
onChange(o.zitadelOrgId);
|
||||||
|
setOpen(false);
|
||||||
|
setQuery("");
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={ref} className="relative">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={open ? query : display}
|
||||||
|
onChange={(e) => {
|
||||||
|
setQuery(e.target.value);
|
||||||
|
setOpen(true);
|
||||||
|
setHi(0);
|
||||||
|
}}
|
||||||
|
onFocus={() => {
|
||||||
|
setOpen(true);
|
||||||
|
setQuery("");
|
||||||
|
}}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "ArrowDown") {
|
||||||
|
e.preventDefault();
|
||||||
|
setOpen(true);
|
||||||
|
setHi((h) => Math.min(h + 1, filtered.length - 1));
|
||||||
|
} else if (e.key === "ArrowUp") {
|
||||||
|
e.preventDefault();
|
||||||
|
setHi((h) => Math.max(h - 1, 0));
|
||||||
|
} else if (e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
if (open && filtered[hi]) choose(filtered[hi]);
|
||||||
|
} else if (e.key === "Escape") {
|
||||||
|
setOpen(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder={placeholder}
|
||||||
|
role="combobox"
|
||||||
|
aria-expanded={open}
|
||||||
|
aria-autocomplete="list"
|
||||||
|
className="w-full px-3 py-2 rounded-md border border-border bg-surface-2 text-sm text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-1 focus:ring-accent focus:border-accent transition-colors"
|
||||||
|
/>
|
||||||
|
{open && (
|
||||||
|
<ul
|
||||||
|
role="listbox"
|
||||||
|
className="absolute z-20 mt-1 max-h-64 w-full overflow-auto rounded-md border border-border bg-surface-1 shadow-xl py-1"
|
||||||
|
>
|
||||||
|
{filtered.length === 0 ? (
|
||||||
|
<li className="px-3 py-2 text-xs text-text-muted">
|
||||||
|
{noMatchesLabel}
|
||||||
|
</li>
|
||||||
|
) : (
|
||||||
|
filtered.map((o, i) => (
|
||||||
|
<li
|
||||||
|
key={o.zitadelOrgId}
|
||||||
|
role="option"
|
||||||
|
aria-selected={o.zitadelOrgId === value}
|
||||||
|
onMouseEnter={() => setHi(i)}
|
||||||
|
// mousedown (not click) so selection runs before the
|
||||||
|
// input's blur closes the list.
|
||||||
|
onMouseDown={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
choose(o);
|
||||||
|
}}
|
||||||
|
className={`px-3 py-2 text-sm cursor-pointer flex items-center justify-between gap-2 ${
|
||||||
|
i === hi ? "bg-surface-3" : "hover:bg-surface-2"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="truncate text-text-primary">
|
||||||
|
{o.companyName ?? o.zitadelOrgId}
|
||||||
|
</span>
|
||||||
|
{!o.hasBillingAddress && (
|
||||||
|
<span className="text-[10px] text-error shrink-0">
|
||||||
|
{noBillingLabel}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
160
src/components/admin/billing/org-payment-mode-list.tsx
Normal file
160
src/components/admin/billing/org-payment-mode-list.tsx
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { Card } from "@/components/ui/card";
|
||||||
|
|
||||||
|
interface OrgEntry {
|
||||||
|
zitadelOrgId: string;
|
||||||
|
companyName: string | null;
|
||||||
|
country: string | null;
|
||||||
|
hasSavedCard: boolean;
|
||||||
|
cardLabel: string | null;
|
||||||
|
payByInvoice: boolean;
|
||||||
|
autoChargeEnabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
orgs: OrgEntry[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inline toggles for pay_by_invoice and auto_charge_enabled per
|
||||||
|
* org. Each toggle round-trips to /api/admin/billing/orgs/[orgId]
|
||||||
|
* /payment-mode and then router.refresh() so the server-fetched
|
||||||
|
* state stays canonical (avoids drift between optimistic UI and
|
||||||
|
* the DB).
|
||||||
|
*
|
||||||
|
* Phase 9b-2.
|
||||||
|
*/
|
||||||
|
export function OrgPaymentModeList({ orgs }: Props) {
|
||||||
|
const t = useTranslations("adminBilling");
|
||||||
|
const router = useRouter();
|
||||||
|
const [busy, setBusy] = useState<string | null>(null);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
const toggle = async (
|
||||||
|
orgId: string,
|
||||||
|
patch: { payByInvoice?: boolean; autoChargeEnabled?: boolean }
|
||||||
|
) => {
|
||||||
|
setError("");
|
||||||
|
setBusy(orgId);
|
||||||
|
try {
|
||||||
|
const res = await fetch(
|
||||||
|
`/api/admin/billing/orgs/${encodeURIComponent(orgId)}/payment-mode`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(patch),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const j = await res.json().catch(() => ({}));
|
||||||
|
if (!res.ok) throw new Error(j.error || `HTTP ${res.status}`);
|
||||||
|
router.refresh();
|
||||||
|
} catch (e: any) {
|
||||||
|
setError(e.message);
|
||||||
|
} finally {
|
||||||
|
setBusy(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (orgs.length === 0) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<div className="p-6 text-center text-text-secondary text-sm">
|
||||||
|
{t("orgsEmpty")}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
{error && (
|
||||||
|
<div className="text-sm text-error border-b border-error/30 bg-error/10 px-4 py-2">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="text-xs text-text-muted text-left">
|
||||||
|
<tr>
|
||||||
|
<th className="pb-2 pl-3 pr-4">{t("orgsColCustomer")}</th>
|
||||||
|
<th className="pb-2 pr-4">{t("orgsColCard")}</th>
|
||||||
|
<th className="pb-2 pr-4 text-center">
|
||||||
|
{t("orgsColPayByInvoice")}
|
||||||
|
</th>
|
||||||
|
<th className="pb-2 pr-4 text-center">
|
||||||
|
{t("orgsColAutoCharge")}
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{orgs.map((o) => (
|
||||||
|
<tr key={o.zitadelOrgId} className="border-t border-border">
|
||||||
|
<td className="py-2 pl-3 pr-4">
|
||||||
|
<div className="font-medium">
|
||||||
|
{o.companyName ?? (
|
||||||
|
<span className="font-mono text-xs">{o.zitadelOrgId}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{o.country && (
|
||||||
|
<div className="text-xs text-text-muted">{o.country}</div>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="py-2 pr-4 text-xs">
|
||||||
|
{o.hasSavedCard ? (
|
||||||
|
<span className="font-mono">{o.cardLabel}</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-text-muted">
|
||||||
|
{t("orgsNoSavedCard")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="py-2 pr-4 text-center">
|
||||||
|
<label className="inline-flex items-center gap-2 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={o.payByInvoice}
|
||||||
|
disabled={busy === o.zitadelOrgId}
|
||||||
|
onChange={(e) =>
|
||||||
|
toggle(o.zitadelOrgId, {
|
||||||
|
payByInvoice: e.target.checked,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<span className="text-xs">
|
||||||
|
{o.payByInvoice
|
||||||
|
? t("orgsPayByInvoiceOn")
|
||||||
|
: t("orgsPayByInvoiceOff")}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</td>
|
||||||
|
<td className="py-2 pr-4 text-center">
|
||||||
|
<label className="inline-flex items-center gap-2 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={o.autoChargeEnabled}
|
||||||
|
disabled={busy === o.zitadelOrgId || o.payByInvoice}
|
||||||
|
onChange={(e) =>
|
||||||
|
toggle(o.zitadelOrgId, {
|
||||||
|
autoChargeEnabled: e.target.checked,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<span className="text-xs">
|
||||||
|
{o.autoChargeEnabled
|
||||||
|
? t("orgsAutoChargeOn")
|
||||||
|
: t("orgsAutoChargeOff")}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -236,7 +236,7 @@ export function PricingEditor({
|
|||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={savingPricing}
|
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")}
|
{savingPricing ? t("saving") : t("save")}
|
||||||
</button>
|
</button>
|
||||||
@@ -255,6 +255,7 @@ export function PricingEditor({
|
|||||||
<p className="text-sm text-text-muted mb-4">{t("skillPricingDesc")}</p>
|
<p className="text-sm text-text-muted mb-4">{t("skillPricingDesc")}</p>
|
||||||
|
|
||||||
{initialSkillPricing.length > 0 ? (
|
{initialSkillPricing.length > 0 ? (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full text-sm mb-6">
|
<table className="w-full text-sm mb-6">
|
||||||
<thead className="text-xs text-text-muted text-left">
|
<thead className="text-xs text-text-muted text-left">
|
||||||
<tr>
|
<tr>
|
||||||
@@ -319,6 +320,7 @@ export function PricingEditor({
|
|||||||
})}
|
})}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-sm text-text-muted italic mb-4">{t("noSkillsPriced")}</p>
|
<p className="text-sm text-text-muted italic mb-4">{t("noSkillsPriced")}</p>
|
||||||
)}
|
)}
|
||||||
@@ -401,7 +403,7 @@ export function PricingEditor({
|
|||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={addingSkill || !newSkillId}
|
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")}
|
{addingSkill ? t("saving") : t("add")}
|
||||||
</button>
|
</button>
|
||||||
@@ -473,7 +475,7 @@ function InlinePriceEditor({
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
disabled={busy}
|
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 ? "…" : "✓"}
|
{busy ? "…" : "✓"}
|
||||||
</button>
|
</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
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={savingDefault}
|
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")}
|
{savingDefault ? tCommon("loading") : t("saveDefault")}
|
||||||
</button>
|
</button>
|
||||||
@@ -265,7 +265,7 @@ function TenantOverrideRow({
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => submit(false)}
|
onClick={() => submit(false)}
|
||||||
disabled={saving || !tag.trim()}
|
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")}
|
{saving ? tCommon("loading") : t("saveOverride")}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -99,6 +99,7 @@ export function PendingSkillRequests({ initialRows }: Props) {
|
|||||||
{error}
|
{error}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead className="text-xs text-text-muted text-left">
|
<thead className="text-xs text-text-muted text-left">
|
||||||
<tr>
|
<tr>
|
||||||
@@ -146,7 +147,7 @@ export function PendingSkillRequests({ initialRows }: Props) {
|
|||||||
<button
|
<button
|
||||||
onClick={() => approve(row.id)}
|
onClick={() => approve(row.id)}
|
||||||
disabled={busyId !== null}
|
disabled={busyId !== null}
|
||||||
className="text-xs px-3 py-1.5 rounded-md bg-accent text-white disabled:opacity-50"
|
className="text-xs px-3 py-1.5 rounded-md bg-accent text-surface-0 disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{busyId === row.id ? t("working") : t("approveBtn")}
|
{busyId === row.id ? t("working") : t("approveBtn")}
|
||||||
</button>
|
</button>
|
||||||
@@ -199,6 +200,7 @@ export function PendingSkillRequests({ initialRows }: Props) {
|
|||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
190
src/components/admin/table-controls.tsx
Normal file
190
src/components/admin/table-controls.tsx
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared client-side table controls for the admin panel.
|
||||||
|
*
|
||||||
|
* The admin tables (requests, tenants) load their full result set into
|
||||||
|
* state already, so search/sort/pagination are applied client-side on
|
||||||
|
* top — no new API surface. At pilot scale the lists are small enough
|
||||||
|
* that filtering/sorting in memory is free; if they grow past a few
|
||||||
|
* hundred rows this is the seam to move server-side (the page/sort
|
||||||
|
* state would become query params).
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const PAGE_SIZE = 15;
|
||||||
|
|
||||||
|
export interface SortState {
|
||||||
|
key: string;
|
||||||
|
dir: "asc" | "desc";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filter → sort → paginate a list. Pure function, called during render.
|
||||||
|
* `searchOf` returns the haystack strings for a row; `sortOf` returns
|
||||||
|
* the comparable value for the active sort key (string or number).
|
||||||
|
*/
|
||||||
|
export function applyTableView<T>(
|
||||||
|
items: T[],
|
||||||
|
opts: {
|
||||||
|
search: string;
|
||||||
|
searchOf: (item: T) => (string | null | undefined)[];
|
||||||
|
sort: SortState;
|
||||||
|
sortOf: (item: T, key: string) => string | number;
|
||||||
|
page: number;
|
||||||
|
pageSize?: number;
|
||||||
|
}
|
||||||
|
): { paged: T[]; total: number; totalPages: number; page: number } {
|
||||||
|
const pageSize = opts.pageSize ?? PAGE_SIZE;
|
||||||
|
|
||||||
|
const q = opts.search.trim().toLowerCase();
|
||||||
|
const filtered = q
|
||||||
|
? items.filter((it) =>
|
||||||
|
opts
|
||||||
|
.searchOf(it)
|
||||||
|
.some((v) => (v ?? "").toString().toLowerCase().includes(q))
|
||||||
|
)
|
||||||
|
: items;
|
||||||
|
|
||||||
|
const sorted = [...filtered].sort((a, b) => {
|
||||||
|
const av = opts.sortOf(a, opts.sort.key);
|
||||||
|
const bv = opts.sortOf(b, opts.sort.key);
|
||||||
|
const cmp =
|
||||||
|
typeof av === "number" && typeof bv === "number"
|
||||||
|
? av - bv
|
||||||
|
: String(av).localeCompare(String(bv));
|
||||||
|
return opts.sort.dir === "asc" ? cmp : -cmp;
|
||||||
|
});
|
||||||
|
|
||||||
|
const total = sorted.length;
|
||||||
|
const totalPages = Math.max(1, Math.ceil(total / pageSize));
|
||||||
|
const page = Math.min(Math.max(1, opts.page), totalPages);
|
||||||
|
const paged = sorted.slice((page - 1) * pageSize, page * pageSize);
|
||||||
|
|
||||||
|
return { paged, total, totalPages, page };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Toggle helper: same key flips direction, new key starts ascending. */
|
||||||
|
export function nextSort(current: SortState, key: string): SortState {
|
||||||
|
if (current.key === key) {
|
||||||
|
return { key, dir: current.dir === "asc" ? "desc" : "asc" };
|
||||||
|
}
|
||||||
|
return { key, dir: "asc" };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SearchInput({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
placeholder,
|
||||||
|
}: {
|
||||||
|
value: string;
|
||||||
|
onChange: (v: string) => void;
|
||||||
|
placeholder: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="relative">
|
||||||
|
<svg
|
||||||
|
className="absolute left-2.5 top-1/2 -translate-y-1/2 h-4 w-4 text-text-muted pointer-events-none"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={1.75}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M21 21l-4.35-4.35M17 11a6 6 0 11-12 0 6 6 0 0112 0z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<input
|
||||||
|
type="search"
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
placeholder={placeholder}
|
||||||
|
className="w-full sm:w-72 pl-8 pr-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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SortableTh({
|
||||||
|
label,
|
||||||
|
sortKey,
|
||||||
|
sort,
|
||||||
|
onSort,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
sortKey: string;
|
||||||
|
sort: SortState;
|
||||||
|
onSort: (key: string) => void;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
const active = sort.key === sortKey;
|
||||||
|
return (
|
||||||
|
<th className={`px-4 py-3 ${className ?? ""}`}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onSort(sortKey)}
|
||||||
|
className={`inline-flex items-center gap-1 text-xs font-semibold uppercase tracking-wider transition-colors cursor-pointer ${
|
||||||
|
active ? "text-text-secondary" : "text-text-muted hover:text-text-secondary"
|
||||||
|
}`}
|
||||||
|
aria-sort={active ? (sort.dir === "asc" ? "ascending" : "descending") : "none"}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
<span className="inline-block w-2 text-[9px]" aria-hidden="true">
|
||||||
|
{active ? (sort.dir === "asc" ? "▲" : "▼") : ""}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</th>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Pagination({
|
||||||
|
page,
|
||||||
|
totalPages,
|
||||||
|
total,
|
||||||
|
onPage,
|
||||||
|
}: {
|
||||||
|
page: number;
|
||||||
|
totalPages: number;
|
||||||
|
total: number;
|
||||||
|
onPage: (p: number) => void;
|
||||||
|
}) {
|
||||||
|
const t = useTranslations("admin");
|
||||||
|
if (totalPages <= 1) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-end px-4 py-2.5 border-t border-border text-xs text-text-muted">
|
||||||
|
<span>{t("paginationCount", { total })}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between px-4 py-2.5 border-t border-border text-xs text-text-muted gap-3">
|
||||||
|
<span className="tabular-nums">{t("paginationCount", { total })}</span>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onPage(page - 1)}
|
||||||
|
disabled={page <= 1}
|
||||||
|
className="px-2.5 py-1 rounded-md border border-border hover:bg-surface-2 disabled:opacity-40 disabled:cursor-not-allowed transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
{t("paginationPrev")}
|
||||||
|
</button>
|
||||||
|
<span className="tabular-nums">
|
||||||
|
{t("paginationPage", { page, total: totalPages })}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onPage(page + 1)}
|
||||||
|
disabled={page >= totalPages}
|
||||||
|
className="px-2.5 py-1 rounded-md border border-border hover:bg-surface-2 disabled:opacity-40 disabled:cursor-not-allowed transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
{t("paginationNext")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -46,11 +46,17 @@ export function CustomerInvoiceDetail({ invoice, lines }: Props) {
|
|||||||
{t(`status.${invoice.status}` as any)}
|
{t(`status.${invoice.status}` as any)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-text-secondary">
|
{invoice.periodStart && invoice.periodEnd && (
|
||||||
{fmt.dateTime(new Date(invoice.periodStart), { dateStyle: "long" })}
|
<p className="text-sm text-text-secondary">
|
||||||
<span className="text-text-muted mx-1">→</span>
|
{fmt.dateTime(new Date(invoice.periodStart), {
|
||||||
{fmt.dateTime(new Date(invoice.periodEnd), { dateStyle: "long" })}
|
dateStyle: "long",
|
||||||
</p>
|
})}
|
||||||
|
<span className="text-text-muted mx-1">→</span>
|
||||||
|
{fmt.dateTime(new Date(invoice.periodEnd), {
|
||||||
|
dateStyle: "long",
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-start gap-2 flex-wrap">
|
<div className="flex items-start gap-2 flex-wrap">
|
||||||
{/* Phase 4: Pay-with-card available for open + overdue.
|
{/* Phase 4: Pay-with-card available for open + overdue.
|
||||||
@@ -101,6 +107,7 @@ export function CustomerInvoiceDetail({ invoice, lines }: Props) {
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead className="text-xs text-text-muted text-left">
|
<thead className="text-xs text-text-muted text-left">
|
||||||
<tr>
|
<tr>
|
||||||
@@ -154,6 +161,7 @@ export function CustomerInvoiceDetail({ invoice, lines }: Props) {
|
|||||||
</tr>
|
</tr>
|
||||||
</tfoot>
|
</tfoot>
|
||||||
</table>
|
</table>
|
||||||
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -12,6 +12,13 @@ const statusColors: Record<string, string> = {
|
|||||||
paid: "text-success bg-success/10",
|
paid: "text-success bg-success/10",
|
||||||
overdue: "text-error bg-error/10",
|
overdue: "text-error bg-error/10",
|
||||||
void: "text-text-muted bg-surface-3 line-through",
|
void: "text-text-muted bg-surface-3 line-through",
|
||||||
|
// 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",
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -39,6 +46,7 @@ export function CustomerInvoiceList({ invoices }: Props) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead className="text-xs text-text-muted text-left">
|
<thead className="text-xs text-text-muted text-left">
|
||||||
<tr>
|
<tr>
|
||||||
@@ -64,9 +72,19 @@ export function CustomerInvoiceList({ invoices }: Props) {
|
|||||||
</Link>
|
</Link>
|
||||||
</td>
|
</td>
|
||||||
<td className="py-2 text-xs text-text-secondary">
|
<td className="py-2 text-xs text-text-secondary">
|
||||||
{fmt.dateTime(new Date(inv.periodStart), { dateStyle: "medium" })}
|
{inv.periodStart && inv.periodEnd ? (
|
||||||
<span className="text-text-muted mx-1">→</span>
|
<>
|
||||||
{fmt.dateTime(new Date(inv.periodEnd), { dateStyle: "medium" })}
|
{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>
|
||||||
<td className="py-2 text-xs text-text-secondary">
|
<td className="py-2 text-xs text-text-secondary">
|
||||||
{fmt.dateTime(new Date(inv.dueAt), { dateStyle: "medium" })}
|
{fmt.dateTime(new Date(inv.dueAt), { dateStyle: "medium" })}
|
||||||
@@ -87,6 +105,7 @@ export function CustomerInvoiceList({ invoices }: Props) {
|
|||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ export function PayInvoiceButton({ invoiceNumber }: Props) {
|
|||||||
<button
|
<button
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
className="px-4 py-2 rounded-md bg-accent text-white text-sm font-medium hover:bg-accent-dim transition-colors disabled:opacity-50 cursor-pointer"
|
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")}
|
{busy ? t("redirectingToStripe") : t("payWithCard")}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -30,13 +30,14 @@ export function PaymentStatusBanner() {
|
|||||||
const params = new URLSearchParams(window.location.search);
|
const params = new URLSearchParams(window.location.search);
|
||||||
if (params.has("paid")) {
|
if (params.has("paid")) {
|
||||||
setState("paid");
|
setState("paid");
|
||||||
// Reload after 4s so the status badge picks up the webhook's
|
// The webhook usually arrives before the browser redirect
|
||||||
// effect on the invoice row. By then most webhook deliveries
|
// completes, so the page often renders with status='paid'
|
||||||
// have landed; if not the user just sees "open" and can
|
// on first load and this refresh is a no-op. In the rare
|
||||||
// manually refresh.
|
// case where it arrives slightly after, a short refresh
|
||||||
|
// picks up the status flip. 1.5s is comfortable for both.
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
router.refresh();
|
router.refresh();
|
||||||
}, 4000);
|
}, 1500);
|
||||||
// Strip the query string out of the URL.
|
// Strip the query string out of the URL.
|
||||||
const cleanUrl = window.location.pathname;
|
const cleanUrl = window.location.pathname;
|
||||||
window.history.replaceState({}, "", cleanUrl);
|
window.history.replaceState({}, "", cleanUrl);
|
||||||
|
|||||||
@@ -11,6 +11,17 @@ type CurrentResponse =
|
|||||||
| { draft: InvoiceDraft }
|
| { draft: InvoiceDraft }
|
||||||
| { error: string; code?: string };
|
| { error: string; code?: string };
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/**
|
||||||
|
* Whether the viewing user has org-owner role. Drives the
|
||||||
|
* "complete your billing details" CTA — only owners can edit
|
||||||
|
* billing settings, so non-owners see a softer message asking
|
||||||
|
* them to contact their org owner instead. The flag is computed
|
||||||
|
* server-side and passed in to avoid a second API round-trip.
|
||||||
|
*/
|
||||||
|
isOwner: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Live running total for the current calendar month.
|
* Live running total for the current calendar month.
|
||||||
*
|
*
|
||||||
@@ -28,7 +39,7 @@ type CurrentResponse =
|
|||||||
* No polling — the page is static enough that an explicit
|
* No polling — the page is static enough that an explicit
|
||||||
* "refresh" link is good enough if the user wants newer numbers.
|
* "refresh" link is good enough if the user wants newer numbers.
|
||||||
*/
|
*/
|
||||||
export function RunningTotalWidget() {
|
export function RunningTotalWidget({ isOwner }: Props) {
|
||||||
const t = useTranslations("customerBilling");
|
const t = useTranslations("customerBilling");
|
||||||
const fmt = useFormatter();
|
const fmt = useFormatter();
|
||||||
const [data, setData] = useState<CurrentResponse | null>(null);
|
const [data, setData] = useState<CurrentResponse | null>(null);
|
||||||
@@ -62,13 +73,29 @@ export function RunningTotalWidget() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (!data || "error" in data) {
|
if (!data || "error" in data) {
|
||||||
|
const noConfig =
|
||||||
|
data && "code" in data && data.code === "COMPUTE_FAILED";
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<p className="text-sm text-text-secondary py-2">
|
<p className="text-sm text-text-secondary py-2">
|
||||||
{data && "code" in data && data.code === "COMPUTE_FAILED"
|
{noConfig ? t("noBillingConfig") : t("currentPeriodError")}
|
||||||
? t("noBillingConfig")
|
|
||||||
: t("currentPeriodError")}
|
|
||||||
</p>
|
</p>
|
||||||
|
{/* Phase 6: owner-only CTA. Non-owners can't edit billing
|
||||||
|
settings, so we show them a "contact owner" hint instead
|
||||||
|
— that's gentler than a button that 404s on click. */}
|
||||||
|
{noConfig && isOwner && (
|
||||||
|
<Link
|
||||||
|
href="/settings/billing"
|
||||||
|
className="inline-block mt-2 px-4 py-2 rounded-md bg-accent text-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>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -98,9 +125,16 @@ export function RunningTotalWidget() {
|
|||||||
}
|
}
|
||||||
// draft
|
// draft
|
||||||
const draft = data.draft;
|
const draft = data.draft;
|
||||||
const periodLabel = `${fmt.dateTime(new Date(draft.periodStart), {
|
// Phase 8: InvoiceDraft.periodStart/End became nullable for the
|
||||||
dateStyle: "long",
|
// custom-invoice flow. The running-total widget only renders the
|
||||||
})} → ${fmt.dateTime(new Date(draft.periodEnd), { dateStyle: "long" })}`;
|
// 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 (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<div className="flex items-start justify-between gap-4 flex-wrap mb-3">
|
<div className="flex items-start justify-between gap-4 flex-wrap mb-3">
|
||||||
@@ -126,6 +160,7 @@ export function RunningTotalWidget() {
|
|||||||
<summary className="cursor-pointer text-text-muted hover:text-text-secondary">
|
<summary className="cursor-pointer text-text-muted hover:text-text-secondary">
|
||||||
{t("breakdownToggle", { count: draft.lines.length })}
|
{t("breakdownToggle", { count: draft.lines.length })}
|
||||||
</summary>
|
</summary>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full mt-2 text-xs">
|
<table className="w-full mt-2 text-xs">
|
||||||
<tbody>
|
<tbody>
|
||||||
{draft.lines.map((ln, i) => (
|
{draft.lines.map((ln, i) => (
|
||||||
@@ -154,6 +189,7 @@ export function RunningTotalWidget() {
|
|||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
</div>
|
||||||
</details>
|
</details>
|
||||||
)}
|
)}
|
||||||
<p className="text-[10px] text-text-muted mt-3 italic">{t("draftNote")}</p>
|
<p className="text-[10px] text-text-muted mt-3 italic">{t("draftNote")}</p>
|
||||||
|
|||||||
@@ -328,7 +328,7 @@ export function ChannelUsers({
|
|||||||
<button
|
<button
|
||||||
onClick={() => handleAdd(channel)}
|
onClick={() => handleAdd(channel)}
|
||||||
disabled={saving || !inputValues[channel]?.trim()}
|
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")}
|
{saving ? "…" : t("add")}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -263,7 +263,7 @@ export function BudgetEditableCard({
|
|||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={saving}
|
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")}
|
{saving ? tCommon("loading") : tCommon("save")}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations, useLocale } from "next-intl";
|
||||||
import { useEffect, useState, useCallback } from "react";
|
import { useEffect, useState, useCallback } from "react";
|
||||||
import { BudgetEditableCard } from "@/components/dashboard/budget-editable-card";
|
import { BudgetEditableCard } from "@/components/dashboard/budget-editable-card";
|
||||||
|
|
||||||
@@ -84,42 +84,149 @@ function formatMonth(month: string, locale: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function UsageChart({ data }: { data: DailyUsage[] }) {
|
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;
|
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 barW = Math.max(4, Math.floor(600 / data.length) - 2);
|
||||||
const h = 120;
|
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 (
|
return (
|
||||||
<div className="overflow-x-auto">
|
<div>
|
||||||
<svg
|
{/* Readout — the touch/keyboard-accessible equivalent of the old
|
||||||
viewBox={`0 0 ${Math.max(data.length * (barW + 2), 600)} ${h + 24}`}
|
hover-only tooltip. Always reflects the active day. */}
|
||||||
className="w-full h-36"
|
<div className="flex flex-wrap items-baseline gap-x-3 gap-y-1 mb-2 text-xs">
|
||||||
preserveAspectRatio="xMinYMid meet"
|
<span className="font-medium text-text-primary">
|
||||||
>
|
{dayLabel(active.date)}
|
||||||
{data.map((d, i) => {
|
</span>
|
||||||
const total = d.inputTokens + d.outputTokens;
|
<span className="text-text-secondary tabular-nums">
|
||||||
const totalH = (total / maxTokens) * h;
|
{fmt(active.inputTokens)} {t("inputTokens")}
|
||||||
const inputH = (d.inputTokens / maxTokens) * h;
|
</span>
|
||||||
const x = i * (barW + 2);
|
<span className="text-text-secondary tabular-nums">
|
||||||
return (
|
{fmt(active.outputTokens)} {t("outputTokens")}
|
||||||
<g key={d.date}>
|
</span>
|
||||||
<title>{d.date}: {fmt(d.inputTokens)} in / {fmt(d.outputTokens)} out — {chf(d.spend)}</title>
|
<span className="text-accent tabular-nums">{chf(active.spend)}</span>
|
||||||
<rect x={x} y={h - totalH} width={barW} height={totalH - inputH} rx={1} fill="var(--color-accent)" opacity={0.3} />
|
</div>
|
||||||
<rect x={x} y={h - inputH} width={barW} height={inputH} rx={1} fill="var(--color-accent)" opacity={0.7} />
|
|
||||||
{i % 7 === 0 && (
|
<div className="overflow-x-auto">
|
||||||
<text x={x + barW / 2} y={h + 14} textAnchor="middle" fill="var(--color-text-muted)" fontSize="8">{d.date.slice(8)}</text>
|
<svg
|
||||||
)}
|
viewBox={`0 0 ${Math.max(data.length * (barW + 2), 600)} ${h + 24}`}
|
||||||
</g>
|
className="w-full h-36"
|
||||||
);
|
preserveAspectRatio="xMinYMid meet"
|
||||||
})}
|
role="group"
|
||||||
</svg>
|
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">
|
<div className="flex items-center gap-4 text-xs text-text-muted mt-1">
|
||||||
<span className="flex items-center gap-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>
|
||||||
<span className="flex items-center gap-1">
|
<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>
|
||||||
|
<span className="ml-auto text-text-muted/70">{t("chartHint")}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -161,6 +268,7 @@ export function UsageDisplay({
|
|||||||
canEditBudget?: boolean;
|
canEditBudget?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const t = useTranslations("usage");
|
const t = useTranslations("usage");
|
||||||
|
const locale = useLocale();
|
||||||
const [month, setMonth] = useState(getCurrentMonth);
|
const [month, setMonth] = useState(getCurrentMonth);
|
||||||
const [data, setData] = useState<UsageData | null>(null);
|
const [data, setData] = useState<UsageData | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@@ -202,7 +310,7 @@ export function UsageDisplay({
|
|||||||
←
|
←
|
||||||
</button>
|
</button>
|
||||||
<span className="font-display text-sm font-medium text-text-primary">
|
<span className="font-display text-sm font-medium text-text-primary">
|
||||||
{formatMonth(month, "en")}
|
{formatMonth(month, locale)}
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
onClick={() => setMonth((m) => shiftMonth(m, 1))}
|
onClick={() => setMonth((m) => shiftMonth(m, 1))}
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { signOut, useSession } from "next-auth/react";
|
import { signOut, useSession } from "next-auth/react";
|
||||||
import { usePathname } from "@/i18n/navigation";
|
import { usePathname } from "@/i18n/navigation";
|
||||||
import { Link } from "@/i18n/navigation";
|
import { Link } from "@/i18n/navigation";
|
||||||
import { SessionProvider } from "next-auth/react";
|
import { SessionProvider } from "next-auth/react";
|
||||||
|
import type { Session } from "next-auth";
|
||||||
import { LanguageSwitcher } from "@/components/ui/language-switcher";
|
import { LanguageSwitcher } from "@/components/ui/language-switcher";
|
||||||
|
import { Logo } from "@/components/ui/logo";
|
||||||
|
|
||||||
function NavBar() {
|
function NavBar() {
|
||||||
const t = useTranslations("common");
|
const t = useTranslations("common");
|
||||||
@@ -13,6 +16,15 @@ function NavBar() {
|
|||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const user = (session as any)?.platformUser;
|
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
|
// Hide the nav entirely on auth-only routes. These pages have no
|
||||||
// session yet — showing "Dashboard" / "Sign Out" is misleading at
|
// session yet — showing "Dashboard" / "Sign Out" is misleading at
|
||||||
// best (the buttons would 401 or redirect-loop). Keep this list
|
// best (the buttons would 401 or redirect-loop). Keep this list
|
||||||
@@ -21,17 +33,55 @@ function NavBar() {
|
|||||||
const isAuthRoute = pathname === "/login" || pathname === "/register";
|
const isAuthRoute = pathname === "/login" || pathname === "/register";
|
||||||
if (isAuthRoute) return null;
|
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 (
|
return (
|
||||||
<header className="sticky top-0 z-50 border-b border-border bg-surface-1/80 backdrop-blur-md">
|
<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">
|
<div className="mx-auto flex h-14 max-w-6xl items-center justify-between px-5">
|
||||||
{/* Logo / brand */}
|
{/* Logo / brand */}
|
||||||
<div className="flex items-center gap-6">
|
<div className="flex items-center gap-6">
|
||||||
<Link href="/dashboard" className="flex items-center gap-2.5 group">
|
<Link href="/dashboard" className="flex items-center gap-2.5 group">
|
||||||
{/* Geometric mark */}
|
{/* Brand mark */}
|
||||||
<div className="relative h-7 w-7">
|
<Logo className="h-7 w-auto text-accent group-hover:text-accent-dim transition-colors" />
|
||||||
<div className="absolute inset-0 rounded-md bg-accent/20 group-hover:bg-accent/30 transition-colors" />
|
|
||||||
<div className="absolute inset-[3px] rounded-sm bg-accent" />
|
|
||||||
</div>
|
|
||||||
<span className="font-display text-base font-semibold tracking-tight text-text-primary">
|
<span className="font-display text-base font-semibold tracking-tight text-text-primary">
|
||||||
{t("appName")}
|
{t("appName")}
|
||||||
</span>
|
</span>
|
||||||
@@ -40,98 +90,96 @@ function NavBar() {
|
|||||||
</span>
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
{/* Nav links */}
|
{/* Desktop nav links */}
|
||||||
<nav className="hidden sm:flex items-center gap-1 ml-2">
|
<nav className="hidden sm:flex items-center gap-1 ml-2">
|
||||||
<NavLink href="/dashboard" active={pathname === "/dashboard"}>
|
{links.map((l) => (
|
||||||
{t("dashboard")}
|
<NavLink key={l.href} href={l.href} active={isActive(l.href)}>
|
||||||
</NavLink>
|
{l.label}
|
||||||
{/* 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>
|
|
||||||
)}
|
|
||||||
{/* Phase 3: Billing visible to anyone signed in. The
|
|
||||||
page is org-scoped server-side — non-owner members
|
|
||||||
see the same invoice history their owner does, but
|
|
||||||
actions like "configure billing details" are gated
|
|
||||||
separately on the settings page. Personal accounts
|
|
||||||
see their own (single-tenant) invoices. */}
|
|
||||||
{user && (
|
|
||||||
<NavLink
|
|
||||||
href="/billing"
|
|
||||||
active={pathname.startsWith("/billing")}
|
|
||||||
>
|
|
||||||
{t("billing")}
|
|
||||||
</NavLink>
|
</NavLink>
|
||||||
)}
|
))}
|
||||||
{/* Feature 5: Support is available to every signed-in
|
|
||||||
user. Customers see their own tickets only; platform
|
|
||||||
admins see the queue. */}
|
|
||||||
{user && (
|
|
||||||
<NavLink
|
|
||||||
href="/support"
|
|
||||||
active={pathname.startsWith("/support")}
|
|
||||||
>
|
|
||||||
{t("support")}
|
|
||||||
</NavLink>
|
|
||||||
)}
|
|
||||||
{user?.isPlatform && (
|
|
||||||
<NavLink href="/admin" active={pathname === "/admin"}>
|
|
||||||
{t("admin")}
|
|
||||||
</NavLink>
|
|
||||||
)}
|
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right side */}
|
{/* Right side */}
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
{user && (
|
{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">
|
<span className="hidden md:inline text-xs text-text-secondary font-mono">
|
||||||
{user.isPersonal
|
{displayName}
|
||||||
? user.name || (user.email ? user.email.split("@")[0] : user.orgName)
|
|
||||||
: user.orgName}
|
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<LanguageSwitcher />
|
<LanguageSwitcher />
|
||||||
<button
|
<button
|
||||||
onClick={() => signOut({ callbackUrl: "/login" })}
|
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")}
|
{t("logout")}
|
||||||
</button>
|
</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>
|
||||||
</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>
|
</header>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -162,9 +210,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 (
|
return (
|
||||||
<SessionProvider>
|
<SessionProvider session={session}>
|
||||||
<NavBar />
|
<NavBar />
|
||||||
<main className="mx-auto max-w-6xl px-5 py-8">{children}</main>
|
<main className="mx-auto max-w-6xl px-5 py-8">{children}</main>
|
||||||
</SessionProvider>
|
</SessionProvider>
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "@/i18n/navigation";
|
||||||
import { OnboardingWizard } from "./wizard";
|
import { OnboardingWizard } from "./wizard";
|
||||||
|
import type { OrgBilling } from "@/types";
|
||||||
|
|
||||||
interface OnboardingFlowProps {
|
interface OnboardingFlowProps {
|
||||||
orgName: string;
|
orgName: string;
|
||||||
@@ -19,6 +20,23 @@ interface OnboardingFlowProps {
|
|||||||
* /settings/billing.
|
* /settings/billing.
|
||||||
*/
|
*/
|
||||||
hasOrgBilling?: boolean;
|
hasOrgBilling?: boolean;
|
||||||
|
/**
|
||||||
|
* Phase 6 fix3: the actual org_billing record (or null). Drives
|
||||||
|
* the review-step "Billing to" rendering AND the confirm-step
|
||||||
|
* validation skip when the billing step was skipped.
|
||||||
|
*/
|
||||||
|
existingOrgBilling?: OrgBilling | null;
|
||||||
|
/**
|
||||||
|
* 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
|
* Bug 6: when present, the wizard is rendered in edit mode against
|
||||||
* the given pending request. See `OnboardingWizard` for the full
|
* the given pending request. See `OnboardingWizard` for the full
|
||||||
@@ -45,6 +63,9 @@ export function OnboardingFlow({
|
|||||||
userName,
|
userName,
|
||||||
userEmail,
|
userEmail,
|
||||||
hasOrgBilling,
|
hasOrgBilling,
|
||||||
|
existingOrgBilling,
|
||||||
|
setupFeeChf,
|
||||||
|
monthlyFeeChf,
|
||||||
editingRequest,
|
editingRequest,
|
||||||
}: OnboardingFlowProps) {
|
}: OnboardingFlowProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -55,6 +76,9 @@ export function OnboardingFlow({
|
|||||||
userName={userName}
|
userName={userName}
|
||||||
userEmail={userEmail}
|
userEmail={userEmail}
|
||||||
hasOrgBilling={hasOrgBilling}
|
hasOrgBilling={hasOrgBilling}
|
||||||
|
existingOrgBilling={existingOrgBilling}
|
||||||
|
setupFeeChf={setupFeeChf}
|
||||||
|
monthlyFeeChf={monthlyFeeChf}
|
||||||
editingRequest={editingRequest}
|
editingRequest={editingRequest}
|
||||||
onComplete={() => {
|
onComplete={() => {
|
||||||
// Navigate back to /dashboard and re-fetch on the server. The
|
// Navigate back to /dashboard and re-fetch on the server. The
|
||||||
|
|||||||
@@ -432,25 +432,35 @@ export function ProvisioningStatus({ requestId, canAct }: Props) {
|
|||||||
<span className="text-xs text-text-muted">{t("phase")}</span>
|
<span className="text-xs text-text-muted">{t("phase")}</span>
|
||||||
<StatusBadge phase={phase} />
|
<StatusBadge phase={phase} />
|
||||||
</div>
|
</div>
|
||||||
{conditions.map((c, i) => (
|
{/* Setup progress. The operator reports readiness as a list of
|
||||||
<div
|
internal K8s conditions (OpenBao policy, LiteLLM key, network
|
||||||
key={i}
|
policy, …) — meaningful to operators, jargon to customers.
|
||||||
className="flex items-center justify-between bg-surface-2 border border-border rounded-lg px-4 py-2"
|
We surface the *shape* of that progress (how many steps are
|
||||||
>
|
done) without leaking the internal names. */}
|
||||||
<span className="text-xs text-text-muted">{c.type}</span>
|
{conditions.length > 0 &&
|
||||||
<span
|
(() => {
|
||||||
className={`text-xs font-mono ${
|
const done = conditions.filter((c) => c.status === "True").length;
|
||||||
c.status === "True"
|
const total = conditions.length;
|
||||||
? "text-emerald-400"
|
const pct = Math.round((done / total) * 100);
|
||||||
: c.status === "False"
|
return (
|
||||||
? "text-red-400"
|
<div className="bg-surface-2 border border-border rounded-lg px-4 py-3">
|
||||||
: "text-text-muted"
|
<div className="flex items-center justify-between mb-2">
|
||||||
}`}
|
<span className="text-xs text-text-muted">
|
||||||
>
|
{t("setupProgress")}
|
||||||
{c.reason || c.status}
|
</span>
|
||||||
</span>
|
<span className="text-xs font-medium text-text-secondary tabular-nums">
|
||||||
</div>
|
{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>
|
</div>
|
||||||
</Card>
|
</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">
|
<p className="text-sm text-text-secondary max-w-sm mx-auto mb-4">
|
||||||
{t("readyDescription")}
|
{t("readyDescription")}
|
||||||
</p>
|
</p>
|
||||||
<button
|
{(() => {
|
||||||
onClick={() => window.location.reload()}
|
// Prefer deep-linking straight to the tenant page, where the
|
||||||
className="py-2 px-6 bg-accent text-white text-sm font-medium rounded-lg hover:bg-accent-dim transition-colors"
|
// ConnectPanel shows how to start chatting. Fall back to a
|
||||||
>
|
// reload only if we somehow don't have a tenant name yet.
|
||||||
{t("goToDashboard")}
|
const tenantName = data.tenant?.name || data.request.tenantName;
|
||||||
</button>
|
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>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useTranslations } from "next-intl";
|
|||||||
import { Card } from "@/components/ui/card";
|
import { Card } from "@/components/ui/card";
|
||||||
import { PACKAGE_CATALOG, DEFAULT_PACKAGE_IDS, type PackageDef } from "@/lib/packages";
|
import { PACKAGE_CATALOG, DEFAULT_PACKAGE_IDS, type PackageDef } from "@/lib/packages";
|
||||||
import { isPersonalOrgName, displayOrgNameFor } from "@/lib/personal-org";
|
import { isPersonalOrgName, displayOrgNameFor } from "@/lib/personal-org";
|
||||||
|
import { THREEMA_GATEWAY } from "@/lib/threema-gateway-config";
|
||||||
import {
|
import {
|
||||||
configureStepSchema,
|
configureStepSchema,
|
||||||
billingStepSchema,
|
billingStepSchema,
|
||||||
@@ -13,6 +14,7 @@ import {
|
|||||||
SUPPORTED_COUNTRIES,
|
SUPPORTED_COUNTRIES,
|
||||||
type SupportedCountry,
|
type SupportedCountry,
|
||||||
} from "@/lib/validation";
|
} from "@/lib/validation";
|
||||||
|
import type { OrgBilling } from "@/types";
|
||||||
|
|
||||||
type Step = "welcome" | "configure" | "billing" | "confirm";
|
type Step = "welcome" | "configure" | "billing" | "confirm";
|
||||||
|
|
||||||
@@ -96,6 +98,32 @@ interface WizardProps {
|
|||||||
* fix it before admin approves.
|
* fix it before admin approves.
|
||||||
*/
|
*/
|
||||||
hasOrgBilling?: boolean;
|
hasOrgBilling?: boolean;
|
||||||
|
/**
|
||||||
|
* Phase 6 fix3: the actual org_billing record when one exists.
|
||||||
|
* Used to render real values on the review-step "Billing to" block
|
||||||
|
* (rather than the wizard's empty default config.billingAddress)
|
||||||
|
* AND to skip the confirm-step's client-side validation of
|
||||||
|
* billingAddress — same logic that already strips billingAddress
|
||||||
|
* at submit time. Null when no org_billing row exists yet.
|
||||||
|
* Ignored in edit mode (the editingRequest carries its own
|
||||||
|
* billingAddress snapshot).
|
||||||
|
*/
|
||||||
|
existingOrgBilling?: OrgBilling | null;
|
||||||
|
/**
|
||||||
|
* 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
|
* Bug 6: when present, the wizard renders in "edit" mode — fields
|
||||||
* are pre-populated from the request, the SOUL.md auto-fetch is
|
* are pre-populated from the request, the SOUL.md auto-fetch is
|
||||||
@@ -134,6 +162,9 @@ export function OnboardingWizard({
|
|||||||
userName,
|
userName,
|
||||||
userEmail,
|
userEmail,
|
||||||
hasOrgBilling,
|
hasOrgBilling,
|
||||||
|
existingOrgBilling,
|
||||||
|
setupFeeChf,
|
||||||
|
monthlyFeeChf,
|
||||||
editingRequest,
|
editingRequest,
|
||||||
onComplete,
|
onComplete,
|
||||||
}: WizardProps) {
|
}: WizardProps) {
|
||||||
@@ -232,6 +263,14 @@ export function OnboardingWizard({
|
|||||||
const [disclaimerAccepted, setDisclaimerAccepted] = useState<
|
const [disclaimerAccepted, setDisclaimerAccepted] = useState<
|
||||||
Record<string, boolean>
|
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
|
// Fetch DB-stored defaults on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -319,7 +358,23 @@ export function OnboardingWizard({
|
|||||||
}
|
}
|
||||||
// confirm: validate the union (defence in depth — submit handler
|
// confirm: validate the union (defence in depth — submit handler
|
||||||
// also runs onboardingSchema before POST).
|
// also runs onboardingSchema before POST).
|
||||||
const r = onboardingSchema.safeParse(config);
|
//
|
||||||
|
// Phase 6 fix3: when hasOrgBilling=true AND not editing, the
|
||||||
|
// billing step was skipped and config.billingAddress is the
|
||||||
|
// empty default. zod's .optional() doesn't help here because the
|
||||||
|
// field IS present (empty object), so billingAddressSchema
|
||||||
|
// validates it and fails with required-field errors that the
|
||||||
|
// user has no way to fix — the form to enter the values was
|
||||||
|
// skipped on purpose. Strip the field for validation, matching
|
||||||
|
// the same strip we already do at submit time.
|
||||||
|
const configForValidation =
|
||||||
|
hasOrgBilling && !isEditing
|
||||||
|
? (() => {
|
||||||
|
const { billingAddress: _b, ...rest } = config;
|
||||||
|
return rest;
|
||||||
|
})()
|
||||||
|
: config;
|
||||||
|
const r = onboardingSchema.safeParse(configForValidation);
|
||||||
if (r.success) {
|
if (r.success) {
|
||||||
setErrors({});
|
setErrors({});
|
||||||
return true;
|
return true;
|
||||||
@@ -373,18 +428,51 @@ export function OnboardingWizard({
|
|||||||
[]
|
[]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Validate that all secret-requiring enabled packages have complete credentials
|
// Enabled packages that still need something from the user before the
|
||||||
const packageCredentialsValid = (): boolean => {
|
// 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) {
|
for (const pkgId of config.packages) {
|
||||||
const def = PACKAGE_CATALOG.find((p) => p.id === pkgId);
|
const def = PACKAGE_CATALOG.find((p) => p.id === pkgId);
|
||||||
if (!def?.requiresSecrets) continue;
|
if (!def) continue;
|
||||||
const secrets = packageSecrets[pkgId] || {};
|
let incomplete = false;
|
||||||
for (const field of def.secrets || []) {
|
if (def.requiresSecrets) {
|
||||||
if (!secrets[field.key]?.trim()) return false;
|
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 () => {
|
const handleSubmit = async () => {
|
||||||
@@ -435,6 +523,20 @@ export function OnboardingWizard({
|
|||||||
})()
|
})()
|
||||||
: config;
|
: 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, {
|
const res = await fetch(url, {
|
||||||
method,
|
method,
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
@@ -444,6 +546,10 @@ export function OnboardingWizard({
|
|||||||
Object.keys(secretsPayload).length > 0
|
Object.keys(secretsPayload).length > 0
|
||||||
? secretsPayload
|
? secretsPayload
|
||||||
: undefined,
|
: undefined,
|
||||||
|
channelUsers:
|
||||||
|
Object.keys(channelUsersPayload).length > 0
|
||||||
|
? channelUsersPayload
|
||||||
|
: undefined,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -452,6 +558,22 @@ export function OnboardingWizard({
|
|||||||
throw new Error(data.error || "Submission failed");
|
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();
|
onComplete();
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err.message);
|
setError(err.message);
|
||||||
@@ -525,7 +647,7 @@ export function OnboardingWizard({
|
|||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
<button
|
<button
|
||||||
onClick={goNext}
|
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")}
|
{t("getStarted")}
|
||||||
</button>
|
</button>
|
||||||
@@ -691,7 +813,9 @@ export function OnboardingWizard({
|
|||||||
className={`border rounded-lg overflow-hidden transition-colors ${
|
className={`border rounded-lg overflow-hidden transition-colors ${
|
||||||
isSelected
|
isSelected
|
||||||
? "border-accent bg-accent/5"
|
? "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 */}
|
{/* Toggle row */}
|
||||||
@@ -710,6 +834,11 @@ export function OnboardingWizard({
|
|||||||
>
|
>
|
||||||
{pkg.name}
|
{pkg.name}
|
||||||
</span>
|
</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 && (
|
{pkg.requiresSecrets && (
|
||||||
<span className="ml-1.5 text-[10px] text-text-muted">
|
<span className="ml-1.5 text-[10px] text-text-muted">
|
||||||
({tPkg("requiresApiKey")})
|
({tPkg("requiresApiKey")})
|
||||||
@@ -731,8 +860,16 @@ export function OnboardingWizard({
|
|||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Inline credential inputs — expand when selected + requires secrets */}
|
{/* Inline expansion when selected — shows
|
||||||
{isSelected && pkg.requiresSecrets && (
|
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">
|
<div className="border-t border-border px-3 py-3 space-y-3 bg-surface-1/50">
|
||||||
{pkg.instructionsKey && (
|
{pkg.instructionsKey && (
|
||||||
<div className="bg-surface-2 border border-border rounded-lg p-3 text-xs text-text-secondary leading-relaxed whitespace-pre-line">
|
<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>
|
</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) => (
|
{(pkg.secrets || []).map((field) => (
|
||||||
<label key={field.key} className="block">
|
<label key={field.key} className="block">
|
||||||
<span className="text-xs text-text-secondary mb-1 block">
|
<span className="text-xs text-text-secondary mb-1 block">
|
||||||
@@ -773,6 +944,46 @@ export function OnboardingWizard({
|
|||||||
</label>
|
</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 && (
|
{pkg.disclaimerKey && (
|
||||||
<label className="flex items-start gap-2 text-xs text-text-secondary">
|
<label className="flex items-start gap-2 text-xs text-text-secondary">
|
||||||
<input
|
<input
|
||||||
@@ -814,20 +1025,33 @@ export function OnboardingWizard({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-between mt-6">
|
<div className="mt-6">
|
||||||
<button
|
{(() => {
|
||||||
onClick={goBack}
|
const blocking = incompletePackages();
|
||||||
className="py-2 px-4 text-sm text-text-secondary hover:text-text-primary transition-colors"
|
if (blocking.length === 0) return null;
|
||||||
>
|
return (
|
||||||
{t("back")}
|
<p className="text-xs text-amber-400/90 mb-3 text-right">
|
||||||
</button>
|
{t("packagesIncompleteHint", {
|
||||||
<button
|
packages: blocking.map((p) => p.name).join(", "),
|
||||||
onClick={goNext}
|
})}
|
||||||
disabled={!packageCredentialsValid()}
|
</p>
|
||||||
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")}
|
<div className="flex justify-between">
|
||||||
</button>
|
<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>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
@@ -1001,28 +1225,6 @@ export function OnboardingWizard({
|
|||||||
</p>
|
</p>
|
||||||
</FieldWithError>
|
</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>
|
||||||
|
|
||||||
<div className="flex justify-between mt-6">
|
<div className="flex justify-between mt-6">
|
||||||
@@ -1034,7 +1236,7 @@ export function OnboardingWizard({
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={goNext}
|
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")}
|
{t("next")}
|
||||||
</button>
|
</button>
|
||||||
@@ -1101,60 +1303,135 @@ export function OnboardingWizard({
|
|||||||
<ReviewRow
|
<ReviewRow
|
||||||
label={t("reviewBillingTo")}
|
label={t("reviewBillingTo")}
|
||||||
value={
|
value={
|
||||||
<div className="text-text-primary text-right">
|
(() => {
|
||||||
{/* For personal: skip the company line so the
|
// Phase 6 fix3: when the org has billing on file
|
||||||
invoice rendering matches what the user actually
|
// and we're not editing, render the saved
|
||||||
entered. For company: include it as the first
|
// org_billing record (the authoritative source)
|
||||||
line. */}
|
// rather than config.billingAddress, which is the
|
||||||
{!isPersonal &&
|
// wizard's empty default state because the billing
|
||||||
config.billingAddress.company &&
|
// step was skipped. In edit mode, fall back to
|
||||||
config.billingAddress.company.trim().length > 0 && (
|
// config.billingAddress, which is pre-populated
|
||||||
<div>{config.billingAddress.company}</div>
|
// from the request being edited.
|
||||||
)}
|
const useSaved =
|
||||||
<div>{config.billingAddress.street}</div>
|
hasOrgBilling && !isEditing && existingOrgBilling;
|
||||||
<div>
|
const company = useSaved
|
||||||
{config.billingAddress.postalCode}{" "}
|
? existingOrgBilling!.companyName
|
||||||
{config.billingAddress.city}
|
: config.billingAddress.company;
|
||||||
</div>
|
const street = useSaved
|
||||||
<div className="text-text-muted">
|
? existingOrgBilling!.streetAddress
|
||||||
{tCountries(
|
: config.billingAddress.street;
|
||||||
config.billingAddress.country as SupportedCountry
|
const postalCode = useSaved
|
||||||
)}
|
? existingOrgBilling!.postalCode
|
||||||
</div>
|
: config.billingAddress.postalCode;
|
||||||
</div>
|
const city = useSaved
|
||||||
|
? existingOrgBilling!.city
|
||||||
|
: config.billingAddress.city;
|
||||||
|
const country = useSaved
|
||||||
|
? existingOrgBilling!.country
|
||||||
|
: config.billingAddress.country;
|
||||||
|
const contactName = useSaved
|
||||||
|
? existingOrgBilling!.contactName
|
||||||
|
: null;
|
||||||
|
return (
|
||||||
|
<div className="text-text-primary text-right">
|
||||||
|
{/* For personal: skip the company line so the
|
||||||
|
invoice rendering matches what the user actually
|
||||||
|
entered. For company: include it as the first
|
||||||
|
line. */}
|
||||||
|
{!isPersonal &&
|
||||||
|
company &&
|
||||||
|
company.trim().length > 0 && <div>{company}</div>}
|
||||||
|
{/* Phase 6 fix2: optional contact-person line
|
||||||
|
("z.Hd. <name>") only present when the saved
|
||||||
|
org_billing has it set. */}
|
||||||
|
{contactName && contactName.trim().length > 0 && (
|
||||||
|
<div className="text-text-muted">
|
||||||
|
{t("reviewContactPersonPrefix")} {contactName}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div>{street}</div>
|
||||||
|
<div>
|
||||||
|
{postalCode} {city}
|
||||||
|
</div>
|
||||||
|
<div className="text-text-muted">
|
||||||
|
{tCountries(country as SupportedCountry)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
{/* Bug 35: VAT review row. Company customers see this so
|
{/* Bug 35: VAT review row. Company customers see this so
|
||||||
they can verify the VAT id they typed before submitting.
|
they can verify the VAT id they typed before submitting.
|
||||||
Personal customers never see it — they don't have a
|
Personal customers never see it — they don't have a
|
||||||
VAT number, the form didn't ask, the review hides it. */}
|
VAT number, the form didn't ask, the review hides it.
|
||||||
|
Phase 6 fix3: when reading from existingOrgBilling,
|
||||||
|
the value comes from there too. */}
|
||||||
{!isPersonal &&
|
{!isPersonal &&
|
||||||
config.billingAddress.vatNumber &&
|
(() => {
|
||||||
config.billingAddress.vatNumber.trim().length > 0 && (
|
const vat =
|
||||||
<ReviewRow
|
hasOrgBilling && !isEditing && existingOrgBilling
|
||||||
label={t("billingVatNumber")}
|
? existingOrgBilling.vatNumber
|
||||||
value={config.billingAddress.vatNumber}
|
: config.billingAddress.vatNumber;
|
||||||
mono
|
return vat && vat.trim().length > 0 ? (
|
||||||
/>
|
<ReviewRow
|
||||||
)}
|
label={t("billingVatNumber")}
|
||||||
|
value={vat}
|
||||||
|
mono
|
||||||
|
/>
|
||||||
|
) : null;
|
||||||
|
})()}
|
||||||
<ReviewRow
|
<ReviewRow
|
||||||
label={t("reviewContactEmail")}
|
label={t("reviewContactEmail")}
|
||||||
value={userEmail || ""}
|
value={userEmail || ""}
|
||||||
mono
|
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>
|
</div>
|
||||||
|
|
||||||
<p className="text-xs text-text-muted">{t("confirmNote")}</p>
|
<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>
|
</div>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
@@ -1175,7 +1452,8 @@ export function OnboardingWizard({
|
|||||||
<ul className="list-disc list-inside space-y-0.5">
|
<ul className="list-disc list-inside space-y-0.5">
|
||||||
{Object.entries(errors).map(([path, msg]) => (
|
{Object.entries(errors).map(([path, msg]) => (
|
||||||
<li key={path}>
|
<li key={path}>
|
||||||
<span className="font-mono">{path}</span>: {msg}
|
<span className="font-medium">{fieldLabel(path)}</span>:{" "}
|
||||||
|
{msg}
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
@@ -1192,7 +1470,7 @@ export function OnboardingWizard({
|
|||||||
<button
|
<button
|
||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
disabled={submitting}
|
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
|
{submitting
|
||||||
? tCommon("loading")
|
? tCommon("loading")
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import type {
|
|||||||
SkillPricing,
|
SkillPricing,
|
||||||
} from "@/types";
|
} from "@/types";
|
||||||
import { SkillCostDialog } from "./skill-cost-dialog";
|
import { SkillCostDialog } from "./skill-cost-dialog";
|
||||||
|
import { ThreemaQrModal } from "@/components/channel-users/threema-qr-modal";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
pkg: PackageDef;
|
pkg: PackageDef;
|
||||||
@@ -51,6 +52,11 @@ export function PackageCard({
|
|||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
// Phase 2.5: cost-disclosure flow + activation-request flow.
|
// Phase 2.5: cost-disclosure flow + activation-request flow.
|
||||||
const [showCostDialog, setShowCostDialog] = useState(false);
|
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 =
|
const isPriced =
|
||||||
(pricing?.dailyPriceChf ?? 0) > 0 || (pricing?.setupFeeChf ?? 0) > 0;
|
(pricing?.dailyPriceChf ?? 0) > 0 || (pricing?.setupFeeChf ?? 0) > 0;
|
||||||
|
|
||||||
@@ -79,6 +85,14 @@ export function PackageCard({
|
|||||||
throw new Error(err.error || `Provisioning failed (HTTP ${provRes.status})`);
|
throw new Error(err.error || `Provisioning failed (HTTP ${provRes.status})`);
|
||||||
}
|
}
|
||||||
await togglePackage(true);
|
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) {
|
} catch (e: any) {
|
||||||
setError(e.message);
|
setError(e.message);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -283,17 +297,33 @@ export function PackageCard({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : canEdit ? (
|
) : canEdit ? (
|
||||||
<button
|
<div className="ml-auto flex items-center gap-2">
|
||||||
onClick={enabled ? handleDisable : handleEnable}
|
{/* Phase 9b: re-open the Threema info popup at any time
|
||||||
disabled={saving}
|
while Threema is enabled. The popup auto-opens after
|
||||||
className={`ml-auto rounded-lg px-3 py-1.5 text-xs font-medium transition-all cursor-pointer ${
|
a fresh enable; this button lets the customer see the
|
||||||
enabled
|
QR + bot ID again without having to disable + re-enable. */}
|
||||||
? "bg-surface-3 text-text-secondary hover:text-text-primary hover:bg-surface-2"
|
{pkg.id === "threema" && enabled && (
|
||||||
: "bg-accent text-surface-0 hover:bg-accent-dim shadow-lg shadow-accent/20"
|
<button
|
||||||
} disabled:opacity-50`}
|
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"
|
||||||
{saving ? "…" : enabled ? t("packages.disable") : t("packages.enable")}
|
title={t("packages.showInfoTitle")}
|
||||||
</button>
|
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
|
// Slice 5: read-only viewers see a static badge instead of a
|
||||||
// toggle. The status badge above the divider already conveys
|
// toggle. The status badge above the divider already conveys
|
||||||
@@ -320,6 +350,16 @@ export function PackageCard({
|
|||||||
busy={saving}
|
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 && (
|
{showModal && (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4">
|
<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">
|
<div className="w-full max-w-md bg-surface-1 border border-border rounded-2xl p-6 space-y-4 shadow-2xl shadow-black/40">
|
||||||
|
|||||||
@@ -104,7 +104,7 @@ export function SkillCostDialog({
|
|||||||
<button
|
<button
|
||||||
onClick={onConfirm}
|
onClick={onConfirm}
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
className="px-4 py-2 rounded-md bg-accent text-white text-sm disabled:opacity-50"
|
className="px-4 py-2 rounded-md bg-accent text-surface-0 text-sm disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{busy ? t("confirming") : t("confirm")}
|
{busy ? t("confirming") : t("confirm")}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
263
src/components/settings/billing-form.tsx
Normal file
263
src/components/settings/billing-form.tsx
Normal file
@@ -0,0 +1,263 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { Card } from "@/components/ui/card";
|
||||||
|
import type { OrgBilling } from "@/types";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
initial: OrgBilling | null;
|
||||||
|
/**
|
||||||
|
* Personal-account (individual customer) flag from the session.
|
||||||
|
* Individuals get a "Full name" field instead of "Company name",
|
||||||
|
* and the VAT input is hidden entirely — they don't have one and
|
||||||
|
* showing the field would only confuse. The underlying column is
|
||||||
|
* still `company_name` in the DB and the invoice PDF; for an
|
||||||
|
* individual that field carries their full name, which is
|
||||||
|
* exactly what should print on the invoice.
|
||||||
|
*/
|
||||||
|
isPersonal: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Customer billing settings form. Drives PUT /api/settings/billing
|
||||||
|
* which upserts org_billing for the caller's org.
|
||||||
|
*
|
||||||
|
* Validation is the same regex as the server-side zod schema for
|
||||||
|
* the country field (ISO 3166-1 alpha-2). Other fields are checked
|
||||||
|
* for required + max-length client-side; the server is the
|
||||||
|
* authority and re-validates everything.
|
||||||
|
*
|
||||||
|
* On success we router.refresh() the page so the server component
|
||||||
|
* re-fetches and any "create now" -> "edit" wording flips.
|
||||||
|
*/
|
||||||
|
export function BillingSettingsForm({ initial, isPersonal }: Props) {
|
||||||
|
const t = useTranslations("settingsBilling");
|
||||||
|
const router = useRouter();
|
||||||
|
const [form, setForm] = useState({
|
||||||
|
companyName: initial?.companyName ?? "",
|
||||||
|
contactName: initial?.contactName ?? "",
|
||||||
|
streetAddress: initial?.streetAddress ?? "",
|
||||||
|
postalCode: initial?.postalCode ?? "",
|
||||||
|
city: initial?.city ?? "",
|
||||||
|
country: initial?.country ?? "CH",
|
||||||
|
vatNumber: initial?.vatNumber ?? "",
|
||||||
|
billingEmail: initial?.billingEmail ?? "",
|
||||||
|
notes: initial?.notes ?? "",
|
||||||
|
});
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [savedFlash, setSavedFlash] = useState(false);
|
||||||
|
|
||||||
|
const set =
|
||||||
|
(field: keyof typeof form) =>
|
||||||
|
(e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) =>
|
||||||
|
setForm((f) => ({ ...f, [field]: e.target.value }));
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
|
setError(null);
|
||||||
|
setSavedFlash(false);
|
||||||
|
// Client-side gate on required fields — the server re-validates.
|
||||||
|
if (
|
||||||
|
!form.companyName.trim() ||
|
||||||
|
!form.streetAddress.trim() ||
|
||||||
|
!form.postalCode.trim() ||
|
||||||
|
!form.city.trim() ||
|
||||||
|
!form.country.trim() ||
|
||||||
|
!form.billingEmail.trim()
|
||||||
|
) {
|
||||||
|
setError(t("missingRequired"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!/^[A-Za-z]{2}$/.test(form.country.trim())) {
|
||||||
|
setError(t("invalidCountry"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(form.billingEmail.trim())) {
|
||||||
|
setError(t("invalidEmail"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/settings/billing", {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
companyName: form.companyName.trim(),
|
||||||
|
// Personal accounts don't have a contact-name field
|
||||||
|
// (companyName IS their name); force null so stale state
|
||||||
|
// from a previously-org-flagged account can't carry over.
|
||||||
|
contactName: isPersonal ? null : form.contactName.trim() || null,
|
||||||
|
streetAddress: form.streetAddress.trim(),
|
||||||
|
postalCode: form.postalCode.trim(),
|
||||||
|
city: form.city.trim(),
|
||||||
|
country: form.country.trim().toUpperCase(),
|
||||||
|
// Personal accounts never have a VAT number — force null
|
||||||
|
// regardless of stale state, in case a value was stored
|
||||||
|
// before the account got flagged as personal.
|
||||||
|
vatNumber: isPersonal ? null : form.vatNumber.trim() || null,
|
||||||
|
billingEmail: form.billingEmail.trim(),
|
||||||
|
notes: form.notes.trim() || null,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(data.error ?? `HTTP ${res.status}`);
|
||||||
|
}
|
||||||
|
setSavedFlash(true);
|
||||||
|
router.refresh();
|
||||||
|
} catch (e: any) {
|
||||||
|
setError(e?.message ?? String(e));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Field
|
||||||
|
label={isPersonal ? t("fullNameLabel") : t("companyNameLabel")}
|
||||||
|
required
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={form.companyName}
|
||||||
|
onChange={set("companyName")}
|
||||||
|
maxLength={200}
|
||||||
|
className="w-full px-3 py-2 rounded-md bg-surface-2 border border-border focus:border-accent focus:outline-none text-sm"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
{!isPersonal && (
|
||||||
|
<Field label={t("contactNameLabel")} hint={t("contactNameHint")}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={form.contactName}
|
||||||
|
onChange={set("contactName")}
|
||||||
|
maxLength={200}
|
||||||
|
className="w-full px-3 py-2 rounded-md bg-surface-2 border border-border focus:border-accent focus:outline-none text-sm"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
<Field label={t("streetAddressLabel")} required>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={form.streetAddress}
|
||||||
|
onChange={set("streetAddress")}
|
||||||
|
maxLength={200}
|
||||||
|
className="w-full px-3 py-2 rounded-md bg-surface-2 border border-border focus:border-accent focus:outline-none text-sm"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
<Field label={t("postalCodeLabel")} required>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={form.postalCode}
|
||||||
|
onChange={set("postalCode")}
|
||||||
|
maxLength={20}
|
||||||
|
className="w-full px-3 py-2 rounded-md bg-surface-2 border border-border focus:border-accent focus:outline-none text-sm"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label={t("cityLabel")} required>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={form.city}
|
||||||
|
onChange={set("city")}
|
||||||
|
maxLength={100}
|
||||||
|
className="w-full px-3 py-2 rounded-md bg-surface-2 border border-border focus:border-accent focus:outline-none text-sm"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field
|
||||||
|
label={t("countryLabel")}
|
||||||
|
required
|
||||||
|
hint={t("countryHint")}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={form.country}
|
||||||
|
onChange={(e) =>
|
||||||
|
setForm((f) => ({
|
||||||
|
...f,
|
||||||
|
country: e.target.value.toUpperCase().slice(0, 2),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
maxLength={2}
|
||||||
|
className="w-full px-3 py-2 rounded-md bg-surface-2 border border-border focus:border-accent focus:outline-none text-sm uppercase font-mono"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
{!isPersonal && (
|
||||||
|
<Field label={t("vatNumberLabel")} hint={t("vatNumberHint")}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={form.vatNumber}
|
||||||
|
onChange={set("vatNumber")}
|
||||||
|
maxLength={40}
|
||||||
|
placeholder="CHE-123.456.789 MWST"
|
||||||
|
className="w-full px-3 py-2 rounded-md bg-surface-2 border border-border focus:border-accent focus:outline-none text-sm font-mono"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
<Field label={t("billingEmailLabel")} required hint={t("billingEmailHint")}>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
value={form.billingEmail}
|
||||||
|
onChange={set("billingEmail")}
|
||||||
|
maxLength={200}
|
||||||
|
className="w-full px-3 py-2 rounded-md bg-surface-2 border border-border focus:border-accent focus:outline-none text-sm"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label={t("notesLabel")} hint={t("notesHint")}>
|
||||||
|
<textarea
|
||||||
|
value={form.notes}
|
||||||
|
onChange={set("notes")}
|
||||||
|
maxLength={2000}
|
||||||
|
rows={3}
|
||||||
|
className="w-full px-3 py-2 rounded-md bg-surface-2 border border-border focus:border-accent focus:outline-none text-sm"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
{error && (
|
||||||
|
<p className="text-sm text-error">{error}</p>
|
||||||
|
)}
|
||||||
|
{savedFlash && (
|
||||||
|
<p className="text-sm text-success">{t("saved")}</p>
|
||||||
|
)}
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<button
|
||||||
|
onClick={submit}
|
||||||
|
disabled={busy}
|
||||||
|
className="px-4 py-2 rounded-md bg-accent text-surface-0 text-sm font-medium hover:bg-accent-dim transition-colors disabled:opacity-50 cursor-pointer"
|
||||||
|
>
|
||||||
|
{busy ? t("saving") : initial ? t("saveChanges") : t("createBilling")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Field({
|
||||||
|
label,
|
||||||
|
required,
|
||||||
|
hint,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
required?: boolean;
|
||||||
|
hint?: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs uppercase tracking-wider text-text-muted mb-1">
|
||||||
|
{label}
|
||||||
|
{required && <span className="text-error ml-1">*</span>}
|
||||||
|
</label>
|
||||||
|
{children}
|
||||||
|
{hint && (
|
||||||
|
<p className="text-xs text-text-muted mt-1 italic">{hint}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -268,7 +268,7 @@ export function BillingSettingsForm({
|
|||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={submitting}
|
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")}
|
{submitting ? tCommon("loading") : t("save")}
|
||||||
</button>
|
</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
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={submitting}
|
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")}
|
{submitting ? tCommon("loading") : t("submitTicket")}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -186,7 +186,7 @@ export function TicketThread({
|
|||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={submitting || closing || body.trim().length === 0}
|
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")}
|
{submitting ? tCommon("loading") : t("sendReply")}
|
||||||
</button>
|
</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
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={state === "submitting"}
|
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")}
|
{state === "submitting" ? tCommon("loading") : t("inviteButton")}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -179,7 +179,7 @@ export function TeamList({
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => saveEdit(m)}
|
onClick={() => saveEdit(m)}
|
||||||
disabled={submitting || !m.authorizationId}
|
disabled={submitting || !m.authorizationId}
|
||||||
className="text-xs px-2.5 py-1 rounded-md bg-accent text-white hover:bg-accent-dim transition-colors disabled:opacity-50"
|
className="text-xs px-2.5 py-1 rounded-md bg-accent text-surface-0 hover:bg-accent-dim transition-colors disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{t("save")}
|
{t("save")}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -218,7 +218,7 @@ export function AssignedUsersPanel({ tenantName, canEdit }: Props) {
|
|||||||
<button
|
<button
|
||||||
onClick={handleAssign}
|
onClick={handleAssign}
|
||||||
disabled={busy || !pickedUserId}
|
disabled={busy || !pickedUserId}
|
||||||
className="px-4 py-2 text-sm font-medium bg-accent text-white rounded-lg hover:bg-accent-dim transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
className="px-4 py-2 text-sm font-medium bg-accent text-surface-0 rounded-lg hover:bg-accent-dim transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
>
|
>
|
||||||
{busy ? "…" : t("assign")}
|
{busy ? "…" : t("assign")}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
234
src/components/tenants/connect-panel.tsx
Normal file
234
src/components/tenants/connect-panel.tsx
Normal file
@@ -0,0 +1,234 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { THREEMA_GATEWAY } from "@/lib/threema-gateway-config";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ConnectPanel
|
||||||
|
*
|
||||||
|
* The portal is a *management* console — config, billing, usage — but
|
||||||
|
* the assistant itself lives in the customer's messaging app. Nothing
|
||||||
|
* previously told the customer how to actually start talking to the
|
||||||
|
* thing they just provisioned ("Your assistant is ready… now what?").
|
||||||
|
*
|
||||||
|
* This panel closes that gap on the tenant-detail page: for each
|
||||||
|
* enabled channel it shows the concrete first-contact steps, and when
|
||||||
|
* NO channel is enabled it says so explicitly (a running assistant with
|
||||||
|
* no channel is unreachable).
|
||||||
|
*
|
||||||
|
* Once a customer has connected they don't need the steps every visit,
|
||||||
|
* so the panel is dismissible: clicking "I've connected" collapses it
|
||||||
|
* to a slim row and remembers that per-tenant (localStorage). The slim
|
||||||
|
* row keeps a "Show connection details" toggle so it's never lost.
|
||||||
|
* The no-channel warning is NOT dismissible — it's an actionable alert,
|
||||||
|
* not reference material.
|
||||||
|
*
|
||||||
|
* It is intentionally complementary to ChannelUsers below it:
|
||||||
|
* - ConnectPanel → "how do *I* reach the assistant"
|
||||||
|
* - ChannelUsers → "*who* is allowed to reach it"
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Render order is fixed (not the order packages happen to appear in
|
||||||
|
// spec.packages) so the panel layout is stable across tenants.
|
||||||
|
const CHANNEL_ORDER = ["threema", "telegram", "discord"] as const;
|
||||||
|
|
||||||
|
const CHANNEL_NAMES: Record<string, string> = {
|
||||||
|
threema: "Threema",
|
||||||
|
telegram: "Telegram",
|
||||||
|
discord: "Discord",
|
||||||
|
};
|
||||||
|
|
||||||
|
// Per-channel instruction key in the `connect` message namespace.
|
||||||
|
const CHANNEL_STEPS_KEY: Record<string, string> = {
|
||||||
|
threema: "threemaSteps",
|
||||||
|
telegram: "telegramSteps",
|
||||||
|
discord: "discordSteps",
|
||||||
|
};
|
||||||
|
|
||||||
|
const dismissKey = (tenantName: string) =>
|
||||||
|
`pieced:connect-hidden:${tenantName}`;
|
||||||
|
|
||||||
|
export function ConnectPanel({
|
||||||
|
tenantName,
|
||||||
|
enabledChannels,
|
||||||
|
phase,
|
||||||
|
}: {
|
||||||
|
tenantName: string;
|
||||||
|
enabledChannels: string[];
|
||||||
|
/** Tenant phase — connection details only "work" once it's Ready. */
|
||||||
|
phase: string;
|
||||||
|
}) {
|
||||||
|
const t = useTranslations("connect");
|
||||||
|
|
||||||
|
const channels = CHANNEL_ORDER.filter((c) => enabledChannels.includes(c));
|
||||||
|
const ready = phase === "Ready" || phase === "Running" || phase === "Active";
|
||||||
|
|
||||||
|
// Dismissed state is read from localStorage after mount to avoid a
|
||||||
|
// hydration mismatch (server has no localStorage). `hydrated` gates
|
||||||
|
// the collapsed view so the first paint matches the server output.
|
||||||
|
const [collapsed, setCollapsed] = useState(false);
|
||||||
|
const [hydrated, setHydrated] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
setCollapsed(localStorage.getItem(dismissKey(tenantName)) === "1");
|
||||||
|
} catch {
|
||||||
|
/* private mode / storage disabled — just stay expanded */
|
||||||
|
}
|
||||||
|
setHydrated(true);
|
||||||
|
}, [tenantName]);
|
||||||
|
|
||||||
|
const dismiss = () => {
|
||||||
|
setCollapsed(true);
|
||||||
|
try {
|
||||||
|
localStorage.setItem(dismissKey(tenantName), "1");
|
||||||
|
} catch {
|
||||||
|
/* no-op */
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const reopen = () => {
|
||||||
|
setCollapsed(false);
|
||||||
|
try {
|
||||||
|
localStorage.removeItem(dismissKey(tenantName));
|
||||||
|
} catch {
|
||||||
|
/* no-op */
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// No channel at all → the assistant is unreachable. Make it loud and
|
||||||
|
// keep it non-dismissible (it's an alert, not reference material).
|
||||||
|
if (channels.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-amber-500/30 bg-amber-500/10 p-5">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<svg
|
||||||
|
className="h-5 w-5 text-amber-400 shrink-0 mt-0.5"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zM12 15.75h.008v.008H12v-.008z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="text-sm font-semibold text-amber-300">
|
||||||
|
{t("noChannelsTitle")}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-text-secondary mt-1 leading-relaxed">
|
||||||
|
{t("noChannelsBody")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collapsed: a slim, unobtrusive row with a toggle to bring the full
|
||||||
|
// panel back. Only shown once hydrated so SSR/CSR agree.
|
||||||
|
if (hydrated && collapsed) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between rounded-lg border border-border bg-surface-1 px-4 py-2">
|
||||||
|
<span className="text-xs text-text-muted">{t("title")}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={reopen}
|
||||||
|
className="shrink-0 inline-flex items-center rounded-md border border-border px-2.5 py-1 text-xs font-medium text-accent hover:bg-surface-2 hover:border-accent/40 transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
{t("show")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-accent/30 bg-accent/5 p-5">
|
||||||
|
<div className="flex items-start justify-between gap-3 mb-1">
|
||||||
|
<h2 className="font-display text-base font-semibold text-text-primary">
|
||||||
|
{t("title")}
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={dismiss}
|
||||||
|
className="shrink-0 inline-flex items-center gap-1.5 rounded-md border border-accent/40 bg-accent/10 px-2.5 py-1 text-xs font-medium text-accent hover:bg-accent/20 hover:border-accent/60 transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className="h-3.5 w-3.5"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={2}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M4.5 12.75l6 6 9-13.5"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
{t("dismiss")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-text-secondary mb-4 leading-relaxed">
|
||||||
|
{t("description")}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{!ready && (
|
||||||
|
<p className="text-xs text-amber-300 bg-amber-500/10 border border-amber-500/20 rounded-lg px-3 py-2 mb-4 leading-relaxed">
|
||||||
|
{t("notReadyNote")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
{channels.map((c) => (
|
||||||
|
<div
|
||||||
|
key={c}
|
||||||
|
className="rounded-lg border border-border bg-surface-1 p-3"
|
||||||
|
>
|
||||||
|
<div className="text-sm font-medium text-text-primary mb-1.5">
|
||||||
|
{CHANNEL_NAMES[c]}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{c === "threema" ? (
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="bg-white p-1.5 rounded-md shrink-0">
|
||||||
|
{/* Shared gateway QR — identical for every tenant, so
|
||||||
|
it can render before/after provisioning alike.
|
||||||
|
eslint-disable-next-line @next/next/no-img-element */}
|
||||||
|
<img
|
||||||
|
src={THREEMA_GATEWAY.qrCodePath}
|
||||||
|
alt={`QR code for ${THREEMA_GATEWAY.displayName}`}
|
||||||
|
width={88}
|
||||||
|
height={88}
|
||||||
|
style={{ display: "block" }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-text-secondary leading-relaxed">
|
||||||
|
<div className="mb-1.5">
|
||||||
|
<span className="text-text-muted">
|
||||||
|
{t("threemaBotIdLabel")}:{" "}
|
||||||
|
</span>
|
||||||
|
<span className="font-mono text-sm text-accent">
|
||||||
|
{THREEMA_GATEWAY.displayName}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="whitespace-pre-line">{t("threemaSteps")}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-xs text-text-secondary leading-relaxed whitespace-pre-line">
|
||||||
|
{t(CHANNEL_STEPS_KEY[c])}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
58
src/components/ui/button.tsx
Normal file
58
src/components/ui/button.tsx
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import { forwardRef } from "react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared button primitive.
|
||||||
|
*
|
||||||
|
* Why this exists
|
||||||
|
* ---------------
|
||||||
|
* The accent fill (#00d4aa) is bright; white text on it measures ~1.9:1,
|
||||||
|
* which fails WCAG even for large/UI text. Dark text (surface-0) on the
|
||||||
|
* same accent is ~10:1. The codebase had ~40 hand-rolled accent buttons,
|
||||||
|
* most using `text-white`. This component centralises the correct token
|
||||||
|
* (`text-surface-0` on accent) so the contrast can't drift again — reach
|
||||||
|
* for `<Button>` instead of re-deriving the class string.
|
||||||
|
*/
|
||||||
|
|
||||||
|
type Variant = "primary" | "secondary" | "ghost" | "danger";
|
||||||
|
type Size = "sm" | "md";
|
||||||
|
|
||||||
|
const BASE =
|
||||||
|
"inline-flex items-center justify-center gap-1.5 font-medium rounded-lg " +
|
||||||
|
"transition-colors cursor-pointer focus:outline-none focus-visible:ring-2 " +
|
||||||
|
"focus-visible:ring-accent/50 disabled:opacity-50 disabled:cursor-not-allowed";
|
||||||
|
|
||||||
|
const VARIANTS: Record<Variant, string> = {
|
||||||
|
// surface-0 (dark) text — the contrast-correct pairing for the accent.
|
||||||
|
primary: "bg-accent text-surface-0 hover:bg-accent-dim shadow-sm shadow-accent/20",
|
||||||
|
secondary:
|
||||||
|
"bg-surface-2 text-text-primary border border-border hover:bg-surface-3 hover:border-border-active",
|
||||||
|
ghost: "text-text-secondary hover:text-text-primary hover:bg-surface-2",
|
||||||
|
danger: "bg-error text-surface-0 hover:opacity-90",
|
||||||
|
};
|
||||||
|
|
||||||
|
const SIZES: Record<Size, string> = {
|
||||||
|
sm: "text-xs px-3 py-1.5",
|
||||||
|
md: "text-sm px-4 py-2",
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface ButtonProps
|
||||||
|
extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||||
|
variant?: Variant;
|
||||||
|
size?: Size;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
|
||||||
|
function Button(
|
||||||
|
{ variant = "primary", size = "md", className = "", type = "button", ...rest },
|
||||||
|
ref
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
ref={ref}
|
||||||
|
type={type}
|
||||||
|
className={`${BASE} ${VARIANTS[variant]} ${SIZES[size]} ${className}`}
|
||||||
|
{...rest}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
83
src/components/ui/logo.tsx
Normal file
83
src/components/ui/logo.tsx
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
/**
|
||||||
|
* PieCed honeycomb mark.
|
||||||
|
*
|
||||||
|
* Six flat-top hexagons: H1/H4 solid, H2/H3 outline, H5/H6 partial.
|
||||||
|
* All strokes/fills use `currentColor` so the mark inherits its colour
|
||||||
|
* from the surrounding text colour (e.g. `text-accent`) and adapts to
|
||||||
|
* hover/theme without editing the SVG. Original brand emerald is
|
||||||
|
* #10B981, which the accent token matches.
|
||||||
|
*
|
||||||
|
* viewBox is portrait (70×106); size it by height and let width follow
|
||||||
|
* (`h-7 w-auto`).
|
||||||
|
*/
|
||||||
|
export function Logo({
|
||||||
|
className,
|
||||||
|
title = "PieCed IT",
|
||||||
|
}: {
|
||||||
|
className?: string;
|
||||||
|
title?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 70 106"
|
||||||
|
className={className}
|
||||||
|
role="img"
|
||||||
|
aria-label={title}
|
||||||
|
fill="none"
|
||||||
|
>
|
||||||
|
<title>{title}</title>
|
||||||
|
{/* H1 — solid, top-left */}
|
||||||
|
<polygon
|
||||||
|
points="38.5,22.69 31.5,10.566 17.5,10.566 10.5,22.69 17.5,34.814 31.5,34.814"
|
||||||
|
fill="currentColor"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.6"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
{/* H2 — outline, upper-right */}
|
||||||
|
<polygon
|
||||||
|
points="59.5,34.814 52.5,22.69 38.5,22.69 31.5,34.814 38.5,46.938 52.5,46.938"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.8"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
{/* H3 — outline, mid-left */}
|
||||||
|
<polygon
|
||||||
|
points="38.5,46.938 31.5,34.814 17.5,34.814 10.5,46.938 17.5,59.062 31.5,59.062"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.8"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
{/* H4 — solid, mid-right */}
|
||||||
|
<polygon
|
||||||
|
points="59.5,59.062 52.5,46.938 38.5,46.938 31.5,59.062 38.5,71.186 52.5,71.186"
|
||||||
|
fill="currentColor"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.6"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
{/* H5 — partial, lower-left */}
|
||||||
|
<polyline
|
||||||
|
points="31.5,83.31 38.5,71.186 31.5,59.062 17.5,59.062 10.5,71.186"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.8"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
{/* H6 — partial, lower-right */}
|
||||||
|
<polyline
|
||||||
|
points="59.5,83.31 52.5,71.186 38.5,71.186 31.5,83.31 38.5,95.434"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.8"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -16,6 +16,9 @@ interface Props {
|
|||||||
ariaLabel?: string;
|
ariaLabel?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const FOCUSABLE =
|
||||||
|
'a[href],button:not([disabled]),textarea:not([disabled]),input:not([disabled]),select:not([disabled]),[tabindex]:not([tabindex="-1"])';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Portal-based modal.
|
* Portal-based modal.
|
||||||
*
|
*
|
||||||
@@ -25,45 +28,86 @@ interface Props {
|
|||||||
* ancestor's containing block, not the viewport, when ANY ancestor
|
* ancestor's containing block, not the viewport, when ANY ancestor
|
||||||
* has a `transform`, `perspective`, or `filter` applied. Our
|
* has a `transform`, `perspective`, or `filter` applied. Our
|
||||||
* `animate-in` utility sets `transform: translateY(0)` on a lot of
|
* `animate-in` utility sets `transform: translateY(0)` on a lot of
|
||||||
* dashboard/tenant-detail containers (because of the fade-up
|
* dashboard/tenant-detail containers, which broke modals rendered as
|
||||||
* animation, which uses `animation-fill-mode: both` to keep the
|
* in-place children — they centred to the panel they lived in, not to
|
||||||
* transform on after the animation finishes). That broke modals
|
* the page. Rendering at `document.body` via `createPortal` escapes
|
||||||
* rendered as in-place children — they centred to the panel they
|
* every containing-block ancestor and gives us true viewport coords.
|
||||||
* lived in, not to the page.
|
|
||||||
*
|
*
|
||||||
* Rendering at `document.body` via `createPortal` escapes every
|
* UX / a11y details
|
||||||
* containing-block ancestor and gives us true viewport coordinates.
|
* -----------------
|
||||||
*
|
* - Backdrop click triggers `onClose` (only when the click target IS
|
||||||
* UX details
|
* the backdrop, not the panel inside).
|
||||||
* ----------
|
* - Escape triggers `onClose`.
|
||||||
* - Backdrop click triggers `onClose`. (Bubbling check: only fires
|
* - `body` overflow is locked while open so background content doesn't
|
||||||
* when the click target IS the backdrop, not the panel inside.)
|
* scroll behind the modal.
|
||||||
* - Escape key triggers `onClose`. Standard modal expectation.
|
* - Focus is moved into the panel on open, trapped within it while open
|
||||||
* - `body` overflow is locked while open so background content
|
* (Tab / Shift+Tab cycle), and restored to the previously focused
|
||||||
* doesn't scroll behind the modal.
|
* element on close — so keyboard and screen-reader users can't tab
|
||||||
* - Renders nothing on first paint server-side, then mounts on
|
* out to the inert page behind the dialog.
|
||||||
* client. `useEffect` gating ensures `document.body` is available;
|
|
||||||
* without it Next.js SSR would throw on `document` reference.
|
|
||||||
*/
|
*/
|
||||||
export function Modal({ open, onClose, children, ariaLabel }: Props) {
|
export function Modal({ open, onClose, children, ariaLabel }: Props) {
|
||||||
const closeRef = useRef(onClose);
|
const closeRef = useRef(onClose);
|
||||||
closeRef.current = onClose;
|
closeRef.current = onClose;
|
||||||
|
const panelRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
|
|
||||||
// Lock background scroll. Restore on unmount/close.
|
// Remember what had focus so we can restore it on close.
|
||||||
|
const previouslyFocused = document.activeElement as HTMLElement | null;
|
||||||
|
|
||||||
|
// Lock background scroll.
|
||||||
const previousOverflow = document.body.style.overflow;
|
const previousOverflow = document.body.style.overflow;
|
||||||
document.body.style.overflow = "hidden";
|
document.body.style.overflow = "hidden";
|
||||||
|
|
||||||
|
// Move focus into the dialog — first focusable element, else the
|
||||||
|
// panel itself (it carries tabIndex={-1}).
|
||||||
|
const panel = panelRef.current;
|
||||||
|
const focusables = panel
|
||||||
|
? Array.from(panel.querySelectorAll<HTMLElement>(FOCUSABLE))
|
||||||
|
: [];
|
||||||
|
(focusables[0] ?? panel)?.focus();
|
||||||
|
|
||||||
const onKey = (e: KeyboardEvent) => {
|
const onKey = (e: KeyboardEvent) => {
|
||||||
if (e.key === "Escape") closeRef.current();
|
if (e.key === "Escape") {
|
||||||
|
closeRef.current();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.key !== "Tab" || !panel) return;
|
||||||
|
|
||||||
|
// Re-query each time — modal content can change between tabs.
|
||||||
|
const items = Array.from(
|
||||||
|
panel.querySelectorAll<HTMLElement>(FOCUSABLE)
|
||||||
|
).filter((el) => el.offsetParent !== null || el === document.activeElement);
|
||||||
|
if (items.length === 0) {
|
||||||
|
e.preventDefault();
|
||||||
|
panel.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const first = items[0];
|
||||||
|
const last = items[items.length - 1];
|
||||||
|
const active = document.activeElement;
|
||||||
|
|
||||||
|
if (e.shiftKey) {
|
||||||
|
if (active === first || active === panel) {
|
||||||
|
e.preventDefault();
|
||||||
|
last.focus();
|
||||||
|
}
|
||||||
|
} else if (active === last) {
|
||||||
|
e.preventDefault();
|
||||||
|
first.focus();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
window.addEventListener("keydown", onKey);
|
window.addEventListener("keydown", onKey);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
document.body.style.overflow = previousOverflow;
|
document.body.style.overflow = previousOverflow;
|
||||||
window.removeEventListener("keydown", onKey);
|
window.removeEventListener("keydown", onKey);
|
||||||
|
// Restore focus to the trigger (if it's still in the document).
|
||||||
|
if (previouslyFocused && document.contains(previouslyFocused)) {
|
||||||
|
previouslyFocused.focus();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}, [open]);
|
}, [open]);
|
||||||
|
|
||||||
@@ -72,15 +116,19 @@ export function Modal({ open, onClose, children, ariaLabel }: Props) {
|
|||||||
|
|
||||||
return createPortal(
|
return createPortal(
|
||||||
<div
|
<div
|
||||||
role="dialog"
|
|
||||||
aria-modal="true"
|
|
||||||
aria-label={ariaLabel}
|
|
||||||
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm"
|
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
if (e.target === e.currentTarget) onClose();
|
if (e.target === e.currentTarget) onClose();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="bg-surface-1 border border-border rounded-xl p-6 max-w-md w-full max-h-[90vh] overflow-y-auto">
|
<div
|
||||||
|
ref={panelRef}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
tabIndex={-1}
|
||||||
|
className="bg-surface-1 border border-border rounded-xl p-6 max-w-md w-full max-h-[90vh] overflow-y-auto focus:outline-none"
|
||||||
|
>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
</div>,
|
</div>,
|
||||||
|
|||||||
@@ -49,7 +49,31 @@ export const authConfig: NextAuthConfig = {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
callbacks: {
|
callbacks: {
|
||||||
async jwt({ token, account, profile }) {
|
async jwt({ token, account, profile, trigger, session }) {
|
||||||
|
// Phase 6 fix5: client-side `useSession().update({ name })` calls
|
||||||
|
// route through this branch. We trust the new value because the
|
||||||
|
// PUT /api/settings/profile route already wrote it to ZITADEL
|
||||||
|
// and re-fetched the canonical displayName before returning.
|
||||||
|
// The session callback reads token.name directly (see below) so
|
||||||
|
// the update propagates without depending on auth.js's implicit
|
||||||
|
// token→session.user mapping, which is flaky for the name claim
|
||||||
|
// in the v5 OIDC provider configuration.
|
||||||
|
//
|
||||||
|
// Defensive: only the `name` field is accepted from the update
|
||||||
|
// payload, even if the client passes additional keys. Other
|
||||||
|
// identity claims (orgId, roles, sub) come from ZITADEL at
|
||||||
|
// sign-in time and are not user-mutable from a settings page.
|
||||||
|
//
|
||||||
|
// Returns a NEW token object (spread) rather than mutating, so
|
||||||
|
// there is no ambiguity for auth.js about whether the token
|
||||||
|
// changed and needs re-encoding into the session cookie.
|
||||||
|
if (trigger === "update" && session) {
|
||||||
|
const update = session as { name?: unknown };
|
||||||
|
if (typeof update.name === "string") {
|
||||||
|
return { ...token, name: update.name };
|
||||||
|
}
|
||||||
|
return token;
|
||||||
|
}
|
||||||
if (account && profile) {
|
if (account && profile) {
|
||||||
const claims = profile as unknown as ZitadelClaims;
|
const claims = profile as unknown as ZitadelClaims;
|
||||||
token.orgId = claims["urn:zitadel:iam:user:resourceowner:id"];
|
token.orgId = claims["urn:zitadel:iam:user:resourceowner:id"];
|
||||||
@@ -58,6 +82,19 @@ export const authConfig: NextAuthConfig = {
|
|||||||
claims["urn:zitadel:iam:org:project:roles"]
|
claims["urn:zitadel:iam:org:project:roles"]
|
||||||
);
|
);
|
||||||
token.accessToken = account.access_token;
|
token.accessToken = account.access_token;
|
||||||
|
// Phase 6 fix5: explicitly pin the standard name/email claims
|
||||||
|
// onto the token from the OIDC profile. Previously these came
|
||||||
|
// through auth.js's implicit mapping, which works on first
|
||||||
|
// sign-in but isn't reliable after update() — once the update
|
||||||
|
// path overrides token.name, the read-back path needs token
|
||||||
|
// to be the authoritative source. Setting them explicitly
|
||||||
|
// here keeps sign-in and update on the same path.
|
||||||
|
if (typeof profile.name === "string") {
|
||||||
|
token.name = profile.name;
|
||||||
|
}
|
||||||
|
if (typeof profile.email === "string") {
|
||||||
|
token.email = profile.email;
|
||||||
|
}
|
||||||
// Pin token.sub to the OIDC subject. Auth.js v5 otherwise puts a
|
// Pin token.sub to the OIDC subject. Auth.js v5 otherwise puts a
|
||||||
// freshly generated UUID in token.sub on initial sign-in,
|
// freshly generated UUID in token.sub on initial sign-in,
|
||||||
// ignoring what profile() returns for `id`. That UUID then
|
// ignoring what profile() returns for `id`. That UUID then
|
||||||
@@ -80,10 +117,19 @@ export const authConfig: NextAuthConfig = {
|
|||||||
async session({ session, token }) {
|
async session({ session, token }) {
|
||||||
const roles = (token.roles as Role[]) ?? [];
|
const roles = (token.roles as Role[]) ?? [];
|
||||||
const orgName = (token.orgName as string) ?? "";
|
const orgName = (token.orgName as string) ?? "";
|
||||||
|
// Phase 6 fix5: read name and email directly from the token.
|
||||||
|
// Previously this code relied on `session.user?.name`, expecting
|
||||||
|
// auth.js to map token.name → session.user.name automatically.
|
||||||
|
// That mapping is brittle: it works on first sign-in (because
|
||||||
|
// OIDC profile() populates session.user) but not after update()
|
||||||
|
// overrides token.name. Reading from token is the canonical
|
||||||
|
// path regardless of how the token was last written.
|
||||||
|
const tokenName = (token.name as string | undefined) ?? "";
|
||||||
|
const tokenEmail = (token.email as string | undefined) ?? "";
|
||||||
const sessionUser: SessionUser = {
|
const sessionUser: SessionUser = {
|
||||||
id: token.sub!,
|
id: token.sub!,
|
||||||
name: session.user?.name ?? "",
|
name: tokenName || session.user?.name || "",
|
||||||
email: session.user?.email ?? "",
|
email: tokenEmail || session.user?.email || "",
|
||||||
orgId: token.orgId as string,
|
orgId: token.orgId as string,
|
||||||
orgName,
|
orgName,
|
||||||
roles,
|
roles,
|
||||||
@@ -96,6 +142,14 @@ export const authConfig: NextAuthConfig = {
|
|||||||
isPersonal: isPersonalOrgName(orgName),
|
isPersonal: isPersonalOrgName(orgName),
|
||||||
};
|
};
|
||||||
(session as any).platformUser = sessionUser;
|
(session as any).platformUser = sessionUser;
|
||||||
|
// Also overwrite session.user so any client-side code that uses
|
||||||
|
// the standard NextAuth shape (session.user.name) sees the new
|
||||||
|
// value. Pre-fix5 code paths read from session.user.name; this
|
||||||
|
// keeps them working without per-component changes.
|
||||||
|
if (session.user) {
|
||||||
|
session.user.name = sessionUser.name;
|
||||||
|
session.user.email = sessionUser.email;
|
||||||
|
}
|
||||||
return session;
|
return session;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -153,5 +153,21 @@ export function formatLineDescription(
|
|||||||
}[L];
|
}[L];
|
||||||
return reason ? `${base}: ${reason}` : base;
|
return reason ? `${base}: ${reason}` : base;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Phase 8: custom invoice lines. The description is what the
|
||||||
|
// admin typed in the editor — return it verbatim (no template,
|
||||||
|
// no locale-specific formatting). billing.ts persists the
|
||||||
|
// already-trimmed admin input into invoice_lines.description.
|
||||||
|
case "custom_line": {
|
||||||
|
const dRaw = (m as Record<string, unknown>)["description"];
|
||||||
|
if (typeof dRaw === "string" && dRaw.trim().length > 0) return dRaw;
|
||||||
|
// Fallback: the description column on the row itself. The
|
||||||
|
// PDF renderer hands us the line so it can read it directly
|
||||||
|
// — see how billing-pdf invokes formatLineDescription.
|
||||||
|
const onRow = (line as unknown as { description?: string }).description;
|
||||||
|
return onRow && onRow.trim().length > 0
|
||||||
|
? onRow
|
||||||
|
: { de: "Leistung", en: "Service", fr: "Service", it: "Servizio" }[L];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,44 +31,18 @@ import {
|
|||||||
Text,
|
Text,
|
||||||
View,
|
View,
|
||||||
StyleSheet,
|
StyleSheet,
|
||||||
Svg,
|
|
||||||
Polygon,
|
|
||||||
Polyline,
|
|
||||||
renderToBuffer,
|
renderToBuffer,
|
||||||
} from "@react-pdf/renderer";
|
} from "@react-pdf/renderer";
|
||||||
import type { Invoice, InvoiceLine, InvoiceLineKind } from "@/types";
|
import type { Invoice, InvoiceLine, InvoiceLineKind } from "@/types";
|
||||||
|
import { BRAND, Logo } from "./pdf-brand";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Brand constants — edit here to tweak look without touching layout
|
// Brand: imported from lib/pdf-brand. Edit there to change issuer
|
||||||
|
// info, colours, or the logo. Both billing-pdf.tsx and credit-note-pdf.tsx
|
||||||
|
// share the same source of truth so a brand change applies to every
|
||||||
|
// PDF the portal produces.
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
const BRAND = {
|
|
||||||
name: "PieCed IT",
|
|
||||||
// Primary emerald — matches the logo SVG fill (#10B981).
|
|
||||||
primary: "#10B981",
|
|
||||||
// Slightly darker emerald for headings.
|
|
||||||
primaryDark: "#0a8060",
|
|
||||||
textColor: "#1a1a1a",
|
|
||||||
mutedColor: "#666",
|
|
||||||
borderColor: "#d4d4d4",
|
|
||||||
// Issuer block — change these to your real legal info.
|
|
||||||
issuer: {
|
|
||||||
legalName: "PieCed IT",
|
|
||||||
addressLine1: "Cedric Mosimann",
|
|
||||||
addressLine2: "[Strasse Nr.]",
|
|
||||||
postalCity: "[PLZ] Basel",
|
|
||||||
country: "Switzerland",
|
|
||||||
email: "billing@pieced.ch",
|
|
||||||
web: "pieced.ch",
|
|
||||||
// Show "MWST-Nr. ..." on PDF when set.
|
|
||||||
vatNumber: null as string | null,
|
|
||||||
// Bank instructions — Phase 7 replaces with QR-bill.
|
|
||||||
bankName: "[Bank name]",
|
|
||||||
bankIban: "[CHxx xxxx xxxx xxxx xxxx x]",
|
|
||||||
bankBic: "[BIC]",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Localized strings
|
// Localized strings
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -80,6 +54,11 @@ interface PdfStrings {
|
|||||||
dueDate: string;
|
dueDate: string;
|
||||||
period: string;
|
period: string;
|
||||||
billTo: string;
|
billTo: string;
|
||||||
|
// Phase 6 fix: prefix shown before the optional contact-person
|
||||||
|
// name on the bill-to block. "z.Hd." (DE) / "Attn:" (EN) /
|
||||||
|
// "À l'attention de" (FR) / "c.a." (IT). Empty/unused when the
|
||||||
|
// invoice has no contactName on its snapshot.
|
||||||
|
attentionPrefix: string;
|
||||||
description: string;
|
description: string;
|
||||||
quantity: string;
|
quantity: string;
|
||||||
unitPrice: string;
|
unitPrice: string;
|
||||||
@@ -107,6 +86,7 @@ const MESSAGES: Record<string, PdfStrings> = {
|
|||||||
dueDate: "Zahlbar bis",
|
dueDate: "Zahlbar bis",
|
||||||
period: "Abrechnungsperiode",
|
period: "Abrechnungsperiode",
|
||||||
billTo: "Rechnungsempfänger",
|
billTo: "Rechnungsempfänger",
|
||||||
|
attentionPrefix: "z.Hd.",
|
||||||
description: "Beschreibung",
|
description: "Beschreibung",
|
||||||
quantity: "Menge",
|
quantity: "Menge",
|
||||||
unitPrice: "Einzelpreis",
|
unitPrice: "Einzelpreis",
|
||||||
@@ -127,6 +107,7 @@ const MESSAGES: Record<string, PdfStrings> = {
|
|||||||
skill_usage: "Skill-Nutzung",
|
skill_usage: "Skill-Nutzung",
|
||||||
skill_setup: "Einrichtungsgebühr Skill",
|
skill_setup: "Einrichtungsgebühr Skill",
|
||||||
adjustment: "Anpassung",
|
adjustment: "Anpassung",
|
||||||
|
custom_line: "Leistungen",
|
||||||
},
|
},
|
||||||
reverseCharge:
|
reverseCharge:
|
||||||
"Steuerschuldnerschaft des Leistungsempfängers (Reverse Charge).",
|
"Steuerschuldnerschaft des Leistungsempfängers (Reverse Charge).",
|
||||||
@@ -139,6 +120,7 @@ const MESSAGES: Record<string, PdfStrings> = {
|
|||||||
dueDate: "Due date",
|
dueDate: "Due date",
|
||||||
period: "Billing period",
|
period: "Billing period",
|
||||||
billTo: "Bill to",
|
billTo: "Bill to",
|
||||||
|
attentionPrefix: "Attn:",
|
||||||
description: "Description",
|
description: "Description",
|
||||||
quantity: "Qty",
|
quantity: "Qty",
|
||||||
unitPrice: "Unit price",
|
unitPrice: "Unit price",
|
||||||
@@ -159,6 +141,7 @@ const MESSAGES: Record<string, PdfStrings> = {
|
|||||||
skill_usage: "Skill usage",
|
skill_usage: "Skill usage",
|
||||||
skill_setup: "Skill setup fee",
|
skill_setup: "Skill setup fee",
|
||||||
adjustment: "Adjustment",
|
adjustment: "Adjustment",
|
||||||
|
custom_line: "Services",
|
||||||
},
|
},
|
||||||
reverseCharge:
|
reverseCharge:
|
||||||
"Reverse charge — VAT to be accounted for by the recipient.",
|
"Reverse charge — VAT to be accounted for by the recipient.",
|
||||||
@@ -171,6 +154,7 @@ const MESSAGES: Record<string, PdfStrings> = {
|
|||||||
dueDate: "Échéance",
|
dueDate: "Échéance",
|
||||||
period: "Période de facturation",
|
period: "Période de facturation",
|
||||||
billTo: "Destinataire",
|
billTo: "Destinataire",
|
||||||
|
attentionPrefix: "À l'attention de",
|
||||||
description: "Description",
|
description: "Description",
|
||||||
quantity: "Qté",
|
quantity: "Qté",
|
||||||
unitPrice: "Prix unitaire",
|
unitPrice: "Prix unitaire",
|
||||||
@@ -191,6 +175,7 @@ const MESSAGES: Record<string, PdfStrings> = {
|
|||||||
skill_usage: "Utilisation Skill",
|
skill_usage: "Utilisation Skill",
|
||||||
skill_setup: "Frais de configuration skill",
|
skill_setup: "Frais de configuration skill",
|
||||||
adjustment: "Ajustement",
|
adjustment: "Ajustement",
|
||||||
|
custom_line: "Services",
|
||||||
},
|
},
|
||||||
reverseCharge:
|
reverseCharge:
|
||||||
"Autoliquidation — TVA à acquitter par le destinataire.",
|
"Autoliquidation — TVA à acquitter par le destinataire.",
|
||||||
@@ -203,6 +188,7 @@ const MESSAGES: Record<string, PdfStrings> = {
|
|||||||
dueDate: "Scadenza",
|
dueDate: "Scadenza",
|
||||||
period: "Periodo di fatturazione",
|
period: "Periodo di fatturazione",
|
||||||
billTo: "Destinatario",
|
billTo: "Destinatario",
|
||||||
|
attentionPrefix: "c.a.",
|
||||||
description: "Descrizione",
|
description: "Descrizione",
|
||||||
quantity: "Qtà",
|
quantity: "Qtà",
|
||||||
unitPrice: "Prezzo unitario",
|
unitPrice: "Prezzo unitario",
|
||||||
@@ -223,6 +209,7 @@ const MESSAGES: Record<string, PdfStrings> = {
|
|||||||
skill_usage: "Utilizzo Skill",
|
skill_usage: "Utilizzo Skill",
|
||||||
skill_setup: "Spese di attivazione skill",
|
skill_setup: "Spese di attivazione skill",
|
||||||
adjustment: "Rettifica",
|
adjustment: "Rettifica",
|
||||||
|
custom_line: "Servizi",
|
||||||
},
|
},
|
||||||
reverseCharge:
|
reverseCharge:
|
||||||
"Inversione contabile — IVA a carico del destinatario.",
|
"Inversione contabile — IVA a carico del destinatario.",
|
||||||
@@ -349,62 +336,6 @@ const styles = StyleSheet.create({
|
|||||||
});
|
});
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Logo — inlined SVG primitives
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* PieCed honeycomb logo. Re-renders the same 6-hex glyph as the
|
|
||||||
* portal's `public/pieced-logo.svg` using React-PDF's SVG support.
|
|
||||||
* Width/height are independent of the original viewBox so we can
|
|
||||||
* scale it without losing stroke quality.
|
|
||||||
*/
|
|
||||||
const Logo = ({ size = 60 }: { size?: number }) => (
|
|
||||||
<Svg width={size} height={size * (106 / 70)} viewBox="0 0 70 106">
|
|
||||||
{/* H1 solid */}
|
|
||||||
<Polygon
|
|
||||||
points="38.5,22.69 31.5,10.566 17.5,10.566 10.5,22.69 17.5,34.814 31.5,34.814"
|
|
||||||
fill="#10B981"
|
|
||||||
stroke="#10B981"
|
|
||||||
strokeWidth={1.6}
|
|
||||||
/>
|
|
||||||
{/* H2 outline */}
|
|
||||||
<Polygon
|
|
||||||
points="59.5,34.814 52.5,22.69 38.5,22.69 31.5,34.814 38.5,46.938 52.5,46.938"
|
|
||||||
fill="none"
|
|
||||||
stroke="#10B981"
|
|
||||||
strokeWidth={1.8}
|
|
||||||
/>
|
|
||||||
{/* H3 outline */}
|
|
||||||
<Polygon
|
|
||||||
points="38.5,46.938 31.5,34.814 17.5,34.814 10.5,46.938 17.5,59.062 31.5,59.062"
|
|
||||||
fill="none"
|
|
||||||
stroke="#10B981"
|
|
||||||
strokeWidth={1.8}
|
|
||||||
/>
|
|
||||||
{/* H4 solid */}
|
|
||||||
<Polygon
|
|
||||||
points="59.5,59.062 52.5,46.938 38.5,46.938 31.5,59.062 38.5,71.186 52.5,71.186"
|
|
||||||
fill="#10B981"
|
|
||||||
stroke="#10B981"
|
|
||||||
strokeWidth={1.6}
|
|
||||||
/>
|
|
||||||
{/* H5 partial */}
|
|
||||||
<Polyline
|
|
||||||
points="31.5,83.31 38.5,71.186 31.5,59.062 17.5,59.062 10.5,71.186"
|
|
||||||
fill="none"
|
|
||||||
stroke="#10B981"
|
|
||||||
strokeWidth={1.8}
|
|
||||||
/>
|
|
||||||
{/* H6 partial */}
|
|
||||||
<Polyline
|
|
||||||
points="59.5,83.31 52.5,71.186 38.5,71.186 31.5,83.31 38.5,95.434"
|
|
||||||
fill="none"
|
|
||||||
stroke="#10B981"
|
|
||||||
strokeWidth={1.8}
|
|
||||||
/>
|
|
||||||
</Svg>
|
|
||||||
);
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Helpers
|
// Helpers
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -508,11 +439,18 @@ const InvoicePdf: React.FC<InvoicePdfProps> = ({ invoice, lines }) => {
|
|||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<View style={styles.metaCol}>
|
<View style={styles.metaCol}>
|
||||||
<Text style={styles.metaLabel}>{s.period}</Text>
|
{/* Phase 8: skip the billing-period block on custom
|
||||||
<Text style={styles.metaValue}>
|
invoices (which aren't tied to a period). Due date
|
||||||
{fmtDate(invoice.periodStart, invoice.locale)} —{" "}
|
still renders. */}
|
||||||
{fmtDate(invoice.periodEnd, invoice.locale)}
|
{invoice.periodStart && invoice.periodEnd && (
|
||||||
</Text>
|
<>
|
||||||
|
<Text style={styles.metaLabel}>{s.period}</Text>
|
||||||
|
<Text style={styles.metaValue}>
|
||||||
|
{fmtDate(invoice.periodStart, invoice.locale)} —{" "}
|
||||||
|
{fmtDate(invoice.periodEnd, invoice.locale)}
|
||||||
|
</Text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<Text style={styles.metaLabel}>{s.dueDate}</Text>
|
<Text style={styles.metaLabel}>{s.dueDate}</Text>
|
||||||
<Text style={styles.metaValue}>
|
<Text style={styles.metaValue}>
|
||||||
{fmtDate(invoice.dueAt, invoice.locale)}
|
{fmtDate(invoice.dueAt, invoice.locale)}
|
||||||
@@ -524,6 +462,15 @@ const InvoicePdf: React.FC<InvoicePdfProps> = ({ invoice, lines }) => {
|
|||||||
<View style={styles.billToBlock}>
|
<View style={styles.billToBlock}>
|
||||||
<Text style={styles.billToLabel}>{s.billTo}</Text>
|
<Text style={styles.billToLabel}>{s.billTo}</Text>
|
||||||
<Text style={styles.billToName}>{snap.companyName}</Text>
|
<Text style={styles.billToName}>{snap.companyName}</Text>
|
||||||
|
{/* Phase 6 fix: optional "z.Hd." / "Attn:" line for routing
|
||||||
|
the printed invoice internally at the customer. Prints
|
||||||
|
between the company name and street address, in the
|
||||||
|
invoice's locale (frozen at issue time). */}
|
||||||
|
{snap.contactName && (
|
||||||
|
<Text>
|
||||||
|
{s.attentionPrefix} {snap.contactName}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
<Text>{snap.streetAddress}</Text>
|
<Text>{snap.streetAddress}</Text>
|
||||||
<Text>
|
<Text>
|
||||||
{snap.postalCode} {snap.city}
|
{snap.postalCode} {snap.city}
|
||||||
|
|||||||
1109
src/lib/billing.ts
1109
src/lib/billing.ts
File diff suppressed because it is too large
Load Diff
467
src/lib/credit-note-pdf.tsx
Normal file
467
src/lib/credit-note-pdf.tsx
Normal file
@@ -0,0 +1,467 @@
|
|||||||
|
/**
|
||||||
|
* Credit-note PDF rendering via @react-pdf/renderer.
|
||||||
|
*
|
||||||
|
* Phase 7. Renders the same brand identity as the invoice PDF
|
||||||
|
* (hexagon logo, issuer block, layout) with one accent override:
|
||||||
|
* red instead of emerald. That difference is enough to make voids
|
||||||
|
* and refunds visually unmistakable from an invoice at a glance,
|
||||||
|
* while keeping every other element (logo shape, fonts, structure,
|
||||||
|
* issuer info, page footer) identical so the document family reads
|
||||||
|
* as one brand.
|
||||||
|
*
|
||||||
|
* Brand + Logo come from lib/pdf-brand. Edit there to change
|
||||||
|
* issuer info, colours, or the logo glyph — both invoice and
|
||||||
|
* credit-note PDFs pick the changes up.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import {
|
||||||
|
Document,
|
||||||
|
Page,
|
||||||
|
Text,
|
||||||
|
View,
|
||||||
|
StyleSheet,
|
||||||
|
renderToBuffer,
|
||||||
|
} from "@react-pdf/renderer";
|
||||||
|
import type { CreditNote, Invoice } from "@/types";
|
||||||
|
import { BRAND, Logo } from "./pdf-brand";
|
||||||
|
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Localized strings
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface CreditNoteStrings {
|
||||||
|
creditNote: string;
|
||||||
|
creditNoteNumber: string;
|
||||||
|
issueDate: string;
|
||||||
|
billTo: string;
|
||||||
|
attentionPrefix: string;
|
||||||
|
referenceInvoice: string;
|
||||||
|
reason: string;
|
||||||
|
voidLineLabel: string;
|
||||||
|
refundLineLabel: string;
|
||||||
|
subtotal: string;
|
||||||
|
vatLabel: string;
|
||||||
|
totalCredited: string;
|
||||||
|
footerVoidNote: string;
|
||||||
|
footerRefundNote: string;
|
||||||
|
vatNoteSwiss: string;
|
||||||
|
vatNoteReverseCharge: string;
|
||||||
|
vatNoteOutOfScope: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MESSAGES: Record<string, CreditNoteStrings> = {
|
||||||
|
de: {
|
||||||
|
creditNote: "Gutschrift",
|
||||||
|
creditNoteNumber: "Gutschrift-Nr.",
|
||||||
|
issueDate: "Ausstellungsdatum",
|
||||||
|
billTo: "Empfänger",
|
||||||
|
attentionPrefix: "z.Hd.",
|
||||||
|
referenceInvoice: "Bezug Rechnung",
|
||||||
|
reason: "Begründung",
|
||||||
|
voidLineLabel: "Stornierung Rechnung {number}",
|
||||||
|
refundLineLabel: "Rückerstattung Rechnung {number}",
|
||||||
|
subtotal: "Zwischensumme",
|
||||||
|
vatLabel: "MWST",
|
||||||
|
totalCredited: "Gesamtbetrag Gutschrift",
|
||||||
|
footerVoidNote:
|
||||||
|
"Diese Gutschrift storniert die oben referenzierte Rechnung. Ein Zahlungsausgleich ist nicht erforderlich.",
|
||||||
|
footerRefundNote:
|
||||||
|
"Diese Gutschrift dokumentiert die Rückerstattung des oben genannten Betrags. Die Auszahlung erfolgt über den ursprünglichen Zahlungsweg.",
|
||||||
|
vatNoteSwiss:
|
||||||
|
"MWST gemäss schweizerischem Mehrwertsteuergesetz (MWSTG).",
|
||||||
|
vatNoteReverseCharge:
|
||||||
|
"Reverse Charge: Steuerschuldnerschaft des Leistungsempfängers nach Art. 196 EU-MwStSyst-RL bzw. nationaler Umsetzung.",
|
||||||
|
vatNoteOutOfScope:
|
||||||
|
"Leistung ausserhalb des Geltungsbereichs der schweizerischen MWST.",
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
creditNote: "Credit note",
|
||||||
|
creditNoteNumber: "Credit note no.",
|
||||||
|
issueDate: "Issue date",
|
||||||
|
billTo: "Bill to",
|
||||||
|
attentionPrefix: "Attn:",
|
||||||
|
referenceInvoice: "Reference invoice",
|
||||||
|
reason: "Reason",
|
||||||
|
voidLineLabel: "Void of invoice {number}",
|
||||||
|
refundLineLabel: "Refund for invoice {number}",
|
||||||
|
subtotal: "Subtotal",
|
||||||
|
vatLabel: "VAT",
|
||||||
|
totalCredited: "Total credited",
|
||||||
|
footerVoidNote:
|
||||||
|
"This credit note voids the referenced invoice. No payment is required.",
|
||||||
|
footerRefundNote:
|
||||||
|
"This credit note documents the refund of the amount above. Settlement occurs via the original payment method.",
|
||||||
|
vatNoteSwiss:
|
||||||
|
"VAT charged in accordance with Swiss VAT law (MWSTG).",
|
||||||
|
vatNoteReverseCharge:
|
||||||
|
"Reverse charge: VAT to be accounted for by the recipient per Art. 196 EU VAT Directive or national implementation.",
|
||||||
|
vatNoteOutOfScope:
|
||||||
|
"Service supplied outside the scope of Swiss VAT.",
|
||||||
|
},
|
||||||
|
fr: {
|
||||||
|
creditNote: "Note de crédit",
|
||||||
|
creditNoteNumber: "N° de note de crédit",
|
||||||
|
issueDate: "Date d'émission",
|
||||||
|
billTo: "Destinataire",
|
||||||
|
attentionPrefix: "À l'attention de",
|
||||||
|
referenceInvoice: "Facture de référence",
|
||||||
|
reason: "Motif",
|
||||||
|
voidLineLabel: "Annulation de la facture {number}",
|
||||||
|
refundLineLabel: "Remboursement de la facture {number}",
|
||||||
|
subtotal: "Sous-total",
|
||||||
|
vatLabel: "TVA",
|
||||||
|
totalCredited: "Total du crédit",
|
||||||
|
footerVoidNote:
|
||||||
|
"Cette note de crédit annule la facture référencée ci-dessus. Aucun paiement n'est requis.",
|
||||||
|
footerRefundNote:
|
||||||
|
"Cette note de crédit documente le remboursement du montant ci-dessus. Le règlement s'effectue via le moyen de paiement initial.",
|
||||||
|
vatNoteSwiss:
|
||||||
|
"TVA facturée conformément à la loi suisse sur la TVA (LTVA).",
|
||||||
|
vatNoteReverseCharge:
|
||||||
|
"Autoliquidation : TVA à acquitter par le destinataire selon l'art. 196 de la directive TVA UE ou sa mise en œuvre nationale.",
|
||||||
|
vatNoteOutOfScope:
|
||||||
|
"Prestation hors du champ d'application de la TVA suisse.",
|
||||||
|
},
|
||||||
|
it: {
|
||||||
|
creditNote: "Nota di credito",
|
||||||
|
creditNoteNumber: "N. nota di credito",
|
||||||
|
issueDate: "Data di emissione",
|
||||||
|
billTo: "Destinatario",
|
||||||
|
attentionPrefix: "c.a.",
|
||||||
|
referenceInvoice: "Fattura di riferimento",
|
||||||
|
reason: "Motivo",
|
||||||
|
voidLineLabel: "Annullamento della fattura {number}",
|
||||||
|
refundLineLabel: "Rimborso della fattura {number}",
|
||||||
|
subtotal: "Subtotale",
|
||||||
|
vatLabel: "IVA",
|
||||||
|
totalCredited: "Totale accreditato",
|
||||||
|
footerVoidNote:
|
||||||
|
"Questa nota di credito annulla la fattura sopra indicata. Non è richiesto alcun pagamento.",
|
||||||
|
footerRefundNote:
|
||||||
|
"Questa nota di credito documenta il rimborso dell'importo sopra indicato. Il regolamento avviene tramite il metodo di pagamento originale.",
|
||||||
|
vatNoteSwiss:
|
||||||
|
"IVA addebitata in conformità alla legge svizzera sull'IVA (LIVA).",
|
||||||
|
vatNoteReverseCharge:
|
||||||
|
"Inversione contabile: IVA dovuta dal destinatario ai sensi dell'art. 196 della direttiva IVA UE o della sua attuazione nazionale.",
|
||||||
|
vatNoteOutOfScope:
|
||||||
|
"Prestazione fuori dal campo di applicazione dell'IVA svizzera.",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function pickStrings(locale: string): CreditNoteStrings {
|
||||||
|
return MESSAGES[locale] ?? MESSAGES.de;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Swiss number formatting — matches billing-pdf for consistency
|
||||||
|
function fmtChf(n: number): string {
|
||||||
|
const fixed = n.toFixed(2);
|
||||||
|
const [intPart, decPart] = fixed.split(".");
|
||||||
|
const withSep = intPart.replace(/\B(?=(\d{3})+(?!\d))/g, "'");
|
||||||
|
return decPart ? `${withSep}.${decPart}` : withSep;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtDate(iso: string, locale: string): string {
|
||||||
|
const [y, m, d] = iso.split("T")[0].split("-").map(Number);
|
||||||
|
if (locale === "en") {
|
||||||
|
return new Date(Date.UTC(y, m - 1, d)).toLocaleDateString("en-US", {
|
||||||
|
year: "numeric",
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return `${String(d).padStart(2, "0")}.${String(m).padStart(2, "0")}.${y}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickVatNote(
|
||||||
|
invoice: Invoice,
|
||||||
|
strings: CreditNoteStrings
|
||||||
|
): string | null {
|
||||||
|
const country = invoice.billingSnapshot.country?.toUpperCase();
|
||||||
|
const hasVat = invoice.billingSnapshot.vatNumber?.trim();
|
||||||
|
if (country === "CH" || country === "LI") return strings.vatNoteSwiss;
|
||||||
|
if (hasVat) return strings.vatNoteReverseCharge;
|
||||||
|
return strings.vatNoteOutOfScope;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Styles
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
page: {
|
||||||
|
paddingTop: 36,
|
||||||
|
paddingBottom: 50,
|
||||||
|
paddingHorizontal: 50,
|
||||||
|
fontSize: 10,
|
||||||
|
fontFamily: "Helvetica",
|
||||||
|
color: BRAND.textColor,
|
||||||
|
},
|
||||||
|
headerRow: {
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
marginBottom: 32,
|
||||||
|
},
|
||||||
|
logoBlock: { flexDirection: "row", alignItems: "center" },
|
||||||
|
brandName: {
|
||||||
|
fontSize: 16,
|
||||||
|
color: BRAND.primaryDark,
|
||||||
|
marginLeft: 8,
|
||||||
|
fontFamily: "Helvetica-Bold",
|
||||||
|
},
|
||||||
|
issuerBlock: { textAlign: "right", fontSize: 8.5, color: BRAND.mutedColor },
|
||||||
|
issuerName: { fontSize: 11, color: BRAND.primaryDark, marginBottom: 2 },
|
||||||
|
docTitle: {
|
||||||
|
fontSize: 22,
|
||||||
|
color: BRAND.primaryDark,
|
||||||
|
marginBottom: 8,
|
||||||
|
fontFamily: "Helvetica-Bold",
|
||||||
|
},
|
||||||
|
metaTable: {
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
marginBottom: 20,
|
||||||
|
},
|
||||||
|
metaCol: { flexDirection: "column", minWidth: 140 },
|
||||||
|
metaLabel: { fontSize: 8, color: BRAND.mutedColor, marginBottom: 2 },
|
||||||
|
metaValue: { fontSize: 10 },
|
||||||
|
billTo: {
|
||||||
|
marginBottom: 24,
|
||||||
|
padding: 8,
|
||||||
|
backgroundColor: "#f7f7f5",
|
||||||
|
borderLeftWidth: 3,
|
||||||
|
borderLeftColor: BRAND.primary,
|
||||||
|
},
|
||||||
|
billToLabel: { fontSize: 8, color: BRAND.mutedColor, marginBottom: 4 },
|
||||||
|
billToName: { fontSize: 11, marginBottom: 2 },
|
||||||
|
amountTable: {
|
||||||
|
borderTopWidth: 1,
|
||||||
|
borderTopColor: BRAND.borderColor,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: BRAND.borderColor,
|
||||||
|
marginBottom: 16,
|
||||||
|
},
|
||||||
|
amountHeader: {
|
||||||
|
flexDirection: "row",
|
||||||
|
backgroundColor: BRAND.primaryDark,
|
||||||
|
color: "#ffffff",
|
||||||
|
paddingVertical: 5,
|
||||||
|
paddingHorizontal: 6,
|
||||||
|
fontSize: 9,
|
||||||
|
fontFamily: "Helvetica-Bold",
|
||||||
|
},
|
||||||
|
amountRow: {
|
||||||
|
flexDirection: "row",
|
||||||
|
paddingVertical: 8,
|
||||||
|
paddingHorizontal: 6,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: "#f0f0f0",
|
||||||
|
},
|
||||||
|
amountDesc: { flex: 1 },
|
||||||
|
amountValue: { width: 90, textAlign: "right" },
|
||||||
|
totals: { marginLeft: "auto", width: 220, marginBottom: 20 },
|
||||||
|
totalsRow: {
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
paddingVertical: 3,
|
||||||
|
},
|
||||||
|
totalsLabel: { color: BRAND.mutedColor, fontSize: 10 },
|
||||||
|
totalsValue: { fontSize: 10 },
|
||||||
|
totalsGrand: {
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
borderTopWidth: 1,
|
||||||
|
borderTopColor: BRAND.primaryDark,
|
||||||
|
paddingTop: 6,
|
||||||
|
marginTop: 4,
|
||||||
|
},
|
||||||
|
totalsGrandLabel: {
|
||||||
|
color: BRAND.primaryDark,
|
||||||
|
fontSize: 11,
|
||||||
|
fontFamily: "Helvetica-Bold",
|
||||||
|
},
|
||||||
|
totalsGrandValue: {
|
||||||
|
color: BRAND.primaryDark,
|
||||||
|
fontSize: 11,
|
||||||
|
textAlign: "right",
|
||||||
|
fontFamily: "Helvetica-Bold",
|
||||||
|
},
|
||||||
|
reasonBox: {
|
||||||
|
marginTop: 4,
|
||||||
|
marginBottom: 18,
|
||||||
|
padding: 8,
|
||||||
|
backgroundColor: "#fafafa",
|
||||||
|
borderLeftWidth: 2,
|
||||||
|
borderLeftColor: BRAND.borderColor,
|
||||||
|
},
|
||||||
|
reasonLabel: {
|
||||||
|
fontSize: 8,
|
||||||
|
color: BRAND.mutedColor,
|
||||||
|
marginBottom: 2,
|
||||||
|
textTransform: "uppercase",
|
||||||
|
letterSpacing: 0.5,
|
||||||
|
},
|
||||||
|
reasonText: { fontSize: 9.5, color: BRAND.textColor },
|
||||||
|
noteBox: {
|
||||||
|
marginTop: 12,
|
||||||
|
padding: 8,
|
||||||
|
fontSize: 8.5,
|
||||||
|
color: BRAND.mutedColor,
|
||||||
|
lineHeight: 1.5,
|
||||||
|
},
|
||||||
|
footer: {
|
||||||
|
position: "absolute",
|
||||||
|
bottom: 24,
|
||||||
|
left: 50,
|
||||||
|
right: 50,
|
||||||
|
fontSize: 7.5,
|
||||||
|
color: BRAND.mutedColor,
|
||||||
|
textAlign: "center",
|
||||||
|
borderTopWidth: 0.5,
|
||||||
|
borderTopColor: BRAND.borderColor,
|
||||||
|
paddingTop: 6,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
interface CreditNotePdfProps {
|
||||||
|
creditNote: CreditNote;
|
||||||
|
invoice: Invoice;
|
||||||
|
}
|
||||||
|
|
||||||
|
function CreditNotePdfDocument({ creditNote, invoice }: CreditNotePdfProps) {
|
||||||
|
const strings = pickStrings(creditNote.locale);
|
||||||
|
const snap = creditNote.billingSnapshot;
|
||||||
|
const vatNote = pickVatNote(invoice, strings);
|
||||||
|
const amountLabelTemplate =
|
||||||
|
creditNote.kind === "void" ? strings.voidLineLabel : strings.refundLineLabel;
|
||||||
|
const amountLabel = amountLabelTemplate.replace(
|
||||||
|
"{number}",
|
||||||
|
invoice.invoiceNumber
|
||||||
|
);
|
||||||
|
const footerNote =
|
||||||
|
creditNote.kind === "void" ? strings.footerVoidNote : strings.footerRefundNote;
|
||||||
|
// Stored convention: amount_chf is gross (incl. VAT),
|
||||||
|
// vat_amount_chf is the VAT portion. Subtotal computed for
|
||||||
|
// display.
|
||||||
|
const subtotal = creditNote.amountChf - creditNote.vatAmountChf;
|
||||||
|
return (
|
||||||
|
<Document>
|
||||||
|
<Page size="A4" style={styles.page}>
|
||||||
|
{/* Header — SAME hexagon logo as the invoice, tinted red.
|
||||||
|
Issuer block from BRAND.issuer (shared with invoice). */}
|
||||||
|
<View style={styles.headerRow}>
|
||||||
|
<View style={styles.logoBlock}>
|
||||||
|
<Logo size={42} color={BRAND.primary} />
|
||||||
|
<Text style={styles.brandName}>{BRAND.name}</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.issuerBlock}>
|
||||||
|
<Text style={styles.issuerName}>{BRAND.issuer.legalName}</Text>
|
||||||
|
<Text>{BRAND.issuer.addressLine1}</Text>
|
||||||
|
<Text>{BRAND.issuer.addressLine2}</Text>
|
||||||
|
<Text>{BRAND.issuer.postalCity}</Text>
|
||||||
|
<Text>{BRAND.issuer.country}</Text>
|
||||||
|
<Text>{BRAND.issuer.email}</Text>
|
||||||
|
<Text>{BRAND.issuer.web}</Text>
|
||||||
|
{BRAND.issuer.vatNumber && (
|
||||||
|
<Text>MWST-Nr. {BRAND.issuer.vatNumber}</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Text style={styles.docTitle}>{strings.creditNote}</Text>
|
||||||
|
|
||||||
|
<View style={styles.metaTable}>
|
||||||
|
<View style={styles.metaCol}>
|
||||||
|
<Text style={styles.metaLabel}>{strings.creditNoteNumber}</Text>
|
||||||
|
<Text style={styles.metaValue}>{creditNote.creditNoteNumber}</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.metaCol}>
|
||||||
|
<Text style={styles.metaLabel}>{strings.issueDate}</Text>
|
||||||
|
<Text style={styles.metaValue}>
|
||||||
|
{fmtDate(creditNote.issuedAt, creditNote.locale)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.metaCol}>
|
||||||
|
<Text style={styles.metaLabel}>{strings.referenceInvoice}</Text>
|
||||||
|
<Text style={styles.metaValue}>{invoice.invoiceNumber}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.billTo}>
|
||||||
|
<Text style={styles.billToLabel}>{strings.billTo}</Text>
|
||||||
|
<Text style={styles.billToName}>{snap.companyName}</Text>
|
||||||
|
{snap.contactName && snap.contactName.trim().length > 0 && (
|
||||||
|
<Text>
|
||||||
|
{strings.attentionPrefix} {snap.contactName}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
<Text>{snap.streetAddress}</Text>
|
||||||
|
<Text>
|
||||||
|
{snap.postalCode} {snap.city}
|
||||||
|
</Text>
|
||||||
|
<Text>{snap.country}</Text>
|
||||||
|
{snap.vatNumber && <Text>MWST/VAT: {snap.vatNumber}</Text>}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.amountTable}>
|
||||||
|
<View style={styles.amountHeader}>
|
||||||
|
<Text style={styles.amountDesc}> </Text>
|
||||||
|
<Text style={styles.amountValue}>CHF</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.amountRow}>
|
||||||
|
<Text style={styles.amountDesc}>{amountLabel}</Text>
|
||||||
|
<Text style={styles.amountValue}>{fmtChf(subtotal)}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.totals}>
|
||||||
|
<View style={styles.totalsRow}>
|
||||||
|
<Text style={styles.totalsLabel}>{strings.subtotal}</Text>
|
||||||
|
<Text style={styles.totalsValue}>CHF {fmtChf(subtotal)}</Text>
|
||||||
|
</View>
|
||||||
|
{creditNote.vatAmountChf > 0 && (
|
||||||
|
<View style={styles.totalsRow}>
|
||||||
|
<Text style={styles.totalsLabel}>
|
||||||
|
{strings.vatLabel} ({Number(invoice.vatRate).toFixed(1)}%)
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.totalsValue}>
|
||||||
|
CHF {fmtChf(creditNote.vatAmountChf)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
<View style={styles.totalsGrand}>
|
||||||
|
<Text style={styles.totalsGrandLabel}>{strings.totalCredited}</Text>
|
||||||
|
<Text style={styles.totalsGrandValue}>
|
||||||
|
CHF {fmtChf(creditNote.amountChf)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{creditNote.reason && creditNote.reason.trim().length > 0 && (
|
||||||
|
<View style={styles.reasonBox}>
|
||||||
|
<Text style={styles.reasonLabel}>{strings.reason}</Text>
|
||||||
|
<Text style={styles.reasonText}>{creditNote.reason}</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<View style={styles.noteBox}>
|
||||||
|
<Text>{footerNote}</Text>
|
||||||
|
{vatNote && <Text style={{ marginTop: 6 }}>{vatNote}</Text>}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Text style={styles.footer} fixed>
|
||||||
|
{BRAND.issuer.legalName} · {creditNote.creditNoteNumber}
|
||||||
|
</Text>
|
||||||
|
</Page>
|
||||||
|
</Document>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function renderCreditNotePdf(
|
||||||
|
creditNote: CreditNote,
|
||||||
|
invoice: Invoice
|
||||||
|
): Promise<Buffer> {
|
||||||
|
const doc = <CreditNotePdfDocument creditNote={creditNote} invoice={invoice} />;
|
||||||
|
return renderToBuffer(doc) as unknown as Buffer;
|
||||||
|
}
|
||||||
360
src/lib/cron.ts
Normal file
360
src/lib/cron.ts
Normal file
@@ -0,0 +1,360 @@
|
|||||||
|
/**
|
||||||
|
* Phase 5 — Automated billing cron logic.
|
||||||
|
*
|
||||||
|
* This module hosts the two sweeps:
|
||||||
|
* - runMonthlyIssuance() — invoked monthly to generate invoices
|
||||||
|
* for orgs opted into auto-issuance. Idempotent via the
|
||||||
|
* uniq_invoices_org_period constraint on invoices: a re-run
|
||||||
|
* for an org that's already been billed for the target period
|
||||||
|
* gets caught as a duplicate and counted as a skip, not a
|
||||||
|
* failure.
|
||||||
|
* - runReminderSweep() — invoked daily. Walks open/overdue
|
||||||
|
* invoices, sends the appropriate reminder level (1/2/3) once
|
||||||
|
* per invoice via the invoice_reminders unique-key constraint.
|
||||||
|
*
|
||||||
|
* Both entry points return a summary {success, failure, skipped}
|
||||||
|
* that the caller persists via finishCronRun(). The shared
|
||||||
|
* structure means the HTTP routes (machine + admin variants) are
|
||||||
|
* trivial wrappers.
|
||||||
|
*
|
||||||
|
* Time-of-month math is timezone-aware: we read the calendar in
|
||||||
|
* Europe/Zurich rather than UTC, because the K8s CronJob schedules
|
||||||
|
* at 00:30 local time on the 1st — UTC at that moment is still in
|
||||||
|
* the previous month, and a naive `getUTCMonth() - 1` would bill
|
||||||
|
* the wrong period.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
finishCronRun,
|
||||||
|
getLastSuccessfulCronRuns,
|
||||||
|
getOrgBilling,
|
||||||
|
getReminderLevelsSent,
|
||||||
|
listAutoIssueOrgIds,
|
||||||
|
listInvoicesPendingReminders,
|
||||||
|
recordReminderSent,
|
||||||
|
startCronRun,
|
||||||
|
syncOverdueInvoices,
|
||||||
|
} from "./db";
|
||||||
|
import { generateInvoice } from "./billing";
|
||||||
|
import { sendInvoiceReminderEmail } from "./email";
|
||||||
|
|
||||||
|
// The org_billing snapshot's company_name field doubles as the
|
||||||
|
// recipient name when no separate "billing contact" exists in
|
||||||
|
// our schema. Same convention as Phase 3's issuance email.
|
||||||
|
|
||||||
|
// All cron timing assumes Switzerland's calendar — the operator,
|
||||||
|
// the customers, and the legal basis (Swiss MWST) are all here.
|
||||||
|
const TZ = "Europe/Zurich";
|
||||||
|
|
||||||
|
export type CronSummary = {
|
||||||
|
successCount: number;
|
||||||
|
failureCount: number;
|
||||||
|
skippedCount: number;
|
||||||
|
errorDetails: Array<{
|
||||||
|
orgId?: string;
|
||||||
|
invoiceId?: string;
|
||||||
|
reason: string;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Monthly issuance
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The (year, month) of the calendar month that ended JUST BEFORE
|
||||||
|
* `now` in the configured timezone. This is what the issuance
|
||||||
|
* sweep bills.
|
||||||
|
*
|
||||||
|
* Reading the local-time calendar avoids a UTC-vs-local off-by-one
|
||||||
|
* when the sweep runs at 00:30 Zurich and UTC is still in the
|
||||||
|
* previous month.
|
||||||
|
*/
|
||||||
|
export function previousLocalMonth(
|
||||||
|
now: Date = new Date()
|
||||||
|
): { year: number; month: number } {
|
||||||
|
const fmt = new Intl.DateTimeFormat("en-CA", {
|
||||||
|
timeZone: TZ,
|
||||||
|
year: "numeric",
|
||||||
|
month: "2-digit",
|
||||||
|
});
|
||||||
|
const parts = fmt.formatToParts(now);
|
||||||
|
const year = Number(parts.find((p) => p.type === "year")!.value);
|
||||||
|
const month = Number(parts.find((p) => p.type === "month")!.value);
|
||||||
|
if (month === 1) return { year: year - 1, month: 12 };
|
||||||
|
return { year, month: month - 1 };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runMonthlyIssuance(opts: {
|
||||||
|
triggeredBy: string;
|
||||||
|
/** Override target year/month — defaults to previous local month. */
|
||||||
|
year?: number;
|
||||||
|
month?: number;
|
||||||
|
}): Promise<{ runId: string; summary: CronSummary }> {
|
||||||
|
const target =
|
||||||
|
opts.year && opts.month
|
||||||
|
? { year: opts.year, month: opts.month }
|
||||||
|
: previousLocalMonth();
|
||||||
|
const runId = await startCronRun("monthly_issue", opts.triggeredBy);
|
||||||
|
const summary: CronSummary = {
|
||||||
|
successCount: 0,
|
||||||
|
failureCount: 0,
|
||||||
|
skippedCount: 0,
|
||||||
|
errorDetails: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const orgIds = await listAutoIssueOrgIds();
|
||||||
|
for (const orgId of orgIds) {
|
||||||
|
try {
|
||||||
|
const orgBilling = await getOrgBilling(orgId);
|
||||||
|
if (!orgBilling) {
|
||||||
|
// Auto-issue is enabled but billing details are missing.
|
||||||
|
// Skip rather than fail — the admin needs to complete the
|
||||||
|
// address before invoicing can succeed.
|
||||||
|
summary.skippedCount += 1;
|
||||||
|
summary.errorDetails.push({
|
||||||
|
orgId,
|
||||||
|
reason: "org_billing not configured",
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Derive invoice locale from the org's country. PieCed is
|
||||||
|
// Swiss-default; CH/LI/AT/DE customers get the German PDF,
|
||||||
|
// FR/BE/LU customers get French, IT customers get Italian,
|
||||||
|
// anything else falls through to English. Customers needing
|
||||||
|
// a different locale can still trigger a manual issuance
|
||||||
|
// with an explicit override from the admin UI.
|
||||||
|
const locale = pickLocaleForCountry(orgBilling.country);
|
||||||
|
const { invoice } = await generateInvoice({
|
||||||
|
zitadelOrgId: orgId,
|
||||||
|
year: target.year,
|
||||||
|
month: target.month,
|
||||||
|
locale,
|
||||||
|
});
|
||||||
|
if (invoice) {
|
||||||
|
summary.successCount += 1;
|
||||||
|
} else {
|
||||||
|
// dryRun path — shouldn't happen in production. Defensive.
|
||||||
|
summary.skippedCount += 1;
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
// The uniqueness constraint on (zitadel_org_id, period_start)
|
||||||
|
// surfaces as "An invoice already exists for this org and
|
||||||
|
// billing period" from createInvoice. Re-running the cron
|
||||||
|
// mid-month or after a partial completion is therefore safe:
|
||||||
|
// already-billed orgs end up as skipped, not failed.
|
||||||
|
const msg = String(e?.message ?? e);
|
||||||
|
const isAlreadyIssued = /already exists for this org and billing period/i.test(
|
||||||
|
msg
|
||||||
|
);
|
||||||
|
if (isAlreadyIssued) {
|
||||||
|
summary.skippedCount += 1;
|
||||||
|
} else {
|
||||||
|
summary.failureCount += 1;
|
||||||
|
summary.errorDetails.push({ orgId, reason: msg });
|
||||||
|
console.error(
|
||||||
|
`runMonthlyIssuance: org ${orgId} failed:`,
|
||||||
|
e
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await finishCronRun(runId, summary);
|
||||||
|
return { runId, summary };
|
||||||
|
} catch (e) {
|
||||||
|
// Catastrophic — the sweep itself failed (DB down, etc).
|
||||||
|
summary.failureCount += 1;
|
||||||
|
summary.errorDetails.push({
|
||||||
|
reason: `sweep aborted: ${e instanceof Error ? e.message : String(e)}`,
|
||||||
|
});
|
||||||
|
await finishCronRun(runId, summary).catch(() => undefined);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Reminder sweep
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which reminder level (if any) is due now for this invoice?
|
||||||
|
*
|
||||||
|
* Logic:
|
||||||
|
* - days_past_due >= 30 AND level 3 not yet sent → 3 (final)
|
||||||
|
* - else days_past_due >= 14 AND level 2 not yet sent → 2
|
||||||
|
* - else days_past_due >= 7 AND level 1 not yet sent → 1
|
||||||
|
* - else → null (nothing to do this run)
|
||||||
|
*
|
||||||
|
* One reminder per cron run per invoice — highest applicable
|
||||||
|
* un-sent level wins. If a customer fell behind quickly and is
|
||||||
|
* already 35 days past due without ever having received levels
|
||||||
|
* 1 or 2 (e.g. the cron was broken for a while), they get level
|
||||||
|
* 3 directly. We don't backfill lower levels.
|
||||||
|
*/
|
||||||
|
function nextReminderLevel(
|
||||||
|
daysPastDue: number,
|
||||||
|
sent: Set<number>
|
||||||
|
): 1 | 2 | 3 | null {
|
||||||
|
if (daysPastDue >= 30 && !sent.has(3)) return 3;
|
||||||
|
if (daysPastDue >= 14 && !sent.has(2)) return 2;
|
||||||
|
if (daysPastDue >= 7 && !sent.has(1)) return 1;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function daysBetween(later: Date, earlier: Date): number {
|
||||||
|
const ms = later.getTime() - earlier.getTime();
|
||||||
|
return Math.floor(ms / (1000 * 60 * 60 * 24));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pick a default invoice locale based on the org's country
|
||||||
|
* (ISO 3166-1 alpha-2 code from org_billing.country). PieCed is
|
||||||
|
* primarily a Swiss-German operator; CH/LI/AT/DE get German,
|
||||||
|
* FR/BE/LU get French, IT gets Italian, anything else falls
|
||||||
|
* through to English.
|
||||||
|
*
|
||||||
|
* This only drives the automated issuance default. Manual
|
||||||
|
* issuance from the admin UI takes an explicit override.
|
||||||
|
*/
|
||||||
|
function pickLocaleForCountry(country: string): "de" | "en" | "fr" | "it" {
|
||||||
|
const c = country.toUpperCase();
|
||||||
|
if (["CH", "LI", "AT", "DE"].includes(c)) return "de";
|
||||||
|
if (["FR", "BE", "LU"].includes(c)) return "fr";
|
||||||
|
if (c === "IT") return "it";
|
||||||
|
return "en";
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runReminderSweep(opts: {
|
||||||
|
triggeredBy: string;
|
||||||
|
}): Promise<{ runId: string; summary: CronSummary }> {
|
||||||
|
const runId = await startCronRun("reminders", opts.triggeredBy);
|
||||||
|
const summary: CronSummary = {
|
||||||
|
successCount: 0,
|
||||||
|
failureCount: 0,
|
||||||
|
skippedCount: 0,
|
||||||
|
errorDetails: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Flip stale 'open' → 'overdue' first so the listing reflects
|
||||||
|
// current status, and audit trails stay accurate.
|
||||||
|
await syncOverdueInvoices().catch((e) => {
|
||||||
|
console.warn("syncOverdueInvoices failed during reminder sweep:", e);
|
||||||
|
});
|
||||||
|
|
||||||
|
const candidates = await listInvoicesPendingReminders();
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
for (const inv of candidates) {
|
||||||
|
try {
|
||||||
|
const sent = await getReminderLevelsSent(inv.id);
|
||||||
|
const dueAt = new Date(inv.dueAt);
|
||||||
|
const days = daysBetween(now, dueAt);
|
||||||
|
const level = nextReminderLevel(days, sent);
|
||||||
|
if (level === null) {
|
||||||
|
summary.skippedCount += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const billing = inv.billingSnapshot;
|
||||||
|
if (!billing.billingEmail) {
|
||||||
|
summary.skippedCount += 1;
|
||||||
|
summary.errorDetails.push({
|
||||||
|
invoiceId: inv.id,
|
||||||
|
reason: "no billing email on snapshot",
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const supportedLocales: Array<"de" | "en" | "fr" | "it"> = [
|
||||||
|
"de", "en", "fr", "it",
|
||||||
|
];
|
||||||
|
const locale = supportedLocales.includes(inv.locale as any)
|
||||||
|
? (inv.locale as "de" | "en" | "fr" | "it")
|
||||||
|
: "de";
|
||||||
|
|
||||||
|
await sendInvoiceReminderEmail({
|
||||||
|
to: billing.billingEmail,
|
||||||
|
contactName: billing.companyName,
|
||||||
|
companyName: billing.companyName,
|
||||||
|
invoiceNumber: inv.invoiceNumber,
|
||||||
|
totalChf: inv.totalChf,
|
||||||
|
currency: "CHF",
|
||||||
|
dueAt: inv.dueAt,
|
||||||
|
daysPastDue: days,
|
||||||
|
level,
|
||||||
|
locale,
|
||||||
|
});
|
||||||
|
// Record AFTER the send. If the SMTP send fails the email
|
||||||
|
// helper logs and doesn't throw, so we'd still record — but
|
||||||
|
// that's a tradeoff we accept: at-least-once delivery semantics
|
||||||
|
// with logged warnings is better than at-most-once where a
|
||||||
|
// transient failure stops the customer from ever getting
|
||||||
|
// reminded. If duplicate-reminder fatigue becomes a real
|
||||||
|
// problem in production, switch to: send first, only record
|
||||||
|
// on confirmed transporter success.
|
||||||
|
await recordReminderSent({
|
||||||
|
invoiceId: inv.id,
|
||||||
|
level,
|
||||||
|
sentBy: opts.triggeredBy,
|
||||||
|
emailSentTo: billing.billingEmail,
|
||||||
|
});
|
||||||
|
summary.successCount += 1;
|
||||||
|
} catch (e: any) {
|
||||||
|
summary.failureCount += 1;
|
||||||
|
summary.errorDetails.push({
|
||||||
|
invoiceId: inv.id,
|
||||||
|
reason: String(e?.message ?? e),
|
||||||
|
});
|
||||||
|
console.error(
|
||||||
|
`runReminderSweep: invoice ${inv.id} failed:`,
|
||||||
|
e
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await finishCronRun(runId, summary);
|
||||||
|
return { runId, summary };
|
||||||
|
} catch (e) {
|
||||||
|
summary.failureCount += 1;
|
||||||
|
summary.errorDetails.push({
|
||||||
|
reason: `sweep aborted: ${e instanceof Error ? e.message : String(e)}`,
|
||||||
|
});
|
||||||
|
await finishCronRun(runId, summary).catch(() => undefined);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Auth — bearer token for the machine endpoints
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constant-time bearer token check. The CRON_BEARER_TOKEN env var
|
||||||
|
* is injected from OpenBao via the portal-cron K8s Secret. Both
|
||||||
|
* the CronJob and the portal Deployment reference it; the
|
||||||
|
* CronJob sends it in the Authorization header, the portal checks
|
||||||
|
* with timing-safe equals to defeat character-by-character probing.
|
||||||
|
*/
|
||||||
|
export function verifyCronBearer(authHeader: string | null): boolean {
|
||||||
|
if (!authHeader) return false;
|
||||||
|
const expected = process.env.CRON_BEARER_TOKEN;
|
||||||
|
if (!expected || expected.length < 16) {
|
||||||
|
// Treat misconfiguration as a hard refusal so a missing/
|
||||||
|
// accidentally-empty token doesn't silently grant access.
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!authHeader.startsWith("Bearer ")) return false;
|
||||||
|
const got = authHeader.slice("Bearer ".length).trim();
|
||||||
|
if (got.length !== expected.length) return false;
|
||||||
|
// Constant-time byte compare. Node's Buffer.compare and the
|
||||||
|
// crypto.timingSafeEqual function both work, but the latter
|
||||||
|
// throws on length mismatch; the length pre-check above
|
||||||
|
// protects against that.
|
||||||
|
let diff = 0;
|
||||||
|
for (let i = 0; i < got.length; i++) {
|
||||||
|
diff |= got.charCodeAt(i) ^ expected.charCodeAt(i);
|
||||||
|
}
|
||||||
|
return diff === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-export for the admin UI to render "last run X ago" indicators.
|
||||||
|
export { getLastSuccessfulCronRuns };
|
||||||
1406
src/lib/db.ts
1406
src/lib/db.ts
File diff suppressed because it is too large
Load Diff
456
src/lib/email.ts
456
src/lib/email.ts
@@ -923,8 +923,8 @@ export async function sendInvoiceIssuedEmail(params: {
|
|||||||
currency: string; // "CHF" — passed for future-proofing
|
currency: string; // "CHF" — passed for future-proofing
|
||||||
dueAt: string; // ISO date
|
dueAt: string; // ISO date
|
||||||
lineCount: number;
|
lineCount: number;
|
||||||
periodStart: string; // ISO date
|
periodStart: string | null; // ISO date; null for custom invoices
|
||||||
periodEnd: string; // ISO date
|
periodEnd: string | null; // ISO date; null for custom invoices
|
||||||
locale: "de" | "en" | "fr" | "it";
|
locale: "de" | "en" | "fr" | "it";
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
// All four locales — the email is sent in the invoice's locale,
|
// All four locales — the email is sent in the invoice's locale,
|
||||||
@@ -960,7 +960,13 @@ export async function sendInvoiceIssuedEmail(params: {
|
|||||||
const safeCompany = escapeHtml(params.companyName);
|
const safeCompany = escapeHtml(params.companyName);
|
||||||
const safeNumber = escapeHtml(params.invoiceNumber);
|
const safeNumber = escapeHtml(params.invoiceNumber);
|
||||||
const totalFmt = `${params.currency} ${params.totalChf.toFixed(2)}`;
|
const totalFmt = `${params.currency} ${params.totalChf.toFixed(2)}`;
|
||||||
const periodFmt = `${params.periodStart.slice(0, 10)} → ${params.periodEnd.slice(0, 10)}`;
|
// Phase 8: period is null for custom invoices. When missing, the
|
||||||
|
// template skips the "Service period:" line entirely; otherwise
|
||||||
|
// it renders the date range as before.
|
||||||
|
const periodFmt =
|
||||||
|
params.periodStart && params.periodEnd
|
||||||
|
? `${params.periodStart.slice(0, 10)} → ${params.periodEnd.slice(0, 10)}`
|
||||||
|
: null;
|
||||||
const dueFmt = params.dueAt.slice(0, 10);
|
const dueFmt = params.dueAt.slice(0, 10);
|
||||||
|
|
||||||
// Both bodies built in the invoice's locale.
|
// Both bodies built in the invoice's locale.
|
||||||
@@ -977,7 +983,9 @@ export async function sendInvoiceIssuedEmail(params: {
|
|||||||
introByLocale[L],
|
introByLocale[L],
|
||||||
"",
|
"",
|
||||||
`${l.number}: ${params.invoiceNumber}`,
|
`${l.number}: ${params.invoiceNumber}`,
|
||||||
`${l.period}: ${periodFmt}`,
|
// Phase 8: omit the period line entirely for custom
|
||||||
|
// invoices (which have no billing period).
|
||||||
|
...(periodFmt ? [`${l.period}: ${periodFmt}`] : []),
|
||||||
`${l.total}: ${totalFmt}`,
|
`${l.total}: ${totalFmt}`,
|
||||||
`${l.due}: ${dueFmt}`,
|
`${l.due}: ${dueFmt}`,
|
||||||
`${l.lines}: ${params.lineCount}`,
|
`${l.lines}: ${params.lineCount}`,
|
||||||
@@ -995,7 +1003,7 @@ export async function sendInvoiceIssuedEmail(params: {
|
|||||||
<p>${escapeHtml(introByLocale[L])}</p>
|
<p>${escapeHtml(introByLocale[L])}</p>
|
||||||
<table style="width:100%; border-collapse:collapse; margin:16px 0; font-size:14px;">
|
<table style="width:100%; border-collapse:collapse; margin:16px 0; font-size:14px;">
|
||||||
<tr><td style="color:#888; padding:6px 0; width:120px;">${l.number}</td><td><strong>${safeNumber}</strong></td></tr>
|
<tr><td style="color:#888; padding:6px 0; width:120px;">${l.number}</td><td><strong>${safeNumber}</strong></td></tr>
|
||||||
<tr><td style="color:#888; padding:6px 0;">${l.period}</td><td>${escapeHtml(periodFmt)}</td></tr>
|
${periodFmt ? `<tr><td style="color:#888; padding:6px 0;">${l.period}</td><td>${escapeHtml(periodFmt)}</td></tr>` : ""}
|
||||||
<tr><td style="color:#888; padding:6px 0;">${l.total}</td><td style="color:#10B981; font-weight:600;">${escapeHtml(totalFmt)}</td></tr>
|
<tr><td style="color:#888; padding:6px 0;">${l.total}</td><td style="color:#10B981; font-weight:600;">${escapeHtml(totalFmt)}</td></tr>
|
||||||
<tr><td style="color:#888; padding:6px 0;">${l.due}</td><td>${escapeHtml(dueFmt)}</td></tr>
|
<tr><td style="color:#888; padding:6px 0;">${l.due}</td><td>${escapeHtml(dueFmt)}</td></tr>
|
||||||
<tr><td style="color:#888; padding:6px 0;">${l.lines}</td><td>${params.lineCount}</td></tr>
|
<tr><td style="color:#888; padding:6px 0;">${l.lines}</td><td>${params.lineCount}</td></tr>
|
||||||
@@ -1014,3 +1022,441 @@ export async function sendInvoiceIssuedEmail(params: {
|
|||||||
console.error("Failed to send invoice issued email:", err);
|
console.error("Failed to send invoice issued email:", err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Reminder emails — Phase 5
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a payment reminder for an open/overdue invoice.
|
||||||
|
*
|
||||||
|
* Three escalation levels:
|
||||||
|
* 1 — Gentle nudge: ~7 days past due. Friendly tone, "in case
|
||||||
|
* you missed it".
|
||||||
|
* 2 — Firmer reminder: ~14 days past due. Clear that payment is
|
||||||
|
* outstanding, please pay.
|
||||||
|
* 3 — Final notice: ~30 days past due. Explicit consequences
|
||||||
|
* (service may be suspended). Last automated touch — beyond
|
||||||
|
* this, admin involvement is expected.
|
||||||
|
*
|
||||||
|
* Failure is logged, never thrown — the cron sweep must continue
|
||||||
|
* past a single failed send.
|
||||||
|
*/
|
||||||
|
export async function sendInvoiceReminderEmail(params: {
|
||||||
|
to: string;
|
||||||
|
contactName: string;
|
||||||
|
companyName: string;
|
||||||
|
invoiceNumber: string;
|
||||||
|
totalChf: number;
|
||||||
|
currency: string;
|
||||||
|
dueAt: string;
|
||||||
|
daysPastDue: number;
|
||||||
|
level: 1 | 2 | 3;
|
||||||
|
locale: "de" | "en" | "fr" | "it";
|
||||||
|
}): Promise<void> {
|
||||||
|
const L = params.locale;
|
||||||
|
// Per-locale strings keyed by the three escalation levels.
|
||||||
|
// Kept inline (rather than the next-intl message files) because
|
||||||
|
// the email layer doesn't import from React's i18n context.
|
||||||
|
const SUBJECTS: Record<typeof L, Record<1 | 2 | 3, string>> = {
|
||||||
|
en: {
|
||||||
|
1: `Friendly reminder: invoice ${params.invoiceNumber} is overdue`,
|
||||||
|
2: `Second reminder: invoice ${params.invoiceNumber} is still unpaid`,
|
||||||
|
3: `Final notice: invoice ${params.invoiceNumber} requires immediate payment`,
|
||||||
|
},
|
||||||
|
de: {
|
||||||
|
1: `Freundliche Erinnerung: Rechnung ${params.invoiceNumber} ist überfällig`,
|
||||||
|
2: `Zweite Mahnung: Rechnung ${params.invoiceNumber} ist weiterhin unbezahlt`,
|
||||||
|
3: `Letzte Mahnung: Rechnung ${params.invoiceNumber} erfordert sofortige Zahlung`,
|
||||||
|
},
|
||||||
|
fr: {
|
||||||
|
1: `Rappel amical : la facture ${params.invoiceNumber} est en retard`,
|
||||||
|
2: `Deuxième rappel : la facture ${params.invoiceNumber} reste impayée`,
|
||||||
|
3: `Dernier avis : la facture ${params.invoiceNumber} doit être réglée sans délai`,
|
||||||
|
},
|
||||||
|
it: {
|
||||||
|
1: `Promemoria amichevole: la fattura ${params.invoiceNumber} è scaduta`,
|
||||||
|
2: `Secondo sollecito: la fattura ${params.invoiceNumber} è ancora insoluta`,
|
||||||
|
3: `Avviso finale: la fattura ${params.invoiceNumber} richiede pagamento immediato`,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const INTROS: Record<typeof L, Record<1 | 2 | 3, string>> = {
|
||||||
|
en: {
|
||||||
|
1: "We noticed this invoice hasn't been settled yet — in case it slipped through.",
|
||||||
|
2: "This invoice remains unpaid. Please arrange payment at your earliest convenience.",
|
||||||
|
3: "This invoice is significantly overdue. Service may be suspended if payment is not received promptly.",
|
||||||
|
},
|
||||||
|
de: {
|
||||||
|
1: "Diese Rechnung scheint noch nicht beglichen — falls sie übersehen wurde, möchten wir freundlich daran erinnern.",
|
||||||
|
2: "Diese Rechnung ist weiterhin unbezahlt. Bitte veranlassen Sie die Zahlung umgehend.",
|
||||||
|
3: "Diese Rechnung ist erheblich überfällig. Bei nicht zeitnaher Zahlung kann der Dienst ausgesetzt werden.",
|
||||||
|
},
|
||||||
|
fr: {
|
||||||
|
1: "Cette facture n'a pas encore été réglée — au cas où elle vous aurait échappé.",
|
||||||
|
2: "Cette facture reste impayée. Merci d'effectuer le paiement dans les meilleurs délais.",
|
||||||
|
3: "Cette facture est en grand retard. Le service pourra être suspendu en l'absence de paiement rapide.",
|
||||||
|
},
|
||||||
|
it: {
|
||||||
|
1: "Questa fattura non risulta ancora saldata — nel caso vi fosse sfuggita.",
|
||||||
|
2: "Questa fattura risulta ancora insoluta. Si prega di provvedere al pagamento al più presto.",
|
||||||
|
3: "Questa fattura è significativamente in ritardo. In assenza di pagamento tempestivo il servizio potrà essere sospeso.",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const LABELS: Record<typeof L, Record<string, string>> = {
|
||||||
|
en: { num: "Invoice", total: "Total", due: "Due date", days: "Days past due", cta: "View invoice & pay", signoff: "Best regards", brand: "PieCed IT", greeting: "Hello" },
|
||||||
|
de: { num: "Rechnung", total: "Gesamt", due: "Fälligkeitsdatum", days: "Tage überfällig", cta: "Rechnung ansehen & bezahlen", signoff: "Mit freundlichen Grüssen", brand: "PieCed IT", greeting: "Sehr geehrte/r" },
|
||||||
|
fr: { num: "Facture", total: "Total", due: "Échéance", days: "Jours de retard", cta: "Voir la facture & payer", signoff: "Cordialement", brand: "PieCed IT", greeting: "Bonjour" },
|
||||||
|
it: { num: "Fattura", total: "Totale", due: "Scadenza", days: "Giorni di ritardo", cta: "Vedi fattura & paga", signoff: "Cordiali saluti", brand: "PieCed IT", greeting: "Gentile" },
|
||||||
|
};
|
||||||
|
const l = LABELS[L];
|
||||||
|
const safeName = escapeHtml(params.contactName);
|
||||||
|
const safeCompany = escapeHtml(params.companyName);
|
||||||
|
const safeNumber = escapeHtml(params.invoiceNumber);
|
||||||
|
const totalFmt = `${params.currency} ${params.totalChf.toFixed(2)}`;
|
||||||
|
const dueFmt = params.dueAt.slice(0, 10);
|
||||||
|
const link = `https://app.pieced.ch/billing/${encodeURIComponent(params.invoiceNumber)}`;
|
||||||
|
// Final-notice gets red accent; earlier levels keep the brand green.
|
||||||
|
const accent = params.level === 3 ? "#dc2626" : "#10B981";
|
||||||
|
|
||||||
|
try {
|
||||||
|
await getTransporter().sendMail({
|
||||||
|
from: getFrom(),
|
||||||
|
to: params.to,
|
||||||
|
subject: SUBJECTS[L][params.level],
|
||||||
|
text: [
|
||||||
|
`${l.greeting} ${params.contactName},`,
|
||||||
|
"",
|
||||||
|
INTROS[L][params.level],
|
||||||
|
"",
|
||||||
|
`${l.num}: ${params.invoiceNumber}`,
|
||||||
|
`${l.total}: ${totalFmt}`,
|
||||||
|
`${l.due}: ${dueFmt}`,
|
||||||
|
`${l.days}: ${params.daysPastDue}`,
|
||||||
|
"",
|
||||||
|
`${l.cta}: ${link}`,
|
||||||
|
"",
|
||||||
|
`${l.signoff},`,
|
||||||
|
l.brand,
|
||||||
|
].join("\n"),
|
||||||
|
html: `
|
||||||
|
<div style="font-family:-apple-system,BlinkMacSystemFont,sans-serif;max-width:560px;padding:24px;background:#1a1a1a;color:#e5e5e5;">
|
||||||
|
<h2 style="margin:0 0 16px;color:${accent};">${escapeHtml(SUBJECTS[L][params.level])}</h2>
|
||||||
|
<p>${l.greeting} ${safeName},</p>
|
||||||
|
<p>${escapeHtml(INTROS[L][params.level])}</p>
|
||||||
|
<table style="width:100%;border-collapse:collapse;margin:16px 0;font-size:14px;">
|
||||||
|
<tr><td style="color:#888;padding:6px 0;width:140px;">${l.num}</td><td><strong>${safeNumber}</strong></td></tr>
|
||||||
|
<tr><td style="color:#888;padding:6px 0;">${l.total}</td><td style="color:${accent};font-weight:600;">${escapeHtml(totalFmt)}</td></tr>
|
||||||
|
<tr><td style="color:#888;padding:6px 0;">${l.due}</td><td>${escapeHtml(dueFmt)}</td></tr>
|
||||||
|
<tr><td style="color:#888;padding:6px 0;">${l.days}</td><td>${params.daysPastDue}</td></tr>
|
||||||
|
</table>
|
||||||
|
<p>
|
||||||
|
<a href="${link}" style="display:inline-block;padding:10px 24px;background:${accent};color:#fff;text-decoration:none;border-radius:8px;font-weight:500;">
|
||||||
|
${l.cta}
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
<hr style="border:none;border-top:1px solid #333;margin:24px 0;" />
|
||||||
|
<p style="color:#666;font-size:12px;">${l.brand}</p>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error(
|
||||||
|
`Failed to send reminder L${params.level} for invoice ${params.invoiceNumber}:`,
|
||||||
|
err
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Credit note emails — Phase 7
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a credit-note notification to the customer's billing email.
|
||||||
|
*
|
||||||
|
* Covers both kinds (void and refund). The subject and body adapt
|
||||||
|
* based on `kind` — voids ("we've cancelled invoice X, no payment
|
||||||
|
* needed") read very differently from refunds ("we've refunded CHF
|
||||||
|
* X, expect to see it on your card statement within 5-10 days").
|
||||||
|
*
|
||||||
|
* Link-only — the PDF is not attached. The customer downloads it
|
||||||
|
* from /api/credit-notes/<number>/pdf when they click through, which
|
||||||
|
* also gives them a permanent in-portal record next to their
|
||||||
|
* invoices. Same approach as invoice emails.
|
||||||
|
*
|
||||||
|
* Best-effort: failures are logged and swallowed. A mail-server
|
||||||
|
* hiccup must never roll back a credit-note issuance.
|
||||||
|
*/
|
||||||
|
export async function sendCreditNoteEmail(params: {
|
||||||
|
to: string;
|
||||||
|
contactName: string;
|
||||||
|
companyName: string;
|
||||||
|
creditNoteNumber: string;
|
||||||
|
invoiceNumber: string;
|
||||||
|
amountChf: number;
|
||||||
|
currency: string;
|
||||||
|
kind: "void" | "refund";
|
||||||
|
reason: string | null;
|
||||||
|
locale: "de" | "en" | "fr" | "it";
|
||||||
|
}): Promise<void> {
|
||||||
|
const L = params.locale;
|
||||||
|
const totalFmt = `${params.currency} ${params.amountChf.toFixed(2)}`;
|
||||||
|
const link = `https://app.pieced.ch/billing/cn/${encodeURIComponent(
|
||||||
|
params.creditNoteNumber
|
||||||
|
)}`;
|
||||||
|
|
||||||
|
// Subject lines diverge between void and refund — different
|
||||||
|
// mental models for the recipient. Void: "your charge is
|
||||||
|
// cancelled". Refund: "your money is on the way back".
|
||||||
|
const subjectsByLocale: Record<typeof L, { void: string; refund: string }> = {
|
||||||
|
en: {
|
||||||
|
void: `Invoice ${params.invoiceNumber} cancelled — credit note ${params.creditNoteNumber}`,
|
||||||
|
refund: `Refund of ${totalFmt} for invoice ${params.invoiceNumber} — credit note ${params.creditNoteNumber}`,
|
||||||
|
},
|
||||||
|
de: {
|
||||||
|
void: `Rechnung ${params.invoiceNumber} storniert — Gutschrift ${params.creditNoteNumber}`,
|
||||||
|
refund: `Rückerstattung ${totalFmt} für Rechnung ${params.invoiceNumber} — Gutschrift ${params.creditNoteNumber}`,
|
||||||
|
},
|
||||||
|
fr: {
|
||||||
|
void: `Facture ${params.invoiceNumber} annulée — note de crédit ${params.creditNoteNumber}`,
|
||||||
|
refund: `Remboursement ${totalFmt} pour la facture ${params.invoiceNumber} — note de crédit ${params.creditNoteNumber}`,
|
||||||
|
},
|
||||||
|
it: {
|
||||||
|
void: `Fattura ${params.invoiceNumber} annullata — nota di credito ${params.creditNoteNumber}`,
|
||||||
|
refund: `Rimborso ${totalFmt} per fattura ${params.invoiceNumber} — nota di credito ${params.creditNoteNumber}`,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const greetingsByLocale: Record<typeof L, string> = {
|
||||||
|
en: `Hello ${params.contactName},`,
|
||||||
|
de: `Sehr geehrte/r ${params.contactName},`,
|
||||||
|
fr: `Bonjour ${params.contactName},`,
|
||||||
|
it: `Gentile ${params.contactName},`,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Intro: distinct phrasing per kind in each locale.
|
||||||
|
const introsByLocale: Record<typeof L, { void: string; refund: string }> = {
|
||||||
|
en: {
|
||||||
|
void: `We've cancelled invoice ${params.invoiceNumber}. The invoice is no longer payable, and a credit note has been issued for your records.`,
|
||||||
|
refund: `We've refunded ${totalFmt} for invoice ${params.invoiceNumber}. The refund will appear on the original payment method within 5–10 business days, depending on your bank.`,
|
||||||
|
},
|
||||||
|
de: {
|
||||||
|
void: `Wir haben Rechnung ${params.invoiceNumber} storniert. Die Rechnung ist nicht mehr zahlbar; eine Gutschrift wurde für Ihre Unterlagen ausgestellt.`,
|
||||||
|
refund: `Wir haben ${totalFmt} für Rechnung ${params.invoiceNumber} zurückerstattet. Der Betrag wird je nach Bank innerhalb von 5–10 Geschäftstagen auf dem ursprünglichen Zahlungsweg gutgeschrieben.`,
|
||||||
|
},
|
||||||
|
fr: {
|
||||||
|
void: `Nous avons annulé la facture ${params.invoiceNumber}. La facture n'est plus exigible ; une note de crédit a été émise pour vos archives.`,
|
||||||
|
refund: `Nous avons remboursé ${totalFmt} pour la facture ${params.invoiceNumber}. Le montant apparaîtra sur le moyen de paiement initial sous 5 à 10 jours ouvrés, selon votre banque.`,
|
||||||
|
},
|
||||||
|
it: {
|
||||||
|
void: `Abbiamo annullato la fattura ${params.invoiceNumber}. La fattura non è più dovuta; è stata emessa una nota di credito per la sua documentazione.`,
|
||||||
|
refund: `Abbiamo rimborsato ${totalFmt} per la fattura ${params.invoiceNumber}. L'importo apparirà sul metodo di pagamento originale entro 5–10 giorni lavorativi, a seconda della banca.`,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const labels: Record<typeof L, Record<string, string>> = {
|
||||||
|
en: { creditNote: "Credit note", invoice: "Invoice", amount: "Amount", reason: "Reason", cta: "View credit note & download PDF", signoff: "Best regards", brand: "PieCed IT" },
|
||||||
|
de: { creditNote: "Gutschrift", invoice: "Rechnung", amount: "Betrag", reason: "Begründung", cta: "Gutschrift ansehen & PDF herunterladen", signoff: "Mit freundlichen Grüssen", brand: "PieCed IT" },
|
||||||
|
fr: { creditNote: "Note de crédit", invoice: "Facture", amount: "Montant", reason: "Motif", cta: "Voir la note de crédit & télécharger le PDF", signoff: "Cordialement", brand: "PieCed IT" },
|
||||||
|
it: { creditNote: "Nota di credito", invoice: "Fattura", amount: "Importo", reason: "Motivo", cta: "Visualizza nota di credito & scarica PDF", signoff: "Cordiali saluti", brand: "PieCed IT" },
|
||||||
|
};
|
||||||
|
const l = labels[L];
|
||||||
|
|
||||||
|
const subject = subjectsByLocale[L][params.kind];
|
||||||
|
const intro = introsByLocale[L][params.kind];
|
||||||
|
const safeName = escapeHtml(params.contactName);
|
||||||
|
const safeNumberCN = escapeHtml(params.creditNoteNumber);
|
||||||
|
const safeNumberINV = escapeHtml(params.invoiceNumber);
|
||||||
|
const safeReason = params.reason ? escapeHtml(params.reason) : null;
|
||||||
|
|
||||||
|
// PieCed brand emerald — same accent the invoice email uses.
|
||||||
|
// A credit note is still a PieCed IT document; the company
|
||||||
|
// identity stays consistent across the document family. The
|
||||||
|
// doc type is distinguished by the subject line and copy, not
|
||||||
|
// by colour.
|
||||||
|
const ACCENT = "#10B981";
|
||||||
|
|
||||||
|
try {
|
||||||
|
await getTransporter().sendMail({
|
||||||
|
from: getFrom(),
|
||||||
|
to: params.to,
|
||||||
|
subject,
|
||||||
|
text: [
|
||||||
|
greetingsByLocale[L],
|
||||||
|
"",
|
||||||
|
intro,
|
||||||
|
"",
|
||||||
|
`${l.creditNote}: ${params.creditNoteNumber}`,
|
||||||
|
`${l.invoice}: ${params.invoiceNumber}`,
|
||||||
|
`${l.amount}: ${totalFmt}`,
|
||||||
|
...(params.reason ? [`${l.reason}: ${params.reason}`] : []),
|
||||||
|
"",
|
||||||
|
`${l.cta}:`,
|
||||||
|
link,
|
||||||
|
"",
|
||||||
|
`${l.signoff},`,
|
||||||
|
l.brand,
|
||||||
|
].join("\n"),
|
||||||
|
html: `
|
||||||
|
<div style="font-family: -apple-system, BlinkMacSystemFont, sans-serif; max-width: 560px; padding: 24px; background: #1a1a1a; color: #e5e5e5;">
|
||||||
|
<h2 style="margin: 0 0 16px; color: ${ACCENT};">${escapeHtml(intro)}</h2>
|
||||||
|
<p>${safeName === "" ? "" : escapeHtml(greetingsByLocale[L])}</p>
|
||||||
|
<table style="width:100%; border-collapse:collapse; margin:16px 0; font-size:14px;">
|
||||||
|
<tr><td style="color:#888; padding:6px 0; width:140px;">${l.creditNote}</td><td><strong>${safeNumberCN}</strong></td></tr>
|
||||||
|
<tr><td style="color:#888; padding:6px 0;">${l.invoice}</td><td>${safeNumberINV}</td></tr>
|
||||||
|
<tr><td style="color:#888; padding:6px 0;">${l.amount}</td><td style="color:${ACCENT}; font-weight:600;">${escapeHtml(totalFmt)}</td></tr>
|
||||||
|
${safeReason ? `<tr><td style="color:#888; padding:6px 0; vertical-align:top;">${l.reason}</td><td style="color:#bbb;">${safeReason}</td></tr>` : ""}
|
||||||
|
</table>
|
||||||
|
<p>
|
||||||
|
<a href="${link}" style="display:inline-block; padding:10px 24px; background:${ACCENT}; color:#fff; text-decoration:none; border-radius:8px; font-weight:500;">
|
||||||
|
${l.cta}
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
<hr style="border:none; border-top:1px solid #333; margin:24px 0;" />
|
||||||
|
<p style="color:#666; font-size:12px;">${l.brand}</p>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to send credit note email:", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Phase 9b-2 — auto-charge failure notice
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sent when an off-session auto-charge attempt fails for an issued
|
||||||
|
* invoice (card declined, expired, 3DS required, etc.). Customer
|
||||||
|
* receives this in their billing-snapshot locale. Contains:
|
||||||
|
* - Invoice number + amount + due date
|
||||||
|
* - Failure reason (a short human-readable string from Stripe)
|
||||||
|
* - Manual-pay link to /billing/<invoiceNumber> where they can
|
||||||
|
* run the regular Pay-by-Card flow (which uses
|
||||||
|
* setup_future_usage to also refresh the saved card)
|
||||||
|
*
|
||||||
|
* Critical: the failure reason from Stripe can contain sensitive
|
||||||
|
* details (card BIN, country, etc.). We pass a sanitized short
|
||||||
|
* string from the caller — never the full raw error.
|
||||||
|
*/
|
||||||
|
export async function sendAutoChargeFailedEmail(params: {
|
||||||
|
to: string;
|
||||||
|
contactName: string;
|
||||||
|
companyName: string;
|
||||||
|
invoiceNumber: string;
|
||||||
|
totalChf: number;
|
||||||
|
currency: string;
|
||||||
|
dueAt: string;
|
||||||
|
/**
|
||||||
|
* Short, customer-safe reason. e.g. "Your card was declined."
|
||||||
|
* or "Your card has expired." Caller maps Stripe error codes to
|
||||||
|
* these strings; we never pass raw API error messages.
|
||||||
|
*/
|
||||||
|
reasonForCustomer: string;
|
||||||
|
locale: "de" | "en" | "fr" | "it";
|
||||||
|
}): Promise<void> {
|
||||||
|
const L = params.locale;
|
||||||
|
const totalFmt = `${params.currency} ${params.totalChf.toFixed(2)}`;
|
||||||
|
const dueFmt = params.dueAt.slice(0, 10);
|
||||||
|
const baseUrl = process.env.APP_BASE_URL ?? "https://app.pieced.ch";
|
||||||
|
const link = `${baseUrl}/billing/${encodeURIComponent(params.invoiceNumber)}`;
|
||||||
|
|
||||||
|
const subjectsByLocale: Record<typeof L, string> = {
|
||||||
|
en: `Auto-charge failed for invoice ${params.invoiceNumber} — please pay manually`,
|
||||||
|
de: `Auto-Abbuchung fehlgeschlagen für Rechnung ${params.invoiceNumber} — bitte manuell bezahlen`,
|
||||||
|
fr: `Échec du prélèvement automatique pour la facture ${params.invoiceNumber} — merci de régler manuellement`,
|
||||||
|
it: `Addebito automatico fallito per la fattura ${params.invoiceNumber} — la preghiamo di pagare manualmente`,
|
||||||
|
};
|
||||||
|
const greetingsByLocale: Record<typeof L, string> = {
|
||||||
|
en: `Hello ${params.contactName},`,
|
||||||
|
de: `Sehr geehrte/r ${params.contactName},`,
|
||||||
|
fr: `Bonjour ${params.contactName},`,
|
||||||
|
it: `Gentile ${params.contactName},`,
|
||||||
|
};
|
||||||
|
const introByLocale: Record<typeof L, string> = {
|
||||||
|
en: `We were unable to charge your saved card for invoice ${params.invoiceNumber} (${params.companyName}).`,
|
||||||
|
de: `Wir konnten die Rechnung ${params.invoiceNumber} (${params.companyName}) nicht über die hinterlegte Karte abbuchen.`,
|
||||||
|
fr: `Nous n'avons pas pu débiter votre carte enregistrée pour la facture ${params.invoiceNumber} (${params.companyName}).`,
|
||||||
|
it: `Non siamo riusciti ad addebitare la carta salvata per la fattura ${params.invoiceNumber} (${params.companyName}).`,
|
||||||
|
};
|
||||||
|
const reasonLabel: Record<typeof L, string> = {
|
||||||
|
en: "Reason given by the card network",
|
||||||
|
de: "Vom Kartennetzwerk gemeldeter Grund",
|
||||||
|
fr: "Motif communiqué par le réseau de carte",
|
||||||
|
it: "Motivo comunicato dal circuito",
|
||||||
|
};
|
||||||
|
const actionLineByLocale: Record<typeof L, string> = {
|
||||||
|
en: `Please pay this invoice manually before ${dueFmt} to avoid service interruption. The "Pay with card" button below will both charge the invoice and update the card we have on file for future charges.`,
|
||||||
|
de: `Bitte begleichen Sie diese Rechnung manuell vor dem ${dueFmt}, um eine Unterbrechung Ihres Dienstes zu vermeiden. Die Schaltfläche "Mit Karte bezahlen" unten begleicht die Rechnung und aktualisiert gleichzeitig die hinterlegte Karte für zukünftige Abbuchungen.`,
|
||||||
|
fr: `Veuillez régler cette facture manuellement avant le ${dueFmt} pour éviter toute interruption du service. Le bouton "Payer par carte" ci-dessous règle la facture et met à jour la carte enregistrée pour les futurs prélèvements.`,
|
||||||
|
it: `La preghiamo di saldare questa fattura manualmente entro il ${dueFmt} per evitare interruzioni del servizio. Il pulsante "Paga con carta" qui sotto salda la fattura e aggiorna allo stesso tempo la carta in archivio per gli addebiti futuri.`,
|
||||||
|
};
|
||||||
|
const labels: Record<typeof L, Record<string, string>> = {
|
||||||
|
en: { number: "Invoice", total: "Total", due: "Due by", cta: "Pay with card", signoff: "Best regards", brand: "PieCed IT" },
|
||||||
|
de: { number: "Rechnung", total: "Gesamt", due: "Zahlbar bis", cta: "Mit Karte bezahlen", signoff: "Mit freundlichen Grüssen", brand: "PieCed IT" },
|
||||||
|
fr: { number: "Facture", total: "Total", due: "À régler avant", cta: "Payer par carte", signoff: "Cordialement", brand: "PieCed IT" },
|
||||||
|
it: { number: "Fattura", total: "Totale", due: "Scadenza", cta: "Paga con carta", signoff: "Cordiali saluti", brand: "PieCed IT" },
|
||||||
|
};
|
||||||
|
const l = labels[L];
|
||||||
|
|
||||||
|
const safeName = escapeHtml(params.contactName);
|
||||||
|
const safeCompany = escapeHtml(params.companyName);
|
||||||
|
const safeNumber = escapeHtml(params.invoiceNumber);
|
||||||
|
const safeReason = escapeHtml(params.reasonForCustomer);
|
||||||
|
const safeIntro = escapeHtml(introByLocale[L]);
|
||||||
|
const safeAction = escapeHtml(actionLineByLocale[L]);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await getTransporter().sendMail({
|
||||||
|
from: getFrom(),
|
||||||
|
to: params.to,
|
||||||
|
subject: subjectsByLocale[L],
|
||||||
|
text: [
|
||||||
|
greetingsByLocale[L],
|
||||||
|
"",
|
||||||
|
introByLocale[L],
|
||||||
|
"",
|
||||||
|
`${l.number}: ${params.invoiceNumber}`,
|
||||||
|
`${l.total}: ${totalFmt}`,
|
||||||
|
`${l.due}: ${dueFmt}`,
|
||||||
|
"",
|
||||||
|
`${reasonLabel[L]}: ${params.reasonForCustomer}`,
|
||||||
|
"",
|
||||||
|
actionLineByLocale[L],
|
||||||
|
"",
|
||||||
|
`${l.cta}:`,
|
||||||
|
link,
|
||||||
|
"",
|
||||||
|
`${l.signoff},`,
|
||||||
|
l.brand,
|
||||||
|
].join("\n"),
|
||||||
|
html: `
|
||||||
|
<div style="font-family: -apple-system, BlinkMacSystemFont, sans-serif; max-width: 560px; padding: 24px; background: #1a1a1a; color: #e5e5e5;">
|
||||||
|
<h2 style="margin: 0 0 16px; color: #f59e0b;">${escapeHtml(subjectsByLocale[L])}</h2>
|
||||||
|
<p>${escapeHtml(greetingsByLocale[L])}</p>
|
||||||
|
<p>${safeIntro}</p>
|
||||||
|
<table style="width:100%; border-collapse:collapse; margin:16px 0; font-size:14px;">
|
||||||
|
<tr><td style="color:#888; padding:6px 0; width:120px;">${l.number}</td><td><strong>${safeNumber}</strong></td></tr>
|
||||||
|
<tr><td style="color:#888; padding:6px 0;">${l.total}</td><td style="color:#f59e0b; font-weight:600;">${escapeHtml(totalFmt)}</td></tr>
|
||||||
|
<tr><td style="color:#888; padding:6px 0;">${l.due}</td><td>${escapeHtml(dueFmt)}</td></tr>
|
||||||
|
</table>
|
||||||
|
<div style="background:#2a2a2a; border-left:3px solid #f59e0b; padding:10px 12px; margin:16px 0; font-size:13px;">
|
||||||
|
<strong>${escapeHtml(reasonLabel[L])}:</strong> ${safeReason}
|
||||||
|
</div>
|
||||||
|
<p style="font-size:14px;">${safeAction}</p>
|
||||||
|
<p>
|
||||||
|
<a href="${link}" style="display:inline-block; padding:10px 24px; background:#10B981; color:#fff; text-decoration:none; border-radius:8px; font-weight:500;">
|
||||||
|
${l.cta}
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
<p style="color:#888; font-size:12px; margin-top:24px;">
|
||||||
|
${l.signoff},<br />${l.brand}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to send auto-charge-failed email:", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -76,6 +76,29 @@ export interface PackageDef {
|
|||||||
* admin does the manual work, then approves.
|
* admin does the manual work, then approves.
|
||||||
*/
|
*/
|
||||||
requiresManualSetup?: boolean;
|
requiresManualSetup?: boolean;
|
||||||
|
/**
|
||||||
|
* Phase 9b: when true, the wizard visually highlights this package
|
||||||
|
* as recommended (a badge + accent border) without pre-selecting
|
||||||
|
* it. Used for the Threema channel — we want customers to choose
|
||||||
|
* Threema as their messaging surface when possible, but the choice
|
||||||
|
* stays opt-in.
|
||||||
|
*/
|
||||||
|
recommended?: boolean;
|
||||||
|
/**
|
||||||
|
* Phase 9b: when true, the onboarding wizard collects the
|
||||||
|
* customer's own user id for this channel (e.g. their Telegram
|
||||||
|
* numeric id, their Threema ID) at request time. The collected
|
||||||
|
* id is forwarded with the tenant request, stored on the row,
|
||||||
|
* and applied on admin approval:
|
||||||
|
* - spec.channelUsers[<channel>] gets the id seeded so the
|
||||||
|
* operator's first reconcile already has it
|
||||||
|
* - for Threema specifically, the approve handler additionally
|
||||||
|
* calls the relay's createRoute() so inbound messages from
|
||||||
|
* that id reach the new tenant
|
||||||
|
* Customers can add more ids later via the channel-users page.
|
||||||
|
* Help copy and label come from channelUsers.<id>IdHelp.
|
||||||
|
*/
|
||||||
|
collectsChannelUserId?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const PACKAGE_CATALOG: PackageDef[] = [
|
export const PACKAGE_CATALOG: PackageDef[] = [
|
||||||
@@ -129,6 +152,7 @@ export const PACKAGE_CATALOG: PackageDef[] = [
|
|||||||
instructionsKey: "packages.telegram.instructions",
|
instructionsKey: "packages.telegram.instructions",
|
||||||
disclaimerKey: "packages.telegram.disclaimer",
|
disclaimerKey: "packages.telegram.disclaimer",
|
||||||
category: "channel",
|
category: "channel",
|
||||||
|
collectsChannelUserId: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "discord",
|
id: "discord",
|
||||||
@@ -158,6 +182,7 @@ export const PACKAGE_CATALOG: PackageDef[] = [
|
|||||||
instructionsKey: "packages.discord.instructions",
|
instructionsKey: "packages.discord.instructions",
|
||||||
disclaimerKey: "packages.discord.disclaimer",
|
disclaimerKey: "packages.discord.disclaimer",
|
||||||
category: "channel",
|
category: "channel",
|
||||||
|
collectsChannelUserId: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "threema",
|
id: "threema",
|
||||||
@@ -173,6 +198,8 @@ export const PACKAGE_CATALOG: PackageDef[] = [
|
|||||||
instructionsKey: "packages.threema.instructions",
|
instructionsKey: "packages.threema.instructions",
|
||||||
disclaimerKey: "packages.threema.disclaimer",
|
disclaimerKey: "packages.threema.disclaimer",
|
||||||
category: "channel",
|
category: "channel",
|
||||||
|
recommended: true,
|
||||||
|
collectsChannelUserId: true,
|
||||||
},
|
},
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
@@ -231,7 +258,6 @@ export const PACKAGE_CATALOG: PackageDef[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "gog",
|
id: "gog",
|
||||||
requiresManualSetup: true,
|
|
||||||
name: "Google Workspace (Gog)",
|
name: "Google Workspace (Gog)",
|
||||||
descriptionKey: "packages.gog.description",
|
descriptionKey: "packages.gog.description",
|
||||||
requiresSecrets: true,
|
requiresSecrets: true,
|
||||||
@@ -334,9 +360,11 @@ export const CHANNEL_PACKAGE_IDS: string[] = PACKAGE_CATALOG
|
|||||||
* audio spend on every inbound voice note (Whisper STT) and every
|
* audio spend on every inbound voice note (Whisper STT) and every
|
||||||
* outbound reply (kani-tts / kokoro-fastapi via LiteLLM). Opt-in keeps
|
* outbound reply (kani-tts / kokoro-fastapi via LiteLLM). Opt-in keeps
|
||||||
* cost predictable for tenants who don't intend to use voice channels.
|
* cost predictable for tenants who don't intend to use voice channels.
|
||||||
|
*
|
||||||
|
* Phase 9b revision: nothing is pre-enabled. New tenants start with a
|
||||||
|
* blank slate — the customer opts into exactly what they want. The
|
||||||
|
* Threema channel is flagged `recommended` (see PACKAGE_CATALOG) so
|
||||||
|
* the wizard highlights it, since we want customers to use Threema as
|
||||||
|
* their channel when possible — but it's still opt-in, not auto-on.
|
||||||
*/
|
*/
|
||||||
export const DEFAULT_PACKAGE_IDS: string[] = [
|
export const DEFAULT_PACKAGE_IDS: string[] = [];
|
||||||
"core-heartbeat",
|
|
||||||
"core-cron",
|
|
||||||
"core-active-memory",
|
|
||||||
];
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user