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({

)} - -
- -