amanpay / api /finance_routes.py
MHamdan's picture
CI deploy 2550795
ef983af verified
Raw
History Blame Contribute Delete
19.2 kB
"""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)