TorbatYar/frontend/modules/commercial/features/billing.tsx
Mortezakoohjani 0d424c500a feat(platform): complete commercial runtime consolidation
Unify commercial runtime ownership across backend and frontend so platform, experience, and hospitality modules use the shared commercial source of truth.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-29 11:11:53 +03:30

283 lines
10 KiB
TypeScript
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import { AuthGuard } from "@/components/AuthGuard";
import { Button, SectionTitle } from "@/components/ui";
import { useMe } from "@/hooks/useMe";
import {
loadCommercialInvoices,
loadCommercialPricing,
loadCommercialTransactions,
loadCommercialUsage,
} from "../adapters";
import { commercialGet, commercialPost } from "../adapters/http";
import type {
CommercialInvoice,
CommercialTransaction,
} from "../adapters/billing-ledger";
import type { AdapterResult, CommercialPricingItem, CommercialUsageCounter } from "../types";
import { CommercialEmptyState, PlanBadge, PlatformShell, SubscriptionBanner } from "../components";
type CommercialSubscription = {
id?: string;
status?: string | null;
plan_code?: string | null;
bundle_code?: string | null;
pricing_item_code?: string | null;
trial_ends_at?: string | null;
current_period_end?: string | null;
};
function BillingInner() {
const { me, loading: meLoading } = useMe();
const [pricing, setPricing] = useState<AdapterResult<CommercialPricingItem[]> | null>(null);
const [usage, setUsage] = useState<AdapterResult<CommercialUsageCounter[]> | null>(null);
const [invoices, setInvoices] = useState<AdapterResult<CommercialInvoice[]> | null>(null);
const [txns, setTxns] = useState<AdapterResult<CommercialTransaction[]> | null>(null);
const [sub, setSub] = useState<CommercialSubscription | null>(null);
const [coupon, setCoupon] = useState("");
const [error, setError] = useState("");
const [busy, setBusy] = useState(false);
const [msg, setMsg] = useState("");
useEffect(() => {
void loadCommercialPricing().then(setPricing);
}, []);
useEffect(() => {
if (!me?.current_tenant_id) return;
const tenantId = me.current_tenant_id;
void (async () => {
const dash = await commercialGet<{
subscription?: CommercialSubscription | null;
}>(`/api/v1/commercial/dashboard?tenant_id=${encodeURIComponent(tenantId)}`);
if (dash.ok) setSub(dash.data.subscription ?? null);
else setError(dash.message || "بارگذاری اشتراک commercial ناموفق بود");
void Promise.all([
loadCommercialUsage(tenantId).then(setUsage),
loadCommercialInvoices(tenantId).then(setInvoices),
loadCommercialTransactions(tenantId).then(setTxns),
]);
})();
}, [me?.current_tenant_id]);
const activatePricing = async (
item: CommercialPricingItem,
mode: "trial" | "active" | "upgrade" | "downgrade"
) => {
if (!me?.current_tenant_id) return;
setBusy(true);
setError("");
setMsg("");
try {
const commercial = await commercialPost<{
subscription?: CommercialSubscription;
id?: string;
status?: string;
plan_code?: string | null;
trial_ends_at?: string | null;
}>("/api/v1/commercial/subscriptions", {
tenant_id: me.current_tenant_id,
pricing_item_code: item.pricing_item_code,
mode,
status: mode === "trial" ? "trialing" : "active",
coupon_code: coupon.trim() || undefined,
});
if (!commercial.ok) {
setError(commercial.message || "فعال‌سازی نیازمند Commercial Runtime subscriptions API است.");
return;
}
const next = commercial.data.subscription ?? {
id: commercial.data.id,
status: commercial.data.status,
plan_code: commercial.data.plan_code ?? item.pricing_item_code,
pricing_item_code: item.pricing_item_code,
trial_ends_at: commercial.data.trial_ends_at ?? null,
};
setSub(next);
setMsg(`عملیات ${mode} از Commercial Runtime ثبت شد.`);
} catch (err) {
setError(err instanceof Error ? err.message : "عملیات ناموفق بود");
} finally {
setBusy(false);
}
};
const applyCoupon = async () => {
if (!me?.current_tenant_id || !coupon.trim()) return;
setBusy(true);
setError("");
setMsg("");
// Coupons SoT is Commercial Runtime registry — apply via published coupon codes
// attached to pricing activation (coupon_code on subscriptions POST).
setMsg(
`کد «${coupon.trim()}» را هنگام Trial/فعال‌سازی ارسال کنید. مدیریت کوپن فقط در Admin Commercial.`
);
setBusy(false);
};
if (meLoading) {
return (
<PlatformShell title="Billing">
<p className="text-sm text-gray-500">در حال بارگذاری...</p>
</PlatformShell>
);
}
const planLabel = sub?.plan_code || sub?.pricing_item_code || sub?.bundle_code || undefined;
return (
<PlatformShell
title="Billing و Subscription"
subtitle="قیمت، فاکتور، تراکنش و ارتقا فقط از Commercial Runtime"
>
<div className="mb-4">
<Link href="/dashboard">
<Button variant="outline" size="sm">
داشبورد
</Button>
</Link>
</div>
<SubscriptionBanner status={sub?.status ?? undefined} planName={planLabel} />
{sub ? (
<div className="mt-3">
<PlanBadge label={planLabel || "—"} status={sub.status ?? undefined} />
{sub.trial_ends_at ? (
<p className="mt-2 text-xs text-gray-500" dir="ltr">
trial_ends_at: {sub.trial_ends_at}
</p>
) : null}
</div>
) : null}
{error ? <p className="mt-4 rounded-lg bg-red-50 px-3 py-2 text-sm text-red-600">{error}</p> : null}
{msg ? <p className="mt-4 rounded-lg bg-emerald-50 px-3 py-2 text-sm text-emerald-700">{msg}</p> : null}
<section className="mt-8">
<SectionTitle title="کوپن / تخفیف" />
<div className="flex flex-wrap gap-2">
<input
className="rounded-xl border border-gray-200 px-3 py-2 text-sm"
dir="ltr"
placeholder="COUPON"
value={coupon}
onChange={(e) => setCoupon(e.target.value)}
/>
<Button size="sm" disabled={busy || !coupon.trim()} onClick={() => void applyCoupon()}>
اعمال
</Button>
</div>
</section>
<section className="mt-10">
<SectionTitle title="کاتالوگ قیمت (Commercial Runtime)" />
{!pricing || pricing.availability === "loading" ? (
<p className="text-sm text-gray-500">در حال بارگذاری...</p>
) : pricing.availability !== "ready" || !pricing.data.length ? (
<CommercialEmptyState
title="قیمتی در رجیستری نیست"
description="از Admin Commercial → Pricing یک آیتم منتشر کنید."
actionHref="/admin/commercial/pricing"
actionLabel="مدیریت Pricing"
/>
) : (
<ul className="mt-4 space-y-3">
{pricing.data.map((item) => (
<li
key={item.pricing_item_code}
className="flex flex-wrap items-center justify-between gap-3 rounded-2xl border border-gray-100 bg-white p-4"
>
<div>
<p className="font-semibold text-secondary">{item.display_name}</p>
<p className="text-xs text-gray-500" dir="ltr">
{item.pricing_item_code}
{item.amount != null ? ` · ${item.amount} ${item.currency || ""}` : ""}
{item.trial_days != null ? ` · trial ${item.trial_days}d` : ""}
</p>
</div>
<div className="flex flex-wrap gap-2">
<Button
size="sm"
variant="outline"
disabled={busy}
onClick={() => void activatePricing(item, "trial")}
>
Trial
</Button>
<Button size="sm" disabled={busy} onClick={() => void activatePricing(item, "active")}>
فعالسازی
</Button>
<Button
size="sm"
variant="outline"
disabled={busy}
onClick={() => void activatePricing(item, "upgrade")}
>
ارتقا
</Button>
</div>
</li>
))}
</ul>
)}
</section>
<section className="mt-10">
<SectionTitle title="Usage" />
{usage?.availability === "ready" && usage.data.length ? (
<ul className="mt-3 space-y-2 text-sm">
{usage.data.map((u) => (
<li key={u.key} className="rounded-xl bg-gray-50 px-3 py-2" dir="ltr">
{u.key}: {u.used ?? 0}/{u.limit ?? "∞"}
</li>
))}
</ul>
) : (
<p className="text-sm text-gray-500">Usage از Commercial Runtime خالی است.</p>
)}
</section>
<section className="mt-10">
<SectionTitle title="Invoices / Transactions" />
<p className="mb-3 text-xs text-gray-500">
لجر مالی متعلق به Payment است؛ Commercial فقط نمای خالی/پروکسی صادقانه برمیگرداند.
</p>
{invoices?.availability === "ready" && invoices.data.length ? (
<ul className="space-y-2 text-sm">
{invoices.data.map((inv) => (
<li key={inv.id} dir="ltr">
{inv.id} · {inv.status} · {inv.amount}
</li>
))}
</ul>
) : (
<p className="text-sm text-gray-500">فاکتوری نیست.</p>
)}
{txns?.availability === "ready" && txns.data.length ? (
<ul className="mt-3 space-y-2 text-sm">
{txns.data.map((t) => (
<li key={t.id} dir="ltr">
{t.id} · {t.status} · {t.amount}
</li>
))}
</ul>
) : (
<p className="mt-2 text-sm text-gray-500">تراکنشی نیست.</p>
)}
</section>
</PlatformShell>
);
}
export function CommercialBillingPage() {
return (
<AuthGuard>
<BillingInner />
</AuthGuard>
);
}