From 3fe35975534fe3ea71c477bef0ffec0ecfc792b6 Mon Sep 17 00:00:00 2001
From: admin
Date: Thu, 28 May 2026 21:29:15 +0200
Subject: [PATCH] Phase8: Auto bill credit card
---
src/app/[locale]/dashboard/new/page.tsx | 8 +-
src/app/[locale]/dashboard/page.tsx | 3 +
src/app/api/billing/auto-charge/route.ts | 64 ++++---------
src/app/api/onboarding/[id]/route.ts | 53 +++++++++-
src/app/api/onboarding/route.ts | 43 ++++++---
src/components/onboarding/onboarding-flow.tsx | 7 ++
src/components/onboarding/wizard.tsx | 96 +++++++++----------
.../settings/saved-card-section.tsx | 32 +------
src/lib/packages.ts | 22 +++--
src/messages/de.json | 10 +-
src/messages/en.json | 10 +-
src/messages/fr.json | 10 +-
src/messages/it.json | 10 +-
13 files changed, 208 insertions(+), 160 deletions(-)
diff --git a/src/app/[locale]/dashboard/new/page.tsx b/src/app/[locale]/dashboard/new/page.tsx
index 90e193b..b5d85ff 100644
--- a/src/app/[locale]/dashboard/new/page.tsx
+++ b/src/app/[locale]/dashboard/new/page.tsx
@@ -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}
/>
diff --git a/src/app/[locale]/dashboard/page.tsx b/src/app/[locale]/dashboard/page.tsx
index 73c0e3c..561beef 100644
--- a/src/app/[locale]/dashboard/page.tsx
+++ b/src/app/[locale]/dashboard/page.tsx
@@ -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}
/>
diff --git a/src/app/api/billing/auto-charge/route.ts b/src/app/api/billing/auto-charge/route.ts
index 5eb1701..36e7c02 100644
--- a/src/app/api/billing/auto-charge/route.ts
+++ b/src/app/api/billing/auto-charge/route.ts
@@ -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 }
+ );
}
diff --git a/src/app/api/onboarding/[id]/route.ts b/src/app/api/onboarding/[id]/route.ts
index b6679c1..6deccf7 100644
--- a/src/app/api/onboarding/[id]/route.ts
+++ b/src/app/api/onboarding/[id]/route.ts
@@ -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>; }
+ | { 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(
diff --git a/src/app/api/onboarding/route.ts b/src/app/api/onboarding/route.ts
index 77245bf..4729b69 100644
--- a/src/app/api/onboarding/route.ts
+++ b/src/app/api/onboarding/route.ts
@@ -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." },
diff --git a/src/components/onboarding/onboarding-flow.tsx b/src/components/onboarding/onboarding-flow.tsx
index 95aeaf6..d3e1c0a 100644
--- a/src/components/onboarding/onboarding-flow.tsx
+++ b/src/components/onboarding/onboarding-flow.tsx
@@ -26,6 +26,11 @@ interface OnboardingFlowProps {
* validation skip when the billing step was skipped.
*/
existingOrgBilling?: OrgBilling | null;
+ /**
+ * Phase 9b: platform setup fee (net CHF) shown on the review
+ * step. Forwarded straight to the wizard.
+ */
+ setupFeeChf?: number | null;
/**
* Bug 6: when present, the wizard is rendered in edit mode against
* the given pending request. See `OnboardingWizard` for the full
@@ -53,6 +58,7 @@ export function OnboardingFlow({
userEmail,
hasOrgBilling,
existingOrgBilling,
+ setupFeeChf,
editingRequest,
}: OnboardingFlowProps) {
const router = useRouter();
@@ -64,6 +70,7 @@ export function OnboardingFlow({
userEmail={userEmail}
hasOrgBilling={hasOrgBilling}
existingOrgBilling={existingOrgBilling}
+ setupFeeChf={setupFeeChf}
editingRequest={editingRequest}
onComplete={() => {
// Navigate back to /dashboard and re-fetch on the server. The
diff --git a/src/components/onboarding/wizard.tsx b/src/components/onboarding/wizard.tsx
index 511a06b..97ddc67 100644
--- a/src/components/onboarding/wizard.tsx
+++ b/src/components/onboarding/wizard.tsx
@@ -108,6 +108,14 @@ interface WizardProps {
* billingAddress snapshot).
*/
existingOrgBilling?: OrgBilling | null;
+ /**
+ * Phase 9b: the platform's current tenant setup fee (net CHF,
+ * before VAT). Shown on the review step so the customer sees how
+ * much they're about to be charged before being sent to Stripe.
+ * Null/0 means no setup fee — the review notice is suppressed and
+ * the order skips the Checkout redirect (handled server-side).
+ */
+ setupFeeChf?: number | null;
/**
* Bug 6: when present, the wizard renders in "edit" mode — fields
* are pre-populated from the request, the SOUL.md auto-fetch is
@@ -147,6 +155,7 @@ export function OnboardingWizard({
userEmail,
hasOrgBilling,
existingOrgBilling,
+ setupFeeChf,
editingRequest,
onComplete,
}: WizardProps) {
@@ -482,14 +491,14 @@ export function OnboardingWizard({
}),
});
- // Phase 9b: 402 means the org needs to set up auto-pay
- // before ordering. Surface a friendly message with a link to
- // /settings/billing instead of the generic submission error.
+ // Phase 9b (revised): 402 means the org needs a saved card
+ // before ordering. There's no "enable auto-pay" step anymore
+ // — a card on file is all that's required.
if (res.status === 402) {
const data = await res.json().catch(() => ({}));
- if (data?.code === "auto_pay_required") {
+ if (data?.code === "card_required" || data?.code === "auto_pay_required") {
setAutoPayRequired(true);
- setError(t("autoPayRequiredError"));
+ setError(t("cardRequiredError"));
return;
}
throw new Error(data.error || "Submission failed");
@@ -755,7 +764,9 @@ export function OnboardingWizard({
className={`border rounded-lg overflow-hidden transition-colors ${
isSelected
? "border-accent bg-accent/5"
- : "border-border bg-surface-2"
+ : pkg.recommended
+ ? "border-accent/40 bg-accent/[0.02]"
+ : "border-border bg-surface-2"
}`}
>
{/* Toggle row */}
@@ -774,6 +785,11 @@ export function OnboardingWizard({
>
{pkg.name}
+ {pkg.recommended && (
+
+ {tPkg("recommended")}
+
+ )}
{pkg.requiresSecrets && (
({tPkg("requiresApiKey")})
@@ -1065,28 +1081,6 @@ export function OnboardingWizard({
- {/* Phase 9b: order-time setup-fee notice. The exact
- amount is determined server-side at submit (the
- platform_pricing table is the authority), but the
- customer should know that *some* charge happens on
- the next click. Wording is neutral about the amount
- — we don't want to mis-display a stale figure. */}
-
+ {/* Phase 9b: order-time setup-fee notice + amount. The
+ figure shown is the net platform fee (before VAT);
+ VAT is added server-side based on the billing
+ country. We show "+ VAT" rather than a computed
+ gross to avoid mis-displaying a country-dependent
+ total. If setupFeeChf is null/0, no charge happens
+ and the whole block is suppressed. */}
+ {typeof setupFeeChf === "number" && setupFeeChf > 0 && (
+