Design a Personal Finance App

Design a personal finance app. Users link their checking, savings and credit card accounts from many different banks. The app pulls in balances and transactions, sorts each transaction into a category such as groceries or rent, lets users set monthly budgets per category, and alerts them when they are close to a limit or when something unusual happens, such as a large charge.

The hard parts are outside your control: thousands of banks with different APIs, rate limits, outages and data quirks; transactions that first show up as pending and later reappear with a different amount and ID; and credentials that are among the most sensitive data a user owns.

In 45 minutes, agree on requirements and scale, design how data is pulled from banks and kept fresh, how transactions are categorised and budgets evaluated, and how you protect the data.

Sketch on the HLD board, write your answer in the Design doc, and size it with the Estimator. Start the mock interview from the timer for a 45-minute run.

Design Path · Commerce & Payments · problem 29 of 70

Ask before you design. Try answering each yourself, then compare.

  1. Do we connect to banks directly or through an aggregator?
    Typical answer

    Through a mix: open-banking APIs where available and a third-party data aggregator for the rest. We never store raw bank passwords; we store access tokens.

  2. How fresh must transactions be?
    Typical answer

    Within a few hours is fine generally; use bank webhooks when available to get closer to real time.

  3. What scale should we plan for?
    Typical answer

    20 million users, 5 million daily active, about 4 linked accounts each and roughly 3 transactions per user per day.

  4. Can users fix a category, and should that stick?
    Typical answer

    Yes. A correction applies to that transaction and, optionally, to future transactions from the same merchant.

  5. Which alerts matter?
    Typical answer

    Budget thresholds (80% and 100%), large transactions above a user-set amount, and low balance. Delivered by push within minutes of the data arriving.

  6. Is this read-only, or can we move money?
    Typical answer

    Read-only. No payments in scope.

  7. How long is history kept?
    Typical answer

    We import up to 2 years back on linking and keep everything while the account is active.

Pulling data from 80 million bank accounts is a scheduling problem before it is a storage problem. What limits how fast you can pull?
The same purchase may arrive twice: once pending, once posted with a new ID and amount. How does your pipeline avoid double counting?
Categorisation has several sources of truth. Which one wins when they disagree?
Budget totals change with every transaction. Recompute them, or update them incrementally?
If an attacker got your database, what would you want them not to find?

Requirements

Functional

  • Link financial accounts through bank or aggregator authorisation and store access tokens.
  • Sync balances and transactions periodically and on webhooks, including a 2-year backfill.
  • Categorise transactions automatically, with user overrides and merchant rules.
  • Create monthly budgets per category and show spend against them.
  • Send alerts for budget thresholds, large transactions and low balances.

Non-functional

  • Dashboard load p95 under 300 ms from our own data, never blocking on a bank call.
  • New transactions visible within 4 hours, within 5 minutes for webhook-capable banks.
  • Respect each provider's rate limits; one failing bank must not slow others.
  • Tokens encrypted with keys in a KMS or HSM; 99.9% availability for reads.

Out of scope

  • Moving money, bill pay and investing.
  • Credit scores and loan offers.

Back-of-the-envelope estimates

  • Linked accounts: 20 M users × 4 = 80 M accounts. Syncing each 4 times a day = 320 M provider calls a day ≈ 3,700 per second, more than many providers allow, so active users sync more often and dormant ones daily.
  • Transactions: 20 M users × 3 per day = 60 M a day ≈ 700 per second, peaking around 2,000 per second during the early morning batch syncs.
  • Storage: 60 M × 500 B ≈ 30 GB a day, about 11 TB a year; the 2-year backfill on linking adds roughly 1,500 rows per new account.
  • Reads: 5 M daily users × 8 screen loads ≈ 40 M a day ≈ 460 per second, mostly current-month summaries, which cache well.

Provider call budgets and ingestion fairness drive the design more than our own storage or read traffic.

API

POST /v1/links                {provider, publicToken}       -> {linkId, accounts[]}
POST /v1/links/{id}/refresh                                 -> 202 {syncJobId}
GET  /v1/accounts                                           -> [{id, name, balance, asOf}]
GET  /v1/transactions?month=2026-10&category=&cursor=    -> {items[], nextCursor}
PATCH /v1/transactions/{id}   {category, applyToMerchant}   -> 200
PUT  /v1/budgets/{month}/{category} {limit}                 -> 200
POST /webhooks/provider/{name} (signed)                     -> 202

Refresh is asynchronous and returns a job ID, because a bank call may take tens of seconds or fail; the app shows the last synced data with an asOf time instead of waiting.

