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

@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from "next/server";
import { getSessionUser, canMutate } from "@/lib/session";
import {
getInvoiceById,
getTenantRequestById,
updateTenantRequestStatus,
updateTenantRequestEditableFields,
@@ -9,6 +10,8 @@ import { encryptSecrets } from "@/lib/crypto";
import { setTenantAnnotation } from "@/lib/k8s";
import { onboardingSchema } from "@/lib/validation";
import { safeError } from "@/lib/errors";
import { refundInvoice, RefundNotAllowedError } from "@/lib/billing";
import type { SessionUser, TenantRequest } from "@/types";
/**
* Customer-side controls for a single tenant_request row.
@@ -29,7 +32,7 @@ async function loadAuthorized(
id: string
): Promise<
| { error: NextResponse }
| { req: Awaited<ReturnType<typeof getTenantRequestById>>; }
| { req: TenantRequest; user: SessionUser }
> {
const user = await getSessionUser();
if (!user) {
@@ -55,7 +58,7 @@ async function loadAuthorized(
error: NextResponse.json({ error: "Not found" }, { status: 404 }),
};
}
return { req: tr };
return { req: tr, user };
}
/**
@@ -93,6 +96,50 @@ export async function DELETE(
try {
await updateTenantRequestStatus(id, "cancelled");
// Phase 9b: a 'pending' provision request has already had its
// setup fee charged (the order-time Checkout completed before
// the webhook flipped it to 'pending'). Cancelling it must
// refund that payment, exactly as an admin rejection does.
// Resume requests never carry a setup_invoice_id, so this only
// fires for provision orders. Best-effort: a refund failure is
// logged + surfaced but doesn't block the cancellation (admin
// can refund manually from the invoice page).
let refund: { attempted: boolean; succeeded: boolean; error?: string } = {
attempted: false,
succeeded: false,
};
if (tr.requestType === "provision" && tr.setupInvoiceId) {
refund.attempted = true;
try {
const inv = await getInvoiceById(tr.setupInvoiceId);
if (!inv) {
throw new Error(`Linked setup invoice ${tr.setupInvoiceId} not found`);
}
const remaining =
Math.round((inv.totalChf - (inv.refundedTotalChf ?? 0)) * 100) / 100;
if (remaining <= 0) {
refund.succeeded = true; // nothing left to refund
} else {
await refundInvoice({
invoiceId: tr.setupInvoiceId,
amountChf: remaining,
reason: "Order cancelled by customer",
refundedBy: loaded.user!.id,
});
refund.succeeded = true;
}
} catch (e: any) {
refund.error =
e instanceof RefundNotAllowedError
? e.message
: (e?.message ?? "refund failed");
console.error(
`Setup-fee refund failed for cancelled request ${id} (invoice ${tr.setupInvoiceId}):`,
e
);
}
}
// Customer cancels their own pending resume request: clear the
// operator-side annotation so the 60-day TTL resumes counting.
// Best-effort — the operator handles missing annotation gracefully.
@@ -111,7 +158,7 @@ export async function DELETE(
}
}
return NextResponse.json({ message: "Request cancelled.", id });
return NextResponse.json({ message: "Request cancelled.", id, refund });
} catch (e: any) {
console.error("Failed to cancel request:", e);
return NextResponse.json(

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." },