Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ad4f614130 | |||
| 8e7691d38a | |||
| 9939f75c03 | |||
| e69b68b73c | |||
| 41c1553b1f | |||
| 38f4c3243e | |||
| ed915ec539 | |||
| 667617296b | |||
| 1c61111da3 | |||
| 6fed5b083b | |||
| 4f868d751e | |||
| e15a668f8e | |||
| 9cd9879a18 | |||
| 323786672f | |||
| a1769eeb00 | |||
| 002867850d | |||
| eea027b3b0 | |||
| 522246e386 |
59
src/app/[locale]/admin/billing/invoice-drafts/[id]/page.tsx
Normal file
59
src/app/[locale]/admin/billing/invoice-drafts/[id]/page.tsx
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import { notFound, redirect } from "next/navigation";
|
||||||
|
import { getTranslations } from "next-intl/server";
|
||||||
|
import { getSessionUser } from "@/lib/session";
|
||||||
|
import { getInvoiceDraftById, getOrgBilling } from "@/lib/db";
|
||||||
|
import { BackLink } from "@/components/ui/back-link";
|
||||||
|
import { CustomInvoiceEditor } from "@/components/admin/billing/custom-invoice-editor";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* /admin/billing/invoice-drafts/[id] — full editor for an
|
||||||
|
* in-progress custom invoice.
|
||||||
|
*
|
||||||
|
* Phase 8. Server-loads the draft + the org's billing snapshot
|
||||||
|
* (used to display the bill-to block preview), then hands off to
|
||||||
|
* the client editor for the interactive line-management UI.
|
||||||
|
*
|
||||||
|
* The snapshot is loaded read-only for display. The actual VAT
|
||||||
|
* computation happens server-side at issue time via
|
||||||
|
* computeCustomInvoiceTotals, which re-reads the same snapshot.
|
||||||
|
* That two-time read is intentional: the editor's preview math
|
||||||
|
* is a hint, the issue-time read is authoritative — if the
|
||||||
|
* customer updates their billing address between Draft and Issue,
|
||||||
|
* the invoice reflects the new address.
|
||||||
|
*/
|
||||||
|
export default async function InvoiceDraftEditorPage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ id: string }>;
|
||||||
|
}) {
|
||||||
|
const user = await getSessionUser();
|
||||||
|
if (!user) redirect("/login");
|
||||||
|
if (!user.isPlatform) redirect("/dashboard");
|
||||||
|
const t = await getTranslations("adminBilling");
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
const draft = await getInvoiceDraftById(id);
|
||||||
|
if (!draft) notFound();
|
||||||
|
const orgBilling = await getOrgBilling(draft.zitadelOrgId).catch(() => null);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="max-w-5xl mx-auto px-6 py-8">
|
||||||
|
<BackLink
|
||||||
|
href="/admin/billing/invoice-drafts"
|
||||||
|
label={t("backToDrafts")}
|
||||||
|
/>
|
||||||
|
<div className="mb-6">
|
||||||
|
<h1 className="font-display text-2xl font-semibold accent-rule">
|
||||||
|
{t("editorPageTitle")}
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-text-secondary mt-3">
|
||||||
|
{orgBilling?.companyName ?? draft.zitadelOrgId}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<CustomInvoiceEditor
|
||||||
|
draft={draft}
|
||||||
|
orgBilling={orgBilling}
|
||||||
|
/>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
72
src/app/[locale]/admin/billing/invoice-drafts/page.tsx
Normal file
72
src/app/[locale]/admin/billing/invoice-drafts/page.tsx
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { getTranslations } from "next-intl/server";
|
||||||
|
import { getSessionUser } from "@/lib/session";
|
||||||
|
import { getOrgBilling, listAllInvoiceDrafts } from "@/lib/db";
|
||||||
|
import { listTenants } from "@/lib/k8s";
|
||||||
|
import { BackLink } from "@/components/ui/back-link";
|
||||||
|
import { DraftList } from "@/components/admin/billing/draft-list";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* /admin/billing/invoice-drafts — list of all open custom-invoice
|
||||||
|
* drafts across orgs.
|
||||||
|
*
|
||||||
|
* Phase 8. Each draft is a JSONB blob the admin is composing into
|
||||||
|
* an invoice; visible only to platform admins. From here the admin
|
||||||
|
* can resume editing or discard.
|
||||||
|
*
|
||||||
|
* Building an org-name map by reading tenant labels (for the set of
|
||||||
|
* known orgs) + getOrgBilling per org (for the actual company name)
|
||||||
|
* so the table can show "Customer X" instead of a raw ZITADEL org id.
|
||||||
|
*/
|
||||||
|
export default async function AdminInvoiceDraftsPage() {
|
||||||
|
const user = await getSessionUser();
|
||||||
|
if (!user) redirect("/login");
|
||||||
|
if (!user.isPlatform) redirect("/dashboard");
|
||||||
|
const t = await getTranslations("adminBilling");
|
||||||
|
|
||||||
|
const [drafts, tenants] = await Promise.all([
|
||||||
|
listAllInvoiceDrafts(),
|
||||||
|
listTenants().catch(() => []),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Build the set of distinct ZITADEL org ids from tenant labels,
|
||||||
|
// PLUS the set referenced by any current draft. Drafts may target
|
||||||
|
// orgs that don't have tenants yet (rare but possible), so we
|
||||||
|
// union both sources before fetching billing rows.
|
||||||
|
const orgIds = new Set<string>();
|
||||||
|
for (const tnt of tenants) {
|
||||||
|
const oid = tnt.metadata.labels?.["pieced.ch/zitadel-org-id"];
|
||||||
|
if (oid) orgIds.add(oid);
|
||||||
|
}
|
||||||
|
for (const d of drafts) {
|
||||||
|
orgIds.add(d.zitadelOrgId);
|
||||||
|
}
|
||||||
|
// Look up billing in parallel — same pattern as
|
||||||
|
// /api/admin/billing/orgs uses. Failure for any single org is
|
||||||
|
// non-fatal (falls back to the raw id in the table).
|
||||||
|
const orgNamePairs = await Promise.all(
|
||||||
|
Array.from(orgIds).map(async (oid) => {
|
||||||
|
const billing = await getOrgBilling(oid).catch(() => null);
|
||||||
|
return [oid, billing?.companyName ?? null] as const;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
const orgNameMap: Record<string, string> = {};
|
||||||
|
for (const [oid, name] of orgNamePairs) {
|
||||||
|
if (name) orgNameMap[oid] = name;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="max-w-5xl mx-auto px-6 py-8">
|
||||||
|
<BackLink href="/admin/billing" label={t("backToBilling")} />
|
||||||
|
<div className="mb-6">
|
||||||
|
<h1 className="font-display text-2xl font-semibold accent-rule">
|
||||||
|
{t("draftsPageTitle")}
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-text-secondary mt-3">
|
||||||
|
{t("draftsPageSubtitle")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<DraftList drafts={drafts} orgNameMap={orgNameMap} />
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { notFound, redirect } from "next/navigation";
|
import { notFound, redirect } from "next/navigation";
|
||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
import { getSessionUser } from "@/lib/session";
|
import { getSessionUser } from "@/lib/session";
|
||||||
import { getInvoiceDetail } from "@/lib/db";
|
import { getInvoiceDetail, listCreditNotesForInvoice } from "@/lib/db";
|
||||||
import { BackLink } from "@/components/ui/back-link";
|
import { BackLink } from "@/components/ui/back-link";
|
||||||
import { InvoiceDetailView } from "@/components/admin/billing/invoice-detail-view";
|
import { InvoiceDetailView } from "@/components/admin/billing/invoice-detail-view";
|
||||||
|
|
||||||
@@ -9,8 +9,12 @@ import { InvoiceDetailView } from "@/components/admin/billing/invoice-detail-vie
|
|||||||
* /admin/billing/invoices/[id] — full detail of one invoice.
|
* /admin/billing/invoices/[id] — full detail of one invoice.
|
||||||
*
|
*
|
||||||
* Server-renders the static body (header, lines, totals, billing
|
* Server-renders the static body (header, lines, totals, billing
|
||||||
* snapshot); the action bar (mark-paid, delete, PDF download) is
|
* snapshot); the action bar (mark-paid, void, refund, delete, PDF
|
||||||
* a client component for the interactive bits.
|
* download) is a client component for the interactive bits.
|
||||||
|
*
|
||||||
|
* Phase 7: also passes any linked credit notes so the detail view
|
||||||
|
* can show the "this invoice was voided / partially refunded" panel
|
||||||
|
* without an extra round-trip.
|
||||||
*/
|
*/
|
||||||
export default async function AdminInvoiceDetailPage({
|
export default async function AdminInvoiceDetailPage({
|
||||||
params,
|
params,
|
||||||
@@ -25,11 +29,12 @@ export default async function AdminInvoiceDetailPage({
|
|||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const detail = await getInvoiceDetail(id);
|
const detail = await getInvoiceDetail(id);
|
||||||
if (!detail) notFound();
|
if (!detail) notFound();
|
||||||
|
const creditNotes = await listCreditNotesForInvoice(id);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="max-w-4xl mx-auto px-6 py-8">
|
<main className="max-w-4xl mx-auto px-6 py-8">
|
||||||
<BackLink href="/admin/billing/invoices" label={t("backToInvoices")} />
|
<BackLink href="/admin/billing/invoices" label={t("backToInvoices")} />
|
||||||
<InvoiceDetailView detail={detail} />
|
<InvoiceDetailView detail={detail} creditNotes={creditNotes} />
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
72
src/app/[locale]/admin/billing/invoices/new/page.tsx
Normal file
72
src/app/[locale]/admin/billing/invoices/new/page.tsx
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { getTranslations } from "next-intl/server";
|
||||||
|
import { getSessionUser } from "@/lib/session";
|
||||||
|
import { listTenants } from "@/lib/k8s";
|
||||||
|
import { getOrgBilling } from "@/lib/db";
|
||||||
|
import { BackLink } from "@/components/ui/back-link";
|
||||||
|
import { NewInvoiceForm } from "@/components/admin/billing/new-invoice-form";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* /admin/billing/invoices/new — entry point for the custom-invoice
|
||||||
|
* flow. The admin picks an org, clicks Continue, and lands on the
|
||||||
|
* editor at /admin/billing/invoice-drafts/<new-id>.
|
||||||
|
*
|
||||||
|
* Phase 8. Org list is built from tenant labels + each org's
|
||||||
|
* billing config (we need the company name and the
|
||||||
|
* has-billing-snapshot flag to gate the picker — orgs without a
|
||||||
|
* snapshot can't be invoiced until they complete onboarding or
|
||||||
|
* admin sets the billing info manually).
|
||||||
|
*/
|
||||||
|
export default async function NewInvoicePage() {
|
||||||
|
const user = await getSessionUser();
|
||||||
|
if (!user) redirect("/login");
|
||||||
|
if (!user.isPlatform) redirect("/dashboard");
|
||||||
|
const t = await getTranslations("adminBilling");
|
||||||
|
|
||||||
|
// Tenants give us org membership; getOrgBilling per org gives us
|
||||||
|
// the snapshot status. We dedupe by org id since one org can own
|
||||||
|
// many tenants.
|
||||||
|
const tenants = await listTenants();
|
||||||
|
const orgIds = new Set<string>();
|
||||||
|
for (const tnt of tenants) {
|
||||||
|
const oid = tnt.metadata.labels?.["pieced.ch/zitadel-org-id"];
|
||||||
|
if (oid) orgIds.add(oid);
|
||||||
|
}
|
||||||
|
const orgs = await Promise.all(
|
||||||
|
Array.from(orgIds).map(async (oid) => {
|
||||||
|
const billing = await getOrgBilling(oid).catch(() => null);
|
||||||
|
return {
|
||||||
|
zitadelOrgId: oid,
|
||||||
|
companyName: billing?.companyName ?? null,
|
||||||
|
country: billing?.country ?? null,
|
||||||
|
hasBillingAddress: !!billing && !!billing.companyName,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
// Sort: orgs with billing first (admin's most likely target),
|
||||||
|
// then alphabetically by company name.
|
||||||
|
orgs.sort((a, b) => {
|
||||||
|
if (a.hasBillingAddress !== b.hasBillingAddress) {
|
||||||
|
return a.hasBillingAddress ? -1 : 1;
|
||||||
|
}
|
||||||
|
return (a.companyName ?? "").localeCompare(b.companyName ?? "");
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="max-w-2xl mx-auto px-6 py-8">
|
||||||
|
<BackLink
|
||||||
|
href="/admin/billing/invoices"
|
||||||
|
label={t("backToInvoices")}
|
||||||
|
/>
|
||||||
|
<div className="mb-6">
|
||||||
|
<h1 className="font-display text-2xl font-semibold accent-rule">
|
||||||
|
{t("newInvoicePageTitle")}
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-text-secondary mt-3">
|
||||||
|
{t("newInvoicePageSubtitle")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<NewInvoiceForm orgs={orgs} />
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,23 +1,30 @@
|
|||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
import { getSessionUser } from "@/lib/session";
|
import { getSessionUser } from "@/lib/session";
|
||||||
import { listInvoices, syncOverdueInvoices } from "@/lib/db";
|
import {
|
||||||
|
listCreditNotesForOrg,
|
||||||
|
listInvoices,
|
||||||
|
syncOverdueInvoices,
|
||||||
|
} from "@/lib/db";
|
||||||
import { CustomerInvoiceList } from "@/components/billing/customer-invoice-list";
|
import { CustomerInvoiceList } from "@/components/billing/customer-invoice-list";
|
||||||
|
import { CustomerCreditNoteList } from "@/components/billing/customer-credit-note-list";
|
||||||
import { RunningTotalWidget } from "@/components/billing/running-total-widget";
|
import { RunningTotalWidget } from "@/components/billing/running-total-widget";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* /billing — customer's billing home.
|
* /billing — customer's billing home.
|
||||||
*
|
*
|
||||||
* Shows two things:
|
* Shows three things:
|
||||||
* 1. RunningTotalWidget — current calendar month's accruing cost
|
* 1. RunningTotalWidget — current calendar month's accruing cost
|
||||||
* (or the already-issued invoice for the current month, if
|
* (or the already-issued invoice for the current month, if
|
||||||
* that ran early).
|
* that ran early).
|
||||||
* 2. CustomerInvoiceList — every issued invoice for this org,
|
* 2. CustomerInvoiceList — every issued invoice for this org,
|
||||||
* newest first. Status is reflected with a colored badge.
|
* newest first. Status is reflected with a colored badge.
|
||||||
|
* 3. CustomerCreditNoteList — Phase 7. Credit notes (voids and
|
||||||
|
* refunds) for this org, with PDF download links. Hidden
|
||||||
|
* entirely when there are none (the common case).
|
||||||
*
|
*
|
||||||
* Anyone signed in can view this. The data is org-scoped; even
|
* Anyone signed in can view this. The data is org-scoped; even
|
||||||
* non-owner team members see the same view. Phase 4 will add a
|
* non-owner team members see the same view.
|
||||||
* "settings.payByInvoice" toggle visibility-gated to owners only.
|
|
||||||
*/
|
*/
|
||||||
export default async function CustomerBillingPage() {
|
export default async function CustomerBillingPage() {
|
||||||
const user = await getSessionUser();
|
const user = await getSessionUser();
|
||||||
@@ -31,10 +38,11 @@ export default async function CustomerBillingPage() {
|
|||||||
console.warn("syncOverdueInvoices failed in /billing:", e);
|
console.warn("syncOverdueInvoices failed in /billing:", e);
|
||||||
}
|
}
|
||||||
|
|
||||||
const invoices = await listInvoices({
|
// Parallel fetch — invoices + credit notes are independent.
|
||||||
zitadelOrgId: user.orgId,
|
const [invoices, creditNotes] = await Promise.all([
|
||||||
limit: 200,
|
listInvoices({ zitadelOrgId: user.orgId, limit: 200 }),
|
||||||
});
|
listCreditNotesForOrg(user.orgId, 200),
|
||||||
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="max-w-5xl mx-auto px-6 py-8">
|
<main className="max-w-5xl mx-auto px-6 py-8">
|
||||||
@@ -54,12 +62,24 @@ export default async function CustomerBillingPage() {
|
|||||||
<RunningTotalWidget isOwner={user.roles.includes("owner")} />
|
<RunningTotalWidget isOwner={user.roles.includes("owner")} />
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="animate-in animate-in-delay-2">
|
<section className="animate-in animate-in-delay-2 mb-8">
|
||||||
<h2 className="text-xs font-semibold uppercase tracking-wider text-text-muted mb-3">
|
<h2 className="text-xs font-semibold uppercase tracking-wider text-text-muted mb-3">
|
||||||
{t("historyHeading")}
|
{t("historyHeading")}
|
||||||
</h2>
|
</h2>
|
||||||
<CustomerInvoiceList invoices={invoices} />
|
<CustomerInvoiceList invoices={invoices} />
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{/* Phase 7: credit-note section. CustomerCreditNoteList itself
|
||||||
|
returns null when there are no credit notes, so this whole
|
||||||
|
section disappears for orgs in normal operation. */}
|
||||||
|
{creditNotes.length > 0 && (
|
||||||
|
<section className="animate-in animate-in-delay-3">
|
||||||
|
<h2 className="text-xs font-semibold uppercase tracking-wider text-text-muted mb-3">
|
||||||
|
{t("creditNotesHeading")}
|
||||||
|
</h2>
|
||||||
|
<CustomerCreditNoteList creditNotes={creditNotes} />
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -76,6 +76,7 @@ export default async function NewInstancePage() {
|
|||||||
userName={user.name}
|
userName={user.name}
|
||||||
userEmail={user.email}
|
userEmail={user.email}
|
||||||
hasOrgBilling={hasOrgBilling}
|
hasOrgBilling={hasOrgBilling}
|
||||||
|
existingOrgBilling={orgBilling}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -317,6 +317,7 @@ export default async function DashboardPage() {
|
|||||||
userName={user.name}
|
userName={user.name}
|
||||||
userEmail={user.email}
|
userEmail={user.email}
|
||||||
hasOrgBilling={hasOrgBilling}
|
hasOrgBilling={hasOrgBilling}
|
||||||
|
existingOrgBilling={orgBilling}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { redirect, notFound } from "next/navigation";
|
import { redirect, notFound } from "next/navigation";
|
||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
import { getSessionUser } from "@/lib/session";
|
import { getSessionUser } from "@/lib/session";
|
||||||
import { getOrgBilling } from "@/lib/db";
|
import { getOrgBilling, getOrgBillingConfig } from "@/lib/db";
|
||||||
import { BillingSettingsForm } from "@/components/settings/billing-form";
|
import { BillingSettingsForm } from "@/components/settings/billing-form";
|
||||||
|
import { SavedCardSection } from "@/components/settings/saved-card-section";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* /settings/billing — customer-side billing details management.
|
* /settings/billing — customer-side billing details management.
|
||||||
@@ -17,6 +18,11 @@ import { BillingSettingsForm } from "@/components/settings/billing-form";
|
|||||||
* the current values, editable. Save creates or updates via the
|
* the current values, editable. Save creates or updates via the
|
||||||
* shared upsert path; the row's existence drives whether the
|
* shared upsert path; the row's existence drives whether the
|
||||||
* monthly issuance cron will pick this org up.
|
* 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() {
|
export default async function BillingSettingsPage() {
|
||||||
const user = await getSessionUser();
|
const user = await getSessionUser();
|
||||||
@@ -25,7 +31,10 @@ export default async function BillingSettingsPage() {
|
|||||||
if (!user.roles.includes("owner")) notFound();
|
if (!user.roles.includes("owner")) notFound();
|
||||||
|
|
||||||
const t = await getTranslations("settingsBilling");
|
const t = await getTranslations("settingsBilling");
|
||||||
const existing = await getOrgBilling(user.orgId);
|
const [existing, config] = await Promise.all([
|
||||||
|
getOrgBilling(user.orgId),
|
||||||
|
getOrgBillingConfig(user.orgId),
|
||||||
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="max-w-3xl mx-auto px-6 py-8">
|
<main className="max-w-3xl mx-auto px-6 py-8">
|
||||||
@@ -43,6 +52,19 @@ export default async function BillingSettingsPage() {
|
|||||||
isPersonal={user.isPersonal}
|
isPersonal={user.isPersonal}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,8 +20,9 @@ export default async function SettingsPage() {
|
|||||||
const t = await getTranslations("settings");
|
const t = await getTranslations("settings");
|
||||||
|
|
||||||
// Build the list of settings cards. Each entry has a stable key, a
|
// Build the list of settings cards. Each entry has a stable key, a
|
||||||
// route, and a visibility predicate. Currently only billing; this
|
// route, and a visibility predicate. Phase 6 fix5: profile is
|
||||||
// shape leaves headroom for adding more without restructuring.
|
// visible to every signed-in user (it's their own identity).
|
||||||
|
// Billing stays gated behind canMutate.
|
||||||
const sections: Array<{
|
const sections: Array<{
|
||||||
key: string;
|
key: string;
|
||||||
href: string;
|
href: string;
|
||||||
@@ -29,6 +30,14 @@ export default async function SettingsPage() {
|
|||||||
description: string;
|
description: string;
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
}> = [
|
}> = [
|
||||||
|
{
|
||||||
|
key: "profile",
|
||||||
|
href: "/settings/profile",
|
||||||
|
title: t("profileTitle"),
|
||||||
|
description: t("profileDescription"),
|
||||||
|
// Every signed-in user can edit their own first/last name.
|
||||||
|
visible: true,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: "billing",
|
key: "billing",
|
||||||
href: "/settings/billing",
|
href: "/settings/billing",
|
||||||
|
|||||||
68
src/app/[locale]/settings/profile/page.tsx
Normal file
68
src/app/[locale]/settings/profile/page.tsx
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { getTranslations } from "next-intl/server";
|
||||||
|
import { getSessionUser } from "@/lib/session";
|
||||||
|
import { getHumanUserDetail } from "@/lib/zitadel";
|
||||||
|
import { ProfileSettingsForm } from "@/components/settings/profile-form";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* /settings/profile — every authenticated user can edit their own
|
||||||
|
* first + last name. Email is shown read-only; changing it requires
|
||||||
|
* verification and is left to ZITADEL's own self-service flow.
|
||||||
|
*
|
||||||
|
* Personal vs company accounts:
|
||||||
|
* - Both can edit their first/last name in ZITADEL.
|
||||||
|
* - Personal accounts get an extra hint: editing the ZITADEL name
|
||||||
|
* does NOT change how the customer's name appears on invoices.
|
||||||
|
* Invoice identity is in org_billing.company_name (the "Full
|
||||||
|
* name" field on /settings/billing) and is intentionally
|
||||||
|
* editable separately, because legal/billing identity may not
|
||||||
|
* match preferred display identity.
|
||||||
|
* - Company accounts see an org-membership hint instead.
|
||||||
|
*
|
||||||
|
* Server-fetches the current profile from ZITADEL via the
|
||||||
|
* service-account PAT so the form starts with the canonical values
|
||||||
|
* rather than whatever happens to be in the JWT (the JWT name might
|
||||||
|
* be stale if the user updated their name in ZITADEL Console).
|
||||||
|
*/
|
||||||
|
export default async function ProfileSettingsPage() {
|
||||||
|
const user = await getSessionUser();
|
||||||
|
if (!user) redirect("/login");
|
||||||
|
|
||||||
|
const t = await getTranslations("settingsProfile");
|
||||||
|
|
||||||
|
let initial = { firstName: "", lastName: "", email: user.email };
|
||||||
|
try {
|
||||||
|
const profile = await getHumanUserDetail(user.id);
|
||||||
|
initial = {
|
||||||
|
firstName: profile.givenName,
|
||||||
|
lastName: profile.familyName,
|
||||||
|
email: profile.email || user.email,
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
// Identity provider unreachable: render the form with whatever
|
||||||
|
// we know from the session. The session has a combined `name`,
|
||||||
|
// not split parts, so we leave first/last empty and let the user
|
||||||
|
// re-enter. Server logs catch the underlying failure.
|
||||||
|
console.error("ProfileSettingsPage: getHumanUserDetail failed:", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="max-w-3xl mx-auto px-6 py-8">
|
||||||
|
<div className="mb-8 animate-in">
|
||||||
|
<h1 className="font-display text-2xl font-semibold accent-rule">
|
||||||
|
{t("title")}
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-text-secondary mt-3">
|
||||||
|
{user.isPersonal ? t("subtitlePersonal") : t("subtitle")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="animate-in animate-in-delay-1">
|
||||||
|
<ProfileSettingsForm
|
||||||
|
initial={initial}
|
||||||
|
isPersonal={user.isPersonal}
|
||||||
|
orgName={user.orgName}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
51
src/app/api/billing/auto-charge/route.ts
Normal file
51
src/app/api/billing/auto-charge/route.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { getSessionUser } from "@/lib/session";
|
||||||
|
import { setAutoChargeEnabled } from "@/lib/db";
|
||||||
|
import { safeError } from "@/lib/errors";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/billing/auto-charge
|
||||||
|
*
|
||||||
|
* Phase 9. Toggle the auto_charge_enabled flag on the caller's
|
||||||
|
* org. The body is `{ enabled: boolean }`.
|
||||||
|
*
|
||||||
|
* When OFF: invoices issued for this org won't trigger an
|
||||||
|
* auto-charge against the saved card. The customer pays
|
||||||
|
* manually (or admin marks paid) — same flow as a bank-transfer
|
||||||
|
* customer.
|
||||||
|
*
|
||||||
|
* When ON: future invoice issuance attempts the auto-charge.
|
||||||
|
* No effect if there's no saved card on file.
|
||||||
|
*
|
||||||
|
* Idempotent: setting OFF on an already-OFF flag is a no-op
|
||||||
|
* (same outcome).
|
||||||
|
*/
|
||||||
|
|
||||||
|
const bodySchema = z.object({
|
||||||
|
enabled: z.boolean(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const user = await getSessionUser();
|
||||||
|
if (!user) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
const body = await request.json().catch(() => ({}));
|
||||||
|
const parsed = bodySchema.safeParse(body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Invalid request", details: parsed.error.flatten() },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await setAutoChargeEnabled(user.orgId, parsed.data.enabled);
|
||||||
|
return NextResponse.json({ enabled: parsed.data.enabled });
|
||||||
|
} catch (e) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: safeError(e, "Failed to update auto-charge setting") },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
71
src/app/api/billing/setup-card/route.ts
Normal file
71
src/app/api/billing/setup-card/route.ts
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
// Pick the base URL from the request's origin so redirects
|
||||||
|
// work in dev (localhost), staging, and prod without env vars.
|
||||||
|
const origin = new URL(request.url).origin;
|
||||||
|
const session = await createSetupCheckoutSession({
|
||||||
|
customerId,
|
||||||
|
baseUrl: origin,
|
||||||
|
});
|
||||||
|
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",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -252,11 +252,24 @@ export async function POST(request: Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// For follow-up instances, prefer the on-file company name and contact
|
// The audit copy of company name on this request stays inherited
|
||||||
// details; the user can't change those by re-typing them in the wizard.
|
// from the first request in the org — it's a historical snapshot
|
||||||
|
// of the company name at the time the request was created, and
|
||||||
|
// org_billing is now the canonical source for current values.
|
||||||
|
//
|
||||||
|
// Phase 6 fix4: contactName and contactEmail are NOT inherited.
|
||||||
|
// They identify whoever submitted THIS specific request (drives
|
||||||
|
// admin display, support ticket routing, and email greetings).
|
||||||
|
// The previous "prior?.contactName ?? user.name" pattern locked
|
||||||
|
// the contact to whoever first onboarded the org, which broke for
|
||||||
|
// any subsequent submission by a different user — admin saw the
|
||||||
|
// wrong name, support emails went to the wrong person, and the
|
||||||
|
// actual submitter had no way to correct it because the wizard
|
||||||
|
// doesn't expose a contact-name input. The fix is simply to use
|
||||||
|
// the current session user every time.
|
||||||
const companyName = prior?.companyName ?? user.orgName;
|
const companyName = prior?.companyName ?? user.orgName;
|
||||||
const contactName = prior?.contactName ?? user.name;
|
const contactName = user.name;
|
||||||
const contactEmail = prior?.contactEmail ?? user.email;
|
const contactEmail = user.email;
|
||||||
|
|
||||||
// Bug 35: org-scoped billing.
|
// Bug 35: org-scoped billing.
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -22,6 +22,10 @@ import { getOrgBilling, upsertOrgBilling } from "@/lib/db";
|
|||||||
|
|
||||||
const upsertSchema = z.object({
|
const upsertSchema = z.object({
|
||||||
companyName: z.string().trim().min(1).max(200),
|
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),
|
streetAddress: z.string().trim().min(1).max(200),
|
||||||
postalCode: z.string().trim().min(1).max(20),
|
postalCode: z.string().trim().min(1).max(20),
|
||||||
city: z.string().trim().min(1).max(100),
|
city: z.string().trim().min(1).max(100),
|
||||||
@@ -73,6 +77,7 @@ export async function PUT(request: Request) {
|
|||||||
const billing = await upsertOrgBilling({
|
const billing = await upsertOrgBilling({
|
||||||
zitadelOrgId: user.orgId,
|
zitadelOrgId: user.orgId,
|
||||||
companyName: data.companyName,
|
companyName: data.companyName,
|
||||||
|
contactName: data.contactName ?? null,
|
||||||
streetAddress: data.streetAddress,
|
streetAddress: data.streetAddress,
|
||||||
postalCode: data.postalCode,
|
postalCode: data.postalCode,
|
||||||
city: data.city,
|
city: data.city,
|
||||||
|
|||||||
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,21 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import type Stripe from "stripe";
|
import type Stripe from "stripe";
|
||||||
import { getStripeClient, getWebhookSecret } from "@/lib/stripe";
|
|
||||||
import {
|
import {
|
||||||
|
getPaymentMethodDisplay,
|
||||||
|
getStripeClient,
|
||||||
|
getWebhookSecret,
|
||||||
|
} from "@/lib/stripe";
|
||||||
|
import {
|
||||||
|
getInvoiceByStripePaymentIntent,
|
||||||
|
getOrgIdByStripeCustomerId,
|
||||||
|
isStripeRefundRecorded,
|
||||||
markInvoicePaid,
|
markInvoicePaid,
|
||||||
markStripeEventProcessed,
|
markStripeEventProcessed,
|
||||||
setInvoiceStripePaymentIntent,
|
setInvoiceStripePaymentIntent,
|
||||||
|
setSavedPaymentMethod,
|
||||||
tryRecordStripeEvent,
|
tryRecordStripeEvent,
|
||||||
} from "@/lib/db";
|
} from "@/lib/db";
|
||||||
|
import { refundInvoice, RefundNotAllowedError } from "@/lib/billing";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /api/stripe/webhook
|
* POST /api/stripe/webhook
|
||||||
@@ -158,6 +167,14 @@ export async function POST(request: Request) {
|
|||||||
async function handleCheckoutCompleted(
|
async function handleCheckoutCompleted(
|
||||||
session: Stripe.Checkout.Session
|
session: Stripe.Checkout.Session
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
// Phase 9: setup-mode sessions don't pay anything — they
|
||||||
|
// authorize a card for off-session future charges. The
|
||||||
|
// PaymentMethod is attached to the customer and the session's
|
||||||
|
// setup_intent.payment_method holds the id we save.
|
||||||
|
if (session.mode === "setup") {
|
||||||
|
await handleSetupCompleted(session);
|
||||||
|
return;
|
||||||
|
}
|
||||||
// Defensive: paid sessions are what we want; sessions can also
|
// Defensive: paid sessions are what we want; sessions can also
|
||||||
// complete in "unpaid" state (rare for mode=payment, more common
|
// complete in "unpaid" state (rare for mode=payment, more common
|
||||||
// for async/delayed methods like SEPA). Only flip the invoice
|
// for async/delayed methods like SEPA). Only flip the invoice
|
||||||
@@ -208,16 +225,197 @@ async function handleCheckoutCompleted(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleChargeRefunded(charge: Stripe.Charge): Promise<void> {
|
/**
|
||||||
// v1 scope: log only. Refunds always go through Stripe → admin
|
* Phase 9: handle setup-mode Checkout completion. The customer
|
||||||
// initiates them in the dashboard. Updating our invoice status
|
* authorized a card for future off-session charges; persist the
|
||||||
// to 'void' or partial-credit needs more product thinking
|
* display fields against their org so the portal can show the
|
||||||
// (partial refunds? credit notes? VAT corrections?). Phase 7.
|
* 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(
|
console.log(
|
||||||
`Charge ${charge.id} refunded (amount ${charge.amount_refunded} ${charge.currency}); no portal-side state change.`
|
`Saved PaymentMethod ${paymentMethodId} (${display.brand} ${display.last4}) for org ${orgId}.`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleChargeRefunded(charge: Stripe.Charge): Promise<void> {
|
||||||
|
// Phase 7: mirror Stripe refunds into the portal so credit notes
|
||||||
|
// are issued for refunds initiated in the Stripe Dashboard. For
|
||||||
|
// refunds initiated via /api/admin/.../refund, this handler is a
|
||||||
|
// no-op (each refund's stripe_refund_id is already recorded
|
||||||
|
// before the webhook lands — refundInvoice records it
|
||||||
|
// synchronously after the Stripe API call).
|
||||||
|
//
|
||||||
|
// A charge can have multiple refund objects (multiple partial
|
||||||
|
// refunds against the same charge accumulate here). We iterate
|
||||||
|
// and process any that aren't yet recorded in our DB.
|
||||||
|
const paymentIntentId =
|
||||||
|
typeof charge.payment_intent === "string"
|
||||||
|
? charge.payment_intent
|
||||||
|
: charge.payment_intent?.id;
|
||||||
|
if (!paymentIntentId) {
|
||||||
|
console.error(
|
||||||
|
`charge.refunded for charge ${charge.id} has no payment_intent; cannot link to invoice.`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const invoice = await getInvoiceByStripePaymentIntent(paymentIntentId);
|
||||||
|
if (!invoice) {
|
||||||
|
console.error(
|
||||||
|
`charge.refunded for payment_intent ${paymentIntentId} has no matching invoice; ignoring.`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const refundsList = charge.refunds?.data ?? [];
|
||||||
|
if (refundsList.length === 0) {
|
||||||
|
// Some charge.refunded events fire with the refunds list
|
||||||
|
// collapsed (the object includes the aggregated amount_refunded
|
||||||
|
// but the data array can be omitted depending on Stripe's
|
||||||
|
// expansion choices). In that case there's nothing for us to
|
||||||
|
// iterate over here; the actual `refund.created` /
|
||||||
|
// `refund.updated` events carry per-refund detail and we'd need
|
||||||
|
// to enable those in Stripe to handle them. For v1 we log and
|
||||||
|
// rely on the in-portal admin path (refundInvoice) being the
|
||||||
|
// only refund initiator.
|
||||||
|
console.log(
|
||||||
|
`charge.refunded for charge ${charge.id} arrived without refund objects in data; in-portal flow assumed.`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const refund of refundsList) {
|
||||||
|
try {
|
||||||
|
// Idempotency: skip refunds we already recorded (either via
|
||||||
|
// portal admin action or a prior webhook delivery).
|
||||||
|
if (await isStripeRefundRecorded(refund.id)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const amountChf = (refund.amount ?? 0) / 100;
|
||||||
|
if (amountChf <= 0) continue;
|
||||||
|
// Map Stripe refund status to ours. Anything other than the
|
||||||
|
// canonical four falls through to 'pending' so we don't lose
|
||||||
|
// the record entirely.
|
||||||
|
let status: "pending" | "succeeded" | "failed" | "canceled" = "pending";
|
||||||
|
if (refund.status === "succeeded") status = "succeeded";
|
||||||
|
else if (refund.status === "failed") status = "failed";
|
||||||
|
else if (refund.status === "canceled") status = "canceled";
|
||||||
|
// For refunds that originated in Stripe Dashboard we don't
|
||||||
|
// have a reason to display. Use a sentinel string so the
|
||||||
|
// credit note PDF has something to print. Admin can edit
|
||||||
|
// post-hoc if needed (no UI for that today, but the DB row
|
||||||
|
// is reachable).
|
||||||
|
const reason = refund.reason
|
||||||
|
? `Stripe Dashboard: ${refund.reason}`
|
||||||
|
: "Refund issued via Stripe Dashboard";
|
||||||
|
// refundInvoice with existingStripeRefund: don't call Stripe
|
||||||
|
// again (we'd error since the refund already exists), just
|
||||||
|
// mirror the record into our DB and issue the credit note.
|
||||||
|
await refundInvoice({
|
||||||
|
invoiceId: invoice.id,
|
||||||
|
amountChf,
|
||||||
|
reason,
|
||||||
|
refundedBy: "stripe-webhook",
|
||||||
|
existingStripeRefund: { id: refund.id, status },
|
||||||
|
});
|
||||||
|
console.log(
|
||||||
|
`Mirrored Stripe refund ${refund.id} for invoice ${invoice.invoiceNumber} (CHF ${amountChf.toFixed(2)}).`
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof RefundNotAllowedError) {
|
||||||
|
// The invoice was already fully refunded by an earlier
|
||||||
|
// webhook delivery or by an in-portal action. That's fine.
|
||||||
|
console.log(
|
||||||
|
`Stripe refund ${refund.id}: ${e.message} (already accounted for).`
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// For any other error, log but continue to the next refund —
|
||||||
|
// we don't want one bad refund to block the rest.
|
||||||
|
console.error(
|
||||||
|
`Failed to mirror Stripe refund ${refund.id} for invoice ${invoice.invoiceNumber}:`,
|
||||||
|
e
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function handlePaymentFailed(
|
async function handlePaymentFailed(
|
||||||
intent: Stripe.PaymentIntent
|
intent: Stripe.PaymentIntent
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
|||||||
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 { useRouter } from "next/navigation";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { Card, CardHeader } from "@/components/ui/card";
|
import { Card, CardHeader } from "@/components/ui/card";
|
||||||
import type { InvoiceDetail, InvoiceStatus } from "@/types";
|
import type { CreditNote, InvoiceDetail, InvoiceStatus } from "@/types";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
detail: InvoiceDetail;
|
detail: InvoiceDetail;
|
||||||
|
/**
|
||||||
|
* Phase 7: credit notes linked to this invoice (voids + refunds).
|
||||||
|
* Empty array when none. Passed from the server page; client
|
||||||
|
* doesn't re-fetch — router.refresh() rebuilds after actions.
|
||||||
|
*/
|
||||||
|
creditNotes?: CreditNote[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Renders the invoice header (status, totals, action bar) then
|
* Renders the invoice header (status, totals, action bar) then
|
||||||
* line items grouped by tenant, then billing snapshot. Actions are
|
* line items grouped by tenant, then billing snapshot. Actions are
|
||||||
* mark-paid (POST), delete (DELETE), PDF download (link to /pdf).
|
* mark-paid (POST), void (POST), refund (POST), delete (DELETE),
|
||||||
|
* PDF download (link to /pdf).
|
||||||
|
*
|
||||||
|
* Phase 7 adds void + refund. The action bar shows:
|
||||||
|
* - status open/overdue → Mark paid, Void, Delete
|
||||||
|
* - status paid → Refund, Delete
|
||||||
|
* - status partially_refunded → Refund (for remainder), Delete
|
||||||
|
* - status fully_refunded / void → Delete only (read-only otherwise)
|
||||||
*
|
*
|
||||||
* On successful action we router.refresh() — the server-side page
|
* On successful action we router.refresh() — the server-side page
|
||||||
* re-renders against the new DB state. For delete we navigate
|
* re-renders against the new DB state, including any new credit
|
||||||
* away first.
|
* notes.
|
||||||
*/
|
*/
|
||||||
export function InvoiceDetailView({ detail }: Props) {
|
export function InvoiceDetailView({ detail, creditNotes = [] }: Props) {
|
||||||
const t = useTranslations("adminBilling");
|
const t = useTranslations("adminBilling");
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { invoice, lines } = detail;
|
const { invoice, lines } = detail;
|
||||||
|
|
||||||
const [busyAction, setBusyAction] = useState<null | "mark-paid" | "delete">(
|
const [busyAction, setBusyAction] = useState<
|
||||||
null
|
null | "mark-paid" | "delete" | "void" | "refund"
|
||||||
);
|
>(null);
|
||||||
const [actionError, setActionError] = useState("");
|
const [actionError, setActionError] = useState("");
|
||||||
const [noteInput, setNoteInput] = useState("");
|
const [noteInput, setNoteInput] = useState("");
|
||||||
const [noteOpen, setNoteOpen] = useState(false);
|
const [noteOpen, setNoteOpen] = useState(false);
|
||||||
|
|
||||||
|
// Phase 7 — void modal state
|
||||||
|
const [voidOpen, setVoidOpen] = useState(false);
|
||||||
|
const [voidReason, setVoidReason] = useState("");
|
||||||
|
|
||||||
|
// Phase 7 — refund modal state. Amount defaults to the full
|
||||||
|
// remaining refundable on open.
|
||||||
|
const [refundOpen, setRefundOpen] = useState(false);
|
||||||
|
const [refundAmount, setRefundAmount] = useState("");
|
||||||
|
const [refundReason, setRefundReason] = useState("");
|
||||||
|
|
||||||
|
const remainingRefundable =
|
||||||
|
Math.round(
|
||||||
|
(invoice.totalChf - invoice.refundedTotalChf) * 100
|
||||||
|
) / 100;
|
||||||
|
|
||||||
const markPaid = async () => {
|
const markPaid = async () => {
|
||||||
setActionError("");
|
setActionError("");
|
||||||
setBusyAction("mark-paid");
|
setBusyAction("mark-paid");
|
||||||
@@ -75,6 +103,84 @@ export function InvoiceDetailView({ detail }: Props) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Phase 7 — void: marks an unpaid invoice as cancelled and issues
|
||||||
|
// a credit note. Backend rejects if the invoice is paid (use
|
||||||
|
// refund) or already voided/refunded.
|
||||||
|
const voidInvoice = async () => {
|
||||||
|
if (!voidReason.trim()) {
|
||||||
|
setActionError(t("voidReasonRequired"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setActionError("");
|
||||||
|
setBusyAction("void");
|
||||||
|
try {
|
||||||
|
const res = await fetch(
|
||||||
|
`/api/admin/billing/invoices/${invoice.id}/void`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ reason: voidReason }),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const j = await res.json().catch(() => ({}));
|
||||||
|
if (!res.ok) throw new Error(j.error || `HTTP ${res.status}`);
|
||||||
|
setVoidOpen(false);
|
||||||
|
setVoidReason("");
|
||||||
|
router.refresh();
|
||||||
|
} catch (e: any) {
|
||||||
|
setActionError(e.message);
|
||||||
|
} finally {
|
||||||
|
setBusyAction(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Phase 7 — refund: paid invoices only. Amount may be partial;
|
||||||
|
// backend caps at remaining refundable.
|
||||||
|
const refundInvoice = async () => {
|
||||||
|
const amt = parseFloat(refundAmount);
|
||||||
|
if (!isFinite(amt) || amt <= 0) {
|
||||||
|
setActionError(t("refundAmountInvalid"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (amt - remainingRefundable > 0.005) {
|
||||||
|
setActionError(
|
||||||
|
t("refundAmountExceeds", {
|
||||||
|
max: remainingRefundable.toFixed(2),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!refundReason.trim()) {
|
||||||
|
setActionError(t("refundReasonRequired"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setActionError("");
|
||||||
|
setBusyAction("refund");
|
||||||
|
try {
|
||||||
|
const res = await fetch(
|
||||||
|
`/api/admin/billing/invoices/${invoice.id}/refund`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
amountChf: Math.round(amt * 100) / 100,
|
||||||
|
reason: refundReason,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const j = await res.json().catch(() => ({}));
|
||||||
|
if (!res.ok) throw new Error(j.error || `HTTP ${res.status}`);
|
||||||
|
setRefundOpen(false);
|
||||||
|
setRefundAmount("");
|
||||||
|
setRefundReason("");
|
||||||
|
router.refresh();
|
||||||
|
} catch (e: any) {
|
||||||
|
setActionError(e.message);
|
||||||
|
} finally {
|
||||||
|
setBusyAction(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Group lines by tenant for display (matches PDF layout).
|
// Group lines by tenant for display (matches PDF layout).
|
||||||
const linesByTenant = new Map<string | null, typeof lines>();
|
const linesByTenant = new Map<string | null, typeof lines>();
|
||||||
for (const ln of lines) {
|
for (const ln of lines) {
|
||||||
@@ -97,10 +203,14 @@ export function InvoiceDetailView({ detail }: Props) {
|
|||||||
</h1>
|
</h1>
|
||||||
<div className="flex items-center gap-3 mt-3 text-sm">
|
<div className="flex items-center gap-3 mt-3 text-sm">
|
||||||
<StatusPill status={invoice.status} />
|
<StatusPill status={invoice.status} />
|
||||||
<span className="text-text-muted">
|
{invoice.periodStart && invoice.periodEnd && (
|
||||||
{invoice.periodStart} → {invoice.periodEnd}
|
<>
|
||||||
</span>
|
<span className="text-text-muted">
|
||||||
<span className="text-text-muted">·</span>
|
{invoice.periodStart} → {invoice.periodEnd}
|
||||||
|
</span>
|
||||||
|
<span className="text-text-muted">·</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<span className="text-text-muted">
|
<span className="text-text-muted">
|
||||||
{t("dueOnLabel")}: {invoice.dueAt}
|
{t("dueOnLabel")}: {invoice.dueAt}
|
||||||
</span>
|
</span>
|
||||||
@@ -171,6 +281,144 @@ export function InvoiceDetailView({ detail }: Props) {
|
|||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
{/* Phase 7 — Void: visible only for open/overdue invoices.
|
||||||
|
Same gating as Mark Paid but mutually exclusive with it
|
||||||
|
via the chosen action. Opens a small inline form so
|
||||||
|
the admin can enter a reason; reason is required and
|
||||||
|
lands on the credit-note PDF. */}
|
||||||
|
{(invoice.status === "open" || invoice.status === "overdue") && (
|
||||||
|
<>
|
||||||
|
{!voidOpen ? (
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setVoidOpen(true);
|
||||||
|
setNoteOpen(false);
|
||||||
|
setRefundOpen(false);
|
||||||
|
}}
|
||||||
|
disabled={busyAction !== null}
|
||||||
|
className="px-4 py-2 rounded-md border border-error text-error text-sm disabled:opacity-50 hover:bg-error/10"
|
||||||
|
>
|
||||||
|
{t("voidBtn")}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center gap-2 flex-grow">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder={t("voidReasonPlaceholder")}
|
||||||
|
value={voidReason}
|
||||||
|
onChange={(e) => setVoidReason(e.target.value)}
|
||||||
|
maxLength={500}
|
||||||
|
className="flex-grow px-3 py-1.5 rounded-md border border-border bg-surface-2 text-sm"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={voidInvoice}
|
||||||
|
disabled={busyAction !== null}
|
||||||
|
className="px-3 py-1.5 rounded-md bg-error text-white text-sm disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{busyAction === "void" ? t("saving") : t("confirmVoid")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setVoidOpen(false);
|
||||||
|
setVoidReason("");
|
||||||
|
}}
|
||||||
|
className="px-3 py-1.5 rounded-md border border-border text-sm"
|
||||||
|
>
|
||||||
|
{t("cancel")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{/* Phase 7 — Refund: paid invoices, including ones already
|
||||||
|
partially refunded (as long as some refundable amount
|
||||||
|
remains). Opens an inline form with amount + reason.
|
||||||
|
The remaining-refundable hint helps admin pick the
|
||||||
|
right number. */}
|
||||||
|
{(invoice.status === "paid" ||
|
||||||
|
invoice.status === "partially_refunded") &&
|
||||||
|
remainingRefundable > 0 && (
|
||||||
|
<>
|
||||||
|
{!refundOpen ? (
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setRefundOpen(true);
|
||||||
|
setNoteOpen(false);
|
||||||
|
setVoidOpen(false);
|
||||||
|
setRefundAmount(remainingRefundable.toFixed(2));
|
||||||
|
}}
|
||||||
|
disabled={busyAction !== null}
|
||||||
|
className="px-4 py-2 rounded-md border border-error text-error text-sm disabled:opacity-50 hover:bg-error/10"
|
||||||
|
>
|
||||||
|
{t("refundBtn")}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-2 flex-grow">
|
||||||
|
<div className="text-xs text-text-muted">
|
||||||
|
{t("refundRemainingHint", {
|
||||||
|
max: remainingRefundable.toFixed(2),
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4 flex-wrap">
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<label className="text-[10px] uppercase tracking-wider text-text-muted">
|
||||||
|
{t("refundAmountLabel")}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
min="0.01"
|
||||||
|
max={remainingRefundable}
|
||||||
|
placeholder="CHF"
|
||||||
|
value={refundAmount}
|
||||||
|
onChange={(e) => setRefundAmount(e.target.value)}
|
||||||
|
className="w-32 px-3 py-1.5 rounded-md border border-border bg-surface-2 text-sm font-mono"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<span className="text-[10px] text-text-muted italic">
|
||||||
|
{t("refundAmountInclVatHint")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1 flex-grow min-w-[200px]">
|
||||||
|
<label className="text-[10px] uppercase tracking-wider text-text-muted">
|
||||||
|
{t("refundReasonLabel")}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder={t("refundReasonPlaceholder")}
|
||||||
|
value={refundReason}
|
||||||
|
onChange={(e) => setRefundReason(e.target.value)}
|
||||||
|
maxLength={500}
|
||||||
|
className="w-full px-3 py-1.5 rounded-md border border-border bg-surface-2 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 self-end">
|
||||||
|
<button
|
||||||
|
onClick={refundInvoice}
|
||||||
|
disabled={busyAction !== null}
|
||||||
|
className="px-3 py-1.5 rounded-md bg-error text-white text-sm disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{busyAction === "refund"
|
||||||
|
? t("saving")
|
||||||
|
: t("confirmRefund")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setRefundOpen(false);
|
||||||
|
setRefundAmount("");
|
||||||
|
setRefundReason("");
|
||||||
|
}}
|
||||||
|
className="px-3 py-1.5 rounded-md border border-border text-sm"
|
||||||
|
>
|
||||||
|
{t("cancel")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={deleteInvoice}
|
onClick={deleteInvoice}
|
||||||
disabled={busyAction !== null}
|
disabled={busyAction !== null}
|
||||||
@@ -189,8 +437,90 @@ export function InvoiceDetailView({ detail }: Props) {
|
|||||||
{invoice.paidMethodDetail}
|
{invoice.paidMethodDetail}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{/* Phase 7 — void/refund summary lines, shown when applicable.
|
||||||
|
Surfaces the auditing context that the columns alone don't
|
||||||
|
(who voided, what the reason was, how much has been
|
||||||
|
refunded vs how much remains). */}
|
||||||
|
{invoice.voidedAt && (
|
||||||
|
<div className="mt-3 text-xs text-text-muted">
|
||||||
|
{t("voidedOnLabel")}: {invoice.voidedAt} · {invoice.voidedBy}
|
||||||
|
{invoice.voidReason ? ` · ${invoice.voidReason}` : ""}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{invoice.refundedTotalChf > 0 && (
|
||||||
|
<div className="mt-3 text-xs text-text-muted">
|
||||||
|
{t("refundedTotalLabel")}: CHF{" "}
|
||||||
|
{invoice.refundedTotalChf.toFixed(2)} ·{" "}
|
||||||
|
{t("refundedRemainingLabel")}: CHF{" "}
|
||||||
|
{remainingRefundable.toFixed(2)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{/* Phase 7 — linked credit notes panel. Hidden when there are
|
||||||
|
none (most invoices). When present, lists each credit note
|
||||||
|
with kind, amount, reason, issued date, and PDF download. */}
|
||||||
|
{creditNotes.length > 0 && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>{t("creditNotesPanelTitle")}</CardHeader>
|
||||||
|
<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 */}
|
{/* Lines */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>{t("lineItemsTitle")}</CardHeader>
|
<CardHeader>{t("lineItemsTitle")}</CardHeader>
|
||||||
@@ -296,7 +626,9 @@ function StatusPill({ status }: { status: InvoiceStatus }) {
|
|||||||
? "bg-error/15 text-error"
|
? "bg-error/15 text-error"
|
||||||
: status === "void" || status === "uncollectible"
|
: status === "void" || status === "uncollectible"
|
||||||
? "bg-text-muted/15 text-text-muted"
|
? "bg-text-muted/15 text-text-muted"
|
||||||
: "bg-accent/15 text-accent";
|
: status === "partially_refunded" || status === "fully_refunded"
|
||||||
|
? "bg-error/15 text-error"
|
||||||
|
: "bg-accent/15 text-accent";
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
className={`inline-block px-2 py-0.5 rounded text-xs font-medium ${color}`}
|
className={`inline-block px-2 py-0.5 rounded text-xs font-medium ${color}`}
|
||||||
|
|||||||
@@ -100,6 +100,23 @@ export function InvoicesTable({ initialInvoices }: Props) {
|
|||||||
{t("loading")}
|
{t("loading")}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
{/* Phase 8: shortcuts to the custom-invoice flow. The
|
||||||
|
Drafts link is muted because most of the time it's
|
||||||
|
empty; New invoice is the prominent CTA. */}
|
||||||
|
<div className={`flex items-center gap-3 ${busy ? "" : "ml-auto"}`}>
|
||||||
|
<Link
|
||||||
|
href="/admin/billing/invoice-drafts"
|
||||||
|
className="text-xs text-text-muted hover:underline"
|
||||||
|
>
|
||||||
|
{t("draftsLink")}
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="/admin/billing/invoices/new"
|
||||||
|
className="px-3 py-1.5 rounded-md bg-accent text-white text-sm"
|
||||||
|
>
|
||||||
|
+ {t("newInvoiceBtn")}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
@@ -142,7 +159,11 @@ export function InvoicesTable({ initialInvoices }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="py-2 text-xs font-mono">
|
<td className="py-2 text-xs font-mono">
|
||||||
{inv.periodStart.slice(0, 7)}
|
{inv.periodStart
|
||||||
|
? inv.periodStart.slice(0, 7)
|
||||||
|
: inv.source === "custom"
|
||||||
|
? "—"
|
||||||
|
: ""}
|
||||||
</td>
|
</td>
|
||||||
<td className="py-2">
|
<td className="py-2">
|
||||||
<StatusPill status={inv.status} />
|
<StatusPill status={inv.status} />
|
||||||
|
|||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
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)}
|
{t(`status.${invoice.status}` as any)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-text-secondary">
|
{invoice.periodStart && invoice.periodEnd && (
|
||||||
{fmt.dateTime(new Date(invoice.periodStart), { dateStyle: "long" })}
|
<p className="text-sm text-text-secondary">
|
||||||
<span className="text-text-muted mx-1">→</span>
|
{fmt.dateTime(new Date(invoice.periodStart), {
|
||||||
{fmt.dateTime(new Date(invoice.periodEnd), { dateStyle: "long" })}
|
dateStyle: "long",
|
||||||
</p>
|
})}
|
||||||
|
<span className="text-text-muted mx-1">→</span>
|
||||||
|
{fmt.dateTime(new Date(invoice.periodEnd), {
|
||||||
|
dateStyle: "long",
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-start gap-2 flex-wrap">
|
<div className="flex items-start gap-2 flex-wrap">
|
||||||
{/* Phase 4: Pay-with-card available for open + overdue.
|
{/* Phase 4: Pay-with-card available for open + overdue.
|
||||||
|
|||||||
@@ -12,6 +12,13 @@ const statusColors: Record<string, string> = {
|
|||||||
paid: "text-success bg-success/10",
|
paid: "text-success bg-success/10",
|
||||||
overdue: "text-error bg-error/10",
|
overdue: "text-error bg-error/10",
|
||||||
void: "text-text-muted bg-surface-3 line-through",
|
void: "text-text-muted bg-surface-3 line-through",
|
||||||
|
// Phase 7: refund states. Red tinting matches the credit-note
|
||||||
|
// PDF accent so customers reading the table get a visual cue
|
||||||
|
// that something was credited back. partially_refunded reads
|
||||||
|
// as a partial state (mixed colour), fully_refunded reads as
|
||||||
|
// closed (line-through like void).
|
||||||
|
partially_refunded: "text-error bg-error/10",
|
||||||
|
fully_refunded: "text-text-muted bg-error/10 line-through",
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -64,9 +71,19 @@ export function CustomerInvoiceList({ invoices }: Props) {
|
|||||||
</Link>
|
</Link>
|
||||||
</td>
|
</td>
|
||||||
<td className="py-2 text-xs text-text-secondary">
|
<td className="py-2 text-xs text-text-secondary">
|
||||||
{fmt.dateTime(new Date(inv.periodStart), { dateStyle: "medium" })}
|
{inv.periodStart && inv.periodEnd ? (
|
||||||
<span className="text-text-muted mx-1">→</span>
|
<>
|
||||||
{fmt.dateTime(new Date(inv.periodEnd), { dateStyle: "medium" })}
|
{fmt.dateTime(new Date(inv.periodStart), {
|
||||||
|
dateStyle: "medium",
|
||||||
|
})}
|
||||||
|
<span className="text-text-muted mx-1">→</span>
|
||||||
|
{fmt.dateTime(new Date(inv.periodEnd), {
|
||||||
|
dateStyle: "medium",
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span className="text-text-muted">—</span>
|
||||||
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="py-2 text-xs text-text-secondary">
|
<td className="py-2 text-xs text-text-secondary">
|
||||||
{fmt.dateTime(new Date(inv.dueAt), { dateStyle: "medium" })}
|
{fmt.dateTime(new Date(inv.dueAt), { dateStyle: "medium" })}
|
||||||
|
|||||||
@@ -125,9 +125,16 @@ export function RunningTotalWidget({ isOwner }: Props) {
|
|||||||
}
|
}
|
||||||
// draft
|
// draft
|
||||||
const draft = data.draft;
|
const draft = data.draft;
|
||||||
const periodLabel = `${fmt.dateTime(new Date(draft.periodStart), {
|
// Phase 8: InvoiceDraft.periodStart/End became nullable for the
|
||||||
dateStyle: "long",
|
// custom-invoice flow. The running-total widget only renders the
|
||||||
})} → ${fmt.dateTime(new Date(draft.periodEnd), { dateStyle: "long" })}`;
|
// auto-cron draft (always has a period), so the null branch is
|
||||||
|
// defensive — if we ever did hit it the label just collapses.
|
||||||
|
const periodLabel =
|
||||||
|
draft.periodStart && draft.periodEnd
|
||||||
|
? `${fmt.dateTime(new Date(draft.periodStart), {
|
||||||
|
dateStyle: "long",
|
||||||
|
})} → ${fmt.dateTime(new Date(draft.periodEnd), { dateStyle: "long" })}`
|
||||||
|
: "";
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<div className="flex items-start justify-between gap-4 flex-wrap mb-3">
|
<div className="flex items-start justify-between gap-4 flex-wrap mb-3">
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { OnboardingWizard } from "./wizard";
|
import { OnboardingWizard } from "./wizard";
|
||||||
|
import type { OrgBilling } from "@/types";
|
||||||
|
|
||||||
interface OnboardingFlowProps {
|
interface OnboardingFlowProps {
|
||||||
orgName: string;
|
orgName: string;
|
||||||
@@ -19,6 +20,12 @@ interface OnboardingFlowProps {
|
|||||||
* /settings/billing.
|
* /settings/billing.
|
||||||
*/
|
*/
|
||||||
hasOrgBilling?: boolean;
|
hasOrgBilling?: boolean;
|
||||||
|
/**
|
||||||
|
* Phase 6 fix3: the actual org_billing record (or null). Drives
|
||||||
|
* the review-step "Billing to" rendering AND the confirm-step
|
||||||
|
* validation skip when the billing step was skipped.
|
||||||
|
*/
|
||||||
|
existingOrgBilling?: OrgBilling | null;
|
||||||
/**
|
/**
|
||||||
* Bug 6: when present, the wizard is rendered in edit mode against
|
* Bug 6: when present, the wizard is rendered in edit mode against
|
||||||
* the given pending request. See `OnboardingWizard` for the full
|
* the given pending request. See `OnboardingWizard` for the full
|
||||||
@@ -45,6 +52,7 @@ export function OnboardingFlow({
|
|||||||
userName,
|
userName,
|
||||||
userEmail,
|
userEmail,
|
||||||
hasOrgBilling,
|
hasOrgBilling,
|
||||||
|
existingOrgBilling,
|
||||||
editingRequest,
|
editingRequest,
|
||||||
}: OnboardingFlowProps) {
|
}: OnboardingFlowProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -55,6 +63,7 @@ export function OnboardingFlow({
|
|||||||
userName={userName}
|
userName={userName}
|
||||||
userEmail={userEmail}
|
userEmail={userEmail}
|
||||||
hasOrgBilling={hasOrgBilling}
|
hasOrgBilling={hasOrgBilling}
|
||||||
|
existingOrgBilling={existingOrgBilling}
|
||||||
editingRequest={editingRequest}
|
editingRequest={editingRequest}
|
||||||
onComplete={() => {
|
onComplete={() => {
|
||||||
// Navigate back to /dashboard and re-fetch on the server. The
|
// Navigate back to /dashboard and re-fetch on the server. The
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
SUPPORTED_COUNTRIES,
|
SUPPORTED_COUNTRIES,
|
||||||
type SupportedCountry,
|
type SupportedCountry,
|
||||||
} from "@/lib/validation";
|
} from "@/lib/validation";
|
||||||
|
import type { OrgBilling } from "@/types";
|
||||||
|
|
||||||
type Step = "welcome" | "configure" | "billing" | "confirm";
|
type Step = "welcome" | "configure" | "billing" | "confirm";
|
||||||
|
|
||||||
@@ -96,6 +97,17 @@ interface WizardProps {
|
|||||||
* fix it before admin approves.
|
* fix it before admin approves.
|
||||||
*/
|
*/
|
||||||
hasOrgBilling?: boolean;
|
hasOrgBilling?: boolean;
|
||||||
|
/**
|
||||||
|
* Phase 6 fix3: the actual org_billing record when one exists.
|
||||||
|
* Used to render real values on the review-step "Billing to" block
|
||||||
|
* (rather than the wizard's empty default config.billingAddress)
|
||||||
|
* AND to skip the confirm-step's client-side validation of
|
||||||
|
* billingAddress — same logic that already strips billingAddress
|
||||||
|
* at submit time. Null when no org_billing row exists yet.
|
||||||
|
* Ignored in edit mode (the editingRequest carries its own
|
||||||
|
* billingAddress snapshot).
|
||||||
|
*/
|
||||||
|
existingOrgBilling?: OrgBilling | null;
|
||||||
/**
|
/**
|
||||||
* Bug 6: when present, the wizard renders in "edit" mode — fields
|
* Bug 6: when present, the wizard renders in "edit" mode — fields
|
||||||
* are pre-populated from the request, the SOUL.md auto-fetch is
|
* are pre-populated from the request, the SOUL.md auto-fetch is
|
||||||
@@ -134,6 +146,7 @@ export function OnboardingWizard({
|
|||||||
userName,
|
userName,
|
||||||
userEmail,
|
userEmail,
|
||||||
hasOrgBilling,
|
hasOrgBilling,
|
||||||
|
existingOrgBilling,
|
||||||
editingRequest,
|
editingRequest,
|
||||||
onComplete,
|
onComplete,
|
||||||
}: WizardProps) {
|
}: WizardProps) {
|
||||||
@@ -319,7 +332,23 @@ export function OnboardingWizard({
|
|||||||
}
|
}
|
||||||
// confirm: validate the union (defence in depth — submit handler
|
// confirm: validate the union (defence in depth — submit handler
|
||||||
// also runs onboardingSchema before POST).
|
// also runs onboardingSchema before POST).
|
||||||
const r = onboardingSchema.safeParse(config);
|
//
|
||||||
|
// Phase 6 fix3: when hasOrgBilling=true AND not editing, the
|
||||||
|
// billing step was skipped and config.billingAddress is the
|
||||||
|
// empty default. zod's .optional() doesn't help here because the
|
||||||
|
// field IS present (empty object), so billingAddressSchema
|
||||||
|
// validates it and fails with required-field errors that the
|
||||||
|
// user has no way to fix — the form to enter the values was
|
||||||
|
// skipped on purpose. Strip the field for validation, matching
|
||||||
|
// the same strip we already do at submit time.
|
||||||
|
const configForValidation =
|
||||||
|
hasOrgBilling && !isEditing
|
||||||
|
? (() => {
|
||||||
|
const { billingAddress: _b, ...rest } = config;
|
||||||
|
return rest;
|
||||||
|
})()
|
||||||
|
: config;
|
||||||
|
const r = onboardingSchema.safeParse(configForValidation);
|
||||||
if (r.success) {
|
if (r.success) {
|
||||||
setErrors({});
|
setErrors({});
|
||||||
return true;
|
return true;
|
||||||
@@ -1101,42 +1130,84 @@ export function OnboardingWizard({
|
|||||||
<ReviewRow
|
<ReviewRow
|
||||||
label={t("reviewBillingTo")}
|
label={t("reviewBillingTo")}
|
||||||
value={
|
value={
|
||||||
<div className="text-text-primary text-right">
|
(() => {
|
||||||
{/* For personal: skip the company line so the
|
// Phase 6 fix3: when the org has billing on file
|
||||||
invoice rendering matches what the user actually
|
// and we're not editing, render the saved
|
||||||
entered. For company: include it as the first
|
// org_billing record (the authoritative source)
|
||||||
line. */}
|
// rather than config.billingAddress, which is the
|
||||||
{!isPersonal &&
|
// wizard's empty default state because the billing
|
||||||
config.billingAddress.company &&
|
// step was skipped. In edit mode, fall back to
|
||||||
config.billingAddress.company.trim().length > 0 && (
|
// config.billingAddress, which is pre-populated
|
||||||
<div>{config.billingAddress.company}</div>
|
// from the request being edited.
|
||||||
)}
|
const useSaved =
|
||||||
<div>{config.billingAddress.street}</div>
|
hasOrgBilling && !isEditing && existingOrgBilling;
|
||||||
<div>
|
const company = useSaved
|
||||||
{config.billingAddress.postalCode}{" "}
|
? existingOrgBilling!.companyName
|
||||||
{config.billingAddress.city}
|
: config.billingAddress.company;
|
||||||
</div>
|
const street = useSaved
|
||||||
<div className="text-text-muted">
|
? existingOrgBilling!.streetAddress
|
||||||
{tCountries(
|
: config.billingAddress.street;
|
||||||
config.billingAddress.country as SupportedCountry
|
const postalCode = useSaved
|
||||||
)}
|
? existingOrgBilling!.postalCode
|
||||||
</div>
|
: config.billingAddress.postalCode;
|
||||||
</div>
|
const city = useSaved
|
||||||
|
? existingOrgBilling!.city
|
||||||
|
: config.billingAddress.city;
|
||||||
|
const country = useSaved
|
||||||
|
? existingOrgBilling!.country
|
||||||
|
: config.billingAddress.country;
|
||||||
|
const contactName = useSaved
|
||||||
|
? existingOrgBilling!.contactName
|
||||||
|
: null;
|
||||||
|
return (
|
||||||
|
<div className="text-text-primary text-right">
|
||||||
|
{/* For personal: skip the company line so the
|
||||||
|
invoice rendering matches what the user actually
|
||||||
|
entered. For company: include it as the first
|
||||||
|
line. */}
|
||||||
|
{!isPersonal &&
|
||||||
|
company &&
|
||||||
|
company.trim().length > 0 && <div>{company}</div>}
|
||||||
|
{/* Phase 6 fix2: optional contact-person line
|
||||||
|
("z.Hd. <name>") only present when the saved
|
||||||
|
org_billing has it set. */}
|
||||||
|
{contactName && contactName.trim().length > 0 && (
|
||||||
|
<div className="text-text-muted">
|
||||||
|
{t("reviewContactPersonPrefix")} {contactName}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div>{street}</div>
|
||||||
|
<div>
|
||||||
|
{postalCode} {city}
|
||||||
|
</div>
|
||||||
|
<div className="text-text-muted">
|
||||||
|
{tCountries(country as SupportedCountry)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
{/* Bug 35: VAT review row. Company customers see this so
|
{/* Bug 35: VAT review row. Company customers see this so
|
||||||
they can verify the VAT id they typed before submitting.
|
they can verify the VAT id they typed before submitting.
|
||||||
Personal customers never see it — they don't have a
|
Personal customers never see it — they don't have a
|
||||||
VAT number, the form didn't ask, the review hides it. */}
|
VAT number, the form didn't ask, the review hides it.
|
||||||
|
Phase 6 fix3: when reading from existingOrgBilling,
|
||||||
|
the value comes from there too. */}
|
||||||
{!isPersonal &&
|
{!isPersonal &&
|
||||||
config.billingAddress.vatNumber &&
|
(() => {
|
||||||
config.billingAddress.vatNumber.trim().length > 0 && (
|
const vat =
|
||||||
<ReviewRow
|
hasOrgBilling && !isEditing && existingOrgBilling
|
||||||
label={t("billingVatNumber")}
|
? existingOrgBilling.vatNumber
|
||||||
value={config.billingAddress.vatNumber}
|
: config.billingAddress.vatNumber;
|
||||||
mono
|
return vat && vat.trim().length > 0 ? (
|
||||||
/>
|
<ReviewRow
|
||||||
)}
|
label={t("billingVatNumber")}
|
||||||
|
value={vat}
|
||||||
|
mono
|
||||||
|
/>
|
||||||
|
) : null;
|
||||||
|
})()}
|
||||||
<ReviewRow
|
<ReviewRow
|
||||||
label={t("reviewContactEmail")}
|
label={t("reviewContactEmail")}
|
||||||
value={userEmail || ""}
|
value={userEmail || ""}
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ export function BillingSettingsForm({ initial, isPersonal }: Props) {
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [form, setForm] = useState({
|
const [form, setForm] = useState({
|
||||||
companyName: initial?.companyName ?? "",
|
companyName: initial?.companyName ?? "",
|
||||||
|
contactName: initial?.contactName ?? "",
|
||||||
streetAddress: initial?.streetAddress ?? "",
|
streetAddress: initial?.streetAddress ?? "",
|
||||||
postalCode: initial?.postalCode ?? "",
|
postalCode: initial?.postalCode ?? "",
|
||||||
city: initial?.city ?? "",
|
city: initial?.city ?? "",
|
||||||
@@ -84,6 +85,10 @@ export function BillingSettingsForm({ initial, isPersonal }: Props) {
|
|||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
companyName: form.companyName.trim(),
|
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(),
|
streetAddress: form.streetAddress.trim(),
|
||||||
postalCode: form.postalCode.trim(),
|
postalCode: form.postalCode.trim(),
|
||||||
city: form.city.trim(),
|
city: form.city.trim(),
|
||||||
@@ -124,6 +129,17 @@ export function BillingSettingsForm({ initial, isPersonal }: Props) {
|
|||||||
className="w-full px-3 py-2 rounded-md bg-surface-2 border border-border focus:border-accent focus:outline-none text-sm"
|
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>
|
||||||
|
{!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>
|
<Field label={t("streetAddressLabel")} required>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
|
|||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
260
src/components/settings/saved-card-section.tsx
Normal file
260
src/components/settings/saved-card-section.tsx
Normal file
@@ -0,0 +1,260 @@
|
|||||||
|
"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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 }: Props) {
|
||||||
|
const t = useTranslations("settingsBilling");
|
||||||
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const [busy, setBusy] = useState<null | "setup" | "remove" | "toggle">(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);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleAutoCharge = async () => {
|
||||||
|
setError("");
|
||||||
|
setBusy("toggle");
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/billing/auto-charge", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ enabled: !autoChargeOn }),
|
||||||
|
});
|
||||||
|
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>
|
||||||
|
{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>
|
||||||
|
<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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{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={toggleAutoCharge}
|
||||||
|
disabled={busy !== null}
|
||||||
|
className="px-3 py-1.5 rounded-md border border-border text-sm disabled:opacity-50 hover:bg-surface-3"
|
||||||
|
>
|
||||||
|
{busy === "toggle"
|
||||||
|
? t("saving")
|
||||||
|
: autoChargeOn
|
||||||
|
? t("savedCardDisableAutoChargeBtn")
|
||||||
|
: t("savedCardEnableAutoChargeBtn")}
|
||||||
|
</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>
|
||||||
|
|
||||||
|
<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: {
|
callbacks: {
|
||||||
async jwt({ token, account, profile }) {
|
async jwt({ token, account, profile, trigger, session }) {
|
||||||
|
// Phase 6 fix5: client-side `useSession().update({ name })` calls
|
||||||
|
// route through this branch. We trust the new value because the
|
||||||
|
// PUT /api/settings/profile route already wrote it to ZITADEL
|
||||||
|
// and re-fetched the canonical displayName before returning.
|
||||||
|
// The session callback reads token.name directly (see below) so
|
||||||
|
// the update propagates without depending on auth.js's implicit
|
||||||
|
// token→session.user mapping, which is flaky for the name claim
|
||||||
|
// in the v5 OIDC provider configuration.
|
||||||
|
//
|
||||||
|
// Defensive: only the `name` field is accepted from the update
|
||||||
|
// payload, even if the client passes additional keys. Other
|
||||||
|
// identity claims (orgId, roles, sub) come from ZITADEL at
|
||||||
|
// sign-in time and are not user-mutable from a settings page.
|
||||||
|
//
|
||||||
|
// Returns a NEW token object (spread) rather than mutating, so
|
||||||
|
// there is no ambiguity for auth.js about whether the token
|
||||||
|
// changed and needs re-encoding into the session cookie.
|
||||||
|
if (trigger === "update" && session) {
|
||||||
|
const update = session as { name?: unknown };
|
||||||
|
if (typeof update.name === "string") {
|
||||||
|
return { ...token, name: update.name };
|
||||||
|
}
|
||||||
|
return token;
|
||||||
|
}
|
||||||
if (account && profile) {
|
if (account && profile) {
|
||||||
const claims = profile as unknown as ZitadelClaims;
|
const claims = profile as unknown as ZitadelClaims;
|
||||||
token.orgId = claims["urn:zitadel:iam:user:resourceowner:id"];
|
token.orgId = claims["urn:zitadel:iam:user:resourceowner:id"];
|
||||||
@@ -58,6 +82,19 @@ export const authConfig: NextAuthConfig = {
|
|||||||
claims["urn:zitadel:iam:org:project:roles"]
|
claims["urn:zitadel:iam:org:project:roles"]
|
||||||
);
|
);
|
||||||
token.accessToken = account.access_token;
|
token.accessToken = account.access_token;
|
||||||
|
// Phase 6 fix5: explicitly pin the standard name/email claims
|
||||||
|
// onto the token from the OIDC profile. Previously these came
|
||||||
|
// through auth.js's implicit mapping, which works on first
|
||||||
|
// sign-in but isn't reliable after update() — once the update
|
||||||
|
// path overrides token.name, the read-back path needs token
|
||||||
|
// to be the authoritative source. Setting them explicitly
|
||||||
|
// here keeps sign-in and update on the same path.
|
||||||
|
if (typeof profile.name === "string") {
|
||||||
|
token.name = profile.name;
|
||||||
|
}
|
||||||
|
if (typeof profile.email === "string") {
|
||||||
|
token.email = profile.email;
|
||||||
|
}
|
||||||
// Pin token.sub to the OIDC subject. Auth.js v5 otherwise puts a
|
// Pin token.sub to the OIDC subject. Auth.js v5 otherwise puts a
|
||||||
// freshly generated UUID in token.sub on initial sign-in,
|
// freshly generated UUID in token.sub on initial sign-in,
|
||||||
// ignoring what profile() returns for `id`. That UUID then
|
// ignoring what profile() returns for `id`. That UUID then
|
||||||
@@ -80,10 +117,19 @@ export const authConfig: NextAuthConfig = {
|
|||||||
async session({ session, token }) {
|
async session({ session, token }) {
|
||||||
const roles = (token.roles as Role[]) ?? [];
|
const roles = (token.roles as Role[]) ?? [];
|
||||||
const orgName = (token.orgName as string) ?? "";
|
const orgName = (token.orgName as string) ?? "";
|
||||||
|
// Phase 6 fix5: read name and email directly from the token.
|
||||||
|
// Previously this code relied on `session.user?.name`, expecting
|
||||||
|
// auth.js to map token.name → session.user.name automatically.
|
||||||
|
// That mapping is brittle: it works on first sign-in (because
|
||||||
|
// OIDC profile() populates session.user) but not after update()
|
||||||
|
// overrides token.name. Reading from token is the canonical
|
||||||
|
// path regardless of how the token was last written.
|
||||||
|
const tokenName = (token.name as string | undefined) ?? "";
|
||||||
|
const tokenEmail = (token.email as string | undefined) ?? "";
|
||||||
const sessionUser: SessionUser = {
|
const sessionUser: SessionUser = {
|
||||||
id: token.sub!,
|
id: token.sub!,
|
||||||
name: session.user?.name ?? "",
|
name: tokenName || session.user?.name || "",
|
||||||
email: session.user?.email ?? "",
|
email: tokenEmail || session.user?.email || "",
|
||||||
orgId: token.orgId as string,
|
orgId: token.orgId as string,
|
||||||
orgName,
|
orgName,
|
||||||
roles,
|
roles,
|
||||||
@@ -96,6 +142,14 @@ export const authConfig: NextAuthConfig = {
|
|||||||
isPersonal: isPersonalOrgName(orgName),
|
isPersonal: isPersonalOrgName(orgName),
|
||||||
};
|
};
|
||||||
(session as any).platformUser = sessionUser;
|
(session as any).platformUser = sessionUser;
|
||||||
|
// Also overwrite session.user so any client-side code that uses
|
||||||
|
// the standard NextAuth shape (session.user.name) sees the new
|
||||||
|
// value. Pre-fix5 code paths read from session.user.name; this
|
||||||
|
// keeps them working without per-component changes.
|
||||||
|
if (session.user) {
|
||||||
|
session.user.name = sessionUser.name;
|
||||||
|
session.user.email = sessionUser.email;
|
||||||
|
}
|
||||||
return session;
|
return session;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -153,5 +153,21 @@ export function formatLineDescription(
|
|||||||
}[L];
|
}[L];
|
||||||
return reason ? `${base}: ${reason}` : base;
|
return reason ? `${base}: ${reason}` : base;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Phase 8: custom invoice lines. The description is what the
|
||||||
|
// admin typed in the editor — return it verbatim (no template,
|
||||||
|
// no locale-specific formatting). billing.ts persists the
|
||||||
|
// already-trimmed admin input into invoice_lines.description.
|
||||||
|
case "custom_line": {
|
||||||
|
const dRaw = (m as Record<string, unknown>)["description"];
|
||||||
|
if (typeof dRaw === "string" && dRaw.trim().length > 0) return dRaw;
|
||||||
|
// Fallback: the description column on the row itself. The
|
||||||
|
// PDF renderer hands us the line so it can read it directly
|
||||||
|
// — see how billing-pdf invokes formatLineDescription.
|
||||||
|
const onRow = (line as unknown as { description?: string }).description;
|
||||||
|
return onRow && onRow.trim().length > 0
|
||||||
|
? onRow
|
||||||
|
: { de: "Leistung", en: "Service", fr: "Service", it: "Servizio" }[L];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,44 +31,18 @@ import {
|
|||||||
Text,
|
Text,
|
||||||
View,
|
View,
|
||||||
StyleSheet,
|
StyleSheet,
|
||||||
Svg,
|
|
||||||
Polygon,
|
|
||||||
Polyline,
|
|
||||||
renderToBuffer,
|
renderToBuffer,
|
||||||
} from "@react-pdf/renderer";
|
} from "@react-pdf/renderer";
|
||||||
import type { Invoice, InvoiceLine, InvoiceLineKind } from "@/types";
|
import type { Invoice, InvoiceLine, InvoiceLineKind } from "@/types";
|
||||||
|
import { BRAND, Logo } from "./pdf-brand";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Brand constants — edit here to tweak look without touching layout
|
// Brand: imported from lib/pdf-brand. Edit there to change issuer
|
||||||
|
// info, colours, or the logo. Both billing-pdf.tsx and credit-note-pdf.tsx
|
||||||
|
// share the same source of truth so a brand change applies to every
|
||||||
|
// PDF the portal produces.
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
const BRAND = {
|
|
||||||
name: "PieCed IT",
|
|
||||||
// Primary emerald — matches the logo SVG fill (#10B981).
|
|
||||||
primary: "#10B981",
|
|
||||||
// Slightly darker emerald for headings.
|
|
||||||
primaryDark: "#0a8060",
|
|
||||||
textColor: "#1a1a1a",
|
|
||||||
mutedColor: "#666",
|
|
||||||
borderColor: "#d4d4d4",
|
|
||||||
// Issuer block — change these to your real legal info.
|
|
||||||
issuer: {
|
|
||||||
legalName: "PieCed IT",
|
|
||||||
addressLine1: "Cedric Mosimann",
|
|
||||||
addressLine2: "[Strasse Nr.]",
|
|
||||||
postalCity: "[PLZ] Basel",
|
|
||||||
country: "Switzerland",
|
|
||||||
email: "billing@pieced.ch",
|
|
||||||
web: "pieced.ch",
|
|
||||||
// Show "MWST-Nr. ..." on PDF when set.
|
|
||||||
vatNumber: null as string | null,
|
|
||||||
// Bank instructions — Phase 7 replaces with QR-bill.
|
|
||||||
bankName: "[Bank name]",
|
|
||||||
bankIban: "[CHxx xxxx xxxx xxxx xxxx x]",
|
|
||||||
bankBic: "[BIC]",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Localized strings
|
// Localized strings
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -80,6 +54,11 @@ interface PdfStrings {
|
|||||||
dueDate: string;
|
dueDate: string;
|
||||||
period: string;
|
period: string;
|
||||||
billTo: string;
|
billTo: string;
|
||||||
|
// Phase 6 fix: prefix shown before the optional contact-person
|
||||||
|
// name on the bill-to block. "z.Hd." (DE) / "Attn:" (EN) /
|
||||||
|
// "À l'attention de" (FR) / "c.a." (IT). Empty/unused when the
|
||||||
|
// invoice has no contactName on its snapshot.
|
||||||
|
attentionPrefix: string;
|
||||||
description: string;
|
description: string;
|
||||||
quantity: string;
|
quantity: string;
|
||||||
unitPrice: string;
|
unitPrice: string;
|
||||||
@@ -107,6 +86,7 @@ const MESSAGES: Record<string, PdfStrings> = {
|
|||||||
dueDate: "Zahlbar bis",
|
dueDate: "Zahlbar bis",
|
||||||
period: "Abrechnungsperiode",
|
period: "Abrechnungsperiode",
|
||||||
billTo: "Rechnungsempfänger",
|
billTo: "Rechnungsempfänger",
|
||||||
|
attentionPrefix: "z.Hd.",
|
||||||
description: "Beschreibung",
|
description: "Beschreibung",
|
||||||
quantity: "Menge",
|
quantity: "Menge",
|
||||||
unitPrice: "Einzelpreis",
|
unitPrice: "Einzelpreis",
|
||||||
@@ -127,6 +107,7 @@ const MESSAGES: Record<string, PdfStrings> = {
|
|||||||
skill_usage: "Skill-Nutzung",
|
skill_usage: "Skill-Nutzung",
|
||||||
skill_setup: "Einrichtungsgebühr Skill",
|
skill_setup: "Einrichtungsgebühr Skill",
|
||||||
adjustment: "Anpassung",
|
adjustment: "Anpassung",
|
||||||
|
custom_line: "Leistungen",
|
||||||
},
|
},
|
||||||
reverseCharge:
|
reverseCharge:
|
||||||
"Steuerschuldnerschaft des Leistungsempfängers (Reverse Charge).",
|
"Steuerschuldnerschaft des Leistungsempfängers (Reverse Charge).",
|
||||||
@@ -139,6 +120,7 @@ const MESSAGES: Record<string, PdfStrings> = {
|
|||||||
dueDate: "Due date",
|
dueDate: "Due date",
|
||||||
period: "Billing period",
|
period: "Billing period",
|
||||||
billTo: "Bill to",
|
billTo: "Bill to",
|
||||||
|
attentionPrefix: "Attn:",
|
||||||
description: "Description",
|
description: "Description",
|
||||||
quantity: "Qty",
|
quantity: "Qty",
|
||||||
unitPrice: "Unit price",
|
unitPrice: "Unit price",
|
||||||
@@ -159,6 +141,7 @@ const MESSAGES: Record<string, PdfStrings> = {
|
|||||||
skill_usage: "Skill usage",
|
skill_usage: "Skill usage",
|
||||||
skill_setup: "Skill setup fee",
|
skill_setup: "Skill setup fee",
|
||||||
adjustment: "Adjustment",
|
adjustment: "Adjustment",
|
||||||
|
custom_line: "Services",
|
||||||
},
|
},
|
||||||
reverseCharge:
|
reverseCharge:
|
||||||
"Reverse charge — VAT to be accounted for by the recipient.",
|
"Reverse charge — VAT to be accounted for by the recipient.",
|
||||||
@@ -171,6 +154,7 @@ const MESSAGES: Record<string, PdfStrings> = {
|
|||||||
dueDate: "Échéance",
|
dueDate: "Échéance",
|
||||||
period: "Période de facturation",
|
period: "Période de facturation",
|
||||||
billTo: "Destinataire",
|
billTo: "Destinataire",
|
||||||
|
attentionPrefix: "À l'attention de",
|
||||||
description: "Description",
|
description: "Description",
|
||||||
quantity: "Qté",
|
quantity: "Qté",
|
||||||
unitPrice: "Prix unitaire",
|
unitPrice: "Prix unitaire",
|
||||||
@@ -191,6 +175,7 @@ const MESSAGES: Record<string, PdfStrings> = {
|
|||||||
skill_usage: "Utilisation Skill",
|
skill_usage: "Utilisation Skill",
|
||||||
skill_setup: "Frais de configuration skill",
|
skill_setup: "Frais de configuration skill",
|
||||||
adjustment: "Ajustement",
|
adjustment: "Ajustement",
|
||||||
|
custom_line: "Services",
|
||||||
},
|
},
|
||||||
reverseCharge:
|
reverseCharge:
|
||||||
"Autoliquidation — TVA à acquitter par le destinataire.",
|
"Autoliquidation — TVA à acquitter par le destinataire.",
|
||||||
@@ -203,6 +188,7 @@ const MESSAGES: Record<string, PdfStrings> = {
|
|||||||
dueDate: "Scadenza",
|
dueDate: "Scadenza",
|
||||||
period: "Periodo di fatturazione",
|
period: "Periodo di fatturazione",
|
||||||
billTo: "Destinatario",
|
billTo: "Destinatario",
|
||||||
|
attentionPrefix: "c.a.",
|
||||||
description: "Descrizione",
|
description: "Descrizione",
|
||||||
quantity: "Qtà",
|
quantity: "Qtà",
|
||||||
unitPrice: "Prezzo unitario",
|
unitPrice: "Prezzo unitario",
|
||||||
@@ -223,6 +209,7 @@ const MESSAGES: Record<string, PdfStrings> = {
|
|||||||
skill_usage: "Utilizzo Skill",
|
skill_usage: "Utilizzo Skill",
|
||||||
skill_setup: "Spese di attivazione skill",
|
skill_setup: "Spese di attivazione skill",
|
||||||
adjustment: "Rettifica",
|
adjustment: "Rettifica",
|
||||||
|
custom_line: "Servizi",
|
||||||
},
|
},
|
||||||
reverseCharge:
|
reverseCharge:
|
||||||
"Inversione contabile — IVA a carico del destinatario.",
|
"Inversione contabile — IVA a carico del destinatario.",
|
||||||
@@ -349,62 +336,6 @@ const styles = StyleSheet.create({
|
|||||||
});
|
});
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Logo — inlined SVG primitives
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* PieCed honeycomb logo. Re-renders the same 6-hex glyph as the
|
|
||||||
* portal's `public/pieced-logo.svg` using React-PDF's SVG support.
|
|
||||||
* Width/height are independent of the original viewBox so we can
|
|
||||||
* scale it without losing stroke quality.
|
|
||||||
*/
|
|
||||||
const Logo = ({ size = 60 }: { size?: number }) => (
|
|
||||||
<Svg width={size} height={size * (106 / 70)} viewBox="0 0 70 106">
|
|
||||||
{/* H1 solid */}
|
|
||||||
<Polygon
|
|
||||||
points="38.5,22.69 31.5,10.566 17.5,10.566 10.5,22.69 17.5,34.814 31.5,34.814"
|
|
||||||
fill="#10B981"
|
|
||||||
stroke="#10B981"
|
|
||||||
strokeWidth={1.6}
|
|
||||||
/>
|
|
||||||
{/* H2 outline */}
|
|
||||||
<Polygon
|
|
||||||
points="59.5,34.814 52.5,22.69 38.5,22.69 31.5,34.814 38.5,46.938 52.5,46.938"
|
|
||||||
fill="none"
|
|
||||||
stroke="#10B981"
|
|
||||||
strokeWidth={1.8}
|
|
||||||
/>
|
|
||||||
{/* H3 outline */}
|
|
||||||
<Polygon
|
|
||||||
points="38.5,46.938 31.5,34.814 17.5,34.814 10.5,46.938 17.5,59.062 31.5,59.062"
|
|
||||||
fill="none"
|
|
||||||
stroke="#10B981"
|
|
||||||
strokeWidth={1.8}
|
|
||||||
/>
|
|
||||||
{/* H4 solid */}
|
|
||||||
<Polygon
|
|
||||||
points="59.5,59.062 52.5,46.938 38.5,46.938 31.5,59.062 38.5,71.186 52.5,71.186"
|
|
||||||
fill="#10B981"
|
|
||||||
stroke="#10B981"
|
|
||||||
strokeWidth={1.6}
|
|
||||||
/>
|
|
||||||
{/* H5 partial */}
|
|
||||||
<Polyline
|
|
||||||
points="31.5,83.31 38.5,71.186 31.5,59.062 17.5,59.062 10.5,71.186"
|
|
||||||
fill="none"
|
|
||||||
stroke="#10B981"
|
|
||||||
strokeWidth={1.8}
|
|
||||||
/>
|
|
||||||
{/* H6 partial */}
|
|
||||||
<Polyline
|
|
||||||
points="59.5,83.31 52.5,71.186 38.5,71.186 31.5,83.31 38.5,95.434"
|
|
||||||
fill="none"
|
|
||||||
stroke="#10B981"
|
|
||||||
strokeWidth={1.8}
|
|
||||||
/>
|
|
||||||
</Svg>
|
|
||||||
);
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Helpers
|
// Helpers
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -508,11 +439,18 @@ const InvoicePdf: React.FC<InvoicePdfProps> = ({ invoice, lines }) => {
|
|||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<View style={styles.metaCol}>
|
<View style={styles.metaCol}>
|
||||||
<Text style={styles.metaLabel}>{s.period}</Text>
|
{/* Phase 8: skip the billing-period block on custom
|
||||||
<Text style={styles.metaValue}>
|
invoices (which aren't tied to a period). Due date
|
||||||
{fmtDate(invoice.periodStart, invoice.locale)} —{" "}
|
still renders. */}
|
||||||
{fmtDate(invoice.periodEnd, invoice.locale)}
|
{invoice.periodStart && invoice.periodEnd && (
|
||||||
</Text>
|
<>
|
||||||
|
<Text style={styles.metaLabel}>{s.period}</Text>
|
||||||
|
<Text style={styles.metaValue}>
|
||||||
|
{fmtDate(invoice.periodStart, invoice.locale)} —{" "}
|
||||||
|
{fmtDate(invoice.periodEnd, invoice.locale)}
|
||||||
|
</Text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<Text style={styles.metaLabel}>{s.dueDate}</Text>
|
<Text style={styles.metaLabel}>{s.dueDate}</Text>
|
||||||
<Text style={styles.metaValue}>
|
<Text style={styles.metaValue}>
|
||||||
{fmtDate(invoice.dueAt, invoice.locale)}
|
{fmtDate(invoice.dueAt, invoice.locale)}
|
||||||
@@ -524,6 +462,15 @@ const InvoicePdf: React.FC<InvoicePdfProps> = ({ invoice, lines }) => {
|
|||||||
<View style={styles.billToBlock}>
|
<View style={styles.billToBlock}>
|
||||||
<Text style={styles.billToLabel}>{s.billTo}</Text>
|
<Text style={styles.billToLabel}>{s.billTo}</Text>
|
||||||
<Text style={styles.billToName}>{snap.companyName}</Text>
|
<Text style={styles.billToName}>{snap.companyName}</Text>
|
||||||
|
{/* Phase 6 fix: optional "z.Hd." / "Attn:" line for routing
|
||||||
|
the printed invoice internally at the customer. Prints
|
||||||
|
between the company name and street address, in the
|
||||||
|
invoice's locale (frozen at issue time). */}
|
||||||
|
{snap.contactName && (
|
||||||
|
<Text>
|
||||||
|
{s.attentionPrefix} {snap.contactName}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
<Text>{snap.streetAddress}</Text>
|
<Text>{snap.streetAddress}</Text>
|
||||||
<Text>
|
<Text>
|
||||||
{snap.postalCode} {snap.city}
|
{snap.postalCode} {snap.city}
|
||||||
|
|||||||
@@ -30,9 +30,12 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
|
CreditNote,
|
||||||
|
CustomInvoiceDraftPayload,
|
||||||
Invoice,
|
Invoice,
|
||||||
InvoiceBillingSnapshot,
|
InvoiceBillingSnapshot,
|
||||||
InvoiceDraft,
|
InvoiceDraft,
|
||||||
|
InvoiceDraftRecord,
|
||||||
InvoiceLine,
|
InvoiceLine,
|
||||||
InvoiceLineKind,
|
InvoiceLineKind,
|
||||||
InvoicePaymentMethod,
|
InvoicePaymentMethod,
|
||||||
@@ -44,8 +47,12 @@ import type {
|
|||||||
TenantSuspensionEvent,
|
TenantSuspensionEvent,
|
||||||
} from "@/types";
|
} from "@/types";
|
||||||
import {
|
import {
|
||||||
|
attachCreditNotePdf,
|
||||||
|
createCreditNote,
|
||||||
createInvoice,
|
createInvoice,
|
||||||
|
deleteInvoiceDraft,
|
||||||
getInvoiceById,
|
getInvoiceById,
|
||||||
|
getInvoiceDraftById,
|
||||||
getOrgBilling,
|
getOrgBilling,
|
||||||
getOrgBillingConfig,
|
getOrgBillingConfig,
|
||||||
getPlatformPricing,
|
getPlatformPricing,
|
||||||
@@ -53,6 +60,8 @@ import {
|
|||||||
listSkillEventsForTenant,
|
listSkillEventsForTenant,
|
||||||
listSkillPricing,
|
listSkillPricing,
|
||||||
listSuspensionEventsForTenant,
|
listSuspensionEventsForTenant,
|
||||||
|
markInvoiceVoided,
|
||||||
|
recordInvoiceRefund,
|
||||||
tenantHasSetupFeeBilled,
|
tenantHasSetupFeeBilled,
|
||||||
tenantSkillHasBeenBilled,
|
tenantSkillHasBeenBilled,
|
||||||
updateInvoicePdf,
|
updateInvoicePdf,
|
||||||
@@ -61,7 +70,9 @@ import { listTenants } from "./k8s";
|
|||||||
import { getTeamSpendLogsV2 } from "./litellm";
|
import { getTeamSpendLogsV2 } from "./litellm";
|
||||||
import { getUsage as getThreemaUsage } from "./threema-relay";
|
import { getUsage as getThreemaUsage } from "./threema-relay";
|
||||||
import { renderInvoicePdf } from "./billing-pdf";
|
import { renderInvoicePdf } from "./billing-pdf";
|
||||||
import { sendInvoiceIssuedEmail } from "./email";
|
import { renderCreditNotePdf } from "./credit-note-pdf";
|
||||||
|
import { sendCreditNoteEmail, sendInvoiceIssuedEmail } from "./email";
|
||||||
|
import { createInvoiceRefund } from "./stripe";
|
||||||
import { formatLineDescription } from "./billing-i18n";
|
import { formatLineDescription } from "./billing-i18n";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -240,6 +251,9 @@ const EU_COUNTRIES = new Set([
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Determine VAT rate from billing address and the platform default.
|
* Determine VAT rate from billing address and the platform default.
|
||||||
|
* Exported for reuse by the Phase 8 custom-invoice flow so both
|
||||||
|
* pipelines (cron and custom) compute VAT identically.
|
||||||
|
*
|
||||||
* See README for the legal interpretation; this implements the
|
* See README for the legal interpretation; this implements the
|
||||||
* defaults you confirmed:
|
* defaults you confirmed:
|
||||||
*
|
*
|
||||||
@@ -248,7 +262,7 @@ const EU_COUNTRIES = new Set([
|
|||||||
* - EU without VAT: CH MWST (B2C consumer, we charge our rate)
|
* - EU without VAT: CH MWST (B2C consumer, we charge our rate)
|
||||||
* - other: 0% (export of services)
|
* - other: 0% (export of services)
|
||||||
*/
|
*/
|
||||||
function vatRateForAddress(
|
export function vatRateForAddress(
|
||||||
snapshot: InvoiceBillingSnapshot,
|
snapshot: InvoiceBillingSnapshot,
|
||||||
platformPricing: PlatformPricing
|
platformPricing: PlatformPricing
|
||||||
): { rate: number; note: string | null } {
|
): { rate: number; note: string | null } {
|
||||||
@@ -645,6 +659,7 @@ export async function computeInvoiceDraft(opts: {
|
|||||||
}
|
}
|
||||||
const snapshot: InvoiceBillingSnapshot = {
|
const snapshot: InvoiceBillingSnapshot = {
|
||||||
companyName: orgBilling.companyName,
|
companyName: orgBilling.companyName,
|
||||||
|
contactName: orgBilling.contactName ?? null,
|
||||||
streetAddress: orgBilling.streetAddress,
|
streetAddress: orgBilling.streetAddress,
|
||||||
postalCode: orgBilling.postalCode,
|
postalCode: orgBilling.postalCode,
|
||||||
city: orgBilling.city,
|
city: orgBilling.city,
|
||||||
@@ -835,3 +850,692 @@ export async function generateInvoice(opts: {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Phase 7 — void and refund orchestration
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export class VoidNotAllowedError extends Error {
|
||||||
|
constructor(message: string, public readonly currentStatus: string) {
|
||||||
|
super(message);
|
||||||
|
this.name = "VoidNotAllowedError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RefundNotAllowedError extends Error {
|
||||||
|
constructor(message: string, public readonly currentStatus: string) {
|
||||||
|
super(message);
|
||||||
|
this.name = "RefundNotAllowedError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sanitize a locale string to the supported four. Used when picking
|
||||||
|
* which translation block to render emails/PDFs with. We never
|
||||||
|
* fall back to admin's locale here — the credit note inherits the
|
||||||
|
* invoice's locale so both documents read consistently to the
|
||||||
|
* customer.
|
||||||
|
*/
|
||||||
|
function pickSupportedLocale(
|
||||||
|
locale: string | null | undefined
|
||||||
|
): "de" | "en" | "fr" | "it" {
|
||||||
|
const supported = ["de", "en", "fr", "it"] as const;
|
||||||
|
return (supported as readonly string[]).includes(locale ?? "")
|
||||||
|
? (locale as "de" | "en" | "fr" | "it")
|
||||||
|
: "de";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Round a CHF amount to 2 decimal places. Used when proportionally
|
||||||
|
* splitting VAT between subtotal and refund amount — avoids
|
||||||
|
* accumulating fractional rappen across operations.
|
||||||
|
*/
|
||||||
|
function roundChf(amount: number): number {
|
||||||
|
return Math.round(amount * 100) / 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Void an unpaid invoice. State transition: open/overdue → void.
|
||||||
|
*
|
||||||
|
* Side effects, in order:
|
||||||
|
* 1. Mark the invoice voided (status, void_reason, voided_at, voided_by)
|
||||||
|
* 2. Insert credit_notes row (kind='void', amount=full invoice total)
|
||||||
|
* 3. Render the credit-note PDF and attach it to the row
|
||||||
|
* 4. Best-effort email to the billing contact
|
||||||
|
*
|
||||||
|
* Not allowed:
|
||||||
|
* - status='paid' (use refundInvoice instead — voiding paid
|
||||||
|
* invoices would create a record mismatch with the payment
|
||||||
|
* processor)
|
||||||
|
* - status='void' (already voided)
|
||||||
|
* - status='draft' (drafts aren't issued; nothing to void)
|
||||||
|
* - status='partially_refunded' / 'fully_refunded' (use refund
|
||||||
|
* for the remaining amount instead)
|
||||||
|
*
|
||||||
|
* Throws VoidNotAllowedError if the invoice is in a non-voidable
|
||||||
|
* state. Caller surfaces this as 409 Conflict to the admin.
|
||||||
|
*/
|
||||||
|
export async function voidInvoice(params: {
|
||||||
|
invoiceId: string;
|
||||||
|
reason: string;
|
||||||
|
voidedBy: string;
|
||||||
|
}): Promise<CreditNote> {
|
||||||
|
const invoice = await getInvoiceById(params.invoiceId);
|
||||||
|
if (!invoice) {
|
||||||
|
throw new Error(`Invoice not found: ${params.invoiceId}`);
|
||||||
|
}
|
||||||
|
// Only unpaid invoices can be voided. The state machine puts
|
||||||
|
// paid invoices on the refund path; voiding them would skip the
|
||||||
|
// payment reversal and leave the customer's money in our account
|
||||||
|
// with no obligation showing in the portal.
|
||||||
|
if (!["open", "overdue"].includes(invoice.status)) {
|
||||||
|
throw new VoidNotAllowedError(
|
||||||
|
`Cannot void invoice in status '${invoice.status}'. Voids are allowed only for open or overdue invoices; paid invoices must be refunded.`,
|
||||||
|
invoice.status
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const locale = pickSupportedLocale(invoice.locale);
|
||||||
|
|
||||||
|
// The credit note matches the invoice 1:1 in amount and VAT.
|
||||||
|
// We carry the same VAT breakdown so the PDF can render
|
||||||
|
// "subtotal + VAT" the same way the original invoice did.
|
||||||
|
const creditNote = await createCreditNote({
|
||||||
|
invoiceId: invoice.id,
|
||||||
|
zitadelOrgId: invoice.zitadelOrgId,
|
||||||
|
kind: "void",
|
||||||
|
amountChf: invoice.totalChf,
|
||||||
|
vatAmountChf: invoice.vatAmountChf,
|
||||||
|
reason: params.reason || null,
|
||||||
|
issuedBy: params.voidedBy,
|
||||||
|
locale,
|
||||||
|
billingSnapshot: invoice.billingSnapshot,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mark invoice voided AFTER the credit note row exists, so the
|
||||||
|
// status change has a credit note to point at. If anything below
|
||||||
|
// here fails (PDF render, email), the invoice is still correctly
|
||||||
|
// voided and the credit note row exists — just without a PDF
|
||||||
|
// until manually re-rendered.
|
||||||
|
await markInvoiceVoided({
|
||||||
|
invoiceId: invoice.id,
|
||||||
|
reason: params.reason,
|
||||||
|
voidedBy: params.voidedBy,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Render PDF + attach. PDF failure here doesn't undo the void —
|
||||||
|
// the customer can be told their invoice is voided and the PDF
|
||||||
|
// can be re-issued later. We surface the error in the response
|
||||||
|
// so admin knows to retry, but the void itself stands.
|
||||||
|
try {
|
||||||
|
const pdfBuffer = await renderCreditNotePdf(creditNote, invoice);
|
||||||
|
const filename = `${creditNote.creditNoteNumber}.pdf`;
|
||||||
|
await attachCreditNotePdf(creditNote.id, pdfBuffer, filename);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(
|
||||||
|
`Credit note ${creditNote.creditNoteNumber} created but PDF render failed; re-render manually.`,
|
||||||
|
e
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Best-effort email. Same fail-soft pattern as invoice issuance.
|
||||||
|
try {
|
||||||
|
const snap = invoice.billingSnapshot;
|
||||||
|
if (snap.billingEmail) {
|
||||||
|
await sendCreditNoteEmail({
|
||||||
|
to: snap.billingEmail,
|
||||||
|
contactName: snap.contactName || snap.companyName,
|
||||||
|
companyName: snap.companyName,
|
||||||
|
creditNoteNumber: creditNote.creditNoteNumber,
|
||||||
|
invoiceNumber: invoice.invoiceNumber,
|
||||||
|
amountChf: creditNote.amountChf,
|
||||||
|
currency: "CHF",
|
||||||
|
kind: "void",
|
||||||
|
reason: params.reason || null,
|
||||||
|
locale,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(
|
||||||
|
`Credit note ${creditNote.creditNoteNumber} issued; email send failed.`,
|
||||||
|
e
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return creditNote;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refund a paid invoice (in part or in full). State transition:
|
||||||
|
* paid → partially_refunded (if amount < remaining)
|
||||||
|
* paid → fully_refunded (if amount >= remaining)
|
||||||
|
* partially_refunded → fully_refunded (if cumulative >= total)
|
||||||
|
*
|
||||||
|
* Side effects, in order:
|
||||||
|
* 1. If the invoice was Stripe-paid (payment_method='card' with a
|
||||||
|
* stripe_payment_intent_id) AND no `existingStripeRefund` was
|
||||||
|
* passed, call Stripe to issue the refund. Stripe is the source
|
||||||
|
* of truth for actual money movement; we mirror its outcome
|
||||||
|
* locally.
|
||||||
|
* 2. Insert credit_notes row (kind='refund', amount=refund amount,
|
||||||
|
* VAT proportional)
|
||||||
|
* 3. Insert invoice_refunds row, linking to the credit note and to
|
||||||
|
* the Stripe refund (if any). recordInvoiceRefund updates the
|
||||||
|
* invoice's status atomically based on the new running total.
|
||||||
|
* 4. Render PDF + attach
|
||||||
|
* 5. Best-effort email
|
||||||
|
*
|
||||||
|
* `existingStripeRefund` is for the webhook path: when Stripe fires
|
||||||
|
* `charge.refunded` for a refund that was initiated directly in the
|
||||||
|
* Stripe Dashboard (not via this portal), the webhook needs to
|
||||||
|
* mirror the refund into the DB and issue a credit note WITHOUT
|
||||||
|
* calling Stripe again. Pass the refund id and status to skip the
|
||||||
|
* Stripe call.
|
||||||
|
*
|
||||||
|
* Not allowed:
|
||||||
|
* - status not in {paid, partially_refunded} — full refunds are
|
||||||
|
* only meaningful against actual payment
|
||||||
|
* - amount <= 0 or > remaining refundable
|
||||||
|
*
|
||||||
|
* For invoice-paid (non-Stripe) customers the Stripe step is
|
||||||
|
* skipped; refund settlement happens out-of-band (bank transfer)
|
||||||
|
* and admin records the action in the portal.
|
||||||
|
*/
|
||||||
|
export async function refundInvoice(params: {
|
||||||
|
invoiceId: string;
|
||||||
|
amountChf: number;
|
||||||
|
reason: string;
|
||||||
|
refundedBy: string;
|
||||||
|
/**
|
||||||
|
* Webhook path: a Stripe refund that has already been created
|
||||||
|
* (in the Stripe Dashboard or via a prior API call) and now needs
|
||||||
|
* to be mirrored into the portal. When set, the Stripe API call
|
||||||
|
* is skipped and the provided id/status are recorded as-is.
|
||||||
|
*/
|
||||||
|
existingStripeRefund?: {
|
||||||
|
id: string;
|
||||||
|
status: "pending" | "succeeded" | "failed" | "canceled";
|
||||||
|
};
|
||||||
|
}): Promise<CreditNote> {
|
||||||
|
const invoice = await getInvoiceById(params.invoiceId);
|
||||||
|
if (!invoice) {
|
||||||
|
throw new Error(`Invoice not found: ${params.invoiceId}`);
|
||||||
|
}
|
||||||
|
if (!["paid", "partially_refunded"].includes(invoice.status)) {
|
||||||
|
throw new RefundNotAllowedError(
|
||||||
|
`Cannot refund invoice in status '${invoice.status}'. Refunds are allowed only for paid invoices.`,
|
||||||
|
invoice.status
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (params.amountChf <= 0) {
|
||||||
|
throw new RefundNotAllowedError(
|
||||||
|
"Refund amount must be greater than zero.",
|
||||||
|
invoice.status
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const remaining = roundChf(invoice.totalChf - invoice.refundedTotalChf);
|
||||||
|
if (params.amountChf - remaining > 0.005) {
|
||||||
|
// Allow a 0.005 tolerance to account for floating-point dust;
|
||||||
|
// anything genuinely larger is a real over-refund attempt.
|
||||||
|
throw new RefundNotAllowedError(
|
||||||
|
`Refund amount CHF ${params.amountChf.toFixed(2)} exceeds remaining refundable CHF ${remaining.toFixed(2)}.`,
|
||||||
|
invoice.status
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const locale = pickSupportedLocale(invoice.locale);
|
||||||
|
|
||||||
|
// Proportional VAT split: refunded VAT / total VAT = refunded
|
||||||
|
// amount / total amount. Keep the proportion explicit so the
|
||||||
|
// credit note's "subtotal + VAT" lines reconcile to the same
|
||||||
|
// VAT rate as the original invoice.
|
||||||
|
const vatPortion =
|
||||||
|
invoice.totalChf > 0
|
||||||
|
? roundChf((params.amountChf * invoice.vatAmountChf) / invoice.totalChf)
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
// Step 1: Stripe (only for card-paid invoices, and only when the
|
||||||
|
// caller hasn't already created the refund). We do this BEFORE
|
||||||
|
// any local DB writes for refund tracking — Stripe is the source
|
||||||
|
// of truth for money movement, and if the Stripe call fails we
|
||||||
|
// must NOT have recorded the refund locally (the customer would
|
||||||
|
// see a credit note for money they never received).
|
||||||
|
//
|
||||||
|
// The charge.refunded webhook will also fire later, but we record
|
||||||
|
// the refund here too so the admin gets immediate confirmation
|
||||||
|
// and the credit note can be issued without waiting for the
|
||||||
|
// webhook round-trip. The webhook is idempotent (dedups by
|
||||||
|
// stripe_refund_id) so it's safe to do both.
|
||||||
|
let stripeRefundId: string | null = null;
|
||||||
|
let stripeStatus: "pending" | "succeeded" | "failed" | "canceled" =
|
||||||
|
"succeeded";
|
||||||
|
const isStripePaid =
|
||||||
|
invoice.paymentMethod === "card" && !!invoice.stripePaymentIntentId;
|
||||||
|
if (params.existingStripeRefund) {
|
||||||
|
// Webhook path: don't call Stripe again; trust the provided id.
|
||||||
|
stripeRefundId = params.existingStripeRefund.id;
|
||||||
|
stripeStatus = params.existingStripeRefund.status;
|
||||||
|
} else if (isStripePaid) {
|
||||||
|
try {
|
||||||
|
const refund = await createInvoiceRefund({
|
||||||
|
paymentIntentId: invoice.stripePaymentIntentId!,
|
||||||
|
amountChf: params.amountChf,
|
||||||
|
reason: "requested_by_customer",
|
||||||
|
metadata: {
|
||||||
|
invoice_number: invoice.invoiceNumber,
|
||||||
|
refunded_by: params.refundedBy,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
stripeRefundId = refund.id;
|
||||||
|
// Map Stripe statuses to our enum. Anything other than
|
||||||
|
// 'succeeded' or 'pending' is treated as a failure — we
|
||||||
|
// don't record the credit note in that case (see below).
|
||||||
|
if (refund.status === "succeeded") stripeStatus = "succeeded";
|
||||||
|
else if (refund.status === "pending") stripeStatus = "pending";
|
||||||
|
else if (refund.status === "canceled") stripeStatus = "canceled";
|
||||||
|
else stripeStatus = "failed";
|
||||||
|
} catch (e) {
|
||||||
|
throw new Error(
|
||||||
|
`Stripe refund failed: ${e instanceof Error ? e.message : String(e)}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (stripeStatus === "failed" || stripeStatus === "canceled") {
|
||||||
|
throw new Error(
|
||||||
|
`Stripe refund returned non-success status: ${stripeStatus}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: insert credit note (PDF still null at this point).
|
||||||
|
const creditNote = await createCreditNote({
|
||||||
|
invoiceId: invoice.id,
|
||||||
|
zitadelOrgId: invoice.zitadelOrgId,
|
||||||
|
kind: "refund",
|
||||||
|
amountChf: params.amountChf,
|
||||||
|
vatAmountChf: vatPortion,
|
||||||
|
reason: params.reason || null,
|
||||||
|
issuedBy: params.refundedBy,
|
||||||
|
locale,
|
||||||
|
billingSnapshot: invoice.billingSnapshot,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Step 3: record the refund event and bump invoice status.
|
||||||
|
// recordInvoiceRefund handles status transitions and idempotency.
|
||||||
|
await recordInvoiceRefund({
|
||||||
|
invoiceId: invoice.id,
|
||||||
|
stripeRefundId,
|
||||||
|
amountChf: params.amountChf,
|
||||||
|
reason: params.reason || null,
|
||||||
|
refundedBy: params.refundedBy,
|
||||||
|
creditNoteId: creditNote.id,
|
||||||
|
status: stripeStatus,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Step 4: render + attach PDF. As with voidInvoice, a PDF failure
|
||||||
|
// here doesn't undo the refund — the refund happened (in Stripe
|
||||||
|
// and the DB), only the document is missing. Admin can re-render.
|
||||||
|
try {
|
||||||
|
const pdfBuffer = await renderCreditNotePdf(creditNote, invoice);
|
||||||
|
const filename = `${creditNote.creditNoteNumber}.pdf`;
|
||||||
|
await attachCreditNotePdf(creditNote.id, pdfBuffer, filename);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(
|
||||||
|
`Credit note ${creditNote.creditNoteNumber} created but PDF render failed; re-render manually.`,
|
||||||
|
e
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 5: best-effort email.
|
||||||
|
try {
|
||||||
|
const snap = invoice.billingSnapshot;
|
||||||
|
if (snap.billingEmail) {
|
||||||
|
await sendCreditNoteEmail({
|
||||||
|
to: snap.billingEmail,
|
||||||
|
contactName: snap.contactName || snap.companyName,
|
||||||
|
companyName: snap.companyName,
|
||||||
|
creditNoteNumber: creditNote.creditNoteNumber,
|
||||||
|
invoiceNumber: invoice.invoiceNumber,
|
||||||
|
amountChf: creditNote.amountChf,
|
||||||
|
currency: "CHF",
|
||||||
|
kind: "refund",
|
||||||
|
reason: params.reason || null,
|
||||||
|
locale,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(
|
||||||
|
`Credit note ${creditNote.creditNoteNumber} issued; email send failed.`,
|
||||||
|
e
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return creditNote;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Phase 8 — custom invoices (admin-entered, ad-hoc)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export class CustomInvoiceValidationError extends Error {
|
||||||
|
constructor(message: string) {
|
||||||
|
super(message);
|
||||||
|
this.name = "CustomInvoiceValidationError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute the totals for a custom-invoice draft payload, applying
|
||||||
|
* the same VAT logic the auto cron uses (vatRateForAddress against
|
||||||
|
* the org's billing snapshot).
|
||||||
|
*
|
||||||
|
* Returns the InvoiceDraft the createInvoice helper expects.
|
||||||
|
* Throws CustomInvoiceValidationError on:
|
||||||
|
* - no lines
|
||||||
|
* - any line with empty description or zero quantity
|
||||||
|
* - invalid date (issue or due)
|
||||||
|
* - issue date in past beyond 1 year (probably a typo)
|
||||||
|
* - due before issue
|
||||||
|
*
|
||||||
|
* Negative line amounts are intentionally allowed — they're the
|
||||||
|
* Rabatt / discount mechanism (one row with a negative unitPriceChf).
|
||||||
|
* The algebraic sum becomes the subtotal.
|
||||||
|
*/
|
||||||
|
export async function computeCustomInvoiceTotals(params: {
|
||||||
|
zitadelOrgId: string;
|
||||||
|
payload: CustomInvoiceDraftPayload;
|
||||||
|
}): Promise<InvoiceDraft> {
|
||||||
|
const { zitadelOrgId, payload } = params;
|
||||||
|
|
||||||
|
// Validation
|
||||||
|
if (!payload.lines || payload.lines.length === 0) {
|
||||||
|
throw new CustomInvoiceValidationError(
|
||||||
|
"Custom invoice must have at least one line."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for (let i = 0; i < payload.lines.length; i++) {
|
||||||
|
const ln = payload.lines[i];
|
||||||
|
if (!ln.description || !ln.description.trim()) {
|
||||||
|
throw new CustomInvoiceValidationError(
|
||||||
|
`Line ${i + 1}: description is required.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
typeof ln.quantity !== "number" ||
|
||||||
|
!isFinite(ln.quantity) ||
|
||||||
|
ln.quantity === 0
|
||||||
|
) {
|
||||||
|
throw new CustomInvoiceValidationError(
|
||||||
|
`Line ${i + 1}: quantity must be a non-zero number.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (typeof ln.unitPriceChf !== "number" || !isFinite(ln.unitPriceChf)) {
|
||||||
|
throw new CustomInvoiceValidationError(
|
||||||
|
`Line ${i + 1}: unit price must be a number (use negative for discounts).`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const issueDate = payload.issueDate;
|
||||||
|
const dueDate = payload.dueDate;
|
||||||
|
if (!/^\d{4}-\d{2}-\d{2}$/.test(issueDate)) {
|
||||||
|
throw new CustomInvoiceValidationError(
|
||||||
|
"Issue date must be a valid YYYY-MM-DD."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!/^\d{4}-\d{2}-\d{2}$/.test(dueDate)) {
|
||||||
|
throw new CustomInvoiceValidationError(
|
||||||
|
"Due date must be a valid YYYY-MM-DD."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (dueDate < issueDate) {
|
||||||
|
throw new CustomInvoiceValidationError(
|
||||||
|
"Due date cannot be before issue date."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Billing snapshot — required for any invoice to render.
|
||||||
|
const orgBilling = await getOrgBilling(zitadelOrgId);
|
||||||
|
if (!orgBilling) {
|
||||||
|
throw new CustomInvoiceValidationError(
|
||||||
|
"Org has no billing configuration. Ask the customer to complete onboarding first, or set the billing info from the admin panel."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Build the same snapshot shape the auto-cron freezes. Mirroring
|
||||||
|
// the auto flow keeps the PDF renderer happy with one code path.
|
||||||
|
const snapshot: InvoiceBillingSnapshot = {
|
||||||
|
companyName: orgBilling.companyName,
|
||||||
|
contactName: orgBilling.contactName ?? null,
|
||||||
|
streetAddress: orgBilling.streetAddress,
|
||||||
|
city: orgBilling.city,
|
||||||
|
postalCode: orgBilling.postalCode,
|
||||||
|
country: orgBilling.country,
|
||||||
|
vatNumber: orgBilling.vatNumber ?? null,
|
||||||
|
billingEmail: orgBilling.billingEmail,
|
||||||
|
notes: orgBilling.notes ?? null,
|
||||||
|
};
|
||||||
|
|
||||||
|
// VAT — same logic as auto.
|
||||||
|
const platformPricing = await getPlatformPricing();
|
||||||
|
const vat = vatRateForAddress(snapshot, platformPricing);
|
||||||
|
|
||||||
|
// Build invoice lines. quantity * unitPrice rounded to 2 decimals
|
||||||
|
// (rappen precision). We carry the per-line amount on the row so
|
||||||
|
// the PDF doesn't need to recompute and any rounding remains
|
||||||
|
// identical between rendering passes.
|
||||||
|
//
|
||||||
|
// tenantName=null because custom invoices aren't bound to a
|
||||||
|
// specific tenant. unitLabel=null because admin-entered lines are
|
||||||
|
// free-form (the auto-cron lines use "day" / "request" /
|
||||||
|
// "message" — for custom lines the quantity is just a number).
|
||||||
|
// metadata.description preserves the admin's input so
|
||||||
|
// formatLineDescription can read it via the metadata channel
|
||||||
|
// (the row's description column also has it, redundantly, for
|
||||||
|
// safety). displayOrder reflects the order the admin added the
|
||||||
|
// rows so the PDF renders them top-to-bottom unchanged.
|
||||||
|
const lines: Omit<InvoiceLine, "id" | "invoiceId">[] = payload.lines.map(
|
||||||
|
(ln, idx) => {
|
||||||
|
const amount = Math.round(ln.quantity * ln.unitPriceChf * 100) / 100;
|
||||||
|
return {
|
||||||
|
tenantName: null,
|
||||||
|
kind: "custom_line" as InvoiceLineKind,
|
||||||
|
description: ln.description.trim(),
|
||||||
|
quantity: ln.quantity,
|
||||||
|
unitLabel: null,
|
||||||
|
unitPriceChf: ln.unitPriceChf,
|
||||||
|
amountChf: amount,
|
||||||
|
metadata: { description: ln.description.trim() },
|
||||||
|
displayOrder: idx,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Subtotal is the algebraic sum (negative lines reduce it).
|
||||||
|
const subtotalChf = Math.round(
|
||||||
|
lines.reduce((s, l) => s + l.amountChf, 0) * 100
|
||||||
|
) / 100;
|
||||||
|
// VAT applies to the subtotal AFTER discounts (which is the
|
||||||
|
// legal default in CH — discounts reduce the taxable base).
|
||||||
|
const vatAmountChf = Math.round(subtotalChf * (vat.rate / 100) * 100) / 100;
|
||||||
|
const totalChf = Math.round((subtotalChf + vatAmountChf) * 100) / 100;
|
||||||
|
|
||||||
|
return {
|
||||||
|
zitadelOrgId,
|
||||||
|
source: "custom",
|
||||||
|
periodStart: null,
|
||||||
|
periodEnd: null,
|
||||||
|
issuedAt: `${issueDate}T00:00:00Z`,
|
||||||
|
dueAt: dueDate,
|
||||||
|
locale: payload.locale,
|
||||||
|
paymentMethod: payload.paymentMethod,
|
||||||
|
billingSnapshot: snapshot,
|
||||||
|
lines,
|
||||||
|
subtotalChf,
|
||||||
|
vatRate: vat.rate,
|
||||||
|
vatAmountChf,
|
||||||
|
totalChf,
|
||||||
|
warnings: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Issue a custom invoice from a draft. Three-step flow:
|
||||||
|
*
|
||||||
|
* 1. Compute totals + validate the payload (computeCustomInvoiceTotals)
|
||||||
|
* 2. Persist via createInvoice (allocates the number, inserts the
|
||||||
|
* row + lines, source='custom', issued_at honours the override)
|
||||||
|
* 3. Render PDF, send email — best-effort each. PDF render failure
|
||||||
|
* leaves the row in place with no PDF; admin can re-render. Email
|
||||||
|
* failure is logged.
|
||||||
|
*
|
||||||
|
* After successful persistence, the draft row is deleted (its job
|
||||||
|
* is done). If persistence fails, the draft stays so the admin can
|
||||||
|
* fix the issue and try again.
|
||||||
|
*/
|
||||||
|
export async function issueCustomInvoiceDraft(params: {
|
||||||
|
draftId: string;
|
||||||
|
issuedBy: string;
|
||||||
|
}): Promise<Invoice> {
|
||||||
|
const draft = await getInvoiceDraftById(params.draftId);
|
||||||
|
if (!draft) {
|
||||||
|
throw new CustomInvoiceValidationError(
|
||||||
|
`Draft not found: ${params.draftId}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const invoiceDraft = await computeCustomInvoiceTotals({
|
||||||
|
zitadelOrgId: draft.zitadelOrgId,
|
||||||
|
payload: draft.payload,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Two-pass: persist without PDF first, render against the canonical
|
||||||
|
// row (now has a number), then attach. Same pattern as the auto
|
||||||
|
// flow — keeps the PDF self-referential without juggling temporary
|
||||||
|
// numbers.
|
||||||
|
const placeholder = await createInvoice(invoiceDraft, null, null);
|
||||||
|
|
||||||
|
let pdfBuffer: Buffer | null = null;
|
||||||
|
try {
|
||||||
|
pdfBuffer = await renderInvoicePdf(
|
||||||
|
placeholder,
|
||||||
|
// Same pattern as the auto-cron generateInvoice: synthesize
|
||||||
|
// temporary ids for the PDF renderer. The real DB rows have
|
||||||
|
// these populated post-insert, but the renderer only reads
|
||||||
|
// them for React keys (display) and id-comparison-free
|
||||||
|
// operations, so synthetic values are fine.
|
||||||
|
invoiceDraft.lines.map((l, i) => ({
|
||||||
|
...l,
|
||||||
|
id: `tmp-${i}`,
|
||||||
|
invoiceId: placeholder.id,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
const filename = `${placeholder.invoiceNumber}.pdf`;
|
||||||
|
await updateInvoicePdf(placeholder.id, pdfBuffer, filename);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(
|
||||||
|
`Custom invoice ${placeholder.invoiceNumber} persisted but PDF render failed:`,
|
||||||
|
e
|
||||||
|
);
|
||||||
|
// Don't throw — the row exists. Admin can re-render via a
|
||||||
|
// future tool (Phase 8.5 or just by deleting+reissuing).
|
||||||
|
}
|
||||||
|
|
||||||
|
// Best-effort email.
|
||||||
|
try {
|
||||||
|
const snap = invoiceDraft.billingSnapshot;
|
||||||
|
if (snap.billingEmail) {
|
||||||
|
await sendInvoiceIssuedEmail({
|
||||||
|
to: snap.billingEmail,
|
||||||
|
contactName: snap.contactName || snap.companyName,
|
||||||
|
companyName: snap.companyName,
|
||||||
|
invoiceNumber: placeholder.invoiceNumber,
|
||||||
|
totalChf: placeholder.totalChf,
|
||||||
|
currency: "CHF",
|
||||||
|
dueAt: placeholder.dueAt,
|
||||||
|
lineCount: invoiceDraft.lines.length,
|
||||||
|
periodStart: null,
|
||||||
|
periodEnd: null,
|
||||||
|
locale: invoiceDraft.locale as "de" | "en" | "fr" | "it",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(
|
||||||
|
`Custom invoice ${placeholder.invoiceNumber} issued; email send failed.`,
|
||||||
|
e
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draft did its job — remove it. If this fails the issuance
|
||||||
|
// still stands (we already have a real invoice). Log and move on.
|
||||||
|
try {
|
||||||
|
await deleteInvoiceDraft(draft.id);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(
|
||||||
|
`Custom invoice ${placeholder.invoiceNumber} issued but draft ${draft.id} could not be deleted:`,
|
||||||
|
e
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return placeholder;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Preview a draft as a PDF without persisting an invoice. The PDF
|
||||||
|
* is rendered with a placeholder number ("DRAFT") and not stored
|
||||||
|
* anywhere — the caller streams the bytes back to the admin's
|
||||||
|
* browser for review.
|
||||||
|
*
|
||||||
|
* Throws CustomInvoiceValidationError if the draft isn't ready to
|
||||||
|
* issue (no lines, missing billing snapshot, etc.) so the editor
|
||||||
|
* can surface the problem before any rendering work.
|
||||||
|
*/
|
||||||
|
export async function renderCustomDraftPreview(
|
||||||
|
draftId: string
|
||||||
|
): Promise<Buffer> {
|
||||||
|
const draft = await getInvoiceDraftById(draftId);
|
||||||
|
if (!draft) {
|
||||||
|
throw new CustomInvoiceValidationError(`Draft not found: ${draftId}`);
|
||||||
|
}
|
||||||
|
const invoiceDraft = await computeCustomInvoiceTotals({
|
||||||
|
zitadelOrgId: draft.zitadelOrgId,
|
||||||
|
payload: draft.payload,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Render against a synthetic Invoice — same shape the persisted
|
||||||
|
// row would have, but with a DRAFT placeholder number. No DB
|
||||||
|
// writes. The PDF renderer doesn't care; it just consumes the
|
||||||
|
// Invoice + lines.
|
||||||
|
const fakeInvoice: Invoice = {
|
||||||
|
id: "preview",
|
||||||
|
invoiceNumber: "DRAFT",
|
||||||
|
zitadelOrgId: draft.zitadelOrgId,
|
||||||
|
source: "custom",
|
||||||
|
periodStart: null,
|
||||||
|
periodEnd: null,
|
||||||
|
issuedAt: invoiceDraft.issuedAt ?? new Date().toISOString(),
|
||||||
|
dueAt: invoiceDraft.dueAt,
|
||||||
|
subtotalChf: invoiceDraft.subtotalChf,
|
||||||
|
vatRate: invoiceDraft.vatRate,
|
||||||
|
vatAmountChf: invoiceDraft.vatAmountChf,
|
||||||
|
totalChf: invoiceDraft.totalChf,
|
||||||
|
status: "draft",
|
||||||
|
locale: invoiceDraft.locale,
|
||||||
|
paymentMethod: invoiceDraft.paymentMethod,
|
||||||
|
billingSnapshot: invoiceDraft.billingSnapshot,
|
||||||
|
stripePaymentIntentId: null,
|
||||||
|
pdfFilename: null,
|
||||||
|
hasPdf: false,
|
||||||
|
adminNotes: null,
|
||||||
|
paidAt: null,
|
||||||
|
paidBy: null,
|
||||||
|
paidMethodDetail: null,
|
||||||
|
voidReason: null,
|
||||||
|
voidedAt: null,
|
||||||
|
voidedBy: null,
|
||||||
|
refundedTotalChf: 0,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
return renderInvoicePdf(
|
||||||
|
fakeInvoice,
|
||||||
|
invoiceDraft.lines.map((l, i) => ({
|
||||||
|
...l,
|
||||||
|
id: `tmp-${i}`,
|
||||||
|
invoiceId: fakeInvoice.id,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
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;
|
||||||
|
}
|
||||||
1026
src/lib/db.ts
1026
src/lib/db.ts
File diff suppressed because it is too large
Load Diff
173
src/lib/email.ts
173
src/lib/email.ts
@@ -923,8 +923,8 @@ export async function sendInvoiceIssuedEmail(params: {
|
|||||||
currency: string; // "CHF" — passed for future-proofing
|
currency: string; // "CHF" — passed for future-proofing
|
||||||
dueAt: string; // ISO date
|
dueAt: string; // ISO date
|
||||||
lineCount: number;
|
lineCount: number;
|
||||||
periodStart: string; // ISO date
|
periodStart: string | null; // ISO date; null for custom invoices
|
||||||
periodEnd: string; // ISO date
|
periodEnd: string | null; // ISO date; null for custom invoices
|
||||||
locale: "de" | "en" | "fr" | "it";
|
locale: "de" | "en" | "fr" | "it";
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
// All four locales — the email is sent in the invoice's locale,
|
// All four locales — the email is sent in the invoice's locale,
|
||||||
@@ -960,7 +960,13 @@ export async function sendInvoiceIssuedEmail(params: {
|
|||||||
const safeCompany = escapeHtml(params.companyName);
|
const safeCompany = escapeHtml(params.companyName);
|
||||||
const safeNumber = escapeHtml(params.invoiceNumber);
|
const safeNumber = escapeHtml(params.invoiceNumber);
|
||||||
const totalFmt = `${params.currency} ${params.totalChf.toFixed(2)}`;
|
const totalFmt = `${params.currency} ${params.totalChf.toFixed(2)}`;
|
||||||
const periodFmt = `${params.periodStart.slice(0, 10)} → ${params.periodEnd.slice(0, 10)}`;
|
// Phase 8: period is null for custom invoices. When missing, the
|
||||||
|
// template skips the "Service period:" line entirely; otherwise
|
||||||
|
// it renders the date range as before.
|
||||||
|
const periodFmt =
|
||||||
|
params.periodStart && params.periodEnd
|
||||||
|
? `${params.periodStart.slice(0, 10)} → ${params.periodEnd.slice(0, 10)}`
|
||||||
|
: null;
|
||||||
const dueFmt = params.dueAt.slice(0, 10);
|
const dueFmt = params.dueAt.slice(0, 10);
|
||||||
|
|
||||||
// Both bodies built in the invoice's locale.
|
// Both bodies built in the invoice's locale.
|
||||||
@@ -977,7 +983,9 @@ export async function sendInvoiceIssuedEmail(params: {
|
|||||||
introByLocale[L],
|
introByLocale[L],
|
||||||
"",
|
"",
|
||||||
`${l.number}: ${params.invoiceNumber}`,
|
`${l.number}: ${params.invoiceNumber}`,
|
||||||
`${l.period}: ${periodFmt}`,
|
// Phase 8: omit the period line entirely for custom
|
||||||
|
// invoices (which have no billing period).
|
||||||
|
...(periodFmt ? [`${l.period}: ${periodFmt}`] : []),
|
||||||
`${l.total}: ${totalFmt}`,
|
`${l.total}: ${totalFmt}`,
|
||||||
`${l.due}: ${dueFmt}`,
|
`${l.due}: ${dueFmt}`,
|
||||||
`${l.lines}: ${params.lineCount}`,
|
`${l.lines}: ${params.lineCount}`,
|
||||||
@@ -995,7 +1003,7 @@ export async function sendInvoiceIssuedEmail(params: {
|
|||||||
<p>${escapeHtml(introByLocale[L])}</p>
|
<p>${escapeHtml(introByLocale[L])}</p>
|
||||||
<table style="width:100%; border-collapse:collapse; margin:16px 0; font-size:14px;">
|
<table style="width:100%; border-collapse:collapse; margin:16px 0; font-size:14px;">
|
||||||
<tr><td style="color:#888; padding:6px 0; width:120px;">${l.number}</td><td><strong>${safeNumber}</strong></td></tr>
|
<tr><td style="color:#888; padding:6px 0; width:120px;">${l.number}</td><td><strong>${safeNumber}</strong></td></tr>
|
||||||
<tr><td style="color:#888; padding:6px 0;">${l.period}</td><td>${escapeHtml(periodFmt)}</td></tr>
|
${periodFmt ? `<tr><td style="color:#888; padding:6px 0;">${l.period}</td><td>${escapeHtml(periodFmt)}</td></tr>` : ""}
|
||||||
<tr><td style="color:#888; padding:6px 0;">${l.total}</td><td style="color:#10B981; font-weight:600;">${escapeHtml(totalFmt)}</td></tr>
|
<tr><td style="color:#888; padding:6px 0;">${l.total}</td><td style="color:#10B981; font-weight:600;">${escapeHtml(totalFmt)}</td></tr>
|
||||||
<tr><td style="color:#888; padding:6px 0;">${l.due}</td><td>${escapeHtml(dueFmt)}</td></tr>
|
<tr><td style="color:#888; padding:6px 0;">${l.due}</td><td>${escapeHtml(dueFmt)}</td></tr>
|
||||||
<tr><td style="color:#888; padding:6px 0;">${l.lines}</td><td>${params.lineCount}</td></tr>
|
<tr><td style="color:#888; padding:6px 0;">${l.lines}</td><td>${params.lineCount}</td></tr>
|
||||||
@@ -1158,3 +1166,158 @@ 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
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),
|
unit_amount: chfToRappen(invoice.totalChf),
|
||||||
product_data: {
|
product_data: {
|
||||||
name: `Invoice ${invoice.invoiceNumber}`,
|
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)}`,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -258,3 +264,182 @@ export async function createCheckoutSessionForInvoice(params: {
|
|||||||
}
|
}
|
||||||
return { url: session.url, sessionId: session.id };
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -528,3 +528,113 @@ export async function registerCustomer(params: {
|
|||||||
throw err;
|
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,8 @@
|
|||||||
"saveChanges": "Änderungen speichern",
|
"saveChanges": "Änderungen speichern",
|
||||||
"billingVatNumber": "MWST-Nummer",
|
"billingVatNumber": "MWST-Nummer",
|
||||||
"billingVatHelp": "Ihre registrierte MWST-Nummer. Falls Ihre Firma von der MWST befreit ist, leer lassen und in den Notizen erläutern.",
|
"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."
|
||||||
},
|
},
|
||||||
"dashboard": {
|
"dashboard": {
|
||||||
"title": "Dashboard",
|
"title": "Dashboard",
|
||||||
@@ -479,7 +480,9 @@
|
|||||||
"billingTitle": "Abrechnung",
|
"billingTitle": "Abrechnung",
|
||||||
"billingDescription": "Adresse, MWST-Nummer und Rechnungs-E-Mail für alle Ihre Tenants.",
|
"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.",
|
"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": {
|
"settingsBilling": {
|
||||||
"title": "Rechnungsdaten",
|
"title": "Rechnungsdaten",
|
||||||
@@ -498,13 +501,32 @@
|
|||||||
"notesHint": "Referenznummern, Bestellnummern oder andere Angaben, die auf der Rechnung erscheinen sollen.",
|
"notesHint": "Referenznummern, Bestellnummern oder andere Angaben, die auf der Rechnung erscheinen sollen.",
|
||||||
"saveChanges": "Änderungen speichern",
|
"saveChanges": "Änderungen speichern",
|
||||||
"createBilling": "Rechnungsdaten speichern",
|
"createBilling": "Rechnungsdaten speichern",
|
||||||
"saving": "Speichern…",
|
"saving": "Wird gespeichert…",
|
||||||
"saved": "Gespeichert.",
|
"saved": "Gespeichert.",
|
||||||
"missingRequired": "Bitte alle Pflichtfelder ausfüllen.",
|
"missingRequired": "Bitte alle Pflichtfelder ausfüllen.",
|
||||||
"invalidCountry": "Ländercode muss aus 2 Buchstaben bestehen (z.B. CH).",
|
"invalidCountry": "Ländercode muss aus 2 Buchstaben bestehen (z.B. CH).",
|
||||||
"invalidEmail": "Bitte eine gültige E-Mail-Adresse eingeben.",
|
"invalidEmail": "Bitte eine gültige E-Mail-Adresse eingeben.",
|
||||||
"fullNameLabel": "Vor- und Nachname",
|
"fullNameLabel": "Vor- und Nachname",
|
||||||
"subtitlePersonal": "Ihre Rechnungsadresse und Rechnungskontakt. Erforderlich, bevor Rechnungen ausgestellt werden können."
|
"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."
|
||||||
},
|
},
|
||||||
"support": {
|
"support": {
|
||||||
"title": "Support",
|
"title": "Support",
|
||||||
@@ -573,7 +595,7 @@
|
|||||||
"subtitle": "Plattform-Preise verwalten, Rechnungen generieren und den Rechnungsstatus aller Organisationen prüfen.",
|
"subtitle": "Plattform-Preise verwalten, Rechnungen generieren und den Rechnungsstatus aller Organisationen prüfen.",
|
||||||
"backToAdmin": "Zurück zur Verwaltung",
|
"backToAdmin": "Zurück zur Verwaltung",
|
||||||
"backToBilling": "Zurück zur Abrechnung",
|
"backToBilling": "Zurück zur Abrechnung",
|
||||||
"backToInvoices": "Zurück zu den Rechnungen",
|
"backToInvoices": "Zurück zu Rechnungen",
|
||||||
"totalOpenBalance": "Offener Saldo gesamt",
|
"totalOpenBalance": "Offener Saldo gesamt",
|
||||||
"orgsWithBalance": "Organisationen mit Saldo",
|
"orgsWithBalance": "Organisationen mit Saldo",
|
||||||
"overdueInvoices": "Überfällige Rechnungen",
|
"overdueInvoices": "Überfällige Rechnungen",
|
||||||
@@ -668,7 +690,98 @@
|
|||||||
"lineItemsTitle": "Positionen",
|
"lineItemsTitle": "Positionen",
|
||||||
"billToSnapshotTitle": "Rechnungsempfänger",
|
"billToSnapshotTitle": "Rechnungsempfänger",
|
||||||
"setupFeeCol": "Einrichtungsgebühr",
|
"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…"
|
||||||
},
|
},
|
||||||
"skillCostDialog": {
|
"skillCostDialog": {
|
||||||
"title": "Aktivierungskosten bestätigen",
|
"title": "Aktivierungskosten bestätigen",
|
||||||
@@ -741,14 +854,26 @@
|
|||||||
"paid": "Bezahlt",
|
"paid": "Bezahlt",
|
||||||
"overdue": "Überfällig",
|
"overdue": "Überfällig",
|
||||||
"void": "Storniert",
|
"void": "Storniert",
|
||||||
"uncollectible": "Uneinbringlich"
|
"uncollectible": "Uneinbringlich",
|
||||||
|
"partially_refunded": "Teilrückerstattung",
|
||||||
|
"fully_refunded": "Vollständig rückerstattet"
|
||||||
},
|
},
|
||||||
"payWithCard": "Mit Karte bezahlen",
|
"payWithCard": "Mit Karte bezahlen",
|
||||||
"redirectingToStripe": "Weiterleitung…",
|
"redirectingToStripe": "Weiterleitung…",
|
||||||
"paymentReceived": "Zahlung erhalten — vielen Dank!",
|
"paymentReceived": "Zahlung erhalten — vielen Dank!",
|
||||||
"paymentCancelled": "Zahlung abgebrochen.",
|
"paymentCancelled": "Zahlung abgebrochen.",
|
||||||
"configureBillingCta": "Rechnungsdaten einrichten",
|
"configureBillingCta": "Rechnungsdaten einrichten",
|
||||||
"noBillingConfigNonOwner": "Nur der Organisations-Owner kann die Rechnungsdaten einrichten. Bitte wenden Sie sich an diese Person, um diesen Schritt abzuschliessen."
|
"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": {
|
"adminCron": {
|
||||||
"title": "Abrechnungsautomatisierung",
|
"title": "Abrechnungsautomatisierung",
|
||||||
@@ -781,5 +906,20 @@
|
|||||||
},
|
},
|
||||||
"failureBannerTitle": "Fehler in jüngsten Automatisierungsläufen",
|
"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."
|
"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,8 @@
|
|||||||
"saveChanges": "Save changes",
|
"saveChanges": "Save changes",
|
||||||
"billingVatNumber": "VAT number",
|
"billingVatNumber": "VAT number",
|
||||||
"billingVatHelp": "Your registered VAT identifier. If your company is VAT-exempt, leave blank and explain in the notes field.",
|
"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:"
|
||||||
},
|
},
|
||||||
"dashboard": {
|
"dashboard": {
|
||||||
"title": "Dashboard",
|
"title": "Dashboard",
|
||||||
@@ -479,7 +480,9 @@
|
|||||||
"billingTitle": "Billing",
|
"billingTitle": "Billing",
|
||||||
"billingDescription": "Address, VAT number, and invoice email used for all your tenants.",
|
"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.",
|
"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": {
|
"settingsBilling": {
|
||||||
"title": "Billing details",
|
"title": "Billing details",
|
||||||
@@ -504,7 +507,26 @@
|
|||||||
"invalidCountry": "Country code must be 2 letters (e.g. CH).",
|
"invalidCountry": "Country code must be 2 letters (e.g. CH).",
|
||||||
"invalidEmail": "Please enter a valid email address.",
|
"invalidEmail": "Please enter a valid email address.",
|
||||||
"fullNameLabel": "Full name",
|
"fullNameLabel": "Full name",
|
||||||
"subtitlePersonal": "Your billing address and invoice contact. Required before invoices can be issued."
|
"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."
|
||||||
},
|
},
|
||||||
"support": {
|
"support": {
|
||||||
"title": "Support",
|
"title": "Support",
|
||||||
@@ -572,8 +594,8 @@
|
|||||||
"title": "Billing administration",
|
"title": "Billing administration",
|
||||||
"subtitle": "Manage platform pricing, generate invoices, and review billing status across all organizations.",
|
"subtitle": "Manage platform pricing, generate invoices, and review billing status across all organizations.",
|
||||||
"backToAdmin": "Back to Admin",
|
"backToAdmin": "Back to Admin",
|
||||||
"backToBilling": "Back to Billing",
|
"backToBilling": "Back to billing",
|
||||||
"backToInvoices": "Back to Invoices",
|
"backToInvoices": "Back to invoices",
|
||||||
"totalOpenBalance": "Total open balance",
|
"totalOpenBalance": "Total open balance",
|
||||||
"orgsWithBalance": "Orgs with balance",
|
"orgsWithBalance": "Orgs with balance",
|
||||||
"overdueInvoices": "Overdue invoices",
|
"overdueInvoices": "Overdue invoices",
|
||||||
@@ -668,7 +690,98 @@
|
|||||||
"lineItemsTitle": "Line items",
|
"lineItemsTitle": "Line items",
|
||||||
"billToSnapshotTitle": "Billed to",
|
"billToSnapshotTitle": "Billed to",
|
||||||
"setupFeeCol": "Setup fee",
|
"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…"
|
||||||
},
|
},
|
||||||
"skillCostDialog": {
|
"skillCostDialog": {
|
||||||
"title": "Confirm activation cost",
|
"title": "Confirm activation cost",
|
||||||
@@ -741,14 +854,26 @@
|
|||||||
"paid": "Paid",
|
"paid": "Paid",
|
||||||
"overdue": "Overdue",
|
"overdue": "Overdue",
|
||||||
"void": "Void",
|
"void": "Void",
|
||||||
"uncollectible": "Uncollectible"
|
"uncollectible": "Uncollectible",
|
||||||
|
"partially_refunded": "Partially refunded",
|
||||||
|
"fully_refunded": "Fully refunded"
|
||||||
},
|
},
|
||||||
"payWithCard": "Pay with card",
|
"payWithCard": "Pay with card",
|
||||||
"redirectingToStripe": "Redirecting…",
|
"redirectingToStripe": "Redirecting…",
|
||||||
"paymentReceived": "Payment received — thank you!",
|
"paymentReceived": "Payment received — thank you!",
|
||||||
"paymentCancelled": "Payment cancelled.",
|
"paymentCancelled": "Payment cancelled.",
|
||||||
"configureBillingCta": "Configure billing details",
|
"configureBillingCta": "Configure billing details",
|
||||||
"noBillingConfigNonOwner": "Only the organization owner can configure billing details. Please contact them to complete this step."
|
"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": {
|
"adminCron": {
|
||||||
"title": "Billing automation",
|
"title": "Billing automation",
|
||||||
@@ -781,5 +906,20 @@
|
|||||||
},
|
},
|
||||||
"failureBannerTitle": "Recent automation failures detected",
|
"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."
|
"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,8 @@
|
|||||||
"saveChanges": "Enregistrer les modifications",
|
"saveChanges": "Enregistrer les modifications",
|
||||||
"billingVatNumber": "Numéro de TVA",
|
"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.",
|
"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"
|
||||||
},
|
},
|
||||||
"dashboard": {
|
"dashboard": {
|
||||||
"title": "Tableau de bord",
|
"title": "Tableau de bord",
|
||||||
@@ -479,7 +480,9 @@
|
|||||||
"billingTitle": "Facturation",
|
"billingTitle": "Facturation",
|
||||||
"billingDescription": "Adresse, numéro de TVA et e-mail de facturation utilisés pour tous vos locataires.",
|
"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.",
|
"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": {
|
"settingsBilling": {
|
||||||
"title": "Informations de facturation",
|
"title": "Informations de facturation",
|
||||||
@@ -504,7 +507,26 @@
|
|||||||
"invalidCountry": "Le code pays doit comporter 2 lettres (p. ex. CH).",
|
"invalidCountry": "Le code pays doit comporter 2 lettres (p. ex. CH).",
|
||||||
"invalidEmail": "Veuillez saisir une adresse e-mail valide.",
|
"invalidEmail": "Veuillez saisir une adresse e-mail valide.",
|
||||||
"fullNameLabel": "Nom et prénom",
|
"fullNameLabel": "Nom et prénom",
|
||||||
"subtitlePersonal": "Votre adresse de facturation et votre contact. Requis avant l'émission de toute facture."
|
"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."
|
||||||
},
|
},
|
||||||
"support": {
|
"support": {
|
||||||
"title": "Support",
|
"title": "Support",
|
||||||
@@ -668,7 +690,98 @@
|
|||||||
"lineItemsTitle": "Lignes",
|
"lineItemsTitle": "Lignes",
|
||||||
"billToSnapshotTitle": "Destinataire",
|
"billToSnapshotTitle": "Destinataire",
|
||||||
"setupFeeCol": "Frais de configuration",
|
"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…"
|
||||||
},
|
},
|
||||||
"skillCostDialog": {
|
"skillCostDialog": {
|
||||||
"title": "Confirmer le coût d'activation",
|
"title": "Confirmer le coût d'activation",
|
||||||
@@ -734,21 +847,33 @@
|
|||||||
"subtotalLabel": "Sous-total",
|
"subtotalLabel": "Sous-total",
|
||||||
"vatLabel": "TVA ({rate}%)",
|
"vatLabel": "TVA ({rate}%)",
|
||||||
"totalLabel": "Total",
|
"totalLabel": "Total",
|
||||||
"downloadPdf": "Télécharger le PDF",
|
"downloadPdf": "Télécharger PDF",
|
||||||
"status": {
|
"status": {
|
||||||
"draft": "Brouillon",
|
"draft": "Brouillon",
|
||||||
"open": "Ouverte",
|
"open": "Ouverte",
|
||||||
"paid": "Payée",
|
"paid": "Payée",
|
||||||
"overdue": "En retard",
|
"overdue": "En retard",
|
||||||
"void": "Annulée",
|
"void": "Annulée",
|
||||||
"uncollectible": "Irrécouvrable"
|
"uncollectible": "Irrécouvrable",
|
||||||
|
"partially_refunded": "Partiellement remboursée",
|
||||||
|
"fully_refunded": "Entièrement remboursée"
|
||||||
},
|
},
|
||||||
"payWithCard": "Payer par carte",
|
"payWithCard": "Payer par carte",
|
||||||
"redirectingToStripe": "Redirection…",
|
"redirectingToStripe": "Redirection…",
|
||||||
"paymentReceived": "Paiement reçu — merci !",
|
"paymentReceived": "Paiement reçu — merci !",
|
||||||
"paymentCancelled": "Paiement annulé.",
|
"paymentCancelled": "Paiement annulé.",
|
||||||
"configureBillingCta": "Configurer les informations de facturation",
|
"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."
|
"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": {
|
"adminCron": {
|
||||||
"title": "Automatisation de la facturation",
|
"title": "Automatisation de la facturation",
|
||||||
@@ -781,5 +906,20 @@
|
|||||||
},
|
},
|
||||||
"failureBannerTitle": "Échecs récents détectés",
|
"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."
|
"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,8 @@
|
|||||||
"saveChanges": "Salva modifiche",
|
"saveChanges": "Salva modifiche",
|
||||||
"billingVatNumber": "Partita IVA",
|
"billingVatNumber": "Partita IVA",
|
||||||
"billingVatHelp": "Il tuo identificativo IVA registrato. Se la tua azienda è esente IVA, lascia vuoto e spiega nelle note.",
|
"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."
|
||||||
},
|
},
|
||||||
"dashboard": {
|
"dashboard": {
|
||||||
"title": "Dashboard",
|
"title": "Dashboard",
|
||||||
@@ -479,7 +480,9 @@
|
|||||||
"billingTitle": "Fatturazione",
|
"billingTitle": "Fatturazione",
|
||||||
"billingDescription": "Indirizzo, numero di IVA ed e-mail di fatturazione usati per tutti i tuoi tenant.",
|
"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.",
|
"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": {
|
"settingsBilling": {
|
||||||
"title": "Dati di fatturazione",
|
"title": "Dati di fatturazione",
|
||||||
@@ -504,7 +507,26 @@
|
|||||||
"invalidCountry": "Il codice paese deve essere di 2 lettere (es. CH).",
|
"invalidCountry": "Il codice paese deve essere di 2 lettere (es. CH).",
|
||||||
"invalidEmail": "Inserisci un indirizzo e-mail valido.",
|
"invalidEmail": "Inserisci un indirizzo e-mail valido.",
|
||||||
"fullNameLabel": "Nome e cognome",
|
"fullNameLabel": "Nome e cognome",
|
||||||
"subtitlePersonal": "Il tuo indirizzo di fatturazione e contatto. Necessari prima che possano essere emesse fatture."
|
"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."
|
||||||
},
|
},
|
||||||
"support": {
|
"support": {
|
||||||
"title": "Supporto",
|
"title": "Supporto",
|
||||||
@@ -668,7 +690,98 @@
|
|||||||
"lineItemsTitle": "Righe",
|
"lineItemsTitle": "Righe",
|
||||||
"billToSnapshotTitle": "Destinatario",
|
"billToSnapshotTitle": "Destinatario",
|
||||||
"setupFeeCol": "Spese di attivazione",
|
"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…"
|
||||||
},
|
},
|
||||||
"skillCostDialog": {
|
"skillCostDialog": {
|
||||||
"title": "Conferma costi di attivazione",
|
"title": "Conferma costi di attivazione",
|
||||||
@@ -741,14 +854,26 @@
|
|||||||
"paid": "Pagata",
|
"paid": "Pagata",
|
||||||
"overdue": "In ritardo",
|
"overdue": "In ritardo",
|
||||||
"void": "Annullata",
|
"void": "Annullata",
|
||||||
"uncollectible": "Inesigibile"
|
"uncollectible": "Inesigibile",
|
||||||
|
"partially_refunded": "Rimborsata parzialmente",
|
||||||
|
"fully_refunded": "Rimborsata integralmente"
|
||||||
},
|
},
|
||||||
"payWithCard": "Paga con carta",
|
"payWithCard": "Paga con carta",
|
||||||
"redirectingToStripe": "Reindirizzamento…",
|
"redirectingToStripe": "Reindirizzamento…",
|
||||||
"paymentReceived": "Pagamento ricevuto — grazie!",
|
"paymentReceived": "Pagamento ricevuto — grazie!",
|
||||||
"paymentCancelled": "Pagamento annullato.",
|
"paymentCancelled": "Pagamento annullato.",
|
||||||
"configureBillingCta": "Configura dati di fatturazione",
|
"configureBillingCta": "Configura dati di fatturazione",
|
||||||
"noBillingConfigNonOwner": "Solo il proprietario dell'organizzazione può configurare i dati di fatturazione. Contattalo per completare questo passaggio."
|
"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": {
|
"adminCron": {
|
||||||
"title": "Automazione fatturazione",
|
"title": "Automazione fatturazione",
|
||||||
@@ -781,5 +906,20 @@
|
|||||||
},
|
},
|
||||||
"failureBannerTitle": "Fallimenti recenti rilevati",
|
"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."
|
"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 {
|
export interface OrgBilling {
|
||||||
zitadelOrgId: string;
|
zitadelOrgId: string;
|
||||||
companyName: 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;
|
streetAddress: string;
|
||||||
postalCode: string;
|
postalCode: string;
|
||||||
city: string;
|
city: string;
|
||||||
@@ -524,6 +530,29 @@ export interface OrgBillingConfig {
|
|||||||
stripeCustomerId: string | null;
|
stripeCustomerId: string | null;
|
||||||
autoInvoiceEnabled: boolean;
|
autoInvoiceEnabled: boolean;
|
||||||
autoRemindersEnabled: 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;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
@@ -538,10 +567,57 @@ export type InvoiceStatus =
|
|||||||
| "paid"
|
| "paid"
|
||||||
| "overdue"
|
| "overdue"
|
||||||
| "void"
|
| "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";
|
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.
|
// Phase 5 — Cron run history rows for the admin /admin/cron page.
|
||||||
export type CronRunKind = "monthly_issue" | "reminders";
|
export type CronRunKind = "monthly_issue" | "reminders";
|
||||||
export interface CronRun {
|
export interface CronRun {
|
||||||
@@ -563,7 +639,11 @@ export type InvoiceLineKind =
|
|||||||
| "threema_messages"
|
| "threema_messages"
|
||||||
| "skill_usage"
|
| "skill_usage"
|
||||||
| "skill_setup"
|
| "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
|
* Snapshot of the customer's billing details captured at invoice
|
||||||
@@ -575,6 +655,7 @@ export type InvoiceLineKind =
|
|||||||
*/
|
*/
|
||||||
export interface InvoiceBillingSnapshot {
|
export interface InvoiceBillingSnapshot {
|
||||||
companyName: string;
|
companyName: string;
|
||||||
|
contactName: string | null;
|
||||||
streetAddress: string;
|
streetAddress: string;
|
||||||
postalCode: string;
|
postalCode: string;
|
||||||
city: string;
|
city: string;
|
||||||
@@ -615,8 +696,19 @@ export interface Invoice {
|
|||||||
id: string;
|
id: string;
|
||||||
invoiceNumber: string;
|
invoiceNumber: string;
|
||||||
zitadelOrgId: 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;
|
issuedAt: string;
|
||||||
dueAt: string;
|
dueAt: string;
|
||||||
subtotalChf: number;
|
subtotalChf: number;
|
||||||
@@ -634,6 +726,16 @@ export interface Invoice {
|
|||||||
paidAt: string | null;
|
paidAt: string | null;
|
||||||
paidBy: string | null;
|
paidBy: string | null;
|
||||||
paidMethodDetail: 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;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -651,8 +753,21 @@ export interface InvoiceDetail {
|
|||||||
*/
|
*/
|
||||||
export interface InvoiceDraft {
|
export interface InvoiceDraft {
|
||||||
zitadelOrgId: string;
|
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;
|
dueAt: string;
|
||||||
locale: string;
|
locale: string;
|
||||||
paymentMethod: InvoicePaymentMethod;
|
paymentMethod: InvoicePaymentMethod;
|
||||||
@@ -670,6 +785,65 @@ export interface InvoiceDraft {
|
|||||||
warnings: string[];
|
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
|
// Skill activation requests — manual provisioning queue
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
Reference in New Issue
Block a user