"use client"; import { useState, useEffect } from "react"; import { useSearchParams } from "next/navigation"; import { useRouter } from "@/i18n/navigation"; import { useTranslations } from "next-intl"; import { Card, CardHeader } from "@/components/ui/card"; import type { OrgBillingConfig } from "@/types"; interface Props { config: OrgBillingConfig | null; /** * True when this org has been flipped to pay-by-invoice by admin. * The card UI still renders (admin-set customers might also have * a saved card as backup), but with an info note that auto-charge * is disabled by their billing mode. */ isPayByInvoice: boolean; /** * Personal-account flag from the session. Personal accounts are * single-user B2C tenants and don't have the bank-transfer * affordance — they pay by card or not at all. We hide the * "Bank transfer is available on request" hint for these accounts * to keep the messaging unambiguous. */ isPersonal: boolean; } const BRAND_LABELS: Record = { visa: "Visa", mastercard: "Mastercard", amex: "American Express", discover: "Discover", jcb: "JCB", diners: "Diners Club", unionpay: "UnionPay", }; /** * Saved-card management — Phase 9. * * State derives entirely from the OrgBillingConfig the server * sends down. Actions are: set up (no card → Checkout setup * mode), update (existing card → same Checkout flow, replaces), * remove (DELETE the PM in Stripe + clear local fields), toggle * auto-charge. * * The component watches for ?card_setup=success on mount and * fires a router.refresh() — the success redirect from Stripe * lands here and the new card info needs to load. We also strip * the query param so a page reload doesn't re-trigger. */ export function SavedCardSection({ config, isPayByInvoice, isPersonal, }: Props) { const t = useTranslations("settingsBilling"); const router = useRouter(); const searchParams = useSearchParams(); const [busy, setBusy] = useState(null); const [error, setError] = useState(""); // Refresh + clean the URL when Stripe redirects back. Stripe's // webhook is what actually persists the card; the refresh just // re-fetches the server-side config so the new fields appear. useEffect(() => { const status = searchParams.get("card_setup"); if (status === "success") { router.replace("/settings/billing"); router.refresh(); } else if (status === "cancelled") { // Just clean the URL. No-op otherwise. router.replace("/settings/billing"); } }, [searchParams, router]); const hasCard = !!config?.stripeDefaultPaymentMethodId; const autoChargeOn = config?.autoChargeEnabled !== false; const startSetup = async () => { setError(""); setBusy("setup"); try { const res = await fetch("/api/billing/setup-card", { method: "POST" }); const j = await res.json().catch(() => ({})); if (!res.ok) throw new Error(j.error || `HTTP ${res.status}`); if (!j.url) throw new Error("No redirect URL returned"); // Hard-redirect — Stripe Checkout doesn't run inside the SPA. window.location.href = j.url; } catch (e: any) { setError(e.message); setBusy(null); } }; const removeCard = async () => { if (!confirm(t("savedCardRemoveConfirm"))) return; setError(""); setBusy("remove"); try { const res = await fetch("/api/billing/saved-card", { method: "DELETE" }); 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 ( {t("savedCardHeading")}

{t("savedCardEmptyBody")}

{/* Phase 9: prominent policy notice. Auto-pay is the expected default — emphasise that failure to keep a chargeable card on file may result in tenant suspension. Sits above the CTA so it's seen before the click. */}
{t("savedCardAutoPayRequiredHeading")} {t("savedCardAutoPayRequiredBody")}
{error && (
{error}
)} {/* Bank-transfer hint shown only for company accounts. Personal (B2C) accounts pay by card only — surfacing the alternative would only confuse. */} {!isPersonal && (

{t("savedCardBankTransferHint")}{" "} {t("savedCardBankTransferLink")}

)}
); } // Card on file. const brandLabel = config?.stripePmBrand ? BRAND_LABELS[config.stripePmBrand] ?? config.stripePmBrand : t("savedCardBrandUnknown"); const last4 = config?.stripePmLast4 ?? "????"; const expMonth = config?.stripePmExpMonth; const expYear = config?.stripePmExpYear; const expLabel = expMonth && expYear ? `${String(expMonth).padStart(2, "0")}/${String(expYear).slice(-2)}` : ""; // Heuristic for "expiring soon" — if the card expires this calendar // month or next. Stripe's pre-expiration emails handle the real // notification, but a portal hint is friendly too. const now = new Date(); const expiringSoon = expMonth && expYear && (expYear < now.getFullYear() || (expYear === now.getFullYear() && expMonth <= now.getMonth() + 2)); return ( {t("savedCardHeading")}
{brandLabel} •••• {last4} {expLabel && ( {t("savedCardExpires", { date: expLabel })} )}
{autoChargeOn ? t("savedCardAutoChargeOn") : t("savedCardAutoChargeOff")}
{isPayByInvoice && (
{t("savedCardPayByInvoiceNote")}
)} {/* If the card is on file but the customer has actively disabled auto-pay, surface the suspension-risk reminder. Not shown when admin has flipped them to pay-by-invoice — that's a different deal and the note above explains it. */} {!isPayByInvoice && !autoChargeOn && (
{t("savedCardAutoPayDisabledNote")}
)} {error &&
{error}
}
{/* Bank-transfer hint shown only for company accounts. */} {!isPersonal && (

{t("savedCardBankTransferHint")}{" "} {t("savedCardBankTransferLink")}

)}
); }