MHamdan commited on
Commit
a12d188
·
verified ·
1 Parent(s): 70332a1

CI deploy f7ddbcd

Browse files
amanpay/notifications/service.py CHANGED
@@ -22,6 +22,7 @@ import hashlib
22
  import logging
23
  import os
24
  import secrets
 
25
  from dataclasses import dataclass, field
26
  from typing import Dict, List, Optional
27
 
@@ -173,6 +174,13 @@ class NotificationService:
173
  def __init__(self) -> None:
174
  self._prefs: Dict[str, _Prefs] = {}
175
  self._subs: Dict[str, dict] = {}
 
 
 
 
 
 
 
176
  self.console = ConsoleProvider()
177
  self.sms = SMSProvider()
178
  self.call = CallProvider()
@@ -228,7 +236,7 @@ class NotificationService:
228
  # PENDING_TTL bounds the confirmation window (timeout handling).
229
  from amanpay.storage.kv import get_kv
230
  get_kv().set(f"oob:{cid}", {"code": code, "amount": amount, "merchant": merchant,
231
- "approved": None, "ts": now},
232
  ttl=self.PENDING_TTL, now=now)
233
  msg = (f"AmanPay: confirm payment of ${amount:.2f} to {merchant}. "
234
  f"Code {code}. If this wasn't you, deny it in the app.")
@@ -250,19 +258,51 @@ class NotificationService:
250
 
251
  def respond(self, confirmation_id: str, approve: bool,
252
  code: Optional[str] = None, now: float = 0.0) -> Dict:
253
- """Approve/deny a pending confirmation. If a code is given it must match
254
- (dynamic linking) — proves the response is bound to this exact payment."""
 
 
 
 
 
 
 
 
 
255
  from amanpay.storage.kv import get_kv
256
- kv = get_kv()
257
- pend = kv.get(f"oob:{confirmation_id}", now=now)
258
- if pend is None:
259
- return {"success": False, "reason": "unknown or expired confirmation"}
260
- if code is not None and code.upper() != pend["code"]:
261
- return {"success": False, "reason": "code mismatch (dynamic-link failed)"}
262
- pend["approved"] = bool(approve)
263
- kv.set(f"oob:{confirmation_id}", pend, ttl=self.PENDING_TTL, now=now)
264
- return {"success": True, "approved": pend["approved"],
265
- "amount": pend["amount"], "merchant": pend["merchant"]}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
266
 
267
  def is_approved(self, confirmation_id: Optional[str], now: float = 0.0) -> bool:
268
  if not confirmation_id:
 
22
  import logging
23
  import os
24
  import secrets
25
+ import threading
26
  from dataclasses import dataclass, field
27
  from typing import Dict, List, Optional
28
 
 
174
  def __init__(self) -> None:
175
  self._prefs: Dict[str, _Prefs] = {}
176
  self._subs: Dict[str, dict] = {}
177
+ # Serializes the read-check-write in respond() so two concurrent responses
178
+ # (FastAPI runs sync handlers in a threadpool) can't both pass the pending
179
+ # check — first terminal transition wins. For a future shared Redis-backed
180
+ # store this critical section must move to a Lua compare-and-set (cf. the
181
+ # `_CONSUME` script in storage/kv.py); the single respond() entry point keeps
182
+ # that swap localized.
183
+ self._respond_lock = threading.Lock()
184
  self.console = ConsoleProvider()
185
  self.sms = SMSProvider()
186
  self.call = CallProvider()
 
236
  # PENDING_TTL bounds the confirmation window (timeout handling).
237
  from amanpay.storage.kv import get_kv
238
  get_kv().set(f"oob:{cid}", {"code": code, "amount": amount, "merchant": merchant,
239
+ "approved": None, "finalized_at": None, "ts": now},
240
  ttl=self.PENDING_TTL, now=now)
241
  msg = (f"AmanPay: confirm payment of ${amount:.2f} to {merchant}. "
242
  f"Code {code}. If this wasn't you, deny it in the app.")
 
258
 
259
  def respond(self, confirmation_id: str, approve: bool,
260
  code: Optional[str] = None, now: float = 0.0) -> Dict:
