A microservices digital wallet built with FastAPI, PostgreSQL, Redis,
RabbitMQ, and Docker Compose. This repo is a runnable slice of the larger
architecture design. Users can register and
verify their email (OTP), open a wallet in USD/EUR/GBP/PKR, fund it, save
beneficiaries, and send money — including across currencies, converted
at the live exchange rate and screened for fraud. Completed transfers and
verification codes are delivered by real email. A React Native mobile app
(mobile/) is the user-facing client (multi-currency UI, statements,
PDF export, identity verification).
This README doubles as a learning guide for Docker and Postgres — it explains not just what to run but why each piece exists.
| Service | Host port | What it does |
|---|---|---|
| api-gateway | 8000 | Single public entrypoint. Rate-limits per IP (Redis) and proxies to the services below. |
| user-service | 8001 | Register, login (issues JWT), profile (/me), email verification (OTP) and email change. |
| wallet-service | 8002 | Create wallet (currency-typed, email-verified only), balance, mock deposit, statement with counterparty + FX detail, beneficiaries, payee lookup. |
| transaction-service | 8003 | Atomic peer-to-peer transfers. Converts cross-currency at the live rate, screens for fraud, and exposes a /quote preview. |
| fraud-service | 8004 | Rule-based, real-time fraud screening. Scores each transfer and can block it. |
| notification-service | — | Background worker. Consumes "transfer completed" events from RabbitMQ and sends a real email per party (currency-aware). |
| migrator | — | One-shot: runs the Alembic database migrations at startup, then exits. |
| postgres | 5432 | The single shared database. |
| redis | 6379 | Backs the gateway's rate limiter. |
| rabbitmq | 5672 / 15672 | Message broker. Carries transfer events to the notification-service. 15672 is the management UI. |
You normally only call port 8000 (the gateway). The other ports are exposed so you can poke individual services directly while learning. The fraud-service is internal — it's called service-to-service by the transaction-service and is deliberately not routed through the public gateway. Port 8004 is exposed only so you can read its fraud logs while learning.
There's also a React Native (Expo) mobile app in mobile/ that
drives this backend — register, open a wallet, deposit, send money, view activity.
See mobile/README.md to run it on your phone.
Not in this pass yet (next steps): merchant/bill payments.
You need Docker Desktop (it bundles both docker and docker compose). If you
don't have it yet, on macOS:
- Download Docker Desktop for Mac from https://www.docker.com/products/docker-desktop/ (pick the Apple Silicon or Intel build to match your Mac).
- Open the
.dmg, drag Docker to Applications, then launch it. - Wait for the whale icon in the menu bar to stop animating — that means the Docker engine is running.
- Verify in a terminal:
docker --version docker compose version
Homebrew alternative:
brew install --cask dockerthen launch Docker Desktop once to finish setup.
cd securepay
# 1. Create your local env file (compose reads it for passwords & secrets)
cp .env.example .env
# 2. Build images and start everything (first run downloads images: a few GB)
docker compose up --build
# ...wait until logs settle. In a SECOND terminal, run the end-to-end test:
python3 scripts/smoke_test.pyTo stop: Ctrl+C, then docker compose down.
To also wipe the database: docker compose down -v.
# Register + log in
curl -s -X POST localhost:8000/api/users/register -H 'Content-Type: application/json' \
-d '{"email":"me@example.com","phone_number":"+15551234","password":"Password123!","first_name":"Me","last_name":"User"}'
TOKEN=$(curl -s -X POST localhost:8000/api/users/login -H 'Content-Type: application/json' \
-d '{"email":"me@example.com","password":"Password123!"}' | python3 -c 'import sys,json;print(json.load(sys.stdin)["access_token"])')
# Verify your email first — a wallet can't be created until you do. This sends a
# 6-digit code by email (see "Email" below); start returns dev_code only when no
# email provider is configured, otherwise read it from `docker compose logs
# user-service`.
curl -s -X POST localhost:8000/api/users/me/verify/start -H "Authorization: Bearer $TOKEN"
curl -s -X POST localhost:8000/api/users/me/verify/confirm -H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' -d '{"code":"123456"}'
# Create a wallet (in a currency), deposit, check balance
curl -s -X POST localhost:8000/api/wallets/create -H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' -d '{"currency":"USD"}'
curl -s -X POST localhost:8000/api/wallets/me/deposit -H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' -d '{"amount":"500.00"}'
curl -s localhost:8000/api/wallets/me -H "Authorization: Bearer $TOKEN"Each service also serves interactive API docs (Swagger UI) at, e.g., http://localhost:8001/docs.
Open a psql shell inside the running database container:
docker compose exec postgres psql -U securepay -d securepayThen try:
\dt -- list tables
\d wallets -- describe the wallets table (columns, constraints)
SELECT * FROM users;
SELECT id, user_id, balance FROM wallets;
SELECT * FROM transactions ORDER BY created_at DESC;
\q -- quitNotice the balance >= 0 CHECK constraint on wallets and the unique
idempotency_key on transactions — the database enforces correctness even if
application code has a bug.
The schema is versioned with Alembic — the migration tool most
Python/Postgres projects use. Rather than each service calling create_all() on
startup (which races when several boot at once, and can't express changes like
"add a column" or "rename a table"), there is one ordered history of migration
files in migrations/versions/.
How it runs. A dedicated one-shot migrator container runs alembic upgrade head before any application service boots, then exits. Every service
that touches the database waits for it to finish:
depends_on:
migrator:
condition: service_completed_successfullySo exactly one process ever builds the schema — no more concurrent create_all()
races. You don't run anything by hand: docker compose up applies migrations
automatically. Alembic records the applied version in an alembic_version
table, so on the next up it sees the database is already current and does
nothing.
Changing the schema later. When you edit the ORM models in
shared/models.py, generate a migration that captures the
diff between your models and the live database:
# 1. Edit shared/models.py (add a column, table, index, ...).
# 2. Autogenerate a migration. The -v mount makes the new file land on your
# host (in migrations/versions/), not just inside the container:
docker compose run --rm -v "$PWD/migrations:/app/migrations" migrator \
alembic revision --autogenerate -m "add merchants table"
# 3. READ the generated file, then apply it:
docker compose up -d migrator # re-runs the one-shot migratorAlways review the generated file before applying — autogenerate is an excellent
first draft, not gospel (it can miss data backfills and some constraint
changes). Handy read-only commands (run them the same docker compose run way):
alembic current, alembic history.
client ──> api-gateway (8000) ──┬──> user-service ┐
rate limit + routing ├──> wallet-service ├──> postgres (shared DB)
└──> transaction-service ──┤
│ │ │
│ └──> fraud-service (internal, 8004)
│
└─ publishes "transaction.completed"
│
rabbitmq (broker) ──> notification-service
(writes notifications)
redis (rate limiter)
The fraud-service sits behind the transaction-service, not behind the gateway: a client never calls it directly. Before a transfer commits, the transaction-service asks the fraud-service "is this transfer suspicious?" and acts on the answer.
After a transfer commits, the transaction-service publishes an event to RabbitMQ and returns immediately — it does not wait for anyone to react. The notification-service consumes those events on its own schedule. This is the opposite of the fraud call: fraud screening is synchronous (the transfer waits for the answer because it changes the outcome), while notifications are asynchronous (the money has already moved, so they must never slow down or fail the payment).
A few design choices worth understanding:
- Shared database. All services talk to one Postgres database. This is a
deliberate, common pattern for an MVP: a transfer debits one wallet and credits
another inside a single ACID transaction (
transaction-service), which is far simpler and safer than coordinating money across separate databases. - Stateless JWT auth.
user-servicesigns tokens withJWT_SECRET; every other service verifies them with the same secret. The gateway just forwards theAuthorizationheader — no shared session store needed. - Row locking for money. Deposits and transfers use
SELECT ... FOR UPDATEso two concurrent operations on the same wallet can't corrupt the balance. Transfers lock wallets in id order to avoid deadlocks. - Idempotency keys. Sending the same transfer twice (e.g. after a network
retry) is safe: the unique
idempotency_keymakes the second call return the original transaction instead of moving money again.
Every peer-to-peer transfer is screened before any money moves. The flow is:
transaction-service fraud-service
│ POST /p2p (you send $600)
│
│ "score this transfer" ───────────> runs 3 rules against transfer history
│ returns a score + an action
│ <─────────── { score: 75, action: "block" }
│
├─ action == "block" ─> reject the transfer with HTTP 403, no money moves
└─ otherwise ─> debit sender, credit recipient, commit
The fraud-service is rule-based (no machine learning yet — that's a later
step). Each rule that fires adds points to a risk score. The rules and their
points live in one place at the top of
services/fraud-service/app/main.py so the
policy is easy to read and tune:
| Rule | Fires when… | Points |
|---|---|---|
| high_velocity | More than 5 transfers from this wallet in the last 5 minutes | +30 |
| unusual_amount | The transfer is more than 2× this wallet's average transfer | +25 |
| new_large_recipient | First-ever payment to this recipient and it's ≥ $500 | +20 |
The points are summed into a 0–100 score, which maps to a risk level and an action:
| Score | Risk level | Action | Meaning |
|---|---|---|---|
| 0–29 | low | approve | Looks normal, let it through. |
| 30–59 | medium | review | Allowed, but logged for a human to look at later. |
| 60–84 | high | block | Stopped with HTTP 403. |
| 85–100 | critical | block | Stopped with HTTP 403. |
The block threshold (60) is configurable via FRAUD_BLOCK_THRESHOLD. Only
transfers that trip at least one rule are written to the fraud_logs table — a
clean transfer leaves no row, keeping the log focused on what actually mattered.
Worked example (the demo below): 6 rapid $10 transfers, then a $600 transfer to someone brand-new trips all three rules at once → 30 + 25 + 20 = 75 → high → blocked.
- Fail-open, not fail-closed. If the fraud-service is down or slow, the
transaction-service lets the transfer proceed rather than freezing all payments
during a fraud outage. For an MVP that's the right availability tradeoff;
a production system might "fail closed" for large amounts. See
_fraud_checkfor the comment that explains this. - Never hold a database lock across a network call. This one bit us, and it's
a great lesson. The transfer locks both wallet rows with
SELECT … FOR UPDATEso balances can't be corrupted. Originally the fraud check ran while those locks were held. But the fraud-service writes afraud_logsrow that references the same wallet — and that INSERT needs its own lock on that wallet row. So the two sides waited on each other. Postgres couldn't even report it as a deadlock, because one side was waiting on a network socket, not a lock; it just stalled until the 2-second HTTP timeout fired, which (fail-open) let the bad transfer through. The fix: run the fraud check before taking any row locks. Rule of thumb: don't make a network call while holding a DB lock.
With the stack running:
python3 scripts/fraud_demo.pyIt funds a sender, makes 6 normal transfers (all allowed), then makes one rapid large transfer to a new recipient — which gets blocked with HTTP 403 — and prints the fraud log that was recorded.
You can also read the fraud logs directly (the service is internal, so this hits port 8004, not the gateway):
curl -s 'localhost:8004/logs' | python3 -m json.toolOr in psql:
SELECT wallet_id, fraud_score, risk_level, action_taken, detected_signals
FROM fraud_logs ORDER BY created_at DESC;When a transfer completes, both people should be told ("you sent $X", "you received $X"). We could write those notifications inline at the end of the transfer, but that would couple a payment to a side effect: if the email step were slow or broke, it would slow down or fail a payment that already succeeded. Instead we use a message broker (RabbitMQ) to do the work asynchronously.
transaction-service rabbitmq notification-service
│ (transfer already committed)
│
├─ publish "transaction.completed" ──> [ securepay.events ] ──> [ notifications ]
│ {tx_id, sender, recipient, amount} topic exchange durable queue
│ │
└─ return 201 to the client immediately consume, write a notification
(does NOT wait for the notification) row per party, log "email -> …"
- Publish — via a transactional outbox. The event is written as a ROW in
outbox_eventsinside the same database transaction as the transfer, then a drainer publishes pending rows to RabbitMQ (retrying on a timer if the broker is down) and marks them sent. Either the transfer and its event both commit or neither does — at-least-once delivery, so a completed transfer can never silently lose its notification. - Route. The event goes to a topic exchange named
securepay.eventswith a routing key liketransaction.completed. A topic exchange routes by a dotted key, so new consumers can subscribe to patterns (the notification-service bindstransaction.*) without the producer changing. - Consume. The notification-service
is a background worker — not an HTTP server (no port, no uvicorn). It
blocks in
start_consuming(), and for each event sends a real email to each party (via Resend/Brevo — see Email below) and writes one row to thenotificationstable as the audit trail. The amount is shown in each wallet's own currency, so the sender sees what they paid and the recipient sees the converted amount they received (e.g.You sent £100.00/You received $134.03). With no email key configured it just logs instead — so the project still runs without credentials.
- Decoupling. The transaction-service doesn't know or care who reacts to a transfer. You could add an SMS service, an analytics consumer, or a fraud back-test later — each just binds its own queue to the same exchange. The producer never changes.
- Resilience. The queue is durable and messages are persisted to disk
(
delivery_mode=2), and the consumer acks a message only after it's handled. So if the notification-service is down when a transfer happens, the event waits in the queue and is delivered when it comes back — notifications aren't lost. - Poison messages. If a message can't be parsed/handled, the consumer
nacks it without requeueing, so one bad message can't loop forever. - Independent scaling. If notification volume grew, you'd run more
notification-service replicas pulling from the same queue — without touching
the payment path.
basic_qos(prefetch_count=10)hands each worker a few messages at a time rather than the whole backlog.
Gotcha worth knowing: RabbitMQ's built-in
guestuser can only connect from localhost. Across the Docker network that fails, so we set an explicitRABBITMQ_USER/RABBITMQ_PASSWORD(see.env.example) that both the broker and the services use.
With the stack running, make any transfer (the smoke test does), then watch the worker react and inspect the rows it wrote:
python3 scripts/smoke_test.py # makes a transfer
docker compose logs notification-service | grep 'email ->'-- in psql
SELECT user_id, channel, destination, message, transaction_id, status
FROM notifications ORDER BY id;You can also open the RabbitMQ management UI at http://localhost:15672
(log in with the RABBITMQ_USER / RABBITMQ_PASSWORD from your .env) to watch
the securepay.events exchange and the notifications queue in real time.
A wallet is opened in one currency — USD, EUR, GBP, or PKR (validated against
ALLOWED_CURRENCIES in shared/fx.py). You can still pay someone
who holds a different currency: the transaction-service converts the
transfer at the live rate.
- Rates come from a free, key-less provider (
open.er-api.com), cached in-process for an hour so a burst of transfers doesn't hammer it. - On a cross-currency transfer the sender is debited in their currency and
the recipient is credited the converted amount in theirs; the rate and the
recipient amount are stored on the
transactionsrow. - If a rate can't be fetched for a cross-currency transfer, the transfer is rejected (HTTP 503) — we never move money at the wrong value.
GET /api/transactions/quote?recipient_wallet_id=…&amount=…previews a conversion so the app can show "they receive ≈ X" before you send.
# Preview converting 100 (your currency) to a recipient's wallet:
curl -s "localhost:8000/api/transactions/quote?recipient_wallet_id=2&amount=100" \
-H "Authorization: Bearer $TOKEN" | python3 -m json.toolLike a bank's payee list: you save someone once, then send to them by name. The
wallet-service owns these (/api/wallets/me/beneficiaries).
- Add a payee by wallet id; the API resolves and stores nothing extra — it returns the payee's real name (from their account) and their wallet currency, so the app shows "To Alice Smith", not a bare number.
- Look up anyone first with
GET /api/wallets/lookup?email=…(or?wallet_id=…) — this powers the app's "add by email or wallet id" and the choice to save the payee or just send once. - A
beneficiariestable (migration0003) stores(owner_user_id, wallet_id, nickname), unique per owner+wallet.
Email verification (OTP). A user must verify their email before they can open
a wallet — wallet-service returns 403 until then.
POST /api/users/me/verify/startgenerates a 6-digit code, stores it in Redis with a 5-minute TTL (auto-expiry — the right tool for one-time codes), and emails it.POST /me/verify/confirmchecks it and flipskyc_verifiedto true.- Change email the same safe way:
/me/email/change/startsends a code to the new address;/me/email/change/confirmswitches it only once that code is confirmed — so you can't move your account to an address you don't own.
Sending email (Resend / Brevo). Both the OTP and the transfer notifications go
out as real email from shared/email.py, which prefers
Resend (set RESEND_API_KEY), falls back to
Brevo (BREVO_API_KEY), and otherwise just logs the
email — so the stack runs fine without any credentials. It's best-effort: a
failed send never breaks a verification or a payment. Set one provider's key plus
EMAIL_SENDER (an address on a domain you've verified with that provider) in
.env (see .env.example).
Deliverability (so email doesn't land in spam). Authenticate your sending domain with the provider: SPF (add the provider's
include:to your SPF TXT record), DKIM (the CNAME/TXT records the provider gives you), and a DMARC record. With all three passing, mailbox providers trust your mail. Sending from a dedicated subdomain (e.g.send.example.com) also helps. A brand-new domain still needs a short reputation "warm-up". SMS is not wired up — truly free SMS doesn't exist — so the OTP is delivered by email.
securepay/
├── docker-compose.yml # defines all 10 containers + healthchecks
├── requirements.txt # shared Python deps for every service
├── .env.example # copy to .env (passwords, JWT, RabbitMQ + Brevo)
├── alembic.ini # Alembic config (schema migrations)
├── migrations/ # the ordered migration history
│ ├── env.py # wires Alembic to the models + DATABASE_URL
│ └── versions/ # one file per schema change (0001_initial…, 0002_notifications, 0003_beneficiaries_and_fx)
├── shared/ # code reused by every service
│ ├── config.py # env-driven settings (fraud, rabbitmq, fx, email)
│ ├── database.py # SQLAlchemy engine + session (schema = Alembic)
│ ├── events.py # best-effort RabbitMQ event publisher
│ ├── fx.py # live currency rates + conversion
│ ├── email.py # best-effort email sender (Brevo)
│ ├── models.py # User, Wallet, Transaction, FraudLog, Notification, Beneficiary
│ └── security.py # password hashing + JWT
├── services/
│ ├── api-gateway/ # proxy + rate limiting
│ ├── user-service/ # auth
│ ├── wallet-service/ # wallets
│ ├── transaction-service/ # transfers (calls fraud-service, publishes events)
│ ├── fraud-service/ # rule-based fraud screening (internal)
│ ├── notification-service/ # background worker: consumes events, writes notifications
│ └── migrator/ # one-shot: runs `alembic upgrade head`
├── scripts/
│ ├── smoke_test.py # end-to-end check
│ └── fraud_demo.py # demonstrates a transfer getting blocked
└── mobile/ # React Native (Expo) wallet app — see mobile/README.md
Each service's Dockerfile uses the repo root as its build context so the
image can include both shared/ and that service's app/.
Fraud service (rule-based first).✅ Done — see Fraud screening above. Next here: layer an ML model on top of the same score.Alembic migrations to replace✅ Done — see Database schema & migrations above.create_all().RabbitMQ + notification-service for async notifications on completed transfers.✅ Done — see Notifications (RabbitMQ) above, now with a transactional outbox for an at-least-once delivery guarantee.Frontend wired to the gateway.✅ Done — a React Native (Expo) mobile app inmobile/(auth, multi-currency wallet, deposit, P2P send, statement + PDF). Next here: push notifications and a web build.Multi-currency wallets + live FX conversion.✅ Done — see Multi-currency & exchange rates above.Beneficiaries (saved payees) with email/wallet-id lookup.✅ Done — see Beneficiaries above.Email verification (OTP) + real email sending (Brevo) for codes and transfer notifications.✅ Done — see Email above. Next here: real SMS (paid) so the OTP can also go to a phone.- Tests with
pytest+ a throwaway Postgres container. - Merchant / bill payments (deferred from the architecture doc).