Implementation Notes

How this was actually built, in order, and why each decision was made — not a tutorial, a record for whoever picks this up next.

1. Offline transfer POC (build order)

Built in the sequence the architecture doc specified, because the riskiest piece (encryption matching between Python and Dart) had to be proven before anything depended on it:

  1. Scaffold — FastAPI, async SQLAlchemy via psycopg3 (not asyncpg — psycopg3 has native async support and was already a project dependency), Alembic, health check.
  2. Data layer — accounts (seeded mirror, explicitly never the live account store) and offline_messages (idempotency + audit).
  3. AES-256-GCM round-trip, in complete isolation, before any networking code. Wire format: base64(nonce[12] + ciphertext + tag[16]). This is called out because Python's AESGCM.encrypt() appends the tag automatically, while Dart's cryptography package keeps it separate — a format mismatch here looks exactly like a wrong-key bug and costs a full debugging cycle across two codebases if caught late.
  4. PBKDF2 PIN hashing.
  5. Outbound SMS via Termii.
  6. Full processing pipeline behind /offline/test, so debit/credit/PIN logic could be proven with curl before a single real SMS was involved.
  7. Real inbound webhook with signature verification.
  8. Duplicate protection under real retries — proven as an explicit test, not just an assumption.
  9. A dedicated security sweep — grep the codebase for anywhere a PIN or decrypted payload could reach a log or a persisted field outside the verification step itself.

A real bug found in step 9: FastAPI's default 422 validation-error handler echoes the raw invalid input value back in the response body. A PIN that failed length validation would have been sent back to the caller verbatim. Fixed with a custom RequestValidationError handler that redacts any field named pin before the response is serialized — see app/main.py.

2. Flutter side

The Dart AES-GCM module (lib/core/crypto/aes_gcm.dart) mirrors the Python module field-for-field. It was proven both directions: Python encrypts, Dart decrypts the exact same ciphertext; Dart encrypts, Python decrypts the exact same ciphertext. Cross-language proof, not just "the Dart tests pass on their own."

Native SMS send/receive is realistically Android-only — iOS sandboxes SMS so tightly that apps can only open the native composer for the user to tap send, and can't read incoming SMS at all. Rather than build a half-working cross-platform abstraction, the app explicitly branches: Android gets full auto send + listen (another_telephony — the original telephony package is discontinued, this is the maintained fork with the same API), iOS gets manual compose + a "paste the reply" decrypt flow.

A real build failure surfaced here worth recording: another_telephony's bundled Gradle config sets Java 11 for javac but leaves Kotlin at the old 1.8 default, which newer Android Gradle Plugin versions reject outright as an inconsistent JVM target. Fixed by forcing every non-:app Gradle subproject's Java and Kotlin compile tasks to the same JVM version in android/build.gradle.kts — a workaround for a third-party plugin's outdated build config, not something in our control to fix upstream.

3. Termii production-readiness / OTP auth layer

This was a distinct expansion, added after the offline-transfer POC was already working, in response to three explicit gaps: hardcoded config, no OTP flow, no auth/session layer.

Why Termii's Token API instead of a hand-rolled OTP system. Termii has a dedicated OTP product (/api/sms/otp/send, /api/sms/otp/verify) that generates the PIN, enforces pin_time_to_live and pin_attempts server-side, and hands back an opaque pin_id. Building our own PIN generation/expiry/attempt-counting would have been strictly more code, more state to get right, and — critically — would mean we hold the plaintext OTP at some point, which the real Termii API never requires. app/services/otp.py only adds what Termii doesn't provide: resend cooldown and an hourly cap, both enforced against our own otp_requests audit table before Termii is even called.

Why 6-digit PIN. Matches Termii's supported pin_length range (4–8) and is the de facto standard for SMS OTP (matches what most Nigerian bank/fintech apps use) — long enough to resist brute force within the attempt limit, short enough to not be a UX tax. Enforced in two places: the Termii request itself (pin_length: 6) and Pydantic's pattern=r"^\d{6}$" on the verify endpoint, so a malformed PIN never even reaches Termii.

