Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
59
src/app/[locale]/admin/billing/invoice-drafts/[id]/page.tsx
Normal file
59
src/app/[locale]/admin/billing/invoice-drafts/[id]/page.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { getSessionUser } from "@/lib/session";
|
||||
import { getInvoiceDraftById, getOrgBilling } from "@/lib/db";
|
||||
import { BackLink } from "@/components/ui/back-link";
|
||||
import { CustomInvoiceEditor } from "@/components/admin/billing/custom-invoice-editor";
|
||||
|
||||
/**
|
||||
* /admin/billing/invoice-drafts/[id] — full editor for an
|
||||
* in-progress custom invoice.
|
||||
*
|
||||
* Phase 8. Server-loads the draft + the org's billing snapshot
|
||||
* (used to display the bill-to block preview), then hands off to
|
||||
* the client editor for the interactive line-management UI.
|
||||
*
|
||||
* The snapshot is loaded read-only for display. The actual VAT
|
||||
* computation happens server-side at issue time via
|
||||
* computeCustomInvoiceTotals, which re-reads the same snapshot.
|
||||
* That two-time read is intentional: the editor's preview math
|
||||
* is a hint, the issue-time read is authoritative — if the
|
||||
* customer updates their billing address between Draft and Issue,
|
||||
* the invoice reflects the new address.
|
||||
*/
|
||||
export default async function InvoiceDraftEditorPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const user = await getSessionUser();
|
||||
if (!user) redirect("/login");
|
||||
if (!user.isPlatform) redirect("/dashboard");
|
||||
const t = await getTranslations("adminBilling");
|
||||
|
||||
const { id } = await params;
|
||||
const draft = await getInvoiceDraftById(id);
|
||||
if (!draft) notFound();
|
||||
const orgBilling = await getOrgBilling(draft.zitadelOrgId).catch(() => null);
|
||||
|
||||
return (
|
||||
<main className="max-w-5xl mx-auto px-6 py-8">
|
||||
<BackLink
|
||||
href="/admin/billing/invoice-drafts"
|
||||
label={t("backToDrafts")}
|
||||
/>
|
||||
<div className="mb-6">
|
||||
<h1 className="font-display text-2xl font-semibold accent-rule">
|
||||
{t("editorPageTitle")}
|
||||
</h1>
|
||||
<p className="text-sm text-text-secondary mt-3">
|
||||
{orgBilling?.companyName ?? draft.zitadelOrgId}
|
||||
</p>
|
||||
</div>
|
||||
<CustomInvoiceEditor
|
||||
draft={draft}
|
||||
orgBilling={orgBilling}
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
72
src/app/[locale]/admin/billing/invoice-drafts/page.tsx
Normal file
72
src/app/[locale]/admin/billing/invoice-drafts/page.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { getSessionUser } from "@/lib/session";
|
||||
import { getOrgBilling, listAllInvoiceDrafts } from "@/lib/db";
|
||||
import { listTenants } from "@/lib/k8s";
|
||||
import { BackLink } from "@/components/ui/back-link";
|
||||
import { DraftList } from "@/components/admin/billing/draft-list";
|
||||
|
||||
/**
|
||||
* /admin/billing/invoice-drafts — list of all open custom-invoice
|
||||
* drafts across orgs.
|
||||
*
|
||||
* Phase 8. Each draft is a JSONB blob the admin is composing into
|
||||
* an invoice; visible only to platform admins. From here the admin
|
||||
* can resume editing or discard.
|
||||
*
|
||||
* Building an org-name map by reading tenant labels (for the set of
|
||||
* known orgs) + getOrgBilling per org (for the actual company name)
|
||||
* so the table can show "Customer X" instead of a raw ZITADEL org id.
|
||||
*/
|
||||
export default async function AdminInvoiceDraftsPage() {
|
||||
const user = await getSessionUser();
|
||||
if (!user) redirect("/login");
|
||||
if (!user.isPlatform) redirect("/dashboard");
|
||||
const t = await getTranslations("adminBilling");
|
||||
|
||||
const [drafts, tenants] = await Promise.all([
|
||||
listAllInvoiceDrafts(),
|
||||
listTenants().catch(() => []),
|
||||
]);
|
||||
|
||||
// Build the set of distinct ZITADEL org ids from tenant labels,
|
||||
// PLUS the set referenced by any current draft. Drafts may target
|
||||
// orgs that don't have tenants yet (rare but possible), so we
|
||||
// union both sources before fetching billing rows.
|
||||
const orgIds = new Set<string>();
|
||||
for (const tnt of tenants) {
|
||||
const oid = tnt.metadata.labels?.["pieced.ch/zitadel-org-id"];
|
||||
if (oid) orgIds.add(oid);
|
||||
}
|
||||
for (const d of drafts) {
|
||||
orgIds.add(d.zitadelOrgId);
|
||||
}
|
||||
// Look up billing in parallel — same pattern as
|
||||
// /api/admin/billing/orgs uses. Failure for any single org is
|
||||
// non-fatal (falls back to the raw id in the table).
|
||||
const orgNamePairs = await Promise.all(
|
||||
Array.from(orgIds).map(async (oid) => {
|
||||
const billing = await getOrgBilling(oid).catch(() => null);
|
||||
return [oid, billing?.companyName ?? null] as const;
|
||||
})
|
||||
);
|
||||
const orgNameMap: Record<string, string> = {};
|
||||
for (const [oid, name] of orgNamePairs) {
|
||||
if (name) orgNameMap[oid] = name;
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="max-w-5xl mx-auto px-6 py-8">
|
||||
<BackLink href="/admin/billing" label={t("backToBilling")} />
|
||||
<div className="mb-6">
|
||||
<h1 className="font-display text-2xl font-semibold accent-rule">
|
||||
{t("draftsPageTitle")}
|
||||
</h1>
|
||||
<p className="text-sm text-text-secondary mt-3">
|
||||
{t("draftsPageSubtitle")}
|
||||
</p>
|
||||
</div>
|
||||
<DraftList drafts={drafts} orgNameMap={orgNameMap} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { getSessionUser } from "@/lib/session";
|
||||
import { getInvoiceDetail } from "@/lib/db";
|
||||
import { getInvoiceDetail, listCreditNotesForInvoice } from "@/lib/db";
|
||||
import { BackLink } from "@/components/ui/back-link";
|
||||
import { InvoiceDetailView } from "@/components/admin/billing/invoice-detail-view";
|
||||
|
||||
@@ -9,8 +9,12 @@ import { InvoiceDetailView } from "@/components/admin/billing/invoice-detail-vie
|
||||
* /admin/billing/invoices/[id] — full detail of one invoice.
|
||||
*
|
||||
* Server-renders the static body (header, lines, totals, billing
|
||||
* snapshot); the action bar (mark-paid, delete, PDF download) is
|
||||
* a client component for the interactive bits.
|
||||
* snapshot); the action bar (mark-paid, void, refund, delete, PDF
|
||||
* download) is a client component for the interactive bits.
|
||||
*
|
||||
* Phase 7: also passes any linked credit notes so the detail view
|
||||
* can show the "this invoice was voided / partially refunded" panel
|
||||
* without an extra round-trip.
|
||||
*/
|
||||
export default async function AdminInvoiceDetailPage({
|
||||
params,
|
||||
@@ -25,11 +29,12 @@ export default async function AdminInvoiceDetailPage({
|
||||
const { id } = await params;
|
||||
const detail = await getInvoiceDetail(id);
|
||||
if (!detail) notFound();
|
||||
const creditNotes = await listCreditNotesForInvoice(id);
|
||||
|
||||
return (
|
||||
<main className="max-w-4xl mx-auto px-6 py-8">
|
||||
<BackLink href="/admin/billing/invoices" label={t("backToInvoices")} />
|
||||
<InvoiceDetailView detail={detail} />
|
||||
<InvoiceDetailView detail={detail} creditNotes={creditNotes} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
72
src/app/[locale]/admin/billing/invoices/new/page.tsx
Normal file
72
src/app/[locale]/admin/billing/invoices/new/page.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { getSessionUser } from "@/lib/session";
|
||||
import { listTenants } from "@/lib/k8s";
|
||||
import { getOrgBilling } from "@/lib/db";
|
||||
import { BackLink } from "@/components/ui/back-link";
|
||||
import { NewInvoiceForm } from "@/components/admin/billing/new-invoice-form";
|
||||
|
||||
/**
|
||||
* /admin/billing/invoices/new — entry point for the custom-invoice
|
||||
* flow. The admin picks an org, clicks Continue, and lands on the
|
||||
* editor at /admin/billing/invoice-drafts/<new-id>.
|
||||
*
|
||||
* Phase 8. Org list is built from tenant labels + each org's
|
||||
* billing config (we need the company name and the
|
||||
* has-billing-snapshot flag to gate the picker — orgs without a
|
||||
* snapshot can't be invoiced until they complete onboarding or
|
||||
* admin sets the billing info manually).
|
||||
*/
|
||||
export default async function NewInvoicePage() {
|
||||
const user = await getSessionUser();
|
||||
if (!user) redirect("/login");
|
||||
if (!user.isPlatform) redirect("/dashboard");
|
||||
const t = await getTranslations("adminBilling");
|
||||
|
||||
// Tenants give us org membership; getOrgBilling per org gives us
|
||||
// the snapshot status. We dedupe by org id since one org can own
|
||||
// many tenants.
|
||||
const tenants = await listTenants();
|
||||
const orgIds = new Set<string>();
|
||||
for (const tnt of tenants) {
|
||||
const oid = tnt.metadata.labels?.["pieced.ch/zitadel-org-id"];
|
||||
if (oid) orgIds.add(oid);
|
||||
}
|
||||
const orgs = await Promise.all(
|
||||
Array.from(orgIds).map(async (oid) => {
|
||||
const billing = await getOrgBilling(oid).catch(() => null);
|
||||
return {
|
||||
zitadelOrgId: oid,
|
||||
companyName: billing?.companyName ?? null,
|
||||
country: billing?.country ?? null,
|
||||
hasBillingAddress: !!billing && !!billing.companyName,
|
||||
};
|
||||
})
|
||||
);
|
||||
// Sort: orgs with billing first (admin's most likely target),
|
||||
// then alphabetically by company name.
|
||||
orgs.sort((a, b) => {
|
||||
if (a.hasBillingAddress !== b.hasBillingAddress) {
|
||||
return a.hasBillingAddress ? -1 : 1;
|
||||
}
|
||||
return (a.companyName ?? "").localeCompare(b.companyName ?? "");
|
||||
});
|
||||
|
||||
return (
|
||||
<main className="max-w-2xl mx-auto px-6 py-8">
|
||||
<BackLink
|
||||
href="/admin/billing/invoices"
|
||||
label={t("backToInvoices")}
|
||||
/>
|
||||
<div className="mb-6">
|
||||
<h1 className="font-display text-2xl font-semibold accent-rule">
|
||||
{t("newInvoicePageTitle")}
|
||||
</h1>
|
||||
<p className="text-sm text-text-secondary mt-3">
|
||||
{t("newInvoicePageSubtitle")}
|
||||
</p>
|
||||
</div>
|
||||
<NewInvoiceForm orgs={orgs} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
83
src/app/[locale]/admin/billing/orgs/page.tsx
Normal file
83
src/app/[locale]/admin/billing/orgs/page.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { getSessionUser } from "@/lib/session";
|
||||
import { getOrgBilling, getOrgBillingConfig } from "@/lib/db";
|
||||
import { listTenants } from "@/lib/k8s";
|
||||
import { BackLink } from "@/components/ui/back-link";
|
||||
import { OrgPaymentModeList } from "@/components/admin/billing/org-payment-mode-list";
|
||||
|
||||
/**
|
||||
* /admin/billing/orgs — list of orgs with their payment mode
|
||||
* settings.
|
||||
*
|
||||
* Phase 9b-2. The customer's /settings/billing only exposes the
|
||||
* saved-card flow (auto-pay). Bank-transfer mode is admin-only —
|
||||
* customer must contact support to request it, admin flips the
|
||||
* pay_by_invoice flag here. Also exposes the auto_charge_enabled
|
||||
* pause-switch for support situations.
|
||||
*
|
||||
* The page is intentionally minimal: org name, country, current
|
||||
* mode, has-saved-card indicator, and toggles. Detail-level work
|
||||
* (open balances, invoice list) is on the existing pages
|
||||
* (/admin/billing, /admin/billing/invoices).
|
||||
*/
|
||||
export default async function AdminOrgsPaymentModePage() {
|
||||
const user = await getSessionUser();
|
||||
if (!user) redirect("/login");
|
||||
if (!user.isPlatform) redirect("/dashboard");
|
||||
const t = await getTranslations("adminBilling");
|
||||
|
||||
// Same org-discovery pattern as /api/admin/billing/orgs: tenant
|
||||
// labels are the source of truth for org membership. We dedupe by
|
||||
// org id since one org can own many tenants.
|
||||
const tenants = await listTenants().catch(() => []);
|
||||
const orgIds = new Set<string>();
|
||||
for (const tnt of tenants) {
|
||||
const oid = tnt.metadata.labels?.["pieced.ch/zitadel-org-id"];
|
||||
if (oid) orgIds.add(oid);
|
||||
}
|
||||
const orgs = await Promise.all(
|
||||
Array.from(orgIds).map(async (oid) => {
|
||||
const [billing, cfg] = await Promise.all([
|
||||
getOrgBilling(oid).catch(() => null),
|
||||
getOrgBillingConfig(oid),
|
||||
]);
|
||||
return {
|
||||
zitadelOrgId: oid,
|
||||
companyName: billing?.companyName ?? null,
|
||||
country: billing?.country ?? null,
|
||||
hasSavedCard: !!cfg.stripeDefaultPaymentMethodId,
|
||||
cardLabel:
|
||||
cfg.stripePmBrand && cfg.stripePmLast4
|
||||
? `${cfg.stripePmBrand} •••• ${cfg.stripePmLast4}`
|
||||
: null,
|
||||
payByInvoice: !!cfg.payByInvoice,
|
||||
autoChargeEnabled: cfg.autoChargeEnabled !== false,
|
||||
};
|
||||
})
|
||||
);
|
||||
// Sort: orgs with billing first (most actionable), then by name.
|
||||
orgs.sort((a, b) => {
|
||||
if (!!a.companyName !== !!b.companyName) {
|
||||
return a.companyName ? -1 : 1;
|
||||
}
|
||||
return (a.companyName ?? a.zitadelOrgId).localeCompare(
|
||||
b.companyName ?? b.zitadelOrgId
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
<main className="max-w-6xl mx-auto px-6 py-8">
|
||||
<BackLink href="/admin/billing" label={t("backToBilling")} />
|
||||
<div className="mb-6">
|
||||
<h1 className="font-display text-2xl font-semibold accent-rule">
|
||||
{t("orgsPageTitle")}
|
||||
</h1>
|
||||
<p className="text-sm text-text-secondary mt-3">
|
||||
{t("orgsPageSubtitle")}
|
||||
</p>
|
||||
</div>
|
||||
<OrgPaymentModeList orgs={orgs} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -66,7 +66,7 @@ export default async function AdminBillingPage() {
|
||||
</div>
|
||||
|
||||
{/* Sub-tool cards */}
|
||||
<div className="grid grid-cols-3 gap-4 mb-8 animate-in animate-in-delay-2">
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8 animate-in animate-in-delay-2">
|
||||
<Link href="/admin/billing/pricing">
|
||||
<Card interactive>
|
||||
<div className="font-semibold mb-1">{t("pricingTitle")}</div>
|
||||
@@ -85,6 +85,12 @@ export default async function AdminBillingPage() {
|
||||
<div className="text-sm text-text-muted">{t("invoicesDesc")}</div>
|
||||
</Card>
|
||||
</Link>
|
||||
<Link href="/admin/billing/orgs">
|
||||
<Card interactive>
|
||||
<div className="font-semibold mb-1">{t("orgsTitle")}</div>
|
||||
<div className="text-sm text-text-muted">{t("orgsDesc")}</div>
|
||||
</Card>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Orgs with open balance */}
|
||||
|
||||
@@ -1,23 +1,30 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
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 { CustomerCreditNoteList } from "@/components/billing/customer-credit-note-list";
|
||||
import { RunningTotalWidget } from "@/components/billing/running-total-widget";
|
||||
|
||||
/**
|
||||
* /billing — customer's billing home.
|
||||
*
|
||||
* Shows two things:
|
||||
* Shows three things:
|
||||
* 1. RunningTotalWidget — current calendar month's accruing cost
|
||||
* (or the already-issued invoice for the current month, if
|
||||
* that ran early).
|
||||
* 2. CustomerInvoiceList — every issued invoice for this org,
|
||||
* newest first. Status is reflected with a colored badge.
|
||||
* 3. CustomerCreditNoteList — Phase 7. Credit notes (voids and
|
||||
* refunds) for this org, with PDF download links. Hidden
|
||||
* entirely when there are none (the common case).
|
||||
*
|
||||
* Anyone signed in can view this. The data is org-scoped; even
|
||||
* non-owner team members see the same view. Phase 4 will add a
|
||||
* "settings.payByInvoice" toggle visibility-gated to owners only.
|
||||
* non-owner team members see the same view.
|
||||
*/
|
||||
export default async function CustomerBillingPage() {
|
||||
const user = await getSessionUser();
|
||||
@@ -31,10 +38,11 @@ export default async function CustomerBillingPage() {
|
||||
console.warn("syncOverdueInvoices failed in /billing:", e);
|
||||
}
|
||||
|
||||
const invoices = await listInvoices({
|
||||
zitadelOrgId: user.orgId,
|
||||
limit: 200,
|
||||
});
|
||||
// Parallel fetch — invoices + credit notes are independent.
|
||||
const [invoices, creditNotes] = await Promise.all([
|
||||
listInvoices({ zitadelOrgId: user.orgId, limit: 200 }),
|
||||
listCreditNotesForOrg(user.orgId, 200),
|
||||
]);
|
||||
|
||||
return (
|
||||
<main className="max-w-5xl mx-auto px-6 py-8">
|
||||
@@ -49,15 +57,29 @@ export default async function CustomerBillingPage() {
|
||||
<h2 className="text-xs font-semibold uppercase tracking-wider text-text-muted mb-3">
|
||||
{t("currentPeriodHeading")}
|
||||
</h2>
|
||||
<RunningTotalWidget />
|
||||
{/* Phase 6: pass the owner flag so the no-config CTA shows
|
||||
the right call-to-action vs the right hint. */}
|
||||
<RunningTotalWidget isOwner={user.roles.includes("owner")} />
|
||||
</section>
|
||||
|
||||
<section className="animate-in animate-in-delay-2">
|
||||
<section className="animate-in animate-in-delay-2 mb-8">
|
||||
<h2 className="text-xs font-semibold uppercase tracking-wider text-text-muted mb-3">
|
||||
{t("historyHeading")}
|
||||
</h2>
|
||||
<CustomerInvoiceList invoices={invoices} />
|
||||
</section>
|
||||
|
||||
{/* Phase 7: credit-note section. CustomerCreditNoteList itself
|
||||
returns null when there are no credit notes, so this whole
|
||||
section disappears for orgs in normal operation. */}
|
||||
{creditNotes.length > 0 && (
|
||||
<section className="animate-in animate-in-delay-3">
|
||||
<h2 className="text-xs font-semibold uppercase tracking-wider text-text-muted mb-3">
|
||||
{t("creditNotesHeading")}
|
||||
</h2>
|
||||
<CustomerCreditNoteList creditNotes={creditNotes} />
|
||||
</section>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { redirect } from "next/navigation";
|
||||
import { OnboardingFlow } from "@/components/onboarding/onboarding-flow";
|
||||
import { BackLink } from "@/components/ui/back-link";
|
||||
import { listTenants } from "@/lib/k8s";
|
||||
import { listActiveTenantRequestsByOrgId, getOrgBilling } from "@/lib/db";
|
||||
import { listActiveTenantRequestsByOrgId, getOrgBilling, getPlatformPricing } from "@/lib/db";
|
||||
import { personalAccountAtCapacity } from "@/lib/personal-org";
|
||||
|
||||
/**
|
||||
@@ -55,7 +55,10 @@ export default async function NewInstancePage() {
|
||||
}
|
||||
|
||||
const t = await getTranslations("dashboard");
|
||||
const orgBilling = await getOrgBilling(user.orgId);
|
||||
const [orgBilling, pricing] = await Promise.all([
|
||||
getOrgBilling(user.orgId),
|
||||
getPlatformPricing(),
|
||||
]);
|
||||
const hasOrgBilling = orgBilling !== null;
|
||||
|
||||
return (
|
||||
@@ -76,6 +79,8 @@ export default async function NewInstancePage() {
|
||||
userName={user.name}
|
||||
userEmail={user.email}
|
||||
hasOrgBilling={hasOrgBilling}
|
||||
existingOrgBilling={orgBilling}
|
||||
setupFeeChf={pricing.tenantSetupFeeChf}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
listActiveTenantRequestsByOrgId,
|
||||
syncProvisioningStatuses,
|
||||
getOrgBilling,
|
||||
getPlatformPricing,
|
||||
} from "@/lib/db";
|
||||
import {
|
||||
listVisibleTenants,
|
||||
@@ -192,6 +193,7 @@ export default async function DashboardPage() {
|
||||
// component.
|
||||
const orgBilling = await getOrgBilling(user.orgId);
|
||||
const hasOrgBilling = orgBilling !== null;
|
||||
const platformPricing = await getPlatformPricing();
|
||||
|
||||
// Pending requests that don't yet have a tenant CR. Once the CR
|
||||
// exists, the tenant card carries the live phase, so a separate
|
||||
@@ -317,6 +319,8 @@ export default async function DashboardPage() {
|
||||
userName={user.name}
|
||||
userEmail={user.email}
|
||||
hasOrgBilling={hasOrgBilling}
|
||||
existingOrgBilling={orgBilling}
|
||||
setupFeeChf={platformPricing.tenantSetupFeeChf}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,30 +1,40 @@
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { redirect, notFound } from "next/navigation";
|
||||
import { getSessionUser, canMutate } from "@/lib/session";
|
||||
import { getOrgBilling } from "@/lib/db";
|
||||
import { BillingSettingsForm } from "@/components/settings/billing-settings-form";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { getSessionUser } from "@/lib/session";
|
||||
import { getOrgBilling, getOrgBillingConfig } from "@/lib/db";
|
||||
import { BillingSettingsForm } from "@/components/settings/billing-form";
|
||||
import { SavedCardSection } from "@/components/settings/saved-card-section";
|
||||
|
||||
/**
|
||||
* /settings/billing — view and edit org-scoped billing (Bug 34/35).
|
||||
* /settings/billing — customer-side billing details management.
|
||||
*
|
||||
* Server-side fetches the existing record (if any) and passes it to
|
||||
* the client form. The form posts to PUT /api/billing on submit.
|
||||
* Owner-only by visibility: non-owner members get a 404 (same
|
||||
* response as if the page didn't exist). The link to this page
|
||||
* is also hidden from non-owners on /billing and elsewhere, but
|
||||
* the page itself enforces too — a non-owner who learns the URL
|
||||
* still gets 404, not 403, so the page's existence doesn't leak.
|
||||
*
|
||||
* Access: same gate as the API — owners and platform admins. `user`
|
||||
* role redirects to /settings (which also wouldn't list billing for
|
||||
* them). 403 here would be friendlier than redirect, but the most
|
||||
* likely cause of a `user` landing on this URL is sharing a bookmark
|
||||
* with their owner — silent redirect is gentle.
|
||||
* First-time visitors see an empty form. Subsequent visits see
|
||||
* the current values, editable. Save creates or updates via the
|
||||
* shared upsert path; the row's existence drives whether the
|
||||
* monthly issuance cron will pick this org up.
|
||||
*
|
||||
* Phase 9: also renders the saved-card section (Set up auto-pay /
|
||||
* Visa dot-dot-dot 4242, expires MM/YY / Update card / Disable
|
||||
* auto-pay / Remove card) when billing info is on file, plus a
|
||||
* footer note explaining that bank transfer is available on request.
|
||||
*/
|
||||
export default async function BillingSettingsPage() {
|
||||
const user = await getSessionUser();
|
||||
if (!user) redirect("/login");
|
||||
if (!canMutate(user)) {
|
||||
redirect("/settings");
|
||||
}
|
||||
const t = await getTranslations("settingsBilling");
|
||||
// Non-owners get a 404 — see comment above.
|
||||
if (!user.roles.includes("owner")) notFound();
|
||||
|
||||
const billing = await getOrgBilling(user.orgId);
|
||||
const t = await getTranslations("settingsBilling");
|
||||
const [existing, config] = await Promise.all([
|
||||
getOrgBilling(user.orgId),
|
||||
getOrgBillingConfig(user.orgId),
|
||||
]);
|
||||
|
||||
return (
|
||||
<main className="max-w-3xl mx-auto px-6 py-8">
|
||||
@@ -32,16 +42,30 @@ export default async function BillingSettingsPage() {
|
||||
<h1 className="font-display text-2xl font-semibold accent-rule">
|
||||
{t("title")}
|
||||
</h1>
|
||||
<p className="text-sm text-text-secondary mt-3">{t("subtitle")}</p>
|
||||
<p className="text-sm text-text-secondary mt-3">
|
||||
{user.isPersonal ? t("subtitlePersonal") : t("subtitle")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<BillingSettingsForm
|
||||
initial={billing}
|
||||
isPersonal={user.isPersonal}
|
||||
orgName={user.orgName}
|
||||
userName={user.name}
|
||||
userEmail={user.email}
|
||||
/>
|
||||
<div className="animate-in animate-in-delay-1">
|
||||
<BillingSettingsForm
|
||||
initial={existing}
|
||||
isPersonal={user.isPersonal}
|
||||
/>
|
||||
</div>
|
||||
{/* Phase 9: saved-card section. Only shown once billing info
|
||||
exists — without an address Stripe can't create the
|
||||
customer object, so the "Set up auto-pay" button would
|
||||
fail anyway. We give a clear hint up there if the form
|
||||
is empty (no need to surface the card UI). */}
|
||||
{existing && (
|
||||
<div className="animate-in animate-in-delay-2 mt-8">
|
||||
<SavedCardSection
|
||||
config={config}
|
||||
isPayByInvoice={!!config?.payByInvoice}
|
||||
isPersonal={user.isPersonal}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -20,8 +20,9 @@ export default async function SettingsPage() {
|
||||
const t = await getTranslations("settings");
|
||||
|
||||
// Build the list of settings cards. Each entry has a stable key, a
|
||||
// route, and a visibility predicate. Currently only billing; this
|
||||
// shape leaves headroom for adding more without restructuring.
|
||||
// route, and a visibility predicate. Phase 6 fix5: profile is
|
||||
// visible to every signed-in user (it's their own identity).
|
||||
// Billing stays gated behind canMutate.
|
||||
const sections: Array<{
|
||||
key: string;
|
||||
href: string;
|
||||
@@ -29,6 +30,14 @@ export default async function SettingsPage() {
|
||||
description: string;
|
||||
visible: boolean;
|
||||
}> = [
|
||||
{
|
||||
key: "profile",
|
||||
href: "/settings/profile",
|
||||
title: t("profileTitle"),
|
||||
description: t("profileDescription"),
|
||||
// Every signed-in user can edit their own first/last name.
|
||||
visible: true,
|
||||
},
|
||||
{
|
||||
key: "billing",
|
||||
href: "/settings/billing",
|
||||
|
||||
68
src/app/[locale]/settings/profile/page.tsx
Normal file
68
src/app/[locale]/settings/profile/page.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { getSessionUser } from "@/lib/session";
|
||||
import { getHumanUserDetail } from "@/lib/zitadel";
|
||||
import { ProfileSettingsForm } from "@/components/settings/profile-form";
|
||||
|
||||
/**
|
||||
* /settings/profile — every authenticated user can edit their own
|
||||
* first + last name. Email is shown read-only; changing it requires
|
||||
* verification and is left to ZITADEL's own self-service flow.
|
||||
*
|
||||
* Personal vs company accounts:
|
||||
* - Both can edit their first/last name in ZITADEL.
|
||||
* - Personal accounts get an extra hint: editing the ZITADEL name
|
||||
* does NOT change how the customer's name appears on invoices.
|
||||
* Invoice identity is in org_billing.company_name (the "Full
|
||||
* name" field on /settings/billing) and is intentionally
|
||||
* editable separately, because legal/billing identity may not
|
||||
* match preferred display identity.
|
||||
* - Company accounts see an org-membership hint instead.
|
||||
*
|
||||
* Server-fetches the current profile from ZITADEL via the
|
||||
* service-account PAT so the form starts with the canonical values
|
||||
* rather than whatever happens to be in the JWT (the JWT name might
|
||||
* be stale if the user updated their name in ZITADEL Console).
|
||||
*/
|
||||
export default async function ProfileSettingsPage() {
|
||||
const user = await getSessionUser();
|
||||
if (!user) redirect("/login");
|
||||
|
||||
const t = await getTranslations("settingsProfile");
|
||||
|
||||
let initial = { firstName: "", lastName: "", email: user.email };
|
||||
try {
|
||||
const profile = await getHumanUserDetail(user.id);
|
||||
initial = {
|
||||
firstName: profile.givenName,
|
||||
lastName: profile.familyName,
|
||||
email: profile.email || user.email,
|
||||
};
|
||||
} catch (e) {
|
||||
// Identity provider unreachable: render the form with whatever
|
||||
// we know from the session. The session has a combined `name`,
|
||||
// not split parts, so we leave first/last empty and let the user
|
||||
// re-enter. Server logs catch the underlying failure.
|
||||
console.error("ProfileSettingsPage: getHumanUserDetail failed:", e);
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="max-w-3xl mx-auto px-6 py-8">
|
||||
<div className="mb-8 animate-in">
|
||||
<h1 className="font-display text-2xl font-semibold accent-rule">
|
||||
{t("title")}
|
||||
</h1>
|
||||
<p className="text-sm text-text-secondary mt-3">
|
||||
{user.isPersonal ? t("subtitlePersonal") : t("subtitle")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="animate-in animate-in-delay-1">
|
||||
<ProfileSettingsForm
|
||||
initial={initial}
|
||||
isPersonal={user.isPersonal}
|
||||
orgName={user.orgName}
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -4,14 +4,12 @@ import {
|
||||
getTenantRequestById,
|
||||
updateTenantRequestStatus,
|
||||
clearEncryptedSecrets,
|
||||
recordTenantCreated,
|
||||
recordSkillEvents,
|
||||
recordSuspensionEvent,
|
||||
} from "@/lib/db";
|
||||
import { createTenant, patchTenantSpec, setTenantAnnotation } from "@/lib/k8s";
|
||||
import { sendApprovalEmail, sendResumeApprovalEmail } from "@/lib/email";
|
||||
import { decryptSecrets } from "@/lib/crypto";
|
||||
import { writePackageSecrets } from "@/lib/openbao";
|
||||
import { createRoute as createRelayRoute } from "@/lib/threema-relay";
|
||||
import {
|
||||
getDefaultSoulMd,
|
||||
getDefaultAgentsMd,
|
||||
@@ -88,23 +86,6 @@ export async function POST(
|
||||
}
|
||||
try {
|
||||
await patchTenantSpec(tenantRequest.tenantName, { suspend: false });
|
||||
|
||||
// Billing — Phase 1: record the resume so monthly proration
|
||||
// counts the suspended segment correctly. Best-effort; if
|
||||
// logging fails, the approval still succeeds.
|
||||
try {
|
||||
await recordSuspensionEvent(
|
||||
tenantRequest.tenantName,
|
||||
tenantRequest.zitadelOrgId,
|
||||
"resumed"
|
||||
);
|
||||
} catch (e) {
|
||||
console.error(
|
||||
"billing: failed to record resumed suspension event:",
|
||||
e
|
||||
);
|
||||
}
|
||||
|
||||
// Clear the annotation that pauses the operator's 60-day TTL.
|
||||
// Best-effort — annotation cleanup is also done by the operator
|
||||
// when it sees suspend=false on the next reconcile (it clears
|
||||
@@ -197,6 +178,29 @@ export async function POST(
|
||||
? tenantRequest.contactName || "Assistant"
|
||||
: tenantRequest.companyName;
|
||||
|
||||
// Phase 9b: split the customer's initial channel-user ids into
|
||||
// (a) ids the operator needs in spec.channelUsers (telegram,
|
||||
// discord, …) — passed straight into createTenant
|
||||
// (b) Threema ids that ALSO need a relay route registered so
|
||||
// inbound messages reach this tenant. Threema is in (a)
|
||||
// AND (b): spec.channelUsers tells the operator the id is
|
||||
// authorized; the relay's route maps inbound traffic from
|
||||
// that id to this tenant.
|
||||
const initialChannelUsers = tenantRequest.channelUsers ?? {};
|
||||
// Strip channels the customer didn't actually enable (defensive
|
||||
// — the wizard already filters this, but the row could carry
|
||||
// stale data if the customer edited their request post-submit).
|
||||
const filteredChannelUsers: Record<string, string[]> = {};
|
||||
for (const [channel, ids] of Object.entries(initialChannelUsers)) {
|
||||
if (!packages.includes(channel)) continue;
|
||||
const cleaned = (ids ?? [])
|
||||
.map((s) => (s ?? "").trim())
|
||||
.filter((s) => s.length > 0);
|
||||
if (cleaned.length > 0) {
|
||||
filteredChannelUsers[channel] = cleaned;
|
||||
}
|
||||
}
|
||||
|
||||
await createTenant(
|
||||
tenantName,
|
||||
{
|
||||
@@ -204,6 +208,9 @@ export async function POST(
|
||||
agentName: tenantRequest.agentName,
|
||||
packages,
|
||||
workspaceFiles,
|
||||
...(Object.keys(filteredChannelUsers).length > 0
|
||||
? { channelUsers: filteredChannelUsers }
|
||||
: {}),
|
||||
},
|
||||
{
|
||||
"pieced.ch/zitadel-org-id": tenantRequest.zitadelOrgId,
|
||||
@@ -219,33 +226,33 @@ export async function POST(
|
||||
}
|
||||
);
|
||||
|
||||
// Billing — Phase 1: record the tenant's creation and initial
|
||||
// package state. Anchored at "now" rather than the CR's
|
||||
// creationTimestamp because we don't get the timestamp back from
|
||||
// createTenant — the few-millisecond skew vs the CR's actual
|
||||
// creationTimestamp is irrelevant for monthly billing.
|
||||
//
|
||||
// Best-effort: tracking failures must never block provisioning.
|
||||
// The backfill helper can repair any gaps later if needed.
|
||||
const billingAnchor = new Date();
|
||||
try {
|
||||
await recordTenantCreated(
|
||||
tenantName,
|
||||
tenantRequest.zitadelOrgId,
|
||||
billingAnchor
|
||||
);
|
||||
await recordSkillEvents(
|
||||
tenantName,
|
||||
tenantRequest.zitadelOrgId,
|
||||
packages,
|
||||
[],
|
||||
billingAnchor
|
||||
);
|
||||
} catch (e) {
|
||||
console.error(
|
||||
"billing: failed to record tenant creation / initial skill events:",
|
||||
e
|
||||
);
|
||||
// Threema: register relay routes for each id the customer
|
||||
// entered. Best-effort — a route failure doesn't unwind the
|
||||
// tenant creation (admin can retry from the tenant page later).
|
||||
// The Threema package itself isn't enabled on the tenant until
|
||||
// the customer toggles it from the tenant detail page (which
|
||||
// also mints the per-tenant token); the routes here pre-warm
|
||||
// the relay so the first toggle works without re-typing the id.
|
||||
if (
|
||||
packages.includes("threema") &&
|
||||
filteredChannelUsers.threema &&
|
||||
filteredChannelUsers.threema.length > 0
|
||||
) {
|
||||
for (const tid of filteredChannelUsers.threema) {
|
||||
try {
|
||||
const res = await createRelayRoute(tenantName, tid);
|
||||
if (!res.ok) {
|
||||
console.warn(
|
||||
`[approve] Threema route create for tenant=${tenantName} id=${tid} returned not-ok: ${res.message}`
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(
|
||||
`[approve] Threema route create threw for tenant=${tenantName} id=${tid}:`,
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Step 5: Update request status — clear admin notes on re-approval
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { requirePlatformRole } from "@/lib/session";
|
||||
import { getTenantRequestById, updateTenantRequestStatus } from "@/lib/db";
|
||||
import {
|
||||
getInvoiceById,
|
||||
getTenantRequestById,
|
||||
updateTenantRequestStatus,
|
||||
} from "@/lib/db";
|
||||
import { setTenantAnnotation } from "@/lib/k8s";
|
||||
import { sendRejectionEmail, sendResumeRejectionEmail } from "@/lib/email";
|
||||
import { refundInvoice, RefundNotAllowedError } from "@/lib/billing";
|
||||
import type { SessionUser } from "@/types";
|
||||
|
||||
/**
|
||||
* POST /api/admin/requests/[id]/reject
|
||||
@@ -14,13 +20,23 @@ import { sendRejectionEmail, sendResumeRejectionEmail } from "@/lib/email";
|
||||
* suspendedAt — rejection doesn't reset it. The customer can submit
|
||||
* a fresh resume request later if circumstances change, but that
|
||||
* starts a new pending row and re-stamps the annotation.
|
||||
*
|
||||
* Phase 9b: provision rejections that have a linked paid setup
|
||||
* invoice (setup_invoice_id) trigger an automatic full refund via
|
||||
* the existing refundInvoice flow. The refund creates a credit
|
||||
* note + Stripe refund + customer email — same paper trail any
|
||||
* post-payment refund would have. Best-effort: a refund failure
|
||||
* does NOT block the rejection (admin can re-refund manually via
|
||||
* the invoice detail page if needed), but it's logged and surfaced
|
||||
* in the response so admin sees what happened.
|
||||
*/
|
||||
export async function POST(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
let user: SessionUser;
|
||||
try {
|
||||
await requirePlatformRole();
|
||||
user = await requirePlatformRole();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
@@ -65,6 +81,63 @@ export async function POST(
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 9b: refund the setup-fee invoice if one is linked. Only
|
||||
// applies to provision rejections; resume requests never have a
|
||||
// setup_invoice_id. Skip silently if no invoice is linked (e.g.
|
||||
// the request was created before Phase 9b shipped, or the setup
|
||||
// fee was 0).
|
||||
const refundSummary: {
|
||||
attempted: boolean;
|
||||
succeeded: boolean;
|
||||
error?: string;
|
||||
} = { attempted: false, succeeded: false };
|
||||
if (
|
||||
tenantRequest.requestType === "provision" &&
|
||||
tenantRequest.setupInvoiceId
|
||||
) {
|
||||
refundSummary.attempted = true;
|
||||
try {
|
||||
// refundInvoice expects an explicit CHF amount (no "full"
|
||||
// sentinel). Compute the remaining refundable amount as
|
||||
// total minus what's already been refunded. For a fresh
|
||||
// setup-fee invoice this is just totalChf, but the formula
|
||||
// is robust if admin had partially refunded earlier (rare
|
||||
// but possible — same invoice could in theory get a manual
|
||||
// partial refund, then a rejection).
|
||||
const inv = await getInvoiceById(tenantRequest.setupInvoiceId);
|
||||
if (!inv) {
|
||||
throw new Error(
|
||||
`Linked setup invoice ${tenantRequest.setupInvoiceId} not found`
|
||||
);
|
||||
}
|
||||
const remaining = Math.round(
|
||||
(inv.totalChf - (inv.refundedTotalChf ?? 0)) * 100
|
||||
) / 100;
|
||||
if (remaining <= 0) {
|
||||
refundSummary.succeeded = true; // nothing to refund — treat as success
|
||||
} else {
|
||||
await refundInvoice({
|
||||
invoiceId: tenantRequest.setupInvoiceId,
|
||||
amountChf: remaining,
|
||||
reason: adminNotes
|
||||
? `Tenant request rejected: ${adminNotes}`
|
||||
: "Tenant request rejected",
|
||||
refundedBy: user.id,
|
||||
});
|
||||
refundSummary.succeeded = true;
|
||||
}
|
||||
} catch (e: any) {
|
||||
refundSummary.error =
|
||||
e instanceof RefundNotAllowedError
|
||||
? e.message
|
||||
: (e?.message ?? "refund failed");
|
||||
console.error(
|
||||
`Setup-fee refund failed for request ${id} (invoice ${tenantRequest.setupInvoiceId}):`,
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Notify customer. Resume requests get a different email — the
|
||||
// tenant already exists; copy needs to mention "stays suspended" and
|
||||
// the 60-day retention deadline. Provision rejections use the
|
||||
@@ -88,5 +161,6 @@ export async function POST(
|
||||
return NextResponse.json({
|
||||
message: "Request rejected.",
|
||||
request: updated,
|
||||
refund: refundSummary,
|
||||
});
|
||||
}
|
||||
|
||||
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",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getSessionUser, canMutate } from "@/lib/session";
|
||||
import {
|
||||
getInvoiceById,
|
||||
getTenantRequestById,
|
||||
updateTenantRequestStatus,
|
||||
updateTenantRequestEditableFields,
|
||||
@@ -9,6 +10,8 @@ import { encryptSecrets } from "@/lib/crypto";
|
||||
import { setTenantAnnotation } from "@/lib/k8s";
|
||||
import { onboardingSchema } from "@/lib/validation";
|
||||
import { safeError } from "@/lib/errors";
|
||||
import { refundInvoice, RefundNotAllowedError } from "@/lib/billing";
|
||||
import type { SessionUser, TenantRequest } from "@/types";
|
||||
|
||||
/**
|
||||
* Customer-side controls for a single tenant_request row.
|
||||
@@ -29,7 +32,7 @@ async function loadAuthorized(
|
||||
id: string
|
||||
): Promise<
|
||||
| { error: NextResponse }
|
||||
| { req: Awaited<ReturnType<typeof getTenantRequestById>>; }
|
||||
| { req: TenantRequest; user: SessionUser }
|
||||
> {
|
||||
const user = await getSessionUser();
|
||||
if (!user) {
|
||||
@@ -55,7 +58,7 @@ async function loadAuthorized(
|
||||
error: NextResponse.json({ error: "Not found" }, { status: 404 }),
|
||||
};
|
||||
}
|
||||
return { req: tr };
|
||||
return { req: tr, user };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -93,6 +96,50 @@ export async function DELETE(
|
||||
try {
|
||||
await updateTenantRequestStatus(id, "cancelled");
|
||||
|
||||
// Phase 9b: a 'pending' provision request has already had its
|
||||
// setup fee charged (the order-time Checkout completed before
|
||||
// the webhook flipped it to 'pending'). Cancelling it must
|
||||
// refund that payment, exactly as an admin rejection does.
|
||||
// Resume requests never carry a setup_invoice_id, so this only
|
||||
// fires for provision orders. Best-effort: a refund failure is
|
||||
// logged + surfaced but doesn't block the cancellation (admin
|
||||
// can refund manually from the invoice page).
|
||||
let refund: { attempted: boolean; succeeded: boolean; error?: string } = {
|
||||
attempted: false,
|
||||
succeeded: false,
|
||||
};
|
||||
if (tr.requestType === "provision" && tr.setupInvoiceId) {
|
||||
refund.attempted = true;
|
||||
try {
|
||||
const inv = await getInvoiceById(tr.setupInvoiceId);
|
||||
if (!inv) {
|
||||
throw new Error(`Linked setup invoice ${tr.setupInvoiceId} not found`);
|
||||
}
|
||||
const remaining =
|
||||
Math.round((inv.totalChf - (inv.refundedTotalChf ?? 0)) * 100) / 100;
|
||||
if (remaining <= 0) {
|
||||
refund.succeeded = true; // nothing left to refund
|
||||
} else {
|
||||
await refundInvoice({
|
||||
invoiceId: tr.setupInvoiceId,
|
||||
amountChf: remaining,
|
||||
reason: "Order cancelled by customer",
|
||||
refundedBy: loaded.user!.id,
|
||||
});
|
||||
refund.succeeded = true;
|
||||
}
|
||||
} catch (e: any) {
|
||||
refund.error =
|
||||
e instanceof RefundNotAllowedError
|
||||
? e.message
|
||||
: (e?.message ?? "refund failed");
|
||||
console.error(
|
||||
`Setup-fee refund failed for cancelled request ${id} (invoice ${tr.setupInvoiceId}):`,
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Customer cancels their own pending resume request: clear the
|
||||
// operator-side annotation so the 60-day TTL resumes counting.
|
||||
// Best-effort — the operator handles missing annotation gracefully.
|
||||
@@ -111,7 +158,7 @@ export async function DELETE(
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ message: "Request cancelled.", id });
|
||||
return NextResponse.json({ message: "Request cancelled.", id, refund });
|
||||
} catch (e: any) {
|
||||
console.error("Failed to cancel request:", e);
|
||||
return NextResponse.json(
|
||||
|
||||
@@ -2,11 +2,14 @@ import { NextRequest, NextResponse } from "next/server";
|
||||
import { getSessionUser, canMutate } from "@/lib/session";
|
||||
import {
|
||||
createTenantRequest,
|
||||
createTenantRequestPendingPayment,
|
||||
deletePendingPaymentRequest,
|
||||
getTenantRequestById,
|
||||
listTenantRequestsByOrgId,
|
||||
listActiveTenantRequestsByOrgId,
|
||||
getMostRecentApprovedRequestForOrg,
|
||||
getOrgBilling,
|
||||
getPlatformPricing,
|
||||
upsertOrgBilling,
|
||||
} from "@/lib/db";
|
||||
import { getTenant, listTenants } from "@/lib/k8s";
|
||||
@@ -19,7 +22,18 @@ import { sendAdminNotificationEmail } from "@/lib/email";
|
||||
import { encryptSecrets } from "@/lib/crypto";
|
||||
import { isPersonalOrgName } from "@/lib/personal-org";
|
||||
import { onboardingSchema, billingAddressSchema } from "@/lib/validation";
|
||||
import type { OnboardingInput, PiecedTenant, TenantRequest } from "@/types";
|
||||
import {
|
||||
createSetupFeeCheckoutSession,
|
||||
ensureStripeCustomerForOrg,
|
||||
} from "@/lib/stripe";
|
||||
import { createTenantSetupFeeInvoice, voidInvoice } from "@/lib/billing";
|
||||
import { deriveTenantName } from "@/lib/tenant-naming";
|
||||
import type {
|
||||
InvoiceBillingSnapshot,
|
||||
OnboardingInput,
|
||||
PiecedTenant,
|
||||
TenantRequest,
|
||||
} from "@/types";
|
||||
import { z } from "zod";
|
||||
|
||||
/**
|
||||
@@ -194,6 +208,7 @@ export async function POST(request: Request) {
|
||||
|
||||
const input: OnboardingInput & {
|
||||
packageSecrets?: Record<string, Record<string, string>>;
|
||||
channelUsers?: Record<string, string[]>;
|
||||
} = parsed.data;
|
||||
|
||||
// Look up an existing approved request for this org to inherit
|
||||
@@ -252,11 +267,24 @@ export async function POST(request: Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// For follow-up instances, prefer the on-file company name and contact
|
||||
// details; the user can't change those by re-typing them in the wizard.
|
||||
// The audit copy of company name on this request stays inherited
|
||||
// from the first request in the org — it's a historical snapshot
|
||||
// of the company name at the time the request was created, and
|
||||
// org_billing is now the canonical source for current values.
|
||||
//
|
||||
// Phase 6 fix4: contactName and contactEmail are NOT inherited.
|
||||
// They identify whoever submitted THIS specific request (drives
|
||||
// admin display, support ticket routing, and email greetings).
|
||||
// The previous "prior?.contactName ?? user.name" pattern locked
|
||||
// the contact to whoever first onboarded the org, which broke for
|
||||
// any subsequent submission by a different user — admin saw the
|
||||
// wrong name, support emails went to the wrong person, and the
|
||||
// actual submitter had no way to correct it because the wizard
|
||||
// doesn't expose a contact-name input. The fix is simply to use
|
||||
// the current session user every time.
|
||||
const companyName = prior?.companyName ?? user.orgName;
|
||||
const contactName = prior?.contactName ?? user.name;
|
||||
const contactEmail = prior?.contactEmail ?? user.email;
|
||||
const contactName = user.name;
|
||||
const contactEmail = user.email;
|
||||
|
||||
// Bug 35: org-scoped billing.
|
||||
//
|
||||
@@ -389,7 +417,64 @@ export async function POST(request: Request) {
|
||||
);
|
||||
}
|
||||
|
||||
const tenantRequest = await createTenantRequest({
|
||||
// Look up the setup fee. If it's 0 we skip the Checkout flow
|
||||
// entirely and create a normal pending request (same as the
|
||||
// pre-Phase-9b behaviour).
|
||||
const platformPricing = await getPlatformPricing();
|
||||
const setupFeeChf = platformPricing.tenantSetupFeeChf;
|
||||
|
||||
// ZERO-FEE PATH ---------------------------------------------------
|
||||
// No payment to collect. Create the request directly in 'pending'
|
||||
// status (same as the pre-Phase-9b flow) and notify admin. The
|
||||
// wizard treats this response identically to its previous
|
||||
// success path.
|
||||
if (setupFeeChf <= 0) {
|
||||
const tenantRequest = await createTenantRequest({
|
||||
zitadelOrgId: user.orgId,
|
||||
zitadelUserId: user.id,
|
||||
companyName,
|
||||
instanceName: input.instanceName,
|
||||
contactName,
|
||||
contactEmail,
|
||||
agentName: input.agentName,
|
||||
soulMd: input.soulMd,
|
||||
agentsMd: input.agentsMd,
|
||||
packages: input.packages ?? [],
|
||||
billingAddress,
|
||||
billingNotes,
|
||||
encryptedSecrets,
|
||||
isPersonal,
|
||||
channelUsers: input.channelUsers ?? {},
|
||||
});
|
||||
try {
|
||||
await sendAdminNotificationEmail(
|
||||
tenantRequest.contactEmail,
|
||||
tenantRequest.contactName,
|
||||
tenantRequest.instanceName
|
||||
? `${tenantRequest.companyName} (${tenantRequest.instanceName})`
|
||||
: tenantRequest.companyName
|
||||
);
|
||||
} catch (e) {
|
||||
console.error("Failed to send admin notification:", e);
|
||||
}
|
||||
const allRequests = await listTenantRequestsByOrgId(user.orgId);
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: "Request submitted.",
|
||||
request: publicRequestShape(tenantRequest),
|
||||
orgRequestCount: allRequests.length,
|
||||
},
|
||||
{ status: 201 }
|
||||
);
|
||||
}
|
||||
|
||||
// PAID-FEE PATH ---------------------------------------------------
|
||||
// Insert as 'pending_payment' (tenant_name stays NULL so abandoned
|
||||
// Checkout sessions don't block retries). Build the setup-fee
|
||||
// invoice, then start a Checkout session. The wizard follows the
|
||||
// returned URL; on completion the webhook flips the row to
|
||||
// 'pending' and admin sees it in their queue.
|
||||
const tenantRequest = await createTenantRequestPendingPayment({
|
||||
zitadelOrgId: user.orgId,
|
||||
zitadelUserId: user.id,
|
||||
companyName,
|
||||
@@ -404,32 +489,140 @@ export async function POST(request: Request) {
|
||||
billingNotes,
|
||||
encryptedSecrets,
|
||||
isPersonal,
|
||||
channelUsers: input.channelUsers ?? {},
|
||||
});
|
||||
|
||||
// Notify admin about the new request. For follow-up instances, include
|
||||
// the instance name in the notification so the admin sees what's
|
||||
// being requested without opening the panel.
|
||||
try {
|
||||
await sendAdminNotificationEmail(
|
||||
tenantRequest.contactEmail,
|
||||
tenantRequest.contactName,
|
||||
tenantRequest.instanceName
|
||||
? `${tenantRequest.companyName} (${tenantRequest.instanceName})`
|
||||
: tenantRequest.companyName
|
||||
// Derive the future tenant_name — needed on the invoice line so
|
||||
// tenantHasSetupFeeBilled() in the monthly cron dedup finds the
|
||||
// already-paid setup fee once the K8s tenant exists. The name is
|
||||
// request-id-suffix-derived, so abandoned Checkout retries each
|
||||
// get unique names.
|
||||
const derivedTenantName = deriveTenantName(
|
||||
isPersonal ? "personal" : "company",
|
||||
companyName,
|
||||
tenantRequest.id
|
||||
);
|
||||
|
||||
// Re-fetch orgBilling here: the variable at the top of POST was
|
||||
// captured BEFORE the upsertOrgBilling call upstream (which fires
|
||||
// when the wizard collected the address on first onboarding). For
|
||||
// a brand-new user that initial fetch returned null; only by
|
||||
// re-fetching now do we get the row we just wrote. Existing
|
||||
// customers get the same orgBilling back either way.
|
||||
const billingForOrder = await getOrgBilling(user.orgId);
|
||||
if (!billingForOrder) {
|
||||
console.error(
|
||||
`Paid-fee onboarding path: no org_billing for org ${user.orgId} even after upsert — wizard did not collect address?`
|
||||
);
|
||||
await deletePendingPaymentRequest(tenantRequest.id).catch(() => undefined);
|
||||
return NextResponse.json(
|
||||
{ error: "Billing record missing. Please re-save your billing details." },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
const billingSnapshot: InvoiceBillingSnapshot = {
|
||||
companyName: billingForOrder.companyName,
|
||||
contactName: billingForOrder.contactName ?? null,
|
||||
streetAddress: billingForOrder.streetAddress,
|
||||
postalCode: billingForOrder.postalCode,
|
||||
city: billingForOrder.city,
|
||||
country: billingForOrder.country,
|
||||
vatNumber: billingForOrder.vatNumber ?? null,
|
||||
billingEmail: billingForOrder.billingEmail,
|
||||
notes: billingForOrder.notes ?? null,
|
||||
};
|
||||
|
||||
// Locale for the invoice + PDF — pick from the org's country
|
||||
// using the same heuristic the auto-cron uses.
|
||||
const c = (billingSnapshot.country ?? "").toUpperCase();
|
||||
const invoiceLocale: "de" | "en" | "fr" | "it" = ["CH", "LI", "AT", "DE"].includes(c)
|
||||
? "de"
|
||||
: ["FR", "BE", "LU"].includes(c)
|
||||
? "fr"
|
||||
: c === "IT"
|
||||
? "it"
|
||||
: "en";
|
||||
|
||||
let setupInvoice;
|
||||
try {
|
||||
setupInvoice = await createTenantSetupFeeInvoice({
|
||||
zitadelOrgId: user.orgId,
|
||||
tenantName: derivedTenantName,
|
||||
billingSnapshot,
|
||||
locale: invoiceLocale,
|
||||
paymentMethod: "card",
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("Failed to send admin notification:", e);
|
||||
console.error("Failed to create setup-fee invoice:", e);
|
||||
// Roll back the pending_payment row so the customer can retry
|
||||
// without an orphan record.
|
||||
await deletePendingPaymentRequest(tenantRequest.id).catch(() => undefined);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to prepare setup-fee invoice. Please try again." },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
// For diagnostics: how many other in-flight requests does this org
|
||||
// already have? Useful for the admin queue.
|
||||
const allRequests = await listTenantRequestsByOrgId(user.orgId);
|
||||
// Create the Checkout session. The Stripe customer must exist
|
||||
// before this — ensureStripeCustomerForOrg returns the existing
|
||||
// one (idempotent) since the saved-card setup already created it.
|
||||
let checkoutUrl: string;
|
||||
try {
|
||||
const stripeCustomerId = await ensureStripeCustomerForOrg({
|
||||
zitadelOrgId: user.orgId,
|
||||
companyName: billingSnapshot.companyName,
|
||||
billingEmail: billingSnapshot.billingEmail,
|
||||
address: {
|
||||
line1: billingSnapshot.streetAddress,
|
||||
postalCode: billingSnapshot.postalCode,
|
||||
city: billingSnapshot.city,
|
||||
country: billingSnapshot.country,
|
||||
},
|
||||
});
|
||||
const baseUrl =
|
||||
process.env.APP_BASE_URL ?? "https://app.pieced.ch";
|
||||
const { url } = await createSetupFeeCheckoutSession({
|
||||
invoice: setupInvoice,
|
||||
customerId: stripeCustomerId,
|
||||
baseUrl,
|
||||
tenantRequestId: tenantRequest.id,
|
||||
});
|
||||
checkoutUrl = url;
|
||||
} catch (e) {
|
||||
console.error("Failed to create setup-fee Checkout session:", e);
|
||||
// Roll back BOTH the pending_payment row and the setup invoice
|
||||
// we already created. The invoice was issued in 'open' status
|
||||
// but no payment will ever arrive (Checkout never started), so
|
||||
// void it to keep the ledger clean — an open invoice with no
|
||||
// route to payment would otherwise linger and show up in
|
||||
// arrears reports. Void (not delete) preserves the audit trail
|
||||
// and the void reason. Best-effort: a void failure is logged
|
||||
// but doesn't change the 500 we return.
|
||||
await voidInvoice({
|
||||
invoiceId: setupInvoice.id,
|
||||
reason: "Order abandoned before payment (Checkout could not be started)",
|
||||
voidedBy: user.id,
|
||||
}).catch((ve) =>
|
||||
console.error(
|
||||
`Failed to void orphaned setup invoice ${setupInvoice.id}:`,
|
||||
ve
|
||||
)
|
||||
);
|
||||
await deletePendingPaymentRequest(tenantRequest.id).catch(() => undefined);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to start payment. Please try again." },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
// Don't notify admin yet — the request is invisible to admin
|
||||
// until the webhook flips it to 'pending'. Notification happens
|
||||
// there.
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: "Request submitted.",
|
||||
message: "Redirecting to payment.",
|
||||
request: publicRequestShape(tenantRequest),
|
||||
orgRequestCount: allRequests.length,
|
||||
checkoutUrl,
|
||||
},
|
||||
{ status: 201 }
|
||||
);
|
||||
|
||||
90
src/app/api/settings/billing/route.ts
Normal file
90
src/app/api/settings/billing/route.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { getSessionUser } from "@/lib/session";
|
||||
import { getOrgBilling, upsertOrgBilling } from "@/lib/db";
|
||||
|
||||
/**
|
||||
* GET /api/settings/billing — read the caller's org_billing row.
|
||||
* Returns null if the org hasn't configured billing yet — the
|
||||
* form renders empty and the PUT will create on first save.
|
||||
*
|
||||
* PUT /api/settings/billing — upsert the row.
|
||||
*
|
||||
* Authorization: caller must have role "owner" in their org.
|
||||
* Non-owners get 403 (they shouldn't have reached the page UI
|
||||
* anyway, which hides the link, but the API enforces too — a
|
||||
* non-owner who hits this directly with curl gets refused).
|
||||
*
|
||||
* Personal accounts are inherently their own owner (single-user
|
||||
* org), so user.roles.includes("owner") returns true and they
|
||||
* can manage their own billing.
|
||||
*/
|
||||
|
||||
const upsertSchema = z.object({
|
||||
companyName: z.string().trim().min(1).max(200),
|
||||
// Phase 6 fix: optional "z.Hd." / "Attn:" line. Personal accounts
|
||||
// never send this (the UI hides the field); orgs may set or leave
|
||||
// it empty.
|
||||
contactName: z.string().trim().max(200).optional().nullable(),
|
||||
streetAddress: z.string().trim().min(1).max(200),
|
||||
postalCode: z.string().trim().min(1).max(20),
|
||||
city: z.string().trim().min(1).max(100),
|
||||
// ISO 3166-1 alpha-2. We normalise to uppercase server-side.
|
||||
country: z
|
||||
.string()
|
||||
.trim()
|
||||
.length(2)
|
||||
.regex(/^[A-Za-z]{2}$/, "Use a 2-letter ISO country code (CH, DE, …)"),
|
||||
vatNumber: z.string().trim().max(40).optional().nullable(),
|
||||
billingEmail: z.string().trim().email().max(200),
|
||||
notes: z.string().trim().max(2000).optional().nullable(),
|
||||
});
|
||||
|
||||
function requireOwner(user: { roles: string[] } | null) {
|
||||
if (!user) return false;
|
||||
return user.roles.includes("owner");
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
const user = await getSessionUser();
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
if (!requireOwner(user as any)) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
const billing = await getOrgBilling(user.orgId);
|
||||
return NextResponse.json({ billing });
|
||||
}
|
||||
|
||||
export async function PUT(request: Request) {
|
||||
const user = await getSessionUser();
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
if (!requireOwner(user as any)) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
const body = await request.json().catch(() => ({}));
|
||||
const parsed = upsertSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid request", details: parsed.error.flatten() },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
const data = parsed.data;
|
||||
const billing = await upsertOrgBilling({
|
||||
zitadelOrgId: user.orgId,
|
||||
companyName: data.companyName,
|
||||
contactName: data.contactName ?? null,
|
||||
streetAddress: data.streetAddress,
|
||||
postalCode: data.postalCode,
|
||||
city: data.city,
|
||||
country: data.country.toUpperCase(),
|
||||
vatNumber: data.vatNumber ?? null,
|
||||
billingEmail: data.billingEmail,
|
||||
notes: data.notes ?? null,
|
||||
});
|
||||
return NextResponse.json({ billing });
|
||||
}
|
||||
81
src/app/api/settings/profile/route.ts
Normal file
81
src/app/api/settings/profile/route.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { getSessionUser } from "@/lib/session";
|
||||
import {
|
||||
getHumanUserDetail,
|
||||
updateHumanUserProfile,
|
||||
} from "@/lib/zitadel";
|
||||
|
||||
/**
|
||||
* GET /api/settings/profile — read the caller's ZITADEL profile.
|
||||
* Returns first/last/display name and email. Used by the settings
|
||||
* page server component to populate the form.
|
||||
*
|
||||
* PUT /api/settings/profile — update first + last name. Email is
|
||||
* NOT mutable here — changing email needs verification flow that
|
||||
* ZITADEL's own self-service UI already provides; we don't
|
||||
* duplicate that.
|
||||
*
|
||||
* Authorization: any authenticated user can edit their own profile.
|
||||
* The PAT (ZITADEL_SA_PAT) is used to call the ZITADEL v2 user
|
||||
* service, but only against the caller's own userId. There is no
|
||||
* userId field on the request — it's always derived from the
|
||||
* session, so the route can't be abused to edit other users.
|
||||
*/
|
||||
|
||||
const updateSchema = z.object({
|
||||
firstName: z.string().trim().min(1).max(100),
|
||||
lastName: z.string().trim().min(1).max(100),
|
||||
});
|
||||
|
||||
export async function GET() {
|
||||
const user = await getSessionUser();
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
try {
|
||||
const profile = await getHumanUserDetail(user.id);
|
||||
return NextResponse.json({ profile });
|
||||
} catch (e: any) {
|
||||
// Surface ZITADEL-side failures (e.g. user not found, PAT expired)
|
||||
// as 502 — the portal couldn't reach its identity provider, which
|
||||
// is operationally different from a 4xx on the caller's input.
|
||||
console.error("getHumanUserDetail failed:", e);
|
||||
return NextResponse.json(
|
||||
{ error: "Could not load profile from identity provider" },
|
||||
{ status: 502 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(request: Request) {
|
||||
const user = await getSessionUser();
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
const body = await request.json().catch(() => ({}));
|
||||
const parsed = updateSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid request", details: parsed.error.flatten() },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
try {
|
||||
const result = await updateHumanUserProfile({
|
||||
userId: user.id,
|
||||
givenName: parsed.data.firstName,
|
||||
familyName: parsed.data.lastName,
|
||||
});
|
||||
return NextResponse.json({
|
||||
displayName: result.displayName,
|
||||
changeDate: result.changeDate,
|
||||
});
|
||||
} catch (e: any) {
|
||||
console.error("updateHumanUserProfile failed:", e);
|
||||
return NextResponse.json(
|
||||
{ error: "Could not update profile in identity provider" },
|
||||
{ status: 502 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,25 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import type Stripe from "stripe";
|
||||
import { getStripeClient, getWebhookSecret } from "@/lib/stripe";
|
||||
import {
|
||||
getPaymentMethodDisplay,
|
||||
getStripeClient,
|
||||
getWebhookSecret,
|
||||
} from "@/lib/stripe";
|
||||
import {
|
||||
getInvoiceByStripePaymentIntent,
|
||||
getInvoiceDetail,
|
||||
getOrgIdByStripeCustomerId,
|
||||
getTenantRequestForSetupFlow,
|
||||
isStripeRefundRecorded,
|
||||
linkTenantRequestSetupPayment,
|
||||
markInvoicePaid,
|
||||
markStripeEventProcessed,
|
||||
setInvoiceStripePaymentIntent,
|
||||
setSavedPaymentMethod,
|
||||
tryRecordStripeEvent,
|
||||
} from "@/lib/db";
|
||||
import { sendAdminNotificationEmail } from "@/lib/email";
|
||||
import { refundInvoice, RefundNotAllowedError } from "@/lib/billing";
|
||||
|
||||
/**
|
||||
* POST /api/stripe/webhook
|
||||
@@ -158,6 +171,14 @@ export async function POST(request: Request) {
|
||||
async function handleCheckoutCompleted(
|
||||
session: Stripe.Checkout.Session
|
||||
): Promise<void> {
|
||||
// Phase 9: setup-mode sessions don't pay anything — they
|
||||
// authorize a card for off-session future charges. The
|
||||
// PaymentMethod is attached to the customer and the session's
|
||||
// setup_intent.payment_method holds the id we save.
|
||||
if (session.mode === "setup") {
|
||||
await handleSetupCompleted(session);
|
||||
return;
|
||||
}
|
||||
// Defensive: paid sessions are what we want; sessions can also
|
||||
// complete in "unpaid" state (rare for mode=payment, more common
|
||||
// for async/delayed methods like SEPA). Only flip the invoice
|
||||
@@ -206,16 +227,320 @@ async function handleCheckoutCompleted(
|
||||
console.log(
|
||||
`Invoice ${invoiceId} marked paid via Stripe (session ${session.id}, intent ${paymentIntentId}).`
|
||||
);
|
||||
|
||||
// Phase 9b: if this Checkout was the setup-fee flow for a tenant
|
||||
// order, flip the linked tenant_request row from 'pending_payment'
|
||||
// to 'pending' so admin sees it in the queue. The invoice line's
|
||||
// tenant_name has the derived name; we also stamp it on the
|
||||
// request row so admin can act on it. linkTenantRequestSetupPayment
|
||||
// is idempotent (no-op if status already advanced).
|
||||
const flow = session.metadata?.flow;
|
||||
const tenantRequestId = session.metadata?.tenant_request_id;
|
||||
if (flow === "setup_fee" && tenantRequestId) {
|
||||
try {
|
||||
// The derived tenant_name lives on the invoice line we just
|
||||
// marked paid. Fetch via getInvoiceDetail (existing helper).
|
||||
const detail = await getInvoiceDetail(invoiceId);
|
||||
const setupLine = detail?.lines.find(
|
||||
(l) => l.kind === "tenant_setup" && l.tenantName
|
||||
);
|
||||
if (!setupLine || !setupLine.tenantName) {
|
||||
console.error(
|
||||
`Setup-fee webhook for invoice ${invoiceId} has no tenant_setup line with tenant_name; cannot link request ${tenantRequestId}.`
|
||||
);
|
||||
} else {
|
||||
const linked = await linkTenantRequestSetupPayment({
|
||||
requestId: tenantRequestId,
|
||||
tenantName: setupLine.tenantName,
|
||||
setupInvoiceId: invoiceId,
|
||||
});
|
||||
if (linked) {
|
||||
console.log(
|
||||
`Tenant request ${tenantRequestId} flipped to 'pending' (tenant=${setupLine.tenantName}, setup invoice=${invoiceId}).`
|
||||
);
|
||||
// Notify admin now that the payment cleared. Best-effort —
|
||||
// a failure here doesn't undo the linkage.
|
||||
try {
|
||||
const req = await getTenantRequestForSetupFlow(tenantRequestId);
|
||||
if (req) {
|
||||
await sendAdminNotificationEmail(
|
||||
req.contactEmail,
|
||||
req.contactName,
|
||||
req.instanceName
|
||||
? `${req.companyName} (${req.instanceName})`
|
||||
: req.companyName
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(
|
||||
`Failed to send admin notification for tenant request ${tenantRequestId}:`,
|
||||
e
|
||||
);
|
||||
}
|
||||
} else {
|
||||
console.log(
|
||||
`Tenant request ${tenantRequestId} not in 'pending_payment' (likely already advanced); webhook is a no-op.`
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(
|
||||
`Setup-fee webhook for invoice ${invoiceId} failed to link tenant request ${tenantRequestId}:`,
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 9b: any payment-mode Checkout that set setup_future_usage
|
||||
// attaches the resulting PaymentMethod to the customer. Read it
|
||||
// back and save the display fields against the org's config —
|
||||
// same behaviour as the setup-mode webhook does. This is what
|
||||
// makes the setup-fee Checkout also "refresh saved card" without
|
||||
// an extra step, and it's also what Phase 9b-2's manual-pay
|
||||
// with setup_future_usage will rely on.
|
||||
try {
|
||||
if (paymentIntentId) {
|
||||
const stripe = getStripeClient();
|
||||
const pi = await stripe.paymentIntents.retrieve(paymentIntentId);
|
||||
const pmId =
|
||||
typeof pi.payment_method === "string"
|
||||
? pi.payment_method
|
||||
: pi.payment_method?.id;
|
||||
const customerId =
|
||||
typeof pi.customer === "string"
|
||||
? pi.customer
|
||||
: pi.customer?.id;
|
||||
// setup_future_usage on the PI tells us this payment also
|
||||
// saved the card. If it's not set, this was a one-off pay
|
||||
// and we shouldn't overwrite anything.
|
||||
if (pmId && customerId && pi.setup_future_usage === "off_session") {
|
||||
const orgId = await getOrgIdByStripeCustomerId(customerId);
|
||||
if (orgId) {
|
||||
const display = await getPaymentMethodDisplay(pmId);
|
||||
await setSavedPaymentMethod({
|
||||
zitadelOrgId: orgId,
|
||||
stripeCustomerId: customerId,
|
||||
paymentMethodId: pmId,
|
||||
brand: display.brand,
|
||||
last4: display.last4,
|
||||
expMonth: display.expMonth,
|
||||
expYear: display.expYear,
|
||||
});
|
||||
// Also tell Stripe this PM is the customer's default for
|
||||
// future invoice charges. Best-effort.
|
||||
try {
|
||||
await stripe.customers.update(customerId, {
|
||||
invoice_settings: { default_payment_method: pmId },
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn(
|
||||
`Failed to set default_payment_method on customer ${customerId}:`,
|
||||
e
|
||||
);
|
||||
}
|
||||
console.log(
|
||||
`Saved PaymentMethod ${pmId} (${display.brand} ${display.last4}) for org ${orgId} via payment-mode Checkout.`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(
|
||||
`Failed to save PaymentMethod from payment-mode Checkout (session ${session.id}):`,
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Phase 9: handle setup-mode Checkout completion. The customer
|
||||
* authorized a card for future off-session charges; persist the
|
||||
* display fields against their org so the portal can show the
|
||||
* saved card and use it for auto-charge.
|
||||
*
|
||||
* The session carries:
|
||||
* - mode: 'setup'
|
||||
* - customer: 'cus_xxx' (the Stripe customer id we created)
|
||||
* - setup_intent: 'seti_xxx' (the SetupIntent — has payment_method)
|
||||
*
|
||||
* We look up which org owns the customer (via
|
||||
* org_billing_config.stripe_customer_id), fetch the SetupIntent
|
||||
* to find the resulting PaymentMethod id, then fetch the PM for
|
||||
* its display fields. Three Stripe round-trips total — acceptable
|
||||
* for a one-off setup event.
|
||||
*/
|
||||
async function handleSetupCompleted(
|
||||
session: Stripe.Checkout.Session
|
||||
): Promise<void> {
|
||||
const customerId =
|
||||
typeof session.customer === "string"
|
||||
? session.customer
|
||||
: session.customer?.id;
|
||||
if (!customerId) {
|
||||
console.error(
|
||||
`Setup session ${session.id} completed without a customer; cannot link to org.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
const orgId = await getOrgIdByStripeCustomerId(customerId);
|
||||
if (!orgId) {
|
||||
console.error(
|
||||
`Setup session ${session.id} for customer ${customerId} has no matching org.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
const setupIntentId =
|
||||
typeof session.setup_intent === "string"
|
||||
? session.setup_intent
|
||||
: session.setup_intent?.id;
|
||||
if (!setupIntentId) {
|
||||
console.error(
|
||||
`Setup session ${session.id} completed without a setup_intent id.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
// Read the SetupIntent for the resulting PaymentMethod id.
|
||||
const stripe = getStripeClient();
|
||||
const setupIntent = await stripe.setupIntents.retrieve(setupIntentId);
|
||||
const paymentMethodId =
|
||||
typeof setupIntent.payment_method === "string"
|
||||
? setupIntent.payment_method
|
||||
: setupIntent.payment_method?.id;
|
||||
if (!paymentMethodId) {
|
||||
console.error(
|
||||
`Setup session ${session.id}: setup_intent ${setupIntentId} has no payment_method.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
// Fetch the PM details for display columns.
|
||||
const display = await getPaymentMethodDisplay(paymentMethodId);
|
||||
await setSavedPaymentMethod({
|
||||
zitadelOrgId: orgId,
|
||||
stripeCustomerId: customerId,
|
||||
paymentMethodId,
|
||||
brand: display.brand,
|
||||
last4: display.last4,
|
||||
expMonth: display.expMonth,
|
||||
expYear: display.expYear,
|
||||
});
|
||||
// Also tell Stripe this PM is the customer's default for invoice
|
||||
// payments — so a future stripe.paymentIntents.create against
|
||||
// this customer without an explicit payment_method picks it up.
|
||||
// Best-effort: a failure here doesn't undo the save (we have the
|
||||
// pm id, we can pass it explicitly when charging in Phase 9b).
|
||||
try {
|
||||
await stripe.customers.update(customerId, {
|
||||
invoice_settings: { default_payment_method: paymentMethodId },
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn(
|
||||
`Setup session ${session.id}: failed to set default_payment_method on customer ${customerId}; will pass pm id explicitly on charges.`,
|
||||
e
|
||||
);
|
||||
}
|
||||
console.log(
|
||||
`Saved PaymentMethod ${paymentMethodId} (${display.brand} ${display.last4}) for org ${orgId}.`
|
||||
);
|
||||
}
|
||||
|
||||
async function handleChargeRefunded(charge: Stripe.Charge): Promise<void> {
|
||||
// v1 scope: log only. Refunds always go through Stripe → admin
|
||||
// initiates them in the dashboard. Updating our invoice status
|
||||
// to 'void' or partial-credit needs more product thinking
|
||||
// (partial refunds? credit notes? VAT corrections?). Phase 7.
|
||||
console.log(
|
||||
`Charge ${charge.id} refunded (amount ${charge.amount_refunded} ${charge.currency}); no portal-side state change.`
|
||||
);
|
||||
// Phase 7: mirror Stripe refunds into the portal so credit notes
|
||||
// are issued for refunds initiated in the Stripe Dashboard. For
|
||||
// refunds initiated via /api/admin/.../refund, this handler is a
|
||||
// no-op (each refund's stripe_refund_id is already recorded
|
||||
// before the webhook lands — refundInvoice records it
|
||||
// synchronously after the Stripe API call).
|
||||
//
|
||||
// A charge can have multiple refund objects (multiple partial
|
||||
// refunds against the same charge accumulate here). We iterate
|
||||
// and process any that aren't yet recorded in our DB.
|
||||
const paymentIntentId =
|
||||
typeof charge.payment_intent === "string"
|
||||
? charge.payment_intent
|
||||
: charge.payment_intent?.id;
|
||||
if (!paymentIntentId) {
|
||||
console.error(
|
||||
`charge.refunded for charge ${charge.id} has no payment_intent; cannot link to invoice.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
const invoice = await getInvoiceByStripePaymentIntent(paymentIntentId);
|
||||
if (!invoice) {
|
||||
console.error(
|
||||
`charge.refunded for payment_intent ${paymentIntentId} has no matching invoice; ignoring.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
const refundsList = charge.refunds?.data ?? [];
|
||||
if (refundsList.length === 0) {
|
||||
// Some charge.refunded events fire with the refunds list
|
||||
// collapsed (the object includes the aggregated amount_refunded
|
||||
// but the data array can be omitted depending on Stripe's
|
||||
// expansion choices). In that case there's nothing for us to
|
||||
// iterate over here; the actual `refund.created` /
|
||||
// `refund.updated` events carry per-refund detail and we'd need
|
||||
// to enable those in Stripe to handle them. For v1 we log and
|
||||
// rely on the in-portal admin path (refundInvoice) being the
|
||||
// only refund initiator.
|
||||
console.log(
|
||||
`charge.refunded for charge ${charge.id} arrived without refund objects in data; in-portal flow assumed.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
for (const refund of refundsList) {
|
||||
try {
|
||||
// Idempotency: skip refunds we already recorded (either via
|
||||
// portal admin action or a prior webhook delivery).
|
||||
if (await isStripeRefundRecorded(refund.id)) {
|
||||
continue;
|
||||
}
|
||||
const amountChf = (refund.amount ?? 0) / 100;
|
||||
if (amountChf <= 0) continue;
|
||||
// Map Stripe refund status to ours. Anything other than the
|
||||
// canonical four falls through to 'pending' so we don't lose
|
||||
// the record entirely.
|
||||
let status: "pending" | "succeeded" | "failed" | "canceled" = "pending";
|
||||
if (refund.status === "succeeded") status = "succeeded";
|
||||
else if (refund.status === "failed") status = "failed";
|
||||
else if (refund.status === "canceled") status = "canceled";
|
||||
// For refunds that originated in Stripe Dashboard we don't
|
||||
// have a reason to display. Use a sentinel string so the
|
||||
// credit note PDF has something to print. Admin can edit
|
||||
// post-hoc if needed (no UI for that today, but the DB row
|
||||
// is reachable).
|
||||
const reason = refund.reason
|
||||
? `Stripe Dashboard: ${refund.reason}`
|
||||
: "Refund issued via Stripe Dashboard";
|
||||
// refundInvoice with existingStripeRefund: don't call Stripe
|
||||
// again (we'd error since the refund already exists), just
|
||||
// mirror the record into our DB and issue the credit note.
|
||||
await refundInvoice({
|
||||
invoiceId: invoice.id,
|
||||
amountChf,
|
||||
reason,
|
||||
refundedBy: "stripe-webhook",
|
||||
existingStripeRefund: { id: refund.id, status },
|
||||
});
|
||||
console.log(
|
||||
`Mirrored Stripe refund ${refund.id} for invoice ${invoice.invoiceNumber} (CHF ${amountChf.toFixed(2)}).`
|
||||
);
|
||||
} catch (e) {
|
||||
if (e instanceof RefundNotAllowedError) {
|
||||
// The invoice was already fully refunded by an earlier
|
||||
// webhook delivery or by an in-portal action. That's fine.
|
||||
console.log(
|
||||
`Stripe refund ${refund.id}: ${e.message} (already accounted for).`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
// For any other error, log but continue to the next refund —
|
||||
// we don't want one bad refund to block the rest.
|
||||
console.error(
|
||||
`Failed to mirror Stripe refund ${refund.id} for invoice ${invoice.invoiceNumber}:`,
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePaymentFailed(
|
||||
|
||||
537
src/components/admin/billing/custom-invoice-editor.tsx
Normal file
537
src/components/admin/billing/custom-invoice-editor.tsx
Normal file
@@ -0,0 +1,537 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useMemo, useCallback } from "react";
|
||||
import { useRouter } from "next/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">
|
||||
<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 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-white text-sm disabled:opacity-50"
|
||||
type="button"
|
||||
>
|
||||
{busy === "issue" ? t("issuing") : t("editorIssueBtn")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
145
src/components/admin/billing/draft-list.tsx
Normal file
145
src/components/admin/billing/draft-list.tsx
Normal file
@@ -0,0 +1,145 @@
|
||||
"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-white 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-white text-sm"
|
||||
>
|
||||
{t("newInvoiceBtn")}
|
||||
</Link>
|
||||
</div>
|
||||
<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>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -4,33 +4,61 @@ import { useState, Fragment } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Card, CardHeader } from "@/components/ui/card";
|
||||
import type { InvoiceDetail, InvoiceStatus } from "@/types";
|
||||
import type { CreditNote, InvoiceDetail, InvoiceStatus } from "@/types";
|
||||
|
||||
interface Props {
|
||||
detail: InvoiceDetail;
|
||||
/**
|
||||
* Phase 7: credit notes linked to this invoice (voids + refunds).
|
||||
* Empty array when none. Passed from the server page; client
|
||||
* doesn't re-fetch — router.refresh() rebuilds after actions.
|
||||
*/
|
||||
creditNotes?: CreditNote[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the invoice header (status, totals, action bar) then
|
||||
* line items grouped by tenant, then billing snapshot. Actions are
|
||||
* mark-paid (POST), delete (DELETE), PDF download (link to /pdf).
|
||||
* mark-paid (POST), void (POST), refund (POST), delete (DELETE),
|
||||
* PDF download (link to /pdf).
|
||||
*
|
||||
* Phase 7 adds void + refund. The action bar shows:
|
||||
* - status open/overdue → Mark paid, Void, Delete
|
||||
* - status paid → Refund, Delete
|
||||
* - status partially_refunded → Refund (for remainder), Delete
|
||||
* - status fully_refunded / void → Delete only (read-only otherwise)
|
||||
*
|
||||
* On successful action we router.refresh() — the server-side page
|
||||
* re-renders against the new DB state. For delete we navigate
|
||||
* away first.
|
||||
* re-renders against the new DB state, including any new credit
|
||||
* notes.
|
||||
*/
|
||||
export function InvoiceDetailView({ detail }: Props) {
|
||||
export function InvoiceDetailView({ detail, creditNotes = [] }: Props) {
|
||||
const t = useTranslations("adminBilling");
|
||||
const router = useRouter();
|
||||
const { invoice, lines } = detail;
|
||||
|
||||
const [busyAction, setBusyAction] = useState<null | "mark-paid" | "delete">(
|
||||
null
|
||||
);
|
||||
const [busyAction, setBusyAction] = useState<
|
||||
null | "mark-paid" | "delete" | "void" | "refund"
|
||||
>(null);
|
||||
const [actionError, setActionError] = useState("");
|
||||
const [noteInput, setNoteInput] = useState("");
|
||||
const [noteOpen, setNoteOpen] = useState(false);
|
||||
|
||||
// Phase 7 — void modal state
|
||||
const [voidOpen, setVoidOpen] = useState(false);
|
||||
const [voidReason, setVoidReason] = useState("");
|
||||
|
||||
// Phase 7 — refund modal state. Amount defaults to the full
|
||||
// remaining refundable on open.
|
||||
const [refundOpen, setRefundOpen] = useState(false);
|
||||
const [refundAmount, setRefundAmount] = useState("");
|
||||
const [refundReason, setRefundReason] = useState("");
|
||||
|
||||
const remainingRefundable =
|
||||
Math.round(
|
||||
(invoice.totalChf - invoice.refundedTotalChf) * 100
|
||||
) / 100;
|
||||
|
||||
const markPaid = async () => {
|
||||
setActionError("");
|
||||
setBusyAction("mark-paid");
|
||||
@@ -75,6 +103,84 @@ export function InvoiceDetailView({ detail }: Props) {
|
||||
}
|
||||
};
|
||||
|
||||
// Phase 7 — void: marks an unpaid invoice as cancelled and issues
|
||||
// a credit note. Backend rejects if the invoice is paid (use
|
||||
// refund) or already voided/refunded.
|
||||
const voidInvoice = async () => {
|
||||
if (!voidReason.trim()) {
|
||||
setActionError(t("voidReasonRequired"));
|
||||
return;
|
||||
}
|
||||
setActionError("");
|
||||
setBusyAction("void");
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/admin/billing/invoices/${invoice.id}/void`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ reason: voidReason }),
|
||||
}
|
||||
);
|
||||
const j = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(j.error || `HTTP ${res.status}`);
|
||||
setVoidOpen(false);
|
||||
setVoidReason("");
|
||||
router.refresh();
|
||||
} catch (e: any) {
|
||||
setActionError(e.message);
|
||||
} finally {
|
||||
setBusyAction(null);
|
||||
}
|
||||
};
|
||||
|
||||
// Phase 7 — refund: paid invoices only. Amount may be partial;
|
||||
// backend caps at remaining refundable.
|
||||
const refundInvoice = async () => {
|
||||
const amt = parseFloat(refundAmount);
|
||||
if (!isFinite(amt) || amt <= 0) {
|
||||
setActionError(t("refundAmountInvalid"));
|
||||
return;
|
||||
}
|
||||
if (amt - remainingRefundable > 0.005) {
|
||||
setActionError(
|
||||
t("refundAmountExceeds", {
|
||||
max: remainingRefundable.toFixed(2),
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!refundReason.trim()) {
|
||||
setActionError(t("refundReasonRequired"));
|
||||
return;
|
||||
}
|
||||
setActionError("");
|
||||
setBusyAction("refund");
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/admin/billing/invoices/${invoice.id}/refund`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
amountChf: Math.round(amt * 100) / 100,
|
||||
reason: refundReason,
|
||||
}),
|
||||
}
|
||||
);
|
||||
const j = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(j.error || `HTTP ${res.status}`);
|
||||
setRefundOpen(false);
|
||||
setRefundAmount("");
|
||||
setRefundReason("");
|
||||
router.refresh();
|
||||
} catch (e: any) {
|
||||
setActionError(e.message);
|
||||
} finally {
|
||||
setBusyAction(null);
|
||||
}
|
||||
};
|
||||
|
||||
// Group lines by tenant for display (matches PDF layout).
|
||||
const linesByTenant = new Map<string | null, typeof lines>();
|
||||
for (const ln of lines) {
|
||||
@@ -97,10 +203,14 @@ export function InvoiceDetailView({ detail }: Props) {
|
||||
</h1>
|
||||
<div className="flex items-center gap-3 mt-3 text-sm">
|
||||
<StatusPill status={invoice.status} />
|
||||
<span className="text-text-muted">
|
||||
{invoice.periodStart} → {invoice.periodEnd}
|
||||
</span>
|
||||
<span className="text-text-muted">·</span>
|
||||
{invoice.periodStart && invoice.periodEnd && (
|
||||
<>
|
||||
<span className="text-text-muted">
|
||||
{invoice.periodStart} → {invoice.periodEnd}
|
||||
</span>
|
||||
<span className="text-text-muted">·</span>
|
||||
</>
|
||||
)}
|
||||
<span className="text-text-muted">
|
||||
{t("dueOnLabel")}: {invoice.dueAt}
|
||||
</span>
|
||||
@@ -171,6 +281,144 @@ export function InvoiceDetailView({ detail }: Props) {
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{/* Phase 7 — Void: visible only for open/overdue invoices.
|
||||
Same gating as Mark Paid but mutually exclusive with it
|
||||
via the chosen action. Opens a small inline form so
|
||||
the admin can enter a reason; reason is required and
|
||||
lands on the credit-note PDF. */}
|
||||
{(invoice.status === "open" || invoice.status === "overdue") && (
|
||||
<>
|
||||
{!voidOpen ? (
|
||||
<button
|
||||
onClick={() => {
|
||||
setVoidOpen(true);
|
||||
setNoteOpen(false);
|
||||
setRefundOpen(false);
|
||||
}}
|
||||
disabled={busyAction !== null}
|
||||
className="px-4 py-2 rounded-md border border-error text-error text-sm disabled:opacity-50 hover:bg-error/10"
|
||||
>
|
||||
{t("voidBtn")}
|
||||
</button>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 flex-grow">
|
||||
<input
|
||||
type="text"
|
||||
placeholder={t("voidReasonPlaceholder")}
|
||||
value={voidReason}
|
||||
onChange={(e) => setVoidReason(e.target.value)}
|
||||
maxLength={500}
|
||||
className="flex-grow px-3 py-1.5 rounded-md border border-border bg-surface-2 text-sm"
|
||||
autoFocus
|
||||
/>
|
||||
<button
|
||||
onClick={voidInvoice}
|
||||
disabled={busyAction !== null}
|
||||
className="px-3 py-1.5 rounded-md bg-error text-white text-sm disabled:opacity-50"
|
||||
>
|
||||
{busyAction === "void" ? t("saving") : t("confirmVoid")}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setVoidOpen(false);
|
||||
setVoidReason("");
|
||||
}}
|
||||
className="px-3 py-1.5 rounded-md border border-border text-sm"
|
||||
>
|
||||
{t("cancel")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{/* Phase 7 — Refund: paid invoices, including ones already
|
||||
partially refunded (as long as some refundable amount
|
||||
remains). Opens an inline form with amount + reason.
|
||||
The remaining-refundable hint helps admin pick the
|
||||
right number. */}
|
||||
{(invoice.status === "paid" ||
|
||||
invoice.status === "partially_refunded") &&
|
||||
remainingRefundable > 0 && (
|
||||
<>
|
||||
{!refundOpen ? (
|
||||
<button
|
||||
onClick={() => {
|
||||
setRefundOpen(true);
|
||||
setNoteOpen(false);
|
||||
setVoidOpen(false);
|
||||
setRefundAmount(remainingRefundable.toFixed(2));
|
||||
}}
|
||||
disabled={busyAction !== null}
|
||||
className="px-4 py-2 rounded-md border border-error text-error text-sm disabled:opacity-50 hover:bg-error/10"
|
||||
>
|
||||
{t("refundBtn")}
|
||||
</button>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2 flex-grow">
|
||||
<div className="text-xs text-text-muted">
|
||||
{t("refundRemainingHint", {
|
||||
max: remainingRefundable.toFixed(2),
|
||||
})}
|
||||
</div>
|
||||
<div className="flex items-center gap-4 flex-wrap">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[10px] uppercase tracking-wider text-text-muted">
|
||||
{t("refundAmountLabel")}
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0.01"
|
||||
max={remainingRefundable}
|
||||
placeholder="CHF"
|
||||
value={refundAmount}
|
||||
onChange={(e) => setRefundAmount(e.target.value)}
|
||||
className="w-32 px-3 py-1.5 rounded-md border border-border bg-surface-2 text-sm font-mono"
|
||||
autoFocus
|
||||
/>
|
||||
<span className="text-[10px] text-text-muted italic">
|
||||
{t("refundAmountInclVatHint")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 flex-grow min-w-[200px]">
|
||||
<label className="text-[10px] uppercase tracking-wider text-text-muted">
|
||||
{t("refundReasonLabel")}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder={t("refundReasonPlaceholder")}
|
||||
value={refundReason}
|
||||
onChange={(e) => setRefundReason(e.target.value)}
|
||||
maxLength={500}
|
||||
className="w-full px-3 py-1.5 rounded-md border border-border bg-surface-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 self-end">
|
||||
<button
|
||||
onClick={refundInvoice}
|
||||
disabled={busyAction !== null}
|
||||
className="px-3 py-1.5 rounded-md bg-error text-white text-sm disabled:opacity-50"
|
||||
>
|
||||
{busyAction === "refund"
|
||||
? t("saving")
|
||||
: t("confirmRefund")}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setRefundOpen(false);
|
||||
setRefundAmount("");
|
||||
setRefundReason("");
|
||||
}}
|
||||
className="px-3 py-1.5 rounded-md border border-border text-sm"
|
||||
>
|
||||
{t("cancel")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
onClick={deleteInvoice}
|
||||
disabled={busyAction !== null}
|
||||
@@ -189,8 +437,90 @@ export function InvoiceDetailView({ detail }: Props) {
|
||||
{invoice.paidMethodDetail}
|
||||
</div>
|
||||
)}
|
||||
{/* Phase 7 — void/refund summary lines, shown when applicable.
|
||||
Surfaces the auditing context that the columns alone don't
|
||||
(who voided, what the reason was, how much has been
|
||||
refunded vs how much remains). */}
|
||||
{invoice.voidedAt && (
|
||||
<div className="mt-3 text-xs text-text-muted">
|
||||
{t("voidedOnLabel")}: {invoice.voidedAt} · {invoice.voidedBy}
|
||||
{invoice.voidReason ? ` · ${invoice.voidReason}` : ""}
|
||||
</div>
|
||||
)}
|
||||
{invoice.refundedTotalChf > 0 && (
|
||||
<div className="mt-3 text-xs text-text-muted">
|
||||
{t("refundedTotalLabel")}: CHF{" "}
|
||||
{invoice.refundedTotalChf.toFixed(2)} ·{" "}
|
||||
{t("refundedRemainingLabel")}: CHF{" "}
|
||||
{remainingRefundable.toFixed(2)}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Phase 7 — linked credit notes panel. Hidden when there are
|
||||
none (most invoices). When present, lists each credit note
|
||||
with kind, amount, reason, issued date, and PDF download. */}
|
||||
{creditNotes.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>{t("creditNotesPanelTitle")}</CardHeader>
|
||||
<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>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Lines */}
|
||||
<Card>
|
||||
<CardHeader>{t("lineItemsTitle")}</CardHeader>
|
||||
@@ -296,7 +626,9 @@ function StatusPill({ status }: { status: InvoiceStatus }) {
|
||||
? "bg-error/15 text-error"
|
||||
: status === "void" || status === "uncollectible"
|
||||
? "bg-text-muted/15 text-text-muted"
|
||||
: "bg-accent/15 text-accent";
|
||||
: status === "partially_refunded" || status === "fully_refunded"
|
||||
? "bg-error/15 text-error"
|
||||
: "bg-accent/15 text-accent";
|
||||
return (
|
||||
<span
|
||||
className={`inline-block px-2 py-0.5 rounded text-xs font-medium ${color}`}
|
||||
|
||||
@@ -100,6 +100,23 @@ export function InvoicesTable({ initialInvoices }: Props) {
|
||||
{t("loading")}
|
||||
</span>
|
||||
)}
|
||||
{/* Phase 8: shortcuts to the custom-invoice flow. The
|
||||
Drafts link is muted because most of the time it's
|
||||
empty; New invoice is the prominent CTA. */}
|
||||
<div className={`flex items-center gap-3 ${busy ? "" : "ml-auto"}`}>
|
||||
<Link
|
||||
href="/admin/billing/invoice-drafts"
|
||||
className="text-xs text-text-muted hover:underline"
|
||||
>
|
||||
{t("draftsLink")}
|
||||
</Link>
|
||||
<Link
|
||||
href="/admin/billing/invoices/new"
|
||||
className="px-3 py-1.5 rounded-md bg-accent text-white text-sm"
|
||||
>
|
||||
+ {t("newInvoiceBtn")}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
@@ -142,7 +159,11 @@ export function InvoicesTable({ initialInvoices }: Props) {
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-2 text-xs font-mono">
|
||||
{inv.periodStart.slice(0, 7)}
|
||||
{inv.periodStart
|
||||
? inv.periodStart.slice(0, 7)
|
||||
: inv.source === "custom"
|
||||
? "—"
|
||||
: ""}
|
||||
</td>
|
||||
<td className="py-2">
|
||||
<StatusPill status={inv.status} />
|
||||
|
||||
166
src/components/admin/billing/new-invoice-form.tsx
Normal file
166
src/components/admin/billing/new-invoice-form.tsx
Normal file
@@ -0,0 +1,166 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Card } from "@/components/ui/card";
|
||||
|
||||
interface OrgEntry {
|
||||
zitadelOrgId: string;
|
||||
companyName: string | null;
|
||||
country: string | null;
|
||||
hasBillingAddress: boolean;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
orgs: OrgEntry[];
|
||||
}
|
||||
|
||||
const LOCALE_OPTIONS = [
|
||||
{ value: "de", label: "Deutsch" },
|
||||
{ value: "en", label: "English" },
|
||||
{ value: "fr", label: "Français" },
|
||||
{ value: "it", label: "Italiano" },
|
||||
];
|
||||
|
||||
/**
|
||||
* Step 1 of the custom-invoice flow: pick an org. Creating the
|
||||
* draft on the backend allocates an id we redirect to; the editor
|
||||
* page then loads the draft and lets the admin add lines.
|
||||
*
|
||||
* The dropdown shows the company name when known, falling back to
|
||||
* the raw org id. Orgs without a billing snapshot are visually
|
||||
* marked and warn the admin — they can still create the draft but
|
||||
* won't be able to issue until billing info is set.
|
||||
*
|
||||
* Default issue date = today; due date = today + 30 days. These
|
||||
* are sensible defaults the editor can override.
|
||||
*/
|
||||
export function NewInvoiceForm({ orgs }: Props) {
|
||||
const t = useTranslations("adminBilling");
|
||||
const router = useRouter();
|
||||
const [orgId, setOrgId] = useState(
|
||||
orgs.find((o) => o.hasBillingAddress)?.zitadelOrgId ??
|
||||
orgs[0]?.zitadelOrgId ??
|
||||
""
|
||||
);
|
||||
const [locale, setLocale] = useState<"de" | "en" | "fr" | "it">("de");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const selected = orgs.find((o) => o.zitadelOrgId === orgId);
|
||||
|
||||
// Pick a locale default from the org's country if admin hasn't
|
||||
// overridden — same heuristic the auto cron uses.
|
||||
const onOrgChange = (newOrgId: string) => {
|
||||
setOrgId(newOrgId);
|
||||
const o = orgs.find((x) => x.zitadelOrgId === newOrgId);
|
||||
const c = (o?.country ?? "").toUpperCase();
|
||||
if (["CH", "LI", "AT", "DE"].includes(c)) setLocale("de");
|
||||
else if (["FR", "BE", "LU"].includes(c)) setLocale("fr");
|
||||
else if (c === "IT") setLocale("it");
|
||||
else setLocale("en");
|
||||
};
|
||||
|
||||
const onSubmit = async () => {
|
||||
if (!orgId) {
|
||||
setError(t("newInvoiceOrgRequired"));
|
||||
return;
|
||||
}
|
||||
setError("");
|
||||
setBusy(true);
|
||||
try {
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const due = new Date();
|
||||
due.setDate(due.getDate() + 30);
|
||||
const dueIso = due.toISOString().slice(0, 10);
|
||||
const res = await fetch("/api/admin/billing/invoice-drafts", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
zitadelOrgId: orgId,
|
||||
payload: {
|
||||
issueDate: today,
|
||||
dueDate: dueIso,
|
||||
locale,
|
||||
paymentMethod: "invoice",
|
||||
lines: [],
|
||||
},
|
||||
}),
|
||||
});
|
||||
const j = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(j.error || `HTTP ${res.status}`);
|
||||
router.push(`/admin/billing/invoice-drafts/${j.draft.id}`);
|
||||
} catch (e: any) {
|
||||
setError(e.message);
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<div className="p-5 flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs uppercase tracking-wider text-text-muted">
|
||||
{t("newInvoiceOrgLabel")}
|
||||
</label>
|
||||
<select
|
||||
value={orgId}
|
||||
onChange={(e) => onOrgChange(e.target.value)}
|
||||
className="px-3 py-2 rounded-md border border-border bg-surface-2 text-sm"
|
||||
>
|
||||
<option value="">{t("newInvoiceOrgPlaceholder")}</option>
|
||||
{orgs.map((o) => (
|
||||
<option
|
||||
key={o.zitadelOrgId}
|
||||
value={o.zitadelOrgId}
|
||||
disabled={!o.hasBillingAddress}
|
||||
>
|
||||
{o.companyName ?? o.zitadelOrgId}
|
||||
{!o.hasBillingAddress
|
||||
? ` (${t("newInvoiceOrgNoBilling")})`
|
||||
: ""}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{selected && !selected.hasBillingAddress && (
|
||||
<p className="text-xs text-error mt-1">
|
||||
{t("newInvoiceOrgBillingMissing")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs uppercase tracking-wider text-text-muted">
|
||||
{t("newInvoiceLocaleLabel")}
|
||||
</label>
|
||||
<select
|
||||
value={locale}
|
||||
onChange={(e) =>
|
||||
setLocale(e.target.value as "de" | "en" | "fr" | "it")
|
||||
}
|
||||
className="px-3 py-2 rounded-md border border-border bg-surface-2 text-sm"
|
||||
>
|
||||
{LOCALE_OPTIONS.map((o) => (
|
||||
<option key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{error && <div className="text-sm text-error">{error}</div>}
|
||||
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<button
|
||||
onClick={onSubmit}
|
||||
disabled={busy || !orgId || !selected?.hasBillingAddress}
|
||||
className="px-4 py-2 rounded-md bg-accent text-white text-sm disabled:opacity-50"
|
||||
>
|
||||
{busy ? t("creating") : t("newInvoiceContinueBtn")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
158
src/components/admin/billing/org-payment-mode-list.tsx
Normal file
158
src/components/admin/billing/org-payment-mode-list.tsx
Normal file
@@ -0,0 +1,158 @@
|
||||
"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>
|
||||
)}
|
||||
<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>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -116,8 +116,23 @@ export function CronControls({ initialRecent, initialLastSuccess }: Props) {
|
||||
});
|
||||
};
|
||||
|
||||
// Phase 6: surface failures prominently. Any run in the recent
|
||||
// window with a non-zero failure_count drives a top-of-page
|
||||
// banner — the row in the table is already red, but a banner
|
||||
// means the admin doesn't have to scroll to notice.
|
||||
const recentFailures = recent.filter((r) => r.failureCount > 0);
|
||||
const hasRecentFailures = recentFailures.length > 0;
|
||||
|
||||
return (
|
||||
<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">
|
||||
@@ -192,7 +207,12 @@ export function CronControls({ initialRecent, initialLastSuccess }: Props) {
|
||||
</thead>
|
||||
<tbody>
|
||||
{recent.map((r) => (
|
||||
<tr key={r.id} className="border-t border-border align-top">
|
||||
<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>
|
||||
|
||||
101
src/components/billing/customer-credit-note-list.tsx
Normal file
101
src/components/billing/customer-credit-note-list.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
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>
|
||||
<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>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -46,11 +46,17 @@ export function CustomerInvoiceDetail({ invoice, lines }: Props) {
|
||||
{t(`status.${invoice.status}` as any)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-text-secondary">
|
||||
{fmt.dateTime(new Date(invoice.periodStart), { dateStyle: "long" })}
|
||||
<span className="text-text-muted mx-1">→</span>
|
||||
{fmt.dateTime(new Date(invoice.periodEnd), { dateStyle: "long" })}
|
||||
</p>
|
||||
{invoice.periodStart && invoice.periodEnd && (
|
||||
<p className="text-sm text-text-secondary">
|
||||
{fmt.dateTime(new Date(invoice.periodStart), {
|
||||
dateStyle: "long",
|
||||
})}
|
||||
<span className="text-text-muted mx-1">→</span>
|
||||
{fmt.dateTime(new Date(invoice.periodEnd), {
|
||||
dateStyle: "long",
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-start gap-2 flex-wrap">
|
||||
{/* Phase 4: Pay-with-card available for open + overdue.
|
||||
|
||||
@@ -12,6 +12,13 @@ const statusColors: Record<string, string> = {
|
||||
paid: "text-success bg-success/10",
|
||||
overdue: "text-error bg-error/10",
|
||||
void: "text-text-muted bg-surface-3 line-through",
|
||||
// Phase 7: refund states. Red tinting matches the credit-note
|
||||
// PDF accent so customers reading the table get a visual cue
|
||||
// that something was credited back. partially_refunded reads
|
||||
// as a partial state (mixed colour), fully_refunded reads as
|
||||
// closed (line-through like void).
|
||||
partially_refunded: "text-error bg-error/10",
|
||||
fully_refunded: "text-text-muted bg-error/10 line-through",
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -64,9 +71,19 @@ export function CustomerInvoiceList({ invoices }: Props) {
|
||||
</Link>
|
||||
</td>
|
||||
<td className="py-2 text-xs text-text-secondary">
|
||||
{fmt.dateTime(new Date(inv.periodStart), { dateStyle: "medium" })}
|
||||
<span className="text-text-muted mx-1">→</span>
|
||||
{fmt.dateTime(new Date(inv.periodEnd), { dateStyle: "medium" })}
|
||||
{inv.periodStart && inv.periodEnd ? (
|
||||
<>
|
||||
{fmt.dateTime(new Date(inv.periodStart), {
|
||||
dateStyle: "medium",
|
||||
})}
|
||||
<span className="text-text-muted mx-1">→</span>
|
||||
{fmt.dateTime(new Date(inv.periodEnd), {
|
||||
dateStyle: "medium",
|
||||
})}
|
||||
</>
|
||||
) : (
|
||||
<span className="text-text-muted">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-2 text-xs text-text-secondary">
|
||||
{fmt.dateTime(new Date(inv.dueAt), { dateStyle: "medium" })}
|
||||
|
||||
@@ -11,6 +11,17 @@ type CurrentResponse =
|
||||
| { draft: InvoiceDraft }
|
||||
| { error: string; code?: string };
|
||||
|
||||
interface Props {
|
||||
/**
|
||||
* Whether the viewing user has org-owner role. Drives the
|
||||
* "complete your billing details" CTA — only owners can edit
|
||||
* billing settings, so non-owners see a softer message asking
|
||||
* them to contact their org owner instead. The flag is computed
|
||||
* server-side and passed in to avoid a second API round-trip.
|
||||
*/
|
||||
isOwner: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Live running total for the current calendar month.
|
||||
*
|
||||
@@ -28,7 +39,7 @@ type CurrentResponse =
|
||||
* No polling — the page is static enough that an explicit
|
||||
* "refresh" link is good enough if the user wants newer numbers.
|
||||
*/
|
||||
export function RunningTotalWidget() {
|
||||
export function RunningTotalWidget({ isOwner }: Props) {
|
||||
const t = useTranslations("customerBilling");
|
||||
const fmt = useFormatter();
|
||||
const [data, setData] = useState<CurrentResponse | null>(null);
|
||||
@@ -62,13 +73,29 @@ export function RunningTotalWidget() {
|
||||
);
|
||||
}
|
||||
if (!data || "error" in data) {
|
||||
const noConfig =
|
||||
data && "code" in data && data.code === "COMPUTE_FAILED";
|
||||
return (
|
||||
<Card>
|
||||
<p className="text-sm text-text-secondary py-2">
|
||||
{data && "code" in data && data.code === "COMPUTE_FAILED"
|
||||
? t("noBillingConfig")
|
||||
: t("currentPeriodError")}
|
||||
{noConfig ? t("noBillingConfig") : t("currentPeriodError")}
|
||||
</p>
|
||||
{/* Phase 6: owner-only CTA. Non-owners can't edit billing
|
||||
settings, so we show them a "contact owner" hint instead
|
||||
— that's gentler than a button that 404s on click. */}
|
||||
{noConfig && isOwner && (
|
||||
<Link
|
||||
href="/settings/billing"
|
||||
className="inline-block mt-2 px-4 py-2 rounded-md bg-accent text-white 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>
|
||||
);
|
||||
}
|
||||
@@ -98,9 +125,16 @@ export function RunningTotalWidget() {
|
||||
}
|
||||
// draft
|
||||
const draft = data.draft;
|
||||
const periodLabel = `${fmt.dateTime(new Date(draft.periodStart), {
|
||||
dateStyle: "long",
|
||||
})} → ${fmt.dateTime(new Date(draft.periodEnd), { dateStyle: "long" })}`;
|
||||
// Phase 8: InvoiceDraft.periodStart/End became nullable for the
|
||||
// custom-invoice flow. The running-total widget only renders the
|
||||
// auto-cron draft (always has a period), so the null branch is
|
||||
// defensive — if we ever did hit it the label just collapses.
|
||||
const periodLabel =
|
||||
draft.periodStart && draft.periodEnd
|
||||
? `${fmt.dateTime(new Date(draft.periodStart), {
|
||||
dateStyle: "long",
|
||||
})} → ${fmt.dateTime(new Date(draft.periodEnd), { dateStyle: "long" })}`
|
||||
: "";
|
||||
return (
|
||||
<Card>
|
||||
<div className="flex items-start justify-between gap-4 flex-wrap mb-3">
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { OnboardingWizard } from "./wizard";
|
||||
import type { OrgBilling } from "@/types";
|
||||
|
||||
interface OnboardingFlowProps {
|
||||
orgName: string;
|
||||
@@ -19,6 +20,17 @@ interface OnboardingFlowProps {
|
||||
* /settings/billing.
|
||||
*/
|
||||
hasOrgBilling?: boolean;
|
||||
/**
|
||||
* Phase 6 fix3: the actual org_billing record (or null). Drives
|
||||
* the review-step "Billing to" rendering AND the confirm-step
|
||||
* validation skip when the billing step was skipped.
|
||||
*/
|
||||
existingOrgBilling?: OrgBilling | null;
|
||||
/**
|
||||
* Phase 9b: platform setup fee (net CHF) shown on the review
|
||||
* step. Forwarded straight to the wizard.
|
||||
*/
|
||||
setupFeeChf?: number | null;
|
||||
/**
|
||||
* Bug 6: when present, the wizard is rendered in edit mode against
|
||||
* the given pending request. See `OnboardingWizard` for the full
|
||||
@@ -45,6 +57,8 @@ export function OnboardingFlow({
|
||||
userName,
|
||||
userEmail,
|
||||
hasOrgBilling,
|
||||
existingOrgBilling,
|
||||
setupFeeChf,
|
||||
editingRequest,
|
||||
}: OnboardingFlowProps) {
|
||||
const router = useRouter();
|
||||
@@ -55,6 +69,8 @@ export function OnboardingFlow({
|
||||
userName={userName}
|
||||
userEmail={userEmail}
|
||||
hasOrgBilling={hasOrgBilling}
|
||||
existingOrgBilling={existingOrgBilling}
|
||||
setupFeeChf={setupFeeChf}
|
||||
editingRequest={editingRequest}
|
||||
onComplete={() => {
|
||||
// Navigate back to /dashboard and re-fetch on the server. The
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useTranslations } from "next-intl";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { PACKAGE_CATALOG, DEFAULT_PACKAGE_IDS, type PackageDef } from "@/lib/packages";
|
||||
import { isPersonalOrgName, displayOrgNameFor } from "@/lib/personal-org";
|
||||
import { THREEMA_GATEWAY } from "@/lib/threema-gateway-config";
|
||||
import {
|
||||
configureStepSchema,
|
||||
billingStepSchema,
|
||||
@@ -13,6 +14,7 @@ import {
|
||||
SUPPORTED_COUNTRIES,
|
||||
type SupportedCountry,
|
||||
} from "@/lib/validation";
|
||||
import type { OrgBilling } from "@/types";
|
||||
|
||||
type Step = "welcome" | "configure" | "billing" | "confirm";
|
||||
|
||||
@@ -96,6 +98,25 @@ interface WizardProps {
|
||||
* fix it before admin approves.
|
||||
*/
|
||||
hasOrgBilling?: boolean;
|
||||
/**
|
||||
* Phase 6 fix3: the actual org_billing record when one exists.
|
||||
* Used to render real values on the review-step "Billing to" block
|
||||
* (rather than the wizard's empty default config.billingAddress)
|
||||
* AND to skip the confirm-step's client-side validation of
|
||||
* billingAddress — same logic that already strips billingAddress
|
||||
* at submit time. Null when no org_billing row exists yet.
|
||||
* Ignored in edit mode (the editingRequest carries its own
|
||||
* billingAddress snapshot).
|
||||
*/
|
||||
existingOrgBilling?: OrgBilling | null;
|
||||
/**
|
||||
* Phase 9b: the platform's current tenant setup fee (net CHF,
|
||||
* before VAT). Shown on the review step so the customer sees how
|
||||
* much they're about to be charged before being sent to Stripe.
|
||||
* Null/0 means no setup fee — the review notice is suppressed and
|
||||
* the order skips the Checkout redirect (handled server-side).
|
||||
*/
|
||||
setupFeeChf?: number | null;
|
||||
/**
|
||||
* Bug 6: when present, the wizard renders in "edit" mode — fields
|
||||
* are pre-populated from the request, the SOUL.md auto-fetch is
|
||||
@@ -134,6 +155,8 @@ export function OnboardingWizard({
|
||||
userName,
|
||||
userEmail,
|
||||
hasOrgBilling,
|
||||
existingOrgBilling,
|
||||
setupFeeChf,
|
||||
editingRequest,
|
||||
onComplete,
|
||||
}: WizardProps) {
|
||||
@@ -232,6 +255,14 @@ export function OnboardingWizard({
|
||||
const [disclaimerAccepted, setDisclaimerAccepted] = useState<
|
||||
Record<string, boolean>
|
||||
>({});
|
||||
// Phase 9b: per-channel customer user id collected at onboarding.
|
||||
// Keyed by package id (e.g. "telegram" → "1234567"). Applied on
|
||||
// admin approval — see /api/admin/requests/[id]/approve. Optional
|
||||
// per channel; the customer can also leave it blank and add their
|
||||
// id later from the tenant's channel-users page.
|
||||
const [channelUserIds, setChannelUserIds] = useState<Record<string, string>>(
|
||||
{}
|
||||
);
|
||||
|
||||
// Fetch DB-stored defaults on mount
|
||||
useEffect(() => {
|
||||
@@ -319,7 +350,23 @@ export function OnboardingWizard({
|
||||
}
|
||||
// confirm: validate the union (defence in depth — submit handler
|
||||
// also runs onboardingSchema before POST).
|
||||
const r = onboardingSchema.safeParse(config);
|
||||
//
|
||||
// Phase 6 fix3: when hasOrgBilling=true AND not editing, the
|
||||
// billing step was skipped and config.billingAddress is the
|
||||
// empty default. zod's .optional() doesn't help here because the
|
||||
// field IS present (empty object), so billingAddressSchema
|
||||
// validates it and fails with required-field errors that the
|
||||
// user has no way to fix — the form to enter the values was
|
||||
// skipped on purpose. Strip the field for validation, matching
|
||||
// the same strip we already do at submit time.
|
||||
const configForValidation =
|
||||
hasOrgBilling && !isEditing
|
||||
? (() => {
|
||||
const { billingAddress: _b, ...rest } = config;
|
||||
return rest;
|
||||
})()
|
||||
: config;
|
||||
const r = onboardingSchema.safeParse(configForValidation);
|
||||
if (r.success) {
|
||||
setErrors({});
|
||||
return true;
|
||||
@@ -435,6 +482,20 @@ export function OnboardingWizard({
|
||||
})()
|
||||
: config;
|
||||
|
||||
// Phase 9b: build the channelUsers payload from the per-package
|
||||
// ids collected during onboarding. Only include channels that
|
||||
// (a) are enabled in the wizard's packages list AND
|
||||
// (b) have a non-empty id entered.
|
||||
// Shape matches PiecedTenantSpec.channelUsers — { channel: [id] }
|
||||
// — so the approve handler can pass it straight through.
|
||||
const channelUsersPayload: Record<string, string[]> = {};
|
||||
for (const [pkgId, rawId] of Object.entries(channelUserIds)) {
|
||||
const trimmed = (rawId ?? "").trim();
|
||||
if (!trimmed) continue;
|
||||
if (!config.packages.includes(pkgId)) continue;
|
||||
channelUsersPayload[pkgId] = [trimmed];
|
||||
}
|
||||
|
||||
const res = await fetch(url, {
|
||||
method,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
@@ -444,6 +505,10 @@ export function OnboardingWizard({
|
||||
Object.keys(secretsPayload).length > 0
|
||||
? secretsPayload
|
||||
: undefined,
|
||||
channelUsers:
|
||||
Object.keys(channelUsersPayload).length > 0
|
||||
? channelUsersPayload
|
||||
: undefined,
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -452,6 +517,22 @@ export function OnboardingWizard({
|
||||
throw new Error(data.error || "Submission failed");
|
||||
}
|
||||
|
||||
// Phase 9b: if the server initiated a setup-fee Checkout, the
|
||||
// response carries a `checkoutUrl`. Redirect the browser
|
||||
// directly — Stripe Checkout is the next step. The
|
||||
// tenant_requests row is already inserted in 'pending_payment'
|
||||
// status; on successful Checkout, the webhook flips it to
|
||||
// 'pending' and admin sees it.
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (data?.checkoutUrl) {
|
||||
// Don't reset submitting=false — let the redirect happen
|
||||
// with the spinner still active so the button stays
|
||||
// disabled.
|
||||
window.location.href = data.checkoutUrl;
|
||||
return;
|
||||
}
|
||||
|
||||
// Zero-fee path or PATCH edit — same behaviour as before.
|
||||
onComplete();
|
||||
} catch (err: any) {
|
||||
setError(err.message);
|
||||
@@ -691,7 +772,9 @@ export function OnboardingWizard({
|
||||
className={`border rounded-lg overflow-hidden transition-colors ${
|
||||
isSelected
|
||||
? "border-accent bg-accent/5"
|
||||
: "border-border bg-surface-2"
|
||||
: pkg.recommended
|
||||
? "border-accent/40 bg-accent/[0.02]"
|
||||
: "border-border bg-surface-2"
|
||||
}`}
|
||||
>
|
||||
{/* Toggle row */}
|
||||
@@ -710,6 +793,11 @@ export function OnboardingWizard({
|
||||
>
|
||||
{pkg.name}
|
||||
</span>
|
||||
{pkg.recommended && (
|
||||
<span className="ml-2 text-[10px] font-semibold uppercase tracking-wide text-accent bg-accent/10 border border-accent/30 rounded-full px-1.5 py-0.5">
|
||||
{tPkg("recommended")}
|
||||
</span>
|
||||
)}
|
||||
{pkg.requiresSecrets && (
|
||||
<span className="ml-1.5 text-[10px] text-text-muted">
|
||||
({tPkg("requiresApiKey")})
|
||||
@@ -731,8 +819,16 @@ export function OnboardingWizard({
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Inline credential inputs — expand when selected + requires secrets */}
|
||||
{isSelected && pkg.requiresSecrets && (
|
||||
{/* Inline expansion when selected — shows
|
||||
instructions (if any), credential inputs
|
||||
(if requiresSecrets), and the disclaimer
|
||||
checkbox (if any). Threema for example
|
||||
has no customer-entered secrets but has
|
||||
instructions + a disclaimer to accept. */}
|
||||
{isSelected &&
|
||||
(pkg.requiresSecrets ||
|
||||
pkg.instructionsKey ||
|
||||
pkg.disclaimerKey) && (
|
||||
<div className="border-t border-border px-3 py-3 space-y-3 bg-surface-1/50">
|
||||
{pkg.instructionsKey && (
|
||||
<div className="bg-surface-2 border border-border rounded-lg p-3 text-xs text-text-secondary leading-relaxed whitespace-pre-line">
|
||||
@@ -745,6 +841,40 @@ export function OnboardingWizard({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Threema: show the bot's Threema ID
|
||||
and QR right here in the wizard. The
|
||||
instructions text refers to a QR
|
||||
that isn't visible until after
|
||||
provisioning — without this block
|
||||
the message is confusing. The QR is
|
||||
the platform's shared gateway QR
|
||||
(*AIAGENT), identical for every
|
||||
tenant, so we can render it before
|
||||
the tenant even exists. */}
|
||||
{pkg.id === "threema" && (
|
||||
<div className="rounded-lg border border-accent/30 bg-surface-1 p-3 flex items-start gap-3">
|
||||
<div className="bg-white p-1.5 rounded-md shrink-0">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={THREEMA_GATEWAY.qrCodePath}
|
||||
alt={`QR code for ${THREEMA_GATEWAY.displayName}`}
|
||||
width={96}
|
||||
height={96}
|
||||
style={{ display: "block" }}
|
||||
/>
|
||||
</div>
|
||||
<div className="text-xs text-text-secondary leading-relaxed">
|
||||
<div className="text-text-primary font-medium mb-1">
|
||||
{tPkg("threemaBotIdHeading")}
|
||||
</div>
|
||||
<div className="font-mono text-sm text-accent mb-2">
|
||||
{THREEMA_GATEWAY.displayName}
|
||||
</div>
|
||||
<div>{tPkg("threemaBotIdHint")}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(pkg.secrets || []).map((field) => (
|
||||
<label key={field.key} className="block">
|
||||
<span className="text-xs text-text-secondary mb-1 block">
|
||||
@@ -773,6 +903,46 @@ export function OnboardingWizard({
|
||||
</label>
|
||||
))}
|
||||
|
||||
{/* Phase 9b: channel-user-id capture
|
||||
during onboarding. For channels
|
||||
where the customer's own user id
|
||||
is needed for routing (Telegram,
|
||||
Discord, Threema), collect it here
|
||||
so the assistant is usable
|
||||
immediately on provisioning. The
|
||||
help text comes from the existing
|
||||
channelUsers.<id>IdHelp keys
|
||||
(same copy as the post-provisioning
|
||||
page uses). Field is optional —
|
||||
blank means "I'll add it later". */}
|
||||
{pkg.collectsChannelUserId && (
|
||||
<label className="block">
|
||||
<span className="text-xs text-text-secondary mb-1 block">
|
||||
{t(`yourChannelIdLabel.${pkg.id}`)}{" "}
|
||||
<span className="text-text-muted normal-case">
|
||||
({t("optional")})
|
||||
</span>
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
placeholder={t(
|
||||
`yourChannelIdPlaceholder.${pkg.id}`
|
||||
)}
|
||||
value={channelUserIds[pkg.id] ?? ""}
|
||||
onChange={(e) =>
|
||||
setChannelUserIds((prev) => ({
|
||||
...prev,
|
||||
[pkg.id]: e.target.value,
|
||||
}))
|
||||
}
|
||||
className="w-full px-3 py-2 bg-surface-2 border border-border rounded-lg text-sm text-text-primary placeholder:text-text-muted font-mono focus:outline-none focus:ring-1 focus:ring-accent focus:border-accent transition-colors"
|
||||
/>
|
||||
<p className="text-[11px] text-text-muted mt-1 leading-relaxed whitespace-pre-line">
|
||||
{t(`yourChannelIdHelp.${pkg.id}`)}
|
||||
</p>
|
||||
</label>
|
||||
)}
|
||||
|
||||
{pkg.disclaimerKey && (
|
||||
<label className="flex items-start gap-2 text-xs text-text-secondary">
|
||||
<input
|
||||
@@ -1001,28 +1171,6 @@ export function OnboardingWizard({
|
||||
</p>
|
||||
</FieldWithError>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-semibold uppercase tracking-wider text-text-muted mb-1.5">
|
||||
{t("billingNotes")}
|
||||
</label>
|
||||
<textarea
|
||||
value={config.billingNotes}
|
||||
onChange={(e) =>
|
||||
setConfig((prev) => ({
|
||||
...prev,
|
||||
billingNotes: e.target.value,
|
||||
}))
|
||||
}
|
||||
rows={3}
|
||||
placeholder={t(
|
||||
isPersonal
|
||||
? "billingNotesPlaceholderPersonal"
|
||||
: "billingNotesPlaceholder"
|
||||
)}
|
||||
className="w-full px-3 py-2 bg-surface-2 border border-border rounded-lg text-sm text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-1 focus:ring-accent focus:border-accent transition-colors resize-y"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between mt-6">
|
||||
@@ -1101,60 +1249,117 @@ export function OnboardingWizard({
|
||||
<ReviewRow
|
||||
label={t("reviewBillingTo")}
|
||||
value={
|
||||
<div className="text-text-primary text-right">
|
||||
{/* For personal: skip the company line so the
|
||||
invoice rendering matches what the user actually
|
||||
entered. For company: include it as the first
|
||||
line. */}
|
||||
{!isPersonal &&
|
||||
config.billingAddress.company &&
|
||||
config.billingAddress.company.trim().length > 0 && (
|
||||
<div>{config.billingAddress.company}</div>
|
||||
)}
|
||||
<div>{config.billingAddress.street}</div>
|
||||
<div>
|
||||
{config.billingAddress.postalCode}{" "}
|
||||
{config.billingAddress.city}
|
||||
</div>
|
||||
<div className="text-text-muted">
|
||||
{tCountries(
|
||||
config.billingAddress.country as SupportedCountry
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
(() => {
|
||||
// Phase 6 fix3: when the org has billing on file
|
||||
// and we're not editing, render the saved
|
||||
// org_billing record (the authoritative source)
|
||||
// rather than config.billingAddress, which is the
|
||||
// wizard's empty default state because the billing
|
||||
// step was skipped. In edit mode, fall back to
|
||||
// config.billingAddress, which is pre-populated
|
||||
// from the request being edited.
|
||||
const useSaved =
|
||||
hasOrgBilling && !isEditing && existingOrgBilling;
|
||||
const company = useSaved
|
||||
? existingOrgBilling!.companyName
|
||||
: config.billingAddress.company;
|
||||
const street = useSaved
|
||||
? existingOrgBilling!.streetAddress
|
||||
: config.billingAddress.street;
|
||||
const postalCode = useSaved
|
||||
? existingOrgBilling!.postalCode
|
||||
: config.billingAddress.postalCode;
|
||||
const city = useSaved
|
||||
? existingOrgBilling!.city
|
||||
: config.billingAddress.city;
|
||||
const country = useSaved
|
||||
? existingOrgBilling!.country
|
||||
: config.billingAddress.country;
|
||||
const contactName = useSaved
|
||||
? existingOrgBilling!.contactName
|
||||
: null;
|
||||
return (
|
||||
<div className="text-text-primary text-right">
|
||||
{/* For personal: skip the company line so the
|
||||
invoice rendering matches what the user actually
|
||||
entered. For company: include it as the first
|
||||
line. */}
|
||||
{!isPersonal &&
|
||||
company &&
|
||||
company.trim().length > 0 && <div>{company}</div>}
|
||||
{/* Phase 6 fix2: optional contact-person line
|
||||
("z.Hd. <name>") only present when the saved
|
||||
org_billing has it set. */}
|
||||
{contactName && contactName.trim().length > 0 && (
|
||||
<div className="text-text-muted">
|
||||
{t("reviewContactPersonPrefix")} {contactName}
|
||||
</div>
|
||||
)}
|
||||
<div>{street}</div>
|
||||
<div>
|
||||
{postalCode} {city}
|
||||
</div>
|
||||
<div className="text-text-muted">
|
||||
{tCountries(country as SupportedCountry)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()
|
||||
}
|
||||
/>
|
||||
{/* Bug 35: VAT review row. Company customers see this so
|
||||
they can verify the VAT id they typed before submitting.
|
||||
Personal customers never see it — they don't have a
|
||||
VAT number, the form didn't ask, the review hides it. */}
|
||||
VAT number, the form didn't ask, the review hides it.
|
||||
Phase 6 fix3: when reading from existingOrgBilling,
|
||||
the value comes from there too. */}
|
||||
{!isPersonal &&
|
||||
config.billingAddress.vatNumber &&
|
||||
config.billingAddress.vatNumber.trim().length > 0 && (
|
||||
<ReviewRow
|
||||
label={t("billingVatNumber")}
|
||||
value={config.billingAddress.vatNumber}
|
||||
mono
|
||||
/>
|
||||
)}
|
||||
(() => {
|
||||
const vat =
|
||||
hasOrgBilling && !isEditing && existingOrgBilling
|
||||
? existingOrgBilling.vatNumber
|
||||
: config.billingAddress.vatNumber;
|
||||
return vat && vat.trim().length > 0 ? (
|
||||
<ReviewRow
|
||||
label={t("billingVatNumber")}
|
||||
value={vat}
|
||||
mono
|
||||
/>
|
||||
) : null;
|
||||
})()}
|
||||
<ReviewRow
|
||||
label={t("reviewContactEmail")}
|
||||
value={userEmail || ""}
|
||||
mono
|
||||
/>
|
||||
{config.billingNotes.trim().length > 0 && (
|
||||
<ReviewRow
|
||||
label={t("billingNotes")}
|
||||
value={
|
||||
<span className="text-text-primary whitespace-pre-wrap text-right">
|
||||
{config.billingNotes}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-text-muted">{t("confirmNote")}</p>
|
||||
|
||||
{/* Phase 9b: order-time setup-fee notice + amount. The
|
||||
figure shown is the net platform fee (before VAT);
|
||||
VAT is added server-side based on the billing
|
||||
country. We show "+ VAT" rather than a computed
|
||||
gross to avoid mis-displaying a country-dependent
|
||||
total. If setupFeeChf is null/0, no charge happens
|
||||
and the whole block is suppressed. */}
|
||||
{typeof setupFeeChf === "number" && setupFeeChf > 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-1">
|
||||
{t("setupFeeNoticeHeading")}
|
||||
</strong>
|
||||
<div className="flex items-baseline justify-between mb-2 pb-2 border-b border-accent/20">
|
||||
<span>{t("setupFeeAmountLabel")}</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>
|
||||
{t("setupFeeNoticeBody")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
|
||||
@@ -9,6 +9,7 @@ import type {
|
||||
SkillPricing,
|
||||
} from "@/types";
|
||||
import { SkillCostDialog } from "./skill-cost-dialog";
|
||||
import { ThreemaQrModal } from "@/components/channel-users/threema-qr-modal";
|
||||
|
||||
interface Props {
|
||||
pkg: PackageDef;
|
||||
@@ -51,6 +52,11 @@ export function PackageCard({
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
// Phase 2.5: cost-disclosure flow + activation-request flow.
|
||||
const [showCostDialog, setShowCostDialog] = useState(false);
|
||||
// Threema: after a successful enable on customProvisioning, surface
|
||||
// the gateway QR + bot Threema ID so the customer immediately knows
|
||||
// how to add the assistant to their Threema contacts. Without this,
|
||||
// the toggle just flips silently with no actionable info.
|
||||
const [showThreemaInfo, setShowThreemaInfo] = useState(false);
|
||||
const isPriced =
|
||||
(pricing?.dailyPriceChf ?? 0) > 0 || (pricing?.setupFeeChf ?? 0) > 0;
|
||||
|
||||
@@ -79,6 +85,14 @@ export function PackageCard({
|
||||
throw new Error(err.error || `Provisioning failed (HTTP ${provRes.status})`);
|
||||
}
|
||||
await togglePackage(true);
|
||||
// For Threema specifically: now that the relay's minted the
|
||||
// per-tenant token and the package is enabled, show the
|
||||
// gateway QR + bot Threema ID so the customer can add the
|
||||
// assistant to their Threema contacts straight away. Other
|
||||
// customProvisioning packages don't need this confirmation.
|
||||
if (pkg.id === "threema") {
|
||||
setShowThreemaInfo(true);
|
||||
}
|
||||
} catch (e: any) {
|
||||
setError(e.message);
|
||||
} finally {
|
||||
@@ -283,17 +297,33 @@ export function PackageCard({
|
||||
</button>
|
||||
</div>
|
||||
) : canEdit ? (
|
||||
<button
|
||||
onClick={enabled ? handleDisable : handleEnable}
|
||||
disabled={saving}
|
||||
className={`ml-auto rounded-lg px-3 py-1.5 text-xs font-medium transition-all cursor-pointer ${
|
||||
enabled
|
||||
? "bg-surface-3 text-text-secondary hover:text-text-primary hover:bg-surface-2"
|
||||
: "bg-accent text-surface-0 hover:bg-accent-dim shadow-lg shadow-accent/20"
|
||||
} disabled:opacity-50`}
|
||||
>
|
||||
{saving ? "…" : enabled ? t("packages.disable") : t("packages.enable")}
|
||||
</button>
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
{/* Phase 9b: re-open the Threema info popup at any time
|
||||
while Threema is enabled. The popup auto-opens after
|
||||
a fresh enable; this button lets the customer see the
|
||||
QR + bot ID again without having to disable + re-enable. */}
|
||||
{pkg.id === "threema" && enabled && (
|
||||
<button
|
||||
onClick={() => setShowThreemaInfo(true)}
|
||||
className="rounded-lg px-2 py-1.5 text-xs font-medium bg-surface-3 text-text-secondary hover:text-text-primary hover:bg-surface-2 transition-colors cursor-pointer"
|
||||
title={t("packages.showInfoTitle")}
|
||||
aria-label={t("packages.showInfoTitle")}
|
||||
>
|
||||
ⓘ {t("packages.showInfo")}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={enabled ? handleDisable : handleEnable}
|
||||
disabled={saving}
|
||||
className={`rounded-lg px-3 py-1.5 text-xs font-medium transition-all cursor-pointer ${
|
||||
enabled
|
||||
? "bg-surface-3 text-text-secondary hover:text-text-primary hover:bg-surface-2"
|
||||
: "bg-accent text-surface-0 hover:bg-accent-dim shadow-lg shadow-accent/20"
|
||||
} disabled:opacity-50`}
|
||||
>
|
||||
{saving ? "…" : enabled ? t("packages.disable") : t("packages.enable")}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
// Slice 5: read-only viewers see a static badge instead of a
|
||||
// toggle. The status badge above the divider already conveys
|
||||
@@ -320,6 +350,16 @@ export function PackageCard({
|
||||
busy={saving}
|
||||
/>
|
||||
|
||||
{/* Threema: post-enable confirmation showing the gateway QR
|
||||
and bot Threema ID. Only rendered for the threema package
|
||||
and only after a successful enable. The same modal is also
|
||||
reachable later on the channel-users page. */}
|
||||
{pkg.id === "threema" && (
|
||||
<ThreemaQrModal
|
||||
open={showThreemaInfo}
|
||||
onClose={() => setShowThreemaInfo(false)}
|
||||
/>
|
||||
)}
|
||||
{showModal && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4">
|
||||
<div className="w-full max-w-md bg-surface-1 border border-border rounded-2xl p-6 space-y-4 shadow-2xl shadow-black/40">
|
||||
|
||||
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-white 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>
|
||||
);
|
||||
}
|
||||
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-white 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>
|
||||
);
|
||||
}
|
||||
272
src/components/settings/saved-card-section.tsx
Normal file
272
src/components/settings/saved-card-section.tsx
Normal file
@@ -0,0 +1,272 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useRouter, useSearchParams } from "next/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-white 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>
|
||||
);
|
||||
}
|
||||
@@ -49,7 +49,31 @@ export const authConfig: NextAuthConfig = {
|
||||
},
|
||||
],
|
||||
callbacks: {
|
||||
async jwt({ token, account, profile }) {
|
||||
async jwt({ token, account, profile, trigger, session }) {
|
||||
// Phase 6 fix5: client-side `useSession().update({ name })` calls
|
||||
// route through this branch. We trust the new value because the
|
||||
// PUT /api/settings/profile route already wrote it to ZITADEL
|
||||
// and re-fetched the canonical displayName before returning.
|
||||
// The session callback reads token.name directly (see below) so
|
||||
// the update propagates without depending on auth.js's implicit
|
||||
// token→session.user mapping, which is flaky for the name claim
|
||||
// in the v5 OIDC provider configuration.
|
||||
//
|
||||
// Defensive: only the `name` field is accepted from the update
|
||||
// payload, even if the client passes additional keys. Other
|
||||
// identity claims (orgId, roles, sub) come from ZITADEL at
|
||||
// sign-in time and are not user-mutable from a settings page.
|
||||
//
|
||||
// Returns a NEW token object (spread) rather than mutating, so
|
||||
// there is no ambiguity for auth.js about whether the token
|
||||
// changed and needs re-encoding into the session cookie.
|
||||
if (trigger === "update" && session) {
|
||||
const update = session as { name?: unknown };
|
||||
if (typeof update.name === "string") {
|
||||
return { ...token, name: update.name };
|
||||
}
|
||||
return token;
|
||||
}
|
||||
if (account && profile) {
|
||||
const claims = profile as unknown as ZitadelClaims;
|
||||
token.orgId = claims["urn:zitadel:iam:user:resourceowner:id"];
|
||||
@@ -58,6 +82,19 @@ export const authConfig: NextAuthConfig = {
|
||||
claims["urn:zitadel:iam:org:project:roles"]
|
||||
);
|
||||
token.accessToken = account.access_token;
|
||||
// Phase 6 fix5: explicitly pin the standard name/email claims
|
||||
// onto the token from the OIDC profile. Previously these came
|
||||
// through auth.js's implicit mapping, which works on first
|
||||
// sign-in but isn't reliable after update() — once the update
|
||||
// path overrides token.name, the read-back path needs token
|
||||
// to be the authoritative source. Setting them explicitly
|
||||
// here keeps sign-in and update on the same path.
|
||||
if (typeof profile.name === "string") {
|
||||
token.name = profile.name;
|
||||
}
|
||||
if (typeof profile.email === "string") {
|
||||
token.email = profile.email;
|
||||
}
|
||||
// Pin token.sub to the OIDC subject. Auth.js v5 otherwise puts a
|
||||
// freshly generated UUID in token.sub on initial sign-in,
|
||||
// ignoring what profile() returns for `id`. That UUID then
|
||||
@@ -80,10 +117,19 @@ export const authConfig: NextAuthConfig = {
|
||||
async session({ session, token }) {
|
||||
const roles = (token.roles as Role[]) ?? [];
|
||||
const orgName = (token.orgName as string) ?? "";
|
||||
// Phase 6 fix5: read name and email directly from the token.
|
||||
// Previously this code relied on `session.user?.name`, expecting
|
||||
// auth.js to map token.name → session.user.name automatically.
|
||||
// That mapping is brittle: it works on first sign-in (because
|
||||
// OIDC profile() populates session.user) but not after update()
|
||||
// overrides token.name. Reading from token is the canonical
|
||||
// path regardless of how the token was last written.
|
||||
const tokenName = (token.name as string | undefined) ?? "";
|
||||
const tokenEmail = (token.email as string | undefined) ?? "";
|
||||
const sessionUser: SessionUser = {
|
||||
id: token.sub!,
|
||||
name: session.user?.name ?? "",
|
||||
email: session.user?.email ?? "",
|
||||
name: tokenName || session.user?.name || "",
|
||||
email: tokenEmail || session.user?.email || "",
|
||||
orgId: token.orgId as string,
|
||||
orgName,
|
||||
roles,
|
||||
@@ -96,6 +142,14 @@ export const authConfig: NextAuthConfig = {
|
||||
isPersonal: isPersonalOrgName(orgName),
|
||||
};
|
||||
(session as any).platformUser = sessionUser;
|
||||
// Also overwrite session.user so any client-side code that uses
|
||||
// the standard NextAuth shape (session.user.name) sees the new
|
||||
// value. Pre-fix5 code paths read from session.user.name; this
|
||||
// keeps them working without per-component changes.
|
||||
if (session.user) {
|
||||
session.user.name = sessionUser.name;
|
||||
session.user.email = sessionUser.email;
|
||||
}
|
||||
return session;
|
||||
},
|
||||
},
|
||||
|
||||
@@ -153,5 +153,21 @@ export function formatLineDescription(
|
||||
}[L];
|
||||
return reason ? `${base}: ${reason}` : base;
|
||||
}
|
||||
|
||||
// Phase 8: custom invoice lines. The description is what the
|
||||
// admin typed in the editor — return it verbatim (no template,
|
||||
// no locale-specific formatting). billing.ts persists the
|
||||
// already-trimmed admin input into invoice_lines.description.
|
||||
case "custom_line": {
|
||||
const dRaw = (m as Record<string, unknown>)["description"];
|
||||
if (typeof dRaw === "string" && dRaw.trim().length > 0) return dRaw;
|
||||
// Fallback: the description column on the row itself. The
|
||||
// PDF renderer hands us the line so it can read it directly
|
||||
// — see how billing-pdf invokes formatLineDescription.
|
||||
const onRow = (line as unknown as { description?: string }).description;
|
||||
return onRow && onRow.trim().length > 0
|
||||
? onRow
|
||||
: { de: "Leistung", en: "Service", fr: "Service", it: "Servizio" }[L];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,44 +31,18 @@ import {
|
||||
Text,
|
||||
View,
|
||||
StyleSheet,
|
||||
Svg,
|
||||
Polygon,
|
||||
Polyline,
|
||||
renderToBuffer,
|
||||
} from "@react-pdf/renderer";
|
||||
import type { Invoice, InvoiceLine, InvoiceLineKind } from "@/types";
|
||||
import { BRAND, Logo } from "./pdf-brand";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Brand constants — edit here to tweak look without touching layout
|
||||
// Brand: imported from lib/pdf-brand. Edit there to change issuer
|
||||
// info, colours, or the logo. Both billing-pdf.tsx and credit-note-pdf.tsx
|
||||
// share the same source of truth so a brand change applies to every
|
||||
// PDF the portal produces.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const BRAND = {
|
||||
name: "PieCed IT",
|
||||
// Primary emerald — matches the logo SVG fill (#10B981).
|
||||
primary: "#10B981",
|
||||
// Slightly darker emerald for headings.
|
||||
primaryDark: "#0a8060",
|
||||
textColor: "#1a1a1a",
|
||||
mutedColor: "#666",
|
||||
borderColor: "#d4d4d4",
|
||||
// Issuer block — change these to your real legal info.
|
||||
issuer: {
|
||||
legalName: "PieCed IT",
|
||||
addressLine1: "Cedric Mosimann",
|
||||
addressLine2: "[Strasse Nr.]",
|
||||
postalCity: "[PLZ] Basel",
|
||||
country: "Switzerland",
|
||||
email: "billing@pieced.ch",
|
||||
web: "pieced.ch",
|
||||
// Show "MWST-Nr. ..." on PDF when set.
|
||||
vatNumber: null as string | null,
|
||||
// Bank instructions — Phase 7 replaces with QR-bill.
|
||||
bankName: "[Bank name]",
|
||||
bankIban: "[CHxx xxxx xxxx xxxx xxxx x]",
|
||||
bankBic: "[BIC]",
|
||||
},
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Localized strings
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -80,6 +54,11 @@ interface PdfStrings {
|
||||
dueDate: string;
|
||||
period: string;
|
||||
billTo: string;
|
||||
// Phase 6 fix: prefix shown before the optional contact-person
|
||||
// name on the bill-to block. "z.Hd." (DE) / "Attn:" (EN) /
|
||||
// "À l'attention de" (FR) / "c.a." (IT). Empty/unused when the
|
||||
// invoice has no contactName on its snapshot.
|
||||
attentionPrefix: string;
|
||||
description: string;
|
||||
quantity: string;
|
||||
unitPrice: string;
|
||||
@@ -107,6 +86,7 @@ const MESSAGES: Record<string, PdfStrings> = {
|
||||
dueDate: "Zahlbar bis",
|
||||
period: "Abrechnungsperiode",
|
||||
billTo: "Rechnungsempfänger",
|
||||
attentionPrefix: "z.Hd.",
|
||||
description: "Beschreibung",
|
||||
quantity: "Menge",
|
||||
unitPrice: "Einzelpreis",
|
||||
@@ -127,6 +107,7 @@ const MESSAGES: Record<string, PdfStrings> = {
|
||||
skill_usage: "Skill-Nutzung",
|
||||
skill_setup: "Einrichtungsgebühr Skill",
|
||||
adjustment: "Anpassung",
|
||||
custom_line: "Leistungen",
|
||||
},
|
||||
reverseCharge:
|
||||
"Steuerschuldnerschaft des Leistungsempfängers (Reverse Charge).",
|
||||
@@ -139,6 +120,7 @@ const MESSAGES: Record<string, PdfStrings> = {
|
||||
dueDate: "Due date",
|
||||
period: "Billing period",
|
||||
billTo: "Bill to",
|
||||
attentionPrefix: "Attn:",
|
||||
description: "Description",
|
||||
quantity: "Qty",
|
||||
unitPrice: "Unit price",
|
||||
@@ -159,6 +141,7 @@ const MESSAGES: Record<string, PdfStrings> = {
|
||||
skill_usage: "Skill usage",
|
||||
skill_setup: "Skill setup fee",
|
||||
adjustment: "Adjustment",
|
||||
custom_line: "Services",
|
||||
},
|
||||
reverseCharge:
|
||||
"Reverse charge — VAT to be accounted for by the recipient.",
|
||||
@@ -171,6 +154,7 @@ const MESSAGES: Record<string, PdfStrings> = {
|
||||
dueDate: "Échéance",
|
||||
period: "Période de facturation",
|
||||
billTo: "Destinataire",
|
||||
attentionPrefix: "À l'attention de",
|
||||
description: "Description",
|
||||
quantity: "Qté",
|
||||
unitPrice: "Prix unitaire",
|
||||
@@ -191,6 +175,7 @@ const MESSAGES: Record<string, PdfStrings> = {
|
||||
skill_usage: "Utilisation Skill",
|
||||
skill_setup: "Frais de configuration skill",
|
||||
adjustment: "Ajustement",
|
||||
custom_line: "Services",
|
||||
},
|
||||
reverseCharge:
|
||||
"Autoliquidation — TVA à acquitter par le destinataire.",
|
||||
@@ -203,6 +188,7 @@ const MESSAGES: Record<string, PdfStrings> = {
|
||||
dueDate: "Scadenza",
|
||||
period: "Periodo di fatturazione",
|
||||
billTo: "Destinatario",
|
||||
attentionPrefix: "c.a.",
|
||||
description: "Descrizione",
|
||||
quantity: "Qtà",
|
||||
unitPrice: "Prezzo unitario",
|
||||
@@ -223,6 +209,7 @@ const MESSAGES: Record<string, PdfStrings> = {
|
||||
skill_usage: "Utilizzo Skill",
|
||||
skill_setup: "Spese di attivazione skill",
|
||||
adjustment: "Rettifica",
|
||||
custom_line: "Servizi",
|
||||
},
|
||||
reverseCharge:
|
||||
"Inversione contabile — IVA a carico del destinatario.",
|
||||
@@ -349,62 +336,6 @@ const styles = StyleSheet.create({
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Logo — inlined SVG primitives
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* PieCed honeycomb logo. Re-renders the same 6-hex glyph as the
|
||||
* portal's `public/pieced-logo.svg` using React-PDF's SVG support.
|
||||
* Width/height are independent of the original viewBox so we can
|
||||
* scale it without losing stroke quality.
|
||||
*/
|
||||
const Logo = ({ size = 60 }: { size?: number }) => (
|
||||
<Svg width={size} height={size * (106 / 70)} viewBox="0 0 70 106">
|
||||
{/* H1 solid */}
|
||||
<Polygon
|
||||
points="38.5,22.69 31.5,10.566 17.5,10.566 10.5,22.69 17.5,34.814 31.5,34.814"
|
||||
fill="#10B981"
|
||||
stroke="#10B981"
|
||||
strokeWidth={1.6}
|
||||
/>
|
||||
{/* H2 outline */}
|
||||
<Polygon
|
||||
points="59.5,34.814 52.5,22.69 38.5,22.69 31.5,34.814 38.5,46.938 52.5,46.938"
|
||||
fill="none"
|
||||
stroke="#10B981"
|
||||
strokeWidth={1.8}
|
||||
/>
|
||||
{/* H3 outline */}
|
||||
<Polygon
|
||||
points="38.5,46.938 31.5,34.814 17.5,34.814 10.5,46.938 17.5,59.062 31.5,59.062"
|
||||
fill="none"
|
||||
stroke="#10B981"
|
||||
strokeWidth={1.8}
|
||||
/>
|
||||
{/* H4 solid */}
|
||||
<Polygon
|
||||
points="59.5,59.062 52.5,46.938 38.5,46.938 31.5,59.062 38.5,71.186 52.5,71.186"
|
||||
fill="#10B981"
|
||||
stroke="#10B981"
|
||||
strokeWidth={1.6}
|
||||
/>
|
||||
{/* H5 partial */}
|
||||
<Polyline
|
||||
points="31.5,83.31 38.5,71.186 31.5,59.062 17.5,59.062 10.5,71.186"
|
||||
fill="none"
|
||||
stroke="#10B981"
|
||||
strokeWidth={1.8}
|
||||
/>
|
||||
{/* H6 partial */}
|
||||
<Polyline
|
||||
points="59.5,83.31 52.5,71.186 38.5,71.186 31.5,83.31 38.5,95.434"
|
||||
fill="none"
|
||||
stroke="#10B981"
|
||||
strokeWidth={1.8}
|
||||
/>
|
||||
</Svg>
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -508,11 +439,18 @@ const InvoicePdf: React.FC<InvoicePdfProps> = ({ invoice, lines }) => {
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.metaCol}>
|
||||
<Text style={styles.metaLabel}>{s.period}</Text>
|
||||
<Text style={styles.metaValue}>
|
||||
{fmtDate(invoice.periodStart, invoice.locale)} —{" "}
|
||||
{fmtDate(invoice.periodEnd, invoice.locale)}
|
||||
</Text>
|
||||
{/* Phase 8: skip the billing-period block on custom
|
||||
invoices (which aren't tied to a period). Due date
|
||||
still renders. */}
|
||||
{invoice.periodStart && invoice.periodEnd && (
|
||||
<>
|
||||
<Text style={styles.metaLabel}>{s.period}</Text>
|
||||
<Text style={styles.metaValue}>
|
||||
{fmtDate(invoice.periodStart, invoice.locale)} —{" "}
|
||||
{fmtDate(invoice.periodEnd, invoice.locale)}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
<Text style={styles.metaLabel}>{s.dueDate}</Text>
|
||||
<Text style={styles.metaValue}>
|
||||
{fmtDate(invoice.dueAt, invoice.locale)}
|
||||
@@ -524,6 +462,15 @@ const InvoicePdf: React.FC<InvoicePdfProps> = ({ invoice, lines }) => {
|
||||
<View style={styles.billToBlock}>
|
||||
<Text style={styles.billToLabel}>{s.billTo}</Text>
|
||||
<Text style={styles.billToName}>{snap.companyName}</Text>
|
||||
{/* Phase 6 fix: optional "z.Hd." / "Attn:" line for routing
|
||||
the printed invoice internally at the customer. Prints
|
||||
between the company name and street address, in the
|
||||
invoice's locale (frozen at issue time). */}
|
||||
{snap.contactName && (
|
||||
<Text>
|
||||
{s.attentionPrefix} {snap.contactName}
|
||||
</Text>
|
||||
)}
|
||||
<Text>{snap.streetAddress}</Text>
|
||||
<Text>
|
||||
{snap.postalCode} {snap.city}
|
||||
|
||||
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;
|
||||
}
|
||||
1206
src/lib/db.ts
1206
src/lib/db.ts
File diff suppressed because it is too large
Load Diff
312
src/lib/email.ts
312
src/lib/email.ts
@@ -923,8 +923,8 @@ export async function sendInvoiceIssuedEmail(params: {
|
||||
currency: string; // "CHF" — passed for future-proofing
|
||||
dueAt: string; // ISO date
|
||||
lineCount: number;
|
||||
periodStart: string; // ISO date
|
||||
periodEnd: string; // ISO date
|
||||
periodStart: string | null; // ISO date; null for custom invoices
|
||||
periodEnd: string | null; // ISO date; null for custom invoices
|
||||
locale: "de" | "en" | "fr" | "it";
|
||||
}): Promise<void> {
|
||||
// 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 safeNumber = escapeHtml(params.invoiceNumber);
|
||||
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);
|
||||
|
||||
// Both bodies built in the invoice's locale.
|
||||
@@ -977,7 +983,9 @@ export async function sendInvoiceIssuedEmail(params: {
|
||||
introByLocale[L],
|
||||
"",
|
||||
`${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.due}: ${dueFmt}`,
|
||||
`${l.lines}: ${params.lineCount}`,
|
||||
@@ -995,7 +1003,7 @@ export async function sendInvoiceIssuedEmail(params: {
|
||||
<p>${escapeHtml(introByLocale[L])}</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.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.due}</td><td>${escapeHtml(dueFmt)}</td></tr>
|
||||
<tr><td style="color:#888; padding:6px 0;">${l.lines}</td><td>${params.lineCount}</td></tr>
|
||||
@@ -1158,3 +1166,297 @@ export async function sendInvoiceReminderEmail(params: {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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.
|
||||
*/
|
||||
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[] = [
|
||||
@@ -129,6 +152,7 @@ export const PACKAGE_CATALOG: PackageDef[] = [
|
||||
instructionsKey: "packages.telegram.instructions",
|
||||
disclaimerKey: "packages.telegram.disclaimer",
|
||||
category: "channel",
|
||||
collectsChannelUserId: true,
|
||||
},
|
||||
{
|
||||
id: "discord",
|
||||
@@ -158,6 +182,7 @@ export const PACKAGE_CATALOG: PackageDef[] = [
|
||||
instructionsKey: "packages.discord.instructions",
|
||||
disclaimerKey: "packages.discord.disclaimer",
|
||||
category: "channel",
|
||||
collectsChannelUserId: true,
|
||||
},
|
||||
{
|
||||
id: "threema",
|
||||
@@ -173,6 +198,8 @@ export const PACKAGE_CATALOG: PackageDef[] = [
|
||||
instructionsKey: "packages.threema.instructions",
|
||||
disclaimerKey: "packages.threema.disclaimer",
|
||||
category: "channel",
|
||||
recommended: true,
|
||||
collectsChannelUserId: true,
|
||||
},
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
@@ -231,7 +258,6 @@ export const PACKAGE_CATALOG: PackageDef[] = [
|
||||
},
|
||||
{
|
||||
id: "gog",
|
||||
requiresManualSetup: true,
|
||||
name: "Google Workspace (Gog)",
|
||||
descriptionKey: "packages.gog.description",
|
||||
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
|
||||
* outbound reply (kani-tts / kokoro-fastapi via LiteLLM). Opt-in keeps
|
||||
* 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[] = [
|
||||
"core-heartbeat",
|
||||
"core-cron",
|
||||
"core-active-memory",
|
||||
];
|
||||
export const DEFAULT_PACKAGE_IDS: string[] = [];
|
||||
|
||||
118
src/lib/pdf-brand.tsx
Normal file
118
src/lib/pdf-brand.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* Shared brand constants and Logo component for all PDF documents
|
||||
* (invoices, credit notes, future quotes / reminders).
|
||||
*
|
||||
* Phase 7 fix: previously each PDF generator carried its own copy
|
||||
* of BRAND and its own Logo. When Cedric customized the invoice
|
||||
* issuer block in his deployment (real Strasse Nr., PLZ, etc.),
|
||||
* the credit note PDF kept the original placeholders because it
|
||||
* had its own duplicate. Hoisting both here means every PDF reads
|
||||
* the same source of truth.
|
||||
*
|
||||
* To change the brand: edit BRAND below. To change the logo:
|
||||
* edit Logo below. To change the issuer info Cedric ships: edit
|
||||
* BRAND.issuer — both billing-pdf.tsx and credit-note-pdf.tsx pick
|
||||
* it up automatically.
|
||||
*
|
||||
* The Logo component accepts a `color` prop so the credit-note
|
||||
* variant can render the SAME shape tinted red (the document
|
||||
* family is visually consistent; only the accent colour signals
|
||||
* "this is a credit, not an invoice").
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import { Svg, Polygon, Polyline } from "@react-pdf/renderer";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Brand constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export 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.
|
||||
// Both billing-pdf.tsx and credit-note-pdf.tsx read from here.
|
||||
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 — used by invoice PDF, ignored on credit
|
||||
// notes (refunds flow back via the original payment method).
|
||||
bankName: "[Bank name]",
|
||||
bankIban: "[CHxx xxxx xxxx xxxx xxxx x]",
|
||||
bankBic: "[BIC]",
|
||||
},
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Logo — PieCed's hexagon-pattern mark. Same shape used everywhere
|
||||
// and same brand colour. The credit note is still a PieCed IT
|
||||
// document and reads with the same company identity as an invoice.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface LogoProps {
|
||||
size?: number;
|
||||
/** Defaults to BRAND.primary. Override only for special cases
|
||||
* (e.g. an inverse variant on a dark background). Standard
|
||||
* documents — invoices, credit notes — all use BRAND.primary. */
|
||||
color?: string;
|
||||
}
|
||||
|
||||
export const Logo = ({ size = 60, color = BRAND.primary }: LogoProps) => (
|
||||
<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={color}
|
||||
stroke={color}
|
||||
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={color}
|
||||
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={color}
|
||||
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={color}
|
||||
stroke={color}
|
||||
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={color}
|
||||
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={color}
|
||||
strokeWidth={1.8}
|
||||
/>
|
||||
</Svg>
|
||||
);
|
||||
@@ -220,7 +220,13 @@ export async function createCheckoutSessionForInvoice(params: {
|
||||
unit_amount: chfToRappen(invoice.totalChf),
|
||||
product_data: {
|
||||
name: `Invoice ${invoice.invoiceNumber}`,
|
||||
description: `PieCed IT — ${invoice.periodStart.slice(0, 10)} → ${invoice.periodEnd.slice(0, 10)}`,
|
||||
// Phase 8: custom invoices have no period — fall back
|
||||
// to a description that just references the invoice
|
||||
// number and due date.
|
||||
description:
|
||||
invoice.periodStart && invoice.periodEnd
|
||||
? `PieCed IT — ${invoice.periodStart.slice(0, 10)} → ${invoice.periodEnd.slice(0, 10)}`
|
||||
: `PieCed IT — due ${invoice.dueAt.slice(0, 10)}`,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -244,6 +250,15 @@ export async function createCheckoutSessionForInvoice(params: {
|
||||
// since Stripe will prepend the merchant name from the
|
||||
// account anyway. Keep it short and recognisable.
|
||||
description: `Invoice ${invoice.invoiceNumber}`,
|
||||
// Phase 9b-2: every manual Pay-by-Card refreshes the org's
|
||||
// saved PaymentMethod. The webhook (payment-mode handler) is
|
||||
// already wired to read setup_future_usage and persist the
|
||||
// resulting PM's display fields against the org. Net effect:
|
||||
// a customer whose auto-charge failed because their card
|
||||
// expired pays manually once → fresh card is now saved →
|
||||
// next month auto-charges work again. No separate "update
|
||||
// card" step needed.
|
||||
setup_future_usage: "off_session",
|
||||
},
|
||||
success_url: successUrl,
|
||||
cancel_url: cancelUrl,
|
||||
@@ -258,3 +273,380 @@ export async function createCheckoutSessionForInvoice(params: {
|
||||
}
|
||||
return { url: session.url, sessionId: session.id };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Phase 7 — refunds
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Create a Stripe Refund against an invoice's PaymentIntent.
|
||||
*
|
||||
* The amount is in CHF; we convert to rappen for Stripe's smallest-
|
||||
* currency-unit API. Pass 0 or undefined for `amountChf` to refund
|
||||
* the full charge.
|
||||
*
|
||||
* Returns the Stripe refund object so the caller can record the
|
||||
* refund id and final status. Stripe processes refunds asynchronously
|
||||
* for some payment methods, so the initial status may be 'pending'
|
||||
* — the charge.refunded webhook delivers the eventual succeeded /
|
||||
* failed transition.
|
||||
*
|
||||
* Throws on Stripe API errors (no charge, insufficient balance,
|
||||
* etc.). The caller surfaces these to the admin via the API
|
||||
* response — we don't swallow them because partial-refund logic
|
||||
* shouldn't be guessing about server state.
|
||||
*/
|
||||
export async function createInvoiceRefund(params: {
|
||||
paymentIntentId: string;
|
||||
amountChf?: number;
|
||||
reason?: "duplicate" | "fraudulent" | "requested_by_customer";
|
||||
metadata?: Record<string, string>;
|
||||
}): Promise<{
|
||||
id: string;
|
||||
amountChf: number;
|
||||
status: string;
|
||||
}> {
|
||||
const stripe = getStripeClient();
|
||||
const refundParams: Parameters<typeof stripe.refunds.create>[0] = {
|
||||
payment_intent: params.paymentIntentId,
|
||||
metadata: params.metadata,
|
||||
};
|
||||
if (params.amountChf && params.amountChf > 0) {
|
||||
refundParams.amount = chfToRappen(params.amountChf);
|
||||
}
|
||||
if (params.reason) {
|
||||
refundParams.reason = params.reason;
|
||||
}
|
||||
const refund = await stripe.refunds.create(refundParams);
|
||||
// The amount on the response is in rappen; convert back. If no
|
||||
// amount was passed, Stripe defaults to the full remaining
|
||||
// charge, which is what we read back.
|
||||
return {
|
||||
id: refund.id,
|
||||
amountChf: refund.amount != null ? refund.amount / 100 : 0,
|
||||
status: refund.status ?? "unknown",
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Phase 9 — saved cards (SetupIntent / Checkout setup mode)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Create a Checkout session in setup mode — Stripe collects card
|
||||
* details and authorizes them for off-session future charges,
|
||||
* without charging anything now. On success, Stripe attaches the
|
||||
* resulting PaymentMethod to the customer object and fires
|
||||
* `checkout.session.completed` with mode='setup'.
|
||||
*
|
||||
* The webhook handler reads the session's setup_intent, extracts
|
||||
* the payment_method id, and persists the display fields
|
||||
* (brand/last4/exp) via setSavedPaymentMethod. From that moment
|
||||
* on, the customer has auto-charge wired up.
|
||||
*
|
||||
* Re-running this against a customer who already has a saved card
|
||||
* is supported — Stripe attaches the new PaymentMethod and the
|
||||
* webhook overwrites the old one in our DB. That's how "Update
|
||||
* card" works.
|
||||
*/
|
||||
export async function createSetupCheckoutSession(params: {
|
||||
customerId: string;
|
||||
baseUrl: string;
|
||||
locale?: "de" | "en" | "fr" | "it";
|
||||
/**
|
||||
* Where to redirect after the customer completes / cancels the
|
||||
* setup. Defaults to /settings/billing — the natural landing
|
||||
* spot after saving a card.
|
||||
*/
|
||||
returnPath?: string;
|
||||
}): Promise<{ url: string; sessionId: string }> {
|
||||
const stripe = getStripeClient();
|
||||
const { customerId, baseUrl, locale } = params;
|
||||
const returnPath = params.returnPath ?? "/settings/billing";
|
||||
const stripeLocale =
|
||||
locale === "de"
|
||||
? ("de" as const)
|
||||
: locale === "fr"
|
||||
? ("fr" as const)
|
||||
: locale === "it"
|
||||
? ("it" as const)
|
||||
: locale === "en"
|
||||
? ("en" as const)
|
||||
: ("auto" as const);
|
||||
|
||||
const successUrl = `${baseUrl}${returnPath}?card_setup=success&session_id={CHECKOUT_SESSION_ID}`;
|
||||
const cancelUrl = `${baseUrl}${returnPath}?card_setup=cancelled`;
|
||||
|
||||
const session = await stripe.checkout.sessions.create({
|
||||
mode: "setup",
|
||||
customer: customerId,
|
||||
locale: stripeLocale,
|
||||
payment_method_types: ["card"],
|
||||
success_url: successUrl,
|
||||
cancel_url: cancelUrl,
|
||||
// Stripe attaches the resulting PaymentMethod to the customer
|
||||
// and the webhook fires with session.setup_intent populated.
|
||||
// No extra setup_intent_data needed for the basic flow.
|
||||
});
|
||||
if (!session.url) {
|
||||
throw new Error(
|
||||
`Stripe returned a setup session without a redirect URL (id=${session.id})`
|
||||
);
|
||||
}
|
||||
return { url: session.url, sessionId: session.id };
|
||||
}
|
||||
|
||||
/**
|
||||
* Detach a PaymentMethod from its customer. Used when the customer
|
||||
* clicks "Remove card" — the PM is no longer usable for charges
|
||||
* once detached. The Stripe Customer object survives (so future
|
||||
* charges can still attach a new card to the same customer).
|
||||
*
|
||||
* Stripe permits detaching a PM that's already detached as a
|
||||
* no-op; safe to retry.
|
||||
*/
|
||||
export async function detachPaymentMethod(
|
||||
paymentMethodId: string
|
||||
): Promise<void> {
|
||||
const stripe = getStripeClient();
|
||||
try {
|
||||
await stripe.paymentMethods.detach(paymentMethodId);
|
||||
} catch (e: any) {
|
||||
// Stripe returns 404 if the PM is already detached or doesn't
|
||||
// exist — treat as success since the intended end-state ("not
|
||||
// attached") is already reached. Re-throw anything else.
|
||||
if (e?.statusCode === 404) return;
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the display fields for a PaymentMethod (brand, last4,
|
||||
* exp). Used by the webhook to read out what to persist after a
|
||||
* setup session completes; the session itself only carries the
|
||||
* PM id, not the card details.
|
||||
*/
|
||||
export async function getPaymentMethodDisplay(
|
||||
paymentMethodId: string
|
||||
): Promise<{
|
||||
brand: string | null;
|
||||
last4: string | null;
|
||||
expMonth: number | null;
|
||||
expYear: number | null;
|
||||
}> {
|
||||
const stripe = getStripeClient();
|
||||
const pm = await stripe.paymentMethods.retrieve(paymentMethodId);
|
||||
// The card object is only present when type='card'. We don't
|
||||
// anticipate non-card PMs in this codebase yet, but defensive
|
||||
// null-handling avoids crashing if Stripe surfaces something
|
||||
// unexpected (Apple Pay, link, etc. — all of which still
|
||||
// resolve to a card under the hood).
|
||||
const card = (pm as any).card;
|
||||
if (!card) {
|
||||
return { brand: null, last4: null, expMonth: null, expYear: null };
|
||||
}
|
||||
return {
|
||||
brand: card.brand ?? null,
|
||||
last4: card.last4 ?? null,
|
||||
expMonth: typeof card.exp_month === "number" ? card.exp_month : null,
|
||||
expYear: typeof card.exp_year === "number" ? card.exp_year : null,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Phase 9b — order-time setup-fee Checkout
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Create a Stripe Checkout session that charges the setup-fee
|
||||
* invoice immediately AND saves/refreshes the customer's
|
||||
* PaymentMethod for future off-session use (recurring monthly
|
||||
* charges).
|
||||
*
|
||||
* Same `mode: 'payment'` as the regular pay-invoice Checkout —
|
||||
* the difference is:
|
||||
* - metadata.flow = 'setup_fee' so the webhook knows to flip
|
||||
* the tenant_request row from 'pending_payment' to 'pending'
|
||||
* and link the invoice to it
|
||||
* - metadata.tenant_request_id is the row to update
|
||||
* - payment_intent_data.setup_future_usage = 'off_session' so
|
||||
* the resulting PaymentMethod gets saved against the customer.
|
||||
* Phase 9b-2's recurring auto-charge reads that PM id
|
||||
*
|
||||
* Success URL routes to /dashboard?ordered=1 (vs. the regular
|
||||
* pay flow which lands on /billing/<invoiceNumber>). Cancel
|
||||
* routes to /onboarding?cancelled=1 so the customer can retry.
|
||||
*/
|
||||
export async function createSetupFeeCheckoutSession(params: {
|
||||
invoice: Invoice;
|
||||
customerId: string;
|
||||
baseUrl: string;
|
||||
tenantRequestId: string;
|
||||
}): Promise<{ url: string; sessionId: string }> {
|
||||
const stripe = getStripeClient();
|
||||
const { invoice, customerId, baseUrl, tenantRequestId } = params;
|
||||
|
||||
const stripeLocale =
|
||||
invoice.locale === "de"
|
||||
? ("de" as const)
|
||||
: invoice.locale === "fr"
|
||||
? ("fr" as const)
|
||||
: invoice.locale === "it"
|
||||
? ("it" as const)
|
||||
: invoice.locale === "en"
|
||||
? ("en" as const)
|
||||
: ("auto" as const);
|
||||
|
||||
const successUrl = `${baseUrl}/dashboard?ordered=1&session_id={CHECKOUT_SESSION_ID}`;
|
||||
const cancelUrl = `${baseUrl}/onboarding?cancelled=1`;
|
||||
|
||||
const session = await stripe.checkout.sessions.create({
|
||||
mode: "payment",
|
||||
customer: customerId,
|
||||
client_reference_id: invoice.id,
|
||||
locale: stripeLocale,
|
||||
line_items: [
|
||||
{
|
||||
quantity: 1,
|
||||
price_data: {
|
||||
currency: "chf",
|
||||
unit_amount: chfToRappen(invoice.totalChf),
|
||||
product_data: {
|
||||
name: `Setup fee — ${invoice.invoiceNumber}`,
|
||||
description: `PieCed IT — tenant setup`,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
payment_intent_data: {
|
||||
// Save the resulting PaymentMethod against the customer for
|
||||
// future off-session use (Phase 9b-2 recurring charges).
|
||||
setup_future_usage: "off_session",
|
||||
metadata: {
|
||||
invoice_id: invoice.id,
|
||||
invoice_number: invoice.invoiceNumber,
|
||||
zitadel_org_id: invoice.zitadelOrgId,
|
||||
},
|
||||
},
|
||||
metadata: {
|
||||
invoice_id: invoice.id,
|
||||
invoice_number: invoice.invoiceNumber,
|
||||
zitadel_org_id: invoice.zitadelOrgId,
|
||||
// Phase 9b discriminators — webhook reads these to do the
|
||||
// tenant_request linkage on top of the regular invoice-paid
|
||||
// flow.
|
||||
flow: "setup_fee",
|
||||
tenant_request_id: tenantRequestId,
|
||||
},
|
||||
success_url: successUrl,
|
||||
cancel_url: cancelUrl,
|
||||
});
|
||||
if (!session.url) {
|
||||
throw new Error(
|
||||
`Stripe returned a setup-fee session without a redirect URL (id=${session.id})`
|
||||
);
|
||||
}
|
||||
return { url: session.url, sessionId: session.id };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Phase 9b-2 — off-session auto-charge for issued invoices
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Attempt to charge an invoice off-session against the customer's
|
||||
* saved PaymentMethod. Used by chargeInvoiceIfPossible() from
|
||||
* generateInvoice (monthly) and issueCustomInvoiceDraft (admin
|
||||
* custom).
|
||||
*
|
||||
* Stripe semantics with `off_session: true, confirm: true`:
|
||||
* - On success: PaymentIntent.status = 'succeeded', card was
|
||||
* charged. Returns 'succeeded'.
|
||||
* - On 3DS required: PaymentIntent.status = 'requires_action'.
|
||||
* We can't complete this off-session. Customer must pay
|
||||
* manually via Checkout (which handles 3DS in-browser).
|
||||
* Returns 'requires_action'.
|
||||
* - On hard decline: thrown StripeCardError, code = 'card_declined'
|
||||
* or 'insufficient_funds' etc. Returns 'declined' with the
|
||||
* error code.
|
||||
* - On expired card or other recoverable issue: thrown
|
||||
* StripeCardError. Returns 'declined' with the code.
|
||||
*
|
||||
* The receipt_email is set to the org's billing email so Stripe
|
||||
* sends the customer an automated receipt on success — we don't
|
||||
* need to send our own "you've been charged" email.
|
||||
*/
|
||||
export type ChargeOutcome =
|
||||
| { status: "succeeded"; paymentIntentId: string }
|
||||
| { status: "requires_action"; paymentIntentId: string; reason: string }
|
||||
| { status: "declined"; reason: string; code?: string };
|
||||
|
||||
export async function chargeInvoiceOffSession(params: {
|
||||
invoice: Invoice;
|
||||
customerId: string;
|
||||
paymentMethodId: string;
|
||||
/**
|
||||
* If set, Stripe emails an automated receipt here on successful
|
||||
* capture. We use the org's billing snapshot email so the receipt
|
||||
* goes to the same address as the issued / failed emails.
|
||||
*/
|
||||
receiptEmail?: string | null;
|
||||
}): Promise<ChargeOutcome> {
|
||||
const stripe = getStripeClient();
|
||||
const { invoice, customerId, paymentMethodId, receiptEmail } = params;
|
||||
try {
|
||||
const pi = await stripe.paymentIntents.create({
|
||||
amount: chfToRappen(invoice.totalChf),
|
||||
currency: "chf",
|
||||
customer: customerId,
|
||||
payment_method: paymentMethodId,
|
||||
off_session: true,
|
||||
confirm: true,
|
||||
description: `Invoice ${invoice.invoiceNumber}`,
|
||||
receipt_email: receiptEmail ?? undefined,
|
||||
metadata: {
|
||||
invoice_id: invoice.id,
|
||||
invoice_number: invoice.invoiceNumber,
|
||||
zitadel_org_id: invoice.zitadelOrgId,
|
||||
flow: "auto_charge",
|
||||
},
|
||||
});
|
||||
if (pi.status === "succeeded") {
|
||||
return { status: "succeeded", paymentIntentId: pi.id };
|
||||
}
|
||||
if (pi.status === "requires_action") {
|
||||
return {
|
||||
status: "requires_action",
|
||||
paymentIntentId: pi.id,
|
||||
reason: "Authentication required (3DS). Customer must pay via Checkout.",
|
||||
};
|
||||
}
|
||||
// Any other non-succeeded status (rare with off_session+confirm)
|
||||
// is treated as a failure for our purposes.
|
||||
return {
|
||||
status: "declined",
|
||||
reason: `Unexpected PaymentIntent status: ${pi.status}`,
|
||||
};
|
||||
} catch (e: any) {
|
||||
// Stripe's off-session declines surface as a StripeCardError
|
||||
// with the PI on e.payment_intent. The 'code' (e.g.
|
||||
// 'card_declined', 'expired_card', 'authentication_required')
|
||||
// is the most actionable signal; e.message is human-readable.
|
||||
const code: string | undefined = e?.code ?? e?.raw?.code;
|
||||
const message: string =
|
||||
e?.message ?? e?.raw?.message ?? "Card was declined.";
|
||||
// authentication_required is technically a "decline" from the
|
||||
// off-session path even though it could succeed on-session.
|
||||
// Surface it distinctly so the caller can tell the customer to
|
||||
// go pay manually (which will use Checkout + handle 3DS).
|
||||
if (code === "authentication_required") {
|
||||
const piId = e?.payment_intent?.id ?? "";
|
||||
return {
|
||||
status: "requires_action",
|
||||
paymentIntentId: piId,
|
||||
reason: "Authentication required (3DS). Customer must pay via Checkout.",
|
||||
};
|
||||
}
|
||||
return { status: "declined", reason: message, code };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -152,6 +152,12 @@ export const onboardingSchema = z.object({
|
||||
packageSecrets: z
|
||||
.record(z.string(), z.record(z.string(), z.string()))
|
||||
.optional(),
|
||||
// Phase 9b: per-channel initial user ids collected during
|
||||
// onboarding. Map of channel package id → list of user ids the
|
||||
// customer wants to authorize. Applied at admin approval time.
|
||||
channelUsers: z
|
||||
.record(z.string(), z.array(z.string().trim().min(1).max(200)))
|
||||
.optional(),
|
||||
billingAddress: billingAddressSchema.optional(),
|
||||
billingNotes: z.string().max(2_000).optional(),
|
||||
});
|
||||
|
||||
@@ -528,3 +528,113 @@ export async function registerCustomer(params: {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// v2 User API — profile updates (Phase 6 fix5)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Update a human user's profile (first name + last name + display
|
||||
* name). Returns the new `details.changeDate` from ZITADEL so the
|
||||
* caller can confirm the write landed.
|
||||
*
|
||||
* The v2 user service endpoint is technically a PUT but accepts
|
||||
* partial bodies — only the `profile` block is sent. ZITADEL
|
||||
* preserves email, password, and other fields across the call
|
||||
* (verified empirically in zitadel-server#7786 and documented in
|
||||
* v2.63+ of zitadel-server).
|
||||
*
|
||||
* `displayName` IS sent explicitly, set to "givenName familyName".
|
||||
* Empirically (and contra what some docs suggest), ZITADEL does
|
||||
* NOT recompute displayName when only the name parts change — it
|
||||
* keeps whatever displayName was previously stored, including the
|
||||
* one set at user creation time. That stale displayName is what
|
||||
* ZITADEL surfaces in the OIDC `name` claim, so without this
|
||||
* explicit write the portal session would never see the updated
|
||||
* name (even after sign-out / sign-in).
|
||||
*
|
||||
* Auth: the portal's service-account PAT (ZITADEL_SA_PAT). The PAT
|
||||
* must have user-write permission in the user's resource org.
|
||||
* Today portal-zitadel-sa-pat already has user-write for
|
||||
* createHumanUser etc. — same scope covers this.
|
||||
*/
|
||||
export interface UpdateHumanUserProfileResult {
|
||||
changeDate: string;
|
||||
/** The displayName ZITADEL stored, which the OIDC `name` claim will
|
||||
* carry on the user's next session. */
|
||||
displayName: string;
|
||||
}
|
||||
|
||||
export async function updateHumanUserProfile(params: {
|
||||
userId: string;
|
||||
givenName: string;
|
||||
familyName: string;
|
||||
}): Promise<UpdateHumanUserProfileResult> {
|
||||
const path = `/v2/users/human/${encodeURIComponent(params.userId)}`;
|
||||
// Compose the displayName ourselves so ZITADEL stores something
|
||||
// sensible. Empty-string fallback only triggers if both name parts
|
||||
// are blank, which the API zod schema prevents anyway.
|
||||
const displayName =
|
||||
`${params.givenName.trim()} ${params.familyName.trim()}`.trim();
|
||||
type ZitadelUpdateResponse = {
|
||||
details?: { changeDate?: string };
|
||||
};
|
||||
await zitadelFetch<ZitadelUpdateResponse>(path, "PUT", {
|
||||
profile: {
|
||||
givenName: params.givenName,
|
||||
familyName: params.familyName,
|
||||
displayName,
|
||||
},
|
||||
});
|
||||
// Re-fetch the user to read back the canonical displayName ZITADEL
|
||||
// committed. Should match what we sent, but reading from the source
|
||||
// of truth catches any sanitization ZITADEL might apply.
|
||||
const detail = await getHumanUserDetail(params.userId);
|
||||
return {
|
||||
changeDate: new Date().toISOString(),
|
||||
displayName: detail.displayName || displayName,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a human user's current profile (given/family/display name +
|
||||
* email). Used by the settings page to populate the form and by the
|
||||
* update helper above to read back the computed displayName.
|
||||
*/
|
||||
export interface HumanUserDetail {
|
||||
userId: string;
|
||||
givenName: string;
|
||||
familyName: string;
|
||||
displayName: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
export async function getHumanUserDetail(
|
||||
userId: string
|
||||
): Promise<HumanUserDetail> {
|
||||
type ZitadelGetUserResponse = {
|
||||
user?: {
|
||||
userId?: string;
|
||||
human?: {
|
||||
profile?: {
|
||||
givenName?: string;
|
||||
familyName?: string;
|
||||
displayName?: string;
|
||||
};
|
||||
email?: { email?: string };
|
||||
};
|
||||
};
|
||||
};
|
||||
const response = await zitadelFetch<ZitadelGetUserResponse>(
|
||||
`/v2/users/${encodeURIComponent(userId)}`,
|
||||
"GET"
|
||||
);
|
||||
const human = response.user?.human;
|
||||
return {
|
||||
userId: response.user?.userId ?? userId,
|
||||
givenName: human?.profile?.givenName ?? "",
|
||||
familyName: human?.profile?.familyName ?? "",
|
||||
displayName: human?.profile?.displayName ?? "",
|
||||
email: human?.email?.email ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -121,7 +121,28 @@
|
||||
"saveChanges": "Änderungen speichern",
|
||||
"billingVatNumber": "MWST-Nummer",
|
||||
"billingVatHelp": "Ihre registrierte MWST-Nummer. Falls Ihre Firma von der MWST befreit ist, leer lassen und in den Notizen erläutern.",
|
||||
"billingNotesPlaceholderPersonal": "Was wir wissen sollten — bevorzugte Zahlungsart, Rechnungsreferenz, etc."
|
||||
"billingNotesPlaceholderPersonal": "Was wir wissen sollten — bevorzugte Zahlungsart, Rechnungsreferenz, etc.",
|
||||
"reviewContactPersonPrefix": "z.Hd.",
|
||||
"setupFeeNoticeHeading": "Einrichtungsgebühr wird beim Senden belastet",
|
||||
"setupFeeNoticeBody": "Mit dem nächsten Klick werden Sie zu Stripe weitergeleitet, um Ihre Zahlungsdetails einzugeben und die einmalige Einrichtungsgebühr zu bezahlen. Ihre Karte wird automatisch für die zukünftige monatliche Abrechnung gespeichert. Anschliessend gelangen Sie direkt zurück zum Dashboard. Die Instanz startet erst nach Admin-Freigabe — monatliche Gebühren beginnen ab dem Freigabedatum.",
|
||||
"setupFeeAmountLabel": "Einmalige Einrichtungsgebühr",
|
||||
"setupFeePlusVat": "+ MwSt.",
|
||||
"optional": "optional",
|
||||
"yourChannelIdLabel": {
|
||||
"telegram": "Ihre Telegram-Benutzer-ID",
|
||||
"discord": "Ihre Discord-Benutzer-ID",
|
||||
"threema": "Ihre Threema-ID"
|
||||
},
|
||||
"yourChannelIdPlaceholder": {
|
||||
"telegram": "z.B. 1234567890",
|
||||
"discord": "z.B. 234567890123456789",
|
||||
"threema": "z.B. ABCD1234"
|
||||
},
|
||||
"yourChannelIdHelp": {
|
||||
"telegram": "Öffnen Sie Telegram, schreiben Sie an @userinfobot und fügen Sie die zurückgegebene numerische ID hier ein. Weitere Benutzer können Sie später auf der Mandantenseite hinzufügen.",
|
||||
"discord": "Aktivieren Sie den Entwicklermodus in Discord (Erweiterte Einstellungen), Rechtsklick auf Ihren Namen → Benutzer-ID kopieren, und hier einfügen. Weitere Benutzer können Sie später auf der Mandantenseite hinzufügen.",
|
||||
"threema": "Die 8-stellige ID, die in Ihrer Threema-App unter Einstellungen → Meine Threema-ID angezeigt wird. Nach der Freigabe können Sie direkt von diesem Threema-Account aus mit dem Assistenten chatten. Weitere autorisierte IDs können Sie später auf der Mandantenseite hinzufügen."
|
||||
}
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Dashboard",
|
||||
@@ -318,7 +339,12 @@
|
||||
"activationRejected": "Abgelehnt",
|
||||
"tryAgain": "Erneut versuchen",
|
||||
"credentialsSaved": "Zugangsdaten gespeichert",
|
||||
"credentialsSavedTip": "Die eingegebenen Zugangsdaten sind sicher gespeichert und werden verwendet, sobald die Aktivierung vom Admin genehmigt wurde. Sie müssen sie nicht erneut eingeben."
|
||||
"credentialsSavedTip": "Die eingegebenen Zugangsdaten sind sicher gespeichert und werden verwendet, sobald die Aktivierung vom Admin genehmigt wurde. Sie müssen sie nicht erneut eingeben.",
|
||||
"recommended": "Empfohlen",
|
||||
"threemaBotIdHeading": "Bot-Threema-ID",
|
||||
"threemaBotIdHint": "Sobald Ihr Mandant freigegeben ist, scannen Sie diesen QR-Code mit Threema, um den Assistenten zu Ihren Kontakten hinzuzufügen. Der QR-Code ist für jeden PieCed-Mandanten identisch — Sie können ihn schon jetzt speichern.",
|
||||
"showInfo": "Info",
|
||||
"showInfoTitle": "Setup-Info erneut anzeigen"
|
||||
},
|
||||
"admin": {
|
||||
"title": "Plattform-Admin",
|
||||
@@ -479,28 +505,56 @@
|
||||
"billingTitle": "Abrechnung",
|
||||
"billingDescription": "Adresse, MWST-Nummer und Rechnungs-E-Mail für alle Ihre Tenants.",
|
||||
"nothingForYou": "Für Ihre Rolle gibt es hier noch nichts. Inhaber können Organisationseinstellungen verwalten.",
|
||||
"billingDescriptionPersonal": "Adresse und Rechnungs-E-Mail für alle Ihre Tenants."
|
||||
"billingDescriptionPersonal": "Adresse und Rechnungs-E-Mail für alle Ihre Tenants.",
|
||||
"profileTitle": "Profil",
|
||||
"profileDescription": "Bearbeiten Sie Ihren Vor- und Nachnamen, wie er im Portal erscheint."
|
||||
},
|
||||
"settingsBilling": {
|
||||
"title": "Abrechnung",
|
||||
"subtitle": "Wird beim ersten Onboarding einmalig erfasst und für jeden Tenant Ihrer Organisation wiederverwendet. Aktualisieren Sie hier, wenn sich Ihre Abrechnungsdaten ändern.",
|
||||
"companyName": "Firmenname",
|
||||
"streetAddress": "Strasse",
|
||||
"postalCode": "PLZ",
|
||||
"city": "Ort",
|
||||
"country": "Land",
|
||||
"vatNumber": "MWST-Nummer",
|
||||
"vatHelp": "Ihre registrierte MWST-Nummer (z. B. CHE-123.456.789 MWST für die Schweiz).",
|
||||
"billingEmail": "Rechnungs-E-Mail",
|
||||
"billingEmailHelp": "An diese Adresse werden Rechnungen und Abrechnungskommunikation gesendet.",
|
||||
"notes": "Notizen",
|
||||
"notesPlaceholder": "Alles, was die Buchhaltung wissen muss – MWST-Befreiung, besondere Rechnungsstellung usw.",
|
||||
"save": "Speichern",
|
||||
"title": "Rechnungsdaten",
|
||||
"subtitle": "Rechnungsadresse, MWST-Nummer und Rechnungskontakt Ihres Unternehmens. Erforderlich, bevor Rechnungen für Ihre Organisation ausgestellt werden können.",
|
||||
"companyNameLabel": "Firmenname",
|
||||
"streetAddressLabel": "Strasse und Hausnummer",
|
||||
"postalCodeLabel": "PLZ",
|
||||
"cityLabel": "Ort",
|
||||
"countryLabel": "Ländercode",
|
||||
"countryHint": "ISO 3166-1 alpha-2 — z.B. CH, DE, AT, FR, IT, GB, US",
|
||||
"vatNumberLabel": "MWST-Nummer (optional)",
|
||||
"vatNumberHint": "Für Schweizer Kunden: CHE-XXX.XXX.XXX MWST. EU-Kunden mit USt-IdNr. erhalten eine Reverse-Charge-Rechnung (0% MWST).",
|
||||
"billingEmailLabel": "Rechnungs-E-Mail",
|
||||
"billingEmailHint": "Rechnungen und Zahlungserinnerungen werden an diese Adresse gesendet. Kann von Ihrer Konto-E-Mail abweichen.",
|
||||
"notesLabel": "Bemerkungen (optional)",
|
||||
"notesHint": "Referenznummern, Bestellnummern oder andere Angaben, die auf der Rechnung erscheinen sollen.",
|
||||
"saveChanges": "Änderungen speichern",
|
||||
"createBilling": "Rechnungsdaten speichern",
|
||||
"saving": "Wird gespeichert…",
|
||||
"saved": "Gespeichert.",
|
||||
"saveFailed": "Konnte nicht gespeichert werden. Bitte erneut versuchen.",
|
||||
"lastUpdated": "Zuletzt aktualisiert {when}",
|
||||
"fullName": "Voller Name",
|
||||
"notesPlaceholderPersonal": "Was wir wissen sollten — bevorzugte Zahlungsart, Rechnungsreferenz, etc."
|
||||
"missingRequired": "Bitte alle Pflichtfelder ausfüllen.",
|
||||
"invalidCountry": "Ländercode muss aus 2 Buchstaben bestehen (z.B. CH).",
|
||||
"invalidEmail": "Bitte eine gültige E-Mail-Adresse eingeben.",
|
||||
"fullNameLabel": "Vor- und Nachname",
|
||||
"subtitlePersonal": "Ihre Rechnungsadresse und Rechnungskontakt. Erforderlich, bevor Rechnungen ausgestellt werden können.",
|
||||
"contactNameLabel": "Ansprechperson (optional)",
|
||||
"contactNameHint": "Erscheint als 'z.Hd. <Name>' auf der Rechnung unter dem Firmennamen. Hilfreich für die Zuordnung in der Buchhaltung grösserer Firmen.",
|
||||
"savedCardHeading": "Hinterlegte Karte",
|
||||
"savedCardEmptyBody": "Hinterlegen Sie eine Karte für die automatische Bezahlung von Rechnungen. Ihre Kartendaten werden sicher bei Stripe gespeichert — wir sehen nur Marke, letzte vier Ziffern und Ablaufdatum.",
|
||||
"savedCardSetupBtn": "Auto-Zahlung einrichten",
|
||||
"savedCardRedirecting": "Weiterleitung…",
|
||||
"savedCardUpdateBtn": "Karte aktualisieren",
|
||||
"savedCardRemoveBtn": "Karte entfernen",
|
||||
"savedCardRemoving": "Entfernen…",
|
||||
"savedCardRemoveConfirm": "Diese Karte entfernen? Sie müssen die Auto-Zahlung erneut einrichten, damit zukünftige Rechnungen automatisch belastet werden.",
|
||||
"savedCardBrandUnknown": "Karte",
|
||||
"savedCardExpires": "läuft ab {date}",
|
||||
"savedCardAutoChargeOn": "Auto-Zahlung aktiv",
|
||||
"savedCardAutoChargeOff": "Auto-Zahlung inaktiv",
|
||||
"savedCardDisableAutoChargeBtn": "Auto-Zahlung deaktivieren",
|
||||
"savedCardEnableAutoChargeBtn": "Auto-Zahlung aktivieren",
|
||||
"savedCardPayByInvoiceNote": "Ihr Konto ist auf Banküberweisung eingestellt; die hinterlegte Karte wird nicht für automatische Abbuchungen verwendet. Wenden Sie sich an den Support, wenn Sie wieder per Karte bezahlen möchten.",
|
||||
"savedCardBankTransferHint": "Banküberweisung ist auf Anfrage ebenfalls möglich.",
|
||||
"savedCardBankTransferLink": "Kontaktieren Sie uns dafür.",
|
||||
"savedCardAutoPayRequiredHeading": "Auto-Zahlung ist erforderlich",
|
||||
"savedCardAutoPayRequiredBody": "PieCed IT arbeitet mit automatischer Kartenzahlung. Wir behalten uns das Recht vor, Tenants bis zur Begleichung offener Rechnungen zu sperren, falls die automatische Abrechnung fehlschlägt.",
|
||||
"savedCardAutoPayDisabledNote": "Auto-Zahlung ist derzeit deaktiviert. Zukünftige Rechnungen müssen manuell beglichen werden — bei Nichtbezahlung behalten wir uns das Recht vor, die zugehörigen Tenants zu sperren."
|
||||
},
|
||||
"support": {
|
||||
"title": "Support",
|
||||
@@ -569,7 +623,7 @@
|
||||
"subtitle": "Plattform-Preise verwalten, Rechnungen generieren und den Rechnungsstatus aller Organisationen prüfen.",
|
||||
"backToAdmin": "Zurück zur Verwaltung",
|
||||
"backToBilling": "Zurück zur Abrechnung",
|
||||
"backToInvoices": "Zurück zu den Rechnungen",
|
||||
"backToInvoices": "Zurück zu Rechnungen",
|
||||
"totalOpenBalance": "Offener Saldo gesamt",
|
||||
"orgsWithBalance": "Organisationen mit Saldo",
|
||||
"overdueInvoices": "Überfällige Rechnungen",
|
||||
@@ -664,7 +718,112 @@
|
||||
"lineItemsTitle": "Positionen",
|
||||
"billToSnapshotTitle": "Rechnungsempfänger",
|
||||
"setupFeeCol": "Einrichtungsgebühr",
|
||||
"skillSetupFeeLabel": "Einrichtungsgebühr"
|
||||
"skillSetupFeeLabel": "Einrichtungsgebühr",
|
||||
"status_partially_refunded": "Teilrückerstattung",
|
||||
"status_fully_refunded": "Vollständig rückerstattet",
|
||||
"voidBtn": "Stornieren",
|
||||
"voidReasonPlaceholder": "Stornierungsgrund (auf Gutschrift gedruckt)",
|
||||
"voidReasonRequired": "Bitte einen Grund für die Stornierung angeben.",
|
||||
"confirmVoid": "Stornierung bestätigen",
|
||||
"voidedOnLabel": "Storniert",
|
||||
"refundBtn": "Rückerstatten",
|
||||
"refundReasonPlaceholder": "Grund der Rückerstattung (auf Gutschrift gedruckt)",
|
||||
"refundReasonRequired": "Bitte einen Grund für die Rückerstattung angeben.",
|
||||
"refundAmountInvalid": "Rückerstattungsbetrag muss eine positive Zahl sein.",
|
||||
"refundAmountExceeds": "Rückerstattungsbetrag überschreitet den verbleibenden Betrag von CHF {max}.",
|
||||
"refundRemainingHint": "Verbleibend erstattbar: CHF {max}",
|
||||
"confirmRefund": "Rückerstattung bestätigen",
|
||||
"refundedTotalLabel": "Rückerstattet",
|
||||
"refundedRemainingLabel": "Verbleibend erstattbar",
|
||||
"creditNotesPanelTitle": "Gutschriften",
|
||||
"creditNoteNumberHeader": "Nummer",
|
||||
"creditNoteKindHeader": "Typ",
|
||||
"creditNoteAmountHeader": "Betrag",
|
||||
"creditNoteReasonHeader": "Grund",
|
||||
"creditNoteIssuedHeader": "Ausgestellt",
|
||||
"creditNotePdfHeader": "PDF",
|
||||
"creditNoteKind_void": "Storno",
|
||||
"creditNoteKind_refund": "Rückerstattung",
|
||||
"creditNoteNoPdf": "—",
|
||||
"refundAmountLabel": "Betrag",
|
||||
"refundReasonLabel": "Grund",
|
||||
"refundAmountInclVatHint": "inkl. MWST",
|
||||
"newInvoiceBtn": "Neue Rechnung",
|
||||
"draftsLink": "Entwürfe",
|
||||
"backToDrafts": "Zurück zu Entwürfen",
|
||||
"newInvoicePageTitle": "Neue Rechnung",
|
||||
"newInvoicePageSubtitle": "Wählen Sie den Kunden, dem Sie eine Rechnung stellen möchten. Im nächsten Schritt fügen Sie die Positionen hinzu.",
|
||||
"newInvoiceOrgLabel": "Kunde",
|
||||
"newInvoiceOrgPlaceholder": "— Kunde wählen —",
|
||||
"newInvoiceOrgNoBilling": "keine Rechnungsadresse",
|
||||
"newInvoiceOrgBillingMissing": "Dieser Kunde hat keine hinterlegte Rechnungsadresse. Bitte abschliessen lassen oder im Admin-Panel hinterlegen, bevor die Rechnung ausgestellt wird.",
|
||||
"newInvoiceLocaleLabel": "Dokumentensprache",
|
||||
"newInvoiceOrgRequired": "Bitte einen Kunden wählen.",
|
||||
"newInvoiceContinueBtn": "Weiter",
|
||||
"creating": "Wird erstellt…",
|
||||
"draftsPageTitle": "Rechnungsentwürfe",
|
||||
"draftsPageSubtitle": "Laufende benutzerdefinierte Rechnungen. Bearbeitung fortsetzen oder verwerfen.",
|
||||
"draftsEmpty": "Noch keine Entwürfe. Starten Sie eine neue Rechnung.",
|
||||
"draftOrgCol": "Kunde",
|
||||
"draftIssueDateCol": "Rechnungsdatum",
|
||||
"draftLinesCol": "Positionen",
|
||||
"draftSubtotalCol": "Zwischensumme (Schätzung)",
|
||||
"draftUpdatedCol": "Zuletzt bearbeitet",
|
||||
"draftActionsCol": "Aktionen",
|
||||
"draftDeleteConfirm": "Diesen Entwurf verwerfen? Kann nicht rückgängig gemacht werden.",
|
||||
"editBtn": "Bearbeiten",
|
||||
"editorPageTitle": "Rechnungsentwurf bearbeiten",
|
||||
"editorBillToHeading": "Rechnungsempfänger",
|
||||
"editorNoBillingSnapshot": "Keine Rechnungsadresse für diesen Kunden hinterlegt. Ausstellung ist nicht möglich, bis Rechnungsinformationen erfasst wurden.",
|
||||
"editorMetadataHeading": "Rechnungsdaten",
|
||||
"editorIssueDateLabel": "Rechnungsdatum",
|
||||
"editorDueDateLabel": "Fälligkeitsdatum",
|
||||
"editorLocaleLabel": "Dokumentensprache",
|
||||
"editorPaymentMethodLabel": "Zahlungsart",
|
||||
"editorPaymentInvoice": "Banküberweisung (Rechnung)",
|
||||
"editorPaymentCard": "Kreditkarte (Stripe)",
|
||||
"editorLinesHeading": "Positionen",
|
||||
"editorLineDescription": "Beschreibung",
|
||||
"editorLineDescriptionPlaceholder": "z.B. Beratungsstunden, individuelle Integration, …",
|
||||
"editorLineQty": "Menge",
|
||||
"editorLineUnitPrice": "Einzelpreis",
|
||||
"editorLineAmount": "Betrag",
|
||||
"editorLineRemove": "Position entfernen",
|
||||
"editorAddLine": "Position hinzufügen",
|
||||
"editorAddDiscount": "Rabatt hinzufügen",
|
||||
"editorAddDiscountHint": "Fügt eine Zeile mit negativem Einzelpreis hinzu. Beschreibung und Betrag nach Bedarf anpassen.",
|
||||
"editorRabattDefaultDescription": "Rabatt",
|
||||
"editorNotesHeading": "Interne Notizen",
|
||||
"editorNotesPlaceholder": "Nur für Admin sichtbar (nicht auf der Rechnung)",
|
||||
"editorNotesHint": "Wird dem Kunden nicht angezeigt.",
|
||||
"editorTotalsHeading": "Beträge (Schätzung)",
|
||||
"editorSubtotal": "Zwischensumme",
|
||||
"editorVat": "MWST",
|
||||
"editorTotal": "Gesamt",
|
||||
"editorTotalsEstimateNote": "Schätzung basierend auf Kundenland. Die endgültige MWST wird bei Ausstellung berechnet.",
|
||||
"editorSaveBtn": "Entwurf speichern",
|
||||
"editorSavedBtn": "Gespeichert",
|
||||
"editorPreviewBtn": "PDF-Vorschau",
|
||||
"editorIssueBtn": "Rechnung ausstellen",
|
||||
"editorDeleteBtn": "Entwurf verwerfen",
|
||||
"editorIssueConfirm": "Rechnung jetzt ausstellen? Eine Rechnungsnummer wird zugewiesen, das PDF wird dem Kunden zugesendet und dieser Entwurf wird entfernt.",
|
||||
"editorDeleteConfirm": "Diesen Entwurf verwerfen? Kann nicht rückgängig gemacht werden.",
|
||||
"previewing": "Wird geöffnet…",
|
||||
"issuing": "Wird ausgestellt…",
|
||||
"orgsTitle": "Kunden-Abrechnung",
|
||||
"orgsDesc": "Zahlungsart + Auto-Zahlung pro Kunde",
|
||||
"orgsPageTitle": "Kunden-Abrechnungsmodi",
|
||||
"orgsPageSubtitle": "Überschreibung der Zahlungsart für einzelne Kunden. Zahlung per Rechnung ersetzt die automatische Kartenabbuchung durch manuelle Banküberweisung; das Pausieren der Auto-Zahlung behält die hinterlegte Karte, stoppt aber Abbuchungsversuche (nützlich bei Streitfällen).",
|
||||
"orgsEmpty": "Noch keine Kunden-Organisationen.",
|
||||
"orgsColCustomer": "Kunde",
|
||||
"orgsColCard": "Hinterlegte Karte",
|
||||
"orgsColPayByInvoice": "Zahlung per Banküberweisung",
|
||||
"orgsColAutoCharge": "Auto-Zahlung",
|
||||
"orgsNoSavedCard": "keine",
|
||||
"orgsPayByInvoiceOn": "ein",
|
||||
"orgsPayByInvoiceOff": "aus",
|
||||
"orgsAutoChargeOn": "ein",
|
||||
"orgsAutoChargeOff": "aus"
|
||||
},
|
||||
"skillCostDialog": {
|
||||
"title": "Aktivierungskosten bestätigen",
|
||||
@@ -737,12 +896,26 @@
|
||||
"paid": "Bezahlt",
|
||||
"overdue": "Überfällig",
|
||||
"void": "Storniert",
|
||||
"uncollectible": "Uneinbringlich"
|
||||
"uncollectible": "Uneinbringlich",
|
||||
"partially_refunded": "Teilrückerstattung",
|
||||
"fully_refunded": "Vollständig rückerstattet"
|
||||
},
|
||||
"payWithCard": "Mit Karte bezahlen",
|
||||
"redirectingToStripe": "Weiterleitung…",
|
||||
"paymentReceived": "Zahlung erhalten — vielen Dank!",
|
||||
"paymentCancelled": "Zahlung abgebrochen."
|
||||
"paymentCancelled": "Zahlung abgebrochen.",
|
||||
"configureBillingCta": "Rechnungsdaten einrichten",
|
||||
"noBillingConfigNonOwner": "Nur der Organisations-Owner kann die Rechnungsdaten einrichten. Bitte wenden Sie sich an diese Person, um diesen Schritt abzuschliessen.",
|
||||
"creditNotesHeading": "Gutschriften",
|
||||
"creditNoteNumberCol": "Gutschrift",
|
||||
"creditNoteInvoiceCol": "Rechnung",
|
||||
"creditNoteIssuedCol": "Ausgestellt",
|
||||
"creditNoteAmountCol": "Betrag",
|
||||
"creditNoteKindCol": "Typ",
|
||||
"creditNotePdfCol": "PDF",
|
||||
"creditNoteKind_void": "Storno",
|
||||
"creditNoteKind_refund": "Rückerstattung",
|
||||
"creditNoteNoPdf": "PDF nicht verfügbar"
|
||||
},
|
||||
"adminCron": {
|
||||
"title": "Abrechnungsautomatisierung",
|
||||
@@ -772,6 +945,23 @@
|
||||
"kind": {
|
||||
"monthly_issue": "Rechnungsstellung",
|
||||
"reminders": "Mahnungen"
|
||||
}
|
||||
},
|
||||
"failureBannerTitle": "Fehler in jüngsten Automatisierungsläufen",
|
||||
"failureBannerBody": "{count} Lauf/Läufe im aktuellen Fenster haben mindestens einen Fehler gemeldet. Bitte die Tabelle unten prüfen — betroffene Zeilen sind rot hervorgehoben."
|
||||
},
|
||||
"settingsProfile": {
|
||||
"title": "Profil",
|
||||
"subtitle": "Ihr Anzeigename, der im Portal, in Tenant-Anfragen und in Support-Tickets erscheint.",
|
||||
"subtitlePersonal": "Ihr Anzeigename, der im Portal erscheint. Um Ihren Namen auf Rechnungen zu ändern, bearbeiten Sie ihn unter Rechnungsdaten.",
|
||||
"firstNameLabel": "Vorname",
|
||||
"lastNameLabel": "Nachname",
|
||||
"emailLabel": "E-Mail",
|
||||
"emailReadOnlyHint": "Die E-Mail-Adresse kann hier nicht geändert werden. Verwenden Sie die Selbstbedienungseinstellungen Ihres Identitätsanbieters.",
|
||||
"personalAccountHint": "Dies ist ein persönliches Konto. Eine Änderung Ihres Namens hier ändert NICHT, wie Ihr Name auf Rechnungen erscheint — bearbeiten Sie diesen separat unter Rechnungsdaten.",
|
||||
"companyAccountHint": "Sie sind als Mitglied von {orgName} angemeldet.",
|
||||
"saveChanges": "Änderungen speichern",
|
||||
"saving": "Speichern…",
|
||||
"saved": "Gespeichert.",
|
||||
"missingRequired": "Vor- und Nachname sind erforderlich."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -121,7 +121,28 @@
|
||||
"saveChanges": "Save changes",
|
||||
"billingVatNumber": "VAT number",
|
||||
"billingVatHelp": "Your registered VAT identifier. If your company is VAT-exempt, leave blank and explain in the notes field.",
|
||||
"billingNotesPlaceholderPersonal": "Anything we should know — preferred payment method, billing reference, etc."
|
||||
"billingNotesPlaceholderPersonal": "Anything we should know — preferred payment method, billing reference, etc.",
|
||||
"reviewContactPersonPrefix": "Attn:",
|
||||
"setupFeeNoticeHeading": "Setup fee will be charged on submit",
|
||||
"setupFeeNoticeBody": "On the next click you'll be redirected to Stripe to enter your payment details and pay the one-time setup fee. Your card is saved automatically for future monthly billing. You'll be brought back to your dashboard immediately afterwards. The instance starts running only after admin approval — monthly fees begin from the approval date.",
|
||||
"setupFeeAmountLabel": "One-time setup fee",
|
||||
"setupFeePlusVat": "+ VAT",
|
||||
"optional": "optional",
|
||||
"yourChannelIdLabel": {
|
||||
"telegram": "Your Telegram user ID",
|
||||
"discord": "Your Discord user ID",
|
||||
"threema": "Your Threema ID"
|
||||
},
|
||||
"yourChannelIdPlaceholder": {
|
||||
"telegram": "e.g. 1234567890",
|
||||
"discord": "e.g. 234567890123456789",
|
||||
"threema": "e.g. ABCD1234"
|
||||
},
|
||||
"yourChannelIdHelp": {
|
||||
"telegram": "Open Telegram, message @userinfobot, and paste the numeric id it returns. You can add more users later from the tenant page.",
|
||||
"discord": "Enable Developer Mode in Discord (Advanced settings), right-click your name → Copy User ID, and paste it here. You can add more users later from the tenant page.",
|
||||
"threema": "The 8-character ID shown in your Threema app under Settings → My Threema ID. Once approved, you can chat with the assistant directly from this Threema account. You can add more authorized IDs later from the tenant page."
|
||||
}
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Dashboard",
|
||||
@@ -318,7 +339,12 @@
|
||||
"activationRejected": "Rejected",
|
||||
"tryAgain": "Try again",
|
||||
"credentialsSaved": "credentials saved",
|
||||
"credentialsSavedTip": "The credentials you entered are securely stored and will be used as soon as admin approves the activation. You don't need to re-enter them."
|
||||
"credentialsSavedTip": "The credentials you entered are securely stored and will be used as soon as admin approves the activation. You don't need to re-enter them.",
|
||||
"recommended": "Recommended",
|
||||
"threemaBotIdHeading": "Bot Threema ID",
|
||||
"threemaBotIdHint": "Once your tenant is approved, scan this QR with Threema to add the assistant to your contacts. The QR is the same for every PieCed tenant — you can save it now.",
|
||||
"showInfo": "Info",
|
||||
"showInfoTitle": "Show setup info again"
|
||||
},
|
||||
"admin": {
|
||||
"title": "Platform Admin",
|
||||
@@ -479,28 +505,56 @@
|
||||
"billingTitle": "Billing",
|
||||
"billingDescription": "Address, VAT number, and invoice email used for all your tenants.",
|
||||
"nothingForYou": "There's nothing here for your role yet. Owners can manage org settings.",
|
||||
"billingDescriptionPersonal": "Address and invoice email used for all your tenants."
|
||||
"billingDescriptionPersonal": "Address and invoice email used for all your tenants.",
|
||||
"profileTitle": "Profile",
|
||||
"profileDescription": "Edit your first and last name as shown across the portal."
|
||||
},
|
||||
"settingsBilling": {
|
||||
"title": "Billing",
|
||||
"subtitle": "Captured once at first onboarding and reused for every tenant in your organization. Update here whenever your billing details change.",
|
||||
"companyName": "Company name",
|
||||
"streetAddress": "Street address",
|
||||
"postalCode": "Postal code",
|
||||
"city": "City",
|
||||
"country": "Country",
|
||||
"vatNumber": "VAT number",
|
||||
"vatHelp": "Your registered VAT identifier (e.g. CHE-123.456.789 MWST for Switzerland).",
|
||||
"billingEmail": "Billing email",
|
||||
"billingEmailHelp": "Where invoices and billing communication will be sent.",
|
||||
"notes": "Notes",
|
||||
"notesPlaceholder": "Anything else accounting needs to know — VAT exemption, special invoicing arrangements, etc.",
|
||||
"save": "Save",
|
||||
"title": "Billing details",
|
||||
"subtitle": "Your company's billing address, VAT number, and invoice contact. Required before invoices can be issued for your organization.",
|
||||
"companyNameLabel": "Company name",
|
||||
"streetAddressLabel": "Street address",
|
||||
"postalCodeLabel": "Postal code",
|
||||
"cityLabel": "City",
|
||||
"countryLabel": "Country code",
|
||||
"countryHint": "ISO 3166-1 alpha-2 — e.g. CH, DE, AT, FR, IT, GB, US",
|
||||
"vatNumberLabel": "VAT number (optional)",
|
||||
"vatNumberHint": "For Swiss customers: CHE-XXX.XXX.XXX MWST. EU customers with a VAT number get a 0% reverse-charge invoice.",
|
||||
"billingEmailLabel": "Billing email",
|
||||
"billingEmailHint": "Invoices and payment reminders are sent here. Can differ from your account email.",
|
||||
"notesLabel": "Notes (optional)",
|
||||
"notesHint": "Reference numbers, purchase order tags, or anything else you'd like printed on invoices.",
|
||||
"saveChanges": "Save changes",
|
||||
"createBilling": "Save billing details",
|
||||
"saving": "Saving…",
|
||||
"saved": "Saved.",
|
||||
"saveFailed": "Could not save. Please try again.",
|
||||
"lastUpdated": "Last updated {when}",
|
||||
"fullName": "Full name",
|
||||
"notesPlaceholderPersonal": "Anything we should know — preferred payment method, billing reference, etc."
|
||||
"missingRequired": "Please fill in all required fields.",
|
||||
"invalidCountry": "Country code must be 2 letters (e.g. CH).",
|
||||
"invalidEmail": "Please enter a valid email address.",
|
||||
"fullNameLabel": "Full name",
|
||||
"subtitlePersonal": "Your billing address and invoice contact. Required before invoices can be issued.",
|
||||
"contactNameLabel": "Contact person (optional)",
|
||||
"contactNameHint": "Prints as 'Attn: <name>' on the invoice below the company name. Useful for AP routing in larger organizations.",
|
||||
"savedCardHeading": "Saved card",
|
||||
"savedCardEmptyBody": "Save a card for automatic invoice payments. Your card details are stored securely by Stripe — we only see the brand, last four digits, and expiration.",
|
||||
"savedCardSetupBtn": "Set up auto-pay",
|
||||
"savedCardRedirecting": "Redirecting…",
|
||||
"savedCardUpdateBtn": "Update card",
|
||||
"savedCardRemoveBtn": "Remove card",
|
||||
"savedCardRemoving": "Removing…",
|
||||
"savedCardRemoveConfirm": "Remove this card? You'll need to set up auto-pay again for future invoices to charge automatically.",
|
||||
"savedCardBrandUnknown": "Card",
|
||||
"savedCardExpires": "expires {date}",
|
||||
"savedCardAutoChargeOn": "Auto-pay on",
|
||||
"savedCardAutoChargeOff": "Auto-pay off",
|
||||
"savedCardDisableAutoChargeBtn": "Disable auto-pay",
|
||||
"savedCardEnableAutoChargeBtn": "Enable auto-pay",
|
||||
"savedCardPayByInvoiceNote": "Your account is set to pay by bank transfer; the saved card is not used for automatic charges. Contact support if you'd like to switch back to card payment.",
|
||||
"savedCardBankTransferHint": "Bank transfer is also available on request.",
|
||||
"savedCardBankTransferLink": "Contact us to arrange.",
|
||||
"savedCardAutoPayRequiredHeading": "Auto-pay is required",
|
||||
"savedCardAutoPayRequiredBody": "PieCed IT operates on automatic card payment. We reserve the right to suspend tenants until outstanding invoices are paid if automatic billing fails.",
|
||||
"savedCardAutoPayDisabledNote": "Auto-pay is currently disabled. Future invoices will need to be paid manually — if they go unpaid we reserve the right to suspend the tenants associated with this account."
|
||||
},
|
||||
"support": {
|
||||
"title": "Support",
|
||||
@@ -568,8 +622,8 @@
|
||||
"title": "Billing administration",
|
||||
"subtitle": "Manage platform pricing, generate invoices, and review billing status across all organizations.",
|
||||
"backToAdmin": "Back to Admin",
|
||||
"backToBilling": "Back to Billing",
|
||||
"backToInvoices": "Back to Invoices",
|
||||
"backToBilling": "Back to billing",
|
||||
"backToInvoices": "Back to invoices",
|
||||
"totalOpenBalance": "Total open balance",
|
||||
"orgsWithBalance": "Orgs with balance",
|
||||
"overdueInvoices": "Overdue invoices",
|
||||
@@ -664,7 +718,112 @@
|
||||
"lineItemsTitle": "Line items",
|
||||
"billToSnapshotTitle": "Billed to",
|
||||
"setupFeeCol": "Setup fee",
|
||||
"skillSetupFeeLabel": "Setup fee"
|
||||
"skillSetupFeeLabel": "Setup fee",
|
||||
"status_partially_refunded": "Partially refunded",
|
||||
"status_fully_refunded": "Fully refunded",
|
||||
"voidBtn": "Void",
|
||||
"voidReasonPlaceholder": "Reason for voiding (printed on credit note)",
|
||||
"voidReasonRequired": "Please provide a reason for voiding.",
|
||||
"confirmVoid": "Confirm void",
|
||||
"voidedOnLabel": "Voided",
|
||||
"refundBtn": "Refund",
|
||||
"refundReasonPlaceholder": "Reason for refund (printed on credit note)",
|
||||
"refundReasonRequired": "Please provide a reason for the refund.",
|
||||
"refundAmountInvalid": "Refund amount must be a positive number.",
|
||||
"refundAmountExceeds": "Refund amount exceeds remaining refundable CHF {max}.",
|
||||
"refundRemainingHint": "Remaining refundable: CHF {max}",
|
||||
"confirmRefund": "Confirm refund",
|
||||
"refundedTotalLabel": "Refunded total",
|
||||
"refundedRemainingLabel": "Remaining refundable",
|
||||
"creditNotesPanelTitle": "Credit notes",
|
||||
"creditNoteNumberHeader": "Number",
|
||||
"creditNoteKindHeader": "Type",
|
||||
"creditNoteAmountHeader": "Amount",
|
||||
"creditNoteReasonHeader": "Reason",
|
||||
"creditNoteIssuedHeader": "Issued",
|
||||
"creditNotePdfHeader": "PDF",
|
||||
"creditNoteKind_void": "Void",
|
||||
"creditNoteKind_refund": "Refund",
|
||||
"creditNoteNoPdf": "—",
|
||||
"refundAmountLabel": "Amount",
|
||||
"refundReasonLabel": "Reason",
|
||||
"refundAmountInclVatHint": "incl. VAT",
|
||||
"newInvoiceBtn": "New invoice",
|
||||
"draftsLink": "Drafts",
|
||||
"backToDrafts": "Back to drafts",
|
||||
"newInvoicePageTitle": "New invoice",
|
||||
"newInvoicePageSubtitle": "Pick the customer you want to invoice. You'll add lines on the next step.",
|
||||
"newInvoiceOrgLabel": "Customer",
|
||||
"newInvoiceOrgPlaceholder": "— select customer —",
|
||||
"newInvoiceOrgNoBilling": "no billing info",
|
||||
"newInvoiceOrgBillingMissing": "This customer has no billing address on file. Ask them to complete onboarding or set the billing info from the admin panel before issuing.",
|
||||
"newInvoiceLocaleLabel": "Document language",
|
||||
"newInvoiceOrgRequired": "Please select a customer.",
|
||||
"newInvoiceContinueBtn": "Continue",
|
||||
"creating": "Creating…",
|
||||
"draftsPageTitle": "Invoice drafts",
|
||||
"draftsPageSubtitle": "Custom invoices in progress. Resume editing or discard.",
|
||||
"draftsEmpty": "No drafts yet. Start a new invoice to begin.",
|
||||
"draftOrgCol": "Customer",
|
||||
"draftIssueDateCol": "Issue date",
|
||||
"draftLinesCol": "Lines",
|
||||
"draftSubtotalCol": "Subtotal (est.)",
|
||||
"draftUpdatedCol": "Last edited",
|
||||
"draftActionsCol": "Actions",
|
||||
"draftDeleteConfirm": "Discard this draft? This cannot be undone.",
|
||||
"editBtn": "Edit",
|
||||
"editorPageTitle": "Edit invoice draft",
|
||||
"editorBillToHeading": "Bill to",
|
||||
"editorNoBillingSnapshot": "No billing address on file for this customer. Issuance will fail until billing info is set.",
|
||||
"editorMetadataHeading": "Invoice details",
|
||||
"editorIssueDateLabel": "Issue date",
|
||||
"editorDueDateLabel": "Due date",
|
||||
"editorLocaleLabel": "Document language",
|
||||
"editorPaymentMethodLabel": "Payment method",
|
||||
"editorPaymentInvoice": "Bank transfer (invoice)",
|
||||
"editorPaymentCard": "Credit card (Stripe)",
|
||||
"editorLinesHeading": "Line items",
|
||||
"editorLineDescription": "Description",
|
||||
"editorLineDescriptionPlaceholder": "e.g. Consulting hours, custom integration, …",
|
||||
"editorLineQty": "Qty",
|
||||
"editorLineUnitPrice": "Unit price",
|
||||
"editorLineAmount": "Amount",
|
||||
"editorLineRemove": "Remove line",
|
||||
"editorAddLine": "Add line",
|
||||
"editorAddDiscount": "Add discount",
|
||||
"editorAddDiscountHint": "Adds a line with negative unit price. Edit description and amount as needed.",
|
||||
"editorRabattDefaultDescription": "Discount",
|
||||
"editorNotesHeading": "Internal notes",
|
||||
"editorNotesPlaceholder": "Notes only visible to admin (not on the invoice PDF)",
|
||||
"editorNotesHint": "Not shown to the customer.",
|
||||
"editorTotalsHeading": "Totals (estimate)",
|
||||
"editorSubtotal": "Subtotal",
|
||||
"editorVat": "VAT",
|
||||
"editorTotal": "Total",
|
||||
"editorTotalsEstimateNote": "Estimate based on customer country. Final VAT is computed at issuance.",
|
||||
"editorSaveBtn": "Save draft",
|
||||
"editorSavedBtn": "Saved",
|
||||
"editorPreviewBtn": "Preview PDF",
|
||||
"editorIssueBtn": "Issue invoice",
|
||||
"editorDeleteBtn": "Discard draft",
|
||||
"editorIssueConfirm": "Issue this invoice now? An invoice number will be allocated, the PDF will be sent to the customer, and this draft will be removed.",
|
||||
"editorDeleteConfirm": "Discard this draft? This cannot be undone.",
|
||||
"previewing": "Opening…",
|
||||
"issuing": "Issuing…",
|
||||
"orgsTitle": "Customer billing",
|
||||
"orgsDesc": "Payment mode + auto-charge per customer",
|
||||
"orgsPageTitle": "Customer billing modes",
|
||||
"orgsPageSubtitle": "Override payment mode for individual customers. Pay-by-invoice replaces card auto-charge with manual bank transfer; pausing auto-charge keeps the saved card on file but stops attempting charges (useful during disputes).",
|
||||
"orgsEmpty": "No customer orgs yet.",
|
||||
"orgsColCustomer": "Customer",
|
||||
"orgsColCard": "Saved card",
|
||||
"orgsColPayByInvoice": "Pay by bank transfer",
|
||||
"orgsColAutoCharge": "Auto-charge",
|
||||
"orgsNoSavedCard": "none",
|
||||
"orgsPayByInvoiceOn": "on",
|
||||
"orgsPayByInvoiceOff": "off",
|
||||
"orgsAutoChargeOn": "on",
|
||||
"orgsAutoChargeOff": "off"
|
||||
},
|
||||
"skillCostDialog": {
|
||||
"title": "Confirm activation cost",
|
||||
@@ -737,12 +896,26 @@
|
||||
"paid": "Paid",
|
||||
"overdue": "Overdue",
|
||||
"void": "Void",
|
||||
"uncollectible": "Uncollectible"
|
||||
"uncollectible": "Uncollectible",
|
||||
"partially_refunded": "Partially refunded",
|
||||
"fully_refunded": "Fully refunded"
|
||||
},
|
||||
"payWithCard": "Pay with card",
|
||||
"redirectingToStripe": "Redirecting…",
|
||||
"paymentReceived": "Payment received — thank you!",
|
||||
"paymentCancelled": "Payment cancelled."
|
||||
"paymentCancelled": "Payment cancelled.",
|
||||
"configureBillingCta": "Configure billing details",
|
||||
"noBillingConfigNonOwner": "Only the organization owner can configure billing details. Please contact them to complete this step.",
|
||||
"creditNotesHeading": "Credit notes",
|
||||
"creditNoteNumberCol": "Credit note",
|
||||
"creditNoteInvoiceCol": "Invoice",
|
||||
"creditNoteIssuedCol": "Issued",
|
||||
"creditNoteAmountCol": "Amount",
|
||||
"creditNoteKindCol": "Type",
|
||||
"creditNotePdfCol": "PDF",
|
||||
"creditNoteKind_void": "Void",
|
||||
"creditNoteKind_refund": "Refund",
|
||||
"creditNoteNoPdf": "PDF unavailable"
|
||||
},
|
||||
"adminCron": {
|
||||
"title": "Billing automation",
|
||||
@@ -772,6 +945,23 @@
|
||||
"kind": {
|
||||
"monthly_issue": "Issuance",
|
||||
"reminders": "Reminders"
|
||||
}
|
||||
},
|
||||
"failureBannerTitle": "Recent automation failures detected",
|
||||
"failureBannerBody": "{count} run(s) in the recent window reported at least one failure. Review the table below — the affected rows are highlighted in red."
|
||||
},
|
||||
"settingsProfile": {
|
||||
"title": "Profile",
|
||||
"subtitle": "Your display name as shown across the portal, in tenant requests, and in support tickets.",
|
||||
"subtitlePersonal": "Your display name as shown across the portal. To change how your name appears on invoices, edit it in Billing details.",
|
||||
"firstNameLabel": "First name",
|
||||
"lastNameLabel": "Last name",
|
||||
"emailLabel": "Email",
|
||||
"emailReadOnlyHint": "Email can't be changed here. Use your identity provider's self-service settings to change your email.",
|
||||
"personalAccountHint": "This is a personal account. Changing your name here does NOT update how your name appears on invoices — edit that separately in Billing details.",
|
||||
"companyAccountHint": "You're signed in as a member of {orgName}.",
|
||||
"saveChanges": "Save changes",
|
||||
"saving": "Saving…",
|
||||
"saved": "Saved.",
|
||||
"missingRequired": "First and last name are required."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -121,7 +121,28 @@
|
||||
"saveChanges": "Enregistrer les modifications",
|
||||
"billingVatNumber": "Numéro de TVA",
|
||||
"billingVatHelp": "Votre identifiant TVA enregistré. Si votre entreprise est exonérée de TVA, laissez vide et précisez dans les notes.",
|
||||
"billingNotesPlaceholderPersonal": "Tout ce que nous devons savoir — moyen de paiement préféré, référence de facturation, etc."
|
||||
"billingNotesPlaceholderPersonal": "Tout ce que nous devons savoir — moyen de paiement préféré, référence de facturation, etc.",
|
||||
"reviewContactPersonPrefix": "À l'attention de",
|
||||
"setupFeeNoticeHeading": "Les frais de configuration seront facturés à l'envoi",
|
||||
"setupFeeNoticeBody": "Au prochain clic vous serez redirigé vers Stripe pour saisir vos coordonnées de paiement et régler les frais d'activation uniques. Votre carte est enregistrée automatiquement pour la facturation mensuelle future. Vous reviendrez immédiatement au tableau de bord. L'instance ne démarre qu'après validation par l'administrateur — les frais mensuels commencent à compter de la date de validation.",
|
||||
"setupFeeAmountLabel": "Frais d'activation uniques",
|
||||
"setupFeePlusVat": "+ TVA",
|
||||
"optional": "facultatif",
|
||||
"yourChannelIdLabel": {
|
||||
"telegram": "Votre ID utilisateur Telegram",
|
||||
"discord": "Votre ID utilisateur Discord",
|
||||
"threema": "Votre ID Threema"
|
||||
},
|
||||
"yourChannelIdPlaceholder": {
|
||||
"telegram": "ex. 1234567890",
|
||||
"discord": "ex. 234567890123456789",
|
||||
"threema": "ex. ABCD1234"
|
||||
},
|
||||
"yourChannelIdHelp": {
|
||||
"telegram": "Ouvrez Telegram, écrivez à @userinfobot et collez l'ID numérique qu'il retourne. Vous pourrez ajouter d'autres utilisateurs plus tard depuis la page du tenant.",
|
||||
"discord": "Activez le mode développeur dans Discord (paramètres avancés), clic-droit sur votre nom → Copier l'ID utilisateur, puis collez-le ici. Vous pourrez ajouter d'autres utilisateurs plus tard depuis la page du tenant.",
|
||||
"threema": "L'identifiant à 8 caractères affiché dans votre app Threema sous Paramètres → Mon ID Threema. Une fois approuvé, vous pourrez chatter avec l'assistant directement depuis ce compte Threema. Vous pourrez ajouter d'autres ID autorisés plus tard depuis la page du tenant."
|
||||
}
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Tableau de bord",
|
||||
@@ -318,7 +339,12 @@
|
||||
"activationRejected": "Refusée",
|
||||
"tryAgain": "Réessayer",
|
||||
"credentialsSaved": "identifiants enregistrés",
|
||||
"credentialsSavedTip": "Les identifiants saisis sont stockés en sécurité et seront utilisés dès l'approbation de l'activation par l'administrateur. Vous n'avez pas besoin de les ressaisir."
|
||||
"credentialsSavedTip": "Les identifiants saisis sont stockés en sécurité et seront utilisés dès l'approbation de l'activation par l'administrateur. Vous n'avez pas besoin de les ressaisir.",
|
||||
"recommended": "Recommandé",
|
||||
"threemaBotIdHeading": "ID Threema du bot",
|
||||
"threemaBotIdHint": "Une fois votre tenant approuvé, scannez ce QR avec Threema pour ajouter l'assistant à vos contacts. Le QR est identique pour chaque tenant PieCed — vous pouvez l'enregistrer dès maintenant.",
|
||||
"showInfo": "Info",
|
||||
"showInfoTitle": "Réafficher les infos de configuration"
|
||||
},
|
||||
"admin": {
|
||||
"title": "Admin plateforme",
|
||||
@@ -479,28 +505,56 @@
|
||||
"billingTitle": "Facturation",
|
||||
"billingDescription": "Adresse, numéro de TVA et e-mail de facturation utilisés pour tous vos locataires.",
|
||||
"nothingForYou": "Il n'y a rien ici pour votre rôle pour le moment. Les propriétaires peuvent gérer les paramètres de l'organisation.",
|
||||
"billingDescriptionPersonal": "Adresse et e-mail de facturation utilisés pour tous vos locataires."
|
||||
"billingDescriptionPersonal": "Adresse et e-mail de facturation utilisés pour tous vos locataires.",
|
||||
"profileTitle": "Profil",
|
||||
"profileDescription": "Modifiez votre prénom et nom tels qu'ils apparaissent dans le portail."
|
||||
},
|
||||
"settingsBilling": {
|
||||
"title": "Facturation",
|
||||
"subtitle": "Saisie une fois lors de l'inscription et réutilisée pour chaque locataire de votre organisation. Mettez à jour ici dès que vos coordonnées de facturation changent.",
|
||||
"companyName": "Nom de l'entreprise",
|
||||
"streetAddress": "Adresse",
|
||||
"postalCode": "Code postal",
|
||||
"city": "Ville",
|
||||
"country": "Pays",
|
||||
"vatNumber": "Numéro de TVA",
|
||||
"vatHelp": "Votre identifiant TVA enregistré (par ex. CHE-123.456.789 TVA pour la Suisse).",
|
||||
"billingEmail": "E-mail de facturation",
|
||||
"billingEmailHelp": "Adresse à laquelle les factures et la communication de facturation seront envoyées.",
|
||||
"notes": "Notes",
|
||||
"notesPlaceholder": "Tout ce que la comptabilité doit savoir – exonération de TVA, modalités de facturation particulières, etc.",
|
||||
"save": "Enregistrer",
|
||||
"title": "Informations de facturation",
|
||||
"subtitle": "Adresse de facturation, numéro de TVA et contact pour les factures. Requis avant l'émission de toute facture pour votre organisation.",
|
||||
"companyNameLabel": "Nom de l'entreprise",
|
||||
"streetAddressLabel": "Adresse",
|
||||
"postalCodeLabel": "Code postal",
|
||||
"cityLabel": "Ville",
|
||||
"countryLabel": "Code pays",
|
||||
"countryHint": "ISO 3166-1 alpha-2 — p. ex. CH, DE, AT, FR, IT, GB, US",
|
||||
"vatNumberLabel": "Numéro de TVA (facultatif)",
|
||||
"vatNumberHint": "Pour les clients suisses : CHE-XXX.XXX.XXX TVA. Les clients UE avec un n° de TVA reçoivent une facture à 0% (autoliquidation).",
|
||||
"billingEmailLabel": "E-mail de facturation",
|
||||
"billingEmailHint": "Les factures et rappels de paiement sont envoyés à cette adresse. Peut différer de l'e-mail du compte.",
|
||||
"notesLabel": "Notes (facultatif)",
|
||||
"notesHint": "Numéros de référence, bons de commande, ou toute autre information à imprimer sur les factures.",
|
||||
"saveChanges": "Enregistrer les modifications",
|
||||
"createBilling": "Enregistrer les informations",
|
||||
"saving": "Enregistrement…",
|
||||
"saved": "Enregistré.",
|
||||
"saveFailed": "Impossible d'enregistrer. Veuillez réessayer.",
|
||||
"lastUpdated": "Dernière mise à jour {when}",
|
||||
"fullName": "Nom complet",
|
||||
"notesPlaceholderPersonal": "Tout ce que nous devons savoir — moyen de paiement préféré, référence de facturation, etc."
|
||||
"missingRequired": "Veuillez remplir tous les champs obligatoires.",
|
||||
"invalidCountry": "Le code pays doit comporter 2 lettres (p. ex. CH).",
|
||||
"invalidEmail": "Veuillez saisir une adresse e-mail valide.",
|
||||
"fullNameLabel": "Nom et prénom",
|
||||
"subtitlePersonal": "Votre adresse de facturation et votre contact. Requis avant l'émission de toute facture.",
|
||||
"contactNameLabel": "Personne à contacter (facultatif)",
|
||||
"contactNameHint": "S'imprime « À l'attention de <nom> » sur la facture, sous le nom de l'entreprise. Utile pour le routage en comptabilité dans les grandes organisations.",
|
||||
"savedCardHeading": "Carte enregistrée",
|
||||
"savedCardEmptyBody": "Enregistrez une carte pour le paiement automatique des factures. Les données de votre carte sont stockées de manière sécurisée par Stripe — nous ne voyons que la marque, les quatre derniers chiffres et la date d'expiration.",
|
||||
"savedCardSetupBtn": "Configurer le paiement automatique",
|
||||
"savedCardRedirecting": "Redirection…",
|
||||
"savedCardUpdateBtn": "Mettre à jour la carte",
|
||||
"savedCardRemoveBtn": "Supprimer la carte",
|
||||
"savedCardRemoving": "Suppression…",
|
||||
"savedCardRemoveConfirm": "Supprimer cette carte ? Vous devrez reconfigurer le paiement automatique pour que les futures factures soient prélevées automatiquement.",
|
||||
"savedCardBrandUnknown": "Carte",
|
||||
"savedCardExpires": "expire {date}",
|
||||
"savedCardAutoChargeOn": "Paiement auto. actif",
|
||||
"savedCardAutoChargeOff": "Paiement auto. inactif",
|
||||
"savedCardDisableAutoChargeBtn": "Désactiver le paiement automatique",
|
||||
"savedCardEnableAutoChargeBtn": "Activer le paiement automatique",
|
||||
"savedCardPayByInvoiceNote": "Votre compte est configuré pour le paiement par virement ; la carte enregistrée n'est pas utilisée pour les prélèvements automatiques. Contactez le support si vous souhaitez revenir au paiement par carte.",
|
||||
"savedCardBankTransferHint": "Le paiement par virement est également possible sur demande.",
|
||||
"savedCardBankTransferLink": "Contactez-nous pour l'organiser.",
|
||||
"savedCardAutoPayRequiredHeading": "Le paiement automatique est requis",
|
||||
"savedCardAutoPayRequiredBody": "PieCed IT fonctionne sur la base d'un paiement automatique par carte. Nous nous réservons le droit de suspendre les tenants jusqu'au règlement des factures impayées si la facturation automatique échoue.",
|
||||
"savedCardAutoPayDisabledNote": "Le paiement automatique est actuellement désactivé. Les factures futures devront être réglées manuellement — en cas de non-paiement, nous nous réservons le droit de suspendre les tenants associés à ce compte."
|
||||
},
|
||||
"support": {
|
||||
"title": "Support",
|
||||
@@ -664,7 +718,112 @@
|
||||
"lineItemsTitle": "Lignes",
|
||||
"billToSnapshotTitle": "Destinataire",
|
||||
"setupFeeCol": "Frais de configuration",
|
||||
"skillSetupFeeLabel": "Frais de configuration"
|
||||
"skillSetupFeeLabel": "Frais de configuration",
|
||||
"status_partially_refunded": "Partiellement remboursée",
|
||||
"status_fully_refunded": "Entièrement remboursée",
|
||||
"voidBtn": "Annuler",
|
||||
"voidReasonPlaceholder": "Motif de l'annulation (imprimé sur la note de crédit)",
|
||||
"voidReasonRequired": "Veuillez indiquer un motif d'annulation.",
|
||||
"confirmVoid": "Confirmer l'annulation",
|
||||
"voidedOnLabel": "Annulée",
|
||||
"refundBtn": "Rembourser",
|
||||
"refundReasonPlaceholder": "Motif du remboursement (imprimé sur la note de crédit)",
|
||||
"refundReasonRequired": "Veuillez indiquer un motif de remboursement.",
|
||||
"refundAmountInvalid": "Le montant du remboursement doit être un nombre positif.",
|
||||
"refundAmountExceeds": "Le montant dépasse le restant remboursable de CHF {max}.",
|
||||
"refundRemainingHint": "Restant remboursable : CHF {max}",
|
||||
"confirmRefund": "Confirmer le remboursement",
|
||||
"refundedTotalLabel": "Remboursé",
|
||||
"refundedRemainingLabel": "Restant remboursable",
|
||||
"creditNotesPanelTitle": "Notes de crédit",
|
||||
"creditNoteNumberHeader": "Numéro",
|
||||
"creditNoteKindHeader": "Type",
|
||||
"creditNoteAmountHeader": "Montant",
|
||||
"creditNoteReasonHeader": "Motif",
|
||||
"creditNoteIssuedHeader": "Émise",
|
||||
"creditNotePdfHeader": "PDF",
|
||||
"creditNoteKind_void": "Annulation",
|
||||
"creditNoteKind_refund": "Remboursement",
|
||||
"creditNoteNoPdf": "—",
|
||||
"refundAmountLabel": "Montant",
|
||||
"refundReasonLabel": "Motif",
|
||||
"refundAmountInclVatHint": "TVA incluse",
|
||||
"newInvoiceBtn": "Nouvelle facture",
|
||||
"draftsLink": "Brouillons",
|
||||
"backToDrafts": "Retour aux brouillons",
|
||||
"newInvoicePageTitle": "Nouvelle facture",
|
||||
"newInvoicePageSubtitle": "Choisissez le client à facturer. Vous ajouterez les lignes à l'étape suivante.",
|
||||
"newInvoiceOrgLabel": "Client",
|
||||
"newInvoiceOrgPlaceholder": "— sélectionner un client —",
|
||||
"newInvoiceOrgNoBilling": "pas d'adresse de facturation",
|
||||
"newInvoiceOrgBillingMissing": "Ce client n'a pas d'adresse de facturation. Demandez-lui de compléter l'inscription ou renseignez-la depuis le panneau d'administration avant d'émettre.",
|
||||
"newInvoiceLocaleLabel": "Langue du document",
|
||||
"newInvoiceOrgRequired": "Veuillez sélectionner un client.",
|
||||
"newInvoiceContinueBtn": "Continuer",
|
||||
"creating": "Création…",
|
||||
"draftsPageTitle": "Brouillons de factures",
|
||||
"draftsPageSubtitle": "Factures personnalisées en cours. Reprenez l'édition ou supprimez.",
|
||||
"draftsEmpty": "Aucun brouillon pour le moment. Démarrez une nouvelle facture.",
|
||||
"draftOrgCol": "Client",
|
||||
"draftIssueDateCol": "Date d'émission",
|
||||
"draftLinesCol": "Lignes",
|
||||
"draftSubtotalCol": "Sous-total (est.)",
|
||||
"draftUpdatedCol": "Modifié",
|
||||
"draftActionsCol": "Actions",
|
||||
"draftDeleteConfirm": "Supprimer ce brouillon ? Cette action est irréversible.",
|
||||
"editBtn": "Modifier",
|
||||
"editorPageTitle": "Modifier le brouillon de facture",
|
||||
"editorBillToHeading": "Destinataire",
|
||||
"editorNoBillingSnapshot": "Aucune adresse de facturation pour ce client. L'émission échouera tant que les informations de facturation ne sont pas renseignées.",
|
||||
"editorMetadataHeading": "Détails de la facture",
|
||||
"editorIssueDateLabel": "Date d'émission",
|
||||
"editorDueDateLabel": "Date d'échéance",
|
||||
"editorLocaleLabel": "Langue du document",
|
||||
"editorPaymentMethodLabel": "Mode de paiement",
|
||||
"editorPaymentInvoice": "Virement (facture)",
|
||||
"editorPaymentCard": "Carte bancaire (Stripe)",
|
||||
"editorLinesHeading": "Lignes",
|
||||
"editorLineDescription": "Description",
|
||||
"editorLineDescriptionPlaceholder": "p.ex. Heures de conseil, intégration sur mesure, …",
|
||||
"editorLineQty": "Qté",
|
||||
"editorLineUnitPrice": "Prix unitaire",
|
||||
"editorLineAmount": "Montant",
|
||||
"editorLineRemove": "Supprimer la ligne",
|
||||
"editorAddLine": "Ajouter une ligne",
|
||||
"editorAddDiscount": "Ajouter une remise",
|
||||
"editorAddDiscountHint": "Ajoute une ligne avec un prix unitaire négatif. Modifiez la description et le montant si nécessaire.",
|
||||
"editorRabattDefaultDescription": "Remise",
|
||||
"editorNotesHeading": "Notes internes",
|
||||
"editorNotesPlaceholder": "Notes visibles uniquement par l'administrateur (pas sur le PDF)",
|
||||
"editorNotesHint": "Non visible par le client.",
|
||||
"editorTotalsHeading": "Totaux (estimation)",
|
||||
"editorSubtotal": "Sous-total",
|
||||
"editorVat": "TVA",
|
||||
"editorTotal": "Total",
|
||||
"editorTotalsEstimateNote": "Estimation basée sur le pays du client. La TVA finale est calculée à l'émission.",
|
||||
"editorSaveBtn": "Enregistrer le brouillon",
|
||||
"editorSavedBtn": "Enregistré",
|
||||
"editorPreviewBtn": "Aperçu PDF",
|
||||
"editorIssueBtn": "Émettre la facture",
|
||||
"editorDeleteBtn": "Supprimer le brouillon",
|
||||
"editorIssueConfirm": "Émettre cette facture maintenant ? Un numéro de facture sera attribué, le PDF sera envoyé au client et ce brouillon sera supprimé.",
|
||||
"editorDeleteConfirm": "Supprimer ce brouillon ? Cette action est irréversible.",
|
||||
"previewing": "Ouverture…",
|
||||
"issuing": "Émission…",
|
||||
"orgsTitle": "Facturation client",
|
||||
"orgsDesc": "Mode de paiement + paiement auto. par client",
|
||||
"orgsPageTitle": "Modes de facturation client",
|
||||
"orgsPageSubtitle": "Surcharge du mode de paiement pour les clients individuels. Le paiement par virement remplace le prélèvement automatique par carte ; la pause du paiement automatique conserve la carte enregistrée mais cesse les tentatives de prélèvement (utile en cas de litige).",
|
||||
"orgsEmpty": "Aucun client pour le moment.",
|
||||
"orgsColCustomer": "Client",
|
||||
"orgsColCard": "Carte enregistrée",
|
||||
"orgsColPayByInvoice": "Paiement par virement",
|
||||
"orgsColAutoCharge": "Paiement automatique",
|
||||
"orgsNoSavedCard": "aucune",
|
||||
"orgsPayByInvoiceOn": "actif",
|
||||
"orgsPayByInvoiceOff": "inactif",
|
||||
"orgsAutoChargeOn": "actif",
|
||||
"orgsAutoChargeOff": "inactif"
|
||||
},
|
||||
"skillCostDialog": {
|
||||
"title": "Confirmer le coût d'activation",
|
||||
@@ -730,19 +889,33 @@
|
||||
"subtotalLabel": "Sous-total",
|
||||
"vatLabel": "TVA ({rate}%)",
|
||||
"totalLabel": "Total",
|
||||
"downloadPdf": "Télécharger le PDF",
|
||||
"downloadPdf": "Télécharger PDF",
|
||||
"status": {
|
||||
"draft": "Brouillon",
|
||||
"open": "Ouverte",
|
||||
"paid": "Payée",
|
||||
"overdue": "En retard",
|
||||
"void": "Annulée",
|
||||
"uncollectible": "Irrécouvrable"
|
||||
"uncollectible": "Irrécouvrable",
|
||||
"partially_refunded": "Partiellement remboursée",
|
||||
"fully_refunded": "Entièrement remboursée"
|
||||
},
|
||||
"payWithCard": "Payer par carte",
|
||||
"redirectingToStripe": "Redirection…",
|
||||
"paymentReceived": "Paiement reçu — merci !",
|
||||
"paymentCancelled": "Paiement annulé."
|
||||
"paymentCancelled": "Paiement annulé.",
|
||||
"configureBillingCta": "Configurer les informations de facturation",
|
||||
"noBillingConfigNonOwner": "Seul le propriétaire de l'organisation peut configurer les informations de facturation. Veuillez le contacter pour terminer cette étape.",
|
||||
"creditNotesHeading": "Notes de crédit",
|
||||
"creditNoteNumberCol": "Note de crédit",
|
||||
"creditNoteInvoiceCol": "Facture",
|
||||
"creditNoteIssuedCol": "Émise",
|
||||
"creditNoteAmountCol": "Montant",
|
||||
"creditNoteKindCol": "Type",
|
||||
"creditNotePdfCol": "PDF",
|
||||
"creditNoteKind_void": "Annulation",
|
||||
"creditNoteKind_refund": "Remboursement",
|
||||
"creditNoteNoPdf": "PDF indisponible"
|
||||
},
|
||||
"adminCron": {
|
||||
"title": "Automatisation de la facturation",
|
||||
@@ -772,6 +945,23 @@
|
||||
"kind": {
|
||||
"monthly_issue": "Émission",
|
||||
"reminders": "Rappels"
|
||||
}
|
||||
},
|
||||
"failureBannerTitle": "Échecs récents détectés",
|
||||
"failureBannerBody": "{count} lancement(s) récent(s) ont signalé au moins un échec. Consultez le tableau ci-dessous — les lignes concernées sont en rouge."
|
||||
},
|
||||
"settingsProfile": {
|
||||
"title": "Profil",
|
||||
"subtitle": "Votre nom d'affichage tel qu'il apparaît dans le portail, les demandes de tenant et les tickets d'assistance.",
|
||||
"subtitlePersonal": "Votre nom d'affichage tel qu'il apparaît dans le portail. Pour modifier votre nom sur les factures, modifiez-le dans Informations de facturation.",
|
||||
"firstNameLabel": "Prénom",
|
||||
"lastNameLabel": "Nom",
|
||||
"emailLabel": "E-mail",
|
||||
"emailReadOnlyHint": "L'e-mail ne peut pas être modifié ici. Utilisez les paramètres en libre-service de votre fournisseur d'identité.",
|
||||
"personalAccountHint": "Ceci est un compte personnel. Modifier votre nom ici ne change PAS la façon dont votre nom apparaît sur les factures — modifiez-le séparément dans Informations de facturation.",
|
||||
"companyAccountHint": "Vous êtes connecté en tant que membre de {orgName}.",
|
||||
"saveChanges": "Enregistrer les modifications",
|
||||
"saving": "Enregistrement…",
|
||||
"saved": "Enregistré.",
|
||||
"missingRequired": "Le prénom et le nom sont obligatoires."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -121,7 +121,28 @@
|
||||
"saveChanges": "Salva modifiche",
|
||||
"billingVatNumber": "Partita IVA",
|
||||
"billingVatHelp": "Il tuo identificativo IVA registrato. Se la tua azienda è esente IVA, lascia vuoto e spiega nelle note.",
|
||||
"billingNotesPlaceholderPersonal": "Qualsiasi cosa dovremmo sapere — metodo di pagamento preferito, riferimento per fatturazione, ecc."
|
||||
"billingNotesPlaceholderPersonal": "Qualsiasi cosa dovremmo sapere — metodo di pagamento preferito, riferimento per fatturazione, ecc.",
|
||||
"reviewContactPersonPrefix": "c.a.",
|
||||
"setupFeeNoticeHeading": "Le spese di attivazione saranno addebitate all'invio",
|
||||
"setupFeeNoticeBody": "Al clic successivo sarà reindirizzato a Stripe per inserire i dati di pagamento e pagare le spese di attivazione una tantum. La sua carta viene salvata automaticamente per la fatturazione mensile futura. Tornerà subito alla dashboard. L'istanza si avvia solo dopo l'approvazione dell'admin — i canoni mensili decorrono dalla data di approvazione.",
|
||||
"setupFeeAmountLabel": "Spese di attivazione una tantum",
|
||||
"setupFeePlusVat": "+ IVA",
|
||||
"optional": "facoltativo",
|
||||
"yourChannelIdLabel": {
|
||||
"telegram": "Il suo ID utente Telegram",
|
||||
"discord": "Il suo ID utente Discord",
|
||||
"threema": "Il suo ID Threema"
|
||||
},
|
||||
"yourChannelIdPlaceholder": {
|
||||
"telegram": "es. 1234567890",
|
||||
"discord": "es. 234567890123456789",
|
||||
"threema": "es. ABCD1234"
|
||||
},
|
||||
"yourChannelIdHelp": {
|
||||
"telegram": "Apra Telegram, scriva a @userinfobot e incolli qui l'ID numerico restituito. Potrà aggiungere altri utenti in seguito dalla pagina del tenant.",
|
||||
"discord": "Attivi la Modalità sviluppatore in Discord (Impostazioni avanzate), clic destro sul suo nome → Copia ID utente, poi incolli qui. Potrà aggiungere altri utenti in seguito dalla pagina del tenant.",
|
||||
"threema": "L'ID di 8 caratteri mostrato nella sua app Threema in Impostazioni → Il mio ID Threema. Una volta approvato, potrà chattare con l'assistente direttamente da questo account Threema. Potrà aggiungere altri ID autorizzati in seguito dalla pagina del tenant."
|
||||
}
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Dashboard",
|
||||
@@ -318,7 +339,12 @@
|
||||
"activationRejected": "Rifiutata",
|
||||
"tryAgain": "Riprova",
|
||||
"credentialsSaved": "credenziali salvate",
|
||||
"credentialsSavedTip": "Le credenziali inserite sono memorizzate in modo sicuro e saranno utilizzate non appena l'attivazione viene approvata dall'amministratore. Non è necessario reinserirle."
|
||||
"credentialsSavedTip": "Le credenziali inserite sono memorizzate in modo sicuro e saranno utilizzate non appena l'attivazione viene approvata dall'amministratore. Non è necessario reinserirle.",
|
||||
"recommended": "Consigliato",
|
||||
"threemaBotIdHeading": "ID Threema del bot",
|
||||
"threemaBotIdHint": "Una volta approvato il suo tenant, scansioni questo QR con Threema per aggiungere l'assistente ai suoi contatti. Il QR è identico per ogni tenant PieCed — può salvarlo già adesso.",
|
||||
"showInfo": "Info",
|
||||
"showInfoTitle": "Mostra di nuovo le info di setup"
|
||||
},
|
||||
"admin": {
|
||||
"title": "Admin piattaforma",
|
||||
@@ -479,28 +505,56 @@
|
||||
"billingTitle": "Fatturazione",
|
||||
"billingDescription": "Indirizzo, numero di IVA ed e-mail di fatturazione usati per tutti i tuoi tenant.",
|
||||
"nothingForYou": "Al momento non c'è nulla qui per il tuo ruolo. I proprietari possono gestire le impostazioni dell'organizzazione.",
|
||||
"billingDescriptionPersonal": "Indirizzo ed e-mail di fatturazione usati per tutti i tuoi tenant."
|
||||
"billingDescriptionPersonal": "Indirizzo ed e-mail di fatturazione usati per tutti i tuoi tenant.",
|
||||
"profileTitle": "Profilo",
|
||||
"profileDescription": "Modifica il tuo nome e cognome come appaiono nel portale."
|
||||
},
|
||||
"settingsBilling": {
|
||||
"title": "Fatturazione",
|
||||
"subtitle": "Acquisita una sola volta al primo onboarding e riutilizzata per ogni tenant della tua organizzazione. Aggiorna qui ogni volta che i dati di fatturazione cambiano.",
|
||||
"companyName": "Ragione sociale",
|
||||
"streetAddress": "Indirizzo",
|
||||
"postalCode": "CAP",
|
||||
"city": "Città",
|
||||
"country": "Paese",
|
||||
"vatNumber": "Partita IVA",
|
||||
"vatHelp": "Il tuo identificativo IVA registrato (es. CHE-123.456.789 IVA per la Svizzera).",
|
||||
"billingEmail": "E-mail di fatturazione",
|
||||
"billingEmailHelp": "Indirizzo a cui verranno inviate le fatture e le comunicazioni di fatturazione.",
|
||||
"notes": "Note",
|
||||
"notesPlaceholder": "Qualsiasi cosa la contabilità debba sapere — esenzione IVA, modalità di fatturazione particolari, ecc.",
|
||||
"save": "Salva",
|
||||
"title": "Dati di fatturazione",
|
||||
"subtitle": "Indirizzo di fatturazione, partita IVA e contatto fatture della tua azienda. Necessari prima che possano essere emesse fatture per la tua organizzazione.",
|
||||
"companyNameLabel": "Nome azienda",
|
||||
"streetAddressLabel": "Indirizzo",
|
||||
"postalCodeLabel": "CAP",
|
||||
"cityLabel": "Città",
|
||||
"countryLabel": "Codice paese",
|
||||
"countryHint": "ISO 3166-1 alpha-2 — es. CH, DE, AT, FR, IT, GB, US",
|
||||
"vatNumberLabel": "Partita IVA (facoltativa)",
|
||||
"vatNumberHint": "Per clienti svizzeri: CHE-XXX.XXX.XXX IVA. Clienti UE con partita IVA ricevono fattura in reverse charge (0% IVA).",
|
||||
"billingEmailLabel": "E-mail di fatturazione",
|
||||
"billingEmailHint": "Le fatture e i solleciti vengono inviati a questo indirizzo. Può differire dall'e-mail dell'account.",
|
||||
"notesLabel": "Note (facoltative)",
|
||||
"notesHint": "Numeri di riferimento, ordini d'acquisto o altre informazioni da riportare in fattura.",
|
||||
"saveChanges": "Salva modifiche",
|
||||
"createBilling": "Salva dati di fatturazione",
|
||||
"saving": "Salvataggio…",
|
||||
"saved": "Salvato.",
|
||||
"saveFailed": "Impossibile salvare. Riprova.",
|
||||
"lastUpdated": "Ultimo aggiornamento {when}",
|
||||
"fullName": "Nome completo",
|
||||
"notesPlaceholderPersonal": "Qualsiasi cosa dovremmo sapere — metodo di pagamento preferito, riferimento per fatturazione, ecc."
|
||||
"missingRequired": "Compila tutti i campi obbligatori.",
|
||||
"invalidCountry": "Il codice paese deve essere di 2 lettere (es. CH).",
|
||||
"invalidEmail": "Inserisci un indirizzo e-mail valido.",
|
||||
"fullNameLabel": "Nome e cognome",
|
||||
"subtitlePersonal": "Il tuo indirizzo di fatturazione e contatto. Necessari prima che possano essere emesse fatture.",
|
||||
"contactNameLabel": "Persona di contatto (facoltativa)",
|
||||
"contactNameHint": "Stampato come 'c.a. <nome>' sulla fattura, sotto il nome dell'azienda. Utile per l'instradamento contabile in grandi organizzazioni.",
|
||||
"savedCardHeading": "Carta salvata",
|
||||
"savedCardEmptyBody": "Salvi una carta per il pagamento automatico delle fatture. I dati della sua carta sono memorizzati in modo sicuro da Stripe — vediamo solo la marca, le ultime quattro cifre e la scadenza.",
|
||||
"savedCardSetupBtn": "Configura pagamento automatico",
|
||||
"savedCardRedirecting": "Reindirizzamento…",
|
||||
"savedCardUpdateBtn": "Aggiorna carta",
|
||||
"savedCardRemoveBtn": "Rimuovi carta",
|
||||
"savedCardRemoving": "Rimozione…",
|
||||
"savedCardRemoveConfirm": "Rimuovere questa carta? Dovrà riconfigurare il pagamento automatico affinché le future fatture vengano addebitate automaticamente.",
|
||||
"savedCardBrandUnknown": "Carta",
|
||||
"savedCardExpires": "scade {date}",
|
||||
"savedCardAutoChargeOn": "Pagamento auto. attivo",
|
||||
"savedCardAutoChargeOff": "Pagamento auto. disattivo",
|
||||
"savedCardDisableAutoChargeBtn": "Disattiva pagamento automatico",
|
||||
"savedCardEnableAutoChargeBtn": "Attiva pagamento automatico",
|
||||
"savedCardPayByInvoiceNote": "Il suo account è impostato per il pagamento tramite bonifico; la carta salvata non viene utilizzata per gli addebiti automatici. Contatti l'assistenza se desidera tornare al pagamento con carta.",
|
||||
"savedCardBankTransferHint": "Il pagamento tramite bonifico è disponibile su richiesta.",
|
||||
"savedCardBankTransferLink": "Ci contatti per organizzarlo.",
|
||||
"savedCardAutoPayRequiredHeading": "Il pagamento automatico è obbligatorio",
|
||||
"savedCardAutoPayRequiredBody": "PieCed IT opera con pagamento automatico tramite carta. Ci riserviamo il diritto di sospendere i tenant fino al saldo delle fatture pendenti in caso di fallimento della fatturazione automatica.",
|
||||
"savedCardAutoPayDisabledNote": "Il pagamento automatico è attualmente disattivato. Le fatture future dovranno essere saldate manualmente — in caso di mancato pagamento ci riserviamo il diritto di sospendere i tenant associati a questo account."
|
||||
},
|
||||
"support": {
|
||||
"title": "Supporto",
|
||||
@@ -664,7 +718,112 @@
|
||||
"lineItemsTitle": "Righe",
|
||||
"billToSnapshotTitle": "Destinatario",
|
||||
"setupFeeCol": "Spese di attivazione",
|
||||
"skillSetupFeeLabel": "Spese di attivazione"
|
||||
"skillSetupFeeLabel": "Spese di attivazione",
|
||||
"status_partially_refunded": "Rimborsata parzialmente",
|
||||
"status_fully_refunded": "Rimborsata integralmente",
|
||||
"voidBtn": "Annulla",
|
||||
"voidReasonPlaceholder": "Motivo dell'annullamento (stampato sulla nota di credito)",
|
||||
"voidReasonRequired": "Indicare un motivo per l'annullamento.",
|
||||
"confirmVoid": "Conferma annullamento",
|
||||
"voidedOnLabel": "Annullata",
|
||||
"refundBtn": "Rimborsa",
|
||||
"refundReasonPlaceholder": "Motivo del rimborso (stampato sulla nota di credito)",
|
||||
"refundReasonRequired": "Indicare un motivo per il rimborso.",
|
||||
"refundAmountInvalid": "L'importo del rimborso deve essere un numero positivo.",
|
||||
"refundAmountExceeds": "L'importo supera il residuo rimborsabile di CHF {max}.",
|
||||
"refundRemainingHint": "Residuo rimborsabile: CHF {max}",
|
||||
"confirmRefund": "Conferma rimborso",
|
||||
"refundedTotalLabel": "Rimborsato",
|
||||
"refundedRemainingLabel": "Residuo rimborsabile",
|
||||
"creditNotesPanelTitle": "Note di credito",
|
||||
"creditNoteNumberHeader": "Numero",
|
||||
"creditNoteKindHeader": "Tipo",
|
||||
"creditNoteAmountHeader": "Importo",
|
||||
"creditNoteReasonHeader": "Motivo",
|
||||
"creditNoteIssuedHeader": "Emessa",
|
||||
"creditNotePdfHeader": "PDF",
|
||||
"creditNoteKind_void": "Annullamento",
|
||||
"creditNoteKind_refund": "Rimborso",
|
||||
"creditNoteNoPdf": "—",
|
||||
"refundAmountLabel": "Importo",
|
||||
"refundReasonLabel": "Motivo",
|
||||
"refundAmountInclVatHint": "IVA inclusa",
|
||||
"newInvoiceBtn": "Nuova fattura",
|
||||
"draftsLink": "Bozze",
|
||||
"backToDrafts": "Torna alle bozze",
|
||||
"newInvoicePageTitle": "Nuova fattura",
|
||||
"newInvoicePageSubtitle": "Scegli il cliente da fatturare. Aggiungerai le righe nel passaggio successivo.",
|
||||
"newInvoiceOrgLabel": "Cliente",
|
||||
"newInvoiceOrgPlaceholder": "— seleziona cliente —",
|
||||
"newInvoiceOrgNoBilling": "nessun indirizzo di fatturazione",
|
||||
"newInvoiceOrgBillingMissing": "Questo cliente non ha un indirizzo di fatturazione registrato. Chiedi al cliente di completare l'onboarding o imposta i dati dal pannello admin prima di emettere.",
|
||||
"newInvoiceLocaleLabel": "Lingua del documento",
|
||||
"newInvoiceOrgRequired": "Selezionare un cliente.",
|
||||
"newInvoiceContinueBtn": "Continua",
|
||||
"creating": "Creazione…",
|
||||
"draftsPageTitle": "Bozze di fatture",
|
||||
"draftsPageSubtitle": "Fatture personalizzate in corso. Riprendi la modifica o scarta.",
|
||||
"draftsEmpty": "Ancora nessuna bozza. Inizia una nuova fattura.",
|
||||
"draftOrgCol": "Cliente",
|
||||
"draftIssueDateCol": "Data emissione",
|
||||
"draftLinesCol": "Righe",
|
||||
"draftSubtotalCol": "Subtotale (stima)",
|
||||
"draftUpdatedCol": "Modificato",
|
||||
"draftActionsCol": "Azioni",
|
||||
"draftDeleteConfirm": "Scartare questa bozza? Operazione irreversibile.",
|
||||
"editBtn": "Modifica",
|
||||
"editorPageTitle": "Modifica bozza di fattura",
|
||||
"editorBillToHeading": "Destinatario",
|
||||
"editorNoBillingSnapshot": "Nessun indirizzo di fatturazione per questo cliente. L'emissione fallirà finché i dati di fatturazione non saranno impostati.",
|
||||
"editorMetadataHeading": "Dettagli fattura",
|
||||
"editorIssueDateLabel": "Data emissione",
|
||||
"editorDueDateLabel": "Data scadenza",
|
||||
"editorLocaleLabel": "Lingua del documento",
|
||||
"editorPaymentMethodLabel": "Metodo di pagamento",
|
||||
"editorPaymentInvoice": "Bonifico (fattura)",
|
||||
"editorPaymentCard": "Carta di credito (Stripe)",
|
||||
"editorLinesHeading": "Voci",
|
||||
"editorLineDescription": "Descrizione",
|
||||
"editorLineDescriptionPlaceholder": "es. Ore di consulenza, integrazione su misura, …",
|
||||
"editorLineQty": "Q.tà",
|
||||
"editorLineUnitPrice": "Prezzo unitario",
|
||||
"editorLineAmount": "Importo",
|
||||
"editorLineRemove": "Rimuovi riga",
|
||||
"editorAddLine": "Aggiungi riga",
|
||||
"editorAddDiscount": "Aggiungi sconto",
|
||||
"editorAddDiscountHint": "Aggiunge una riga con prezzo unitario negativo. Modifica descrizione e importo se necessario.",
|
||||
"editorRabattDefaultDescription": "Sconto",
|
||||
"editorNotesHeading": "Note interne",
|
||||
"editorNotesPlaceholder": "Note visibili solo all'admin (non sul PDF)",
|
||||
"editorNotesHint": "Non mostrato al cliente.",
|
||||
"editorTotalsHeading": "Totali (stima)",
|
||||
"editorSubtotal": "Subtotale",
|
||||
"editorVat": "IVA",
|
||||
"editorTotal": "Totale",
|
||||
"editorTotalsEstimateNote": "Stima basata sul paese del cliente. L'IVA finale è calcolata all'emissione.",
|
||||
"editorSaveBtn": "Salva bozza",
|
||||
"editorSavedBtn": "Salvato",
|
||||
"editorPreviewBtn": "Anteprima PDF",
|
||||
"editorIssueBtn": "Emetti fattura",
|
||||
"editorDeleteBtn": "Scarta bozza",
|
||||
"editorIssueConfirm": "Emettere questa fattura ora? Verrà assegnato un numero di fattura, il PDF sarà inviato al cliente e questa bozza verrà rimossa.",
|
||||
"editorDeleteConfirm": "Scartare questa bozza? Operazione irreversibile.",
|
||||
"previewing": "Apertura…",
|
||||
"issuing": "Emissione…",
|
||||
"orgsTitle": "Fatturazione cliente",
|
||||
"orgsDesc": "Modalità di pagamento + pagamento auto. per cliente",
|
||||
"orgsPageTitle": "Modalità di fatturazione clienti",
|
||||
"orgsPageSubtitle": "Override della modalità di pagamento per singoli clienti. Il pagamento tramite bonifico sostituisce l'addebito automatico su carta; mettere in pausa il pagamento automatico mantiene la carta salvata ma interrompe i tentativi di addebito (utile in caso di contestazioni).",
|
||||
"orgsEmpty": "Ancora nessun cliente.",
|
||||
"orgsColCustomer": "Cliente",
|
||||
"orgsColCard": "Carta salvata",
|
||||
"orgsColPayByInvoice": "Pagamento tramite bonifico",
|
||||
"orgsColAutoCharge": "Pagamento automatico",
|
||||
"orgsNoSavedCard": "nessuna",
|
||||
"orgsPayByInvoiceOn": "attivo",
|
||||
"orgsPayByInvoiceOff": "disattivo",
|
||||
"orgsAutoChargeOn": "attivo",
|
||||
"orgsAutoChargeOff": "disattivo"
|
||||
},
|
||||
"skillCostDialog": {
|
||||
"title": "Conferma costi di attivazione",
|
||||
@@ -737,12 +896,26 @@
|
||||
"paid": "Pagata",
|
||||
"overdue": "In ritardo",
|
||||
"void": "Annullata",
|
||||
"uncollectible": "Inesigibile"
|
||||
"uncollectible": "Inesigibile",
|
||||
"partially_refunded": "Rimborsata parzialmente",
|
||||
"fully_refunded": "Rimborsata integralmente"
|
||||
},
|
||||
"payWithCard": "Paga con carta",
|
||||
"redirectingToStripe": "Reindirizzamento…",
|
||||
"paymentReceived": "Pagamento ricevuto — grazie!",
|
||||
"paymentCancelled": "Pagamento annullato."
|
||||
"paymentCancelled": "Pagamento annullato.",
|
||||
"configureBillingCta": "Configura dati di fatturazione",
|
||||
"noBillingConfigNonOwner": "Solo il proprietario dell'organizzazione può configurare i dati di fatturazione. Contattalo per completare questo passaggio.",
|
||||
"creditNotesHeading": "Note di credito",
|
||||
"creditNoteNumberCol": "Nota di credito",
|
||||
"creditNoteInvoiceCol": "Fattura",
|
||||
"creditNoteIssuedCol": "Emessa",
|
||||
"creditNoteAmountCol": "Importo",
|
||||
"creditNoteKindCol": "Tipo",
|
||||
"creditNotePdfCol": "PDF",
|
||||
"creditNoteKind_void": "Annullamento",
|
||||
"creditNoteKind_refund": "Rimborso",
|
||||
"creditNoteNoPdf": "PDF non disponibile"
|
||||
},
|
||||
"adminCron": {
|
||||
"title": "Automazione fatturazione",
|
||||
@@ -772,6 +945,23 @@
|
||||
"kind": {
|
||||
"monthly_issue": "Emissione",
|
||||
"reminders": "Solleciti"
|
||||
}
|
||||
},
|
||||
"failureBannerTitle": "Fallimenti recenti rilevati",
|
||||
"failureBannerBody": "{count} esecuzione/i recente/i hanno segnalato almeno un fallimento. Controlla la tabella sotto — le righe interessate sono in rosso."
|
||||
},
|
||||
"settingsProfile": {
|
||||
"title": "Profilo",
|
||||
"subtitle": "Il tuo nome visualizzato come appare nel portale, nelle richieste tenant e nei ticket di supporto.",
|
||||
"subtitlePersonal": "Il tuo nome visualizzato come appare nel portale. Per modificare il tuo nome in fattura, modificalo in Dati di fatturazione.",
|
||||
"firstNameLabel": "Nome",
|
||||
"lastNameLabel": "Cognome",
|
||||
"emailLabel": "E-mail",
|
||||
"emailReadOnlyHint": "L'e-mail non può essere modificata qui. Usa le impostazioni self-service del tuo provider di identità.",
|
||||
"personalAccountHint": "Questo è un account personale. Modificare il tuo nome qui NON cambia come appare in fattura — modificalo separatamente in Dati di fatturazione.",
|
||||
"companyAccountHint": "Sei connesso come membro di {orgName}.",
|
||||
"saveChanges": "Salva modifiche",
|
||||
"saving": "Salvataggio…",
|
||||
"saved": "Salvato.",
|
||||
"missingRequired": "Nome e cognome sono obbligatori."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -234,6 +234,12 @@ export interface BillingAddress {
|
||||
export interface OrgBilling {
|
||||
zitadelOrgId: string;
|
||||
companyName: string;
|
||||
// Optional contact-person line ("z.Hd. / Attn:") shown on the
|
||||
// invoice PDF below the company name. Useful when invoicing
|
||||
// larger companies where the mailroom needs a name to route
|
||||
// the document. Personal accounts don't expose this in the UI —
|
||||
// their "Full name" already lives in companyName.
|
||||
contactName?: string | null;
|
||||
streetAddress: string;
|
||||
postalCode: string;
|
||||
city: string;
|
||||
@@ -247,6 +253,13 @@ export interface OrgBilling {
|
||||
|
||||
export type TenantRequestStatus =
|
||||
| "pending" // Submitted, awaiting admin approval
|
||||
// Phase 9b: setup-fee Checkout pending. The row exists, has no
|
||||
// tenant_name yet (set when payment succeeds), and is invisible
|
||||
// to admin (the queue filters to status='pending'). On webhook
|
||||
// success the row flips to 'pending'. On abandonment the row
|
||||
// stays here harmlessly — each retry creates a fresh row with
|
||||
// a different derived tenant_name.
|
||||
| "pending_payment"
|
||||
| "approved" // Admin approved, provisioning will start
|
||||
| "provisioning" // PiecedTenant CR created, operator reconciling
|
||||
| "active" // Tenant running
|
||||
@@ -277,6 +290,24 @@ export interface TenantRequest {
|
||||
status: TenantRequestStatus;
|
||||
adminNotes?: string;
|
||||
tenantName?: string;
|
||||
/**
|
||||
* Phase 9b: the paid setup-fee invoice linked to this request.
|
||||
* Set by the Stripe webhook when the order-time Checkout
|
||||
* completes successfully. Null on requests that pre-date Phase 9b
|
||||
* and on resume requests (which don't have a setup fee). Admin
|
||||
* rejection refunds this invoice via the existing refund flow.
|
||||
*/
|
||||
setupInvoiceId?: string | null;
|
||||
/**
|
||||
* Phase 9b: optional initial channel-user ids the customer entered
|
||||
* during onboarding for each enabled channel package (e.g.
|
||||
* { telegram: ["1234567"], threema: ["ABCD1234"] }). Empty/absent
|
||||
* on requests that pre-date the field. Applied on admin approval:
|
||||
* the values get seeded into PiecedTenantSpec.channelUsers, and
|
||||
* for Threema specifically, the relay's route table is updated so
|
||||
* inbound messages from those ids reach the newly-created tenant.
|
||||
*/
|
||||
channelUsers?: Record<string, string[]>;
|
||||
encryptedSecrets?: Buffer | null;
|
||||
/**
|
||||
* Slice 4: true for personal accounts. Drives CR-naming (`p-{suffix}`
|
||||
@@ -340,6 +371,14 @@ export interface OnboardingInput {
|
||||
*/
|
||||
billingAddress?: BillingAddress;
|
||||
billingNotes?: string;
|
||||
/**
|
||||
* Phase 9b: initial channel-user ids the customer entered during
|
||||
* onboarding, keyed by channel package id (e.g. { telegram:
|
||||
* ["1234567"], threema: ["ABCD1234"] }). Optional — customers
|
||||
* can also leave channels blank and add ids later from the
|
||||
* tenant's channel-users page.
|
||||
*/
|
||||
channelUsers?: Record<string, string[]>;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -524,6 +563,29 @@ export interface OrgBillingConfig {
|
||||
stripeCustomerId: string | null;
|
||||
autoInvoiceEnabled: boolean;
|
||||
autoRemindersEnabled: boolean;
|
||||
/**
|
||||
* Phase 9: saved-card info for off-session auto-charge.
|
||||
* Populated by the SetupIntent webhook when a customer completes
|
||||
* the "Set up auto-pay" flow. Only display fields are stored
|
||||
* locally — never the PAN. The Stripe PaymentMethod id
|
||||
* (`pm_xxx`) is the handle the platform uses to charge against
|
||||
* the card; the brand/last4/exp_month/exp_year fields are for
|
||||
* showing "Visa •••• 4242, expires 05/27" without an API call.
|
||||
*/
|
||||
stripeDefaultPaymentMethodId: string | null;
|
||||
stripePmBrand: string | null;
|
||||
stripePmLast4: string | null;
|
||||
stripePmExpMonth: number | null;
|
||||
stripePmExpYear: number | null;
|
||||
/**
|
||||
* Phase 9: off-session auto-charge gate. Default TRUE for new
|
||||
* customers (card is the default payment method). Admin can
|
||||
* flip this off to pause auto-charging for a specific customer
|
||||
* (e.g. during a dispute) without removing the saved card. With
|
||||
* no saved PaymentMethod set, the flag is irrelevant — there's
|
||||
* nothing to charge against.
|
||||
*/
|
||||
autoChargeEnabled: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
@@ -538,10 +600,57 @@ export type InvoiceStatus =
|
||||
| "paid"
|
||||
| "overdue"
|
||||
| "void"
|
||||
| "uncollectible";
|
||||
| "uncollectible"
|
||||
// Phase 7: refund states. partially_refunded = at least one refund
|
||||
// recorded but sum < total. fully_refunded = sum >= total. Voiding
|
||||
// applies to unpaid invoices; refunding applies to paid invoices —
|
||||
// the two states are mutually exclusive transitions from 'paid'
|
||||
// versus 'open'.
|
||||
| "partially_refunded"
|
||||
| "fully_refunded";
|
||||
|
||||
export type InvoicePaymentMethod = "invoice" | "card";
|
||||
|
||||
// Phase 7 — credit notes are independent documents (separate
|
||||
// numbering, separate PDF) that record a void or refund against an
|
||||
// original invoice. Issued as part of voidInvoice() or
|
||||
// refundInvoice() flows; the customer downloads them from
|
||||
// /api/credit-notes/<number>/pdf.
|
||||
export type CreditNoteKind = "void" | "refund";
|
||||
|
||||
export interface CreditNote {
|
||||
id: string;
|
||||
creditNoteNumber: string;
|
||||
invoiceId: string;
|
||||
invoiceNumber: string;
|
||||
zitadelOrgId: string;
|
||||
kind: CreditNoteKind;
|
||||
amountChf: number;
|
||||
vatAmountChf: number;
|
||||
reason: string | null;
|
||||
issuedAt: string;
|
||||
issuedBy: string;
|
||||
locale: string;
|
||||
pdfFilename: string | null;
|
||||
hasPdf: boolean;
|
||||
billingSnapshot: InvoiceBillingSnapshot;
|
||||
}
|
||||
|
||||
// Phase 7 — per-refund-event record (one row per Stripe Refund
|
||||
// object, or per admin-initiated refund for invoice-paid customers).
|
||||
// Aggregated into invoices.refunded_total_chf for query convenience.
|
||||
export interface InvoiceRefund {
|
||||
id: string;
|
||||
invoiceId: string;
|
||||
stripeRefundId: string | null;
|
||||
amountChf: number;
|
||||
reason: string | null;
|
||||
status: "pending" | "succeeded" | "failed" | "canceled";
|
||||
refundedAt: string;
|
||||
refundedBy: string;
|
||||
creditNoteId: string | null;
|
||||
}
|
||||
|
||||
// Phase 5 — Cron run history rows for the admin /admin/cron page.
|
||||
export type CronRunKind = "monthly_issue" | "reminders";
|
||||
export interface CronRun {
|
||||
@@ -563,7 +672,11 @@ export type InvoiceLineKind =
|
||||
| "threema_messages"
|
||||
| "skill_usage"
|
||||
| "skill_setup"
|
||||
| "adjustment";
|
||||
| "adjustment"
|
||||
// Phase 8 — line kind for ad-hoc invoices. Rendered under a
|
||||
// "Services" / "Leistungen" header on the PDF. Negative
|
||||
// unitPriceChf is allowed (used for Rabatt rows).
|
||||
| "custom_line";
|
||||
|
||||
/**
|
||||
* Snapshot of the customer's billing details captured at invoice
|
||||
@@ -575,6 +688,7 @@ export type InvoiceLineKind =
|
||||
*/
|
||||
export interface InvoiceBillingSnapshot {
|
||||
companyName: string;
|
||||
contactName: string | null;
|
||||
streetAddress: string;
|
||||
postalCode: string;
|
||||
city: string;
|
||||
@@ -615,8 +729,19 @@ export interface Invoice {
|
||||
id: string;
|
||||
invoiceNumber: string;
|
||||
zitadelOrgId: string;
|
||||
periodStart: string; // ISO date (YYYY-MM-DD)
|
||||
periodEnd: string;
|
||||
/**
|
||||
* Phase 8: invoice provenance. 'auto' = generated by the monthly
|
||||
* cron from tenant usage; 'custom' = created via the admin
|
||||
* "New invoice" flow. Custom invoices have nullable period_start
|
||||
* / period_end and skip the per-org-per-month uniqueness guard.
|
||||
* Defaults to 'auto' for all pre-Phase-8 rows (backfilled by the
|
||||
* column DEFAULT).
|
||||
*/
|
||||
source: "auto" | "custom";
|
||||
// Billing period — null on custom invoices that aren't tied to a
|
||||
// billing period.
|
||||
periodStart: string | null;
|
||||
periodEnd: string | null;
|
||||
issuedAt: string;
|
||||
dueAt: string;
|
||||
subtotalChf: number;
|
||||
@@ -634,6 +759,16 @@ export interface Invoice {
|
||||
paidAt: string | null;
|
||||
paidBy: string | null;
|
||||
paidMethodDetail: string | null;
|
||||
// Phase 7 — void tracking. Populated when status='void'. The reason
|
||||
// free-text is rendered on the credit note PDF.
|
||||
voidReason: string | null;
|
||||
voidedAt: string | null;
|
||||
voidedBy: string | null;
|
||||
// Phase 7 — running sum of refunds applied to this invoice. Zero
|
||||
// for invoices that have never been refunded. Drives status
|
||||
// transitions (partially_refunded vs fully_refunded) and the
|
||||
// running-total widget on /billing.
|
||||
refundedTotalChf: number;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
@@ -651,8 +786,21 @@ export interface InvoiceDetail {
|
||||
*/
|
||||
export interface InvoiceDraft {
|
||||
zitadelOrgId: string;
|
||||
periodStart: string;
|
||||
periodEnd: string;
|
||||
/**
|
||||
* Phase 8: optional for custom invoices. The auto cron always
|
||||
* sets both period_start and period_end; the custom flow may
|
||||
* leave them null.
|
||||
*/
|
||||
periodStart: string | null;
|
||||
periodEnd: string | null;
|
||||
/**
|
||||
* Phase 8: optional override of the issue date. When omitted,
|
||||
* the DB uses now() at insertion time. The custom flow uses
|
||||
* this to let admin backdate or future-date invoices.
|
||||
*/
|
||||
issuedAt?: string;
|
||||
/** Phase 8: 'auto' (cron) or 'custom' (admin form). Defaults to 'auto'. */
|
||||
source?: "auto" | "custom";
|
||||
dueAt: string;
|
||||
locale: string;
|
||||
paymentMethod: InvoicePaymentMethod;
|
||||
@@ -670,6 +818,65 @@ export interface InvoiceDraft {
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Phase 8 — custom invoice drafts
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* The shape persisted in the invoice_drafts.payload JSONB column.
|
||||
* This is the in-progress form state the admin is composing — not
|
||||
* yet an invoice. On "Issue" it's converted into a real Invoice row
|
||||
* via billing.issueCustomInvoiceDraft and the draft row deleted.
|
||||
*
|
||||
* Kept separate from InvoiceDraft (which is the compute pipeline's
|
||||
* type for in-flight monthly bills) so the two domains don't
|
||||
* accidentally drift.
|
||||
*/
|
||||
export interface CustomInvoiceDraftPayload {
|
||||
/** ISO date (YYYY-MM-DD). Defaults to today on creation. */
|
||||
issueDate: string;
|
||||
/** ISO date (YYYY-MM-DD). Defaults to issueDate + 30 days. */
|
||||
dueDate: string;
|
||||
/** Locale for the PDF and email; defaults to org's default. */
|
||||
locale: "de" | "en" | "fr" | "it";
|
||||
paymentMethod: InvoicePaymentMethod;
|
||||
/**
|
||||
* Optional notes only the admin sees in the portal (not on the PDF).
|
||||
*/
|
||||
adminNotes?: string;
|
||||
lines: CustomInvoiceDraftLine[];
|
||||
}
|
||||
|
||||
export interface CustomInvoiceDraftLine {
|
||||
/** Free-text description, shown on the PDF as the line label. */
|
||||
description: string;
|
||||
/**
|
||||
* Decimal quantity. Most cases are integer (1, 2, 10 hours) but
|
||||
* we allow decimal for fractional hours (0.5) or ratios.
|
||||
*/
|
||||
quantity: number;
|
||||
/**
|
||||
* CHF per unit. Negative values are allowed for discount /
|
||||
* Rabatt rows — the PDF shows them as negative amounts and the
|
||||
* subtotal is the algebraic sum of all line amounts.
|
||||
*/
|
||||
unitPriceChf: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The DB row in invoice_drafts. The admin can save a draft, come
|
||||
* back later, and issue it (or delete it) at any time. Drafts have
|
||||
* no invoice number, no PDF, and are never visible to the customer.
|
||||
*/
|
||||
export interface InvoiceDraftRecord {
|
||||
id: string;
|
||||
zitadelOrgId: string;
|
||||
createdBy: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
payload: CustomInvoiceDraftPayload;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Skill activation requests — manual provisioning queue
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user