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

View File

@@ -93,6 +93,18 @@ const MIGRATION_SQL = `
-- is only meaningful for rejected and cancelled rows.
ALTER TABLE tenant_requests ADD COLUMN IF NOT EXISTS dismissed_at TIMESTAMPTZ;
-- Phase 9b: link a provision request to the paid setup-fee invoice
-- it was charged against at order time. Null on requests created
-- before Phase 9b, on resume requests, and during the brief
-- 'pending_payment' window before the Stripe webhook fires. The
-- admin reject flow refunds this invoice via the existing
-- refundInvoice helper.
ALTER TABLE tenant_requests
ADD COLUMN IF NOT EXISTS setup_invoice_id UUID REFERENCES invoices(id);
CREATE INDEX IF NOT EXISTS idx_tenant_requests_setup_invoice
ON tenant_requests(setup_invoice_id)
WHERE setup_invoice_id IS NOT NULL;
-- Feature 6: free-form customer note attached to the request.
-- Currently surfaced only by resume requests (where the customer
-- explains why they want reactivation), but the column is generic
@@ -1000,13 +1012,18 @@ export async function listTenantRequests(
status?: TenantRequestStatus
): Promise<TenantRequest[]> {
await ensureSchema();
// Phase 9b: 'pending_payment' rows are pre-Checkout: the customer
// submitted the wizard but hasn't paid the setup fee yet. They're
// invisible to admin until the webhook flips them to 'pending'.
// The explicit filter path still allows querying them (e.g.
// ?status=pending_payment) for debugging.
const result = status
? await getPool().query<TenantRequest>(
"SELECT * FROM tenant_requests WHERE status = $1 ORDER BY created_at DESC",
[status]
)
: await getPool().query<TenantRequest>(
"SELECT * FROM tenant_requests ORDER BY created_at DESC"
"SELECT * FROM tenant_requests WHERE status <> 'pending_payment' ORDER BY created_at DESC"
);
return result.rows.map(mapRow);
}
@@ -1431,6 +1448,7 @@ function mapRow(row: any): TenantRequest {
status: row.status as TenantRequestStatus,
adminNotes: row.admin_notes,
tenantName: row.tenant_name,
setupInvoiceId: row.setup_invoice_id ?? null,
encryptedSecrets: row.encrypted_secrets ?? null,
isPersonal: row.is_personal ?? false,
dismissedAt:
@@ -4131,3 +4149,146 @@ export async function getOrgIdByStripeCustomerId(
);
return result.rows.length > 0 ? result.rows[0].zitadel_org_id : null;
}
// ---------------------------------------------------------------------------
// Phase 9b — tenant order with setup-fee charge
// ---------------------------------------------------------------------------
/**
* Phase 9b: invoked by the Stripe webhook when the setup-fee
* Checkout for a tenant order completes. Atomically:
* - flips the request status from 'pending_payment' → 'pending'
* (admin queue now sees it)
* - sets tenant_name to the derived value (so monthly cron's
* setup-fee dedup works)
* - links the paid invoice via setup_invoice_id (so admin reject
* can refund it via the existing refund flow)
*
* Idempotent on the request side: if the webhook re-fires after
* the row already has status='pending', the UPDATE is a no-op
* (same values). On the rare case of webhook retry happening after
* admin already approved/rejected, the WHERE clause guards against
* regressing the status.
*/
export async function linkTenantRequestSetupPayment(params: {
requestId: string;
tenantName: string;
setupInvoiceId: string;
}): Promise<boolean> {
const result = await getPool().query(
`UPDATE tenant_requests
SET status = 'pending',
tenant_name = $2,
setup_invoice_id = $3,
updated_at = now()
WHERE id = $1
AND status = 'pending_payment'
RETURNING id`,
[params.requestId, params.tenantName, params.setupInvoiceId]
);
return (result.rowCount ?? 0) > 0;
}
/**
* Look up a tenant request by id without restricting by status —
* used by the webhook + reject handler. Caller is responsible for
* any role-gating; this is a pure read.
*/
export async function getTenantRequestForSetupFlow(
requestId: string
): Promise<TenantRequest | null> {
await ensureSchema();
const result = await getPool().query(
`SELECT * FROM tenant_requests WHERE id = $1`,
[requestId]
);
return result.rows.length > 0
? rowToTenantRequest(result.rows[0])
: null;
}
/**
* Insert a tenant request row in the 'pending_payment' status —
* used at order time, before the Stripe Checkout completes. Once
* payment succeeds the webhook flips it to 'pending' via
* linkTenantRequestSetupPayment.
*
* tenant_name stays NULL throughout pending_payment so the unique
* partial index uniq_tenant_requests_tenant_name_provision
* (WHERE tenant_name IS NOT NULL) doesn't block retries from
* abandoned Checkout sessions. The derived tenant_name is computed
* by the caller from the inserted row's id and stored only at
* webhook time.
*/
export async function createTenantRequestPendingPayment(params: {
zitadelOrgId: string;
zitadelUserId: string;
companyName: string;
instanceName?: string | null;
contactName: string;
contactEmail: string;
agentName: string;
soulMd?: string;
agentsMd?: string | null;
packages: string[];
billingAddress: Record<string, unknown>;
billingNotes?: string;
encryptedSecrets?: Buffer | null;
isPersonal: boolean;
}): Promise<TenantRequest> {
await ensureSchema();
const result = await getPool().query(
`INSERT INTO tenant_requests (
zitadel_org_id, zitadel_user_id,
company_name, instance_name, contact_name, contact_email,
agent_name, soul_md, agents_md, packages,
billing_address, billing_notes,
encrypted_secrets, is_personal,
status, request_type
) VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11::jsonb, $12,
$13, $14, 'pending_payment', 'provision'
)
RETURNING *`,
[
params.zitadelOrgId,
params.zitadelUserId,
params.companyName,
params.instanceName ?? null,
params.contactName,
params.contactEmail,
params.agentName,
params.soulMd ?? null,
params.agentsMd ?? null,
params.packages,
JSON.stringify(params.billingAddress),
params.billingNotes ?? null,
params.encryptedSecrets ?? null,
params.isPersonal,
]
);
return rowToTenantRequest(result.rows[0]);
}
/**
* Delete a pending_payment row — used when admin or system needs
* to clean up an abandoned order (e.g. Checkout session expired
* before the customer completed payment). Guarded: only deletes
* if status is still 'pending_payment' so we never accidentally
* delete a request that admin has already approved.
*
* Also nulls any setup_invoice_id reference before deleting so we
* don't leave dangling FK refs (we don't have ON DELETE behavior
* defined on the column).
*/
export async function deletePendingPaymentRequest(
requestId: string
): Promise<boolean> {
const result = await getPool().query(
`DELETE FROM tenant_requests
WHERE id = $1 AND status = 'pending_payment'
RETURNING id`,
[requestId]
);
return (result.rowCount ?? 0) > 0;
}

View File

@@ -1321,3 +1321,142 @@ export async function sendCreditNoteEmail(params: {
console.error("Failed to send credit note email:", err);
}
}
// ---------------------------------------------------------------------------
// Phase 9b-2 — auto-charge failure notice
// ---------------------------------------------------------------------------
/**
* Sent when an off-session auto-charge attempt fails for an issued
* invoice (card declined, expired, 3DS required, etc.). Customer
* receives this in their billing-snapshot locale. Contains:
* - Invoice number + amount + due date
* - Failure reason (a short human-readable string from Stripe)
* - Manual-pay link to /billing/<invoiceNumber> where they can
* run the regular Pay-by-Card flow (which uses
* setup_future_usage to also refresh the saved card)
*
* Critical: the failure reason from Stripe can contain sensitive
* details (card BIN, country, etc.). We pass a sanitized short
* string from the caller — never the full raw error.
*/
export async function sendAutoChargeFailedEmail(params: {
to: string;
contactName: string;
companyName: string;
invoiceNumber: string;
totalChf: number;
currency: string;
dueAt: string;
/**
* Short, customer-safe reason. e.g. "Your card was declined."
* or "Your card has expired." Caller maps Stripe error codes to
* these strings; we never pass raw API error messages.
*/
reasonForCustomer: string;
locale: "de" | "en" | "fr" | "it";
}): Promise<void> {
const L = params.locale;
const totalFmt = `${params.currency} ${params.totalChf.toFixed(2)}`;
const dueFmt = params.dueAt.slice(0, 10);
const baseUrl = process.env.APP_BASE_URL ?? "https://app.pieced.ch";
const link = `${baseUrl}/billing/${encodeURIComponent(params.invoiceNumber)}`;
const subjectsByLocale: Record<typeof L, string> = {
en: `Auto-charge failed for invoice ${params.invoiceNumber} — please pay manually`,
de: `Auto-Abbuchung fehlgeschlagen für Rechnung ${params.invoiceNumber} — bitte manuell bezahlen`,
fr: `Échec du prélèvement automatique pour la facture ${params.invoiceNumber} — merci de régler manuellement`,
it: `Addebito automatico fallito per la fattura ${params.invoiceNumber} — la preghiamo di pagare manualmente`,
};
const greetingsByLocale: Record<typeof L, string> = {
en: `Hello ${params.contactName},`,
de: `Sehr geehrte/r ${params.contactName},`,
fr: `Bonjour ${params.contactName},`,
it: `Gentile ${params.contactName},`,
};
const introByLocale: Record<typeof L, string> = {
en: `We were unable to charge your saved card for invoice ${params.invoiceNumber} (${params.companyName}).`,
de: `Wir konnten die Rechnung ${params.invoiceNumber} (${params.companyName}) nicht über die hinterlegte Karte abbuchen.`,
fr: `Nous n'avons pas pu débiter votre carte enregistrée pour la facture ${params.invoiceNumber} (${params.companyName}).`,
it: `Non siamo riusciti ad addebitare la carta salvata per la fattura ${params.invoiceNumber} (${params.companyName}).`,
};
const reasonLabel: Record<typeof L, string> = {
en: "Reason given by the card network",
de: "Vom Kartennetzwerk gemeldeter Grund",
fr: "Motif communiqué par le réseau de carte",
it: "Motivo comunicato dal circuito",
};
const actionLineByLocale: Record<typeof L, string> = {
en: `Please pay this invoice manually before ${dueFmt} to avoid service interruption. The "Pay with card" button below will both charge the invoice and update the card we have on file for future charges.`,
de: `Bitte begleichen Sie diese Rechnung manuell vor dem ${dueFmt}, um eine Unterbrechung Ihres Dienstes zu vermeiden. Die Schaltfläche "Mit Karte bezahlen" unten begleicht die Rechnung und aktualisiert gleichzeitig die hinterlegte Karte für zukünftige Abbuchungen.`,
fr: `Veuillez régler cette facture manuellement avant le ${dueFmt} pour éviter toute interruption du service. Le bouton "Payer par carte" ci-dessous règle la facture et met à jour la carte enregistrée pour les futurs prélèvements.`,
it: `La preghiamo di saldare questa fattura manualmente entro il ${dueFmt} per evitare interruzioni del servizio. Il pulsante "Paga con carta" qui sotto salda la fattura e aggiorna allo stesso tempo la carta in archivio per gli addebiti futuri.`,
};
const labels: Record<typeof L, Record<string, string>> = {
en: { number: "Invoice", total: "Total", due: "Due by", cta: "Pay with card", signoff: "Best regards", brand: "PieCed IT" },
de: { number: "Rechnung", total: "Gesamt", due: "Zahlbar bis", cta: "Mit Karte bezahlen", signoff: "Mit freundlichen Grüssen", brand: "PieCed IT" },
fr: { number: "Facture", total: "Total", due: "À régler avant", cta: "Payer par carte", signoff: "Cordialement", brand: "PieCed IT" },
it: { number: "Fattura", total: "Totale", due: "Scadenza", cta: "Paga con carta", signoff: "Cordiali saluti", brand: "PieCed IT" },
};
const l = labels[L];
const safeName = escapeHtml(params.contactName);
const safeCompany = escapeHtml(params.companyName);
const safeNumber = escapeHtml(params.invoiceNumber);
const safeReason = escapeHtml(params.reasonForCustomer);
const safeIntro = escapeHtml(introByLocale[L]);
const safeAction = escapeHtml(actionLineByLocale[L]);
try {
await getTransporter().sendMail({
from: getFrom(),
to: params.to,
subject: subjectsByLocale[L],
text: [
greetingsByLocale[L],
"",
introByLocale[L],
"",
`${l.number}: ${params.invoiceNumber}`,
`${l.total}: ${totalFmt}`,
`${l.due}: ${dueFmt}`,
"",
`${reasonLabel[L]}: ${params.reasonForCustomer}`,
"",
actionLineByLocale[L],
"",
`${l.cta}:`,
link,
"",
`${l.signoff},`,
l.brand,
].join("\n"),
html: `
<div style="font-family: -apple-system, BlinkMacSystemFont, sans-serif; max-width: 560px; padding: 24px; background: #1a1a1a; color: #e5e5e5;">
<h2 style="margin: 0 0 16px; color: #f59e0b;">${escapeHtml(subjectsByLocale[L])}</h2>
<p>${escapeHtml(greetingsByLocale[L])}</p>
<p>${safeIntro}</p>
<table style="width:100%; border-collapse:collapse; margin:16px 0; font-size:14px;">
<tr><td style="color:#888; padding:6px 0; width:120px;">${l.number}</td><td><strong>${safeNumber}</strong></td></tr>
<tr><td style="color:#888; padding:6px 0;">${l.total}</td><td style="color:#f59e0b; font-weight:600;">${escapeHtml(totalFmt)}</td></tr>
<tr><td style="color:#888; padding:6px 0;">${l.due}</td><td>${escapeHtml(dueFmt)}</td></tr>
</table>
<div style="background:#2a2a2a; border-left:3px solid #f59e0b; padding:10px 12px; margin:16px 0; font-size:13px;">
<strong>${escapeHtml(reasonLabel[L])}:</strong> ${safeReason}
</div>
<p style="font-size:14px;">${safeAction}</p>
<p>
<a href="${link}" style="display:inline-block; padding:10px 24px; background:#10B981; color:#fff; text-decoration:none; border-radius:8px; font-weight:500;">
${l.cta}
</a>
</p>
<p style="color:#888; font-size:12px; margin-top:24px;">
${l.signoff},<br />${l.brand}
</p>
</div>
`,
});
} catch (err) {
console.error("Failed to send auto-charge-failed email:", err);
}
}

