Files
hope-events/frontend/src/lib/api.ts
T
joshuaandClaude Opus 5.5 ede1ddbe00 Payment provider interface: Yoco behind it, Site settings → Payments
- 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>
2026-10-23 12:17:29 +02:00

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",
},
});
}