Data model

  • links(link_id, user_id, provider, token_ref, status, cursor, next_sync_at): token_ref points into the token vault; the token itself is never in this table.
  • accounts(account_id, user_id, link_id, type, balance, currency, as_of)
  • transactions(user_id, txn_id, account_id, provider_txn_id, pending_id, posted_at, amount, merchant, category, category_source), unique on (account_id, provider_txn_id).
  • budgets(user_id, month, category, limit) and monthly_spend(user_id, month, category, total) maintained incrementally.
  • merchant_rules(user_id, merchant_key, category)

All in a relational store sharded by user_id: every query is per user, and constraints make re-synced data idempotent. Current-month summaries are cached per user in Redis.

High-level design

Linking: the app completes the bank or aggregator authorisation flow and sends a short-lived token to the Link Service, which exchanges it for a long-lived access token, stores it encrypted in the Token Vault, and schedules a backfill.

Ingestion: the Sync Scheduler picks links whose next_sync_at has passed and puts jobs on per-provider queues. Sync Workers obey each provider's rate limit, fetch the token from the vault, and call the Bank / Aggregator APIs incrementally with a stored cursor. Webhooks from providers move a link to the front of the queue. Workers normalise merchants, send batches to the Categoriser, and upsert transactions into the Finance DB, keyed by provider transaction ID so repeats are harmless.

Reacting: change capture from the database publishes Transaction Events. The Budget & Alerts worker updates monthly spend per category and checks thresholds, sending pushes through Notifications.

Reading: the app calls the Accounts API through the gateway, which serves summaries from the Summary Cache and pages through transactions in the database. No read ever calls a bank.

Open the HLD board and press Reference to see the architecture diagram.

Deep dives

Scheduling syncs under provider limits

Treat each provider as its own pool with a token bucket sized to its published or observed rate limit, and give each pool its own queue and workers so a slow bank cannot starve others. Priority comes from user activity: users who opened the app today sync every few hours, dormant users daily, and a manual refresh or a webhook jumps the queue. Spread scheduled times with jitter to avoid a midnight spike. Circuit breakers per provider pause syncing after repeated errors and resume with backoff; links that need the user to re-authorise are marked so the app can prompt them.

Pending, posted and duplicate transactions

Banks often report a card purchase as pending, then later as posted with a new ID and a slightly different amount, such as a tip added to a restaurant bill. Store transactions with a unique key on the provider's ID so re-fetching the same window is a no-op. When a posted transaction references a pending one, replace the pending row and apply only the difference to budget totals. When providers omit that link, match on account, merchant, amount within a tolerance and a date window of a few days, and expire pending rows that never post after about 10 days.

Categorisation pipeline

Run a chain where the first confident answer wins: the user's own merchant rules, then a curated global merchant table built from cleaned merchant names, then the provider's category hint, then a text classifier on the description and amount. Record which source decided, so that a later user correction can override it and also feed training data. Categorise in batches during ingest so transactions arrive already labelled, and when a user adds a rule, recategorise their recent history asynchronously and recompute affected budget totals.

Protecting credentials and data

Never store bank passwords; keep only provider access tokens, encrypted with per-user data keys that are themselves wrapped by a KMS master key. Only sync workers can call the vault to decrypt, and every decryption is logged. Encrypt the finance database at rest, keep personal data out of logs, and tokenise account numbers. Users can unlink an account, which revokes the token at the provider and deletes or anonymises its data on a schedule that meets regulatory requirements.

Failure modes

  • If a bank's API is down, its circuit breaker opens, its queue backs up without affecting other banks, and users see the last synced data with its timestamp.
  • If a token expires or is revoked, the link moves to NEEDS_REAUTH and the app prompts the user instead of retrying forever.
  • If the categoriser is unavailable, transactions are stored as Uncategorised and relabelled when it recovers; budgets are recomputed for those rows.
  • If the event stream lags, alerts arrive late but are not lost, since the CDC position is durable.
  • If the summary cache is lost, the API recomputes current-month totals from the monthly spend table, which stays the source of truth.

Core algorithm in code

from collections import defaultdict


class Categoriser:
    """User rules beat merchant mappings, which beat keyword guesses."""
    def __init__(self, merchant_map, keywords):
        self.merchant_map, self.keywords = merchant_map, keywords
        self.user_rules = defaultdict(dict)

    def add_user_rule(self, user, merchant, category):
        self.user_rules[user][merchant.lower()] = category

    def categorise(self, user, merchant, description):
        m = merchant.lower()
        if m in self.user_rules[user]:
            return self.user_rules[user][m]
        if m in self.merchant_map:
            return self.merchant_map[m]
        text = description.lower()
        for word, category in self.keywords.items():
            if word in text:
                return category
        return "Uncategorised"


