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:
@@ -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
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user