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>
211 lines
8.5 KiB
TypeScript
211 lines
8.5 KiB
TypeScript
/**
|
||
* 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;
|