Phase8: Auto bill credit card
All checks were successful
Build and Push / build (push) Successful in 1m48s

This commit is contained in:
2026-05-28 21:29:15 +02:00
parent 9243beddd3
commit 3fe3597553
13 changed files with 208 additions and 160 deletions

View File

@@ -27,7 +27,7 @@ import {
createSetupFeeCheckoutSession,
ensureStripeCustomerForOrg,
} from "@/lib/stripe";
import { createTenantSetupFeeInvoice } from "@/lib/billing";
import { createTenantSetupFeeInvoice, voidInvoice } from "@/lib/billing";
import { deriveTenantName } from "@/lib/tenant-naming";
import type {
InvoiceBillingSnapshot,
@@ -417,21 +417,23 @@ export async function POST(request: Request) {
);
}
// Phase 9b: enforce auto-pay before accepting an order. If the
// org has no saved card OR has explicitly disabled auto-charge,
// the order can't proceed — return 402 with a link to the
// settings page where they can set up auto-pay. The wizard
// surfaces this as a friendly redirect rather than an error.
// Phase 9b (revised): a saved card on file IS the consent to
// auto-bill. There is no customer-facing "disable auto-pay"
// switch — ordering requires a card, full stop. The
// auto_charge_enabled flag is now an admin-only pause (used
// during disputes) and does NOT block a customer from ordering:
// if admin has paused recurring charges, that's a separate
// concern handled on the invoice side, not here. So the gate is
// simply: do they have a card on file?
const cfg = await getOrgBillingConfig(user.orgId);
const hasSavedCard = !!cfg.stripeDefaultPaymentMethodId;
const autoChargeOn = cfg.autoChargeEnabled !== false;
if (!hasSavedCard || !autoChargeOn) {
if (!hasSavedCard) {
return NextResponse.json(
{
error:
"Auto-pay must be set up before ordering a new instance. " +
"Please save a card and ensure auto-pay is enabled on /settings/billing.",
code: "auto_pay_required",
"A payment card is required before ordering a new instance. " +
"Please save a card on /settings/billing, then submit again.",
code: "card_required",
redirectTo: "/settings/billing",
},
{ status: 402 }
@@ -611,7 +613,24 @@ export async function POST(request: Request) {
checkoutUrl = url;
} catch (e) {
console.error("Failed to create setup-fee Checkout session:", e);
// Roll back the pending_payment row.
// Roll back BOTH the pending_payment row and the setup invoice
// we already created. The invoice was issued in 'open' status
// but no payment will ever arrive (Checkout never started), so
// void it to keep the ledger clean — an open invoice with no
// route to payment would otherwise linger and show up in
// arrears reports. Void (not delete) preserves the audit trail
// and the void reason. Best-effort: a void failure is logged
// but doesn't change the 500 we return.
await voidInvoice({
invoiceId: setupInvoice.id,
reason: "Order abandoned before payment (Checkout could not be started)",
voidedBy: user.id,
}).catch((ve) =>
console.error(
`Failed to void orphaned setup invoice ${setupInvoice.id}:`,
ve
)
);
await deletePendingPaymentRequest(tenantRequest.id).catch(() => undefined);
return NextResponse.json(
{ error: "Failed to start payment. Please try again." },