Add payment module, BFF proxy, hub/dashboard/ops/PSP/merchant screens wired to real APIs, and mark payment-frontend complete in project status. Co-authored-by: Cursor <cursoragent@cursor.com>
479 lines
18 KiB
JavaScript
479 lines
18 KiB
JavaScript
#!/usr/bin/env node
|
||
/**
|
||
* Scaffold Torbat Pay frontend module (MVP payment-14.0–14.5).
|
||
* Run: node scripts/scaffold-payment-module.mjs
|
||
*/
|
||
import fs from "node:fs";
|
||
import path from "node:path";
|
||
import { fileURLToPath } from "node:url";
|
||
|
||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||
const ROOT = path.resolve(__dirname, "..");
|
||
const MOD = path.join(ROOT, "modules", "payment");
|
||
const APP = path.join(ROOT, "app", "payment");
|
||
|
||
function write(rel, content) {
|
||
const full = path.isAbsolute(rel) ? rel : path.join(ROOT, rel);
|
||
fs.mkdirSync(path.dirname(full), { recursive: true });
|
||
fs.writeFileSync(full, content.replace(/\r\n/g, "\n"), "utf8");
|
||
console.log("write", path.relative(ROOT, full));
|
||
}
|
||
|
||
const ROUTES = [
|
||
{ route: "hub", feature: "hub", title: "هاب تربتپی" },
|
||
{ route: "dashboard", feature: "dashboard", title: "داشبورد اجرایی" },
|
||
{ route: "overview", feature: "overview", title: "نمای کلی پرداخت" },
|
||
{ route: "kpis", feature: "kpis", title: "شاخصهای کلیدی" },
|
||
{ route: "transactions", feature: "transactions", title: "تراکنشهای اخیر" },
|
||
{ route: "transactions/[id]", feature: "transactionDetail", title: "جزئیات تراکنش" },
|
||
{ route: "ledger", feature: "ledger", title: "دفتر کل پرداخت" },
|
||
{ route: "psp", feature: "psp", title: "مدیریت درگاهها" },
|
||
{ route: "psp/health", feature: "gatewayHealth", title: "سلامت درگاه" },
|
||
{ route: "providers", feature: "providers", title: "تخصیص ارائهدهنده" },
|
||
{ route: "merchants", feature: "merchants", title: "حسابهای پذیرنده" },
|
||
{ route: "merchants/[id]", feature: "merchantDetail", title: "جزئیات پذیرنده" },
|
||
{ route: "merchants/approvals", feature: "merchantApprovals", title: "تأیید پذیرنده" },
|
||
{ route: "requests", feature: "requests", title: "درخواستهای پرداخت" },
|
||
{ route: "requests/[id]", feature: "requestDetail", title: "جزئیات درخواست / چکاوت" },
|
||
{ route: "callbacks", feature: "callbacks", title: "مانیتور کالبک" },
|
||
{ route: "verification", feature: "verification", title: "مانیتور تأیید" },
|
||
{ route: "audit", feature: "audit", title: "لاگ حسابرسی" },
|
||
{ route: "bundles", feature: "bundles", title: "باندلها" },
|
||
{ route: "feature-toggles", feature: "featureToggles", title: "کلیدهای ویژگی" },
|
||
{ route: "capabilities", feature: "capabilities", title: "قابلیتها" },
|
||
{ route: "settings", feature: "settings", title: "تنظیمات پرداخت" },
|
||
{ route: "metrics", feature: "metrics", title: "متریکها" },
|
||
{ route: "monitoring", feature: "monitoring", title: "مانیتورینگ" },
|
||
{ route: "api-explorer", feature: "apiExplorer", title: "کاوشگر API" },
|
||
{ route: "openapi", feature: "openapi", title: "مشاهده OpenAPI" },
|
||
];
|
||
|
||
// --- core module files ---
|
||
write("modules/payment/types/index.ts", `export type PaymentRecord = Record<string, unknown> & { id?: string; status?: string };
|
||
|
||
export type HealthResponse = { status: string; service: string; version: string };
|
||
export type CapabilitiesResponse = {
|
||
service: string;
|
||
version: string;
|
||
phase: string;
|
||
workspace_status?: string;
|
||
default_payment_mode?: string;
|
||
active_bundles?: string[];
|
||
feature_toggles?: Record<string, boolean>;
|
||
payment_sources_supported?: string[];
|
||
payment_sources_reserved?: string[];
|
||
contract_versions?: Record<string, string>;
|
||
};
|
||
export type MetricsResponse = {
|
||
service: string;
|
||
version: string;
|
||
phase: string;
|
||
metrics: Record<string, unknown>;
|
||
};
|
||
export type PermissionsCatalogResponse = { permissions: string[] };
|
||
`);
|
||
|
||
write("modules/payment/constants/permissions.ts", `export const PAYMENT_VIEW = "payment.view";
|
||
export const PAYMENT_MANAGE = "payment.manage";
|
||
export const PAYMENT_AUDIT_VIEW = "payment.audit.view";
|
||
export const PAYMENT_SETTINGS_MANAGE = "payment.settings.manage";
|
||
export const PAYMENT_PSP_VIEW = "payment.psp_connections.view";
|
||
export const PAYMENT_PSP_MANAGE = "payment.psp_connections.manage";
|
||
export const PAYMENT_MERCHANT_VIEW = "payment.merchant_accounts.view";
|
||
export const PAYMENT_MERCHANT_MANAGE = "payment.merchant_accounts.manage";
|
||
export const PAYMENT_REQUEST_VIEW = "payment.requests.view";
|
||
export const PAYMENT_REQUEST_CREATE = "payment.requests.create";
|
||
export const PAYMENT_TX_VIEW = "payment.transactions.view";
|
||
export const PAYMENT_LEDGER_VIEW = "payment.ledger.view";
|
||
|
||
export const ALL_PAYMENT_PERMISSIONS = [
|
||
PAYMENT_VIEW,
|
||
PAYMENT_MANAGE,
|
||
PAYMENT_AUDIT_VIEW,
|
||
PAYMENT_SETTINGS_MANAGE,
|
||
PAYMENT_PSP_VIEW,
|
||
PAYMENT_PSP_MANAGE,
|
||
PAYMENT_MERCHANT_VIEW,
|
||
PAYMENT_MERCHANT_MANAGE,
|
||
PAYMENT_REQUEST_VIEW,
|
||
PAYMENT_REQUEST_CREATE,
|
||
PAYMENT_TX_VIEW,
|
||
PAYMENT_LEDGER_VIEW,
|
||
] as const;
|
||
`);
|
||
|
||
write("modules/payment/constants/portals.ts", `import type { LucideIcon } from "lucide-react";
|
||
import {
|
||
LayoutDashboard,
|
||
CreditCard,
|
||
Building2,
|
||
Settings,
|
||
Shield,
|
||
Activity,
|
||
BarChart3,
|
||
Wallet,
|
||
Plug,
|
||
FileText,
|
||
BookOpen,
|
||
KeyRound,
|
||
Gauge,
|
||
ScrollText,
|
||
Network,
|
||
Eye,
|
||
} from "lucide-react";
|
||
|
||
export type PortalNavItem = { href: string; label: string; bundle?: string | null };
|
||
export type PortalNavGroup = {
|
||
id: string;
|
||
label: string;
|
||
icon: LucideIcon;
|
||
href?: string;
|
||
bundle?: string | null;
|
||
items?: PortalNavItem[];
|
||
};
|
||
|
||
export const PAYMENT_PORTAL = {
|
||
id: "payment" as const,
|
||
label: "تربتپی",
|
||
description: "پلتفرم پرداخت سازمانی — BYO-PSP و پذیرنده",
|
||
basePath: "/payment",
|
||
icon: CreditCard,
|
||
};
|
||
|
||
export const PAYMENT_NAV: PortalNavGroup[] = [
|
||
{
|
||
id: "dashboards",
|
||
label: "داشبوردها",
|
||
icon: LayoutDashboard,
|
||
items: [
|
||
{ href: "/payment/dashboard", label: "داشبورد اجرایی" },
|
||
{ href: "/payment/overview", label: "نمای کلی" },
|
||
{ href: "/payment/kpis", label: "شاخصهای کلیدی" },
|
||
],
|
||
},
|
||
{
|
||
id: "operations",
|
||
label: "عملیات پرداخت",
|
||
icon: Wallet,
|
||
items: [
|
||
{ href: "/payment/requests", label: "درخواستهای پرداخت" },
|
||
{ href: "/payment/transactions", label: "تراکنشها" },
|
||
{ href: "/payment/ledger", label: "دفتر کل" },
|
||
{ href: "/payment/callbacks", label: "کالبکها" },
|
||
{ href: "/payment/verification", label: "تأیید پرداخت" },
|
||
],
|
||
},
|
||
{
|
||
id: "psp",
|
||
label: "درگاهها (PSP)",
|
||
icon: Plug,
|
||
bundle: "payment.byo_psp.basic",
|
||
items: [
|
||
{ href: "/payment/psp", label: "مدیریت درگاه", bundle: "payment.byo_psp.basic" },
|
||
{ href: "/payment/psp/health", label: "سلامت درگاه", bundle: "payment.byo_psp.basic" },
|
||
{ href: "/payment/providers", label: "تخصیص ارائهدهنده" },
|
||
],
|
||
},
|
||
{
|
||
id: "merchants",
|
||
label: "پذیرندگان",
|
||
icon: Building2,
|
||
bundle: "payment.torbat_pay.merchant",
|
||
items: [
|
||
{ href: "/payment/merchants", label: "حسابها", bundle: "payment.torbat_pay.merchant" },
|
||
{ href: "/payment/merchants/approvals", label: "تأییدها", bundle: "payment.torbat_pay.merchant" },
|
||
],
|
||
},
|
||
{
|
||
id: "licensing",
|
||
label: "مجوز و باندل",
|
||
icon: KeyRound,
|
||
items: [
|
||
{ href: "/payment/bundles", label: "باندلها" },
|
||
{ href: "/payment/feature-toggles", label: "کلیدهای ویژگی" },
|
||
{ href: "/payment/capabilities", label: "قابلیتها" },
|
||
],
|
||
},
|
||
{
|
||
id: "ops",
|
||
label: "عملیات پلتفرم",
|
||
icon: Activity,
|
||
items: [
|
||
{ href: "/payment/monitoring", label: "مانیتورینگ" },
|
||
{ href: "/payment/metrics", label: "متریکها" },
|
||
{ href: "/payment/audit", label: "حسابرسی" },
|
||
{ href: "/payment/settings", label: "تنظیمات" },
|
||
],
|
||
},
|
||
{
|
||
id: "dev",
|
||
label: "توسعهدهنده",
|
||
icon: Network,
|
||
items: [
|
||
{ href: "/payment/api-explorer", label: "کاوشگر API" },
|
||
{ href: "/payment/openapi", label: "OpenAPI" },
|
||
],
|
||
},
|
||
];
|
||
|
||
export function navForPayment() {
|
||
return PAYMENT_NAV;
|
||
}
|
||
|
||
export function filterNavByBundles(
|
||
nav: PortalNavGroup[],
|
||
activeBundles: string[] | undefined
|
||
) {
|
||
const bundles = new Set(activeBundles ?? []);
|
||
return nav
|
||
.map((g) => {
|
||
if (g.bundle && !bundles.has(g.bundle)) return null;
|
||
const items = g.items?.filter((i) => !i.bundle || bundles.has(i.bundle));
|
||
if (g.items && (!items || items.length === 0)) return null;
|
||
return { ...g, items };
|
||
})
|
||
.filter(Boolean) as PortalNavGroup[];
|
||
}
|
||
|
||
export const MVP_ROUTE_HREFS = [
|
||
"/payment/hub",
|
||
"/payment/dashboard",
|
||
"/payment/overview",
|
||
"/payment/kpis",
|
||
"/payment/transactions",
|
||
"/payment/ledger",
|
||
"/payment/psp",
|
||
"/payment/psp/health",
|
||
"/payment/providers",
|
||
"/payment/merchants",
|
||
"/payment/merchants/approvals",
|
||
"/payment/requests",
|
||
"/payment/callbacks",
|
||
"/payment/verification",
|
||
"/payment/audit",
|
||
"/payment/bundles",
|
||
"/payment/feature-toggles",
|
||
"/payment/capabilities",
|
||
"/payment/settings",
|
||
"/payment/metrics",
|
||
"/payment/monitoring",
|
||
"/payment/api-explorer",
|
||
"/payment/openapi",
|
||
] as const;
|
||
`);
|
||
|
||
write(
|
||
"modules/payment/services/payment-api.ts",
|
||
`/**
|
||
* Torbat Pay API client — real backend only (MVP 14.0–14.5).
|
||
* Browser → /api/payment BFF; SSR → direct upstream :8012
|
||
*/
|
||
import { getValidAccessToken, refreshSession } from "@/lib/auth";
|
||
import type {
|
||
CapabilitiesResponse,
|
||
HealthResponse,
|
||
MetricsResponse,
|
||
PaymentRecord,
|
||
PermissionsCatalogResponse,
|
||
} from "@/modules/payment/types";
|
||
|
||
const PAYMENT_BASE =
|
||
typeof window !== "undefined"
|
||
? "/api/payment"
|
||
: process.env.PAYMENT_SERVICE_URL ||
|
||
process.env.NEXT_PUBLIC_PAYMENT_API_URL ||
|
||
"http://localhost:8012";
|
||
|
||
export class PaymentApiError extends Error {
|
||
constructor(
|
||
public status: number,
|
||
public code: string,
|
||
message: string,
|
||
public details?: unknown
|
||
) {
|
||
super(message);
|
||
this.name = "PaymentApiError";
|
||
}
|
||
}
|
||
|
||
type RequestOptions = RequestInit & {
|
||
tenantId: string;
|
||
auth?: boolean;
|
||
idempotencyKey?: string;
|
||
};
|
||
|
||
async function request<T>(path: string, options: RequestOptions): Promise<T> {
|
||
const { tenantId, auth = true, idempotencyKey, headers: customHeaders, ...init } = options;
|
||
const headers = new Headers(customHeaders);
|
||
if (!headers.has("Content-Type") && init.body) headers.set("Content-Type", "application/json");
|
||
headers.set("X-Tenant-ID", tenantId);
|
||
if (idempotencyKey) headers.set("Idempotency-Key", idempotencyKey);
|
||
if (auth) {
|
||
const token = await getValidAccessToken();
|
||
if (token) headers.set("Authorization", \`Bearer \${token}\`);
|
||
}
|
||
const url = path.startsWith("http")
|
||
? path
|
||
: \`\${PAYMENT_BASE.replace(/\\/$/, "")}\${path.startsWith("/") ? path : \`/\${path}\`}\`;
|
||
|
||
let res: Response;
|
||
try {
|
||
res = await fetch(url, { ...init, headers, cache: "no-store" });
|
||
} catch (err) {
|
||
const detail = err instanceof Error ? err.message : "network_error";
|
||
throw new Error(\`اتصال به سرویس تربتپی برقرار نشد — \${detail}\`);
|
||
}
|
||
|
||
if (res.status === 401 && auth) {
|
||
const refreshed = await refreshSession({ force: true });
|
||
if (refreshed) {
|
||
headers.set("Authorization", \`Bearer \${refreshed}\`);
|
||
res = await fetch(url, { ...init, headers, cache: "no-store" });
|
||
}
|
||
}
|
||
|
||
if (!res.ok) {
|
||
const body = await res.json().catch(() => ({}));
|
||
const detail = body?.error ?? body?.detail ?? {};
|
||
throw new PaymentApiError(
|
||
res.status,
|
||
detail?.code || (typeof detail === "string" ? detail : "unknown_error"),
|
||
detail?.message || (typeof detail === "string" ? detail : res.statusText),
|
||
detail
|
||
);
|
||
}
|
||
if (res.status === 204) return undefined as T;
|
||
return res.json() as Promise<T>;
|
||
}
|
||
|
||
function crud(basePath: string) {
|
||
return {
|
||
list: (tenantId: string) => request<PaymentRecord[]>(basePath, { tenantId }),
|
||
get: (tenantId: string, id: string) => request<PaymentRecord>(\`\${basePath}/\${id}\`, { tenantId }),
|
||
create: (tenantId: string, body: Record<string, unknown>) =>
|
||
request<PaymentRecord>(basePath, { tenantId, method: "POST", body: JSON.stringify(body) }),
|
||
update: (tenantId: string, id: string, body: Record<string, unknown>) =>
|
||
request<PaymentRecord>(\`\${basePath}/\${id}\`, {
|
||
tenantId,
|
||
method: "PATCH",
|
||
body: JSON.stringify(body),
|
||
}),
|
||
};
|
||
}
|
||
|
||
export const paymentApi = {
|
||
health: (): Promise<HealthResponse> => fetch(\`\${PAYMENT_BASE}/health\`).then((r) => r.json()),
|
||
capabilities: (tenantId: string) =>
|
||
request<CapabilitiesResponse>("/capabilities", { tenantId }),
|
||
metrics: (tenantId?: string) =>
|
||
tenantId
|
||
? request<MetricsResponse>("/metrics", { tenantId })
|
||
: fetch(\`\${PAYMENT_BASE}/metrics\`).then((r) => r.json()),
|
||
openapi: async (): Promise<Record<string, unknown>> => {
|
||
const r = await fetch(\`\${PAYMENT_BASE}/openapi.json\`);
|
||
if (!r.ok) throw new PaymentApiError(r.status, "openapi_error", "OpenAPI در دسترس نیست");
|
||
return r.json();
|
||
},
|
||
permissions: {
|
||
catalog: (tenantId: string) =>
|
||
request<PermissionsCatalogResponse>("/api/v1/permissions/catalog", { tenantId }),
|
||
},
|
||
audit: {
|
||
list: (tenantId: string) => request<PaymentRecord[]>("/api/v1/audit", { tenantId }),
|
||
},
|
||
workspaces: crud("/api/v1/payment-workspaces"),
|
||
bundleDefinitions: crud("/api/v1/bundle-definitions"),
|
||
tenantBundles: crud("/api/v1/tenant-bundles"),
|
||
featureToggles: crud("/api/v1/feature-toggles"),
|
||
providerAssignments: crud("/api/v1/provider-assignments"),
|
||
pspRegistrations: crud("/api/v1/psp-provider-registrations"),
|
||
creditRegistrations: crud("/api/v1/credit-provider-registrations"),
|
||
configurations: crud("/api/v1/configurations"),
|
||
settings: crud("/api/v1/settings"),
|
||
pspConnections: {
|
||
...crud("/api/v1/psp-connections"),
|
||
test: (tenantId: string, id: string) =>
|
||
request<Record<string, unknown>>(\`/api/v1/psp-connections/\${id}/test\`, {
|
||
tenantId,
|
||
method: "POST",
|
||
body: "{}",
|
||
}),
|
||
},
|
||
pspRouting: crud("/api/v1/psp-routing-policies"),
|
||
merchants: {
|
||
list: (tenantId: string) => request<PaymentRecord[]>("/api/v1/merchant-accounts", { tenantId }),
|
||
get: (tenantId: string, id: string) =>
|
||
request<PaymentRecord>(\`/api/v1/merchant-accounts/\${id}\`, { tenantId }),
|
||
create: (tenantId: string, body: Record<string, unknown>) =>
|
||
request<PaymentRecord>("/api/v1/merchant-accounts", {
|
||
tenantId,
|
||
method: "POST",
|
||
body: JSON.stringify(body),
|
||
}),
|
||
submit: (tenantId: string, id: string) =>
|
||
request<PaymentRecord>(\`/api/v1/merchant-accounts/\${id}/submit\`, { tenantId, method: "POST", body: "{}" }),
|
||
activate: (tenantId: string, id: string) =>
|
||
request<PaymentRecord>(\`/api/v1/merchant-accounts/\${id}/activate\`, { tenantId, method: "POST", body: "{}" }),
|
||
suspend: (tenantId: string, id: string) =>
|
||
request<PaymentRecord>(\`/api/v1/merchant-accounts/\${id}/suspend\`, { tenantId, method: "POST", body: "{}" }),
|
||
close: (tenantId: string, id: string) =>
|
||
request<PaymentRecord>(\`/api/v1/merchant-accounts/\${id}/close\`, { tenantId, method: "POST", body: "{}" }),
|
||
},
|
||
settlementProfiles: crud("/api/v1/merchant-settlement-profiles"),
|
||
requests: {
|
||
list: (tenantId: string) => request<PaymentRecord[]>("/api/v1/payment-requests", { tenantId }),
|
||
get: (tenantId: string, id: string) =>
|
||
request<PaymentRecord>(\`/api/v1/payment-requests/\${id}\`, { tenantId }),
|
||
create: (tenantId: string, body: Record<string, unknown>, idempotencyKey: string) =>
|
||
request<PaymentRecord>("/api/v1/payment-requests", {
|
||
tenantId,
|
||
method: "POST",
|
||
body: JSON.stringify(body),
|
||
idempotencyKey,
|
||
}),
|
||
cancel: (tenantId: string, id: string) =>
|
||
request<PaymentRecord>(\`/api/v1/payment-requests/\${id}/cancel\`, { tenantId, method: "POST", body: "{}" }),
|
||
verify: (tenantId: string, id: string, body?: Record<string, unknown>) =>
|
||
request<PaymentRecord>(\`/api/v1/payment-requests/\${id}/verify\`, {
|
||
tenantId,
|
||
method: "POST",
|
||
body: JSON.stringify(body ?? { status: "paid" }),
|
||
}),
|
||
},
|
||
transactions: {
|
||
list: (tenantId: string) => request<PaymentRecord[]>("/api/v1/payment-transactions", { tenantId }),
|
||
get: (tenantId: string, id: string) =>
|
||
request<PaymentRecord>(\`/api/v1/payment-transactions/\${id}\`, { tenantId }),
|
||
bySource: (tenantId: string, service?: string, refId?: string) => {
|
||
const sp = new URLSearchParams();
|
||
if (service) sp.set("service", service);
|
||
if (refId) sp.set("ref_id", refId);
|
||
const q = sp.toString();
|
||
return request<PaymentRecord[]>(\`/api/v1/payment-transactions/by-source\${q ? \`?\${q}\` : ""}\`, {
|
||
tenantId,
|
||
});
|
||
},
|
||
},
|
||
ledger: {
|
||
list: (tenantId: string) => request<PaymentRecord[]>("/api/v1/payment-ledger-entries", { tenantId }),
|
||
},
|
||
};
|
||
|
||
export const PAYMENT_API_CATALOG = [
|
||
{ method: "GET", path: "/health", phase: "14.0" },
|
||
{ method: "GET", path: "/capabilities", phase: "14.0" },
|
||
{ method: "GET", path: "/metrics", phase: "14.0" },
|
||
{ method: "GET|POST", path: "/api/v1/payment-workspaces", phase: "14.0" },
|
||
{ method: "GET|POST", path: "/api/v1/tenant-bundles", phase: "14.0" },
|
||
{ method: "GET|POST", path: "/api/v1/feature-toggles", phase: "14.0" },
|
||
{ method: "GET|POST", path: "/api/v1/psp-connections", phase: "14.1" },
|
||
{ method: "POST", path: "/api/v1/psp-connections/{id}/test", phase: "14.1" },
|
||
{ method: "GET|POST", path: "/api/v1/merchant-accounts", phase: "14.2" },
|
||
{ method: "POST", path: "/api/v1/payment-requests", phase: "14.3" },
|
||
{ method: "POST", path: "/api/v1/payment-requests/{id}/verify", phase: "14.4" },
|
||
{ method: "GET", path: "/api/v1/payment-transactions", phase: "14.5" },
|
||
{ method: "GET", path: "/api/v1/payment-ledger-entries", phase: "14.5" },
|
||
] as const;
|
||
`
|
||
);
|