Phase2: Invoicecomputation/AdminpricingUI/Ainvoicemgnt
All checks were successful
Build and Push / build (push) Successful in 1m34s

This commit is contained in:
2026-05-24 16:38:41 +02:00
parent 11d7dbb06e
commit cd15b391ac
11 changed files with 369 additions and 52 deletions

View File

@@ -24,6 +24,9 @@ import { safeError } from "@/lib/errors";
const upsertSchema = z.object({ const upsertSchema = z.object({
skillId: z.string().min(1).max(100), skillId: z.string().min(1).max(100),
dailyPriceChf: z.number().min(0).max(1_000_000), dailyPriceChf: z.number().min(0).max(1_000_000),
// Optional with default 0 so existing API callers keep working.
// Setup fee fires once per (tenant, skill); see billing.ts.
setupFeeChf: z.number().min(0).max(1_000_000).optional().default(0),
}); });
export async function GET() { export async function GET() {
@@ -63,7 +66,8 @@ export async function PUT(request: Request) {
try { try {
const row = await setSkillPricing( const row = await setSkillPricing(
parsed.data.skillId, parsed.data.skillId,
parsed.data.dailyPriceChf parsed.data.dailyPriceChf,
parsed.data.setupFeeChf
); );
return NextResponse.json(row); return NextResponse.json(row);
} catch (e) { } catch (e) {

View File

@@ -85,20 +85,27 @@ export function PricingEditor({
catalog.find((c) => c.category === "skill")?.id ?? "" catalog.find((c) => c.category === "skill")?.id ?? ""
); );
const [newSkillPrice, setNewSkillPrice] = useState("0.10"); const [newSkillPrice, setNewSkillPrice] = useState("0.10");
const [newSkillSetupFee, setNewSkillSetupFee] = useState("0");
const [addingSkill, setAddingSkill] = useState(false); const [addingSkill, setAddingSkill] = useState(false);
const [skillError, setSkillError] = useState(""); const [skillError, setSkillError] = useState("");
// Core upsert — used by both the "add new skill" form and the inline // Core upsert — used by both the "add new skill" form and the inline
// editor on existing rows. Kept event-free so callers can invoke it // editors on existing rows. Kept event-free so callers can invoke it
// without synthesizing a fake form event. // without synthesizing a fake form event. Both `dailyPriceChf` and
const upsertSkillPrice = async (skillId: string, dailyPriceChf: number) => { // `setupFeeChf` are written together because the API does a full
// upsert; partial updates would silently zero the other field.
const upsertSkillPrice = async (
skillId: string,
dailyPriceChf: number,
setupFeeChf: number
) => {
setAddingSkill(true); setAddingSkill(true);
setSkillError(""); setSkillError("");
try { try {
const res = await fetch("/api/admin/billing/skill-pricing", { const res = await fetch("/api/admin/billing/skill-pricing", {
method: "PUT", method: "PUT",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ skillId, dailyPriceChf }), body: JSON.stringify({ skillId, dailyPriceChf, setupFeeChf }),
}); });
if (!res.ok) { if (!res.ok) {
const j = await res.json().catch(() => ({})); const j = await res.json().catch(() => ({}));
@@ -115,7 +122,11 @@ export function PricingEditor({
const onAddNewSkill = (e: React.FormEvent) => { const onAddNewSkill = (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (!newSkillId) return; if (!newSkillId) return;
void upsertSkillPrice(newSkillId, Number(newSkillPrice)); void upsertSkillPrice(
newSkillId,
Number(newSkillPrice),
Number(newSkillSetupFee)
);
}; };
const deleteSkill = async (skillId: string) => { const deleteSkill = async (skillId: string) => {
@@ -234,6 +245,7 @@ export function PricingEditor({
<tr> <tr>
<th className="pb-2">{t("skillCol")}</th> <th className="pb-2">{t("skillCol")}</th>
<th className="pb-2 text-right">{t("dailyPriceCol")}</th> <th className="pb-2 text-right">{t("dailyPriceCol")}</th>
<th className="pb-2 text-right">{t("setupFeeCol")}</th>
<th className="pb-2 text-right">{t("actionsCol")}</th> <th className="pb-2 text-right">{t("actionsCol")}</th>
</tr> </tr>
</thead> </thead>
@@ -252,10 +264,26 @@ export function PricingEditor({
)} )}
</td> </td>
<td className="py-2 text-right"> <td className="py-2 text-right">
{/* Inline edits write daily + setup together (full
upsert on the API side). The other field is
held constant from the snapshot here. */}
<InlinePriceEditor <InlinePriceEditor
skillId={sp.skillId} skillId={sp.skillId}
initialPrice={sp.dailyPriceChf} initialPrice={sp.dailyPriceChf}
onSave={(price) => upsertSkillPrice(sp.skillId, price)} decimals={4}
onSave={(price) =>
upsertSkillPrice(sp.skillId, price, sp.setupFeeChf)
}
/>
</td>
<td className="py-2 text-right">
<InlinePriceEditor
skillId={`${sp.skillId}-setup`}
initialPrice={sp.setupFeeChf}
decimals={2}
onSave={(fee) =>
upsertSkillPrice(sp.skillId, sp.dailyPriceChf, fee)
}
/> />
</td> </td>
<td className="py-2 text-right"> <td className="py-2 text-right">
@@ -292,9 +320,9 @@ export function PricingEditor({
))} ))}
</select> </select>
</label> </label>
<label className="w-32"> <label className="w-28">
<span className="text-xs text-text-muted"> <span className="text-xs text-text-muted">
{t("dailyPriceLabel")} (CHF) {t("dailyPriceLabel")}
</span> </span>
<input <input
type="number" type="number"
@@ -305,6 +333,19 @@ export function PricingEditor({
className="mt-1 w-full px-3 py-2 rounded-md border border-border bg-surface-2 text-sm" className="mt-1 w-full px-3 py-2 rounded-md border border-border bg-surface-2 text-sm"
/> />
</label> </label>
<label className="w-28">
<span className="text-xs text-text-muted">
{t("skillSetupFeeLabel")}
</span>
<input
type="number"
step="0.01"
min="0"
value={newSkillSetupFee}
onChange={(e) => setNewSkillSetupFee(e.target.value)}
className="mt-1 w-full px-3 py-2 rounded-md border border-border bg-surface-2 text-sm"
/>
</label>
<button <button
type="submit" type="submit"
disabled={addingSkill || !newSkillId} disabled={addingSkill || !newSkillId}
@@ -322,23 +363,30 @@ export function PricingEditor({
} }
/** /**
* Tiny inline editor for a single skill's daily price. Mounts in * Tiny inline editor for a single numeric price/fee. Mounts in
* "view" mode showing the current value as a clickable badge; * "view" mode showing the current value as a clickable badge;
* clicking turns it into an input + save/cancel buttons. * clicking turns it into an input + save/cancel buttons.
*
* `decimals` controls the display precision in view mode AND the
* step granularity of the input (daily prices use 4dp, setup fees
* use 2dp).
*/ */
function InlinePriceEditor({ function InlinePriceEditor({
skillId, skillId,
initialPrice, initialPrice,
decimals = 2,
onSave, onSave,
}: { }: {
skillId: string; skillId: string;
initialPrice: number; initialPrice: number;
decimals?: number;
onSave: (price: number) => Promise<void> | void; onSave: (price: number) => Promise<void> | void;
}) { }) {
const t = useTranslations("adminBilling"); const t = useTranslations("adminBilling");
const [editing, setEditing] = useState(false); const [editing, setEditing] = useState(false);
const [value, setValue] = useState(String(initialPrice)); const [value, setValue] = useState(String(initialPrice));
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const step = decimals === 4 ? "0.0001" : "0.01";
if (!editing) { if (!editing) {
return ( return (
@@ -347,7 +395,7 @@ function InlinePriceEditor({
className="text-sm font-mono hover:underline" className="text-sm font-mono hover:underline"
title={t("clickToEdit")} title={t("clickToEdit")}
> >
CHF {initialPrice.toFixed(2)} CHF {initialPrice.toFixed(decimals)}
</button> </button>
); );
} }
@@ -355,7 +403,7 @@ function InlinePriceEditor({
<span className="inline-flex items-center gap-1"> <span className="inline-flex items-center gap-1">
<input <input
type="number" type="number"
step="0.01" step={step}
min="0" min="0"
value={value} value={value}
onChange={(e) => setValue(e.target.value)} onChange={(e) => setValue(e.target.value)}

157
src/lib/billing-i18n.ts Normal file
View File

@@ -0,0 +1,157 @@
/**
* Shared billing localization. Used by:
* - billing.ts (compute path) — pre-renders the localized
* line description and stores it on the invoice line at issue
* time. Descriptions are then frozen in the customer's locale.
* - billing-pdf.tsx (render path) — can fall back to this if a
* stored description is missing (e.g. legacy invoice from the
* pre-i18n era) or if the PDF is re-rendered in a different
* locale (Phase 7).
*
* Locale set matches the portal's next-intl locales: de, en, fr, it.
* Unknown locales fall back to German (Swiss B2B default).
*/
import type { InvoiceLineKind } from "@/types";
export type BillingLocale = "de" | "en" | "fr" | "it";
function normaliseLocale(locale: string): BillingLocale {
if (locale === "en" || locale === "fr" || locale === "it" || locale === "de") {
return locale;
}
return "de";
}
/**
* Localized "N day(s)" — covers the only plural case in billing
* line descriptions. Other plurals (months, requests, messages)
* either don't change form in the supported languages or are
* always >1 in practice.
*/
function days(n: number, locale: BillingLocale): string {
const labels = {
de: { one: "Tag", many: "Tage" },
en: { one: "day", many: "days" },
fr: { one: "jour", many: "jours" },
it: { one: "giorno", many: "giorni" },
} as const;
const label = labels[locale];
return `${n} ${n === 1 ? label.one : label.many}`;
}
/** Subset of InvoiceLine needed for description formatting. */
export interface LineForDescription {
kind: InvoiceLineKind;
tenantName: string | null;
metadata: Record<string, unknown> | null;
}
/**
* Build the localized line description from a line's kind +
* metadata. Pure function — no DB/IO. Output mirrors what the
* PDF and admin preview show in the description column.
*
* Metadata expectations per kind (must match what billing.ts
* stores when emitting the line):
* tenant_monthly: { billable_days, days_in_month }
* tenant_setup: {} (uses tenantName only)
* ai_usage: { requests }
* threema_messages: { in_count, out_count }
* skill_usage: { skill_id, billable_days }
* skill_setup: { skill_id }
* adjustment: { reason? }
*
* Missing fields fall back to "?" so a malformed line still
* renders something readable rather than crashing the PDF.
*/
export function formatLineDescription(
line: LineForDescription,
locale: string
): string {
const L = normaliseLocale(locale);
const m = line.metadata ?? {};
const tenant = line.tenantName ?? "—";
// Helper to fetch a metadata field with a safe fallback.
const f = (key: string): string | number => {
const v = (m as Record<string, unknown>)[key];
if (v === undefined || v === null) return "?";
return v as string | number;
};
switch (line.kind) {
case "tenant_monthly": {
const bd = f("billable_days");
const dim = f("days_in_month");
return {
de: `Monatliche Grundgebühr für ${tenant} (${bd}/${dim} Tage)`,
en: `Monthly fee for ${tenant} (${bd}/${dim} days)`,
fr: `Forfait mensuel pour ${tenant} (${bd}/${dim} jours)`,
it: `Canone mensile per ${tenant} (${bd}/${dim} giorni)`,
}[L];
}
case "tenant_setup":
return {
de: `Einrichtungsgebühr für ${tenant}`,
en: `Setup fee for ${tenant}`,
fr: `Frais de configuration pour ${tenant}`,
it: `Spese di attivazione per ${tenant}`,
}[L];
case "ai_usage": {
const r = f("requests");
return {
de: `KI-Inferenz-Nutzung (${r} Anfragen)`,
en: `AI inference usage (${r} requests)`,
fr: `Utilisation IA (${r} requêtes)`,
it: `Utilizzo IA (${r} richieste)`,
}[L];
}
case "threema_messages": {
const inC = f("in_count");
const outC = f("out_count");
return {
de: `Threema-Nachrichten (${inC} eingehend + ${outC} ausgehend)`,
en: `Threema messages (${inC} in + ${outC} out)`,
fr: `Messages Threema (${inC} entrants + ${outC} sortants)`,
it: `Messaggi Threema (${inC} in entrata + ${outC} in uscita)`,
}[L];
}
case "skill_usage": {
const skill = f("skill_id");
const bdRaw = (m as Record<string, unknown>)["billable_days"];
const bd = typeof bdRaw === "number" ? bdRaw : 0;
return {
de: `Skill: ${skill} (${days(bd, "de")})`,
en: `Skill: ${skill} (${days(bd, "en")})`,
fr: `Skill: ${skill} (${days(bd, "fr")})`,
it: `Skill: ${skill} (${days(bd, "it")})`,
}[L];
}
case "skill_setup": {
const skill = f("skill_id");
return {
de: `Einrichtungsgebühr Skill: ${skill}`,
en: `Setup fee skill: ${skill}`,
fr: `Frais de configuration skill: ${skill}`,
it: `Spese di attivazione skill: ${skill}`,
}[L];
}
case "adjustment": {
const reasonRaw = (m as Record<string, unknown>)["reason"];
const reason = typeof reasonRaw === "string" ? reasonRaw : null;
const base = {
de: "Anpassung",
en: "Adjustment",
fr: "Ajustement",
it: "Rettifica",
}[L];
return reason ? `${base}: ${reason}` : base;
}
}
}

View File

@@ -125,6 +125,7 @@ const MESSAGES: Record<string, PdfStrings> = {
ai_usage: "KI-Nutzung", ai_usage: "KI-Nutzung",
threema_messages: "Threema-Nachrichten", threema_messages: "Threema-Nachrichten",
skill_usage: "Skill-Nutzung", skill_usage: "Skill-Nutzung",
skill_setup: "Einrichtungsgebühr Skill",
adjustment: "Anpassung", adjustment: "Anpassung",
}, },
reverseCharge: reverseCharge:
@@ -156,6 +157,7 @@ const MESSAGES: Record<string, PdfStrings> = {
ai_usage: "AI usage", ai_usage: "AI usage",
threema_messages: "Threema messages", threema_messages: "Threema messages",
skill_usage: "Skill usage", skill_usage: "Skill usage",
skill_setup: "Skill setup fee",
adjustment: "Adjustment", adjustment: "Adjustment",
}, },
reverseCharge: reverseCharge:
@@ -187,6 +189,7 @@ const MESSAGES: Record<string, PdfStrings> = {
ai_usage: "Utilisation IA", ai_usage: "Utilisation IA",
threema_messages: "Messages Threema", threema_messages: "Messages Threema",
skill_usage: "Utilisation Skill", skill_usage: "Utilisation Skill",
skill_setup: "Frais de configuration skill",
adjustment: "Ajustement", adjustment: "Ajustement",
}, },
reverseCharge: reverseCharge:
@@ -218,6 +221,7 @@ const MESSAGES: Record<string, PdfStrings> = {
ai_usage: "Utilizzo IA", ai_usage: "Utilizzo IA",
threema_messages: "Messaggi Threema", threema_messages: "Messaggi Threema",
skill_usage: "Utilizzo Skill", skill_usage: "Utilizzo Skill",
skill_setup: "Spese di attivazione skill",
adjustment: "Rettifica", adjustment: "Rettifica",
}, },
reverseCharge: reverseCharge:

View File

@@ -54,12 +54,14 @@ import {
listSkillPricing, listSkillPricing,
listSuspensionEventsForTenant, listSuspensionEventsForTenant,
tenantHasSetupFeeBilled, tenantHasSetupFeeBilled,
tenantSkillHasBeenBilled,
updateInvoicePdf, updateInvoicePdf,
} from "./db"; } from "./db";
import { listTenants } from "./k8s"; import { listTenants } from "./k8s";
import { getTeamSpendLogsV2 } from "./litellm"; import { getTeamSpendLogsV2 } from "./litellm";
import { getUsage as getThreemaUsage } from "./threema-relay"; import { getUsage as getThreemaUsage } from "./threema-relay";
import { renderInvoicePdf } from "./billing-pdf"; import { renderInvoicePdf } from "./billing-pdf";
import { formatLineDescription } from "./billing-i18n";
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Period helpers // Period helpers
@@ -370,6 +372,7 @@ async function buildTenantLines(opts: {
daysInMonth: number; daysInMonth: number;
platformPricing: PlatformPricing; platformPricing: PlatformPricing;
skillPricing: SkillPricing[]; skillPricing: SkillPricing[];
locale: string;
warnings: string[]; warnings: string[];
displayOrderOffset: number; displayOrderOffset: number;
}): Promise<Omit<InvoiceLine, "id" | "invoiceId">[]> { }): Promise<Omit<InvoiceLine, "id" | "invoiceId">[]> {
@@ -380,6 +383,7 @@ async function buildTenantLines(opts: {
daysInMonth, daysInMonth,
platformPricing, platformPricing,
skillPricing, skillPricing,
locale,
warnings, warnings,
} = opts; } = opts;
let displayOrder = opts.displayOrderOffset; let displayOrder = opts.displayOrderOffset;
@@ -428,19 +432,23 @@ async function buildTenantLines(opts: {
if (billableDays > 0) { if (billableDays > 0) {
const unit = platformPricing.tenantMonthlyFeeChf / daysInMonth; const unit = platformPricing.tenantMonthlyFeeChf / daysInMonth;
const amount = round2(unit * billableDays); const amount = round2(unit * billableDays);
const metadata = {
billable_days: billableDays,
suspended_days: suspendedDays,
days_in_month: daysInMonth,
};
lines.push({ lines.push({
tenantName, tenantName,
kind: "tenant_monthly", kind: "tenant_monthly",
description: `Monthly fee for ${tenantName} (${billableDays}/${daysInMonth} days)`, description: formatLineDescription(
{ kind: "tenant_monthly", tenantName, metadata },
locale
),
quantity: billableDays, quantity: billableDays,
unitLabel: "days", unitLabel: "days",
unitPriceChf: round2(unit * 1e5) / 1e5, unitPriceChf: round2(unit * 1e5) / 1e5,
amountChf: amount, amountChf: amount,
metadata: { metadata,
billable_days: billableDays,
suspended_days: suspendedDays,
days_in_month: daysInMonth,
},
displayOrder: displayOrder++, displayOrder: displayOrder++,
}); });
} }
@@ -453,7 +461,10 @@ async function buildTenantLines(opts: {
lines.push({ lines.push({
tenantName, tenantName,
kind: "tenant_setup", kind: "tenant_setup",
description: `Setup fee for ${tenantName}`, description: formatLineDescription(
{ kind: "tenant_setup", tenantName, metadata: null },
locale
),
quantity: 1, quantity: 1,
unitLabel: null, unitLabel: null,
unitPriceChf: platformPricing.tenantSetupFeeChf, unitPriceChf: platformPricing.tenantSetupFeeChf,
@@ -480,19 +491,23 @@ async function buildTenantLines(opts: {
`Tenant ${tenantName} has no LiteLLM team yet — AI usage skipped.` `Tenant ${tenantName} has no LiteLLM team yet — AI usage skipped.`
); );
} else if (aiUsage.spendChf > 0) { } else if (aiUsage.spendChf > 0) {
const aiMetadata = {
litellm_key_alias: tenantName,
spend_chf: aiUsage.spendChf,
requests: aiUsage.requestCount,
};
lines.push({ lines.push({
tenantName, tenantName,
kind: "ai_usage", kind: "ai_usage",
description: `AI inference usage (${aiUsage.requestCount} requests)`, description: formatLineDescription(
{ kind: "ai_usage", tenantName, metadata: aiMetadata },
locale
),
quantity: 1, quantity: 1,
unitLabel: null, unitLabel: null,
unitPriceChf: aiUsage.spendChf, unitPriceChf: aiUsage.spendChf,
amountChf: aiUsage.spendChf, amountChf: aiUsage.spendChf,
metadata: { metadata: aiMetadata,
litellm_key_alias: tenantName,
spend_chf: aiUsage.spendChf,
requests: aiUsage.requestCount,
},
displayOrder: displayOrder++, displayOrder: displayOrder++,
}); });
} }
@@ -502,19 +517,23 @@ async function buildTenantLines(opts: {
const threema = await collectThreemaUsage(tenant, periodStart, periodEnd); const threema = await collectThreemaUsage(tenant, periodStart, periodEnd);
if (threema && (threema.inCount + threema.outCount) > 0) { if (threema && (threema.inCount + threema.outCount) > 0) {
const total = threema.inCount + threema.outCount; const total = threema.inCount + threema.outCount;
const threemaMetadata = {
in_count: threema.inCount,
out_count: threema.outCount,
total_count: total,
};
lines.push({ lines.push({
tenantName, tenantName,
kind: "threema_messages", kind: "threema_messages",
description: `Threema messages (${threema.inCount} in + ${threema.outCount} out)`, description: formatLineDescription(
{ kind: "threema_messages", tenantName, metadata: threemaMetadata },
locale
),
quantity: total, quantity: total,
unitLabel: "msgs", unitLabel: "msgs",
unitPriceChf: platformPricing.threemaMessageChf, unitPriceChf: platformPricing.threemaMessageChf,
amountChf: round2(total * platformPricing.threemaMessageChf), amountChf: round2(total * platformPricing.threemaMessageChf),
metadata: { metadata: threemaMetadata,
in_count: threema.inCount,
out_count: threema.outCount,
total_count: total,
},
displayOrder: displayOrder++, displayOrder: displayOrder++,
}); });
} }
@@ -548,19 +567,48 @@ async function buildTenantLines(opts: {
} }
} }
if (billableDays > 0) { if (billableDays > 0) {
// Setup fee fires once per (tenant, skill) — before the
// usage line so it appears above it on the PDF.
if (sp.setupFeeChf > 0) {
const alreadyBilled = await tenantSkillHasBeenBilled(
tenantName,
sp.skillId
);
if (!alreadyBilled) {
const setupMetadata = { skill_id: sp.skillId };
lines.push({
tenantName,
kind: "skill_setup",
description: formatLineDescription(
{ kind: "skill_setup", tenantName, metadata: setupMetadata },
locale
),
quantity: 1,
unitLabel: null,
unitPriceChf: sp.setupFeeChf,
amountChf: round2(sp.setupFeeChf),
metadata: setupMetadata,
displayOrder: displayOrder++,
});
}
}
const skillMetadata = {
skill_id: sp.skillId,
billable_days: billableDays,
event_count: skillEvents.length,
};
lines.push({ lines.push({
tenantName, tenantName,
kind: "skill_usage", kind: "skill_usage",
description: `Skill: ${sp.skillId} (${billableDays} day${billableDays === 1 ? "" : "s"})`, description: formatLineDescription(
{ kind: "skill_usage", tenantName, metadata: skillMetadata },
locale
),
quantity: billableDays, quantity: billableDays,
unitLabel: "days", unitLabel: "days",
unitPriceChf: sp.dailyPriceChf, unitPriceChf: sp.dailyPriceChf,
amountChf: round2(billableDays * sp.dailyPriceChf), amountChf: round2(billableDays * sp.dailyPriceChf),
metadata: { metadata: skillMetadata,
skill_id: sp.skillId,
billable_days: billableDays,
event_count: skillEvents.length,
},
displayOrder: displayOrder++, displayOrder: displayOrder++,
}); });
} }
@@ -620,6 +668,9 @@ export async function computeInvoiceDraft(opts: {
} }
// 4. Build lines, grouped per tenant (display order preserved). // 4. Build lines, grouped per tenant (display order preserved).
// Locale must be resolved before line construction since the
// descriptions are localized at compute time.
const locale = opts.locale ?? defaultLocaleForCountry(snapshot.country);
const lines: Omit<InvoiceLine, "id" | "invoiceId">[] = []; const lines: Omit<InvoiceLine, "id" | "invoiceId">[] = [];
let nextDisplayOrder = 0; let nextDisplayOrder = 0;
// Sort tenants by name for stable line ordering across regenerations. // Sort tenants by name for stable line ordering across regenerations.
@@ -632,6 +683,7 @@ export async function computeInvoiceDraft(opts: {
daysInMonth, daysInMonth,
platformPricing, platformPricing,
skillPricing, skillPricing,
locale,
warnings, warnings,
displayOrderOffset: nextDisplayOrder, displayOrderOffset: nextDisplayOrder,
}); });
@@ -653,9 +705,6 @@ export async function computeInvoiceDraft(opts: {
const paymentMethod: InvoicePaymentMethod = const paymentMethod: InvoicePaymentMethod =
opts.paymentMethod ?? (orgConfig.payByInvoice ? "invoice" : "invoice"); opts.paymentMethod ?? (orgConfig.payByInvoice ? "invoice" : "invoice");
// 7. Locale resolution
const locale = opts.locale ?? defaultLocaleForCountry(snapshot.country);
return { return {
zitadelOrgId, zitadelOrgId,
periodStart, periodStart,

View File

@@ -331,6 +331,12 @@ const MIGRATION_SQL = `
created_at TIMESTAMPTZ NOT NULL DEFAULT now(), created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now() updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
); );
-- Phase 2 addition: per-skill one-time setup fee. Charged the
-- first time a given (tenant, skill) appears on an invoice line.
-- Default 0 so pricing rows created before this column exists
-- stay free until the admin sets a fee.
ALTER TABLE skill_pricing
ADD COLUMN IF NOT EXISTS setup_fee_chf NUMERIC(10,2) NOT NULL DEFAULT 0;
-- One row per tenant. created_at anchors first-month proration; -- One row per tenant. created_at anchors first-month proration;
-- deleted_at (nullable, stamped on delete) anchors last-month -- deleted_at (nullable, stamped on delete) anchors last-month
@@ -1699,6 +1705,7 @@ function rowToSkillPricing(row: any): SkillPricing {
return { return {
skillId: row.skill_id, skillId: row.skill_id,
dailyPriceChf: Number(row.daily_price_chf), dailyPriceChf: Number(row.daily_price_chf),
setupFeeChf: Number(row.setup_fee_chf ?? 0),
createdAt: row.created_at?.toISOString?.() ?? row.created_at, createdAt: row.created_at?.toISOString?.() ?? row.created_at,
updatedAt: row.updated_at?.toISOString?.() ?? row.updated_at, updatedAt: row.updated_at?.toISOString?.() ?? row.updated_at,
}; };
@@ -1724,24 +1731,30 @@ export async function getSkillPricing(
} }
/** /**
* Upsert a daily price for a package. Setting a price activates * Upsert pricing for a package. `dailyPriceChf` activates
* usage-based billing for the (tenant, skill) pair: every UTC day * usage-based billing (one billable unit per UTC day the package
* the package was enabled in the billing month is one unit on the * was enabled). `setupFeeChf` is a one-time charge emitted on the
* invoice. * first invoice line for any given (tenant, skill).
*
* Both fields are required so admin must consciously set 0 to mean
* "no setup fee" rather than accidentally inheriting an old value
* from a partial update.
*/ */
export async function setSkillPricing( export async function setSkillPricing(
skillId: string, skillId: string,
dailyPriceChf: number dailyPriceChf: number,
setupFeeChf: number
): Promise<SkillPricing> { ): Promise<SkillPricing> {
await ensureSchema(); await ensureSchema();
const result = await getPool().query( const result = await getPool().query(
`INSERT INTO skill_pricing (skill_id, daily_price_chf) `INSERT INTO skill_pricing (skill_id, daily_price_chf, setup_fee_chf)
VALUES ($1, $2) VALUES ($1, $2, $3)
ON CONFLICT (skill_id) DO UPDATE SET ON CONFLICT (skill_id) DO UPDATE SET
daily_price_chf = EXCLUDED.daily_price_chf, daily_price_chf = EXCLUDED.daily_price_chf,
setup_fee_chf = EXCLUDED.setup_fee_chf,
updated_at = now() updated_at = now()
RETURNING *`, RETURNING *`,
[skillId, dailyPriceChf] [skillId, dailyPriceChf, setupFeeChf]
); );
return rowToSkillPricing(result.rows[0]); return rowToSkillPricing(result.rows[0]);
} }
@@ -2500,6 +2513,32 @@ export async function tenantHasSetupFeeBilled(
return result.rows.length > 0; return result.rows.length > 0;
} }
/**
* Has this (tenant, skill) pair already appeared on any prior
* invoice line — either as setup or usage? Drives the per-skill
* setup-fee gate. Same "first appearance" semantics as the tenant
* setup fee: a previously-free skill that newly gets a setup fee
* configured will trigger the fee on its next billed period.
*
* Uses metadata->>'skill_id' (which is what both skill_setup and
* skill_usage lines store) rather than parsing description.
*/
export async function tenantSkillHasBeenBilled(
tenantName: string,
skillId: string
): Promise<boolean> {
await ensureSchema();
const result = await getPool().query(
`SELECT 1 FROM invoice_lines
WHERE tenant_name = $1
AND kind IN ('skill_setup', 'skill_usage')
AND metadata->>'skill_id' = $2
LIMIT 1`,
[tenantName, skillId]
);
return result.rows.length > 0;
}
/** /**
* Aggregate open balance per org for the admin overview. Returns * Aggregate open balance per org for the admin overview. Returns
* orgs with at least one open or overdue invoice; orgs in good * orgs with at least one open or overdue invoice; orgs in good

View File

@@ -653,6 +653,8 @@
"confirmDeleteInvoice": "Rechnung {num} löschen? Dies ist eine harte Löschung — die Rechnungsnummer bleibt verbraucht.", "confirmDeleteInvoice": "Rechnung {num} löschen? Dies ist eine harte Löschung — die Rechnungsnummer bleibt verbraucht.",
"paidOnLabel": "Bezahlt am", "paidOnLabel": "Bezahlt am",
"lineItemsTitle": "Positionen", "lineItemsTitle": "Positionen",
"billToSnapshotTitle": "Rechnungsempfänger" "billToSnapshotTitle": "Rechnungsempfänger",
"setupFeeCol": "Einrichtungsgebühr",
"skillSetupFeeLabel": "Einrichtungsgebühr"
} }
} }

View File

@@ -653,6 +653,8 @@
"confirmDeleteInvoice": "Delete invoice {num}? This is a hard delete — the invoice number stays consumed.", "confirmDeleteInvoice": "Delete invoice {num}? This is a hard delete — the invoice number stays consumed.",
"paidOnLabel": "Paid", "paidOnLabel": "Paid",
"lineItemsTitle": "Line items", "lineItemsTitle": "Line items",
"billToSnapshotTitle": "Billed to" "billToSnapshotTitle": "Billed to",
"setupFeeCol": "Setup fee",
"skillSetupFeeLabel": "Setup fee"
} }
} }

View File

@@ -653,6 +653,8 @@
"confirmDeleteInvoice": "Supprimer la facture {num}? Suppression définitive — le numéro reste utilisé.", "confirmDeleteInvoice": "Supprimer la facture {num}? Suppression définitive — le numéro reste utilisé.",
"paidOnLabel": "Payée le", "paidOnLabel": "Payée le",
"lineItemsTitle": "Lignes", "lineItemsTitle": "Lignes",
"billToSnapshotTitle": "Destinataire" "billToSnapshotTitle": "Destinataire",
"setupFeeCol": "Frais de configuration",
"skillSetupFeeLabel": "Frais de configuration"
} }
} }

View File

@@ -653,6 +653,8 @@
"confirmDeleteInvoice": "Eliminare la fattura {num}? Eliminazione definitiva — il numero rimane consumato.", "confirmDeleteInvoice": "Eliminare la fattura {num}? Eliminazione definitiva — il numero rimane consumato.",
"paidOnLabel": "Pagata il", "paidOnLabel": "Pagata il",
"lineItemsTitle": "Righe", "lineItemsTitle": "Righe",
"billToSnapshotTitle": "Destinatario" "billToSnapshotTitle": "Destinatario",
"setupFeeCol": "Spese di attivazione",
"skillSetupFeeLabel": "Spese di attivazione"
} }
} }

View File

@@ -449,6 +449,13 @@ export interface PlatformPricing {
export interface SkillPricing { export interface SkillPricing {
skillId: string; skillId: string;
dailyPriceChf: number; dailyPriceChf: number;
/**
* One-time setup fee charged the first time this skill appears
* on an invoice for a given tenant. Detection mirrors the
* tenant-level setup fee: a `skill_setup` line is emitted only
* when no prior invoice line exists for (tenant, skill).
*/
setupFeeChf: number;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
} }
@@ -541,6 +548,7 @@ export type InvoiceLineKind =
| "ai_usage" | "ai_usage"
| "threema_messages" | "threema_messages"
| "skill_usage" | "skill_usage"
| "skill_setup"
| "adjustment"; | "adjustment";
/** /**