MHamdan commited on
Commit
de6cac5
·
verified ·
1 Parent(s): 256ac8c

CI deploy 84761d0

Browse files
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 useHashRoute(): [Route, (r: Route) => void] {
10
- const parse = (): Route => (window.location.hash.replace('#/', '') === 'enroll' ? 'enroll' : 'pay')
 
 
 
 
 
 
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: Route) => {
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
- <Button onClick={() => setLocale(locale === 'ar' ? 'en' : 'ar')} aria-label={t('lang.toggle')}>
40
- {t('lang.toggle')}
41
- </Button>
 
 
 
 
 
42
  </header>
 
43
  <main className="content">
44
  <p className="tagline">{t('app.tagline')}</p>
45
- {route === 'pay' ? <PayPage /> : <EnrollPage />}
 
 
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
- /** "Register / log in" for the demo: seed a tri-modal identity and receive a session token. */
 
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 token is kept in memory only — NEVER localStorage/sessionStorage/URLs.
 
 
 
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
- listeners.forEach((fn) => fn())
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
- setToken(null)
 
 
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
- export function useSession() {
5
- const [authed, setAuthed] = useState(isAuthenticated())
6
- useEffect(() => onSessionChange(() => setAuthed(isAuthenticated())), [])
7
- return { authed, token: getToken() }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- 'enroll.title': 'التسجيل / تسجيل الدخول',
 
 
 
 
 
10
  'enroll.userId': 'معرّف المستخدم',
 
 
11
  'enroll.demo': 'تحميل هوية تجريبية',
12
- 'enroll.passkey': 'إضافة بصمة الجهاز / Face ID',
13
- 'enroll.passkeyOk': 'تم تسجيل بصمة الجهاز',
14
- 'enroll.passkeyCancelled': 'أُلغيت — حاول مرة أخرى أو استخدم جهازًا آخر',
15
- 'enroll.passkeyUnsupported': 'لا يوجد مستشعر حيوي متاح على هذا الجهاز/المتصفح',
16
- 'enroll.loggedInAs': 'مُسجّل الدخول باسم {user}',
 
 
 
 
 
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.customerAction': 'يحتاج بنكك إلى الموافقة على هذه العملية.',
26
- 'pay.openBank': 'فتح موافقة البنك',
27
- 'pay.simulateSettle': 'محاكاة التسوية (تجريبي)',
28
- 'pay.processing': 'قيد المعالجة — لم تكتمل بعد',
 
 
 
29
  'pay.newPayment': 'عملية دفع جديدة',
 
 
 
 
30
  'pay.refunded': 'تم استرداد {amount}',
31
- 'pay.correlation': 'معرّف الربط',
32
- 'pay.requestId': 'معرّف الطلب',
 
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
- 'enroll.title': 'Register / Sign in',
 
 
 
 
 
8
  'enroll.userId': 'User ID',
 
 
9
  'enroll.demo': 'Load demo identity',
10
- 'enroll.passkey': 'Add device fingerprint / Face ID',
11
- 'enroll.passkeyOk': 'Device biometric registered',
12
- 'enroll.passkeyCancelled': 'Cancelled try again or use another device',
13
- 'enroll.passkeyUnsupported': 'This device/browser has no usable biometric sensor',
14
- 'enroll.loggedInAs': 'Signed in as {user}',
 
 
 
 
 
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 first (Enroll tab).',
23
- 'pay.customerAction': 'Your bank needs to authorize this payment.',
24
- 'pay.openBank': 'Open bank authorization',
25
- 'pay.simulateSettle': 'Simulate settlement (mock)',
26
- 'pay.processing': 'Processingnot yet complete',
 
 
 
27
  'pay.newPayment': 'New payment',
 
 
 
 
28
  'pay.refunded': 'Refunded {amount}',
29
- 'pay.correlation': 'Correlation ID',
30
- 'pay.requestId': 'Request ID',
 
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
- import { ApiRequestError } from '../api/client'
 
 
9
 
10
  export function EnrollPage() {
11
  const { t } = useI18n()
12
- const { authed } = useSession()
13
  const [userId, setUserId] = useState('alice')
14
- const [busy, setBusy] = useState<string | null>(null)
15
- const [msg, setMsg] = useState<{ tone: 'ok' | 'bad' | 'info'; text: string } | null>(null)
 
 
16
 
17
  async function onDemo() {
 
18
  setBusy('demo'); setMsg(null)
19
  try {
20
- const r = await demoSeed(userId.trim() || 'alice')
21
- setMsg({ tone: 'ok', text: t('enroll.loggedInAs', { user: r.user_id }) })
22
  } catch (e) {
23
- setMsg({ tone: 'bad', text: e instanceof ApiRequestError ? e.message : t('error.generic') })
24
  } finally {
25
  setBusy(null)
26
  }
27
  }
28
 
29
- async function onPasskey() {
30
- setBusy('passkey'); setMsg(null)
 
31
  try {
32
  if (!(await platformAuthenticatorAvailable())) {
33
- setMsg({ tone: 'info', text: t('enroll.passkeyUnsupported') })
34
- return
35
  }
36
- await registerPasskey(userId.trim() || 'alice')
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: t('error.generic') })
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
- <div className="row">
 
 
 
53
  <Button onClick={onDemo} disabled={busy !== null}>
54
  {busy === 'demo' ? <Spinner label={t('pay.submitting')} /> : t('enroll.demo')}
55
  </Button>
56
- <Button onClick={onPasskey} disabled={busy !== null}>
57
- {busy === 'passkey' ? <Spinner label={t('pay.submitting')} /> : t('enroll.passkey')}
58
- </Button>
59
- {authed && <Button onClick={() => { logout(); setMsg(null) }}>{t('auth.logout')}</Button>}
60
- </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 { setToken } from '../auth/session'
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: 'alice', status: 'succeeded', amount_minor: 15050, currency: 'SAR',
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
- const renderPay = () => render(<I18nProvider initial="en"><PayPage /></I18nProvider>)
 
 
 
 
 
 
 
 
 
20
 
21
  describe('PayPage', () => {
22
- beforeEach(() => { vi.restoreAllMocks(); setToken('t'); (window as any).__xss = undefined })
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- () => new Promise((r) => { resolve = r }),
 
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', requires_action_url: null }))
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() // no HTML injected
44
- expect((window as any).__xss).toBeUndefined() // handler never fired
 
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.getByText('Processing')).toBeInTheDocument())
53
- expect(screen.queryByText('Succeeded')).toBeNull()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 { createPayment, mockAdvance } from '../api/payments'
 
 
3
  import { usePaymentPolling } from '../payments/usePaymentPolling'
4
- import { isSuccess, isTerminal } from '../payments/status'
 
 
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 { ApiRequestError, getLastRequestId } from '../api/client'
12
- import type { PaymentView } from '../types'
 
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 { authed } = useSession()
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
- // Idempotency key is generated once per user action and reused across retries.
 
33
  const idemRef = useRef<string>(newIdemKey())
34
  const submittingRef = useRef(false)
35
 
36
  const { payment: polled } = usePaymentPolling(created?.id ?? null)
37
- const payment = polled ?? created
 
 
 
 
38
 
39
- const amountError = useMemo(() => {
40
- const n = Number(amount)
41
- return !Number.isFinite(n) || n <= 0 ? t('error.generic') : undefined
42
- }, [amount, t])
 
 
 
 
 
 
 
 
 
 
 
 
43
 
44
  async function onPay() {
45
  if (submittingRef.current || created) return // duplicate-submission guard
46
- if (amountError) return
 
47
  submittingRef.current = true
48
  setSubmitting(true)
49
  setError(null)
50
  try {
51
  const p = await createPayment({
52
- userId: 'alice',
53
  amountMinor: toMinor(amount, 'SAR'),
54
- merchantId: merchant.trim() || 'merchant',
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
- // Keep the idempotency key so a retry of THIS action is deduplicated server-side.
63
- setError(e instanceof ApiRequestError ? e.message : t('error.generic'))
64
  } finally {
65
  submittingRef.current = false
66
  setSubmitting(false)
67
  }
68
  }
69
 
70
- async function onSimulateSettle() {
 
71
  if (!created) return
 
72
  try {
73
- await mockAdvance(created.id, 'SETTLED')
74
- } catch {
75
- setError(t('error.generic'))
 
 
 
 
 
 
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
- {!authed && <Callout tone="info">{t('pay.needLogin')}</Callout>}
 
 
 
 
 
 
 
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={amountError} />
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
- <Button onClick={onPay} disabled={!authed || submitting || !!amountError} aria-busy={submitting}>
 
 
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={onSimulateSettle}>{t('pay.simulateSettle')}</Button>
 
 
 
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
- <Callout tone="info">
138
- {t('pay.refunded', { amount: formatMoney(payment.refunded_minor, payment.currency, locale) })}
139
- </Callout>
 
 
 
 
 
 
 
 
 
 
 
 
 
140
  )}
141
 
142
- {isSuccess(payment.status) && <Callout tone="ok">✓</Callout>}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
143
 
144
  {isTerminal(payment.status) && (
145
  <Button onClick={reset}>{t('pay.newPayment')}</Button>
146
  )}
147
 
 
148
  <details className="support">
149
- <summary>{t('pay.requestId')}</summary>
150
- <div className="mono">
151
- {t('pay.correlation')}: {payment.correlation_id}
152
- <br />
153
- {t('pay.requestId')}: {getLastRequestId() ?? '—'}
154
- <br />
155
- {formatDateTime(payment.updated_at, locale)}
156
- </div>
 
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 { isTerminal, isSuccess, statusTone } from './status'
 
 
 
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
- @media (max-width: 560px) { .app { padding: 10px; } .topbar { flex-wrap: wrap; } }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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?: unknown; status?: string }>
 
 
 
 
 
 
 
 
 
 
 
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
+ }