CI deploy 84761d0
Browse files- Dockerfile +3 -0
- amanpay/version.py +36 -0
- api/main.py +15 -0
- build_info.json +1 -0
- web/src/App.tsx +28 -8
- web/src/api/auth.ts +8 -2
- web/src/api/payments.ts +9 -0
- web/src/api/version.ts +7 -0
- web/src/auth/session.test.ts +24 -0
- web/src/auth/session.ts +29 -4
- web/src/components/AppFooter.tsx +52 -0
- web/src/hooks/useSession.ts +21 -5
- web/src/i18n/ar.ts +114 -13
- web/src/i18n/en.ts +114 -13
- web/src/i18n/i18n.test.tsx +28 -0
- web/src/pages/EnrollPage.test.tsx +39 -0
- web/src/pages/EnrollPage.tsx +64 -20
- web/src/pages/NotFoundPage.test.tsx +14 -0
- web/src/pages/NotFoundPage.tsx +15 -0
- web/src/pages/PayPage.test.tsx +68 -11
- web/src/pages/PayPage.tsx +148 -43
- web/src/payments/status.test.ts +19 -1
- web/src/payments/status.ts +21 -0
- web/src/styles.css +43 -1
- web/src/test/setup.ts +6 -0
- web/src/types/index.ts +23 -1
- web/src/utils/errors.test.ts +40 -0
- web/src/utils/errors.ts +19 -0
- web/src/utils/validate.test.ts +31 -0
- web/src/utils/validate.ts +39 -0
Dockerfile
CHANGED
|
@@ -50,6 +50,9 @@ RUN pip install -e .
|
|
| 50 |
# The built React SPA (served by api/main.py at / with hashed assets under /assets).
|
| 51 |
COPY --from=web /web/dist ./web/dist
|
| 52 |
|
|
|
|
|
|
|
|
|
|
| 53 |
# Writable dirs for weights + caches (owned so the Space's non-root user can write).
|
| 54 |
RUN mkdir -p /app/checkpoints /app/.cache && chmod -R 777 /app/checkpoints /app/.cache
|
| 55 |
|
|
|
|
| 50 |
# The built React SPA (served by api/main.py at / with hashed assets under /assets).
|
| 51 |
COPY --from=web /web/dist ./web/dist
|
| 52 |
|
| 53 |
+
# Non-sensitive build metadata for GET /version (CI overwrites with the real commit).
|
| 54 |
+
COPY build_info.json ./
|
| 55 |
+
|
| 56 |
# Writable dirs for weights + caches (owned so the Space's non-root user can write).
|
| 57 |
RUN mkdir -p /app/checkpoints /app/.cache && chmod -R 777 /app/checkpoints /app/.cache
|
| 58 |
|
amanpay/version.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Non-sensitive build/version info. Torch-free so it is unit-testable in the
|
| 2 |
+
light-dependency CI job. Never returns tokens, secrets, DB/Redis URLs, or env values."""
|
| 3 |
+
|
| 4 |
+
from __future__ import annotations
|
| 5 |
+
|
| 6 |
+
import json
|
| 7 |
+
import os
|
| 8 |
+
|
| 9 |
+
APP_VERSION = "1.0.0"
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def read_build_info(root_dir: str) -> dict:
|
| 13 |
+
"""Load build_info.json (written by CI into the deploy bundle) or {} if absent."""
|
| 14 |
+
path = os.path.join(root_dir, "build_info.json")
|
| 15 |
+
try:
|
| 16 |
+
with open(path) as fh:
|
| 17 |
+
data = json.load(fh)
|
| 18 |
+
return data if isinstance(data, dict) else {}
|
| 19 |
+
except Exception:
|
| 20 |
+
return {}
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def build_version_info(root_dir: str, *, ui_mode: str = "react",
|
| 24 |
+
provider_mode: str = "mock") -> dict:
|
| 25 |
+
"""Assemble the /version payload. Only build metadata + operating mode — no secrets."""
|
| 26 |
+
bi = read_build_info(root_dir)
|
| 27 |
+
return {
|
| 28 |
+
"app_version": APP_VERSION,
|
| 29 |
+
"commit": bi.get("commit", "dev"),
|
| 30 |
+
"build_time": bi.get("build_time"),
|
| 31 |
+
"frontend": bi.get("frontend", APP_VERSION),
|
| 32 |
+
"backend": APP_VERSION,
|
| 33 |
+
"ui_mode": ui_mode,
|
| 34 |
+
"provider_mode": provider_mode,
|
| 35 |
+
"non_custodial": True,
|
| 36 |
+
}
|
api/main.py
CHANGED
|
@@ -173,3 +173,18 @@ def app_icon(size: str):
|
|
| 173 |
@app.get("/info", include_in_schema=False)
|
| 174 |
def info() -> dict:
|
| 175 |
return {"name": "AmanPay Biometric API", "docs": "/docs", "health": "/health"}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 173 |
@app.get("/info", include_in_schema=False)
|
| 174 |
def info() -> dict:
|
| 175 |
return {"name": "AmanPay Biometric API", "docs": "/docs", "health": "/health"}
|
| 176 |
+
|
| 177 |
+
|
| 178 |
+
@app.get("/version")
|
| 179 |
+
def version() -> dict:
|
| 180 |
+
"""Non-sensitive build/version info for deploy verification and the UI footer.
|
| 181 |
+
Contains NO tokens, secrets, DB/Redis URLs, or env values."""
|
| 182 |
+
from amanpay.version import build_version_info
|
| 183 |
+
try:
|
| 184 |
+
from amanpay.payments.registry import provider_name_for
|
| 185 |
+
provider_mode = provider_name_for("SA")
|
| 186 |
+
except Exception:
|
| 187 |
+
provider_mode = "mock"
|
| 188 |
+
return build_version_info(_ROOT,
|
| 189 |
+
ui_mode="react" if _use_react() else "legacy",
|
| 190 |
+
provider_mode=provider_mode)
|
build_info.json
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
{"commit":"84761d0","build_time":"2026-07-12T07:47:41Z","frontend":"1.0.0"}
|
web/src/App.tsx
CHANGED
|
@@ -1,20 +1,30 @@
|
|
| 1 |
import { useEffect, useState } from 'react'
|
| 2 |
import { EnrollPage } from './pages/EnrollPage'
|
| 3 |
import { PayPage } from './pages/PayPage'
|
|
|
|
|
|
|
|
|
|
| 4 |
import { useI18n } from './i18n'
|
| 5 |
import { Button } from './components/ui'
|
| 6 |
|
| 7 |
-
type Route = 'pay' | 'enroll'
|
|
|
|
| 8 |
|
| 9 |
-
function
|
| 10 |
-
const
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
const [route, setRoute] = useState<Route>(parse())
|
| 12 |
useEffect(() => {
|
| 13 |
const onHash = () => setRoute(parse())
|
| 14 |
window.addEventListener('hashchange', onHash)
|
| 15 |
return () => window.removeEventListener('hashchange', onHash)
|
| 16 |
}, [])
|
| 17 |
-
const nav = (r:
|
| 18 |
window.location.hash = `#/${r}`
|
| 19 |
setRoute(r)
|
| 20 |
}
|
|
@@ -23,6 +33,7 @@ function useHashRoute(): [Route, (r: Route) => void] {
|
|
| 23 |
|
| 24 |
export function App() {
|
| 25 |
const { t, locale, setLocale } = useI18n()
|
|
|
|
| 26 |
const [route, nav] = useHashRoute()
|
| 27 |
return (
|
| 28 |
<div className="app">
|
|
@@ -36,14 +47,23 @@ export function App() {
|
|
| 36 |
{t('nav.enroll')}
|
| 37 |
</button>
|
| 38 |
</nav>
|
| 39 |
-
<
|
| 40 |
-
{
|
| 41 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 42 |
</header>
|
|
|
|
| 43 |
<main className="content">
|
| 44 |
<p className="tagline">{t('app.tagline')}</p>
|
| 45 |
-
{route === 'pay'
|
|
|
|
|
|
|
| 46 |
</main>
|
|
|
|
| 47 |
</div>
|
| 48 |
)
|
| 49 |
}
|
|
|
|
| 1 |
import { useEffect, useState } from 'react'
|
| 2 |
import { EnrollPage } from './pages/EnrollPage'
|
| 3 |
import { PayPage } from './pages/PayPage'
|
| 4 |
+
import { NotFoundPage } from './pages/NotFoundPage'
|
| 5 |
+
import { AppFooter } from './components/AppFooter'
|
| 6 |
+
import { useSession } from './hooks/useSession'
|
| 7 |
import { useI18n } from './i18n'
|
| 8 |
import { Button } from './components/ui'
|
| 9 |
|
| 10 |
+
type Route = 'pay' | 'enroll' | 'notfound'
|
| 11 |
+
type NavTarget = 'pay' | 'enroll'
|
| 12 |
|
| 13 |
+
function parse(): Route {
|
| 14 |
+
const raw = window.location.hash.replace(/^#\/?/, '')
|
| 15 |
+
if (raw === '' || raw === 'pay') return 'pay'
|
| 16 |
+
if (raw === 'enroll') return 'enroll'
|
| 17 |
+
return 'notfound'
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
function useHashRoute(): [Route, (r: NavTarget) => void] {
|
| 21 |
const [route, setRoute] = useState<Route>(parse())
|
| 22 |
useEffect(() => {
|
| 23 |
const onHash = () => setRoute(parse())
|
| 24 |
window.addEventListener('hashchange', onHash)
|
| 25 |
return () => window.removeEventListener('hashchange', onHash)
|
| 26 |
}, [])
|
| 27 |
+
const nav = (r: NavTarget) => {
|
| 28 |
window.location.hash = `#/${r}`
|
| 29 |
setRoute(r)
|
| 30 |
}
|
|
|
|
| 33 |
|
| 34 |
export function App() {
|
| 35 |
const { t, locale, setLocale } = useI18n()
|
| 36 |
+
const { activeUser } = useSession()
|
| 37 |
const [route, nav] = useHashRoute()
|
| 38 |
return (
|
| 39 |
<div className="app">
|
|
|
|
| 47 |
{t('nav.enroll')}
|
| 48 |
</button>
|
| 49 |
</nav>
|
| 50 |
+
<div className="topbar-right">
|
| 51 |
+
<span className="active-user" title={activeUser ? activeUser.userId : ''}>
|
| 52 |
+
{activeUser ? t('header.activeUser', { user: activeUser.userId }) : t('header.noUser')}
|
| 53 |
+
</span>
|
| 54 |
+
<Button onClick={() => setLocale(locale === 'ar' ? 'en' : 'ar')} aria-label={t('lang.toggle')}>
|
| 55 |
+
{t('lang.toggle')}
|
| 56 |
+
</Button>
|
| 57 |
+
</div>
|
| 58 |
</header>
|
| 59 |
+
<p className="demo-banner" role="note">{t('demo.banner')}</p>
|
| 60 |
<main className="content">
|
| 61 |
<p className="tagline">{t('app.tagline')}</p>
|
| 62 |
+
{route === 'pay' && <PayPage onNavEnroll={() => nav('enroll')} />}
|
| 63 |
+
{route === 'enroll' && <EnrollPage />}
|
| 64 |
+
{route === 'notfound' && <NotFoundPage onNav={nav} />}
|
| 65 |
</main>
|
| 66 |
+
<AppFooter />
|
| 67 |
</div>
|
| 68 |
)
|
| 69 |
}
|
web/src/api/auth.ts
CHANGED
|
@@ -1,15 +1,17 @@
|
|
| 1 |
import { apiRequest } from './client'
|
| 2 |
-
import { setToken } from '../auth/session'
|
| 3 |
import {
|
| 4 |
serializeAssertion, serializeRegistration, toCreationOptions, toRequestOptions,
|
| 5 |
isWebAuthnSupported, PasskeyCancelled, PasskeyUnsupported,
|
| 6 |
} from '../auth/passkey'
|
| 7 |
import type { DemoSeedResponse, EnrollResponse } from '../types'
|
| 8 |
|
| 9 |
-
/** "
|
|
|
|
| 10 |
export async function demoSeed(userId: string): Promise<DemoSeedResponse> {
|
| 11 |
const r = await apiRequest<DemoSeedResponse>('/demo/seed', { body: { user_id: userId } })
|
| 12 |
if (r.token) setToken(r.token)
|
|
|
|
| 13 |
return r
|
| 14 |
}
|
| 15 |
|
|
@@ -59,5 +61,9 @@ export async function authenticatePasskey(userId: string): Promise<boolean> {
|
|
| 59 |
const r = await apiRequest<{ success: boolean }>('/webauthn/authenticate/complete', {
|
| 60 |
body: { user_id: userId, credential: serializeAssertion(cred) },
|
| 61 |
})
|
|
|
|
|
|
|
|
|
|
|
|
|
| 62 |
return !!r.success
|
| 63 |
}
|
|
|
|
| 1 |
import { apiRequest } from './client'
|
| 2 |
+
import { setActiveUser, setToken } from '../auth/session'
|
| 3 |
import {
|
| 4 |
serializeAssertion, serializeRegistration, toCreationOptions, toRequestOptions,
|
| 5 |
isWebAuthnSupported, PasskeyCancelled, PasskeyUnsupported,
|
| 6 |
} from '../auth/passkey'
|
| 7 |
import type { DemoSeedResponse, EnrollResponse } from '../types'
|
| 8 |
|
| 9 |
+
/** "Load demo identity": seed a tri-modal identity, receive a session token, and
|
| 10 |
+
* mark this user as the active session user (used by the payment flow). */
|
| 11 |
export async function demoSeed(userId: string): Promise<DemoSeedResponse> {
|
| 12 |
const r = await apiRequest<DemoSeedResponse>('/demo/seed', { body: { user_id: userId } })
|
| 13 |
if (r.token) setToken(r.token)
|
| 14 |
+
setActiveUser({ userId: r.user_id, via: 'demo' })
|
| 15 |
return r
|
| 16 |
}
|
| 17 |
|
|
|
|
| 61 |
const r = await apiRequest<{ success: boolean }>('/webauthn/authenticate/complete', {
|
| 62 |
body: { user_id: userId, credential: serializeAssertion(cred) },
|
| 63 |
})
|
| 64 |
+
// Passkey login establishes the active user for the payment flow. The demo backend
|
| 65 |
+
// does not require AMANPAY_REQUIRE_AUTH in this trusted environment, so a bearer
|
| 66 |
+
// token is optional here; the active user is what the Pay page uses.
|
| 67 |
+
if (r.success) setActiveUser({ userId, via: 'passkey' })
|
| 68 |
return !!r.success
|
| 69 |
}
|
web/src/api/payments.ts
CHANGED
|
@@ -37,6 +37,15 @@ export async function cancelPayment(paymentId: string): Promise<PaymentView> {
|
|
| 37 |
return apiRequest<PaymentView>(`/payments/${encodeURIComponent(paymentId)}/cancel`, { method: 'POST', body: {} })
|
| 38 |
}
|
| 39 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 40 |
export async function getProviders(): Promise<ProvidersResponse> {
|
| 41 |
return apiRequest<ProvidersResponse>('/payments/providers')
|
| 42 |
}
|
|
|
|
| 37 |
return apiRequest<PaymentView>(`/payments/${encodeURIComponent(paymentId)}/cancel`, { method: 'POST', body: {} })
|
| 38 |
}
|
| 39 |
|
| 40 |
+
/** Refund via the real Payment Core refund API (amount in minor units / halalas).
|
| 41 |
+
* Omit amountMinor for a full refund of the remaining balance. */
|
| 42 |
+
export async function refundPayment(paymentId: string, amountMinor?: number, reason?: string): Promise<PaymentView> {
|
| 43 |
+
const body: Record<string, unknown> = {}
|
| 44 |
+
if (amountMinor !== undefined) body.amount_minor = amountMinor
|
| 45 |
+
if (reason) body.reason = reason
|
| 46 |
+
return apiRequest<PaymentView>(`/payments/${encodeURIComponent(paymentId)}/refund`, { method: 'POST', body })
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
export async function getProviders(): Promise<ProvidersResponse> {
|
| 50 |
return apiRequest<ProvidersResponse>('/payments/providers')
|
| 51 |
}
|
web/src/api/version.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { apiRequest } from './client'
|
| 2 |
+
import type { VersionInfo } from '../types'
|
| 3 |
+
|
| 4 |
+
/** Non-sensitive build/version info for the footer and deploy verification. */
|
| 5 |
+
export async function getVersion(): Promise<VersionInfo> {
|
| 6 |
+
return apiRequest<VersionInfo>('/version')
|
| 7 |
+
}
|
web/src/auth/session.test.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { describe, it, expect, beforeEach } from 'vitest'
|
| 2 |
+
import {
|
| 3 |
+
getActiveUser, getToken, isAuthenticated, logout, setActiveUser, setToken,
|
| 4 |
+
} from './session'
|
| 5 |
+
|
| 6 |
+
describe('session (in-memory token + active user)', () => {
|
| 7 |
+
beforeEach(() => logout())
|
| 8 |
+
|
| 9 |
+
it('tracks the active user', () => {
|
| 10 |
+
expect(getActiveUser()).toBeNull()
|
| 11 |
+
setActiveUser({ userId: 'bob', via: 'demo' })
|
| 12 |
+
expect(getActiveUser()).toEqual({ userId: 'bob', via: 'demo' })
|
| 13 |
+
})
|
| 14 |
+
|
| 15 |
+
it('logout clears BOTH token and active user', () => {
|
| 16 |
+
setToken('t')
|
| 17 |
+
setActiveUser({ userId: 'bob', via: 'passkey' })
|
| 18 |
+
expect(isAuthenticated()).toBe(true)
|
| 19 |
+
logout()
|
| 20 |
+
expect(getToken()).toBeNull()
|
| 21 |
+
expect(getActiveUser()).toBeNull()
|
| 22 |
+
expect(isAuthenticated()).toBe(false)
|
| 23 |
+
})
|
| 24 |
+
})
|
web/src/auth/session.ts
CHANGED
|
@@ -1,25 +1,50 @@
|
|
| 1 |
-
// Session
|
|
|
|
|
|
|
|
|
|
| 2 |
// If the backend later moves to HTTP-only cookies, `credentials: 'include'` on the
|
| 3 |
-
// client already carries them and this module becomes a no-op.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
|
| 5 |
let token: string | null = null
|
|
|
|
| 6 |
const listeners = new Set<() => void>()
|
| 7 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
export function setToken(t: string | null): void {
|
| 9 |
token = t
|
| 10 |
-
|
| 11 |
}
|
| 12 |
|
| 13 |
export function getToken(): string | null {
|
| 14 |
return token
|
| 15 |
}
|
| 16 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
export function isAuthenticated(): boolean {
|
| 18 |
return token !== null
|
| 19 |
}
|
| 20 |
|
|
|
|
| 21 |
export function logout(): void {
|
| 22 |
-
|
|
|
|
|
|
|
| 23 |
}
|
| 24 |
|
| 25 |
export function onSessionChange(fn: () => void): () => void {
|
|
|
|
| 1 |
+
// Session state is kept in memory only — NEVER localStorage/sessionStorage/URLs.
|
| 2 |
+
// It holds (a) the bearer token and (b) the active demo user. Both are lost on a
|
| 3 |
+
// full page refresh by design (in-memory only); this is acceptable for the trusted
|
| 4 |
+
// demo environment and documented in the UI ("sign in again after refresh").
|
| 5 |
// If the backend later moves to HTTP-only cookies, `credentials: 'include'` on the
|
| 6 |
+
// client already carries them and the token half of this module becomes a no-op.
|
| 7 |
+
|
| 8 |
+
export interface ActiveUser {
|
| 9 |
+
userId: string
|
| 10 |
+
via: 'demo' | 'passkey'
|
| 11 |
+
}
|
| 12 |
|
| 13 |
let token: string | null = null
|
| 14 |
+
let activeUser: ActiveUser | null = null
|
| 15 |
const listeners = new Set<() => void>()
|
| 16 |
|
| 17 |
+
function emit(): void {
|
| 18 |
+
listeners.forEach((fn) => fn())
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
export function setToken(t: string | null): void {
|
| 22 |
token = t
|
| 23 |
+
emit()
|
| 24 |
}
|
| 25 |
|
| 26 |
export function getToken(): string | null {
|
| 27 |
return token
|
| 28 |
}
|
| 29 |
|
| 30 |
+
export function setActiveUser(user: ActiveUser | null): void {
|
| 31 |
+
activeUser = user
|
| 32 |
+
emit()
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
export function getActiveUser(): ActiveUser | null {
|
| 36 |
+
return activeUser
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
export function isAuthenticated(): boolean {
|
| 40 |
return token !== null
|
| 41 |
}
|
| 42 |
|
| 43 |
+
/** Clears both the token and the active user. */
|
| 44 |
export function logout(): void {
|
| 45 |
+
token = null
|
| 46 |
+
activeUser = null
|
| 47 |
+
emit()
|
| 48 |
}
|
| 49 |
|
| 50 |
export function onSessionChange(fn: () => void): () => void {
|
web/src/components/AppFooter.tsx
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useEffect, useState } from 'react'
|
| 2 |
+
import { getVersion } from '../api/version'
|
| 3 |
+
import { getProviders } from '../api/payments'
|
| 4 |
+
import { useI18n } from '../i18n'
|
| 5 |
+
import type { ProvidersResponse, VersionInfo } from '../types'
|
| 6 |
+
|
| 7 |
+
/** Non-intrusive footer: environment facts (from /payments/providers) + a version
|
| 8 |
+
* line (from /version) so the deployed build is verifiable. No secrets are shown. */
|
| 9 |
+
export function AppFooter() {
|
| 10 |
+
const { t } = useI18n()
|
| 11 |
+
const [version, setVersion] = useState<VersionInfo | null>(null)
|
| 12 |
+
const [providers, setProviders] = useState<ProvidersResponse | null>(null)
|
| 13 |
+
|
| 14 |
+
useEffect(() => {
|
| 15 |
+
let live = true
|
| 16 |
+
getVersion().then((v) => live && setVersion(v)).catch(() => {})
|
| 17 |
+
getProviders().then((p) => live && setProviders(p)).catch(() => {})
|
| 18 |
+
return () => {
|
| 19 |
+
live = false
|
| 20 |
+
}
|
| 21 |
+
}, [])
|
| 22 |
+
|
| 23 |
+
const d = providers?.default
|
| 24 |
+
return (
|
| 25 |
+
<footer className="app-footer">
|
| 26 |
+
<details className="env-panel">
|
| 27 |
+
<summary>{t('env.title')}</summary>
|
| 28 |
+
<dl className="env-grid">
|
| 29 |
+
<dt>{t('env.country')}</dt>
|
| 30 |
+
<dd>{t('env.countryValue')}</dd>
|
| 31 |
+
<dt>{t('env.currency')}</dt>
|
| 32 |
+
<dd className="mono">{d?.currency ?? 'SAR'}</dd>
|
| 33 |
+
<dt>{t('env.timezone')}</dt>
|
| 34 |
+
<dd className="mono">{d?.timezone ?? 'Asia/Riyadh'}</dd>
|
| 35 |
+
<dt>{t('env.languages')}</dt>
|
| 36 |
+
<dd>{t('env.languagesValue')}</dd>
|
| 37 |
+
<dt>{t('env.provider')}</dt>
|
| 38 |
+
<dd>{version?.provider_mode === 'mock' || !version ? t('env.providerValue') : version.provider_mode}</dd>
|
| 39 |
+
<dt>{t('env.mode')}</dt>
|
| 40 |
+
<dd>{t('env.modeValue')}</dd>
|
| 41 |
+
<dt>{t('env.realMoney')}</dt>
|
| 42 |
+
<dd>{t('env.no')}</dd>
|
| 43 |
+
</dl>
|
| 44 |
+
</details>
|
| 45 |
+
<p className="version-line mono" dir="ltr">
|
| 46 |
+
{version
|
| 47 |
+
? `${t('version.prefix')}${version.app_version} · ${version.commit} · ${version.ui_mode} · ${version.provider_mode}`
|
| 48 |
+
: t('version.unknown')}
|
| 49 |
+
</p>
|
| 50 |
+
</footer>
|
| 51 |
+
)
|
| 52 |
+
}
|
web/src/hooks/useSession.ts
CHANGED
|
@@ -1,8 +1,24 @@
|
|
| 1 |
import { useEffect, useState } from 'react'
|
| 2 |
-
import { getToken, isAuthenticated, onSessionChange } from '../auth/session'
|
| 3 |
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
}
|
|
|
|
| 1 |
import { useEffect, useState } from 'react'
|
| 2 |
+
import { getActiveUser, getToken, isAuthenticated, onSessionChange, type ActiveUser } from '../auth/session'
|
| 3 |
|
| 4 |
+
interface SessionState {
|
| 5 |
+
authed: boolean
|
| 6 |
+
token: string | null
|
| 7 |
+
activeUser: ActiveUser | null
|
| 8 |
+
}
|
| 9 |
+
|
| 10 |
+
export function useSession(): SessionState {
|
| 11 |
+
const [state, setState] = useState<SessionState>(() => ({
|
| 12 |
+
authed: isAuthenticated(),
|
| 13 |
+
token: getToken(),
|
| 14 |
+
activeUser: getActiveUser(),
|
| 15 |
+
}))
|
| 16 |
+
useEffect(
|
| 17 |
+
() =>
|
| 18 |
+
onSessionChange(() =>
|
| 19 |
+
setState({ authed: isAuthenticated(), token: getToken(), activeUser: getActiveUser() }),
|
| 20 |
+
),
|
| 21 |
+
[],
|
| 22 |
+
)
|
| 23 |
+
return state
|
| 24 |
}
|
web/src/i18n/ar.ts
CHANGED
|
@@ -3,33 +3,57 @@ import type { MessageKey } from './en'
|
|
| 3 |
export const ar: Record<MessageKey, string> = {
|
| 4 |
'app.title': 'أمان باي',
|
| 5 |
'app.tagline': 'بصمتك الحيوية هي بطاقتك',
|
|
|
|
| 6 |
'nav.pay': 'الدفع',
|
| 7 |
'nav.enroll': 'التسجيل',
|
| 8 |
'lang.toggle': 'English',
|
| 9 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
'enroll.userId': 'معرّف المستخدم',
|
|
|
|
|
|
|
| 11 |
'enroll.demo': 'تحميل هوية تجريبية',
|
| 12 |
-
'enroll.
|
| 13 |
-
'enroll.
|
| 14 |
-
'enroll.
|
| 15 |
-
'enroll.
|
| 16 |
-
'enroll.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
'auth.logout': 'تسجيل الخروج',
|
|
|
|
|
|
|
| 18 |
'pay.title': 'الدفع — بدون بطاقة',
|
| 19 |
'pay.amount': 'المبلغ (ريال)',
|
| 20 |
'pay.merchant': 'التاجر',
|
| 21 |
'pay.iban': 'آيبان المستفيد',
|
|
|
|
| 22 |
'pay.submit': 'ادفع',
|
| 23 |
'pay.submitting': 'جارٍ الإرسال…',
|
| 24 |
-
'pay.needLogin': 'سجّل الدخول
|
| 25 |
-
'pay.
|
| 26 |
-
'pay.
|
| 27 |
-
'pay.
|
| 28 |
-
'pay.
|
|
|
|
|
|
|
|
|
|
| 29 |
'pay.newPayment': 'عملية دفع جديدة',
|
|
|
|
|
|
|
|
|
|
|
|
|
| 30 |
'pay.refunded': 'تم استرداد {amount}',
|
| 31 |
-
'pay.
|
| 32 |
-
|
|
|
|
| 33 |
'status.created': 'أُنشئت',
|
| 34 |
'status.requires_customer_action': 'بانتظار بنكك',
|
| 35 |
'status.authorizing': 'جارٍ التفويض',
|
|
@@ -40,6 +64,83 @@ export const ar: Record<MessageKey, string> = {
|
|
| 40 |
'status.partially_refunded': 'مُستردة جزئيًا',
|
| 41 |
'status.refunded': 'مُستردة',
|
| 42 |
'status.expired': 'منتهية',
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 43 |
'error.generic': 'حدث خطأ ما. حاول مرة أخرى.',
|
| 44 |
'error.network': 'مشكلة في الشبكة — تحقّق من اتصالك.',
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 45 |
}
|
|
|
|
| 3 |
export const ar: Record<MessageKey, string> = {
|
| 4 |
'app.title': 'أمان باي',
|
| 5 |
'app.tagline': 'بصمتك الحيوية هي بطاقتك',
|
| 6 |
+
'demo.banner': 'بيئة تجريبية موثوقة — مدفوعات وهمية، لا تُحرّك أموالًا حقيقية.',
|
| 7 |
'nav.pay': 'الدفع',
|
| 8 |
'nav.enroll': 'التسجيل',
|
| 9 |
'lang.toggle': 'English',
|
| 10 |
+
|
| 11 |
+
'header.activeUser': 'مُسجّل الدخول: {user}',
|
| 12 |
+
'header.noUser': 'غير مُسجّل الدخول',
|
| 13 |
+
|
| 14 |
+
// ---- Enroll / auth ----
|
| 15 |
+
'enroll.title': 'التسجيل وتسجيل الدخول',
|
| 16 |
'enroll.userId': 'معرّف المستخدم',
|
| 17 |
+
'enroll.needUserId': 'أدخل معرّف المستخدم أولًا.',
|
| 18 |
+
'enroll.sectionDemo': 'هوية تجريبية',
|
| 19 |
'enroll.demo': 'تحميل هوية تجريبية',
|
| 20 |
+
'enroll.demoHint': 'يُسجّل البصمات الحيوية التجريبية لهذا المعرّف ويُدخلك.',
|
| 21 |
+
'enroll.sectionPasskey': 'مفتاح مرور الجهاز',
|
| 22 |
+
'enroll.registerPasskey': 'تسجيل مفتاح مرور',
|
| 23 |
+
'enroll.signInPasskey': 'الدخول بمفتاح المرور',
|
| 24 |
+
'enroll.passkeyHint': 'يستخدم بصمة الجهاز / Face ID عبر WebAuthn.',
|
| 25 |
+
'enroll.passkeyOk': 'تم تسجيل مفتاح المرور على هذا الجهاز.',
|
| 26 |
+
'enroll.signedIn': 'مُسجّل الدخول باسم {user}.',
|
| 27 |
+
'enroll.signedOut': 'تم تسجيل الخروج.',
|
| 28 |
+
'enroll.passkeyCancelled': 'أُلغيت — حاول مرة أخرى أو استخدم جهازًا آخر.',
|
| 29 |
+
'enroll.passkeyUnsupported': 'لا يوجد مستشعر حيوي متاح على هذا الجهاز/المتصفح.',
|
| 30 |
'auth.logout': 'تسجيل الخروج',
|
| 31 |
+
|
| 32 |
+
// ---- Pay ----
|
| 33 |
'pay.title': 'الدفع — بدون بطاقة',
|
| 34 |
'pay.amount': 'المبلغ (ريال)',
|
| 35 |
'pay.merchant': 'التاجر',
|
| 36 |
'pay.iban': 'آيبان المستفيد',
|
| 37 |
+
'pay.description': 'مرجع (اختياري)',
|
| 38 |
'pay.submit': 'ادفع',
|
| 39 |
'pay.submitting': 'جارٍ الإرسال…',
|
| 40 |
+
'pay.needLogin': 'سجّل الدخول من تبويب التسجيل للدفع.',
|
| 41 |
+
'pay.payingAs': 'الدفع باسم {user}',
|
| 42 |
+
'pay.summary': 'ملخّص العملية',
|
| 43 |
+
'pay.customerAction': 'مطلوب موافقة البنك لإكمال هذه العملية.',
|
| 44 |
+
'pay.mockBankInfo': 'هذه خطوة موافقة بنكية محاكاة — لا يتم الاتصال ببنك حقيقي.',
|
| 45 |
+
'pay.openBank': 'المتابعة إلى البنك',
|
| 46 |
+
'pay.simulateSettle': 'محاكاة موافقة البنك',
|
| 47 |
+
'pay.processing': 'قيد المعالجة — لم تكتمل بعد.',
|
| 48 |
'pay.newPayment': 'عملية دفع جديدة',
|
| 49 |
+
'pay.cancel': 'إلغاء العملية',
|
| 50 |
+
'pay.original': 'الأصلي',
|
| 51 |
+
'pay.refundedAmount': 'المُسترد',
|
| 52 |
+
'pay.remaining': 'المتبقّي',
|
| 53 |
'pay.refunded': 'تم استرداد {amount}',
|
| 54 |
+
'pay.succeeded': 'تمت العملية بنجاح.',
|
| 55 |
+
|
| 56 |
+
// ---- Status labels ----
|
| 57 |
'status.created': 'أُنشئت',
|
| 58 |
'status.requires_customer_action': 'بانتظار بنكك',
|
| 59 |
'status.authorizing': 'جارٍ التفويض',
|
|
|
|
| 64 |
'status.partially_refunded': 'مُستردة جزئيًا',
|
| 65 |
'status.refunded': 'مُستردة',
|
| 66 |
'status.expired': 'منتهية',
|
| 67 |
+
|
| 68 |
+
// ---- Status explanations ----
|
| 69 |
+
'statusHelp.created': 'تم إنشاء طلب الدفع.',
|
| 70 |
+
'statusHelp.requires_customer_action': 'أكّد العملية عبر خطوة البنك المحاكاة.',
|
| 71 |
+
'statusHelp.authorizing': 'يجري التحقّق من تفويض العميل.',
|
| 72 |
+
'statusHelp.processing': 'جارٍ تنفيذ التحويل — لا تعتبرها ناجحة بعد.',
|
| 73 |
+
'statusHelp.succeeded': 'تمت العملية بنجاح.',
|
| 74 |
+
'statusHelp.failed': 'رُفضت العملية أو تعذّر إكمالها.',
|
| 75 |
+
'statusHelp.cancelled': 'أُلغيت العملية.',
|
| 76 |
+
'statusHelp.partially_refunded': 'تم استرداد جزء ��ن العملية.',
|
| 77 |
+
'statusHelp.refunded': 'تم استرداد كامل المبلغ.',
|
| 78 |
+
'statusHelp.expired': 'انتهت مهلة التفويض.',
|
| 79 |
+
|
| 80 |
+
// ---- Demo scenario selector ----
|
| 81 |
+
'scenario.title': 'سيناريوهات تجريبية (محاكاة)',
|
| 82 |
+
'scenario.hint': 'يُشغّل نواة الدفع الحقيقية عبر المزوّد الوهمي. حالة الخادم هي المرجع.',
|
| 83 |
+
'scenario.approve': 'الموافقة والتسوية',
|
| 84 |
+
'scenario.decline': 'الرفض',
|
| 85 |
+
'scenario.expire': 'انتهاء المهلة',
|
| 86 |
+
'scenario.cancelProvider': 'الإلغاء عبر المزوّد',
|
| 87 |
+
'scenario.authorizing': 'الانتقال إلى التفويض',
|
| 88 |
+
'scenario.processing': 'الانتقال إلى المعالجة',
|
| 89 |
+
'scenario.partialRefund': 'محاكاة استرداد جزئي',
|
| 90 |
+
'scenario.fullRefund': 'محاكاة استرداد كامل',
|
| 91 |
+
|
| 92 |
+
// ---- Environment panel ----
|
| 93 |
+
'env.title': 'البيئة',
|
| 94 |
+
'env.country': 'الدولة',
|
| 95 |
+
'env.countryValue': 'المملكة العربية السعودية',
|
| 96 |
+
'env.currency': 'العملة',
|
| 97 |
+
'env.timezone': 'المنطقة الزمنية',
|
| 98 |
+
'env.languages': 'اللغات',
|
| 99 |
+
'env.languagesValue': 'العربية، الإنجليزية',
|
| 100 |
+
'env.provider': 'المزوّد',
|
| 101 |
+
'env.providerValue': 'وهمي',
|
| 102 |
+
'env.mode': 'الوضع',
|
| 103 |
+
'env.modeValue': 'غير حافظ للأموال',
|
| 104 |
+
'env.realMoney': 'تحويل أموال حقيقية',
|
| 105 |
+
'env.no': 'لا',
|
| 106 |
+
|
| 107 |
+
// ---- Version footer ----
|
| 108 |
+
'version.prefix': 'إصدار ',
|
| 109 |
+
'version.unknown': 'الإصدار غير متاح',
|
| 110 |
+
|
| 111 |
+
// ---- Not found ----
|
| 112 |
+
'notFound.title': 'الصفحة غير موجودة',
|
| 113 |
+
'notFound.body': 'هذا المسار غير موجود.',
|
| 114 |
+
'notFound.goPay': 'الذهاب إلى الدفع',
|
| 115 |
+
'notFound.goEnroll': 'الذهاب إلى التسجيل',
|
| 116 |
+
|
| 117 |
+
// ---- Technical / support details ----
|
| 118 |
+
'tech.title': 'تفاصيل تقنية / للدعم',
|
| 119 |
+
'tech.paymentId': 'معرّف العملية',
|
| 120 |
+
'tech.providerRef': 'مرجع المزوّد',
|
| 121 |
+
'tech.correlationId': 'معرّف الربط',
|
| 122 |
+
'tech.requestId': 'معرّف الطلب',
|
| 123 |
+
'tech.created': 'أُنشئت',
|
| 124 |
+
'tech.updated': 'آخر تحديث',
|
| 125 |
+
|
| 126 |
+
// ---- Errors ----
|
| 127 |
'error.generic': 'حدث خطأ ما. حاول مرة أخرى.',
|
| 128 |
'error.network': 'مشكلة في الشبكة — تحقّق من اتصالك.',
|
| 129 |
+
'error.timeout': 'انتهت مهلة الطلب — حاول مرة أخرى.',
|
| 130 |
+
'error.withRef': '{msg} (المرجع: {id})',
|
| 131 |
+
'error.amountEmpty': 'أدخل المبلغ.',
|
| 132 |
+
'error.amountInvalid': 'أدخل مبلغًا صحيحًا (خانتان عشريتان كحدّ أقصى).',
|
| 133 |
+
'error.amountZero': 'يجب أن يكون المبلغ أكبر من صفر.',
|
| 134 |
+
'error.amountNegative': 'يجب أن يكون المبلغ موجبًا.',
|
| 135 |
+
'error.amountTooLarge': 'المبلغ كبير جدًا لهذه البيئة التجريبية.',
|
| 136 |
+
'error.ibanInvalid': 'أدخل آيبان سعودي صحيح (SA ثم 22 رقمًا).',
|
| 137 |
+
'error.merchantEmpty': 'أدخل اسم التاجر.',
|
| 138 |
+
'error.payeeEmpty': 'أدخل آيبان المستفيد.',
|
| 139 |
+
'error.duplicate': 'تم إرسال هذه العملية مسبقًا.',
|
| 140 |
+
'error.noActiveUser': 'سجّل الدخول من تبويب التسجيل أولًا.',
|
| 141 |
+
'error.paymentCreate': 'تعذّر إنشاء العملية.',
|
| 142 |
+
'error.cancel': 'تعذّر إلغاء العملية.',
|
| 143 |
+
'error.refund': 'تعذّر تنفيذ الاسترداد.',
|
| 144 |
+
'error.passkeyRegister': 'فشل تسجيل مفتاح المرور.',
|
| 145 |
+
'error.passkeySignin': 'فشل الدخول بمفتاح المرور.',
|
| 146 |
}
|
web/src/i18n/en.ts
CHANGED
|
@@ -1,33 +1,57 @@
|
|
| 1 |
export const en = {
|
| 2 |
'app.title': 'AmanPay',
|
| 3 |
'app.tagline': 'Your biometrics are your card',
|
|
|
|
| 4 |
'nav.pay': 'Pay',
|
| 5 |
'nav.enroll': 'Enroll',
|
| 6 |
'lang.toggle': 'العربية',
|
| 7 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
'enroll.userId': 'User ID',
|
|
|
|
|
|
|
| 9 |
'enroll.demo': 'Load demo identity',
|
| 10 |
-
'enroll.
|
| 11 |
-
'enroll.
|
| 12 |
-
'enroll.
|
| 13 |
-
'enroll.
|
| 14 |
-
'enroll.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
'auth.logout': 'Sign out',
|
|
|
|
|
|
|
| 16 |
'pay.title': 'Pay — cardless',
|
| 17 |
'pay.amount': 'Amount (SAR)',
|
| 18 |
'pay.merchant': 'Merchant',
|
| 19 |
'pay.iban': 'Payee IBAN',
|
|
|
|
| 20 |
'pay.submit': 'Pay',
|
| 21 |
'pay.submitting': 'Submitting…',
|
| 22 |
-
'pay.needLogin': 'Sign in
|
| 23 |
-
'pay.
|
| 24 |
-
'pay.
|
| 25 |
-
'pay.
|
| 26 |
-
'pay.
|
|
|
|
|
|
|
|
|
|
| 27 |
'pay.newPayment': 'New payment',
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
'pay.refunded': 'Refunded {amount}',
|
| 29 |
-
'pay.
|
| 30 |
-
|
|
|
|
| 31 |
'status.created': 'Created',
|
| 32 |
'status.requires_customer_action': 'Awaiting your bank',
|
| 33 |
'status.authorizing': 'Authorizing',
|
|
@@ -38,8 +62,85 @@ export const en = {
|
|
| 38 |
'status.partially_refunded': 'Partially refunded',
|
| 39 |
'status.refunded': 'Refunded',
|
| 40 |
'status.expired': 'Expired',
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 41 |
'error.generic': 'Something went wrong. Please try again.',
|
| 42 |
'error.network': 'Network problem — check your connection.',
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 43 |
} as const
|
| 44 |
|
| 45 |
export type MessageKey = keyof typeof en
|
|
|
|
| 1 |
export const en = {
|
| 2 |
'app.title': 'AmanPay',
|
| 3 |
'app.tagline': 'Your biometrics are your card',
|
| 4 |
+
'demo.banner': 'Trusted demo — mock payments, no real money moves.',
|
| 5 |
'nav.pay': 'Pay',
|
| 6 |
'nav.enroll': 'Enroll',
|
| 7 |
'lang.toggle': 'العربية',
|
| 8 |
+
|
| 9 |
+
'header.activeUser': 'Signed in: {user}',
|
| 10 |
+
'header.noUser': 'Not signed in',
|
| 11 |
+
|
| 12 |
+
// ---- Enroll / auth ----
|
| 13 |
+
'enroll.title': 'Enroll & sign in',
|
| 14 |
'enroll.userId': 'User ID',
|
| 15 |
+
'enroll.needUserId': 'Enter a user ID first.',
|
| 16 |
+
'enroll.sectionDemo': 'Demo identity',
|
| 17 |
'enroll.demo': 'Load demo identity',
|
| 18 |
+
'enroll.demoHint': 'Enrolls the bundled demo biometrics for this user ID and signs you in.',
|
| 19 |
+
'enroll.sectionPasskey': 'Device passkey',
|
| 20 |
+
'enroll.registerPasskey': 'Register a passkey',
|
| 21 |
+
'enroll.signInPasskey': 'Sign in with passkey',
|
| 22 |
+
'enroll.passkeyHint': 'Uses your device fingerprint / Face ID (WebAuthn).',
|
| 23 |
+
'enroll.passkeyOk': 'Passkey registered on this device.',
|
| 24 |
+
'enroll.signedIn': 'Signed in as {user}.',
|
| 25 |
+
'enroll.signedOut': 'Signed out.',
|
| 26 |
+
'enroll.passkeyCancelled': 'Cancelled — try again or use another device.',
|
| 27 |
+
'enroll.passkeyUnsupported': 'This device/browser has no usable biometric sensor.',
|
| 28 |
'auth.logout': 'Sign out',
|
| 29 |
+
|
| 30 |
+
// ---- Pay ----
|
| 31 |
'pay.title': 'Pay — cardless',
|
| 32 |
'pay.amount': 'Amount (SAR)',
|
| 33 |
'pay.merchant': 'Merchant',
|
| 34 |
'pay.iban': 'Payee IBAN',
|
| 35 |
+
'pay.description': 'Reference (optional)',
|
| 36 |
'pay.submit': 'Pay',
|
| 37 |
'pay.submitting': 'Submitting…',
|
| 38 |
+
'pay.needLogin': 'Sign in on the Enroll tab to pay.',
|
| 39 |
+
'pay.payingAs': 'Paying as {user}',
|
| 40 |
+
'pay.summary': 'Payment summary',
|
| 41 |
+
'pay.customerAction': 'Bank authorization is required to complete this payment.',
|
| 42 |
+
'pay.mockBankInfo': 'This is a simulated bank-authorization step — no real bank is contacted.',
|
| 43 |
+
'pay.openBank': 'Continue to bank',
|
| 44 |
+
'pay.simulateSettle': 'Simulate bank approval',
|
| 45 |
+
'pay.processing': 'Processing — not yet complete.',
|
| 46 |
'pay.newPayment': 'New payment',
|
| 47 |
+
'pay.cancel': 'Cancel payment',
|
| 48 |
+
'pay.original': 'Original',
|
| 49 |
+
'pay.refundedAmount': 'Refunded',
|
| 50 |
+
'pay.remaining': 'Remaining',
|
| 51 |
'pay.refunded': 'Refunded {amount}',
|
| 52 |
+
'pay.succeeded': 'Payment completed successfully.',
|
| 53 |
+
|
| 54 |
+
// ---- Status labels ----
|
| 55 |
'status.created': 'Created',
|
| 56 |
'status.requires_customer_action': 'Awaiting your bank',
|
| 57 |
'status.authorizing': 'Authorizing',
|
|
|
|
| 62 |
'status.partially_refunded': 'Partially refunded',
|
| 63 |
'status.refunded': 'Refunded',
|
| 64 |
'status.expired': 'Expired',
|
| 65 |
+
|
| 66 |
+
// ---- Status explanations ----
|
| 67 |
+
'statusHelp.created': 'Payment request created.',
|
| 68 |
+
'statusHelp.requires_customer_action': 'Confirm the payment through the simulated bank action.',
|
| 69 |
+
'statusHelp.authorizing': 'Customer authorization is being verified.',
|
| 70 |
+
'statusHelp.processing': 'The transfer is processing — do not treat it as successful yet.',
|
| 71 |
+
'statusHelp.succeeded': 'Payment completed successfully.',
|
| 72 |
+
'statusHelp.failed': 'Payment was declined or could not be completed.',
|
| 73 |
+
'statusHelp.cancelled': 'Payment was cancelled.',
|
| 74 |
+
'statusHelp.partially_refunded': 'Part of the payment was refunded.',
|
| 75 |
+
'statusHelp.refunded': 'The payment was fully refunded.',
|
| 76 |
+
'statusHelp.expired': 'The authorization window expired.',
|
| 77 |
+
|
| 78 |
+
// ---- Demo scenario selector ----
|
| 79 |
+
'scenario.title': 'Demo scenarios (simulation)',
|
| 80 |
+
'scenario.hint': 'Drives the real Payment Core via the mock provider. Backend status is authoritative.',
|
| 81 |
+
'scenario.approve': 'Approve & settle',
|
| 82 |
+
'scenario.decline': 'Decline',
|
| 83 |
+
'scenario.expire': 'Expire',
|
| 84 |
+
'scenario.cancelProvider': 'Cancel via provider',
|
| 85 |
+
'scenario.authorizing': 'Move to authorizing',
|
| 86 |
+
'scenario.processing': 'Move to processing',
|
| 87 |
+
'scenario.partialRefund': 'Simulate partial refund',
|
| 88 |
+
'scenario.fullRefund': 'Simulate full refund',
|
| 89 |
+
|
| 90 |
+
// ---- Environment panel ----
|
| 91 |
+
'env.title': 'Environment',
|
| 92 |
+
'env.country': 'Country',
|
| 93 |
+
'env.countryValue': 'Saudi Arabia',
|
| 94 |
+
'env.currency': 'Currency',
|
| 95 |
+
'env.timezone': 'Time zone',
|
| 96 |
+
'env.languages': 'Languages',
|
| 97 |
+
'env.languagesValue': 'Arabic, English',
|
| 98 |
+
'env.provider': 'Provider',
|
| 99 |
+
'env.providerValue': 'Mock',
|
| 100 |
+
'env.mode': 'Mode',
|
| 101 |
+
'env.modeValue': 'Non-custodial',
|
| 102 |
+
'env.realMoney': 'Real money moved',
|
| 103 |
+
'env.no': 'No',
|
| 104 |
+
|
| 105 |
+
// ---- Version footer ----
|
| 106 |
+
'version.prefix': 'v',
|
| 107 |
+
'version.unknown': 'version unavailable',
|
| 108 |
+
|
| 109 |
+
// ---- Not found ----
|
| 110 |
+
'notFound.title': 'Page not found',
|
| 111 |
+
'notFound.body': "That route doesn't exist.",
|
| 112 |
+
'notFound.goPay': 'Go to Pay',
|
| 113 |
+
'notFound.goEnroll': 'Go to Enroll',
|
| 114 |
+
|
| 115 |
+
// ---- Technical / support details ----
|
| 116 |
+
'tech.title': 'Technical / support details',
|
| 117 |
+
'tech.paymentId': 'Payment ID',
|
| 118 |
+
'tech.providerRef': 'Provider reference',
|
| 119 |
+
'tech.correlationId': 'Correlation ID',
|
| 120 |
+
'tech.requestId': 'Request ID',
|
| 121 |
+
'tech.created': 'Created',
|
| 122 |
+
'tech.updated': 'Updated',
|
| 123 |
+
|
| 124 |
+
// ---- Errors ----
|
| 125 |
'error.generic': 'Something went wrong. Please try again.',
|
| 126 |
'error.network': 'Network problem — check your connection.',
|
| 127 |
+
'error.timeout': 'The request timed out — please try again.',
|
| 128 |
+
'error.withRef': '{msg} (ref: {id})',
|
| 129 |
+
'error.amountEmpty': 'Enter an amount.',
|
| 130 |
+
'error.amountInvalid': 'Enter a valid amount (up to 2 decimals).',
|
| 131 |
+
'error.amountZero': 'Amount must be greater than zero.',
|
| 132 |
+
'error.amountNegative': 'Amount must be positive.',
|
| 133 |
+
'error.amountTooLarge': 'Amount is too large for this demo.',
|
| 134 |
+
'error.ibanInvalid': 'Enter a valid Saudi IBAN (SA + 22 digits).',
|
| 135 |
+
'error.merchantEmpty': 'Enter a merchant name.',
|
| 136 |
+
'error.payeeEmpty': 'Enter a payee IBAN.',
|
| 137 |
+
'error.duplicate': 'This payment was already submitted.',
|
| 138 |
+
'error.noActiveUser': 'Sign in on the Enroll tab first.',
|
| 139 |
+
'error.paymentCreate': 'Could not create the payment.',
|
| 140 |
+
'error.cancel': 'Could not cancel the payment.',
|
| 141 |
+
'error.refund': 'Could not process the refund.',
|
| 142 |
+
'error.passkeyRegister': 'Passkey registration failed.',
|
| 143 |
+
'error.passkeySignin': 'Passkey sign-in failed.',
|
| 144 |
} as const
|
| 145 |
|
| 146 |
export type MessageKey = keyof typeof en
|
web/src/i18n/i18n.test.tsx
CHANGED
|
@@ -1,6 +1,9 @@
|
|
| 1 |
import { describe, it, expect } from 'vitest'
|
| 2 |
import { render, screen } from '@testing-library/react'
|
| 3 |
import { I18nProvider, useI18n } from './index'
|
|
|
|
|
|
|
|
|
|
| 4 |
|
| 5 |
function Probe() {
|
| 6 |
const { t, dir } = useI18n()
|
|
@@ -20,3 +23,28 @@ describe('i18n + RTL', () => {
|
|
| 20 |
expect(screen.getByText('الدفع')).toBeInTheDocument()
|
| 21 |
})
|
| 22 |
})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
import { describe, it, expect } from 'vitest'
|
| 2 |
import { render, screen } from '@testing-library/react'
|
| 3 |
import { I18nProvider, useI18n } from './index'
|
| 4 |
+
import { en } from './en'
|
| 5 |
+
import { ar } from './ar'
|
| 6 |
+
import { PAYMENT_STATUSES } from '../types'
|
| 7 |
|
| 8 |
function Probe() {
|
| 9 |
const { t, dir } = useI18n()
|
|
|
|
| 23 |
expect(screen.getByText('الدفع')).toBeInTheDocument()
|
| 24 |
})
|
| 25 |
})
|
| 26 |
+
|
| 27 |
+
describe('i18n completeness', () => {
|
| 28 |
+
it('English and Arabic dictionaries have identical keys', () => {
|
| 29 |
+
const enKeys = Object.keys(en).sort()
|
| 30 |
+
const arKeys = Object.keys(ar).sort()
|
| 31 |
+
expect(arKeys).toEqual(enKeys)
|
| 32 |
+
})
|
| 33 |
+
|
| 34 |
+
it('every payment status has a label and an explanation in both locales', () => {
|
| 35 |
+
for (const s of PAYMENT_STATUSES) {
|
| 36 |
+
for (const dict of [en, ar] as const) {
|
| 37 |
+
expect(dict[`status.${s}` as keyof typeof dict]).toBeTruthy()
|
| 38 |
+
expect(dict[`statusHelp.${s}` as keyof typeof dict]).toBeTruthy()
|
| 39 |
+
}
|
| 40 |
+
}
|
| 41 |
+
})
|
| 42 |
+
|
| 43 |
+
it('runtime error messages are actually translated to Arabic (not English)', () => {
|
| 44 |
+
for (const k of ['error.network', 'error.timeout', 'error.paymentCreate', 'error.cancel',
|
| 45 |
+
'error.refund', 'error.passkeySignin', 'error.ibanInvalid'] as const) {
|
| 46 |
+
expect(ar[k]).toBeTruthy()
|
| 47 |
+
expect(ar[k]).not.toBe(en[k]) // genuinely localized, not an English fallback
|
| 48 |
+
}
|
| 49 |
+
})
|
| 50 |
+
})
|
web/src/pages/EnrollPage.test.tsx
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
| 2 |
+
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
|
| 3 |
+
import { I18nProvider } from '../i18n'
|
| 4 |
+
import { EnrollPage } from './EnrollPage'
|
| 5 |
+
import * as authApi from '../api/auth'
|
| 6 |
+
import { logout } from '../auth/session'
|
| 7 |
+
|
| 8 |
+
vi.mock('../api/auth')
|
| 9 |
+
|
| 10 |
+
const renderEnroll = () => render(<I18nProvider initial="en"><EnrollPage /></I18nProvider>)
|
| 11 |
+
|
| 12 |
+
describe('EnrollPage — separated auth actions', () => {
|
| 13 |
+
beforeEach(() => { vi.resetAllMocks(); logout() })
|
| 14 |
+
|
| 15 |
+
it('shows the demo, register-passkey and sign-in-passkey actions distinctly', () => {
|
| 16 |
+
renderEnroll()
|
| 17 |
+
expect(screen.getByRole('button', { name: 'Load demo identity' })).toBeInTheDocument()
|
| 18 |
+
expect(screen.getByRole('button', { name: 'Register a passkey' })).toBeInTheDocument()
|
| 19 |
+
expect(screen.getByRole('button', { name: 'Sign in with passkey' })).toBeInTheDocument()
|
| 20 |
+
})
|
| 21 |
+
|
| 22 |
+
it('Load demo identity calls demoSeed with the typed user id', async () => {
|
| 23 |
+
const seed = vi.spyOn(authApi, 'demoSeed').mockResolvedValue({ success: true, user_id: 'carol', modalities: [] })
|
| 24 |
+
renderEnroll()
|
| 25 |
+
const input = screen.getByLabelText('User ID')
|
| 26 |
+
fireEvent.change(input, { target: { value: 'carol' } })
|
| 27 |
+
fireEvent.click(screen.getByRole('button', { name: 'Load demo identity' }))
|
| 28 |
+
await waitFor(() => expect(seed).toHaveBeenCalledWith('carol'))
|
| 29 |
+
expect(await screen.findByText(/Signed in as carol/)).toBeInTheDocument()
|
| 30 |
+
})
|
| 31 |
+
|
| 32 |
+
it('Sign in with passkey calls authenticatePasskey (wired login)', async () => {
|
| 33 |
+
const auth = vi.spyOn(authApi, 'authenticatePasskey').mockResolvedValue(true)
|
| 34 |
+
renderEnroll()
|
| 35 |
+
fireEvent.change(screen.getByLabelText('User ID'), { target: { value: 'dave' } })
|
| 36 |
+
fireEvent.click(screen.getByRole('button', { name: 'Sign in with passkey' }))
|
| 37 |
+
await waitFor(() => expect(auth).toHaveBeenCalledWith('dave'))
|
| 38 |
+
})
|
| 39 |
+
})
|
web/src/pages/EnrollPage.tsx
CHANGED
|
@@ -1,63 +1,107 @@
|
|
| 1 |
import { useState } from 'react'
|
| 2 |
-
import { demoSeed, registerPasskey } from '../api/auth'
|
| 3 |
import { logout } from '../auth/session'
|
| 4 |
import { PasskeyCancelled, PasskeyUnsupported, platformAuthenticatorAvailable } from '../auth/passkey'
|
| 5 |
import { useSession } from '../hooks/useSession'
|
| 6 |
import { useI18n } from '../i18n'
|
|
|
|
| 7 |
import { Button, Field, Callout, Spinner } from '../components/ui'
|
| 8 |
-
|
|
|
|
|
|
|
| 9 |
|
| 10 |
export function EnrollPage() {
|
| 11 |
const { t } = useI18n()
|
| 12 |
-
const { authed } = useSession()
|
| 13 |
const [userId, setUserId] = useState('alice')
|
| 14 |
-
const [busy, setBusy] = useState<
|
| 15 |
-
const [msg, setMsg] = useState<
|
|
|
|
|
|
|
| 16 |
|
| 17 |
async function onDemo() {
|
|
|
|
| 18 |
setBusy('demo'); setMsg(null)
|
| 19 |
try {
|
| 20 |
-
const r = await demoSeed(
|
| 21 |
-
setMsg({ tone: 'ok', text: t('enroll.
|
| 22 |
} catch (e) {
|
| 23 |
-
setMsg({ tone: 'bad', text: e
|
| 24 |
} finally {
|
| 25 |
setBusy(null)
|
| 26 |
}
|
| 27 |
}
|
| 28 |
|
| 29 |
-
async function
|
| 30 |
-
|
|
|
|
| 31 |
try {
|
| 32 |
if (!(await platformAuthenticatorAvailable())) {
|
| 33 |
-
setMsg({ tone: 'info', text: t('enroll.passkeyUnsupported') })
|
| 34 |
-
return
|
| 35 |
}
|
| 36 |
-
await registerPasskey(
|
| 37 |
setMsg({ tone: 'ok', text: t('enroll.passkeyOk') })
|
| 38 |
} catch (e) {
|
| 39 |
if (e instanceof PasskeyUnsupported) setMsg({ tone: 'info', text: t('enroll.passkeyUnsupported') })
|
| 40 |
else if (e instanceof PasskeyCancelled) setMsg({ tone: 'info', text: t('enroll.passkeyCancelled') })
|
| 41 |
-
else setMsg({ tone: 'bad', text:
|
| 42 |
} finally {
|
| 43 |
setBusy(null)
|
| 44 |
}
|
| 45 |
}
|
| 46 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 47 |
return (
|
| 48 |
<section className="card" aria-labelledby="enroll-h">
|
| 49 |
<h2 id="enroll-h">{t('enroll.title')}</h2>
|
|
|
|
| 50 |
<Field id="e_user" label={t('enroll.userId')} value={userId}
|
| 51 |
onChange={(e) => setUserId(e.target.value)} autoComplete="username" />
|
| 52 |
-
|
|
|
|
|
|
|
|
|
|
| 53 |
<Button onClick={onDemo} disabled={busy !== null}>
|
| 54 |
{busy === 'demo' ? <Spinner label={t('pay.submitting')} /> : t('enroll.demo')}
|
| 55 |
</Button>
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 61 |
{msg && <Callout tone={msg.tone}>{msg.text}</Callout>}
|
| 62 |
</section>
|
| 63 |
)
|
|
|
|
| 1 |
import { useState } from 'react'
|
| 2 |
+
import { authenticatePasskey, demoSeed, registerPasskey } from '../api/auth'
|
| 3 |
import { logout } from '../auth/session'
|
| 4 |
import { PasskeyCancelled, PasskeyUnsupported, platformAuthenticatorAvailable } from '../auth/passkey'
|
| 5 |
import { useSession } from '../hooks/useSession'
|
| 6 |
import { useI18n } from '../i18n'
|
| 7 |
+
import { localizeError } from '../utils/errors'
|
| 8 |
import { Button, Field, Callout, Spinner } from '../components/ui'
|
| 9 |
+
|
| 10 |
+
type Busy = 'demo' | 'register' | 'signin' | null
|
| 11 |
+
type Msg = { tone: 'ok' | 'bad' | 'info'; text: string } | null
|
| 12 |
|
| 13 |
export function EnrollPage() {
|
| 14 |
const { t } = useI18n()
|
| 15 |
+
const { authed, activeUser } = useSession()
|
| 16 |
const [userId, setUserId] = useState('alice')
|
| 17 |
+
const [busy, setBusy] = useState<Busy>(null)
|
| 18 |
+
const [msg, setMsg] = useState<Msg>(null)
|
| 19 |
+
|
| 20 |
+
const uid = () => userId.trim()
|
| 21 |
|
| 22 |
async function onDemo() {
|
| 23 |
+
if (!uid()) { setMsg({ tone: 'info', text: t('enroll.needUserId') }); return }
|
| 24 |
setBusy('demo'); setMsg(null)
|
| 25 |
try {
|
| 26 |
+
const r = await demoSeed(uid())
|
| 27 |
+
setMsg({ tone: 'ok', text: t('enroll.signedIn', { user: r.user_id }) })
|
| 28 |
} catch (e) {
|
| 29 |
+
setMsg({ tone: 'bad', text: localizeError(e, t, 'error.generic') })
|
| 30 |
} finally {
|
| 31 |
setBusy(null)
|
| 32 |
}
|
| 33 |
}
|
| 34 |
|
| 35 |
+
async function onRegister() {
|
| 36 |
+
if (!uid()) { setMsg({ tone: 'info', text: t('enroll.needUserId') }); return }
|
| 37 |
+
setBusy('register'); setMsg(null)
|
| 38 |
try {
|
| 39 |
if (!(await platformAuthenticatorAvailable())) {
|
| 40 |
+
setMsg({ tone: 'info', text: t('enroll.passkeyUnsupported') }); return
|
|
|
|
| 41 |
}
|
| 42 |
+
await registerPasskey(uid())
|
| 43 |
setMsg({ tone: 'ok', text: t('enroll.passkeyOk') })
|
| 44 |
} catch (e) {
|
| 45 |
if (e instanceof PasskeyUnsupported) setMsg({ tone: 'info', text: t('enroll.passkeyUnsupported') })
|
| 46 |
else if (e instanceof PasskeyCancelled) setMsg({ tone: 'info', text: t('enroll.passkeyCancelled') })
|
| 47 |
+
else setMsg({ tone: 'bad', text: localizeError(e, t, 'error.passkeyRegister') })
|
| 48 |
} finally {
|
| 49 |
setBusy(null)
|
| 50 |
}
|
| 51 |
}
|
| 52 |
|
| 53 |
+
async function onSignIn() {
|
| 54 |
+
if (!uid()) { setMsg({ tone: 'info', text: t('enroll.needUserId') }); return }
|
| 55 |
+
setBusy('signin'); setMsg(null)
|
| 56 |
+
try {
|
| 57 |
+
await authenticatePasskey(uid())
|
| 58 |
+
setMsg({ tone: 'ok', text: t('enroll.signedIn', { user: uid() }) })
|
| 59 |
+
} catch (e) {
|
| 60 |
+
if (e instanceof PasskeyUnsupported) setMsg({ tone: 'info', text: t('enroll.passkeyUnsupported') })
|
| 61 |
+
else if (e instanceof PasskeyCancelled) setMsg({ tone: 'info', text: t('enroll.passkeyCancelled') })
|
| 62 |
+
else setMsg({ tone: 'bad', text: localizeError(e, t, 'error.passkeySignin') })
|
| 63 |
+
} finally {
|
| 64 |
+
setBusy(null)
|
| 65 |
+
}
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
function onSignOut() {
|
| 69 |
+
logout()
|
| 70 |
+
setMsg({ tone: 'info', text: t('enroll.signedOut') })
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
return (
|
| 74 |
<section className="card" aria-labelledby="enroll-h">
|
| 75 |
<h2 id="enroll-h">{t('enroll.title')}</h2>
|
| 76 |
+
|
| 77 |
<Field id="e_user" label={t('enroll.userId')} value={userId}
|
| 78 |
onChange={(e) => setUserId(e.target.value)} autoComplete="username" />
|
| 79 |
+
|
| 80 |
+
<fieldset className="action-group">
|
| 81 |
+
<legend>{t('enroll.sectionDemo')}</legend>
|
| 82 |
+
<p className="hint">{t('enroll.demoHint')}</p>
|
| 83 |
<Button onClick={onDemo} disabled={busy !== null}>
|
| 84 |
{busy === 'demo' ? <Spinner label={t('pay.submitting')} /> : t('enroll.demo')}
|
| 85 |
</Button>
|
| 86 |
+
</fieldset>
|
| 87 |
+
|
| 88 |
+
<fieldset className="action-group">
|
| 89 |
+
<legend>{t('enroll.sectionPasskey')}</legend>
|
| 90 |
+
<p className="hint">{t('enroll.passkeyHint')}</p>
|
| 91 |
+
<div className="row">
|
| 92 |
+
<Button onClick={onRegister} disabled={busy !== null}>
|
| 93 |
+
{busy === 'register' ? <Spinner label={t('pay.submitting')} /> : t('enroll.registerPasskey')}
|
| 94 |
+
</Button>
|
| 95 |
+
<Button onClick={onSignIn} disabled={busy !== null}>
|
| 96 |
+
{busy === 'signin' ? <Spinner label={t('pay.submitting')} /> : t('enroll.signInPasskey')}
|
| 97 |
+
</Button>
|
| 98 |
+
</div>
|
| 99 |
+
</fieldset>
|
| 100 |
+
|
| 101 |
+
{(authed || activeUser) && (
|
| 102 |
+
<Button onClick={onSignOut} disabled={busy !== null}>{t('auth.logout')}</Button>
|
| 103 |
+
)}
|
| 104 |
+
|
| 105 |
{msg && <Callout tone={msg.tone}>{msg.text}</Callout>}
|
| 106 |
</section>
|
| 107 |
)
|
web/src/pages/NotFoundPage.test.tsx
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { describe, it, expect, vi } from 'vitest'
|
| 2 |
+
import { render, screen, fireEvent } from '@testing-library/react'
|
| 3 |
+
import { I18nProvider } from '../i18n'
|
| 4 |
+
import { NotFoundPage } from './NotFoundPage'
|
| 5 |
+
|
| 6 |
+
describe('NotFoundPage', () => {
|
| 7 |
+
it('renders a 404 with navigation back to Pay/Enroll', () => {
|
| 8 |
+
const onNav = vi.fn()
|
| 9 |
+
render(<I18nProvider initial="en"><NotFoundPage onNav={onNav} /></I18nProvider>)
|
| 10 |
+
expect(screen.getByText('Page not found')).toBeInTheDocument()
|
| 11 |
+
fireEvent.click(screen.getByRole('button', { name: 'Go to Pay' }))
|
| 12 |
+
expect(onNav).toHaveBeenCalledWith('pay')
|
| 13 |
+
})
|
| 14 |
+
})
|
web/src/pages/NotFoundPage.tsx
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useI18n } from '../i18n'
|
| 2 |
+
|
| 3 |
+
export function NotFoundPage({ onNav }: { onNav: (r: 'pay' | 'enroll') => void }) {
|
| 4 |
+
const { t } = useI18n()
|
| 5 |
+
return (
|
| 6 |
+
<section className="card" aria-labelledby="nf-h">
|
| 7 |
+
<h2 id="nf-h">{t('notFound.title')}</h2>
|
| 8 |
+
<p>{t('notFound.body')}</p>
|
| 9 |
+
<div className="row">
|
| 10 |
+
<button className="btn" onClick={() => onNav('pay')}>{t('notFound.goPay')}</button>
|
| 11 |
+
<button className="btn" onClick={() => onNav('enroll')}>{t('notFound.goEnroll')}</button>
|
| 12 |
+
</div>
|
| 13 |
+
</section>
|
| 14 |
+
)
|
| 15 |
+
}
|
web/src/pages/PayPage.test.tsx
CHANGED
|
@@ -3,53 +3,110 @@ import { render, screen, fireEvent, waitFor } from '@testing-library/react'
|
|
| 3 |
import { I18nProvider } from '../i18n'
|
| 4 |
import { PayPage } from './PayPage'
|
| 5 |
import * as payApi from '../api/payments'
|
| 6 |
-
import {
|
| 7 |
|
| 8 |
vi.mock('../api/payments')
|
| 9 |
|
| 10 |
const HOSTILE = '<img src=x onerror="window.__xss=1">'
|
|
|
|
| 11 |
function mkPayment(over: Partial<any> = {}): any {
|
| 12 |
return {
|
| 13 |
-
id: 'p1', user_id: '
|
| 14 |
amount: '150.50 SAR', country: 'SA', merchant_id: 'm', provider: 'mock',
|
| 15 |
provider_ref: 'mp_1', correlation_id: HOSTILE, consent_id: 'c', refunded_minor: 0,
|
| 16 |
requires_action_url: null, locked: true, created_at: 0, updated_at: 0, ...over,
|
| 17 |
}
|
| 18 |
}
|
| 19 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
|
| 21 |
describe('PayPage', () => {
|
| 22 |
-
beforeEach(() => {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
|
| 24 |
it('prevents duplicate submission (one create per action)', async () => {
|
|
|
|
|
|
|
| 25 |
let resolve: (v: any) => void = () => {}
|
| 26 |
const create = vi.spyOn(payApi, 'createPayment').mockImplementation(
|
| 27 |
-
|
|
|
|
| 28 |
)
|
| 29 |
-
vi.spyOn(payApi, 'getPayment').mockResolvedValue(mkPayment({ status: 'requires_customer_action' }))
|
| 30 |
renderPay()
|
| 31 |
const btn = screen.getByRole('button', { name: 'Pay' })
|
| 32 |
fireEvent.click(btn); fireEvent.click(btn); fireEvent.click(btn)
|
| 33 |
-
resolve(mkPayment({ status: 'requires_customer_action'
|
| 34 |
await waitFor(() => expect(create).toHaveBeenCalledTimes(1))
|
| 35 |
})
|
| 36 |
|
| 37 |
it('renders hostile backend text as text, not HTML (XSS regression)', async () => {
|
|
|
|
| 38 |
vi.spyOn(payApi, 'createPayment').mockResolvedValue(mkPayment())
|
| 39 |
vi.spyOn(payApi, 'getPayment').mockResolvedValue(mkPayment())
|
| 40 |
const { container } = renderPay()
|
| 41 |
fireEvent.click(screen.getByRole('button', { name: 'Pay' }))
|
| 42 |
await waitFor(() => expect(screen.getByText('Succeeded')).toBeInTheDocument())
|
| 43 |
-
expect(container.querySelector('img')).toBeNull()
|
| 44 |
-
|
|
|
|
| 45 |
})
|
| 46 |
|
| 47 |
it('distinguishes processing from success', async () => {
|
|
|
|
| 48 |
vi.spyOn(payApi, 'createPayment').mockResolvedValue(mkPayment({ status: 'processing' }))
|
| 49 |
vi.spyOn(payApi, 'getPayment').mockResolvedValue(mkPayment({ status: 'processing' }))
|
| 50 |
renderPay()
|
| 51 |
fireEvent.click(screen.getByRole('button', { name: 'Pay' }))
|
| 52 |
-
await waitFor(() => expect(screen.
|
| 53 |
-
expect(screen.queryByText('
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 54 |
})
|
| 55 |
})
|
|
|
|
| 3 |
import { I18nProvider } from '../i18n'
|
| 4 |
import { PayPage } from './PayPage'
|
| 5 |
import * as payApi from '../api/payments'
|
| 6 |
+
import { logout, setActiveUser } from '../auth/session'
|
| 7 |
|
| 8 |
vi.mock('../api/payments')
|
| 9 |
|
| 10 |
const HOSTILE = '<img src=x onerror="window.__xss=1">'
|
| 11 |
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
| 12 |
function mkPayment(over: Partial<any> = {}): any {
|
| 13 |
return {
|
| 14 |
+
id: 'p1', user_id: 'bob', status: 'succeeded', amount_minor: 15050, currency: 'SAR',
|
| 15 |
amount: '150.50 SAR', country: 'SA', merchant_id: 'm', provider: 'mock',
|
| 16 |
provider_ref: 'mp_1', correlation_id: HOSTILE, consent_id: 'c', refunded_minor: 0,
|
| 17 |
requires_action_url: null, locked: true, created_at: 0, updated_at: 0, ...over,
|
| 18 |
}
|
| 19 |
}
|
| 20 |
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
| 21 |
+
const CAPS: any = {
|
| 22 |
+
routing: { SA: { provider: 'mock', capabilities: {
|
| 23 |
+
name: 'mock', supports_cancel: true, supports_refund: true, supports_partial_refund: true,
|
| 24 |
+
countries: ['*'], currencies: ['SAR'], payment_initiation: true, sandbox: true,
|
| 25 |
+
} } },
|
| 26 |
+
default: { country: 'SA', currency: 'SAR', timezone: 'Asia/Riyadh', locales: ['ar', 'en'] },
|
| 27 |
+
non_custodial: true,
|
| 28 |
+
}
|
| 29 |
+
const renderPay = () => render(<I18nProvider initial="en"><PayPage onNavEnroll={() => {}} /></I18nProvider>)
|
| 30 |
|
| 31 |
describe('PayPage', () => {
|
| 32 |
+
beforeEach(() => {
|
| 33 |
+
vi.resetAllMocks() // clears call history AND implementations of the auto-mocked module
|
| 34 |
+
logout()
|
| 35 |
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
| 36 |
+
;(window as any).__xss = undefined
|
| 37 |
+
vi.spyOn(payApi, 'getProviders').mockResolvedValue(CAPS)
|
| 38 |
+
vi.spyOn(payApi, 'getPayment').mockResolvedValue(mkPayment({ status: 'requires_customer_action' }))
|
| 39 |
+
})
|
| 40 |
+
|
| 41 |
+
it('blocks payment when no active user and directs to Enroll', () => {
|
| 42 |
+
renderPay()
|
| 43 |
+
expect(screen.getByText(/Sign in on the Enroll tab/i)).toBeInTheDocument()
|
| 44 |
+
expect(screen.getByRole('button', { name: 'Pay' })).toBeDisabled()
|
| 45 |
+
})
|
| 46 |
+
|
| 47 |
+
it('creates a payment using the ACTIVE user (never hard-coded alice)', async () => {
|
| 48 |
+
setActiveUser({ userId: 'bob', via: 'demo' })
|
| 49 |
+
const create = vi.spyOn(payApi, 'createPayment').mockResolvedValue(mkPayment({ status: 'requires_customer_action' }))
|
| 50 |
+
renderPay()
|
| 51 |
+
fireEvent.click(screen.getByRole('button', { name: 'Pay' }))
|
| 52 |
+
await waitFor(() => expect(create).toHaveBeenCalledTimes(1))
|
| 53 |
+
expect(create.mock.calls[0][0].userId).toBe('bob')
|
| 54 |
+
expect(create.mock.calls[0][0].userId).not.toBe('alice')
|
| 55 |
+
})
|
| 56 |
|
| 57 |
it('prevents duplicate submission (one create per action)', async () => {
|
| 58 |
+
setActiveUser({ userId: 'bob', via: 'demo' })
|
| 59 |
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
| 60 |
let resolve: (v: any) => void = () => {}
|
| 61 |
const create = vi.spyOn(payApi, 'createPayment').mockImplementation(
|
| 62 |
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
| 63 |
+
() => new Promise<any>((r) => { resolve = r }),
|
| 64 |
)
|
|
|
|
| 65 |
renderPay()
|
| 66 |
const btn = screen.getByRole('button', { name: 'Pay' })
|
| 67 |
fireEvent.click(btn); fireEvent.click(btn); fireEvent.click(btn)
|
| 68 |
+
resolve(mkPayment({ status: 'requires_customer_action' }))
|
| 69 |
await waitFor(() => expect(create).toHaveBeenCalledTimes(1))
|
| 70 |
})
|
| 71 |
|
| 72 |
it('renders hostile backend text as text, not HTML (XSS regression)', async () => {
|
| 73 |
+
setActiveUser({ userId: 'bob', via: 'demo' })
|
| 74 |
vi.spyOn(payApi, 'createPayment').mockResolvedValue(mkPayment())
|
| 75 |
vi.spyOn(payApi, 'getPayment').mockResolvedValue(mkPayment())
|
| 76 |
const { container } = renderPay()
|
| 77 |
fireEvent.click(screen.getByRole('button', { name: 'Pay' }))
|
| 78 |
await waitFor(() => expect(screen.getByText('Succeeded')).toBeInTheDocument())
|
| 79 |
+
expect(container.querySelector('img')).toBeNull()
|
| 80 |
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
| 81 |
+
expect((window as any).__xss).toBeUndefined()
|
| 82 |
})
|
| 83 |
|
| 84 |
it('distinguishes processing from success', async () => {
|
| 85 |
+
setActiveUser({ userId: 'bob', via: 'demo' })
|
| 86 |
vi.spyOn(payApi, 'createPayment').mockResolvedValue(mkPayment({ status: 'processing' }))
|
| 87 |
vi.spyOn(payApi, 'getPayment').mockResolvedValue(mkPayment({ status: 'processing' }))
|
| 88 |
renderPay()
|
| 89 |
fireEvent.click(screen.getByRole('button', { name: 'Pay' }))
|
| 90 |
+
await waitFor(() => expect(screen.getAllByText('Processing').length).toBeGreaterThan(0))
|
| 91 |
+
expect(screen.queryByText('Payment completed successfully.')).toBeNull()
|
| 92 |
+
})
|
| 93 |
+
|
| 94 |
+
it('shows Cancel only in a cancellable state, not when succeeded', async () => {
|
| 95 |
+
setActiveUser({ userId: 'bob', via: 'demo' })
|
| 96 |
+
vi.spyOn(payApi, 'createPayment').mockResolvedValue(mkPayment({ status: 'requires_customer_action' }))
|
| 97 |
+
vi.spyOn(payApi, 'getPayment').mockResolvedValue(mkPayment({ status: 'requires_customer_action' }))
|
| 98 |
+
renderPay()
|
| 99 |
+
fireEvent.click(screen.getByRole('button', { name: 'Pay' }))
|
| 100 |
+
await waitFor(() => expect(screen.getByRole('button', { name: 'Cancel payment' })).toBeInTheDocument())
|
| 101 |
+
})
|
| 102 |
+
|
| 103 |
+
it('hides Cancel when terminal (succeeded)', async () => {
|
| 104 |
+
setActiveUser({ userId: 'bob', via: 'demo' })
|
| 105 |
+
vi.spyOn(payApi, 'createPayment').mockResolvedValue(mkPayment({ status: 'succeeded' }))
|
| 106 |
+
vi.spyOn(payApi, 'getPayment').mockResolvedValue(mkPayment({ status: 'succeeded' }))
|
| 107 |
+
renderPay()
|
| 108 |
+
fireEvent.click(screen.getByRole('button', { name: 'Pay' }))
|
| 109 |
+
await waitFor(() => expect(screen.getByText('Succeeded')).toBeInTheDocument())
|
| 110 |
+
expect(screen.queryByRole('button', { name: 'Cancel payment' })).toBeNull()
|
| 111 |
})
|
| 112 |
})
|
web/src/pages/PayPage.tsx
CHANGED
|
@@ -1,15 +1,22 @@
|
|
| 1 |
-
import { useMemo, useRef, useState } from 'react'
|
| 2 |
-
import {
|
|
|
|
|
|
|
| 3 |
import { usePaymentPolling } from '../payments/usePaymentPolling'
|
| 4 |
-
import {
|
|
|
|
|
|
|
| 5 |
import { formatMoney, toMinor } from '../payments/money'
|
| 6 |
import { formatDateTime } from '../utils/format'
|
| 7 |
import { isSafeExternalUrl } from '../utils/url'
|
|
|
|
|
|
|
| 8 |
import { useSession } from '../hooks/useSession'
|
| 9 |
import { useI18n } from '../i18n'
|
| 10 |
import { Button, Field, StatusBadge, Callout, Spinner } from '../components/ui'
|
| 11 |
-
import {
|
| 12 |
-
import type {
|
|
|
|
| 13 |
|
| 14 |
const DEMO_IBAN = 'SA0380000000608010167519'
|
| 15 |
const ALLOWED_ACTION_HOSTS = ['mock-bank.example'] // real provider hosts added via config
|
|
@@ -20,59 +27,87 @@ function newIdemKey(): string {
|
|
| 20 |
: `idem-${Date.now()}-${Math.random().toString(16).slice(2)}`
|
| 21 |
}
|
| 22 |
|
| 23 |
-
export function PayPage() {
|
| 24 |
const { t, locale } = useI18n()
|
| 25 |
-
const {
|
| 26 |
const [amount, setAmount] = useState('150.50')
|
| 27 |
const [merchant, setMerchant] = useState('Blue Bottle')
|
| 28 |
const [iban, setIban] = useState(DEMO_IBAN)
|
|
|
|
| 29 |
const [created, setCreated] = useState<PaymentView | null>(null)
|
| 30 |
const [submitting, setSubmitting] = useState(false)
|
|
|
|
| 31 |
const [error, setError] = useState<string | null>(null)
|
| 32 |
-
|
|
|
|
| 33 |
const idemRef = useRef<string>(newIdemKey())
|
| 34 |
const submittingRef = useRef(false)
|
| 35 |
|
| 36 |
const { payment: polled } = usePaymentPolling(created?.id ?? null)
|
| 37 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 43 |
|
| 44 |
async function onPay() {
|
| 45 |
if (submittingRef.current || created) return // duplicate-submission guard
|
| 46 |
-
if (
|
|
|
|
| 47 |
submittingRef.current = true
|
| 48 |
setSubmitting(true)
|
| 49 |
setError(null)
|
| 50 |
try {
|
| 51 |
const p = await createPayment({
|
| 52 |
-
userId:
|
| 53 |
amountMinor: toMinor(amount, 'SAR'),
|
| 54 |
-
merchantId: merchant.trim()
|
| 55 |
payeeIban: iban.trim(),
|
|
|
|
| 56 |
country: 'SA',
|
| 57 |
currency: 'SAR',
|
| 58 |
idempotencyKey: idemRef.current, // same key on retry -> backend idempotency
|
| 59 |
})
|
| 60 |
setCreated(p)
|
| 61 |
} catch (e) {
|
| 62 |
-
|
| 63 |
-
setError(e instanceof ApiRequestError ? e.message : t('error.generic'))
|
| 64 |
} finally {
|
| 65 |
submittingRef.current = false
|
| 66 |
setSubmitting(false)
|
| 67 |
}
|
| 68 |
}
|
| 69 |
|
| 70 |
-
|
|
|
|
| 71 |
if (!created) return
|
|
|
|
| 72 |
try {
|
| 73 |
-
await
|
| 74 |
-
|
| 75 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 76 |
}
|
| 77 |
}
|
| 78 |
|
|
@@ -84,21 +119,34 @@ export function PayPage() {
|
|
| 84 |
|
| 85 |
const actionUrl = payment?.requires_action_url ?? null
|
| 86 |
const actionUrlSafe = actionUrl ? isSafeExternalUrl(actionUrl, ALLOWED_ACTION_HOSTS) : false
|
|
|
|
|
|
|
|
|
|
|
|
|
| 87 |
|
| 88 |
return (
|
| 89 |
<section className="card" aria-labelledby="pay-h">
|
| 90 |
<h2 id="pay-h">{t('pay.title')}</h2>
|
| 91 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 92 |
|
| 93 |
{!created && (
|
| 94 |
<>
|
| 95 |
<Field id="p_amt" label={t('pay.amount')} value={amount} inputMode="decimal"
|
| 96 |
-
onChange={(e) => setAmount(e.target.value)} error={
|
| 97 |
<Field id="p_merch" label={t('pay.merchant')} value={merchant}
|
| 98 |
-
onChange={(e) => setMerchant(e.target.value)} />
|
| 99 |
<Field id="p_iban" label={t('pay.iban')} value={iban}
|
| 100 |
-
onChange={(e) => setIban(e.target.value)} />
|
| 101 |
-
<
|
|
|
|
|
|
|
| 102 |
{submitting ? <Spinner label={t('pay.submitting')} /> : t('pay.submit')}
|
| 103 |
</Button>
|
| 104 |
</>
|
|
@@ -112,48 +160,105 @@ export function PayPage() {
|
|
| 112 |
<strong>{formatMoney(payment.amount_minor, payment.currency, locale)}</strong>
|
| 113 |
<StatusBadge status={payment.status} />
|
| 114 |
</div>
|
|
|
|
| 115 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 116 |
{payment.status === 'requires_customer_action' && (
|
| 117 |
<Callout tone="warn">
|
| 118 |
<p>{t('pay.customerAction')}</p>
|
|
|
|
| 119 |
<div className="row">
|
| 120 |
{actionUrlSafe && actionUrl && (
|
| 121 |
<a className="btn" href={actionUrl} target="_blank" rel="noopener noreferrer">
|
| 122 |
{t('pay.openBank')}
|
| 123 |
</a>
|
| 124 |
)}
|
| 125 |
-
<Button onClick={
|
|
|
|
|
|
|
|
|
|
| 126 |
</div>
|
| 127 |
</Callout>
|
| 128 |
)}
|
| 129 |
|
| 130 |
{(payment.status === 'processing' || payment.status === 'authorizing') && (
|
| 131 |
-
<Callout tone="warn">
|
| 132 |
-
<Spinner label={t('pay.processing')} />
|
| 133 |
-
</Callout>
|
| 134 |
)}
|
| 135 |
|
|
|
|
|
|
|
|
|
|
| 136 |
{payment.refunded_minor > 0 && (
|
| 137 |
-
<
|
| 138 |
-
{t('pay.
|
| 139 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 140 |
)}
|
| 141 |
|
| 142 |
-
{
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 143 |
|
| 144 |
{isTerminal(payment.status) && (
|
| 145 |
<Button onClick={reset}>{t('pay.newPayment')}</Button>
|
| 146 |
)}
|
| 147 |
|
|
|
|
| 148 |
<details className="support">
|
| 149 |
-
<summary>{t('
|
| 150 |
-
<
|
| 151 |
-
{t('
|
| 152 |
-
<
|
| 153 |
-
{t('
|
| 154 |
-
<
|
| 155 |
-
{formatDateTime(payment.
|
| 156 |
-
|
|
|
|
| 157 |
</details>
|
| 158 |
</div>
|
| 159 |
)}
|
|
|
|
| 1 |
+
import { useEffect, useMemo, useRef, useState } from 'react'
|
| 2 |
+
import {
|
| 3 |
+
cancelPayment, createPayment, getPayment, getProviders, mockAdvance, refundPayment,
|
| 4 |
+
} from '../api/payments'
|
| 5 |
import { usePaymentPolling } from '../payments/usePaymentPolling'
|
| 6 |
+
import {
|
| 7 |
+
isCancellable, isRefundable, isSuccess, isTerminal, statusHelpKey,
|
| 8 |
+
} from '../payments/status'
|
| 9 |
import { formatMoney, toMinor } from '../payments/money'
|
| 10 |
import { formatDateTime } from '../utils/format'
|
| 11 |
import { isSafeExternalUrl } from '../utils/url'
|
| 12 |
+
import { validateAmount, validateIban, validateMerchant } from '../utils/validate'
|
| 13 |
+
import { localizeError } from '../utils/errors'
|
| 14 |
import { useSession } from '../hooks/useSession'
|
| 15 |
import { useI18n } from '../i18n'
|
| 16 |
import { Button, Field, StatusBadge, Callout, Spinner } from '../components/ui'
|
| 17 |
+
import { getLastRequestId } from '../api/client'
|
| 18 |
+
import type { MessageKey } from '../i18n/en'
|
| 19 |
+
import type { PaymentView, ProviderCapabilities } from '../types'
|
| 20 |
|
| 21 |
const DEMO_IBAN = 'SA0380000000608010167519'
|
| 22 |
const ALLOWED_ACTION_HOSTS = ['mock-bank.example'] // real provider hosts added via config
|
|
|
|
| 27 |
: `idem-${Date.now()}-${Math.random().toString(16).slice(2)}`
|
| 28 |
}
|
| 29 |
|
| 30 |
+
export function PayPage({ onNavEnroll }: { onNavEnroll: () => void }) {
|
| 31 |
const { t, locale } = useI18n()
|
| 32 |
+
const { activeUser } = useSession()
|
| 33 |
const [amount, setAmount] = useState('150.50')
|
| 34 |
const [merchant, setMerchant] = useState('Blue Bottle')
|
| 35 |
const [iban, setIban] = useState(DEMO_IBAN)
|
| 36 |
+
const [reference, setReference] = useState('')
|
| 37 |
const [created, setCreated] = useState<PaymentView | null>(null)
|
| 38 |
const [submitting, setSubmitting] = useState(false)
|
| 39 |
+
const [busy, setBusy] = useState<string | null>(null)
|
| 40 |
const [error, setError] = useState<string | null>(null)
|
| 41 |
+
const [caps, setCaps] = useState<ProviderCapabilities | null>(null)
|
| 42 |
+
const [mockActive, setMockActive] = useState(false)
|
| 43 |
const idemRef = useRef<string>(newIdemKey())
|
| 44 |
const submittingRef = useRef(false)
|
| 45 |
|
| 46 |
const { payment: polled } = usePaymentPolling(created?.id ?? null)
|
| 47 |
+
// Prefer whichever snapshot is newer so manual actions reflect immediately.
|
| 48 |
+
const payment = useMemo<PaymentView | null>(() => {
|
| 49 |
+
if (polled && created) return polled.updated_at >= created.updated_at ? polled : created
|
| 50 |
+
return polled ?? created
|
| 51 |
+
}, [polled, created])
|
| 52 |
|
| 53 |
+
useEffect(() => {
|
| 54 |
+
let live = true
|
| 55 |
+
getProviders().then((p) => {
|
| 56 |
+
if (!live) return
|
| 57 |
+
const sa = p.routing?.SA
|
| 58 |
+
setCaps(sa?.capabilities ?? null)
|
| 59 |
+
setMockActive(sa?.provider === 'mock')
|
| 60 |
+
}).catch(() => {})
|
| 61 |
+
return () => { live = false }
|
| 62 |
+
}, [])
|
| 63 |
+
|
| 64 |
+
const amountErr = useMemo(() => validateAmount(amount), [amount])
|
| 65 |
+
const merchantErr = useMemo(() => validateMerchant(merchant), [merchant])
|
| 66 |
+
const ibanErr = useMemo(() => validateIban(iban), [iban])
|
| 67 |
+
const formInvalid = !!(amountErr || merchantErr || ibanErr)
|
| 68 |
+
const err = (k?: MessageKey) => (k ? t(k) : undefined)
|
| 69 |
|
| 70 |
async function onPay() {
|
| 71 |
if (submittingRef.current || created) return // duplicate-submission guard
|
| 72 |
+
if (!activeUser) { setError(t('error.noActiveUser')); return }
|
| 73 |
+
if (formInvalid) return
|
| 74 |
submittingRef.current = true
|
| 75 |
setSubmitting(true)
|
| 76 |
setError(null)
|
| 77 |
try {
|
| 78 |
const p = await createPayment({
|
| 79 |
+
userId: activeUser.userId, // active user — never hard-coded
|
| 80 |
amountMinor: toMinor(amount, 'SAR'),
|
| 81 |
+
merchantId: merchant.trim(),
|
| 82 |
payeeIban: iban.trim(),
|
| 83 |
+
description: reference.trim(),
|
| 84 |
country: 'SA',
|
| 85 |
currency: 'SAR',
|
| 86 |
idempotencyKey: idemRef.current, // same key on retry -> backend idempotency
|
| 87 |
})
|
| 88 |
setCreated(p)
|
| 89 |
} catch (e) {
|
| 90 |
+
setError(localizeError(e, t, 'error.paymentCreate'))
|
|
|
|
| 91 |
} finally {
|
| 92 |
submittingRef.current = false
|
| 93 |
setSubmitting(false)
|
| 94 |
}
|
| 95 |
}
|
| 96 |
|
| 97 |
+
// Runs an action against the real backend, then refreshes to the authoritative status.
|
| 98 |
+
async function run(label: string, fn: () => Promise<unknown>, fallback: MessageKey) {
|
| 99 |
if (!created) return
|
| 100 |
+
setBusy(label); setError(null)
|
| 101 |
try {
|
| 102 |
+
const r = await fn()
|
| 103 |
+
const fresh = r && typeof r === 'object' && 'status' in r
|
| 104 |
+
? (r as PaymentView)
|
| 105 |
+
: await getPayment(created.id)
|
| 106 |
+
setCreated(fresh)
|
| 107 |
+
} catch (e) {
|
| 108 |
+
setError(localizeError(e, t, fallback))
|
| 109 |
+
} finally {
|
| 110 |
+
setBusy(null)
|
| 111 |
}
|
| 112 |
}
|
| 113 |
|
|
|
|
| 119 |
|
| 120 |
const actionUrl = payment?.requires_action_url ?? null
|
| 121 |
const actionUrlSafe = actionUrl ? isSafeExternalUrl(actionUrl, ALLOWED_ACTION_HOSTS) : false
|
| 122 |
+
const canCancel = payment && isCancellable(payment.status) && caps?.supports_cancel !== false
|
| 123 |
+
const canRefund = payment && isRefundable(payment.status) && caps?.supports_refund !== false
|
| 124 |
+
const remaining = payment ? payment.amount_minor - payment.refunded_minor : 0
|
| 125 |
+
const showScenarios = mockActive && payment && !isTerminal(payment.status)
|
| 126 |
|
| 127 |
return (
|
| 128 |
<section className="card" aria-labelledby="pay-h">
|
| 129 |
<h2 id="pay-h">{t('pay.title')}</h2>
|
| 130 |
+
|
| 131 |
+
{!activeUser && (
|
| 132 |
+
<Callout tone="info">
|
| 133 |
+
{t('pay.needLogin')}{' '}
|
| 134 |
+
<button className="link" onClick={onNavEnroll}>{t('nav.enroll')}</button>
|
| 135 |
+
</Callout>
|
| 136 |
+
)}
|
| 137 |
+
{activeUser && <p className="paying-as">{t('pay.payingAs', { user: activeUser.userId })}</p>}
|
| 138 |
|
| 139 |
{!created && (
|
| 140 |
<>
|
| 141 |
<Field id="p_amt" label={t('pay.amount')} value={amount} inputMode="decimal"
|
| 142 |
+
onChange={(e) => setAmount(e.target.value)} error={err(amountErr)} />
|
| 143 |
<Field id="p_merch" label={t('pay.merchant')} value={merchant}
|
| 144 |
+
onChange={(e) => setMerchant(e.target.value)} error={err(merchantErr)} />
|
| 145 |
<Field id="p_iban" label={t('pay.iban')} value={iban}
|
| 146 |
+
onChange={(e) => setIban(e.target.value)} error={err(ibanErr)} />
|
| 147 |
+
<Field id="p_ref" label={t('pay.description')} value={reference}
|
| 148 |
+
onChange={(e) => setReference(e.target.value)} />
|
| 149 |
+
<Button onClick={onPay} disabled={!activeUser || submitting || formInvalid} aria-busy={submitting}>
|
| 150 |
{submitting ? <Spinner label={t('pay.submitting')} /> : t('pay.submit')}
|
| 151 |
</Button>
|
| 152 |
</>
|
|
|
|
| 160 |
<strong>{formatMoney(payment.amount_minor, payment.currency, locale)}</strong>
|
| 161 |
<StatusBadge status={payment.status} />
|
| 162 |
</div>
|
| 163 |
+
<p className="status-help">{t(statusHelpKey(payment.status) as MessageKey)}</p>
|
| 164 |
|
| 165 |
+
{/* Payment summary */}
|
| 166 |
+
<dl className="summary-grid">
|
| 167 |
+
<dt>{t('pay.merchant')}</dt><dd>{merchant}</dd>
|
| 168 |
+
<dt>{t('pay.iban')}</dt><dd className="mono" dir="ltr">{iban}</dd>
|
| 169 |
+
{activeUser && (<><dt>{t('nav.enroll')}</dt><dd>{activeUser.userId}</dd></>)}
|
| 170 |
+
</dl>
|
| 171 |
+
|
| 172 |
+
{/* Customer-action (simulated bank authorization) */}
|
| 173 |
{payment.status === 'requires_customer_action' && (
|
| 174 |
<Callout tone="warn">
|
| 175 |
<p>{t('pay.customerAction')}</p>
|
| 176 |
+
<p className="hint">{t('pay.mockBankInfo')}</p>
|
| 177 |
<div className="row">
|
| 178 |
{actionUrlSafe && actionUrl && (
|
| 179 |
<a className="btn" href={actionUrl} target="_blank" rel="noopener noreferrer">
|
| 180 |
{t('pay.openBank')}
|
| 181 |
</a>
|
| 182 |
)}
|
| 183 |
+
<Button onClick={() => run('settle', () => mockAdvance(payment.id, 'SETTLED'), 'error.generic')}
|
| 184 |
+
disabled={busy !== null}>
|
| 185 |
+
{t('pay.simulateSettle')}
|
| 186 |
+
</Button>
|
| 187 |
</div>
|
| 188 |
</Callout>
|
| 189 |
)}
|
| 190 |
|
| 191 |
{(payment.status === 'processing' || payment.status === 'authorizing') && (
|
| 192 |
+
<Callout tone="warn"><Spinner label={t('pay.processing')} /></Callout>
|
|
|
|
|
|
|
| 193 |
)}
|
| 194 |
|
| 195 |
+
{isSuccess(payment.status) && <Callout tone="ok">{t('pay.succeeded')}</Callout>}
|
| 196 |
+
|
| 197 |
+
{/* Refund visibility */}
|
| 198 |
{payment.refunded_minor > 0 && (
|
| 199 |
+
<dl className="summary-grid">
|
| 200 |
+
<dt>{t('pay.original')}</dt>
|
| 201 |
+
<dd>{formatMoney(payment.amount_minor, payment.currency, locale)}</dd>
|
| 202 |
+
<dt>{t('pay.refundedAmount')}</dt>
|
| 203 |
+
<dd>{formatMoney(payment.refunded_minor, payment.currency, locale)}</dd>
|
| 204 |
+
<dt>{t('pay.remaining')}</dt>
|
| 205 |
+
<dd>{formatMoney(remaining, payment.currency, locale)}</dd>
|
| 206 |
+
</dl>
|
| 207 |
+
)}
|
| 208 |
+
|
| 209 |
+
{/* Cancel — only when the state + provider allow it */}
|
| 210 |
+
{canCancel && (
|
| 211 |
+
<Button onClick={() => run('cancel', () => cancelPayment(payment.id), 'error.cancel')}
|
| 212 |
+
disabled={busy !== null}>
|
| 213 |
+
{busy === 'cancel' ? <Spinner label={t('pay.submitting')} /> : t('pay.cancel')}
|
| 214 |
+
</Button>
|
| 215 |
)}
|
| 216 |
|
| 217 |
+
{/* Demo refund actions — use the REAL Payment Core refund API */}
|
| 218 |
+
{canRefund && remaining > 0 && (
|
| 219 |
+
<div className="row">
|
| 220 |
+
<Button onClick={() => run('prefund', () => refundPayment(payment.id, Math.floor(remaining / 2), 'demo'), 'error.refund')}
|
| 221 |
+
disabled={busy !== null}>
|
| 222 |
+
{t('scenario.partialRefund')}
|
| 223 |
+
</Button>
|
| 224 |
+
<Button onClick={() => run('frefund', () => refundPayment(payment.id, undefined, 'demo'), 'error.refund')}
|
| 225 |
+
disabled={busy !== null}>
|
| 226 |
+
{t('scenario.fullRefund')}
|
| 227 |
+
</Button>
|
| 228 |
+
</div>
|
| 229 |
+
)}
|
| 230 |
+
|
| 231 |
+
{/* Demo scenario selector — mock provider only, real backend transitions */}
|
| 232 |
+
{showScenarios && (
|
| 233 |
+
<details className="scenario">
|
| 234 |
+
<summary>{t('scenario.title')}</summary>
|
| 235 |
+
<p className="hint">{t('scenario.hint')}</p>
|
| 236 |
+
<div className="row wrap">
|
| 237 |
+
<Button onClick={() => run('s', () => mockAdvance(payment.id, 'AUTH_IN_PROGRESS'), 'error.generic')} disabled={busy !== null}>{t('scenario.authorizing')}</Button>
|
| 238 |
+
<Button onClick={() => run('s', () => mockAdvance(payment.id, 'SETTLING'), 'error.generic')} disabled={busy !== null}>{t('scenario.processing')}</Button>
|
| 239 |
+
<Button onClick={() => run('s', () => mockAdvance(payment.id, 'SETTLED'), 'error.generic')} disabled={busy !== null}>{t('scenario.approve')}</Button>
|
| 240 |
+
<Button onClick={() => run('s', () => mockAdvance(payment.id, 'DECLINED'), 'error.generic')} disabled={busy !== null}>{t('scenario.decline')}</Button>
|
| 241 |
+
<Button onClick={() => run('s', () => mockAdvance(payment.id, 'TIMED_OUT'), 'error.generic')} disabled={busy !== null}>{t('scenario.expire')}</Button>
|
| 242 |
+
<Button onClick={() => run('s', () => mockAdvance(payment.id, 'VOIDED'), 'error.generic')} disabled={busy !== null}>{t('scenario.cancelProvider')}</Button>
|
| 243 |
+
</div>
|
| 244 |
+
</details>
|
| 245 |
+
)}
|
| 246 |
|
| 247 |
{isTerminal(payment.status) && (
|
| 248 |
<Button onClick={reset}>{t('pay.newPayment')}</Button>
|
| 249 |
)}
|
| 250 |
|
| 251 |
+
{/* Technical / support details */}
|
| 252 |
<details className="support">
|
| 253 |
+
<summary>{t('tech.title')}</summary>
|
| 254 |
+
<dl className="tech-grid mono" dir="ltr">
|
| 255 |
+
<dt>{t('tech.paymentId')}</dt><dd>{payment.id}</dd>
|
| 256 |
+
<dt>{t('tech.providerRef')}</dt><dd>{payment.provider_ref ?? '—'}</dd>
|
| 257 |
+
<dt>{t('tech.correlationId')}</dt><dd>{payment.correlation_id}</dd>
|
| 258 |
+
<dt>{t('tech.requestId')}</dt><dd>{getLastRequestId() ?? '—'}</dd>
|
| 259 |
+
<dt>{t('tech.created')}</dt><dd>{formatDateTime(payment.created_at, locale)}</dd>
|
| 260 |
+
<dt>{t('tech.updated')}</dt><dd>{formatDateTime(payment.updated_at, locale)}</dd>
|
| 261 |
+
</dl>
|
| 262 |
</details>
|
| 263 |
</div>
|
| 264 |
)}
|
web/src/payments/status.test.ts
CHANGED
|
@@ -1,5 +1,8 @@
|
|
| 1 |
import { describe, it, expect } from 'vitest'
|
| 2 |
-
import {
|
|
|
|
|
|
|
|
|
|
| 3 |
|
| 4 |
describe('payment status presentation', () => {
|
| 5 |
it('terminal states', () => {
|
|
@@ -17,4 +20,19 @@ describe('payment status presentation', () => {
|
|
| 17 |
expect(statusTone('failed')).toBe('bad')
|
| 18 |
expect(statusTone('processing')).toBe('warn')
|
| 19 |
})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
})
|
|
|
|
| 1 |
import { describe, it, expect } from 'vitest'
|
| 2 |
+
import {
|
| 3 |
+
isTerminal, isSuccess, statusTone, isCancellable, isRefundable, statusHelpKey,
|
| 4 |
+
} from './status'
|
| 5 |
+
import { PAYMENT_STATUSES } from '../types'
|
| 6 |
|
| 7 |
describe('payment status presentation', () => {
|
| 8 |
it('terminal states', () => {
|
|
|
|
| 20 |
expect(statusTone('failed')).toBe('bad')
|
| 21 |
expect(statusTone('processing')).toBe('warn')
|
| 22 |
})
|
| 23 |
+
it('cancellable states mirror the backend (created/req-action/authorizing only)', () => {
|
| 24 |
+
for (const s of ['created', 'requires_customer_action', 'authorizing'] as const)
|
| 25 |
+
expect(isCancellable(s)).toBe(true)
|
| 26 |
+
for (const s of ['processing', 'succeeded', 'failed', 'cancelled', 'refunded', 'expired', 'partially_refunded'] as const)
|
| 27 |
+
expect(isCancellable(s)).toBe(false)
|
| 28 |
+
})
|
| 29 |
+
it('refundable states are succeeded / partially_refunded', () => {
|
| 30 |
+
expect(isRefundable('succeeded')).toBe(true)
|
| 31 |
+
expect(isRefundable('partially_refunded')).toBe(true)
|
| 32 |
+
expect(isRefundable('processing')).toBe(false)
|
| 33 |
+
expect(isRefundable('failed')).toBe(false)
|
| 34 |
+
})
|
| 35 |
+
it('every status has a help key', () => {
|
| 36 |
+
for (const s of PAYMENT_STATUSES) expect(statusHelpKey(s)).toBe(`statusHelp.${s}`)
|
| 37 |
+
})
|
| 38 |
})
|
web/src/payments/status.ts
CHANGED
|
@@ -37,7 +37,28 @@ export function statusKey(status: PaymentStatus): string {
|
|
| 37 |
return `status.${status}`
|
| 38 |
}
|
| 39 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 40 |
/** "processing" is distinct from "success" — never conflate them in the UI. */
|
| 41 |
export function isSuccess(status: PaymentStatus): boolean {
|
| 42 |
return status === 'succeeded'
|
| 43 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
return `status.${status}`
|
| 38 |
}
|
| 39 |
|
| 40 |
+
/** i18n key for a one-line explanation of the status (localized). */
|
| 41 |
+
export function statusHelpKey(status: PaymentStatus): string {
|
| 42 |
+
return `statusHelp.${status}`
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
/** "processing" is distinct from "success" — never conflate them in the UI. */
|
| 46 |
export function isSuccess(status: PaymentStatus): boolean {
|
| 47 |
return status === 'succeeded'
|
| 48 |
}
|
| 49 |
+
|
| 50 |
+
// A payment is cancellable only from these states — mirrors the backend state
|
| 51 |
+
// machine (status.py): created / requires_customer_action / authorizing can move to
|
| 52 |
+
// `cancelled`; `processing` cannot. The UI additionally checks provider capability.
|
| 53 |
+
const CANCELLABLE: ReadonlySet<PaymentStatus> = new Set<PaymentStatus>([
|
| 54 |
+
'created', 'requires_customer_action', 'authorizing',
|
| 55 |
+
])
|
| 56 |
+
|
| 57 |
+
export function isCancellable(status: PaymentStatus): boolean {
|
| 58 |
+
return CANCELLABLE.has(status)
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
/** Refundable states — mirrors the backend (succeeded / partially_refunded). */
|
| 62 |
+
export function isRefundable(status: PaymentStatus): boolean {
|
| 63 |
+
return status === 'succeeded' || status === 'partially_refunded'
|
| 64 |
+
}
|
web/src/styles.css
CHANGED
|
@@ -49,4 +49,46 @@ h2 { margin: 0 0 12px; font-size: 18px; }
|
|
| 49 |
.spin { width: 14px; height: 14px; border: 2px solid var(--muted); border-top-color: transparent;
|
| 50 |
border-radius: 50%; animation: sp .8s linear infinite; }
|
| 51 |
@keyframes sp { to { transform: rotate(360deg); } }
|
| 52 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 49 |
.spin { width: 14px; height: 14px; border: 2px solid var(--muted); border-top-color: transparent;
|
| 50 |
border-radius: 50%; animation: sp .8s linear infinite; }
|
| 51 |
@keyframes sp { to { transform: rotate(360deg); } }
|
| 52 |
+
|
| 53 |
+
/* Header active user + language */
|
| 54 |
+
.topbar-right { display: flex; align-items: center; gap: 10px; }
|
| 55 |
+
.active-user { font-size: 12.5px; color: var(--muted); max-width: 160px; overflow: hidden;
|
| 56 |
+
text-overflow: ellipsis; white-space: nowrap; }
|
| 57 |
+
|
| 58 |
+
/* Demo banner */
|
| 59 |
+
.demo-banner { margin: 10px 0 0; padding: 8px 12px; border-radius: 10px; font-size: 12.5px;
|
| 60 |
+
color: var(--warn); background: color-mix(in srgb, var(--warn) 12%, transparent);
|
| 61 |
+
border: 1px solid color-mix(in srgb, var(--warn) 40%, transparent); }
|
| 62 |
+
|
| 63 |
+
/* Enroll action groups */
|
| 64 |
+
.action-group { border: 1px solid var(--border); border-radius: 12px; padding: 12px 14px; margin: 12px 0; }
|
| 65 |
+
.action-group legend { color: var(--text); font-weight: 600; font-size: 13.5px; padding: 0 6px; }
|
| 66 |
+
.hint { color: var(--muted); font-size: 12.5px; margin: 2px 0 8px; }
|
| 67 |
+
.paying-as { color: var(--muted); font-size: 13px; margin: 4px 0 10px; }
|
| 68 |
+
.status-help { color: var(--muted); font-size: 13px; margin: 6px 0 10px; }
|
| 69 |
+
|
| 70 |
+
/* Definition grids (summary / technical / environment) */
|
| 71 |
+
.summary-grid, .tech-grid, .env-grid { display: grid; grid-template-columns: max-content 1fr;
|
| 72 |
+
gap: 4px 14px; margin: 10px 0; font-size: 13px; }
|
| 73 |
+
.summary-grid dt, .tech-grid dt, .env-grid dt { color: var(--muted); }
|
| 74 |
+
.summary-grid dd, .tech-grid dd, .env-grid dd { margin: 0; }
|
| 75 |
+
|
| 76 |
+
/* Scenario selector */
|
| 77 |
+
.scenario { margin-top: 12px; padding: 4px 0; }
|
| 78 |
+
.scenario summary { cursor: pointer; color: var(--warn); font-size: 13px; }
|
| 79 |
+
.row.wrap { flex-wrap: wrap; }
|
| 80 |
+
|
| 81 |
+
/* Inline link button (e.g. "go to Enroll") */
|
| 82 |
+
.link { background: none; border: none; color: var(--primary); text-decoration: underline;
|
| 83 |
+
cursor: pointer; font: inherit; padding: 0; }
|
| 84 |
+
|
| 85 |
+
/* Footer */
|
| 86 |
+
.app-footer { margin-top: 20px; padding-top: 12px; border-top: 1px solid var(--border); color: var(--muted); }
|
| 87 |
+
.env-panel summary { cursor: pointer; font-size: 13px; }
|
| 88 |
+
.version-line { font-size: 11.5px; color: var(--muted); margin-top: 8px; }
|
| 89 |
+
|
| 90 |
+
@media (max-width: 560px) {
|
| 91 |
+
.app { padding: 10px; }
|
| 92 |
+
.topbar { flex-wrap: wrap; }
|
| 93 |
+
.active-user { max-width: 110px; }
|
| 94 |
+
}
|
web/src/test/setup.ts
CHANGED
|
@@ -1 +1,7 @@
|
|
| 1 |
import '@testing-library/jest-dom/vitest'
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
import '@testing-library/jest-dom/vitest'
|
| 2 |
+
import { afterEach } from 'vitest'
|
| 3 |
+
import { cleanup } from '@testing-library/react'
|
| 4 |
+
|
| 5 |
+
// Unmount React trees between tests so components (and their polling timers /
|
| 6 |
+
// event handlers) from one test never leak into the next.
|
| 7 |
+
afterEach(() => cleanup())
|
web/src/types/index.ts
CHANGED
|
@@ -42,10 +42,32 @@ export interface EnrollResponse {
|
|
| 42 |
reason?: string
|
| 43 |
}
|
| 44 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 45 |
export interface ProvidersResponse {
|
| 46 |
default: { country: string; currency: string; timezone: string; locales: string[] }
|
| 47 |
non_custodial: boolean
|
| 48 |
-
routing: Record<string, { provider: string; capabilities?:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 49 |
}
|
| 50 |
|
| 51 |
export interface ApiError {
|
|
|
|
| 42 |
reason?: string
|
| 43 |
}
|
| 44 |
|
| 45 |
+
export interface ProviderCapabilities {
|
| 46 |
+
name: string
|
| 47 |
+
countries: string[]
|
| 48 |
+
currencies: string[]
|
| 49 |
+
supports_cancel: boolean
|
| 50 |
+
supports_refund: boolean
|
| 51 |
+
supports_partial_refund: boolean
|
| 52 |
+
payment_initiation: boolean
|
| 53 |
+
sandbox: boolean
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
export interface ProvidersResponse {
|
| 57 |
default: { country: string; currency: string; timezone: string; locales: string[] }
|
| 58 |
non_custodial: boolean
|
| 59 |
+
routing: Record<string, { provider: string; capabilities?: ProviderCapabilities; status?: string }>
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
export interface VersionInfo {
|
| 63 |
+
app_version: string
|
| 64 |
+
commit: string
|
| 65 |
+
build_time: string | null
|
| 66 |
+
frontend: string
|
| 67 |
+
backend: string
|
| 68 |
+
ui_mode: 'react' | 'legacy'
|
| 69 |
+
provider_mode: string
|
| 70 |
+
non_custodial: boolean
|
| 71 |
}
|
| 72 |
|
| 73 |
export interface ApiError {
|
web/src/utils/errors.test.ts
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { describe, it, expect } from 'vitest'
|
| 2 |
+
import { localizeError } from './errors'
|
| 3 |
+
import { ApiRequestError } from '../api/client'
|
| 4 |
+
import type { MessageKey } from '../i18n/en'
|
| 5 |
+
|
| 6 |
+
// Echo translator: returns the key (with vars) so we can assert which key was chosen.
|
| 7 |
+
const t = ((key: MessageKey, vars?: Record<string, string | number>) =>
|
| 8 |
+
vars ? `${key}:${JSON.stringify(vars)}` : key) as (
|
| 9 |
+
key: MessageKey, vars?: Record<string, string | number>,
|
| 10 |
+
) => string
|
| 11 |
+
|
| 12 |
+
describe('localizeError', () => {
|
| 13 |
+
it('maps network error to error.network', () => {
|
| 14 |
+
const e = new ApiRequestError({ status: 0, message: 'network error' })
|
| 15 |
+
expect(localizeError(e, t, 'error.paymentCreate')).toBe('error.network')
|
| 16 |
+
})
|
| 17 |
+
|
| 18 |
+
it('maps timeout to error.timeout', () => {
|
| 19 |
+
const e = new ApiRequestError({ status: 0, message: 'request timed out' })
|
| 20 |
+
expect(localizeError(e, t, 'error.paymentCreate')).toBe('error.timeout')
|
| 21 |
+
})
|
| 22 |
+
|
| 23 |
+
it('uses localized fallback + request id, NOT raw backend text', () => {
|
| 24 |
+
const e = new ApiRequestError({ status: 422, message: 'cannot cancel a succeeded payment', requestId: 'req1' })
|
| 25 |
+
const out = localizeError(e, t, 'error.cancel')
|
| 26 |
+
expect(out).toContain('error.withRef')
|
| 27 |
+
expect(out).toContain('error.cancel')
|
| 28 |
+
expect(out).toContain('req1')
|
| 29 |
+
expect(out).not.toContain('cannot cancel') // raw backend text never shown
|
| 30 |
+
})
|
| 31 |
+
|
| 32 |
+
it('falls back to the operation key when no request id', () => {
|
| 33 |
+
const e = new ApiRequestError({ status: 500, message: 'boom' })
|
| 34 |
+
expect(localizeError(e, t, 'error.refund')).toBe('error.refund')
|
| 35 |
+
})
|
| 36 |
+
|
| 37 |
+
it('handles non-API errors with the fallback', () => {
|
| 38 |
+
expect(localizeError(new Error('x'), t, 'error.generic')).toBe('error.generic')
|
| 39 |
+
})
|
| 40 |
+
})
|
web/src/utils/errors.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { ApiRequestError, getLastRequestId } from '../api/client'
|
| 2 |
+
import type { MessageKey } from '../i18n/en'
|
| 3 |
+
|
| 4 |
+
type Translate = (key: MessageKey, vars?: Record<string, string | number>) => string
|
| 5 |
+
|
| 6 |
+
/** Turn any thrown error into a SAFE, localized message. We never render raw backend
|
| 7 |
+
* exception text to the user. Network/timeout get dedicated messages; every other
|
| 8 |
+
* failure shows a context-specific localized fallback plus the request ID (when known)
|
| 9 |
+
* so support can trace it. `fallbackKey` is the operation-specific message key. */
|
| 10 |
+
export function localizeError(e: unknown, t: Translate, fallbackKey: MessageKey): string {
|
| 11 |
+
if (e instanceof ApiRequestError) {
|
| 12 |
+
if (e.status === 0) {
|
| 13 |
+
return t(e.message === 'request timed out' ? 'error.timeout' : 'error.network')
|
| 14 |
+
}
|
| 15 |
+
const rid = e.requestId ?? getLastRequestId() ?? undefined
|
| 16 |
+
return rid ? t('error.withRef', { msg: t(fallbackKey), id: rid }) : t(fallbackKey)
|
| 17 |
+
}
|
| 18 |
+
return t(fallbackKey)
|
| 19 |
+
}
|
web/src/utils/validate.test.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { describe, it, expect } from 'vitest'
|
| 2 |
+
import { validateAmount, validateIban, isValidSaudiIban, validateMerchant } from './validate'
|
| 3 |
+
|
| 4 |
+
describe('validateAmount', () => {
|
| 5 |
+
it('accepts a valid 2-decimal amount', () => expect(validateAmount('150.50')).toBeUndefined())
|
| 6 |
+
it('accepts an integer amount', () => expect(validateAmount('200')).toBeUndefined())
|
| 7 |
+
it('rejects empty', () => expect(validateAmount('')).toBe('error.amountEmpty'))
|
| 8 |
+
it('rejects zero', () => expect(validateAmount('0')).toBe('error.amountZero'))
|
| 9 |
+
it('rejects negative', () => expect(validateAmount('-5')).toBe('error.amountInvalid'))
|
| 10 |
+
it('rejects non-numeric', () => expect(validateAmount('abc')).toBe('error.amountInvalid'))
|
| 11 |
+
it('rejects >2 decimals', () => expect(validateAmount('1.234')).toBe('error.amountInvalid'))
|
| 12 |
+
it('rejects too large', () => expect(validateAmount('2000000')).toBe('error.amountTooLarge'))
|
| 13 |
+
it('rejects lone dot', () => expect(validateAmount('.')).toBe('error.amountInvalid'))
|
| 14 |
+
})
|
| 15 |
+
|
| 16 |
+
describe('Saudi IBAN', () => {
|
| 17 |
+
const VALID = 'SA0380000000608010167519'
|
| 18 |
+
it('accepts a valid Saudi IBAN', () => expect(isValidSaudiIban(VALID)).toBe(true))
|
| 19 |
+
it('accepts with spaces', () => expect(isValidSaudiIban('SA03 8000 0000 6080 1016 7519')).toBe(true))
|
| 20 |
+
it('rejects wrong checksum', () => expect(isValidSaudiIban('SA0380000000608010167518')).toBe(false))
|
| 21 |
+
it('rejects wrong country', () => expect(isValidSaudiIban('GB0380000000608010167519')).toBe(false))
|
| 22 |
+
it('rejects short', () => expect(isValidSaudiIban('SA038000')).toBe(false))
|
| 23 |
+
it('validateIban maps invalid -> key', () => expect(validateIban('SA00')).toBe('error.ibanInvalid'))
|
| 24 |
+
it('validateIban maps empty -> key', () => expect(validateIban('')).toBe('error.payeeEmpty'))
|
| 25 |
+
it('validateIban passes valid', () => expect(validateIban(VALID)).toBeUndefined())
|
| 26 |
+
})
|
| 27 |
+
|
| 28 |
+
describe('validateMerchant', () => {
|
| 29 |
+
it('rejects empty', () => expect(validateMerchant(' ')).toBe('error.merchantEmpty'))
|
| 30 |
+
it('accepts text', () => expect(validateMerchant('Blue Bottle')).toBeUndefined())
|
| 31 |
+
})
|
web/src/utils/validate.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { MessageKey } from '../i18n/en'
|
| 2 |
+
|
| 3 |
+
const MAX_MAJOR = 1_000_000 // demo ceiling; backend enforces its own limits
|
| 4 |
+
|
| 5 |
+
/** Validate a SAR major-unit amount string. Returns an i18n error key or undefined. */
|
| 6 |
+
export function validateAmount(raw: string): MessageKey | undefined {
|
| 7 |
+
const s = raw.trim()
|
| 8 |
+
if (!s) return 'error.amountEmpty'
|
| 9 |
+
if (!/^\d*\.?\d*$/.test(s) || s === '.') return 'error.amountInvalid'
|
| 10 |
+
const n = Number(s)
|
| 11 |
+
if (!Number.isFinite(n)) return 'error.amountInvalid'
|
| 12 |
+
if (n < 0) return 'error.amountNegative'
|
| 13 |
+
if (n === 0) return 'error.amountZero'
|
| 14 |
+
if (n > MAX_MAJOR) return 'error.amountTooLarge'
|
| 15 |
+
if (!/^\d+(\.\d{1,2})?$/.test(s)) return 'error.amountInvalid' // SAR = 2 decimals max
|
| 16 |
+
return undefined
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
/** Saudi IBAN: "SA" + 22 digits (24 chars), ISO 13616 mod-97 check == 1. */
|
| 20 |
+
export function isValidSaudiIban(raw: string): boolean {
|
| 21 |
+
const s = raw.replace(/\s+/g, '').toUpperCase()
|
| 22 |
+
if (!/^SA\d{22}$/.test(s)) return false
|
| 23 |
+
const rearranged = s.slice(4) + s.slice(0, 4)
|
| 24 |
+
let rem = 0
|
| 25 |
+
for (const ch of rearranged) {
|
| 26 |
+
const val = ch >= 'A' && ch <= 'Z' ? String(ch.charCodeAt(0) - 55) : ch
|
| 27 |
+
for (const d of val) rem = (rem * 10 + (d.charCodeAt(0) - 48)) % 97
|
| 28 |
+
}
|
| 29 |
+
return rem === 1
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
export function validateIban(raw: string): MessageKey | undefined {
|
| 33 |
+
if (!raw.trim()) return 'error.payeeEmpty'
|
| 34 |
+
return isValidSaudiIban(raw) ? undefined : 'error.ibanInvalid'
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
export function validateMerchant(raw: string): MessageKey | undefined {
|
| 38 |
+
return raw.trim() ? undefined : 'error.merchantEmpty'
|
| 39 |
+
}
|