261
+ """Approve/deny a confirmation, enforcing TERMINAL-STATE IMMUTABILITY.
262
+
263
+ - unknown/expired id -> {success: False, status: "expired"}.
264
+ - already finalized, SAME decision -> idempotent: returns the UNCHANGED final
265
+ state (success: True, idempotent: True) — decision + finalized_at preserved.
266
+ - already finalized, CONFLICTING decision -> {success: False, conflict: True,
267
+ error_code: "oob_already_finalized"} (route maps to HTTP 409); state unchanged.
268
+ - pending -> requires the dynamic-link code, then finalizes (first writer wins).
269
+
270
+ The whole read-check-write runs under a lock so concurrent approve/deny can't
271
+ both succeed. If a code is given it must match (dynamic linking)."""
272
  from amanpay.storage.kv import get_kv
273
+ approve = bool(approve)
274
+ key = f"oob:{confirmation_id}"
275
+ with self._respond_lock:
276
+ kv = get_kv()
277
+ pend = kv.get(key, now=now)
278
+ if pend is None:
279
+ return {"success": False, "status": "expired",
280
+ "reason": "unknown or expired confirmation"}
281
+ finalized = pend.get("approved")
282
+ if finalized is not None:
283
+ current = "approved" if finalized else "rejected"
284
+ if approve == finalized:
285
+ # Idempotent retry — return the existing final state, unchanged.
286
+ return {"success": True, "approved": finalized, "status": current,
287
+ "amount": pend["amount"], "merchant": pend["merchant"],
288
+ "finalized_at": pend.get("finalized_at"), "idempotent": True}
289
+ # Conflicting response after a terminal state — reject, do not mutate.
290
+ return {"success": False, "conflict": True,
291
+ "error_code": "oob_already_finalized", "status": current,
292
+ "confirmation_id": confirmation_id,
293
+ "finalized_at": pend.get("finalized_at"),
294
+ "reason": "confirmation already finalized"}
295
+ # Still pending — enforce dynamic linking, then finalize.
296
+ if code is not None and code.upper() != pend["code"]:
297
+ return {"success": False, "status": "pending",
298
+ "reason": "code mismatch (dynamic-link failed)"}
299
+ pend["approved"] = approve
300
+ pend["finalized_at"] = now
301
+ kv.set(key, pend, ttl=self.PENDING_TTL, now=now)
302
+ return {"success": True, "approved": approve,
303
+ "status": "approved" if approve else "rejected",
304
+ "amount": pend["amount"], "merchant": pend["merchant"],
305
+ "finalized_at": now, "idempotent": False}
306
 
307
  def is_approved(self, confirmation_id: Optional[str], now: float = 0.0) -> bool:
308
  if not confirmation_id:
api/dependencies.py CHANGED
@@ -39,7 +39,7 @@ class ModelState:
39
  from amanpay.banking.wallet import WalletRegistry
40
  from amanpay.banking.risk import RiskEngine
41
  from amanpay.banking.geo import GeoRegistry
42
- from amanpay.notifications.service import NotificationService
43
  self.passkeys = PasskeyRegistry()
44
  self.voice = VoiceRegistry()
45
  self.liveness = LivenessRegistry()
@@ -47,7 +47,10 @@ class ModelState:
47
  self.wallet = WalletRegistry()
48
  self.risk = RiskEngine()
49
  self.geo = GeoRegistry()
50
- self.notify = NotificationService()
 
 
 
51
  # Datastore: a real SQL DB (SQLite/Postgres) when DATABASE_URL is set —
52
  # per-user rows, audit log, cascade erasure (P1). Otherwise the HF Dataset
53
  # store (keeps the zero-config HF Space demo working).
 
39
  from amanpay.banking.wallet import WalletRegistry
40
  from amanpay.banking.risk import RiskEngine
41
  from amanpay.banking.geo import GeoRegistry
42
+ from api import services
43
  self.passkeys = PasskeyRegistry()
44
  self.voice = VoiceRegistry()
45
  self.liveness = LivenessRegistry()
 
47
  self.wallet = WalletRegistry()
48
  self.risk = RiskEngine()
49
  self.geo = GeoRegistry()
