0

Design Mint.com

by · about 3 hours ago · ⑂ 0 remixes

Source: https://www.hack2hire.com/question-bank/questions/system-design/69eb9acffbaf3da6418293dc
Company: Perplexity
Difficulty: Medium

Problem statement

Link users' bank and card accounts, pull and categorize transactions daily, and show budgets and spending trends, keeping credentials safe.

Requirements

We are building a personal-finance aggregator like Mint: users link bank and card accounts through a third-party aggregator (Plaid-style), we pull and categorize their transactions every day, and we show budgets, alerts and spending trends without ever holding their bank passwords.

Functional requirements

  • Link an account through the aggregator's hosted flow; we store only the aggregator's access token, never bank credentials.
  • Pull new and changed transactions at least daily per linked item, plus on demand ("refresh now") and on aggregator webhooks.
  • Auto-categorize each transaction (Groceries, Rent, Dining); users can recategorize, and a user rule ("Starbucks → Coffee") applies to future transactions.
  • Monthly budgets per category; alert when spend crosses 80% and 100% of a budget.
  • Transaction history (search, filter by account, category, date) and monthly reports: spend by category, month-over-month trend, net cash flow.
  • Unlink an account and delete its data.

Non-functional requirements

  • Credential safety is the top requirement: aggregator tokens encrypted with envelope encryption, readable only by the sync workers.
  • Freshness: data under 24 h old for every item; under 1 h when the bank pushes webhooks.
  • Reads are fast: history page p99 < 200 ms, dashboard/report < 300 ms.
  • No duplicate transactions and no duplicate alerts, even when pulls overlap or retry.
  • Survive one data-center loss with RPO < 1 min and RTO < 15 min.
  • Respect the aggregator's rate limits and per-call cost; pulls are the dominant variable cost.

Back-of-the-envelope estimation

  • 20M users, 2.5 linked items each → 50M items; 3 accounts per item → 150M accounts.
  • ~60 transactions per user per month → 1.2B new rows/month, ~40M/day. At ~400 B per row with indexes, ~500 GB/month, ~6 TB/year.
  • Daily pull: 50M items / 86,400 s ≈ 580 calls/s if perfectly spread; webhook-triggered pulls add ~2×. Plan for ~2,000 calls/s peak against the aggregator.
  • Reads: 4M DAU × 5 page views ≈ 20M reads/day, ~250/s average, ~1,500/s peak. Read load is small; write ingestion and aggregator calls dominate.
  • Alerts: ~5% of users cross a threshold on a given day → ~1M notifications/day.

API design

Public (mobile/web), all authenticated with the user session:

  • POST /v1/link/token → {link_token} to open the aggregator's hosted link UI.
  • POST /v1/items {public_token} → {item_id, accounts:[{id, name, mask, type}]}. Server exchanges the public token for an access token; the client never sees it.
  • GET /v1/transactions?account_id=&category=&from=2026-09-01&to=2026-09-30&cursor=&limit=50 → {transactions:[{id, account_id, posted_at, amount_cents, currency, merchant, category, pending}], next_cursor}. Keyset pagination on (posted_at, id).
  • PATCH /v1/transactions/{id} {category, create_rule: true} → updated transaction.
  • PUT /v1/budgets/{month}/{category} {limit_cents} (idempotent upsert) → {budget, spent_cents, pct}.
  • GET /v1/reports/monthly?month=2026-09 → {by_category:[{category, spent_cents, budget_cents}], total_in, total_out, vs_prev_month_pct, as_of}.
  • POST /v1/items/{id}/refresh → 202 {sync_id}; rate-limited to a few per day per item.
  • DELETE /v1/items/{id} → 202; revokes the token at the aggregator and purges data asynchronously.

Inbound:

  • POST /webhooks/aggregator with {webhook_type: "TRANSACTIONS", code: "SYNC_UPDATES_AVAILABLE", item_id}; signature verified (JWT signed by the aggregator), acknowledged with 200 in < 1 s, work enqueued.

Data model

Postgres, sharded by user_id (Citus or app-level shards, ~64 shards). Every query is per-user, so one user's data lives on one shard and joins stay local. Structured rows, not raw JSON blobs, because budgets and reports need SUM ... GROUP BY category and the history page needs indexed filters.

