This commit is contained in:
@@ -60,8 +60,10 @@ import {
|
||||
listSkillEventsForTenant,
|
||||
listSkillPricing,
|
||||
listSuspensionEventsForTenant,
|
||||
markInvoicePaid,
|
||||
markInvoiceVoided,
|
||||
recordInvoiceRefund,
|
||||
setInvoiceStripePaymentIntent,
|
||||
tenantHasSetupFeeBilled,
|
||||
tenantSkillHasBeenBilled,
|
||||
updateInvoicePdf,
|
||||
@@ -71,8 +73,12 @@ import { getTeamSpendLogsV2 } from "./litellm";
|
||||
import { getUsage as getThreemaUsage } from "./threema-relay";
|
||||
import { renderInvoicePdf } from "./billing-pdf";
|
||||
import { renderCreditNotePdf } from "./credit-note-pdf";
|
||||
import { sendCreditNoteEmail, sendInvoiceIssuedEmail } from "./email";
|
||||
import { createInvoiceRefund } from "./stripe";
|
||||
import {
|
||||
sendAutoChargeFailedEmail,
|
||||
sendCreditNoteEmail,
|
||||
sendInvoiceIssuedEmail,
|
||||
} from "./email";
|
||||
import { chargeInvoiceOffSession, createInvoiceRefund } from "./stripe";
|
||||
import { formatLineDescription } from "./billing-i18n";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -796,50 +802,90 @@ export async function generateInvoice(opts: {
|
||||
await updateInvoicePdf(placeholder.id, pdfBuffer, filename);
|
||||
const finalInvoice = await getInvoiceById(placeholder.id);
|
||||
|
||||
// Phase 3: best-effort notification to the billing contact.
|
||||
// We send AFTER the PDF is fully persisted (so the deep link
|
||||
// in the email immediately resolves to a downloadable PDF) but
|
||||
// BEFORE returning, since the cron caller doesn't otherwise
|
||||
// know to trigger this. Failure is logged, never thrown — a
|
||||
// mail-server hiccup must not roll back an issued invoice.
|
||||
// The recipient is the billing email captured in the invoice
|
||||
// snapshot (immutable; reflects who was on file at issue time).
|
||||
try {
|
||||
const settled = finalInvoice ?? placeholder;
|
||||
const snapshot = settled.billingSnapshot;
|
||||
if (snapshot.billingEmail) {
|
||||
const supportedLocales: Array<"en" | "de" | "fr" | "it"> = [
|
||||
"en", "de", "fr", "it",
|
||||
];
|
||||
const locale = supportedLocales.includes(settled.locale as any)
|
||||
? (settled.locale as "en" | "de" | "fr" | "it")
|
||||
: "de";
|
||||
await sendInvoiceIssuedEmail({
|
||||
to: snapshot.billingEmail,
|
||||
contactName: snapshot.companyName, // no separate contact-name field
|
||||
companyName: snapshot.companyName,
|
||||
invoiceNumber: settled.invoiceNumber,
|
||||
totalChf: settled.totalChf,
|
||||
currency: "CHF",
|
||||
dueAt: settled.dueAt,
|
||||
lineCount: draft.lines.length,
|
||||
periodStart: settled.periodStart,
|
||||
periodEnd: settled.periodEnd,
|
||||
locale,
|
||||
});
|
||||
} else {
|
||||
console.warn(
|
||||
`Invoice ${settled.invoiceNumber} issued but billing snapshot has no email — notification skipped.`
|
||||
// Phase 9b-2: attempt off-session auto-charge BEFORE sending
|
||||
// any email. This drives which email goes out:
|
||||
// - Charge succeeded: skip the "your invoice is ready" email
|
||||
// (would be misleading — invoice is already paid). Stripe
|
||||
// sends an automated receipt to billingSnapshot.billingEmail.
|
||||
// - Charge failed: send the auto-charge-failed email instead
|
||||
// of the regular issued email (clear action: pay manually).
|
||||
// - Charge skipped (pay_by_invoice / no card / disabled):
|
||||
// send the regular "your invoice is ready" email — that's
|
||||
// the only signal the customer gets.
|
||||
const chargeOutcome = await chargeInvoiceIfPossible(placeholder.id);
|
||||
const settled =
|
||||
chargeOutcome.kind === "succeeded"
|
||||
? (await getInvoiceById(placeholder.id)) ?? finalInvoice ?? placeholder
|
||||
: finalInvoice ?? placeholder;
|
||||
const supportedLocales: Array<"en" | "de" | "fr" | "it"> = [
|
||||
"en", "de", "fr", "it",
|
||||
];
|
||||
const emailLocale = supportedLocales.includes(settled.locale as any)
|
||||
? (settled.locale as "en" | "de" | "fr" | "it")
|
||||
: "de";
|
||||
const snapshot = settled.billingSnapshot;
|
||||
|
||||
if (chargeOutcome.kind === "succeeded") {
|
||||
console.log(
|
||||
`Invoice ${settled.invoiceNumber} auto-charged successfully (intent ${chargeOutcome.paymentIntentId}); Stripe receipt handles customer email.`
|
||||
);
|
||||
} else if (chargeOutcome.kind === "failed") {
|
||||
// Send the auto-charge-failed email (not the regular issued
|
||||
// email). The customer should be told the charge failed and
|
||||
// pointed to the manual-pay flow.
|
||||
try {
|
||||
if (snapshot.billingEmail) {
|
||||
await sendAutoChargeFailedEmail({
|
||||
to: snapshot.billingEmail,
|
||||
contactName: snapshot.companyName,
|
||||
companyName: snapshot.companyName,
|
||||
invoiceNumber: settled.invoiceNumber,
|
||||
totalChf: settled.totalChf,
|
||||
currency: "CHF",
|
||||
dueAt: settled.dueAt,
|
||||
reasonForCustomer: chargeOutcome.reasonForCustomer,
|
||||
locale: emailLocale,
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(
|
||||
`Invoice ${settled.invoiceNumber} auto-charge failed; failed-charge email also failed:`,
|
||||
e
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// Skipped — pay-by-invoice / disabled / no card. Send the
|
||||
// regular issued email so the customer knows there's
|
||||
// something to pay.
|
||||
try {
|
||||
if (snapshot.billingEmail) {
|
||||
await sendInvoiceIssuedEmail({
|
||||
to: snapshot.billingEmail,
|
||||
contactName: snapshot.companyName,
|
||||
companyName: snapshot.companyName,
|
||||
invoiceNumber: settled.invoiceNumber,
|
||||
totalChf: settled.totalChf,
|
||||
currency: "CHF",
|
||||
dueAt: settled.dueAt,
|
||||
lineCount: draft.lines.length,
|
||||
periodStart: settled.periodStart,
|
||||
periodEnd: settled.periodEnd,
|
||||
locale: emailLocale,
|
||||
});
|
||||
} else {
|
||||
console.warn(
|
||||
`Invoice ${settled.invoiceNumber} issued but billing snapshot has no email — notification skipped.`
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(
|
||||
`Invoice ${placeholder.invoiceNumber} issued; notification email failed:`,
|
||||
e
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(
|
||||
`Invoice ${placeholder.invoiceNumber} issued; notification email failed:`,
|
||||
e
|
||||
);
|
||||
}
|
||||
|
||||
return { draft, invoice: finalInvoice ?? placeholder };
|
||||
return { draft, invoice: settled };
|
||||
} catch (e) {
|
||||
// Render failed — leave the persisted row in place so admin can
|
||||
// inspect it, but surface the error.
|
||||
@@ -1435,29 +1481,67 @@ export async function issueCustomInvoiceDraft(params: {
|
||||
// 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
|
||||
// Phase 9b-2: same auto-charge + email branching as the cron
|
||||
// path. Custom invoices go through the same gate: pay_by_invoice
|
||||
// / auto_charge_enabled / saved card determine whether we attempt
|
||||
// the charge.
|
||||
const chargeOutcome = await chargeInvoiceIfPossible(placeholder.id);
|
||||
const settledCustom =
|
||||
chargeOutcome.kind === "succeeded"
|
||||
? (await getInvoiceById(placeholder.id)) ?? placeholder
|
||||
: placeholder;
|
||||
|
||||
if (chargeOutcome.kind === "succeeded") {
|
||||
console.log(
|
||||
`Custom invoice ${settledCustom.invoiceNumber} auto-charged successfully (intent ${chargeOutcome.paymentIntentId}); Stripe receipt handles customer email.`
|
||||
);
|
||||
} else if (chargeOutcome.kind === "failed") {
|
||||
try {
|
||||
const snap = invoiceDraft.billingSnapshot;
|
||||
if (snap.billingEmail) {
|
||||
await sendAutoChargeFailedEmail({
|
||||
to: snap.billingEmail,
|
||||
contactName: snap.contactName || snap.companyName,
|
||||
companyName: snap.companyName,
|
||||
invoiceNumber: settledCustom.invoiceNumber,
|
||||
totalChf: settledCustom.totalChf,
|
||||
currency: "CHF",
|
||||
dueAt: settledCustom.dueAt,
|
||||
reasonForCustomer: chargeOutcome.reasonForCustomer,
|
||||
locale: invoiceDraft.locale as "de" | "en" | "fr" | "it",
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(
|
||||
`Custom invoice ${settledCustom.invoiceNumber} auto-charge failed; failed-charge email also failed:`,
|
||||
e
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// Skipped — send the regular issued email.
|
||||
try {
|
||||
const snap = invoiceDraft.billingSnapshot;
|
||||
if (snap.billingEmail) {
|
||||
await sendInvoiceIssuedEmail({
|
||||
to: snap.billingEmail,
|
||||
contactName: snap.contactName || snap.companyName,
|
||||
companyName: snap.companyName,
|
||||
invoiceNumber: settledCustom.invoiceNumber,
|
||||
totalChf: settledCustom.totalChf,
|
||||
currency: "CHF",
|
||||
dueAt: settledCustom.dueAt,
|
||||
lineCount: invoiceDraft.lines.length,
|
||||
periodStart: null,
|
||||
periodEnd: null,
|
||||
locale: invoiceDraft.locale as "de" | "en" | "fr" | "it",
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(
|
||||
`Custom invoice ${settledCustom.invoiceNumber} issued; email send failed.`,
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Draft did its job — remove it. If this fails the issuance
|
||||
@@ -1471,7 +1555,7 @@ export async function issueCustomInvoiceDraft(params: {
|
||||
);
|
||||
}
|
||||
|
||||
return placeholder;
|
||||
return settledCustom;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1539,3 +1623,240 @@ export async function renderCustomDraftPreview(
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Phase 9b — tenant setup-fee invoice at order time
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Build and persist the one-line custom invoice that captures
|
||||
* the tenant setup fee at order time. The customer is then
|
||||
* redirected to Stripe Checkout to pay it.
|
||||
*
|
||||
* - source = 'custom' so the monthly cron's per-period uniqueness
|
||||
* guard (partial index WHERE source='auto') doesn't interfere
|
||||
* - line.kind = 'tenant_setup' so the monthly cron's setup-fee
|
||||
* dedup (tenantHasSetupFeeBilled) sees this as the setup fee
|
||||
* billing event for the future tenant
|
||||
* - line.tenant_name = the derived name (computed from request id
|
||||
* via deriveTenantName) so the dedup query finds the line
|
||||
* - period_start / period_end stay null (no billing period)
|
||||
* - issuedAt = now (no override)
|
||||
* - dueAt = same day (charge happens immediately via Checkout)
|
||||
*
|
||||
* VAT uses the same vatRateForAddress() logic as the monthly cron
|
||||
* and the admin custom-invoice flow.
|
||||
*/
|
||||
export async function createTenantSetupFeeInvoice(params: {
|
||||
zitadelOrgId: string;
|
||||
tenantName: string;
|
||||
billingSnapshot: InvoiceBillingSnapshot;
|
||||
locale: "de" | "en" | "fr" | "it";
|
||||
paymentMethod: InvoicePaymentMethod;
|
||||
}): Promise<Invoice> {
|
||||
const platformPricing = await getPlatformPricing();
|
||||
const setupFeeChf = platformPricing.tenantSetupFeeChf;
|
||||
if (setupFeeChf <= 0) {
|
||||
throw new Error(
|
||||
"createTenantSetupFeeInvoice called but tenant_setup_fee_chf is 0 — caller should skip the charge flow entirely."
|
||||
);
|
||||
}
|
||||
|
||||
const vat = vatRateForAddress(params.billingSnapshot, platformPricing);
|
||||
const subtotalChf = setupFeeChf;
|
||||
const vatAmountChf = Math.round(subtotalChf * (vat.rate / 100) * 100) / 100;
|
||||
const totalChf = Math.round((subtotalChf + vatAmountChf) * 100) / 100;
|
||||
|
||||
// tenant_name on the line is the dedup anchor. metadata empty —
|
||||
// tenant_setup lines from the monthly cron also carry no metadata
|
||||
// beyond what billing-i18n needs, which is just the kind itself.
|
||||
const lines: Omit<InvoiceLine, "id" | "invoiceId">[] = [
|
||||
{
|
||||
tenantName: params.tenantName,
|
||||
kind: "tenant_setup" as InvoiceLineKind,
|
||||
description: formatLineDescription(
|
||||
{ kind: "tenant_setup", tenantName: params.tenantName, metadata: null },
|
||||
params.locale
|
||||
),
|
||||
quantity: 1,
|
||||
unitLabel: null,
|
||||
unitPriceChf: setupFeeChf,
|
||||
amountChf: setupFeeChf,
|
||||
metadata: null,
|
||||
displayOrder: 0,
|
||||
},
|
||||
];
|
||||
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
|
||||
const draft: InvoiceDraft = {
|
||||
zitadelOrgId: params.zitadelOrgId,
|
||||
source: "custom",
|
||||
periodStart: null,
|
||||
periodEnd: null,
|
||||
issuedAt: undefined, // let createInvoice default to now()
|
||||
dueAt: today,
|
||||
locale: params.locale,
|
||||
paymentMethod: params.paymentMethod,
|
||||
billingSnapshot: params.billingSnapshot,
|
||||
lines,
|
||||
subtotalChf,
|
||||
vatRate: vat.rate,
|
||||
vatAmountChf,
|
||||
totalChf,
|
||||
warnings: [],
|
||||
};
|
||||
|
||||
// Persist without PDF — the PDF render here would block the
|
||||
// Checkout redirect path and isn't needed for the customer's
|
||||
// payment step. Render lazily after payment succeeds (Phase 9c
|
||||
// candidate); for now the invoice carries no PDF until then.
|
||||
// It'll still appear on /billing for the customer; the download
|
||||
// button will be disabled (hasPdf = false) until a render lands.
|
||||
const invoice = await createInvoice(draft, null, null);
|
||||
|
||||
// Best-effort: render the PDF asynchronously so the customer
|
||||
// has it on /billing soon after paying. The async fire-and-
|
||||
// forget pattern: failures only log, the invoice row stays
|
||||
// valid either way.
|
||||
renderInvoicePdf(
|
||||
invoice,
|
||||
lines.map((l, i) => ({
|
||||
...l,
|
||||
id: `tmp-${i}`,
|
||||
invoiceId: invoice.id,
|
||||
}))
|
||||
)
|
||||
.then((pdf) =>
|
||||
updateInvoicePdf(invoice.id, pdf, `${invoice.invoiceNumber}.pdf`)
|
||||
)
|
||||
.catch((e) =>
|
||||
console.error(
|
||||
`Setup-fee invoice ${invoice.invoiceNumber} PDF render failed (async):`,
|
||||
e
|
||||
)
|
||||
);
|
||||
|
||||
return invoice;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Phase 9b-2 — recurring off-session auto-charge
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type AutoChargeOutcome =
|
||||
| { kind: "skipped"; reason: string }
|
||||
| { kind: "succeeded"; paymentIntentId: string }
|
||||
| { kind: "failed"; reasonForCustomer: string; code?: string };
|
||||
|
||||
/**
|
||||
* Reduce a Stripe decline code into a short, locale-neutral string
|
||||
* the customer can read. We never put the raw Stripe message in
|
||||
* an email (it can leak BIN, country, etc.); this maps known codes
|
||||
* to safe equivalents and falls back to a generic "card was
|
||||
* declined" string for unknown codes.
|
||||
*
|
||||
* Phase 9b-2 keeps this in English only — the email template
|
||||
* translates the surrounding copy, and the reason itself is short
|
||||
* enough that admin can decide later whether to localize it.
|
||||
*/
|
||||
function describeDeclineCode(code: string | undefined, fallback: string): string {
|
||||
if (!code) return fallback;
|
||||
const map: Record<string, string> = {
|
||||
card_declined: "Card was declined by the issuer.",
|
||||
expired_card: "Card has expired.",
|
||||
insufficient_funds: "Insufficient funds.",
|
||||
incorrect_cvc: "Card security code (CVC) was incorrect.",
|
||||
processing_error: "Card processing error at the issuer.",
|
||||
authentication_required: "Authentication required (3D Secure).",
|
||||
do_not_honor: "Card was declined by the issuer (do not honor).",
|
||||
pickup_card: "Card cannot be used — please contact the issuer.",
|
||||
lost_card: "Card was reported lost.",
|
||||
stolen_card: "Card was reported stolen.",
|
||||
generic_decline: "Card was declined.",
|
||||
};
|
||||
return map[code] ?? fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide whether an invoice can be auto-charged and attempt it.
|
||||
*
|
||||
* Gates (in order — first match wins):
|
||||
* 1. Invoice not in 'open' status → skip ("not_open")
|
||||
* 2. org_billing_config.pay_by_invoice = true → skip ("pay_by_invoice")
|
||||
* (admin override for bank-transfer customers)
|
||||
* 3. org_billing_config.auto_charge_enabled = false → skip ("disabled")
|
||||
* 4. No saved payment method id → skip ("no_card")
|
||||
* 5. No Stripe customer id → skip ("no_customer") — shouldn't happen
|
||||
* if PM is saved (the setup flow creates one) but defensive
|
||||
*
|
||||
* On charge attempt:
|
||||
* - succeeded: markInvoicePaid + return outcome
|
||||
* - declined / requires_action: leave invoice open, return reason
|
||||
* for the caller to send the auto-charge-failed email
|
||||
*
|
||||
* This function is idempotent on the invoice side (markInvoicePaid
|
||||
* is a no-op if already paid). Calling twice in rapid succession
|
||||
* may cause two Stripe charges if both attempts pass the gates —
|
||||
* the caller (generateInvoice / issueCustomInvoiceDraft) only
|
||||
* calls once per issuance and is the natural single-shot guard.
|
||||
*/
|
||||
export async function chargeInvoiceIfPossible(
|
||||
invoiceId: string
|
||||
): Promise<AutoChargeOutcome> {
|
||||
const invoice = await getInvoiceById(invoiceId);
|
||||
if (!invoice) {
|
||||
return { kind: "skipped", reason: "invoice_not_found" };
|
||||
}
|
||||
if (invoice.status !== "open") {
|
||||
return { kind: "skipped", reason: `not_open (status=${invoice.status})` };
|
||||
}
|
||||
|
||||
const cfg = await getOrgBillingConfig(invoice.zitadelOrgId);
|
||||
if (cfg.payByInvoice) {
|
||||
return { kind: "skipped", reason: "pay_by_invoice" };
|
||||
}
|
||||
if (cfg.autoChargeEnabled === false) {
|
||||
return { kind: "skipped", reason: "disabled" };
|
||||
}
|
||||
if (!cfg.stripeDefaultPaymentMethodId) {
|
||||
return { kind: "skipped", reason: "no_card" };
|
||||
}
|
||||
if (!cfg.stripeCustomerId) {
|
||||
return { kind: "skipped", reason: "no_customer" };
|
||||
}
|
||||
|
||||
const outcome = await chargeInvoiceOffSession({
|
||||
invoice,
|
||||
customerId: cfg.stripeCustomerId,
|
||||
paymentMethodId: cfg.stripeDefaultPaymentMethodId,
|
||||
receiptEmail: invoice.billingSnapshot.billingEmail ?? null,
|
||||
});
|
||||
|
||||
if (outcome.status === "succeeded") {
|
||||
// Persist the PI id + flip to paid in one shot. markInvoicePaid
|
||||
// is idempotent (returns null if already paid).
|
||||
await setInvoiceStripePaymentIntent(invoice.id, outcome.paymentIntentId);
|
||||
await markInvoicePaid(invoice.id, {
|
||||
paidBy: "stripe",
|
||||
paidMethodDetail: `Auto-charge (${outcome.paymentIntentId})`,
|
||||
});
|
||||
return { kind: "succeeded", paymentIntentId: outcome.paymentIntentId };
|
||||
}
|
||||
|
||||
// Map outcome to a customer-safe reason string.
|
||||
if (outcome.status === "requires_action") {
|
||||
return {
|
||||
kind: "failed",
|
||||
reasonForCustomer:
|
||||
"Authentication required (3D Secure). Please pay manually so your bank can complete verification.",
|
||||
code: "authentication_required",
|
||||
};
|
||||
}
|
||||
// declined
|
||||
return {
|
||||
kind: "failed",
|
||||
reasonForCustomer: describeDeclineCode(outcome.code, outcome.reason),
|
||||
code: outcome.code,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user