50
+ # Share the torch-free notification singleton (the /notify router uses the same
51
+ # instance) and wire durable-persistence + audit hooks into it.
52
+ self.notify = services.notifications
53
+ services.wire(persist=self.persist, audit=self.audit)
54
  # Datastore: a real SQL DB (SQLite/Postgres) when DATABASE_URL is set —
55
  # per-user rows, audit log, cascade erasure (P1). Otherwise the HF Dataset
56
  # store (keeps the zero-config HF Space demo working).
api/main.py CHANGED
@@ -30,6 +30,7 @@ from fastapi.middleware.cors import CORSMiddleware
30
  from api.dependencies import state
31
  from api.observability import (init_sentry, metrics_middleware, metrics_response,
32
  record_readiness, setup_logging)
 
33
  from api.routes import router
34
 
35
  logging.basicConfig(level=logging.INFO,
@@ -110,6 +111,7 @@ def readyz():
110
 
111
 
112
  app.include_router(router)
 
113
 
114
 
115
  _ROOT = os.path.dirname(os.path.dirname(__file__))
 
30
  from api.dependencies import state
31
  from api.observability import (init_sentry, metrics_middleware, metrics_response,
32
  record_readiness, setup_logging)
33
+ from api.routers.notifications import router as notifications_router
34
  from api.routes import router
35
 
36
  logging.basicConfig(level=logging.INFO,
 
111
 
112
 
113
  app.include_router(router)
114
+ app.include_router(notifications_router) # torch-free /notify/* endpoints
115
 
116
 
117
  _ROOT = os.path.dirname(os.path.dirname(__file__))
api/routers/__init__.py ADDED
File without changes
api/routers/notifications.py ADDED
@@ -0,0 +1,68 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Notification / out-of-band confirmation endpoints — a torch-free router.
2
+
3
+ Isolated from the monolithic `api.routes` (which imports the biometric models/torch)
4
+ so these endpoints and their API tests run in the lightweight, torch-free environment.
5
+ Paths, methods and request/response contracts are UNCHANGED from the previous
6
+ definitions in `api.routes`."""
7
+
8
+ from __future__ import annotations
9
+
10
+ import time
11
+
12
+ from fastapi import APIRouter, Body
13
+ from fastapi.responses import JSONResponse
14
+
15
+ from api.services import audit_event, notifications, persist_prefs
16
+
17
+ router = APIRouter()
18
+
19
+
20
+ @router.post("/notify/prefs")
21
+ def notify_set_prefs(body: dict = Body(...)) -> dict:
22
+ r = notifications.set_prefs(body["user_id"], channels=body.get("channels"),
23
+ email=body.get("email"), phone=body.get("phone"))
24
+ persist_prefs()
25
+ return r
26
+
27
+
28
+ @router.get("/notify/prefs")
29
+ def notify_get_prefs(user_id: str) -> dict:
30
+ return notifications.get_prefs(user_id)
31
+
32
+
33
+ @router.post("/notify/subscribe")
34
+ def notify_subscribe(body: dict = Body(...)) -> dict:
35
+ """Store a Web Push subscription (from the browser) for the user."""
36
+ return notifications.subscribe_push(body["user_id"], body["subscription"])
37
+
38
+
39
+ @router.post("/notify/request")
40
+ def notify_request(body: dict = Body(...)) -> dict:
41
+ """Create a demo out-of-band payment confirmation (dynamic-linked to amount+merchant)
42
+ using the existing notification service, so the React OOB demo can exercise the
43
+ request -> respond -> status workflow without going through /wallet/pay."""
44
+ return notifications.send_payment_confirmation(
45
+ body["user_id"], float(body.get("amount", 0.0)),
46
+ str(body.get("merchant", "")), now=time.time())
47
+
48
+
49
+ @router.get("/notify/status")
50
+ def notify_status(confirmation_id: str) -> dict:
51
+ """Lifecycle of an OOB confirmation for UI polling: pending/approved/rejected/expired."""
52
+ return {"confirmation_id": confirmation_id,
53
+ "status": notifications.confirmation_status(confirmation_id, now=time.time())}
54
+
55
+
56
+ @router.post("/notify/respond")
57
+ def notify_respond(body: dict = Body(...)):
58
+ """Approve/deny an OOB confirmation. Enforces terminal-state immutability: a
59
+ conflicting response after a terminal state returns HTTP 409 (oob_already_finalized)
60
+ and leaves the final state unchanged; a same-decision retry is idempotent."""
61
+ r = notifications.respond(body["confirmation_id"], bool(body.get("approve", False)),
62
+ code=body.get("code"), now=time.time())
63
+ if r.get("conflict"):
64
+ # Safe security-audit event (no code/secret) for the rejected conflicting transition.
65
+ audit_event("oob", "oob_conflict_rejected",
66
+ {"confirmation_id": r.get("confirmation_id"), "final_state": r.get("status")})
67
+ return JSONResponse(status_code=409, content=r)
68
+ return r
api/routes.py CHANGED
@@ -298,47 +298,9 @@ def pad_verify(body: dict = Body(...)) -> dict:
298
 
299
 
300
  # ---- Notification preferences + out-of-band payment confirmation ----
301
- @router.post("/notify/prefs")
302
- def notify_set_prefs(body: dict = Body(...)) -> dict:
303
- r = state.notify.set_prefs(body["user_id"], channels=body.get("channels"),
304
- email=body.get("email"), phone=body.get("phone"))
305
- state.persist()
306
- return r
307
-
308
-
309
- @router.get("/notify/prefs")
310
- def notify_get_prefs(user_id: str) -> dict:
311
- return state.notify.get_prefs(user_id)
312
-
313
-
314
- @router.post("/notify/subscribe")
315
- def notify_subscribe(body: dict = Body(...)) -> dict:
316
- """Store a Web Push subscription (from the browser) for the user."""
317
- return state.notify.subscribe_push(body["user_id"], body["subscription"])
318
-
319
-
320
- @router.post("/notify/request")
321
- def notify_request(body: dict = Body(...)) -> dict:
322
- """Create a demo out-of-band payment confirmation (dynamic-linked to amount+merchant)
323
- using the existing notification service, so the React OOB demo can exercise the
324
- request -> respond -> status workflow without going through /wallet/pay."""
325
- return state.notify.send_payment_confirmation(
326
- body["user_id"], float(body.get("amount", 0.0)),
327
- str(body.get("merchant", "")), now=time.time())
328
-
329
-
330
- @router.get("/notify/status")
331
- def notify_status(confirmation_id: str) -> dict:
332
- """Lifecycle of an OOB confirmation for UI polling: pending/approved/rejected/expired."""
333
- return {"confirmation_id": confirmation_id,
334
- "status": state.notify.confirmation_status(confirmation_id, now=time.time())}
335
-
336
-
337
- @router.post("/notify/respond")
338
- def notify_respond(body: dict = Body(...)) -> dict:
339
- """User approves/denies a pending out-of-band confirmation (dynamic-linked)."""
340
- return state.notify.respond(body["confirmation_id"], bool(body.get("approve", False)),
341
- code=body.get("code"), now=time.time())
342
 
343
 
344
  # ---- Device-native biometric (platform authenticator: Touch ID / Face ID / fingerprint) ----
 
298
 
299
 
300
  # ---- Notification preferences + out-of-band payment confirmation ----
301
+ # Notification / out-of-band confirmation endpoints (/notify/*) live in the torch-free
302
+ # api.routers.notifications router (included by api.main), so they and their API tests
303
+ # stay isolated from the biometric-model imports. Paths/contracts are unchanged.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
304
 
305
 
306
  # ---- Device-native biometric (platform authenticator: Touch ID / Face ID / fingerprint) ----
api/services.py ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Torch-free application services shared between the model-heavy dependency layer
2
+ and the lightweight routers. Importing this module never pulls torch or the biometric
3
+ models, so the notification/OOB router (and its API tests) stay torch-free.
4
+
5
+ The `notifications` singleton IS the instance the full app uses (ModelState.notify
6
+ references it), so production and tests share one service + state. Persistence and
7
+ audit are optional hooks the full app wires at startup; they no-op when unwired
8
+ (e.g. in the light CI test app), matching the demo store's behavior."""
9
+
10
+ from __future__ import annotations
11
+
12
+ from typing import Callable, Optional
13
+
14
+ from amanpay.notifications.service import NotificationService
15
+
16
+ # Single shared notification/OOB service (referenced by ModelState.notify).
17
+ notifications = NotificationService()
18
+
19
+ _persist: Optional[Callable[[], None]] = None
20
+ _audit: Optional[Callable[[str, str, dict], None]] = None
21
+
22
+
23
+ def wire(persist: Optional[Callable[[], None]] = None,
24
+ audit: Optional[Callable[[str, str, dict], None]] = None) -> None:
25
+ """Wire durable-persistence and audit callbacks from the full app (torch side)."""
26
+ global _persist, _audit
27
+ _persist = persist
28
+ _audit = audit
29
+
30
+
31
+ def persist_prefs() -> None:
32
+ if _persist is not None:
33
+ _persist()
34
+
35
+
36
+ def audit_event(user_id: str, action: str, detail: dict) -> None:
37
+ if _audit is not None:
38
+ _audit(user_id, action, detail)
build_info.json CHANGED
@@ -1 +1 @@
1
- {"commit":"f5b3d10","build_time":"2026-07-12T10:04:06Z","frontend":"1.0.0"}
 
1
+ {"commit":"f7ddbcd","build_time":"2026-07-12T16:08:09Z","frontend":"1.0.0"}
web/e2e/biometrics.spec.ts CHANGED
@@ -71,6 +71,21 @@ test('OOB confirmation: request -> approve -> approved', async ({ page }) => {
71
  await expect(page.getByText('Approved.')).toBeVisible()
72
  })
73
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
74
  test('report card shows benchmark-unavailable (not a fabricated available)', async ({ page }) => {
75
  await page.goto('/#/biometrics/reportcard')
76
  await expect(page.getByText('Benchmark data unavailable')).toBeVisible()
 
71
  await expect(page.getByText('Approved.')).toBeVisible()
72
  })
73
 
74
+ test('OOB stale conflicting response keeps the approved state (409 handled)', async ({ page }) => {
75
+ // Simulate the confirmation already finalized elsewhere -> backend returns 409.
76
+ await page.route('**/notify/respond', (r) => r.fulfill({
77
+ status: 409, contentType: 'application/json',
78
+ body: JSON.stringify({ success: false, conflict: true, error_code: 'oob_already_finalized', status: 'approved' }),
79
+ }))
80
+ await page.route('**/notify/status**', (r) => r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ status: 'approved' }) }))
81
+ await page.goto('/#/biometrics/oob')
82
+ await page.getByRole('button', { name: 'Send confirmation' }).click()
83
+ await page.getByRole('button', { name: 'Approve' }).click()
84
+ await expect(page.getByText('Approved.')).toBeVisible() // refreshed to the real terminal state
85
+ await expect(page.getByText(/already finalized/i)).toBeVisible() // informative, not a failure
86
+ await expect(page.getByText('Rejected.')).toHaveCount(0) // did NOT flip to rejected
87
+ })
88
+
89
  test('report card shows benchmark-unavailable (not a fabricated available)', async ({ page }) => {
90
  await page.goto('/#/biometrics/reportcard')
91
  await expect(page.getByText('Benchmark data unavailable')).toBeVisible()
web/src/i18n/ar.ts CHANGED
@@ -256,4 +256,5 @@ export const ar: Record<MessageKey, string> = {
256
  'oob.status.approved': 'تمت الموافقة.',
257
  'oob.status.rejected': 'مرفوض.',
258
  'oob.status.expired': 'منتهي الصلاحية.',
 
259
  }
 
256
  'oob.status.approved': 'تمت الموافقة.',
257
  'oob.status.rejected': 'مرفوض.',
258
  'oob.status.expired': 'منتهي الصلاحية.',
259
+ 'oob.alreadyFinalized': 'تم إنهاء هذا التأكيد مسبقًا — يتم عرض نتيجته الحالية.',
260
  }
web/src/i18n/en.ts CHANGED
@@ -254,6 +254,7 @@ export const en = {
254
  'oob.status.approved': 'Approved.',
255
  'oob.status.rejected': 'Rejected.',
256
  'oob.status.expired': 'Expired.',
 
257
  } as const
258
 
259
  export type MessageKey = keyof typeof en
 
254
  'oob.status.approved': 'Approved.',
255
  'oob.status.rejected': 'Rejected.',
256
  'oob.status.expired': 'Expired.',
257
+ 'oob.alreadyFinalized': 'This confirmation was already finalized — showing its current result.',
258
  } as const