View File

@@ -250,6 +250,15 @@ export async function createCheckoutSessionForInvoice(params: {
// since Stripe will prepend the merchant name from the
// account anyway. Keep it short and recognisable.
description: `Invoice ${invoice.invoiceNumber}`,
// Phase 9b-2: every manual Pay-by-Card refreshes the org's
// saved PaymentMethod. The webhook (payment-mode handler) is
// already wired to read setup_future_usage and persist the
// resulting PM's display fields against the org. Net effect:
// a customer whose auto-charge failed because their card
// expired pays manually once → fresh card is now saved →
// next month auto-charges work again. No separate "update
// card" step needed.
setup_future_usage: "off_session",
},
success_url: successUrl,
cancel_url: cancelUrl,
@@ -443,3 +452,201 @@ export async function getPaymentMethodDisplay(
expYear: typeof card.exp_year === "number" ? card.exp_year : null,
};
}
// ---------------------------------------------------------------------------
// Phase 9b — order-time setup-fee Checkout
// ---------------------------------------------------------------------------
/**
* Create a Stripe Checkout session that charges the setup-fee
* invoice immediately AND saves/refreshes the customer's
* PaymentMethod for future off-session use (recurring monthly
* charges).
*
* Same `mode: 'payment'` as the regular pay-invoice Checkout —
* the difference is:
* - metadata.flow = 'setup_fee' so the webhook knows to flip
* the tenant_request row from 'pending_payment' to 'pending'
* and link the invoice to it
* - metadata.tenant_request_id is the row to update
* - payment_intent_data.setup_future_usage = 'off_session' so
* the resulting PaymentMethod gets saved against the customer.
* Phase 9b-2's recurring auto-charge reads that PM id
*
* Success URL routes to /dashboard?ordered=1 (vs. the regular
* pay flow which lands on /billing/<invoiceNumber>). Cancel
* routes to /onboarding?cancelled=1 so the customer can retry.
*/
export async function createSetupFeeCheckoutSession(params: {
invoice: Invoice;
customerId: string;
baseUrl: string;
tenantRequestId: string;
}): Promise<{ url: string; sessionId: string }> {
const stripe = getStripeClient();
const { invoice, customerId, baseUrl, tenantRequestId } = params;
const stripeLocale =
invoice.locale === "de"
? ("de" as const)
: invoice.locale === "fr"
? ("fr" as const)
: invoice.locale === "it"
? ("it" as const)
: invoice.locale === "en"
? ("en" as const)
: ("auto" as const);
const successUrl = `${baseUrl}/dashboard?ordered=1&session_id={CHECKOUT_SESSION_ID}`;
const cancelUrl = `${baseUrl}/onboarding?cancelled=1`;
const session = await stripe.checkout.sessions.create({
mode: "payment",
customer: customerId,
client_reference_id: invoice.id,
locale: stripeLocale,
line_items: [
{
quantity: 1,
price_data: {
currency: "chf",
unit_amount: chfToRappen(invoice.totalChf),
product_data: {
name: `Setup fee — ${invoice.invoiceNumber}`,
description: `PieCed IT — tenant setup`,
},
},
},
],
payment_intent_data: {
// Save the resulting PaymentMethod against the customer for
// future off-session use (Phase 9b-2 recurring charges).
setup_future_usage: "off_session",
metadata: {
invoice_id: invoice.id,
invoice_number: invoice.invoiceNumber,
zitadel_org_id: invoice.zitadelOrgId,
},
},
metadata: {
invoice_id: invoice.id,
invoice_number: invoice.invoiceNumber,
zitadel_org_id: invoice.zitadelOrgId,
// Phase 9b discriminators — webhook reads these to do the
// tenant_request linkage on top of the regular invoice-paid
// flow.
flow: "setup_fee",
tenant_request_id: tenantRequestId,
},
success_url: successUrl,
cancel_url: cancelUrl,
});
if (!session.url) {
throw new Error(
`Stripe returned a setup-fee session without a redirect URL (id=${session.id})`
);
}
return { url: session.url, sessionId: session.id };
}
// ---------------------------------------------------------------------------
// Phase 9b-2 — off-session auto-charge for issued invoices
// ---------------------------------------------------------------------------
/**
* Attempt to charge an invoice off-session against the customer's
* saved PaymentMethod. Used by chargeInvoiceIfPossible() from
* generateInvoice (monthly) and issueCustomInvoiceDraft (admin
* custom).
*
* Stripe semantics with `off_session: true, confirm: true`:
* - On success: PaymentIntent.status = 'succeeded', card was
* charged. Returns 'succeeded'.
* - On 3DS required: PaymentIntent.status = 'requires_action'.
* We can't complete this off-session. Customer must pay
* manually via Checkout (which handles 3DS in-browser).
* Returns 'requires_action'.
* - On hard decline: thrown StripeCardError, code = 'card_declined'
* or 'insufficient_funds' etc. Returns 'declined' with the
* error code.
* - On expired card or other recoverable issue: thrown
* StripeCardError. Returns 'declined' with the code.
*
* The receipt_email is set to the org's billing email so Stripe
* sends the customer an automated receipt on success — we don't
* need to send our own "you've been charged" email.
*/
export type ChargeOutcome =
| { status: "succeeded"; paymentIntentId: string }
| { status: "requires_action"; paymentIntentId: string; reason: string }
| { status: "declined"; reason: string; code?: string };
export async function chargeInvoiceOffSession(params: {
invoice: Invoice;
customerId: string;
paymentMethodId: string;
/**
* If set, Stripe emails an automated receipt here on successful
* capture. We use the org's billing snapshot email so the receipt
* goes to the same address as the issued / failed emails.
*/
receiptEmail?: string | null;
}): Promise<ChargeOutcome> {
const stripe = getStripeClient();
const { invoice, customerId, paymentMethodId, receiptEmail } = params;
try {
const pi = await stripe.paymentIntents.create({
amount: chfToRappen(invoice.totalChf),
currency: "chf",
customer: customerId,
payment_method: paymentMethodId,
off_session: true,
confirm: true,
description: `Invoice ${invoice.invoiceNumber}`,
receipt_email: receiptEmail ?? undefined,
metadata: {
invoice_id: invoice.id,
invoice_number: invoice.invoiceNumber,
zitadel_org_id: invoice.zitadelOrgId,
flow: "auto_charge",
},
});
if (pi.status === "succeeded") {
return { status: "succeeded", paymentIntentId: pi.id };
}
if (pi.status === "requires_action") {
return {
status: "requires_action",
paymentIntentId: pi.id,
reason: "Authentication required (3DS). Customer must pay via Checkout.",
};
}
// Any other non-succeeded status (rare with off_session+confirm)
// is treated as a failure for our purposes.
return {
status: "declined",
reason: `Unexpected PaymentIntent status: ${pi.status}`,
};
} catch (e: any) {
// Stripe's off-session declines surface as a StripeCardError
// with the PI on e.payment_intent. The 'code' (e.g.
// 'card_declined', 'expired_card', 'authentication_required')
// is the most actionable signal; e.message is human-readable.
const code: string | undefined = e?.code ?? e?.raw?.code;
const message: string =
e?.message ?? e?.raw?.message ?? "Card was declined.";
// authentication_required is technically a "decline" from the
// off-session path even though it could succeed on-session.
// Surface it distinctly so the caller can tell the customer to
// go pay manually (which will use Checkout + handle 3DS).
if (code === "authentication_required") {
const piId = e?.payment_intent?.id ?? "";
return {
status: "requires_action",
paymentIntentId: piId,
reason: "Authentication required (3DS). Customer must pay via Checkout.",
};
}
return { status: "declined", reason: message, code };
}
}