1509 lines
52 KiB
TypeScript
1509 lines
52 KiB
TypeScript
/**
|
||
* Billing computation pipeline.
|
||
*
|
||
* Public entry points:
|
||
* - computeInvoiceDraft({ zitadelOrgId, year, month, locale? })
|
||
* Builds an in-memory InvoiceDraft from the live signals
|
||
* (LiteLLM spend, Threema relay usage, tenant skill events,
|
||
* lifecycle, suspension). Does NOT persist or render the PDF.
|
||
*
|
||
* - generateInvoice({ zitadelOrgId, year, month, locale?, dryRun? })
|
||
* Calls computeInvoiceDraft, renders the PDF, persists the
|
||
* invoice transactionally. Returns the persisted Invoice
|
||
* (or the draft if dryRun=true).
|
||
*
|
||
* Design choices:
|
||
*
|
||
* - All compute is over UTC calendar days. "Active during day D"
|
||
* means the tenant existed and was not fully suspended at some
|
||
* moment in [D 00:00 UTC, D+1 00:00 UTC). This matches the
|
||
* skill billing rule ("same-day toggle = 1 day") for monthly
|
||
* fee proration too.
|
||
*
|
||
* - Computation is independent of persistence. Callers can preview
|
||
* without committing (the admin generate form does this on first
|
||
* click), and the same compute path is reused when committing.
|
||
*
|
||
* - The compute path collects warnings rather than throwing on
|
||
* recoverable issues (missing LiteLLM team for a tenant, etc.).
|
||
* The UI surfaces these to the admin before they confirm.
|
||
*/
|
||
|
||
import type {
|
||
CreditNote,
|
||
CustomInvoiceDraftPayload,
|
||
Invoice,
|
||
InvoiceBillingSnapshot,
|
||
InvoiceDraft,
|
||
InvoiceDraftRecord,
|
||
InvoiceLine,
|
||
InvoiceLineKind,
|
||
InvoicePaymentMethod,
|
||
PiecedTenant,
|
||
PlatformPricing,
|
||
SkillPricing,
|
||
TenantBillingLifecycle,
|
||
TenantSkillEvent,
|
||
TenantSuspensionEvent,
|
||
} from "@/types";
|
||
import {
|
||
attachCreditNotePdf,
|
||
createCreditNote,
|
||
createInvoice,
|
||
deleteInvoiceDraft,
|
||
getInvoiceById,
|
||
getInvoiceDraftById,
|
||
getOrgBilling,
|
||
getOrgBillingConfig,
|
||
getPlatformPricing,
|
||
getTenantBillingLifecycle,
|
||
listSkillEventsForTenant,
|
||
listSkillPricing,
|
||
listSuspensionEventsForTenant,
|
||
markInvoiceVoided,
|
||
recordInvoiceRefund,
|
||
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 { renderCreditNotePdf } from "./credit-note-pdf";
|
||
import { sendCreditNoteEmail, sendInvoiceIssuedEmail } from "./email";
|
||
import { createInvoiceRefund } from "./stripe";
|
||
import { formatLineDescription } from "./billing-i18n";
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Period helpers
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/**
|
||
* Returns the [periodStart, periodEnd] inclusive calendar dates for
|
||
* the given month, plus the count of days in the month.
|
||
*
|
||
* Dates returned as ISO `YYYY-MM-DD` strings (no time). Convertible
|
||
* to UTC midnight via `new Date(`${date}T00:00:00Z`)`.
|
||
*/
|
||
export function monthBounds(year: number, month: number): {
|
||
periodStart: string;
|
||
periodEnd: string;
|
||
daysInMonth: number;
|
||
} {
|
||
if (month < 1 || month > 12) throw new Error(`Invalid month: ${month}`);
|
||
const start = new Date(Date.UTC(year, month - 1, 1));
|
||
// Day 0 of next month = last day of this month
|
||
const end = new Date(Date.UTC(year, month, 0));
|
||
return {
|
||
periodStart: start.toISOString().split("T")[0],
|
||
periodEnd: end.toISOString().split("T")[0],
|
||
daysInMonth: end.getUTCDate(),
|
||
};
|
||
}
|
||
|
||
function isoDate(d: Date): string {
|
||
return d.toISOString().split("T")[0];
|
||
}
|
||
|
||
function dueDate(periodEnd: string, netDays: number = 30): string {
|
||
// due_at = period_end + netDays
|
||
const d = new Date(`${periodEnd}T00:00:00Z`);
|
||
d.setUTCDate(d.getUTCDate() + netDays);
|
||
return isoDate(d);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Day-set computation (calendar-day model, UTC)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/**
|
||
* Iterates UTC calendar days in [periodStart, periodEnd] inclusive.
|
||
* Yields { date: 'YYYY-MM-DD', dayStartMs, dayEndMs } where dayEnd
|
||
* is exclusive (next-day-midnight UTC).
|
||
*/
|
||
function* iterDays(periodStart: string, periodEnd: string) {
|
||
const start = new Date(`${periodStart}T00:00:00Z`).getTime();
|
||
const end = new Date(`${periodEnd}T00:00:00Z`).getTime();
|
||
for (let t = start; t <= end; t += 86_400_000) {
|
||
yield {
|
||
date: isoDate(new Date(t)),
|
||
dayStartMs: t,
|
||
dayEndMs: t + 86_400_000,
|
||
};
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Was the tenant "running" (created, not deleted, not suspended) at
|
||
* any moment in the half-open interval [dayStartMs, dayEndMs)?
|
||
*
|
||
* Inputs: tenant lifecycle and the timeline of suspension events
|
||
* sorted ascending by occurredAt.
|
||
*
|
||
* The state-at-day-start is reconstructed from suspension events
|
||
* BEFORE the day. If the count of suspension events before the day
|
||
* is odd, the tenant was suspended at day start (because we record
|
||
* suspend then resume, so an odd prefix-count means the last
|
||
* recorded transition is "suspended"). This is robust as long as
|
||
* events are correctly ordered.
|
||
*
|
||
* Actually we use the actual event kinds from the events list,
|
||
* not the parity heuristic — the heuristic is documentation for
|
||
* intuition.
|
||
*/
|
||
function activeDuringDay(
|
||
lifecycle: TenantBillingLifecycle,
|
||
suspensionEvents: TenantSuspensionEvent[],
|
||
dayStartMs: number,
|
||
dayEndMs: number
|
||
): boolean {
|
||
// Lifecycle gate: tenant must have existed during some part of the day.
|
||
const createdMs = new Date(lifecycle.createdAt).getTime();
|
||
const deletedMs = lifecycle.deletedAt
|
||
? new Date(lifecycle.deletedAt).getTime()
|
||
: Infinity;
|
||
if (createdMs >= dayEndMs) return false;
|
||
if (deletedMs <= dayStartMs) return false;
|
||
// Effective existence window within this day
|
||
const existsFrom = Math.max(createdMs, dayStartMs);
|
||
const existsTo = Math.min(deletedMs, dayEndMs);
|
||
if (existsFrom >= existsTo) return false;
|
||
|
||
// Determine suspended state at existsFrom by replaying events.
|
||
// Initial state at lifecycle.createdAt is 'running' (we don't
|
||
// record an explicit 'created → running' event; this is the
|
||
// implicit baseline).
|
||
let suspended = false;
|
||
for (const e of suspensionEvents) {
|
||
const ts = new Date(e.occurredAt).getTime();
|
||
if (ts > existsFrom) break;
|
||
suspended = e.eventKind === "suspended";
|
||
}
|
||
|
||
// Walk events from existsFrom to existsTo. If at any moment the
|
||
// tenant is running, the day counts.
|
||
if (!suspended) return true;
|
||
for (const e of suspensionEvents) {
|
||
const ts = new Date(e.occurredAt).getTime();
|
||
if (ts <= existsFrom) continue;
|
||
if (ts >= existsTo) break;
|
||
if (e.eventKind === "resumed") return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
/**
|
||
* Was the skill 'enabled' at any moment in the day?
|
||
*
|
||
* Same shape as activeDuringDay but driven by skill events instead
|
||
* of suspension events.
|
||
*
|
||
* Important: callers must include events from before periodStart in
|
||
* `prevState` (state at day start), since a skill enabled three
|
||
* months ago and never disabled has no events in the billing
|
||
* window but is still enabled.
|
||
*/
|
||
function skillActiveDuringDay(
|
||
events: TenantSkillEvent[],
|
||
initiallyEnabled: boolean,
|
||
dayStartMs: number,
|
||
dayEndMs: number
|
||
): boolean {
|
||
let enabled = initiallyEnabled;
|
||
// First, replay events that occurred AT OR BEFORE dayStartMs to
|
||
// get the state at day start.
|
||
for (const e of events) {
|
||
const ts = new Date(e.occurredAt).getTime();
|
||
if (ts > dayStartMs) break;
|
||
enabled = e.eventKind === "enabled";
|
||
}
|
||
if (enabled) return true;
|
||
// Walk events in [dayStart, dayEnd). If any 'enabled' event
|
||
// appears, the day counts.
|
||
for (const e of events) {
|
||
const ts = new Date(e.occurredAt).getTime();
|
||
if (ts <= dayStartMs) continue;
|
||
if (ts >= dayEndMs) break;
|
||
if (e.eventKind === "enabled") return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Rounding
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/** Round to 2dp, half-up. */
|
||
function round2(n: number): number {
|
||
return Math.round(n * 100) / 100;
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// VAT logic
|
||
// ---------------------------------------------------------------------------
|
||
|
||
const EU_COUNTRIES = new Set([
|
||
"AT", "BE", "BG", "HR", "CY", "CZ", "DK", "EE", "FI", "FR",
|
||
"DE", "GR", "HU", "IE", "IT", "LV", "LT", "LU", "MT", "NL",
|
||
"PL", "PT", "RO", "SK", "SI", "ES", "SE",
|
||
]);
|
||
|
||
/**
|
||
* 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:
|
||
*
|
||
* - CH or LI: platform_pricing.vat_rate_chli (default 8.10)
|
||
* - EU + VAT number: 0% (reverse charge — B2B)
|
||
* - EU without VAT: CH MWST (B2C consumer, we charge our rate)
|
||
* - other: 0% (export of services)
|
||
*/
|
||
export function vatRateForAddress(
|
||
snapshot: InvoiceBillingSnapshot,
|
||
platformPricing: PlatformPricing
|
||
): { rate: number; note: string | null } {
|
||
const country = snapshot.country?.toUpperCase().trim() ?? "";
|
||
if (country === "CH" || country === "LI") {
|
||
return { rate: platformPricing.vatRateChli, note: null };
|
||
}
|
||
if (EU_COUNTRIES.has(country)) {
|
||
if (snapshot.vatNumber && snapshot.vatNumber.trim().length > 0) {
|
||
return {
|
||
rate: 0,
|
||
note:
|
||
"Steuerschuldnerschaft des Leistungsempfängers / Reverse charge — VAT to be accounted for by the recipient.",
|
||
};
|
||
}
|
||
return { rate: platformPricing.vatRateChli, note: null };
|
||
}
|
||
return { rate: 0, note: "Export of services — VAT not applicable." };
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Locale default
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/**
|
||
* Pick a default invoice locale from the billing country. Admins
|
||
* can override at generation time. We default to German for
|
||
* CH/LI/AT/DE; French for FR/BE/LU; Italian for IT; English
|
||
* otherwise.
|
||
*/
|
||
export function defaultLocaleForCountry(country: string): string {
|
||
const c = (country || "").toUpperCase().trim();
|
||
if (["CH", "LI", "AT", "DE"].includes(c)) return "de";
|
||
if (["FR", "BE", "LU"].includes(c)) return "fr";
|
||
if (c === "IT") return "it";
|
||
return "en";
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Tenant signal collectors
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/**
|
||
* Sum AI usage spend for a tenant over the billing period via
|
||
* LiteLLM. Returns the CHF total (already in CHF — LiteLLM stores
|
||
* costs after the platform's USD→CHF conversion) and the request
|
||
* count for the metadata.
|
||
*
|
||
* Tolerates missing litellmTeamId on the tenant: such tenants are
|
||
* skipped and the warning is surfaced upstream.
|
||
*/
|
||
async function collectAiUsage(
|
||
tenant: PiecedTenant,
|
||
periodStart: string,
|
||
periodEnd: string
|
||
): Promise<{ spendChf: number; requestCount: number } | null> {
|
||
const teamId = tenant.status?.litellmTeamId;
|
||
if (!teamId) return null;
|
||
const keyAlias = tenant.metadata.name;
|
||
let spendChf = 0;
|
||
let requestCount = 0;
|
||
let page = 1;
|
||
// 50-page cap matches the existing usage route's defensive cap.
|
||
while (page <= 50) {
|
||
const result = await getTeamSpendLogsV2(
|
||
teamId,
|
||
periodStart,
|
||
periodEnd,
|
||
page,
|
||
100,
|
||
keyAlias
|
||
);
|
||
const rows: any[] = result.data ?? [];
|
||
for (const r of rows) {
|
||
spendChf += Number(r.spend ?? 0);
|
||
requestCount += 1;
|
||
}
|
||
if (page >= (result.total_pages || 1)) break;
|
||
page++;
|
||
}
|
||
return { spendChf: round2(spendChf), requestCount };
|
||
}
|
||
|
||
/**
|
||
* Sum Threema messages (in + out) for the tenant over the period.
|
||
* Returns null if the relay refuses or the tenant has no Threema
|
||
* package — billing is skipped silently in that case.
|
||
*/
|
||
async function collectThreemaUsage(
|
||
tenant: PiecedTenant,
|
||
periodStart: string,
|
||
periodEnd: string
|
||
): Promise<{ inCount: number; outCount: number } | null> {
|
||
const packages = tenant.spec.packages ?? [];
|
||
if (!packages.includes("threema")) return null;
|
||
// threema-relay.getUsage takes Date params, not strings, and
|
||
// returns a discriminated RelayResult<UsageBreakdown> — the
|
||
// `ok` discriminant must be checked before reading the totals.
|
||
// Period end is exclusive in the relay's API; pass the next-day
|
||
// midnight UTC to capture the full last day of the period.
|
||
const from = new Date(`${periodStart}T00:00:00Z`);
|
||
const to = new Date(`${periodEnd}T00:00:00Z`);
|
||
to.setUTCDate(to.getUTCDate() + 1);
|
||
const result = await getThreemaUsage(tenant.metadata.name, from, to).catch(
|
||
() => null
|
||
);
|
||
if (!result || !result.ok) return null;
|
||
return {
|
||
inCount: Number(result.totals?.in ?? 0),
|
||
outCount: Number(result.totals?.out ?? 0),
|
||
};
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Per-tenant line builders
|
||
// ---------------------------------------------------------------------------
|
||
|
||
async function buildTenantLines(opts: {
|
||
tenant: PiecedTenant;
|
||
periodStart: string;
|
||
periodEnd: string;
|
||
daysInMonth: number;
|
||
platformPricing: PlatformPricing;
|
||
skillPricing: SkillPricing[];
|
||
locale: string;
|
||
warnings: string[];
|
||
displayOrderOffset: number;
|
||
}): Promise<Omit<InvoiceLine, "id" | "invoiceId">[]> {
|
||
const {
|
||
tenant,
|
||
periodStart,
|
||
periodEnd,
|
||
daysInMonth,
|
||
platformPricing,
|
||
skillPricing,
|
||
locale,
|
||
warnings,
|
||
} = opts;
|
||
let displayOrder = opts.displayOrderOffset;
|
||
const tenantName = tenant.metadata.name;
|
||
const lines: Omit<InvoiceLine, "id" | "invoiceId">[] = [];
|
||
|
||
// Lifecycle & suspension events — required for monthly proration.
|
||
const lifecycle = await getTenantBillingLifecycle(tenantName);
|
||
if (!lifecycle) {
|
||
warnings.push(
|
||
`Tenant "${tenantName}" has no billing lifecycle row — run the Phase 1 backfill.`
|
||
);
|
||
return lines;
|
||
}
|
||
|
||
// Period interval in millis (extended by one day on each side as
|
||
// buffer for events that occur at month boundaries).
|
||
const periodStartMs = new Date(`${periodStart}T00:00:00Z`).getTime();
|
||
const periodEndMs = new Date(`${periodEnd}T00:00:00Z`).getTime() + 86_400_000;
|
||
|
||
const suspensionEvents = await listSuspensionEventsForTenant(
|
||
tenantName,
|
||
new Date(periodStartMs - 365 * 86_400_000), // look back a year for state-at-start
|
||
new Date(periodEndMs)
|
||
);
|
||
|
||
// --- tenant_monthly (prorated, suspended days excluded) -------------------
|
||
if (platformPricing.tenantMonthlyFeeChf > 0) {
|
||
let billableDays = 0;
|
||
let suspendedDays = 0;
|
||
for (const day of iterDays(periodStart, periodEnd)) {
|
||
if (activeDuringDay(lifecycle, suspensionEvents, day.dayStartMs, day.dayEndMs)) {
|
||
billableDays++;
|
||
} else {
|
||
// Distinguish "not yet existed / deleted" from "suspended"
|
||
// for the metadata audit trail. Cheap re-check.
|
||
const createdMs = new Date(lifecycle.createdAt).getTime();
|
||
const deletedMs = lifecycle.deletedAt
|
||
? new Date(lifecycle.deletedAt).getTime()
|
||
: Infinity;
|
||
if (createdMs < day.dayEndMs && deletedMs > day.dayStartMs) {
|
||
suspendedDays++;
|
||
}
|
||
}
|
||
}
|
||
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: formatLineDescription(
|
||
{ kind: "tenant_monthly", tenantName, metadata },
|
||
locale
|
||
),
|
||
quantity: billableDays,
|
||
unitLabel: "days",
|
||
unitPriceChf: round2(unit * 1e5) / 1e5,
|
||
amountChf: amount,
|
||
metadata,
|
||
displayOrder: displayOrder++,
|
||
});
|
||
}
|
||
}
|
||
|
||
// --- tenant_setup (first invoice only) -----------------------------------
|
||
if (platformPricing.tenantSetupFeeChf > 0) {
|
||
const alreadyBilled = await tenantHasSetupFeeBilled(tenantName);
|
||
if (!alreadyBilled) {
|
||
lines.push({
|
||
tenantName,
|
||
kind: "tenant_setup",
|
||
description: formatLineDescription(
|
||
{ kind: "tenant_setup", tenantName, metadata: null },
|
||
locale
|
||
),
|
||
quantity: 1,
|
||
unitLabel: null,
|
||
unitPriceChf: platformPricing.tenantSetupFeeChf,
|
||
amountChf: round2(platformPricing.tenantSetupFeeChf),
|
||
metadata: null,
|
||
displayOrder: displayOrder++,
|
||
});
|
||
}
|
||
}
|
||
|
||
// --- ai_usage --------------------------------------------------------------
|
||
const aiUsage = await collectAiUsage(tenant, periodStart, periodEnd).catch(
|
||
(e) => {
|
||
warnings.push(
|
||
`AI usage fetch failed for ${tenantName}: ${e instanceof Error ? e.message : String(e)}`
|
||
);
|
||
return null;
|
||
}
|
||
);
|
||
if (aiUsage === null && tenant.status?.litellmTeamId) {
|
||
// teamId exists but fetch returned null — already warned above
|
||
} else if (aiUsage === null) {
|
||
warnings.push(
|
||
`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: formatLineDescription(
|
||
{ kind: "ai_usage", tenantName, metadata: aiMetadata },
|
||
locale
|
||
),
|
||
quantity: 1,
|
||
unitLabel: null,
|
||
unitPriceChf: aiUsage.spendChf,
|
||
amountChf: aiUsage.spendChf,
|
||
metadata: aiMetadata,
|
||
displayOrder: displayOrder++,
|
||
});
|
||
}
|
||
|
||
// --- threema_messages -----------------------------------------------------
|
||
if (platformPricing.threemaMessageChf > 0) {
|
||
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: formatLineDescription(
|
||
{ kind: "threema_messages", tenantName, metadata: threemaMetadata },
|
||
locale
|
||
),
|
||
quantity: total,
|
||
unitLabel: "msgs",
|
||
unitPriceChf: platformPricing.threemaMessageChf,
|
||
amountChf: round2(total * platformPricing.threemaMessageChf),
|
||
metadata: threemaMetadata,
|
||
displayOrder: displayOrder++,
|
||
});
|
||
}
|
||
}
|
||
|
||
// --- skill_usage ----------------------------------------------------------
|
||
// For each priced skill, count distinct UTC days the skill was
|
||
// enabled during the period.
|
||
if (skillPricing.length > 0) {
|
||
// Fetch all skill events for the tenant within the period plus
|
||
// a long lookback so we can determine state-at-period-start.
|
||
// The state-at-day-start logic in skillActiveDuringDay walks
|
||
// these events forward.
|
||
const allEvents = await listSkillEventsForTenant(
|
||
tenantName,
|
||
new Date(0),
|
||
new Date(periodEndMs)
|
||
);
|
||
for (const sp of skillPricing) {
|
||
const skillEvents = allEvents.filter((e) => e.skillId === sp.skillId);
|
||
// Skip cheaply if no events ever existed for this skill on
|
||
// this tenant.
|
||
if (skillEvents.length === 0) continue;
|
||
// Initial state assumption: false. The very first event is
|
||
// always 'enabled' (we only record toggles, and the implicit
|
||
// pre-toggle state for a never-seen skill is 'disabled').
|
||
let billableDays = 0;
|
||
for (const day of iterDays(periodStart, periodEnd)) {
|
||
if (skillActiveDuringDay(skillEvents, false, day.dayStartMs, day.dayEndMs)) {
|
||
billableDays++;
|
||
}
|
||
}
|
||
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: formatLineDescription(
|
||
{ kind: "skill_usage", tenantName, metadata: skillMetadata },
|
||
locale
|
||
),
|
||
quantity: billableDays,
|
||
unitLabel: "days",
|
||
unitPriceChf: sp.dailyPriceChf,
|
||
amountChf: round2(billableDays * sp.dailyPriceChf),
|
||
metadata: skillMetadata,
|
||
displayOrder: displayOrder++,
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
return lines;
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Public API
|
||
// ---------------------------------------------------------------------------
|
||
|
||
export async function computeInvoiceDraft(opts: {
|
||
zitadelOrgId: string;
|
||
year: number;
|
||
month: number;
|
||
locale?: string;
|
||
paymentMethod?: InvoicePaymentMethod;
|
||
}): Promise<InvoiceDraft> {
|
||
const { zitadelOrgId, year, month } = opts;
|
||
const { periodStart, periodEnd, daysInMonth } = monthBounds(year, month);
|
||
const warnings: string[] = [];
|
||
|
||
// 1. Billing address. Required — without it we can't produce a
|
||
// valid invoice.
|
||
const orgBilling = await getOrgBilling(zitadelOrgId);
|
||
if (!orgBilling) {
|
||
throw new Error(
|
||
`Org ${zitadelOrgId} has no billing address on file. ` +
|
||
`The customer must complete /settings/billing before an invoice can be issued.`
|
||
);
|
||
}
|
||
const snapshot: InvoiceBillingSnapshot = {
|
||
companyName: orgBilling.companyName,
|
||
contactName: orgBilling.contactName ?? null,
|
||
streetAddress: orgBilling.streetAddress,
|
||
postalCode: orgBilling.postalCode,
|
||
city: orgBilling.city,
|
||
country: orgBilling.country,
|
||
vatNumber: orgBilling.vatNumber ?? null,
|
||
billingEmail: orgBilling.billingEmail,
|
||
notes: orgBilling.notes ?? null,
|
||
};
|
||
|
||
// 2. Platform pricing + skill prices.
|
||
const platformPricing = await getPlatformPricing();
|
||
const skillPricing = await listSkillPricing();
|
||
|
||
// 3. Find all tenants for this org. We list from K8s (source of
|
||
// truth) and filter by the zitadel-org-id label.
|
||
const allTenants = await listTenants();
|
||
const orgTenants = allTenants.filter(
|
||
(t) => t.metadata.labels?.["pieced.ch/zitadel-org-id"] === zitadelOrgId
|
||
);
|
||
if (orgTenants.length === 0) {
|
||
warnings.push(`No tenants found for org ${zitadelOrgId}.`);
|
||
}
|
||
|
||
// 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">[] = [];
|
||
let nextDisplayOrder = 0;
|
||
// Sort tenants by name for stable line ordering across regenerations.
|
||
orgTenants.sort((a, b) => a.metadata.name.localeCompare(b.metadata.name));
|
||
for (const tenant of orgTenants) {
|
||
const tenantLines = await buildTenantLines({
|
||
tenant,
|
||
periodStart,
|
||
periodEnd,
|
||
daysInMonth,
|
||
platformPricing,
|
||
skillPricing,
|
||
locale,
|
||
warnings,
|
||
displayOrderOffset: nextDisplayOrder,
|
||
});
|
||
lines.push(...tenantLines);
|
||
nextDisplayOrder += tenantLines.length;
|
||
}
|
||
|
||
// 5. Subtotal & VAT.
|
||
const subtotal = round2(lines.reduce((acc, l) => acc + l.amountChf, 0));
|
||
const vat = vatRateForAddress(snapshot, platformPricing);
|
||
const vatAmount = round2((subtotal * vat.rate) / 100);
|
||
const total = round2(subtotal + vatAmount);
|
||
if (vat.note) warnings.push(vat.note);
|
||
|
||
// 6. Payment method: prefer pay-by-invoice if the admin enabled
|
||
// it for the org, otherwise default to invoice. Card payment
|
||
// is wired in Phase 4 — for Phase 2 every invoice is 'invoice'.
|
||
const orgConfig = await getOrgBillingConfig(zitadelOrgId);
|
||
const paymentMethod: InvoicePaymentMethod =
|
||
opts.paymentMethod ?? (orgConfig.payByInvoice ? "invoice" : "invoice");
|
||
|
||
return {
|
||
zitadelOrgId,
|
||
periodStart,
|
||
periodEnd,
|
||
dueAt: dueDate(periodEnd, 30),
|
||
locale,
|
||
paymentMethod,
|
||
billingSnapshot: snapshot,
|
||
lines,
|
||
subtotalChf: subtotal,
|
||
vatRate: vat.rate,
|
||
vatAmountChf: vatAmount,
|
||
totalChf: total,
|
||
warnings,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Compute + render + persist in one step. If dryRun is true, the
|
||
* draft is returned without persisting and no PDF is rendered (the
|
||
* preview UI hits this).
|
||
*/
|
||
export async function generateInvoice(opts: {
|
||
zitadelOrgId: string;
|
||
year: number;
|
||
month: number;
|
||
locale?: string;
|
||
dryRun?: boolean;
|
||
}): Promise<{ draft: InvoiceDraft; invoice: Invoice | null }> {
|
||
const draft = await computeInvoiceDraft(opts);
|
||
if (opts.dryRun) {
|
||
return { draft, invoice: null };
|
||
}
|
||
// Render the PDF first — if it fails, we never touch the DB.
|
||
// The PDF render needs the invoice number, which is allocated
|
||
// inside createInvoice's transaction. To keep the PDF rendering
|
||
// outside the DB transaction (it can be slow), we render with a
|
||
// placeholder number, allocate the real number inside the tx,
|
||
// then re-render? No — instead we generate a temporary draft
|
||
// number for the PDF and accept that the displayed number on
|
||
// the PDF matches what we'll persist (because the allocator is
|
||
// serialized).
|
||
//
|
||
// Practical approach: render the PDF inside createInvoice's tx,
|
||
// immediately after allocation. This is fine because react-pdf
|
||
// is reasonably fast (~50–200 ms for a typical invoice) and
|
||
// happens once per invoice.
|
||
//
|
||
// To avoid restructuring createInvoice, we do this in two
|
||
// passes: (1) reserve a number via createInvoice with a
|
||
// placeholder PDF; (2) render with the real number; (3) UPDATE
|
||
// pdf_data. The trade-off is two write trips but keeps the code
|
||
// shape simple. We accept it.
|
||
//
|
||
// Reasoning behind two-pass: if PDF render is moved inside the
|
||
// tx and fails (font missing, etc.), the allocated counter rolls
|
||
// back — good. But it also means the connection is held during
|
||
// render. At v1 scale that's fine; the choice is reversible.
|
||
|
||
// Pass 1: allocate number + persist with empty PDF.
|
||
const placeholder = await createInvoice(draft, null, null);
|
||
try {
|
||
const pdfBuffer = await renderInvoicePdf(
|
||
placeholder,
|
||
draft.lines.map((l, i) => ({
|
||
...l,
|
||
id: `tmp-${i}`,
|
||
invoiceId: placeholder.id,
|
||
}))
|
||
);
|
||
const filename = `${placeholder.invoiceNumber}.pdf`;
|
||
// Pass 2: store the PDF bytes.
|
||
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.`
|
||
);
|
||
}
|
||
} catch (e) {
|
||
console.error(
|
||
`Invoice ${placeholder.invoiceNumber} issued; notification email failed:`,
|
||
e
|
||
);
|
||
}
|
||
|
||
return { draft, invoice: finalInvoice ?? placeholder };
|
||
} catch (e) {
|
||
// Render failed — leave the persisted row in place so admin can
|
||
// inspect it, but surface the error.
|
||
throw new Error(
|
||
`Invoice ${placeholder.invoiceNumber} persisted but PDF rendering failed: ${
|
||
e instanceof Error ? e.message : String(e)
|
||
}. Use the admin "delete invoice" tool to clean up if needed.`
|
||
);
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Phase 7 — void and refund orchestration
|
||
// ---------------------------------------------------------------------------
|
||
|
||
export class VoidNotAllowedError extends Error {
|
||
constructor(message: string, public readonly currentStatus: string) {
|
||
super(message);
|
||
this.name = "VoidNotAllowedError";
|
||
}
|
||
}
|
||
|
||
export class RefundNotAllowedError extends Error {
|
||
constructor(message: string, public readonly currentStatus: string) {
|
||
super(message);
|
||
this.name = "RefundNotAllowedError";
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Sanitize a locale string to the supported four. Used when picking
|
||
* which translation block to render emails/PDFs with. We never
|
||
* fall back to admin's locale here — the credit note inherits the
|
||
* invoice's locale so both documents read consistently to the
|
||
* customer.
|
||
*/
|
||
function pickSupportedLocale(
|
||
locale: string | null | undefined
|
||
): "de" | "en" | "fr" | "it" {
|
||
const supported = ["de", "en", "fr", "it"] as const;
|
||
return (supported as readonly string[]).includes(locale ?? "")
|
||
? (locale as "de" | "en" | "fr" | "it")
|
||
: "de";
|
||
}
|
||
|
||
/**
|
||
* Round a CHF amount to 2 decimal places. Used when proportionally
|
||
* splitting VAT between subtotal and refund amount — avoids
|
||
* accumulating fractional rappen across operations.
|
||
*/
|
||
function roundChf(amount: number): number {
|
||
return Math.round(amount * 100) / 100;
|
||
}
|
||
|
||
/**
|
||
* Void an unpaid invoice. State transition: open/overdue → void.
|
||
*
|
||
* Side effects, in order:
|
||
* 1. Mark the invoice voided (status, void_reason, voided_at, voided_by)
|
||
* 2. Insert credit_notes row (kind='void', amount=full invoice total)
|
||
* 3. Render the credit-note PDF and attach it to the row
|
||
* 4. Best-effort email to the billing contact
|
||
*
|
||
* Not allowed:
|
||
* - status='paid' (use refundInvoice instead — voiding paid
|
||
* invoices would create a record mismatch with the payment
|
||
* processor)
|
||
* - status='void' (already voided)
|
||
* - status='draft' (drafts aren't issued; nothing to void)
|
||
* - status='partially_refunded' / 'fully_refunded' (use refund
|
||
* for the remaining amount instead)
|
||
*
|
||
* Throws VoidNotAllowedError if the invoice is in a non-voidable
|
||
* state. Caller surfaces this as 409 Conflict to the admin.
|
||
*/
|
||
export async function voidInvoice(params: {
|
||
invoiceId: string;
|
||
reason: string;
|
||
voidedBy: string;
|
||
}): Promise<CreditNote> {
|
||
const invoice = await getInvoiceById(params.invoiceId);
|
||
if (!invoice) {
|
||
throw new Error(`Invoice not found: ${params.invoiceId}`);
|
||
}
|
||
// Only unpaid invoices can be voided. The state machine puts
|
||
// paid invoices on the refund path; voiding them would skip the
|
||
// payment reversal and leave the customer's money in our account
|
||
// with no obligation showing in the portal.
|
||
if (!["open", "overdue"].includes(invoice.status)) {
|
||
throw new VoidNotAllowedError(
|
||
`Cannot void invoice in status '${invoice.status}'. Voids are allowed only for open or overdue invoices; paid invoices must be refunded.`,
|
||
invoice.status
|
||
);
|
||
}
|
||
const locale = pickSupportedLocale(invoice.locale);
|
||
|
||
// The credit note matches the invoice 1:1 in amount and VAT.
|
||
// We carry the same VAT breakdown so the PDF can render
|
||
// "subtotal + VAT" the same way the original invoice did.
|
||
const creditNote = await createCreditNote({
|
||
invoiceId: invoice.id,
|
||
zitadelOrgId: invoice.zitadelOrgId,
|
||
kind: "void",
|
||
amountChf: invoice.totalChf,
|
||
vatAmountChf: invoice.vatAmountChf,
|
||
reason: params.reason || null,
|
||
issuedBy: params.voidedBy,
|
||
locale,
|
||
billingSnapshot: invoice.billingSnapshot,
|
||
});
|
||
|
||
// Mark invoice voided AFTER the credit note row exists, so the
|
||
// status change has a credit note to point at. If anything below
|
||
// here fails (PDF render, email), the invoice is still correctly
|
||
// voided and the credit note row exists — just without a PDF
|
||
// until manually re-rendered.
|
||
await markInvoiceVoided({
|
||
invoiceId: invoice.id,
|
||
reason: params.reason,
|
||
voidedBy: params.voidedBy,
|
||
});
|
||
|
||
// Render PDF + attach. PDF failure here doesn't undo the void —
|
||
// the customer can be told their invoice is voided and the PDF
|
||
// can be re-issued later. We surface the error in the response
|
||
// so admin knows to retry, but the void itself stands.
|
||
try {
|
||
const pdfBuffer = await renderCreditNotePdf(creditNote, invoice);
|
||
const filename = `${creditNote.creditNoteNumber}.pdf`;
|
||
await attachCreditNotePdf(creditNote.id, pdfBuffer, filename);
|
||
} catch (e) {
|
||
console.error(
|
||
`Credit note ${creditNote.creditNoteNumber} created but PDF render failed; re-render manually.`,
|
||
e
|
||
);
|
||
}
|
||
|
||
// Best-effort email. Same fail-soft pattern as invoice issuance.
|
||
try {
|
||
const snap = invoice.billingSnapshot;
|
||
if (snap.billingEmail) {
|
||
await sendCreditNoteEmail({
|
||
to: snap.billingEmail,
|
||
contactName: snap.contactName || snap.companyName,
|
||
companyName: snap.companyName,
|
||
creditNoteNumber: creditNote.creditNoteNumber,
|
||
invoiceNumber: invoice.invoiceNumber,
|
||
amountChf: creditNote.amountChf,
|
||
currency: "CHF",
|
||
kind: "void",
|
||
reason: params.reason || null,
|
||
locale,
|
||
});
|
||
}
|
||
} catch (e) {
|
||
console.error(
|
||
`Credit note ${creditNote.creditNoteNumber} issued; email send failed.`,
|
||
e
|
||
);
|
||
}
|
||
|
||
return creditNote;
|
||
}
|
||
|
||
/**
|
||
* Refund a paid invoice (in part or in full). State transition:
|
||
* paid → partially_refunded (if amount < remaining)
|
||
* paid → fully_refunded (if amount >= remaining)
|
||
* partially_refunded → fully_refunded (if cumulative >= total)
|
||
*
|
||
* Side effects, in order:
|
||
* 1. If the invoice was Stripe-paid (payment_method='card' with a
|
||
* stripe_payment_intent_id) AND no `existingStripeRefund` was
|
||
* passed, call Stripe to issue the refund. Stripe is the source
|
||
* of truth for actual money movement; we mirror its outcome
|
||
* locally.
|
||
* 2. Insert credit_notes row (kind='refund', amount=refund amount,
|
||
* VAT proportional)
|
||
* 3. Insert invoice_refunds row, linking to the credit note and to
|
||
* the Stripe refund (if any). recordInvoiceRefund updates the
|
||
* invoice's status atomically based on the new running total.
|
||
* 4. Render PDF + attach
|
||
* 5. Best-effort email
|
||
*
|
||
* `existingStripeRefund` is for the webhook path: when Stripe fires
|
||
* `charge.refunded` for a refund that was initiated directly in the
|
||
* Stripe Dashboard (not via this portal), the webhook needs to
|
||
* mirror the refund into the DB and issue a credit note WITHOUT
|
||
* calling Stripe again. Pass the refund id and status to skip the
|
||
* Stripe call.
|
||
*
|
||
* Not allowed:
|
||
* - status not in {paid, partially_refunded} — full refunds are
|
||
* only meaningful against actual payment
|
||
* - amount <= 0 or > remaining refundable
|
||
*
|
||
* For invoice-paid (non-Stripe) customers the Stripe step is
|
||
* skipped; refund settlement happens out-of-band (bank transfer)
|
||
* and admin records the action in the portal.
|
||
*/
|
||
export async function refundInvoice(params: {
|
||
invoiceId: string;
|
||
amountChf: number;
|
||
reason: string;
|
||
refundedBy: string;
|
||
/**
|
||
* Webhook path: a Stripe refund that has already been created
|
||
* (in the Stripe Dashboard or via a prior API call) and now needs
|
||
* to be mirrored into the portal. When set, the Stripe API call
|
||
* is skipped and the provided id/status are recorded as-is.
|
||
*/
|
||
existingStripeRefund?: {
|
||
id: string;
|
||
status: "pending" | "succeeded" | "failed" | "canceled";
|
||
};
|
||
}): Promise<CreditNote> {
|
||
const invoice = await getInvoiceById(params.invoiceId);
|
||
if (!invoice) {
|
||
throw new Error(`Invoice not found: ${params.invoiceId}`);
|
||
}
|
||
if (!["paid", "partially_refunded"].includes(invoice.status)) {
|
||
throw new RefundNotAllowedError(
|
||
`Cannot refund invoice in status '${invoice.status}'. Refunds are allowed only for paid invoices.`,
|
||
invoice.status
|
||
);
|
||
}
|
||
if (params.amountChf <= 0) {
|
||
throw new RefundNotAllowedError(
|
||
"Refund amount must be greater than zero.",
|
||
invoice.status
|
||
);
|
||
}
|
||
const remaining = roundChf(invoice.totalChf - invoice.refundedTotalChf);
|
||
if (params.amountChf - remaining > 0.005) {
|
||
// Allow a 0.005 tolerance to account for floating-point dust;
|
||
// anything genuinely larger is a real over-refund attempt.
|
||
throw new RefundNotAllowedError(
|
||
`Refund amount CHF ${params.amountChf.toFixed(2)} exceeds remaining refundable CHF ${remaining.toFixed(2)}.`,
|
||
invoice.status
|
||
);
|
||
}
|
||
const locale = pickSupportedLocale(invoice.locale);
|
||
|
||
// Proportional VAT split: refunded VAT / total VAT = refunded
|
||
// amount / total amount. Keep the proportion explicit so the
|
||
// credit note's "subtotal + VAT" lines reconcile to the same
|
||
// VAT rate as the original invoice.
|
||
const vatPortion =
|
||
invoice.totalChf > 0
|
||
? roundChf((params.amountChf * invoice.vatAmountChf) / invoice.totalChf)
|
||
: 0;
|
||
|
||
// Step 1: Stripe (only for card-paid invoices, and only when the
|
||
// caller hasn't already created the refund). We do this BEFORE
|
||
// any local DB writes for refund tracking — Stripe is the source
|
||
// of truth for money movement, and if the Stripe call fails we
|
||
// must NOT have recorded the refund locally (the customer would
|
||
// see a credit note for money they never received).
|
||
//
|
||
// The charge.refunded webhook will also fire later, but we record
|
||
// the refund here too so the admin gets immediate confirmation
|
||
// and the credit note can be issued without waiting for the
|
||
// webhook round-trip. The webhook is idempotent (dedups by
|
||
// stripe_refund_id) so it's safe to do both.
|
||
let stripeRefundId: string | null = null;
|
||
let stripeStatus: "pending" | "succeeded" | "failed" | "canceled" =
|
||
"succeeded";
|
||
const isStripePaid =
|
||
invoice.paymentMethod === "card" && !!invoice.stripePaymentIntentId;
|
||
if (params.existingStripeRefund) {
|
||
// Webhook path: don't call Stripe again; trust the provided id.
|
||
stripeRefundId = params.existingStripeRefund.id;
|
||
stripeStatus = params.existingStripeRefund.status;
|
||
} else if (isStripePaid) {
|
||
try {
|
||
const refund = await createInvoiceRefund({
|
||
paymentIntentId: invoice.stripePaymentIntentId!,
|
||
amountChf: params.amountChf,
|
||
reason: "requested_by_customer",
|
||
metadata: {
|
||
invoice_number: invoice.invoiceNumber,
|
||
refunded_by: params.refundedBy,
|
||
},
|
||
});
|
||
stripeRefundId = refund.id;
|
||
// Map Stripe statuses to our enum. Anything other than
|
||
// 'succeeded' or 'pending' is treated as a failure — we
|
||
// don't record the credit note in that case (see below).
|
||
if (refund.status === "succeeded") stripeStatus = "succeeded";
|
||
else if (refund.status === "pending") stripeStatus = "pending";
|
||
else if (refund.status === "canceled") stripeStatus = "canceled";
|
||
else stripeStatus = "failed";
|
||
} catch (e) {
|
||
throw new Error(
|
||
`Stripe refund failed: ${e instanceof Error ? e.message : String(e)}`
|
||
);
|
||
}
|
||
if (stripeStatus === "failed" || stripeStatus === "canceled") {
|
||
throw new Error(
|
||
`Stripe refund returned non-success status: ${stripeStatus}`
|
||
);
|
||
}
|
||
}
|
||
|
||
// Step 2: insert credit note (PDF still null at this point).
|
||
const creditNote = await createCreditNote({
|
||
invoiceId: invoice.id,
|
||
zitadelOrgId: invoice.zitadelOrgId,
|
||
kind: "refund",
|
||
amountChf: params.amountChf,
|
||
vatAmountChf: vatPortion,
|
||
reason: params.reason || null,
|
||
issuedBy: params.refundedBy,
|
||
locale,
|
||
billingSnapshot: invoice.billingSnapshot,
|
||
});
|
||
|
||
// Step 3: record the refund event and bump invoice status.
|
||
// recordInvoiceRefund handles status transitions and idempotency.
|
||
await recordInvoiceRefund({
|
||
invoiceId: invoice.id,
|
||
stripeRefundId,
|
||
amountChf: params.amountChf,
|
||
reason: params.reason || null,
|
||
refundedBy: params.refundedBy,
|
||
creditNoteId: creditNote.id,
|
||
status: stripeStatus,
|
||
});
|
||
|
||
// Step 4: render + attach PDF. As with voidInvoice, a PDF failure
|
||
// here doesn't undo the refund — the refund happened (in Stripe
|
||
// and the DB), only the document is missing. Admin can re-render.
|
||
try {
|
||
const pdfBuffer = await renderCreditNotePdf(creditNote, invoice);
|
||
const filename = `${creditNote.creditNoteNumber}.pdf`;
|
||
await attachCreditNotePdf(creditNote.id, pdfBuffer, filename);
|
||
} catch (e) {
|
||
console.error(
|
||
`Credit note ${creditNote.creditNoteNumber} created but PDF render failed; re-render manually.`,
|
||
e
|
||
);
|
||
}
|
||
|
||
// Step 5: best-effort email.
|
||
try {
|
||
const snap = invoice.billingSnapshot;
|
||
if (snap.billingEmail) {
|
||
await sendCreditNoteEmail({
|
||
to: snap.billingEmail,
|
||
contactName: snap.contactName || snap.companyName,
|
||
companyName: snap.companyName,
|
||
creditNoteNumber: creditNote.creditNoteNumber,
|
||
invoiceNumber: invoice.invoiceNumber,
|
||
amountChf: creditNote.amountChf,
|
||
currency: "CHF",
|
||
kind: "refund",
|
||
reason: params.reason || null,
|
||
locale,
|
||
});
|
||
}
|
||
} catch (e) {
|
||
console.error(
|
||
`Credit note ${creditNote.creditNoteNumber} issued; email send failed.`,
|
||
e
|
||
);
|
||
}
|
||
|
||
return creditNote;
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Phase 8 — custom invoices (admin-entered, ad-hoc)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
export class CustomInvoiceValidationError extends Error {
|
||
constructor(message: string) {
|
||
super(message);
|
||
this.name = "CustomInvoiceValidationError";
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Compute the totals for a custom-invoice draft payload, applying
|
||
* the same VAT logic the auto cron uses (vatRateForAddress against
|
||
* the org's billing snapshot).
|
||
*
|
||
* Returns the InvoiceDraft the createInvoice helper expects.
|
||
* Throws CustomInvoiceValidationError on:
|
||
* - no lines
|
||
* - any line with empty description or zero quantity
|
||
* - invalid date (issue or due)
|
||
* - issue date in past beyond 1 year (probably a typo)
|
||
* - due before issue
|
||
*
|
||
* Negative line amounts are intentionally allowed — they're the
|
||
* Rabatt / discount mechanism (one row with a negative unitPriceChf).
|
||
* The algebraic sum becomes the subtotal.
|
||
*/
|
||
export async function computeCustomInvoiceTotals(params: {
|
||
zitadelOrgId: string;
|
||
payload: CustomInvoiceDraftPayload;
|
||
}): Promise<InvoiceDraft> {
|
||
const { zitadelOrgId, payload } = params;
|
||
|
||
// Validation
|
||
if (!payload.lines || payload.lines.length === 0) {
|
||
throw new CustomInvoiceValidationError(
|
||
"Custom invoice must have at least one line."
|
||
);
|
||
}
|
||
for (let i = 0; i < payload.lines.length; i++) {
|
||
const ln = payload.lines[i];
|
||
if (!ln.description || !ln.description.trim()) {
|
||
throw new CustomInvoiceValidationError(
|
||
`Line ${i + 1}: description is required.`
|
||
);
|
||
}
|
||
if (
|
||
typeof ln.quantity !== "number" ||
|
||
!isFinite(ln.quantity) ||
|
||
ln.quantity === 0
|
||
) {
|
||
throw new CustomInvoiceValidationError(
|
||
`Line ${i + 1}: quantity must be a non-zero number.`
|
||
);
|
||
}
|
||
if (typeof ln.unitPriceChf !== "number" || !isFinite(ln.unitPriceChf)) {
|
||
throw new CustomInvoiceValidationError(
|
||
`Line ${i + 1}: unit price must be a number (use negative for discounts).`
|
||
);
|
||
}
|
||
}
|
||
const issueDate = payload.issueDate;
|
||
const dueDate = payload.dueDate;
|
||
if (!/^\d{4}-\d{2}-\d{2}$/.test(issueDate)) {
|
||
throw new CustomInvoiceValidationError(
|
||
"Issue date must be a valid YYYY-MM-DD."
|
||
);
|
||
}
|
||
if (!/^\d{4}-\d{2}-\d{2}$/.test(dueDate)) {
|
||
throw new CustomInvoiceValidationError(
|
||
"Due date must be a valid YYYY-MM-DD."
|
||
);
|
||
}
|
||
if (dueDate < issueDate) {
|
||
throw new CustomInvoiceValidationError(
|
||
"Due date cannot be before issue date."
|
||
);
|
||
}
|
||
|
||
// Billing snapshot — required for any invoice to render.
|
||
const orgBilling = await getOrgBilling(zitadelOrgId);
|
||
if (!orgBilling) {
|
||
throw new CustomInvoiceValidationError(
|
||
"Org has no billing configuration. Ask the customer to complete onboarding first, or set the billing info from the admin panel."
|
||
);
|
||
}
|
||
// Build the same snapshot shape the auto-cron freezes. Mirroring
|
||
// the auto flow keeps the PDF renderer happy with one code path.
|
||
const snapshot: InvoiceBillingSnapshot = {
|
||
companyName: orgBilling.companyName,
|
||
contactName: orgBilling.contactName ?? null,
|
||
streetAddress: orgBilling.streetAddress,
|
||
city: orgBilling.city,
|
||
postalCode: orgBilling.postalCode,
|
||
country: orgBilling.country,
|
||
vatNumber: orgBilling.vatNumber ?? null,
|
||
billingEmail: orgBilling.billingEmail,
|
||
notes: orgBilling.notes ?? null,
|
||
};
|
||
|
||
// VAT — same logic as auto.
|
||
const platformPricing = await getPlatformPricing();
|
||
const vat = vatRateForAddress(snapshot, platformPricing);
|
||
|
||
// Build invoice lines. quantity * unitPrice rounded to 2 decimals
|
||
// (rappen precision). We carry the per-line amount on the row so
|
||
// the PDF doesn't need to recompute and any rounding remains
|
||
// identical between rendering passes.
|
||
const lines: Omit<InvoiceLine, "id" | "invoiceId">[] = payload.lines.map(
|
||
(ln) => {
|
||
const amount = Math.round(ln.quantity * ln.unitPriceChf * 100) / 100;
|
||
return {
|
||
kind: "custom_line" as InvoiceLineKind,
|
||
description: ln.description.trim(),
|
||
quantity: ln.quantity,
|
||
unitPriceChf: ln.unitPriceChf,
|
||
amountChf: amount,
|
||
};
|
||
}
|
||
);
|
||
|
||
// 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, invoiceDraft.lines);
|
||
const filename = `${placeholder.invoiceNumber}.pdf`;
|
||
await updateInvoicePdf(placeholder.id, pdfBuffer, filename);
|
||
} catch (e) {
|
||
console.error(
|
||
`Custom invoice ${placeholder.invoiceNumber} persisted but PDF render failed:`,
|
||
e
|
||
);
|
||
// Don't throw — the row exists. Admin can re-render via a
|
||
// future tool (Phase 8.5 or just by deleting+reissuing).
|
||
}
|
||
|
||
// Best-effort email.
|
||
try {
|
||
const snap = invoiceDraft.billingSnapshot;
|
||
if (snap.billingEmail) {
|
||
await sendInvoiceIssuedEmail({
|
||
to: snap.billingEmail,
|
||
contactName: snap.contactName || snap.companyName,
|
||
companyName: snap.companyName,
|
||
invoiceNumber: placeholder.invoiceNumber,
|
||
totalChf: placeholder.totalChf,
|
||
currency: "CHF",
|
||
dueAt: placeholder.dueAt,
|
||
lineCount: invoiceDraft.lines.length,
|
||
periodStart: null,
|
||
periodEnd: null,
|
||
locale: invoiceDraft.locale as "de" | "en" | "fr" | "it",
|
||
});
|
||
}
|
||
} catch (e) {
|
||
console.error(
|
||
`Custom invoice ${placeholder.invoiceNumber} issued; email send failed.`,
|
||
e
|
||
);
|
||
}
|
||
|
||
// Draft did its job — remove it. If this fails the issuance
|
||
// still stands (we already have a real invoice). Log and move on.
|
||
try {
|
||
await deleteInvoiceDraft(draft.id);
|
||
} catch (e) {
|
||
console.error(
|
||
`Custom invoice ${placeholder.invoiceNumber} issued but draft ${draft.id} could not be deleted:`,
|
||
e
|
||
);
|
||
}
|
||
|
||
return placeholder;
|
||
}
|
||
|
||
/**
|
||
* Preview a draft as a PDF without persisting an invoice. The PDF
|
||
* is rendered with a placeholder number ("DRAFT") and not stored
|
||
* anywhere — the caller streams the bytes back to the admin's
|
||
* browser for review.
|
||
*
|
||
* Throws CustomInvoiceValidationError if the draft isn't ready to
|
||
* issue (no lines, missing billing snapshot, etc.) so the editor
|
||
* can surface the problem before any rendering work.
|
||
*/
|
||
export async function renderCustomDraftPreview(
|
||
draftId: string
|
||
): Promise<Buffer> {
|
||
const draft = await getInvoiceDraftById(draftId);
|
||
if (!draft) {
|
||
throw new CustomInvoiceValidationError(`Draft not found: ${draftId}`);
|
||
}
|
||
const invoiceDraft = await computeCustomInvoiceTotals({
|
||
zitadelOrgId: draft.zitadelOrgId,
|
||
payload: draft.payload,
|
||
});
|
||
|
||
// Render against a synthetic Invoice — same shape the persisted
|
||
// row would have, but with a DRAFT placeholder number. No DB
|
||
// writes. The PDF renderer doesn't care; it just consumes the
|
||
// Invoice + lines.
|
||
const fakeInvoice: Invoice = {
|
||
id: "preview",
|
||
invoiceNumber: "DRAFT",
|
||
zitadelOrgId: draft.zitadelOrgId,
|
||
source: "custom",
|
||
periodStart: null,
|
||
periodEnd: null,
|
||
issuedAt: invoiceDraft.issuedAt ?? new Date().toISOString(),
|
||
dueAt: invoiceDraft.dueAt,
|
||
subtotalChf: invoiceDraft.subtotalChf,
|
||
vatRate: invoiceDraft.vatRate,
|
||
vatAmountChf: invoiceDraft.vatAmountChf,
|
||
totalChf: invoiceDraft.totalChf,
|
||
status: "draft",
|
||
locale: invoiceDraft.locale,
|
||
paymentMethod: invoiceDraft.paymentMethod,
|
||
billingSnapshot: invoiceDraft.billingSnapshot,
|
||
stripePaymentIntentId: null,
|
||
pdfFilename: null,
|
||
hasPdf: false,
|
||
adminNotes: null,
|
||
paidAt: null,
|
||
paidBy: null,
|
||
paidMethodDetail: null,
|
||
voidReason: null,
|
||
voidedAt: null,
|
||
voidedBy: null,
|
||
refundedTotalChf: 0,
|
||
createdAt: new Date().toISOString(),
|
||
};
|
||
return renderInvoicePdf(fakeInvoice, invoiceDraft.lines);
|
||
}
|