259
 
260
  export type MessageKey = keyof typeof en
web/src/pages/OobPage.test.tsx CHANGED
@@ -3,6 +3,7 @@ import { render, screen, fireEvent, waitFor } from '@testing-library/react'
3
  import { I18nProvider } from '../i18n'
4
  import { OobPage } from './OobPage'
5
  import * as oob from '../api/oob'
 
6
 
7
  vi.mock('../api/oob')
8
 
@@ -37,4 +38,17 @@ describe('OobPage', () => {
37
  await waitFor(() => expect(oob.respondConfirmation).toHaveBeenCalledWith('cnf_1', true, 'AB12CD'))
38
  expect(await screen.findByText('Approved.')).toBeInTheDocument()
39
  })
 
 
 
 
 
 
 
 
 
 
 
 
 
40
  })
 
3
  import { I18nProvider } from '../i18n'
4
  import { OobPage } from './OobPage'
5
  import * as oob from '../api/oob'
6
+ import { ApiRequestError } from '../api/client'
7
 
8
  vi.mock('../api/oob')
9
 
 
38
  await waitFor(() => expect(oob.respondConfirmation).toHaveBeenCalledWith('cnf_1', true, 'AB12CD'))
39
  expect(await screen.findByText('Approved.')).toBeInTheDocument()
