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,
|
||||
};
|
||||
}
|
||||
|
||||
163
src/lib/db.ts
163
src/lib/db.ts
@@ -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;
|
||||
}
|
||||
|
||||
139
src/lib/email.ts
139
src/lib/email.ts
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user