A small backend (FastAPI + SQLite) and a static demo frontend that together demonstrate: request validation, safe handling of concurrent writes, duplicate-request prevention, consistent aggregate updates, and a multi-factor, abuse-resistant ranking system.
POST /transaction record a transaction for a user
GET /summary/{userId} a user's totals, ranking score and rank
GET /ranking paginated leaderboard
Live demo UI is served from the same app at /. Interactive API docs
(Swagger UI, auto-generated by FastAPI) are at /docs.
python3 -m venv venv && source venv/bin/activate # optional but recommended
pip install -r requirements.txt
uvicorn main:app --reload --port 8000Open http://127.0.0.1:8000/ for the demo UI, or http://127.0.0.1:8000/docs for interactive API docs.
A SQLite file (data.db, configurable via the DB_PATH env var) is
created automatically in the working directory on first run.
pip install -r requirements-dev.txt
pytest -v # 15 unit/integration tests, no server needed
python3 tests/concurrency_test.py # start the server first, then run this separatelyThe app is a single Python service (API + static frontend in one process), so any "deploy a Python web service" platform works. The simplest free option:
Render (recommended — render.yaml included):
- Push this folder to a GitHub repo.
- On render.com: New + → Blueprint → point it at your repo.
- Render reads
render.yamland deploys automatically. You'll get a public URL likehttps://transaction-ranking-service.onrender.com.
Railway / Heroku-style platforms: the included Procfile works directly — connect the repo and deploy.
Docker (any host that runs containers):
docker build -t txn-ranking .
docker run -p 8000:8000 txn-rankingPersistence on a free hosting tier: free instances on platforms like Render have an ephemeral filesystem — the SQLite file resets on every redeploy and whenever a spun-down free instance restarts after inactivity. This is fine for a live demo (it stays consistent for as long as the instance is up, which is what actually matters for correctness), but isn't durable storage. For real durability, either attach a persistent disk (paid plan), or point
DB_PATHoutside the repo at a mounted volume, or swap the storage layer for managed Postgres — the SQL indb.py/service.pyis plain, portable SQL with no SQLite-specific syntax beyondPRAGMAsetup, so the port is small.
Note on how this was built: I implemented and tested this entirely in a sandboxed dev environment (ran the server, hit every endpoint with curl, ran the concurrency stress test, ran the pytest suite — all included below and in
tests/). That environment can't expose a public URL, so the actual "make it live" step — pushing to GitHub and deploying on Render — is one I'd do from my own machine/account in a few minutes using the steps above.
This is a generic points/value ledger, not tied to a specific business domain, since the brief didn't pin one down. Concretely:
- A "transaction" is just
{user_id, amount, description?}— think "this user did something worthamountpoints/rupees." No transfers between users, no transaction "type" enum — kept minimal on purpose. - No mock/seed data ships with the app. The database starts empty; every number you see comes from requests you actually make (via the UI buttons, curl, or the test suite). This was a deliberate choice so the grader sees the real system behave, not canned numbers.
- All tunable constants (rate limits, score weights, caps) live in one
place,
config.py, with inline justification for each value. - Currency symbol (₹) in the UI is cosmetic only; the API itself is currency-agnostic and never assumes INR.
{
"user_id": "priya_k",
"amount": 250.0,
"description": "order #4521", // optional
"idempotency_key": "a-client-generated-uuid"
}| Field | Rules |
|---|---|
user_id |
required, 1–50 chars, letters/numbers/underscore/hyphen only |
amount |
required, 0.01 ≤ amount ≤ 1,000,000, finite (no NaN/Infinity) |
description |
optional, ≤200 chars |
idempotency_key |
required, 1–100 chars, client-generated |
Responses
| Status | Meaning |
|---|---|
201 |
Created — new transaction recorded |
200 |
Same idempotency_key + same payload seen before → original result returned, "duplicate": true, nothing reprocessed |
409 |
Same idempotency_key reused with a different payload — rejected so silent data corruption can't happen |
422 |
Validation failed (bad amount, missing field, malformed JSON, etc.) — message says which field and why |
429 |
Rate limit exceeded for this user (see §5) |
Success response body:
{
"transaction_id": 1,
"user_id": "priya_k",
"amount": 250.0,
"description": "order #4521",
"created_at": "2026-06-24T07:44:08.772065+00:00",
"duplicate": false,
"user_summary": {
"total_amount": 250.0,
"transaction_count": 1,
"ranking_score": 30.81
}
}Returns 404 if the user has never had a transaction (with a clear
message), 422 if userId has invalid characters, otherwise:
{
"user_id": "priya_k",
"total_amount": 250.0,
"transaction_count": 1,
"average_transaction_amount": 250.0,
"active_days_count": 1,
"first_transaction_at": "2026-06-24T07:44:08.772065+00:00",
"last_transaction_at": "2026-06-24T07:44:08.772065+00:00",
"ranking_score": 30.81,
"rank": 1,
"total_ranked_users": 1
}limit 1–100 (default 20), offset ≥0 (default 0).
{
"total_users": 2,
"limit": 20,
"offset": 0,
"rankings": [
{ "rank": 1, "user_id": "priya_k", "ranking_score": 30.81,
"total_amount": 250.0, "transaction_count": 1, "active_days_count": 1 }
]
}Plain {"status": "ok"} for load-balancer/uptime checks.
score = sqrt(score_amount_total) * AMOUNT_WEIGHT + active_days_count * ACTIVITY_WEIGHT
(AMOUNT_WEIGHT = 1.0, ACTIVITY_WEIGHT = 15.0, both in config.py)
This is two independent factors, not one, by design:
-
Volume, with diminishing returns.
score_amount_totalis the sum of each transaction's amount, but capped per transaction atPER_TRANSACTION_SCORE_CAP(₹10,000) before being added — and then the running total goes throughsqrt(). Two effects stack:- the per-transaction cap means one inflated transaction can't inject arbitrary score in a single shot,
- the square root means that even legitimate large totals give shrinking marginal score as they grow.
The real, uncapped
total_amountis still tracked and shown in/summary— capping only limits influence on the competitive ranking, it never hides or alters a user's own ledger. -
Consistency, counted in distinct days, not raw transaction count.
active_days_countincrements at most once per UTC calendar day per user, no matter how many transactions they post that day. Counting rawtransaction_countinstead would let someone farm this factor by firing many tiny transactions in one sitting; counting distinct days means that strategy gains at most +1, and genuine engagement over many real days is what actually moves this number.
Worked example (also covered by tests/test_api.py::test_single_huge_transaction_does_not_dominate_score):
| User | Real total | Transactions | Score |
|---|---|---|---|
alice_whale |
₹500,000 (one transaction) | 1 | sqrt(min(500000,10000)) * 1 + 1*15 = 115.0 |
bob_regular |
₹4,000 (two transactions) | 2 | sqrt(4000) * 1 + 1*15 = 78.25 |
Alice's real total is 125× Bob's, but her score is only ~1.5× Bob's — a few more honest transactions and Bob is competitive. That compression is the whole point: the formula rewards genuine, sustained participation over one large or manipulated event.
Ties are broken by earlier first_transaction_at (rewards being an
early, consistent participant over someone who matches their score
later).
Where ranks are computed: on every /summary or /ranking request,
scores for all users are computed from their stored aggregates and
sorted in Python (see _all_ranked_users in service.py). This was a
deliberate simplicity choice for this scale — see §7 for the production
alternative.
Every POST /transaction requires a client-generated idempotency_key
(a UUID is the natural choice, and the demo UI generates one
automatically). The server:
- Checks first. Looks up the key in an
idempotency_recordstable. If found and the rest of the payload matches what was stored under that key (compared via a SHA-256 hash ofuser_id+amount+description), the original response is returned with HTTP200and"duplicate": true— no row is inserted again, no aggregate is touched again. - Rejects mismatches loudly. If the same key is reused with a
different payload, that's either a client bug or someone trying to
smuggle a different transaction through a familiar key — the server
returns
409 Conflictinstead of guessing which payload was "right." - Closes the race, not just the common case. The
idempotency_keycolumn has aUNIQUEconstraint at the database level. If two requests with the same brand-new key arrive at the same instant (e.g. a real network retry racing the original), only oneINSERTcan win; the other gets asqlite3.IntegrityError, rolls back, re-reads the now-committed result, and returns that — so even the unlucky race loser gets a correct, idempotent answer, not an error.
This is exercised directly by tests/test_api.py::test_duplicate_key_same_payload_is_not_double_counted
and ::test_duplicate_key_different_payload_is_conflict, and the demo UI
has a "Resend last request" button that replays the exact same call
so you can watch this happen live.
Storage: SQLite, accessed through a thin connection helper in
db.py. Why SQLite for a take-home service, explained honestly: it needs
zero external infrastructure to run anywhere (including a free hosting
tier), while still being a real ACID database I can reason about
explicitly — not an in-memory dict that would lose the "concurrent
writes" problem entirely.
The actual mechanism, in service.process_transaction:
- Every write path runs inside one
BEGIN IMMEDIATE … COMMITSQLite transaction. SQLite only ever allows one writer at a time to hold such a transaction; every other concurrent request trying to start its own write simply waits (PRAGMA busy_timeout=5000) until the first one finishes. - Aggregates are updated with
UPDATE users SET total_amount = total_amount + ?— never "read the value in Python, add, write it back." That pattern is what causes lost updates under concurrency; the atomic SQL increment doesn't have that gap. - The rate-limit check happens inside the same lock, not before it
(see the comment in
service.py). I initially wrote it as a check before acquiring the write lock — the more obvious-looking approach — and a stress test caught the bug immediately: under a 40-way concurrent burst, several requests all read "4 so far" before any of them committed their 5th, and the nominal limit of 5 let through 9. Moving the count to afterBEGIN IMMEDIATEclosed that, because by then no sibling request can possibly be holding the lock concurrently. I'm calling this out explicitly because it's exactly the kind of bug this assignment is testing for, and it only showed up under genuine load, not in a single-request test.
Proof, not just a claim: tests/concurrency_test.py fires 40
simultaneous POST /transaction requests at the same user from a
40-thread pool, then asserts the server's total_amount and
transaction_count exactly equal what should have been accepted — i.e.,
no lost updates, no double counting. Sample run:
Firing 40 concurrent transactions for 'concurrency_stress_user'...
Completed in 1.31s
201 Created : 5
429 Rate limited : 35
Server-reported total_amount : 37.5
Expected total (accepted * amt) : 37.5
Server-reported transaction_count: 5
Expected transaction_count : 5
PASS: no lost updates, no double counting under concurrency.
(5 accepted, exactly matching RATE_LIMIT_MAX_REQUESTS = 5 — the limit
held precisely even with 40 requests racing it at once.)
| Mechanism | Prevents |
|---|---|
| Idempotency key (required, unique, hash-checked) | Retried/duplicated requests double-counting |
Rate limit — 5 transactions / 10s per user (config.py) |
Spam bursts inflating activity or volume quickly |
| Per-transaction score cap (₹10,000) | One inflated transaction dominating the leaderboard |
sqrt() diminishing returns on volume |
Large totals (even capped ones) scaling rank linearly forever |
| Active-days, not raw transaction count, for the activity factor | Farming the consistency factor with many same-day tiny transactions |
| Strict input validation (amount bounds, user_id charset, length limits, finite-number checks) | Garbage/adversarial input corrupting aggregates or scores |
Tie-break by earliest first_transaction_at |
A late entrant exactly matching a score from gaming, rather than genuine early participation |
Four tables (db.py has the full CREATE TABLE statements with inline
comments):
users— one row per user:total_amount(true, uncapped),transaction_count,score_amount_total(capped, ranking-only),active_days_count,first_transaction_at,last_transaction_at. This is the materialized aggregate every/transactionwrite updates atomically and every/summary//rankingread uses directly — no expensiveSUM()/COUNT()over the full transaction history on every read.transactions— the append-only ledger: every accepted transaction, withidempotency_key UNIQUE(the concurrency-safety net described in §5/§6) and an index on(user_id, created_at)for the rate-limit window query.user_active_days— one row per(user_id, calendar day), used purely to cheaply detect "is this the user's first transaction today" viaINSERT OR IGNORE+ checking the row count, soactive_days_countcan be incremented in O(1) instead of aCOUNT(DISTINCT date)scan.idempotency_records— the source of truth for "have we already processed this exact request," storing the full response so a retry gets back byte-for-byte the same answer.
Write flow: POST /transaction → validate (Pydantic) → idempotency
fast-path read → BEGIN IMMEDIATE → rate-limit check → insert
transaction → upsert users aggregate → upsert user_active_days →
store idempotency response → COMMIT. All in service.process_transaction.
Read flow: /summary and /ranking both call the same
_all_ranked_users() helper, which pulls the (small) users table,
computes each score in Python, sorts once, and slices — see §4 for why
ranks are computed at read-time rather than stored.
- SQLite's single-writer model caps write throughput under very high
concurrent load compared to a multi-master setup. The locking logic
here (claim-then-update inside one atomic transaction) ports directly
to Postgres with
SELECT ... FOR UPDATEor an optimistic concurrency/version column if this needed to scale past what a single SQLite writer can do. - Ranking is recomputed from
userson every read, not maintained as a live materialized leaderboard. Fine at this scale (the table is small, recompute is O(n log n) in Python); at real scale I'd maintain an incrementally-updated sorted structure (e.g., a Redis sorted set keyed by score, updated alongside the SQL write) so/rankingis O(log n) instead of O(n). - Rate limiting is per-process, reading directly from the
transactionstable rather than a separate counter store. That's correct for a single instance (which is what this deploys as), but wouldn't coordinate across multiple horizontally-scaled instances without moving the counter to something shared like Redis. - No authentication.
user_idis just a free-text identifier anyone can claim — there's no login, so nothing stops one person from posting transactions "as" another user_id. Out of scope for this assignment's brief, but the first thing I'd add before this touched real money or real user accounts. - Abuse detection is rule-based, not statistical. The rate limit and
score caps are fixed thresholds in
config.py, not an adaptive/ML fraud model. That's an intentional scope choice for a take-home-sized service — the thresholds are isolated in one file specifically so they could be replaced by something smarter later without touching the request-handling logic. - Currency precision uses
float/REALrather than fixed-pointDecimal/integer-paise storage. For a real money-handling system I'd store integer minor units (paise) to avoid floating-point rounding drift over many transactions; amounts here are rounded to 2 decimals on input as a partial mitigation, which is fine for a demo but not a production financial ledger.
.
├── main.py FastAPI app: routes, exception handlers, static mount
├── service.py Core logic: idempotency, rate limiting, atomic writes, ranking
├── schemas.py Pydantic request/response models + field validation
├── scoring.py The ranking score formula, documented inline
├── db.py SQLite schema + connection helpers
├── config.py Every tunable constant, in one place, with justification
├── frontend/
│ ├── index.html Demo UI
│ ├── styles.css
│ └── app.js
├── tests/
│ ├── test_api.py pytest suite (validation, idempotency, rate limit, ranking)
│ └── concurrency_test.py Live 40-thread concurrency stress test
├── requirements.txt runtime deps
├── requirements-dev.txt + test deps
├── render.yaml Render Blueprint (one-click deploy)
├── Procfile Railway/Heroku-style deploy
├── Dockerfile portable container deploy
└── README.md this file