Phase8: Auto bill credit card
Some checks failed
Build and Push / build (push) Failing after 42s

This commit is contained in:
2026-05-27 22:06:32 +02:00
parent ad4f614130
commit ee6bb89fb6
20 changed files with 1857 additions and 122 deletions

View File

@@ -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,
};
}