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

@@ -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

View File

@@ -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}
</span>
{pkg.recommended && (
<span className="ml-2 text-[10px] font-semibold uppercase tracking-wide text-accent bg-accent/10 border border-accent/30 rounded-full px-1.5 py-0.5">
{tPkg("recommended")}
</span>
)}
{pkg.requiresSecrets && (
<span className="ml-1.5 text-[10px] text-text-muted">
({tPkg("requiresApiKey")})
@@ -1065,28 +1081,6 @@ export function OnboardingWizard({
</p>
</FieldWithError>
)}
<div>
<label className="block text-xs font-semibold uppercase tracking-wider text-text-muted mb-1.5">
{t("billingNotes")}
</label>
<textarea
value={config.billingNotes}
onChange={(e) =>
setConfig((prev) => ({
...prev,
billingNotes: e.target.value,
}))
}
rows={3}
placeholder={t(
isPersonal
? "billingNotesPlaceholderPersonal"
: "billingNotesPlaceholder"
)}
className="w-full px-3 py-2 bg-surface-2 border border-border rounded-lg text-sm text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-1 focus:ring-accent focus:border-accent transition-colors resize-y"
/>
</div>
</div>
<div className="flex justify-between mt-6">
@@ -1248,32 +1242,34 @@ export function OnboardingWizard({
value={userEmail || ""}
mono
/>
{config.billingNotes.trim().length > 0 && (
<ReviewRow
label={t("billingNotes")}
value={
<span className="text-text-primary whitespace-pre-wrap text-right">
{config.billingNotes}
</span>
}
/>
)}
</div>
<p className="text-xs text-text-muted">{t("confirmNote")}</p>
{/* 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. */}
<div className="text-xs rounded-md border border-accent/30 bg-accent/10 text-text-secondary px-3 py-3 mt-4">
<strong className="block text-text-primary mb-1">
{t("setupFeeNoticeHeading")}
</strong>
{t("setupFeeNoticeBody")}
</div>
{/* 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 && (
<div className="text-xs rounded-md border border-accent/30 bg-accent/10 text-text-secondary px-3 py-3 mt-4">
<strong className="block text-text-primary mb-1">
{t("setupFeeNoticeHeading")}
</strong>
<div className="flex items-baseline justify-between mb-2 pb-2 border-b border-accent/20">
<span>{t("setupFeeAmountLabel")}</span>
<span className="text-sm font-semibold text-text-primary">
CHF {setupFeeChf.toFixed(2)}{" "}
<span className="text-[10px] font-normal text-text-muted">
{t("setupFeePlusVat")}
</span>
</span>
</div>
{t("setupFeeNoticeBody")}
</div>
)}
</div>
{error && (

View File

@@ -57,7 +57,7 @@ export function SavedCardSection({
const t = useTranslations("settingsBilling");
const router = useRouter();
const searchParams = useSearchParams();
const [busy, setBusy] = useState<null | "setup" | "remove" | "toggle">(null);
const [busy, setBusy] = useState<null | "setup" | "remove">(null);
const [error, setError] = useState("");
// Refresh + clean the URL when Stripe redirects back. Stripe's
@@ -109,25 +109,6 @@ export function SavedCardSection({
}
};
const toggleAutoCharge = async () => {
setError("");
setBusy("toggle");
try {
const res = await fetch("/api/billing/auto-charge", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ enabled: !autoChargeOn }),
});
const j = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(j.error || `HTTP ${res.status}`);
router.refresh();
} catch (e: any) {
setError(e.message);
} finally {
setBusy(null);
}
};
// Empty state — no card on file.
if (!hasCard) {
return (
@@ -262,17 +243,6 @@ export function SavedCardSection({
? t("savedCardRedirecting")
: t("savedCardUpdateBtn")}
</button>
<button
onClick={toggleAutoCharge}
disabled={busy !== null}
className="px-3 py-1.5 rounded-md border border-border text-sm disabled:opacity-50 hover:bg-surface-3"
>
{busy === "toggle"
? t("saving")
: autoChargeOn
? t("savedCardDisableAutoChargeBtn")
: t("savedCardEnableAutoChargeBtn")}
</button>
<button
onClick={removeCard}
disabled={busy !== null}