TorbatYar/frontend/modules/commercial/features/billing.tsx
2026-07-28 20:39:10 +03:30

338 lines
13 KiB
TypeScript
Raw 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 { api, ApiError, type Subscription } from "@/lib/api";
import { useMe } from "@/hooks/useMe";
import {
loadCommercialInvoices,
loadCommercialPricing,
loadCommercialTransactions,
loadCommercialUsage,
} from "../adapters";
import { 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";
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<Subscription | 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 () => {
try {
const subscription = await api.subscriptions.get(tenantId).catch(() => null);
setSub(subscription);
} catch (err) {
setError(err instanceof ApiError ? err.message : "بارگذاری اشتراک ناموفق بود");
}
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<{
id?: string;
status?: string;
plan_id?: string;
trial_ends_at?: string | null;
starts_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) {
setMsg(`عملیات ${mode} از commercial API ثبت شد.`);
if (commercial.data.status) {
setSub({
id: commercial.data.id || "commercial",
tenant_id: me.current_tenant_id,
plan_id: commercial.data.plan_id || item.pricing_item_code,
status: commercial.data.status as Subscription["status"],
trial_ends_at: commercial.data.trial_ends_at ?? null,
starts_at: commercial.data.starts_at ?? new Date().toISOString(),
ends_at: null,
created_at: new Date().toISOString(),
});
}
return;
}
const planId =
typeof item.metadata?.plan_id === "string" ? item.metadata.plan_id : null;
if (!planId) {
setError(
commercial.message ||
"فعال‌سازی نیازمند commercial subscriptions API یا plan_id در metadata است."
);
return;
}
const ends = new Date();
ends.setDate(ends.getDate() + (item.trial_days || 14));
const created = await api.subscriptions.create(me.current_tenant_id, {
plan_id: planId,
status: mode === "trial" ? "trialing" : "active",
trial_ends_at: mode === "trial" ? ends.toISOString() : null,
starts_at: new Date().toISOString(),
});
setSub(created);
setMsg("اشتراک از طریق bridge ثبت شد.");
} catch (err) {
setError(err instanceof ApiError ? err.message : "عملیات ناموفق بود");
} finally {
setBusy(false);
}
};
const applyCoupon = async () => {
if (!me?.current_tenant_id || !coupon.trim()) return;
setBusy(true);
setError("");
const res = await commercialPost("/api/v1/commercial/coupons/apply", {
tenant_id: me.current_tenant_id,
coupon_code: coupon.trim(),
});
if (!res.ok) setError(res.message);
else setMsg("کوپن ارسال شد (در صورت پشتیبانی بک‌اند).");
setBusy(false);
};
if (meLoading) {
return (
<PlatformShell title="Billing">
<p className="text-sm text-gray-500">در حال بارگذاری...</p>
</PlatformShell>
);
}
return (
<PlatformShell
title="Billing و Subscription"
subtitle="قیمت، فاکتور، تراکنش و ارتقا از commercial APIs"
>
<div className="mb-4">
<Link href="/dashboard">
<Button variant="outline" size="sm">
داشبورد
</Button>
</Link>
</div>
<SubscriptionBanner status={sub?.status} planName={sub?.plan_id} />
{sub ? (
<div className="mt-3">
<PlanBadge label={sub.plan_id} status={sub.status} />
{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" variant="outline" loading={busy} onClick={() => void applyCoupon()}>
اعمال کوپن
</Button>
</div>
</section>
<section className="mt-8">
<SectionTitle title="کاتالوگ قیمت‌گذاری" subtitle="از commercial pricing — بدون مبلغ جعلی" />
{pricing?.availability === "ready" && pricing.data.length ? (
<ul className="grid gap-3 sm:grid-cols-2">
{pricing.data.map((item) => (
<li
key={item.pricing_item_code}
className="rounded-2xl border border-gray-100 bg-white p-4 text-sm"
>
<p className="font-semibold text-secondary">{item.display_name}</p>
<p className="mt-1 font-mono text-[10px] text-gray-400" dir="ltr">
{item.pricing_item_code}
</p>
<p className="mt-2 text-xs text-gray-600">
مدل: {item.pricing_model || "—"} · ارز: {item.currency || "—"} · مبلغ:{" "}
{item.amount_minor != null ? item.amount_minor : "از بک‌اند"}
{item.tax_class_code ? ` · مالیات: ${item.tax_class_code}` : ""}
</p>
<div className="mt-4 flex flex-wrap gap-2">
<Button
size="sm"
variant="outline"
loading={busy}
onClick={() => void activatePricing(item, "trial")}
>
Trial
</Button>
<Button size="sm" loading={busy} onClick={() => void activatePricing(item, "active")}>
فعالسازی
</Button>
<Button
size="sm"
variant="outline"
loading={busy}
onClick={() => void activatePricing(item, "upgrade")}
>
ارتقا
</Button>
<Button
size="sm"
variant="outline"
loading={busy}
onClick={() => void activatePricing(item, "downgrade")}
>
کاهش
</Button>
</div>
</li>
))}
</ul>
) : (
<CommercialEmptyState
title="قیمت‌گذاری خالی است"
description={pricing?.message}
hint="پلن‌ها از رجیستری تجاری می‌آیند؛ تا پر شدن کاتالوگ می‌توانید باندل‌ها را در کشف ببینید."
actionHref="/discover"
actionLabel="کشف باندل"
secondaryHref="/help"
secondaryLabel="راهنما"
/>
)}
</section>
<section className="mt-8">
<SectionTitle title="فاکتورها" />
{invoices?.availability === "ready" && invoices.data.length ? (
<ul className="space-y-2">
{invoices.data.map((inv) => (
<li key={inv.invoice_code} className="rounded-xl border border-gray-100 bg-white px-3 py-2 text-sm">
{inv.display_name || inv.invoice_code} · {inv.status || "—"} ·{" "}
{inv.amount_minor != null ? inv.amount_minor : "—"} {inv.currency || ""}
{inv.href ? (
<Link href={inv.href} className="ms-2 text-xs text-primary">
رسید
</Link>
) : null}
</li>
))}
</ul>
) : (
<CommercialEmptyState
title="فاکتوری نیست"
description={invoices?.message}
hint="پس از فعال‌سازی اشتراک یا Trial، فاکتورها اینجا ظاهر می‌شوند."
actionHref="/apps"
actionLabel="باز کردن اپ‌ها"
secondaryHref="/help"
secondaryLabel="پشتیبانی"
/>
)}
</section>
<section className="mt-8">
<SectionTitle title="تاریخچه پرداخت / تراکنش" />
{txns?.availability === "ready" && txns.data.length ? (
<ul className="space-y-2">
{txns.data.map((t) => (
<li key={t.transaction_code} className="rounded-xl border border-gray-100 bg-white px-3 py-2 text-sm">
{t.display_name || t.transaction_code} · {t.status || "—"} ·{" "}
{t.amount_minor != null ? t.amount_minor : "—"} {t.currency || ""}
</li>
))}
</ul>
) : (
<CommercialEmptyState
title="تراکنشی نیست"
description={txns?.message}
hint="پرداخت‌های موفق و ناموفق پس از اتصال درگاه اینجا ثبت می‌شوند."
actionHref="/billing"
actionLabel="تازه‌سازی Billing"
secondaryHref="/help"
secondaryLabel="راهنمای پرداخت"
/>
)}
</section>
<section className="mt-8">
<SectionTitle title="Usage" />
{usage?.availability === "ready" && usage.data.length ? (
<ul className="grid gap-2 sm:grid-cols-2">
{usage.data.map((q) => (
<li key={q.key} className="rounded-xl border border-gray-100 bg-white px-3 py-2 text-sm">
{q.label || q.key}: {q.used ?? "—"} / {q.limit ?? "∞"} {q.unit || ""}
</li>
))}
</ul>
) : (
<CommercialEmptyState
title="مصرف در دسترس نیست"
description={usage?.message}
hint="شمارنده‌های Trial و سهمیه از entitlement API می‌آیند."
actionHref="/dashboard"
actionLabel="بازگشت به داشبورد"
secondaryHref="/discover"
secondaryLabel="ارتقا از کشف"
/>
)}
</section>
</PlatformShell>
);
}
export function CommercialBillingPage() {
return (
<AuthGuard>
<BillingInner />
</AuthGuard>
);
}