40
  })
41
+
42
+ it('handles a 409 already-finalized by refreshing to the terminal state, not a generic error', async () => {
43
+ renderOob()
44
+ fireEvent.click(screen.getByRole('button', { name: 'Send confirmation' }))
45
+ await screen.findByRole('button', { name: 'Approve' })
46
+ // Simulate a stale/conflicting response: backend already finalized -> 409.
47
+ vi.spyOn(oob, 'respondConfirmation').mockRejectedValue(new ApiRequestError({ status: 409, message: 'conflict' }))
48
+ vi.spyOn(oob, 'getConfirmationStatus').mockResolvedValue({ status: 'approved' })
49
+ fireEvent.click(screen.getByRole('button', { name: 'Approve' }))
50
+ expect(await screen.findByText('Approved.')).toBeInTheDocument() // shows the real terminal state
51
+ expect(screen.getByText(/already finalized/i)).toBeInTheDocument() // informative, not a failure
52
+ expect(screen.queryByText(/Something went wrong/i)).toBeNull() // no generic error
53
+ })
54
  })
web/src/pages/OobPage.tsx CHANGED
@@ -4,6 +4,7 @@ import {
4
  type OobConfirmation, type OobStatus,
5
  } from '../api/oob'
6
  import { Button, Callout, Field, Spinner } from '../components/ui'
 
