Files
flatrender/src/components/admin/PaymentsAdmin.tsx
T
soroush.asadi ec51e87d2d feat(payment): standalone ZarinPal broker on pay.flatrender.ir
A generic multi-client payment gateway so FlatRender, meezi.ir and
bargevasat.ir can all pay through ZarinPal's single verified callback
domain (pay.flatrender.ir).

New Go service services/payment (clones the notification skeleton +
vendored deps):
- migration 31_payment_broker.sql — `payment` schema: client_apps,
  transactions, webhook_deliveries.
- ZarinPal v4 client ported from the proven identity PaymentService
  (request.json -> StartPay -> verify.json; codes 100/101).
- client API: POST /v1/pay/request + /v1/pay/inquiry, authed by
  X-Api-Key + HMAC body signature; GET /callback/zarinpal (the single
  verified endpoint) verifies, then 302s the user back to the site's
  return_url (signed) and fires a signed, retried webhook.
- per-client ZarinPal merchant override (default = shared merchant);
  amount stored canonically in Rial, unit to ZarinPal env-configurable.
- admin API /v1/admin/* (FlatRender admin JWT): client-app CRUD +
  key issue/rotate + transactions list.

Deploy wiring: payment-svc in docker-compose.v2.yml (host port 1607),
pay.flatrender.ir server block in mirror-nginx conf, ENV_FILE +
README updates (cert SAN + manual migration note).

Admin UI: src/components/admin/PaymentsAdmin.tsx (client apps with
one-time key reveal + rotate, transactions table) + /admin/payments
page + nav link + fa/en strings; pay-admin proxy route to payment-svc.

Docs/SDK: deploy/PAYMENTS.md (integration contract) + deploy/sdk/flatpay.js
(zero-dep Node client + webhook verifier) for meezi/any site.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 23:59:54 +03:30

455 lines
17 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 { useCallback, useEffect, useState } from "react";
const card = "rounded-xl border border-[#1e2235] bg-[#0f1120]";
const btn = "rounded-lg bg-indigo-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-indigo-500 disabled:opacity-50";
const btnGhost = "rounded-lg border border-[#262b40] px-3 py-1.5 text-xs font-medium text-gray-300 hover:border-indigo-500";
const inp = "w-full rounded-lg border border-[#262b40] bg-[#0c0e1a] px-3 py-2 text-sm text-gray-100 outline-none focus:border-indigo-500";
const lbl = "mb-1 block text-xs font-medium text-gray-400";
interface ClientApp {
id: string;
name: string;
slug: string;
api_key: string;
secret?: string;
zarinpal_merchant_id?: string | null;
zarinpal_sandbox?: boolean | null;
allowed_return_origins: string[];
webhook_url?: string | null;
is_active: boolean;
created_at: string;
}
interface Txn {
id: string;
client_slug?: string;
status: string;
amount_rial: number;
currency: string;
client_ref?: string | null;
ref_id?: string | null;
created_at: string;
}
const toman = (rial: number) => (rial / 10).toLocaleString("fa-IR") + " تومان";
function statusBadge(s: string) {
const map: Record<string, string> = {
Paid: "bg-emerald-500/15 text-emerald-300",
Pending: "bg-amber-500/15 text-amber-300",
Created: "bg-amber-500/15 text-amber-300",
Failed: "bg-red-500/15 text-red-300",
Cancelled: "bg-red-500/15 text-red-300",
Expired: "bg-gray-500/15 text-gray-300",
};
return map[s] ?? "bg-gray-500/15 text-gray-300";
}
export function PaymentsAdmin() {
const [tab, setTab] = useState<"apps" | "txns">("apps");
return (
<div className="space-y-4">
<div>
<h1 className="text-xl font-semibold text-white">درگاه پرداخت (ZarinPal)</h1>
<p className="mt-1 text-sm text-gray-400">
سرویس پرداخت مشترک روی <span dir="ltr" className="font-mono">pay.flatrender.ir</span>. هر سایت
(فلترندر، میزی، برگ وصلت) یک اپلیکیشن با کلید اختصاصی میگیرد و پرداختها را از این درگاه عبور میدهد.
</p>
</div>
<div className="flex gap-2">
<button className={tab === "apps" ? btn : btnGhost} onClick={() => setTab("apps")}>
اپلیکیشنها
</button>
<button className={tab === "txns" ? btn : btnGhost} onClick={() => setTab("txns")}>
تراکنشها
</button>
</div>
{tab === "apps" ? <ClientApps /> : <Transactions />}
</div>
);
}
// ── Client apps tab ────────────────────────────────────────────────────────────
function ClientApps() {
const [clients, setClients] = useState<ClientApp[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [creating, setCreating] = useState(false);
const [revealed, setRevealed] = useState<ClientApp | null>(null);
const reload = useCallback(async () => {
setLoading(true);
setError(null);
try {
const res = await fetch("/api/admin/pay/clients", { cache: "no-store" });
const data = await res.json();
if (!res.ok) throw new Error(data?.error ?? "بارگذاری ناموفق بود");
setClients(Array.isArray(data?.data) ? data.data : []);
} catch (e) {
setError(e instanceof Error ? e.message : "بارگذاری ناموفق بود");
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
reload();
}, [reload]);
const rotate = async (id: string) => {
if (!confirm("کلید مخفی جدید ساخته شود؟ کلید قبلی باطل می‌شود.")) return;
const res = await fetch(`/api/admin/pay/clients/${id}/rotate-secret`, { method: "POST" });
const data = await res.json();
if (res.ok) setRevealed(data);
else setError(data?.error ?? "خطا");
};
const remove = async (id: string) => {
if (!confirm("این اپلیکیشن حذف شود؟")) return;
const res = await fetch(`/api/admin/pay/clients/${id}`, { method: "DELETE" });
if (res.ok || res.status === 204) reload();
else setError("حذف ناموفق بود (ممکن است تراکنش داشته باشد)");
};
const toggleActive = async (c: ClientApp) => {
await fetch(`/api/admin/pay/clients/${c.id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: c.name,
zarinpal_merchant_id: c.zarinpal_merchant_id,
zarinpal_sandbox: c.zarinpal_sandbox,
allowed_return_origins: c.allowed_return_origins,
webhook_url: c.webhook_url,
is_active: !c.is_active,
}),
});
reload();
};
return (
<div className="space-y-4">
<div className="flex justify-between">
<button className={btn} onClick={() => setCreating((v) => !v)}>
{creating ? "بستن" : "+ افزودن اپلیکیشن"}
</button>
<button className={btnGhost} onClick={reload}>
بارگذاری مجدد
</button>
</div>
{error && <p className="rounded-lg bg-red-500/10 px-3 py-2 text-sm text-red-300">{error}</p>}
{creating && (
<CreateClientForm
onCreated={(c) => {
setCreating(false);
setRevealed(c);
reload();
}}
onError={setError}
/>
)}
{revealed && <SecretReveal client={revealed} onClose={() => setRevealed(null)} />}
{loading ? (
<p className="py-10 text-center text-gray-500">در حال بارگذاری</p>
) : clients.length === 0 ? (
<p className="py-10 text-center text-gray-500">هنوز اپلیکیشنی ثبت نشده است.</p>
) : (
<div className="space-y-3">
{clients.map((c) => (
<div key={c.id} className={`${card} p-4`}>
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<div className="flex items-center gap-2">
<span className="font-medium text-white">{c.name}</span>
<span className="rounded bg-[#1a1d2e] px-2 py-0.5 text-[11px] text-gray-400" dir="ltr">
{c.slug}
</span>
{c.zarinpal_sandbox && (
<span className="rounded bg-amber-500/15 px-2 py-0.5 text-[10px] text-amber-300">sandbox</span>
)}
{!c.is_active && (
<span className="rounded bg-red-500/15 px-2 py-0.5 text-[10px] text-red-300">غیرفعال</span>
)}
</div>
<div className="mt-1 flex items-center gap-2 text-xs text-gray-400" dir="ltr">
<span className="font-mono">{c.api_key}</span>
<CopyBtn text={c.api_key} />
</div>
{c.webhook_url && (
<p className="mt-1 text-[11px] text-gray-500" dir="ltr">
webhook: {c.webhook_url}
</p>
)}
{c.allowed_return_origins?.length > 0 && (
<p className="mt-1 text-[11px] text-gray-500" dir="ltr">
origins: {c.allowed_return_origins.join(", ")}
</p>
)}
</div>
<div className="flex flex-wrap gap-2">
<button className={btnGhost} onClick={() => toggleActive(c)}>
{c.is_active ? "غیرفعال‌سازی" : "فعال‌سازی"}
</button>
<button className={btnGhost} onClick={() => rotate(c.id)}>
چرخش کلید مخفی
</button>
<button
className="rounded-lg border border-red-500/40 px-3 py-1.5 text-xs text-red-300 hover:bg-red-500/10"
onClick={() => remove(c.id)}
>
حذف
</button>
</div>
</div>
</div>
))}
</div>
)}
</div>
);
}
function CreateClientForm({
onCreated,
onError,
}: {
onCreated: (c: ClientApp) => void;
onError: (m: string) => void;
}) {
const [form, setForm] = useState({
name: "",
slug: "",
zarinpal_merchant_id: "",
zarinpal_sandbox: false,
allowed_return_origins: "",
webhook_url: "",
});
const [saving, setSaving] = useState(false);
const submit = async () => {
setSaving(true);
try {
const res = await fetch("/api/admin/pay/clients", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: form.name,
slug: form.slug || undefined,
zarinpal_merchant_id: form.zarinpal_merchant_id || null,
zarinpal_sandbox: form.zarinpal_sandbox || null,
allowed_return_origins: form.allowed_return_origins
.split(/[\n,]/)
.map((s) => s.trim())
.filter(Boolean),
webhook_url: form.webhook_url || null,
}),
});
const data = await res.json();
if (!res.ok) throw new Error(data?.error ?? "ساخت ناموفق بود");
onCreated(data);
} catch (e) {
onError(e instanceof Error ? e.message : "ساخت ناموفق بود");
} finally {
setSaving(false);
}
};
return (
<div className={`${card} space-y-4 p-5`}>
<div className="grid gap-4 sm:grid-cols-2">
<div>
<label className={lbl}>نام سایت *</label>
<input className={inp} value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} placeholder="meezi.ir" />
</div>
<div>
<label className={lbl}>شناسه (اختیاری)</label>
<input className={inp} dir="ltr" value={form.slug} onChange={(e) => setForm({ ...form, slug: e.target.value })} placeholder="meezi" />
</div>
<div>
<label className={lbl}>مرچنت زرینپال اختصاصی (اختیاری)</label>
<input className={inp} dir="ltr" value={form.zarinpal_merchant_id} onChange={(e) => setForm({ ...form, zarinpal_merchant_id: e.target.value })} placeholder="خالی = مرچنت مشترک" />
</div>
<div>
<label className={lbl}>آدرس Webhook (اختیاری)</label>
<input className={inp} dir="ltr" value={form.webhook_url} onChange={(e) => setForm({ ...form, webhook_url: e.target.value })} placeholder="https://meezi.ir/api/flatpay/webhook" />
</div>
<div className="sm:col-span-2">
<label className={lbl}>دامنههای مجاز بازگشت (هر کدام در یک خط خالی = همه)</label>
<textarea className={`${inp} min-h-[60px]`} dir="ltr" value={form.allowed_return_origins} onChange={(e) => setForm({ ...form, allowed_return_origins: e.target.value })} placeholder="https://meezi.ir" />
</div>
<label className="flex items-center gap-2 text-sm text-gray-300">
<input type="checkbox" checked={form.zarinpal_sandbox} onChange={(e) => setForm({ ...form, zarinpal_sandbox: e.target.checked })} />
استفاده از حالت تست (sandbox) برای این اپلیکیشن
</label>
</div>
<button className={btn} onClick={submit} disabled={saving || !form.name.trim()}>
{saving ? "در حال ساخت…" : "ساخت اپلیکیشن"}
</button>
</div>
);
}
function SecretReveal({ client, onClose }: { client: ClientApp; onClose: () => void }) {
return (
<div className="rounded-xl border border-amber-500/40 bg-amber-500/5 p-5">
<div className="flex items-start justify-between">
<h3 className="font-semibold text-amber-200">کلیدهای «{client.name}»</h3>
<button className={btnGhost} onClick={onClose}>
بستن
</button>
</div>
<p className="mt-1 text-xs text-amber-300/80">
کلید مخفی فقط همین یکبار نمایش داده میشود آن را در جای امن ذخیره کنید.
</p>
<div className="mt-3 space-y-2">
<KeyRow label="API Key" value={client.api_key} />
{client.secret && <KeyRow label="Secret" value={client.secret} />}
</div>
</div>
);
}
function KeyRow({ label, value }: { label: string; value: string }) {
return (
<div className="flex items-center gap-2">
<span className="w-20 text-xs text-gray-400">{label}</span>
<code className="flex-1 truncate rounded bg-[#0c0e1a] px-2 py-1.5 text-xs text-gray-100" dir="ltr">
{value}
</code>
<CopyBtn text={value} />
</div>
);
}
function CopyBtn({ text }: { text: string }) {
const [done, setDone] = useState(false);
return (
<button
className="rounded border border-[#262b40] px-2 py-1 text-[11px] text-gray-400 hover:border-indigo-500"
onClick={() => {
navigator.clipboard?.writeText(text);
setDone(true);
setTimeout(() => setDone(false), 1200);
}}
>
{done ? "✓" : "کپی"}
</button>
);
}
// ── Transactions tab ───────────────────────────────────────────────────────────
function Transactions() {
const [txns, setTxns] = useState<Txn[]>([]);
const [loading, setLoading] = useState(true);
const [status, setStatus] = useState("");
const [page, setPage] = useState(1);
const [hasMore, setHasMore] = useState(false);
const reload = useCallback(async () => {
setLoading(true);
try {
const qs = new URLSearchParams({ page: String(page), page_size: "20" });
if (status) qs.set("status", status);
const res = await fetch(`/api/admin/pay/transactions?${qs}`, { cache: "no-store" });
const data = await res.json();
setTxns(Array.isArray(data?.data) ? data.data : []);
setHasMore(Boolean(data?.meta?.has_more));
} catch {
setTxns([]);
} finally {
setLoading(false);
}
}, [page, status]);
useEffect(() => {
reload();
}, [reload]);
return (
<div className="space-y-3">
<div className="flex items-center gap-2">
<select
className={`${inp} w-auto`}
value={status}
onChange={(e) => {
setPage(1);
setStatus(e.target.value);
}}
>
<option value="">همه وضعیتها</option>
<option value="Paid">پرداختشده</option>
<option value="Pending">در انتظار</option>
<option value="Failed">ناموفق</option>
<option value="Cancelled">لغوشده</option>
</select>
<button className={btnGhost} onClick={reload}>
بارگذاری مجدد
</button>
</div>
{loading ? (
<p className="py-10 text-center text-gray-500">در حال بارگذاری</p>
) : txns.length === 0 ? (
<p className="py-10 text-center text-gray-500">تراکنشی یافت نشد.</p>
) : (
<div className={`${card} overflow-x-auto`}>
<table className="w-full text-sm">
<thead>
<tr className="border-b border-[#1e2235] text-right text-xs text-gray-500">
<th className="px-3 py-2 font-medium">تاریخ</th>
<th className="px-3 py-2 font-medium">اپلیکیشن</th>
<th className="px-3 py-2 font-medium">مبلغ</th>
<th className="px-3 py-2 font-medium">وضعیت</th>
<th className="px-3 py-2 font-medium">کد پیگیری</th>
<th className="px-3 py-2 font-medium">مرجع</th>
</tr>
</thead>
<tbody>
{txns.map((t) => (
<tr key={t.id} className="border-b border-[#15182a] text-gray-300">
<td className="px-3 py-2 text-xs" dir="ltr">
{new Date(t.created_at).toLocaleString("fa-IR")}
</td>
<td className="px-3 py-2 text-xs" dir="ltr">
{t.client_slug ?? "—"}
</td>
<td className="px-3 py-2">{toman(t.amount_rial)}</td>
<td className="px-3 py-2">
<span className={`rounded px-2 py-0.5 text-[11px] ${statusBadge(t.status)}`}>{t.status}</span>
</td>
<td className="px-3 py-2 text-xs" dir="ltr">
{t.ref_id ?? "—"}
</td>
<td className="px-3 py-2 text-xs" dir="ltr">
{t.client_ref ?? "—"}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<div className="flex justify-center gap-2">
<button className={btnGhost} disabled={page <= 1} onClick={() => setPage((p) => p - 1)}>
قبلی
</button>
<span className="px-3 py-1.5 text-sm text-gray-400">صفحه {page}</span>
<button className={btnGhost} disabled={!hasMore} onClick={() => setPage((p) => p + 1)}>
بعدی
</button>
</div>
</div>
);
}