mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-26 19:12:50 +00:00
feat(payment): implement payment feature with Telebirr integration first try
- Define shared payment types and interfaces in payment.ts - Create PaymentModal component for handling payment interactions - Implement payment API client for initiating and confirming payments - Add payment history management with localStorage - Create PaymentsPage for displaying payment status and history - Introduce utility functions for money conversion and formatting - Set up hooks for managing payment flow and state - Update SeamanBookApplicationPage to utilize new payment modal - Add tests for payment API functions and history management
This commit is contained in:
@@ -90,6 +90,8 @@ npm run dev:all
|
||||
|---|---|---|---|
|
||||
| `VITE_BASE_API_URL` | Yes | `http://localhost:3000` | Base URL for all API requests |
|
||||
| `VITE_ENABLE_DEVELOPER_TOOLS` | No | `true` | Toggle Redux DevTools |
|
||||
| `VITE_PAYMENT_API_URL` | Yes (portal) | — | Base URL for the payment service (portal `payment` feature). Can be a same-origin path if a backend proxy is later placed in front of it |
|
||||
| `VITE_PAYMENT_SERVICE_TOKEN` | Yes (portal) | — | Sent as `x-service-token` on every payment request. Note: bundled `VITE_*` values are public in the built app, not secret |
|
||||
| `PORTAL_PORT` | No | `4200` | Docker host port for portal |
|
||||
| `BACKOFFICE_PORT` | No | `4201` | Docker host port for backoffice |
|
||||
|
||||
|
||||
@@ -39,7 +39,8 @@ import {
|
||||
IconUpload,
|
||||
} from '@tabler/icons-react';
|
||||
import { notify } from '@ema-platform/ui';
|
||||
import { TelebirrPayment } from '../../payment/components/TelebirrPayment';
|
||||
import { PaymentModal } from '../../payment/components/PaymentModal';
|
||||
import { toMinor } from '../../payment/utils/money';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// STCW certificate catalog — sourced from the STCW Convention & Code
|
||||
@@ -651,8 +652,12 @@ export function CoCApplicationPage() {
|
||||
const [paymentDate, setPaymentDate] = useState('');
|
||||
const [paymentFile, setPaymentFile] = useState<File | null>(null);
|
||||
|
||||
// Telebirr order id, set once TelebirrPayment confirms — shown as the ref in Step 3
|
||||
// Telebirr intent id, set once PaymentModal's onSuccess fires — shown as the ref in Step 3
|
||||
const [telebirrOrderId, setTelebirrOrderId] = useState<string | null>(null);
|
||||
const [payModalOpen, setPayModalOpen] = useState(false);
|
||||
// Stable draft reference for this application session — there's no application id yet at
|
||||
// payment time (payment happens before submit), so this is what orderRef/referenceId key off.
|
||||
const [payRef] = useState(() => `COC-${crypto.randomUUID().slice(0, 8).toUpperCase()}`);
|
||||
|
||||
const docsStepOk = docSlots.filter((d) => d.required).every((d) => !!docs[d.key]);
|
||||
const payOk = paymentMethod === 'telebirr'
|
||||
@@ -1020,9 +1025,35 @@ export function CoCApplicationPage() {
|
||||
{paymentMethod === 'telebirr' && (
|
||||
<Stack gap="sm">
|
||||
<Divider label="Telebirr Payment" labelPosition="left" />
|
||||
<TelebirrPayment
|
||||
onConfirmed={(orderId) => {
|
||||
setTelebirrOrderId(orderId);
|
||||
{telebirrOrderId ? (
|
||||
<Alert variant="light" color="teal" icon={<IconCircleCheck size={15} />}>
|
||||
Payment confirmed — reference {telebirrOrderId}
|
||||
</Alert>
|
||||
) : (
|
||||
<Button
|
||||
variant="light"
|
||||
color="violet"
|
||||
leftSection={<IconCreditCard size={15} />}
|
||||
onClick={() => setPayModalOpen(true)}
|
||||
>
|
||||
Pay with Telebirr
|
||||
</Button>
|
||||
)}
|
||||
<PaymentModal
|
||||
opened={payModalOpen}
|
||||
onClose={() => setPayModalOpen(false)}
|
||||
title={`${selectedCert?.type ?? 'Certificate'} Application Fees`}
|
||||
amountMinor={toMinor(TOTAL)}
|
||||
currency="ETB"
|
||||
referenceId={payRef}
|
||||
orderRef={payRef}
|
||||
service="SEAFARER"
|
||||
referenceType="CERTIFICATE"
|
||||
defaultProvider="TELEBIRR"
|
||||
lockedProvider="TELEBIRR"
|
||||
onSuccess={(intent) => {
|
||||
setTelebirrOrderId(intent.id);
|
||||
setPayModalOpen(false);
|
||||
setStep(3);
|
||||
}}
|
||||
/>
|
||||
|
||||
96
apps/portal/src/app/features/payment/api/client.ts
Normal file
96
apps/portal/src/app/features/payment/api/client.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import { REQUEST_TIMEOUT_MS } from '../constants';
|
||||
|
||||
/**
|
||||
* The ONLY file in this feature that knows a URL, a header name, an env var, or `fetch` exists.
|
||||
* Everything above this file (services, hooks, components) calls `paymentFetch` and never sees
|
||||
* any of that — so swapping direct-to-payment-service calls for a backend proxy later means
|
||||
* changing this file alone: repoint VITE_PAYMENT_API_URL at the proxy path and nothing else moves.
|
||||
*/
|
||||
|
||||
function readEnv(name: string): string | undefined {
|
||||
return (import.meta as { env?: Record<string, string> }).env?.[name];
|
||||
}
|
||||
|
||||
interface PaymentClientConfig {
|
||||
baseUrl: string;
|
||||
serviceToken: string;
|
||||
}
|
||||
|
||||
function resolveConfig(): PaymentClientConfig {
|
||||
const baseUrl = readEnv('VITE_PAYMENT_API_URL');
|
||||
const serviceToken = readEnv('VITE_PAYMENT_SERVICE_TOKEN');
|
||||
if (!baseUrl || !serviceToken) {
|
||||
// Thrown lazily (only when a payment is actually attempted), not at module load — a
|
||||
// module-load throw would break the whole bundle for every user who never pays.
|
||||
throw new Error('Payment is not configured. Contact support.');
|
||||
}
|
||||
return { baseUrl, serviceToken };
|
||||
}
|
||||
|
||||
function messageForStatus(status: number): string {
|
||||
if (status >= 500) return 'The payment service is unavailable right now. Please try again.';
|
||||
if (status === 404) return 'The payment could not be found.';
|
||||
if (status >= 400) return 'The payment service rejected the request.';
|
||||
return `The payment service responded with an unexpected status (${status}).`;
|
||||
}
|
||||
|
||||
/** Reads a possibly-empty response body as text, never letting a malformed body throw raw. */
|
||||
async function parseBody(res: Response): Promise<unknown> {
|
||||
const text = await res.text();
|
||||
if (!text) return undefined; // empty body (e.g. 204) is valid, not an error
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
return undefined; // malformed JSON is handled by the caller, never surfaced as a raw SyntaxError
|
||||
}
|
||||
}
|
||||
|
||||
function extractServerMessage(body: unknown): string | undefined {
|
||||
if (typeof body !== 'object' || body === null) return undefined;
|
||||
const record = body as Record<string, unknown>;
|
||||
const message = record['message'] ?? record['error'] ?? record['detail'];
|
||||
return typeof message === 'string' ? message : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* The single request path for the whole feature. Attaches Content-Type + x-service-token exactly
|
||||
* once, times every call out, and never lets a raw fetch/parse failure escape — every error path
|
||||
* throws a plain Error with a message that's safe to show the user directly (ApiErrorAlert renders
|
||||
* Error.message as-is).
|
||||
*/
|
||||
export async function paymentFetch<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const { baseUrl, serviceToken } = resolveConfig();
|
||||
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(`${baseUrl}${path}`, {
|
||||
...init,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-service-token': serviceToken,
|
||||
...init?.headers,
|
||||
},
|
||||
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof DOMException && err.name === 'AbortError') {
|
||||
throw new Error('The payment service took too long to respond.');
|
||||
}
|
||||
// fetch rejects with TypeError on network failure (offline, DNS, CORS) — never re-throw it raw.
|
||||
throw new Error("Can't reach the payment service. Check your connection and try again.");
|
||||
}
|
||||
|
||||
const body = await parseBody(res);
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(extractServerMessage(body) ?? messageForStatus(res.status));
|
||||
}
|
||||
|
||||
if (body === undefined) {
|
||||
// A 2xx with an unreadable/empty body is only valid for endpoints that don't promise a payload;
|
||||
// callers that need a value will simply get `undefined` typed as T rather than a thrown SyntaxError.
|
||||
return undefined as T;
|
||||
}
|
||||
|
||||
return body as T;
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
import { baseApi } from "@ema-platform/api";
|
||||
import {
|
||||
TelebirrCreatePaymentResponse,
|
||||
TelebirrPayload,
|
||||
TelebirrResponse,
|
||||
} from "../types/payment";
|
||||
const paymentApi = baseApi.injectEndpoints({
|
||||
endpoints: (builder) => ({
|
||||
getPaymentStatusTelebirr: builder.query<TelebirrResponse, void>({
|
||||
query: (orderId) => `/payments/${orderId}/status`,
|
||||
providesTags: ["Api"],
|
||||
}),
|
||||
getPaymentDetail: builder.query<TelebirrResponse, void>({
|
||||
query: (paymentId) => `/payments/${paymentId}`,
|
||||
providesTags: ["Api"],
|
||||
}),
|
||||
createPaymentTelebirr: builder.mutation<
|
||||
TelebirrCreatePaymentResponse,
|
||||
TelebirrPayload
|
||||
>({
|
||||
query: (body) => ({
|
||||
url: "/payments/telebirr/create",
|
||||
method: "POST",
|
||||
body,
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
export const {
|
||||
useCreatePaymentTelebirrMutation,
|
||||
useGetPaymentStatusTelebirrQuery,
|
||||
useGetPaymentDetailQuery,
|
||||
} = paymentApi;
|
||||
103
apps/portal/src/app/features/payment/api/payments.test.ts
Normal file
103
apps/portal/src/app/features/payment/api/payments.test.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import { describe, expect, it, beforeEach } from 'vitest';
|
||||
import {
|
||||
isSuccessStatus,
|
||||
isTerminalStatus,
|
||||
listPaymentHistory,
|
||||
recordPayment,
|
||||
updatePaymentStatus,
|
||||
} from './payments';
|
||||
import { toMinor } from '../utils/money';
|
||||
import type { PaymentHistoryRecord } from '../types/payment';
|
||||
|
||||
// jsdom/happy-dom aren't installed in this repo, so localStorage is stubbed with a minimal
|
||||
// in-memory shim rather than adding a dependency for one test file.
|
||||
function installLocalStorageStub() {
|
||||
const store = new Map<string, string>();
|
||||
(globalThis as { localStorage?: Storage }).localStorage = {
|
||||
getItem: (key: string) => store.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => void store.set(key, value),
|
||||
removeItem: (key: string) => void store.delete(key),
|
||||
clear: () => store.clear(),
|
||||
key: () => null,
|
||||
get length() {
|
||||
return store.size;
|
||||
},
|
||||
} as Storage;
|
||||
}
|
||||
installLocalStorageStub();
|
||||
|
||||
const sampleRecord: PaymentHistoryRecord = {
|
||||
intentId: 'intent-1',
|
||||
title: 'Seaman Book Application',
|
||||
orderRef: 'SB-ABC123',
|
||||
referenceId: 'SB-ABC123',
|
||||
provider: 'TELEBIRR',
|
||||
amountMinor: 140000,
|
||||
currency: 'ETB',
|
||||
status: 'PENDING',
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
describe('isTerminalStatus / isSuccessStatus', () => {
|
||||
it('treats an unknown status as neither terminal nor success', () => {
|
||||
expect(isTerminalStatus('WEIRD_STATE')).toBe(false);
|
||||
expect(isSuccessStatus('WEIRD_STATE')).toBe(false);
|
||||
});
|
||||
|
||||
it('treats PAY_SUCCESS (Telebirr return param) as both terminal and success', () => {
|
||||
expect(isTerminalStatus('PAY_SUCCESS')).toBe(true);
|
||||
expect(isSuccessStatus('PAY_SUCCESS')).toBe(true);
|
||||
});
|
||||
|
||||
it('is case-insensitive', () => {
|
||||
expect(isTerminalStatus('failed')).toBe(true);
|
||||
expect(isSuccessStatus('success')).toBe(true);
|
||||
});
|
||||
|
||||
it('treats undefined as neither', () => {
|
||||
expect(isTerminalStatus(undefined)).toBe(false);
|
||||
expect(isSuccessStatus(undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('toMinor', () => {
|
||||
it('rounds to the nearest minor unit', () => {
|
||||
expect(toMinor(19.99)).toBe(1999);
|
||||
expect(toMinor(1400)).toBe(140000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('payment history round-trip', () => {
|
||||
it('records, lists, and updates a payment', () => {
|
||||
recordPayment(sampleRecord);
|
||||
expect(listPaymentHistory()).toHaveLength(1);
|
||||
|
||||
updatePaymentStatus('intent-1', 'SUCCESS');
|
||||
const [updated] = listPaymentHistory();
|
||||
expect(updated.status).toBe('SUCCESS');
|
||||
});
|
||||
|
||||
it('upserts by intentId instead of duplicating', () => {
|
||||
recordPayment(sampleRecord);
|
||||
recordPayment({ ...sampleRecord, status: 'FAILED' });
|
||||
const history = listPaymentHistory();
|
||||
expect(history).toHaveLength(1);
|
||||
expect(history[0].status).toBe('FAILED');
|
||||
});
|
||||
|
||||
it('does not rewrite storage when the status is unchanged', () => {
|
||||
recordPayment(sampleRecord);
|
||||
const before = localStorage.getItem('ema-payment-history');
|
||||
updatePaymentStatus('intent-1', 'PENDING'); // same status as recorded
|
||||
expect(localStorage.getItem('ema-payment-history')).toBe(before);
|
||||
});
|
||||
|
||||
it('tolerates corrupt JSON and returns an empty list', () => {
|
||||
localStorage.setItem('ema-payment-history', '{not json');
|
||||
expect(listPaymentHistory()).toEqual([]);
|
||||
});
|
||||
});
|
||||
121
apps/portal/src/app/features/payment/api/payments.ts
Normal file
121
apps/portal/src/app/features/payment/api/payments.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import { paymentFetch } from './client';
|
||||
import { PAYMENT_HISTORY_KEY, SUCCESS_STATUSES, TERMINAL_STATUSES } from '../constants';
|
||||
import type {
|
||||
PaymentHistoryRecord,
|
||||
PaymentIntent,
|
||||
PaymentRequest,
|
||||
PaymentStatus,
|
||||
} from '../types/payment';
|
||||
|
||||
// The complete surface components/hooks are allowed to import. Everything below goes through
|
||||
// `paymentFetch` — nothing here (or above it) knows a URL, token, header, or `fetch` exists.
|
||||
|
||||
/** POST /payments/initiate — the only way a new payment intent gets created. */
|
||||
export function initiatePayment(req: PaymentRequest): Promise<PaymentIntent> {
|
||||
return paymentFetch<PaymentIntent>('/payments/initiate', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(req),
|
||||
});
|
||||
}
|
||||
|
||||
/** GET /payments/intents/:intentId — used both by the poll and by manual "check again". */
|
||||
export function getIntent(intentId: string): Promise<PaymentIntent> {
|
||||
return paymentFetch<PaymentIntent>(`/payments/intents/${intentId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /payments/intents/:intentId/confirm — part of the documented contract, exported for
|
||||
* completeness. No requirement asks for an OTP screen and the modal's responsibilities don't
|
||||
* include one, so nothing in the UI calls this yet; building an OTP flow would be inventing
|
||||
* behaviour the spec never asked for.
|
||||
*/
|
||||
export function confirmIntent(intentId: string, otp: string): Promise<PaymentIntent> {
|
||||
return paymentFetch<PaymentIntent>(`/payments/intents/${intentId}/confirm`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ otp }),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Both predicates default to `false` for an unrecognised or missing status. That default matters
|
||||
* in both directions: an unknown status must never be read as success, and it must never stop the
|
||||
* poll (refetchInterval keys off isTerminalStatus — `false` means "keep polling"). Guessing the
|
||||
* other way in either case would silently lose or fake a payment.
|
||||
*/
|
||||
export function isTerminalStatus(status: PaymentStatus | undefined): boolean {
|
||||
return !!status && TERMINAL_STATUSES.has(status.toUpperCase());
|
||||
}
|
||||
|
||||
export function isSuccessStatus(status: PaymentStatus | undefined): boolean {
|
||||
return !!status && SUCCESS_STATUSES.has(status.toUpperCase());
|
||||
}
|
||||
|
||||
// ---- localStorage history -------------------------------------------------
|
||||
|
||||
export function listPaymentHistory(): PaymentHistoryRecord[] {
|
||||
const raw = localStorage.getItem(PAYMENT_HISTORY_KEY);
|
||||
if (!raw) return [];
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
return Array.isArray(parsed) ? (parsed as PaymentHistoryRecord[]) : [];
|
||||
} catch {
|
||||
return []; // corrupt JSON is treated as "no history", never a crash
|
||||
}
|
||||
}
|
||||
|
||||
function savePaymentHistory(records: PaymentHistoryRecord[]): void {
|
||||
localStorage.setItem(PAYMENT_HISTORY_KEY, JSON.stringify(records));
|
||||
}
|
||||
|
||||
/** Upsert by intentId — a retry creates a new record; re-recording the same intent replaces it in place. */
|
||||
export function recordPayment(record: PaymentHistoryRecord): void {
|
||||
const history = listPaymentHistory();
|
||||
const idx = history.findIndex((r) => r.intentId === record.intentId);
|
||||
if (idx === -1) {
|
||||
history.push(record);
|
||||
} else {
|
||||
history[idx] = record;
|
||||
}
|
||||
savePaymentHistory(history);
|
||||
}
|
||||
|
||||
export function updatePaymentStatus(intentId: string, status: PaymentStatus): void {
|
||||
const history = listPaymentHistory();
|
||||
const record = history.find((r) => r.intentId === intentId);
|
||||
if (!record || record.status === status) return; // no-op write: reconcile must not churn storage
|
||||
record.status = status;
|
||||
savePaymentHistory(history);
|
||||
}
|
||||
|
||||
/**
|
||||
* Refreshes AT MOST ONE record from the backend and writes it back if its status changed.
|
||||
*
|
||||
* Why one record, not all: the Telebirr return URL identifies exactly one payment (`merch_order_id`),
|
||||
* the documented contract has no batch-status endpoint, and a terminal record can never change again —
|
||||
* so re-fetching the rest of history would be N requests buying zero new information. When `orderRef`
|
||||
* is omitted (a bare `reconcilePaymentHistory()` call), the single most-recent non-terminal record is
|
||||
* used instead, since that's the only one that could plausibly still be in flight.
|
||||
*
|
||||
* A failed fetch leaves the local record untouched (best-effort refresh, never blanks history) and is
|
||||
* swallowed to a console.warn — reconciliation must not crash the page it's called from.
|
||||
*/
|
||||
export async function reconcilePaymentHistory(orderRef?: string): Promise<PaymentHistoryRecord[]> {
|
||||
const history = listPaymentHistory();
|
||||
|
||||
const target = orderRef
|
||||
? history.find((r) => r.orderRef === orderRef)
|
||||
: [...history].reverse().find((r) => !isTerminalStatus(r.status));
|
||||
|
||||
if (!target || isTerminalStatus(target.status)) {
|
||||
return history; // nothing to refresh — zero requests
|
||||
}
|
||||
|
||||
try {
|
||||
const intent = await getIntent(target.intentId);
|
||||
updatePaymentStatus(target.intentId, intent.status);
|
||||
} catch (err) {
|
||||
console.warn('reconcilePaymentHistory: failed to refresh intent', target.intentId, err);
|
||||
}
|
||||
|
||||
return listPaymentHistory();
|
||||
}
|
||||
156
apps/portal/src/app/features/payment/components/PaymentModal.tsx
Normal file
156
apps/portal/src/app/features/payment/components/PaymentModal.tsx
Normal file
@@ -0,0 +1,156 @@
|
||||
import { Alert, Anchor, Badge, Button, Group, Loader, Modal, Stack, Text } from '@mantine/core';
|
||||
import { IconAlertTriangle, IconCheck } from '@tabler/icons-react';
|
||||
import { ApiErrorAlert } from '@ema-platform/ui';
|
||||
import { usePaymentFlow } from '../hooks/usePaymentFlow';
|
||||
import { formatMinor } from '../utils/money';
|
||||
import { DEFAULT_PAYMENT_PROVIDER, PAYMENT_PHASE } from '../constants';
|
||||
import type { PaymentIntent, PaymentProvider } from '../types/payment';
|
||||
|
||||
export interface PaymentModalProps {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
title: string;
|
||||
amountMinor: number;
|
||||
currency: string;
|
||||
referenceId: string;
|
||||
orderRef: string;
|
||||
service: string;
|
||||
referenceType: string;
|
||||
defaultProvider?: PaymentProvider;
|
||||
lockedProvider?: PaymentProvider;
|
||||
payerAccount?: string;
|
||||
onSuccess?: (intent: PaymentIntent) => void;
|
||||
onFailure?: (intent: PaymentIntent) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reusable Telebirr payment modal. Renders from a single `phase` — see usePaymentFlow — so states
|
||||
* like "loading && success" or "loading && error" are unrepresentable rather than merely avoided.
|
||||
* Talks only to api/payments (via the hook); never touches a URL, token, header, or fetch directly.
|
||||
*/
|
||||
export function PaymentModal({
|
||||
opened,
|
||||
onClose,
|
||||
title,
|
||||
amountMinor,
|
||||
currency,
|
||||
referenceId,
|
||||
orderRef,
|
||||
service,
|
||||
referenceType,
|
||||
defaultProvider,
|
||||
lockedProvider,
|
||||
payerAccount,
|
||||
onSuccess,
|
||||
onFailure,
|
||||
}: PaymentModalProps) {
|
||||
const provider = lockedProvider ?? defaultProvider ?? DEFAULT_PAYMENT_PROVIDER;
|
||||
|
||||
const { phase, intent, error, isSlow, redirectUrl, pay, retry, openPayment, refresh } = usePaymentFlow({
|
||||
opened,
|
||||
title,
|
||||
amountMinor,
|
||||
currency,
|
||||
referenceId,
|
||||
orderRef,
|
||||
service,
|
||||
referenceType,
|
||||
provider,
|
||||
payerAccount,
|
||||
onSuccess,
|
||||
onFailure,
|
||||
});
|
||||
|
||||
const unhandledActionType =
|
||||
intent?.clientAction && intent.clientAction.type !== 'REDIRECT' ? intent.clientAction.type : null;
|
||||
|
||||
return (
|
||||
<Modal opened={opened} onClose={onClose} title={title} size="sm" centered closeOnClickOutside={phase !== PAYMENT_PHASE.Creating}>
|
||||
<Stack gap="md">
|
||||
<Group justify="space-between">
|
||||
<Text fz="sm" c="dimmed">Amount</Text>
|
||||
<Text fw={700}>{formatMinor(amountMinor, currency)}</Text>
|
||||
</Group>
|
||||
<Group justify="space-between">
|
||||
<Text fz="sm" c="dimmed">Provider</Text>
|
||||
<Badge color="green" variant="light">{provider}</Badge>
|
||||
</Group>
|
||||
|
||||
{phase === PAYMENT_PHASE.Idle && (
|
||||
<Button onClick={pay} fullWidth>
|
||||
Pay with {provider === 'TELEBIRR' ? 'Telebirr' : provider}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{phase === PAYMENT_PHASE.Creating && (
|
||||
<Group gap="xs">
|
||||
<Loader size="sm" />
|
||||
<Text fz="sm" c="dimmed">Creating your payment…</Text>
|
||||
</Group>
|
||||
)}
|
||||
|
||||
{phase === PAYMENT_PHASE.Opening && (
|
||||
<Group gap="xs">
|
||||
<Loader size="sm" />
|
||||
<Text fz="sm" c="dimmed">Opening Telebirr…</Text>
|
||||
</Group>
|
||||
)}
|
||||
|
||||
{phase === PAYMENT_PHASE.Blocked && redirectUrl && (
|
||||
<Alert color="yellow" icon={<IconAlertTriangle size={15} />} title="Popup blocked">
|
||||
<Stack gap="xs">
|
||||
<Text fz="sm">Your browser blocked the payment window. Open it manually to continue.</Text>
|
||||
<Button onClick={openPayment} size="sm">Open Payment</Button>
|
||||
</Stack>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{phase === PAYMENT_PHASE.Waiting && (
|
||||
<Stack gap="xs">
|
||||
<Group gap="xs">
|
||||
<Loader size="sm" />
|
||||
<Text fz="sm" c="dimmed">
|
||||
Waiting for confirmation from Telebirr… {intent ? `(${intent.status})` : null}
|
||||
</Text>
|
||||
</Group>
|
||||
{unhandledActionType && (
|
||||
<Text fz="xs" c="dimmed">
|
||||
The payment service returned an unhandled action ("{unhandledActionType}") — still
|
||||
checking for a result.
|
||||
</Text>
|
||||
)}
|
||||
{redirectUrl && (
|
||||
<Anchor fz="xs" onClick={() => window.open(redirectUrl, '_blank', 'noopener')}>
|
||||
Reopen Telebirr
|
||||
</Anchor>
|
||||
)}
|
||||
{isSlow && (
|
||||
<Alert color="yellow" variant="light" fz="xs">
|
||||
<Stack gap="xs">
|
||||
<Text fz="xs">This is taking longer than usual. It may still complete.</Text>
|
||||
<Group gap="xs">
|
||||
<Button size="xs" variant="default" onClick={refresh}>Check again</Button>
|
||||
<Button size="xs" variant="light" onClick={retry}>Try again</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Alert>
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{phase === PAYMENT_PHASE.Success && (
|
||||
<Alert color="teal" icon={<IconCheck size={15} />} title="Payment confirmed">
|
||||
<Text fz="sm">{intent ? `Reference: ${intent.id}` : null}</Text>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{phase === PAYMENT_PHASE.Failed && (
|
||||
<Stack gap="xs">
|
||||
<ApiErrorAlert error={error ? new Error(error) : (intent?.status ?? 'Payment failed')} title="Payment failed" />
|
||||
<Button onClick={retry} variant="light">Try again</Button>
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Alert, Box, Button, Group, Loader, Text } from '@mantine/core';
|
||||
import { IconCheck, IconExternalLink, IconQrcode } from '@tabler/icons-react';
|
||||
import { notify } from '@ema-platform/ui';
|
||||
import QRCode from 'react-qr-code';
|
||||
|
||||
type TelebirrStatus = 'creating' | 'ready' | 'polling' | 'confirmed';
|
||||
|
||||
interface TelebirrPaymentProps {
|
||||
onConfirmed: (orderId: string) => void;
|
||||
}
|
||||
|
||||
export function TelebirrPayment({ onConfirmed }: TelebirrPaymentProps) {
|
||||
const [status, setStatus] = useState<TelebirrStatus>('creating');
|
||||
const [orderId, setOrderId] = useState<string | null>(null);
|
||||
const [paymentUrl, setPaymentUrl] = useState<string | null>(null);
|
||||
const [choice, setChoice] = useState<'redirect' | 'qr' | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const id = crypto.randomUUID();
|
||||
const t = setTimeout(() => {
|
||||
setOrderId(id);
|
||||
// ponytail: mock createPaymentTelebirr({ orderid: id }) response — replace with
|
||||
// useCreatePaymentTelebirrMutation() from features/payment/api/payment-api once backend is live
|
||||
setPaymentUrl(`https://h5.telebirr.et/pay/${id}`);
|
||||
setStatus('ready');
|
||||
}, 900);
|
||||
return () => clearTimeout(t);
|
||||
}, []);
|
||||
|
||||
const startPolling = () => {
|
||||
setStatus('polling');
|
||||
// ponytail: mock status poll — replace with useGetPaymentStatusTelebirrQuery(orderId,
|
||||
// { pollingInterval: 3000 }) and branch on data.status once backend is live
|
||||
setTimeout(() => {
|
||||
setStatus('confirmed');
|
||||
notify.success('Telebirr payment confirmed');
|
||||
onConfirmed(orderId!);
|
||||
}, 4000);
|
||||
};
|
||||
|
||||
const handleGoToTelebirr = () => {
|
||||
window.open(paymentUrl!, '_blank', 'noopener,noreferrer');
|
||||
setChoice('redirect');
|
||||
startPolling();
|
||||
};
|
||||
|
||||
const handleGenerateQr = () => {
|
||||
setChoice('qr');
|
||||
startPolling();
|
||||
};
|
||||
|
||||
if (status === 'creating') {
|
||||
return (
|
||||
<Group gap="xs">
|
||||
<Loader size="sm" />
|
||||
<Text fz="sm" c="dimmed">Preparing Telebirr payment…</Text>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Group gap="sm" align="flex-start" wrap="wrap">
|
||||
{status === 'ready' && (
|
||||
<Group gap="sm">
|
||||
<Button leftSection={<IconExternalLink size={15} />} onClick={handleGoToTelebirr}>
|
||||
Go to Telebirr
|
||||
</Button>
|
||||
<Button variant="default" leftSection={<IconQrcode size={15} />} onClick={handleGenerateQr}>
|
||||
Generate QR Code
|
||||
</Button>
|
||||
</Group>
|
||||
)}
|
||||
|
||||
{choice === 'qr' && paymentUrl && (
|
||||
<Box p="md" style={{ border: '1px solid var(--mantine-color-gray-3)', borderRadius: 8, width: 'fit-content' }}>
|
||||
<QRCode value={paymentUrl} size={180} />
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{status === 'polling' && (
|
||||
<Group gap="xs">
|
||||
<Loader size="sm" />
|
||||
<Text fz="sm" c="dimmed">Waiting for payment confirmation…</Text>
|
||||
</Group>
|
||||
)}
|
||||
|
||||
{status === 'confirmed' && (
|
||||
<Alert variant="light" color="teal" icon={<IconCheck size={15} />}>
|
||||
Payment confirmed
|
||||
</Alert>
|
||||
)}
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
51
apps/portal/src/app/features/payment/constants.ts
Normal file
51
apps/portal/src/app/features/payment/constants.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
// Single source of truth for every literal used across the payment feature.
|
||||
// Nothing outside this file should contain a payment-related magic string or number —
|
||||
// grep for any of the values below and this file should be the only hit.
|
||||
|
||||
export const DEFAULT_PAYMENT_PROVIDER = 'TELEBIRR' as const;
|
||||
|
||||
export const PAYMENT_HISTORY_KEY = 'ema-payment-history'; // matches the repo's ema-portal-* key convention
|
||||
export const PAYMENT_INTENT_QUERY_KEY = 'payment-intent'; // react-query cache namespace for GET /payments/intents/:id
|
||||
|
||||
export const POLL_INTERVAL_MS = 4_000; // contract: poll every 4s
|
||||
export const REQUEST_TIMEOUT_MS = 15_000; // per-request abort, so one hung call can't stall the UI forever
|
||||
export const SLOW_PAYMENT_MS = 5 * 60_000; // after this we show a "taking longer" note — polling itself never stops for it
|
||||
|
||||
export const PAYMENTS_ROUTE = '/payments';
|
||||
|
||||
// Phase names live with their type so there is one definition, not a const list plus a
|
||||
// hand-written union that can drift apart.
|
||||
export const PAYMENT_PHASE = {
|
||||
Idle: 'idle',
|
||||
Creating: 'creating',
|
||||
Opening: 'opening',
|
||||
Blocked: 'blocked',
|
||||
Waiting: 'waiting',
|
||||
Success: 'success',
|
||||
Failed: 'failed',
|
||||
} as const;
|
||||
export type PaymentPhase = (typeof PAYMENT_PHASE)[keyof typeof PAYMENT_PHASE];
|
||||
|
||||
// The only place status strings are enumerated. isTerminalStatus/isSuccessStatus (api/payments.ts)
|
||||
// read these sets; nothing else in the feature compares a status string directly.
|
||||
// PAY_SUCCESS / PAY_FAILED are the literal `trade_status` values Telebirr puts on the return URL.
|
||||
export const TERMINAL_STATUSES = new Set([
|
||||
'SUCCESS',
|
||||
'PAY_SUCCESS',
|
||||
'FAILED',
|
||||
'PAY_FAILED',
|
||||
'CANCELLED',
|
||||
'CANCELED',
|
||||
'EXPIRED',
|
||||
]);
|
||||
export const SUCCESS_STATUSES = new Set(['SUCCESS', 'PAY_SUCCESS', 'COMPLETED']);
|
||||
|
||||
/**
|
||||
* Both the Telebirr `returnUrl` and `failureUrl` point at the same landing page — it reads
|
||||
* `trade_status` off the query string and branches, so one route serves both outcomes.
|
||||
* Deriving both from PAYMENTS_ROUTE means the route only needs to change in one place.
|
||||
*/
|
||||
export function paymentReturnUrls(): { returnUrl: string; failureUrl: string } {
|
||||
const url = `${window.location.origin}${PAYMENTS_ROUTE}`;
|
||||
return { returnUrl: url, failureUrl: url };
|
||||
}
|
||||
249
apps/portal/src/app/features/payment/hooks/usePaymentFlow.ts
Normal file
249
apps/portal/src/app/features/payment/hooks/usePaymentFlow.ts
Normal file
@@ -0,0 +1,249 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import {
|
||||
getIntent,
|
||||
initiatePayment,
|
||||
isSuccessStatus,
|
||||
isTerminalStatus,
|
||||
recordPayment,
|
||||
updatePaymentStatus,
|
||||
} from '../api/payments';
|
||||
import {
|
||||
PAYMENT_INTENT_QUERY_KEY,
|
||||
PAYMENT_PHASE,
|
||||
POLL_INTERVAL_MS,
|
||||
SLOW_PAYMENT_MS,
|
||||
paymentReturnUrls,
|
||||
type PaymentPhase,
|
||||
} from '../constants';
|
||||
import type { PaymentIntent, PaymentProvider } from '../types/payment';
|
||||
|
||||
export interface UsePaymentFlowOptions {
|
||||
opened: boolean;
|
||||
title: string;
|
||||
amountMinor: number;
|
||||
currency: string;
|
||||
referenceId: string;
|
||||
orderRef: string;
|
||||
service: string;
|
||||
referenceType: string;
|
||||
provider: PaymentProvider;
|
||||
payerAccount?: string;
|
||||
onSuccess?: (intent: PaymentIntent) => void;
|
||||
onFailure?: (intent: PaymentIntent) => void;
|
||||
}
|
||||
|
||||
export interface UsePaymentFlowResult {
|
||||
phase: PaymentPhase;
|
||||
intent: PaymentIntent | null;
|
||||
error: string | null;
|
||||
/** True once the modal has been waiting past SLOW_PAYMENT_MS — the poll keeps running regardless. */
|
||||
isSlow: boolean;
|
||||
/** clientAction.url, kept around so a blocked popup can be reopened from a user click. */
|
||||
redirectUrl: string | null;
|
||||
/** Starts a fresh attempt. Only meaningful from idle/failed/blocked — a no-op otherwise. */
|
||||
pay: () => void;
|
||||
/** Re-opens the Telebirr tab from an explicit user gesture (browsers require this — never automatic). */
|
||||
openPayment: () => void;
|
||||
/** Same as pay(), exposed under its own name for the "Try again" button's intent. */
|
||||
retry: () => void;
|
||||
/** One manual GET /payments/intents/:id, for the "Check again" button. */
|
||||
refresh: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Owns the full lifecycle: initiate -> record history -> redirect -> poll -> fire onSuccess/onFailure
|
||||
* exactly once -> retry. `phase` is the single value the modal renders from.
|
||||
*/
|
||||
export function usePaymentFlow(options: UsePaymentFlowOptions): UsePaymentFlowResult {
|
||||
const {
|
||||
opened,
|
||||
title,
|
||||
amountMinor,
|
||||
currency,
|
||||
referenceId,
|
||||
orderRef,
|
||||
service,
|
||||
referenceType,
|
||||
provider,
|
||||
payerAccount,
|
||||
} = options;
|
||||
|
||||
const [phase, setPhase] = useState<PaymentPhase>(PAYMENT_PHASE.Idle);
|
||||
const [intentId, setIntentId] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [redirectUrl, setRedirectUrl] = useState<string | null>(null);
|
||||
const [startedAt, setStartedAt] = useState<number | null>(null);
|
||||
|
||||
// Callbacks kept in a ref, refreshed every render, so the fire-once effect below never closes
|
||||
// over a stale onSuccess/onFailure prop.
|
||||
const callbacksRef = useRef({ onSuccess: options.onSuccess, onFailure: options.onFailure });
|
||||
callbacksRef.current = { onSuccess: options.onSuccess, onFailure: options.onFailure };
|
||||
|
||||
// Guards against firing onSuccess/onFailure more than once for the same intent.
|
||||
const firedForRef = useRef<string | null>(null);
|
||||
|
||||
// Reopening the modal starts a fresh attempt rather than resuming a previous one — resuming would
|
||||
// risk a second initiate. The documented recovery path for an in-flight payment is /payments,
|
||||
// which reconciles the persisted history record instead.
|
||||
useEffect(() => {
|
||||
if (!opened) {
|
||||
setPhase(PAYMENT_PHASE.Idle);
|
||||
setIntentId(null);
|
||||
setError(null);
|
||||
setRedirectUrl(null);
|
||||
setStartedAt(null);
|
||||
firedForRef.current = null;
|
||||
}
|
||||
}, [opened]);
|
||||
|
||||
// Poll — react-query, never setInterval. A hand-rolled interval would additionally need an
|
||||
// overlap guard, a stale-closure guard, and a cancel-on-unmount guard; react-query gives all
|
||||
// three for free, which is the whole reason it's used here over a manual timer.
|
||||
const {
|
||||
data: polledIntent,
|
||||
refetch,
|
||||
} = useQuery({
|
||||
queryKey: [PAYMENT_INTENT_QUERY_KEY, intentId],
|
||||
queryFn: () => getIntent(intentId as string),
|
||||
enabled: opened && !!intentId,
|
||||
staleTime: 0, // overrides AppProviders' global 5-minute staleTime — this data must never be "fresh enough to skip"
|
||||
retry: 0, // the next 4s tick IS the retry; a query-level retry would double requests
|
||||
refetchOnWindowFocus: false, // returning from the Telebirr tab must not trigger an extra request
|
||||
// Re-evaluated against the latest fetched data on every settle, so polling stops on the exact
|
||||
// tick the terminal status arrives — no request fires after it.
|
||||
refetchInterval: (query) => (isTerminalStatus(query.state.data?.status) ? false : POLL_INTERVAL_MS),
|
||||
// window.open(..., '_blank') hands focus to the Telebirr tab, so our tab goes background.
|
||||
// react-query's default pauses background polling, which would stall confirmation detection
|
||||
// for the entire duration the user is on Telebirr's page — the opposite of what's needed here.
|
||||
refetchIntervalInBackground: true,
|
||||
});
|
||||
|
||||
// Fire onSuccess/onFailure exactly once per intent, the moment a terminal status is observed.
|
||||
// An unknown status is neither terminal nor success (isTerminalStatus/isSuccessStatus both
|
||||
// default to false for it) — the poll simply keeps running and the raw value is shown as-is.
|
||||
useEffect(() => {
|
||||
if (!polledIntent || !isTerminalStatus(polledIntent.status)) return;
|
||||
if (firedForRef.current === polledIntent.id) return;
|
||||
firedForRef.current = polledIntent.id;
|
||||
|
||||
updatePaymentStatus(polledIntent.id, polledIntent.status);
|
||||
|
||||
if (isSuccessStatus(polledIntent.status)) {
|
||||
setPhase(PAYMENT_PHASE.Success);
|
||||
callbacksRef.current.onSuccess?.(polledIntent);
|
||||
} else {
|
||||
setPhase(PAYMENT_PHASE.Failed);
|
||||
callbacksRef.current.onFailure?.(polledIntent);
|
||||
}
|
||||
}, [polledIntent]);
|
||||
|
||||
const start = useCallback(() => {
|
||||
// Defensive guard against a double-click producing two POST /payments/initiate calls — the
|
||||
// modal also disables the Pay button while creating, but the hook doesn't rely on that alone.
|
||||
if (phase === PAYMENT_PHASE.Creating || phase === PAYMENT_PHASE.Opening || phase === PAYMENT_PHASE.Waiting) {
|
||||
return;
|
||||
}
|
||||
|
||||
setError(null);
|
||||
setPhase(PAYMENT_PHASE.Creating);
|
||||
|
||||
const { returnUrl, failureUrl } = paymentReturnUrls();
|
||||
// A fresh idempotency key per attempt — required so a user retry after a failure creates a new
|
||||
// payment attempt rather than resubmitting the failed one. Held only for the duration of this
|
||||
// call; nothing above `start()` sees or reuses it.
|
||||
const idempotencyKey = crypto.randomUUID();
|
||||
|
||||
initiatePayment({
|
||||
service,
|
||||
referenceType,
|
||||
referenceId,
|
||||
orderRef,
|
||||
amountMinor,
|
||||
currency,
|
||||
provider,
|
||||
payerAccount,
|
||||
returnUrl,
|
||||
failureUrl,
|
||||
idempotencyKey,
|
||||
platform: 'web',
|
||||
})
|
||||
.then((intent) => {
|
||||
recordPayment({
|
||||
intentId: intent.id,
|
||||
title,
|
||||
orderRef,
|
||||
referenceId,
|
||||
provider,
|
||||
amountMinor,
|
||||
currency,
|
||||
status: intent.status,
|
||||
createdAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
firedForRef.current = null;
|
||||
setStartedAt(Date.now());
|
||||
setPhase(PAYMENT_PHASE.Opening);
|
||||
|
||||
const clientAction = intent.clientAction;
|
||||
if (clientAction?.type === 'REDIRECT' && clientAction.url) {
|
||||
const win = window.open(clientAction.url, '_blank', 'noopener');
|
||||
if (win) {
|
||||
setPhase(PAYMENT_PHASE.Waiting);
|
||||
} else {
|
||||
// Popup blocked: keep the intent alive and keep polling (it's gated on intentId, not
|
||||
// phase) so a payment completed another way is still picked up. Only a user click on
|
||||
// "Open Payment" retries window.open — browsers require a user gesture for that anyway.
|
||||
setRedirectUrl(clientAction.url);
|
||||
setPhase(PAYMENT_PHASE.Blocked);
|
||||
}
|
||||
} else {
|
||||
// Undocumented/absent clientAction type: poll anyway, since the service may complete
|
||||
// server-side. Never invents an OTP screen, never hangs silently.
|
||||
setPhase(PAYMENT_PHASE.Waiting);
|
||||
}
|
||||
|
||||
setIntentId(intent.id);
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
setError(err instanceof Error ? err.message : 'Could not start the payment.');
|
||||
setPhase(PAYMENT_PHASE.Failed);
|
||||
});
|
||||
}, [
|
||||
amountMinor,
|
||||
currency,
|
||||
orderRef,
|
||||
payerAccount,
|
||||
phase,
|
||||
provider,
|
||||
referenceId,
|
||||
referenceType,
|
||||
service,
|
||||
title,
|
||||
]);
|
||||
|
||||
const openPayment = useCallback(() => {
|
||||
if (!redirectUrl) return;
|
||||
const win = window.open(redirectUrl, '_blank', 'noopener');
|
||||
if (win) setPhase(PAYMENT_PHASE.Waiting);
|
||||
}, [redirectUrl]);
|
||||
|
||||
const refresh = useCallback(() => {
|
||||
void refetch();
|
||||
}, [refetch]);
|
||||
|
||||
const isSlow =
|
||||
phase === PAYMENT_PHASE.Waiting && startedAt !== null && Date.now() - startedAt > SLOW_PAYMENT_MS;
|
||||
|
||||
return {
|
||||
phase,
|
||||
intent: polledIntent ?? null,
|
||||
error,
|
||||
isSlow,
|
||||
redirectUrl,
|
||||
pay: start,
|
||||
retry: start,
|
||||
openPayment,
|
||||
refresh,
|
||||
};
|
||||
}
|
||||
120
apps/portal/src/app/features/payment/pages/PaymentsPage.tsx
Normal file
120
apps/portal/src/app/features/payment/pages/PaymentsPage.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { Alert, Badge, Container, Group, Paper, Stack, Table, Text, Title } from '@mantine/core';
|
||||
import { IconCheck, IconInfoCircle } from '@tabler/icons-react';
|
||||
import { isSuccessStatus, reconcilePaymentHistory } from '../api/payments';
|
||||
import { formatMinor } from '../utils/money';
|
||||
import type { PaymentHistoryRecord, PaymentStatus } from '../types/payment';
|
||||
|
||||
// Status -> colour has exactly one consumer (this table), so it stays inline rather than becoming
|
||||
// a shared PaymentStatusBadge component.
|
||||
function statusColor(status: PaymentStatus): string {
|
||||
if (isSuccessStatus(status)) return 'teal';
|
||||
const upper = status.toUpperCase();
|
||||
if (upper === 'FAILED' || upper === 'PAY_FAILED' || upper === 'CANCELLED' || upper === 'CANCELED') return 'red';
|
||||
if (upper === 'EXPIRED') return 'gray';
|
||||
return 'blue';
|
||||
}
|
||||
|
||||
/**
|
||||
* Landing page for Telebirr's returnUrl/failureUrl. Reads the five documented query params,
|
||||
* reconciles the one relevant history record against the backend, then renders the outcome —
|
||||
* trade_status === "PAY_SUCCESS" takes precedence, otherwise the backend's own status is shown.
|
||||
*/
|
||||
export function PaymentsPage() {
|
||||
const [searchParams] = useSearchParams();
|
||||
const [history, setHistory] = useState<PaymentHistoryRecord[]>([]);
|
||||
const [reconciled, setReconciled] = useState(false);
|
||||
|
||||
const tradeStatus = searchParams.get('trade_status');
|
||||
const totalAmount = searchParams.get('total_amount');
|
||||
const transCurrency = searchParams.get('trans_currency');
|
||||
const merchOrderId = searchParams.get('merch_order_id');
|
||||
const paymentOrderId = searchParams.get('payment_order_id');
|
||||
|
||||
useEffect(() => {
|
||||
// Only ever reconcile once on load, against the orderRef captured from the initial URL —
|
||||
// this page is not a live dashboard, so `merchOrderId` is deliberately not a dependency here.
|
||||
// (The react-hooks/exhaustive-deps rule isn't configured in this repo's eslint setup, so no
|
||||
// disable directive is needed — this comment documents the omission instead.)
|
||||
reconcilePaymentHistory(merchOrderId ?? undefined)
|
||||
.then(setHistory)
|
||||
.finally(() => setReconciled(true));
|
||||
}, []);
|
||||
|
||||
const record = history.find((r) => r.orderRef === merchOrderId);
|
||||
const isSuccess = tradeStatus === 'PAY_SUCCESS';
|
||||
|
||||
return (
|
||||
<Container size="sm" py="xl">
|
||||
<Stack gap="lg">
|
||||
<Title order={2}>Payment Status</Title>
|
||||
|
||||
{reconciled && (
|
||||
<Paper withBorder radius="lg" p="lg">
|
||||
{isSuccess ? (
|
||||
<Alert color="teal" icon={<IconCheck size={16} />} title="Payment successful">
|
||||
<Stack gap={4}>
|
||||
{totalAmount && (
|
||||
<Text fz="sm">
|
||||
Amount: {totalAmount} {transCurrency ?? ''}
|
||||
</Text>
|
||||
)}
|
||||
{paymentOrderId && <Text fz="xs" c="dimmed">Order: {paymentOrderId}</Text>}
|
||||
</Stack>
|
||||
</Alert>
|
||||
) : (
|
||||
<Alert color="blue" icon={<IconInfoCircle size={16} />} title="Payment status">
|
||||
<Stack gap={4}>
|
||||
<Group gap="xs">
|
||||
<Text fz="sm">Latest status:</Text>
|
||||
<Badge color={statusColor(record?.status ?? tradeStatus ?? 'UNKNOWN')} variant="light">
|
||||
{record?.status ?? tradeStatus ?? 'UNKNOWN'}
|
||||
</Badge>
|
||||
</Group>
|
||||
{totalAmount && (
|
||||
<Text fz="sm">
|
||||
Amount: {totalAmount} {transCurrency ?? ''}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</Alert>
|
||||
)}
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
<Paper withBorder radius="lg" p="lg">
|
||||
<Text fw={600} mb="sm">Payment History</Text>
|
||||
{history.length === 0 ? (
|
||||
<Text fz="sm" c="dimmed">No payments recorded on this device yet.</Text>
|
||||
) : (
|
||||
<Table striped highlightOnHover>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Title</Table.Th>
|
||||
<Table.Th>Order Ref</Table.Th>
|
||||
<Table.Th>Amount</Table.Th>
|
||||
<Table.Th>Status</Table.Th>
|
||||
<Table.Th>Date</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{[...history].reverse().map((r) => (
|
||||
<Table.Tr key={r.intentId}>
|
||||
<Table.Td>{r.title}</Table.Td>
|
||||
<Table.Td>{r.orderRef}</Table.Td>
|
||||
<Table.Td>{formatMinor(r.amountMinor, r.currency)}</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge color={statusColor(r.status)} variant="light">{r.status}</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>{new Date(r.createdAt).toLocaleString()}</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
)}
|
||||
</Paper>
|
||||
</Stack>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,81 @@
|
||||
export interface TelebirrPayload {
|
||||
orderid: string;
|
||||
// Shared payment types. Every request/response shape the feature touches is declared here —
|
||||
// no `any`, no inline object literals typed ad hoc at the call site.
|
||||
|
||||
/**
|
||||
* One provider today (Telebirr only). Kept as its own union — rather than a bare string —
|
||||
* so that adding a second provider later is a one-line widen here; every function and prop
|
||||
* that carries `provider` already threads it through, so no signatures need to change.
|
||||
*/
|
||||
export type PaymentProvider = 'TELEBIRR';
|
||||
|
||||
/**
|
||||
* OPEN union, deliberately. A closed union would compile-time-guarantee something the backend
|
||||
* does not guarantee: the first new status the payment service ships would either fail to compile
|
||||
* everywhere or force `as PaymentStatus` casts at every boundary, silently defeating the point of
|
||||
* isTerminalStatus()/isSuccessStatus(). `(string & {})` keeps autocomplete on the known set while
|
||||
* still accepting anything the server actually sends, so an unknown status stays representable
|
||||
* instead of being cast away or crashing.
|
||||
*/
|
||||
export type KnownPaymentStatus =
|
||||
| 'PENDING'
|
||||
| 'INITIATED'
|
||||
| 'PROCESSING'
|
||||
| 'SUCCESS'
|
||||
| 'FAILED'
|
||||
| 'CANCELLED'
|
||||
| 'EXPIRED';
|
||||
export type PaymentStatus = KnownPaymentStatus | (string & {});
|
||||
|
||||
export interface PaymentClientAction {
|
||||
/** Only "REDIRECT" is handled today; anything else is displayed, never silently dropped. */
|
||||
type: 'REDIRECT' | (string & {});
|
||||
url?: string;
|
||||
}
|
||||
export interface TelebirrCreatePaymentResponse {
|
||||
status: string;
|
||||
paymentUrl: string;
|
||||
|
||||
/** Returned by POST /payments/initiate and GET /payments/intents/:intentId. */
|
||||
export interface PaymentIntent {
|
||||
id: string;
|
||||
status: PaymentStatus;
|
||||
provider: PaymentProvider;
|
||||
amountMinor: number;
|
||||
currency: string;
|
||||
orderRef?: string;
|
||||
referenceId?: string;
|
||||
clientAction?: PaymentClientAction;
|
||||
}
|
||||
export interface TelebirrResponse {
|
||||
status: string;
|
||||
|
||||
/** Body for POST /payments/initiate. */
|
||||
export interface PaymentRequest {
|
||||
/** No service enum exists in this repo yet — see plan's open question on the exact values a caller should pass. */
|
||||
service: string;
|
||||
referenceType: string;
|
||||
referenceId: string;
|
||||
orderRef: string;
|
||||
amountMinor: number;
|
||||
currency: string;
|
||||
provider: PaymentProvider;
|
||||
/** Optional in the contract — Telebirr's own H5 page collects the payer's phone number. */
|
||||
payerAccount?: string;
|
||||
returnUrl: string;
|
||||
failureUrl: string;
|
||||
idempotencyKey: string;
|
||||
platform: 'web';
|
||||
}
|
||||
|
||||
/** Body for POST /payments/intents/:intentId/confirm. */
|
||||
export interface PaymentConfirmRequest {
|
||||
otp: string;
|
||||
}
|
||||
|
||||
/** One row of the localStorage payment history. */
|
||||
export interface PaymentHistoryRecord {
|
||||
intentId: string;
|
||||
title: string;
|
||||
orderRef: string;
|
||||
referenceId: string;
|
||||
provider: PaymentProvider;
|
||||
amountMinor: number;
|
||||
currency: string;
|
||||
status: PaymentStatus;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
20
apps/portal/src/app/features/payment/utils/money.ts
Normal file
20
apps/portal/src/app/features/payment/utils/money.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
/** Whole-birr major amount -> minor units (cents). Rounds to avoid float drift (19.99 -> 1999). */
|
||||
export function toMinor(major: number): number {
|
||||
return Math.round(major * 100);
|
||||
}
|
||||
|
||||
export function fromMinor(minor: number): number {
|
||||
return minor / 100;
|
||||
}
|
||||
|
||||
/**
|
||||
* Matches the format already used across the portal (`ETB ${n.toFixed(2)}`, e.g.
|
||||
* SeamanBookApplicationPage.tsx) rather than Intl.NumberFormat({style:'currency'}), whose ETB
|
||||
* output varies by ICU build. Not unifying the ~20 existing inline call sites — out of scope here.
|
||||
*/
|
||||
export function formatMinor(amountMinor: number, currency: string): string {
|
||||
return `${currency} ${fromMinor(amountMinor).toLocaleString(undefined, {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
})}`;
|
||||
}
|
||||
@@ -33,7 +33,8 @@ import {
|
||||
} from '@tabler/icons-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { notify, useErrorHandler } from '@ema-platform/ui';
|
||||
import { TelebirrPayment } from '../../payment/components/TelebirrPayment';
|
||||
import { PaymentModal } from '../../payment/components/PaymentModal';
|
||||
import { toMinor } from '../../payment/utils/money';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Steps
|
||||
@@ -236,8 +237,12 @@ export function SeamanBookApplicationPage() {
|
||||
const [paymentDate, setPaymentDate] = useState('');
|
||||
const [paymentFile, setPaymentFile] = useState<File | null>(null);
|
||||
const payResetRef = useRef<() => void>(null);
|
||||
// Telebirr order id, set once TelebirrPayment confirms — real gateway flow, no manual ref for telebirr
|
||||
// Telebirr intent id, set once PaymentModal's onSuccess fires — real gateway flow, no manual ref for telebirr
|
||||
const [telebirrOrderId, setTelebirrOrderId] = useState<string | null>(null);
|
||||
const [payModalOpen, setPayModalOpen] = useState(false);
|
||||
// Stable draft reference for this application session — there's no application id yet at
|
||||
// payment time (payment happens before submit), so this is what orderRef/referenceId key off.
|
||||
const [payRef] = useState(() => `SB-${crypto.randomUUID().slice(0, 8).toUpperCase()}`);
|
||||
|
||||
// Validation
|
||||
const bstComplete = BST_SLOTS.every((s) => {
|
||||
@@ -547,9 +552,35 @@ export function SeamanBookApplicationPage() {
|
||||
{paymentMethod === 'telebirr' && (
|
||||
<>
|
||||
<Divider label="Telebirr Payment" labelPosition="left" />
|
||||
<TelebirrPayment
|
||||
onConfirmed={(orderId) => {
|
||||
setTelebirrOrderId(orderId);
|
||||
{telebirrOrderId ? (
|
||||
<Alert variant="light" color="teal" icon={<IconCircleCheck size={15} />}>
|
||||
Payment confirmed — reference {telebirrOrderId}
|
||||
</Alert>
|
||||
) : (
|
||||
<Button
|
||||
variant="light"
|
||||
color="violet"
|
||||
leftSection={<IconCreditCard size={15} />}
|
||||
onClick={() => setPayModalOpen(true)}
|
||||
>
|
||||
Pay with Telebirr
|
||||
</Button>
|
||||
)}
|
||||
<PaymentModal
|
||||
opened={payModalOpen}
|
||||
onClose={() => setPayModalOpen(false)}
|
||||
title="Seaman Book Application Fees"
|
||||
amountMinor={toMinor(TOTAL)}
|
||||
currency="ETB"
|
||||
referenceId={payRef}
|
||||
orderRef={payRef}
|
||||
service="SEAFARER"
|
||||
referenceType="SEAMAN_BOOK"
|
||||
defaultProvider="TELEBIRR"
|
||||
lockedProvider="TELEBIRR"
|
||||
onSuccess={(intent) => {
|
||||
setTelebirrOrderId(intent.id);
|
||||
setPayModalOpen(false);
|
||||
setActive(3);
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -59,7 +59,11 @@ import { WaiverPage } from './features/waiver/pages/WaiverPage';
|
||||
import { WaiverApplicationPage } from './features/waiver/pages/WaiverApplicationPage';
|
||||
|
||||
|
||||
import { VesselRegistrationStatusPage } from './features/vessel-registration/pages/VesselRegistrationStatusPage';
|
||||
import { VesselRegistrationStatusPage } from "./features/vessel-registration/pages/VesselRegistrationStatusPage";
|
||||
|
||||
// Payments
|
||||
import { PaymentsPage } from "./features/payment/pages/PaymentsPage";
|
||||
import { PAYMENTS_ROUTE } from "./features/payment/constants";
|
||||
|
||||
export const router = createBrowserRouter([
|
||||
// Public auth pages
|
||||
@@ -142,8 +146,9 @@ export const router = createBrowserRouter([
|
||||
|
||||
|
||||
// General
|
||||
{ path: '/profile', element: <ProfilePage /> },
|
||||
{ path: '/support', element: <SupportPage /> },
|
||||
{ path: "/profile", element: <ProfilePage /> },
|
||||
{ path: "/support", element: <SupportPage /> },
|
||||
{ path: PAYMENTS_ROUTE, element: <PaymentsPage /> },
|
||||
],
|
||||
},
|
||||
|
||||
|
||||
Reference in New Issue
Block a user