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

@@ -4,7 +4,7 @@ import { redirect } from "next/navigation";
import { OnboardingFlow } from "@/components/onboarding/onboarding-flow";
import { BackLink } from "@/components/ui/back-link";
import { listTenants } from "@/lib/k8s";
import { listActiveTenantRequestsByOrgId, getOrgBilling } from "@/lib/db";
import { listActiveTenantRequestsByOrgId, getOrgBilling, getPlatformPricing } from "@/lib/db";
import { personalAccountAtCapacity } from "@/lib/personal-org";
/**
@@ -55,7 +55,10 @@ export default async function NewInstancePage() {
}
const t = await getTranslations("dashboard");
const orgBilling = await getOrgBilling(user.orgId);
const [orgBilling, pricing] = await Promise.all([
getOrgBilling(user.orgId),
getPlatformPricing(),
]);
const hasOrgBilling = orgBilling !== null;
return (
@@ -77,6 +80,7 @@ export default async function NewInstancePage() {
userEmail={user.email}
hasOrgBilling={hasOrgBilling}
existingOrgBilling={orgBilling}
setupFeeChf={pricing.tenantSetupFeeChf}
/>
</div>
</div>

View File

@@ -6,6 +6,7 @@ import {
listActiveTenantRequestsByOrgId,
syncProvisioningStatuses,
getOrgBilling,
getPlatformPricing,
} from "@/lib/db";
import {
listVisibleTenants,
@@ -192,6 +193,7 @@ export default async function DashboardPage() {
// component.
const orgBilling = await getOrgBilling(user.orgId);
const hasOrgBilling = orgBilling !== null;
const platformPricing = await getPlatformPricing();
// Pending requests that don't yet have a tenant CR. Once the CR
// exists, the tenant card carries the live phase, so a separate
@@ -318,6 +320,7 @@ export default async function DashboardPage() {
userEmail={user.email}
hasOrgBilling={hasOrgBilling}
existingOrgBilling={orgBilling}
setupFeeChf={platformPricing.tenantSetupFeeChf}
/>
</div>
</div>

View File

@@ -1,51 +1,27 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { getSessionUser } from "@/lib/session";
import { setAutoChargeEnabled } from "@/lib/db";
import { safeError } from "@/lib/errors";
/**
* POST /api/billing/auto-charge
* POST /api/billing/auto-charge — RETIRED.
*
* Phase 9. Toggle the auto_charge_enabled flag on the caller's
* org. The body is `{ enabled: boolean }`.
* Auto-pay is no longer a customer-toggleable setting. A saved
* card on file is the consent to auto-bill; customers manage their
* card via update/remove on /settings/billing, nothing else. The
* auto_charge_enabled flag is now an admin-only pause used during
* disputes, set from /admin/billing/orgs.
*
* When OFF: invoices issued for this org won't trigger an
* auto-charge against the saved card. The customer pays
* manually (or admin marks paid) — same flow as a bank-transfer
* customer.
*
* When ON: future invoice issuance attempts the auto-charge.
* No effect if there's no saved card on file.
*
* Idempotent: setting OFF on an already-OFF flag is a no-op
* (same outcome).
* This route is kept as an explicit 410 (Gone) so any stale client
* that still POSTs here fails loudly rather than silently toggling
* a flag the customer shouldn't control. The old behaviour lived
* here through Phase 9b-2.
*/
const bodySchema = z.object({
enabled: z.boolean(),
});
export async function POST(request: Request) {
const user = await getSessionUser();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const body = await request.json().catch(() => ({}));
const parsed = bodySchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: "Invalid request", details: parsed.error.flatten() },
{ status: 400 }
);
}
try {
await setAutoChargeEnabled(user.orgId, parsed.data.enabled);
return NextResponse.json({ enabled: parsed.data.enabled });
} catch (e) {
return NextResponse.json(
{ error: safeError(e, "Failed to update auto-charge setting") },
{ status: 500 }
);
}
export async function POST() {
return NextResponse.json(
{
error:
"Auto-pay can no longer be disabled. A saved card is required for service. " +
"Contact support if you need to switch to bank-transfer billing.",
code: "auto_pay_not_toggleable",
},
{ status: 410 }
);
}

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