274 lines
9.4 KiB
TypeScript
274 lines
9.4 KiB
TypeScript
"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<string, string> = {
|
|
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 | "setup" | "remove">(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 (
|
|
<Card>
|
|
<CardHeader>{t("savedCardHeading")}</CardHeader>
|
|
<div className="p-5">
|
|
<p className="text-sm text-text-secondary mb-4">
|
|
{t("savedCardEmptyBody")}
|
|
</p>
|
|
{/* 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. */}
|
|
<div className="text-sm rounded-md border border-warning/40 bg-warning/10 text-warning px-4 py-3 mb-4">
|
|
<strong className="block mb-1">
|
|
{t("savedCardAutoPayRequiredHeading")}
|
|
</strong>
|
|
<span className="text-text-secondary">
|
|
{t("savedCardAutoPayRequiredBody")}
|
|
</span>
|
|
</div>
|
|
{error && (
|
|
<div className="text-sm text-error mb-3">{error}</div>
|
|
)}
|
|
<button
|
|
onClick={startSetup}
|
|
disabled={busy !== null}
|
|
className="px-4 py-2 rounded-md bg-accent text-surface-0 text-sm disabled:opacity-50"
|
|
>
|
|
{busy === "setup" ? t("savedCardRedirecting") : t("savedCardSetupBtn")}
|
|
</button>
|
|
{/* Bank-transfer hint shown only for company accounts.
|
|
Personal (B2C) accounts pay by card only — surfacing
|
|
the alternative would only confuse. */}
|
|
{!isPersonal && (
|
|
<p className="text-xs text-text-muted mt-4">
|
|
{t("savedCardBankTransferHint")}{" "}
|
|
<a
|
|
href="/support"
|
|
className="text-accent hover:underline"
|
|
>
|
|
{t("savedCardBankTransferLink")}
|
|
</a>
|
|
</p>
|
|
)}
|
|
</div>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
// 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 (
|
|
<Card>
|
|
<CardHeader>{t("savedCardHeading")}</CardHeader>
|
|
<div className="p-5">
|
|
<div className="flex items-center justify-between mb-4 flex-wrap gap-3">
|
|
<div className="flex items-center gap-3">
|
|
<span className="font-mono text-sm">
|
|
{brandLabel} •••• {last4}
|
|
</span>
|
|
{expLabel && (
|
|
<span
|
|
className={`text-xs ${
|
|
expiringSoon ? "text-warning" : "text-text-muted"
|
|
}`}
|
|
>
|
|
{t("savedCardExpires", { date: expLabel })}
|
|
</span>
|
|
)}
|
|
</div>
|
|
<div className="flex items-center gap-3 text-xs">
|
|
<span
|
|
className={`px-2 py-0.5 rounded text-xs ${
|
|
autoChargeOn
|
|
? "bg-success/15 text-success"
|
|
: "bg-text-muted/15 text-text-muted"
|
|
}`}
|
|
>
|
|
{autoChargeOn
|
|
? t("savedCardAutoChargeOn")
|
|
: t("savedCardAutoChargeOff")}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
{isPayByInvoice && (
|
|
<div className="text-xs text-text-muted bg-surface-3 rounded-md px-3 py-2 mb-3">
|
|
{t("savedCardPayByInvoiceNote")}
|
|
</div>
|
|
)}
|
|
|
|
{/* 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 && (
|
|
<div className="text-xs rounded-md border border-warning/40 bg-warning/10 text-warning px-3 py-2 mb-3">
|
|
{t("savedCardAutoPayDisabledNote")}
|
|
</div>
|
|
)}
|
|
|
|
{error && <div className="text-sm text-error mb-3">{error}</div>}
|
|
|
|
<div className="flex gap-2 flex-wrap">
|
|
<button
|
|
onClick={startSetup}
|
|
disabled={busy !== null}
|
|
className="px-3 py-1.5 rounded-md border border-border text-sm disabled:opacity-50 hover:bg-surface-3"
|
|
>
|
|
{busy === "setup"
|
|
? t("savedCardRedirecting")
|
|
: t("savedCardUpdateBtn")}
|
|
</button>
|
|
<button
|
|
onClick={removeCard}
|
|
disabled={busy !== null}
|
|
className="px-3 py-1.5 rounded-md border border-error text-error text-sm disabled:opacity-50 hover:bg-error/10 ml-auto"
|
|
>
|
|
{busy === "remove"
|
|
? t("savedCardRemoving")
|
|
: t("savedCardRemoveBtn")}
|
|
</button>
|
|
</div>
|
|
|
|
{/* Bank-transfer hint shown only for company accounts. */}
|
|
{!isPersonal && (
|
|
<p className="text-xs text-text-muted mt-4">
|
|
{t("savedCardBankTransferHint")}{" "}
|
|
<a
|
|
href="/support"
|
|
className="text-accent hover:underline"
|
|
>
|
|
{t("savedCardBankTransferLink")}
|
|
</a>
|
|
</p>
|
|
)}
|
|
</div>
|
|
</Card>
|
|
);
|
|
}
|