Phase8: Auto bill credit card
All checks were successful
Build and Push / build (push) Successful in 1m48s
All checks were successful
Build and Push / build (push) Successful in 1m48s
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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." },
|
||||
|
||||
Reference in New Issue
Block a user