TorbatYar/frontend/modules/payment/services/payment-api.ts
Mortezakoohjani 4451b32a33 feat(payment-frontend): ship Torbat Pay admin portal for MVP 14.0-14.5
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>
2026-07-27 18:35:07 +03:30

211 lines
8.5 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

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.

/**
* Torbat Pay API client — real backend only (MVP 14.014.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;