class BudgetTracker:
    """Applies transactions idempotently and fires each threshold alert once per month."""
    def __init__(self, thresholds=(0.8, 1.0)):
        self.thresholds = thresholds
        self.limits = {}
        self.spent = defaultdict(int)          # (user, month, category) -> cents
        self.applied = {}                      # txn id -> (key, amount) so replays are no-ops
        self.fired = set()

    def set_budget(self, user, category, limit):
        self.limits[(user, category)] = limit

    def apply(self, user, txn):
        if txn["id"] in self.applied:
            return []
        if txn.get("pending_id") in self.applied:   # posted version replaces the pending one
            old_key, old_amount = self.applied.pop(txn["pending_id"])
            self.spent[old_key] -= old_amount
        key = (user, txn["month"], txn["category"])
        self.spent[key] += txn["amount"]
        self.applied[txn["id"]] = (key, txn["amount"])
        limit = self.limits.get((user, txn["category"]))
        alerts = []
        for t in self.thresholds if limit else ():
            if self.spent[key] >= t * limit and (key, t) not in self.fired:
                self.fired.add((key, t))
                alerts.append("%s: %s at %d%% of budget (%d / %d)" % (user, txn["category"],
                              int(t * 100), self.spent[key], limit))
        return alerts


cat = Categoriser({"freshmart": "Groceries", "metro transit": "Transport"},
                  {"coffee": "Eating Out", "uber": "Transport"})
cat.add_user_rule("u1", "Corner Shop", "Groceries")
budgets = BudgetTracker()
budgets.set_budget("u1", "Groceries", 40_000)

feed = [
    {"id": "p1", "pending_id": None, "merchant": "FreshMart", "desc": "card 1234", "amount": 25_000},
    {"id": "t1", "pending_id": "p1", "merchant": "FreshMart", "desc": "card 1234", "amount": 26_500},
    {"id": "t1", "pending_id": "p1", "merchant": "FreshMart", "desc": "card 1234", "amount": 26_500},
    {"id": "t2", "pending_id": None, "merchant": "Corner Shop", "desc": "pos", "amount": 7_000},
    {"id": "t3", "pending_id": None, "merchant": "Blue Door", "desc": "COFFEE BAR", "amount": 450},
    {"id": "t4", "pending_id": None, "merchant": "Corner Shop", "desc": "pos", "amount": 9_000},
]
for t in feed:
    t["category"] = cat.categorise("u1", t["merchant"], t["desc"])
    t["month"] = "2026-10"
    fired = budgets.apply("u1", t)
    print("%-3s %-12s %-10s %6d  %s" % (t["id"], t["merchant"], t["category"], t["amount"], fired or ""))
print("groceries spent:", budgets.spent[("u1", "2026-10", "Groceries")])

Trade-offs

  • Scheduled polling works with every bank but wastes calls and adds latency; webhooks are fresh but only some providers offer them.
  • Categorising during ingest gives labelled data immediately but adds latency and a dependency to the sync path.
  • Incremental budget totals are cheap but can drift, so a periodic recompute is needed to correct them.
  • Using an aggregator covers many banks quickly but adds cost per call and a dependency you cannot fix yourself.
  • Per-user encryption keys limit the blast radius of a breach but make bulk analytics and key rotation harder.

What a strong answer shows

  • Separates the user-facing read path from bank ingestion, so reads never wait on banks.
  • Designs scheduling around provider rate limits, isolation per provider and prioritisation by user activity.
  • Handles pending-to-posted transitions and idempotent re-syncs without double counting.
  • Defines a clear precedence for categorisation sources, including user corrections.
  • Updates budget totals incrementally and fires each alert once.
  • Treats token storage and data protection as first-class, with a KMS-backed vault.

Mistakes to avoid

  • Calling bank APIs synchronously when the user opens the app.
  • Running one global worker pool so a single slow bank backs up every sync.
  • Inserting every fetched transaction blindly and double counting pending and posted versions.
  • Recomputing every budget from scratch on each new transaction at 700 writes a second.
  • Storing bank credentials or tokens in plain columns next to the user's data.

Follow-ups to expect

  • How would you detect recurring subscriptions? (Group by merchant, amount and roughly monthly cadence; flag new or increased ones.)
  • How do you support shared household budgets? (A household entity owning budgets; spend aggregated across members' accounts.)
  • A provider changes its category taxonomy. What breaks? (Only the provider-hint stage; map through a versioned translation table.)
  • How would you add spending insights like 'you spent 30% more on dining'? (Nightly batch over monthly spend; cache the results.)

Practice next

Next Post Previous Post
No Comment
Add Comment
comment url