Table Key columns Indexes / notes
users id, email, tz
items id, user_id, aggregator_item_id, institution_id, status (ok/login_required/error), sync_cursor, last_synced_at, next_sync_at (next_sync_at) for the scheduler
item_secrets item_id, ciphertext, dek_wrapped, kms_key_id separate DB/schema; only the sync service role can read
accounts id, item_id, user_id, type, mask, balance_cents, balance_at
transactions id, user_id, account_id, aggregator_txn_id, posted_at, amount_cents, merchant_norm, category_id, category_source (model/user/rule), pending, deleted_at unique (account_id, aggregator_txn_id); (user_id, posted_at desc, id); (user_id, category_id, posted_at); partitioned by month
category_rules user_id, merchant_norm, category_id unique (user_id, merchant_norm)
budgets user_id, month, category_id, limit_cents PK (user_id, month, category_id)
monthly_rollups user_id, month, category_id, spent_cents, txn_count, updated_at PK (user_id, month, category_id); the report and budget source
alerts_sent user_id, month, category_id, threshold PK on all four: the idempotency key

Raw aggregator responses go to object storage (S3, 30-day lifecycle, encrypted) keyed by item_id/sync_id, only for debugging and replay; nothing reads them on the request path.

High-level design

flowchart LR
  U[Mobile and web] --> GW[API gateway]
  GW --> API[App API]
  API --> PG[(Postgres shards)]
  API --> RC[(Redis cache)]
  AG[Aggregator] -->|webhook| WH[Webhook receiver]
  WH --> Q[Sync queue]
  SCH[Pull scheduler] --> Q
  Q --> SW[Sync workers]
  SW -->|"fetch with token"| AG
  SW --> VAULT["Token vault and KMS"]
  SW --> CAT[Categorizer]
  SW --> PG
  SW --> RAW[(Raw payload S3)]
  PG -->|outbox| EV[Event stream]
  EV --> BUD[Budget evaluator]
  BUD --> NOTIF[Notification service]
  1. Link. The client opens the aggregator's link UI, the user logs into their bank there, and we receive a public token. The API exchanges it for an access token, which the vault encrypts and stores. Accounts are created and an initial 90-day backfill is enqueued.
  2. Scheduled pull. The scheduler scans items where next_sync_at <= now() and enqueues sync jobs, each keyed by item_id so one item never syncs twice at once. A sync worker decrypts the token, calls the aggregator's incremental sync with the stored cursor, gets added/modified/removed transactions, normalizes merchants, categorizes, and upserts on (account_id, aggregator_txn_id) in one transaction with the new cursor and rollup deltas.
  3. Webhook pull. The aggregator posts "updates available"; the receiver verifies the signature, enqueues the same sync job, and returns 200. Pulls by schedule and by webhook share one code path.
  4. Read history. The API hits the user's shard with a keyset query on (user_id, posted_at desc, id); the first page is cached in Redis for 5 min and invalidated by the sync commit.
  5. Budget alert. The sync commit writes an outbox row per touched (user, month, category). The budget evaluator reads monthly_rollups against budgets, and on crossing 80% or 100% inserts into alerts_sent; only a successful insert sends a push or email.

Deep dives

Pull cadence and the aggregator rate budget. A flat "pull every item at midnight" hits the aggregator with 50M calls in a burst and gets throttled. Instead, give each item a next_sync_at spread across the day (hash of item_id into a 24 h slot), then adjust per item:

Item type Cadence Why
Webhook-capable bank, user active in 7 days On webhook, plus one safety pull/day Fresh and cheap
No webhooks, active user Every 6–12 h Freshness for the dashboard
Dormant user (no login 30 days) Every 3–7 days Cuts cost ~40%
login_required or erroring Paused; exponential backoff to 24 h Calls would fail anyway

A token bucket per aggregator (global, e.g. 2,000/s) and per institution (banks have their own limits) gates workers; a job that cannot get a token is re-enqueued with delay. User "refresh now" jumps the queue but costs a token from a small per-user daily allowance (e.g. 5). On app open, if data is older than 12 h, enqueue a priority pull so the next screen is fresh.

