Add proforma invoice step to subscription checkout

Insert a factor/invoice page between plan selection and payment showing
billing-period choice, line items, and totals before redirecting to the
gateway, moving payment-method selection to where the charge happens.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-05-30 00:29:17 +03:30
parent 639573dfde
commit 09c55669ca
4 changed files with 297 additions and 68 deletions
@@ -0,0 +1,10 @@
import { Suspense } from "react";
import { CheckoutScreen } from "@/components/subscription/checkout-screen";
export default function SubscriptionCheckoutPage() {
return (
<Suspense fallback={null}>
<CheckoutScreen />
</Suspense>
);
}
@@ -9,9 +9,9 @@ import { Badge } from "@/components/ui/badge";
export type PlanId = "Free" | "Pro" | "Business" | "Enterprise";
const PLAN_ORDER: PlanId[] = ["Free", "Pro", "Business", "Enterprise"];
export const PLAN_ORDER: PlanId[] = ["Free", "Pro", "Business", "Enterprise"];
const PRICES: Record<PlanId, number | null> = {
export const PRICES: Record<PlanId, number | null> = {
Free: 0,
Pro: 1_490_000,
Business: 3_490_000,
@@ -0,0 +1,281 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { useSearchParams } from "next/navigation";
import { useQuery, useMutation } from "@tanstack/react-query";
import { useTranslations } from "next-intl";
import { useRouter } from "@/i18n/routing";
import { ArrowRight, ArrowLeft, ShieldCheck } from "lucide-react";
import { apiGet, apiPost } from "@/lib/api/client";
import { isCafeOwner } from "@/lib/auth-permissions";
import { useAuthStore } from "@/lib/stores/auth.store";
import { formatCurrency, formatNumber } from "@/lib/format";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { PageHeader } from "@/components/layout/page-header";
import { PRICES, type PlanId } from "@/components/settings/plan-comparison";
type SubscribeResponse = {
paymentId: string;
paymentUrl: string;
};
type PaymentMethod = {
id: string;
displayNameFa: string;
isDefault: boolean;
};
const BILLABLE_PLANS: PlanId[] = ["Pro", "Business"];
const MONTH_OPTIONS = [1, 3, 6, 12];
export function CheckoutScreen() {
const t = useTranslations("subscription");
const tc = useTranslations("subscription.checkout");
const tPlans = useTranslations("settings.plans");
const searchParams = useSearchParams();
const router = useRouter();
const user = useAuthStore((s) => s.user);
const cafeId = user?.cafeId;
const role = user?.role;
const planParam = searchParams.get("plan") as PlanId | null;
const isBillable = !!planParam && BILLABLE_PLANS.includes(planParam);
const plan = (isBillable ? planParam : "Pro") as PlanId;
const [months, setMonths] = useState(1);
const [paymentMethod, setPaymentMethod] = useState("");
const numberLocale =
typeof document !== "undefined" && document.documentElement.lang === "en"
? "en-US"
: "fa-IR";
const isRtl = numberLocale !== "en-US";
const cafeName = useMemo(() => {
if (!user) return "";
const membership = user.memberships?.find((m) => m.cafeId === user.cafeId);
return membership?.cafeName ?? "";
}, [user]);
const { data: paymentMethods = [] } = useQuery({
queryKey: ["billing-payment-methods", cafeId],
queryFn: () => apiGet<PaymentMethod[]>("/api/billing/payment-methods"),
enabled: !!cafeId && isCafeOwner(role),
});
useEffect(() => {
if (!paymentMethod && paymentMethods.length > 0) {
const def = paymentMethods.find((m) => m.isDefault) ?? paymentMethods[0];
setPaymentMethod(def.id);
}
}, [paymentMethods, paymentMethod]);
const subscribe = useMutation({
mutationFn: (body: { planTier: string; months: number; paymentMethod: string }) =>
apiPost<SubscribeResponse>("/api/billing/subscribe", body),
onSuccess: (data) => {
window.location.href = data.paymentUrl;
},
});
if (!cafeId) return null;
if (!isCafeOwner(role)) {
return (
<div className="space-y-6">
<PageHeader title={tc("title")} subtitle={tc("subtitle")} />
<p className="text-sm text-muted-foreground">{t("ownerOnly")}</p>
</div>
);
}
if (!isBillable) {
return (
<div className="space-y-6">
<PageHeader title={tc("title")} subtitle={tc("subtitle")} />
<Card className="rounded-xl border border-border/80 shadow-sm">
<CardContent className="space-y-4 py-8 text-center">
<p className="text-sm text-muted-foreground">{tc("invalidPlan")}</p>
<Button variant="outline" onClick={() => router.push("/subscription")}>
{tc("backToPlans")}
</Button>
</CardContent>
</Card>
</div>
);
}
const unitPrice = PRICES[plan] ?? 0;
const subtotal = unitPrice * months;
const total = subtotal;
const planName = tPlans(`names.${plan}`);
const BackIcon = isRtl ? ArrowRight : ArrowLeft;
const issuedAt = new Date().toLocaleDateString(numberLocale);
const invoiceNo = `MZ-${Date.now().toString().slice(-8)}`;
return (
<div className="mx-auto max-w-3xl space-y-6">
<PageHeader
title={tc("title")}
subtitle={tc("subtitle")}
action={
<Button
variant="ghost"
size="sm"
className="gap-1.5"
onClick={() => router.push("/subscription")}
>
<BackIcon className="h-4 w-4" aria-hidden />
{tc("backToPlans")}
</Button>
}
/>
{/* Factor / invoice */}
<Card className="overflow-hidden rounded-xl border border-border/80 shadow-sm">
{/* Invoice header */}
<div className="flex flex-wrap items-start justify-between gap-3 border-b border-border/80 bg-muted/30 px-5 py-4">
<div>
<p className="text-[11px] font-medium uppercase tracking-[0.06em] text-muted-foreground">
{tc("invoiceLabel")}
</p>
<p className="mt-0.5 text-base font-semibold text-foreground">
{cafeName || tc("invoiceLabel")}
</p>
</div>
<dl className="text-end text-xs text-muted-foreground">
<div className="flex items-center justify-end gap-1.5">
<dt>{tc("invoiceNo")}:</dt>
<dd className="font-medium text-foreground" dir="ltr">
{invoiceNo}
</dd>
</div>
<div className="mt-0.5 flex items-center justify-end gap-1.5">
<dt>{tc("issuedAt")}:</dt>
<dd className="font-medium text-foreground">{issuedAt}</dd>
</div>
</dl>
</div>
<CardContent className="space-y-6 px-5 py-5">
{/* Billing period selector */}
<div className="space-y-2">
<p className="text-sm font-medium text-foreground">{tc("billingPeriod")}</p>
<div className="flex flex-wrap gap-2">
{MONTH_OPTIONS.map((m) => (
<button
key={m}
type="button"
onClick={() => setMonths(m)}
className={cn(
"rounded-lg border px-3.5 py-2 text-sm transition active:scale-[0.98]",
months === m
? "border-[#0F6E56] bg-[#E1F5EE] font-medium text-[#0F6E56]"
: "border-border/80 hover:border-[#0F6E56]/40"
)}
>
{tc("monthsCount", { count: formatNumber(m, numberLocale) })}
</button>
))}
</div>
</div>
{/* Line items */}
<div className="overflow-hidden rounded-lg border border-border/60">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border/60 bg-muted/20 text-[11px] uppercase tracking-[0.06em] text-muted-foreground">
<th className="px-4 py-2.5 text-start font-medium">{tc("description")}</th>
<th className="px-4 py-2.5 text-center font-medium">{tc("qty")}</th>
<th className="px-4 py-2.5 text-end font-medium">{tc("unitPrice")}</th>
<th className="px-4 py-2.5 text-end font-medium">{tc("amount")}</th>
</tr>
</thead>
<tbody>
<tr className="border-b border-border/40">
<td className="px-4 py-3 text-start">
<span className="font-medium text-foreground">
{tc("planLine", { plan: planName })}
</span>
</td>
<td className="px-4 py-3 text-center text-muted-foreground">
{tc("monthsCount", { count: formatNumber(months, numberLocale) })}
</td>
<td className="px-4 py-3 text-end text-muted-foreground" dir="ltr">
{formatCurrency(unitPrice, numberLocale)}
</td>
<td className="px-4 py-3 text-end font-medium text-foreground" dir="ltr">
{formatCurrency(subtotal, numberLocale)}
</td>
</tr>
</tbody>
</table>
</div>
{/* Totals */}
<div className="space-y-1.5">
<div className="flex items-center justify-between text-sm text-muted-foreground">
<span>{tc("subtotal")}</span>
<span dir="ltr">{formatCurrency(subtotal, numberLocale)}</span>
</div>
<div className="flex items-center justify-between border-t border-border/60 pt-2.5 text-base font-semibold text-foreground">
<span>{tc("total")}</span>
<span className="text-[#0F6E56]" dir="ltr">
{formatCurrency(total, numberLocale)}
</span>
</div>
</div>
{/* Payment method */}
{paymentMethods.length > 0 ? (
<div className="space-y-2">
<p className="text-sm font-medium text-foreground">{t("paymentMethod")}</p>
<div className="flex flex-wrap gap-2">
{paymentMethods.map((m) => (
<button
key={m.id}
type="button"
onClick={() => setPaymentMethod(m.id)}
className={cn(
"rounded-lg border px-3 py-2 text-sm transition active:scale-[0.98]",
paymentMethod === m.id
? "border-[#0F6E56] bg-[#E1F5EE] text-[#0F6E56]"
: "border-border/80 hover:border-[#0F6E56]/40"
)}
>
{m.displayNameFa}
</button>
))}
</div>
</div>
) : null}
</CardContent>
{/* Pay action */}
<div className="flex flex-col gap-3 border-t border-border/80 bg-muted/20 px-5 py-4 sm:flex-row sm:items-center sm:justify-between">
<p className="flex items-center gap-1.5 text-xs text-muted-foreground">
<ShieldCheck className="h-4 w-4 text-[#0F6E56]" aria-hidden />
{tc("secureNote")}
</p>
<Button
className="bg-[#0F6E56] hover:bg-[#0c5a46]"
disabled={subscribe.isPending}
onClick={() =>
subscribe.mutate({
planTier: plan,
months,
paymentMethod: paymentMethod || paymentMethods[0]?.id || "zarinpal",
})
}
>
{subscribe.isPending
? tc("redirecting")
: tc("payTotal", { total: formatCurrency(total, numberLocale) })}
</Button>
</div>
</Card>
</div>
);
}
@@ -2,13 +2,13 @@
import { useEffect, useRef, useState } from "react";
import { useSearchParams } from "next/navigation";
import { useQuery, useMutation } from "@tanstack/react-query";
import { useQuery } from "@tanstack/react-query";
import { useTranslations } from "next-intl";
import { useRouter } from "@/i18n/routing";
import { apiGet, apiPost } from "@/lib/api/client";
import { isCafeOwner } from "@/lib/auth-permissions";
import { useAuthStore } from "@/lib/stores/auth.store";
import { formatNumber } from "@/lib/format";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
@@ -32,27 +32,16 @@ type BillingStatus = {
isPlanExpired: boolean;
};
type SubscribeResponse = {
paymentId: string;
paymentUrl: string;
};
type PaymentMethod = {
id: string;
displayNameFa: string;
isDefault: boolean;
};
export function SubscriptionScreen() {
const t = useTranslations("subscription");
const tSettings = useTranslations("settings");
const searchParams = useSearchParams();
const router = useRouter();
const cafeId = useAuthStore((s) => s.user?.cafeId);
const role = useAuthStore((s) => s.user?.role);
const setAuth = useAuthStore((s) => s.setAuth);
const billingRefreshed = useRef(false);
const [billingBanner, setBillingBanner] = useState<"success" | "failed" | null>(null);
const [paymentMethod, setPaymentMethod] = useState("");
useEffect(() => {
const billing = searchParams.get("billing");
@@ -72,19 +61,6 @@ export function SubscriptionScreen() {
enabled: !!cafeId,
});
const { data: paymentMethods = [] } = useQuery({
queryKey: ["billing-payment-methods", cafeId],
queryFn: () => apiGet<PaymentMethod[]>("/api/billing/payment-methods"),
enabled: !!cafeId && isCafeOwner(role),
});
useEffect(() => {
if (!paymentMethod && paymentMethods.length > 0) {
const def = paymentMethods.find((m) => m.isDefault) ?? paymentMethods[0];
setPaymentMethod(def.id);
}
}, [paymentMethods, paymentMethod]);
useEffect(() => {
if (searchParams.get("billing") !== "success" || billingRefreshed.current) return;
const refresh = localStorage.getItem("meezi_refresh_token");
@@ -98,14 +74,6 @@ export function SubscriptionScreen() {
.catch(() => notify.warning(tSettings("profile.reloginHint")));
}, [searchParams, setAuth, refetch, tSettings]);
const subscribe = useMutation({
mutationFn: (body: { planTier: string; months: number; paymentMethod: string }) =>
apiPost<SubscribeResponse>("/api/billing/subscribe", body),
onSuccess: (data) => {
window.location.href = data.paymentUrl;
},
});
if (!cafeId) return null;
if (!isCafeOwner(role)) {
@@ -187,41 +155,11 @@ export function SubscriptionScreen() {
</CardContent>
</Card>
{paymentMethods.length > 0 ? (
<Card className="rounded-xl border border-border/80 shadow-sm">
<CardHeader className="pb-2">
<CardTitle className="text-base">{t("paymentMethod")}</CardTitle>
</CardHeader>
<CardContent className="flex flex-wrap gap-2">
{paymentMethods.map((m) => (
<button
key={m.id}
type="button"
onClick={() => setPaymentMethod(m.id)}
className={cn(
"rounded-lg border px-3 py-2 text-sm transition active:scale-[0.98]",
paymentMethod === m.id
? "border-[#0F6E56] bg-[#E1F5EE] text-[#0F6E56]"
: "border-border/80 hover:border-[#0F6E56]/40"
)}
>
{m.displayNameFa}
</button>
))}
</CardContent>
</Card>
) : null}
<PlanComparison
currentPlan={status?.planTier ?? "Free"}
onSubscribe={(planTier) =>
subscribe.mutate({
planTier,
months: 1,
paymentMethod: paymentMethod || paymentMethods[0]?.id || "zarinpal",
})
router.push(`/subscription/checkout?plan=${planTier}`)
}
isSubscribing={subscribe.isPending}
/>
</div>
);