- src/payments: provider registry and PaymentProvider interface (createCheckout, verifyWebhook/parseWebhook, getTransaction, refund, listTransactions as capabilities), Yoco as the first provider. All four direct Yoco checkout calls go through payments.createCheckout. - Webhook split into the provider part (signature, parsing) and shared processing; same URL (/api/webhooks/yoco), same behaviour. - YocoTransaction renamed in place to ProviderTransaction (+ provider, unique per provider + externalId); Payment.provider added and backfilled to "yoco". Payment.method stays the wallet type so cashups are unchanged. - Site settings → Payments: Enabled toggle and order per provider; Yoco's settings shown while it's enabled. R15 minimum for all online payments. - New endpoint names (/api/payments/checkout, /api/provider-transactions); the old /yoco-checkout and /yoco-transactions paths still work. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
219 lines
9.1 KiB
TypeScript
219 lines
9.1 KiB
TypeScript
import type { Paginated, Payment, User } from "@/types";
|
|
|
|
export type ApiOptions = {
|
|
method?: string;
|
|
headers?: Record<string, string>;
|
|
body?: unknown;
|
|
authToken?: string | null;
|
|
nextOptions?: RequestInit;
|
|
};
|
|
|
|
// Compute a robust API base that works locally, across devices, and in prod.
|
|
// Priority: NEXT_PUBLIC_API_URL -> API_URL -> (browser) smart dev fallbacks -> (browser) window.origin -> ""
|
|
function computeApiBase(): string {
|
|
const envBase = process.env.NEXT_PUBLIC_API_URL || process.env.API_URL || "";
|
|
|
|
// If running in the browser, try to adapt localhost to current hostname for LAN/mobile testing
|
|
if (typeof window !== "undefined") {
|
|
try {
|
|
if (envBase) {
|
|
const u = new URL(envBase);
|
|
const isLocalHost = ["localhost", "127.0.0.1", "::1"].includes(u.hostname);
|
|
if (isLocalHost) {
|
|
// Replace localhost with the actual device hostname so other devices can reach the dev machine
|
|
const protocol = u.protocol || window.location.protocol;
|
|
const hostname = window.location.hostname; // e.g., 192.168.x.x or your.dev.domain
|
|
const port = u.port ? `:${u.port}` : "";
|
|
return `${protocol}//${hostname}${port}`;
|
|
}
|
|
return u.origin;
|
|
}
|
|
|
|
// No env base provided — provide a sensible dev fallback
|
|
// If Next dev server is on 3000, assume backend on the same host port 5000
|
|
if (window.location.port === "3000") {
|
|
const protocol = window.location.protocol;
|
|
const hostname = window.location.hostname;
|
|
return `${protocol}//${hostname}:5000`;
|
|
}
|
|
|
|
// Otherwise, assume same origin will proxy /api (works if reverse proxy is configured)
|
|
return window.location.origin;
|
|
} catch {
|
|
// Fallbacks below
|
|
}
|
|
}
|
|
|
|
// On the server (or if window not available), use envBase if present; otherwise empty string.
|
|
return envBase || "";
|
|
}
|
|
|
|
export const API_BASE = computeApiBase();
|
|
|
|
// Resolve a URL or path to use the API origin. If it's a relative path, prefix with API_BASE.
|
|
// If it's an absolute URL but points to localhost/127.0.0.1/::1, rewrite its origin to API_BASE.
|
|
export function resolveToApiOrigin(urlOrPath: string | null | undefined): string | null {
|
|
const raw = (urlOrPath || "").trim();
|
|
if (!raw) return null;
|
|
try {
|
|
if (raw.startsWith("http")) {
|
|
const u = new URL(raw);
|
|
const isLocal = ["localhost", "127.0.0.1", "::1"].includes(u.hostname);
|
|
if (isLocal && API_BASE) {
|
|
const api = new URL(API_BASE);
|
|
u.protocol = api.protocol;
|
|
u.hostname = api.hostname;
|
|
u.port = api.port;
|
|
return u.toString();
|
|
}
|
|
return raw; // already absolute and not localhost
|
|
}
|
|
} catch {
|
|
// fall through to relative handling
|
|
}
|
|
return API_BASE ? `${API_BASE}${raw}` : raw;
|
|
}
|
|
|
|
// The parsed JSON body of a failed request — the backend always sends `message`, and
|
|
// some routes add a machine-readable `code` or extra detail fields.
|
|
export type ApiErrorData = { message?: string; code?: string; [key: string]: unknown };
|
|
|
|
export class ApiError extends Error {
|
|
constructor(public readonly status: number, message: string, public readonly data?: ApiErrorData) {
|
|
super(message);
|
|
this.name = "ApiError";
|
|
}
|
|
}
|
|
|
|
export async function apiFetch<T = unknown>(path: string, options: ApiOptions = {}): Promise<T> {
|
|
const { method = "GET", headers = {}, body, authToken, nextOptions } = options;
|
|
|
|
const url = path.startsWith("http") ? path : `${API_BASE}${path}`;
|
|
|
|
// On the server a relative URL has no origin to resolve against, and Next's
|
|
// patched fetch never settles on one during prerender (the build then times out
|
|
// every page). Fail fast instead so callers can degrade.
|
|
if (typeof window === "undefined" && !url.startsWith("http")) {
|
|
throw new Error(`API base URL is not configured (set NEXT_PUBLIC_API_URL or API_URL); cannot fetch ${path}`);
|
|
}
|
|
|
|
const mergedHeaders: Record<string, string> = { ...headers };
|
|
if (!(body instanceof FormData)) {
|
|
mergedHeaders["Content-Type"] = "application/json";
|
|
}
|
|
|
|
if (authToken) {
|
|
mergedHeaders["Authorization"] = `Bearer ${authToken}`;
|
|
}
|
|
|
|
const res = await fetch(url, {
|
|
method,
|
|
headers: mergedHeaders as HeadersInit,
|
|
body: body instanceof FormData ? body : body != null ? JSON.stringify(body) : undefined,
|
|
...nextOptions,
|
|
} as RequestInit);
|
|
|
|
if (!res.ok) {
|
|
const text = await res.text().catch(() => "");
|
|
let message = text || `Request failed with status ${res.status}`;
|
|
let data: ApiErrorData | undefined;
|
|
// Backend returns { message: "..." } — extract it so callers get a plain string
|
|
try {
|
|
const json = JSON.parse(text);
|
|
if (json?.message && typeof json.message === "string") message = json.message;
|
|
data = json;
|
|
} catch { /* not JSON — use raw text */ }
|
|
// An admin/supervisor session from before 2FA was required for their role:
|
|
// end it and send them to log in, where they enrol.
|
|
// The admin's "view as" session ended or expired: put their own session back.
|
|
if (res.status === 401 && data?.code === "IMPERSONATION_ENDED" && typeof window !== "undefined") {
|
|
const { restoreAdmin } = await import("@/lib/impersonation");
|
|
restoreAdmin();
|
|
}
|
|
if (res.status === 401 && data?.code === "TWO_FACTOR_REQUIRED" && typeof window !== "undefined") {
|
|
localStorage.removeItem("hope_events_token");
|
|
localStorage.removeItem("hope_events_user_cache");
|
|
if (!window.location.pathname.startsWith("/login")) {
|
|
const back = window.location.pathname + window.location.search;
|
|
window.location.href = `/login?reason=2fa&redirect=${encodeURIComponent(back)}`;
|
|
}
|
|
}
|
|
throw new ApiError(res.status, message, data);
|
|
}
|
|
const contentType = res.headers.get("content-type") || "";
|
|
if (contentType.includes("application/json")) {
|
|
return res.json();
|
|
}
|
|
// Non-JSON responses come back as text; callers that expect that pass T = string.
|
|
return (await res.text()) as T;
|
|
}
|
|
|
|
// For server components that prerender a public list: during `next build` the
|
|
// backend is often not running (or no API URL is set), so fall back to an empty
|
|
// list rather than failing the build — the page fills in on its first revalidate.
|
|
// At runtime the error still propagates, so ISR keeps serving the last good page
|
|
// instead of caching an empty one.
|
|
export async function apiFetchListForPrerender<T>(path: string, options: ApiOptions = {}): Promise<T[]> {
|
|
try {
|
|
return (await apiFetch<T[]>(path, options)) || [];
|
|
} catch (err) {
|
|
if (process.env.NEXT_PHASE === "phase-production-build") return [];
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
// Several list endpoints (/api/users, /api/payments) are paginated and cap
|
|
// `limit` at 200 server-side, so a single request can silently miss records
|
|
// beyond the first page. This walks every page and returns the full list.
|
|
async function fetchAllPages<T>(path: string, token: string, params: Record<string, string> = {}): Promise<T[]> {
|
|
const query = new URLSearchParams({ ...params, limit: "200", page: "1" });
|
|
const first = await apiFetch<Paginated<T>>(`${path}?${query.toString()}`, { authToken: token });
|
|
const items: T[] = Array.isArray(first?.data) ? first.data : [];
|
|
const pages = first?.pages || 1;
|
|
|
|
if (pages > 1) {
|
|
// Page 1 told us the total page count — fetch the rest concurrently instead of
|
|
// awaiting them one at a time. This function is called on every dashboard poll
|
|
// (see useVisiblePolling usages), so a serial loop here directly slows those polls.
|
|
const remaining = await Promise.all(
|
|
Array.from({ length: pages - 1 }, (_, i) => {
|
|
const page = i + 2;
|
|
const pageQuery = new URLSearchParams({ ...params, limit: "200", page: String(page) });
|
|
return apiFetch<Paginated<T>>(`${path}?${pageQuery.toString()}`, { authToken: token });
|
|
})
|
|
);
|
|
for (const res of remaining) {
|
|
if (Array.isArray(res?.data)) items.push(...res.data);
|
|
}
|
|
}
|
|
|
|
return items;
|
|
}
|
|
|
|
export function fetchAllUsers(token: string, params: Record<string, string> = {}): Promise<User[]> {
|
|
return fetchAllPages<User>("/api/users", token, params);
|
|
}
|
|
|
|
export function fetchAllPayments(token: string, params: Record<string, string> = {}): Promise<Payment[]> {
|
|
return fetchAllPages<Payment>("/api/payments", token, params);
|
|
}
|
|
|
|
// Creates an online checkout (default payment provider) for a registration's full remaining balance.
|
|
// Omitting `amount` makes the backend charge the full remainder rather than a partial amount —
|
|
// partial-amount checkouts are only ever created from the supervisor payments dashboard.
|
|
export type CheckoutResponse = { redirectUrl?: string; priceUpdated?: boolean; newTotal?: number; message?: string };
|
|
|
|
export function createFullPaymentCheckout(token: string, registrationId: string, donationAmount?: number): Promise<CheckoutResponse> {
|
|
return apiFetch<CheckoutResponse>("/api/payments/checkout", {
|
|
method: "POST",
|
|
authToken: token,
|
|
body: {
|
|
registrationId,
|
|
...(donationAmount && donationAmount > 0 ? { donationAmount } : {}),
|
|
successUrl: window.location.origin + "/payment/success",
|
|
cancelUrl: window.location.origin + "/payment/cancel",
|
|
failureUrl: window.location.origin + "/payment/failure",
|
|
},
|
|
});
|
|
}
|