A personal finance app gathers a user's bank accounts, credit cards and wallets in one place, sorts every transaction into categories such as "Food & dining" or "Rent", and tells the user how they are doing against monthly budgets. Mint popularised the idea; many banking and budgeting apps work the same way today.
This problem is different from the social or media designs you may have practised. Users read the app only a few times a day, but the backend imports transactions for every linked account, every day, whether anyone opens the app or not. That makes it a write-heavy, batch-heavy system. It also holds some of the most sensitive data a person has, so security is a core requirement, not an afterthought.
What interviewers typically probe:
- How you pull transactions from banks through aggregator APIs (third parties that connect to many banks) on a schedule, within their rate limits.
- How you make imports idempotent, so retries and overlapping pulls never create duplicate transactions.
- How categorisation works, and how user corrections feed back into it.
- How budgets and alerts are computed incrementally.
- How you protect data: encryption at rest, access tokens instead of passwords, and handling of PII (personally identifiable information, such as names, account numbers and phone numbers).
If you want a refresher first, see Identity and security, Message queues and Estimation practice.
Problem and scope
Design the backend for an app where users can:
- Link financial accounts (bank, credit card, wallet) through an aggregator.
- See balances and a unified transaction list.
- Have transactions automatically categorised, and fix a category when it is wrong.
- Set monthly budgets per category and receive alerts when they approach or exceed them.
- See monthly spending summaries and trends.
Out of scope: moving money (payments, transfers), investment advice, tax filing and credit scores. Saying this explicitly matters: a read-only app has a much smaller security and compliance surface than one that can move money.
Clarifying questions
| Question | Why it matters | Assumption here |
|---|---|---|
| Do we connect to banks directly or through an aggregator? | Direct integrations with hundreds of banks are a huge effort | Through one or more aggregators (Plaid-style, or account-aggregator frameworks where regulated) |
| Read-only or can users move money? | Security scope, regulation | Read-only |
| How fresh must transactions be? | Pull frequency and cost | Within a day; faster when the aggregator sends a webhook |
| How many users and accounts? | Load and storage | 20 million users, 3 accounts each |
| How long do we keep history? | Storage growth | 5 years, then archive |
| Which notification channels? | Notification design | Push and email |
| Multi-currency? | Money types, summaries | Store currency per account; summaries in the user's home currency |
Interview tip
State early that you will never store bank usernames or passwords. The aggregator handles the user's login and gives you an access token scoped to read-only data. This one sentence shows the interviewer you understand the security model before you draw a single box.
Functional and non-functional requirements
Functional:
- Link and unlink accounts; show connection status ("needs re-login").
- Import transactions and balances periodically and on aggregator webhooks.
- Categorise each transaction; let the user override, and optionally create a rule ("always put AMAZON under Gifts").
- Create, update and delete monthly budgets per category.
- Send alerts: budget at 80%, budget exceeded, unusually large transaction, low balance.
- Show monthly summaries by category.
Non-functional:
- Correctness: no duplicate or missing transactions; totals must match the bank. Money is stored as integers in the smallest unit (paise for rupees, cents for dollars) to avoid floating-point rounding errors.
- Security and privacy: encryption in transit and at rest, least-privilege access, audit logs.
- Freshness: transactions visible within 24 hours, usually much sooner.
- Availability: reading the app should work even when imports are delayed; show "last updated at".
- Throughput: comfortably handle daily imports for all accounts and spikes when an aggregator recovers from an outage.
Back-of-the-envelope estimates
Accounts. 20 million users × 3 linked accounts = 60 million accounts.
Pulls. If each account is refreshed once a day: 60M ÷ 86,400 ≈ 694 pulls per second on average. If you tried to squeeze all pulls into a 6-hour overnight window, it would be 60M ÷ 21,600 ≈ 2,778 per second. Spreading pulls across the day is cheaper and kinder to aggregator rate limits.
New transactions. Assume 2 new transactions per account per day: 60M × 2 = 120 million transactions per day, about 1,389 per second on average.
Storage. At about 300 bytes per transaction row (IDs, amount, dates, merchant text, category, status):
- 120M × 300 B = 36 GB per day
- × 365 ≈ 13 TB per year of raw rows; roughly double with indexes, about 26 TB.
- Five years of history is about 219 billion rows. That clearly needs partitioning.
Reads. Assume 25% of users are active on a given day and each makes 6 requests: 20M × 0.25 × 6 = 30 million requests per day ≈ 347 per second.
What this tells you. Rows written per day (120 million) far exceed user requests (30 million), and the writes arrive from background jobs, not from users. Design the import pipeline as the main system and the read API as a lighter layer on top of precomputed summaries.
Summaries. If each user has about 15 active categories in a month, monthly summary rows are 20M × 15 = 300 million per month. Each is tiny, and a user's dashboard reads only their own 15 rows, so this is cheap.
Common mistake
Do not compute the dashboard by summing a user's raw transactions on every page load. With years of history, that is a large scan per request. Maintain per-user, per-month, per-category totals as transactions arrive, and read those.
API design
POST /v1/links/start
-> {"link_token": "lt_..."} # opens the aggregator's login widget
POST /v1/links/complete
body: {"public_token": "pt_..."} # server exchanges it for an access token
-> {"item_id": "it_123", "accounts": [{"id": 7, "mask": "4821", ...}]}
DELETE /v1/items/it_123 # unlink, revoke token, delete data
GET /v1/transactions?account_id=7&from=2026-10-01&cursor=...&limit=50
PATCH /v1/transactions/{id}
body: {"category": "Gifts", "create_rule": true}
GET /v1/summaries?month=2026-10
PUT /v1/budgets/{category}
body: {"month": "2026-10", "limit_paise": 1000000}
GET /v1/budgets?month=2026-10
POST /v1/webhooks/aggregator # called by the aggregator, signed
Notes:
- The link flow follows the common aggregator pattern: the app opens the aggregator's hosted login, the user authenticates with their bank there, and your server receives a short-lived public token which it exchanges for a long-lived access token. Your app never sees the bank password.
- Money fields end in
_paise(or_cents) and are integers. - The webhook endpoint verifies a signature from the aggregator before trusting it, then simply enqueues a refresh job. It never does heavy work inline.
- Every user endpoint scopes queries to the authenticated user's ID, never an ID supplied in the request body.
Data model and storage choice
A relational database (PostgreSQL, MySQL) is the right primary store. The data is structured, correctness matters, and you need unique constraints, transactions and joins for summaries. Partition and shard it by user_id so that one user's data lives together.
CREATE TABLE items ( -- one bank login via the aggregator
item_id TEXT PRIMARY KEY,
user_id BIGINT NOT NULL,
provider TEXT NOT NULL, -- which aggregator
access_token_enc BYTEA NOT NULL, -- encrypted, see security section
sync_cursor TEXT, -- aggregator's "changes since" cursor
status TEXT NOT NULL, -- active | needs_relogin | revoked
next_sync_at TIMESTAMP NOT NULL
);
CREATE TABLE accounts (
account_id BIGINT PRIMARY KEY,
user_id BIGINT NOT NULL,
item_id TEXT NOT NULL REFERENCES items(item_id),
type TEXT NOT NULL, -- savings | credit | wallet
mask TEXT NOT NULL, -- last 4 digits only
currency CHAR(3) NOT NULL,
balance_paise BIGINT
);
CREATE TABLE transactions (
user_id BIGINT NOT NULL,
account_id BIGINT NOT NULL,
provider_txn_id TEXT NOT NULL, -- the aggregator's stable ID
pending_txn_id TEXT, -- links a posted txn to its pending one
amount_paise BIGINT NOT NULL, -- positive = money out
merchant_raw TEXT NOT NULL,
category TEXT NOT NULL,
category_source TEXT NOT NULL, -- user | user_rule | merchant_map | mcc | model
status TEXT NOT NULL, -- pending | posted | removed
txn_date DATE NOT NULL,
PRIMARY KEY (account_id, provider_txn_id)
);
CREATE INDEX txn_by_user_date ON transactions (user_id, txn_date DESC);
CREATE TABLE monthly_summaries (
user_id BIGINT NOT NULL,
month DATE NOT NULL,
category TEXT NOT NULL,
spent_paise BIGINT NOT NULL,
PRIMARY KEY (user_id, month, category)
);
CREATE TABLE budgets (
user_id BIGINT NOT NULL,
month DATE NOT NULL,
category TEXT NOT NULL,
limit_paise BIGINT NOT NULL CHECK (limit_paise > 0),
alerted_80 BOOLEAN NOT NULL DEFAULT FALSE,
alerted_100 BOOLEAN NOT NULL DEFAULT FALSE,
PRIMARY KEY (user_id, month, category)
);
Key choices:
(account_id, provider_txn_id)as the primary key is the idempotency key for imports (deep dive 2).- The
(user_id, txn_date DESC)index serves the transaction list newest first. - Time partitioning of
transactionsby month keeps recent partitions hot and lets old ones move to cheaper storage. - Raw aggregator responses go to object storage (encrypted) for a limited period, so you can replay an import after a bug without calling the bank again.
High-level design
Mobile / web app
|
+-----v------+ +--------------------+
| API gateway|<---->| Auth service |
+-----+------+ +--------------------+
|
+-----v---------+ reads +----------------------+
| App API +---------->| Postgres (sharded by |
| (lists, | | user_id): txns, |
| budgets) | | summaries, budgets |
+---------------+ +----------^-----------+
| upserts
+-------------+ jobs +-------------+--------+
| Scheduler +--------->| Sync workers |
| (due items) | queue | fetch -> normalise ->|
+------^------+ | dedupe -> categorise |
| +---+-------------+----+
+------+------+ | | events
| Webhook | +----------v---+ +-----v-----------+
| receiver | | Aggregator | | Event log |
+------^------+ | API (banks) | +--+----------+---+
| +--------------+ | |
aggregator +-------v---+ +----v---------+
webhooks | Budget & | | Notification |
| alert svc +>| service |
+-----------+ | push / email |
+--------------+
Secrets: KMS / HSM holds key-encryption keys for tokens and PII
Components:
- Scheduler: decides which items are due for a sync and enqueues jobs, respecting per-aggregator and per-bank rate limits.
- Job queue: decouples scheduling from execution and absorbs bursts. See Message queues.
- Sync workers: stateless workers that decrypt an access token, call the aggregator, normalise the response, upsert transactions, categorise new ones and publish events.
- Event log:
TransactionsChangedevents feed summaries, budgets and alerts without slowing down the import. - Budget and alert service: updates summaries and checks thresholds.
- Notification service: applies user preferences and quiet hours, then sends push or email with retries.
- KMS (key management service): a managed service or hardware security module that holds master keys; application servers never see them in plain form.
Request flows
Flow 1: link a bank account
- The app calls
POST /v1/links/start; the server asks the aggregator for a link token tied to this user. - The app opens the aggregator's widget. The user picks a bank and logs in there (often with the bank's own OTP or consent screen).
- The widget returns a public token to the app, which sends it to
POST /v1/links/complete. - The server exchanges it for an access token, encrypts it (envelope encryption, deep dive 4) and stores the item with
next_sync_at = now. - The server enqueues a high-priority initial sync, which may pull up to 90 days or more of history in pages.
- The app shows "Importing your transactions…" and updates as pages land.
Flow 2: scheduled daily sync
- Every minute the scheduler selects items where
next_sync_at <= nowand status is active, in small batches, usingSELECT ... FOR UPDATE SKIP LOCKED(so multiple scheduler instances never grab the same rows) or a sharded scheduler. - It enqueues one job per item and sets
next_sync_atto roughly 24 hours later plus random jitter (a small random offset that stops all jobs piling up at the same minute). - A worker takes the job, decrypts the token, and calls the aggregator's "transactions changed since cursor" endpoint.
- For each page: upsert added and modified transactions, mark removed ones, categorise new ones, and save the new cursor in the same database transaction as the rows.
- After the last page, it publishes
TransactionsChanged(user_id, account_ids, months). - Budget service recomputes the affected months' summaries and checks thresholds.
Flow 3: webhook-triggered sync
- The aggregator calls
POST /v1/webhooks/aggregatorsaying "new transactions for item it_123". - The receiver verifies the signature and enqueues a sync job for that item, with a deduplication key such as
sync:it_123so ten webhooks in a minute produce one job. - The rest is the same as Flow 2.
Flow 4: the user recategorises a transaction
PATCH /v1/transactions/{id}with{"category": "Gifts", "create_rule": true}.- The server updates the row with
category_source = 'user', so later imports never overwrite it. - If
create_ruleis true, it stores a user rule for that merchant and recategorises matching past transactions in a background job. - Summaries for the affected months are recomputed.
Deep dive 1: scheduling imports at scale
Pulling 60 million accounts a day is a scheduling problem with outside constraints.
Spread the load. Instead of one big nightly batch, give each item its own next_sync_at and add jitter. Load becomes a flat ~700 pulls per second instead of a spike.
Respect rate limits. Aggregators and banks limit how often you may call them. A limit applies per aggregator and often per bank. Worked example: suppose Bank X allows 100 requests per second through the aggregator, and 5 million of your accounts are at Bank X. One full refresh needs at least 5,000,000 ÷ 100 = 50,000 seconds, about 13.9 hours. A nightly window of 6 hours is impossible for this bank, no matter how many workers you add. The scheduler must know per-bank capacity and spread that bank's pulls over the whole day.
Implement this with a token bucket per bank (a counter that refills at the allowed rate; each call takes one token, and calls wait when it is empty) shared by all workers, typically in Redis. Jobs for a throttled bank are delayed, not failed.
Prioritise. Not all syncs are equal:
| Priority | Example | Why |
|---|---|---|
| High | Initial import after linking | The user is staring at a spinner |
| High | User pressed "refresh" | Interactive |
| Medium | Webhook says new data | Cheap and fresh |
| Low | Routine daily refresh | Background |
| Lowest | Users inactive for 90 days | Pull weekly to save cost |
Use separate queues or a priority queue so a backlog of routine work never delays interactive syncs.
Backoff on errors. If a bank is down, retry with exponential backoff (wait 1, 2, 4, 8 minutes… with jitter) and a cap. If the aggregator says the user's consent expired, set status to needs_relogin, stop retrying and notify the user.
Deep dive 2: idempotent imports
An idempotent operation gives the same result whether it runs once or many times. Imports must be idempotent because they will be repeated: workers crash mid-page, jobs are retried, webhooks arrive twice, and a daily pull overlaps the previous day's window.
The rule is simple: never insert blindly; upsert on a stable key. The aggregator's transaction ID, scoped to the account, is that key.
Pending and posted transactions
Card transactions often appear first as pending (authorised but not settled) and later as posted (settled), sometimes with a different amount (a restaurant tip) and a new ID that references the pending one.
Worked example (amounts in paise; ₹450 = 45,000 paise):
| Pull | Aggregator sends | Action | Rows after |
|---|---|---|---|
| Day 1 | p_881, pending, ₹450, SWIGGY | Insert | p_881 pending 45,000 |
| Day 1 retry | p_881 again | Upsert hits existing key, no change | p_881 pending 45,000 |
| Day 2 | t_9031, posted, ₹470, pending ID p_881 | Insert t_9031; mark p_881 removed | p_881 removed, t_9031 posted 47,000 |
Spending counts only rows that are not removed, so the total is ₹470, not ₹920 and not ₹450. The SQL below (tested in SQLite; PostgreSQL uses the same ON CONFLICT syntax) shows the pattern:
CREATE TABLE transactions (
account_id INTEGER NOT NULL,
provider_txn_id TEXT NOT NULL,
pending_txn_id TEXT,
amount_paise INTEGER NOT NULL,
merchant TEXT NOT NULL,
status TEXT NOT NULL CHECK (status IN ('pending','posted','removed')),
posted_on TEXT,
PRIMARY KEY (account_id, provider_txn_id)
);
-- Day 1 pull: a pending card payment.
INSERT INTO transactions VALUES (7, 'p_881', NULL, 45000, 'SWIGGY', 'pending', NULL)
ON CONFLICT (account_id, provider_txn_id) DO UPDATE SET
amount_paise = excluded.amount_paise, status = excluded.status,
posted_on = excluded.posted_on;
-- Day 1 pull retried after a timeout: same row, no duplicate.
INSERT INTO transactions VALUES (7, 'p_881', NULL, 45000, 'SWIGGY', 'pending', NULL)
ON CONFLICT (account_id, provider_txn_id) DO UPDATE SET
amount_paise = excluded.amount_paise, status = excluded.status,
posted_on = excluded.posted_on;
-- Day 2 pull: the bank posts it under a new id, final amount with tip.
INSERT INTO transactions VALUES (7, 't_9031', 'p_881', 47000, 'SWIGGY', 'posted', '2026-10-09')
ON CONFLICT (account_id, provider_txn_id) DO UPDATE SET
amount_paise = excluded.amount_paise, status = excluded.status,
posted_on = excluded.posted_on;
UPDATE transactions SET status = 'removed'
WHERE account_id = 7 AND provider_txn_id = 'p_881';
SELECT SUM(amount_paise) FROM transactions WHERE status <> 'removed';
-- 47000
Notice the upsert does not overwrite category: a user's manual category must survive re-imports.
Cursors and atomic progress
Many aggregators offer a sync cursor: you send the last cursor and receive only what was added, modified or removed since. Store the new cursor in the same database transaction as the rows from that page. If the worker crashes after the commit, the next run starts from the new cursor; if it crashes before, it repeats the page, and upserts make the repeat harmless.
When there is no stable ID
Some sources (CSV uploads, a few older bank feeds) have no transaction ID. Then build a fingerprint: a hash of (account_id, date, amount, normalised description, occurrence number). The occurrence number handles two genuinely identical purchases on the same day (two ₹20 tea payments). Fingerprints are less reliable, so flag likely duplicates for the user rather than silently merging them.
Common mistake
Do not deduplicate by (date, amount, merchant) alone. Two identical coffees on the same day are real, separate transactions, and dropping one silently makes balances wrong. Prefer provider IDs; with fingerprints, include an occurrence counter.
Deep dive 3: categorisation
Raw bank descriptions look like UPI/SWIGGY*ORDER 8812/BLR or POS RELIANCE FRESH 0042. Turning them into categories uses layered rules, from most to least specific:
- User override on this transaction (never changed by the system).
- User rules ("AMAZON → Gifts" for this user).
- Global merchant map: a curated table from normalised merchant names to categories.
- Merchant category code (MCC): card networks attach a 4-digit code describing the merchant type, such as 5411 for grocery stores.
- Machine-learning model trained on descriptions and anonymised, aggregated user corrections.
- Uncategorised, shown to the user to fix.
A small runnable version of the first layers:
import re
USER_RULES = { # user_id -> {merchant_key: category}, highest priority
42: {"AMAZON": "Gifts"},
}
MERCHANT_MAP = { # curated global map, second priority
"SWIGGY": "Food & dining", "ZOMATO": "Food & dining",
"UBER": "Transport", "IRCTC": "Travel", "AMAZON": "Shopping",
}
MCC_MAP = {"5411": "Groceries", "5812": "Food & dining", "4111": "Transport"}
def merchant_key(description):
"""'UPI/SWIGGY*ORDER 8812/BLR' -> 'SWIGGY' (strip noise, keep brand)."""
text = re.sub(r"[^A-Z ]", " ", description.upper())
for word in text.split():
if word not in {"UPI", "POS", "ORDER", "BLR", "IN", "PVT", "LTD"}:
return word
return ""
def categorise(user_id, description, mcc=None):
key = merchant_key(description)
if key in USER_RULES.get(user_id, {}):
return USER_RULES[user_id][key], "user_rule"
if key in MERCHANT_MAP:
return MERCHANT_MAP[key], "merchant_map"
if mcc in MCC_MAP:
return MCC_MAP[mcc], "mcc"
return "Uncategorised", "fallback" # later: ML model, then ask the user
print(categorise(7, "UPI/SWIGGY*ORDER 8812/BLR")) # ('Food & dining', 'merchant_map')
print(categorise(42, "POS AMAZON PAY IN")) # ('Gifts', 'user_rule')
print(categorise(7, "POS AMAZON PAY IN")) # ('Shopping', 'merchant_map')
print(categorise(7, "POS RELIANCE FRESH 0042", mcc="5411")) # ('Groceries', 'mcc')
print(categorise(7, "NEFT 99812 RAJESH K")) # ('Uncategorised', 'fallback')
Design points:
- Store
category_sourceso later passes know what may be overwritten (a model guess may be improved; a user choice may not). - Categorisation runs inside the import worker for speed, but a model upgrade triggers a background re-categorisation job over past transactions, followed by summary recomputation.
- User corrections are a valuable signal. Aggregate them across users (for example, "70% of users who corrected merchant X chose Groceries") before updating the global map, so one user's unusual habit does not change everyone's data.
Deep dive 4: security, tokens and PII
This app concentrates sensitive data, so assume attackers will target it.
No bank credentials. Users log in through the aggregator. You store only a revocable, read-only access token per item.
Envelope encryption for tokens. Each secret is encrypted with a data encryption key (DEK); the DEK itself is encrypted with a key encryption key (KEK) that never leaves the KMS. To use a token, a worker asks the KMS to decrypt the DEK, decrypts the token in memory, uses it, and discards it. Benefits: a stolen database dump is useless without KMS access, KMS calls are logged, and rotating the KEK does not require re-encrypting every row.
DB row: access_token_enc = Encrypt(DEK, token)
dek_enc = Encrypt(KEK, DEK)
^
| KEK lives only in KMS / HSM
Worker: DEK = KMS.decrypt(dek_enc) (audited, permission-checked)
token = Decrypt(DEK, access_token_enc)
Encryption at rest and in transit. Database volumes, backups and object storage are encrypted; all traffic uses TLS (including inside the data centre). Field-level encryption adds protection for the most sensitive columns, such as tokens and full account numbers (store only the last four digits for display).
Least privilege. Only sync workers may decrypt tokens. The read API has no KMS permission at all. Support staff see masked data and every access is audited.
Tenant isolation. Every query is scoped by the authenticated user_id; database row-level security policies add a second guard against a missed WHERE clause.
PII in logs and analytics. Never log tokens, full account numbers or raw descriptions with names. Analytics and ML training use pseudonymous IDs and aggregated data.
Unlink means delete. Unlinking revokes the token with the aggregator and schedules deletion of that item's data, honouring data-protection laws and the user's expectations.
Interview tip
Name a concrete threat and its control: "If someone steals a database backup, tokens are encrypted with keys only the KMS can unwrap, and the read API cannot call the KMS." Specific threat-to-control pairs are far more convincing than "we encrypt everything".
Budgets, alerts and notifications
Incremental summaries
When TransactionsChanged arrives for a user and month, recompute that user's monthly totals for the affected months from their transactions (a small query thanks to the (user_id, txn_date) index) and upsert monthly_summaries. Recomputing a user-month is simpler and safer than applying deltas, because pending-to-posted changes and recategorisations would otherwise need careful reversal. It is still cheap: a user has perhaps a hundred transactions in a month.
Worked example: budget alerts
Budget for "Food & dining" in October: ₹10,000. Alerts at 80% and 100%, each sent once per month.
| Transaction | Amount | Month total | % of budget | Alert |
|---|---|---|---|---|
| 1 | ₹2,400 | ₹2,400 | 24% | none |
| 2 | ₹3,100 | ₹5,500 | 55% | none |
| 3 | ₹1,800 | ₹7,300 | 73% | none |
| 4 | ₹950 | ₹8,250 | 82.5% | "80% used" |
| 5 | ₹2,000 | ₹10,250 | 102.5% | "Budget exceeded" |
Each alert sets a flag (alerted_80, alerted_100) in the same transaction that decides to send it, so a re-run of the job does not send the alert twice. If a refund later pulls the total below the threshold, you may reset the flag so a later crossing alerts again; decide this as a product rule.
Notification delivery
The alert service publishes "send alert" requests; the notification service checks user preferences, quiet hours and frequency caps, then calls push providers (APNs for iOS, FCM for Android) or an email provider, retrying with backoff. Each request carries an idempotency key such as budget:user42:2026-10:food:80 so retries do not create duplicates. See Design a notification system for the full pipeline.
Scaling and bottlenecks
| Bottleneck | Symptom | Fix |
|---|---|---|
| Aggregator or bank rate limits | Sync backlog for one bank | Per-bank token buckets, spread schedule, lower priority for inactive users |
| Aggregator outage recovery | Millions of jobs retry at once | Exponential backoff with jitter; cap concurrency per bank |
| Transaction table growth | Slow queries, huge indexes | Shard by user_id, partition by month, archive old partitions |
| Initial imports for new users | Big pages compete with routine work | Separate high-priority queue and worker pool |
| Re-categorisation after model change | Massive rewrite | Throttled background job, per shard, off-peak |
| Monthly report generation | Spike on the 1st of the month | Precomputed summaries; generate reports lazily or spread across the day |
The read path is small. Cache a user's dashboard (summaries and budgets) for a short time, invalidated by TransactionsChanged events.
Failure handling
- Worker crashes mid-sync. The job returns to the queue after its visibility timeout. Upserts and an atomically stored cursor make the retry safe.
- Aggregator returns errors. Retry with backoff for temporary errors; mark
needs_reloginand notify the user for consent or credential errors. - Bank data arrives late or changes. Modified and removed events from the aggregator update rows; summaries are recomputed per affected month.
- Duplicate webhooks. Deduplicate jobs on
sync:<item_id>; even if two jobs run, upserts keep data correct. - Notification provider down. Queue and retry; budget alerts are not time-critical to the second.
- Bug corrupts categories. Raw responses in object storage plus
category_sourcelet you replay and fix without touching banks. - KMS unavailable. Syncs pause (they cannot decrypt tokens), but reads continue. This is the correct failure mode: fail closed for secrets.
See Reliability and recovery for retry and backoff patterns.
Trade-offs and alternatives
| Decision | Chosen | Alternative | Why |
|---|---|---|---|
| Bank access | Aggregator with access tokens | Direct bank integrations or screen scraping | Far less effort; no stored passwords; scraping is fragile and risky |
| Freshness | Daily pulls plus webhooks | Pull every hour for everyone | Hourly pulls cost 24× more calls for little user benefit |
| Store | Sharded relational DB | Wide-column NoSQL | Unique constraints, transactions and joins matter for money |
| Dedup key | Provider txn ID, fingerprint fallback | Content-only matching | Stable IDs avoid false merges |
| Summaries | Recompute per user-month on change | Apply running deltas | Robust to pending-to-posted and recategorisation |
| Categorisation | Layered rules plus ML | ML only | Rules are explainable and respect user choices |
| Scheduling | Per-item next_sync_at with jitter | One nightly batch | Flat load and fits rate limits |
What interviewers probe
"What if the same transaction is imported twice?" It cannot create two rows: the primary key (account_id, provider_txn_id) makes the second import an upsert. For sources without IDs, a fingerprint with an occurrence counter is used and suspected duplicates are flagged.
"How do you handle a bank that allows only 100 requests per second?" Per-bank token buckets in the scheduler, spreading that bank's accounts across 24 hours, and lower frequency for inactive users. More workers do not help when the limit is external.
"Where do you store the access token and who can read it?" Encrypted with envelope encryption, KEK in a KMS, decryptable only by sync workers, every decryption audited.
"How do you know a budget alert was not sent twice?" The decision and an alerted_80 flag are written in one transaction, and the notification carries an idempotency key.
"Why integers for money?" Floating-point numbers cannot represent most decimal fractions exactly (0.1 + 0.2 is not exactly 0.3 in binary floating point), so sums drift. Integers in the smallest currency unit are exact.
"How would you add real-time alerts for large transactions?" Subscribe to aggregator webhooks for faster updates, evaluate a rule on each new transaction in the alert service, and push immediately. Freshness is still limited by how quickly the bank reports the transaction.
Interview questions
Q1. Why is this system write-heavy even though users rarely open the app?
Imports run for every linked account on a schedule regardless of user activity. With 60 million accounts and about 120 million new transactions a day, background writes far exceed the roughly 30 million daily user requests. The design therefore centres on the import pipeline.
Q2. How do you make transaction imports idempotent?
Use a stable key from the source, the aggregator's transaction ID scoped to the account, as a primary key and upsert on it. Save the sync cursor in the same database transaction as the rows. Repeated pages, retries and overlapping pulls then cannot create duplicates or lose progress.
Q3. How are pending transactions handled?
Store them with status pending. When the posted version arrives, often with a new ID and possibly a different amount, insert it and mark the pending row as removed using the link the aggregator provides. Totals count only non-removed rows.
Q4. Why should the app never store bank passwords?
Stored passwords would give full account access to anyone who breaches you, and they break whenever the user changes them. Aggregator access tokens are read-only, revocable and scoped, and the user authenticates directly with the bank or aggregator.
Q5. Explain envelope encryption.
Data is encrypted with a data key, and the data key is encrypted with a master key that stays inside a KMS or HSM. To read, a service asks the KMS to unwrap the data key, which is permission-checked and logged. A stolen database dump is useless without KMS access, and master-key rotation does not require rewriting all data.
Q6. How do you schedule 60 million account refreshes per day?
Give each item a next_sync_at with random jitter, have scheduler instances claim due rows in batches without conflicts, and enqueue jobs into priority queues. Enforce per-bank rate limits with shared token buckets and back off on errors.
Q7. How does categorisation work?
A layered approach: user overrides, then user rules, then a curated merchant map, then the card merchant category code, then an ML model, then "Uncategorised". Recording the source of each category ensures automated passes never overwrite a user's choice.
Q8. How do you keep budget totals correct when transactions change?
On each change event, recompute the affected user's monthly totals for the affected months from transactions and upsert the summary rows. Recomputation is cheap per user-month and avoids errors from applying and reversing deltas.
Q9. What happens when an aggregator is down for two hours?
Sync jobs fail and are retried with exponential backoff and jitter, so recovery does not cause a thundering herd. The app keeps serving stored data and shows "last updated" times. After recovery, cursors let workers fetch everything that changed.
Q10. How do you shard the data?
By user_id, so a user's accounts, transactions, summaries and budgets live together and every user query hits one shard. Within a shard, partition transactions by month to keep indexes small and make archiving easy.
Q11. How do you protect PII in logs and analytics?
Never log tokens, full account numbers or raw descriptions tied to identities. Use masked values for support tools, pseudonymous IDs for analytics, and aggregated corrections for model training. Audit every access to sensitive data.
Q12. How would you deduplicate a CSV upload with no transaction IDs?
Create a fingerprint from account, date, amount, normalised description and an occurrence number within that group. Upsert on the fingerprint, and flag near-matches for the user to confirm rather than merging silently.
Key takeaways
- A personal finance app is write-heavy and batch-driven: background imports dominate, and reads come from precomputed summaries.
- Use aggregators and read-only access tokens; never store bank credentials.
- Make imports idempotent with upserts on provider transaction IDs and cursors saved atomically with the data.
- Handle pending-to-posted transitions explicitly and store money as integers in the smallest unit.
- Schedule syncs per item with jitter, priorities and per-bank rate limits; external limits, not worker count, often set the pace.
- Categorise with layered rules plus ML and record the source so user choices are never overwritten.
- Protect secrets with envelope encryption and a KMS, least-privilege access, masked PII and audited access.
- Budget alerts need once-only semantics: a flag written with the decision plus an idempotency key on the notification.
Next lesson
Continue with Design a chat and messaging system.

