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:
- Offline SMS transfer — a user moves money between two accounts entirely over SMS, with no internet connection on the device.
- 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:
TERMII_MODE=live|sandbox|mockselects the live-vs-mock implementation (get_termii_client()).mockneeds no credentials at all, which is what makes it viable for CI.- Sender ID fallback chain:
TERMII_SENDER_ID(your custom, possibly-pending ID) is tried first; on aSENDER_ID_NOT_APPROVEDresponse, the client automatically retries withTERMII_FALLBACK_SENDER_ID. Once Termii approves the custom ID, it's used automatically — no flag to flip, no deploy. - Fail-fast validation:
Settingshas amodel_validatorthat refuses to start the app inlive/sandboxmode without real Termii credentials, and refuses to start at all without aJWT_SECRET_KEY.mockmode is the exception — it needs no credentials. - Destination numbers are always request input, never a constant —
toon/sms/send,phone_numberon the OTP endpoints,sender_msisdnextracted from the inbound webhook body.
See /docs/faq for local setup and /docs/security for how secrets are actually handled at rest.