diff --git a/src/app/api/admin/billing/skill-pricing/route.ts b/src/app/api/admin/billing/skill-pricing/route.ts
index 64e4359..d0305ca 100644
--- a/src/app/api/admin/billing/skill-pricing/route.ts
+++ b/src/app/api/admin/billing/skill-pricing/route.ts
@@ -24,6 +24,9 @@ import { safeError } from "@/lib/errors";
const upsertSchema = z.object({
skillId: z.string().min(1).max(100),
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() {
@@ -63,7 +66,8 @@ export async function PUT(request: Request) {
try {
const row = await setSkillPricing(
parsed.data.skillId,
- parsed.data.dailyPriceChf
+ parsed.data.dailyPriceChf,
+ parsed.data.setupFeeChf
);
return NextResponse.json(row);
} catch (e) {
diff --git a/src/components/admin/billing/pricing-editor.tsx b/src/components/admin/billing/pricing-editor.tsx
index 6d8020a..8e17690 100644
--- a/src/components/admin/billing/pricing-editor.tsx
+++ b/src/components/admin/billing/pricing-editor.tsx
@@ -85,20 +85,27 @@ export function PricingEditor({
catalog.find((c) => c.category === "skill")?.id ?? ""
);
const [newSkillPrice, setNewSkillPrice] = useState("0.10");
+ const [newSkillSetupFee, setNewSkillSetupFee] = useState("0");
const [addingSkill, setAddingSkill] = useState(false);
const [skillError, setSkillError] = useState("");
// 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
- // without synthesizing a fake form event.
- const upsertSkillPrice = async (skillId: string, dailyPriceChf: number) => {
+ // editors on existing rows. Kept event-free so callers can invoke it
+ // without synthesizing a fake form event. Both `dailyPriceChf` and
+ // `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);
setSkillError("");
try {
const res = await fetch("/api/admin/billing/skill-pricing", {
method: "PUT",
headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ skillId, dailyPriceChf }),
+ body: JSON.stringify({ skillId, dailyPriceChf, setupFeeChf }),
});
if (!res.ok) {
const j = await res.json().catch(() => ({}));
@@ -115,7 +122,11 @@ export function PricingEditor({
const onAddNewSkill = (e: React.FormEvent) => {
e.preventDefault();
if (!newSkillId) return;
- void upsertSkillPrice(newSkillId, Number(newSkillPrice));
+ void upsertSkillPrice(
+ newSkillId,
+ Number(newSkillPrice),
+ Number(newSkillSetupFee)
+ );
};
const deleteSkill = async (skillId: string) => {
@@ -234,6 +245,7 @@ export function PricingEditor({
{t("skillCol")}
{t("dailyPriceCol")}
+ {t("setupFeeCol")}
{t("actionsCol")}
@@ -252,10 +264,26 @@ export function PricingEditor({
)}
+ {/* Inline edits write daily + setup together (full
+ upsert on the API side). The other field is
+ held constant from the snapshot here. */}
upsertSkillPrice(sp.skillId, price)}
+ decimals={4}
+ onSave={(price) =>
+ upsertSkillPrice(sp.skillId, price, sp.setupFeeChf)
+ }
+ />
+
+
+
+ upsertSkillPrice(sp.skillId, sp.dailyPriceChf, fee)
+ }
/>
@@ -292,9 +320,9 @@ export function PricingEditor({
))}
-
+
- {t("dailyPriceLabel")} (CHF)
+ {t("dailyPriceLabel")}
+
+
+ {t("skillSetupFeeLabel")}
+
+ setNewSkillSetupFee(e.target.value)}
+ className="mt-1 w-full px-3 py-2 rounded-md border border-border bg-surface-2 text-sm"
+ />
+
Promise | void;
}) {
const t = useTranslations("adminBilling");
const [editing, setEditing] = useState(false);
const [value, setValue] = useState(String(initialPrice));
const [busy, setBusy] = useState(false);
+ const step = decimals === 4 ? "0.0001" : "0.01";
if (!editing) {
return (
@@ -347,7 +395,7 @@ function InlinePriceEditor({
className="text-sm font-mono hover:underline"
title={t("clickToEdit")}
>
- CHF {initialPrice.toFixed(2)}
+ CHF {initialPrice.toFixed(decimals)}
);
}
@@ -355,7 +403,7 @@ function InlinePriceEditor({
setValue(e.target.value)}
diff --git a/src/lib/billing-i18n.ts b/src/lib/billing-i18n.ts
new file mode 100644
index 0000000..c5762e2
--- /dev/null
+++ b/src/lib/billing-i18n.ts
@@ -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 | 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)[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)["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)["reason"];
+ const reason = typeof reasonRaw === "string" ? reasonRaw : null;
+ const base = {
+ de: "Anpassung",
+ en: "Adjustment",
+ fr: "Ajustement",
+ it: "Rettifica",
+ }[L];
+ return reason ? `${base}: ${reason}` : base;
+ }
+ }
+}
diff --git a/src/lib/billing-pdf.tsx b/src/lib/billing-pdf.tsx
index 255dcae..e9c5dc6 100644
--- a/src/lib/billing-pdf.tsx
+++ b/src/lib/billing-pdf.tsx
@@ -125,6 +125,7 @@ const MESSAGES: Record = {
ai_usage: "KI-Nutzung",
threema_messages: "Threema-Nachrichten",
skill_usage: "Skill-Nutzung",
+ skill_setup: "Einrichtungsgebühr Skill",
adjustment: "Anpassung",
},
reverseCharge:
@@ -156,6 +157,7 @@ const MESSAGES: Record = {
ai_usage: "AI usage",
threema_messages: "Threema messages",
skill_usage: "Skill usage",
+ skill_setup: "Skill setup fee",
adjustment: "Adjustment",
},
reverseCharge:
@@ -187,6 +189,7 @@ const MESSAGES: Record = {
ai_usage: "Utilisation IA",
threema_messages: "Messages Threema",
skill_usage: "Utilisation Skill",
+ skill_setup: "Frais de configuration skill",
adjustment: "Ajustement",
},
reverseCharge:
@@ -218,6 +221,7 @@ const MESSAGES: Record = {
ai_usage: "Utilizzo IA",
threema_messages: "Messaggi Threema",
skill_usage: "Utilizzo Skill",
+ skill_setup: "Spese di attivazione skill",
adjustment: "Rettifica",
},
reverseCharge:
diff --git a/src/lib/billing.ts b/src/lib/billing.ts
index 8dc4a55..89670f7 100644
--- a/src/lib/billing.ts
+++ b/src/lib/billing.ts
@@ -54,12 +54,14 @@ import {
listSkillPricing,
listSuspensionEventsForTenant,
tenantHasSetupFeeBilled,
+ tenantSkillHasBeenBilled,
updateInvoicePdf,
} from "./db";
import { listTenants } from "./k8s";
import { getTeamSpendLogsV2 } from "./litellm";
import { getUsage as getThreemaUsage } from "./threema-relay";
import { renderInvoicePdf } from "./billing-pdf";
+import { formatLineDescription } from "./billing-i18n";
// ---------------------------------------------------------------------------
// Period helpers
@@ -370,6 +372,7 @@ async function buildTenantLines(opts: {
daysInMonth: number;
platformPricing: PlatformPricing;
skillPricing: SkillPricing[];
+ locale: string;
warnings: string[];
displayOrderOffset: number;
}): Promise[]> {
@@ -380,6 +383,7 @@ async function buildTenantLines(opts: {
daysInMonth,
platformPricing,
skillPricing,
+ locale,
warnings,
} = opts;
let displayOrder = opts.displayOrderOffset;
@@ -428,19 +432,23 @@ async function buildTenantLines(opts: {
if (billableDays > 0) {
const unit = platformPricing.tenantMonthlyFeeChf / daysInMonth;
const amount = round2(unit * billableDays);
+ const metadata = {
+ billable_days: billableDays,
+ suspended_days: suspendedDays,
+ days_in_month: daysInMonth,
+ };
lines.push({
tenantName,
kind: "tenant_monthly",
- description: `Monthly fee for ${tenantName} (${billableDays}/${daysInMonth} days)`,
+ description: formatLineDescription(
+ { kind: "tenant_monthly", tenantName, metadata },
+ locale
+ ),
quantity: billableDays,
unitLabel: "days",
unitPriceChf: round2(unit * 1e5) / 1e5,
amountChf: amount,
- metadata: {
- billable_days: billableDays,
- suspended_days: suspendedDays,
- days_in_month: daysInMonth,
- },
+ metadata,
displayOrder: displayOrder++,
});
}
@@ -453,7 +461,10 @@ async function buildTenantLines(opts: {
lines.push({
tenantName,
kind: "tenant_setup",
- description: `Setup fee for ${tenantName}`,
+ description: formatLineDescription(
+ { kind: "tenant_setup", tenantName, metadata: null },
+ locale
+ ),
quantity: 1,
unitLabel: null,
unitPriceChf: platformPricing.tenantSetupFeeChf,
@@ -480,19 +491,23 @@ async function buildTenantLines(opts: {
`Tenant ${tenantName} has no LiteLLM team yet — AI usage skipped.`
);
} else if (aiUsage.spendChf > 0) {
+ const aiMetadata = {
+ litellm_key_alias: tenantName,
+ spend_chf: aiUsage.spendChf,
+ requests: aiUsage.requestCount,
+ };
lines.push({
tenantName,
kind: "ai_usage",
- description: `AI inference usage (${aiUsage.requestCount} requests)`,
+ description: formatLineDescription(
+ { kind: "ai_usage", tenantName, metadata: aiMetadata },
+ locale
+ ),
quantity: 1,
unitLabel: null,
unitPriceChf: aiUsage.spendChf,
amountChf: aiUsage.spendChf,
- metadata: {
- litellm_key_alias: tenantName,
- spend_chf: aiUsage.spendChf,
- requests: aiUsage.requestCount,
- },
+ metadata: aiMetadata,
displayOrder: displayOrder++,
});
}
@@ -502,19 +517,23 @@ async function buildTenantLines(opts: {
const threema = await collectThreemaUsage(tenant, periodStart, periodEnd);
if (threema && (threema.inCount + threema.outCount) > 0) {
const total = threema.inCount + threema.outCount;
+ const threemaMetadata = {
+ in_count: threema.inCount,
+ out_count: threema.outCount,
+ total_count: total,
+ };
lines.push({
tenantName,
kind: "threema_messages",
- description: `Threema messages (${threema.inCount} in + ${threema.outCount} out)`,
+ description: formatLineDescription(
+ { kind: "threema_messages", tenantName, metadata: threemaMetadata },
+ locale
+ ),
quantity: total,
unitLabel: "msgs",
unitPriceChf: platformPricing.threemaMessageChf,
amountChf: round2(total * platformPricing.threemaMessageChf),
- metadata: {
- in_count: threema.inCount,
- out_count: threema.outCount,
- total_count: total,
- },
+ metadata: threemaMetadata,
displayOrder: displayOrder++,
});
}
@@ -548,19 +567,48 @@ async function buildTenantLines(opts: {
}
}
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({
tenantName,
kind: "skill_usage",
- description: `Skill: ${sp.skillId} (${billableDays} day${billableDays === 1 ? "" : "s"})`,
+ description: formatLineDescription(
+ { kind: "skill_usage", tenantName, metadata: skillMetadata },
+ locale
+ ),
quantity: billableDays,
unitLabel: "days",
unitPriceChf: sp.dailyPriceChf,
amountChf: round2(billableDays * sp.dailyPriceChf),
- metadata: {
- skill_id: sp.skillId,
- billable_days: billableDays,
- event_count: skillEvents.length,
- },
+ metadata: skillMetadata,
displayOrder: displayOrder++,
});
}
@@ -620,6 +668,9 @@ export async function computeInvoiceDraft(opts: {
}
// 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[] = [];
let nextDisplayOrder = 0;
// Sort tenants by name for stable line ordering across regenerations.
@@ -632,6 +683,7 @@ export async function computeInvoiceDraft(opts: {
daysInMonth,
platformPricing,
skillPricing,
+ locale,
warnings,
displayOrderOffset: nextDisplayOrder,
});
@@ -653,9 +705,6 @@ export async function computeInvoiceDraft(opts: {
const paymentMethod: InvoicePaymentMethod =
opts.paymentMethod ?? (orgConfig.payByInvoice ? "invoice" : "invoice");
- // 7. Locale resolution
- const locale = opts.locale ?? defaultLocaleForCountry(snapshot.country);
-
return {
zitadelOrgId,
periodStart,
diff --git a/src/lib/db.ts b/src/lib/db.ts
index 35240f6..857592c 100644
--- a/src/lib/db.ts
+++ b/src/lib/db.ts
@@ -331,6 +331,12 @@ const MIGRATION_SQL = `
created_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;
-- deleted_at (nullable, stamped on delete) anchors last-month
@@ -1699,6 +1705,7 @@ function rowToSkillPricing(row: any): SkillPricing {
return {
skillId: row.skill_id,
dailyPriceChf: Number(row.daily_price_chf),
+ setupFeeChf: Number(row.setup_fee_chf ?? 0),
createdAt: row.created_at?.toISOString?.() ?? row.created_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
- * usage-based billing for the (tenant, skill) pair: every UTC day
- * the package was enabled in the billing month is one unit on the
- * invoice.
+ * Upsert pricing for a package. `dailyPriceChf` activates
+ * usage-based billing (one billable unit per UTC day the package
+ * was enabled). `setupFeeChf` is a one-time charge emitted on the
+ * 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(
skillId: string,
- dailyPriceChf: number
+ dailyPriceChf: number,
+ setupFeeChf: number
): Promise {
await ensureSchema();
const result = await getPool().query(
- `INSERT INTO skill_pricing (skill_id, daily_price_chf)
- VALUES ($1, $2)
+ `INSERT INTO skill_pricing (skill_id, daily_price_chf, setup_fee_chf)
+ VALUES ($1, $2, $3)
ON CONFLICT (skill_id) DO UPDATE SET
daily_price_chf = EXCLUDED.daily_price_chf,
+ setup_fee_chf = EXCLUDED.setup_fee_chf,
updated_at = now()
RETURNING *`,
- [skillId, dailyPriceChf]
+ [skillId, dailyPriceChf, setupFeeChf]
);
return rowToSkillPricing(result.rows[0]);
}
@@ -2500,6 +2513,32 @@ export async function tenantHasSetupFeeBilled(
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 {
+ 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
* orgs with at least one open or overdue invoice; orgs in good
diff --git a/src/messages/de.json b/src/messages/de.json
index 4c4a947..8d2c3e4 100644
--- a/src/messages/de.json
+++ b/src/messages/de.json
@@ -653,6 +653,8 @@
"confirmDeleteInvoice": "Rechnung {num} löschen? Dies ist eine harte Löschung — die Rechnungsnummer bleibt verbraucht.",
"paidOnLabel": "Bezahlt am",
"lineItemsTitle": "Positionen",
- "billToSnapshotTitle": "Rechnungsempfänger"
+ "billToSnapshotTitle": "Rechnungsempfänger",
+ "setupFeeCol": "Einrichtungsgebühr",
+ "skillSetupFeeLabel": "Einrichtungsgebühr"
}
}
diff --git a/src/messages/en.json b/src/messages/en.json
index 334c942..d7911b8 100644
--- a/src/messages/en.json
+++ b/src/messages/en.json
@@ -653,6 +653,8 @@
"confirmDeleteInvoice": "Delete invoice {num}? This is a hard delete — the invoice number stays consumed.",
"paidOnLabel": "Paid",
"lineItemsTitle": "Line items",
- "billToSnapshotTitle": "Billed to"
+ "billToSnapshotTitle": "Billed to",
+ "setupFeeCol": "Setup fee",
+ "skillSetupFeeLabel": "Setup fee"
}
}
diff --git a/src/messages/fr.json b/src/messages/fr.json
index 7afec6d..001a49a 100644
--- a/src/messages/fr.json
+++ b/src/messages/fr.json
@@ -653,6 +653,8 @@
"confirmDeleteInvoice": "Supprimer la facture {num}? Suppression définitive — le numéro reste utilisé.",
"paidOnLabel": "Payée le",
"lineItemsTitle": "Lignes",
- "billToSnapshotTitle": "Destinataire"
+ "billToSnapshotTitle": "Destinataire",
+ "setupFeeCol": "Frais de configuration",
+ "skillSetupFeeLabel": "Frais de configuration"
}
}
diff --git a/src/messages/it.json b/src/messages/it.json
index 0da4f53..5970188 100644
--- a/src/messages/it.json
+++ b/src/messages/it.json
@@ -653,6 +653,8 @@
"confirmDeleteInvoice": "Eliminare la fattura {num}? Eliminazione definitiva — il numero rimane consumato.",
"paidOnLabel": "Pagata il",
"lineItemsTitle": "Righe",
- "billToSnapshotTitle": "Destinatario"
+ "billToSnapshotTitle": "Destinatario",
+ "setupFeeCol": "Spese di attivazione",
+ "skillSetupFeeLabel": "Spese di attivazione"
}
}
diff --git a/src/types/index.ts b/src/types/index.ts
index 20dfb46..51a1a36 100644
--- a/src/types/index.ts
+++ b/src/types/index.ts
@@ -449,6 +449,13 @@ export interface PlatformPricing {
export interface SkillPricing {
skillId: string;
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;
updatedAt: string;
}
@@ -541,6 +548,7 @@ export type InvoiceLineKind =
| "ai_usage"
| "threema_messages"
| "skill_usage"
+ | "skill_setup"
| "adjustment";
/**