| """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): |
| token = str(exc) |
| return JSONResponse(status_code=status_for(token), content={"detail": token}) |
|
|
| |
| 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): |
| token = str(exc) |
| return JSONResponse(status_code=d2_status_for(token), content={"detail": token}) |
|
|
|
|
| |
| 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)) |
|
|
|
|
| |
| @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} |
|
|
|
|
| |
| @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]} |
|
|
|
|
| |
| @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 |
| |
| 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)} |
|
|
|
|
| |
| @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)} |
|
|
|
|
| |
| @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)} |
|
|
|
|
| |
| @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)} |
|
|
|
|
| |
| @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)} |
|
|
|
|
| |
| @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) |
|
|
|
|
| |
| @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) |
|
|