Idempotent ingestion. Pulls overlap (webhook plus schedule, retries after a timeout), and banks rewrite history: a pending transaction disappears and comes back posted with a new id and a slightly different amount (tips). Rules: upsert on (account_id, aggregator_txn_id); apply the aggregator's removed list as soft deletes; when a posted transaction carries pending_transaction_id, retire the pending row and carry over any user-set category. The cursor and the row changes commit in one database transaction, so a crash replays the same page and changes nothing. Rollups are updated from deltas computed against the old row (new_amount - old_amount, category moves subtract from one and add to another), not by counting events, so a replay cannot double-count. A nightly reconciliation recomputes rollups for users touched that day and alerts on drift.

Report generation.

Approach Latency Freshness Cost
Sync query over transactions with cache 50–500 ms, depends on volume Exact Heavy scans on power users
Nightly precompute < 20 ms Up to 24 h stale Recomputes idle users daily
Async with status Seconds, needs polling Exact Complex UX for a simple page
Incremental rollups on write < 20 ms Same as data Small write amplification

Pick incremental monthly_rollups updated in the sync transaction: a month's report is ~20 rows per user. Month-over-month and trend charts read 12 months of rollups (~240 rows). Keep the async path only for heavy exports (CSV for all years, tax summaries), which return 202 {job_id} and a download link.

Credential trust boundary. We never see bank passwords; the aggregator's hosted UI collects them. The access token is still a key to someone's financial history, so: envelope encryption (per-item data key, wrapped by a KMS key); item_secrets in a separate database that only the sync worker's IAM role can read; the API tier can never decrypt; tokens are never logged (log scrubbing plus structured logging tests); decrypt calls are audited and alerted on volume anomalies. Unlinking revokes at the aggregator first, then deletes the secret and purges transactions within 30 days. The webhook endpoint verifies the aggregator signature and timestamp to stop forged sync triggers.

Multi-DC failover. Two regions, active-passive per shard: Postgres primaries in region A with synchronous replicas in-region and async replicas in region B (lag < 1 s). Global DNS/anycast steers users to the nearest region for reads; writes (budget edits, recategorization) go to the shard primary's region. On region loss, promote region B replicas (RTO ~10 min, RPO ≈ replication lag). Because sync is idempotent and cursor-based, any transactions lost in the RPO window are simply re-fetched on the next pull. The scheduler runs with a leader lease per region so only one region issues pulls at a time, which keeps the aggregator budget intact.

Trade-offs

  • Using an aggregator gives up control of coverage, cost per call and outage behavior in exchange for never touching bank credentials or screen-scraping thousands of banks.
  • Daily-plus-webhook pulls trade some freshness (hours, not seconds) for a bill that scales with active users, not total items.
  • Incremental rollups make reports instant but add write work and a drift risk, which the nightly reconciliation covers.
  • Structured Postgres rows cost schema work for each aggregator field we keep, but make reports, filters and budgets plain SQL; raw payloads in S3 cover the rare replay.
  • Sharding by user keeps every query local but makes cross-user analytics (category model training) a separate warehouse job.
  • Active-passive regions are simpler and safe for money-adjacent data but leave region B's write capacity idle.
  • During an aggregator outage we show the last good data with an "as of" timestamp and pause alerts for stale items, rather than alerting on incomplete data.

Interviewer expectations

Mid-level (L4)

  • Uses an aggregator with a token instead of storing bank passwords.
  • Clear tables for items, accounts, transactions and budgets, with a unique key that prevents duplicate transactions.
  • A working scheduled pull and a paginated history API.

Senior (L5)

  • Sizes the aggregator call rate and spreads pulls with per-item cadence and rate budgets.
  • Handles pending-to-posted transitions, removals and replays idempotently, with cursor and rows committed together.
  • Chooses incremental rollups for reports and dedupes alerts with a unique (user, month, category, threshold) row.

Staff (L6+)

  • Draws the credential trust boundary: envelope encryption, separate secret store, decrypt only in sync workers, audit and revocation on unlink.
  • Defines the degraded-mode contract for aggregator outages (stale banners, paused alerts, backoff) and multi-region failover that relies on re-fetching for RPO.
  • Treats aggregator calls and storage as the main cost lever: dormant-user cadence, raw-payload lifecycle, monthly partitions and archive.

Sign in to comment, vote or remix.