From 06eb9eca4f377a2e21baeffcce590ee5ee0b1ca6 Mon Sep 17 00:00:00 2001 From: estifanos Date: Thu, 30 Jul 2026 07:30:56 +0000 Subject: [PATCH] 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 --- README.md | 2 + .../certificates/pages/CoCApplicationPage.tsx | 41 ++- .../src/app/features/payment/api/client.ts | 96 +++++++ .../app/features/payment/api/payment-api.ts | 33 --- .../app/features/payment/api/payments.test.ts | 103 ++++++++ .../src/app/features/payment/api/payments.ts | 121 +++++++++ .../payment/components/PaymentModal.tsx | 156 +++++++++++ .../payment/components/TelebirrPayment.tsx | 95 ------- .../src/app/features/payment/constants.ts | 51 ++++ .../features/payment/hooks/usePaymentFlow.ts | 249 ++++++++++++++++++ .../features/payment/pages/PaymentsPage.tsx | 120 +++++++++ .../src/app/features/payment/types/payment.ts | 85 +++++- .../src/app/features/payment/utils/money.ts | 20 ++ .../pages/SeamanBookApplicationPage.tsx | 41 ++- apps/portal/src/app/router.tsx | 11 +- 15 files changed, 1076 insertions(+), 148 deletions(-) create mode 100644 apps/portal/src/app/features/payment/api/client.ts delete mode 100644 apps/portal/src/app/features/payment/api/payment-api.ts create mode 100644 apps/portal/src/app/features/payment/api/payments.test.ts create mode 100644 apps/portal/src/app/features/payment/api/payments.ts create mode 100644 apps/portal/src/app/features/payment/components/PaymentModal.tsx delete mode 100644 apps/portal/src/app/features/payment/components/TelebirrPayment.tsx create mode 100644 apps/portal/src/app/features/payment/constants.ts create mode 100644 apps/portal/src/app/features/payment/hooks/usePaymentFlow.ts create mode 100644 apps/portal/src/app/features/payment/pages/PaymentsPage.tsx create mode 100644 apps/portal/src/app/features/payment/utils/money.ts diff --git a/README.md b/README.md index b3f5e9696..9a2abe9b7 100644 --- a/README.md +++ b/README.md @@ -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 | diff --git a/apps/portal/src/app/features/certificates/pages/CoCApplicationPage.tsx b/apps/portal/src/app/features/certificates/pages/CoCApplicationPage.tsx index dcbf801b9..7eacb0124 100644 --- a/apps/portal/src/app/features/certificates/pages/CoCApplicationPage.tsx +++ b/apps/portal/src/app/features/certificates/pages/CoCApplicationPage.tsx @@ -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(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(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' && ( - { - setTelebirrOrderId(orderId); + {telebirrOrderId ? ( + }> + Payment confirmed — reference {telebirrOrderId} + + ) : ( + + )} + 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); }} /> diff --git a/apps/portal/src/app/features/payment/api/client.ts b/apps/portal/src/app/features/payment/api/client.ts new file mode 100644 index 000000000..0e09c33cd --- /dev/null +++ b/apps/portal/src/app/features/payment/api/client.ts @@ -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 }).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 { + 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; + 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(path: string, init?: RequestInit): Promise { + 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; +} diff --git a/apps/portal/src/app/features/payment/api/payment-api.ts b/apps/portal/src/app/features/payment/api/payment-api.ts deleted file mode 100644 index 9fa9eddf1..000000000 --- a/apps/portal/src/app/features/payment/api/payment-api.ts +++ /dev/null @@ -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({ - query: (orderId) => `/payments/${orderId}/status`, - providesTags: ["Api"], - }), - getPaymentDetail: builder.query({ - 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; diff --git a/apps/portal/src/app/features/payment/api/payments.test.ts b/apps/portal/src/app/features/payment/api/payments.test.ts new file mode 100644 index 000000000..5fb1eb24e --- /dev/null +++ b/apps/portal/src/app/features/payment/api/payments.test.ts @@ -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(); + (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([]); + }); +}); diff --git a/apps/portal/src/app/features/payment/api/payments.ts b/apps/portal/src/app/features/payment/api/payments.ts new file mode 100644 index 000000000..596317399 --- /dev/null +++ b/apps/portal/src/app/features/payment/api/payments.ts @@ -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 { + return paymentFetch('/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 { + return paymentFetch(`/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 { + return paymentFetch(`/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 { + 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(); +} diff --git a/apps/portal/src/app/features/payment/components/PaymentModal.tsx b/apps/portal/src/app/features/payment/components/PaymentModal.tsx new file mode 100644 index 000000000..e53c490cb --- /dev/null +++ b/apps/portal/src/app/features/payment/components/PaymentModal.tsx @@ -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 ( + + + + Amount + {formatMinor(amountMinor, currency)} + + + Provider + {provider} + + + {phase === PAYMENT_PHASE.Idle && ( + + )} + + {phase === PAYMENT_PHASE.Creating && ( + + + Creating your payment… + + )} + + {phase === PAYMENT_PHASE.Opening && ( + + + Opening Telebirr… + + )} + + {phase === PAYMENT_PHASE.Blocked && redirectUrl && ( + } title="Popup blocked"> + + Your browser blocked the payment window. Open it manually to continue. + + + + )} + + {phase === PAYMENT_PHASE.Waiting && ( + + + + + Waiting for confirmation from Telebirr… {intent ? `(${intent.status})` : null} + + + {unhandledActionType && ( + + The payment service returned an unhandled action ("{unhandledActionType}") — still + checking for a result. + + )} + {redirectUrl && ( + window.open(redirectUrl, '_blank', 'noopener')}> + Reopen Telebirr + + )} + {isSlow && ( + + + This is taking longer than usual. It may still complete. + + + + + + + )} + + )} + + {phase === PAYMENT_PHASE.Success && ( + } title="Payment confirmed"> + {intent ? `Reference: ${intent.id}` : null} + + )} + + {phase === PAYMENT_PHASE.Failed && ( + + + + + )} + + + ); +} diff --git a/apps/portal/src/app/features/payment/components/TelebirrPayment.tsx b/apps/portal/src/app/features/payment/components/TelebirrPayment.tsx deleted file mode 100644 index bfa5d9d9c..000000000 --- a/apps/portal/src/app/features/payment/components/TelebirrPayment.tsx +++ /dev/null @@ -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('creating'); - const [orderId, setOrderId] = useState(null); - const [paymentUrl, setPaymentUrl] = useState(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 ( - - - Preparing Telebirr payment… - - ); - } - - return ( - - {status === 'ready' && ( - - - - - )} - - {choice === 'qr' && paymentUrl && ( - - - - )} - - {status === 'polling' && ( - - - Waiting for payment confirmation… - - )} - - {status === 'confirmed' && ( - }> - Payment confirmed - - )} - - ); -} diff --git a/apps/portal/src/app/features/payment/constants.ts b/apps/portal/src/app/features/payment/constants.ts new file mode 100644 index 000000000..999f186f3 --- /dev/null +++ b/apps/portal/src/app/features/payment/constants.ts @@ -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 }; +} diff --git a/apps/portal/src/app/features/payment/hooks/usePaymentFlow.ts b/apps/portal/src/app/features/payment/hooks/usePaymentFlow.ts new file mode 100644 index 000000000..39ec69e25 --- /dev/null +++ b/apps/portal/src/app/features/payment/hooks/usePaymentFlow.ts @@ -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(PAYMENT_PHASE.Idle); + const [intentId, setIntentId] = useState(null); + const [error, setError] = useState(null); + const [redirectUrl, setRedirectUrl] = useState(null); + const [startedAt, setStartedAt] = useState(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(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, + }; +} diff --git a/apps/portal/src/app/features/payment/pages/PaymentsPage.tsx b/apps/portal/src/app/features/payment/pages/PaymentsPage.tsx new file mode 100644 index 000000000..18e43e438 --- /dev/null +++ b/apps/portal/src/app/features/payment/pages/PaymentsPage.tsx @@ -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([]); + 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 ( + + + Payment Status + + {reconciled && ( + + {isSuccess ? ( + } title="Payment successful"> + + {totalAmount && ( + + Amount: {totalAmount} {transCurrency ?? ''} + + )} + {paymentOrderId && Order: {paymentOrderId}} + + + ) : ( + } title="Payment status"> + + + Latest status: + + {record?.status ?? tradeStatus ?? 'UNKNOWN'} + + + {totalAmount && ( + + Amount: {totalAmount} {transCurrency ?? ''} + + )} + + + )} + + )} + + + Payment History + {history.length === 0 ? ( + No payments recorded on this device yet. + ) : ( + + + + Title + Order Ref + Amount + Status + Date + + + + {[...history].reverse().map((r) => ( + + {r.title} + {r.orderRef} + {formatMinor(r.amountMinor, r.currency)} + + {r.status} + + {new Date(r.createdAt).toLocaleString()} + + ))} + +
+ )} +
+
+
+ ); +} diff --git a/apps/portal/src/app/features/payment/types/payment.ts b/apps/portal/src/app/features/payment/types/payment.ts index 0cf92ca8e..9895732c2 100644 --- a/apps/portal/src/app/features/payment/types/payment.ts +++ b/apps/portal/src/app/features/payment/types/payment.ts @@ -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; } diff --git a/apps/portal/src/app/features/payment/utils/money.ts b/apps/portal/src/app/features/payment/utils/money.ts new file mode 100644 index 000000000..97bbf338c --- /dev/null +++ b/apps/portal/src/app/features/payment/utils/money.ts @@ -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, + })}`; +} diff --git a/apps/portal/src/app/features/seaman-book/pages/SeamanBookApplicationPage.tsx b/apps/portal/src/app/features/seaman-book/pages/SeamanBookApplicationPage.tsx index 1e369746c..1955e96f3 100644 --- a/apps/portal/src/app/features/seaman-book/pages/SeamanBookApplicationPage.tsx +++ b/apps/portal/src/app/features/seaman-book/pages/SeamanBookApplicationPage.tsx @@ -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(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(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' && ( <> - { - setTelebirrOrderId(orderId); + {telebirrOrderId ? ( + }> + Payment confirmed — reference {telebirrOrderId} + + ) : ( + + )} + 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); }} /> diff --git a/apps/portal/src/app/router.tsx b/apps/portal/src/app/router.tsx index 78b4bce44..9210de326 100644 --- a/apps/portal/src/app/router.tsx +++ b/apps/portal/src/app/router.tsx @@ -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: }, - { path: '/support', element: }, + { path: "/profile", element: }, + { path: "/support", element: }, + { path: PAYMENTS_ROUTE, element: }, ], },