Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7fac3c3aa8 | |||
| bff3aad1ca | |||
| f2a9637058 | |||
| bfc2194e24 | |||
| 6f8de14b4a | |||
| a6ed74b1be | |||
| 1741574eb2 | |||
| d78f9f2696 | |||
| 3fe3597553 | |||
| 9243beddd3 | |||
| a6c3c42ec9 | |||
| ee6bb89fb6 | |||
| ad4f614130 | |||
| 8e7691d38a | |||
| 9939f75c03 | |||
| e69b68b73c | |||
| 41c1553b1f | |||
| 38f4c3243e | |||
| ed915ec539 | |||
| 667617296b | |||
| 1c61111da3 |
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>
|
||||
);
|
||||
}
|
||||
72
src/app/[locale]/admin/billing/invoices/new/page.tsx
Normal file
72
src/app/[locale]/admin/billing/invoices/new/page.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { getSessionUser } from "@/lib/session";
|
||||
import { listTenants } from "@/lib/k8s";
|
||||
import { getOrgBilling } from "@/lib/db";
|
||||
import { BackLink } from "@/components/ui/back-link";
|
||||
import { NewInvoiceForm } from "@/components/admin/billing/new-invoice-form";
|
||||
|
||||
/**
|
||||
* /admin/billing/invoices/new — entry point for the custom-invoice
|
||||
* flow. The admin picks an org, clicks Continue, and lands on the
|
||||
* editor at /admin/billing/invoice-drafts/<new-id>.
|
||||
*
|
||||
* Phase 8. Org list is built from tenant labels + each org's
|
||||
* billing config (we need the company name and the
|
||||
* has-billing-snapshot flag to gate the picker — orgs without a
|
||||
* snapshot can't be invoiced until they complete onboarding or
|
||||
* admin sets the billing info manually).
|
||||
*/
|
||||
export default async function NewInvoicePage() {
|
||||
const user = await getSessionUser();
|
||||
if (!user) redirect("/login");
|
||||
if (!user.isPlatform) redirect("/dashboard");
|
||||
const t = await getTranslations("adminBilling");
|
||||
|
||||
// Tenants give us org membership; getOrgBilling per org gives us
|
||||
// the snapshot status. We dedupe by org id since one org can own
|
||||
// many tenants.
|
||||
const tenants = await listTenants();
|
||||
const orgIds = new Set<string>();
|
||||
for (const tnt of tenants) {
|
||||
const oid = tnt.metadata.labels?.["pieced.ch/zitadel-org-id"];
|
||||
if (oid) orgIds.add(oid);
|
||||
}
|
||||
const orgs = await Promise.all(
|
||||
Array.from(orgIds).map(async (oid) => {
|
||||
const billing = await getOrgBilling(oid).catch(() => null);
|
||||
return {
|
||||
zitadelOrgId: oid,
|
||||
companyName: billing?.companyName ?? null,
|
||||
country: billing?.country ?? null,
|
||||
hasBillingAddress: !!billing && !!billing.companyName,
|
||||
};
|
||||
})
|
||||
);
|
||||
// Sort: orgs with billing first (admin's most likely target),
|
||||
// then alphabetically by company name.
|
||||
orgs.sort((a, b) => {
|
||||
if (a.hasBillingAddress !== b.hasBillingAddress) {
|
||||
return a.hasBillingAddress ? -1 : 1;
|
||||
}
|
||||
return (a.companyName ?? "").localeCompare(b.companyName ?? "");
|
||||
});
|
||||
|
||||
return (
|
||||
<main className="max-w-2xl mx-auto px-6 py-8">
|
||||
<BackLink
|
||||
href="/admin/billing/invoices"
|
||||
label={t("backToInvoices")}
|
||||
/>
|
||||
<div className="mb-6">
|
||||
<h1 className="font-display text-2xl font-semibold accent-rule">
|
||||
{t("newInvoicePageTitle")}
|
||||
</h1>
|
||||
<p className="text-sm text-text-secondary mt-3">
|
||||
{t("newInvoicePageSubtitle")}
|
||||
</p>
|
||||
</div>
|
||||
<NewInvoiceForm orgs={orgs} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
83
src/app/[locale]/admin/billing/orgs/page.tsx
Normal file
83
src/app/[locale]/admin/billing/orgs/page.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { getSessionUser } from "@/lib/session";
|
||||
import { getOrgBilling, getOrgBillingConfig } from "@/lib/db";
|
||||
import { listTenants } from "@/lib/k8s";
|
||||
import { BackLink } from "@/components/ui/back-link";
|
||||
import { OrgPaymentModeList } from "@/components/admin/billing/org-payment-mode-list";
|
||||
|
||||
/**
|
||||
* /admin/billing/orgs — list of orgs with their payment mode
|
||||
* settings.
|
||||
*
|
||||
* Phase 9b-2. The customer's /settings/billing only exposes the
|
||||
* saved-card flow (auto-pay). Bank-transfer mode is admin-only —
|
||||
* customer must contact support to request it, admin flips the
|
||||
* pay_by_invoice flag here. Also exposes the auto_charge_enabled
|
||||
* pause-switch for support situations.
|
||||
*
|
||||
* The page is intentionally minimal: org name, country, current
|
||||
* mode, has-saved-card indicator, and toggles. Detail-level work
|
||||
* (open balances, invoice list) is on the existing pages
|
||||
* (/admin/billing, /admin/billing/invoices).
|
||||
*/
|
||||
export default async function AdminOrgsPaymentModePage() {
|
||||
const user = await getSessionUser();
|
||||
if (!user) redirect("/login");
|
||||
if (!user.isPlatform) redirect("/dashboard");
|
||||
const t = await getTranslations("adminBilling");
|
||||
|
||||
// Same org-discovery pattern as /api/admin/billing/orgs: tenant
|
||||
// labels are the source of truth for org membership. We dedupe by
|
||||
// org id since one org can own many tenants.
|
||||
const tenants = await listTenants().catch(() => []);
|
||||
const orgIds = new Set<string>();
|
||||
for (const tnt of tenants) {
|
||||
const oid = tnt.metadata.labels?.["pieced.ch/zitadel-org-id"];
|
||||
if (oid) orgIds.add(oid);
|
||||
}
|
||||
const orgs = await Promise.all(
|
||||
Array.from(orgIds).map(async (oid) => {
|
||||
const [billing, cfg] = await Promise.all([
|
||||
getOrgBilling(oid).catch(() => null),
|
||||
getOrgBillingConfig(oid),
|
||||
]);
|
||||
return {
|
||||
zitadelOrgId: oid,
|
||||
companyName: billing?.companyName ?? null,
|
||||
country: billing?.country ?? null,
|
||||
hasSavedCard: !!cfg.stripeDefaultPaymentMethodId,
|
||||
cardLabel:
|
||||
cfg.stripePmBrand && cfg.stripePmLast4
|
||||
? `${cfg.stripePmBrand} •••• ${cfg.stripePmLast4}`
|
||||
: null,
|
||||
payByInvoice: !!cfg.payByInvoice,
|
||||
autoChargeEnabled: cfg.autoChargeEnabled !== false,
|
||||
};
|
||||
})
|
||||
);
|
||||
// Sort: orgs with billing first (most actionable), then by name.
|
||||
orgs.sort((a, b) => {
|
||||
if (!!a.companyName !== !!b.companyName) {
|
||||
return a.companyName ? -1 : 1;
|
||||
}
|
||||
return (a.companyName ?? a.zitadelOrgId).localeCompare(
|
||||
b.companyName ?? b.zitadelOrgId
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
<main className="max-w-6xl mx-auto px-6 py-8">
|
||||
<BackLink href="/admin/billing" label={t("backToBilling")} />
|
||||
<div className="mb-6">
|
||||
<h1 className="font-display text-2xl font-semibold accent-rule">
|
||||
{t("orgsPageTitle")}
|
||||
</h1>
|
||||
<p className="text-sm text-text-secondary mt-3">
|
||||
{t("orgsPageSubtitle")}
|
||||
</p>
|
||||
</div>
|
||||
<OrgPaymentModeList orgs={orgs} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -66,7 +66,7 @@ export default async function AdminBillingPage() {
|
||||
</div>
|
||||
|
||||
{/* Sub-tool cards */}
|
||||
<div className="grid grid-cols-3 gap-4 mb-8 animate-in animate-in-delay-2">
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8 animate-in animate-in-delay-2">
|
||||
<Link href="/admin/billing/pricing">
|
||||
<Card interactive>
|
||||
<div className="font-semibold mb-1">{t("pricingTitle")}</div>
|
||||
@@ -85,6 +85,12 @@ export default async function AdminBillingPage() {
|
||||
<div className="text-sm text-text-muted">{t("invoicesDesc")}</div>
|
||||
</Card>
|
||||
</Link>
|
||||
<Link href="/admin/billing/orgs">
|
||||
<Card interactive>
|
||||
<div className="font-semibold mb-1">{t("orgsTitle")}</div>
|
||||
<div className="text-sm text-text-muted">{t("orgsDesc")}</div>
|
||||
</Card>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Orgs with open balance */}
|
||||
@@ -92,6 +98,7 @@ export default async function AdminBillingPage() {
|
||||
<div className="animate-in animate-in-delay-3">
|
||||
<h2 className="text-lg font-semibold mb-3">{t("balancesTitle")}</h2>
|
||||
<Card>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="text-xs text-text-muted text-left">
|
||||
<tr>
|
||||
@@ -120,6 +127,7 @@ export default async function AdminBillingPage() {
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -5,6 +5,11 @@ import { listTenants } from "@/lib/k8s";
|
||||
import { countPendingSkillActivationRequests } from "@/lib/db";
|
||||
import { AdminPanel } from "@/components/admin/admin-panel";
|
||||
|
||||
export async function generateMetadata() {
|
||||
const t = await getTranslations("common");
|
||||
return { title: t("admin") };
|
||||
}
|
||||
|
||||
export default async function AdminPage() {
|
||||
const user = await getSessionUser();
|
||||
if (!user) redirect("/login");
|
||||
|
||||
@@ -26,6 +26,11 @@ import { RunningTotalWidget } from "@/components/billing/running-total-widget";
|
||||
* Anyone signed in can view this. The data is org-scoped; even
|
||||
* non-owner team members see the same view.
|
||||
*/
|
||||
export async function generateMetadata() {
|
||||
const t = await getTranslations("common");
|
||||
return { title: t("billing") };
|
||||
}
|
||||
|
||||
export default async function CustomerBillingPage() {
|
||||
const user = await getSessionUser();
|
||||
if (!user) redirect("/login");
|
||||
|
||||
@@ -4,7 +4,7 @@ import { redirect } from "next/navigation";
|
||||
import { OnboardingFlow } from "@/components/onboarding/onboarding-flow";
|
||||
import { BackLink } from "@/components/ui/back-link";
|
||||
import { listTenants } from "@/lib/k8s";
|
||||
import { listActiveTenantRequestsByOrgId, getOrgBilling } from "@/lib/db";
|
||||
import { listActiveTenantRequestsByOrgId, getOrgBilling, getPlatformPricing } from "@/lib/db";
|
||||
import { personalAccountAtCapacity } from "@/lib/personal-org";
|
||||
|
||||
/**
|
||||
@@ -55,7 +55,10 @@ export default async function NewInstancePage() {
|
||||
}
|
||||
|
||||
const t = await getTranslations("dashboard");
|
||||
const orgBilling = await getOrgBilling(user.orgId);
|
||||
const [orgBilling, pricing] = await Promise.all([
|
||||
getOrgBilling(user.orgId),
|
||||
getPlatformPricing(),
|
||||
]);
|
||||
const hasOrgBilling = orgBilling !== null;
|
||||
|
||||
return (
|
||||
@@ -77,6 +80,7 @@ export default async function NewInstancePage() {
|
||||
userEmail={user.email}
|
||||
hasOrgBilling={hasOrgBilling}
|
||||
existingOrgBilling={orgBilling}
|
||||
setupFeeChf={pricing.tenantSetupFeeChf}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
listActiveTenantRequestsByOrgId,
|
||||
syncProvisioningStatuses,
|
||||
getOrgBilling,
|
||||
getPlatformPricing,
|
||||
} from "@/lib/db";
|
||||
import {
|
||||
listVisibleTenants,
|
||||
@@ -21,6 +22,11 @@ import { ProvisioningStatus } from "@/components/onboarding/provisioning-status"
|
||||
import { formatDateTime } from "@/lib/format";
|
||||
import Link from "next/link";
|
||||
|
||||
export async function generateMetadata() {
|
||||
const t = await getTranslations("common");
|
||||
return { title: t("dashboard") };
|
||||
}
|
||||
|
||||
export default async function DashboardPage() {
|
||||
const user = await getSessionUser();
|
||||
if (!user) redirect("/login");
|
||||
@@ -192,6 +198,7 @@ export default async function DashboardPage() {
|
||||
// component.
|
||||
const orgBilling = await getOrgBilling(user.orgId);
|
||||
const hasOrgBilling = orgBilling !== null;
|
||||
const platformPricing = await getPlatformPricing();
|
||||
|
||||
// Pending requests that don't yet have a tenant CR. Once the CR
|
||||
// exists, the tenant card carries the live phase, so a separate
|
||||
@@ -318,6 +325,7 @@ export default async function DashboardPage() {
|
||||
userEmail={user.email}
|
||||
hasOrgBilling={hasOrgBilling}
|
||||
existingOrgBilling={orgBilling}
|
||||
setupFeeChf={platformPricing.tenantSetupFeeChf}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -341,7 +349,7 @@ export default async function DashboardPage() {
|
||||
{canCreate && (
|
||||
<Link
|
||||
href="/dashboard/new"
|
||||
className="shrink-0 inline-flex items-center gap-1.5 py-2 px-4 bg-accent text-white text-xs font-medium rounded-lg hover:bg-accent-dim transition-colors"
|
||||
className="shrink-0 inline-flex items-center gap-1.5 py-2 px-4 bg-accent text-surface-0 text-xs font-medium rounded-lg hover:bg-accent-dim transition-colors"
|
||||
>
|
||||
<span>+</span> {t("createInstance")}
|
||||
</Link>
|
||||
|
||||
72
src/app/[locale]/error.tsx
Normal file
72
src/app/[locale]/error.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Link } from "@/i18n/navigation";
|
||||
|
||||
/**
|
||||
* Error boundary for the [locale] segment. Catches render/data errors
|
||||
* thrown by any page below the locale layout (which is where K8s, DB,
|
||||
* LiteLLM and Stripe calls happen). Renders inside NextIntlClientProvider,
|
||||
* so translations are available. Root-layout failures fall through to
|
||||
* global-error.tsx instead.
|
||||
*/
|
||||
export default function LocaleError({
|
||||
error,
|
||||
reset,
|
||||
}: {
|
||||
error: Error & { digest?: string };
|
||||
reset: () => void;
|
||||
}) {
|
||||
const t = useTranslations("errors");
|
||||
|
||||
useEffect(() => {
|
||||
// Surface the error for log scraping; the digest correlates with
|
||||
// the server-side stack in production.
|
||||
console.error("Portal error boundary:", error);
|
||||
}, [error]);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-[60vh] items-center justify-center px-5">
|
||||
<div className="w-full max-w-md text-center">
|
||||
<div className="mx-auto mb-5 flex h-14 w-14 items-center justify-center rounded-xl bg-error/10">
|
||||
<svg
|
||||
className="h-7 w-7 text-error"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.75}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M12 9v4M12 17h.01M10.3 3.86l-8.5 14.7A1.5 1.5 0 003.1 21h17.8a1.5 1.5 0 001.3-2.44l-8.5-14.7a1.5 1.5 0 00-2.6 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h1 className="font-display text-xl font-semibold text-text-primary mb-2">
|
||||
{t("title")}
|
||||
</h1>
|
||||
<p className="text-sm text-text-secondary mb-6">{t("description")}</p>
|
||||
{error?.digest && (
|
||||
<p className="text-[11px] font-mono text-text-muted mb-6">
|
||||
{error.digest}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-center justify-center gap-3">
|
||||
<button
|
||||
onClick={reset}
|
||||
className="py-2 px-4 rounded-lg bg-accent text-surface-0 text-sm font-medium hover:bg-accent-dim transition-colors cursor-pointer"
|
||||
>
|
||||
{t("retry")}
|
||||
</button>
|
||||
<Link
|
||||
href="/dashboard"
|
||||
className="py-2 px-4 rounded-lg border border-border text-sm font-medium text-text-secondary hover:text-text-primary hover:bg-surface-2 transition-colors"
|
||||
>
|
||||
{t("backToDashboard")}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +1,36 @@
|
||||
import type { Metadata, Viewport } from "next";
|
||||
import { NextIntlClientProvider } from "next-intl";
|
||||
import { getMessages } from "next-intl/server";
|
||||
import { getMessages, getTranslations } from "next-intl/server";
|
||||
import { routing } from "@/i18n/routing";
|
||||
import { notFound } from "next/navigation";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { NavShell } from "@/components/layout/nav-shell";
|
||||
|
||||
export function generateStaticParams() {
|
||||
return routing.locales.map((locale) => ({ locale }));
|
||||
}
|
||||
|
||||
// Metadata API (Next 15) instead of a hand-rolled <head>. The title
|
||||
// template lets each page export a short `title` (e.g. "Dashboard")
|
||||
// that renders as "Dashboard · PieCed". Pages that export no metadata
|
||||
// fall back to the default below.
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const t = await getTranslations("common");
|
||||
const appName = t("appName");
|
||||
return {
|
||||
title: {
|
||||
default: `${appName} Portal`,
|
||||
template: `%s · ${appName}`,
|
||||
},
|
||||
description: "PieCed IT — Multi-tenant AI assistant platform",
|
||||
};
|
||||
}
|
||||
|
||||
export const viewport: Viewport = {
|
||||
width: "device-width",
|
||||
initialScale: 1,
|
||||
};
|
||||
|
||||
export default async function LocaleLayout({
|
||||
children,
|
||||
params,
|
||||
@@ -22,20 +45,13 @@ export default async function LocaleLayout({
|
||||
}
|
||||
|
||||
const messages = await getMessages();
|
||||
const session = await auth();
|
||||
|
||||
return (
|
||||
<html lang={locale} className="dark">
|
||||
<head>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>PieCed Portal</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="PieCed IT — Multi-tenant AI assistant platform"
|
||||
/>
|
||||
</head>
|
||||
<body className="min-h-screen bg-surface-0 text-text-primary antialiased">
|
||||
<NextIntlClientProvider messages={messages}>
|
||||
<NavShell>{children}</NavShell>
|
||||
<NavShell session={session}>{children}</NavShell>
|
||||
</NextIntlClientProvider>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
25
src/app/[locale]/loading.tsx
Normal file
25
src/app/[locale]/loading.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* Loading skeleton for the [locale] segment. Shown during navigation
|
||||
* while a server component fetches (the dashboard, for instance, does
|
||||
* listTenants() + one K8s GET per provisioning row). Textless on
|
||||
* purpose so it needs no translations and adds no layout shift.
|
||||
*/
|
||||
export default function LocaleLoading() {
|
||||
return (
|
||||
<div className="animate-pulse" aria-hidden="true">
|
||||
<div className="mb-8">
|
||||
<div className="h-7 w-48 rounded-md bg-surface-2" />
|
||||
<div className="mt-4 h-4 w-72 rounded bg-surface-1" />
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="h-28 rounded-xl border border-border bg-surface-1"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<span className="sr-only">Loading…</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { signIn } from "next-auth/react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import Link from "next/link";
|
||||
import { useTranslations, useLocale } from "next-intl";
|
||||
import { Link, getPathname } from "@/i18n/navigation";
|
||||
|
||||
export default function LoginPage() {
|
||||
const t = useTranslations("login");
|
||||
const locale = useLocale();
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 flex items-center justify-center bg-surface-0">
|
||||
@@ -39,7 +40,14 @@ export default function LoginPage() {
|
||||
</p>
|
||||
|
||||
<button
|
||||
onClick={() => signIn("zitadel", { callbackUrl: "/dashboard" })}
|
||||
onClick={() =>
|
||||
signIn("zitadel", {
|
||||
// Preserve the active locale across the OIDC round-trip.
|
||||
// A bare "/dashboard" would resolve to the default (de)
|
||||
// locale on return; getPathname prefixes it as needed.
|
||||
callbackUrl: getPathname({ href: "/dashboard", locale }),
|
||||
})
|
||||
}
|
||||
className="
|
||||
w-full py-3 px-4 rounded-lg font-medium text-sm
|
||||
bg-accent text-surface-0 cursor-pointer
|
||||
|
||||
34
src/app/[locale]/not-found.tsx
Normal file
34
src/app/[locale]/not-found.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { Link } from "@/i18n/navigation";
|
||||
|
||||
/**
|
||||
* 404 for the [locale] segment. Triggered by notFound() calls in pages
|
||||
* below the locale layout. (A notFound() thrown by the locale layout
|
||||
* itself — e.g. an unknown locale — resolves to the framework default,
|
||||
* which is acceptable for that narrow case.)
|
||||
*/
|
||||
export default async function LocaleNotFound() {
|
||||
const t = await getTranslations("errors");
|
||||
|
||||
return (
|
||||
<div className="flex min-h-[60vh] items-center justify-center px-5">
|
||||
<div className="w-full max-w-md text-center">
|
||||
<div className="font-display text-5xl font-semibold text-accent mb-4 tabular-nums">
|
||||
404
|
||||
</div>
|
||||
<h1 className="font-display text-xl font-semibold text-text-primary mb-2">
|
||||
{t("notFoundTitle")}
|
||||
</h1>
|
||||
<p className="text-sm text-text-secondary mb-6">
|
||||
{t("notFoundDescription")}
|
||||
</p>
|
||||
<Link
|
||||
href="/dashboard"
|
||||
className="inline-flex py-2 px-4 rounded-lg bg-accent text-surface-0 text-sm font-medium hover:bg-accent-dim transition-colors"
|
||||
>
|
||||
{t("backToDashboard")}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,13 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { redirect } from "@/i18n/navigation";
|
||||
|
||||
export default function RootPage() {
|
||||
redirect("/dashboard");
|
||||
export default async function RootPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>;
|
||||
}) {
|
||||
// Locale-aware redirect: a bare next/navigation redirect("/dashboard")
|
||||
// drops the prefix and lands non-default-locale users on the German
|
||||
// dashboard. The i18n redirect prefixes per the active locale.
|
||||
const { locale } = await params;
|
||||
redirect({ href: "/dashboard", locale });
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useState, useRef, forwardRef } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useRouter, Link } from "@/i18n/navigation";
|
||||
import { Card } from "@/components/ui/card";
|
||||
|
||||
type FormState = "idle" | "submitting" | "success" | "error";
|
||||
@@ -50,6 +50,30 @@ export default function RegisterPage() {
|
||||
const [state, setState] = useState<FormState>("idle");
|
||||
const [error, setError] = useState("");
|
||||
|
||||
// Radiogroup keyboard support. `role="radio"` requires roving
|
||||
// tabindex (one tab stop) + arrow-key navigation between options —
|
||||
// native buttons don't move focus on arrows. The selected card is
|
||||
// the tab stop; when nothing is selected yet the first card is
|
||||
// focusable so keyboard users can enter the group.
|
||||
const TYPES: AccountType[] = ["personal", "company"];
|
||||
const cardRefs = useRef<(HTMLButtonElement | null)[]>([]);
|
||||
|
||||
const rovingTabIndex = (type: AccountType, index: number) =>
|
||||
accountType === type || (accountType === null && index === 0) ? 0 : -1;
|
||||
|
||||
const handleCardKeyDown = (e: React.KeyboardEvent, index: number) => {
|
||||
let next: number | null = null;
|
||||
if (e.key === "ArrowRight" || e.key === "ArrowDown") {
|
||||
next = (index + 1) % TYPES.length;
|
||||
} else if (e.key === "ArrowLeft" || e.key === "ArrowUp") {
|
||||
next = (index - 1 + TYPES.length) % TYPES.length;
|
||||
}
|
||||
if (next === null) return;
|
||||
e.preventDefault();
|
||||
setAccountType(TYPES[next]);
|
||||
cardRefs.current[next]?.focus();
|
||||
};
|
||||
|
||||
const isPersonal = accountType === "personal";
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
@@ -120,7 +144,7 @@ export default function RegisterPage() {
|
||||
</p>
|
||||
<button
|
||||
onClick={() => router.push("/login")}
|
||||
className="w-full py-2.5 px-4 bg-accent text-white text-sm font-medium rounded-lg hover:bg-accent-dim transition-colors"
|
||||
className="w-full py-2.5 px-4 bg-accent text-surface-0 text-sm font-medium rounded-lg hover:bg-accent-dim transition-colors"
|
||||
>
|
||||
{t("goToLogin")}
|
||||
</button>
|
||||
@@ -146,8 +170,13 @@ export default function RegisterPage() {
|
||||
className="grid grid-cols-2 gap-3 mb-6 animate-in animate-in-delay-1"
|
||||
>
|
||||
<AccountTypeCard
|
||||
ref={(el) => {
|
||||
cardRefs.current[0] = el;
|
||||
}}
|
||||
selected={accountType === "personal"}
|
||||
onClick={() => setAccountType("personal")}
|
||||
tabIndex={rovingTabIndex("personal", 0)}
|
||||
onKeyDown={(e) => handleCardKeyDown(e, 0)}
|
||||
label={t("personalCardTitle")}
|
||||
description={t("personalCardDescription")}
|
||||
icon={
|
||||
@@ -168,8 +197,13 @@ export default function RegisterPage() {
|
||||
}
|
||||
/>
|
||||
<AccountTypeCard
|
||||
ref={(el) => {
|
||||
cardRefs.current[1] = el;
|
||||
}}
|
||||
selected={accountType === "company"}
|
||||
onClick={() => setAccountType("company")}
|
||||
tabIndex={rovingTabIndex("company", 1)}
|
||||
onKeyDown={(e) => handleCardKeyDown(e, 1)}
|
||||
label={t("companyCardTitle")}
|
||||
description={t("companyCardDescription")}
|
||||
icon={
|
||||
@@ -270,7 +304,7 @@ export default function RegisterPage() {
|
||||
<button
|
||||
type="submit"
|
||||
disabled={state === "submitting"}
|
||||
className="w-full py-2.5 px-4 bg-accent text-white text-sm font-medium rounded-lg hover:bg-accent-dim transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
className="w-full py-2.5 px-4 bg-accent text-surface-0 text-sm font-medium rounded-lg hover:bg-accent-dim transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{state === "submitting" ? tCommon("loading") : t("submit")}
|
||||
</button>
|
||||
@@ -278,12 +312,12 @@ export default function RegisterPage() {
|
||||
|
||||
<p className="text-xs text-text-muted text-center mt-4">
|
||||
{t("hasAccount")}{" "}
|
||||
<a
|
||||
<Link
|
||||
href="/login"
|
||||
className="text-accent hover:text-accent-dim transition-colors"
|
||||
>
|
||||
{tCommon("login")}
|
||||
</a>
|
||||
</Link>
|
||||
</p>
|
||||
</Card>
|
||||
)}
|
||||
@@ -305,41 +339,42 @@ export default function RegisterPage() {
|
||||
* and text colours intensify when selected to give a clear "this one
|
||||
* is on" signal beyond just the border colour.
|
||||
*/
|
||||
function AccountTypeCard({
|
||||
selected,
|
||||
onClick,
|
||||
label,
|
||||
description,
|
||||
icon,
|
||||
}: {
|
||||
selected: boolean;
|
||||
onClick: () => void;
|
||||
label: string;
|
||||
description: string;
|
||||
icon: React.ReactNode;
|
||||
}) {
|
||||
const AccountTypeCard = forwardRef<
|
||||
HTMLButtonElement,
|
||||
{
|
||||
selected: boolean;
|
||||
onClick: () => void;
|
||||
label: string;
|
||||
description: string;
|
||||
icon: React.ReactNode;
|
||||
tabIndex: number;
|
||||
onKeyDown: (e: React.KeyboardEvent) => void;
|
||||
}
|
||||
>(function AccountTypeCard(
|
||||
{ selected, onClick, label, description, icon, tabIndex, onKeyDown },
|
||||
ref
|
||||
) {
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={selected}
|
||||
tabIndex={tabIndex}
|
||||
onClick={onClick}
|
||||
onKeyDown={onKeyDown}
|
||||
className={`text-left rounded-xl border p-4 transition-colors cursor-pointer focus:outline-none focus:ring-2 focus:ring-accent/40 ${
|
||||
selected
|
||||
? "border-accent bg-accent/10"
|
||||
: "border-border bg-surface-2 hover:border-accent/40 hover:bg-surface-3/30"
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`mb-2 ${
|
||||
selected ? "text-accent" : "text-text-muted"
|
||||
}`}
|
||||
>
|
||||
<div className={`mb-2 ${selected ? "text-accent" : "text-text-muted"}`}>
|
||||
{icon}
|
||||
</div>
|
||||
<div
|
||||
className={`text-sm font-semibold mb-0.5 ${
|
||||
selected ? "text-text-primary" : "text-text-primary"
|
||||
selected ? "text-text-primary" : "text-text-secondary"
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
@@ -347,4 +382,4 @@ function AccountTypeCard({
|
||||
<div className="text-xs text-text-muted leading-snug">{description}</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { redirect, notFound } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { getSessionUser } from "@/lib/session";
|
||||
import { getOrgBilling } from "@/lib/db";
|
||||
import { getOrgBilling, getOrgBillingConfig } from "@/lib/db";
|
||||
import { BillingSettingsForm } from "@/components/settings/billing-form";
|
||||
import { SavedCardSection } from "@/components/settings/saved-card-section";
|
||||
|
||||
/**
|
||||
* /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
|
||||
* shared upsert path; the row's existence drives whether the
|
||||
* monthly issuance cron will pick this org up.
|
||||
*
|
||||
* Phase 9: also renders the saved-card section (Set up auto-pay /
|
||||
* Visa dot-dot-dot 4242, expires MM/YY / Update card / Disable
|
||||
* auto-pay / Remove card) when billing info is on file, plus a
|
||||
* footer note explaining that bank transfer is available on request.
|
||||
*/
|
||||
export default async function BillingSettingsPage() {
|
||||
const user = await getSessionUser();
|
||||
@@ -25,7 +31,10 @@ export default async function BillingSettingsPage() {
|
||||
if (!user.roles.includes("owner")) notFound();
|
||||
|
||||
const t = await getTranslations("settingsBilling");
|
||||
const existing = await getOrgBilling(user.orgId);
|
||||
const [existing, config] = await Promise.all([
|
||||
getOrgBilling(user.orgId),
|
||||
getOrgBillingConfig(user.orgId),
|
||||
]);
|
||||
|
||||
return (
|
||||
<main className="max-w-3xl mx-auto px-6 py-8">
|
||||
@@ -43,6 +52,20 @@ export default async function BillingSettingsPage() {
|
||||
isPersonal={user.isPersonal}
|
||||
/>
|
||||
</div>
|
||||
{/* Phase 9: saved-card section. Only shown once billing info
|
||||
exists — without an address Stripe can't create the
|
||||
customer object, so the "Set up auto-pay" button would
|
||||
fail anyway. We give a clear hint up there if the form
|
||||
is empty (no need to surface the card UI). */}
|
||||
{existing && (
|
||||
<div className="animate-in animate-in-delay-2 mt-8">
|
||||
<SavedCardSection
|
||||
config={config}
|
||||
isPayByInvoice={!!config?.payByInvoice}
|
||||
isPersonal={user.isPersonal}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -14,6 +14,11 @@ import { Card } from "@/components/ui/card";
|
||||
* Access: any authenticated user (the cards themselves gate further;
|
||||
* non-owner users would not see "Billing" as actionable, etc.).
|
||||
*/
|
||||
export async function generateMetadata() {
|
||||
const t = await getTranslations("common");
|
||||
return { title: t("settings") };
|
||||
}
|
||||
|
||||
export default async function SettingsPage() {
|
||||
const user = await getSessionUser();
|
||||
if (!user) redirect("/login");
|
||||
|
||||
@@ -24,6 +24,11 @@ import { TicketCategoryLabel } from "@/components/support/ticket-category-label"
|
||||
* having recent activity, but we don't sort by status; that's a
|
||||
* filter the admin can add later if the queue grows.
|
||||
*/
|
||||
export async function generateMetadata() {
|
||||
const t = await getTranslations("common");
|
||||
return { title: t("support") };
|
||||
}
|
||||
|
||||
export default async function SupportListPage() {
|
||||
const user = await getSessionUser();
|
||||
if (!user) redirect("/login");
|
||||
@@ -48,7 +53,7 @@ export default async function SupportListPage() {
|
||||
{!user.isPlatform && (
|
||||
<Link
|
||||
href="/support/new"
|
||||
className="text-sm font-medium px-4 py-2 rounded-lg bg-accent text-white hover:bg-accent/90 transition-colors"
|
||||
className="text-sm font-medium px-4 py-2 rounded-lg bg-accent text-surface-0 hover:bg-accent/90 transition-colors"
|
||||
>
|
||||
{t("newTicket")}
|
||||
</Link>
|
||||
|
||||
@@ -17,6 +17,11 @@ import { InviteForm } from "@/components/team/invite-form";
|
||||
* `<TeamList>` and `<InviteForm>` client components handle live
|
||||
* updates after invites and refreshes.
|
||||
*/
|
||||
export async function generateMetadata() {
|
||||
const t = await getTranslations("common");
|
||||
return { title: t("team") };
|
||||
}
|
||||
|
||||
export default async function TeamPage() {
|
||||
const user = await getSessionUser();
|
||||
if (!user) redirect("/login");
|
||||
|
||||
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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
72
src/app/api/admin/billing/orgs/[orgId]/payment-mode/route.ts
Normal file
72
src/app/api/admin/billing/orgs/[orgId]/payment-mode/route.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { requirePlatformRole } from "@/lib/session";
|
||||
import {
|
||||
getOrgBillingConfig,
|
||||
setAutoChargeEnabled,
|
||||
updateOrgBillingConfig,
|
||||
} from "@/lib/db";
|
||||
import { safeError } from "@/lib/errors";
|
||||
|
||||
/**
|
||||
* POST /api/admin/billing/orgs/[orgId]/payment-mode
|
||||
*
|
||||
* Phase 9b-2. Admin-only override of an org's billing mode:
|
||||
* - payByInvoice (boolean) — flip the customer's account to
|
||||
* bank-transfer billing. Auto-charge is skipped entirely for
|
||||
* these orgs; they receive the regular issued-invoice email
|
||||
* and pay manually. Switching ON also implicitly stops
|
||||
* attempting card charges even if a saved card exists.
|
||||
* - autoChargeEnabled (boolean) — pause auto-charge without
|
||||
* committing to pay-by-invoice. Useful during disputes or
|
||||
* billing investigations.
|
||||
*
|
||||
* Either flag may be omitted; the endpoint only writes what's
|
||||
* provided. Returns the updated config.
|
||||
*/
|
||||
const bodySchema = z.object({
|
||||
payByInvoice: z.boolean().optional(),
|
||||
autoChargeEnabled: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export async function POST(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ orgId: string }> }
|
||||
) {
|
||||
try {
|
||||
await requirePlatformRole();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
const { orgId } = await params;
|
||||
const body = await request.json().catch(() => ({}));
|
||||
const parsed = bodySchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid request", details: parsed.error.flatten() },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
const { payByInvoice, autoChargeEnabled } = parsed.data;
|
||||
if (payByInvoice === undefined && autoChargeEnabled === undefined) {
|
||||
return NextResponse.json(
|
||||
{ error: "Provide at least one of payByInvoice or autoChargeEnabled" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
try {
|
||||
if (payByInvoice !== undefined) {
|
||||
await updateOrgBillingConfig(orgId, { payByInvoice });
|
||||
}
|
||||
if (autoChargeEnabled !== undefined) {
|
||||
await setAutoChargeEnabled(orgId, autoChargeEnabled);
|
||||
}
|
||||
const cfg = await getOrgBillingConfig(orgId);
|
||||
return NextResponse.json({ config: cfg });
|
||||
} catch (e) {
|
||||
return NextResponse.json(
|
||||
{ error: safeError(e, "Failed to update payment mode") },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -4,14 +4,12 @@ import {
|
||||
getTenantRequestById,
|
||||
updateTenantRequestStatus,
|
||||
clearEncryptedSecrets,
|
||||
recordTenantCreated,
|
||||
recordSkillEvents,
|
||||
recordSuspensionEvent,
|
||||
} from "@/lib/db";
|
||||
import { createTenant, patchTenantSpec, setTenantAnnotation } from "@/lib/k8s";
|
||||
import { sendApprovalEmail, sendResumeApprovalEmail } from "@/lib/email";
|
||||
import { decryptSecrets } from "@/lib/crypto";
|
||||
import { writePackageSecrets } from "@/lib/openbao";
|
||||
import { createRoute as createRelayRoute } from "@/lib/threema-relay";
|
||||
import {
|
||||
getDefaultSoulMd,
|
||||
getDefaultAgentsMd,
|
||||
@@ -88,23 +86,6 @@ export async function POST(
|
||||
}
|
||||
try {
|
||||
await patchTenantSpec(tenantRequest.tenantName, { suspend: false });
|
||||
|
||||
// Billing — Phase 1: record the resume so monthly proration
|
||||
// counts the suspended segment correctly. Best-effort; if
|
||||
// logging fails, the approval still succeeds.
|
||||
try {
|
||||
await recordSuspensionEvent(
|
||||
tenantRequest.tenantName,
|
||||
tenantRequest.zitadelOrgId,
|
||||
"resumed"
|
||||
);
|
||||
} catch (e) {
|
||||
console.error(
|
||||
"billing: failed to record resumed suspension event:",
|
||||
e
|
||||
);
|
||||
}
|
||||
|
||||
// Clear the annotation that pauses the operator's 60-day TTL.
|
||||
// Best-effort — annotation cleanup is also done by the operator
|
||||
// when it sees suspend=false on the next reconcile (it clears
|
||||
@@ -197,6 +178,29 @@ export async function POST(
|
||||
? tenantRequest.contactName || "Assistant"
|
||||
: tenantRequest.companyName;
|
||||
|
||||
// Phase 9b: split the customer's initial channel-user ids into
|
||||
// (a) ids the operator needs in spec.channelUsers (telegram,
|
||||
// discord, …) — passed straight into createTenant
|
||||
// (b) Threema ids that ALSO need a relay route registered so
|
||||
// inbound messages reach this tenant. Threema is in (a)
|
||||
// AND (b): spec.channelUsers tells the operator the id is
|
||||
// authorized; the relay's route maps inbound traffic from
|
||||
// that id to this tenant.
|
||||
const initialChannelUsers = tenantRequest.channelUsers ?? {};
|
||||
// Strip channels the customer didn't actually enable (defensive
|
||||
// — the wizard already filters this, but the row could carry
|
||||
// stale data if the customer edited their request post-submit).
|
||||
const filteredChannelUsers: Record<string, string[]> = {};
|
||||
for (const [channel, ids] of Object.entries(initialChannelUsers)) {
|
||||
if (!packages.includes(channel)) continue;
|
||||
const cleaned = (ids ?? [])
|
||||
.map((s) => (s ?? "").trim())
|
||||
.filter((s) => s.length > 0);
|
||||
if (cleaned.length > 0) {
|
||||
filteredChannelUsers[channel] = cleaned;
|
||||
}
|
||||
}
|
||||
|
||||
await createTenant(
|
||||
tenantName,
|
||||
{
|
||||
@@ -204,6 +208,9 @@ export async function POST(
|
||||
agentName: tenantRequest.agentName,
|
||||
packages,
|
||||
workspaceFiles,
|
||||
...(Object.keys(filteredChannelUsers).length > 0
|
||||
? { channelUsers: filteredChannelUsers }
|
||||
: {}),
|
||||
},
|
||||
{
|
||||
"pieced.ch/zitadel-org-id": tenantRequest.zitadelOrgId,
|
||||
@@ -219,33 +226,33 @@ export async function POST(
|
||||
}
|
||||
);
|
||||
|
||||
// Billing — Phase 1: record the tenant's creation and initial
|
||||
// package state. Anchored at "now" rather than the CR's
|
||||
// creationTimestamp because we don't get the timestamp back from
|
||||
// createTenant — the few-millisecond skew vs the CR's actual
|
||||
// creationTimestamp is irrelevant for monthly billing.
|
||||
//
|
||||
// Best-effort: tracking failures must never block provisioning.
|
||||
// The backfill helper can repair any gaps later if needed.
|
||||
const billingAnchor = new Date();
|
||||
try {
|
||||
await recordTenantCreated(
|
||||
tenantName,
|
||||
tenantRequest.zitadelOrgId,
|
||||
billingAnchor
|
||||
);
|
||||
await recordSkillEvents(
|
||||
tenantName,
|
||||
tenantRequest.zitadelOrgId,
|
||||
packages,
|
||||
[],
|
||||
billingAnchor
|
||||
);
|
||||
} catch (e) {
|
||||
console.error(
|
||||
"billing: failed to record tenant creation / initial skill events:",
|
||||
e
|
||||
);
|
||||
// Threema: register relay routes for each id the customer
|
||||
// entered. Best-effort — a route failure doesn't unwind the
|
||||
// tenant creation (admin can retry from the tenant page later).
|
||||
// The Threema package itself isn't enabled on the tenant until
|
||||
// the customer toggles it from the tenant detail page (which
|
||||
// also mints the per-tenant token); the routes here pre-warm
|
||||
// the relay so the first toggle works without re-typing the id.
|
||||
if (
|
||||
packages.includes("threema") &&
|
||||
filteredChannelUsers.threema &&
|
||||
filteredChannelUsers.threema.length > 0
|
||||
) {
|
||||
for (const tid of filteredChannelUsers.threema) {
|
||||
try {
|
||||
const res = await createRelayRoute(tenantName, tid);
|
||||
if (!res.ok) {
|
||||
console.warn(
|
||||
`[approve] Threema route create for tenant=${tenantName} id=${tid} returned not-ok: ${res.message}`
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(
|
||||
`[approve] Threema route create threw for tenant=${tenantName} id=${tid}:`,
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Step 5: Update request status — clear admin notes on re-approval
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { requirePlatformRole } from "@/lib/session";
|
||||
import { getTenantRequestById, updateTenantRequestStatus } from "@/lib/db";
|
||||
import {
|
||||
getInvoiceById,
|
||||
getTenantRequestById,
|
||||
updateTenantRequestStatus,
|
||||
} from "@/lib/db";
|
||||
import { setTenantAnnotation } from "@/lib/k8s";
|
||||
import { sendRejectionEmail, sendResumeRejectionEmail } from "@/lib/email";
|
||||
import { refundInvoice, RefundNotAllowedError } from "@/lib/billing";
|
||||
import type { SessionUser } from "@/types";
|
||||
|
||||
/**
|
||||
* POST /api/admin/requests/[id]/reject
|
||||
@@ -14,13 +20,23 @@ import { sendRejectionEmail, sendResumeRejectionEmail } from "@/lib/email";
|
||||
* suspendedAt — rejection doesn't reset it. The customer can submit
|
||||
* a fresh resume request later if circumstances change, but that
|
||||
* starts a new pending row and re-stamps the annotation.
|
||||
*
|
||||
* Phase 9b: provision rejections that have a linked paid setup
|
||||
* invoice (setup_invoice_id) trigger an automatic full refund via
|
||||
* the existing refundInvoice flow. The refund creates a credit
|
||||
* note + Stripe refund + customer email — same paper trail any
|
||||
* post-payment refund would have. Best-effort: a refund failure
|
||||
* does NOT block the rejection (admin can re-refund manually via
|
||||
* the invoice detail page if needed), but it's logged and surfaced
|
||||
* in the response so admin sees what happened.
|
||||
*/
|
||||
export async function POST(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
let user: SessionUser;
|
||||
try {
|
||||
await requirePlatformRole();
|
||||
user = await requirePlatformRole();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
@@ -65,6 +81,63 @@ export async function POST(
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 9b: refund the setup-fee invoice if one is linked. Only
|
||||
// applies to provision rejections; resume requests never have a
|
||||
// setup_invoice_id. Skip silently if no invoice is linked (e.g.
|
||||
// the request was created before Phase 9b shipped, or the setup
|
||||
// fee was 0).
|
||||
const refundSummary: {
|
||||
attempted: boolean;
|
||||
succeeded: boolean;
|
||||
error?: string;
|
||||
} = { attempted: false, succeeded: false };
|
||||
if (
|
||||
tenantRequest.requestType === "provision" &&
|
||||
tenantRequest.setupInvoiceId
|
||||
) {
|
||||
refundSummary.attempted = true;
|
||||
try {
|
||||
// refundInvoice expects an explicit CHF amount (no "full"
|
||||
// sentinel). Compute the remaining refundable amount as
|
||||
// total minus what's already been refunded. For a fresh
|
||||
// setup-fee invoice this is just totalChf, but the formula
|
||||
// is robust if admin had partially refunded earlier (rare
|
||||
// but possible — same invoice could in theory get a manual
|
||||
// partial refund, then a rejection).
|
||||
const inv = await getInvoiceById(tenantRequest.setupInvoiceId);
|
||||
if (!inv) {
|
||||
throw new Error(
|
||||
`Linked setup invoice ${tenantRequest.setupInvoiceId} not found`
|
||||
);
|
||||
}
|
||||
const remaining = Math.round(
|
||||
(inv.totalChf - (inv.refundedTotalChf ?? 0)) * 100
|
||||
) / 100;
|
||||
if (remaining <= 0) {
|
||||
refundSummary.succeeded = true; // nothing to refund — treat as success
|
||||
} else {
|
||||
await refundInvoice({
|
||||
invoiceId: tenantRequest.setupInvoiceId,
|
||||
amountChf: remaining,
|
||||
reason: adminNotes
|
||||
? `Tenant request rejected: ${adminNotes}`
|
||||
: "Tenant request rejected",
|
||||
refundedBy: user.id,
|
||||
});
|
||||
refundSummary.succeeded = true;
|
||||
}
|
||||
} catch (e: any) {
|
||||
refundSummary.error =
|
||||
e instanceof RefundNotAllowedError
|
||||
? e.message
|
||||
: (e?.message ?? "refund failed");
|
||||
console.error(
|
||||
`Setup-fee refund failed for request ${id} (invoice ${tenantRequest.setupInvoiceId}):`,
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Notify customer. Resume requests get a different email — the
|
||||
// tenant already exists; copy needs to mention "stays suspended" and
|
||||
// the 60-day retention deadline. Provision rejections use the
|
||||
@@ -88,5 +161,6 @@ export async function POST(
|
||||
return NextResponse.json({
|
||||
message: "Request rejected.",
|
||||
request: updated,
|
||||
refund: refundSummary,
|
||||
});
|
||||
}
|
||||
|
||||
27
src/app/api/billing/auto-charge/route.ts
Normal file
27
src/app/api/billing/auto-charge/route.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
/**
|
||||
* POST /api/billing/auto-charge — RETIRED.
|
||||
*
|
||||
* Auto-pay is no longer a customer-toggleable setting. A saved
|
||||
* card on file is the consent to auto-bill; customers manage their
|
||||
* card via update/remove on /settings/billing, nothing else. The
|
||||
* auto_charge_enabled flag is now an admin-only pause used during
|
||||
* disputes, set from /admin/billing/orgs.
|
||||
*
|
||||
* This route is kept as an explicit 410 (Gone) so any stale client
|
||||
* that still POSTs here fails loudly rather than silently toggling
|
||||
* a flag the customer shouldn't control. The old behaviour lived
|
||||
* here through Phase 9b-2.
|
||||
*/
|
||||
export async function POST() {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
"Auto-pay can no longer be disabled. A saved card is required for service. " +
|
||||
"Contact support if you need to switch to bank-transfer billing.",
|
||||
code: "auto_pay_not_toggleable",
|
||||
},
|
||||
{ status: 410 }
|
||||
);
|
||||
}
|
||||
46
src/app/api/billing/saved-card/route.ts
Normal file
46
src/app/api/billing/saved-card/route.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getSessionUser } from "@/lib/session";
|
||||
import { clearSavedPaymentMethod, getOrgBillingConfig } from "@/lib/db";
|
||||
import { detachPaymentMethod } from "@/lib/stripe";
|
||||
import { safeError } from "@/lib/errors";
|
||||
|
||||
/**
|
||||
* DELETE /api/billing/saved-card
|
||||
*
|
||||
* Phase 9. Remove the saved card for the caller's org. Detaches
|
||||
* the PaymentMethod in Stripe (so it can't be charged again) and
|
||||
* clears the four display columns + the pm_id reference locally.
|
||||
*
|
||||
* Idempotent: calling on an org with no saved card returns 200
|
||||
* (the desired end-state is already reached).
|
||||
*
|
||||
* Auth: any signed-in member of the org. Same reasoning as the
|
||||
* setup endpoint — card removal is a customer-visible action; it
|
||||
* doesn't leak anything, and a non-owner needing to remove a
|
||||
* stolen-card-on-file shouldn't be blocked by role gating.
|
||||
*/
|
||||
export async function DELETE() {
|
||||
const user = await getSessionUser();
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
try {
|
||||
const cfg = await getOrgBillingConfig(user.orgId);
|
||||
if (!cfg || !cfg.stripeDefaultPaymentMethodId) {
|
||||
// Already empty — no-op, return success.
|
||||
return NextResponse.json({ removed: false });
|
||||
}
|
||||
// Stripe detach first. If it fails for a real reason (network,
|
||||
// 500 from Stripe), we don't clear the DB — admin can retry.
|
||||
// 404 is treated as success by detachPaymentMethod (PM already
|
||||
// gone), so we proceed to clear the DB regardless.
|
||||
await detachPaymentMethod(cfg.stripeDefaultPaymentMethodId);
|
||||
await clearSavedPaymentMethod(user.orgId);
|
||||
return NextResponse.json({ removed: true });
|
||||
} catch (e) {
|
||||
return NextResponse.json(
|
||||
{ error: safeError(e, "Failed to remove card") },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
75
src/app/api/billing/setup-card/route.ts
Normal file
75
src/app/api/billing/setup-card/route.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getSessionUser } from "@/lib/session";
|
||||
import { getOrgBilling } from "@/lib/db";
|
||||
import {
|
||||
createSetupCheckoutSession,
|
||||
ensureStripeCustomerForOrg,
|
||||
} from "@/lib/stripe";
|
||||
import { safeError } from "@/lib/errors";
|
||||
|
||||
/**
|
||||
* POST /api/billing/setup-card
|
||||
*
|
||||
* Phase 9. Customer-initiated "Set up auto-pay" / "Update card"
|
||||
* flow. Creates a Checkout session in setup mode and returns its
|
||||
* URL — the caller redirects the browser. On completion, the
|
||||
* webhook handler saves the resulting PaymentMethod's display
|
||||
* fields against this org's billing config.
|
||||
*
|
||||
* Auth: any signed-in member of the org. We don't owner-gate this
|
||||
* because non-owners might legitimately need to update payment
|
||||
* (e.g., for a team they administer). The actual card data is
|
||||
* collected by Stripe, not us — there's nothing to leak from
|
||||
* misuse here.
|
||||
*
|
||||
* Requires an existing billing snapshot (org_billing row). If
|
||||
* absent, returns 400 — the customer hasn't set their billing
|
||||
* address yet, and Stripe needs the address for the customer
|
||||
* object.
|
||||
*/
|
||||
export async function POST(request: Request) {
|
||||
const user = await getSessionUser();
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
const orgBilling = await getOrgBilling(user.orgId);
|
||||
if (!orgBilling) {
|
||||
return NextResponse.json(
|
||||
{ error: "Billing address required before saving a card." },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
try {
|
||||
// Ensure the Stripe customer exists. Idempotent — if we
|
||||
// already created one for this org (e.g. from a prior
|
||||
// "Pay by Card" Checkout), it's reused.
|
||||
const customerId = await ensureStripeCustomerForOrg({
|
||||
zitadelOrgId: user.orgId,
|
||||
companyName: orgBilling.companyName,
|
||||
billingEmail: orgBilling.billingEmail,
|
||||
address: {
|
||||
line1: orgBilling.streetAddress,
|
||||
postalCode: orgBilling.postalCode,
|
||||
city: orgBilling.city,
|
||||
country: orgBilling.country,
|
||||
},
|
||||
});
|
||||
// Base URL for redirect targets — must be the public-facing
|
||||
// origin since Stripe redirects the browser back. Behind an
|
||||
// ingress (Cedric's setup) request.url is the internal pod
|
||||
// address ("0.0.0.0:3000" / cluster.svc), useless for the
|
||||
// browser. Same env-var pattern as the invoice pay endpoint.
|
||||
const baseUrl =
|
||||
process.env.APP_BASE_URL ?? "https://app.pieced.ch";
|
||||
const session = await createSetupCheckoutSession({
|
||||
customerId,
|
||||
baseUrl,
|
||||
});
|
||||
return NextResponse.json({ url: session.url });
|
||||
} catch (e) {
|
||||
return NextResponse.json(
|
||||
{ error: safeError(e, "Failed to start card setup") },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getSessionUser, canMutate } from "@/lib/session";
|
||||
import {
|
||||
getInvoiceById,
|
||||
getTenantRequestById,
|
||||
updateTenantRequestStatus,
|
||||
updateTenantRequestEditableFields,
|
||||
@@ -9,6 +10,8 @@ import { encryptSecrets } from "@/lib/crypto";
|
||||
import { setTenantAnnotation } from "@/lib/k8s";
|
||||
import { onboardingSchema } from "@/lib/validation";
|
||||
import { safeError } from "@/lib/errors";
|
||||
import { refundInvoice, RefundNotAllowedError } from "@/lib/billing";
|
||||
import type { SessionUser, TenantRequest } from "@/types";
|
||||
|
||||
/**
|
||||
* Customer-side controls for a single tenant_request row.
|
||||
@@ -29,7 +32,7 @@ async function loadAuthorized(
|
||||
id: string
|
||||
): Promise<
|
||||
| { error: NextResponse }
|
||||
| { req: Awaited<ReturnType<typeof getTenantRequestById>>; }
|
||||
| { req: TenantRequest; user: SessionUser }
|
||||
> {
|
||||
const user = await getSessionUser();
|
||||
if (!user) {
|
||||
@@ -55,7 +58,7 @@ async function loadAuthorized(
|
||||
error: NextResponse.json({ error: "Not found" }, { status: 404 }),
|
||||
};
|
||||
}
|
||||
return { req: tr };
|
||||
return { req: tr, user };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -93,6 +96,50 @@ export async function DELETE(
|
||||
try {
|
||||
await updateTenantRequestStatus(id, "cancelled");
|
||||
|
||||
// Phase 9b: a 'pending' provision request has already had its
|
||||
// setup fee charged (the order-time Checkout completed before
|
||||
// the webhook flipped it to 'pending'). Cancelling it must
|
||||
// refund that payment, exactly as an admin rejection does.
|
||||
// Resume requests never carry a setup_invoice_id, so this only
|
||||
// fires for provision orders. Best-effort: a refund failure is
|
||||
// logged + surfaced but doesn't block the cancellation (admin
|
||||
// can refund manually from the invoice page).
|
||||
let refund: { attempted: boolean; succeeded: boolean; error?: string } = {
|
||||
attempted: false,
|
||||
succeeded: false,
|
||||
};
|
||||
if (tr.requestType === "provision" && tr.setupInvoiceId) {
|
||||
refund.attempted = true;
|
||||
try {
|
||||
const inv = await getInvoiceById(tr.setupInvoiceId);
|
||||
if (!inv) {
|
||||
throw new Error(`Linked setup invoice ${tr.setupInvoiceId} not found`);
|
||||
}
|
||||
const remaining =
|
||||
Math.round((inv.totalChf - (inv.refundedTotalChf ?? 0)) * 100) / 100;
|
||||
if (remaining <= 0) {
|
||||
refund.succeeded = true; // nothing left to refund
|
||||
} else {
|
||||
await refundInvoice({
|
||||
invoiceId: tr.setupInvoiceId,
|
||||
amountChf: remaining,
|
||||
reason: "Order cancelled by customer",
|
||||
refundedBy: loaded.user!.id,
|
||||
});
|
||||
refund.succeeded = true;
|
||||
}
|
||||
} catch (e: any) {
|
||||
refund.error =
|
||||
e instanceof RefundNotAllowedError
|
||||
? e.message
|
||||
: (e?.message ?? "refund failed");
|
||||
console.error(
|
||||
`Setup-fee refund failed for cancelled request ${id} (invoice ${tr.setupInvoiceId}):`,
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Customer cancels their own pending resume request: clear the
|
||||
// operator-side annotation so the 60-day TTL resumes counting.
|
||||
// Best-effort — the operator handles missing annotation gracefully.
|
||||
@@ -111,7 +158,7 @@ export async function DELETE(
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ message: "Request cancelled.", id });
|
||||
return NextResponse.json({ message: "Request cancelled.", id, refund });
|
||||
} catch (e: any) {
|
||||
console.error("Failed to cancel request:", e);
|
||||
return NextResponse.json(
|
||||
|
||||
@@ -2,11 +2,14 @@ import { NextRequest, NextResponse } from "next/server";
|
||||
import { getSessionUser, canMutate } from "@/lib/session";
|
||||
import {
|
||||
createTenantRequest,
|
||||
createTenantRequestPendingPayment,
|
||||
deletePendingPaymentRequest,
|
||||
getTenantRequestById,
|
||||
listTenantRequestsByOrgId,
|
||||
listActiveTenantRequestsByOrgId,
|
||||
getMostRecentApprovedRequestForOrg,
|
||||
getOrgBilling,
|
||||
getPlatformPricing,
|
||||
upsertOrgBilling,
|
||||
} from "@/lib/db";
|
||||
import { getTenant, listTenants } from "@/lib/k8s";
|
||||
@@ -19,7 +22,18 @@ import { sendAdminNotificationEmail } from "@/lib/email";
|
||||
import { encryptSecrets } from "@/lib/crypto";
|
||||
import { isPersonalOrgName } from "@/lib/personal-org";
|
||||
import { onboardingSchema, billingAddressSchema } from "@/lib/validation";
|
||||
import type { OnboardingInput, PiecedTenant, TenantRequest } from "@/types";
|
||||
import {
|
||||
createSetupFeeCheckoutSession,
|
||||
ensureStripeCustomerForOrg,
|
||||
} from "@/lib/stripe";
|
||||
import { createTenantSetupFeeInvoice, voidInvoice } from "@/lib/billing";
|
||||
import { deriveTenantName } from "@/lib/tenant-naming";
|
||||
import type {
|
||||
InvoiceBillingSnapshot,
|
||||
OnboardingInput,
|
||||
PiecedTenant,
|
||||
TenantRequest,
|
||||
} from "@/types";
|
||||
import { z } from "zod";
|
||||
|
||||
/**
|
||||
@@ -194,6 +208,7 @@ export async function POST(request: Request) {
|
||||
|
||||
const input: OnboardingInput & {
|
||||
packageSecrets?: Record<string, Record<string, string>>;
|
||||
channelUsers?: Record<string, string[]>;
|
||||
} = parsed.data;
|
||||
|
||||
// Look up an existing approved request for this org to inherit
|
||||
@@ -402,7 +417,64 @@ export async function POST(request: Request) {
|
||||
);
|
||||
}
|
||||
|
||||
const tenantRequest = await createTenantRequest({
|
||||
// Look up the setup fee. If it's 0 we skip the Checkout flow
|
||||
// entirely and create a normal pending request (same as the
|
||||
// pre-Phase-9b behaviour).
|
||||
const platformPricing = await getPlatformPricing();
|
||||
const setupFeeChf = platformPricing.tenantSetupFeeChf;
|
||||
|
||||
// ZERO-FEE PATH ---------------------------------------------------
|
||||
// No payment to collect. Create the request directly in 'pending'
|
||||
// status (same as the pre-Phase-9b flow) and notify admin. The
|
||||
// wizard treats this response identically to its previous
|
||||
// success path.
|
||||
if (setupFeeChf <= 0) {
|
||||
const tenantRequest = await createTenantRequest({
|
||||
zitadelOrgId: user.orgId,
|
||||
zitadelUserId: user.id,
|
||||
companyName,
|
||||
instanceName: input.instanceName,
|
||||
contactName,
|
||||
contactEmail,
|
||||
agentName: input.agentName,
|
||||
soulMd: input.soulMd,
|
||||
agentsMd: input.agentsMd,
|
||||
packages: input.packages ?? [],
|
||||
billingAddress,
|
||||
billingNotes,
|
||||
encryptedSecrets,
|
||||
isPersonal,
|
||||
channelUsers: input.channelUsers ?? {},
|
||||
});
|
||||
try {
|
||||
await sendAdminNotificationEmail(
|
||||
tenantRequest.contactEmail,
|
||||
tenantRequest.contactName,
|
||||
tenantRequest.instanceName
|
||||
? `${tenantRequest.companyName} (${tenantRequest.instanceName})`
|
||||
: tenantRequest.companyName
|
||||
);
|
||||
} catch (e) {
|
||||
console.error("Failed to send admin notification:", e);
|
||||
}
|
||||
const allRequests = await listTenantRequestsByOrgId(user.orgId);
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: "Request submitted.",
|
||||
request: publicRequestShape(tenantRequest),
|
||||
orgRequestCount: allRequests.length,
|
||||
},
|
||||
{ status: 201 }
|
||||
);
|
||||
}
|
||||
|
||||
// PAID-FEE PATH ---------------------------------------------------
|
||||
// Insert as 'pending_payment' (tenant_name stays NULL so abandoned
|
||||
// Checkout sessions don't block retries). Build the setup-fee
|
||||
// invoice, then start a Checkout session. The wizard follows the
|
||||
// returned URL; on completion the webhook flips the row to
|
||||
// 'pending' and admin sees it in their queue.
|
||||
const tenantRequest = await createTenantRequestPendingPayment({
|
||||
zitadelOrgId: user.orgId,
|
||||
zitadelUserId: user.id,
|
||||
companyName,
|
||||
@@ -417,32 +489,140 @@ export async function POST(request: Request) {
|
||||
billingNotes,
|
||||
encryptedSecrets,
|
||||
isPersonal,
|
||||
channelUsers: input.channelUsers ?? {},
|
||||
});
|
||||
|
||||
// Notify admin about the new request. For follow-up instances, include
|
||||
// the instance name in the notification so the admin sees what's
|
||||
// being requested without opening the panel.
|
||||
try {
|
||||
await sendAdminNotificationEmail(
|
||||
tenantRequest.contactEmail,
|
||||
tenantRequest.contactName,
|
||||
tenantRequest.instanceName
|
||||
? `${tenantRequest.companyName} (${tenantRequest.instanceName})`
|
||||
: tenantRequest.companyName
|
||||
// Derive the future tenant_name — needed on the invoice line so
|
||||
// tenantHasSetupFeeBilled() in the monthly cron dedup finds the
|
||||
// already-paid setup fee once the K8s tenant exists. The name is
|
||||
// request-id-suffix-derived, so abandoned Checkout retries each
|
||||
// get unique names.
|
||||
const derivedTenantName = deriveTenantName(
|
||||
isPersonal ? "personal" : "company",
|
||||
companyName,
|
||||
tenantRequest.id
|
||||
);
|
||||
|
||||
// Re-fetch orgBilling here: the variable at the top of POST was
|
||||
// captured BEFORE the upsertOrgBilling call upstream (which fires
|
||||
// when the wizard collected the address on first onboarding). For
|
||||
// a brand-new user that initial fetch returned null; only by
|
||||
// re-fetching now do we get the row we just wrote. Existing
|
||||
// customers get the same orgBilling back either way.
|
||||
const billingForOrder = await getOrgBilling(user.orgId);
|
||||
if (!billingForOrder) {
|
||||
console.error(
|
||||
`Paid-fee onboarding path: no org_billing for org ${user.orgId} even after upsert — wizard did not collect address?`
|
||||
);
|
||||
await deletePendingPaymentRequest(tenantRequest.id).catch(() => undefined);
|
||||
return NextResponse.json(
|
||||
{ error: "Billing record missing. Please re-save your billing details." },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
const billingSnapshot: InvoiceBillingSnapshot = {
|
||||
companyName: billingForOrder.companyName,
|
||||
contactName: billingForOrder.contactName ?? null,
|
||||
streetAddress: billingForOrder.streetAddress,
|
||||
postalCode: billingForOrder.postalCode,
|
||||
city: billingForOrder.city,
|
||||
country: billingForOrder.country,
|
||||
vatNumber: billingForOrder.vatNumber ?? null,
|
||||
billingEmail: billingForOrder.billingEmail,
|
||||
notes: billingForOrder.notes ?? null,
|
||||
};
|
||||
|
||||
// Locale for the invoice + PDF — pick from the org's country
|
||||
// using the same heuristic the auto-cron uses.
|
||||
const c = (billingSnapshot.country ?? "").toUpperCase();
|
||||
const invoiceLocale: "de" | "en" | "fr" | "it" = ["CH", "LI", "AT", "DE"].includes(c)
|
||||
? "de"
|
||||
: ["FR", "BE", "LU"].includes(c)
|
||||
? "fr"
|
||||
: c === "IT"
|
||||
? "it"
|
||||
: "en";
|
||||
|
||||
let setupInvoice;
|
||||
try {
|
||||
setupInvoice = await createTenantSetupFeeInvoice({
|
||||
zitadelOrgId: user.orgId,
|
||||
tenantName: derivedTenantName,
|
||||
billingSnapshot,
|
||||
locale: invoiceLocale,
|
||||
paymentMethod: "card",
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("Failed to send admin notification:", e);
|
||||
console.error("Failed to create setup-fee invoice:", e);
|
||||
// Roll back the pending_payment row so the customer can retry
|
||||
// without an orphan record.
|
||||
await deletePendingPaymentRequest(tenantRequest.id).catch(() => undefined);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to prepare setup-fee invoice. Please try again." },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
// For diagnostics: how many other in-flight requests does this org
|
||||
// already have? Useful for the admin queue.
|
||||
const allRequests = await listTenantRequestsByOrgId(user.orgId);
|
||||
// Create the Checkout session. The Stripe customer must exist
|
||||
// before this — ensureStripeCustomerForOrg returns the existing
|
||||
// one (idempotent) since the saved-card setup already created it.
|
||||
let checkoutUrl: string;
|
||||
try {
|
||||
const stripeCustomerId = await ensureStripeCustomerForOrg({
|
||||
zitadelOrgId: user.orgId,
|
||||
companyName: billingSnapshot.companyName,
|
||||
billingEmail: billingSnapshot.billingEmail,
|
||||
address: {
|
||||
line1: billingSnapshot.streetAddress,
|
||||
postalCode: billingSnapshot.postalCode,
|
||||
city: billingSnapshot.city,
|
||||
country: billingSnapshot.country,
|
||||
},
|
||||
});
|
||||
const baseUrl =
|
||||
process.env.APP_BASE_URL ?? "https://app.pieced.ch";
|
||||
const { url } = await createSetupFeeCheckoutSession({
|
||||
invoice: setupInvoice,
|
||||
customerId: stripeCustomerId,
|
||||
baseUrl,
|
||||
tenantRequestId: tenantRequest.id,
|
||||
});
|
||||
checkoutUrl = url;
|
||||
} catch (e) {
|
||||
console.error("Failed to create setup-fee Checkout session:", e);
|
||||
// Roll back BOTH the pending_payment row and the setup invoice
|
||||
// we already created. The invoice was issued in 'open' status
|
||||
// but no payment will ever arrive (Checkout never started), so
|
||||
// void it to keep the ledger clean — an open invoice with no
|
||||
// route to payment would otherwise linger and show up in
|
||||
// arrears reports. Void (not delete) preserves the audit trail
|
||||
// and the void reason. Best-effort: a void failure is logged
|
||||
// but doesn't change the 500 we return.
|
||||
await voidInvoice({
|
||||
invoiceId: setupInvoice.id,
|
||||
reason: "Order abandoned before payment (Checkout could not be started)",
|
||||
voidedBy: user.id,
|
||||
}).catch((ve) =>
|
||||
console.error(
|
||||
`Failed to void orphaned setup invoice ${setupInvoice.id}:`,
|
||||
ve
|
||||
)
|
||||
);
|
||||
await deletePendingPaymentRequest(tenantRequest.id).catch(() => undefined);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to start payment. Please try again." },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
// Don't notify admin yet — the request is invisible to admin
|
||||
// until the webhook flips it to 'pending'. Notification happens
|
||||
// there.
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: "Request submitted.",
|
||||
message: "Redirecting to payment.",
|
||||
request: publicRequestShape(tenantRequest),
|
||||
orgRequestCount: allRequests.length,
|
||||
checkoutUrl,
|
||||
},
|
||||
{ status: 201 }
|
||||
);
|
||||
|
||||
@@ -1,14 +1,24 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import type Stripe from "stripe";
|
||||
import { getStripeClient, getWebhookSecret } from "@/lib/stripe";
|
||||
import {
|
||||
getPaymentMethodDisplay,
|
||||
getStripeClient,
|
||||
getWebhookSecret,
|
||||
} from "@/lib/stripe";
|
||||
import {
|
||||
getInvoiceByStripePaymentIntent,
|
||||
getInvoiceDetail,
|
||||
getOrgIdByStripeCustomerId,
|
||||
getTenantRequestForSetupFlow,
|
||||
isStripeRefundRecorded,
|
||||
linkTenantRequestSetupPayment,
|
||||
markInvoicePaid,
|
||||
markStripeEventProcessed,
|
||||
setInvoiceStripePaymentIntent,
|
||||
setSavedPaymentMethod,
|
||||
tryRecordStripeEvent,
|
||||
} from "@/lib/db";
|
||||
import { sendAdminNotificationEmail } from "@/lib/email";
|
||||
import { refundInvoice, RefundNotAllowedError } from "@/lib/billing";
|
||||
|
||||
/**
|
||||
@@ -161,6 +171,14 @@ export async function POST(request: Request) {
|
||||
async function handleCheckoutCompleted(
|
||||
session: Stripe.Checkout.Session
|
||||
): Promise<void> {
|
||||
// Phase 9: setup-mode sessions don't pay anything — they
|
||||
// authorize a card for off-session future charges. The
|
||||
// PaymentMethod is attached to the customer and the session's
|
||||
// setup_intent.payment_method holds the id we save.
|
||||
if (session.mode === "setup") {
|
||||
await handleSetupCompleted(session);
|
||||
return;
|
||||
}
|
||||
// Defensive: paid sessions are what we want; sessions can also
|
||||
// complete in "unpaid" state (rare for mode=payment, more common
|
||||
// for async/delayed methods like SEPA). Only flip the invoice
|
||||
@@ -209,6 +227,220 @@ async function handleCheckoutCompleted(
|
||||
console.log(
|
||||
`Invoice ${invoiceId} marked paid via Stripe (session ${session.id}, intent ${paymentIntentId}).`
|
||||
);
|
||||
|
||||
// Phase 9b: if this Checkout was the setup-fee flow for a tenant
|
||||
// order, flip the linked tenant_request row from 'pending_payment'
|
||||
// to 'pending' so admin sees it in the queue. The invoice line's
|
||||
// tenant_name has the derived name; we also stamp it on the
|
||||
// request row so admin can act on it. linkTenantRequestSetupPayment
|
||||
// is idempotent (no-op if status already advanced).
|
||||
const flow = session.metadata?.flow;
|
||||
const tenantRequestId = session.metadata?.tenant_request_id;
|
||||
if (flow === "setup_fee" && tenantRequestId) {
|
||||
try {
|
||||
// The derived tenant_name lives on the invoice line we just
|
||||
// marked paid. Fetch via getInvoiceDetail (existing helper).
|
||||
const detail = await getInvoiceDetail(invoiceId);
|
||||
const setupLine = detail?.lines.find(
|
||||
(l) => l.kind === "tenant_setup" && l.tenantName
|
||||
);
|
||||
if (!setupLine || !setupLine.tenantName) {
|
||||
console.error(
|
||||
`Setup-fee webhook for invoice ${invoiceId} has no tenant_setup line with tenant_name; cannot link request ${tenantRequestId}.`
|
||||
);
|
||||
} else {
|
||||
const linked = await linkTenantRequestSetupPayment({
|
||||
requestId: tenantRequestId,
|
||||
tenantName: setupLine.tenantName,
|
||||
setupInvoiceId: invoiceId,
|
||||
});
|
||||
if (linked) {
|
||||
console.log(
|
||||
`Tenant request ${tenantRequestId} flipped to 'pending' (tenant=${setupLine.tenantName}, setup invoice=${invoiceId}).`
|
||||
);
|
||||
// Notify admin now that the payment cleared. Best-effort —
|
||||
// a failure here doesn't undo the linkage.
|
||||
try {
|
||||
const req = await getTenantRequestForSetupFlow(tenantRequestId);
|
||||
if (req) {
|
||||
await sendAdminNotificationEmail(
|
||||
req.contactEmail,
|
||||
req.contactName,
|
||||
req.instanceName
|
||||
? `${req.companyName} (${req.instanceName})`
|
||||
: req.companyName
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(
|
||||
`Failed to send admin notification for tenant request ${tenantRequestId}:`,
|
||||
e
|
||||
);
|
||||
}
|
||||
} else {
|
||||
console.log(
|
||||
`Tenant request ${tenantRequestId} not in 'pending_payment' (likely already advanced); webhook is a no-op.`
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(
|
||||
`Setup-fee webhook for invoice ${invoiceId} failed to link tenant request ${tenantRequestId}:`,
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 9b: any payment-mode Checkout that set setup_future_usage
|
||||
// attaches the resulting PaymentMethod to the customer. Read it
|
||||
// back and save the display fields against the org's config —
|
||||
// same behaviour as the setup-mode webhook does. This is what
|
||||
// makes the setup-fee Checkout also "refresh saved card" without
|
||||
// an extra step, and it's also what Phase 9b-2's manual-pay
|
||||
// with setup_future_usage will rely on.
|
||||
try {
|
||||
if (paymentIntentId) {
|
||||
const stripe = getStripeClient();
|
||||
const pi = await stripe.paymentIntents.retrieve(paymentIntentId);
|
||||
const pmId =
|
||||
typeof pi.payment_method === "string"
|
||||
? pi.payment_method
|
||||
: pi.payment_method?.id;
|
||||
const customerId =
|
||||
typeof pi.customer === "string"
|
||||
? pi.customer
|
||||
: pi.customer?.id;
|
||||
// setup_future_usage on the PI tells us this payment also
|
||||
// saved the card. If it's not set, this was a one-off pay
|
||||
// and we shouldn't overwrite anything.
|
||||
if (pmId && customerId && pi.setup_future_usage === "off_session") {
|
||||
const orgId = await getOrgIdByStripeCustomerId(customerId);
|
||||
if (orgId) {
|
||||
const display = await getPaymentMethodDisplay(pmId);
|
||||
await setSavedPaymentMethod({
|
||||
zitadelOrgId: orgId,
|
||||
stripeCustomerId: customerId,
|
||||
paymentMethodId: pmId,
|
||||
brand: display.brand,
|
||||
last4: display.last4,
|
||||
expMonth: display.expMonth,
|
||||
expYear: display.expYear,
|
||||
});
|
||||
// Also tell Stripe this PM is the customer's default for
|
||||
// future invoice charges. Best-effort.
|
||||
try {
|
||||
await stripe.customers.update(customerId, {
|
||||
invoice_settings: { default_payment_method: pmId },
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn(
|
||||
`Failed to set default_payment_method on customer ${customerId}:`,
|
||||
e
|
||||
);
|
||||
}
|
||||
console.log(
|
||||
`Saved PaymentMethod ${pmId} (${display.brand} ${display.last4}) for org ${orgId} via payment-mode Checkout.`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(
|
||||
`Failed to save PaymentMethod from payment-mode Checkout (session ${session.id}):`,
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Phase 9: handle setup-mode Checkout completion. The customer
|
||||
* authorized a card for future off-session charges; persist the
|
||||
* display fields against their org so the portal can show the
|
||||
* saved card and use it for auto-charge.
|
||||
*
|
||||
* The session carries:
|
||||
* - mode: 'setup'
|
||||
* - customer: 'cus_xxx' (the Stripe customer id we created)
|
||||
* - setup_intent: 'seti_xxx' (the SetupIntent — has payment_method)
|
||||
*
|
||||
* We look up which org owns the customer (via
|
||||
* org_billing_config.stripe_customer_id), fetch the SetupIntent
|
||||
* to find the resulting PaymentMethod id, then fetch the PM for
|
||||
* its display fields. Three Stripe round-trips total — acceptable
|
||||
* for a one-off setup event.
|
||||
*/
|
||||
async function handleSetupCompleted(
|
||||
session: Stripe.Checkout.Session
|
||||
): Promise<void> {
|
||||
const customerId =
|
||||
typeof session.customer === "string"
|
||||
? session.customer
|
||||
: session.customer?.id;
|
||||
if (!customerId) {
|
||||
console.error(
|
||||
`Setup session ${session.id} completed without a customer; cannot link to org.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
const orgId = await getOrgIdByStripeCustomerId(customerId);
|
||||
if (!orgId) {
|
||||
console.error(
|
||||
`Setup session ${session.id} for customer ${customerId} has no matching org.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
const setupIntentId =
|
||||
typeof session.setup_intent === "string"
|
||||
? session.setup_intent
|
||||
: session.setup_intent?.id;
|
||||
if (!setupIntentId) {
|
||||
console.error(
|
||||
`Setup session ${session.id} completed without a setup_intent id.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
// Read the SetupIntent for the resulting PaymentMethod id.
|
||||
const stripe = getStripeClient();
|
||||
const setupIntent = await stripe.setupIntents.retrieve(setupIntentId);
|
||||
const paymentMethodId =
|
||||
typeof setupIntent.payment_method === "string"
|
||||
? setupIntent.payment_method
|
||||
: setupIntent.payment_method?.id;
|
||||
if (!paymentMethodId) {
|
||||
console.error(
|
||||
`Setup session ${session.id}: setup_intent ${setupIntentId} has no payment_method.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
// Fetch the PM details for display columns.
|
||||
const display = await getPaymentMethodDisplay(paymentMethodId);
|
||||
await setSavedPaymentMethod({
|
||||
zitadelOrgId: orgId,
|
||||
stripeCustomerId: customerId,
|
||||
paymentMethodId,
|
||||
brand: display.brand,
|
||||
last4: display.last4,
|
||||
expMonth: display.expMonth,
|
||||
expYear: display.expYear,
|
||||
});
|
||||
// Also tell Stripe this PM is the customer's default for invoice
|
||||
// payments — so a future stripe.paymentIntents.create against
|
||||
// this customer without an explicit payment_method picks it up.
|
||||
// Best-effort: a failure here doesn't undo the save (we have the
|
||||
// pm id, we can pass it explicitly when charging in Phase 9b).
|
||||
try {
|
||||
await stripe.customers.update(customerId, {
|
||||
invoice_settings: { default_payment_method: paymentMethodId },
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn(
|
||||
`Setup session ${session.id}: failed to set default_payment_method on customer ${customerId}; will pass pm id explicitly on charges.`,
|
||||
e
|
||||
);
|
||||
}
|
||||
console.log(
|
||||
`Saved PaymentMethod ${paymentMethodId} (${display.brand} ${display.last4}) for org ${orgId}.`
|
||||
);
|
||||
}
|
||||
|
||||
async function handleChargeRefunded(charge: Stripe.Charge): Promise<void> {
|
||||
|
||||
78
src/app/global-error.tsx
Normal file
78
src/app/global-error.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
|
||||
/**
|
||||
* Last-resort boundary for errors thrown in the root layout itself
|
||||
* (before the locale layout / intl provider mount). It replaces the
|
||||
* entire document, so it must render its own <html>/<body> and cannot
|
||||
* use translations or rely on the app stylesheet being applied — styles
|
||||
* are inlined with the palette's hex values so it renders correctly in
|
||||
* isolation. Everything below the locale layout is handled by
|
||||
* [locale]/error.tsx instead; this should almost never be seen.
|
||||
*/
|
||||
export default function GlobalError({
|
||||
error,
|
||||
reset,
|
||||
}: {
|
||||
error: Error & { digest?: string };
|
||||
reset: () => void;
|
||||
}) {
|
||||
useEffect(() => {
|
||||
console.error("Portal global error:", error);
|
||||
}, [error]);
|
||||
|
||||
return (
|
||||
<html lang="en" className="dark">
|
||||
<body
|
||||
style={{
|
||||
margin: 0,
|
||||
minHeight: "100vh",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
background: "#0a0c10",
|
||||
color: "#e8ecf4",
|
||||
fontFamily: "system-ui, sans-serif",
|
||||
padding: "20px",
|
||||
}}
|
||||
>
|
||||
<div style={{ maxWidth: "28rem", textAlign: "center" }}>
|
||||
<h1 style={{ fontSize: "1.25rem", fontWeight: 600, margin: "0 0 0.5rem" }}>
|
||||
Something went wrong
|
||||
</h1>
|
||||
<p style={{ fontSize: "0.875rem", color: "#8892a4", margin: "0 0 1.5rem" }}>
|
||||
An unexpected error occurred. Please try again.
|
||||
</p>
|
||||
{error?.digest && (
|
||||
<p
|
||||
style={{
|
||||
fontSize: "11px",
|
||||
fontFamily: "monospace",
|
||||
color: "#565e6e",
|
||||
margin: "0 0 1.5rem",
|
||||
}}
|
||||
>
|
||||
{error.digest}
|
||||
</p>
|
||||
)}
|
||||
<button
|
||||
onClick={reset}
|
||||
style={{
|
||||
padding: "0.5rem 1rem",
|
||||
borderRadius: "0.5rem",
|
||||
border: "none",
|
||||
background: "#00d4aa",
|
||||
color: "#0a0c10",
|
||||
fontSize: "0.875rem",
|
||||
fontWeight: 500,
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
Try again
|
||||
</button>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -246,7 +246,7 @@ export function AdminPanel({ initialTenants }: AdminPanelProps) {
|
||||
>
|
||||
{t("requests")}
|
||||
{pendingCount > 0 && tab !== "requests" && (
|
||||
<span className="ml-1.5 inline-flex items-center justify-center h-4 min-w-[16px] px-1 text-[10px] font-bold bg-accent text-white rounded-full">
|
||||
<span className="ml-1.5 inline-flex items-center justify-center h-4 min-w-[16px] px-1 text-[10px] font-bold bg-accent text-surface-0 rounded-full">
|
||||
{pendingCount}
|
||||
</span>
|
||||
)}
|
||||
@@ -308,7 +308,7 @@ export function AdminPanel({ initialTenants }: AdminPanelProps) {
|
||||
onClick={() => setFilter(f)}
|
||||
className={`px-3 py-1 text-xs rounded-full transition-colors ${
|
||||
filter === f
|
||||
? "bg-accent text-white"
|
||||
? "bg-accent text-surface-0"
|
||||
: "bg-surface-2 text-text-muted hover:text-text-secondary border border-border"
|
||||
}`}
|
||||
>
|
||||
|
||||
539
src/components/admin/billing/custom-invoice-editor.tsx
Normal file
539
src/components/admin/billing/custom-invoice-editor.tsx
Normal file
@@ -0,0 +1,539 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useMemo, useCallback } from "react";
|
||||
import { useRouter } from "@/i18n/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Card, CardHeader } from "@/components/ui/card";
|
||||
import type {
|
||||
CustomInvoiceDraftLine,
|
||||
CustomInvoiceDraftPayload,
|
||||
InvoiceDraftRecord,
|
||||
OrgBilling,
|
||||
} from "@/types";
|
||||
|
||||
interface Props {
|
||||
draft: InvoiceDraftRecord;
|
||||
orgBilling: OrgBilling | null;
|
||||
}
|
||||
|
||||
const LOCALE_OPTIONS = [
|
||||
{ value: "de", label: "Deutsch" },
|
||||
{ value: "en", label: "English" },
|
||||
{ value: "fr", label: "Français" },
|
||||
{ value: "it", label: "Italiano" },
|
||||
];
|
||||
|
||||
/**
|
||||
* Custom invoice editor — Phase 8.
|
||||
*
|
||||
* Local state mirrors the persisted payload. Save persists the
|
||||
* current state via PUT. Preview re-renders the PDF in-memory (no
|
||||
* persistence). Issue allocates the invoice number and emails the
|
||||
* customer.
|
||||
*
|
||||
* VAT preview is computed client-side from the country in the org
|
||||
* billing snapshot — it's an estimate for the admin's eye, not
|
||||
* authoritative. The server recomputes at issue time using the
|
||||
* same vatRateForAddress() helper to ensure consistency.
|
||||
*
|
||||
* Discount/Rabatt is supported via a row with a negative
|
||||
* unitPriceChf. The "Add discount" button seeds a new row with
|
||||
* quantity 1 and a -50 placeholder to nudge the admin toward the
|
||||
* intended sign.
|
||||
*/
|
||||
export function CustomInvoiceEditor({ draft, orgBilling }: Props) {
|
||||
const t = useTranslations("adminBilling");
|
||||
const router = useRouter();
|
||||
|
||||
// Editable state — initialized from the draft payload.
|
||||
const [issueDate, setIssueDate] = useState(draft.payload.issueDate);
|
||||
const [dueDate, setDueDate] = useState(draft.payload.dueDate);
|
||||
const [locale, setLocale] = useState<"de" | "en" | "fr" | "it">(
|
||||
draft.payload.locale
|
||||
);
|
||||
const [paymentMethod, setPaymentMethod] = useState<"invoice" | "card">(
|
||||
draft.payload.paymentMethod
|
||||
);
|
||||
const [adminNotes, setAdminNotes] = useState(draft.payload.adminNotes ?? "");
|
||||
const [lines, setLines] = useState<CustomInvoiceDraftLine[]>(
|
||||
draft.payload.lines.length > 0
|
||||
? draft.payload.lines
|
||||
: [{ description: "", quantity: 1, unitPriceChf: 0 }]
|
||||
);
|
||||
|
||||
const [busy, setBusy] = useState<null | "save" | "preview" | "issue" | "delete">(
|
||||
null
|
||||
);
|
||||
const [error, setError] = useState("");
|
||||
const [dirty, setDirty] = useState(false);
|
||||
|
||||
// Build current payload — used by every action.
|
||||
const buildPayload = useCallback((): CustomInvoiceDraftPayload => {
|
||||
return {
|
||||
issueDate,
|
||||
dueDate,
|
||||
locale,
|
||||
paymentMethod,
|
||||
adminNotes: adminNotes.trim() ? adminNotes.trim() : undefined,
|
||||
lines: lines.map((ln) => ({
|
||||
description: ln.description,
|
||||
quantity: Number(ln.quantity) || 0,
|
||||
unitPriceChf: Number(ln.unitPriceChf) || 0,
|
||||
})),
|
||||
};
|
||||
}, [issueDate, dueDate, locale, paymentMethod, adminNotes, lines]);
|
||||
|
||||
// Client-side VAT estimate. The auth-of-truth math runs server-side
|
||||
// at issue time; this is just to show the admin what they're about
|
||||
// to commit to.
|
||||
const totals = useMemo(() => {
|
||||
const subtotal = Math.round(
|
||||
lines.reduce(
|
||||
(s, ln) => s + (Number(ln.quantity) || 0) * (Number(ln.unitPriceChf) || 0),
|
||||
0
|
||||
) * 100
|
||||
) / 100;
|
||||
// Country-based VAT estimate. Mirrors vatRateForAddress() —
|
||||
// simplified because the editor doesn't know the platform
|
||||
// pricing config. Defaults to 8.1 for CH/LI; 0 otherwise.
|
||||
const country = (orgBilling?.country ?? "").toUpperCase();
|
||||
let vatRate = 0;
|
||||
if (country === "CH" || country === "LI") {
|
||||
vatRate = 8.1;
|
||||
} else if (orgBilling?.vatNumber) {
|
||||
vatRate = 0; // reverse charge
|
||||
} else {
|
||||
vatRate = 0; // out of scope OR consumer (server will fix)
|
||||
}
|
||||
const vatAmount = Math.round(subtotal * (vatRate / 100) * 100) / 100;
|
||||
const total = Math.round((subtotal + vatAmount) * 100) / 100;
|
||||
return { subtotal, vatRate, vatAmount, total };
|
||||
}, [lines, orgBilling]);
|
||||
|
||||
// Line management
|
||||
const updateLine = (idx: number, patch: Partial<CustomInvoiceDraftLine>) => {
|
||||
setLines((prev) =>
|
||||
prev.map((ln, i) => (i === idx ? { ...ln, ...patch } : ln))
|
||||
);
|
||||
setDirty(true);
|
||||
};
|
||||
const addLine = () => {
|
||||
setLines((prev) => [
|
||||
...prev,
|
||||
{ description: "", quantity: 1, unitPriceChf: 0 },
|
||||
]);
|
||||
setDirty(true);
|
||||
};
|
||||
const addDiscountLine = () => {
|
||||
setLines((prev) => [
|
||||
...prev,
|
||||
{ description: t("editorRabattDefaultDescription"), quantity: 1, unitPriceChf: -50 },
|
||||
]);
|
||||
setDirty(true);
|
||||
};
|
||||
const removeLine = (idx: number) => {
|
||||
setLines((prev) => prev.filter((_, i) => i !== idx));
|
||||
setDirty(true);
|
||||
};
|
||||
|
||||
// Actions
|
||||
const save = async (): Promise<boolean> => {
|
||||
setError("");
|
||||
setBusy("save");
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/admin/billing/invoice-drafts/${draft.id}`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(buildPayload()),
|
||||
}
|
||||
);
|
||||
const j = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(j.error || `HTTP ${res.status}`);
|
||||
setDirty(false);
|
||||
return true;
|
||||
} catch (e: any) {
|
||||
setError(e.message);
|
||||
return false;
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
const preview = async () => {
|
||||
// Save first if there are unsaved changes — otherwise the
|
||||
// preview reflects stale data.
|
||||
if (dirty) {
|
||||
const ok = await save();
|
||||
if (!ok) return;
|
||||
}
|
||||
// Open the preview in a new tab. The browser handles the PDF
|
||||
// download/render natively; we don't need to fetch the bytes
|
||||
// ourselves.
|
||||
window.open(
|
||||
`/api/admin/billing/invoice-drafts/${draft.id}/preview`,
|
||||
"_blank",
|
||||
"noopener"
|
||||
);
|
||||
};
|
||||
|
||||
const issue = async () => {
|
||||
if (!confirm(t("editorIssueConfirm"))) return;
|
||||
if (dirty) {
|
||||
const ok = await save();
|
||||
if (!ok) return;
|
||||
}
|
||||
setError("");
|
||||
setBusy("issue");
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/admin/billing/invoice-drafts/${draft.id}/issue`,
|
||||
{ method: "POST" }
|
||||
);
|
||||
const j = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(j.error || `HTTP ${res.status}`);
|
||||
// The draft was deleted server-side; go look at the new invoice.
|
||||
router.push(`/admin/billing/invoices/${j.invoice.id}`);
|
||||
} catch (e: any) {
|
||||
setError(e.message);
|
||||
setBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteDraft = async () => {
|
||||
if (!confirm(t("editorDeleteConfirm"))) return;
|
||||
setError("");
|
||||
setBusy("delete");
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/admin/billing/invoice-drafts/${draft.id}`,
|
||||
{ method: "DELETE" }
|
||||
);
|
||||
if (!res.ok) {
|
||||
const j = await res.json().catch(() => ({}));
|
||||
throw new Error(j.error || `HTTP ${res.status}`);
|
||||
}
|
||||
router.push("/admin/billing/invoice-drafts");
|
||||
} catch (e: any) {
|
||||
setError(e.message);
|
||||
setBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
// No billing snapshot = can't issue. Save still works so admin
|
||||
// can come back once the customer has completed onboarding.
|
||||
const canIssue =
|
||||
!!orgBilling &&
|
||||
lines.length > 0 &&
|
||||
lines.every((ln) => ln.description.trim().length > 0);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
{/* Bill-to preview — read-only, sourced from the org's billing
|
||||
snapshot. Issued at issue time. */}
|
||||
<Card>
|
||||
<CardHeader>{t("editorBillToHeading")}</CardHeader>
|
||||
<div className="p-4 text-sm">
|
||||
{orgBilling ? (
|
||||
<>
|
||||
<p className="font-medium">{orgBilling.companyName}</p>
|
||||
{orgBilling.contactName && (
|
||||
<p className="text-text-secondary text-xs">
|
||||
{orgBilling.contactName}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-text-secondary text-xs">
|
||||
{orgBilling.streetAddress}, {orgBilling.postalCode}{" "}
|
||||
{orgBilling.city}, {orgBilling.country}
|
||||
</p>
|
||||
{orgBilling.vatNumber && (
|
||||
<p className="text-text-muted text-xs mt-1">
|
||||
MWST/VAT: {orgBilling.vatNumber}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-text-muted text-xs">
|
||||
{orgBilling.billingEmail}
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-error">{t("editorNoBillingSnapshot")}</p>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Dates + locale + payment method */}
|
||||
<Card>
|
||||
<CardHeader>{t("editorMetadataHeading")}</CardHeader>
|
||||
<div className="p-4 grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs uppercase tracking-wider text-text-muted">
|
||||
{t("editorIssueDateLabel")}
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={issueDate}
|
||||
onChange={(e) => {
|
||||
setIssueDate(e.target.value);
|
||||
setDirty(true);
|
||||
}}
|
||||
className="px-3 py-2 rounded-md border border-border bg-surface-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs uppercase tracking-wider text-text-muted">
|
||||
{t("editorDueDateLabel")}
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={dueDate}
|
||||
onChange={(e) => {
|
||||
setDueDate(e.target.value);
|
||||
setDirty(true);
|
||||
}}
|
||||
className="px-3 py-2 rounded-md border border-border bg-surface-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs uppercase tracking-wider text-text-muted">
|
||||
{t("editorLocaleLabel")}
|
||||
</label>
|
||||
<select
|
||||
value={locale}
|
||||
onChange={(e) => {
|
||||
setLocale(e.target.value as "de" | "en" | "fr" | "it");
|
||||
setDirty(true);
|
||||
}}
|
||||
className="px-3 py-2 rounded-md border border-border bg-surface-2 text-sm"
|
||||
>
|
||||
{LOCALE_OPTIONS.map((o) => (
|
||||
<option key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs uppercase tracking-wider text-text-muted">
|
||||
{t("editorPaymentMethodLabel")}
|
||||
</label>
|
||||
<select
|
||||
value={paymentMethod}
|
||||
onChange={(e) => {
|
||||
setPaymentMethod(e.target.value as "invoice" | "card");
|
||||
setDirty(true);
|
||||
}}
|
||||
className="px-3 py-2 rounded-md border border-border bg-surface-2 text-sm"
|
||||
>
|
||||
<option value="invoice">{t("editorPaymentInvoice")}</option>
|
||||
<option value="card">{t("editorPaymentCard")}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Line editor */}
|
||||
<Card>
|
||||
<CardHeader>{t("editorLinesHeading")}</CardHeader>
|
||||
<div className="p-4">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="text-xs text-text-muted text-left">
|
||||
<tr>
|
||||
<th className="pb-2 pr-3">{t("editorLineDescription")}</th>
|
||||
<th className="pb-2 pr-3 w-20 text-right">
|
||||
{t("editorLineQty")}
|
||||
</th>
|
||||
<th className="pb-2 pr-3 w-32 text-right">
|
||||
{t("editorLineUnitPrice")}
|
||||
</th>
|
||||
<th className="pb-2 pr-3 w-32 text-right">
|
||||
{t("editorLineAmount")}
|
||||
</th>
|
||||
<th className="pb-2 w-12"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{lines.map((ln, idx) => {
|
||||
const amount =
|
||||
Math.round(
|
||||
(Number(ln.quantity) || 0) *
|
||||
(Number(ln.unitPriceChf) || 0) *
|
||||
100
|
||||
) / 100;
|
||||
return (
|
||||
<tr key={idx} className="border-t border-border">
|
||||
<td className="py-2 pr-3">
|
||||
<input
|
||||
type="text"
|
||||
value={ln.description}
|
||||
onChange={(e) =>
|
||||
updateLine(idx, { description: e.target.value })
|
||||
}
|
||||
placeholder={t("editorLineDescriptionPlaceholder")}
|
||||
className="w-full px-2 py-1.5 rounded border border-border bg-surface-2 text-sm"
|
||||
maxLength={500}
|
||||
/>
|
||||
</td>
|
||||
<td className="py-2 pr-3">
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={ln.quantity}
|
||||
onChange={(e) =>
|
||||
updateLine(idx, {
|
||||
quantity: parseFloat(e.target.value) || 0,
|
||||
})
|
||||
}
|
||||
className="w-full px-2 py-1.5 rounded border border-border bg-surface-2 text-sm font-mono text-right"
|
||||
/>
|
||||
</td>
|
||||
<td className="py-2 pr-3">
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={ln.unitPriceChf}
|
||||
onChange={(e) =>
|
||||
updateLine(idx, {
|
||||
unitPriceChf: parseFloat(e.target.value) || 0,
|
||||
})
|
||||
}
|
||||
className="w-full px-2 py-1.5 rounded border border-border bg-surface-2 text-sm font-mono text-right"
|
||||
/>
|
||||
</td>
|
||||
<td className="py-2 pr-3 text-right font-mono text-sm whitespace-nowrap">
|
||||
<span className={amount < 0 ? "text-error" : ""}>
|
||||
CHF {amount.toFixed(2)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-2 text-right">
|
||||
<button
|
||||
onClick={() => removeLine(idx)}
|
||||
className="text-text-muted hover:text-error text-lg leading-none"
|
||||
title={t("editorLineRemove")}
|
||||
type="button"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="flex gap-2 mt-3">
|
||||
<button
|
||||
onClick={addLine}
|
||||
type="button"
|
||||
className="px-3 py-1.5 rounded-md border border-border text-sm hover:bg-surface-3"
|
||||
>
|
||||
+ {t("editorAddLine")}
|
||||
</button>
|
||||
<button
|
||||
onClick={addDiscountLine}
|
||||
type="button"
|
||||
className="px-3 py-1.5 rounded-md border border-border text-sm hover:bg-surface-3 text-text-secondary"
|
||||
title={t("editorAddDiscountHint")}
|
||||
>
|
||||
− {t("editorAddDiscount")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Admin notes */}
|
||||
<Card>
|
||||
<CardHeader>{t("editorNotesHeading")}</CardHeader>
|
||||
<div className="p-4">
|
||||
<textarea
|
||||
value={adminNotes}
|
||||
onChange={(e) => {
|
||||
setAdminNotes(e.target.value);
|
||||
setDirty(true);
|
||||
}}
|
||||
placeholder={t("editorNotesPlaceholder")}
|
||||
rows={2}
|
||||
maxLength={2000}
|
||||
className="w-full px-3 py-2 rounded-md border border-border bg-surface-2 text-sm"
|
||||
/>
|
||||
<p className="text-xs text-text-muted mt-1">
|
||||
{t("editorNotesHint")}
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Totals preview */}
|
||||
<Card>
|
||||
<CardHeader>{t("editorTotalsHeading")}</CardHeader>
|
||||
<div className="p-4 max-w-sm ml-auto text-sm">
|
||||
<div className="flex justify-between py-1">
|
||||
<span className="text-text-muted">{t("editorSubtotal")}</span>
|
||||
<span className="font-mono">CHF {totals.subtotal.toFixed(2)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between py-1">
|
||||
<span className="text-text-muted">
|
||||
{t("editorVat")} ({totals.vatRate.toFixed(1)}%)
|
||||
</span>
|
||||
<span className="font-mono">CHF {totals.vatAmount.toFixed(2)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between py-2 border-t border-border mt-1 font-medium">
|
||||
<span>{t("editorTotal")}</span>
|
||||
<span className="font-mono">CHF {totals.total.toFixed(2)}</span>
|
||||
</div>
|
||||
<p className="text-xs text-text-muted mt-2 italic">
|
||||
{t("editorTotalsEstimateNote")}
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Error + actions */}
|
||||
{error && (
|
||||
<div className="text-sm text-error border border-error/30 bg-error/10 rounded-md px-4 py-2">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap gap-2 justify-between items-center">
|
||||
<button
|
||||
onClick={deleteDraft}
|
||||
disabled={busy !== null}
|
||||
className="px-4 py-2 rounded-md border border-error text-error text-sm disabled:opacity-50 hover:bg-error/10"
|
||||
type="button"
|
||||
>
|
||||
{busy === "delete" ? t("deleting") : t("editorDeleteBtn")}
|
||||
</button>
|
||||
<div className="flex gap-2 ml-auto">
|
||||
<button
|
||||
onClick={save}
|
||||
disabled={busy !== null || !dirty}
|
||||
className="px-4 py-2 rounded-md border border-border text-sm disabled:opacity-50"
|
||||
type="button"
|
||||
>
|
||||
{busy === "save"
|
||||
? t("saving")
|
||||
: dirty
|
||||
? t("editorSaveBtn")
|
||||
: t("editorSavedBtn")}
|
||||
</button>
|
||||
<button
|
||||
onClick={preview}
|
||||
disabled={busy !== null || lines.length === 0}
|
||||
className="px-4 py-2 rounded-md border border-border text-sm disabled:opacity-50"
|
||||
type="button"
|
||||
>
|
||||
{busy === "preview" ? t("previewing") : t("editorPreviewBtn")}
|
||||
</button>
|
||||
<button
|
||||
onClick={issue}
|
||||
disabled={busy !== null || !canIssue}
|
||||
className="px-4 py-2 rounded-md bg-accent text-surface-0 text-sm disabled:opacity-50"
|
||||
type="button"
|
||||
>
|
||||
{busy === "issue" ? t("issuing") : t("editorIssueBtn")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
147
src/components/admin/billing/draft-list.tsx
Normal file
147
src/components/admin/billing/draft-list.tsx
Normal file
@@ -0,0 +1,147 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useTranslations, useFormatter } from "next-intl";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import type { InvoiceDraftRecord } from "@/types";
|
||||
|
||||
interface Props {
|
||||
drafts: InvoiceDraftRecord[];
|
||||
/** Map ZITADEL org id → company name for friendlier display. */
|
||||
orgNameMap: Record<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the drafts table with per-row Edit / Delete actions.
|
||||
*
|
||||
* The total preview is the algebraic sum of line amounts (the same
|
||||
* formula billing.computeCustomInvoiceTotals uses for the subtotal,
|
||||
* minus VAT — which we don't know without the org's billing
|
||||
* snapshot). It's a hint, not authoritative; the real total
|
||||
* appears when the draft is issued.
|
||||
*
|
||||
* Empty state shows a clear CTA so a fresh admin knows where to
|
||||
* start.
|
||||
*/
|
||||
export function DraftList({ drafts, orgNameMap }: Props) {
|
||||
const t = useTranslations("adminBilling");
|
||||
const fmt = useFormatter();
|
||||
const router = useRouter();
|
||||
const [busyId, setBusyId] = useState<string | null>(null);
|
||||
|
||||
const onDelete = async (id: string) => {
|
||||
if (!confirm(t("draftDeleteConfirm"))) return;
|
||||
setBusyId(id);
|
||||
try {
|
||||
const res = await fetch(`/api/admin/billing/invoice-drafts/${id}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
if (!res.ok) {
|
||||
const j = await res.json().catch(() => ({}));
|
||||
throw new Error(j.error || `HTTP ${res.status}`);
|
||||
}
|
||||
router.refresh();
|
||||
} catch (e: any) {
|
||||
alert(e.message);
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
};
|
||||
|
||||
if (drafts.length === 0) {
|
||||
return (
|
||||
<Card>
|
||||
<div className="p-6 text-center">
|
||||
<p className="text-text-secondary mb-4">{t("draftsEmpty")}</p>
|
||||
<Link
|
||||
href="/admin/billing/invoices/new"
|
||||
className="inline-block px-4 py-2 rounded-md bg-accent text-surface-0 text-sm"
|
||||
>
|
||||
{t("newInvoiceBtn")}
|
||||
</Link>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<div className="flex justify-end p-3 border-b border-border">
|
||||
<Link
|
||||
href="/admin/billing/invoices/new"
|
||||
className="inline-block px-3 py-1.5 rounded-md bg-accent text-surface-0 text-sm"
|
||||
>
|
||||
{t("newInvoiceBtn")}
|
||||
</Link>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="text-xs text-text-muted text-left">
|
||||
<tr>
|
||||
<th className="pb-2 pl-3 pr-4">{t("draftOrgCol")}</th>
|
||||
<th className="pb-2 pr-4">{t("draftIssueDateCol")}</th>
|
||||
<th className="pb-2 pr-4 text-center">{t("draftLinesCol")}</th>
|
||||
<th className="pb-2 pr-4 text-right">{t("draftSubtotalCol")}</th>
|
||||
<th className="pb-2 pr-4">{t("draftUpdatedCol")}</th>
|
||||
<th className="pb-2 pr-3 text-right">{t("draftActionsCol")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{drafts.map((d) => {
|
||||
const subtotal = d.payload.lines.reduce(
|
||||
(s, ln) =>
|
||||
s +
|
||||
Math.round(ln.quantity * ln.unitPriceChf * 100) / 100,
|
||||
0
|
||||
);
|
||||
return (
|
||||
<tr key={d.id} className="border-t border-border">
|
||||
<td className="py-2 pl-3 pr-4">
|
||||
<Link
|
||||
href={`/admin/billing/invoice-drafts/${d.id}`}
|
||||
className="hover:underline"
|
||||
>
|
||||
{orgNameMap[d.zitadelOrgId] ?? d.zitadelOrgId}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="py-2 pr-4 text-xs font-mono text-text-secondary whitespace-nowrap">
|
||||
{d.payload.issueDate}
|
||||
</td>
|
||||
<td className="py-2 pr-4 text-center text-xs">
|
||||
{d.payload.lines.length}
|
||||
</td>
|
||||
<td className="py-2 pr-4 text-right font-mono text-xs whitespace-nowrap">
|
||||
CHF {subtotal.toFixed(2)}
|
||||
</td>
|
||||
<td className="py-2 pr-4 text-xs text-text-muted whitespace-nowrap">
|
||||
{fmt.dateTime(new Date(d.updatedAt), {
|
||||
dateStyle: "medium",
|
||||
timeStyle: "short",
|
||||
})}
|
||||
</td>
|
||||
<td className="py-2 pr-3 text-right">
|
||||
<Link
|
||||
href={`/admin/billing/invoice-drafts/${d.id}`}
|
||||
className="text-accent hover:underline text-xs mr-3"
|
||||
>
|
||||
{t("editBtn")}
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => onDelete(d.id)}
|
||||
disabled={busyId === d.id}
|
||||
className="text-error hover:underline text-xs disabled:opacity-50"
|
||||
>
|
||||
{busyId === d.id ? t("deleting") : t("deleteBtn")}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -216,7 +216,7 @@ export function GenerateForm({ orgs }: Props) {
|
||||
<button
|
||||
onClick={commit}
|
||||
disabled={busy}
|
||||
className="px-4 py-2 rounded-md bg-accent text-white text-sm disabled:opacity-50"
|
||||
className="px-4 py-2 rounded-md bg-accent text-surface-0 text-sm disabled:opacity-50"
|
||||
>
|
||||
{busy ? t("saving") : t("commitBtn")}
|
||||
</button>
|
||||
@@ -265,6 +265,7 @@ function DraftPreview({ draft }: { draft: InvoiceDraft }) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="text-xs text-text-muted text-left">
|
||||
<tr>
|
||||
@@ -323,6 +324,7 @@ function DraftPreview({ draft }: { draft: InvoiceDraft }) {
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 pt-3 border-t border-border space-y-1 text-sm">
|
||||
<div className="flex justify-between">
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState, Fragment } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useRouter } from "@/i18n/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Card, CardHeader } from "@/components/ui/card";
|
||||
import type { CreditNote, InvoiceDetail, InvoiceStatus } from "@/types";
|
||||
@@ -203,10 +203,14 @@ export function InvoiceDetailView({ detail, creditNotes = [] }: Props) {
|
||||
</h1>
|
||||
<div className="flex items-center gap-3 mt-3 text-sm">
|
||||
<StatusPill status={invoice.status} />
|
||||
<span className="text-text-muted">
|
||||
{invoice.periodStart} → {invoice.periodEnd}
|
||||
</span>
|
||||
<span className="text-text-muted">·</span>
|
||||
{invoice.periodStart && invoice.periodEnd && (
|
||||
<>
|
||||
<span className="text-text-muted">
|
||||
{invoice.periodStart} → {invoice.periodEnd}
|
||||
</span>
|
||||
<span className="text-text-muted">·</span>
|
||||
</>
|
||||
)}
|
||||
<span className="text-text-muted">
|
||||
{t("dueOnLabel")}: {invoice.dueAt}
|
||||
</span>
|
||||
@@ -243,7 +247,7 @@ export function InvoiceDetailView({ detail, creditNotes = [] }: Props) {
|
||||
<button
|
||||
onClick={() => setNoteOpen(true)}
|
||||
disabled={busyAction !== null}
|
||||
className="px-4 py-2 rounded-md bg-accent text-white text-sm disabled:opacity-50"
|
||||
className="px-4 py-2 rounded-md bg-accent text-surface-0 text-sm disabled:opacity-50"
|
||||
>
|
||||
{t("markPaidBtn")}
|
||||
</button>
|
||||
@@ -260,7 +264,7 @@ export function InvoiceDetailView({ detail, creditNotes = [] }: Props) {
|
||||
<button
|
||||
onClick={markPaid}
|
||||
disabled={busyAction !== null}
|
||||
className="px-3 py-1.5 rounded-md bg-accent text-white text-sm disabled:opacity-50"
|
||||
className="px-3 py-1.5 rounded-md bg-accent text-surface-0 text-sm disabled:opacity-50"
|
||||
>
|
||||
{busyAction === "mark-paid" ? t("saving") : t("confirm")}
|
||||
</button>
|
||||
@@ -459,37 +463,38 @@ export function InvoiceDetailView({ detail, creditNotes = [] }: Props) {
|
||||
{creditNotes.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>{t("creditNotesPanelTitle")}</CardHeader>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="text-xs text-text-muted text-left">
|
||||
<tr>
|
||||
<th className="pb-2">{t("creditNoteNumberHeader")}</th>
|
||||
<th className="pb-2">{t("creditNoteKindHeader")}</th>
|
||||
<th className="pb-2 text-right">
|
||||
<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">{t("creditNoteReasonHeader")}</th>
|
||||
<th className="pb-2">{t("creditNoteIssuedHeader")}</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 font-mono text-xs">
|
||||
<td className="py-2 pr-4 font-mono text-xs">
|
||||
{cn.creditNoteNumber}
|
||||
</td>
|
||||
<td className="py-2">
|
||||
<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 text-right font-mono">
|
||||
<td className="py-2 pr-4 text-right font-mono whitespace-nowrap">
|
||||
CHF {cn.amountChf.toFixed(2)}
|
||||
</td>
|
||||
<td className="py-2 text-text-secondary text-xs">
|
||||
<td className="py-2 pr-4 text-text-secondary text-xs">
|
||||
{cn.reason ?? "—"}
|
||||
</td>
|
||||
<td className="py-2 text-xs text-text-muted">
|
||||
<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">
|
||||
@@ -514,12 +519,14 @@ export function InvoiceDetailView({ detail, creditNotes = [] }: Props) {
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Lines */}
|
||||
<Card>
|
||||
<CardHeader>{t("lineItemsTitle")}</CardHeader>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="text-xs text-text-muted text-left">
|
||||
<tr>
|
||||
@@ -568,6 +575,7 @@ export function InvoiceDetailView({ detail, creditNotes = [] }: Props) {
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="mt-4 pt-3 border-t border-border space-y-1 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-text-muted">{t("subtotal")}</span>
|
||||
|
||||
@@ -100,6 +100,23 @@ export function InvoicesTable({ initialInvoices }: Props) {
|
||||
{t("loading")}
|
||||
</span>
|
||||
)}
|
||||
{/* Phase 8: shortcuts to the custom-invoice flow. The
|
||||
Drafts link is muted because most of the time it's
|
||||
empty; New invoice is the prominent CTA. */}
|
||||
<div className={`flex items-center gap-3 ${busy ? "" : "ml-auto"}`}>
|
||||
<Link
|
||||
href="/admin/billing/invoice-drafts"
|
||||
className="text-xs text-text-muted hover:underline"
|
||||
>
|
||||
{t("draftsLink")}
|
||||
</Link>
|
||||
<Link
|
||||
href="/admin/billing/invoices/new"
|
||||
className="px-3 py-1.5 rounded-md bg-accent text-surface-0 text-sm"
|
||||
>
|
||||
+ {t("newInvoiceBtn")}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
@@ -109,6 +126,7 @@ export function InvoicesTable({ initialInvoices }: Props) {
|
||||
{t("noInvoicesFound")}
|
||||
</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="text-xs text-text-muted text-left">
|
||||
<tr>
|
||||
@@ -142,7 +160,11 @@ export function InvoicesTable({ initialInvoices }: Props) {
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-2 text-xs font-mono">
|
||||
{inv.periodStart.slice(0, 7)}
|
||||
{inv.periodStart
|
||||
? inv.periodStart.slice(0, 7)
|
||||
: inv.source === "custom"
|
||||
? "—"
|
||||
: ""}
|
||||
</td>
|
||||
<td className="py-2">
|
||||
<StatusPill status={inv.status} />
|
||||
@@ -157,6 +179,7 @@ export function InvoicesTable({ initialInvoices }: Props) {
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
166
src/components/admin/billing/new-invoice-form.tsx
Normal file
166
src/components/admin/billing/new-invoice-form.tsx
Normal file
@@ -0,0 +1,166 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Card } from "@/components/ui/card";
|
||||
|
||||
interface OrgEntry {
|
||||
zitadelOrgId: string;
|
||||
companyName: string | null;
|
||||
country: string | null;
|
||||
hasBillingAddress: boolean;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
orgs: OrgEntry[];
|
||||
}
|
||||
|
||||
const LOCALE_OPTIONS = [
|
||||
{ value: "de", label: "Deutsch" },
|
||||
{ value: "en", label: "English" },
|
||||
{ value: "fr", label: "Français" },
|
||||
{ value: "it", label: "Italiano" },
|
||||
];
|
||||
|
||||
/**
|
||||
* Step 1 of the custom-invoice flow: pick an org. Creating the
|
||||
* draft on the backend allocates an id we redirect to; the editor
|
||||
* page then loads the draft and lets the admin add lines.
|
||||
*
|
||||
* The dropdown shows the company name when known, falling back to
|
||||
* the raw org id. Orgs without a billing snapshot are visually
|
||||
* marked and warn the admin — they can still create the draft but
|
||||
* won't be able to issue until billing info is set.
|
||||
*
|
||||
* Default issue date = today; due date = today + 30 days. These
|
||||
* are sensible defaults the editor can override.
|
||||
*/
|
||||
export function NewInvoiceForm({ orgs }: Props) {
|
||||
const t = useTranslations("adminBilling");
|
||||
const router = useRouter();
|
||||
const [orgId, setOrgId] = useState(
|
||||
orgs.find((o) => o.hasBillingAddress)?.zitadelOrgId ??
|
||||
orgs[0]?.zitadelOrgId ??
|
||||
""
|
||||
);
|
||||
const [locale, setLocale] = useState<"de" | "en" | "fr" | "it">("de");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const selected = orgs.find((o) => o.zitadelOrgId === orgId);
|
||||
|
||||
// Pick a locale default from the org's country if admin hasn't
|
||||
// overridden — same heuristic the auto cron uses.
|
||||
const onOrgChange = (newOrgId: string) => {
|
||||
setOrgId(newOrgId);
|
||||
const o = orgs.find((x) => x.zitadelOrgId === newOrgId);
|
||||
const c = (o?.country ?? "").toUpperCase();
|
||||
if (["CH", "LI", "AT", "DE"].includes(c)) setLocale("de");
|
||||
else if (["FR", "BE", "LU"].includes(c)) setLocale("fr");
|
||||
else if (c === "IT") setLocale("it");
|
||||
else setLocale("en");
|
||||
};
|
||||
|
||||
const onSubmit = async () => {
|
||||
if (!orgId) {
|
||||
setError(t("newInvoiceOrgRequired"));
|
||||
return;
|
||||
}
|
||||
setError("");
|
||||
setBusy(true);
|
||||
try {
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const due = new Date();
|
||||
due.setDate(due.getDate() + 30);
|
||||
const dueIso = due.toISOString().slice(0, 10);
|
||||
const res = await fetch("/api/admin/billing/invoice-drafts", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
zitadelOrgId: orgId,
|
||||
payload: {
|
||||
issueDate: today,
|
||||
dueDate: dueIso,
|
||||
locale,
|
||||
paymentMethod: "invoice",
|
||||
lines: [],
|
||||
},
|
||||
}),
|
||||
});
|
||||
const j = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(j.error || `HTTP ${res.status}`);
|
||||
router.push(`/admin/billing/invoice-drafts/${j.draft.id}`);
|
||||
} catch (e: any) {
|
||||
setError(e.message);
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<div className="p-5 flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs uppercase tracking-wider text-text-muted">
|
||||
{t("newInvoiceOrgLabel")}
|
||||
</label>
|
||||
<select
|
||||
value={orgId}
|
||||
onChange={(e) => onOrgChange(e.target.value)}
|
||||
className="px-3 py-2 rounded-md border border-border bg-surface-2 text-sm"
|
||||
>
|
||||
<option value="">{t("newInvoiceOrgPlaceholder")}</option>
|
||||
{orgs.map((o) => (
|
||||
<option
|
||||
key={o.zitadelOrgId}
|
||||
value={o.zitadelOrgId}
|
||||
disabled={!o.hasBillingAddress}
|
||||
>
|
||||
{o.companyName ?? o.zitadelOrgId}
|
||||
{!o.hasBillingAddress
|
||||
? ` (${t("newInvoiceOrgNoBilling")})`
|
||||
: ""}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{selected && !selected.hasBillingAddress && (
|
||||
<p className="text-xs text-error mt-1">
|
||||
{t("newInvoiceOrgBillingMissing")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs uppercase tracking-wider text-text-muted">
|
||||
{t("newInvoiceLocaleLabel")}
|
||||
</label>
|
||||
<select
|
||||
value={locale}
|
||||
onChange={(e) =>
|
||||
setLocale(e.target.value as "de" | "en" | "fr" | "it")
|
||||
}
|
||||
className="px-3 py-2 rounded-md border border-border bg-surface-2 text-sm"
|
||||
>
|
||||
{LOCALE_OPTIONS.map((o) => (
|
||||
<option key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{error && <div className="text-sm text-error">{error}</div>}
|
||||
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<button
|
||||
onClick={onSubmit}
|
||||
disabled={busy || !orgId || !selected?.hasBillingAddress}
|
||||
className="px-4 py-2 rounded-md bg-accent text-surface-0 text-sm disabled:opacity-50"
|
||||
>
|
||||
{busy ? t("creating") : t("newInvoiceContinueBtn")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
160
src/components/admin/billing/org-payment-mode-list.tsx
Normal file
160
src/components/admin/billing/org-payment-mode-list.tsx
Normal file
@@ -0,0 +1,160 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Card } from "@/components/ui/card";
|
||||
|
||||
interface OrgEntry {
|
||||
zitadelOrgId: string;
|
||||
companyName: string | null;
|
||||
country: string | null;
|
||||
hasSavedCard: boolean;
|
||||
cardLabel: string | null;
|
||||
payByInvoice: boolean;
|
||||
autoChargeEnabled: boolean;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
orgs: OrgEntry[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Inline toggles for pay_by_invoice and auto_charge_enabled per
|
||||
* org. Each toggle round-trips to /api/admin/billing/orgs/[orgId]
|
||||
* /payment-mode and then router.refresh() so the server-fetched
|
||||
* state stays canonical (avoids drift between optimistic UI and
|
||||
* the DB).
|
||||
*
|
||||
* Phase 9b-2.
|
||||
*/
|
||||
export function OrgPaymentModeList({ orgs }: Props) {
|
||||
const t = useTranslations("adminBilling");
|
||||
const router = useRouter();
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const toggle = async (
|
||||
orgId: string,
|
||||
patch: { payByInvoice?: boolean; autoChargeEnabled?: boolean }
|
||||
) => {
|
||||
setError("");
|
||||
setBusy(orgId);
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/admin/billing/orgs/${encodeURIComponent(orgId)}/payment-mode`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(patch),
|
||||
}
|
||||
);
|
||||
const j = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(j.error || `HTTP ${res.status}`);
|
||||
router.refresh();
|
||||
} catch (e: any) {
|
||||
setError(e.message);
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
if (orgs.length === 0) {
|
||||
return (
|
||||
<Card>
|
||||
<div className="p-6 text-center text-text-secondary text-sm">
|
||||
{t("orgsEmpty")}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
{error && (
|
||||
<div className="text-sm text-error border-b border-error/30 bg-error/10 px-4 py-2">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="text-xs text-text-muted text-left">
|
||||
<tr>
|
||||
<th className="pb-2 pl-3 pr-4">{t("orgsColCustomer")}</th>
|
||||
<th className="pb-2 pr-4">{t("orgsColCard")}</th>
|
||||
<th className="pb-2 pr-4 text-center">
|
||||
{t("orgsColPayByInvoice")}
|
||||
</th>
|
||||
<th className="pb-2 pr-4 text-center">
|
||||
{t("orgsColAutoCharge")}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{orgs.map((o) => (
|
||||
<tr key={o.zitadelOrgId} className="border-t border-border">
|
||||
<td className="py-2 pl-3 pr-4">
|
||||
<div className="font-medium">
|
||||
{o.companyName ?? (
|
||||
<span className="font-mono text-xs">{o.zitadelOrgId}</span>
|
||||
)}
|
||||
</div>
|
||||
{o.country && (
|
||||
<div className="text-xs text-text-muted">{o.country}</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-2 pr-4 text-xs">
|
||||
{o.hasSavedCard ? (
|
||||
<span className="font-mono">{o.cardLabel}</span>
|
||||
) : (
|
||||
<span className="text-text-muted">
|
||||
{t("orgsNoSavedCard")}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-2 pr-4 text-center">
|
||||
<label className="inline-flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={o.payByInvoice}
|
||||
disabled={busy === o.zitadelOrgId}
|
||||
onChange={(e) =>
|
||||
toggle(o.zitadelOrgId, {
|
||||
payByInvoice: e.target.checked,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<span className="text-xs">
|
||||
{o.payByInvoice
|
||||
? t("orgsPayByInvoiceOn")
|
||||
: t("orgsPayByInvoiceOff")}
|
||||
</span>
|
||||
</label>
|
||||
</td>
|
||||
<td className="py-2 pr-4 text-center">
|
||||
<label className="inline-flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={o.autoChargeEnabled}
|
||||
disabled={busy === o.zitadelOrgId || o.payByInvoice}
|
||||
onChange={(e) =>
|
||||
toggle(o.zitadelOrgId, {
|
||||
autoChargeEnabled: e.target.checked,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<span className="text-xs">
|
||||
{o.autoChargeEnabled
|
||||
? t("orgsAutoChargeOn")
|
||||
: t("orgsAutoChargeOff")}
|
||||
</span>
|
||||
</label>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -236,7 +236,7 @@ export function PricingEditor({
|
||||
<button
|
||||
type="submit"
|
||||
disabled={savingPricing}
|
||||
className="px-4 py-2 rounded-md bg-accent text-white text-sm disabled:opacity-50"
|
||||
className="px-4 py-2 rounded-md bg-accent text-surface-0 text-sm disabled:opacity-50"
|
||||
>
|
||||
{savingPricing ? t("saving") : t("save")}
|
||||
</button>
|
||||
@@ -255,6 +255,7 @@ export function PricingEditor({
|
||||
<p className="text-sm text-text-muted mb-4">{t("skillPricingDesc")}</p>
|
||||
|
||||
{initialSkillPricing.length > 0 ? (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm mb-6">
|
||||
<thead className="text-xs text-text-muted text-left">
|
||||
<tr>
|
||||
@@ -319,6 +320,7 @@ export function PricingEditor({
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-text-muted italic mb-4">{t("noSkillsPriced")}</p>
|
||||
)}
|
||||
@@ -401,7 +403,7 @@ export function PricingEditor({
|
||||
<button
|
||||
type="submit"
|
||||
disabled={addingSkill || !newSkillId}
|
||||
className="px-4 py-2 rounded-md bg-accent text-white text-sm disabled:opacity-50"
|
||||
className="px-4 py-2 rounded-md bg-accent text-surface-0 text-sm disabled:opacity-50"
|
||||
>
|
||||
{addingSkill ? t("saving") : t("add")}
|
||||
</button>
|
||||
@@ -473,7 +475,7 @@ function InlinePriceEditor({
|
||||
}
|
||||
}}
|
||||
disabled={busy}
|
||||
className="text-xs px-2 py-1 bg-accent text-white rounded"
|
||||
className="text-xs px-2 py-1 bg-accent text-surface-0 rounded"
|
||||
>
|
||||
{busy ? "…" : "✓"}
|
||||
</button>
|
||||
|
||||
@@ -147,7 +147,7 @@ export function CronControls({ initialRecent, initialLastSuccess }: Props) {
|
||||
<button
|
||||
onClick={triggerIssue}
|
||||
disabled={busy !== null}
|
||||
className="px-4 py-2 rounded-md bg-accent text-white text-sm font-medium hover:bg-accent-dim transition-colors disabled:opacity-50 cursor-pointer"
|
||||
className="px-4 py-2 rounded-md bg-accent text-surface-0 text-sm font-medium hover:bg-accent-dim transition-colors disabled:opacity-50 cursor-pointer"
|
||||
>
|
||||
{busy === "issue" ? t("running") : t("runIssueNow")}
|
||||
</button>
|
||||
@@ -165,7 +165,7 @@ export function CronControls({ initialRecent, initialLastSuccess }: Props) {
|
||||
<button
|
||||
onClick={triggerReminders}
|
||||
disabled={busy !== null}
|
||||
className="px-4 py-2 rounded-md bg-accent text-white text-sm font-medium hover:bg-accent-dim transition-colors disabled:opacity-50 cursor-pointer"
|
||||
className="px-4 py-2 rounded-md bg-accent text-surface-0 text-sm font-medium hover:bg-accent-dim transition-colors disabled:opacity-50 cursor-pointer"
|
||||
>
|
||||
{busy === "reminders" ? t("running") : t("runRemindersNow")}
|
||||
</button>
|
||||
@@ -194,6 +194,7 @@ export function CronControls({ initialRecent, initialLastSuccess }: Props) {
|
||||
{t("noRunsYet")}
|
||||
</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="text-xs text-text-muted text-left">
|
||||
<tr>
|
||||
@@ -241,6 +242,7 @@ export function CronControls({ initialRecent, initialLastSuccess }: Props) {
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
@@ -107,7 +107,7 @@ export function OpenClawAdminPanel({ initialDefaults, tenants }: Props) {
|
||||
<button
|
||||
type="submit"
|
||||
disabled={savingDefault}
|
||||
className="text-sm font-medium px-4 py-2 rounded-lg bg-accent text-white hover:bg-accent/90 transition-colors disabled:opacity-50"
|
||||
className="text-sm font-medium px-4 py-2 rounded-lg bg-accent text-surface-0 hover:bg-accent/90 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{savingDefault ? tCommon("loading") : t("saveDefault")}
|
||||
</button>
|
||||
@@ -265,7 +265,7 @@ function TenantOverrideRow({
|
||||
type="button"
|
||||
onClick={() => submit(false)}
|
||||
disabled={saving || !tag.trim()}
|
||||
className="text-xs px-3 py-1.5 rounded-lg bg-accent text-white hover:bg-accent/90 transition-colors disabled:opacity-50"
|
||||
className="text-xs px-3 py-1.5 rounded-lg bg-accent text-surface-0 hover:bg-accent/90 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{saving ? tCommon("loading") : t("saveOverride")}
|
||||
</button>
|
||||
|
||||
@@ -99,6 +99,7 @@ export function PendingSkillRequests({ initialRows }: Props) {
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="text-xs text-text-muted text-left">
|
||||
<tr>
|
||||
@@ -146,7 +147,7 @@ export function PendingSkillRequests({ initialRows }: Props) {
|
||||
<button
|
||||
onClick={() => approve(row.id)}
|
||||
disabled={busyId !== null}
|
||||
className="text-xs px-3 py-1.5 rounded-md bg-accent text-white disabled:opacity-50"
|
||||
className="text-xs px-3 py-1.5 rounded-md bg-accent text-surface-0 disabled:opacity-50"
|
||||
>
|
||||
{busyId === row.id ? t("working") : t("approveBtn")}
|
||||
</button>
|
||||
@@ -199,6 +200,7 @@ export function PendingSkillRequests({ initialRows }: Props) {
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -36,14 +36,15 @@ export function CustomerCreditNoteList({ creditNotes }: Props) {
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="text-xs text-text-muted text-left">
|
||||
<tr>
|
||||
<th className="pb-2">{t("creditNoteNumberCol")}</th>
|
||||
<th className="pb-2">{t("creditNoteInvoiceCol")}</th>
|
||||
<th className="pb-2">{t("creditNoteIssuedCol")}</th>
|
||||
<th className="pb-2 text-right">{t("creditNoteAmountCol")}</th>
|
||||
<th className="pb-2 text-right">{t("creditNoteKindCol")}</th>
|
||||
<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>
|
||||
@@ -53,19 +54,19 @@ export function CustomerCreditNoteList({ creditNotes }: Props) {
|
||||
key={cn.id}
|
||||
className="border-t border-border align-middle"
|
||||
>
|
||||
<td className="py-2 font-mono text-xs">
|
||||
<td className="py-2 pr-4 font-mono text-xs">
|
||||
{cn.creditNoteNumber}
|
||||
</td>
|
||||
<td className="py-2 font-mono text-xs text-text-secondary">
|
||||
<td className="py-2 pr-4 font-mono text-xs text-text-secondary">
|
||||
{cn.invoiceNumber}
|
||||
</td>
|
||||
<td className="py-2 text-text-secondary">
|
||||
<td className="py-2 pr-4 text-text-secondary whitespace-nowrap">
|
||||
{fmt.dateTime(new Date(cn.issuedAt), { dateStyle: "medium" })}
|
||||
</td>
|
||||
<td className="py-2 text-right font-mono">
|
||||
<td className="py-2 pr-4 text-right font-mono whitespace-nowrap">
|
||||
CHF {cn.amountChf.toFixed(2)}
|
||||
</td>
|
||||
<td className="py-2 text-right">
|
||||
<td className="py-2 pr-4 text-right">
|
||||
<span
|
||||
className={`px-2 py-0.5 rounded text-xs ${
|
||||
kindColors[cn.kind] ?? ""
|
||||
@@ -96,6 +97,7 @@ export function CustomerCreditNoteList({ creditNotes }: Props) {
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -46,11 +46,17 @@ export function CustomerInvoiceDetail({ invoice, lines }: Props) {
|
||||
{t(`status.${invoice.status}` as any)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-text-secondary">
|
||||
{fmt.dateTime(new Date(invoice.periodStart), { dateStyle: "long" })}
|
||||
<span className="text-text-muted mx-1">→</span>
|
||||
{fmt.dateTime(new Date(invoice.periodEnd), { dateStyle: "long" })}
|
||||
</p>
|
||||
{invoice.periodStart && invoice.periodEnd && (
|
||||
<p className="text-sm text-text-secondary">
|
||||
{fmt.dateTime(new Date(invoice.periodStart), {
|
||||
dateStyle: "long",
|
||||
})}
|
||||
<span className="text-text-muted mx-1">→</span>
|
||||
{fmt.dateTime(new Date(invoice.periodEnd), {
|
||||
dateStyle: "long",
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-start gap-2 flex-wrap">
|
||||
{/* Phase 4: Pay-with-card available for open + overdue.
|
||||
@@ -101,6 +107,7 @@ export function CustomerInvoiceDetail({ invoice, lines }: Props) {
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="text-xs text-text-muted text-left">
|
||||
<tr>
|
||||
@@ -154,6 +161,7 @@ export function CustomerInvoiceDetail({ invoice, lines }: Props) {
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -46,6 +46,7 @@ export function CustomerInvoiceList({ invoices }: Props) {
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="text-xs text-text-muted text-left">
|
||||
<tr>
|
||||
@@ -71,9 +72,19 @@ export function CustomerInvoiceList({ invoices }: Props) {
|
||||
</Link>
|
||||
</td>
|
||||
<td className="py-2 text-xs text-text-secondary">
|
||||
{fmt.dateTime(new Date(inv.periodStart), { dateStyle: "medium" })}
|
||||
<span className="text-text-muted mx-1">→</span>
|
||||
{fmt.dateTime(new Date(inv.periodEnd), { dateStyle: "medium" })}
|
||||
{inv.periodStart && inv.periodEnd ? (
|
||||
<>
|
||||
{fmt.dateTime(new Date(inv.periodStart), {
|
||||
dateStyle: "medium",
|
||||
})}
|
||||
<span className="text-text-muted mx-1">→</span>
|
||||
{fmt.dateTime(new Date(inv.periodEnd), {
|
||||
dateStyle: "medium",
|
||||
})}
|
||||
</>
|
||||
) : (
|
||||
<span className="text-text-muted">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-2 text-xs text-text-secondary">
|
||||
{fmt.dateTime(new Date(inv.dueAt), { dateStyle: "medium" })}
|
||||
@@ -94,6 +105,7 @@ export function CustomerInvoiceList({ invoices }: Props) {
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -50,7 +50,7 @@ export function PayInvoiceButton({ invoiceNumber }: Props) {
|
||||
<button
|
||||
onClick={onClick}
|
||||
disabled={busy}
|
||||
className="px-4 py-2 rounded-md bg-accent text-white text-sm font-medium hover:bg-accent-dim transition-colors disabled:opacity-50 cursor-pointer"
|
||||
className="px-4 py-2 rounded-md bg-accent text-surface-0 text-sm font-medium hover:bg-accent-dim transition-colors disabled:opacity-50 cursor-pointer"
|
||||
>
|
||||
{busy ? t("redirectingToStripe") : t("payWithCard")}
|
||||
</button>
|
||||
|
||||
@@ -86,7 +86,7 @@ export function RunningTotalWidget({ isOwner }: Props) {
|
||||
{noConfig && isOwner && (
|
||||
<Link
|
||||
href="/settings/billing"
|
||||
className="inline-block mt-2 px-4 py-2 rounded-md bg-accent text-white text-sm font-medium hover:bg-accent-dim transition-colors"
|
||||
className="inline-block mt-2 px-4 py-2 rounded-md bg-accent text-surface-0 text-sm font-medium hover:bg-accent-dim transition-colors"
|
||||
>
|
||||
{t("configureBillingCta")}
|
||||
</Link>
|
||||
@@ -125,9 +125,16 @@ export function RunningTotalWidget({ isOwner }: Props) {
|
||||
}
|
||||
// draft
|
||||
const draft = data.draft;
|
||||
const periodLabel = `${fmt.dateTime(new Date(draft.periodStart), {
|
||||
dateStyle: "long",
|
||||
})} → ${fmt.dateTime(new Date(draft.periodEnd), { dateStyle: "long" })}`;
|
||||
// Phase 8: InvoiceDraft.periodStart/End became nullable for the
|
||||
// custom-invoice flow. The running-total widget only renders the
|
||||
// auto-cron draft (always has a period), so the null branch is
|
||||
// defensive — if we ever did hit it the label just collapses.
|
||||
const periodLabel =
|
||||
draft.periodStart && draft.periodEnd
|
||||
? `${fmt.dateTime(new Date(draft.periodStart), {
|
||||
dateStyle: "long",
|
||||
})} → ${fmt.dateTime(new Date(draft.periodEnd), { dateStyle: "long" })}`
|
||||
: "";
|
||||
return (
|
||||
<Card>
|
||||
<div className="flex items-start justify-between gap-4 flex-wrap mb-3">
|
||||
@@ -153,6 +160,7 @@ export function RunningTotalWidget({ isOwner }: Props) {
|
||||
<summary className="cursor-pointer text-text-muted hover:text-text-secondary">
|
||||
{t("breakdownToggle", { count: draft.lines.length })}
|
||||
</summary>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full mt-2 text-xs">
|
||||
<tbody>
|
||||
{draft.lines.map((ln, i) => (
|
||||
@@ -181,6 +189,7 @@ export function RunningTotalWidget({ isOwner }: Props) {
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
<p className="text-[10px] text-text-muted mt-3 italic">{t("draftNote")}</p>
|
||||
|
||||
@@ -328,7 +328,7 @@ export function ChannelUsers({
|
||||
<button
|
||||
onClick={() => handleAdd(channel)}
|
||||
disabled={saving || !inputValues[channel]?.trim()}
|
||||
className="px-4 py-2 text-sm font-medium bg-accent text-white rounded-lg hover:bg-accent-dim transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
className="px-4 py-2 text-sm font-medium bg-accent text-surface-0 rounded-lg hover:bg-accent-dim transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{saving ? "…" : t("add")}
|
||||
</button>
|
||||
|
||||
@@ -263,7 +263,7 @@ export function BudgetEditableCard({
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving}
|
||||
className="text-sm px-4 py-2 rounded-lg bg-accent text-white hover:bg-accent/90 transition-colors disabled:opacity-50"
|
||||
className="text-sm px-4 py-2 rounded-lg bg-accent text-surface-0 hover:bg-accent/90 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{saving ? tCommon("loading") : tCommon("save")}
|
||||
</button>
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { signOut, useSession } from "next-auth/react";
|
||||
import { usePathname } from "@/i18n/navigation";
|
||||
import { Link } from "@/i18n/navigation";
|
||||
import { SessionProvider } from "next-auth/react";
|
||||
import type { Session } from "next-auth";
|
||||
import { LanguageSwitcher } from "@/components/ui/language-switcher";
|
||||
|
||||
function NavBar() {
|
||||
@@ -13,6 +15,15 @@ function NavBar() {
|
||||
const pathname = usePathname();
|
||||
const user = (session as any)?.platformUser;
|
||||
|
||||
const [mobileOpen, setMobileOpen] = useState(false);
|
||||
|
||||
// Close the mobile menu on any navigation. Without this the panel
|
||||
// would stay open across route changes (the component doesn't
|
||||
// unmount — it lives in the layout).
|
||||
useEffect(() => {
|
||||
setMobileOpen(false);
|
||||
}, [pathname]);
|
||||
|
||||
// Hide the nav entirely on auth-only routes. These pages have no
|
||||
// session yet — showing "Dashboard" / "Sign Out" is misleading at
|
||||
// best (the buttons would 401 or redirect-loop). Keep this list
|
||||
@@ -21,6 +32,47 @@ function NavBar() {
|
||||
const isAuthRoute = pathname === "/login" || pathname === "/register";
|
||||
if (isAuthRoute) return null;
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Visibility gates — computed once, shared by the desktop nav and the
|
||||
// mobile panel so the two can never diverge.
|
||||
//
|
||||
// - team: owner+platform only AND not a personal account (Bug 8 —
|
||||
// personal accounts have no team). Matches `canMutate` /
|
||||
// `user.isPersonal === false` server-side.
|
||||
// - settings: anyone who can mutate org-level state (owners + platform).
|
||||
// `user`-role customers don't see it (canMutate is false).
|
||||
// - billing / support: any signed-in user (org-scoped server-side).
|
||||
// - admin: platform only.
|
||||
// ------------------------------------------------------------------
|
||||
const isOwner =
|
||||
user && Array.isArray(user.roles) && user.roles.includes("owner");
|
||||
const showTeam = !!user && !user.isPersonal && (user.isPlatform || isOwner);
|
||||
const showSettings = !!user && (user.isPlatform || isOwner);
|
||||
const showBilling = !!user;
|
||||
const showSupport = !!user;
|
||||
const showAdmin = !!user?.isPlatform;
|
||||
|
||||
// Active-state helper. Dashboard/Admin previously used exact `===`,
|
||||
// so sub-routes (/dashboard/new, /admin/billing, …) showed no active
|
||||
// item. startsWith keeps the parent lit on its children too.
|
||||
const isActive = (href: string) =>
|
||||
pathname === href || pathname.startsWith(`${href}/`);
|
||||
|
||||
const links = [
|
||||
{ href: "/dashboard", label: t("dashboard"), show: !!user },
|
||||
{ href: "/team", label: t("team"), show: showTeam },
|
||||
{ href: "/settings", label: t("settings"), show: showSettings },
|
||||
{ href: "/billing", label: t("billing"), show: showBilling },
|
||||
{ href: "/support", label: t("support"), show: showSupport },
|
||||
{ href: "/admin", label: t("admin"), show: showAdmin },
|
||||
].filter((l) => l.show);
|
||||
|
||||
const displayName = user
|
||||
? user.isPersonal
|
||||
? user.name || (user.email ? user.email.split("@")[0] : user.orgName)
|
||||
: user.orgName
|
||||
: "";
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-50 border-b border-border bg-surface-1/80 backdrop-blur-md">
|
||||
<div className="mx-auto flex h-14 max-w-6xl items-center justify-between px-5">
|
||||
@@ -40,98 +92,96 @@ function NavBar() {
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
{/* Nav links */}
|
||||
{/* Desktop nav links */}
|
||||
<nav className="hidden sm:flex items-center gap-1 ml-2">
|
||||
<NavLink href="/dashboard" active={pathname === "/dashboard"}>
|
||||
{t("dashboard")}
|
||||
</NavLink>
|
||||
{/* Slice 7: /team is owner+platform only AND personal
|
||||
accounts are excluded — they have no team to manage
|
||||
(Bug 8). Match server-side gates (`canMutate`,
|
||||
`user.isPersonal === false`). The roles array carries
|
||||
either "owner" or "user" for customer sessions;
|
||||
isPlatform covers the platform side. */}
|
||||
{user &&
|
||||
!user.isPersonal &&
|
||||
(user.isPlatform ||
|
||||
(Array.isArray(user.roles) && user.roles.includes("owner"))) && (
|
||||
<NavLink href="/team" active={pathname === "/team"}>
|
||||
{t("team")}
|
||||
</NavLink>
|
||||
)}
|
||||
{/* Bug 35: /settings is shown to anyone who can mutate org-level
|
||||
state — owners and platform admins. Personal accounts also
|
||||
see it; their billing page is optional but the entry point
|
||||
exists for consistency. `user`-role customers don't see it
|
||||
(canMutate is false). */}
|
||||
{user &&
|
||||
(user.isPlatform ||
|
||||
(Array.isArray(user.roles) && user.roles.includes("owner"))) && (
|
||||
<NavLink
|
||||
href="/settings"
|
||||
active={pathname.startsWith("/settings")}
|
||||
>
|
||||
{t("settings")}
|
||||
</NavLink>
|
||||
)}
|
||||
{/* Phase 3: Billing visible to anyone signed in. The
|
||||
page is org-scoped server-side — non-owner members
|
||||
see the same invoice history their owner does, but
|
||||
actions like "configure billing details" are gated
|
||||
separately on the settings page. Personal accounts
|
||||
see their own (single-tenant) invoices. */}
|
||||
{user && (
|
||||
<NavLink
|
||||
href="/billing"
|
||||
active={pathname.startsWith("/billing")}
|
||||
>
|
||||
{t("billing")}
|
||||
{links.map((l) => (
|
||||
<NavLink key={l.href} href={l.href} active={isActive(l.href)}>
|
||||
{l.label}
|
||||
</NavLink>
|
||||
)}
|
||||
{/* Feature 5: Support is available to every signed-in
|
||||
user. Customers see their own tickets only; platform
|
||||
admins see the queue. */}
|
||||
{user && (
|
||||
<NavLink
|
||||
href="/support"
|
||||
active={pathname.startsWith("/support")}
|
||||
>
|
||||
{t("support")}
|
||||
</NavLink>
|
||||
)}
|
||||
{user?.isPlatform && (
|
||||
<NavLink href="/admin" active={pathname === "/admin"}>
|
||||
{t("admin")}
|
||||
</NavLink>
|
||||
)}
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* Right side */}
|
||||
<div className="flex items-center gap-4">
|
||||
{user && (
|
||||
// For personal accounts the orgName is opaque
|
||||
// ("personal-3f2a8b1c") or a synthetic legacy
|
||||
// "Name (Personal)" — neither is what we want in the nav.
|
||||
// Show the user's display name instead. The detection logic
|
||||
// and fallback chain live in `lib/personal-org.ts`; keeping
|
||||
// a thin inline branch here avoids importing a server-only
|
||||
// helper into a client component.
|
||||
<span className="hidden md:inline text-xs text-text-secondary font-mono">
|
||||
{user.isPersonal
|
||||
? user.name || (user.email ? user.email.split("@")[0] : user.orgName)
|
||||
: user.orgName}
|
||||
{displayName}
|
||||
</span>
|
||||
)}
|
||||
<LanguageSwitcher />
|
||||
<button
|
||||
onClick={() => signOut({ callbackUrl: "/login" })}
|
||||
className="text-xs font-medium text-text-secondary hover:text-error transition-colors cursor-pointer"
|
||||
className="hidden sm:inline text-xs font-medium text-text-secondary hover:text-error transition-colors cursor-pointer"
|
||||
>
|
||||
{t("logout")}
|
||||
</button>
|
||||
|
||||
{/* Mobile menu toggle — only shown below the `sm` breakpoint,
|
||||
where the desktop nav and logout button are hidden. */}
|
||||
{user && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMobileOpen((v) => !v)}
|
||||
aria-expanded={mobileOpen}
|
||||
aria-controls="mobile-nav"
|
||||
aria-label={t("menu")}
|
||||
className="sm:hidden inline-flex items-center justify-center h-8 w-8 -mr-1 rounded-md text-text-secondary hover:text-text-primary hover:bg-surface-2 transition-colors cursor-pointer"
|
||||
>
|
||||
<svg
|
||||
className="h-5 w-5"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.75"
|
||||
strokeLinecap="round"
|
||||
>
|
||||
{mobileOpen ? (
|
||||
<path d="M6 6l12 12M18 6L6 18" />
|
||||
) : (
|
||||
<path d="M4 7h16M4 12h16M4 17h16" />
|
||||
)}
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile panel */}
|
||||
{user && mobileOpen && (
|
||||
<nav
|
||||
id="mobile-nav"
|
||||
className="sm:hidden border-t border-border bg-surface-1 px-3 py-3"
|
||||
>
|
||||
<div className="flex flex-col gap-1">
|
||||
{links.map((l) => (
|
||||
<Link
|
||||
key={l.href}
|
||||
href={l.href}
|
||||
className={`px-3 py-2.5 rounded-md text-sm font-medium transition-colors ${
|
||||
isActive(l.href)
|
||||
? "bg-surface-3 text-text-primary"
|
||||
: "text-text-secondary hover:text-text-primary hover:bg-surface-2"
|
||||
}`}
|
||||
>
|
||||
{l.label}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-3 pt-3 border-t border-border flex items-center justify-between px-3">
|
||||
<span className="text-xs text-text-secondary font-mono truncate">
|
||||
{displayName}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => signOut({ callbackUrl: "/login" })}
|
||||
className="text-xs font-medium text-text-secondary hover:text-error transition-colors cursor-pointer shrink-0 ml-3"
|
||||
>
|
||||
{t("logout")}
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
)}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -162,9 +212,19 @@ function NavLink({
|
||||
);
|
||||
}
|
||||
|
||||
export function NavShell({ children }: { children: React.ReactNode }) {
|
||||
export function NavShell({
|
||||
children,
|
||||
session,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
// Server-resolved session passed down from the locale layout. Seeding
|
||||
// SessionProvider with it means useSession() is populated on the first
|
||||
// client render, so the nav links render immediately instead of
|
||||
// popping in after the client-side session fetch (CLS / flash).
|
||||
session: Session | null;
|
||||
}) {
|
||||
return (
|
||||
<SessionProvider>
|
||||
<SessionProvider session={session}>
|
||||
<NavBar />
|
||||
<main className="mx-auto max-w-6xl px-5 py-8">{children}</main>
|
||||
</SessionProvider>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useRouter } from "@/i18n/navigation";
|
||||
import { OnboardingWizard } from "./wizard";
|
||||
import type { OrgBilling } from "@/types";
|
||||
|
||||
@@ -26,6 +26,11 @@ interface OnboardingFlowProps {
|
||||
* validation skip when the billing step was skipped.
|
||||
*/
|
||||
existingOrgBilling?: OrgBilling | null;
|
||||
/**
|
||||
* Phase 9b: platform setup fee (net CHF) shown on the review
|
||||
* step. Forwarded straight to the wizard.
|
||||
*/
|
||||
setupFeeChf?: number | null;
|
||||
/**
|
||||
* Bug 6: when present, the wizard is rendered in edit mode against
|
||||
* the given pending request. See `OnboardingWizard` for the full
|
||||
@@ -53,6 +58,7 @@ export function OnboardingFlow({
|
||||
userEmail,
|
||||
hasOrgBilling,
|
||||
existingOrgBilling,
|
||||
setupFeeChf,
|
||||
editingRequest,
|
||||
}: OnboardingFlowProps) {
|
||||
const router = useRouter();
|
||||
@@ -64,6 +70,7 @@ export function OnboardingFlow({
|
||||
userEmail={userEmail}
|
||||
hasOrgBilling={hasOrgBilling}
|
||||
existingOrgBilling={existingOrgBilling}
|
||||
setupFeeChf={setupFeeChf}
|
||||
editingRequest={editingRequest}
|
||||
onComplete={() => {
|
||||
// Navigate back to /dashboard and re-fetch on the server. The
|
||||
|
||||
@@ -489,7 +489,7 @@ export function ProvisioningStatus({ requestId, canAct }: Props) {
|
||||
</p>
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
className="py-2 px-6 bg-accent text-white text-sm font-medium rounded-lg hover:bg-accent-dim transition-colors"
|
||||
className="py-2 px-6 bg-accent text-surface-0 text-sm font-medium rounded-lg hover:bg-accent-dim transition-colors"
|
||||
>
|
||||
{t("goToDashboard")}
|
||||
</button>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useTranslations } from "next-intl";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { PACKAGE_CATALOG, DEFAULT_PACKAGE_IDS, type PackageDef } from "@/lib/packages";
|
||||
import { isPersonalOrgName, displayOrgNameFor } from "@/lib/personal-org";
|
||||
import { THREEMA_GATEWAY } from "@/lib/threema-gateway-config";
|
||||
import {
|
||||
configureStepSchema,
|
||||
billingStepSchema,
|
||||
@@ -108,6 +109,14 @@ interface WizardProps {
|
||||
* billingAddress snapshot).
|
||||
*/
|
||||
existingOrgBilling?: OrgBilling | null;
|
||||
/**
|
||||
* Phase 9b: the platform's current tenant setup fee (net CHF,
|
||||
* before VAT). Shown on the review step so the customer sees how
|
||||
* much they're about to be charged before being sent to Stripe.
|
||||
* Null/0 means no setup fee — the review notice is suppressed and
|
||||
* the order skips the Checkout redirect (handled server-side).
|
||||
*/
|
||||
setupFeeChf?: number | null;
|
||||
/**
|
||||
* Bug 6: when present, the wizard renders in "edit" mode — fields
|
||||
* are pre-populated from the request, the SOUL.md auto-fetch is
|
||||
@@ -147,6 +156,7 @@ export function OnboardingWizard({
|
||||
userEmail,
|
||||
hasOrgBilling,
|
||||
existingOrgBilling,
|
||||
setupFeeChf,
|
||||
editingRequest,
|
||||
onComplete,
|
||||
}: WizardProps) {
|
||||
@@ -245,6 +255,14 @@ export function OnboardingWizard({
|
||||
const [disclaimerAccepted, setDisclaimerAccepted] = useState<
|
||||
Record<string, boolean>
|
||||
>({});
|
||||
// Phase 9b: per-channel customer user id collected at onboarding.
|
||||
// Keyed by package id (e.g. "telegram" → "1234567"). Applied on
|
||||
// admin approval — see /api/admin/requests/[id]/approve. Optional
|
||||
// per channel; the customer can also leave it blank and add their
|
||||
// id later from the tenant's channel-users page.
|
||||
const [channelUserIds, setChannelUserIds] = useState<Record<string, string>>(
|
||||
{}
|
||||
);
|
||||
|
||||
// Fetch DB-stored defaults on mount
|
||||
useEffect(() => {
|
||||
@@ -464,6 +482,20 @@ export function OnboardingWizard({
|
||||
})()
|
||||
: config;
|
||||
|
||||
// Phase 9b: build the channelUsers payload from the per-package
|
||||
// ids collected during onboarding. Only include channels that
|
||||
// (a) are enabled in the wizard's packages list AND
|
||||
// (b) have a non-empty id entered.
|
||||
// Shape matches PiecedTenantSpec.channelUsers — { channel: [id] }
|
||||
// — so the approve handler can pass it straight through.
|
||||
const channelUsersPayload: Record<string, string[]> = {};
|
||||
for (const [pkgId, rawId] of Object.entries(channelUserIds)) {
|
||||
const trimmed = (rawId ?? "").trim();
|
||||
if (!trimmed) continue;
|
||||
if (!config.packages.includes(pkgId)) continue;
|
||||
channelUsersPayload[pkgId] = [trimmed];
|
||||
}
|
||||
|
||||
const res = await fetch(url, {
|
||||
method,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
@@ -473,6 +505,10 @@ export function OnboardingWizard({
|
||||
Object.keys(secretsPayload).length > 0
|
||||
? secretsPayload
|
||||
: undefined,
|
||||
channelUsers:
|
||||
Object.keys(channelUsersPayload).length > 0
|
||||
? channelUsersPayload
|
||||
: undefined,
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -481,6 +517,22 @@ export function OnboardingWizard({
|
||||
throw new Error(data.error || "Submission failed");
|
||||
}
|
||||
|
||||
// Phase 9b: if the server initiated a setup-fee Checkout, the
|
||||
// response carries a `checkoutUrl`. Redirect the browser
|
||||
// directly — Stripe Checkout is the next step. The
|
||||
// tenant_requests row is already inserted in 'pending_payment'
|
||||
// status; on successful Checkout, the webhook flips it to
|
||||
// 'pending' and admin sees it.
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (data?.checkoutUrl) {
|
||||
// Don't reset submitting=false — let the redirect happen
|
||||
// with the spinner still active so the button stays
|
||||
// disabled.
|
||||
window.location.href = data.checkoutUrl;
|
||||
return;
|
||||
}
|
||||
|
||||
// Zero-fee path or PATCH edit — same behaviour as before.
|
||||
onComplete();
|
||||
} catch (err: any) {
|
||||
setError(err.message);
|
||||
@@ -554,7 +606,7 @@ export function OnboardingWizard({
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
onClick={goNext}
|
||||
className="py-2 px-6 bg-accent text-white text-sm font-medium rounded-lg hover:bg-accent-dim transition-colors"
|
||||
className="py-2 px-6 bg-accent text-surface-0 text-sm font-medium rounded-lg hover:bg-accent-dim transition-colors"
|
||||
>
|
||||
{t("getStarted")}
|
||||
</button>
|
||||
@@ -720,7 +772,9 @@ export function OnboardingWizard({
|
||||
className={`border rounded-lg overflow-hidden transition-colors ${
|
||||
isSelected
|
||||
? "border-accent bg-accent/5"
|
||||
: "border-border bg-surface-2"
|
||||
: pkg.recommended
|
||||
? "border-accent/40 bg-accent/[0.02]"
|
||||
: "border-border bg-surface-2"
|
||||
}`}
|
||||
>
|
||||
{/* Toggle row */}
|
||||
@@ -739,6 +793,11 @@ export function OnboardingWizard({
|
||||
>
|
||||
{pkg.name}
|
||||
</span>
|
||||
{pkg.recommended && (
|
||||
<span className="ml-2 text-[10px] font-semibold uppercase tracking-wide text-accent bg-accent/10 border border-accent/30 rounded-full px-1.5 py-0.5">
|
||||
{tPkg("recommended")}
|
||||
</span>
|
||||
)}
|
||||
{pkg.requiresSecrets && (
|
||||
<span className="ml-1.5 text-[10px] text-text-muted">
|
||||
({tPkg("requiresApiKey")})
|
||||
@@ -760,8 +819,16 @@ export function OnboardingWizard({
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Inline credential inputs — expand when selected + requires secrets */}
|
||||
{isSelected && pkg.requiresSecrets && (
|
||||
{/* Inline expansion when selected — shows
|
||||
instructions (if any), credential inputs
|
||||
(if requiresSecrets), and the disclaimer
|
||||
checkbox (if any). Threema for example
|
||||
has no customer-entered secrets but has
|
||||
instructions + a disclaimer to accept. */}
|
||||
{isSelected &&
|
||||
(pkg.requiresSecrets ||
|
||||
pkg.instructionsKey ||
|
||||
pkg.disclaimerKey) && (
|
||||
<div className="border-t border-border px-3 py-3 space-y-3 bg-surface-1/50">
|
||||
{pkg.instructionsKey && (
|
||||
<div className="bg-surface-2 border border-border rounded-lg p-3 text-xs text-text-secondary leading-relaxed whitespace-pre-line">
|
||||
@@ -774,6 +841,40 @@ export function OnboardingWizard({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Threema: show the bot's Threema ID
|
||||
and QR right here in the wizard. The
|
||||
instructions text refers to a QR
|
||||
that isn't visible until after
|
||||
provisioning — without this block
|
||||
the message is confusing. The QR is
|
||||
the platform's shared gateway QR
|
||||
(*AIAGENT), identical for every
|
||||
tenant, so we can render it before
|
||||
the tenant even exists. */}
|
||||
{pkg.id === "threema" && (
|
||||
<div className="rounded-lg border border-accent/30 bg-surface-1 p-3 flex items-start gap-3">
|
||||
<div className="bg-white p-1.5 rounded-md shrink-0">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={THREEMA_GATEWAY.qrCodePath}
|
||||
alt={`QR code for ${THREEMA_GATEWAY.displayName}`}
|
||||
width={96}
|
||||
height={96}
|
||||
style={{ display: "block" }}
|
||||
/>
|
||||
</div>
|
||||
<div className="text-xs text-text-secondary leading-relaxed">
|
||||
<div className="text-text-primary font-medium mb-1">
|
||||
{tPkg("threemaBotIdHeading")}
|
||||
</div>
|
||||
<div className="font-mono text-sm text-accent mb-2">
|
||||
{THREEMA_GATEWAY.displayName}
|
||||
</div>
|
||||
<div>{tPkg("threemaBotIdHint")}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(pkg.secrets || []).map((field) => (
|
||||
<label key={field.key} className="block">
|
||||
<span className="text-xs text-text-secondary mb-1 block">
|
||||
@@ -802,6 +903,46 @@ export function OnboardingWizard({
|
||||
</label>
|
||||
))}
|
||||
|
||||
{/* Phase 9b: channel-user-id capture
|
||||
during onboarding. For channels
|
||||
where the customer's own user id
|
||||
is needed for routing (Telegram,
|
||||
Discord, Threema), collect it here
|
||||
so the assistant is usable
|
||||
immediately on provisioning. The
|
||||
help text comes from the existing
|
||||
channelUsers.<id>IdHelp keys
|
||||
(same copy as the post-provisioning
|
||||
page uses). Field is optional —
|
||||
blank means "I'll add it later". */}
|
||||
{pkg.collectsChannelUserId && (
|
||||
<label className="block">
|
||||
<span className="text-xs text-text-secondary mb-1 block">
|
||||
{t(`yourChannelIdLabel.${pkg.id}`)}{" "}
|
||||
<span className="text-text-muted normal-case">
|
||||
({t("optional")})
|
||||
</span>
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
placeholder={t(
|
||||
`yourChannelIdPlaceholder.${pkg.id}`
|
||||
)}
|
||||
value={channelUserIds[pkg.id] ?? ""}
|
||||
onChange={(e) =>
|
||||
setChannelUserIds((prev) => ({
|
||||
...prev,
|
||||
[pkg.id]: e.target.value,
|
||||
}))
|
||||
}
|
||||
className="w-full px-3 py-2 bg-surface-2 border border-border rounded-lg text-sm text-text-primary placeholder:text-text-muted font-mono focus:outline-none focus:ring-1 focus:ring-accent focus:border-accent transition-colors"
|
||||
/>
|
||||
<p className="text-[11px] text-text-muted mt-1 leading-relaxed whitespace-pre-line">
|
||||
{t(`yourChannelIdHelp.${pkg.id}`)}
|
||||
</p>
|
||||
</label>
|
||||
)}
|
||||
|
||||
{pkg.disclaimerKey && (
|
||||
<label className="flex items-start gap-2 text-xs text-text-secondary">
|
||||
<input
|
||||
@@ -853,7 +994,7 @@ export function OnboardingWizard({
|
||||
<button
|
||||
onClick={goNext}
|
||||
disabled={!packageCredentialsValid()}
|
||||
className="py-2 px-6 bg-accent text-white text-sm font-medium rounded-lg hover:bg-accent-dim transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
className="py-2 px-6 bg-accent text-surface-0 text-sm font-medium rounded-lg hover:bg-accent-dim transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{t("next")}
|
||||
</button>
|
||||
@@ -1030,28 +1171,6 @@ export function OnboardingWizard({
|
||||
</p>
|
||||
</FieldWithError>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-semibold uppercase tracking-wider text-text-muted mb-1.5">
|
||||
{t("billingNotes")}
|
||||
</label>
|
||||
<textarea
|
||||
value={config.billingNotes}
|
||||
onChange={(e) =>
|
||||
setConfig((prev) => ({
|
||||
...prev,
|
||||
billingNotes: e.target.value,
|
||||
}))
|
||||
}
|
||||
rows={3}
|
||||
placeholder={t(
|
||||
isPersonal
|
||||
? "billingNotesPlaceholderPersonal"
|
||||
: "billingNotesPlaceholder"
|
||||
)}
|
||||
className="w-full px-3 py-2 bg-surface-2 border border-border rounded-lg text-sm text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-1 focus:ring-accent focus:border-accent transition-colors resize-y"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between mt-6">
|
||||
@@ -1063,7 +1182,7 @@ export function OnboardingWizard({
|
||||
</button>
|
||||
<button
|
||||
onClick={goNext}
|
||||
className="py-2 px-6 bg-accent text-white text-sm font-medium rounded-lg hover:bg-accent-dim transition-colors"
|
||||
className="py-2 px-6 bg-accent text-surface-0 text-sm font-medium rounded-lg hover:bg-accent-dim transition-colors"
|
||||
>
|
||||
{t("next")}
|
||||
</button>
|
||||
@@ -1213,19 +1332,34 @@ export function OnboardingWizard({
|
||||
value={userEmail || ""}
|
||||
mono
|
||||
/>
|
||||
{config.billingNotes.trim().length > 0 && (
|
||||
<ReviewRow
|
||||
label={t("billingNotes")}
|
||||
value={
|
||||
<span className="text-text-primary whitespace-pre-wrap text-right">
|
||||
{config.billingNotes}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-text-muted">{t("confirmNote")}</p>
|
||||
|
||||
{/* Phase 9b: order-time setup-fee notice + amount. The
|
||||
figure shown is the net platform fee (before VAT);
|
||||
VAT is added server-side based on the billing
|
||||
country. We show "+ VAT" rather than a computed
|
||||
gross to avoid mis-displaying a country-dependent
|
||||
total. If setupFeeChf is null/0, no charge happens
|
||||
and the whole block is suppressed. */}
|
||||
{typeof setupFeeChf === "number" && setupFeeChf > 0 && (
|
||||
<div className="text-xs rounded-md border border-accent/30 bg-accent/10 text-text-secondary px-3 py-3 mt-4">
|
||||
<strong className="block text-text-primary mb-1">
|
||||
{t("setupFeeNoticeHeading")}
|
||||
</strong>
|
||||
<div className="flex items-baseline justify-between mb-2 pb-2 border-b border-accent/20">
|
||||
<span>{t("setupFeeAmountLabel")}</span>
|
||||
<span className="text-sm font-semibold text-text-primary">
|
||||
CHF {setupFeeChf.toFixed(2)}{" "}
|
||||
<span className="text-[10px] font-normal text-text-muted">
|
||||
{t("setupFeePlusVat")}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
{t("setupFeeNoticeBody")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
@@ -1263,7 +1397,7 @@ export function OnboardingWizard({
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={submitting}
|
||||
className="py-2.5 px-6 bg-accent text-white text-sm font-medium rounded-lg hover:bg-accent-dim transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
className="py-2.5 px-6 bg-accent text-surface-0 text-sm font-medium rounded-lg hover:bg-accent-dim transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{submitting
|
||||
? tCommon("loading")
|
||||
|
||||
@@ -9,6 +9,7 @@ import type {
|
||||
SkillPricing,
|
||||
} from "@/types";
|
||||
import { SkillCostDialog } from "./skill-cost-dialog";
|
||||
import { ThreemaQrModal } from "@/components/channel-users/threema-qr-modal";
|
||||
|
||||
interface Props {
|
||||
pkg: PackageDef;
|
||||
@@ -51,6 +52,11 @@ export function PackageCard({
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
// Phase 2.5: cost-disclosure flow + activation-request flow.
|
||||
const [showCostDialog, setShowCostDialog] = useState(false);
|
||||
// Threema: after a successful enable on customProvisioning, surface
|
||||
// the gateway QR + bot Threema ID so the customer immediately knows
|
||||
// how to add the assistant to their Threema contacts. Without this,
|
||||
// the toggle just flips silently with no actionable info.
|
||||
const [showThreemaInfo, setShowThreemaInfo] = useState(false);
|
||||
const isPriced =
|
||||
(pricing?.dailyPriceChf ?? 0) > 0 || (pricing?.setupFeeChf ?? 0) > 0;
|
||||
|
||||
@@ -79,6 +85,14 @@ export function PackageCard({
|
||||
throw new Error(err.error || `Provisioning failed (HTTP ${provRes.status})`);
|
||||
}
|
||||
await togglePackage(true);
|
||||
// For Threema specifically: now that the relay's minted the
|
||||
// per-tenant token and the package is enabled, show the
|
||||
// gateway QR + bot Threema ID so the customer can add the
|
||||
// assistant to their Threema contacts straight away. Other
|
||||
// customProvisioning packages don't need this confirmation.
|
||||
if (pkg.id === "threema") {
|
||||
setShowThreemaInfo(true);
|
||||
}
|
||||
} catch (e: any) {
|
||||
setError(e.message);
|
||||
} finally {
|
||||
@@ -283,17 +297,33 @@ export function PackageCard({
|
||||
</button>
|
||||
</div>
|
||||
) : canEdit ? (
|
||||
<button
|
||||
onClick={enabled ? handleDisable : handleEnable}
|
||||
disabled={saving}
|
||||
className={`ml-auto rounded-lg px-3 py-1.5 text-xs font-medium transition-all cursor-pointer ${
|
||||
enabled
|
||||
? "bg-surface-3 text-text-secondary hover:text-text-primary hover:bg-surface-2"
|
||||
: "bg-accent text-surface-0 hover:bg-accent-dim shadow-lg shadow-accent/20"
|
||||
} disabled:opacity-50`}
|
||||
>
|
||||
{saving ? "…" : enabled ? t("packages.disable") : t("packages.enable")}
|
||||
</button>
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
{/* Phase 9b: re-open the Threema info popup at any time
|
||||
while Threema is enabled. The popup auto-opens after
|
||||
a fresh enable; this button lets the customer see the
|
||||
QR + bot ID again without having to disable + re-enable. */}
|
||||
{pkg.id === "threema" && enabled && (
|
||||
<button
|
||||
onClick={() => setShowThreemaInfo(true)}
|
||||
className="rounded-lg px-2 py-1.5 text-xs font-medium bg-surface-3 text-text-secondary hover:text-text-primary hover:bg-surface-2 transition-colors cursor-pointer"
|
||||
title={t("packages.showInfoTitle")}
|
||||
aria-label={t("packages.showInfoTitle")}
|
||||
>
|
||||
ⓘ {t("packages.showInfo")}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={enabled ? handleDisable : handleEnable}
|
||||
disabled={saving}
|
||||
className={`rounded-lg px-3 py-1.5 text-xs font-medium transition-all cursor-pointer ${
|
||||
enabled
|
||||
? "bg-surface-3 text-text-secondary hover:text-text-primary hover:bg-surface-2"
|
||||
: "bg-accent text-surface-0 hover:bg-accent-dim shadow-lg shadow-accent/20"
|
||||
} disabled:opacity-50`}
|
||||
>
|
||||
{saving ? "…" : enabled ? t("packages.disable") : t("packages.enable")}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
// Slice 5: read-only viewers see a static badge instead of a
|
||||
// toggle. The status badge above the divider already conveys
|
||||
@@ -320,6 +350,16 @@ export function PackageCard({
|
||||
busy={saving}
|
||||
/>
|
||||
|
||||
{/* Threema: post-enable confirmation showing the gateway QR
|
||||
and bot Threema ID. Only rendered for the threema package
|
||||
and only after a successful enable. The same modal is also
|
||||
reachable later on the channel-users page. */}
|
||||
{pkg.id === "threema" && (
|
||||
<ThreemaQrModal
|
||||
open={showThreemaInfo}
|
||||
onClose={() => setShowThreemaInfo(false)}
|
||||
/>
|
||||
)}
|
||||
{showModal && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4">
|
||||
<div className="w-full max-w-md bg-surface-1 border border-border rounded-2xl p-6 space-y-4 shadow-2xl shadow-black/40">
|
||||
|
||||
@@ -104,7 +104,7 @@ export function SkillCostDialog({
|
||||
<button
|
||||
onClick={onConfirm}
|
||||
disabled={busy}
|
||||
className="px-4 py-2 rounded-md bg-accent text-white text-sm disabled:opacity-50"
|
||||
className="px-4 py-2 rounded-md bg-accent text-surface-0 text-sm disabled:opacity-50"
|
||||
>
|
||||
{busy ? t("confirming") : t("confirm")}
|
||||
</button>
|
||||
|
||||
@@ -227,7 +227,7 @@ export function BillingSettingsForm({ initial, isPersonal }: Props) {
|
||||
<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"
|
||||
className="px-4 py-2 rounded-md bg-accent text-surface-0 text-sm font-medium hover:bg-accent-dim transition-colors disabled:opacity-50 cursor-pointer"
|
||||
>
|
||||
{busy ? t("saving") : initial ? t("saveChanges") : t("createBilling")}
|
||||
</button>
|
||||
|
||||
@@ -268,7 +268,7 @@ export function BillingSettingsForm({
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
className="ml-auto text-sm font-medium px-4 py-2 rounded-lg bg-accent text-white hover:bg-accent/90 transition-colors disabled:opacity-50"
|
||||
className="ml-auto text-sm font-medium px-4 py-2 rounded-lg bg-accent text-surface-0 hover:bg-accent/90 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{submitting ? tCommon("loading") : t("save")}
|
||||
</button>
|
||||
|
||||
@@ -153,7 +153,7 @@ export function ProfileSettingsForm({ initial, isPersonal, orgName }: Props) {
|
||||
<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"
|
||||
className="px-4 py-2 rounded-md bg-accent text-surface-0 text-sm font-medium hover:bg-accent-dim transition-colors disabled:opacity-50 cursor-pointer"
|
||||
>
|
||||
{busy ? t("saving") : t("saveChanges")}
|
||||
</button>
|
||||
|
||||
273
src/components/settings/saved-card-section.tsx
Normal file
273
src/components/settings/saved-card-section.tsx
Normal file
@@ -0,0 +1,273 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useRouter } from "@/i18n/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Card, CardHeader } from "@/components/ui/card";
|
||||
import type { OrgBillingConfig } from "@/types";
|
||||
|
||||
interface Props {
|
||||
config: OrgBillingConfig | null;
|
||||
/**
|
||||
* True when this org has been flipped to pay-by-invoice by admin.
|
||||
* The card UI still renders (admin-set customers might also have
|
||||
* a saved card as backup), but with an info note that auto-charge
|
||||
* is disabled by their billing mode.
|
||||
*/
|
||||
isPayByInvoice: boolean;
|
||||
/**
|
||||
* Personal-account flag from the session. Personal accounts are
|
||||
* single-user B2C tenants and don't have the bank-transfer
|
||||
* affordance — they pay by card or not at all. We hide the
|
||||
* "Bank transfer is available on request" hint for these accounts
|
||||
* to keep the messaging unambiguous.
|
||||
*/
|
||||
isPersonal: boolean;
|
||||
}
|
||||
|
||||
const BRAND_LABELS: Record<string, string> = {
|
||||
visa: "Visa",
|
||||
mastercard: "Mastercard",
|
||||
amex: "American Express",
|
||||
discover: "Discover",
|
||||
jcb: "JCB",
|
||||
diners: "Diners Club",
|
||||
unionpay: "UnionPay",
|
||||
};
|
||||
|
||||
/**
|
||||
* Saved-card management — Phase 9.
|
||||
*
|
||||
* State derives entirely from the OrgBillingConfig the server
|
||||
* sends down. Actions are: set up (no card → Checkout setup
|
||||
* mode), update (existing card → same Checkout flow, replaces),
|
||||
* remove (DELETE the PM in Stripe + clear local fields), toggle
|
||||
* auto-charge.
|
||||
*
|
||||
* The component watches for ?card_setup=success on mount and
|
||||
* fires a router.refresh() — the success redirect from Stripe
|
||||
* lands here and the new card info needs to load. We also strip
|
||||
* the query param so a page reload doesn't re-trigger.
|
||||
*/
|
||||
export function SavedCardSection({
|
||||
config,
|
||||
isPayByInvoice,
|
||||
isPersonal,
|
||||
}: Props) {
|
||||
const t = useTranslations("settingsBilling");
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const [busy, setBusy] = useState<null | "setup" | "remove">(null);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
// Refresh + clean the URL when Stripe redirects back. Stripe's
|
||||
// webhook is what actually persists the card; the refresh just
|
||||
// re-fetches the server-side config so the new fields appear.
|
||||
useEffect(() => {
|
||||
const status = searchParams.get("card_setup");
|
||||
if (status === "success") {
|
||||
router.replace("/settings/billing");
|
||||
router.refresh();
|
||||
} else if (status === "cancelled") {
|
||||
// Just clean the URL. No-op otherwise.
|
||||
router.replace("/settings/billing");
|
||||
}
|
||||
}, [searchParams, router]);
|
||||
|
||||
const hasCard = !!config?.stripeDefaultPaymentMethodId;
|
||||
const autoChargeOn = config?.autoChargeEnabled !== false;
|
||||
|
||||
const startSetup = async () => {
|
||||
setError("");
|
||||
setBusy("setup");
|
||||
try {
|
||||
const res = await fetch("/api/billing/setup-card", { method: "POST" });
|
||||
const j = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(j.error || `HTTP ${res.status}`);
|
||||
if (!j.url) throw new Error("No redirect URL returned");
|
||||
// Hard-redirect — Stripe Checkout doesn't run inside the SPA.
|
||||
window.location.href = j.url;
|
||||
} catch (e: any) {
|
||||
setError(e.message);
|
||||
setBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
const removeCard = async () => {
|
||||
if (!confirm(t("savedCardRemoveConfirm"))) return;
|
||||
setError("");
|
||||
setBusy("remove");
|
||||
try {
|
||||
const res = await fetch("/api/billing/saved-card", { method: "DELETE" });
|
||||
const j = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(j.error || `HTTP ${res.status}`);
|
||||
router.refresh();
|
||||
} catch (e: any) {
|
||||
setError(e.message);
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
// Empty state — no card on file.
|
||||
if (!hasCard) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>{t("savedCardHeading")}</CardHeader>
|
||||
<div className="p-5">
|
||||
<p className="text-sm text-text-secondary mb-4">
|
||||
{t("savedCardEmptyBody")}
|
||||
</p>
|
||||
{/* Phase 9: prominent policy notice. Auto-pay is the
|
||||
expected default — emphasise that failure to keep a
|
||||
chargeable card on file may result in tenant suspension.
|
||||
Sits above the CTA so it's seen before the click. */}
|
||||
<div className="text-sm rounded-md border border-warning/40 bg-warning/10 text-warning px-4 py-3 mb-4">
|
||||
<strong className="block mb-1">
|
||||
{t("savedCardAutoPayRequiredHeading")}
|
||||
</strong>
|
||||
<span className="text-text-secondary">
|
||||
{t("savedCardAutoPayRequiredBody")}
|
||||
</span>
|
||||
</div>
|
||||
{error && (
|
||||
<div className="text-sm text-error mb-3">{error}</div>
|
||||
)}
|
||||
<button
|
||||
onClick={startSetup}
|
||||
disabled={busy !== null}
|
||||
className="px-4 py-2 rounded-md bg-accent text-surface-0 text-sm disabled:opacity-50"
|
||||
>
|
||||
{busy === "setup" ? t("savedCardRedirecting") : t("savedCardSetupBtn")}
|
||||
</button>
|
||||
{/* Bank-transfer hint shown only for company accounts.
|
||||
Personal (B2C) accounts pay by card only — surfacing
|
||||
the alternative would only confuse. */}
|
||||
{!isPersonal && (
|
||||
<p className="text-xs text-text-muted mt-4">
|
||||
{t("savedCardBankTransferHint")}{" "}
|
||||
<a
|
||||
href="/support"
|
||||
className="text-accent hover:underline"
|
||||
>
|
||||
{t("savedCardBankTransferLink")}
|
||||
</a>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// Card on file.
|
||||
const brandLabel =
|
||||
config?.stripePmBrand
|
||||
? BRAND_LABELS[config.stripePmBrand] ?? config.stripePmBrand
|
||||
: t("savedCardBrandUnknown");
|
||||
const last4 = config?.stripePmLast4 ?? "????";
|
||||
const expMonth = config?.stripePmExpMonth;
|
||||
const expYear = config?.stripePmExpYear;
|
||||
const expLabel =
|
||||
expMonth && expYear
|
||||
? `${String(expMonth).padStart(2, "0")}/${String(expYear).slice(-2)}`
|
||||
: "";
|
||||
// Heuristic for "expiring soon" — if the card expires this calendar
|
||||
// month or next. Stripe's pre-expiration emails handle the real
|
||||
// notification, but a portal hint is friendly too.
|
||||
const now = new Date();
|
||||
const expiringSoon =
|
||||
expMonth &&
|
||||
expYear &&
|
||||
(expYear < now.getFullYear() ||
|
||||
(expYear === now.getFullYear() && expMonth <= now.getMonth() + 2));
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>{t("savedCardHeading")}</CardHeader>
|
||||
<div className="p-5">
|
||||
<div className="flex items-center justify-between mb-4 flex-wrap gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="font-mono text-sm">
|
||||
{brandLabel} •••• {last4}
|
||||
</span>
|
||||
{expLabel && (
|
||||
<span
|
||||
className={`text-xs ${
|
||||
expiringSoon ? "text-warning" : "text-text-muted"
|
||||
}`}
|
||||
>
|
||||
{t("savedCardExpires", { date: expLabel })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-xs">
|
||||
<span
|
||||
className={`px-2 py-0.5 rounded text-xs ${
|
||||
autoChargeOn
|
||||
? "bg-success/15 text-success"
|
||||
: "bg-text-muted/15 text-text-muted"
|
||||
}`}
|
||||
>
|
||||
{autoChargeOn
|
||||
? t("savedCardAutoChargeOn")
|
||||
: t("savedCardAutoChargeOff")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isPayByInvoice && (
|
||||
<div className="text-xs text-text-muted bg-surface-3 rounded-md px-3 py-2 mb-3">
|
||||
{t("savedCardPayByInvoiceNote")}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* If the card is on file but the customer has actively
|
||||
disabled auto-pay, surface the suspension-risk reminder.
|
||||
Not shown when admin has flipped them to pay-by-invoice —
|
||||
that's a different deal and the note above explains it. */}
|
||||
{!isPayByInvoice && !autoChargeOn && (
|
||||
<div className="text-xs rounded-md border border-warning/40 bg-warning/10 text-warning px-3 py-2 mb-3">
|
||||
{t("savedCardAutoPayDisabledNote")}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && <div className="text-sm text-error mb-3">{error}</div>}
|
||||
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<button
|
||||
onClick={startSetup}
|
||||
disabled={busy !== null}
|
||||
className="px-3 py-1.5 rounded-md border border-border text-sm disabled:opacity-50 hover:bg-surface-3"
|
||||
>
|
||||
{busy === "setup"
|
||||
? t("savedCardRedirecting")
|
||||
: t("savedCardUpdateBtn")}
|
||||
</button>
|
||||
<button
|
||||
onClick={removeCard}
|
||||
disabled={busy !== null}
|
||||
className="px-3 py-1.5 rounded-md border border-error text-error text-sm disabled:opacity-50 hover:bg-error/10 ml-auto"
|
||||
>
|
||||
{busy === "remove"
|
||||
? t("savedCardRemoving")
|
||||
: t("savedCardRemoveBtn")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Bank-transfer hint shown only for company accounts. */}
|
||||
{!isPersonal && (
|
||||
<p className="text-xs text-text-muted mt-4">
|
||||
{t("savedCardBankTransferHint")}{" "}
|
||||
<a
|
||||
href="/support"
|
||||
className="text-accent hover:underline"
|
||||
>
|
||||
{t("savedCardBankTransferLink")}
|
||||
</a>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -119,7 +119,7 @@ export function TicketCreateForm() {
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
className="text-sm font-medium px-4 py-2 rounded-lg bg-accent text-white hover:bg-accent/90 transition-colors disabled:opacity-50"
|
||||
className="text-sm font-medium px-4 py-2 rounded-lg bg-accent text-surface-0 hover:bg-accent/90 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{submitting ? tCommon("loading") : t("submitTicket")}
|
||||
</button>
|
||||
|
||||
@@ -186,7 +186,7 @@ export function TicketThread({
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting || closing || body.trim().length === 0}
|
||||
className="text-sm font-medium px-4 py-2 rounded-lg bg-accent text-white hover:bg-accent/90 transition-colors disabled:opacity-50"
|
||||
className="text-sm font-medium px-4 py-2 rounded-lg bg-accent text-surface-0 hover:bg-accent/90 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{submitting ? tCommon("loading") : t("sendReply")}
|
||||
</button>
|
||||
|
||||
@@ -141,7 +141,7 @@ export function InviteForm() {
|
||||
<button
|
||||
type="submit"
|
||||
disabled={state === "submitting"}
|
||||
className="w-full py-2.5 px-4 bg-accent text-white text-sm font-medium rounded-lg hover:bg-accent-dim transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
className="w-full py-2.5 px-4 bg-accent text-surface-0 text-sm font-medium rounded-lg hover:bg-accent-dim transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{state === "submitting" ? tCommon("loading") : t("inviteButton")}
|
||||
</button>
|
||||
|
||||
@@ -179,7 +179,7 @@ export function TeamList({
|
||||
type="button"
|
||||
onClick={() => saveEdit(m)}
|
||||
disabled={submitting || !m.authorizationId}
|
||||
className="text-xs px-2.5 py-1 rounded-md bg-accent text-white hover:bg-accent-dim transition-colors disabled:opacity-50"
|
||||
className="text-xs px-2.5 py-1 rounded-md bg-accent text-surface-0 hover:bg-accent-dim transition-colors disabled:opacity-50"
|
||||
>
|
||||
{t("save")}
|
||||
</button>
|
||||
|
||||
@@ -218,7 +218,7 @@ export function AssignedUsersPanel({ tenantName, canEdit }: Props) {
|
||||
<button
|
||||
onClick={handleAssign}
|
||||
disabled={busy || !pickedUserId}
|
||||
className="px-4 py-2 text-sm font-medium bg-accent text-white rounded-lg hover:bg-accent-dim transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
className="px-4 py-2 text-sm font-medium bg-accent text-surface-0 rounded-lg hover:bg-accent-dim transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{busy ? "…" : t("assign")}
|
||||
</button>
|
||||
|
||||
58
src/components/ui/button.tsx
Normal file
58
src/components/ui/button.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import { forwardRef } from "react";
|
||||
|
||||
/**
|
||||
* Shared button primitive.
|
||||
*
|
||||
* Why this exists
|
||||
* ---------------
|
||||
* The accent fill (#00d4aa) is bright; white text on it measures ~1.9:1,
|
||||
* which fails WCAG even for large/UI text. Dark text (surface-0) on the
|
||||
* same accent is ~10:1. The codebase had ~40 hand-rolled accent buttons,
|
||||
* most using `text-white`. This component centralises the correct token
|
||||
* (`text-surface-0` on accent) so the contrast can't drift again — reach
|
||||
* for `<Button>` instead of re-deriving the class string.
|
||||
*/
|
||||
|
||||
type Variant = "primary" | "secondary" | "ghost" | "danger";
|
||||
type Size = "sm" | "md";
|
||||
|
||||
const BASE =
|
||||
"inline-flex items-center justify-center gap-1.5 font-medium rounded-lg " +
|
||||
"transition-colors cursor-pointer focus:outline-none focus-visible:ring-2 " +
|
||||
"focus-visible:ring-accent/50 disabled:opacity-50 disabled:cursor-not-allowed";
|
||||
|
||||
const VARIANTS: Record<Variant, string> = {
|
||||
// surface-0 (dark) text — the contrast-correct pairing for the accent.
|
||||
primary: "bg-accent text-surface-0 hover:bg-accent-dim shadow-sm shadow-accent/20",
|
||||
secondary:
|
||||
"bg-surface-2 text-text-primary border border-border hover:bg-surface-3 hover:border-border-active",
|
||||
ghost: "text-text-secondary hover:text-text-primary hover:bg-surface-2",
|
||||
danger: "bg-error text-surface-0 hover:opacity-90",
|
||||
};
|
||||
|
||||
const SIZES: Record<Size, string> = {
|
||||
sm: "text-xs px-3 py-1.5",
|
||||
md: "text-sm px-4 py-2",
|
||||
};
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: Variant;
|
||||
size?: Size;
|
||||
}
|
||||
|
||||
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
function Button(
|
||||
{ variant = "primary", size = "md", className = "", type = "button", ...rest },
|
||||
ref
|
||||
) {
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
type={type}
|
||||
className={`${BASE} ${VARIANTS[variant]} ${SIZES[size]} ${className}`}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
@@ -16,6 +16,9 @@ interface Props {
|
||||
ariaLabel?: string;
|
||||
}
|
||||
|
||||
const FOCUSABLE =
|
||||
'a[href],button:not([disabled]),textarea:not([disabled]),input:not([disabled]),select:not([disabled]),[tabindex]:not([tabindex="-1"])';
|
||||
|
||||
/**
|
||||
* Portal-based modal.
|
||||
*
|
||||
@@ -25,45 +28,86 @@ interface Props {
|
||||
* ancestor's containing block, not the viewport, when ANY ancestor
|
||||
* has a `transform`, `perspective`, or `filter` applied. Our
|
||||
* `animate-in` utility sets `transform: translateY(0)` on a lot of
|
||||
* dashboard/tenant-detail containers (because of the fade-up
|
||||
* animation, which uses `animation-fill-mode: both` to keep the
|
||||
* transform on after the animation finishes). That broke modals
|
||||
* rendered as in-place children — they centred to the panel they
|
||||
* lived in, not to the page.
|
||||
* dashboard/tenant-detail containers, which broke modals rendered as
|
||||
* in-place children — they centred to the panel they lived in, not to
|
||||
* the page. Rendering at `document.body` via `createPortal` escapes
|
||||
* every containing-block ancestor and gives us true viewport coords.
|
||||
*
|
||||
* Rendering at `document.body` via `createPortal` escapes every
|
||||
* containing-block ancestor and gives us true viewport coordinates.
|
||||
*
|
||||
* UX details
|
||||
* ----------
|
||||
* - Backdrop click triggers `onClose`. (Bubbling check: only fires
|
||||
* when the click target IS the backdrop, not the panel inside.)
|
||||
* - Escape key triggers `onClose`. Standard modal expectation.
|
||||
* - `body` overflow is locked while open so background content
|
||||
* doesn't scroll behind the modal.
|
||||
* - Renders nothing on first paint server-side, then mounts on
|
||||
* client. `useEffect` gating ensures `document.body` is available;
|
||||
* without it Next.js SSR would throw on `document` reference.
|
||||
* UX / a11y details
|
||||
* -----------------
|
||||
* - Backdrop click triggers `onClose` (only when the click target IS
|
||||
* the backdrop, not the panel inside).
|
||||
* - Escape triggers `onClose`.
|
||||
* - `body` overflow is locked while open so background content doesn't
|
||||
* scroll behind the modal.
|
||||
* - Focus is moved into the panel on open, trapped within it while open
|
||||
* (Tab / Shift+Tab cycle), and restored to the previously focused
|
||||
* element on close — so keyboard and screen-reader users can't tab
|
||||
* out to the inert page behind the dialog.
|
||||
*/
|
||||
export function Modal({ open, onClose, children, ariaLabel }: Props) {
|
||||
const closeRef = useRef(onClose);
|
||||
closeRef.current = onClose;
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
// Lock background scroll. Restore on unmount/close.
|
||||
// Remember what had focus so we can restore it on close.
|
||||
const previouslyFocused = document.activeElement as HTMLElement | null;
|
||||
|
||||
// Lock background scroll.
|
||||
const previousOverflow = document.body.style.overflow;
|
||||
document.body.style.overflow = "hidden";
|
||||
|
||||
// Move focus into the dialog — first focusable element, else the
|
||||
// panel itself (it carries tabIndex={-1}).
|
||||
const panel = panelRef.current;
|
||||
const focusables = panel
|
||||
? Array.from(panel.querySelectorAll<HTMLElement>(FOCUSABLE))
|
||||
: [];
|
||||
(focusables[0] ?? panel)?.focus();
|
||||
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") closeRef.current();
|
||||
if (e.key === "Escape") {
|
||||
closeRef.current();
|
||||
return;
|
||||
}
|
||||
if (e.key !== "Tab" || !panel) return;
|
||||
|
||||
// Re-query each time — modal content can change between tabs.
|
||||
const items = Array.from(
|
||||
panel.querySelectorAll<HTMLElement>(FOCUSABLE)
|
||||
).filter((el) => el.offsetParent !== null || el === document.activeElement);
|
||||
if (items.length === 0) {
|
||||
e.preventDefault();
|
||||
panel.focus();
|
||||
return;
|
||||
}
|
||||
const first = items[0];
|
||||
const last = items[items.length - 1];
|
||||
const active = document.activeElement;
|
||||
|
||||
if (e.shiftKey) {
|
||||
if (active === first || active === panel) {
|
||||
e.preventDefault();
|
||||
last.focus();
|
||||
}
|
||||
} else if (active === last) {
|
||||
e.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", onKey);
|
||||
|
||||
return () => {
|
||||
document.body.style.overflow = previousOverflow;
|
||||
window.removeEventListener("keydown", onKey);
|
||||
// Restore focus to the trigger (if it's still in the document).
|
||||
if (previouslyFocused && document.contains(previouslyFocused)) {
|
||||
previouslyFocused.focus();
|
||||
}
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
@@ -72,15 +116,19 @@ export function Modal({ open, onClose, children, ariaLabel }: Props) {
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={ariaLabel}
|
||||
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm"
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) onClose();
|
||||
}}
|
||||
>
|
||||
<div className="bg-surface-1 border border-border rounded-xl p-6 max-w-md w-full max-h-[90vh] overflow-y-auto">
|
||||
<div
|
||||
ref={panelRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={ariaLabel}
|
||||
tabIndex={-1}
|
||||
className="bg-surface-1 border border-border rounded-xl p-6 max-w-md w-full max-h-[90vh] overflow-y-auto focus:outline-none"
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>,
|
||||
|
||||
@@ -153,5 +153,21 @@ export function formatLineDescription(
|
||||
}[L];
|
||||
return reason ? `${base}: ${reason}` : base;
|
||||
}
|
||||
|
||||
// Phase 8: custom invoice lines. The description is what the
|
||||
// admin typed in the editor — return it verbatim (no template,
|
||||
// no locale-specific formatting). billing.ts persists the
|
||||
// already-trimmed admin input into invoice_lines.description.
|
||||
case "custom_line": {
|
||||
const dRaw = (m as Record<string, unknown>)["description"];
|
||||
if (typeof dRaw === "string" && dRaw.trim().length > 0) return dRaw;
|
||||
// Fallback: the description column on the row itself. The
|
||||
// PDF renderer hands us the line so it can read it directly
|
||||
// — see how billing-pdf invokes formatLineDescription.
|
||||
const onRow = (line as unknown as { description?: string }).description;
|
||||
return onRow && onRow.trim().length > 0
|
||||
? onRow
|
||||
: { de: "Leistung", en: "Service", fr: "Service", it: "Servizio" }[L];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -107,6 +107,7 @@ const MESSAGES: Record<string, PdfStrings> = {
|
||||
skill_usage: "Skill-Nutzung",
|
||||
skill_setup: "Einrichtungsgebühr Skill",
|
||||
adjustment: "Anpassung",
|
||||
custom_line: "Leistungen",
|
||||
},
|
||||
reverseCharge:
|
||||
"Steuerschuldnerschaft des Leistungsempfängers (Reverse Charge).",
|
||||
@@ -140,6 +141,7 @@ const MESSAGES: Record<string, PdfStrings> = {
|
||||
skill_usage: "Skill usage",
|
||||
skill_setup: "Skill setup fee",
|
||||
adjustment: "Adjustment",
|
||||
custom_line: "Services",
|
||||
},
|
||||
reverseCharge:
|
||||
"Reverse charge — VAT to be accounted for by the recipient.",
|
||||
@@ -173,6 +175,7 @@ const MESSAGES: Record<string, PdfStrings> = {
|
||||
skill_usage: "Utilisation Skill",
|
||||
skill_setup: "Frais de configuration skill",
|
||||
adjustment: "Ajustement",
|
||||
custom_line: "Services",
|
||||
},
|
||||
reverseCharge:
|
||||
"Autoliquidation — TVA à acquitter par le destinataire.",
|
||||
@@ -206,6 +209,7 @@ const MESSAGES: Record<string, PdfStrings> = {
|
||||
skill_usage: "Utilizzo Skill",
|
||||
skill_setup: "Spese di attivazione skill",
|
||||
adjustment: "Rettifica",
|
||||
custom_line: "Servizi",
|
||||
},
|
||||
reverseCharge:
|
||||
"Inversione contabile — IVA a carico del destinatario.",
|
||||
@@ -435,11 +439,18 @@ const InvoicePdf: React.FC<InvoicePdfProps> = ({ invoice, lines }) => {
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.metaCol}>
|
||||
<Text style={styles.metaLabel}>{s.period}</Text>
|
||||
<Text style={styles.metaValue}>
|
||||
{fmtDate(invoice.periodStart, invoice.locale)} —{" "}
|
||||
{fmtDate(invoice.periodEnd, invoice.locale)}
|
||||
</Text>
|
||||
{/* Phase 8: skip the billing-period block on custom
|
||||
invoices (which aren't tied to a period). Due date
|
||||
still renders. */}
|
||||
{invoice.periodStart && invoice.periodEnd && (
|
||||
<>
|
||||
<Text style={styles.metaLabel}>{s.period}</Text>
|
||||
<Text style={styles.metaValue}>
|
||||
{fmtDate(invoice.periodStart, invoice.locale)} —{" "}
|
||||
{fmtDate(invoice.periodEnd, invoice.locale)}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
<Text style={styles.metaLabel}>{s.dueDate}</Text>
|
||||
<Text style={styles.metaValue}>
|
||||
{fmtDate(invoice.dueAt, invoice.locale)}
|
||||
|
||||
@@ -31,9 +31,11 @@
|
||||
|
||||
import type {
|
||||
CreditNote,
|
||||
CustomInvoiceDraftPayload,
|
||||
Invoice,
|
||||
InvoiceBillingSnapshot,
|
||||
InvoiceDraft,
|
||||
InvoiceDraftRecord,
|
||||
InvoiceLine,
|
||||
InvoiceLineKind,
|
||||
InvoicePaymentMethod,
|
||||
@@ -48,7 +50,9 @@ import {
|
||||
attachCreditNotePdf,
|
||||
createCreditNote,
|
||||
createInvoice,
|
||||
deleteInvoiceDraft,
|
||||
getInvoiceById,
|
||||
getInvoiceDraftById,
|
||||
getOrgBilling,
|
||||
getOrgBillingConfig,
|
||||
getPlatformPricing,
|
||||
@@ -56,8 +60,10 @@ import {
|
||||
listSkillEventsForTenant,
|
||||
listSkillPricing,
|
||||
listSuspensionEventsForTenant,
|
||||
markInvoicePaid,
|
||||
markInvoiceVoided,
|
||||
recordInvoiceRefund,
|
||||
setInvoiceStripePaymentIntent,
|
||||
tenantHasSetupFeeBilled,
|
||||
tenantSkillHasBeenBilled,
|
||||
updateInvoicePdf,
|
||||
@@ -67,8 +73,12 @@ import { getTeamSpendLogsV2 } from "./litellm";
|
||||
import { getUsage as getThreemaUsage } from "./threema-relay";
|
||||
import { renderInvoicePdf } from "./billing-pdf";
|
||||
import { renderCreditNotePdf } from "./credit-note-pdf";
|
||||
import { sendCreditNoteEmail, sendInvoiceIssuedEmail } from "./email";
|
||||
import { createInvoiceRefund } from "./stripe";
|
||||
import {
|
||||
sendAutoChargeFailedEmail,
|
||||
sendCreditNoteEmail,
|
||||
sendInvoiceIssuedEmail,
|
||||
} from "./email";
|
||||
import { chargeInvoiceOffSession, createInvoiceRefund } from "./stripe";
|
||||
import { formatLineDescription } from "./billing-i18n";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -247,6 +257,9 @@ const EU_COUNTRIES = new Set([
|
||||
|
||||
/**
|
||||
* 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
|
||||
* defaults you confirmed:
|
||||
*
|
||||
@@ -255,7 +268,7 @@ const EU_COUNTRIES = new Set([
|
||||
* - EU without VAT: CH MWST (B2C consumer, we charge our rate)
|
||||
* - other: 0% (export of services)
|
||||
*/
|
||||
function vatRateForAddress(
|
||||
export function vatRateForAddress(
|
||||
snapshot: InvoiceBillingSnapshot,
|
||||
platformPricing: PlatformPricing
|
||||
): { rate: number; note: string | null } {
|
||||
@@ -789,50 +802,90 @@ export async function generateInvoice(opts: {
|
||||
await updateInvoicePdf(placeholder.id, pdfBuffer, filename);
|
||||
const finalInvoice = await getInvoiceById(placeholder.id);
|
||||
|
||||
// Phase 3: best-effort notification to the billing contact.
|
||||
// We send AFTER the PDF is fully persisted (so the deep link
|
||||
// in the email immediately resolves to a downloadable PDF) but
|
||||
// BEFORE returning, since the cron caller doesn't otherwise
|
||||
// know to trigger this. Failure is logged, never thrown — a
|
||||
// mail-server hiccup must not roll back an issued invoice.
|
||||
// The recipient is the billing email captured in the invoice
|
||||
// snapshot (immutable; reflects who was on file at issue time).
|
||||
try {
|
||||
const settled = finalInvoice ?? placeholder;
|
||||
const snapshot = settled.billingSnapshot;
|
||||
if (snapshot.billingEmail) {
|
||||
const supportedLocales: Array<"en" | "de" | "fr" | "it"> = [
|
||||
"en", "de", "fr", "it",
|
||||
];
|
||||
const locale = supportedLocales.includes(settled.locale as any)
|
||||
? (settled.locale as "en" | "de" | "fr" | "it")
|
||||
: "de";
|
||||
await sendInvoiceIssuedEmail({
|
||||
to: snapshot.billingEmail,
|
||||
contactName: snapshot.companyName, // no separate contact-name field
|
||||
companyName: snapshot.companyName,
|
||||
invoiceNumber: settled.invoiceNumber,
|
||||
totalChf: settled.totalChf,
|
||||
currency: "CHF",
|
||||
dueAt: settled.dueAt,
|
||||
lineCount: draft.lines.length,
|
||||
periodStart: settled.periodStart,
|
||||
periodEnd: settled.periodEnd,
|
||||
locale,
|
||||
});
|
||||
} else {
|
||||
console.warn(
|
||||
`Invoice ${settled.invoiceNumber} issued but billing snapshot has no email — notification skipped.`
|
||||
// Phase 9b-2: attempt off-session auto-charge BEFORE sending
|
||||
// any email. This drives which email goes out:
|
||||
// - Charge succeeded: skip the "your invoice is ready" email
|
||||
// (would be misleading — invoice is already paid). Stripe
|
||||
// sends an automated receipt to billingSnapshot.billingEmail.
|
||||
// - Charge failed: send the auto-charge-failed email instead
|
||||
// of the regular issued email (clear action: pay manually).
|
||||
// - Charge skipped (pay_by_invoice / no card / disabled):
|
||||
// send the regular "your invoice is ready" email — that's
|
||||
// the only signal the customer gets.
|
||||
const chargeOutcome = await chargeInvoiceIfPossible(placeholder.id);
|
||||
const settled =
|
||||
chargeOutcome.kind === "succeeded"
|
||||
? (await getInvoiceById(placeholder.id)) ?? finalInvoice ?? placeholder
|
||||
: finalInvoice ?? placeholder;
|
||||
const supportedLocales: Array<"en" | "de" | "fr" | "it"> = [
|
||||
"en", "de", "fr", "it",
|
||||
];
|
||||
const emailLocale = supportedLocales.includes(settled.locale as any)
|
||||
? (settled.locale as "en" | "de" | "fr" | "it")
|
||||
: "de";
|
||||
const snapshot = settled.billingSnapshot;
|
||||
|
||||
if (chargeOutcome.kind === "succeeded") {
|
||||
console.log(
|
||||
`Invoice ${settled.invoiceNumber} auto-charged successfully (intent ${chargeOutcome.paymentIntentId}); Stripe receipt handles customer email.`
|
||||
);
|
||||
} else if (chargeOutcome.kind === "failed") {
|
||||
// Send the auto-charge-failed email (not the regular issued
|
||||
// email). The customer should be told the charge failed and
|
||||
// pointed to the manual-pay flow.
|
||||
try {
|
||||
if (snapshot.billingEmail) {
|
||||
await sendAutoChargeFailedEmail({
|
||||
to: snapshot.billingEmail,
|
||||
contactName: snapshot.companyName,
|
||||
companyName: snapshot.companyName,
|
||||
invoiceNumber: settled.invoiceNumber,
|
||||
totalChf: settled.totalChf,
|
||||
currency: "CHF",
|
||||
dueAt: settled.dueAt,
|
||||
reasonForCustomer: chargeOutcome.reasonForCustomer,
|
||||
locale: emailLocale,
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(
|
||||
`Invoice ${settled.invoiceNumber} auto-charge failed; failed-charge email also failed:`,
|
||||
e
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// Skipped — pay-by-invoice / disabled / no card. Send the
|
||||
// regular issued email so the customer knows there's
|
||||
// something to pay.
|
||||
try {
|
||||
if (snapshot.billingEmail) {
|
||||
await sendInvoiceIssuedEmail({
|
||||
to: snapshot.billingEmail,
|
||||
contactName: snapshot.companyName,
|
||||
companyName: snapshot.companyName,
|
||||
invoiceNumber: settled.invoiceNumber,
|
||||
totalChf: settled.totalChf,
|
||||
currency: "CHF",
|
||||
dueAt: settled.dueAt,
|
||||
lineCount: draft.lines.length,
|
||||
periodStart: settled.periodStart,
|
||||
periodEnd: settled.periodEnd,
|
||||
locale: emailLocale,
|
||||
});
|
||||
} else {
|
||||
console.warn(
|
||||
`Invoice ${settled.invoiceNumber} issued but billing snapshot has no email — notification skipped.`
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(
|
||||
`Invoice ${placeholder.invoiceNumber} issued; notification email failed:`,
|
||||
e
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(
|
||||
`Invoice ${placeholder.invoiceNumber} issued; notification email failed:`,
|
||||
e
|
||||
);
|
||||
}
|
||||
|
||||
return { draft, invoice: finalInvoice ?? placeholder };
|
||||
return { draft, invoice: settled };
|
||||
} catch (e) {
|
||||
// Render failed — leave the persisted row in place so admin can
|
||||
// inspect it, but surface the error.
|
||||
@@ -1202,3 +1255,608 @@ export async function refundInvoice(params: {
|
||||
|
||||
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).
|
||||
}
|
||||
|
||||
// Phase 9b-2: same auto-charge + email branching as the cron
|
||||
// path. Custom invoices go through the same gate: pay_by_invoice
|
||||
// / auto_charge_enabled / saved card determine whether we attempt
|
||||
// the charge.
|
||||
const chargeOutcome = await chargeInvoiceIfPossible(placeholder.id);
|
||||
const settledCustom =
|
||||
chargeOutcome.kind === "succeeded"
|
||||
? (await getInvoiceById(placeholder.id)) ?? placeholder
|
||||
: placeholder;
|
||||
|
||||
if (chargeOutcome.kind === "succeeded") {
|
||||
console.log(
|
||||
`Custom invoice ${settledCustom.invoiceNumber} auto-charged successfully (intent ${chargeOutcome.paymentIntentId}); Stripe receipt handles customer email.`
|
||||
);
|
||||
} else if (chargeOutcome.kind === "failed") {
|
||||
try {
|
||||
const snap = invoiceDraft.billingSnapshot;
|
||||
if (snap.billingEmail) {
|
||||
await sendAutoChargeFailedEmail({
|
||||
to: snap.billingEmail,
|
||||
contactName: snap.contactName || snap.companyName,
|
||||
companyName: snap.companyName,
|
||||
invoiceNumber: settledCustom.invoiceNumber,
|
||||
totalChf: settledCustom.totalChf,
|
||||
currency: "CHF",
|
||||
dueAt: settledCustom.dueAt,
|
||||
reasonForCustomer: chargeOutcome.reasonForCustomer,
|
||||
locale: invoiceDraft.locale as "de" | "en" | "fr" | "it",
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(
|
||||
`Custom invoice ${settledCustom.invoiceNumber} auto-charge failed; failed-charge email also failed:`,
|
||||
e
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// Skipped — send the regular issued email.
|
||||
try {
|
||||
const snap = invoiceDraft.billingSnapshot;
|
||||
if (snap.billingEmail) {
|
||||
await sendInvoiceIssuedEmail({
|
||||
to: snap.billingEmail,
|
||||
contactName: snap.contactName || snap.companyName,
|
||||
companyName: snap.companyName,
|
||||
invoiceNumber: settledCustom.invoiceNumber,
|
||||
totalChf: settledCustom.totalChf,
|
||||
currency: "CHF",
|
||||
dueAt: settledCustom.dueAt,
|
||||
lineCount: invoiceDraft.lines.length,
|
||||
periodStart: null,
|
||||
periodEnd: null,
|
||||
locale: invoiceDraft.locale as "de" | "en" | "fr" | "it",
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(
|
||||
`Custom invoice ${settledCustom.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 settledCustom;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Phase 9b — tenant setup-fee invoice at order time
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Build and persist the one-line custom invoice that captures
|
||||
* the tenant setup fee at order time. The customer is then
|
||||
* redirected to Stripe Checkout to pay it.
|
||||
*
|
||||
* - source = 'custom' so the monthly cron's per-period uniqueness
|
||||
* guard (partial index WHERE source='auto') doesn't interfere
|
||||
* - line.kind = 'tenant_setup' so the monthly cron's setup-fee
|
||||
* dedup (tenantHasSetupFeeBilled) sees this as the setup fee
|
||||
* billing event for the future tenant
|
||||
* - line.tenant_name = the derived name (computed from request id
|
||||
* via deriveTenantName) so the dedup query finds the line
|
||||
* - period_start / period_end stay null (no billing period)
|
||||
* - issuedAt = now (no override)
|
||||
* - dueAt = same day (charge happens immediately via Checkout)
|
||||
*
|
||||
* VAT uses the same vatRateForAddress() logic as the monthly cron
|
||||
* and the admin custom-invoice flow.
|
||||
*/
|
||||
export async function createTenantSetupFeeInvoice(params: {
|
||||
zitadelOrgId: string;
|
||||
tenantName: string;
|
||||
billingSnapshot: InvoiceBillingSnapshot;
|
||||
locale: "de" | "en" | "fr" | "it";
|
||||
paymentMethod: InvoicePaymentMethod;
|
||||
}): Promise<Invoice> {
|
||||
const platformPricing = await getPlatformPricing();
|
||||
const setupFeeChf = platformPricing.tenantSetupFeeChf;
|
||||
if (setupFeeChf <= 0) {
|
||||
throw new Error(
|
||||
"createTenantSetupFeeInvoice called but tenant_setup_fee_chf is 0 — caller should skip the charge flow entirely."
|
||||
);
|
||||
}
|
||||
|
||||
const vat = vatRateForAddress(params.billingSnapshot, platformPricing);
|
||||
const subtotalChf = setupFeeChf;
|
||||
const vatAmountChf = Math.round(subtotalChf * (vat.rate / 100) * 100) / 100;
|
||||
const totalChf = Math.round((subtotalChf + vatAmountChf) * 100) / 100;
|
||||
|
||||
// tenant_name on the line is the dedup anchor. metadata empty —
|
||||
// tenant_setup lines from the monthly cron also carry no metadata
|
||||
// beyond what billing-i18n needs, which is just the kind itself.
|
||||
const lines: Omit<InvoiceLine, "id" | "invoiceId">[] = [
|
||||
{
|
||||
tenantName: params.tenantName,
|
||||
kind: "tenant_setup" as InvoiceLineKind,
|
||||
description: formatLineDescription(
|
||||
{ kind: "tenant_setup", tenantName: params.tenantName, metadata: null },
|
||||
params.locale
|
||||
),
|
||||
quantity: 1,
|
||||
unitLabel: null,
|
||||
unitPriceChf: setupFeeChf,
|
||||
amountChf: setupFeeChf,
|
||||
metadata: null,
|
||||
displayOrder: 0,
|
||||
},
|
||||
];
|
||||
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
|
||||
const draft: InvoiceDraft = {
|
||||
zitadelOrgId: params.zitadelOrgId,
|
||||
source: "custom",
|
||||
periodStart: null,
|
||||
periodEnd: null,
|
||||
issuedAt: undefined, // let createInvoice default to now()
|
||||
dueAt: today,
|
||||
locale: params.locale,
|
||||
paymentMethod: params.paymentMethod,
|
||||
billingSnapshot: params.billingSnapshot,
|
||||
lines,
|
||||
subtotalChf,
|
||||
vatRate: vat.rate,
|
||||
vatAmountChf,
|
||||
totalChf,
|
||||
warnings: [],
|
||||
};
|
||||
|
||||
// Persist without PDF — the PDF render here would block the
|
||||
// Checkout redirect path and isn't needed for the customer's
|
||||
// payment step. Render lazily after payment succeeds (Phase 9c
|
||||
// candidate); for now the invoice carries no PDF until then.
|
||||
// It'll still appear on /billing for the customer; the download
|
||||
// button will be disabled (hasPdf = false) until a render lands.
|
||||
const invoice = await createInvoice(draft, null, null);
|
||||
|
||||
// Best-effort: render the PDF asynchronously so the customer
|
||||
// has it on /billing soon after paying. The async fire-and-
|
||||
// forget pattern: failures only log, the invoice row stays
|
||||
// valid either way.
|
||||
renderInvoicePdf(
|
||||
invoice,
|
||||
lines.map((l, i) => ({
|
||||
...l,
|
||||
id: `tmp-${i}`,
|
||||
invoiceId: invoice.id,
|
||||
}))
|
||||
)
|
||||
.then((pdf) =>
|
||||
updateInvoicePdf(invoice.id, pdf, `${invoice.invoiceNumber}.pdf`)
|
||||
)
|
||||
.catch((e) =>
|
||||
console.error(
|
||||
`Setup-fee invoice ${invoice.invoiceNumber} PDF render failed (async):`,
|
||||
e
|
||||
)
|
||||
);
|
||||
|
||||
return invoice;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Phase 9b-2 — recurring off-session auto-charge
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type AutoChargeOutcome =
|
||||
| { kind: "skipped"; reason: string }
|
||||
| { kind: "succeeded"; paymentIntentId: string }
|
||||
| { kind: "failed"; reasonForCustomer: string; code?: string };
|
||||
|
||||
/**
|
||||
* Reduce a Stripe decline code into a short, locale-neutral string
|
||||
* the customer can read. We never put the raw Stripe message in
|
||||
* an email (it can leak BIN, country, etc.); this maps known codes
|
||||
* to safe equivalents and falls back to a generic "card was
|
||||
* declined" string for unknown codes.
|
||||
*
|
||||
* Phase 9b-2 keeps this in English only — the email template
|
||||
* translates the surrounding copy, and the reason itself is short
|
||||
* enough that admin can decide later whether to localize it.
|
||||
*/
|
||||
function describeDeclineCode(code: string | undefined, fallback: string): string {
|
||||
if (!code) return fallback;
|
||||
const map: Record<string, string> = {
|
||||
card_declined: "Card was declined by the issuer.",
|
||||
expired_card: "Card has expired.",
|
||||
insufficient_funds: "Insufficient funds.",
|
||||
incorrect_cvc: "Card security code (CVC) was incorrect.",
|
||||
processing_error: "Card processing error at the issuer.",
|
||||
authentication_required: "Authentication required (3D Secure).",
|
||||
do_not_honor: "Card was declined by the issuer (do not honor).",
|
||||
pickup_card: "Card cannot be used — please contact the issuer.",
|
||||
lost_card: "Card was reported lost.",
|
||||
stolen_card: "Card was reported stolen.",
|
||||
generic_decline: "Card was declined.",
|
||||
};
|
||||
return map[code] ?? fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide whether an invoice can be auto-charged and attempt it.
|
||||
*
|
||||
* Gates (in order — first match wins):
|
||||
* 1. Invoice not in 'open' status → skip ("not_open")
|
||||
* 2. org_billing_config.pay_by_invoice = true → skip ("pay_by_invoice")
|
||||
* (admin override for bank-transfer customers)
|
||||
* 3. org_billing_config.auto_charge_enabled = false → skip ("disabled")
|
||||
* 4. No saved payment method id → skip ("no_card")
|
||||
* 5. No Stripe customer id → skip ("no_customer") — shouldn't happen
|
||||
* if PM is saved (the setup flow creates one) but defensive
|
||||
*
|
||||
* On charge attempt:
|
||||
* - succeeded: markInvoicePaid + return outcome
|
||||
* - declined / requires_action: leave invoice open, return reason
|
||||
* for the caller to send the auto-charge-failed email
|
||||
*
|
||||
* This function is idempotent on the invoice side (markInvoicePaid
|
||||
* is a no-op if already paid). Calling twice in rapid succession
|
||||
* may cause two Stripe charges if both attempts pass the gates —
|
||||
* the caller (generateInvoice / issueCustomInvoiceDraft) only
|
||||
* calls once per issuance and is the natural single-shot guard.
|
||||
*/
|
||||
export async function chargeInvoiceIfPossible(
|
||||
invoiceId: string
|
||||
): Promise<AutoChargeOutcome> {
|
||||
const invoice = await getInvoiceById(invoiceId);
|
||||
if (!invoice) {
|
||||
return { kind: "skipped", reason: "invoice_not_found" };
|
||||
}
|
||||
if (invoice.status !== "open") {
|
||||
return { kind: "skipped", reason: `not_open (status=${invoice.status})` };
|
||||
}
|
||||
|
||||
const cfg = await getOrgBillingConfig(invoice.zitadelOrgId);
|
||||
if (cfg.payByInvoice) {
|
||||
return { kind: "skipped", reason: "pay_by_invoice" };
|
||||
}
|
||||
if (cfg.autoChargeEnabled === false) {
|
||||
return { kind: "skipped", reason: "disabled" };
|
||||
}
|
||||
if (!cfg.stripeDefaultPaymentMethodId) {
|
||||
return { kind: "skipped", reason: "no_card" };
|
||||
}
|
||||
if (!cfg.stripeCustomerId) {
|
||||
return { kind: "skipped", reason: "no_customer" };
|
||||
}
|
||||
|
||||
const outcome = await chargeInvoiceOffSession({
|
||||
invoice,
|
||||
customerId: cfg.stripeCustomerId,
|
||||
paymentMethodId: cfg.stripeDefaultPaymentMethodId,
|
||||
receiptEmail: invoice.billingSnapshot.billingEmail ?? null,
|
||||
});
|
||||
|
||||
if (outcome.status === "succeeded") {
|
||||
// Persist the PI id + flip to paid in one shot. markInvoicePaid
|
||||
// is idempotent (returns null if already paid).
|
||||
await setInvoiceStripePaymentIntent(invoice.id, outcome.paymentIntentId);
|
||||
await markInvoicePaid(invoice.id, {
|
||||
paidBy: "stripe",
|
||||
paidMethodDetail: `Auto-charge (${outcome.paymentIntentId})`,
|
||||
});
|
||||
return { kind: "succeeded", paymentIntentId: outcome.paymentIntentId };
|
||||
}
|
||||
|
||||
// Map outcome to a customer-safe reason string.
|
||||
if (outcome.status === "requires_action") {
|
||||
return {
|
||||
kind: "failed",
|
||||
reasonForCustomer:
|
||||
"Authentication required (3D Secure). Please pay manually so your bank can complete verification.",
|
||||
code: "authentication_required",
|
||||
};
|
||||
}
|
||||
// declined
|
||||
return {
|
||||
kind: "failed",
|
||||
reasonForCustomer: describeDeclineCode(outcome.code, outcome.reason),
|
||||
code: outcome.code,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -24,9 +24,8 @@ import {
|
||||
renderToBuffer,
|
||||
} from "@react-pdf/renderer";
|
||||
import type { CreditNote, Invoice } from "@/types";
|
||||
import { BRAND, Logo, ACCENT_CREDIT_NOTE } from "./pdf-brand";
|
||||
import { BRAND, Logo } from "./pdf-brand";
|
||||
|
||||
const ACCENT = ACCENT_CREDIT_NOTE;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Localized strings
|
||||
@@ -207,15 +206,15 @@ const styles = StyleSheet.create({
|
||||
logoBlock: { flexDirection: "row", alignItems: "center" },
|
||||
brandName: {
|
||||
fontSize: 16,
|
||||
color: ACCENT.primaryDark,
|
||||
color: BRAND.primaryDark,
|
||||
marginLeft: 8,
|
||||
fontFamily: "Helvetica-Bold",
|
||||
},
|
||||
issuerBlock: { textAlign: "right", fontSize: 8.5, color: BRAND.mutedColor },
|
||||
issuerName: { fontSize: 11, color: ACCENT.primaryDark, marginBottom: 2 },
|
||||
issuerName: { fontSize: 11, color: BRAND.primaryDark, marginBottom: 2 },
|
||||
docTitle: {
|
||||
fontSize: 22,
|
||||
color: ACCENT.primaryDark,
|
||||
color: BRAND.primaryDark,
|
||||
marginBottom: 8,
|
||||
fontFamily: "Helvetica-Bold",
|
||||
},
|
||||
@@ -230,9 +229,9 @@ const styles = StyleSheet.create({
|
||||
billTo: {
|
||||
marginBottom: 24,
|
||||
padding: 8,
|
||||
backgroundColor: "#fdf2f2",
|
||||
backgroundColor: "#f7f7f5",
|
||||
borderLeftWidth: 3,
|
||||
borderLeftColor: ACCENT.primary,
|
||||
borderLeftColor: BRAND.primary,
|
||||
},
|
||||
billToLabel: { fontSize: 8, color: BRAND.mutedColor, marginBottom: 4 },
|
||||
billToName: { fontSize: 11, marginBottom: 2 },
|
||||
@@ -245,7 +244,7 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
amountHeader: {
|
||||
flexDirection: "row",
|
||||
backgroundColor: ACCENT.primaryDark,
|
||||
backgroundColor: BRAND.primaryDark,
|
||||
color: "#ffffff",
|
||||
paddingVertical: 5,
|
||||
paddingHorizontal: 6,
|
||||
@@ -273,17 +272,17 @@ const styles = StyleSheet.create({
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: ACCENT.primaryDark,
|
||||
borderTopColor: BRAND.primaryDark,
|
||||
paddingTop: 6,
|
||||
marginTop: 4,
|
||||
},
|
||||
totalsGrandLabel: {
|
||||
color: ACCENT.primaryDark,
|
||||
color: BRAND.primaryDark,
|
||||
fontSize: 11,
|
||||
fontFamily: "Helvetica-Bold",
|
||||
},
|
||||
totalsGrandValue: {
|
||||
color: ACCENT.primaryDark,
|
||||
color: BRAND.primaryDark,
|
||||
fontSize: 11,
|
||||
textAlign: "right",
|
||||
fontFamily: "Helvetica-Bold",
|
||||
@@ -353,7 +352,7 @@ function CreditNotePdfDocument({ creditNote, invoice }: CreditNotePdfProps) {
|
||||
Issuer block from BRAND.issuer (shared with invoice). */}
|
||||
<View style={styles.headerRow}>
|
||||
<View style={styles.logoBlock}>
|
||||
<Logo size={42} color={ACCENT.primary} />
|
||||
<Logo size={42} color={BRAND.primary} />
|
||||
<Text style={styles.brandName}>{BRAND.name}</Text>
|
||||
</View>
|
||||
<View style={styles.issuerBlock}>
|
||||
|
||||
608
src/lib/db.ts
608
src/lib/db.ts
@@ -93,6 +93,26 @@ const MIGRATION_SQL = `
|
||||
-- is only meaningful for rejected and cancelled rows.
|
||||
ALTER TABLE tenant_requests ADD COLUMN IF NOT EXISTS dismissed_at TIMESTAMPTZ;
|
||||
|
||||
-- Phase 9b: link a provision request to the paid setup-fee invoice
|
||||
-- it was charged against at order time. Null on requests created
|
||||
-- before Phase 9b, on resume requests, and during the brief
|
||||
-- 'pending_payment' window before the Stripe webhook fires. The
|
||||
-- admin reject flow refunds this invoice via the existing
|
||||
-- refundInvoice helper.
|
||||
ALTER TABLE tenant_requests
|
||||
ADD COLUMN IF NOT EXISTS setup_invoice_id UUID REFERENCES invoices(id);
|
||||
CREATE INDEX IF NOT EXISTS idx_tenant_requests_setup_invoice
|
||||
ON tenant_requests(setup_invoice_id)
|
||||
WHERE setup_invoice_id IS NOT NULL;
|
||||
|
||||
-- Phase 9b: optional initial channel-user ids per channel package
|
||||
-- collected during onboarding. JSONB so the shape can vary by
|
||||
-- channel (today it's a string[] per channel id, matching
|
||||
-- PiecedTenantSpec.channelUsers). Default '{}' so reads on legacy
|
||||
-- rows return an empty object rather than null.
|
||||
ALTER TABLE tenant_requests
|
||||
ADD COLUMN IF NOT EXISTS channel_users JSONB NOT NULL DEFAULT '{}'::jsonb;
|
||||
|
||||
-- Feature 6: free-form customer note attached to the request.
|
||||
-- Currently surfaced only by resume requests (where the customer
|
||||
-- explains why they want reactivation), but the column is generic
|
||||
@@ -421,6 +441,28 @@ const MIGRATION_SQL = `
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
-- Phase 9: saved-card columns. The PaymentMethod id ('pm_xxx')
|
||||
-- is the handle for off-session charges; brand/last4/exp are
|
||||
-- display fields. No PAN, CVV, or anything PCI-scope — Stripe
|
||||
-- holds those. The columns are nullable because a fresh org has
|
||||
-- no saved card; setting up auto-pay populates them via the
|
||||
-- checkout.session.completed webhook in setup mode.
|
||||
ALTER TABLE org_billing_config
|
||||
ADD COLUMN IF NOT EXISTS stripe_default_payment_method_id TEXT;
|
||||
ALTER TABLE org_billing_config
|
||||
ADD COLUMN IF NOT EXISTS stripe_pm_brand TEXT;
|
||||
ALTER TABLE org_billing_config
|
||||
ADD COLUMN IF NOT EXISTS stripe_pm_last4 TEXT;
|
||||
ALTER TABLE org_billing_config
|
||||
ADD COLUMN IF NOT EXISTS stripe_pm_exp_month INTEGER;
|
||||
ALTER TABLE org_billing_config
|
||||
ADD COLUMN IF NOT EXISTS stripe_pm_exp_year INTEGER;
|
||||
-- Phase 9: off-session auto-charge gate. Default TRUE — new orgs
|
||||
-- pay by card automatically when an invoice is issued (assuming
|
||||
-- they've also set up a saved card). Admin can flip OFF to pause
|
||||
-- charging without removing the saved card.
|
||||
ALTER TABLE org_billing_config
|
||||
ADD COLUMN IF NOT EXISTS auto_charge_enabled BOOLEAN NOT NULL DEFAULT TRUE;
|
||||
|
||||
-- Stripe payment methods. Populated by the Phase 4 webhook handler.
|
||||
-- Created in Phase 1 so all billing schema is together; rows are
|
||||
@@ -522,10 +564,30 @@ const MIGRATION_SQL = `
|
||||
ON invoices(zitadel_org_id, issued_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_invoices_status
|
||||
ON invoices(status, due_at);
|
||||
-- One invoice per org per billing month — protects the monthly
|
||||
-- cron from double-issuing if it gets retried mid-run.
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uniq_invoices_org_period
|
||||
ON invoices(zitadel_org_id, period_start);
|
||||
-- Phase 8: distinguish auto (cron) from custom (admin-entered)
|
||||
-- invoices. All pre-Phase-8 rows backfill to 'auto' via the
|
||||
-- column DEFAULT. Custom invoices skip the per-org/per-month
|
||||
-- uniqueness guard (admin may issue multiple custom invoices
|
||||
-- against the same org in the same month).
|
||||
ALTER TABLE invoices
|
||||
ADD COLUMN IF NOT EXISTS source TEXT NOT NULL DEFAULT 'auto'
|
||||
CHECK (source IN ('auto','custom'));
|
||||
-- period_start / period_end become nullable so custom invoices
|
||||
-- (no fixed billing period) can leave them empty. Auto-cron
|
||||
-- invoices keep their values; only the NOT NULL constraint is
|
||||
-- relaxed. Idempotent: DROP NOT NULL on an already-nullable column
|
||||
-- is a no-op in Postgres.
|
||||
ALTER TABLE invoices ALTER COLUMN period_start DROP NOT NULL;
|
||||
ALTER TABLE invoices ALTER COLUMN period_end DROP NOT NULL;
|
||||
-- Replace the old unconditional uniqueness with a partial index
|
||||
-- limited to auto invoices. Both invariants the cron relies on
|
||||
-- (no double-issuance per period) survive; custom invoices are
|
||||
-- unaffected. Idempotent: DROP IF EXISTS handles the migration
|
||||
-- case, and CREATE IF NOT EXISTS handles re-runs.
|
||||
DROP INDEX IF EXISTS uniq_invoices_org_period;
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uniq_invoices_org_period_auto
|
||||
ON invoices(zitadel_org_id, period_start)
|
||||
WHERE source = 'auto';
|
||||
|
||||
-- Phase 7: credit notes. One row per void or refund event. The
|
||||
-- credit_note_number follows CN-YYYY-NNNNN allocated from the
|
||||
@@ -695,9 +757,39 @@ const MIGRATION_SQL = `
|
||||
ADD CONSTRAINT invoice_lines_kind_check
|
||||
CHECK (kind IN (
|
||||
'tenant_monthly','tenant_setup','ai_usage','threema_messages',
|
||||
'skill_usage','skill_setup','adjustment'
|
||||
'skill_usage','skill_setup','adjustment',
|
||||
-- Phase 8: lines on admin-created custom invoices. PDF
|
||||
-- groups these under a "Services" section header.
|
||||
'custom_line'
|
||||
));
|
||||
|
||||
-- Phase 8: drafts for the admin "New invoice" flow. The payload
|
||||
-- is a JSONB blob with the in-progress form (lines, dates,
|
||||
-- locale, etc.). On Issue the payload is read, a real Invoice
|
||||
-- row is allocated via createInvoice with source='custom', and
|
||||
-- this draft row is deleted. Drafts are admin-only — they have
|
||||
-- no invoice number, no PDF, and aren't reachable from any
|
||||
-- customer-facing route.
|
||||
--
|
||||
-- Why a JSONB blob instead of mirroring the invoices schema:
|
||||
-- drafts and issued invoices share only a fraction of fields
|
||||
-- (no number, no totals computed yet, possibly incomplete
|
||||
-- billing snapshot), and any parallel-table design would force
|
||||
-- a costly conversion step. The blob keeps the schema minimal
|
||||
-- and the read/write paths trivial.
|
||||
CREATE TABLE IF NOT EXISTS invoice_drafts (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
zitadel_org_id TEXT NOT NULL,
|
||||
created_by TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
payload JSONB NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_invoice_drafts_org
|
||||
ON invoice_drafts(zitadel_org_id, updated_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_invoice_drafts_recent
|
||||
ON invoice_drafts(updated_at DESC);
|
||||
|
||||
-- Phase 4: Stripe webhook idempotency. Stripe guarantees at-least-once
|
||||
-- delivery and retries failures with exponential backoff for up to 72h,
|
||||
-- so the same event.id can arrive multiple times. We insert each
|
||||
@@ -812,8 +904,8 @@ export async function createTenantRequest(
|
||||
(zitadel_org_id, zitadel_user_id, company_name, instance_name,
|
||||
contact_name, contact_email, agent_name, soul_md, agents_md,
|
||||
packages, billing_address, billing_notes, encrypted_secrets,
|
||||
is_personal)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
|
||||
is_personal, channel_users)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15::jsonb)
|
||||
RETURNING *`,
|
||||
[
|
||||
params.zitadelOrgId,
|
||||
@@ -830,6 +922,7 @@ export async function createTenantRequest(
|
||||
params.billingNotes,
|
||||
params.encryptedSecrets ?? null,
|
||||
params.isPersonal ?? false,
|
||||
JSON.stringify(params.channelUsers ?? {}),
|
||||
]
|
||||
);
|
||||
return mapRow(result.rows[0]);
|
||||
@@ -928,13 +1021,18 @@ export async function listTenantRequests(
|
||||
status?: TenantRequestStatus
|
||||
): Promise<TenantRequest[]> {
|
||||
await ensureSchema();
|
||||
// Phase 9b: 'pending_payment' rows are pre-Checkout: the customer
|
||||
// submitted the wizard but hasn't paid the setup fee yet. They're
|
||||
// invisible to admin until the webhook flips them to 'pending'.
|
||||
// The explicit filter path still allows querying them (e.g.
|
||||
// ?status=pending_payment) for debugging.
|
||||
const result = status
|
||||
? await getPool().query<TenantRequest>(
|
||||
"SELECT * FROM tenant_requests WHERE status = $1 ORDER BY created_at DESC",
|
||||
[status]
|
||||
)
|
||||
: await getPool().query<TenantRequest>(
|
||||
"SELECT * FROM tenant_requests ORDER BY created_at DESC"
|
||||
"SELECT * FROM tenant_requests WHERE status <> 'pending_payment' ORDER BY created_at DESC"
|
||||
);
|
||||
return result.rows.map(mapRow);
|
||||
}
|
||||
@@ -1359,6 +1457,8 @@ function mapRow(row: any): TenantRequest {
|
||||
status: row.status as TenantRequestStatus,
|
||||
adminNotes: row.admin_notes,
|
||||
tenantName: row.tenant_name,
|
||||
setupInvoiceId: row.setup_invoice_id ?? null,
|
||||
channelUsers: (row.channel_users ?? {}) as Record<string, string[]>,
|
||||
encryptedSecrets: row.encrypted_secrets ?? null,
|
||||
isPersonal: row.is_personal ?? false,
|
||||
dismissedAt:
|
||||
@@ -2200,6 +2300,15 @@ function rowToOrgBillingConfig(row: any): OrgBillingConfig {
|
||||
stripeCustomerId: row.stripe_customer_id ?? null,
|
||||
autoInvoiceEnabled: row.auto_invoice_enabled,
|
||||
autoRemindersEnabled: row.auto_reminders_enabled,
|
||||
stripeDefaultPaymentMethodId: row.stripe_default_payment_method_id ?? null,
|
||||
stripePmBrand: row.stripe_pm_brand ?? null,
|
||||
stripePmLast4: row.stripe_pm_last4 ?? null,
|
||||
stripePmExpMonth:
|
||||
row.stripe_pm_exp_month != null ? Number(row.stripe_pm_exp_month) : null,
|
||||
stripePmExpYear:
|
||||
row.stripe_pm_exp_year != null ? Number(row.stripe_pm_exp_year) : null,
|
||||
autoChargeEnabled:
|
||||
row.auto_charge_enabled === undefined ? true : !!row.auto_charge_enabled,
|
||||
createdAt: row.created_at?.toISOString?.() ?? row.created_at,
|
||||
updatedAt: row.updated_at?.toISOString?.() ?? row.updated_at,
|
||||
};
|
||||
@@ -2372,19 +2481,31 @@ import type {
|
||||
CreditNote,
|
||||
CreditNoteKind,
|
||||
InvoiceRefund,
|
||||
CustomInvoiceDraftPayload,
|
||||
InvoiceDraftRecord,
|
||||
} from "@/types";
|
||||
|
||||
function rowToInvoice(row: any): Invoice {
|
||||
// Phase 8: period_start / period_end are nullable for custom
|
||||
// invoices (no fixed billing period). Convert defensively whether
|
||||
// the driver returned a string or Date.
|
||||
const periodStartIso = row.period_start == null
|
||||
? null
|
||||
: typeof row.period_start === "string"
|
||||
? row.period_start
|
||||
: row.period_start.toISOString().split("T")[0];
|
||||
const periodEndIso = row.period_end == null
|
||||
? null
|
||||
: typeof row.period_end === "string"
|
||||
? row.period_end
|
||||
: row.period_end.toISOString().split("T")[0];
|
||||
return {
|
||||
id: row.id,
|
||||
invoiceNumber: row.invoice_number,
|
||||
zitadelOrgId: row.zitadel_org_id,
|
||||
periodStart: typeof row.period_start === "string"
|
||||
? row.period_start
|
||||
: row.period_start.toISOString().split("T")[0],
|
||||
periodEnd: typeof row.period_end === "string"
|
||||
? row.period_end
|
||||
: row.period_end.toISOString().split("T")[0],
|
||||
source: (row.source ?? "auto") as "auto" | "custom",
|
||||
periodStart: periodStartIso,
|
||||
periodEnd: periodEndIso,
|
||||
issuedAt: row.issued_at?.toISOString?.() ?? row.issued_at,
|
||||
dueAt: typeof row.due_at === "string"
|
||||
? row.due_at
|
||||
@@ -2440,7 +2561,7 @@ const INVOICE_LIST_COLUMNS = `
|
||||
total_chf, status, locale, payment_method, billing_snapshot,
|
||||
stripe_payment_intent_id, pdf_filename, admin_notes, paid_at,
|
||||
paid_by, paid_method_detail, void_reason, voided_at, voided_by,
|
||||
refunded_total_chf, created_at,
|
||||
refunded_total_chf, source, created_at,
|
||||
(pdf_data IS NOT NULL) AS has_pdf
|
||||
`;
|
||||
|
||||
@@ -2472,9 +2593,24 @@ export async function createInvoice(
|
||||
try {
|
||||
await client.query("BEGIN");
|
||||
|
||||
// Allocate number for the year of period_start. Locking the
|
||||
// counter row prevents concurrent allocators from racing.
|
||||
const year = parseInt(draft.periodStart.slice(0, 4), 10);
|
||||
// Phase 8: pick the year for invoice-number allocation.
|
||||
// - auto invoices: use period_start (the original behaviour)
|
||||
// - custom invoices: use the override issued_at if set, else
|
||||
// the year of "now"
|
||||
// The sequence is shared across auto and custom — every invoice
|
||||
// gets a number from the same year-scoped counter so the audit
|
||||
// trail is gapless and sequential regardless of source.
|
||||
let year: number;
|
||||
if (draft.source === "custom") {
|
||||
const yearSource = draft.issuedAt
|
||||
? draft.issuedAt.slice(0, 4)
|
||||
: new Date().getUTCFullYear().toString();
|
||||
year = parseInt(yearSource, 10);
|
||||
} else if (draft.periodStart) {
|
||||
year = parseInt(draft.periodStart.slice(0, 4), 10);
|
||||
} else {
|
||||
year = new Date().getUTCFullYear();
|
||||
}
|
||||
const counterResult = await client.query(
|
||||
`INSERT INTO invoice_number_counters (year, last_number)
|
||||
VALUES ($1, 1)
|
||||
@@ -2486,24 +2622,29 @@ export async function createInvoice(
|
||||
const seq = counterResult.rows[0].last_number;
|
||||
const invoiceNumber = `${year}-${String(seq).padStart(5, "0")}`;
|
||||
|
||||
// Insert invoice row. PDF goes inline as bytea for v1; we can
|
||||
// migrate to MinIO/S3 later if storage gets noisy.
|
||||
// Phase 8: optional override of issued_at (custom flow lets
|
||||
// admin backdate or future-date). Empty → now(); set → that
|
||||
// exact date.
|
||||
const source = draft.source ?? "auto";
|
||||
const inv = await client.query(
|
||||
`INSERT INTO invoices (
|
||||
invoice_number, zitadel_org_id, period_start, period_end,
|
||||
issued_at, due_at, subtotal_chf, vat_rate, vat_amount_chf,
|
||||
total_chf, status, locale, payment_method, billing_snapshot,
|
||||
pdf_data, pdf_filename
|
||||
pdf_data, pdf_filename, source
|
||||
) VALUES (
|
||||
$1, $2, $3::date, $4::date, now(), $5::date, $6, $7, $8, $9,
|
||||
'open', $10, $11, $12::jsonb, $13, $14
|
||||
$1, $2, $3::date, $4::date,
|
||||
COALESCE($5::timestamptz, now()),
|
||||
$6::date, $7, $8, $9, $10,
|
||||
'open', $11, $12, $13::jsonb, $14, $15, $16
|
||||
)
|
||||
RETURNING ${INVOICE_LIST_COLUMNS}`,
|
||||
[
|
||||
invoiceNumber,
|
||||
draft.zitadelOrgId,
|
||||
draft.periodStart,
|
||||
draft.periodEnd,
|
||||
draft.periodStart, // may be null for custom
|
||||
draft.periodEnd, // may be null for custom
|
||||
draft.issuedAt ?? null,
|
||||
draft.dueAt,
|
||||
draft.subtotalChf,
|
||||
draft.vatRate,
|
||||
@@ -2514,6 +2655,7 @@ export async function createInvoice(
|
||||
JSON.stringify(draft.billingSnapshot),
|
||||
pdfBuffer,
|
||||
pdfFilename,
|
||||
source,
|
||||
]
|
||||
);
|
||||
const invoiceId = inv.rows[0].id;
|
||||
@@ -2556,9 +2698,15 @@ export async function createInvoice(
|
||||
} catch (e: any) {
|
||||
await client.query("ROLLBACK").catch(() => undefined);
|
||||
// Translate the uniqueness violation into a user-friendly error.
|
||||
// 23505 = unique_violation in Postgres.
|
||||
if (e?.code === "23505" && /uniq_invoices_org_period/.test(e?.constraint ?? "")) {
|
||||
const month = draft.periodStart.slice(0, 7);
|
||||
// 23505 = unique_violation in Postgres. The partial index is
|
||||
// named uniq_invoices_org_period_auto post-Phase-8; we match
|
||||
// both for back-compat with DBs that haven't run the migration
|
||||
// yet (the old name) or have run it (the new name).
|
||||
if (
|
||||
e?.code === "23505" &&
|
||||
/uniq_invoices_org_period/.test(e?.constraint ?? "")
|
||||
) {
|
||||
const month = draft.periodStart?.slice(0, 7) ?? "this period";
|
||||
throw new Error(
|
||||
`An invoice already exists for this org and billing period (${month}). ` +
|
||||
`Delete the existing invoice first if you want to regenerate.`
|
||||
@@ -3253,15 +3401,28 @@ export async function listAutoIssueOrgIds(): Promise<string[]> {
|
||||
*/
|
||||
export async function listInvoicesPendingReminders(): Promise<Invoice[]> {
|
||||
await ensureSchema();
|
||||
// Bug fix: the prior version did `FROM invoices i JOIN org_billing_config c ON c.zitadel_org_id = i.zitadel_org_id`,
|
||||
// but INVOICE_LIST_COLUMNS selects unqualified column names —
|
||||
// `zitadel_org_id` appears in both tables and Postgres rejects
|
||||
// it as ambiguous. Rewriting as a subquery filter keeps every
|
||||
// referenced column on the single `invoices` row source, so the
|
||||
// unqualified list stays valid (matching every other caller of
|
||||
// INVOICE_LIST_COLUMNS in this file).
|
||||
//
|
||||
// Semantics are unchanged: only include invoices belonging to
|
||||
// orgs that have opted into auto-reminders. Orgs with no
|
||||
// org_billing_config row at all are excluded (same as before —
|
||||
// the inner join didn't match for them either).
|
||||
const result = await getPool().query(
|
||||
`SELECT ${INVOICE_LIST_COLUMNS}
|
||||
FROM invoices i
|
||||
JOIN org_billing_config c
|
||||
ON c.zitadel_org_id = i.zitadel_org_id
|
||||
AND c.auto_reminders_enabled = TRUE
|
||||
WHERE i.status IN ('open','overdue')
|
||||
AND i.due_at < now() - INTERVAL '7 days'
|
||||
ORDER BY i.due_at ASC`
|
||||
FROM invoices
|
||||
WHERE status IN ('open','overdue')
|
||||
AND due_at < now() - INTERVAL '7 days'
|
||||
AND zitadel_org_id IN (
|
||||
SELECT zitadel_org_id FROM org_billing_config
|
||||
WHERE auto_reminders_enabled = TRUE
|
||||
)
|
||||
ORDER BY due_at ASC`
|
||||
);
|
||||
return result.rows.map(rowToInvoice);
|
||||
}
|
||||
@@ -3767,3 +3928,380 @@ export async function isStripeRefundRecorded(
|
||||
);
|
||||
return result.rows.length > 0;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Phase 8 — custom invoice drafts
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function rowToInvoiceDraft(row: any): InvoiceDraftRecord {
|
||||
return {
|
||||
id: row.id,
|
||||
zitadelOrgId: row.zitadel_org_id,
|
||||
createdBy: row.created_by,
|
||||
createdAt: row.created_at?.toISOString?.() ?? row.created_at,
|
||||
updatedAt: row.updated_at?.toISOString?.() ?? row.updated_at,
|
||||
payload: row.payload as CustomInvoiceDraftPayload,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new draft for the given org with the supplied payload.
|
||||
* The payload is whatever the editor has so far (possibly minimal —
|
||||
* just a date and an empty lines array). Returns the inserted row.
|
||||
*/
|
||||
export async function createInvoiceDraft(params: {
|
||||
zitadelOrgId: string;
|
||||
createdBy: string;
|
||||
payload: CustomInvoiceDraftPayload;
|
||||
}): Promise<InvoiceDraftRecord> {
|
||||
await ensureSchema();
|
||||
const result = await getPool().query(
|
||||
`INSERT INTO invoice_drafts (zitadel_org_id, created_by, payload)
|
||||
VALUES ($1, $2, $3::jsonb)
|
||||
RETURNING *`,
|
||||
[params.zitadelOrgId, params.createdBy, JSON.stringify(params.payload)]
|
||||
);
|
||||
return rowToInvoiceDraft(result.rows[0]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing draft's payload. Updated_at gets bumped to now()
|
||||
* so the drafts list can sort by recent activity. Returns the
|
||||
* updated row, or null if no row with that id exists.
|
||||
*
|
||||
* Org boundary check is the caller's responsibility (the admin API
|
||||
* route only accepts requests from platform admins, but you could
|
||||
* pass a zitadelOrgId filter here too if you ever expose drafts to
|
||||
* customer-level roles).
|
||||
*/
|
||||
export async function updateInvoiceDraft(
|
||||
id: string,
|
||||
payload: CustomInvoiceDraftPayload
|
||||
): Promise<InvoiceDraftRecord | null> {
|
||||
const result = await getPool().query(
|
||||
`UPDATE invoice_drafts
|
||||
SET payload = $2::jsonb,
|
||||
updated_at = now()
|
||||
WHERE id = $1
|
||||
RETURNING *`,
|
||||
[id, JSON.stringify(payload)]
|
||||
);
|
||||
return result.rows.length > 0 ? rowToInvoiceDraft(result.rows[0]) : null;
|
||||
}
|
||||
|
||||
export async function getInvoiceDraftById(
|
||||
id: string
|
||||
): Promise<InvoiceDraftRecord | null> {
|
||||
await ensureSchema();
|
||||
const result = await getPool().query(
|
||||
"SELECT * FROM invoice_drafts WHERE id = $1",
|
||||
[id]
|
||||
);
|
||||
return result.rows.length > 0 ? rowToInvoiceDraft(result.rows[0]) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* List all open drafts across all orgs, newest first. Used by the
|
||||
* admin "Drafts" tab so the admin can resume any in-progress
|
||||
* invoice. Drafts are tiny (a JSONB payload), so we don't paginate
|
||||
* by default — 200 rows is plenty of headroom for a solo-founder
|
||||
* workflow.
|
||||
*/
|
||||
export async function listAllInvoiceDrafts(
|
||||
limit = 200
|
||||
): Promise<InvoiceDraftRecord[]> {
|
||||
await ensureSchema();
|
||||
const result = await getPool().query(
|
||||
`SELECT * FROM invoice_drafts ORDER BY updated_at DESC LIMIT $1`,
|
||||
[limit]
|
||||
);
|
||||
return result.rows.map(rowToInvoiceDraft);
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-org listing — used if you ever want to surface drafts on an
|
||||
* org-detail page or filter by org from the drafts list.
|
||||
*/
|
||||
export async function listInvoiceDraftsForOrg(
|
||||
zitadelOrgId: string,
|
||||
limit = 50
|
||||
): Promise<InvoiceDraftRecord[]> {
|
||||
await ensureSchema();
|
||||
const result = await getPool().query(
|
||||
`SELECT * FROM invoice_drafts
|
||||
WHERE zitadel_org_id = $1
|
||||
ORDER BY updated_at DESC
|
||||
LIMIT $2`,
|
||||
[zitadelOrgId, limit]
|
||||
);
|
||||
return result.rows.map(rowToInvoiceDraft);
|
||||
}
|
||||
|
||||
export async function deleteInvoiceDraft(id: string): Promise<boolean> {
|
||||
const result = await getPool().query(
|
||||
"DELETE FROM invoice_drafts WHERE id = $1",
|
||||
[id]
|
||||
);
|
||||
return (result.rowCount ?? 0) > 0;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Phase 9 — saved-card management for off-session auto-charge
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Persist a saved PaymentMethod against an org's billing config.
|
||||
* Called from the webhook after a successful setup-mode Checkout
|
||||
* session, and again when "Pay by Card" with setup_future_usage
|
||||
* delivers a fresh PaymentMethod. Upserts the config row in case
|
||||
* the org has none yet (rare — onboarding usually creates one,
|
||||
* but defensive doesn't hurt).
|
||||
*
|
||||
* Only display fields (brand/last4/exp) are persisted. The full PAN
|
||||
* is never seen by this code — Stripe holds it.
|
||||
*/
|
||||
export async function setSavedPaymentMethod(params: {
|
||||
zitadelOrgId: string;
|
||||
stripeCustomerId: string;
|
||||
paymentMethodId: string;
|
||||
brand: string | null;
|
||||
last4: string | null;
|
||||
expMonth: number | null;
|
||||
expYear: number | null;
|
||||
}): Promise<void> {
|
||||
await ensureSchema();
|
||||
await getPool().query(
|
||||
`INSERT INTO org_billing_config (
|
||||
zitadel_org_id, stripe_customer_id,
|
||||
stripe_default_payment_method_id, stripe_pm_brand, stripe_pm_last4,
|
||||
stripe_pm_exp_month, stripe_pm_exp_year, updated_at
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, now())
|
||||
ON CONFLICT (zitadel_org_id) DO UPDATE SET
|
||||
stripe_customer_id = COALESCE(org_billing_config.stripe_customer_id, EXCLUDED.stripe_customer_id),
|
||||
stripe_default_payment_method_id = EXCLUDED.stripe_default_payment_method_id,
|
||||
stripe_pm_brand = EXCLUDED.stripe_pm_brand,
|
||||
stripe_pm_last4 = EXCLUDED.stripe_pm_last4,
|
||||
stripe_pm_exp_month = EXCLUDED.stripe_pm_exp_month,
|
||||
stripe_pm_exp_year = EXCLUDED.stripe_pm_exp_year,
|
||||
updated_at = now()`,
|
||||
[
|
||||
params.zitadelOrgId,
|
||||
params.stripeCustomerId,
|
||||
params.paymentMethodId,
|
||||
params.brand,
|
||||
params.last4,
|
||||
params.expMonth,
|
||||
params.expYear,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the saved PaymentMethod fields. Used when the customer
|
||||
* clicks "Remove card" — the Stripe-side detach happens in the
|
||||
* caller (stripe.detachPaymentMethod); this just nulls the
|
||||
* portal-side display fields and the pm id reference.
|
||||
*
|
||||
* Does not touch stripe_customer_id (the customer object survives),
|
||||
* auto_charge_enabled, or any other config — only the four card
|
||||
* fields and the pm id pointer.
|
||||
*/
|
||||
export async function clearSavedPaymentMethod(
|
||||
zitadelOrgId: string
|
||||
): Promise<void> {
|
||||
await getPool().query(
|
||||
`UPDATE org_billing_config
|
||||
SET stripe_default_payment_method_id = NULL,
|
||||
stripe_pm_brand = NULL,
|
||||
stripe_pm_last4 = NULL,
|
||||
stripe_pm_exp_month = NULL,
|
||||
stripe_pm_exp_year = NULL,
|
||||
updated_at = now()
|
||||
WHERE zitadel_org_id = $1`,
|
||||
[zitadelOrgId]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle the auto_charge_enabled flag. Used by the customer's
|
||||
* "Disable auto-pay / Enable auto-pay" button in /settings/billing
|
||||
* and (Phase 9b) the admin override on /admin/billing/orgs.
|
||||
*/
|
||||
export async function setAutoChargeEnabled(
|
||||
zitadelOrgId: string,
|
||||
enabled: boolean
|
||||
): Promise<void> {
|
||||
await getPool().query(
|
||||
`INSERT INTO org_billing_config (zitadel_org_id, auto_charge_enabled, updated_at)
|
||||
VALUES ($1, $2, now())
|
||||
ON CONFLICT (zitadel_org_id) DO UPDATE SET
|
||||
auto_charge_enabled = EXCLUDED.auto_charge_enabled,
|
||||
updated_at = now()`,
|
||||
[zitadelOrgId, enabled]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up the org id for a given Stripe customer id — used by the
|
||||
* webhook when a checkout.session.completed in setup mode arrives
|
||||
* and we need to find which org to save the card against. The
|
||||
* customer id is the join key Stripe gives us in the session.
|
||||
*/
|
||||
export async function getOrgIdByStripeCustomerId(
|
||||
stripeCustomerId: string
|
||||
): Promise<string | null> {
|
||||
await ensureSchema();
|
||||
const result = await getPool().query(
|
||||
`SELECT zitadel_org_id FROM org_billing_config
|
||||
WHERE stripe_customer_id = $1
|
||||
LIMIT 1`,
|
||||
[stripeCustomerId]
|
||||
);
|
||||
return result.rows.length > 0 ? result.rows[0].zitadel_org_id : null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Phase 9b — tenant order with setup-fee charge
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Phase 9b: invoked by the Stripe webhook when the setup-fee
|
||||
* Checkout for a tenant order completes. Atomically:
|
||||
* - flips the request status from 'pending_payment' → 'pending'
|
||||
* (admin queue now sees it)
|
||||
* - sets tenant_name to the derived value (so monthly cron's
|
||||
* setup-fee dedup works)
|
||||
* - links the paid invoice via setup_invoice_id (so admin reject
|
||||
* can refund it via the existing refund flow)
|
||||
*
|
||||
* Idempotent on the request side: if the webhook re-fires after
|
||||
* the row already has status='pending', the UPDATE is a no-op
|
||||
* (same values). On the rare case of webhook retry happening after
|
||||
* admin already approved/rejected, the WHERE clause guards against
|
||||
* regressing the status.
|
||||
*/
|
||||
export async function linkTenantRequestSetupPayment(params: {
|
||||
requestId: string;
|
||||
tenantName: string;
|
||||
setupInvoiceId: string;
|
||||
}): Promise<boolean> {
|
||||
const result = await getPool().query(
|
||||
`UPDATE tenant_requests
|
||||
SET status = 'pending',
|
||||
tenant_name = $2,
|
||||
setup_invoice_id = $3,
|
||||
updated_at = now()
|
||||
WHERE id = $1
|
||||
AND status = 'pending_payment'
|
||||
RETURNING id`,
|
||||
[params.requestId, params.tenantName, params.setupInvoiceId]
|
||||
);
|
||||
return (result.rowCount ?? 0) > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up a tenant request by id without restricting by status —
|
||||
* used by the webhook + reject handler. Caller is responsible for
|
||||
* any role-gating; this is a pure read.
|
||||
*/
|
||||
export async function getTenantRequestForSetupFlow(
|
||||
requestId: string
|
||||
): Promise<TenantRequest | null> {
|
||||
await ensureSchema();
|
||||
const result = await getPool().query(
|
||||
`SELECT * FROM tenant_requests WHERE id = $1`,
|
||||
[requestId]
|
||||
);
|
||||
return result.rows.length > 0
|
||||
? mapRow(result.rows[0])
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert a tenant request row in the 'pending_payment' status —
|
||||
* used at order time, before the Stripe Checkout completes. Once
|
||||
* payment succeeds the webhook flips it to 'pending' via
|
||||
* linkTenantRequestSetupPayment.
|
||||
*
|
||||
* tenant_name stays NULL throughout pending_payment so the unique
|
||||
* partial index uniq_tenant_requests_tenant_name_provision
|
||||
* (WHERE tenant_name IS NOT NULL) doesn't block retries from
|
||||
* abandoned Checkout sessions. The derived tenant_name is computed
|
||||
* by the caller from the inserted row's id and stored only at
|
||||
* webhook time.
|
||||
*/
|
||||
export async function createTenantRequestPendingPayment(params: {
|
||||
zitadelOrgId: string;
|
||||
zitadelUserId: string;
|
||||
companyName: string;
|
||||
instanceName?: string | null;
|
||||
contactName: string;
|
||||
contactEmail: string;
|
||||
agentName: string;
|
||||
soulMd?: string;
|
||||
agentsMd?: string | null;
|
||||
packages: string[];
|
||||
billingAddress: BillingAddress;
|
||||
billingNotes?: string;
|
||||
encryptedSecrets?: Buffer | null;
|
||||
isPersonal: boolean;
|
||||
channelUsers?: Record<string, string[]>;
|
||||
}): Promise<TenantRequest> {
|
||||
await ensureSchema();
|
||||
const result = await getPool().query(
|
||||
`INSERT INTO tenant_requests (
|
||||
zitadel_org_id, zitadel_user_id,
|
||||
company_name, instance_name, contact_name, contact_email,
|
||||
agent_name, soul_md, agents_md, packages,
|
||||
billing_address, billing_notes,
|
||||
encrypted_secrets, is_personal,
|
||||
channel_users,
|
||||
status, request_type
|
||||
) VALUES (
|
||||
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11::jsonb, $12,
|
||||
$13, $14, $15::jsonb, 'pending_payment', 'provision'
|
||||
)
|
||||
RETURNING *`,
|
||||
[
|
||||
params.zitadelOrgId,
|
||||
params.zitadelUserId,
|
||||
params.companyName,
|
||||
params.instanceName ?? null,
|
||||
params.contactName,
|
||||
params.contactEmail,
|
||||
params.agentName,
|
||||
params.soulMd ?? null,
|
||||
params.agentsMd ?? null,
|
||||
params.packages,
|
||||
JSON.stringify(params.billingAddress),
|
||||
params.billingNotes ?? null,
|
||||
params.encryptedSecrets ?? null,
|
||||
params.isPersonal,
|
||||
JSON.stringify(params.channelUsers ?? {}),
|
||||
]
|
||||
);
|
||||
return mapRow(result.rows[0]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a pending_payment row — used when admin or system needs
|
||||
* to clean up an abandoned order (e.g. Checkout session expired
|
||||
* before the customer completed payment). Guarded: only deletes
|
||||
* if status is still 'pending_payment' so we never accidentally
|
||||
* delete a request that admin has already approved.
|
||||
*
|
||||
* Also nulls any setup_invoice_id reference before deleting so we
|
||||
* don't leave dangling FK refs (we don't have ON DELETE behavior
|
||||
* defined on the column).
|
||||
*/
|
||||
export async function deletePendingPaymentRequest(
|
||||
requestId: string
|
||||
): Promise<boolean> {
|
||||
const result = await getPool().query(
|
||||
`DELETE FROM tenant_requests
|
||||
WHERE id = $1 AND status = 'pending_payment'
|
||||
RETURNING id`,
|
||||
[requestId]
|
||||
);
|
||||
return (result.rowCount ?? 0) > 0;
|
||||
}
|
||||
|
||||
167
src/lib/email.ts
167
src/lib/email.ts
@@ -923,8 +923,8 @@ export async function sendInvoiceIssuedEmail(params: {
|
||||
currency: string; // "CHF" — passed for future-proofing
|
||||
dueAt: string; // ISO date
|
||||
lineCount: number;
|
||||
periodStart: string; // ISO date
|
||||
periodEnd: string; // ISO date
|
||||
periodStart: string | null; // ISO date; null for custom invoices
|
||||
periodEnd: string | null; // ISO date; null for custom invoices
|
||||
locale: "de" | "en" | "fr" | "it";
|
||||
}): Promise<void> {
|
||||
// All four locales — the email is sent in the invoice's locale,
|
||||
@@ -960,7 +960,13 @@ export async function sendInvoiceIssuedEmail(params: {
|
||||
const safeCompany = escapeHtml(params.companyName);
|
||||
const safeNumber = escapeHtml(params.invoiceNumber);
|
||||
const totalFmt = `${params.currency} ${params.totalChf.toFixed(2)}`;
|
||||
const periodFmt = `${params.periodStart.slice(0, 10)} → ${params.periodEnd.slice(0, 10)}`;
|
||||
// Phase 8: period is null for custom invoices. When missing, the
|
||||
// template skips the "Service period:" line entirely; otherwise
|
||||
// it renders the date range as before.
|
||||
const periodFmt =
|
||||
params.periodStart && params.periodEnd
|
||||
? `${params.periodStart.slice(0, 10)} → ${params.periodEnd.slice(0, 10)}`
|
||||
: null;
|
||||
const dueFmt = params.dueAt.slice(0, 10);
|
||||
|
||||
// Both bodies built in the invoice's locale.
|
||||
@@ -977,7 +983,9 @@ export async function sendInvoiceIssuedEmail(params: {
|
||||
introByLocale[L],
|
||||
"",
|
||||
`${l.number}: ${params.invoiceNumber}`,
|
||||
`${l.period}: ${periodFmt}`,
|
||||
// Phase 8: omit the period line entirely for custom
|
||||
// invoices (which have no billing period).
|
||||
...(periodFmt ? [`${l.period}: ${periodFmt}`] : []),
|
||||
`${l.total}: ${totalFmt}`,
|
||||
`${l.due}: ${dueFmt}`,
|
||||
`${l.lines}: ${params.lineCount}`,
|
||||
@@ -995,7 +1003,7 @@ export async function sendInvoiceIssuedEmail(params: {
|
||||
<p>${escapeHtml(introByLocale[L])}</p>
|
||||
<table style="width:100%; border-collapse:collapse; margin:16px 0; font-size:14px;">
|
||||
<tr><td style="color:#888; padding:6px 0; width:120px;">${l.number}</td><td><strong>${safeNumber}</strong></td></tr>
|
||||
<tr><td style="color:#888; padding:6px 0;">${l.period}</td><td>${escapeHtml(periodFmt)}</td></tr>
|
||||
${periodFmt ? `<tr><td style="color:#888; padding:6px 0;">${l.period}</td><td>${escapeHtml(periodFmt)}</td></tr>` : ""}
|
||||
<tr><td style="color:#888; padding:6px 0;">${l.total}</td><td style="color:#10B981; font-weight:600;">${escapeHtml(totalFmt)}</td></tr>
|
||||
<tr><td style="color:#888; padding:6px 0;">${l.due}</td><td>${escapeHtml(dueFmt)}</td></tr>
|
||||
<tr><td style="color:#888; padding:6px 0;">${l.lines}</td><td>${params.lineCount}</td></tr>
|
||||
@@ -1261,10 +1269,12 @@ export async function sendCreditNoteEmail(params: {
|
||||
const safeNumberINV = escapeHtml(params.invoiceNumber);
|
||||
const safeReason = params.reason ? escapeHtml(params.reason) : null;
|
||||
|
||||
// Red accent (#DC2626) for the credit-note emails, mirroring the
|
||||
// PDF accent so the document family reads visually consistent.
|
||||
// The invoice email uses emerald; the credit note uses red.
|
||||
const ACCENT = "#DC2626";
|
||||
// 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({
|
||||
@@ -1311,3 +1321,142 @@ export async function sendCreditNoteEmail(params: {
|
||||
console.error("Failed to send credit note email:", err);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Phase 9b-2 — auto-charge failure notice
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Sent when an off-session auto-charge attempt fails for an issued
|
||||
* invoice (card declined, expired, 3DS required, etc.). Customer
|
||||
* receives this in their billing-snapshot locale. Contains:
|
||||
* - Invoice number + amount + due date
|
||||
* - Failure reason (a short human-readable string from Stripe)
|
||||
* - Manual-pay link to /billing/<invoiceNumber> where they can
|
||||
* run the regular Pay-by-Card flow (which uses
|
||||
* setup_future_usage to also refresh the saved card)
|
||||
*
|
||||
* Critical: the failure reason from Stripe can contain sensitive
|
||||
* details (card BIN, country, etc.). We pass a sanitized short
|
||||
* string from the caller — never the full raw error.
|
||||
*/
|
||||
export async function sendAutoChargeFailedEmail(params: {
|
||||
to: string;
|
||||
contactName: string;
|
||||
companyName: string;
|
||||
invoiceNumber: string;
|
||||
totalChf: number;
|
||||
currency: string;
|
||||
dueAt: string;
|
||||
/**
|
||||
* Short, customer-safe reason. e.g. "Your card was declined."
|
||||
* or "Your card has expired." Caller maps Stripe error codes to
|
||||
* these strings; we never pass raw API error messages.
|
||||
*/
|
||||
reasonForCustomer: string;
|
||||
locale: "de" | "en" | "fr" | "it";
|
||||
}): Promise<void> {
|
||||
const L = params.locale;
|
||||
const totalFmt = `${params.currency} ${params.totalChf.toFixed(2)}`;
|
||||
const dueFmt = params.dueAt.slice(0, 10);
|
||||
const baseUrl = process.env.APP_BASE_URL ?? "https://app.pieced.ch";
|
||||
const link = `${baseUrl}/billing/${encodeURIComponent(params.invoiceNumber)}`;
|
||||
|
||||
const subjectsByLocale: Record<typeof L, string> = {
|
||||
en: `Auto-charge failed for invoice ${params.invoiceNumber} — please pay manually`,
|
||||
de: `Auto-Abbuchung fehlgeschlagen für Rechnung ${params.invoiceNumber} — bitte manuell bezahlen`,
|
||||
fr: `Échec du prélèvement automatique pour la facture ${params.invoiceNumber} — merci de régler manuellement`,
|
||||
it: `Addebito automatico fallito per la fattura ${params.invoiceNumber} — la preghiamo di pagare manualmente`,
|
||||
};
|
||||
const greetingsByLocale: Record<typeof L, string> = {
|
||||
en: `Hello ${params.contactName},`,
|
||||
de: `Sehr geehrte/r ${params.contactName},`,
|
||||
fr: `Bonjour ${params.contactName},`,
|
||||
it: `Gentile ${params.contactName},`,
|
||||
};
|
||||
const introByLocale: Record<typeof L, string> = {
|
||||
en: `We were unable to charge your saved card for invoice ${params.invoiceNumber} (${params.companyName}).`,
|
||||
de: `Wir konnten die Rechnung ${params.invoiceNumber} (${params.companyName}) nicht über die hinterlegte Karte abbuchen.`,
|
||||
fr: `Nous n'avons pas pu débiter votre carte enregistrée pour la facture ${params.invoiceNumber} (${params.companyName}).`,
|
||||
it: `Non siamo riusciti ad addebitare la carta salvata per la fattura ${params.invoiceNumber} (${params.companyName}).`,
|
||||
};
|
||||
const reasonLabel: Record<typeof L, string> = {
|
||||
en: "Reason given by the card network",
|
||||
de: "Vom Kartennetzwerk gemeldeter Grund",
|
||||
fr: "Motif communiqué par le réseau de carte",
|
||||
it: "Motivo comunicato dal circuito",
|
||||
};
|
||||
const actionLineByLocale: Record<typeof L, string> = {
|
||||
en: `Please pay this invoice manually before ${dueFmt} to avoid service interruption. The "Pay with card" button below will both charge the invoice and update the card we have on file for future charges.`,
|
||||
de: `Bitte begleichen Sie diese Rechnung manuell vor dem ${dueFmt}, um eine Unterbrechung Ihres Dienstes zu vermeiden. Die Schaltfläche "Mit Karte bezahlen" unten begleicht die Rechnung und aktualisiert gleichzeitig die hinterlegte Karte für zukünftige Abbuchungen.`,
|
||||
fr: `Veuillez régler cette facture manuellement avant le ${dueFmt} pour éviter toute interruption du service. Le bouton "Payer par carte" ci-dessous règle la facture et met à jour la carte enregistrée pour les futurs prélèvements.`,
|
||||
it: `La preghiamo di saldare questa fattura manualmente entro il ${dueFmt} per evitare interruzioni del servizio. Il pulsante "Paga con carta" qui sotto salda la fattura e aggiorna allo stesso tempo la carta in archivio per gli addebiti futuri.`,
|
||||
};
|
||||
const labels: Record<typeof L, Record<string, string>> = {
|
||||
en: { number: "Invoice", total: "Total", due: "Due by", cta: "Pay with card", signoff: "Best regards", brand: "PieCed IT" },
|
||||
de: { number: "Rechnung", total: "Gesamt", due: "Zahlbar bis", cta: "Mit Karte bezahlen", signoff: "Mit freundlichen Grüssen", brand: "PieCed IT" },
|
||||
fr: { number: "Facture", total: "Total", due: "À régler avant", cta: "Payer par carte", signoff: "Cordialement", brand: "PieCed IT" },
|
||||
it: { number: "Fattura", total: "Totale", due: "Scadenza", cta: "Paga con carta", signoff: "Cordiali saluti", brand: "PieCed IT" },
|
||||
};
|
||||
const l = labels[L];
|
||||
|
||||
const safeName = escapeHtml(params.contactName);
|
||||
const safeCompany = escapeHtml(params.companyName);
|
||||
const safeNumber = escapeHtml(params.invoiceNumber);
|
||||
const safeReason = escapeHtml(params.reasonForCustomer);
|
||||
const safeIntro = escapeHtml(introByLocale[L]);
|
||||
const safeAction = escapeHtml(actionLineByLocale[L]);
|
||||
|
||||
try {
|
||||
await getTransporter().sendMail({
|
||||
from: getFrom(),
|
||||
to: params.to,
|
||||
subject: subjectsByLocale[L],
|
||||
text: [
|
||||
greetingsByLocale[L],
|
||||
"",
|
||||
introByLocale[L],
|
||||
"",
|
||||
`${l.number}: ${params.invoiceNumber}`,
|
||||
`${l.total}: ${totalFmt}`,
|
||||
`${l.due}: ${dueFmt}`,
|
||||
"",
|
||||
`${reasonLabel[L]}: ${params.reasonForCustomer}`,
|
||||
"",
|
||||
actionLineByLocale[L],
|
||||
"",
|
||||
`${l.cta}:`,
|
||||
link,
|
||||
"",
|
||||
`${l.signoff},`,
|
||||
l.brand,
|
||||
].join("\n"),
|
||||
html: `
|
||||
<div style="font-family: -apple-system, BlinkMacSystemFont, sans-serif; max-width: 560px; padding: 24px; background: #1a1a1a; color: #e5e5e5;">
|
||||
<h2 style="margin: 0 0 16px; color: #f59e0b;">${escapeHtml(subjectsByLocale[L])}</h2>
|
||||
<p>${escapeHtml(greetingsByLocale[L])}</p>
|
||||
<p>${safeIntro}</p>
|
||||
<table style="width:100%; border-collapse:collapse; margin:16px 0; font-size:14px;">
|
||||
<tr><td style="color:#888; padding:6px 0; width:120px;">${l.number}</td><td><strong>${safeNumber}</strong></td></tr>
|
||||
<tr><td style="color:#888; padding:6px 0;">${l.total}</td><td style="color:#f59e0b; font-weight:600;">${escapeHtml(totalFmt)}</td></tr>
|
||||
<tr><td style="color:#888; padding:6px 0;">${l.due}</td><td>${escapeHtml(dueFmt)}</td></tr>
|
||||
</table>
|
||||
<div style="background:#2a2a2a; border-left:3px solid #f59e0b; padding:10px 12px; margin:16px 0; font-size:13px;">
|
||||
<strong>${escapeHtml(reasonLabel[L])}:</strong> ${safeReason}
|
||||
</div>
|
||||
<p style="font-size:14px;">${safeAction}</p>
|
||||
<p>
|
||||
<a href="${link}" style="display:inline-block; padding:10px 24px; background:#10B981; color:#fff; text-decoration:none; border-radius:8px; font-weight:500;">
|
||||
${l.cta}
|
||||
</a>
|
||||
</p>
|
||||
<p style="color:#888; font-size:12px; margin-top:24px;">
|
||||
${l.signoff},<br />${l.brand}
|
||||
</p>
|
||||
</div>
|
||||
`,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Failed to send auto-charge-failed email:", err);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,6 +76,29 @@ export interface PackageDef {
|
||||
* admin does the manual work, then approves.
|
||||
*/
|
||||
requiresManualSetup?: boolean;
|
||||
/**
|
||||
* Phase 9b: when true, the wizard visually highlights this package
|
||||
* as recommended (a badge + accent border) without pre-selecting
|
||||
* it. Used for the Threema channel — we want customers to choose
|
||||
* Threema as their messaging surface when possible, but the choice
|
||||
* stays opt-in.
|
||||
*/
|
||||
recommended?: boolean;
|
||||
/**
|
||||
* Phase 9b: when true, the onboarding wizard collects the
|
||||
* customer's own user id for this channel (e.g. their Telegram
|
||||
* numeric id, their Threema ID) at request time. The collected
|
||||
* id is forwarded with the tenant request, stored on the row,
|
||||
* and applied on admin approval:
|
||||
* - spec.channelUsers[<channel>] gets the id seeded so the
|
||||
* operator's first reconcile already has it
|
||||
* - for Threema specifically, the approve handler additionally
|
||||
* calls the relay's createRoute() so inbound messages from
|
||||
* that id reach the new tenant
|
||||
* Customers can add more ids later via the channel-users page.
|
||||
* Help copy and label come from channelUsers.<id>IdHelp.
|
||||
*/
|
||||
collectsChannelUserId?: boolean;
|
||||
}
|
||||
|
||||
export const PACKAGE_CATALOG: PackageDef[] = [
|
||||
@@ -129,6 +152,7 @@ export const PACKAGE_CATALOG: PackageDef[] = [
|
||||
instructionsKey: "packages.telegram.instructions",
|
||||
disclaimerKey: "packages.telegram.disclaimer",
|
||||
category: "channel",
|
||||
collectsChannelUserId: true,
|
||||
},
|
||||
{
|
||||
id: "discord",
|
||||
@@ -158,6 +182,7 @@ export const PACKAGE_CATALOG: PackageDef[] = [
|
||||
instructionsKey: "packages.discord.instructions",
|
||||
disclaimerKey: "packages.discord.disclaimer",
|
||||
category: "channel",
|
||||
collectsChannelUserId: true,
|
||||
},
|
||||
{
|
||||
id: "threema",
|
||||
@@ -173,6 +198,8 @@ export const PACKAGE_CATALOG: PackageDef[] = [
|
||||
instructionsKey: "packages.threema.instructions",
|
||||
disclaimerKey: "packages.threema.disclaimer",
|
||||
category: "channel",
|
||||
recommended: true,
|
||||
collectsChannelUserId: true,
|
||||
},
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
@@ -231,7 +258,6 @@ export const PACKAGE_CATALOG: PackageDef[] = [
|
||||
},
|
||||
{
|
||||
id: "gog",
|
||||
requiresManualSetup: true,
|
||||
name: "Google Workspace (Gog)",
|
||||
descriptionKey: "packages.gog.description",
|
||||
requiresSecrets: true,
|
||||
@@ -334,9 +360,11 @@ export const CHANNEL_PACKAGE_IDS: string[] = PACKAGE_CATALOG
|
||||
* audio spend on every inbound voice note (Whisper STT) and every
|
||||
* outbound reply (kani-tts / kokoro-fastapi via LiteLLM). Opt-in keeps
|
||||
* cost predictable for tenants who don't intend to use voice channels.
|
||||
*
|
||||
* Phase 9b revision: nothing is pre-enabled. New tenants start with a
|
||||
* blank slate — the customer opts into exactly what they want. The
|
||||
* Threema channel is flagged `recommended` (see PACKAGE_CATALOG) so
|
||||
* the wizard highlights it, since we want customers to use Threema as
|
||||
* their channel when possible — but it's still opt-in, not auto-on.
|
||||
*/
|
||||
export const DEFAULT_PACKAGE_IDS: string[] = [
|
||||
"core-heartbeat",
|
||||
"core-cron",
|
||||
"core-active-memory",
|
||||
];
|
||||
export const DEFAULT_PACKAGE_IDS: string[] = [];
|
||||
|
||||
@@ -57,24 +57,16 @@ export const BRAND = {
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Accent colours for document variants — credit notes are red so
|
||||
// customers can tell them apart from invoices at a glance.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const ACCENT_CREDIT_NOTE = {
|
||||
primary: "#DC2626",
|
||||
primaryDark: "#991B1B",
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Logo — PieCed's hexagon-pattern mark. Same shape used everywhere;
|
||||
// only the colour changes per document type.
|
||||
// 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 (emerald). Pass ACCENT_CREDIT_NOTE.primary
|
||||
* on credit notes for the red variant. */
|
||||
/** 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;
|
||||
}
|
||||
|
||||
|
||||
@@ -220,7 +220,13 @@ export async function createCheckoutSessionForInvoice(params: {
|
||||
unit_amount: chfToRappen(invoice.totalChf),
|
||||
product_data: {
|
||||
name: `Invoice ${invoice.invoiceNumber}`,
|
||||
description: `PieCed IT — ${invoice.periodStart.slice(0, 10)} → ${invoice.periodEnd.slice(0, 10)}`,
|
||||
// Phase 8: custom invoices have no period — fall back
|
||||
// to a description that just references the invoice
|
||||
// number and due date.
|
||||
description:
|
||||
invoice.periodStart && invoice.periodEnd
|
||||
? `PieCed IT — ${invoice.periodStart.slice(0, 10)} → ${invoice.periodEnd.slice(0, 10)}`
|
||||
: `PieCed IT — due ${invoice.dueAt.slice(0, 10)}`,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -244,6 +250,15 @@ export async function createCheckoutSessionForInvoice(params: {
|
||||
// since Stripe will prepend the merchant name from the
|
||||
// account anyway. Keep it short and recognisable.
|
||||
description: `Invoice ${invoice.invoiceNumber}`,
|
||||
// Phase 9b-2: every manual Pay-by-Card refreshes the org's
|
||||
// saved PaymentMethod. The webhook (payment-mode handler) is
|
||||
// already wired to read setup_future_usage and persist the
|
||||
// resulting PM's display fields against the org. Net effect:
|
||||
// a customer whose auto-charge failed because their card
|
||||
// expired pays manually once → fresh card is now saved →
|
||||
// next month auto-charges work again. No separate "update
|
||||
// card" step needed.
|
||||
setup_future_usage: "off_session",
|
||||
},
|
||||
success_url: successUrl,
|
||||
cancel_url: cancelUrl,
|
||||
@@ -312,3 +327,326 @@ export async function createInvoiceRefund(params: {
|
||||
status: refund.status ?? "unknown",
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Phase 9 — saved cards (SetupIntent / Checkout setup mode)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Create a Checkout session in setup mode — Stripe collects card
|
||||
* details and authorizes them for off-session future charges,
|
||||
* without charging anything now. On success, Stripe attaches the
|
||||
* resulting PaymentMethod to the customer object and fires
|
||||
* `checkout.session.completed` with mode='setup'.
|
||||
*
|
||||
* The webhook handler reads the session's setup_intent, extracts
|
||||
* the payment_method id, and persists the display fields
|
||||
* (brand/last4/exp) via setSavedPaymentMethod. From that moment
|
||||
* on, the customer has auto-charge wired up.
|
||||
*
|
||||
* Re-running this against a customer who already has a saved card
|
||||
* is supported — Stripe attaches the new PaymentMethod and the
|
||||
* webhook overwrites the old one in our DB. That's how "Update
|
||||
* card" works.
|
||||
*/
|
||||
export async function createSetupCheckoutSession(params: {
|
||||
customerId: string;
|
||||
baseUrl: string;
|
||||
locale?: "de" | "en" | "fr" | "it";
|
||||
/**
|
||||
* Where to redirect after the customer completes / cancels the
|
||||
* setup. Defaults to /settings/billing — the natural landing
|
||||
* spot after saving a card.
|
||||
*/
|
||||
returnPath?: string;
|
||||
}): Promise<{ url: string; sessionId: string }> {
|
||||
const stripe = getStripeClient();
|
||||
const { customerId, baseUrl, locale } = params;
|
||||
const returnPath = params.returnPath ?? "/settings/billing";
|
||||
const stripeLocale =
|
||||
locale === "de"
|
||||
? ("de" as const)
|
||||
: locale === "fr"
|
||||
? ("fr" as const)
|
||||
: locale === "it"
|
||||
? ("it" as const)
|
||||
: locale === "en"
|
||||
? ("en" as const)
|
||||
: ("auto" as const);
|
||||
|
||||
const successUrl = `${baseUrl}${returnPath}?card_setup=success&session_id={CHECKOUT_SESSION_ID}`;
|
||||
const cancelUrl = `${baseUrl}${returnPath}?card_setup=cancelled`;
|
||||
|
||||
const session = await stripe.checkout.sessions.create({
|
||||
mode: "setup",
|
||||
customer: customerId,
|
||||
locale: stripeLocale,
|
||||
payment_method_types: ["card"],
|
||||
success_url: successUrl,
|
||||
cancel_url: cancelUrl,
|
||||
// Stripe attaches the resulting PaymentMethod to the customer
|
||||
// and the webhook fires with session.setup_intent populated.
|
||||
// No extra setup_intent_data needed for the basic flow.
|
||||
});
|
||||
if (!session.url) {
|
||||
throw new Error(
|
||||
`Stripe returned a setup session without a redirect URL (id=${session.id})`
|
||||
);
|
||||
}
|
||||
return { url: session.url, sessionId: session.id };
|
||||
}
|
||||
|
||||
/**
|
||||
* Detach a PaymentMethod from its customer. Used when the customer
|
||||
* clicks "Remove card" — the PM is no longer usable for charges
|
||||
* once detached. The Stripe Customer object survives (so future
|
||||
* charges can still attach a new card to the same customer).
|
||||
*
|
||||
* Stripe permits detaching a PM that's already detached as a
|
||||
* no-op; safe to retry.
|
||||
*/
|
||||
export async function detachPaymentMethod(
|
||||
paymentMethodId: string
|
||||
): Promise<void> {
|
||||
const stripe = getStripeClient();
|
||||
try {
|
||||
await stripe.paymentMethods.detach(paymentMethodId);
|
||||
} catch (e: any) {
|
||||
// Stripe returns 404 if the PM is already detached or doesn't
|
||||
// exist — treat as success since the intended end-state ("not
|
||||
// attached") is already reached. Re-throw anything else.
|
||||
if (e?.statusCode === 404) return;
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the display fields for a PaymentMethod (brand, last4,
|
||||
* exp). Used by the webhook to read out what to persist after a
|
||||
* setup session completes; the session itself only carries the
|
||||
* PM id, not the card details.
|
||||
*/
|
||||
export async function getPaymentMethodDisplay(
|
||||
paymentMethodId: string
|
||||
): Promise<{
|
||||
brand: string | null;
|
||||
last4: string | null;
|
||||
expMonth: number | null;
|
||||
expYear: number | null;
|
||||
}> {
|
||||
const stripe = getStripeClient();
|
||||
const pm = await stripe.paymentMethods.retrieve(paymentMethodId);
|
||||
// The card object is only present when type='card'. We don't
|
||||
// anticipate non-card PMs in this codebase yet, but defensive
|
||||
// null-handling avoids crashing if Stripe surfaces something
|
||||
// unexpected (Apple Pay, link, etc. — all of which still
|
||||
// resolve to a card under the hood).
|
||||
const card = (pm as any).card;
|
||||
if (!card) {
|
||||
return { brand: null, last4: null, expMonth: null, expYear: null };
|
||||
}
|
||||
return {
|
||||
brand: card.brand ?? null,
|
||||
last4: card.last4 ?? null,
|
||||
expMonth: typeof card.exp_month === "number" ? card.exp_month : null,
|
||||
expYear: typeof card.exp_year === "number" ? card.exp_year : null,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Phase 9b — order-time setup-fee Checkout
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Create a Stripe Checkout session that charges the setup-fee
|
||||
* invoice immediately AND saves/refreshes the customer's
|
||||
* PaymentMethod for future off-session use (recurring monthly
|
||||
* charges).
|
||||
*
|
||||
* Same `mode: 'payment'` as the regular pay-invoice Checkout —
|
||||
* the difference is:
|
||||
* - metadata.flow = 'setup_fee' so the webhook knows to flip
|
||||
* the tenant_request row from 'pending_payment' to 'pending'
|
||||
* and link the invoice to it
|
||||
* - metadata.tenant_request_id is the row to update
|
||||
* - payment_intent_data.setup_future_usage = 'off_session' so
|
||||
* the resulting PaymentMethod gets saved against the customer.
|
||||
* Phase 9b-2's recurring auto-charge reads that PM id
|
||||
*
|
||||
* Success URL routes to /dashboard?ordered=1 (vs. the regular
|
||||
* pay flow which lands on /billing/<invoiceNumber>). Cancel
|
||||
* routes to /onboarding?cancelled=1 so the customer can retry.
|
||||
*/
|
||||
export async function createSetupFeeCheckoutSession(params: {
|
||||
invoice: Invoice;
|
||||
customerId: string;
|
||||
baseUrl: string;
|
||||
tenantRequestId: string;
|
||||
}): Promise<{ url: string; sessionId: string }> {
|
||||
const stripe = getStripeClient();
|
||||
const { invoice, customerId, baseUrl, tenantRequestId } = params;
|
||||
|
||||
const stripeLocale =
|
||||
invoice.locale === "de"
|
||||
? ("de" as const)
|
||||
: invoice.locale === "fr"
|
||||
? ("fr" as const)
|
||||
: invoice.locale === "it"
|
||||
? ("it" as const)
|
||||
: invoice.locale === "en"
|
||||
? ("en" as const)
|
||||
: ("auto" as const);
|
||||
|
||||
const successUrl = `${baseUrl}/dashboard?ordered=1&session_id={CHECKOUT_SESSION_ID}`;
|
||||
const cancelUrl = `${baseUrl}/onboarding?cancelled=1`;
|
||||
|
||||
const session = await stripe.checkout.sessions.create({
|
||||
mode: "payment",
|
||||
customer: customerId,
|
||||
client_reference_id: invoice.id,
|
||||
locale: stripeLocale,
|
||||
line_items: [
|
||||
{
|
||||
quantity: 1,
|
||||
price_data: {
|
||||
currency: "chf",
|
||||
unit_amount: chfToRappen(invoice.totalChf),
|
||||
product_data: {
|
||||
name: `Setup fee — ${invoice.invoiceNumber}`,
|
||||
description: `PieCed IT — tenant setup`,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
payment_intent_data: {
|
||||
// Save the resulting PaymentMethod against the customer for
|
||||
// future off-session use (Phase 9b-2 recurring charges).
|
||||
setup_future_usage: "off_session",
|
||||
metadata: {
|
||||
invoice_id: invoice.id,
|
||||
invoice_number: invoice.invoiceNumber,
|
||||
zitadel_org_id: invoice.zitadelOrgId,
|
||||
},
|
||||
},
|
||||
metadata: {
|
||||
invoice_id: invoice.id,
|
||||
invoice_number: invoice.invoiceNumber,
|
||||
zitadel_org_id: invoice.zitadelOrgId,
|
||||
// Phase 9b discriminators — webhook reads these to do the
|
||||
// tenant_request linkage on top of the regular invoice-paid
|
||||
// flow.
|
||||
flow: "setup_fee",
|
||||
tenant_request_id: tenantRequestId,
|
||||
},
|
||||
success_url: successUrl,
|
||||
cancel_url: cancelUrl,
|
||||
});
|
||||
if (!session.url) {
|
||||
throw new Error(
|
||||
`Stripe returned a setup-fee session without a redirect URL (id=${session.id})`
|
||||
);
|
||||
}
|
||||
return { url: session.url, sessionId: session.id };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Phase 9b-2 — off-session auto-charge for issued invoices
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Attempt to charge an invoice off-session against the customer's
|
||||
* saved PaymentMethod. Used by chargeInvoiceIfPossible() from
|
||||
* generateInvoice (monthly) and issueCustomInvoiceDraft (admin
|
||||
* custom).
|
||||
*
|
||||
* Stripe semantics with `off_session: true, confirm: true`:
|
||||
* - On success: PaymentIntent.status = 'succeeded', card was
|
||||
* charged. Returns 'succeeded'.
|
||||
* - On 3DS required: PaymentIntent.status = 'requires_action'.
|
||||
* We can't complete this off-session. Customer must pay
|
||||
* manually via Checkout (which handles 3DS in-browser).
|
||||
* Returns 'requires_action'.
|
||||
* - On hard decline: thrown StripeCardError, code = 'card_declined'
|
||||
* or 'insufficient_funds' etc. Returns 'declined' with the
|
||||
* error code.
|
||||
* - On expired card or other recoverable issue: thrown
|
||||
* StripeCardError. Returns 'declined' with the code.
|
||||
*
|
||||
* The receipt_email is set to the org's billing email so Stripe
|
||||
* sends the customer an automated receipt on success — we don't
|
||||
* need to send our own "you've been charged" email.
|
||||
*/
|
||||
export type ChargeOutcome =
|
||||
| { status: "succeeded"; paymentIntentId: string }
|
||||
| { status: "requires_action"; paymentIntentId: string; reason: string }
|
||||
| { status: "declined"; reason: string; code?: string };
|
||||
|
||||
export async function chargeInvoiceOffSession(params: {
|
||||
invoice: Invoice;
|
||||
customerId: string;
|
||||
paymentMethodId: string;
|
||||
/**
|
||||
* If set, Stripe emails an automated receipt here on successful
|
||||
* capture. We use the org's billing snapshot email so the receipt
|
||||
* goes to the same address as the issued / failed emails.
|
||||
*/
|
||||
receiptEmail?: string | null;
|
||||
}): Promise<ChargeOutcome> {
|
||||
const stripe = getStripeClient();
|
||||
const { invoice, customerId, paymentMethodId, receiptEmail } = params;
|
||||
try {
|
||||
const pi = await stripe.paymentIntents.create({
|
||||
amount: chfToRappen(invoice.totalChf),
|
||||
currency: "chf",
|
||||
customer: customerId,
|
||||
payment_method: paymentMethodId,
|
||||
off_session: true,
|
||||
confirm: true,
|
||||
description: `Invoice ${invoice.invoiceNumber}`,
|
||||
receipt_email: receiptEmail ?? undefined,
|
||||
metadata: {
|
||||
invoice_id: invoice.id,
|
||||
invoice_number: invoice.invoiceNumber,
|
||||
zitadel_org_id: invoice.zitadelOrgId,
|
||||
flow: "auto_charge",
|
||||
},
|
||||
});
|
||||
if (pi.status === "succeeded") {
|
||||
return { status: "succeeded", paymentIntentId: pi.id };
|
||||
}
|
||||
if (pi.status === "requires_action") {
|
||||
return {
|
||||
status: "requires_action",
|
||||
paymentIntentId: pi.id,
|
||||
reason: "Authentication required (3DS). Customer must pay via Checkout.",
|
||||
};
|
||||
}
|
||||
// Any other non-succeeded status (rare with off_session+confirm)
|
||||
// is treated as a failure for our purposes.
|
||||
return {
|
||||
status: "declined",
|
||||
reason: `Unexpected PaymentIntent status: ${pi.status}`,
|
||||
};
|
||||
} catch (e: any) {
|
||||
// Stripe's off-session declines surface as a StripeCardError
|
||||
// with the PI on e.payment_intent. The 'code' (e.g.
|
||||
// 'card_declined', 'expired_card', 'authentication_required')
|
||||
// is the most actionable signal; e.message is human-readable.
|
||||
const code: string | undefined = e?.code ?? e?.raw?.code;
|
||||
const message: string =
|
||||
e?.message ?? e?.raw?.message ?? "Card was declined.";
|
||||
// authentication_required is technically a "decline" from the
|
||||
// off-session path even though it could succeed on-session.
|
||||
// Surface it distinctly so the caller can tell the customer to
|
||||
// go pay manually (which will use Checkout + handle 3DS).
|
||||
if (code === "authentication_required") {
|
||||
const piId = e?.payment_intent?.id ?? "";
|
||||
return {
|
||||
status: "requires_action",
|
||||
paymentIntentId: piId,
|
||||
reason: "Authentication required (3DS). Customer must pay via Checkout.",
|
||||
};
|
||||
}
|
||||
return { status: "declined", reason: message, code };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -152,6 +152,12 @@ export const onboardingSchema = z.object({
|
||||
packageSecrets: z
|
||||
.record(z.string(), z.record(z.string(), z.string()))
|
||||
.optional(),
|
||||
// Phase 9b: per-channel initial user ids collected during
|
||||
// onboarding. Map of channel package id → list of user ids the
|
||||
// customer wants to authorize. Applied at admin approval time.
|
||||
channelUsers: z
|
||||
.record(z.string(), z.array(z.string().trim().min(1).max(200)))
|
||||
.optional(),
|
||||
billingAddress: billingAddressSchema.optional(),
|
||||
billingNotes: z.string().max(2_000).optional(),
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"tagline": "KI-Plattform",
|
||||
"login": "Anmelden",
|
||||
"logout": "Abmelden",
|
||||
"menu": "Menü",
|
||||
"dashboard": "Dashboard",
|
||||
"admin": "Admin",
|
||||
"loading": "Laden…",
|
||||
@@ -122,7 +123,27 @@
|
||||
"billingVatNumber": "MWST-Nummer",
|
||||
"billingVatHelp": "Ihre registrierte MWST-Nummer. Falls Ihre Firma von der MWST befreit ist, leer lassen und in den Notizen erläutern.",
|
||||
"billingNotesPlaceholderPersonal": "Was wir wissen sollten — bevorzugte Zahlungsart, Rechnungsreferenz, etc.",
|
||||
"reviewContactPersonPrefix": "z.Hd."
|
||||
"reviewContactPersonPrefix": "z.Hd.",
|
||||
"setupFeeNoticeHeading": "Einrichtungsgebühr wird beim Senden belastet",
|
||||
"setupFeeNoticeBody": "Mit dem nächsten Klick werden Sie zu Stripe weitergeleitet, um Ihre Zahlungsdetails einzugeben und die einmalige Einrichtungsgebühr zu bezahlen. Ihre Karte wird automatisch für die zukünftige monatliche Abrechnung gespeichert. Anschliessend gelangen Sie direkt zurück zum Dashboard. Die Instanz startet erst nach Admin-Freigabe — monatliche Gebühren beginnen ab dem Freigabedatum.",
|
||||
"setupFeeAmountLabel": "Einmalige Einrichtungsgebühr",
|
||||
"setupFeePlusVat": "+ MwSt.",
|
||||
"optional": "optional",
|
||||
"yourChannelIdLabel": {
|
||||
"telegram": "Ihre Telegram-Benutzer-ID",
|
||||
"discord": "Ihre Discord-Benutzer-ID",
|
||||
"threema": "Ihre Threema-ID"
|
||||
},
|
||||
"yourChannelIdPlaceholder": {
|
||||
"telegram": "z.B. 1234567890",
|
||||
"discord": "z.B. 234567890123456789",
|
||||
"threema": "z.B. ABCD1234"
|
||||
},
|
||||
"yourChannelIdHelp": {
|
||||
"telegram": "Öffnen Sie Telegram, schreiben Sie an @userinfobot und fügen Sie die zurückgegebene numerische ID hier ein. Weitere Benutzer können Sie später auf der Mandantenseite hinzufügen.",
|
||||
"discord": "Aktivieren Sie den Entwicklermodus in Discord (Erweiterte Einstellungen), Rechtsklick auf Ihren Namen → Benutzer-ID kopieren, und hier einfügen. Weitere Benutzer können Sie später auf der Mandantenseite hinzufügen.",
|
||||
"threema": "Die 8 Zeichen, die in Ihrer Threema-App unter Einstellungen → Meine Threema-ID angezeigt werden. Sobald Ihr Mandant freigegeben ist und Threema aktiviert wurde, können Sie aus diesem Account heraus mit dem Assistenten chatten. Weitere autorisierte IDs können später auf der Mandantenseite hinzugefügt werden."
|
||||
}
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Dashboard",
|
||||
@@ -311,7 +332,7 @@
|
||||
},
|
||||
"threema": {
|
||||
"description": "Senden und empfangen Sie Nachrichten über Threema. Jede eingehende und ausgehende Nachricht läuft über den gemeinsamen PieCed-Messaging-Dienst und verursacht eine Gebühr pro Nachricht bei Threema — eine Drittanbieter-Kostenposition, unabhängig von Ihrem PieCed-Abonnement.",
|
||||
"instructions": "1. Aktivieren Sie dieses Paket.\n2. Öffnen Sie Threema auf Ihrem Telefon, scannen Sie den QR-Code unter Autorisierte Benutzer → threema und akzeptieren Sie den Kontakt.\n3. Tragen Sie Ihre eigene Threema-ID unter Autorisierte Benutzer → threema ein, damit der Assistent Ihre Nachrichten erkennt.\n4. Schreiben Sie eine Nachricht aus Threema, um das Gespräch zu beginnen.",
|
||||
"instructions": "1. Öffnen Sie Threema auf Ihrem Telefon und scannen Sie den unten angezeigten QR-Code — am besten gleich jetzt, damit Sie loslegen können, sobald Ihr Mandant läuft.\n2. Tragen Sie Ihre eigene Threema-ID im Feld weiter unten ein (die 8 Zeichen aus Einstellungen → Meine Threema-ID in der Threema-App), damit der Assistent Ihre Nachrichten annimmt.\n3. Sobald Ihr Mandant freigegeben ist und läuft, senden Sie eine Nachricht aus Threema, um das Gespräch zu beginnen.",
|
||||
"disclaimer": "Nachrichten zwischen Threema und PieCed werden Ende-zu-Ende verschlüsselt bis zum PieCed-Messaging-Dienst, wo sie entschlüsselt und an Ihren Assistenten weitergeleitet werden. Jede gesendete oder empfangene Nachricht wird gemäss Threema-Tarif pro Nachricht abgerechnet — die aktuellen Preise finden Sie in Ihrem Plan."
|
||||
},
|
||||
"manualReviewPending": "Manuelle Prüfung ausstehend",
|
||||
@@ -319,7 +340,12 @@
|
||||
"activationRejected": "Abgelehnt",
|
||||
"tryAgain": "Erneut versuchen",
|
||||
"credentialsSaved": "Zugangsdaten gespeichert",
|
||||
"credentialsSavedTip": "Die eingegebenen Zugangsdaten sind sicher gespeichert und werden verwendet, sobald die Aktivierung vom Admin genehmigt wurde. Sie müssen sie nicht erneut eingeben."
|
||||
"credentialsSavedTip": "Die eingegebenen Zugangsdaten sind sicher gespeichert und werden verwendet, sobald die Aktivierung vom Admin genehmigt wurde. Sie müssen sie nicht erneut eingeben.",
|
||||
"recommended": "Empfohlen",
|
||||
"threemaBotIdHeading": "Bot-Threema-ID",
|
||||
"threemaBotIdHint": "Das ist die Threema-ID des Assistenten — bei jedem PieCed-Mandanten identisch. Scannen Sie den QR jetzt mit Ihrer Threema-App, damit Sie startklar sind, sobald Ihr Mandant freigegeben und Threema aktiviert ist.",
|
||||
"showInfo": "Info",
|
||||
"showInfoTitle": "Setup-Info erneut anzeigen"
|
||||
},
|
||||
"admin": {
|
||||
"title": "Plattform-Admin",
|
||||
@@ -412,7 +438,7 @@
|
||||
"title": "Assistenten zu Threema hinzufügen",
|
||||
"step1": "Öffnen Sie Threema auf Ihrem Telefon.",
|
||||
"step2": "Tippen Sie auf das Scan-Symbol und scannen Sie diesen QR-Code, um den Assistenten als Kontakt hinzuzufügen.",
|
||||
"step3": "Fügen Sie anschliessend unten Ihre eigene Threema-ID hinzu.",
|
||||
"step3": "Stellen Sie sicher, dass Ihre Threema-ID als autorisierter Benutzer eingetragen ist, damit der Assistent Ihre Nachrichten annimmt.",
|
||||
"qrAlt": "QR-Code, um {gateway} als Threema-Kontakt hinzuzufügen",
|
||||
"bannerTitle": "Threema einrichten",
|
||||
"bannerBody": "Öffnen Sie Threema auf Ihrem Telefon und scannen Sie unseren QR-Code, um den Assistenten als Kontakt hinzuzufügen. Geben Sie anschliessend unten Ihre eigene Threema-ID ein.",
|
||||
@@ -501,7 +527,7 @@
|
||||
"notesHint": "Referenznummern, Bestellnummern oder andere Angaben, die auf der Rechnung erscheinen sollen.",
|
||||
"saveChanges": "Änderungen speichern",
|
||||
"createBilling": "Rechnungsdaten speichern",
|
||||
"saving": "Speichern…",
|
||||
"saving": "Wird gespeichert…",
|
||||
"saved": "Gespeichert.",
|
||||
"missingRequired": "Bitte alle Pflichtfelder ausfüllen.",
|
||||
"invalidCountry": "Ländercode muss aus 2 Buchstaben bestehen (z.B. CH).",
|
||||
@@ -509,7 +535,27 @@
|
||||
"fullNameLabel": "Vor- und Nachname",
|
||||
"subtitlePersonal": "Ihre Rechnungsadresse und Rechnungskontakt. Erforderlich, bevor Rechnungen ausgestellt werden können.",
|
||||
"contactNameLabel": "Ansprechperson (optional)",
|
||||
"contactNameHint": "Erscheint als 'z.Hd. <Name>' auf der Rechnung unter dem Firmennamen. Hilfreich für die Zuordnung in der Buchhaltung grösserer Firmen."
|
||||
"contactNameHint": "Erscheint als 'z.Hd. <Name>' auf der Rechnung unter dem Firmennamen. Hilfreich für die Zuordnung in der Buchhaltung grösserer Firmen.",
|
||||
"savedCardHeading": "Hinterlegte Karte",
|
||||
"savedCardEmptyBody": "Hinterlegen Sie eine Karte für die automatische Bezahlung von Rechnungen. Ihre Kartendaten werden sicher bei Stripe gespeichert — wir sehen nur Marke, letzte vier Ziffern und Ablaufdatum.",
|
||||
"savedCardSetupBtn": "Auto-Zahlung einrichten",
|
||||
"savedCardRedirecting": "Weiterleitung…",
|
||||
"savedCardUpdateBtn": "Karte aktualisieren",
|
||||
"savedCardRemoveBtn": "Karte entfernen",
|
||||
"savedCardRemoving": "Entfernen…",
|
||||
"savedCardRemoveConfirm": "Diese Karte entfernen? Sie müssen die Auto-Zahlung erneut einrichten, damit zukünftige Rechnungen automatisch belastet werden.",
|
||||
"savedCardBrandUnknown": "Karte",
|
||||
"savedCardExpires": "läuft ab {date}",
|
||||
"savedCardAutoChargeOn": "Auto-Zahlung aktiv",
|
||||
"savedCardAutoChargeOff": "Auto-Zahlung inaktiv",
|
||||
"savedCardDisableAutoChargeBtn": "Auto-Zahlung deaktivieren",
|
||||
"savedCardEnableAutoChargeBtn": "Auto-Zahlung aktivieren",
|
||||
"savedCardPayByInvoiceNote": "Ihr Konto ist auf Banküberweisung eingestellt; die hinterlegte Karte wird nicht für automatische Abbuchungen verwendet. Wenden Sie sich an den Support, wenn Sie wieder per Karte bezahlen möchten.",
|
||||
"savedCardBankTransferHint": "Banküberweisung ist auf Anfrage ebenfalls möglich.",
|
||||
"savedCardBankTransferLink": "Kontaktieren Sie uns dafür.",
|
||||
"savedCardAutoPayRequiredHeading": "Auto-Zahlung ist erforderlich",
|
||||
"savedCardAutoPayRequiredBody": "PieCed IT arbeitet mit automatischer Kartenzahlung. Wir behalten uns das Recht vor, Tenants bis zur Begleichung offener Rechnungen zu sperren, falls die automatische Abrechnung fehlschlägt.",
|
||||
"savedCardAutoPayDisabledNote": "Auto-Zahlung ist derzeit deaktiviert. Zukünftige Rechnungen müssen manuell beglichen werden — bei Nichtbezahlung behalten wir uns das Recht vor, die zugehörigen Tenants zu sperren."
|
||||
},
|
||||
"support": {
|
||||
"title": "Support",
|
||||
@@ -578,7 +624,7 @@
|
||||
"subtitle": "Plattform-Preise verwalten, Rechnungen generieren und den Rechnungsstatus aller Organisationen prüfen.",
|
||||
"backToAdmin": "Zurück zur Verwaltung",
|
||||
"backToBilling": "Zurück zur Abrechnung",
|
||||
"backToInvoices": "Zurück zu den Rechnungen",
|
||||
"backToInvoices": "Zurück zu Rechnungen",
|
||||
"totalOpenBalance": "Offener Saldo gesamt",
|
||||
"orgsWithBalance": "Organisationen mit Saldo",
|
||||
"overdueInvoices": "Überfällige Rechnungen",
|
||||
@@ -702,7 +748,83 @@
|
||||
"creditNoteNoPdf": "—",
|
||||
"refundAmountLabel": "Betrag",
|
||||
"refundReasonLabel": "Grund",
|
||||
"refundAmountInclVatHint": "inkl. MWST"
|
||||
"refundAmountInclVatHint": "inkl. MWST",
|
||||
"newInvoiceBtn": "Neue Rechnung",
|
||||
"draftsLink": "Entwürfe",
|
||||
"backToDrafts": "Zurück zu Entwürfen",
|
||||
"newInvoicePageTitle": "Neue Rechnung",
|
||||
"newInvoicePageSubtitle": "Wählen Sie den Kunden, dem Sie eine Rechnung stellen möchten. Im nächsten Schritt fügen Sie die Positionen hinzu.",
|
||||
"newInvoiceOrgLabel": "Kunde",
|
||||
"newInvoiceOrgPlaceholder": "— Kunde wählen —",
|
||||
"newInvoiceOrgNoBilling": "keine Rechnungsadresse",
|
||||
"newInvoiceOrgBillingMissing": "Dieser Kunde hat keine hinterlegte Rechnungsadresse. Bitte abschliessen lassen oder im Admin-Panel hinterlegen, bevor die Rechnung ausgestellt wird.",
|
||||
"newInvoiceLocaleLabel": "Dokumentensprache",
|
||||
"newInvoiceOrgRequired": "Bitte einen Kunden wählen.",
|
||||
"newInvoiceContinueBtn": "Weiter",
|
||||
"creating": "Wird erstellt…",
|
||||
"draftsPageTitle": "Rechnungsentwürfe",
|
||||
"draftsPageSubtitle": "Laufende benutzerdefinierte Rechnungen. Bearbeitung fortsetzen oder verwerfen.",
|
||||
"draftsEmpty": "Noch keine Entwürfe. Starten Sie eine neue Rechnung.",
|
||||
"draftOrgCol": "Kunde",
|
||||
"draftIssueDateCol": "Rechnungsdatum",
|
||||
"draftLinesCol": "Positionen",
|
||||
"draftSubtotalCol": "Zwischensumme (Schätzung)",
|
||||
"draftUpdatedCol": "Zuletzt bearbeitet",
|
||||
"draftActionsCol": "Aktionen",
|
||||
"draftDeleteConfirm": "Diesen Entwurf verwerfen? Kann nicht rückgängig gemacht werden.",
|
||||
"editBtn": "Bearbeiten",
|
||||
"editorPageTitle": "Rechnungsentwurf bearbeiten",
|
||||
"editorBillToHeading": "Rechnungsempfänger",
|
||||
"editorNoBillingSnapshot": "Keine Rechnungsadresse für diesen Kunden hinterlegt. Ausstellung ist nicht möglich, bis Rechnungsinformationen erfasst wurden.",
|
||||
"editorMetadataHeading": "Rechnungsdaten",
|
||||
"editorIssueDateLabel": "Rechnungsdatum",
|
||||
"editorDueDateLabel": "Fälligkeitsdatum",
|
||||
"editorLocaleLabel": "Dokumentensprache",
|
||||
"editorPaymentMethodLabel": "Zahlungsart",
|
||||
"editorPaymentInvoice": "Banküberweisung (Rechnung)",
|
||||
"editorPaymentCard": "Kreditkarte (Stripe)",
|
||||
"editorLinesHeading": "Positionen",
|
||||
"editorLineDescription": "Beschreibung",
|
||||
"editorLineDescriptionPlaceholder": "z.B. Beratungsstunden, individuelle Integration, …",
|
||||
"editorLineQty": "Menge",
|
||||
"editorLineUnitPrice": "Einzelpreis",
|
||||
"editorLineAmount": "Betrag",
|
||||
"editorLineRemove": "Position entfernen",
|
||||
"editorAddLine": "Position hinzufügen",
|
||||
"editorAddDiscount": "Rabatt hinzufügen",
|
||||
"editorAddDiscountHint": "Fügt eine Zeile mit negativem Einzelpreis hinzu. Beschreibung und Betrag nach Bedarf anpassen.",
|
||||
"editorRabattDefaultDescription": "Rabatt",
|
||||
"editorNotesHeading": "Interne Notizen",
|
||||
"editorNotesPlaceholder": "Nur für Admin sichtbar (nicht auf der Rechnung)",
|
||||
"editorNotesHint": "Wird dem Kunden nicht angezeigt.",
|
||||
"editorTotalsHeading": "Beträge (Schätzung)",
|
||||
"editorSubtotal": "Zwischensumme",
|
||||
"editorVat": "MWST",
|
||||
"editorTotal": "Gesamt",
|
||||
"editorTotalsEstimateNote": "Schätzung basierend auf Kundenland. Die endgültige MWST wird bei Ausstellung berechnet.",
|
||||
"editorSaveBtn": "Entwurf speichern",
|
||||
"editorSavedBtn": "Gespeichert",
|
||||
"editorPreviewBtn": "PDF-Vorschau",
|
||||
"editorIssueBtn": "Rechnung ausstellen",
|
||||
"editorDeleteBtn": "Entwurf verwerfen",
|
||||
"editorIssueConfirm": "Rechnung jetzt ausstellen? Eine Rechnungsnummer wird zugewiesen, das PDF wird dem Kunden zugesendet und dieser Entwurf wird entfernt.",
|
||||
"editorDeleteConfirm": "Diesen Entwurf verwerfen? Kann nicht rückgängig gemacht werden.",
|
||||
"previewing": "Wird geöffnet…",
|
||||
"issuing": "Wird ausgestellt…",
|
||||
"orgsTitle": "Kunden-Abrechnung",
|
||||
"orgsDesc": "Zahlungsart + Auto-Zahlung pro Kunde",
|
||||
"orgsPageTitle": "Kunden-Abrechnungsmodi",
|
||||
"orgsPageSubtitle": "Überschreibung der Zahlungsart für einzelne Kunden. Zahlung per Rechnung ersetzt die automatische Kartenabbuchung durch manuelle Banküberweisung; das Pausieren der Auto-Zahlung behält die hinterlegte Karte, stoppt aber Abbuchungsversuche (nützlich bei Streitfällen).",
|
||||
"orgsEmpty": "Noch keine Kunden-Organisationen.",
|
||||
"orgsColCustomer": "Kunde",
|
||||
"orgsColCard": "Hinterlegte Karte",
|
||||
"orgsColPayByInvoice": "Zahlung per Banküberweisung",
|
||||
"orgsColAutoCharge": "Auto-Zahlung",
|
||||
"orgsNoSavedCard": "keine",
|
||||
"orgsPayByInvoiceOn": "ein",
|
||||
"orgsPayByInvoiceOff": "aus",
|
||||
"orgsAutoChargeOn": "ein",
|
||||
"orgsAutoChargeOff": "aus"
|
||||
},
|
||||
"skillCostDialog": {
|
||||
"title": "Aktivierungskosten bestätigen",
|
||||
@@ -842,5 +964,13 @@
|
||||
"saving": "Speichern…",
|
||||
"saved": "Gespeichert.",
|
||||
"missingRequired": "Vor- und Nachname sind erforderlich."
|
||||
},
|
||||
"errors": {
|
||||
"title": "Etwas ist schiefgelaufen",
|
||||
"description": "Beim Laden dieser Seite ist ein Fehler aufgetreten. Bitte versuchen Sie es erneut.",
|
||||
"retry": "Erneut versuchen",
|
||||
"backToDashboard": "Zurück zum Dashboard",
|
||||
"notFoundTitle": "Seite nicht gefunden",
|
||||
"notFoundDescription": "Die angeforderte Seite existiert nicht oder wurde verschoben."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"tagline": "AI Platform",
|
||||
"login": "Sign In",
|
||||
"logout": "Sign Out",
|
||||
"menu": "Menu",
|
||||
"dashboard": "Dashboard",
|
||||
"admin": "Admin",
|
||||
"loading": "Loading…",
|
||||
@@ -122,7 +123,27 @@
|
||||
"billingVatNumber": "VAT number",
|
||||
"billingVatHelp": "Your registered VAT identifier. If your company is VAT-exempt, leave blank and explain in the notes field.",
|
||||
"billingNotesPlaceholderPersonal": "Anything we should know — preferred payment method, billing reference, etc.",
|
||||
"reviewContactPersonPrefix": "Attn:"
|
||||
"reviewContactPersonPrefix": "Attn:",
|
||||
"setupFeeNoticeHeading": "Setup fee will be charged on submit",
|
||||
"setupFeeNoticeBody": "On the next click you'll be redirected to Stripe to enter your payment details and pay the one-time setup fee. Your card is saved automatically for future monthly billing. You'll be brought back to your dashboard immediately afterwards. The instance starts running only after admin approval — monthly fees begin from the approval date.",
|
||||
"setupFeeAmountLabel": "One-time setup fee",
|
||||
"setupFeePlusVat": "+ VAT",
|
||||
"optional": "optional",
|
||||
"yourChannelIdLabel": {
|
||||
"telegram": "Your Telegram user ID",
|
||||
"discord": "Your Discord user ID",
|
||||
"threema": "Your Threema ID"
|
||||
},
|
||||
"yourChannelIdPlaceholder": {
|
||||
"telegram": "e.g. 1234567890",
|
||||
"discord": "e.g. 234567890123456789",
|
||||
"threema": "e.g. ABCD1234"
|
||||
},
|
||||
"yourChannelIdHelp": {
|
||||
"telegram": "Open Telegram, message @userinfobot, and paste the numeric id it returns. You can add more users later from the tenant page.",
|
||||
"discord": "Enable Developer Mode in Discord (Advanced settings), right-click your name → Copy User ID, and paste it here. You can add more users later from the tenant page.",
|
||||
"threema": "The 8 characters shown in your Threema app under Settings → My Threema ID. Once your tenant is approved and Threema is enabled, you'll be able to chat with the assistant from this account. More authorized IDs can be added later from the tenant page."
|
||||
}
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Dashboard",
|
||||
@@ -311,7 +332,7 @@
|
||||
},
|
||||
"threema": {
|
||||
"description": "Send and receive messages through Threema. Each inbound and outbound message uses the shared PieCed messaging service and incurs a per-message charge from Threema — a third-party cost, separate from your PieCed subscription.",
|
||||
"instructions": "1. Enable this package.\n2. Open Threema on your phone, scan the QR code shown under Authorized Users → threema, and accept the contact.\n3. Add your own Threema ID under Authorized Users → threema so the assistant recognises your messages.\n4. Send a message from Threema to start chatting with the assistant.",
|
||||
"instructions": "1. Open Threema on your phone and scan the QR code shown below — do it now so you're ready to chat the moment your tenant is running.\n2. Enter your own Threema ID in the field below (the 8 characters from Settings → My Threema ID in your Threema app) so the assistant accepts your messages.\n3. When your tenant is approved and running, send a message from Threema to start chatting.",
|
||||
"disclaimer": "Messages between Threema and PieCed are end-to-end encrypted up to PieCed's messaging service, where they are decrypted to be routed to your assistant. Each message sent or received is counted toward Threema's per-message billing — see your plan for current rates."
|
||||
},
|
||||
"manualReviewPending": "Manual review pending",
|
||||
@@ -319,7 +340,12 @@
|
||||
"activationRejected": "Rejected",
|
||||
"tryAgain": "Try again",
|
||||
"credentialsSaved": "credentials saved",
|
||||
"credentialsSavedTip": "The credentials you entered are securely stored and will be used as soon as admin approves the activation. You don't need to re-enter them."
|
||||
"credentialsSavedTip": "The credentials you entered are securely stored and will be used as soon as admin approves the activation. You don't need to re-enter them.",
|
||||
"recommended": "Recommended",
|
||||
"threemaBotIdHeading": "Bot Threema ID",
|
||||
"threemaBotIdHint": "This is the assistant's Threema ID — identical for every PieCed tenant. Scan the QR now with your Threema app so you're ready the moment your tenant is approved and Threema is enabled.",
|
||||
"showInfo": "Info",
|
||||
"showInfoTitle": "Show setup info again"
|
||||
},
|
||||
"admin": {
|
||||
"title": "Platform Admin",
|
||||
@@ -412,7 +438,7 @@
|
||||
"title": "Add the assistant to your Threema",
|
||||
"step1": "Open Threema on your phone.",
|
||||
"step2": "Tap the scan icon and scan this QR code to add the assistant as a contact.",
|
||||
"step3": "Then add your own Threema ID below.",
|
||||
"step3": "Make sure your Threema ID is registered as an authorized user so the assistant accepts your messages.",
|
||||
"qrAlt": "QR code to add {gateway} as a Threema contact",
|
||||
"bannerTitle": "Set up Threema",
|
||||
"bannerBody": "Open Threema on your phone and scan our QR code to add the assistant as a contact. Then add your own Threema ID below.",
|
||||
@@ -509,7 +535,27 @@
|
||||
"fullNameLabel": "Full name",
|
||||
"subtitlePersonal": "Your billing address and invoice contact. Required before invoices can be issued.",
|
||||
"contactNameLabel": "Contact person (optional)",
|
||||
"contactNameHint": "Prints as 'Attn: <name>' on the invoice below the company name. Useful for AP routing in larger organizations."
|
||||
"contactNameHint": "Prints as 'Attn: <name>' on the invoice below the company name. Useful for AP routing in larger organizations.",
|
||||
"savedCardHeading": "Saved card",
|
||||
"savedCardEmptyBody": "Save a card for automatic invoice payments. Your card details are stored securely by Stripe — we only see the brand, last four digits, and expiration.",
|
||||
"savedCardSetupBtn": "Set up auto-pay",
|
||||
"savedCardRedirecting": "Redirecting…",
|
||||
"savedCardUpdateBtn": "Update card",
|
||||
"savedCardRemoveBtn": "Remove card",
|
||||
"savedCardRemoving": "Removing…",
|
||||
"savedCardRemoveConfirm": "Remove this card? You'll need to set up auto-pay again for future invoices to charge automatically.",
|
||||
"savedCardBrandUnknown": "Card",
|
||||
"savedCardExpires": "expires {date}",
|
||||
"savedCardAutoChargeOn": "Auto-pay on",
|
||||
"savedCardAutoChargeOff": "Auto-pay off",
|
||||
"savedCardDisableAutoChargeBtn": "Disable auto-pay",
|
||||
"savedCardEnableAutoChargeBtn": "Enable auto-pay",
|
||||
"savedCardPayByInvoiceNote": "Your account is set to pay by bank transfer; the saved card is not used for automatic charges. Contact support if you'd like to switch back to card payment.",
|
||||
"savedCardBankTransferHint": "Bank transfer is also available on request.",
|
||||
"savedCardBankTransferLink": "Contact us to arrange.",
|
||||
"savedCardAutoPayRequiredHeading": "Auto-pay is required",
|
||||
"savedCardAutoPayRequiredBody": "PieCed IT operates on automatic card payment. We reserve the right to suspend tenants until outstanding invoices are paid if automatic billing fails.",
|
||||
"savedCardAutoPayDisabledNote": "Auto-pay is currently disabled. Future invoices will need to be paid manually — if they go unpaid we reserve the right to suspend the tenants associated with this account."
|
||||
},
|
||||
"support": {
|
||||
"title": "Support",
|
||||
@@ -577,8 +623,8 @@
|
||||
"title": "Billing administration",
|
||||
"subtitle": "Manage platform pricing, generate invoices, and review billing status across all organizations.",
|
||||
"backToAdmin": "Back to Admin",
|
||||
"backToBilling": "Back to Billing",
|
||||
"backToInvoices": "Back to Invoices",
|
||||
"backToBilling": "Back to billing",
|
||||
"backToInvoices": "Back to invoices",
|
||||
"totalOpenBalance": "Total open balance",
|
||||
"orgsWithBalance": "Orgs with balance",
|
||||
"overdueInvoices": "Overdue invoices",
|
||||
@@ -702,7 +748,83 @@
|
||||
"creditNoteNoPdf": "—",
|
||||
"refundAmountLabel": "Amount",
|
||||
"refundReasonLabel": "Reason",
|
||||
"refundAmountInclVatHint": "incl. VAT"
|
||||
"refundAmountInclVatHint": "incl. VAT",
|
||||
"newInvoiceBtn": "New invoice",
|
||||
"draftsLink": "Drafts",
|
||||
"backToDrafts": "Back to drafts",
|
||||
"newInvoicePageTitle": "New invoice",
|
||||
"newInvoicePageSubtitle": "Pick the customer you want to invoice. You'll add lines on the next step.",
|
||||
"newInvoiceOrgLabel": "Customer",
|
||||
"newInvoiceOrgPlaceholder": "— select customer —",
|
||||
"newInvoiceOrgNoBilling": "no billing info",
|
||||
"newInvoiceOrgBillingMissing": "This customer has no billing address on file. Ask them to complete onboarding or set the billing info from the admin panel before issuing.",
|
||||
"newInvoiceLocaleLabel": "Document language",
|
||||
"newInvoiceOrgRequired": "Please select a customer.",
|
||||
"newInvoiceContinueBtn": "Continue",
|
||||
"creating": "Creating…",
|
||||
"draftsPageTitle": "Invoice drafts",
|
||||
"draftsPageSubtitle": "Custom invoices in progress. Resume editing or discard.",
|
||||
"draftsEmpty": "No drafts yet. Start a new invoice to begin.",
|
||||
"draftOrgCol": "Customer",
|
||||
"draftIssueDateCol": "Issue date",
|
||||
"draftLinesCol": "Lines",
|
||||
"draftSubtotalCol": "Subtotal (est.)",
|
||||
"draftUpdatedCol": "Last edited",
|
||||
"draftActionsCol": "Actions",
|
||||
"draftDeleteConfirm": "Discard this draft? This cannot be undone.",
|
||||
"editBtn": "Edit",
|
||||
"editorPageTitle": "Edit invoice draft",
|
||||
"editorBillToHeading": "Bill to",
|
||||
"editorNoBillingSnapshot": "No billing address on file for this customer. Issuance will fail until billing info is set.",
|
||||
"editorMetadataHeading": "Invoice details",
|
||||
"editorIssueDateLabel": "Issue date",
|
||||
"editorDueDateLabel": "Due date",
|
||||
"editorLocaleLabel": "Document language",
|
||||
"editorPaymentMethodLabel": "Payment method",
|
||||
"editorPaymentInvoice": "Bank transfer (invoice)",
|
||||
"editorPaymentCard": "Credit card (Stripe)",
|
||||
"editorLinesHeading": "Line items",
|
||||
"editorLineDescription": "Description",
|
||||
"editorLineDescriptionPlaceholder": "e.g. Consulting hours, custom integration, …",
|
||||
"editorLineQty": "Qty",
|
||||
"editorLineUnitPrice": "Unit price",
|
||||
"editorLineAmount": "Amount",
|
||||
"editorLineRemove": "Remove line",
|
||||
"editorAddLine": "Add line",
|
||||
"editorAddDiscount": "Add discount",
|
||||
"editorAddDiscountHint": "Adds a line with negative unit price. Edit description and amount as needed.",
|
||||
"editorRabattDefaultDescription": "Discount",
|
||||
"editorNotesHeading": "Internal notes",
|
||||
"editorNotesPlaceholder": "Notes only visible to admin (not on the invoice PDF)",
|
||||
"editorNotesHint": "Not shown to the customer.",
|
||||
"editorTotalsHeading": "Totals (estimate)",
|
||||
"editorSubtotal": "Subtotal",
|
||||
"editorVat": "VAT",
|
||||
"editorTotal": "Total",
|
||||
"editorTotalsEstimateNote": "Estimate based on customer country. Final VAT is computed at issuance.",
|
||||
"editorSaveBtn": "Save draft",
|
||||
"editorSavedBtn": "Saved",
|
||||
"editorPreviewBtn": "Preview PDF",
|
||||
"editorIssueBtn": "Issue invoice",
|
||||
"editorDeleteBtn": "Discard draft",
|
||||
"editorIssueConfirm": "Issue this invoice now? An invoice number will be allocated, the PDF will be sent to the customer, and this draft will be removed.",
|
||||
"editorDeleteConfirm": "Discard this draft? This cannot be undone.",
|
||||
"previewing": "Opening…",
|
||||
"issuing": "Issuing…",
|
||||
"orgsTitle": "Customer billing",
|
||||
"orgsDesc": "Payment mode + auto-charge per customer",
|
||||
"orgsPageTitle": "Customer billing modes",
|
||||
"orgsPageSubtitle": "Override payment mode for individual customers. Pay-by-invoice replaces card auto-charge with manual bank transfer; pausing auto-charge keeps the saved card on file but stops attempting charges (useful during disputes).",
|
||||
"orgsEmpty": "No customer orgs yet.",
|
||||
"orgsColCustomer": "Customer",
|
||||
"orgsColCard": "Saved card",
|
||||
"orgsColPayByInvoice": "Pay by bank transfer",
|
||||
"orgsColAutoCharge": "Auto-charge",
|
||||
"orgsNoSavedCard": "none",
|
||||
"orgsPayByInvoiceOn": "on",
|
||||
"orgsPayByInvoiceOff": "off",
|
||||
"orgsAutoChargeOn": "on",
|
||||
"orgsAutoChargeOff": "off"
|
||||
},
|
||||
"skillCostDialog": {
|
||||
"title": "Confirm activation cost",
|
||||
@@ -842,5 +964,13 @@
|
||||
"saving": "Saving…",
|
||||
"saved": "Saved.",
|
||||
"missingRequired": "First and last name are required."
|
||||
},
|
||||
"errors": {
|
||||
"title": "Something went wrong",
|
||||
"description": "An error occurred while loading this page. Please try again.",
|
||||
"retry": "Try again",
|
||||
"backToDashboard": "Back to dashboard",
|
||||
"notFoundTitle": "Page not found",
|
||||
"notFoundDescription": "The page you're looking for doesn't exist or has moved."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"tagline": "Plateforme IA",
|
||||
"login": "Connexion",
|
||||
"logout": "Déconnexion",
|
||||
"menu": "Menu",
|
||||
"dashboard": "Tableau de bord",
|
||||
"admin": "Admin",
|
||||
"loading": "Chargement…",
|
||||
@@ -122,7 +123,27 @@
|
||||
"billingVatNumber": "Numéro de TVA",
|
||||
"billingVatHelp": "Votre identifiant TVA enregistré. Si votre entreprise est exonérée de TVA, laissez vide et précisez dans les notes.",
|
||||
"billingNotesPlaceholderPersonal": "Tout ce que nous devons savoir — moyen de paiement préféré, référence de facturation, etc.",
|
||||
"reviewContactPersonPrefix": "À l'attention de"
|
||||
"reviewContactPersonPrefix": "À l'attention de",
|
||||
"setupFeeNoticeHeading": "Les frais de configuration seront facturés à l'envoi",
|
||||
"setupFeeNoticeBody": "Au prochain clic vous serez redirigé vers Stripe pour saisir vos coordonnées de paiement et régler les frais d'activation uniques. Votre carte est enregistrée automatiquement pour la facturation mensuelle future. Vous reviendrez immédiatement au tableau de bord. L'instance ne démarre qu'après validation par l'administrateur — les frais mensuels commencent à compter de la date de validation.",
|
||||
"setupFeeAmountLabel": "Frais d'activation uniques",
|
||||
"setupFeePlusVat": "+ TVA",
|
||||
"optional": "facultatif",
|
||||
"yourChannelIdLabel": {
|
||||
"telegram": "Votre ID utilisateur Telegram",
|
||||
"discord": "Votre ID utilisateur Discord",
|
||||
"threema": "Votre ID Threema"
|
||||
},
|
||||
"yourChannelIdPlaceholder": {
|
||||
"telegram": "ex. 1234567890",
|
||||
"discord": "ex. 234567890123456789",
|
||||
"threema": "ex. ABCD1234"
|
||||
},
|
||||
"yourChannelIdHelp": {
|
||||
"telegram": "Ouvrez Telegram, écrivez à @userinfobot et collez l'ID numérique qu'il retourne. Vous pourrez ajouter d'autres utilisateurs plus tard depuis la page du tenant.",
|
||||
"discord": "Activez le mode développeur dans Discord (paramètres avancés), clic-droit sur votre nom → Copier l'ID utilisateur, puis collez-le ici. Vous pourrez ajouter d'autres utilisateurs plus tard depuis la page du tenant.",
|
||||
"threema": "Les 8 caractères affichés dans votre app Threema sous Réglages → Mon identifiant Threema. Une fois votre tenant approuvé et Threema activé, vous pourrez discuter avec l'assistant depuis ce compte. D'autres ID autorisés peuvent être ajoutés plus tard depuis la page du tenant."
|
||||
}
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Tableau de bord",
|
||||
@@ -311,7 +332,7 @@
|
||||
},
|
||||
"threema": {
|
||||
"description": "Envoyez et recevez des messages via Threema. Chaque message entrant ou sortant transite par le service de messagerie PieCed partagé et entraîne des frais par message facturés par Threema — un coût tiers, distinct de votre abonnement PieCed.",
|
||||
"instructions": "1. Activez ce package.\n2. Ouvrez Threema sur votre téléphone, scannez le QR code affiché dans Utilisateurs autorisés → threema, puis acceptez le contact.\n3. Ajoutez votre propre identifiant Threema sous Utilisateurs autorisés → threema afin que l'assistant reconnaisse vos messages.\n4. Envoyez un message depuis Threema pour commencer la conversation.",
|
||||
"instructions": "1. Ouvrez Threema sur votre téléphone et scannez le QR code affiché ci-dessous — faites-le dès maintenant pour être prêt à discuter dès que votre tenant sera opérationnel.\n2. Saisissez votre propre identifiant Threema dans le champ ci-dessous (les 8 caractères figurant dans Réglages → Mon identifiant Threema dans l'app Threema) afin que l'assistant accepte vos messages.\n3. Une fois votre tenant approuvé et opérationnel, envoyez un message depuis Threema pour démarrer la conversation.",
|
||||
"disclaimer": "Les messages entre Threema et PieCed sont chiffrés de bout en bout jusqu'au service de messagerie PieCed, où ils sont déchiffrés pour être acheminés vers votre assistant. Chaque message envoyé ou reçu est facturé par Threema selon son tarif par message — consultez votre plan pour les tarifs en vigueur."
|
||||
},
|
||||
"manualReviewPending": "Revue manuelle en attente",
|
||||
@@ -319,7 +340,12 @@
|
||||
"activationRejected": "Refusée",
|
||||
"tryAgain": "Réessayer",
|
||||
"credentialsSaved": "identifiants enregistrés",
|
||||
"credentialsSavedTip": "Les identifiants saisis sont stockés en sécurité et seront utilisés dès l'approbation de l'activation par l'administrateur. Vous n'avez pas besoin de les ressaisir."
|
||||
"credentialsSavedTip": "Les identifiants saisis sont stockés en sécurité et seront utilisés dès l'approbation de l'activation par l'administrateur. Vous n'avez pas besoin de les ressaisir.",
|
||||
"recommended": "Recommandé",
|
||||
"threemaBotIdHeading": "ID Threema du bot",
|
||||
"threemaBotIdHint": "Voici l'identifiant Threema de l'assistant — identique pour chaque tenant PieCed. Scannez le QR dès maintenant avec votre app Threema afin d'être prêt dès l'approbation de votre tenant et l'activation de Threema.",
|
||||
"showInfo": "Info",
|
||||
"showInfoTitle": "Réafficher les infos de configuration"
|
||||
},
|
||||
"admin": {
|
||||
"title": "Admin plateforme",
|
||||
@@ -412,7 +438,7 @@
|
||||
"title": "Ajouter l'assistant à Threema",
|
||||
"step1": "Ouvrez Threema sur votre téléphone.",
|
||||
"step2": "Appuyez sur l'icône de scan et scannez ce QR code pour ajouter l'assistant comme contact.",
|
||||
"step3": "Puis ajoutez votre propre identifiant Threema ci-dessous.",
|
||||
"step3": "Assurez-vous que votre identifiant Threema est enregistré comme utilisateur autorisé pour que l'assistant accepte vos messages.",
|
||||
"qrAlt": "QR code pour ajouter {gateway} comme contact Threema",
|
||||
"bannerTitle": "Configurer Threema",
|
||||
"bannerBody": "Ouvrez Threema sur votre téléphone et scannez notre QR code pour ajouter l'assistant comme contact. Saisissez ensuite votre propre identifiant Threema ci-dessous.",
|
||||
@@ -509,7 +535,27 @@
|
||||
"fullNameLabel": "Nom et prénom",
|
||||
"subtitlePersonal": "Votre adresse de facturation et votre contact. Requis avant l'émission de toute facture.",
|
||||
"contactNameLabel": "Personne à contacter (facultatif)",
|
||||
"contactNameHint": "S'imprime « À l'attention de <nom> » sur la facture, sous le nom de l'entreprise. Utile pour le routage en comptabilité dans les grandes organisations."
|
||||
"contactNameHint": "S'imprime « À l'attention de <nom> » sur la facture, sous le nom de l'entreprise. Utile pour le routage en comptabilité dans les grandes organisations.",
|
||||
"savedCardHeading": "Carte enregistrée",
|
||||
"savedCardEmptyBody": "Enregistrez une carte pour le paiement automatique des factures. Les données de votre carte sont stockées de manière sécurisée par Stripe — nous ne voyons que la marque, les quatre derniers chiffres et la date d'expiration.",
|
||||
"savedCardSetupBtn": "Configurer le paiement automatique",
|
||||
"savedCardRedirecting": "Redirection…",
|
||||
"savedCardUpdateBtn": "Mettre à jour la carte",
|
||||
"savedCardRemoveBtn": "Supprimer la carte",
|
||||
"savedCardRemoving": "Suppression…",
|
||||
"savedCardRemoveConfirm": "Supprimer cette carte ? Vous devrez reconfigurer le paiement automatique pour que les futures factures soient prélevées automatiquement.",
|
||||
"savedCardBrandUnknown": "Carte",
|
||||
"savedCardExpires": "expire {date}",
|
||||
"savedCardAutoChargeOn": "Paiement auto. actif",
|
||||
"savedCardAutoChargeOff": "Paiement auto. inactif",
|
||||
"savedCardDisableAutoChargeBtn": "Désactiver le paiement automatique",
|
||||
"savedCardEnableAutoChargeBtn": "Activer le paiement automatique",
|
||||
"savedCardPayByInvoiceNote": "Votre compte est configuré pour le paiement par virement ; la carte enregistrée n'est pas utilisée pour les prélèvements automatiques. Contactez le support si vous souhaitez revenir au paiement par carte.",
|
||||
"savedCardBankTransferHint": "Le paiement par virement est également possible sur demande.",
|
||||
"savedCardBankTransferLink": "Contactez-nous pour l'organiser.",
|
||||
"savedCardAutoPayRequiredHeading": "Le paiement automatique est requis",
|
||||
"savedCardAutoPayRequiredBody": "PieCed IT fonctionne sur la base d'un paiement automatique par carte. Nous nous réservons le droit de suspendre les tenants jusqu'au règlement des factures impayées si la facturation automatique échoue.",
|
||||
"savedCardAutoPayDisabledNote": "Le paiement automatique est actuellement désactivé. Les factures futures devront être réglées manuellement — en cas de non-paiement, nous nous réservons le droit de suspendre les tenants associés à ce compte."
|
||||
},
|
||||
"support": {
|
||||
"title": "Support",
|
||||
@@ -702,7 +748,83 @@
|
||||
"creditNoteNoPdf": "—",
|
||||
"refundAmountLabel": "Montant",
|
||||
"refundReasonLabel": "Motif",
|
||||
"refundAmountInclVatHint": "TVA incluse"
|
||||
"refundAmountInclVatHint": "TVA incluse",
|
||||
"newInvoiceBtn": "Nouvelle facture",
|
||||
"draftsLink": "Brouillons",
|
||||
"backToDrafts": "Retour aux brouillons",
|
||||
"newInvoicePageTitle": "Nouvelle facture",
|
||||
"newInvoicePageSubtitle": "Choisissez le client à facturer. Vous ajouterez les lignes à l'étape suivante.",
|
||||
"newInvoiceOrgLabel": "Client",
|
||||
"newInvoiceOrgPlaceholder": "— sélectionner un client —",
|
||||
"newInvoiceOrgNoBilling": "pas d'adresse de facturation",
|
||||
"newInvoiceOrgBillingMissing": "Ce client n'a pas d'adresse de facturation. Demandez-lui de compléter l'inscription ou renseignez-la depuis le panneau d'administration avant d'émettre.",
|
||||
"newInvoiceLocaleLabel": "Langue du document",
|
||||
"newInvoiceOrgRequired": "Veuillez sélectionner un client.",
|
||||
"newInvoiceContinueBtn": "Continuer",
|
||||
"creating": "Création…",
|
||||
"draftsPageTitle": "Brouillons de factures",
|
||||
"draftsPageSubtitle": "Factures personnalisées en cours. Reprenez l'édition ou supprimez.",
|
||||
"draftsEmpty": "Aucun brouillon pour le moment. Démarrez une nouvelle facture.",
|
||||
"draftOrgCol": "Client",
|
||||
"draftIssueDateCol": "Date d'émission",
|
||||
"draftLinesCol": "Lignes",
|
||||
"draftSubtotalCol": "Sous-total (est.)",
|
||||
"draftUpdatedCol": "Modifié",
|
||||
"draftActionsCol": "Actions",
|
||||
"draftDeleteConfirm": "Supprimer ce brouillon ? Cette action est irréversible.",
|
||||
"editBtn": "Modifier",
|
||||
"editorPageTitle": "Modifier le brouillon de facture",
|
||||
"editorBillToHeading": "Destinataire",
|
||||
"editorNoBillingSnapshot": "Aucune adresse de facturation pour ce client. L'émission échouera tant que les informations de facturation ne sont pas renseignées.",
|
||||
"editorMetadataHeading": "Détails de la facture",
|
||||
"editorIssueDateLabel": "Date d'émission",
|
||||
"editorDueDateLabel": "Date d'échéance",
|
||||
"editorLocaleLabel": "Langue du document",
|
||||
"editorPaymentMethodLabel": "Mode de paiement",
|
||||
"editorPaymentInvoice": "Virement (facture)",
|
||||
"editorPaymentCard": "Carte bancaire (Stripe)",
|
||||
"editorLinesHeading": "Lignes",
|
||||
"editorLineDescription": "Description",
|
||||
"editorLineDescriptionPlaceholder": "p.ex. Heures de conseil, intégration sur mesure, …",
|
||||
"editorLineQty": "Qté",
|
||||
"editorLineUnitPrice": "Prix unitaire",
|
||||
"editorLineAmount": "Montant",
|
||||
"editorLineRemove": "Supprimer la ligne",
|
||||
"editorAddLine": "Ajouter une ligne",
|
||||
"editorAddDiscount": "Ajouter une remise",
|
||||
"editorAddDiscountHint": "Ajoute une ligne avec un prix unitaire négatif. Modifiez la description et le montant si nécessaire.",
|
||||
"editorRabattDefaultDescription": "Remise",
|
||||
"editorNotesHeading": "Notes internes",
|
||||
"editorNotesPlaceholder": "Notes visibles uniquement par l'administrateur (pas sur le PDF)",
|
||||
"editorNotesHint": "Non visible par le client.",
|
||||
"editorTotalsHeading": "Totaux (estimation)",
|
||||
"editorSubtotal": "Sous-total",
|
||||
"editorVat": "TVA",
|
||||
"editorTotal": "Total",
|
||||
"editorTotalsEstimateNote": "Estimation basée sur le pays du client. La TVA finale est calculée à l'émission.",
|
||||
"editorSaveBtn": "Enregistrer le brouillon",
|
||||
"editorSavedBtn": "Enregistré",
|
||||
"editorPreviewBtn": "Aperçu PDF",
|
||||
"editorIssueBtn": "Émettre la facture",
|
||||
"editorDeleteBtn": "Supprimer le brouillon",
|
||||
"editorIssueConfirm": "Émettre cette facture maintenant ? Un numéro de facture sera attribué, le PDF sera envoyé au client et ce brouillon sera supprimé.",
|
||||
"editorDeleteConfirm": "Supprimer ce brouillon ? Cette action est irréversible.",
|
||||
"previewing": "Ouverture…",
|
||||
"issuing": "Émission…",
|
||||
"orgsTitle": "Facturation client",
|
||||
"orgsDesc": "Mode de paiement + paiement auto. par client",
|
||||
"orgsPageTitle": "Modes de facturation client",
|
||||
"orgsPageSubtitle": "Surcharge du mode de paiement pour les clients individuels. Le paiement par virement remplace le prélèvement automatique par carte ; la pause du paiement automatique conserve la carte enregistrée mais cesse les tentatives de prélèvement (utile en cas de litige).",
|
||||
"orgsEmpty": "Aucun client pour le moment.",
|
||||
"orgsColCustomer": "Client",
|
||||
"orgsColCard": "Carte enregistrée",
|
||||
"orgsColPayByInvoice": "Paiement par virement",
|
||||
"orgsColAutoCharge": "Paiement automatique",
|
||||
"orgsNoSavedCard": "aucune",
|
||||
"orgsPayByInvoiceOn": "actif",
|
||||
"orgsPayByInvoiceOff": "inactif",
|
||||
"orgsAutoChargeOn": "actif",
|
||||
"orgsAutoChargeOff": "inactif"
|
||||
},
|
||||
"skillCostDialog": {
|
||||
"title": "Confirmer le coût d'activation",
|
||||
@@ -842,5 +964,13 @@
|
||||
"saving": "Enregistrement…",
|
||||
"saved": "Enregistré.",
|
||||
"missingRequired": "Le prénom et le nom sont obligatoires."
|
||||
},
|
||||
"errors": {
|
||||
"title": "Une erreur est survenue",
|
||||
"description": "Une erreur s'est produite lors du chargement de cette page. Veuillez réessayer.",
|
||||
"retry": "Réessayer",
|
||||
"backToDashboard": "Retour au tableau de bord",
|
||||
"notFoundTitle": "Page introuvable",
|
||||
"notFoundDescription": "La page que vous recherchez n'existe pas ou a été déplacée."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,14 +2,15 @@
|
||||
"common": {
|
||||
"appName": "PieCed",
|
||||
"tagline": "Piattaforma IA",
|
||||
"login": "Accedi",
|
||||
"login": "Acceda",
|
||||
"logout": "Esci",
|
||||
"menu": "Menu",
|
||||
"dashboard": "Dashboard",
|
||||
"admin": "Admin",
|
||||
"loading": "Caricamento…",
|
||||
"language": "Lingua",
|
||||
"cancel": "Annulla",
|
||||
"save": "Salva",
|
||||
"cancel": "Annulli",
|
||||
"save": "Salvi",
|
||||
"error": "Si è verificato un errore",
|
||||
"register": "Registrati",
|
||||
"team": "Team",
|
||||
@@ -20,14 +21,14 @@
|
||||
},
|
||||
"login": {
|
||||
"title": "Portale PieCed",
|
||||
"subtitle": "Accedi per gestire il tuo assistente IA",
|
||||
"button": "Continua con ZITADEL",
|
||||
"subtitle": "Acceda per gestire il suo assistente IA",
|
||||
"button": "Continui con ZITADEL",
|
||||
"footer": "Ospitato on-premises in Svizzera",
|
||||
"noAccount": "Non hai ancora un account?",
|
||||
"register": "Crea un account"
|
||||
"noAccount": "Non ha ancora un account?",
|
||||
"register": "Crei un account"
|
||||
},
|
||||
"register": {
|
||||
"title": "Crea il tuo account",
|
||||
"title": "Crei il suo account",
|
||||
"subtitle": "Configuri il suo assistente IA ospitato in Svizzera",
|
||||
"companyName": "Nome azienda",
|
||||
"companyNamePlaceholder": "Esempio SA",
|
||||
@@ -35,14 +36,14 @@
|
||||
"familyName": "Cognome",
|
||||
"email": "Indirizzo e-mail",
|
||||
"submit": "Registrati",
|
||||
"hasAccount": "Hai già un account?",
|
||||
"footer": "I tuoi dati sono ospitati esclusivamente on-premises in Svizzera.",
|
||||
"hasAccount": "Ha già un account?",
|
||||
"footer": "I suoi dati sono ospitati esclusivamente on-premises in Svizzera.",
|
||||
"successTitle": "Registrazione ricevuta",
|
||||
"successDescription": "Riceverai un'e-mail di invito con un link per impostare la password e verificare il tuo indirizzo e-mail. Dopodiché potrai accedere e configurare il tuo assistente IA.",
|
||||
"goToLogin": "Vai all'accesso",
|
||||
"duplicateDomain": "Un account per il dominio e-mail {domain} è già registrato. Contatta l'amministratore della tua azienda per essere invitato, oppure contatta il supporto PieCed IT se ritieni che si tratti di un errore.",
|
||||
"successDescription": "Riceverà un'e-mail di invito con un link per impostare la password e verificare il suo indirizzo e-mail. Dopodiché potrà accedere e configurare il suo assistente IA.",
|
||||
"goToLogin": "Vada all'accesso",
|
||||
"duplicateDomain": "Un account per il dominio e-mail {domain} è già registrato. Contatti l'amministratore della sua azienda per essere invitato, oppure contatti il supporto PieCed IT se ritiene che si tratti di un errore.",
|
||||
"individualToggle": "Registrati come privato",
|
||||
"individualHint": "Seleziona questa opzione se non ti stai registrando per conto di un'azienda. Il tuo account sarà configurato come area di lavoro personale.",
|
||||
"individualHint": "Selezioni questa opzione se non Le sta registrando per conto di un'azienda. Il suo account sarà configurato come area di lavoro personale.",
|
||||
"accountTypeLabel": "Tipo di account",
|
||||
"personalCardTitle": "Privato",
|
||||
"personalCardDescription": "Per lei.",
|
||||
@@ -51,26 +52,26 @@
|
||||
},
|
||||
"onboarding": {
|
||||
"loading": "Caricamento stato…",
|
||||
"welcomeTitle": "Configura il tuo assistente IA",
|
||||
"welcomeDescription": "In pochi passaggi avrai il tuo assistente IA — ospitato esclusivamente in Svizzera, completamente sotto il tuo controllo.",
|
||||
"welcomeFeature_swissHosted": "Ospitato on-premises in Svizzera — i tuoi dati non lasciano mai il Paese",
|
||||
"welcomeFeature_privacy": "Nessun dato condiviso con terzi — privacy completa",
|
||||
"welcomeTitle": "Configura il suo assistente IA",
|
||||
"welcomeDescription": "In pochi passaggi avrà il suo assistente IA — ospitato esclusivamente in Svizzera, completamente sotto il suo controllo.",
|
||||
"welcomeFeature_swissHosted": "Ospitato on-premises in Svizzera — i suoi dati non lasciano mai il Paese",
|
||||
"welcomeFeature_privacy": "Nessun dato condiviso con terzi — privacy completi",
|
||||
"welcomeFeature_customizable": "Personalità, pacchetti e integrazioni completamente personalizzabili",
|
||||
"getStarted": "Inizia",
|
||||
"configureTitle": "Configura il tuo assistente",
|
||||
"configureDescription": "Dai un nome e una personalità al tuo assistente. Puoi sempre modificarli in seguito.",
|
||||
"getStarted": "Inizi",
|
||||
"configureTitle": "Configura il suo assistente",
|
||||
"configureDescription": "Dia un nome e una personalità al suo assistente. Può sempre modificarli in seguito.",
|
||||
"agentName": "Nome agente",
|
||||
"soulMd": "Personalità (SOUL.md)",
|
||||
"soulMdHint": "Definisce il comportamento del tuo assistente. Formato Markdown. Modificabile in seguito.",
|
||||
"soulMdHint": "Definisce il comportamento del suo assistente. Formato Markdown. Modificabile in seguito.",
|
||||
"agentsMd": "Istruzioni agente (AGENTS.md)",
|
||||
"agentsMdHint": "Definisce cosa fa il tuo assistente all'avvio della sessione. Opzionale — i valori predefiniti funzionano per la maggior parte delle configurazioni.",
|
||||
"agentsMdHint": "Definisce cosa fa il suo assistente all'avvio della sessione. Opzionale — i valori predefiniti funzionano per la maggior parte delle configurazioni.",
|
||||
"toolsMd": "Strumenti disponibili (TOOLS.md)",
|
||||
"toolsMdHint": "Generato automaticamente in base ai pacchetti selezionati. Questo file viene gestito automaticamente.",
|
||||
"advancedConfig": "Configurazione avanzata",
|
||||
"packages": "Pacchetti",
|
||||
"packagesHint": "Integrazioni opzionali. I pacchetti che richiedono credenziali le chiederanno inline. Puoi attivarli anche in seguito.",
|
||||
"packagesHint": "Integrazioni opzionali. I pacchetti che richiedono credenziali le chiederanno inline. Può attivarli anche in seguito.",
|
||||
"billingTitle": "Informazioni di fatturazione",
|
||||
"billingDescription": "Abbiamo bisogno del tuo indirizzo di fatturazione. Un fornitore di pagamento verrà integrato in futuro.",
|
||||
"billingDescription": "Abbiamo bisogno del suo indirizzo di fatturazione. Un fornitore di pagamento verrà integrato in futuro.",
|
||||
"billingCompany": "Azienda",
|
||||
"billingStreet": "Via",
|
||||
"billingPostalCode": "CAP",
|
||||
@@ -78,38 +79,38 @@
|
||||
"billingCountry": "Paese",
|
||||
"billingNotes": "Note",
|
||||
"billingNotesPlaceholder": "Note sulla fatturazione (numero ordine, partita IVA, metodo di pagamento preferito, ecc.)",
|
||||
"confirmTitle": "Verifica e invia",
|
||||
"confirmDescription": "Verifica la tua configurazione. La tua richiesta verrà esaminata dal nostro team prima dell'attivazione.",
|
||||
"confirmNote": "Dopo l'invio, il nostro team esaminerà la tua richiesta e i dati di fatturazione. Riceverai l'accesso dopo l'approvazione — di solito entro un giorno lavorativo.",
|
||||
"confirmTitle": "Verifichi e invii",
|
||||
"confirmDescription": "Verifichi la sua configurazione. La sua richiesta verrà esaminata dal nostro team prima dell'attivazione.",
|
||||
"confirmNote": "Dopo l'invio, il nostro team esaminerà la sua richiesta e i dati di fatturazione. Riceverà l'accesso dopo l'approvazione — di solito entro un giorno lavorativo.",
|
||||
"credentialsProvided": "Credenziali fornite",
|
||||
"submitRequest": "Invia richiesta",
|
||||
"submitRequest": "Invii richiesta",
|
||||
"back": "Indietro",
|
||||
"next": "Avanti",
|
||||
"pendingTitle": "Richiesta inviata",
|
||||
"pendingDescription": "La tua richiesta è stata inviata ed è in fase di esame da parte del nostro team. Riceverai l'accesso dopo l'approvazione — di solito entro un giorno lavorativo.",
|
||||
"pendingDescription": "La sua richiesta è stata inviata ed è in fase di esame da parte del nostro team. Riceverà l'accesso dopo l'approvazione — di solito entro un giorno lavorativo.",
|
||||
"rejectedTitle": "Richiesta non approvata",
|
||||
"rejectedDescription": "Purtroppo la tua richiesta non è stata approvata. Contattaci per ulteriori informazioni.",
|
||||
"rejectedDescription": "Purtroppo la sua richiesta non è stata approvata. Contattaci per ulteriori informazioni.",
|
||||
"provisioningTitle": "Configurazione dell'istanza",
|
||||
"provisioningDescription": "Il tuo assistente IA è in fase di attivazione. Di solito richiede pochi minuti.",
|
||||
"provisioningDescription": "Il suo assistente IA è in fase di attivazione. Di solito richiede pochi minuti.",
|
||||
"phase": "Fase",
|
||||
"readyTitle": "Il tuo assistente è pronto!",
|
||||
"readyDescription": "Il tuo assistente IA è stato attivato ed è operativo. Ora puoi gestirlo dalla dashboard.",
|
||||
"goToDashboard": "Vai alla dashboard",
|
||||
"readyTitle": "Il suo assistente è pronto!",
|
||||
"readyDescription": "Il suo assistente IA è stato attivato ed è operativo. Ora può gestirlo dalla dashboard.",
|
||||
"goToDashboard": "Vada alla dashboard",
|
||||
"submittedAt": "Inviato",
|
||||
"instanceName": "Nome istanza",
|
||||
"instanceNamePlaceholder": "es. Produzione, Dev, Vendite",
|
||||
"instanceNameHint": "Nome leggibile facoltativo per distinguere questa istanza dalle altre nella dashboard. Lasciare vuoto per usare il nome dell'azienda.",
|
||||
"validationError": "Correggere gli errori prima di inviare.",
|
||||
"validationErrorsTitle": "Alcuni campi obbligatori sono mancanti o non validi:",
|
||||
"reviewInstanceDefault": "(predefinito — usa il nome dell'azienda)",
|
||||
"reviewInstanceDefault": "(predefinito — usi il nome dell'azienda)",
|
||||
"reviewNoPackages": "Nessuno selezionato",
|
||||
"reviewBillingTo": "Fatturare a",
|
||||
"reviewContactEmail": "Email di contatto",
|
||||
"editRequestTitle": "Modifica la sua richiesta",
|
||||
"editRequestTitle": "Modifichi la sua richiesta",
|
||||
"editRequestDescription": "Modifichi la configurazione prima che il nostro team la esamini.",
|
||||
"editRequest": "Modifica",
|
||||
"cancelRequest": "Annulla richiesta",
|
||||
"cancelRequestConfirm": "Sì, annulla la richiesta",
|
||||
"editRequest": "Modifichi",
|
||||
"cancelRequest": "Annulli richiesta",
|
||||
"cancelRequestConfirm": "Sì, annulli la richiesta",
|
||||
"cancelConfirmRequestTitle": "Annullare questa richiesta?",
|
||||
"cancelConfirmRequestDescription": "La sua richiesta in attesa sarà contrassegnata come annullata e rimossa dalla coda di revisione. Può inviare una nuova richiesta in qualsiasi momento.",
|
||||
"cancelFailed": "Impossibile annullare la richiesta.",
|
||||
@@ -118,11 +119,31 @@
|
||||
"dismiss": "Nascondi",
|
||||
"dismissFailed": "Impossibile nascondere.",
|
||||
"rejectionReason": "Motivo indicato",
|
||||
"saveChanges": "Salva modifiche",
|
||||
"saveChanges": "Salvi modifiche",
|
||||
"billingVatNumber": "Partita IVA",
|
||||
"billingVatHelp": "Il tuo identificativo IVA registrato. Se la tua azienda è esente IVA, lascia vuoto e spiega nelle note.",
|
||||
"billingVatHelp": "Il suo identificativo IVA registrato. Se la sua azienda è esente IVA, lascia vuoto e spiega nelle note.",
|
||||
"billingNotesPlaceholderPersonal": "Qualsiasi cosa dovremmo sapere — metodo di pagamento preferito, riferimento per fatturazione, ecc.",
|
||||
"reviewContactPersonPrefix": "c.a."
|
||||
"reviewContactPersonPrefix": "c.a.",
|
||||
"setupFeeNoticeHeading": "Le spese di attivazione saranno addebitate all'invio",
|
||||
"setupFeeNoticeBody": "Al clic successivo sarà reindirizzato a Stripe per inserire i dati di pagamento e pagare le spese di attivazione una tantum. La sua carta viene salvata automaticamente per la fatturazione mensile futura. Tornerà subito alla dashboard. L'istanza si avvia solo dopo l'approvazione dell'admin — i canoni mensili decorrono dalla data di approvazione.",
|
||||
"setupFeeAmountLabel": "Spese di attivazione una tantum",
|
||||
"setupFeePlusVat": "+ IVA",
|
||||
"optional": "facoltativo",
|
||||
"yourChannelIdLabel": {
|
||||
"telegram": "Il suo ID utente Telegram",
|
||||
"discord": "Il suo ID utente Discord",
|
||||
"threema": "Il suo ID Threema"
|
||||
},
|
||||
"yourChannelIdPlaceholder": {
|
||||
"telegram": "es. 1234567890",
|
||||
"discord": "es. 234567890123456789",
|
||||
"threema": "es. ABCD1234"
|
||||
},
|
||||
"yourChannelIdHelp": {
|
||||
"telegram": "Apra Telegram, scriva a @userinfobot e incolli qui l'ID numerico restituito. Potrà aggiungere altri utenti in seguito dalla pagina del tenant.",
|
||||
"discord": "Attivi la Modalità sviluppatore in Discord (Impostazioni avanzate), clic destro sul suo nome → Copia ID utente, poi incolli qui. Potrà aggiungere altri utenti in seguito dalla pagina del tenant.",
|
||||
"threema": "Gli 8 caratteri mostrati nella sua app Threema in Impostazioni → Il mio ID Threema. Una volta approvato il suo tenant e attivato Threema, potrà chattare con l'assistente da questo account. Altri ID autorizzati possono essere aggiunti in seguito dalla pagina del tenant."
|
||||
}
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Dashboard",
|
||||
@@ -132,17 +153,17 @@
|
||||
"packages": "Pacchetti",
|
||||
"noInstance": "Nessuna istanza attivata.",
|
||||
"comingSoon": "Vista dettagliata in arrivo nella Sessione 6.2",
|
||||
"noInstanceDescription": "Configura la tua istanza di assistente IA per iniziare con PieCed IT.",
|
||||
"manage": "Gestisci istanza e pacchetti",
|
||||
"instances": "Le tue istanze",
|
||||
"noInstanceDescription": "Configura la sua istanza di assistente IA per iniziare con PieCed IT.",
|
||||
"manage": "Gestisca istanza e pacchetti",
|
||||
"instances": "Le sue istanze",
|
||||
"inflightRequests": "Richieste in corso",
|
||||
"createInstance": "Crea nuova istanza",
|
||||
"createInstanceDescription": "Effettua il provisioning di un'ulteriore istanza dell'assistente IA per la tua organizzazione. La richiesta sarà esaminata da un amministratore prima della creazione dell'istanza.",
|
||||
"noAccessNoInstances": "La tua organizzazione non ha ancora istanze. Chiedi al proprietario dell'organizzazione di configurarne una.",
|
||||
"createInstance": "Crei nuova istanza",
|
||||
"createInstanceDescription": "Effettua il provisioning di un'ulteriore istanza dell'assistente IA per la sua organizzazione. La richiesta sarà esaminata da un amministratore prima della creazione dell'istanza.",
|
||||
"noAccessNoInstances": "La sua organizzazione non ha ancora istanze. Chieda al proprietario dell'organizzazione di configurarne una.",
|
||||
"noAssignmentsTitle": "Nessuna istanza assegnata",
|
||||
"noAssignmentsDescription": "La tua organizzazione ha delle istanze, ma non ti è stato concesso l'accesso a nessuna di esse. Chiedi al proprietario della tua organizzazione di assegnarti a un'istanza.",
|
||||
"noAssignmentsDescription": "La sua organizzazione ha delle istanze, ma non Le è stato concesso l'accesso a nessuna di esse. Chieda al proprietario della sua organizzazione di assegnarLa a un'istanza.",
|
||||
"noInstancesYetTitle": "Nessuna istanza ancora",
|
||||
"noInstancesYetDescription": "La tua organizzazione non ha ancora istanze. Chiedi al proprietario della tua organizzazione di configurarne una."
|
||||
"noInstancesYetDescription": "La sua organizzazione non ha ancora istanze. Chieda al proprietario della sua organizzazione di configurarne una."
|
||||
},
|
||||
"tenantDetail": {
|
||||
"agent": "Agente",
|
||||
@@ -155,9 +176,9 @@
|
||||
"subscriptionTitle": "Abbonamento",
|
||||
"subscriptionDescriptionActive": "Annulli il suo abbonamento se non ha più bisogno di questo assistente. I suoi dati saranno preservati e potrà riprendere in qualsiasi momento.",
|
||||
"subscriptionDescriptionSuspended": "Il suo abbonamento è annullato. Riprenda per riportare l'assistente online.",
|
||||
"cancelSubscription": "Annulla abbonamento",
|
||||
"cancelSubscriptionConfirm": "Sì, annulla",
|
||||
"resumeSubscription": "Riprendi abbonamento",
|
||||
"cancelSubscription": "Annulli abbonamento",
|
||||
"cancelSubscriptionConfirm": "Sì, annulli",
|
||||
"resumeSubscription": "Riprenda abbonamento",
|
||||
"cancelConfirmTitle": "Annullare questo abbonamento?",
|
||||
"cancelConfirmDescription": "Il suo assistente diventerà non disponibile. Può riprendere in qualsiasi momento — i suoi dati sono preservati.",
|
||||
"cancelConfirmBullet1": "I file del workspace (SOUL.md, AGENTS.md) sono mantenuti",
|
||||
@@ -165,16 +186,16 @@
|
||||
"cancelConfirmBullet3": "Le informazioni di fatturazione sono mantenute",
|
||||
"subscriptionUpdateFailed": "Impossibile aggiornare l'abbonamento.",
|
||||
"suspendedTitle": "Abbonamento annullato",
|
||||
"suspendedDescription": "Il suo assistente è in pausa. Configurazione e dati sono preservati. Usi il controllo Riprendi in fondo a questa pagina per riportarlo online.",
|
||||
"requestReactivation": "Richiedi riattivazione",
|
||||
"suspendedDescription": "Il suo assistente è in pausa. Configurazione e dati sono preservati. Usi il controllo Riprenda in fondo a questa pagina per riportarlo online.",
|
||||
"requestReactivation": "Richieda riattivazione",
|
||||
"requestReactivationConfirmTitle": "Richiedere la riattivazione?",
|
||||
"requestReactivationConfirmDescription": "Un amministratore esaminerà la tua richiesta e riattiverà il tuo tenant. Riceverai un'email non appena la richiesta sarà approvata.",
|
||||
"requestReactivationConfirm": "Invia richiesta",
|
||||
"cancelResumeRequest": "Annulla richiesta",
|
||||
"requestReactivationConfirmDescription": "Un amministratore esaminerà la sua richiesta e riattiverà il suo tenant. Riceverà un'email non appena la richiesta sarà approvata.",
|
||||
"requestReactivationConfirm": "Invii richiesta",
|
||||
"cancelResumeRequest": "Annulli richiesta",
|
||||
"resumeRequestPendingTitle": "Richiesta di riattivazione in sospeso",
|
||||
"resumeRequestPendingDescription": "Inviata {when}. Un amministratore la esaminerà a breve.",
|
||||
"resumeRequestPendingNoteAdmin": "Un proprietario ha richiesto la riattivazione; puoi riprendere direttamente sopra o elaborare la richiesta dalla coda di amministrazione.",
|
||||
"cancelConfirmRetentionWarning": "I tuoi dati sono conservati per 60 giorni dopo l'annullamento. Trascorso tale periodo, tutti i dati del tenant — configurazione, segreti, conversazioni e file — verranno eliminati definitivamente.",
|
||||
"resumeRequestPendingNoteAdmin": "Un proprietario ha richiesto la riattivazione; può riprendere direttamente sopra o elaborare la richiesta dalla coda di amministrazione.",
|
||||
"cancelConfirmRetentionWarning": "I suoi dati sono conservati per 60 giorni dopo l'annullamento. Trascorso tale periodo, tutti i dati del tenant — configurazione, segreti, conversazioni e file — verranno eliminati definitivamente.",
|
||||
"suspendedSince": "Sospeso il {date}",
|
||||
"suspendedDeletionIn": "eliminazione dei dati tra {days, plural, one {# giorno} other {# giorni}} ({date})",
|
||||
"suspendedDeletionImminent": "i dati vengono eliminati ora",
|
||||
@@ -192,26 +213,26 @@
|
||||
"noData": "Nessun dato di utilizzo disponibile.",
|
||||
"dailyBreakdown": "Dettaglio giornaliero",
|
||||
"requests": "richieste",
|
||||
"budgetEdit": "Modifica",
|
||||
"budgetEditTitle": "Imposta budget",
|
||||
"budgetEditDescription": "Limita quanto gli assistenti di questo tenant possono spendere prima che le richieste vengano rifiutate.",
|
||||
"budgetEdit": "Modifichi",
|
||||
"budgetEditTitle": "Imposti budget",
|
||||
"budgetEditDescription": "Limiti quanto gli assistenti di questo tenant possono spendere prima che le richieste vengano rifiutate.",
|
||||
"budgetModeUnlimited": "Nessun limite",
|
||||
"budgetModeUnlimitedDescription": "Spesa libera, nessun tetto.",
|
||||
"budgetModeCapped": "Imposta un tetto",
|
||||
"budgetModeCapped": "Imposti un tetto",
|
||||
"budgetModeCappedDescription": "Rifiuta le richieste una volta raggiunto questo importo.",
|
||||
"budgetAmount": "Importo",
|
||||
"budgetResetCadence": "Ripristino",
|
||||
"budgetCadence_30d": "Ogni 30 giorni",
|
||||
"budgetCadence_1mo": "Mensile",
|
||||
"budgetCadence_1y": "Annuale",
|
||||
"budgetInvalid": "Inserisci un importo positivo.",
|
||||
"budgetInvalid": "Inserisca un importo positivo.",
|
||||
"budgetSaveFailed": "Impossibile salvare il budget. Riprova."
|
||||
},
|
||||
"workspace": {
|
||||
"save": "Salva",
|
||||
"placeholder": "Inserisci il contenuto per {file}…",
|
||||
"save": "Salvi",
|
||||
"placeholder": "Inserisca il contenuto per {file}…",
|
||||
"readonlyNote": "Questo file viene generato automaticamente e non può essere modificato manualmente.",
|
||||
"seedingNote": "I file workspace vengono inizializzati al primo avvio. Un aggiornamento su un'istanza esistente attiva un aggiornamento del ConfigMap e un riavvio del pod."
|
||||
"seedingNote": "I file workspace vengono inizializzati al primo avvio. Un aggiornamento su un'istanza esistente attivi un aggiornamento del ConfigMap e un riavvio del pod."
|
||||
},
|
||||
"packages": {
|
||||
"categories": {
|
||||
@@ -219,9 +240,9 @@
|
||||
"skills": "Capacità",
|
||||
"core": "Core"
|
||||
},
|
||||
"enable": "Attiva",
|
||||
"disable": "Disattiva",
|
||||
"enableAndSave": "Attiva e salva",
|
||||
"enable": "Attivi",
|
||||
"disable": "Disattivi",
|
||||
"enableAndSave": "Attivi e salvi",
|
||||
"configure": "Configura",
|
||||
"requiresApiKey": "Richiede chiave API",
|
||||
"missingFields": "Compilare tutti i campi obbligatori.",
|
||||
@@ -231,17 +252,17 @@
|
||||
"error": "Errore"
|
||||
},
|
||||
"telegram": {
|
||||
"description": "Collega il tuo assistente IA a un bot Telegram.",
|
||||
"description": "Collega il suo assistente IA a un bot Telegram.",
|
||||
"botTokenLabel": "Token bot Telegram",
|
||||
"botTokenPlaceholder": "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11",
|
||||
"instructions": "1. Apri @BotFather su Telegram\n2. Invia /newbot e segui le istruzioni\n3. Copia il token del bot",
|
||||
"instructions": "1. Apra @BotFather su Telegram\n2. Invii /newbot e segua le istruzioni\n3. Copi il token del bot",
|
||||
"disclaimer": "Confermo di possedere questo bot Telegram e autorizzo PieCed IT a collegarlo al mio assistente IA."
|
||||
},
|
||||
"discord": {
|
||||
"description": "Collega il tuo assistente IA a un server Discord tramite un bot.",
|
||||
"description": "Collega il suo assistente IA a un server Discord tramite un bot.",
|
||||
"botTokenLabel": "Token bot Discord",
|
||||
"botTokenPlaceholder": "MTAxNjQ0OTk2NjAz...",
|
||||
"instructions": "1. Vai su discord.com/developers/applications\n2. Crea una nuova applicazione e aggiungi un bot\n3. Copia il token del bot",
|
||||
"instructions": "1. Vada su discord.com/developers/applications\n2. Crei una nuova applicazione e aggiunga un bot\n3. Copi il token del bot",
|
||||
"disclaimer": "Confermo di possedere questo bot Discord e autorizzo PieCed IT a collegarlo al mio assistente IA.",
|
||||
"appIdLabel": "ID applicazione Discord",
|
||||
"appIdPlaceholder": "ID numerico di 18–19 cifre dal Developer Portal"
|
||||
@@ -249,7 +270,7 @@
|
||||
"statusEnabled": "abilitato",
|
||||
"statusDisabled": "disabilitato",
|
||||
"coreHeartbeat": {
|
||||
"description": "Esecuzione periodica dell'agente ogni 30 minuti che consente all'assistente di controllare posta, calendario e altre fonti configurate e di avvisarti proattivamente quando serve attenzione. Senza questa opzione, l'assistente risponde solo quando lo contatti."
|
||||
"description": "Esecuzione periodica dell'agente ogni 30 minuti che consente all'assistente di controllare posta, calendario e altre fonti configurate e di avvisarLa proattivamente quando serve attenzione. Senza questa opzione, l'assistente risponde solo quando lo contatti."
|
||||
},
|
||||
"coreCron": {
|
||||
"description": "Consente all'assistente di eseguire attività pianificate (briefing giornalieri, promemoria ricorrenti, report periodici). Disattivato per impostazione predefinita. Quando è disattivato, lo strumento cron resta disponibile ma nessuna attività pianificata viene eseguita."
|
||||
@@ -258,42 +279,42 @@
|
||||
"description": "Consente all'assistente di richiamare preferenze stabili, abitudini ricorrenti e contesto a lungo termine dalle conversazioni precedenti. Utilizza un turno extra di sub-agente per ogni messaggio in entrata per interrogare lo store di memoria. Solo messaggi diretti. Aggiunge un piccolo costo in token in cambio di continuità e personalizzazione."
|
||||
},
|
||||
"coreVoice": {
|
||||
"description": "Riconoscimento vocale sui messaggi audio in entrata e sintesi vocale sulle risposte, instradati attraverso il gateway PieCed LiteLLM per tracciare il costo audio per tenant. L'integrazione runtime arriverà nel prossimo rilascio della piattaforma; attivare ora salva la preferenza per quel rilascio."
|
||||
"description": "Riconoscimento vocale sui messaggi audio in entrata e sintesi vocale sulle risposte, instradati attraverso il gateway PieCed LiteLLM per tracciare il costo audio per tenant. L'integrazione runtime arriverà nel prossimo rilascio della piattaforma; attivare ora salvi la preferenza per quel rilascio."
|
||||
},
|
||||
"gitCli": {
|
||||
"description": "Operazioni git da riga di comando autonome (clone, commit, branch, diff, log, status). Per i repository privati, configura le credenziali nel tuo workspace."
|
||||
"description": "Operazioni git da riga di comando autonome (clone, commit, branch, diff, log, status). Per i repository privati, configura le credenziali nel suo workspace."
|
||||
},
|
||||
"github": {
|
||||
"description": "Interagisci con repository GitHub tramite la CLI gh — issue, pull request, esecuzioni CI, release, gist. Richiede un token di accesso personale.",
|
||||
"tokenLabel": "Token di accesso personale GitHub",
|
||||
"tokenPlaceholder": "ghp_… o github_pat_…",
|
||||
"instructions": "1. Apri https://github.com/settings/tokens\n2. Genera un token di accesso personale fine con gli ambiti repo desiderati\n3. Copia il token (viene mostrato una sola volta)"
|
||||
"instructions": "1. Apra https://github.com/settings/tokens\n2. Generi un token di accesso personale fine con gli ambiti repo desiderati\n3. Copi il token (viene mostrato una sola volta)"
|
||||
},
|
||||
"gitea": {
|
||||
"description": "Interagisci con un'istanza Gitea — repository, issue, pull request, release. Per impostazione predefinita, l'istanza Gitea PieCed su git.c5ai.ch.",
|
||||
"tokenLabel": "Token di accesso Gitea",
|
||||
"tokenPlaceholder": "Generato in Impostazioni → Applicazioni",
|
||||
"instructions": "1. Accedi alla tua istanza Gitea (predefinito https://git.c5ai.ch)\n2. Vai a Impostazioni → Applicazioni → Genera nuovo token\n3. Concedi gli ambiti desiderati (repo, issue, user)\n4. Copia il token"
|
||||
"instructions": "1. Acceda alla sua istanza Gitea (predefinito https://git.c5ai.ch)\n2. Vada a Impostazioni → Applicazioni → Generi nuovo token\n3. Conceda gli ambiti desiderati (repo, issue, user)\n4. Copi il token"
|
||||
},
|
||||
"whisperSelfHosted": {
|
||||
"description": "Trascrivi file audio tramite l'istanza Whisper auto-ospitata della piattaforma. Utile per attività di trascrizione ad hoc avviate dalla chat."
|
||||
},
|
||||
"searxngLocalSearch": {
|
||||
"description": "Ricerca web rispettosa della privacy tramite l'istanza SearXNG interna della piattaforma. Cerca sul web, nelle immagini e nelle notizie senza chiamate ad API esterne né tracker."
|
||||
"description": "Ricerca web rispettosa della privacy tramite l'istanza SearXNG interna della piattaforma. Cerchi sul web, nelle immagini e nelle notizie senza chiamate ad API esterne né tracker."
|
||||
},
|
||||
"gog": {
|
||||
"description": "Accesso integrato a Gmail, Calendar, Drive, Docs, Sheets e Contatti tramite Google OAuth. La configurazione richiede un progetto Google Cloud — contatta il supporto PieCed per l'onboarding.",
|
||||
"description": "Accesso integrato a Gmail, Calendar, Drive, Docs, Sheets e Contatti tramite Google OAuth. La configurazione richiede un progetto Google Cloud — contatti il supporto PieCed per l'onboarding.",
|
||||
"clientIdLabel": "ID client Google OAuth",
|
||||
"clientIdPlaceholder": "xxxxxxxxxxx.apps.googleusercontent.com",
|
||||
"clientSecretLabel": "Client secret Google OAuth",
|
||||
"clientSecretPlaceholder": "GOCSPX-…",
|
||||
"refreshTokenLabel": "Token di refresh Google OAuth",
|
||||
"refreshTokenPlaceholder": "1//0g…",
|
||||
"instructions": "Google Workspace utilizza OAuth. Crea un client OAuth nel tuo progetto Google Cloud, autorizzalo con gli scope necessari (Gmail, Calendar, Drive, ecc.), quindi incolla le credenziali qui sotto. L'invio le memorizza in modo sicuro e mette in coda l'attivazione per la revisione amministrativa — dopo l'approvazione, l'integrazione si attiva automaticamente.",
|
||||
"disclaimer": "Abilitando l'integrazione con Google Workspace autorizzi PieCed ad accedere per tuo conto a Gmail, Calendar, Drive, Docs, Sheets e Contatti. I dati transitano attraverso le API di Google, soggetti ai termini di Google."
|
||||
"instructions": "Google Workspace utilizza OAuth. Crei un client OAuth nel suo progetto Google Cloud, lo autorizzi con gli scope necessari (Gmail, Calendar, Drive, ecc.), quindi incolla le credenziali qui sotto. L'invio le memorizza in modo sicuro e mette in coda l'attivazione per la revisione amministrativa — dopo l'approvazione, l'integrazione si attivi automaticamente.",
|
||||
"disclaimer": "Abilitando l'integrazione con Google Workspace autorizzi PieCed ad accedere per suo conto a Gmail, Calendar, Drive, Docs, Sheets e Contatti. I dati transitano attraverso le API di Google, soggetti ai termini di Google."
|
||||
},
|
||||
"mail": {
|
||||
"description": "Leggi, cerca e gestisci le e-mail via IMAP; invia tramite SMTP. Funziona con Gmail (con una password per app), Outlook, Fastmail e qualsiasi host IMAP/SMTP standard.",
|
||||
"description": "Legga, cerchi e gestisca le e-mail via IMAP; invii tramite SMTP. Funziona con Gmail (con una password per app), Outlook, Fastmail e qualsiasi host IMAP/SMTP standard.",
|
||||
"imapHostLabel": "Host IMAP",
|
||||
"imapHostPlaceholder": "imap.example.com",
|
||||
"imapUserLabel": "Username IMAP",
|
||||
@@ -306,24 +327,29 @@
|
||||
"smtpUserPlaceholder": "utente@example.com",
|
||||
"smtpPassLabel": "Password SMTP",
|
||||
"smtpPassPlaceholder": "••••••••",
|
||||
"instructions": "1. Per Gmail: abilita la verifica in due passaggi, quindi crea una password per app su https://myaccount.google.com/apppasswords e usala come password IMAP e SMTP.\n2. Per Outlook / Microsoft 365 con MFA: genera una password per app nelle impostazioni di sicurezza del tuo account.\n3. Per altri provider: consulta la loro documentazione IMAP/SMTP per nomi host e porte.\n4. Host IMAP tipici: imap.gmail.com, outlook.office365.com.\n5. Host SMTP tipici: smtp.gmail.com, smtp.office365.com.",
|
||||
"disclaimer": "L'assistente ottiene accesso in lettura/scrittura alla casella di posta che configuri. Valuta l'uso di un indirizzo dedicato anziché di una casella personale se vuoi limitare la portata."
|
||||
"instructions": "1. Per Gmail: abiliti la verifica in due passaggi, quindi crei una password per app su https://myaccount.google.com/apppasswords e la usi come password IMAP e SMTP.\n2. Per Outlook / Microsoft 365 con MFA: generi una password per app nelle impostazioni di sicurezza del suo account.\n3. Per altri provider: consulti la loro documentazione IMAP/SMTP per nomi host e porte.\n4. Host IMAP tipici: imap.gmail.com, outlook.office365.com.\n5. Host SMTP tipici: smtp.gmail.com, smtp.office365.com.",
|
||||
"disclaimer": "L'assistente ottiene accesso in lettura/scrittura alla casella di posta che configuri. Valuta l'uso di un indirizzo dedicato anziché di una casella personale se vuole limitare la portata."
|
||||
},
|
||||
"threema": {
|
||||
"description": "Invia e ricevi messaggi tramite Threema. Ogni messaggio in entrata e in uscita passa attraverso il servizio di messaggistica condiviso di PieCed e comporta un addebito per messaggio da parte di Threema — un costo di terzi, separato dall'abbonamento PieCed.",
|
||||
"instructions": "1. Attiva questo pacchetto.\n2. Apri Threema sul tuo telefono, scansiona il QR code mostrato in Utenti autorizzati → threema e accetta il contatto.\n3. Aggiungi il tuo ID Threema sotto Utenti autorizzati → threema affinché l'assistente riconosca i tuoi messaggi.\n4. Invia un messaggio da Threema per iniziare la conversazione.",
|
||||
"disclaimer": "I messaggi tra Threema e PieCed sono cifrati end-to-end fino al servizio di messaggistica PieCed, dove vengono decifrati per essere inoltrati al tuo assistente. Ogni messaggio inviato o ricevuto viene addebitato da Threema secondo la sua tariffa per messaggio — consulta il tuo piano per i prezzi attuali."
|
||||
"description": "Invii e riceva messaggi tramite Threema. Ogni messaggio in entrata e in uscita passa attraverso il servizio di messaggistica condiviso di PieCed e comporta un addebito per messaggio da parte di Threema — un costo di terzi, separato dall'abbonamento PieCed.",
|
||||
"instructions": "1. Apra Threema sul suo telefono e scansioni il QR code mostrato qui sotto — lo faccia subito, così sarà pronto a chattare appena il suo tenant sarà operativo.\n2. Inserisca il suo ID Threema nel campo qui sotto (gli 8 caratteri da Impostazioni → Il mio ID Threema nell'app Threema) affinché l'assistente accetti i suoi messaggi.\n3. Una volta che il suo tenant è approvato e operativo, invii un messaggio da Threema per iniziare la conversazione.",
|
||||
"disclaimer": "I messaggi tra Threema e PieCed sono cifrati end-to-end fino al servizio di messaggistica PieCed, dove vengono decifrati per essere inoltrati al suo assistente. Ogni messaggio inviato o ricevuto viene addebitato da Threema secondo la sua tariffa per messaggio — consulti il suo piano per i prezzi attuali."
|
||||
},
|
||||
"manualReviewPending": "Revisione manuale in attesa",
|
||||
"withdraw": "Ritira",
|
||||
"activationRejected": "Rifiutata",
|
||||
"tryAgain": "Riprova",
|
||||
"credentialsSaved": "credenziali salvate",
|
||||
"credentialsSavedTip": "Le credenziali inserite sono memorizzate in modo sicuro e saranno utilizzate non appena l'attivazione viene approvata dall'amministratore. Non è necessario reinserirle."
|
||||
"credentialsSavedTip": "Le credenziali inserite sono memorizzate in modo sicuro e saranno utilizzate non appena l'attivazione viene approvata dall'amministratore. Non è necessario reinserirle.",
|
||||
"recommended": "Consigliato",
|
||||
"threemaBotIdHeading": "ID Threema del bot",
|
||||
"threemaBotIdHint": "Questo è l'ID Threema dell'assistente — identico per ogni tenant PieCed. Scansioni il QR ora con la sua app Threema, così sarà pronto non appena il suo tenant verrà approvato e Threema attivato.",
|
||||
"showInfo": "Info",
|
||||
"showInfoTitle": "Mostra di nuovo le info di setup"
|
||||
},
|
||||
"admin": {
|
||||
"title": "Admin piattaforma",
|
||||
"subtitle": "Gestisci le richieste di onboarding e il ciclo di vita dei tenant",
|
||||
"subtitle": "Gestisca le richieste di onboarding e il ciclo di vita dei tenant",
|
||||
"allTenants": "Tenant",
|
||||
"noTenants": "Nessun tenant attivato.",
|
||||
"noAccess": "Permessi insufficienti per questa vista.",
|
||||
@@ -332,7 +358,7 @@
|
||||
"phase": "Fase",
|
||||
"packages": "Pacchetti",
|
||||
"created": "Creato",
|
||||
"manage": "Gestisci",
|
||||
"manage": "Gestisca",
|
||||
"requests": "Richieste",
|
||||
"pendingRequests": "Richieste in attesa",
|
||||
"approve": "Approva",
|
||||
@@ -352,9 +378,9 @@
|
||||
"rejectTitle": "Rifiuta richiesta",
|
||||
"adminNotesLabel": "Note (opzionale)",
|
||||
"adminNotesPlaceholder": "Motivo del rifiuto…",
|
||||
"cancelAction": "Annulla",
|
||||
"cancelAction": "Annulli",
|
||||
"confirmReject": "Rifiuta",
|
||||
"viewTenant": "Visualizza",
|
||||
"viewTenant": "Visualizzi",
|
||||
"filter_all": "Tutti",
|
||||
"filter_pending": "In attesa",
|
||||
"filter_provisioning": "Attivazione",
|
||||
@@ -365,13 +391,13 @@
|
||||
"provisioning": "Attivazione",
|
||||
"errors": "Errori",
|
||||
"suspend": "Sospendi",
|
||||
"resume": "Riprendi",
|
||||
"resume": "Riprenda",
|
||||
"suspended": "Sospeso",
|
||||
"suspendedBadge": "SOSPESO",
|
||||
"deleteTenant": "Elimina",
|
||||
"deleteTitle": "Elimina tenant",
|
||||
"deleteTenant": "Elimini",
|
||||
"deleteTitle": "Elimini tenant",
|
||||
"deleteWarning": "Questo eliminerà permanentemente il tenant, il suo namespace, i secrets e tutti i dati associati. Questa azione non può essere annullata.",
|
||||
"confirmDelete": "Elimina definitivamente",
|
||||
"confirmDelete": "Elimini definitivamente",
|
||||
"loadingTenants": "Caricamento tenant…",
|
||||
"filter_deleted": "Eliminato",
|
||||
"filter_active": "Attivo",
|
||||
@@ -384,7 +410,7 @@
|
||||
"globalSpend": "Costi globali (CHF)",
|
||||
"activeTenants": "Tenant attivi",
|
||||
"tenantsWithSpend": "tenant con spese registrate",
|
||||
"refresh": "Aggiorna",
|
||||
"refresh": "Aggiorni",
|
||||
"healthUnavailable": "Dati di stato non disponibili.",
|
||||
"loadingHealth": "Caricamento dati di stato…",
|
||||
"statusHealthy": "OK",
|
||||
@@ -399,33 +425,33 @@
|
||||
},
|
||||
"channelUsers": {
|
||||
"title": "Utenti autorizzati",
|
||||
"description": "Gestisci quali utenti possono interagire con il tuo assistente su ogni canale. Aggiungi il loro ID numerico per autorizzare l'accesso.",
|
||||
"description": "Gestisca quali utenti possono interagire con il suo assistente su ogni canale. Aggiunga il loro ID numerico per autorizzare l'accesso.",
|
||||
"users": "utenti",
|
||||
"placeholder": "Inserisci l'ID numerico…",
|
||||
"add": "Aggiungi",
|
||||
"placeholder": "Inserisca l'ID numerico…",
|
||||
"add": "Aggiunga",
|
||||
"remove": "Rimuovi",
|
||||
"alreadyAdded": "Questo ID utente è già autorizzato.",
|
||||
"telegramIdHelp": "Per trovare il tuo ID Telegram:\n1. Apri Telegram e invia un messaggio a @userinfobot\n2. Risponde istantaneamente con il tuo ID numerico\n3. Inserisci quel numero qui",
|
||||
"discordIdHelp": "Per trovare il tuo ID Discord:\n1. Attiva la Modalità sviluppatore nelle impostazioni Discord (Avanzate)\n2. Clic destro sul tuo nome → Copia ID utente\n3. Inserisci quel numero qui",
|
||||
"threemaIdHelp": "Inserisci il tuo ID Threema — gli 8 caratteri mostrati nella tua app Threema sotto Impostazioni → Il mio ID Threema. Una volta aggiunto, potrai conversare con l'assistente direttamente da Threema.",
|
||||
"telegramIdHelp": "Per trovare il suo ID Telegram:\n1. Apra Telegram e invii un messaggio a @userinfobot\n2. Risponde istantaneamente con il suo ID numerico\n3. Inserisca quel numero qui",
|
||||
"discordIdHelp": "Per trovare il suo ID Discord:\n1. Attivi la Modalità sviluppatore nelle impostazioni Discord (Avanzate)\n2. Clic destro sul suo nome → Copia ID utente\n3. Inserisca quel numero qui",
|
||||
"threemaIdHelp": "Inserisca il suo ID Threema — gli 8 caratteri mostrati nella sua app Threema sotto Impostazioni → Il mio ID Threema. Una volta aggiunto, potrà conversare con l'assistente direttamente da Threema.",
|
||||
"threemaSetup": {
|
||||
"title": "Aggiungi l'assistente a Threema",
|
||||
"step1": "Apri Threema sul tuo telefono.",
|
||||
"step2": "Tocca l'icona di scansione e scansiona questo QR code per aggiungere l'assistente ai contatti.",
|
||||
"step3": "Quindi aggiungi il tuo ID Threema qui sotto.",
|
||||
"title": "Aggiunga l'assistente a Threema",
|
||||
"step1": "Apra Threema sul suo telefono.",
|
||||
"step2": "Tocchi l'icona di scansione e scansioni questo QR code per aggiungere l'assistente ai contatti.",
|
||||
"step3": "Si assicuri che il suo ID Threema sia registrato come utente autorizzato così l'assistente accetterà i suoi messaggi.",
|
||||
"qrAlt": "QR code per aggiungere {gateway} come contatto Threema",
|
||||
"bannerTitle": "Configura Threema",
|
||||
"bannerBody": "Apri Threema sul tuo telefono e scansiona il nostro QR code per aggiungere l'assistente ai contatti. Inserisci poi il tuo ID Threema qui sotto.",
|
||||
"bannerBody": "Apra Threema sul suo telefono e scansioni il nostro QR code per aggiungere l'assistente ai contatti. Inserisca poi il suo ID Threema qui sotto.",
|
||||
"bannerButton": "Mostra QR code"
|
||||
}
|
||||
},
|
||||
"team": {
|
||||
"title": "Team",
|
||||
"description": "Gestisci i membri della tua organizzazione. Invita colleghi e assegnali alle istanze.",
|
||||
"description": "Gestisca i membri della sua organizzazione. Invita colleghi e assegnali alle istanze.",
|
||||
"inviteSectionTitle": "Invita un membro",
|
||||
"membersSectionTitle": "Membri",
|
||||
"noMembers": "Nessun membro ancora.",
|
||||
"you": "Tu",
|
||||
"you": "Lei",
|
||||
"noRole": "nessun ruolo",
|
||||
"givenName": "Nome",
|
||||
"familyName": "Cognome",
|
||||
@@ -434,21 +460,21 @@
|
||||
"roleUser": "Utente (sola lettura, deve essere assegnato a istanze)",
|
||||
"roleOwner": "Proprietario (accesso completo a tutte le istanze)",
|
||||
"roleHint": "I proprietari possono gestire istanze, fatturazione e membri del team. Gli utenti possono solo visualizzare le istanze a loro assegnate.",
|
||||
"inviteButton": "Invia invito",
|
||||
"inviteButton": "Invii invito",
|
||||
"inviteSent": "Invito inviato. L'utente riceverà un'e-mail con un link per impostare la password.",
|
||||
"inviteUserExists": "Un utente con questa e-mail è già registrato.",
|
||||
"changeRole": "Modifica ruolo",
|
||||
"changeRole": "Modifichi ruolo",
|
||||
"roleUpdated": "Ruolo aggiornato.",
|
||||
"roleUpdateFailed": "Impossibile aggiornare il ruolo.",
|
||||
"cancel": "Annulla",
|
||||
"save": "Salva",
|
||||
"selfChangeBlocked": "Non puoi modificare il tuo ruolo."
|
||||
"cancel": "Annulli",
|
||||
"save": "Salvi",
|
||||
"selfChangeBlocked": "Non può modificare il suo ruolo."
|
||||
},
|
||||
"assignments": {
|
||||
"loading": "Caricamento assegnazioni…",
|
||||
"noneAssigned": "Nessun utente è ancora assegnato a questa istanza.",
|
||||
"noCandidates": "Nessun membro del team disponibile per l'assegnazione. Invita prima gli utenti dalla pagina Team.",
|
||||
"pickUser": "Seleziona un utente…",
|
||||
"pickUser": "Selezioni un utente…",
|
||||
"assign": "Assegna",
|
||||
"revoke": "Rimuovi"
|
||||
},
|
||||
@@ -476,17 +502,17 @@
|
||||
},
|
||||
"settings": {
|
||||
"title": "Impostazioni",
|
||||
"subtitle": "Gestisci la configurazione a livello di organizzazione, valida per tutti i tuoi tenant.",
|
||||
"subtitle": "Gestisca la configurazione a livello di organizzazione, valida per tutti i suoi tenant.",
|
||||
"billingTitle": "Fatturazione",
|
||||
"billingDescription": "Indirizzo, numero di IVA ed e-mail di fatturazione usati per tutti i tuoi tenant.",
|
||||
"nothingForYou": "Al momento non c'è nulla qui per il tuo ruolo. I proprietari possono gestire le impostazioni dell'organizzazione.",
|
||||
"billingDescriptionPersonal": "Indirizzo ed e-mail di fatturazione usati per tutti i tuoi tenant.",
|
||||
"billingDescription": "Indirizzo, numero di IVA ed e-mail di fatturazione usati per tutti i suoi tenant.",
|
||||
"nothingForYou": "Al momento non c'è nulla qui per il suo ruolo. I proprietari possono gestire le impostazioni dell'organizzazione.",
|
||||
"billingDescriptionPersonal": "Indirizzo ed e-mail di fatturazione usati per tutti i suoi tenant.",
|
||||
"profileTitle": "Profilo",
|
||||
"profileDescription": "Modifica il tuo nome e cognome come appaiono nel portale."
|
||||
"profileDescription": "Modifichi il suo nome e cognome come appaiono nel portale."
|
||||
},
|
||||
"settingsBilling": {
|
||||
"title": "Dati di fatturazione",
|
||||
"subtitle": "Indirizzo di fatturazione, partita IVA e contatto fatture della tua azienda. Necessari prima che possano essere emesse fatture per la tua organizzazione.",
|
||||
"subtitle": "Indirizzo di fatturazione, partita IVA e contatto fatture della sua azienda. Necessari prima che possano essere emesse fatture per la sua organizzazione.",
|
||||
"companyNameLabel": "Nome azienda",
|
||||
"streetAddressLabel": "Indirizzo",
|
||||
"postalCodeLabel": "CAP",
|
||||
@@ -499,36 +525,56 @@
|
||||
"billingEmailHint": "Le fatture e i solleciti vengono inviati a questo indirizzo. Può differire dall'e-mail dell'account.",
|
||||
"notesLabel": "Note (facoltative)",
|
||||
"notesHint": "Numeri di riferimento, ordini d'acquisto o altre informazioni da riportare in fattura.",
|
||||
"saveChanges": "Salva modifiche",
|
||||
"createBilling": "Salva dati di fatturazione",
|
||||
"saveChanges": "Salvi modifiche",
|
||||
"createBilling": "Salvi dati di fatturazione",
|
||||
"saving": "Salvataggio…",
|
||||
"saved": "Salvato.",
|
||||
"missingRequired": "Compila tutti i campi obbligatori.",
|
||||
"missingRequired": "Compili tutti i campi obbligatori.",
|
||||
"invalidCountry": "Il codice paese deve essere di 2 lettere (es. CH).",
|
||||
"invalidEmail": "Inserisci un indirizzo e-mail valido.",
|
||||
"invalidEmail": "Inserisca un indirizzo e-mail valido.",
|
||||
"fullNameLabel": "Nome e cognome",
|
||||
"subtitlePersonal": "Il tuo indirizzo di fatturazione e contatto. Necessari prima che possano essere emesse fatture.",
|
||||
"subtitlePersonal": "Il suo 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."
|
||||
"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": "Aggiorni 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": "Disattivi pagamento automatico",
|
||||
"savedCardEnableAutoChargeBtn": "Attivi pagamento automatico",
|
||||
"savedCardPayByInvoiceNote": "Il suo account è impostato per il pagamento tramite bonifico; la carta salvata non viene utilizzata per gli addebiti automatici. Contatti l'assistenza se desidera tornare al pagamento con carta.",
|
||||
"savedCardBankTransferHint": "Il pagamento tramite bonifico è disponibile su richiesta.",
|
||||
"savedCardBankTransferLink": "Ci contatti per organizzarlo.",
|
||||
"savedCardAutoPayRequiredHeading": "Il pagamento automatico è obbligatorio",
|
||||
"savedCardAutoPayRequiredBody": "PieCed IT opera con pagamento automatico tramite carta. Ci riserviamo il diritto di sospendere i tenant fino al saldo delle fatture pendenti in caso di fallimento della fatturazione automatica.",
|
||||
"savedCardAutoPayDisabledNote": "Il pagamento automatico è attualmente disattivato. Le fatture future dovranno essere saldate manualmente — in caso di mancato pagamento ci riserviamo il diritto di sospendere i tenant associati a questo account."
|
||||
},
|
||||
"support": {
|
||||
"title": "Supporto",
|
||||
"subtitle": "Apri un ticket per fare una domanda, segnalare un bug o condividere un feedback. Le risposte verranno inviate alla tua email registrata.",
|
||||
"subtitle": "Apra un ticket per fare una domanda, segnalare un bug o condividere un feedback. Le risposte verranno inviate alla sua email registrata.",
|
||||
"titleAdmin": "Coda supporto",
|
||||
"subtitleAdmin": "Ticket di tutti i clienti, attività più recente per prima.",
|
||||
"newTicket": "Nuovo ticket",
|
||||
"newTicketTitle": "Apri un ticket di supporto",
|
||||
"newTicketSubtitle": "Raccontaci cosa succede. Più dettagli ci dai, più velocemente possiamo aiutarti.",
|
||||
"empty": "Non hai ancora aperto ticket.",
|
||||
"newTicketTitle": "Apra un ticket di supporto",
|
||||
"newTicketSubtitle": "Ci racconti cosa succede. Più dettagli ci dà, più velocemente possiamo aiutarLa.",
|
||||
"empty": "Non ha ancora aperto ticket.",
|
||||
"emptyAdmin": "Nessun ticket di supporto in coda.",
|
||||
"fieldCategory": "Categoria",
|
||||
"fieldTitle": "Titolo",
|
||||
"fieldDescription": "Descrizione",
|
||||
"fieldStatus": "Stato",
|
||||
"titlePlaceholder": "Breve riassunto della tua richiesta",
|
||||
"descriptionPlaceholder": "Descrivi cosa è successo, cosa ti aspettavi e qualsiasi messaggio d'errore visto.",
|
||||
"descriptionHelp": "Puoi incollare messaggi d'errore e log. Niente password o altri segreti.",
|
||||
"submitTicket": "Invia ticket",
|
||||
"titlePlaceholder": "Breve riassunto della sua richiesta",
|
||||
"descriptionPlaceholder": "Descriva cosa è successo, cosa Le aspettavi e qualsiasi messaggio d'errore visto.",
|
||||
"descriptionHelp": "Può incollare messaggi d'errore e log. Niente password o altri segreti.",
|
||||
"submitTicket": "Invii ticket",
|
||||
"createFailed": "Impossibile creare il ticket. Riprova.",
|
||||
"category_bug": "Bug",
|
||||
"category_feature_request": "Richiesta funzionalità",
|
||||
@@ -537,20 +583,20 @@
|
||||
"category_other": "Altro",
|
||||
"status_open": "Aperto",
|
||||
"status_in_progress": "In corso",
|
||||
"status_waiting_for_customer": "In attesa della tua risposta",
|
||||
"status_waiting_for_customer": "In attesa della sua risposta",
|
||||
"status_resolved": "Risolto",
|
||||
"status_reopened": "Riaperto",
|
||||
"openedBy": "Aperto da {name} il {when}",
|
||||
"authorTagAdmin": "Supporto PieCed",
|
||||
"replyLabel": "Aggiungi una risposta",
|
||||
"replyPlaceholder": "Il tuo messaggio…",
|
||||
"replyLabel": "Aggiunga una risposta",
|
||||
"replyPlaceholder": "Il suo messaggio…",
|
||||
"replyPlaceholderReopen": "Risposta (questo riaprirà il ticket)…",
|
||||
"sendReply": "Invia risposta",
|
||||
"sendReply": "Invii risposta",
|
||||
"commentFailed": "Impossibile inviare la risposta. Riprova.",
|
||||
"closeTicket": "Segna come risolto",
|
||||
"confirmClose": "Segnare questo ticket come risolto? Potrai riaprirlo in seguito rispondendo.",
|
||||
"closeTicket": "Segni come risolto",
|
||||
"confirmClose": "Segnare questo ticket come risolto? Potrà riaprirlo in seguito rispondendo.",
|
||||
"closeFailed": "Impossibile chiudere il ticket. Riprova.",
|
||||
"resolvedBanner": "Questo ticket è risolto. Rispondi qui sotto se hai bisogno di un seguito — questo lo riaprirà.",
|
||||
"resolvedBanner": "Questo ticket è risolto. Risponda qui sotto se ha bisogno di un seguito — questo lo riaprirà.",
|
||||
"adminControlsTitle": "Controlli admin",
|
||||
"updateFailed": "Impossibile salvare le modifiche. Riprova."
|
||||
},
|
||||
@@ -561,7 +607,7 @@
|
||||
"defaultDescription": "Usato da ogni tenant senza override proprio.",
|
||||
"fieldTag": "Tag",
|
||||
"emptyHint": "Lascia vuoto per usare il predefinito integrato dell'operatore.",
|
||||
"saveDefault": "Salva predefinito",
|
||||
"saveDefault": "Salvi predefinito",
|
||||
"defaultSaved": "Predefinito salvato. I tenant senza override lo applicheranno al prossimo reconcile.",
|
||||
"saveFailed": "Salvataggio fallito. Riprova.",
|
||||
"overridesSection": "Override per tenant",
|
||||
@@ -570,27 +616,27 @@
|
||||
"statusFollowsDefault": "Segue predefinito",
|
||||
"builtinFallback": "(fallback integrato)",
|
||||
"defaultPrefix": "Predefinito:",
|
||||
"saveOverride": "Salva override",
|
||||
"saveOverride": "Salvi override",
|
||||
"clearOverride": "Rimuovi override"
|
||||
},
|
||||
"adminBilling": {
|
||||
"title": "Amministrazione fatturazione",
|
||||
"subtitle": "Gestire prezzi della piattaforma, generare fatture e verificare lo stato di fatturazione delle organizzazioni.",
|
||||
"backToAdmin": "Torna ad amministrazione",
|
||||
"backToBilling": "Torna alla fatturazione",
|
||||
"backToInvoices": "Torna alle fatture",
|
||||
"backToAdmin": "Torni ad amministrazione",
|
||||
"backToBilling": "Torni alla fatturazione",
|
||||
"backToInvoices": "Torni alle fatture",
|
||||
"totalOpenBalance": "Saldo aperto totale",
|
||||
"orgsWithBalance": "Organizzazioni con saldo",
|
||||
"overdueInvoices": "Fatture scadute",
|
||||
"pricingTitle": "Prezzi",
|
||||
"pricingDesc": "Prezzi piattaforma & skill, aliquota IVA.",
|
||||
"pricingPageDesc": "Modificare i prezzi della piattaforma e i prezzi giornalieri per skill.",
|
||||
"generateTitle": "Genera fattura",
|
||||
"generateTitle": "Generi fattura",
|
||||
"generateDesc": "Calcolare ed emettere una fattura per organizzazione e mese.",
|
||||
"generatePageDesc": "Scegli organizzazione, periodo e lingua. L'anteprima mostra le righe calcolate; conferma emette la fattura e genera il PDF.",
|
||||
"generatePageDesc": "Scelga organizzazione, periodo e lingua. L'anteprima mostra le righe calcolate; confermi emette la fattura e generi il PDF.",
|
||||
"invoicesTitle": "Fatture",
|
||||
"invoicesDesc": "Sfoglia le fatture, segna come pagate, scarica i PDF.",
|
||||
"invoicesPageDesc": "Tutte le fatture emesse dalla piattaforma. Usa il filtro di stato per focalizzarti su voci aperte o scadute.",
|
||||
"invoicesDesc": "Sfogli le fatture, segni come pagate, scarichi i PDF.",
|
||||
"invoicesPageDesc": "Tutte le fatture emesse dalla piattaforma. Usi il filtro di stato per concentrarsi su voci aperte o scadute.",
|
||||
"balancesTitle": "Organizzazioni con saldo aperto",
|
||||
"orgIdCol": "ID org Zitadel",
|
||||
"openCountCol": "Aperte",
|
||||
@@ -601,22 +647,22 @@
|
||||
"setupFeeLabel": "Spese di attivazione tenant",
|
||||
"threemaMessageLabel": "Threema per messaggio",
|
||||
"vatRateLabel": "Aliquota IVA (CH/LI)",
|
||||
"save": "Salva",
|
||||
"save": "Salvi",
|
||||
"saving": "Salvataggio…",
|
||||
"savedOk": "Salvato",
|
||||
"skillPricingTitle": "Prezzi dei pacchetti",
|
||||
"skillPricingDesc": "Tariffa giornaliera e spese di attivazione una tantum per qualsiasi pacchetto — core, canale o skill. La tariffazione si applica a ogni tenant che attiva il pacchetto.",
|
||||
"skillPricingDesc": "Tariffa giornaliera e spese di attivazione una tantum per qualsiasi pacchetto — core, canale o skill. La tariffazione si applica a ogni tenant che attivi il pacchetto.",
|
||||
"skillCol": "Pacchetto",
|
||||
"dailyPriceCol": "Prezzo/giorno",
|
||||
"actionsCol": "",
|
||||
"remove": "Rimuovi",
|
||||
"noSkillsPriced": "Nessun pacchetto con prezzo.",
|
||||
"addSkillLabel": "Aggiungi pacchetto",
|
||||
"addSkillLabel": "Aggiunga pacchetto",
|
||||
"dailyPriceLabel": "Prezzo/giorno",
|
||||
"add": "Aggiungi",
|
||||
"add": "Aggiunga",
|
||||
"confirmDeleteSkillPrice": "Rimuovere la tariffazione per {skill}? I periodi già fatturati non sono influenzati.",
|
||||
"clickToEdit": "Clicca per modificare",
|
||||
"generateFormTitle": "Genera fattura",
|
||||
"clickToEdit": "Clicchi per modificare",
|
||||
"generateFormTitle": "Generi fattura",
|
||||
"noOrgsToGenerate": "Nessuna organizzazione con tenant trovata.",
|
||||
"orgLabel": "Organizzazione",
|
||||
"noBillingAddrTag": "nessun indirizzo di fatturazione",
|
||||
@@ -627,9 +673,9 @@
|
||||
"localeLabel": "Lingua PDF",
|
||||
"localeAuto": "Auto",
|
||||
"previewBtn": "Anteprima",
|
||||
"commitBtn": "Conferma & emetti",
|
||||
"commitBtn": "Confermi & emetti",
|
||||
"computing": "Calcolo…",
|
||||
"confirmGenerate": "Emettere questa fattura? L'operazione assegna un numero di fattura e genera il PDF.",
|
||||
"confirmGenerate": "Emettere questa fattura? L'operazione assegna un numero di fattura e generi il PDF.",
|
||||
"previewTitle": "Anteprima bozza",
|
||||
"warningsTitle": "Avvisi",
|
||||
"noLinesGenerated": "Nessuna riga fatturabile per questo periodo.",
|
||||
@@ -660,12 +706,12 @@
|
||||
"status_uncollectible": "Inesigibile",
|
||||
"dueOnLabel": "Scadenza",
|
||||
"totalLabel": "Totale",
|
||||
"downloadPdfBtn": "Scarica PDF",
|
||||
"markPaidBtn": "Segna come pagata",
|
||||
"downloadPdfBtn": "Scarichi PDF",
|
||||
"markPaidBtn": "Segni come pagata",
|
||||
"paidNotePlaceholder": "Nota opzionale (es. riferimento bancario, data di pagamento)",
|
||||
"confirm": "Conferma",
|
||||
"cancel": "Annulla",
|
||||
"deleteBtn": "Elimina",
|
||||
"confirm": "Confermi",
|
||||
"cancel": "Annulli",
|
||||
"deleteBtn": "Elimini",
|
||||
"deleting": "Eliminazione…",
|
||||
"deleteHint": "Eliminazione definitiva (strumento di test). Il numero rimane consumato.",
|
||||
"confirmDeleteInvoice": "Eliminare la fattura {num}? Eliminazione definitiva — il numero rimane consumato.",
|
||||
@@ -676,10 +722,10 @@
|
||||
"skillSetupFeeLabel": "Spese di attivazione",
|
||||
"status_partially_refunded": "Rimborsata parzialmente",
|
||||
"status_fully_refunded": "Rimborsata integralmente",
|
||||
"voidBtn": "Annulla",
|
||||
"voidBtn": "Annulli",
|
||||
"voidReasonPlaceholder": "Motivo dell'annullamento (stampato sulla nota di credito)",
|
||||
"voidReasonRequired": "Indicare un motivo per l'annullamento.",
|
||||
"confirmVoid": "Conferma annullamento",
|
||||
"confirmVoid": "Confermi annullamento",
|
||||
"voidedOnLabel": "Annullata",
|
||||
"refundBtn": "Rimborsa",
|
||||
"refundReasonPlaceholder": "Motivo del rimborso (stampato sulla nota di credito)",
|
||||
@@ -687,7 +733,7 @@
|
||||
"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",
|
||||
"confirmRefund": "Confermi rimborso",
|
||||
"refundedTotalLabel": "Rimborsato",
|
||||
"refundedRemainingLabel": "Residuo rimborsabile",
|
||||
"creditNotesPanelTitle": "Note di credito",
|
||||
@@ -702,10 +748,86 @@
|
||||
"creditNoteNoPdf": "—",
|
||||
"refundAmountLabel": "Importo",
|
||||
"refundReasonLabel": "Motivo",
|
||||
"refundAmountInclVatHint": "IVA inclusa"
|
||||
"refundAmountInclVatHint": "IVA inclusa",
|
||||
"newInvoiceBtn": "Nuova fattura",
|
||||
"draftsLink": "Bozze",
|
||||
"backToDrafts": "Torni alle bozze",
|
||||
"newInvoicePageTitle": "Nuova fattura",
|
||||
"newInvoicePageSubtitle": "Scelga il cliente da fatturare. Aggiungerai le righe nel passaggio successivo.",
|
||||
"newInvoiceOrgLabel": "Cliente",
|
||||
"newInvoiceOrgPlaceholder": "— selezioni cliente —",
|
||||
"newInvoiceOrgNoBilling": "nessun indirizzo di fatturazione",
|
||||
"newInvoiceOrgBillingMissing": "Questo cliente non ha un indirizzo di fatturazione registrato. Chieda al cliente di completare l'onboarding o imposti i dati dal pannello admin prima di emettere.",
|
||||
"newInvoiceLocaleLabel": "Lingua del documento",
|
||||
"newInvoiceOrgRequired": "Selezionare un cliente.",
|
||||
"newInvoiceContinueBtn": "Continui",
|
||||
"creating": "Creazione…",
|
||||
"draftsPageTitle": "Bozze di fatture",
|
||||
"draftsPageSubtitle": "Fatture personalizzate in corso. Riprenda la modifichi o scarta.",
|
||||
"draftsEmpty": "Ancora nessuna bozza. Inizi una nuova fattura.",
|
||||
"draftOrgCol": "Cliente",
|
||||
"draftIssueDateCol": "Data emissione",
|
||||
"draftLinesCol": "Righe",
|
||||
"draftSubtotalCol": "Subtotale (stima)",
|
||||
"draftUpdatedCol": "Modificato",
|
||||
"draftActionsCol": "Azioni",
|
||||
"draftDeleteConfirm": "Scartare questa bozza? Operazione irreversibile.",
|
||||
"editBtn": "Modifichi",
|
||||
"editorPageTitle": "Modifichi 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": "Aggiunga riga",
|
||||
"editorAddDiscount": "Aggiunga sconto",
|
||||
"editorAddDiscountHint": "Aggiunge una riga con prezzo unitario negativo. Modifichi 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": "Salvi bozza",
|
||||
"editorSavedBtn": "Salvato",
|
||||
"editorPreviewBtn": "Anteprima PDF",
|
||||
"editorIssueBtn": "Emetti fattura",
|
||||
"editorDeleteBtn": "Scarta bozza",
|
||||
"editorIssueConfirm": "Emettere questa fattura ora? Verrà assegnato un numero di fattura, il PDF sarà inviato al cliente e questa bozza verrà rimossa.",
|
||||
"editorDeleteConfirm": "Scartare questa bozza? Operazione irreversibile.",
|
||||
"previewing": "Apertura…",
|
||||
"issuing": "Emissione…",
|
||||
"orgsTitle": "Fatturazione cliente",
|
||||
"orgsDesc": "Modalità di pagamento + pagamento auto. per cliente",
|
||||
"orgsPageTitle": "Modalità di fatturazione clienti",
|
||||
"orgsPageSubtitle": "Override della modalità di pagamento per singoli clienti. Il pagamento tramite bonifico sostituisce l'addebito automatico su carta; mettere in pausa il pagamento automatico mantiene la carta salvata ma interrompe i tentativi di addebito (utile in caso di contestazioni).",
|
||||
"orgsEmpty": "Ancora nessun cliente.",
|
||||
"orgsColCustomer": "Cliente",
|
||||
"orgsColCard": "Carta salvata",
|
||||
"orgsColPayByInvoice": "Pagamento tramite bonifico",
|
||||
"orgsColAutoCharge": "Pagamento automatico",
|
||||
"orgsNoSavedCard": "nessuna",
|
||||
"orgsPayByInvoiceOn": "attivo",
|
||||
"orgsPayByInvoiceOff": "disattivo",
|
||||
"orgsAutoChargeOn": "attivo",
|
||||
"orgsAutoChargeOff": "disattivo"
|
||||
},
|
||||
"skillCostDialog": {
|
||||
"title": "Conferma costi di attivazione",
|
||||
"title": "Confermi costi di attivazione",
|
||||
"intro": "L'attivazione di {skill} comporterà i seguenti costi:",
|
||||
"setupFeeLabel": "Spese di attivazione",
|
||||
"setupFeeNote": "Una tantum, addebitate solo alla prima attivazione",
|
||||
@@ -713,14 +835,14 @@
|
||||
"monthlyPriceNote": "CHF {daily}/giorno attivo; mesi parziali calcolati al giorno",
|
||||
"monthUnit": "mese",
|
||||
"disclaimer": "Questi costi appariranno sulla prossima fattura mensile. Confermando accetti di sostenerli.",
|
||||
"cancel": "Annulla",
|
||||
"confirm": "Conferma & attiva",
|
||||
"cancel": "Annulli",
|
||||
"confirm": "Confermi & attivi",
|
||||
"confirming": "Attivazione…"
|
||||
},
|
||||
"adminSkills": {
|
||||
"title": "Coda di attivazione",
|
||||
"subtitle": "Richieste dei clienti per attivare pacchetti che richiedono configurazione manuale lato piattaforma. Approva quando la configurazione è pronta; rifiuta con motivazione se l'attivazione non è possibile.",
|
||||
"backToAdmin": "Torna ad amministrazione",
|
||||
"backToAdmin": "Torni ad amministrazione",
|
||||
"emptyQueue": "Nessuna richiesta di attivazione skill in attesa.",
|
||||
"requestedAtCol": "Richiesta",
|
||||
"skillCol": "Skill",
|
||||
@@ -729,9 +851,9 @@
|
||||
"actionsCol": "",
|
||||
"approveBtn": "Approva",
|
||||
"rejectBtn": "Rifiuta",
|
||||
"confirmRejectBtn": "Conferma rifiuto",
|
||||
"confirmRejectBtn": "Confermi rifiuto",
|
||||
"working": "In corso…",
|
||||
"cancel": "Annulla",
|
||||
"cancel": "Annulli",
|
||||
"reasonLabel": "Motivo (mostrato al cliente)",
|
||||
"reasonPlaceholder": "Spiega perché l'attivazione non può procedere — es. dati cliente mancanti, hardware non disponibile, ecc.",
|
||||
"reasonRequired": "Un motivo è necessario per rifiutare."
|
||||
@@ -739,16 +861,16 @@
|
||||
"customerBilling": {
|
||||
"title": "Fatturazione",
|
||||
"subtitle": "Periodo corrente e cronologia delle fatture. Le fatture emesse sono disponibili come download PDF.",
|
||||
"backToBilling": "Torna alla fatturazione",
|
||||
"backToBilling": "Torni alla fatturazione",
|
||||
"currentPeriodHeading": "Periodo corrente",
|
||||
"historyHeading": "Cronologia fatture",
|
||||
"computing": "Calcolo del totale del periodo corrente…",
|
||||
"currentPeriodError": "Impossibile caricare il totale del periodo corrente. Riprova più tardi.",
|
||||
"noBillingConfig": "I dati di fatturazione non sono ancora configurati. Una volta registrato l'indirizzo di fatturazione della tua organizzazione, il totale corrente apparirà qui.",
|
||||
"noBillingConfig": "I dati di fatturazione non sono ancora configurati. Una volta registrato l'indirizzo di fatturazione della sua organizzazione, il totale corrente apparirà qui.",
|
||||
"accruedSoFar": "Accumulato questo mese",
|
||||
"estimatedTotal": "Totale stimato",
|
||||
"currentInvoiceIssued": "Mese corrente già fatturato",
|
||||
"refresh": "aggiorna",
|
||||
"refresh": "aggiorni",
|
||||
"breakdownToggle": "Mostra dettaglio ({count} voci)",
|
||||
"draftNote": "Stima in tempo reale. La fattura finale può variare leggermente per arrotondamenti di fine mese, dati di utilizzo in ritardo o aggiustamenti manuali.",
|
||||
"emptyHistory": "Nessuna fattura emessa ancora. Dopo la chiusura del primo mese, appariranno qui.",
|
||||
@@ -768,7 +890,7 @@
|
||||
"subtotalLabel": "Subtotale",
|
||||
"vatLabel": "IVA ({rate}%)",
|
||||
"totalLabel": "Totale",
|
||||
"downloadPdf": "Scarica PDF",
|
||||
"downloadPdf": "Scarichi PDF",
|
||||
"status": {
|
||||
"draft": "Bozza",
|
||||
"open": "Aperta",
|
||||
@@ -798,7 +920,7 @@
|
||||
},
|
||||
"adminCron": {
|
||||
"title": "Automazione fatturazione",
|
||||
"subtitle": "Emissione mensile e invio quotidiano dei solleciti. Entrambi vengono eseguiti automaticamente; usa i pulsanti sotto per avviare un'esecuzione su richiesta.",
|
||||
"subtitle": "Emissione mensile e invio quotidiano dei solleciti. Entrambi vengono eseguiti automaticamente; usi i pulsanti sotto per avviare un'esecuzione su richiesta.",
|
||||
"monthlyIssue": "Emissione mensile",
|
||||
"reminders": "Solleciti",
|
||||
"scheduleIssueLabel": "Pianificazione",
|
||||
@@ -826,21 +948,29 @@
|
||||
"reminders": "Solleciti"
|
||||
},
|
||||
"failureBannerTitle": "Fallimenti recenti rilevati",
|
||||
"failureBannerBody": "{count} esecuzione/i recente/i hanno segnalato almeno un fallimento. Controlla la tabella sotto — le righe interessate sono in rosso."
|
||||
"failureBannerBody": "{count} esecuzione/i recente/i hanno segnalato almeno un fallimento. Controlli 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.",
|
||||
"subtitle": "Il suo nome visualizzato come appare nel portale, nelle richieste tenant e nei ticket di supporto.",
|
||||
"subtitlePersonal": "Il suo nome visualizzato come appare nel portale. Per modificare il suo 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",
|
||||
"emailReadOnlyHint": "L'e-mail non può essere modificata qui. Usi le impostazioni self-service del suo provider di identità.",
|
||||
"personalAccountHint": "Questo è un account personale. Modificare il suo nome qui NON cambia come appare in fattura — modificalo separatamente in Dati di fatturazione.",
|
||||
"companyAccountHint": "È connesso come membro di {orgName}.",
|
||||
"saveChanges": "Salvi modifiche",
|
||||
"saving": "Salvataggio…",
|
||||
"saved": "Salvato.",
|
||||
"missingRequired": "Nome e cognome sono obbligatori."
|
||||
},
|
||||
"errors": {
|
||||
"title": "Si è verificato un errore",
|
||||
"description": "Si è verificato un errore durante il caricamento di questa pagina. Riprova.",
|
||||
"retry": "Riprova",
|
||||
"backToDashboard": "Torna alla dashboard",
|
||||
"notFoundTitle": "Pagina non trovata",
|
||||
"notFoundDescription": "La pagina che stai cercando non esiste o è stata spostata."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -253,6 +253,13 @@ export interface OrgBilling {
|
||||
|
||||
export type TenantRequestStatus =
|
||||
| "pending" // Submitted, awaiting admin approval
|
||||
// Phase 9b: setup-fee Checkout pending. The row exists, has no
|
||||
// tenant_name yet (set when payment succeeds), and is invisible
|
||||
// to admin (the queue filters to status='pending'). On webhook
|
||||
// success the row flips to 'pending'. On abandonment the row
|
||||
// stays here harmlessly — each retry creates a fresh row with
|
||||
// a different derived tenant_name.
|
||||
| "pending_payment"
|
||||
| "approved" // Admin approved, provisioning will start
|
||||
| "provisioning" // PiecedTenant CR created, operator reconciling
|
||||
| "active" // Tenant running
|
||||
@@ -283,6 +290,24 @@ export interface TenantRequest {
|
||||
status: TenantRequestStatus;
|
||||
adminNotes?: string;
|
||||
tenantName?: string;
|
||||
/**
|
||||
* Phase 9b: the paid setup-fee invoice linked to this request.
|
||||
* Set by the Stripe webhook when the order-time Checkout
|
||||
* completes successfully. Null on requests that pre-date Phase 9b
|
||||
* and on resume requests (which don't have a setup fee). Admin
|
||||
* rejection refunds this invoice via the existing refund flow.
|
||||
*/
|
||||
setupInvoiceId?: string | null;
|
||||
/**
|
||||
* Phase 9b: optional initial channel-user ids the customer entered
|
||||
* during onboarding for each enabled channel package (e.g.
|
||||
* { telegram: ["1234567"], threema: ["ABCD1234"] }). Empty/absent
|
||||
* on requests that pre-date the field. Applied on admin approval:
|
||||
* the values get seeded into PiecedTenantSpec.channelUsers, and
|
||||
* for Threema specifically, the relay's route table is updated so
|
||||
* inbound messages from those ids reach the newly-created tenant.
|
||||
*/
|
||||
channelUsers?: Record<string, string[]>;
|
||||
encryptedSecrets?: Buffer | null;
|
||||
/**
|
||||
* Slice 4: true for personal accounts. Drives CR-naming (`p-{suffix}`
|
||||
@@ -346,6 +371,14 @@ export interface OnboardingInput {
|
||||
*/
|
||||
billingAddress?: BillingAddress;
|
||||
billingNotes?: string;
|
||||
/**
|
||||
* Phase 9b: initial channel-user ids the customer entered during
|
||||
* onboarding, keyed by channel package id (e.g. { telegram:
|
||||
* ["1234567"], threema: ["ABCD1234"] }). Optional — customers
|
||||
* can also leave channels blank and add ids later from the
|
||||
* tenant's channel-users page.
|
||||
*/
|
||||
channelUsers?: Record<string, string[]>;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -530,6 +563,29 @@ export interface OrgBillingConfig {
|
||||
stripeCustomerId: string | null;
|
||||
autoInvoiceEnabled: boolean;
|
||||
autoRemindersEnabled: boolean;
|
||||
/**
|
||||
* Phase 9: saved-card info for off-session auto-charge.
|
||||
* Populated by the SetupIntent webhook when a customer completes
|
||||
* the "Set up auto-pay" flow. Only display fields are stored
|
||||
* locally — never the PAN. The Stripe PaymentMethod id
|
||||
* (`pm_xxx`) is the handle the platform uses to charge against
|
||||
* the card; the brand/last4/exp_month/exp_year fields are for
|
||||
* showing "Visa •••• 4242, expires 05/27" without an API call.
|
||||
*/
|
||||
stripeDefaultPaymentMethodId: string | null;
|
||||
stripePmBrand: string | null;
|
||||
stripePmLast4: string | null;
|
||||
stripePmExpMonth: number | null;
|
||||
stripePmExpYear: number | null;
|
||||
/**
|
||||
* Phase 9: off-session auto-charge gate. Default TRUE for new
|
||||
* customers (card is the default payment method). Admin can
|
||||
* flip this off to pause auto-charging for a specific customer
|
||||
* (e.g. during a dispute) without removing the saved card. With
|
||||
* no saved PaymentMethod set, the flag is irrelevant — there's
|
||||
* nothing to charge against.
|
||||
*/
|
||||
autoChargeEnabled: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
@@ -616,7 +672,11 @@ export type InvoiceLineKind =
|
||||
| "threema_messages"
|
||||
| "skill_usage"
|
||||
| "skill_setup"
|
||||
| "adjustment";
|
||||
| "adjustment"
|
||||
// Phase 8 — line kind for ad-hoc invoices. Rendered under a
|
||||
// "Services" / "Leistungen" header on the PDF. Negative
|
||||
// unitPriceChf is allowed (used for Rabatt rows).
|
||||
| "custom_line";
|
||||
|
||||
/**
|
||||
* Snapshot of the customer's billing details captured at invoice
|
||||
@@ -669,8 +729,19 @@ export interface Invoice {
|
||||
id: string;
|
||||
invoiceNumber: string;
|
||||
zitadelOrgId: string;
|
||||
periodStart: string; // ISO date (YYYY-MM-DD)
|
||||
periodEnd: string;
|
||||
/**
|
||||
* Phase 8: invoice provenance. 'auto' = generated by the monthly
|
||||
* cron from tenant usage; 'custom' = created via the admin
|
||||
* "New invoice" flow. Custom invoices have nullable period_start
|
||||
* / period_end and skip the per-org-per-month uniqueness guard.
|
||||
* Defaults to 'auto' for all pre-Phase-8 rows (backfilled by the
|
||||
* column DEFAULT).
|
||||
*/
|
||||
source: "auto" | "custom";
|
||||
// Billing period — null on custom invoices that aren't tied to a
|
||||
// billing period.
|
||||
periodStart: string | null;
|
||||
periodEnd: string | null;
|
||||
issuedAt: string;
|
||||
dueAt: string;
|
||||
subtotalChf: number;
|
||||
@@ -715,8 +786,21 @@ export interface InvoiceDetail {
|
||||
*/
|
||||
export interface InvoiceDraft {
|
||||
zitadelOrgId: string;
|
||||
periodStart: string;
|
||||
periodEnd: string;
|
||||
/**
|
||||
* Phase 8: optional for custom invoices. The auto cron always
|
||||
* sets both period_start and period_end; the custom flow may
|
||||
* leave them null.
|
||||
*/
|
||||
periodStart: string | null;
|
||||
periodEnd: string | null;
|
||||
/**
|
||||
* Phase 8: optional override of the issue date. When omitted,
|
||||
* the DB uses now() at insertion time. The custom flow uses
|
||||
* this to let admin backdate or future-date invoices.
|
||||
*/
|
||||
issuedAt?: string;
|
||||
/** Phase 8: 'auto' (cron) or 'custom' (admin form). Defaults to 'auto'. */
|
||||
source?: "auto" | "custom";
|
||||
dueAt: string;
|
||||
locale: string;
|
||||
paymentMethod: InvoicePaymentMethod;
|
||||
@@ -734,6 +818,65 @@ export interface InvoiceDraft {
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Phase 8 — custom invoice drafts
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* The shape persisted in the invoice_drafts.payload JSONB column.
|
||||
* This is the in-progress form state the admin is composing — not
|
||||
* yet an invoice. On "Issue" it's converted into a real Invoice row
|
||||
* via billing.issueCustomInvoiceDraft and the draft row deleted.
|
||||
*
|
||||
* Kept separate from InvoiceDraft (which is the compute pipeline's
|
||||
* type for in-flight monthly bills) so the two domains don't
|
||||
* accidentally drift.
|
||||
*/
|
||||
export interface CustomInvoiceDraftPayload {
|
||||
/** ISO date (YYYY-MM-DD). Defaults to today on creation. */
|
||||
issueDate: string;
|
||||
/** ISO date (YYYY-MM-DD). Defaults to issueDate + 30 days. */
|
||||
dueDate: string;
|
||||
/** Locale for the PDF and email; defaults to org's default. */
|
||||
locale: "de" | "en" | "fr" | "it";
|
||||
paymentMethod: InvoicePaymentMethod;
|
||||
/**
|
||||
* Optional notes only the admin sees in the portal (not on the PDF).
|
||||
*/
|
||||
adminNotes?: string;
|
||||
lines: CustomInvoiceDraftLine[];
|
||||
}
|
||||
|
||||
export interface CustomInvoiceDraftLine {
|
||||
/** Free-text description, shown on the PDF as the line label. */
|
||||
description: string;
|
||||
/**
|
||||
* Decimal quantity. Most cases are integer (1, 2, 10 hours) but
|
||||
* we allow decimal for fractional hours (0.5) or ratios.
|
||||
*/
|
||||
quantity: number;
|
||||
/**
|
||||
* CHF per unit. Negative values are allowed for discount /
|
||||
* Rabatt rows — the PDF shows them as negative amounts and the
|
||||
* subtotal is the algebraic sum of all line amounts.
|
||||
*/
|
||||
unitPriceChf: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The DB row in invoice_drafts. The admin can save a draft, come
|
||||
* back later, and issue it (or delete it) at any time. Drafts have
|
||||
* no invoice number, no PDF, and are never visible to the customer.
|
||||
*/
|
||||
export interface InvoiceDraftRecord {
|
||||
id: string;
|
||||
zitadelOrgId: string;
|
||||
createdBy: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
payload: CustomInvoiceDraftPayload;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Skill activation requests — manual provisioning queue
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user