7
  import { localizeError } from '../utils/errors'
8
  import { useI18n } from '../i18n'
9
  import type { MessageKey } from '../i18n/en'
@@ -21,6 +22,7 @@ export function OobPage() {
21
  const [status, setStatus] = useState<OobStatus | null>(null)
22
  const [busy, setBusy] = useState(false)
23
  const [error, setError] = useState<string | null>(null)
 
24
  const timer = useRef<ReturnType<typeof setInterval> | null>(null)
25
 
26
  function stopPolling() { if (timer.current) { clearInterval(timer.current); timer.current = null } }
@@ -28,7 +30,7 @@ export function OobPage() {
28
 
29
  async function send() {
30
  if (busy) return
31
- setBusy(true); setError(null); setStatus(null); setConf(null); stopPolling()
32
  try {
33
  await setNotifyPrefs(userId.trim() || 'alice', channels)
34
  const c = await requestConfirmation(userId.trim() || 'alice', Number(amount) || 0, merchant.trim())
@@ -44,18 +46,35 @@ export function OobPage() {
44
  } catch (e) { setError(localizeError(e, t, 'error.generic')) } finally { setBusy(false) }
45
  }
46
 
 
 
 
 
 
 
 
 
 
47
  async function respond(approve: boolean) {
48
  if (!conf) return
49
- setBusy(true); setError(null)
50
  try {
51
  const r = await respondConfirmation(conf.confirmation_id, approve, conf.code)
52
  if (!r.success) { setError(t('error.generic')); return }
53
- const s = (await getConfirmationStatus(conf.confirmation_id)).status
54
- setStatus(s); if (TERMINAL.has(s)) stopPolling()
55
- } catch (e) { setError(localizeError(e, t, 'error.generic')) } finally { setBusy(false) }
 
 
 
 
 
 
 
 
56
  }
57
 
58
- function reset() { stopPolling(); setConf(null); setStatus(null); setError(null) }
59
 
60
  const statusTone = status === 'approved' ? 'ok' : status === 'pending' ? 'warn' : 'bad'
61
 
@@ -94,6 +113,7 @@ export function OobPage() {
94
  <dt>{t('oob.code')}</dt><dd className="mono" dir="ltr">{conf.code}</dd>
95
  </dl>
96
  <p className="hint">{t('oob.dynamicLink')}</p>
 
97
  {status && <Callout tone={statusTone}>{t((`oob.status.${status}`) as MessageKey)}</Callout>}
98
  {status === 'pending' && (
99
  <div className="row">
 
4
  type OobConfirmation, type OobStatus,
5
  } from '../api/oob'
6
  import { Button, Callout, Field, Spinner } from '../components/ui'
7
+ import { ApiRequestError } from '../api/client'
8
  import { localizeError } from '../utils/errors'
9
  import { useI18n } from '../i18n'
10
  import type { MessageKey } from '../i18n/en'
 
22
  const [status, setStatus] = useState<OobStatus | null>(null)
23
  const [busy, setBusy] = useState(false)
24
  const [error, setError] = useState<string | null>(null)
25
+ const [notice, setNotice] = useState<string | null>(null)
26
  const timer = useRef<ReturnType<typeof setInterval> | null>(null)
27
 
28
  function stopPolling() { if (timer.current) { clearInterval(timer.current); timer.current = null } }
 
30
 
31
  async function send() {
32
  if (busy) return
33
+ setBusy(true); setError(null); setNotice(null); setStatus(null); setConf(null); stopPolling()
34
  try {
35
  await setNotifyPrefs(userId.trim() || 'alice', channels)
36
  const c = await requestConfirmation(userId.trim() || 'alice', Number(amount) || 0, merchant.trim())
 
46
  } catch (e) { setError(localizeError(e, t, 'error.generic')) } finally { setBusy(false) }
47
  }
48
 
49
+ async function refreshStatus(): Promise<OobStatus | null> {
50
+ if (!conf) return null
51
+ try {
52
+ const s = (await getConfirmationStatus(conf.confirmation_id)).status
53
+ setStatus(s); if (TERMINAL.has(s)) stopPolling()
54
+ return s
55
+ } catch { return null }
56
+ }
57
+
58
  async function respond(approve: boolean) {
59
  if (!conf) return
60
+ setBusy(true); setError(null); setNotice(null)
61
  try {
62
  const r = await respondConfirmation(conf.confirmation_id, approve, conf.code)
63
  if (!r.success) { setError(t('error.generic')); return }
64
+ await refreshStatus()
65
+ } catch (e) {
66
+ // 409 = already finalized (stale page / double click / another tab / late network).
67
+ // Refresh to the real terminal state and show it — NOT a generic failure.
68
+ if (e instanceof ApiRequestError && e.status === 409) {
69
+ setNotice(t('oob.alreadyFinalized'))
70
+ await refreshStatus()
71
+ return
72
+ }
73
+ setError(localizeError(e, t, 'error.generic'))
74
+ } finally { setBusy(false) }
75
  }
76
 
77
+ function reset() { stopPolling(); setConf(null); setStatus(null); setError(null); setNotice(null) }
78
 
79
  const statusTone = status === 'approved' ? 'ok' : status === 'pending' ? 'warn' : 'bad'
80
 
 
113
  <dt>{t('oob.code')}</dt><dd className="mono" dir="ltr">{conf.code}</dd>
114
  </dl>
115
  <p className="hint">{t('oob.dynamicLink')}</p>
116
+ {notice && <Callout tone="info">{notice}</Callout>}
117
  {status && <Callout tone={statusTone}>{t((`oob.status.${status}`) as MessageKey)}</Callout>}
118
  {status === 'pending' && (
119
  <div className="row">