A small insurance back office as a REST service. Agents write policies and file claims, assessors decide them, admins see everything. Built with FastAPI, PostgreSQL and Redis, with JWT auth, role based access control and idempotent claim filing.
The interesting part isn't the CRUD. It's the rules that stop money going out twice.
| Rule | Why it's here |
|---|---|
| A claim can't exceed the policy's sum insured | The obvious one |
| Approved claims can't add up past the sum insured | The one people miss. Three claims of 40k each on a 100k policy are each individually valid |
| A rejected claim frees its cover again | A rejection must not quietly eat the limit |
| A claim can't be decided twice | Second decision returns 409, not a silent overwrite |
| You can't approve more than was claimed | Partial approval is allowed, inflation isn't |
| No claims on a cancelled or out-of-period policy | Checked against today, not against the record alone |
| An agent sees only its own policies and claims | Returns 404, not 403, so the API never confirms a record exists to someone who can't read it |
Idempotency-Key makes filing safe to retry |
A dropped response must not create a second claim |
| Money is stored in paise as an integer | Never a float. 0.07 comes back as 0.07 |
| agent | assessor | admin | |
|---|---|---|---|
| Create a policy | yes | no | yes |
| Read a policy | own only | all | all |
| Cancel a policy | no | no | yes |
| File a claim | own policies | no | yes |
| Decide a claim | no | yes | yes |
The role is carried in the JWT for speed but re-checked against the database row on every request, so a demotion takes effect immediately instead of waiting for the token to expire.
cp .env.example .env # set JWT_SECRET
docker compose up --build # api on :8000, postgres on :5432, redis on :6379
open http://localhost:8000/docsWithout Docker:
python -m venv .venv && source .venv/bin/activate
pip install -r requirements-dev.txt
export DATABASE_URL="sqlite:///./local.db" JWT_SECRET="dev-secret"
uvicorn app.main:app --reloadBASE=http://localhost:8000
curl -s -X POST $BASE/auth/register -H 'content-type: application/json' \
-d '{"email":"agent@example.com","password":"sup3rsecret","role":"agent"}'
curl -s -X POST $BASE/auth/register -H 'content-type: application/json' \
-d '{"email":"assessor@example.com","password":"sup3rsecret","role":"assessor"}'
AGENT=$(curl -s -X POST $BASE/auth/login \
-d 'username=agent@example.com&password=sup3rsecret' | python3 -c 'import sys,json;print(json.load(sys.stdin)["access_token"])')
ASSESSOR=$(curl -s -X POST $BASE/auth/login \
-d 'username=assessor@example.com&password=sup3rsecret' | python3 -c 'import sys,json;print(json.load(sys.stdin)["access_token"])')
POLICY=$(curl -s -X POST $BASE/policies -H "authorization: Bearer $AGENT" \
-H 'content-type: application/json' -d '{
"holder_name":"Asha Kumari","product":"motor-own-damage",
"premium":"12500.00","sum_insured":"500000.00",
"start_date":"2026-01-01","end_date":"2026-12-31"}' \
| python3 -c 'import sys,json;print(json.load(sys.stdin)["id"])')
# Retry this as many times as you like. The key makes it file exactly one claim.
curl -s -X POST $BASE/claims -H "authorization: Bearer $AGENT" \
-H 'content-type: application/json' -H 'Idempotency-Key: claim-2026-0042' \
-d "{\"policy_id\":$POLICY,\"amount\":\"20000.00\",\"reason\":\"bumper and headlight\"}"
curl -s -X POST $BASE/claims/1/decision -H "authorization: Bearer $ASSESSOR" \
-H 'content-type: application/json' \
-d '{"approve":true,"amount":"18000.00","note":"depreciation applied"}'Policy reads go through Redis with a short TTL, and a cancel drops the key. Two things the cache deliberately does not do:
- It caches the row, not the permission. Authorisation runs again on every request, so a warm cache can't leak a policy to the wrong agent. There's a test for exactly that.
- It never takes the API down with it. Every Redis call is wrapped, and a failure is treated
as a miss.
test_a_broken_cache_does_not_break_the_apipoints an exploding client at it and the endpoint still answers 200.
pytest --cov=app --cov-report=term-missing35 tests, 93% line coverage, running against SQLite so CI needs no services. They cover the auth surface, every role boundary, the money rules above, idempotency including two retries racing each other, and the cache behaviour with a fake Redis.
CI runs ruff, the suite with coverage, and a separate job that builds the image, boots the
container and curls /health.
app/
config.py settings from the environment
db.py engine, session, declarative base
models.py users, policies, claims; money in paise
schemas.py request and response bodies, rupee to paise conversion
security.py bcrypt hashing and JWT issue/verify
deps.py current_user and the require(*roles) guard
cache.py Redis with a no-op fallback
routers/ auth, policies, claims
tests/ 35 tests
- Alembic migrations.
create_allis fine at this size and wrong at the next one. - A refresh token flow. Right now a token just expires.
- An audit trail table, because in this domain "who approved it and when" is usually a regulatory question rather than a nice to have.