Architecture

Fundity's backend is a single FastAPI service with two related but distinct feature areas that share the same Termii SMS integration and database:

  1. Offline SMS transfer — a user moves money between two accounts entirely over SMS, with no internet connection on the device.
  2. Phone/OTP authentication — a user logs in with just a phone number and a 6-digit code, gets a session, and can hit protected routes.

Both are POC-scope: seeded/mirror data, not a production ledger.

Deployment topology

Live at offline.getfundity.com — a Docker container on the same Contabo box as fundity-django-backend (separate port, separate Postgres database, separate nginx vhost + TLS cert; network_mode: host so it reaches Postgres on 127.0.0.1 the same way the Django backend does). Deployed via GitHub Actions (.github/workflows/deploy.yml) on every push to main, same rsync-release + docker compose build + health-check-with-rollback pattern as that repo. Running TERMII_MODE=live with real credentials — this is not a mock deployment. fundity-django-backend is a completely separate service with its own database; this repo never reads or writes its tables directly. See /status for current production open items.

Request flow — offline transfer

Flutter App (logged in, real account)
   |  taps "Offline", enters recipient + amount + PIN
   |  app encrypts payload (AES-256-GCM)
   v
Termii
   |  delivers SMS -> triggers inbound webhook
   v
FastAPI Backend
   |  verify X-Termii-Signature (HMAC-SHA512)
   |  -> check idempotency (provider_message_id, BEFORE any balance touch)
   |  -> decrypt payload
   |  -> verify transaction PIN (PBKDF2 hash compare, constant-time)
   |  -> check lockout, check balance
   |  -> debit sender / credit recipient (one atomic DB transaction)
   |  -> generate Fundity reference
   |  -> send response SMS via Termii
   v
PostgreSQL (accounts, offline_messages)

Request flow — OTP login

Client (Flutter app, or curl for testing)
   |  POST /api/v1/auth/otp/request  { phone_number }
   v
FastAPI Backend
   |  rate-limit check (cooldown + hourly cap) against otp_requests
   |  -> Termii Token API POST /api/sms/otp/send
   |     (Termii generates the PIN, owns expiry + attempt counting;
   |      we only ever hold its opaque pin_id)
   v
Termii  --SMS-->  User's phone

User reads the code, submits it back:
   |  POST /api/v1/auth/otp/verify  { phone_number, pin }
   v
FastAPI Backend
   |  Termii Token API POST /api/sms/otp/verify (pin_id, pin)
   |  -> on success: get_or_create_user, issue JWT + refresh token
   v
Client stores tokens, sends `Authorization: Bearer <access_token>`
on subsequent requests to protected routes (e.g. GET /api/v1/auth/me)

Why one backend, two concerns

The OTP/session layer exists because the offline-transfer PIN is a transaction PIN, not a login credential — a user must already be authenticated before they ever see the "Offline" button. These are kept as separate modules (app/services/otp.py + app/services/auth.py vs. app/services/transfer.py + app/crypto/pin.py) precisely so a bug or change in one can't leak into the other. They share only the Termii client and the database.

Module map

Module Responsibility
app/services/termii/ The only place that talks to Termii's HTTP API. base.py defines the contract (TermiiClient); live.py and mock.py implement it; get_termii_client() picks one based on TERMII_MODE.
app/services/otp.py Rate limiting (cooldown + hourly cap), our own audit trail (otp_requests), and issuance/verification via get_termii_client().
app/services/auth.py Session issuance, refresh-token rotation, revocation. The only place that mints or invalidates a session.
app/services/account_linking.py Get-or-create a real per-phone-number offline account, (re)setting its transfer PIN — called from /accounts/link.
app/services/transfer.py The offline-transfer pipeline: idempotency → decrypt → PIN verify (with lockout) → balance check → atomic debit/credit.
app/crypto/aes_gcm.py AES-256-GCM for the offline-transfer SMS payload.
app/crypto/pin.py PBKDF2-HMAC-SHA256 — used for the offline transfer PIN.
app/crypto/jwt.py Access token issuance/verification only — refresh tokens are deliberately not JWTs (see /docs/security).
app/core/security.py Two auth dependencies: require_dev_api_key (dev/test endpoints) and get_current_user (real user sessions).
app/web/router.py This documentation site. Not part of the API — plain server-rendered HTML.

Environment/config strategy

Nothing is hardcoded. Every environment-specific value — API key, base URL, Sender ID, destination numbers, JWT secret, OTP policy — comes from Settings (app/core/config.py), a pydantic-settings model that reads .env. A few things worth calling out:

See /docs/faq for local setup and /docs/security for how secrets are actually handled at rest.