"""D3 finance API — /finance/v1, deterministic, torch-free, dormant unless enabled. Mounted only when ``AMANPAY_D3_ENABLED=1`` (see ``api/main.py``). The runtime (shared D2 store, finance services) is built during startup and injected here; nothing in this module opens a database, connects to a bucket, or runs at import time. Security boundaries enforced here (reusing the D2 identity plumbing — no parallel systems): * the actor/tenant come from the SESSION COOKIE, never client input; * state-changing requests require a matching ``X-CSRF-Token``; * operator routes require ``role == 'operator'``; * sensitive actions require a recent passkey step-up (D2 recent-auth), surfaced as 422 ``step_up_required`` so the frontend runs the D2 step-up ceremony and retries; * responses are safe projections; errors are normalized reason tokens (never disclose existence). """ from __future__ import annotations import time from typing import Optional from fastapi import APIRouter, Body, Request, Response from fastapi.responses import JSONResponse from amanpay.finance.errors import D3Error, status_for from amanpay.finance.money import MoneyError, parse_amount_input from amanpay.finance.receipts import build_receipt, transaction_view from amanpay.identity.errors import D2Error from amanpay.identity.errors import status_for as d2_status_for from amanpay.identity.sessions import SESSION_COOKIE from api.auth import rate_limit router = APIRouter(prefix="/finance/v1", tags=["finance"]) _RUNTIME = None def set_runtime(runtime) -> None: global _RUNTIME _RUNTIME = runtime def _rt(): if _RUNTIME is None: raise D3Error("not_found") return _RUNTIME def install_error_handler(app) -> None: """Register D3Error (and D2Error, since D3 reuses D2 auth) -> normalized HTTP.""" @app.exception_handler(D3Error) async def _d3_handler(_request: Request, exc: D3Error): # noqa: ANN202 token = str(exc) return JSONResponse(status_code=status_for(token), content={"detail": token}) # D3 reuses D2 auth; translate D2Error too if D2's handler is not already installed. if not any(getattr(h, "__name__", "") == "_d2_error_handler" for h in getattr(app, "exception_handlers", {}).values()): @app.exception_handler(D2Error) async def _d2_handler(_request: Request, exc: D2Error): # noqa: ANN202 token = str(exc) return JSONResponse(status_code=d2_status_for(token), content={"detail": token}) # --------------------------------------------------------------------- helpers def _cookie(request: Request) -> Optional[str]: return request.cookies.get(SESSION_COOKIE) def _authed(request: Request): return _rt().identity.authenticate_request(_cookie(request)) def _require_csrf(request: Request, session) -> None: _rt().identity.require_csrf(session, request.headers.get("x-csrf-token")) def _operator(request: Request): sess, user = _authed(request) if user.role != "operator" or user.status != "active": raise D3Error("not_operator") return sess, user def _recent_auth_ok(session) -> bool: return (time.time() - session.last_auth_at) <= _rt().config.step_up_max_age_seconds def _amount_minor(body: dict, currency: str = "SAR") -> int: """Normalize + validate a client amount at the boundary (string or amount_minor int).""" if "amount_minor" in body and body["amount_minor"] is not None: raw = body["amount_minor"] if isinstance(raw, bool) or not isinstance(raw, int): raise D3Error("amount_not_integer_minor") from amanpay.finance.money import validate_minor mv = validate_minor(raw, currency) if mv.minor <= 0: raise D3Error("amount_must_be_positive") return mv.minor try: return parse_amount_input(body.get("amount", ""), currency).minor except MoneyError as e: raise D3Error(str(e)) # ================================================================= status @router.get("/status") def status() -> dict: rt = _RUNTIME if rt is None: return {"enabled": False, "ready": False} rr = rt.store.runtime_report() return {"enabled": True, "ready": True, "persistent": rt.persistent, "default_currency": rt.config.default_currency, "journal_mode": rr.get("journal_mode"), "wal_status": rr.get("wal_status"), "max_transfer_minor": rt.config.max_transfer_minor, "max_daily_outflow_minor": rt.config.max_daily_outflow_minor, "simulation_only": True} # ================================================================= accounts @router.get("/accounts") def list_accounts(request: Request) -> dict: _sess, user = _authed(request) return {"accounts": _rt().accounts.list_for_user(user.tenant_id, user.id)} @router.get("/accounts/{account_id}") def get_account(request: Request, account_id: str) -> dict: _sess, user = _authed(request) acct = _rt().accounts.require_owned(user.tenant_id, user.id, account_id) return {"account": _rt().accounts.view(acct)} @router.get("/accounts/{account_id}/balance") def account_balance(request: Request, account_id: str) -> dict: _sess, user = _authed(request) acct = _rt().accounts.require_owned(user.tenant_id, user.id, account_id) return {"balance": _rt().ledger.balances_for_financial_account(acct), "simulation_only": True} @router.get("/accounts/{account_id}/transactions") def account_transactions(request: Request, account_id: str) -> dict: rt = _rt() _sess, user = _authed(request) rt.accounts.require_owned(user.tenant_id, user.id, account_id) txns = rt.repo.list_transactions_for_account(user.tenant_id, account_id) return {"transactions": [transaction_view(rt.repo, t) for t in txns]} # ============================================================= transactions @router.get("/transactions") def list_transactions(request: Request) -> dict: rt = _rt() _sess, user = _authed(request) txns = rt.repo.list_transactions_for_user(user.tenant_id, user.id) return {"transactions": [transaction_view(rt.repo, t) for t in txns]} def _require_visible_txn(rt, user, txn_id): txn = rt.repo.get_transaction(user.tenant_id, txn_id) if txn is None: raise D3Error("transaction_not_found") if txn.initiated_by_user_id == user.id: return txn # Also visible to the owner of the source/destination account. for aid in (txn.source_financial_account_id, txn.dest_financial_account_id): if aid: fa = rt.repo.get_financial_account(user.tenant_id, aid) if fa and fa.owner_type == "customer" and fa.owner_user_id == user.id: return txn raise D3Error("transaction_not_found") @router.get("/transactions/{txn_id}") def get_transaction(request: Request, txn_id: str) -> dict: rt = _rt() _sess, user = _authed(request) txn = _require_visible_txn(rt, user, txn_id) return {"transaction": transaction_view(rt.repo, txn)} @router.get("/transactions/{txn_id}/receipt") def get_receipt(request: Request, txn_id: str) -> dict: rt = _rt() _sess, user = _authed(request) txn = _require_visible_txn(rt, user, txn_id) return {"receipt": build_receipt(rt.repo, txn)} # ============================================================ own transfers @router.post("/transfers/own/options") def own_options(request: Request) -> dict: rt = _rt() _sess, user = _authed(request) return {"accounts": rt.accounts.list_for_user(user.tenant_id, user.id), "max_transfer_minor": rt.config.max_transfer_minor, "step_up_threshold_minor": rt.config.step_up_threshold_minor} @router.post("/transfers/own/submit") def own_submit(request: Request, body: dict = Body(...)) -> dict: rt = _rt() sess, user = _authed(request) _require_csrf(request, sess) rate_limit(request, "d3_transfer", capacity=10, refill_per_sec=0.5) txn = rt.transfers.own_transfer( tenant_id=user.tenant_id, user_id=user.id, actor_role=user.role, source_id=str(body.get("source_account_id", "")), dest_id=str(body.get("dest_account_id", "")), amount_minor=_amount_minor(body), note=str(body.get("note", "")), recent_auth_ok=_recent_auth_ok(sess), idempotency_key=body.get("idempotency_key")) return {"transaction": transaction_view(rt.repo, txn), "receipt": build_receipt(rt.repo, txn)} # =================================================================== p2p @router.post("/recipients/resolve") def resolve_recipient(request: Request, body: dict = Body(...)) -> dict: rt = _rt() _sess, user = _authed(request) rate_limit(request, "d3_recipient", capacity=8, refill_per_sec=0.2) return {"recipient": rt.transfers.resolve_recipient( tenant_id=user.tenant_id, actor_user_id=user.id, handle=str(body.get("handle", "")))} @router.post("/transfers/p2p/options") def p2p_options(request: Request, body: dict = Body(...)) -> dict: rt = _rt() _sess, user = _authed(request) rate_limit(request, "d3_recipient", capacity=8, refill_per_sec=0.2) recipient = rt.transfers.resolve_recipient( tenant_id=user.tenant_id, actor_user_id=user.id, handle=str(body.get("handle", ""))) return {"recipient": recipient, "accounts": rt.accounts.list_for_user(user.tenant_id, user.id), "max_transfer_minor": rt.config.max_transfer_minor} @router.post("/transfers/p2p/submit") def p2p_submit(request: Request, body: dict = Body(...)) -> dict: rt = _rt() sess, user = _authed(request) _require_csrf(request, sess) rate_limit(request, "d3_transfer", capacity=10, refill_per_sec=0.5) txn = rt.transfers.p2p_transfer( tenant_id=user.tenant_id, user_id=user.id, actor_role=user.role, source_id=str(body.get("source_account_id", "")), recipient_handle=str(body.get("handle", "")), amount_minor=_amount_minor(body), note=str(body.get("note", "")), recent_auth_ok=_recent_auth_ok(sess), idempotency_key=body.get("idempotency_key")) return {"transaction": transaction_view(rt.repo, txn), "receipt": build_receipt(rt.repo, txn)} # ============================================================== merchants @router.get("/merchants") def list_merchants(request: Request) -> dict: rt = _rt() _authed(request) _sess, user = _authed(request) return {"merchants": rt.merchants.list_directory(user.tenant_id)} @router.get("/merchants/{merchant_id}") def merchant_detail(request: Request, merchant_id: str) -> dict: rt = _rt() _sess, user = _authed(request) return {"merchant": rt.merchants.view(rt.merchants.get_active(user.tenant_id, merchant_id))} @router.post("/merchants/{merchant_id}/payment-options") def merchant_payment_options(request: Request, merchant_id: str) -> dict: rt = _rt() _sess, user = _authed(request) mch = rt.merchants.get_active(user.tenant_id, merchant_id) return {"merchant": rt.merchants.view(mch), "accounts": rt.accounts.list_for_user(user.tenant_id, user.id), "max_transfer_minor": rt.config.max_transfer_minor} @router.post("/merchants/{merchant_id}/payments") def merchant_pay(request: Request, merchant_id: str, body: dict = Body(...)) -> dict: rt = _rt() sess, user = _authed(request) _require_csrf(request, sess) rate_limit(request, "d3_merchant_pay", capacity=10, refill_per_sec=0.5) txn = rt.merchants.pay( tenant_id=user.tenant_id, user_id=user.id, actor_role=user.role, source_id=str(body.get("source_account_id", "")), merchant_id=merchant_id, amount_minor=_amount_minor(body), note=str(body.get("note", "")), recent_auth_ok=_recent_auth_ok(sess), idempotency_key=body.get("idempotency_key")) return {"transaction": transaction_view(rt.repo, txn), "receipt": build_receipt(rt.repo, txn)} @router.post("/merchant/transactions/{transaction_id}/refunds") def merchant_refund(request: Request, transaction_id: str, body: dict = Body(...)) -> dict: rt = _rt() sess, user = _operator(request) _require_csrf(request, sess) rate_limit(request, "d3_refund", capacity=6, refill_per_sec=0.1) txn = rt.merchants.refund( tenant_id=user.tenant_id, operator_user_id=user.id, merchant_id=str(body.get("merchant_id", "")), transaction_id=transaction_id, amount_minor=_amount_minor(body), recent_auth_ok=_recent_auth_ok(sess), idempotency_key=body.get("idempotency_key")) return {"transaction": transaction_view(rt.repo, txn), "receipt": build_receipt(rt.repo, txn)} # =================================================================== ATM @router.post("/atm/deposits") def atm_initiate(request: Request, body: dict = Body(...)) -> dict: rt = _rt() sess, user = _authed(request) _require_csrf(request, sess) rate_limit(request, "d3_atm", capacity=6, refill_per_sec=0.2) dep = rt.atm.initiate( tenant_id=user.tenant_id, customer_user_id=user.id, destination_account_id=str(body.get("destination_account_id", "")), amount_minor=_amount_minor(body), kiosk_ref=str(body.get("kiosk_ref", "")), idempotency_key=body.get("idempotency_key")) return {"deposit": rt.atm.view(dep)} @router.get("/atm/deposits/{deposit_id}") def atm_get(request: Request, deposit_id: str) -> dict: rt = _rt() _sess, user = _authed(request) dep = rt.repo.get_atm_deposit(user.tenant_id, deposit_id) if dep is None or (dep.customer_user_id != user.id and user.role != "operator"): raise D3Error("deposit_not_found") return {"deposit": rt.atm.view(dep, for_operator=(user.role == "operator"))} @router.post("/atm/deposits/{deposit_id}/customer-confirm") def atm_customer_confirm(request: Request, deposit_id: str) -> dict: rt = _rt() sess, user = _authed(request) _require_csrf(request, sess) dep = rt.atm.customer_confirm(tenant_id=user.tenant_id, customer_user_id=user.id, deposit_id=deposit_id) return {"deposit": rt.atm.view(dep)} @router.post("/operator/atm/deposits/{deposit_id}/confirm") def atm_operator_confirm(request: Request, deposit_id: str) -> dict: rt = _rt() sess, user = _operator(request) _require_csrf(request, sess) rate_limit(request, "d3_atm_confirm", capacity=10, refill_per_sec=0.3) txn = rt.atm.operator_confirm(tenant_id=user.tenant_id, operator_user_id=user.id, deposit_id=deposit_id, recent_auth_ok=_recent_auth_ok(sess)) return {"transaction": transaction_view(rt.repo, txn), "receipt": build_receipt(rt.repo, txn)} @router.post("/operator/atm/deposits/{deposit_id}/reject") def atm_operator_reject(request: Request, deposit_id: str) -> dict: rt = _rt() sess, user = _operator(request) _require_csrf(request, sess) dep = rt.atm.operator_reject(tenant_id=user.tenant_id, operator_user_id=user.id, deposit_id=deposit_id) return {"deposit": rt.atm.view(dep, for_operator=True)} # ============================================================ intent assistant @router.post("/intents") @router.post("/intent-assistant/parse") def intent_parse(request: Request, body: dict = Body(default={})) -> dict: rt = _rt() sess, user = _authed(request) _require_csrf(request, sess) rate_limit(request, "d3_intent", capacity=12, refill_per_sec=0.5) return {"intent": rt.intents.parse(tenant_id=user.tenant_id, user_id=user.id, text=str((body or {}).get("text", "")), origin=str((body or {}).get("origin", "text")))} @router.get("/intents/{intent_id}") def intent_get(request: Request, intent_id: str) -> dict: rt = _rt() _sess, user = _authed(request) intent = rt.intents.get(user.tenant_id, user.id, intent_id) return {"intent": rt.intents.view(intent)} @router.post("/intent-assistant/{intent_id}/clarify") def intent_clarify(request: Request, intent_id: str, body: dict = Body(...)) -> dict: rt = _rt() sess, user = _authed(request) _require_csrf(request, sess) return {"intent": rt.intents.clarify( tenant_id=user.tenant_id, user_id=user.id, intent_id=intent_id, amount_input=body.get("amount"), recipient_ref=body.get("handle"), source_account_id=body.get("source_account_id"), note=body.get("note"))} @router.post("/intent-assistant/{intent_id}/prepare") def intent_prepare(request: Request, intent_id: str) -> dict: rt = _rt() sess, user = _authed(request) _require_csrf(request, sess) return {"intent": rt.intents.prepare(tenant_id=user.tenant_id, user_id=user.id, intent_id=intent_id)} @router.post("/intents/{intent_id}/confirm") def intent_confirm(request: Request, intent_id: str, body: dict = Body(default={})) -> dict: rt = _rt() sess, user = _authed(request) _require_csrf(request, sess) rate_limit(request, "d3_transfer", capacity=10, refill_per_sec=0.5) txn = rt.intents.confirm( tenant_id=user.tenant_id, user_id=user.id, actor_role=user.role, intent_id=intent_id, recent_auth_ok=_recent_auth_ok(sess), source_account_id=(body or {}).get("source_account_id"), dest_account_id=(body or {}).get("dest_account_id")) return {"transaction": transaction_view(rt.repo, txn), "receipt": build_receipt(rt.repo, txn)} @router.post("/intents/{intent_id}/cancel") def intent_cancel(request: Request, intent_id: str) -> dict: rt = _rt() sess, user = _authed(request) _require_csrf(request, sess) return rt.intents.cancel(tenant_id=user.tenant_id, user_id=user.id, intent_id=intent_id) # =============================================================== operator @router.get("/operator/transactions") def op_transactions(request: Request) -> dict: rt = _rt() _sess, user = _operator(request) return {"transactions": [transaction_view(rt.repo, t) for t in rt.repo.list_transactions_for_tenant(user.tenant_id)]} @router.get("/operator/holds") def op_holds(request: Request) -> dict: rt = _rt() _sess, user = _operator(request) holds = rt.repo.list_holds_for_operator(user.tenant_id, state="active") return {"holds": [{"id": h.id, "financial_account_id": h.financial_account_id, "amount_minor": h.amount_minor, "currency": h.currency, "state": h.state, "expires_at": h.expires_at} for h in holds]} @router.get("/operator/atm/deposits") def op_atm_deposits(request: Request) -> dict: rt = _rt() _sess, user = _operator(request) deps = rt.repo.list_atm_deposits(user.tenant_id, state="awaiting_operator_confirmation") return {"deposits": [rt.atm.view(d, for_operator=True) for d in deps]} @router.get("/operator/ledger/health") def op_ledger_health(request: Request) -> dict: rt = _rt() _sess, user = _operator(request) from amanpay.finance.verification import verify_ledger return verify_ledger(rt.store, user.tenant_id)