Why JWT access + opaque rotating refresh, not cookies. See /docs/security for the full reasoning — the short version is that the client is a mobile app, not a browser, and bearer tokens are the natural fit. Refresh tokens are intentionally not JWTs, specifically so logout/revocation is an immediate DB write instead of "wait for the token to expire."

Why a provider abstraction (TermiiClient) instead of an if TERMII_MODE == "mock" scattered through the code. Every call site — sms.py, webhook.py, otp.py — depends on the abstract contract in app/services/termii/base.py, resolved once via get_termii_client(). Adding a third mode, or swapping providers entirely, never touches a call site.

Sender ID fallback was built as automatic retry, not a manual flag. The original plan was an env var like TERMII_SENDER_ID_APPROVED=true/false that a human flips once Termii approves the ID. That's strictly worse than just trying the primary ID first and catching SENDER_ID_NOT_APPROVED — the automatic version needs zero human action once approval lands, and degrades gracefully (falls back) if it doesn't.

4. Real per-user offline accounts and PIN lockout

Added for production deployment: every offline transfer previously debited the same single hardcoded seed account (0000000001) regardless of who was using the app.

Why account linking rides the existing OTP+JWT auth instead of a new auth scheme. The OTP+JWT layer from section 3 already proves phone ownership and was otherwise unused in production — POST /accounts/link and GET /accounts/me (app/services/account_linking.py) just put it to work: get-or-create a POC Account keyed by current_user.phone_number, generating a fresh account number on first link.

Why re-linking always refreshes the PIN rather than rejecting a second call. Re-running OTP verification already re-proves phone ownership, so letting /accounts/link overwrite pin_hash/pin_salt on an existing account (while preserving its account number and balance) doubles as the only "forgot my offline PIN" recovery path — no separate reset endpoint needed.

Why PIN lockout was added now. There was previously no limit on the offline transfer PIN at all — only 10,000 possible 4-digit combinations stood between a guesser and a real account, over an endpoint with no other rate limiting. Account.failed_pin_attempts/locked_until (app/services/transfer.py) lock the account for 30 minutes after 5 wrong attempts, checked before the PIN comparison even runs.

5. Production deployment

The repo had no Dockerfile, no CI, and no deploy target at all until this point — just local uv run uvicorn.

Why the Dockerfile/docker-compose/GitHub Actions pattern is copy-adapted from fundity-django-backend instead of designed fresh. That repo already has a proven, working deploy pipeline on the exact same Contabo box (rsync a release into /var/www/apps/<name>/releases/<ts>/, docker compose build, health-check loop with automatic image rollback on failure). Inventing a different pattern for this repo would mean two deploy mechanisms to maintain on one box for no benefit — the differences that exist (host networking for Postgres access, alembic upgrade head instead of manage.py migrate, uv/pyproject.toml instead of pip/requirements.txt) are the minimum needed to fit this repo's actual stack, not a redesign.

Why port 8010, not 8000. fundity-django-backend's gunicorn already owns 127.0.0.1:8000 via the same host-networking approach — both containers share the host's network namespace, so they need distinct ports the way two processes on the same machine would.

Why environment-scoped secrets instead of one combined env blob. The original plan mirrored fundity-django-backend's single PRODUCTION_ENV secret containing the whole .env file as one value. In practice the secrets got set up as individually-named GitHub Actions environment secrets instead (DATABASE_URL, TERMII_API_KEY, etc.) — the deploy workflow was adapted to assemble .env from those instead of requiring a hand-typed multi-line blob, which is easier to audit (secret names visible in the GitHub UI) and rotate individually.

Why the root-requiring setup (new Postgres role/db, nginx vhost, certbot) is a separate script a human runs once, not something automated. Same reasoning as the original fundity-django-backend bootstrap: the deploying user (adewale, same as the other repos) has no passwordless sudo on this box by design, and shouldn't need it for routine deploys — only this one-time setup needs root, so it's isolated into deploy/bootstrap.sh instead of weakening that boundary for everything else.

What's still open

See /status for the live checklist. The real, external blockers — Termii Sender ID approval and an on-device end-to-end test — aren't things more code can solve; they need Termii's review queue and a physical test run respectively. Separately, bridging this POC's isolated test ledger to fundity-django-backend's real customer balances is real backend work, not something this repo can do alone — see /status.