Design a Personal Finance App: The Complete Guide
Building Mint, YNAB, and Personal Capital at scale — bank linking, transaction categorization, budgeting engines, and AI-powered insights
Table of Contents
- Introduction — The Personal Finance App Landscape
- Functional & Non-Functional Requirements
- Capacity Estimation & Back-of-Envelope
- Data Model & Storage Schema
- High-Level Architecture
- API Design
- Bank Account Linking with Plaid
- Transaction Categorization Engine
- Budget Management System
- Savings Goals Tracker
- Investment Tracking & Portfolio Analytics
- Bill Reminders & Recurring Detection
- Credit Score Integration
- Multi-Currency Support
- Reporting & Charts Engine
- Security, Encryption & Compliance
- Data Aggregation Pipeline
- AI-Powered Financial Insights
- Monitoring & Observability
- Cost Estimation
- Testing Strategy
- Interview Q&A
1. Introduction — The Personal Finance App Landscape
Personal finance applications have transformed how millions of people manage money, track spending, build budgets, and plan for retirement. Mint, acquired by Intuit for $170 million, grew to over 20 million users by aggregating bank accounts and automatically categorizing transactions. YNAB (You Need A Budget) built a subscription-based model around zero-based budgeting that generates over $100 million in annual revenue. Personal Capital, now Empower Personal Dashboard, manages over $16 billion in assets under advisement by combining account aggregation with investment tracking and retirement planning tools. These products demonstrate that personal finance is not just a consumer utility but a deeply monetizable platform.
Designing a personal finance application from scratch is one of the most complex system design challenges because it sits at the intersection of financial data aggregation, real-time processing, security compliance, machine learning, and high-fidelity user interfaces. You must handle sensitive financial data subject to regulations like PCI DSS, SOC 2, and GDPR. You must integrate with thousands of financial institutions through aggregation APIs. You must process millions of transactions per day and categorize them accurately. And you must present all of this data in real-time dashboards, charts, and reports that users trust with their financial lives.
This guide walks through the complete architecture of a modern personal finance application, from bank account linking and transaction ingestion to AI-powered insights and investment portfolio tracking. Every design decision is explained in the context of real-world tradeoffs, with production-grade C# code, database schemas, API contracts, and Mermaid architecture diagrams. By the end, you will be able to design and defend a personal finance system in any senior-level system design interview or architecture review.
2. Functional & Non-Functional Requirements
Functional Requirements
- Bank Account Linking: Users connect checking, savings, credit card, and investment accounts via Plaid or similar aggregation APIs. Support for over 10,000 financial institutions across the US, Canada, UK, and EU.
- Transaction Sync: Automatic daily synchronization of all transactions from linked accounts with support for historical data going back at least 24 months.
- Transaction Categorization: Automatic categorization of transactions into 20-30 top-level categories (Food & Dining, Transportation, Housing, etc.) with merchant-level subcategories. Users can override and re-categorize.
- Budget Management: Users create monthly or custom-period budgets per category with overspend alerts, rollover options, and envelope-style allocation.
- Savings Goals: Users define financial goals with target amounts and dates, track progress, and allocate surplus funds across goals.
- Investment Tracking: Real-time portfolio valuation, asset allocation breakdown, performance tracking against benchmarks, and dividend monitoring.
- Bill Reminders: Detection of recurring bills and subscriptions, due date reminders, and payment tracking.
- Credit Score Monitoring: Integration with credit bureaus or aggregators to display credit score trends and factors impacting the score.
- Multi-Currency Support: Transactions in multiple currencies with real-time conversion, historical exchange rates, and net worth calculation in a base currency.
- Reporting & Charts: Interactive spending breakdowns, income vs expense trends, net worth growth, and customizable date-range reports.
- AI-Powered Insights: Anomaly detection for unusual spending, personalized savings recommendations, and cash flow forecasting.
- Notifications & Alerts: Push notifications and email alerts for budget overspend, bill due dates, large transactions, and unusual account activity.
Non-Functional Requirements
| Requirement | Target | Justification |
|---|---|---|
| Availability | 99.99% | Financial data must be accessible; downtime directly impacts user trust |
| Read Latency (P95) | < 200ms | Dashboard loads and transaction lists must feel instant |
| Write Latency (P95) | < 500ms | Budget updates and categorization changes apply immediately |
| Data Durability | 99.999999% | Financial data loss is unacceptable; multi-AZ replication mandatory |
| Security | SOC 2 Type II, PCI DSS L1 | Regulatory requirement for handling financial data |
| Encryption | AES-256 at rest, TLS 1.3 in transit | Banking-grade encryption for all sensitive data |
| User Scale | 5 million users | Target user base within 2 years of launch |
| Transaction Throughput | 50M transactions/day | Average 10 transactions per user per day across all linked accounts |
3. Capacity Estimation & Back-of-Envelope
User and Transaction Volume
Assuming 5 million registered users with an average of 3 linked accounts per user, we have 15 million connected financial accounts. Each account generates approximately 10 transactions per day, yielding 150 million transactions daily or roughly 1,736 transactions per second at peak. With a 3x burst factor for payday spikes and holiday shopping, we need to sustain approximately 5,200 transactions per second during peak loads. The write volume for transaction storage alone is approximately 50 million inserts per day.
Storage Estimation
Each transaction record with metadata (amount, merchant, category, location, notes, tags) occupies approximately 1.5 kilobytes. Storing 50 million transactions per day yields 75 GB per day or 27.4 TB per year. User profiles, budgets, goals, and account metadata add roughly 500 bytes per record, totaling about 2.5 GB for 5 million users. Over five years with compression, the transaction store grows to approximately 80 TB. This calls for a tiered storage strategy where recent transactions (last 12 months) reside in hot storage (PostgreSQL or DynamoDB) and older data moves to warm/cold tiers (S3 with Athena for ad-hoc queries).
Bandwidth Estimation
| Direction | Per Request | Daily Volume | Bandwidth |
|---|---|---|---|
| Inbound (Transaction Sync) | 2 KB | 150M | 300 GB/day |
| Outbound (Dashboard Reads) | 5 KB | 25M page views | 125 GB/day |
| Outbound (Push Notifications) | 0.5 KB | 10M notifications | 5 GB/day |
| Plaid API Calls (Inbound) | 15 KB | 45M sync calls | 675 GB/day |
Cache Estimation
The dashboard data for each user (recent transactions, budget status, net worth summary) can be cached aggressively because financial data only updates once per bank sync cycle (typically daily). Caching the top 20% of most active users in a Redis cluster of 64 GB (approximately 1 million active user sessions) reduces database reads by approximately 60%. Transaction category data, merchant mappings, and exchange rates are essentially static and should be cached globally with TTLs of 24 hours.
4. Data Model & Storage Schema
Entity Relationship Overview
Core Tables
SQL
CREATE TABLE users (
user_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email VARCHAR(255) NOT NULL UNIQUE,
password_hash VARCHAR(512) NOT NULL,
display_name VARCHAR(100),
base_currency CHAR(3) NOT NULL DEFAULT 'USD',
timezone VARCHAR(50) NOT NULL DEFAULT 'America/New_York',
onboarding_complete BOOLEAN NOT NULL DEFAULT FALSE,
mfa_enabled BOOLEAN NOT NULL DEFAULT FALSE,
mfa_secret VARCHAR(255),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMPTZ
);
CREATE TABLE linked_accounts (
account_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(user_id),
plaid_access_token VARCHAR(512) NOT NULL, -- encrypted at rest
plaid_item_id VARCHAR(255) NOT NULL,
institution_id VARCHAR(100) NOT NULL,
institution_name VARCHAR(255) NOT NULL,
account_type VARCHAR(50) NOT NULL, -- checking, savings, credit, investment
account_subtype VARCHAR(50),
account_name VARCHAR(255),
mask VARCHAR(10), -- last 4 digits
currency_code CHAR(3) NOT NULL DEFAULT 'USD',
is_active BOOLEAN NOT NULL DEFAULT TRUE,
sync_status VARCHAR(20) NOT NULL DEFAULT 'pending',
last_synced_at TIMESTAMPTZ,
cursor VARCHAR(512), -- Plaid cursor for incremental sync
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE transactions (
transaction_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
account_id UUID NOT NULL REFERENCES linked_accounts(account_id),
user_id UUID NOT NULL REFERENCES users(user_id),
plaid_transaction_id VARCHAR(255),
amount DECIMAL(18,4) NOT NULL,
currency_code CHAR(3) NOT NULL DEFAULT 'USD',
amount_in_base DECIMAL(18,4), -- converted to user base currency
exchange_rate DECIMAL(18,8),
date DATE NOT NULL,
authorized_date DATE,
name VARCHAR(500) NOT NULL,
merchant_name VARCHAR(255),
merchant_id VARCHAR(255),
category_id UUID REFERENCES categories(category_id),
category_source VARCHAR(20) NOT NULL DEFAULT 'auto', -- auto, plaid, user
subcategory VARCHAR(100),
is_pending BOOLEAN NOT NULL DEFAULT FALSE,
is_transfer BOOLEAN NOT NULL DEFAULT FALSE,
is_duplicate BOOLEAN NOT NULL DEFAULT FALSE,
location_city VARCHAR(255),
location_state VARCHAR(100),
location_country CHAR(2),
latitude DECIMAL(10,7),
longitude DECIMAL(10,7),
notes TEXT,
tags TEXT[],
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_txn_user_date ON transactions(user_id, date DESC);
CREATE INDEX idx_txn_account_date ON transactions(account_id, date DESC);
CREATE INDEX idx_txn_category ON transactions(category_id);
CREATE INDEX idx_txn_merchant ON transactions(merchant_id);
CREATE TABLE categories (
category_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(100) NOT NULL,
parent_id UUID REFERENCES categories(category_id),
icon VARCHAR(50),
color CHAR(7),
sort_order INT NOT NULL DEFAULT 0,
is_system BOOLEAN NOT NULL DEFAULT TRUE,
user_id UUID REFERENCES users(user_id), -- NULL for system categories
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE budgets (
budget_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(user_id),
category_id UUID NOT NULL REFERENCES categories(category_id),
amount DECIMAL(18,2) NOT NULL,
period_type VARCHAR(20) NOT NULL DEFAULT 'monthly', -- monthly, weekly, custom
start_date DATE NOT NULL,
end_date DATE,
rollover_enabled BOOLEAN NOT NULL DEFAULT FALSE,
alert_threshold DECIMAL(5,2) NOT NULL DEFAULT 0.80, -- 80% overspend warning
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE savings_goals (
goal_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(user_id),
name VARCHAR(200) NOT NULL,
target_amount DECIMAL(18,2) NOT NULL,
current_amount DECIMAL(18,2) NOT NULL DEFAULT 0,
target_date DATE,
priority INT NOT NULL DEFAULT 0,
icon VARCHAR(50),
color CHAR(7),
auto_allocate BOOLEAN NOT NULL DEFAULT FALSE,
allocate_percentage DECIMAL(5,2),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE account_balances (
balance_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
account_id UUID NOT NULL REFERENCES linked_accounts(account_id),
available_balance DECIMAL(18,4),
current_balance DECIMAL(18,4),
limit_amount DECIMAL(18,4),
iso_currency_code CHAR(3) NOT NULL,
as_of TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
Partitioning Strategy
The transactions table is the largest and most query-intensive table in the system. Range partitioning by month on the date column ensures that queries filtered by date range scan only relevant partitions. With PostgreSQL, declarative partitioning creates monthly partitions automatically. For DynamoDB, the partition key is user_id and the sort key is date#transaction_id, enabling efficient per-user time-range queries while distributing load evenly across partitions.
5. High-Level Architecture
Service Responsibilities
The architecture follows a microservices pattern with clear domain boundaries. The User Service manages authentication, profiles, and preferences. The Account Linking Service handles Plaid token exchange, institution management, and re-authentication flows. The Transaction Sync Service is the highest-throughput component, polling Plaid for new transactions on a configurable schedule and publishing events to Kafka. The Categorization Engine consumes transaction events, applies rule-based and ML-based classification, and updates transaction records. The Budget Service tracks spending against budgets in real-time using materialized views. The AI Insights Service runs batch and streaming analytics to generate spending anomalies, savings recommendations, and cash flow forecasts.
PostgreSQL serves as the primary transactional database with read replicas handling analytical queries. Redis caches hot user data, budget status, and session tokens. Elasticsearch powers full-text search across transactions (merchant names, notes, tags). S3 serves as the data lake for historical analytics, ML training data, and compliance archives. Apache Kafka connects all services through an event bus, ensuring loose coupling and enabling replay of events for new consumer onboarding or debugging.
6. API Design
REST API Endpoints
| Method | Endpoint | Description |
|---|---|---|
| POST | /api/v1/auth/register | Create new user account |
| POST | /api/v1/auth/login | Authenticate and return JWT tokens |
| GET | /api/v1/accounts | List all linked accounts |
| POST | /api/v1/accounts/link | Initiate Plaid Link token exchange |
| DELETE | /api/v1/accounts/{id} | Unlink a financial account |
| GET | /api/v1/transactions | List transactions with filters |
| GET | /api/v1/transactions/{id} | Get single transaction details |
| PATCH | /api/v1/transactions/{id} | Update category, notes, tags |
| POST | /api/v1/transactions/sync | Trigger manual sync |
| GET | /api/v1/budgets | List all budgets with progress |
| POST | /api/v1/budgets | Create a new budget |
| PUT | /api/v1/budgets/{id} | Update budget amount or settings |
| GET | /api/v1/goals | List savings goals |
| POST | /api/v1/goals | Create savings goal |
| POST | /api/v1/goals/{id}/allocate | Allocate funds to goal |
| GET | /api/v1/investments/portfolio | Get portfolio summary |
| GET | /api/v1/reports/spending | Spending breakdown report |
| GET | /api/v1/reports/net-worth | Net worth history report |
| GET | /api/v1/insights | AI-generated insights |
| GET | /api/v1/credit-score | Latest credit score and history |
Transaction List Request Example
C#
public class TransactionListRequest
{
public DateTime? StartDate { get; set; }
public DateTime? EndDate { get; set; }
public Guid? AccountId { get; set; }
public Guid? CategoryId { get; set; }
public string? MerchantSearch { get; set; }
public decimal? MinAmount { get; set; }
public decimal? MaxAmount { get; set; }
public string? SortBy { get; set; } = "date";
public string? SortOrder { get; set; } = "desc";
public int Page { get; set; } = 1;
public int PageSize { get; set; } = 50;
}
public class TransactionListResponse
{
public List<TransactionDto> Transactions { get; set; } = new();
public int TotalCount { get; set; }
public decimal TotalIncome { get; set; }
public decimal TotalExpenses { get; set; }
public decimal NetCashFlow { get; set; }
public int Page { get; set; }
public int PageSize { get; set; }
public bool HasMore { get; set; }
}
Transaction Sync Request Flow
The Plaid transaction sync endpoint follows a cursor-based pagination model. When a user links an account, the system stores an initial Plaid access token. The sync service then calls /transactions/sync with the stored cursor, receiving new, modified, and removed transactions in batches of up to 500. After processing each batch, the cursor is updated. If a cursor becomes stale (Plaid expires cursors after 30 days of inactivity), the system falls back to a full /transactions/get with a date range and rebuilds the cursor. This design ensures that no transactions are missed even during periods of sync downtime.
C#
public class PlaidTransactionSyncService
{
private readonly IPlaidClient _plaidClient;
private readonly ITransactionRepository _transactionRepo;
private readonly ILinkedAccountRepository _accountRepo;
private readonly IKafkaProducer _kafkaProducer;
private readonly ILogger<PlaidTransactionSyncService> _logger;
public async Task<SyncResult> SyncAccountAsync(
Guid accountId, CancellationToken ct = default)
{
var account = await _accountRepo.GetByIdAsync(accountId, ct)
?? throw new NotFoundException($"Account {accountId} not found");
var allAdded = new List<PlaidTransaction>();
var allModified = new List<PlaidTransaction>();
var allRemoved = new List<string>();
string? cursor = account.Cursor;
bool hasMore = true;
while (hasMore)
{
var request = new TransactionsSyncRequest
{
AccessToken = DecryptToken(account.PlaidAccessToken),
Cursor = cursor,
Count = 500
};
var response = await _plaidClient.TransactionsSyncAsync(request);
allAdded.AddRange(response.Added);
allModified.AddRange(response.Modified);
allRemoved.AddRange(response.Removed);
cursor = response.NextCursor;
hasMore = response.HasMore;
_logger.LogInformation(
"Sync batch for {AccountId}: +{Added} ~{Modified} -{Removed}",
accountId, response.Added.Count,
response.Modified.Count, response.Removed.Count);
}
await ProcessTransactionsAsync(account, allAdded, ct);
await ProcessModifiedTransactionsAsync(account, allModified, ct);
await ProcessRemovedTransactionsAsync(account, allRemoved, ct);
account.Cursor = cursor;
account.LastSyncedAt = DateTimeOffset.UtcNow;
account.SyncStatus = "synced";
await _accountRepo.UpdateAsync(account, ct);
return new SyncResult
{
Added = allAdded.Count,
Modified = allModified.Count,
Removed = allRemoved.Count,
SyncedAt = DateTimeOffset.UtcNow
};
}
}
The reporting engine also supports exporting data in multiple formats including CSV, PDF, and interactive HTML reports for offline viewing and sharing with financial advisors. The export service generates the report asynchronously, stores the result in S3, and provides a signed download URL valid for 24 hours. For recurring reports, users can schedule automatic exports delivered to their email on a weekly or monthly cadence. The PDF generation uses a headless browser with a custom stylesheet to produce professional-looking reports with embedded charts and formatted tables.
Custom category creation is another critical feature that allows users to tailor the classification system to their specific spending patterns. Users can create subcategories under existing top-level categories, define regex-based rules that automatically categorize transactions matching specific merchant names or transaction descriptions, and set default categories for specific merchants. The rule engine evaluates user-defined rules first before falling back to the system-wide merchant database and ML classifier. User rules are stored in a separate rules table with a priority field that determines evaluation order, allowing users to override system defaults at the most granular level possible.
7. Bank Account Linking with Plaid
Plaid Integration Architecture
Plaid is the industry-standard financial data aggregator used by over 12,000 financial institutions. The integration follows a three-step process: first, the client-side application creates a Plaid Link token and launches the Plaid Link widget where users authenticate with their bank credentials. Second, the client sends the resulting public_token to our backend, which exchanges it for a persistent access_token via Plaid's /item/public_token/exchange endpoint. Third, the backend stores the encrypted access token and immediately fetches account metadata and recent transactions.
Token Security
Plaid access tokens grant read access to a user's financial accounts and must be protected with the same rigor as database credentials. Tokens are encrypted using AES-256-GCM with a key managed in AWS KMS before being stored in the database. The decryption key is never stored alongside the token; the encryption and decryption operations happen within a dedicated Secrets Service running in a private subnet. At no point does an access token appear in application logs, error messages, or API responses. When a user unlinks an account, the access token is deleted from storage and Plaid is notified to revoke it.
C#
public class PlaidTokenService
{
private readonly IKmsClient _kms;
private readonly ISecureRepository _secureStore;
public async Task<string> ExchangeAndStoreTokenAsync(
string publicToken, Guid userId, Guid accountId)
{
var exchangeRequest = new ItemPublicTokenExchangeRequest
{
PublicToken = publicToken
};
var response = await _plaidClient.ItemPublicTokenExchangeAsync(
exchangeRequest);
var accessToken = response.AccessToken;
var itemId = response.ItemId;
var encryptedToken = await _kms.EncryptAsync(
Encoding.UTF8.GetBytes(accessToken));
await _secureStore.StoreAsync(new SecureCredential
{
UserId = userId,
AccountId = accountId,
PlaidItemId = itemId,
EncryptedAccessToken = encryptedToken,
KeyVersion = await _kms.GetCurrentKeyVersionAsync(),
CreatedAt = DateTimeOffset.UtcNow
});
return itemId;
}
public async Task<string> GetDecryptedTokenAsync(Guid accountId)
{
var credential = await _secureStore.GetByAccountIdAsync(accountId);
var decryptedBytes = await _kms.DecryptAsync(
credential.EncryptedAccessToken,
credential.KeyVersion);
return Encoding.UTF8.GetString(decryptedBytes);
}
}
Re-Authentication Handling
Banks frequently require users to re-authenticate due to password changes, security updates, or credential expiration. Plaid signals this through ITEM_LOGIN_REQUIRED and ITEM_ERROR webhook events. When the system receives these webhooks, it marks the account as reauth_required, pauses syncing, and sends a push notification to the user prompting them to re-link the account through Plaid Link's update mode. The update mode preserves existing account metadata while refreshing the access token, minimizing data loss.
8. Transaction Categorization Engine
Multi-Layer Classification
Transaction categorization is the core intelligence of a personal finance app. Plaid provides its own categorization through the personal_finance_category field, but it is often too coarse for detailed budgeting. The system implements a three-layer classification pipeline: rule-based matching, merchant database lookup, and ML-based classification. Rule-based matching applies user-defined rules and regex patterns (for example, any transaction containing "STARBUCKS" maps to Coffee Shops under Food & Dining). Merchant database lookup uses a curated database of 500,000+ merchants with known categories. ML classification handles remaining unclassified transactions using a gradient-boosted decision tree trained on anonymized user corrections.
C#
public class TransactionCategorizer : ICategorizer
{
private readonly IRuleEngine _ruleEngine;
private readonly IMerchantDatabase _merchantDb;
private readonly IMLClassifier _mlClassifier;
private readonly ILogger<TransactionCategorizer> _logger;
public async Task<CategoryResult> CategorizeAsync(
Transaction transaction, CancellationToken ct = default)
{
// Layer 1: Rule-based matching
var ruleResult = await _ruleEngine.EvaluateAsync(transaction, ct);
if (ruleResult.IsMatch)
{
_logger.LogDebug(
"Transaction {TxId} matched rule: {RuleId}",
transaction.Id, ruleResult.RuleId);
return new CategoryResult
{
CategoryId = ruleResult.CategoryId,
Source = CategorySource.UserRule,
Confidence = 1.0m
};
}
// Layer 2: Merchant database lookup
var merchantResult = await _merchantDb.LookupAsync(
transaction.MerchantName, transaction.Name, ct);
if (merchantResult != null && merchantResult.Confidence > 0.9m)
{
return new CategoryResult
{
CategoryId = merchantResult.CategoryId,
Source = CategorySource.MerchantDatabase,
Confidence = merchantResult.Confidence
};
}
// Layer 3: ML classification
var features = ExtractFeatures(transaction);
var prediction = await _mlClassifier.PredictAsync(features, ct);
if (prediction.Confidence > 0.85m)
{
return new CategoryResult
{
CategoryId = prediction.CategoryId,
Source = CategorySource.MLModel,
Confidence = prediction.Confidence
};
}
// Low confidence - flag for user review
return new CategoryResult
{
CategoryId = null,
Source = CategorySource.Uncategorized,
Confidence = prediction.Confidence,
SuggestedCategoryId = prediction.CategoryId
};
}
private FeatureVector ExtractFeatures(Transaction txn)
{
return new FeatureVector
{
MerchantName = txn.MerchantName,
MerchantCategoryCode = txn.MccCode,
TransactionAmount = txn.Amount,
TransactionDayOfWeek = txn.Date.DayOfWeek,
TransactionHour = txn.AuthorizedDate?.Hour ?? 0,
DescriptionTokens = TokenizeDescription(txn.Name),
HistoricalCategories = txn.UserCategoryHistory,
LocationFeatures = new LocationFeature
{
City = txn.LocationCity,
State = txn.LocationState,
Country = txn.LocationCountry
}
};
}
}
Category Hierarchy
| Top-Level Category | Subcategories | Typical Budget % |
|---|---|---|
| Housing | Rent, Mortgage, HOA, Home Insurance, Maintenance | 25-35% |
| Transportation | Gas, Public Transit, Ride Share, Car Insurance, Parking | 10-15% |
| Food & Dining | Groceries, Restaurants, Coffee Shops, Delivery, Alcohol | 10-15% |
| Utilities | Electric, Gas, Water, Internet, Phone | 5-10% |
| Healthcare | Insurance, Doctor, Pharmacy, Dental, Vision | 5-10% |
| Entertainment | Streaming, Movies, Games, Hobbies, Sports | 5-10% |
| Shopping | Clothing, Electronics, Home Goods, Gifts | 5-10% |
| Personal Care | Haircut, Gym, Toiletries, Clothing Maintenance | 2-5% |
| Education | Tuition, Books, Online Courses, Student Loans | 5-10% |
| Savings & Investments | Emergency Fund, Retirement, Brokerage, Savings Account | 10-20% |
User Override and Feedback Loop
When a user re-categorizes a transaction, the system records the correction and updates the merchant database mapping for that merchant. Over time, user corrections build a personalized categorization layer that takes priority over automatic classification for that user. Aggregate anonymized corrections are fed back into the ML model training pipeline on a weekly basis, continuously improving classification accuracy across all users. The model achieves approximately 92% top-1 accuracy and 98% top-3 accuracy after six months of user feedback data.
9. Budget Management System
Budget Calculation Engine
The budgeting system tracks spending against user-defined limits in real time. Each budget defines a category, an amount limit, and a period (monthly, weekly, or custom date range). The system calculates budget progress by summing all transactions in the category that fall within the budget period, excluding transfers and pending transactions. Budget progress is materialized as a view that is updated on every transaction insert or categorization change.
C#
public class BudgetService
{
private readonly IBudgetRepository _budgetRepo;
private readonly ITransactionRepository _transactionRepo;
private readonly ICacheService _cache;
private readonly INotificationService _notifications;
public async Task<BudgetStatusResponse> GetBudgetStatusAsync(
Guid userId, CancellationToken ct = default)
{
var cacheKey = $"budget_status:{userId}";
var cached = await _cache.GetAsync<BudgetStatusResponse>(cacheKey, ct);
if (cached != null) return cached;
var budgets = await _budgetRepo.GetActiveBudgetsByUserAsync(userId, ct);
var now = DateTime.UtcNow;
var periodStart = GetPeriodStart(now);
var periodEnd = GetPeriodEnd(now);
var statuses = new List<BudgetStatusDto>();
foreach (var budget in budgets)
{
var spent = await _transactionRepo.SumByCategoryAsync(
userId, budget.CategoryId, periodStart, periodEnd, ct);
var remaining = budget.Amount - spent;
var percentUsed = budget.Amount > 0
? (spent / budget.Amount) * 100
: 0;
var status = new BudgetStatusDto
{
BudgetId = budget.BudgetId,
CategoryId = budget.CategoryId,
BudgetAmount = budget.Amount,
SpentAmount = spent,
RemainingAmount = remaining,
PercentUsed = percentUsed,
PeriodStart = periodStart,
PeriodEnd = periodEnd,
IsOverspent = spent > budget.Amount,
IsNearLimit = percentUsed >= (budget.AlertThreshold * 100),
DailyBurnRate = CalculateDailyBurnRate(
spent, periodStart, now),
ProjectedSpend = CalculateProjectedSpend(
spent, periodStart, periodEnd, now),
DaysRemaining = (periodEnd - now).Days
};
if (status.IsNearLimit && !status.IsOverspent)
{
await _notifications.SendBudgetAlertAsync(userId, status, ct);
}
statuses.Add(status);
}
var response = new BudgetStatusResponse
{
PeriodStart = periodStart,
PeriodEnd = periodEnd,
TotalBudgeted = statuses.Sum(s => s.BudgetAmount),
TotalSpent = statuses.Sum(s => s.SpentAmount),
Budgets = statuses
};
await _cache.SetAsync(cacheKey, response,
TimeSpan.FromMinutes(15), ct);
return response;
}
private decimal CalculateDailyBurnRate(
decimal spent, DateTime periodStart, DateTime now)
{
var daysElapsed = (now - periodStart).Days;
return daysElapsed > 0 ? spent / daysElapsed : 0;
}
private decimal CalculateProjectedSpend(
decimal spent, DateTime periodStart,
DateTime periodEnd, DateTime now)
{
var totalDays = (periodEnd - periodStart).Days;
var daysElapsed = (now - periodStart).Days;
if (daysElapsed == 0 || totalDays == 0) return spent;
var dailyRate = spent / daysElapsed;
return dailyRate * totalDays;
}
}
Rollover Budgets
Rollover budgets allow unspent amounts to carry over to the next period. For example, if a user budgets $500 for groceries but only spends $420, the remaining $80 rolls into next month's budget, giving them $580 to spend. The system tracks rollable amounts in a separate ledger table and applies them when computing the effective budget for the new period. This feature is essential for apps targeting the envelope budgeting methodology used by YNAB.
10. Savings Goals Tracker
Goal Allocation Algorithm
Savings goals allow users to earmark portions of their income for specific purposes such as an emergency fund, vacation, down payment, or retirement contribution. The system tracks target amounts, current allocations, progress percentages, and projected completion dates based on historical contribution rates. When auto-allocation is enabled, the system computes a recommended split across goals using a priority-weighted algorithm.
C#
public class SavingsGoalService
{
private readonly IGoalRepository _goalRepo;
private readonly ITransactionRepository _transactionRepo;
public async Task<AllocationRecommendation> CalculateAutoAllocationAsync(
Guid userId, decimal availableAmount, CancellationToken ct)
{
var goals = await _goalRepo.GetActiveGoalsAsync(userId, ct);
if (!goals.Any())
{
return new AllocationRecommendation
{
Allocations = new List<GoalAllocation>(),
RemainingUnallocated = availableAmount
};
}
var allocations = new List<GoalAllocation>();
var remaining = availableAmount;
// Sort by priority (highest first), then by target date (earliest first)
var sortedGoals = goals
.OrderByDescending(g => g.Priority)
.ThenBy(g => g.TargetDate ?? DateTime.MaxValue)
.ToList();
foreach (var goal in sortedGoals)
{
if (remaining <= 0) break;
var deficit = goal.TargetAmount - goal.CurrentAmount;
if (deficit <= 0) continue;
decimal allocation;
if (goal.AutoAllocate && goal.AllocatePercentage.HasValue)
{
allocation = Math.Min(
availableAmount * (goal.AllocatePercentage.Value / 100),
deficit);
}
else
{
// Equal weight among remaining goals
var goalsRemaining = sortedGoals
.Count(g => (g.TargetAmount - g.CurrentAmount) > 0);
allocation = Math.Min(
remaining / goalsRemaining, deficit);
}
allocation = Math.Round(allocation, 2);
if (allocation > 0)
{
allocations.Add(new GoalAllocation
{
GoalId = goal.GoalId,
GoalName = goal.Name,
Amount = allocation,
NewProgress = ((goal.CurrentAmount + allocation)
/ goal.TargetAmount) * 100,
ProjectedCompletionDate =
CalculateCompletionDate(goal, allocation)
});
remaining -= allocation;
}
}
return new AllocationRecommendation
{
Allocations = allocations,
RemainingUnallocated = Math.Round(remaining, 2)
};
}
private DateTime? CalculateCompletionDate(
SavingsGoal goal, decimal monthlyContribution)
{
if (monthlyContribution <= 0) return null;
var deficit = goal.TargetAmount - goal.CurrentAmount;
var monthsNeeded = Math.Ceiling(deficit / monthlyContribution);
return DateTime.UtcNow.AddMonths((int)monthsNeeded);
}
}
Goal Progress Tracking
| Goal Name | Target | Current | Progress | Monthly Rate | Est. Completion |
|---|---|---|---|---|---|
| Emergency Fund | $15,000 | $9,200 | 61.3% | $800/mo | Jul 2027 |
| Japan Vacation | $5,000 | $3,750 | 75.0% | $500/mo | Sep 2026 |
| Down Payment | $60,000 | $18,000 | 30.0% | $1,500/mo | Mar 2029 |
| New Laptop | $2,500 | $2,100 | 84.0% | $400/mo | Aug 2026 |
11. Investment Tracking & Portfolio Analytics
Portfolio Data Model
Investment tracking extends the transaction model to include security-level data. Plaid's investment endpoint returns holdings (current positions) and securities (metadata about each stock, ETF, mutual fund, or bond). The system fetches real-time or delayed quotes from a market data provider (Yahoo Finance API, Alpha Vantage, or Polygon.io) and computes portfolio-level metrics including total value, daily change, asset allocation, sector breakdown, and performance against benchmarks like the S&P 500.
C#
public class PortfolioAnalyticsService
{
private readonly IHoldingRepository _holdingRepo;
private readonly ISecurityQuoteService _quoteService;
private readonly ICacheService _cache;
public async Task<PortfolioSummary> GetPortfolioSummaryAsync(
Guid userId, CancellationToken ct = default)
{
var cacheKey = $"portfolio:{userId}";
var cached = await _cache.GetAsync<PortfolioSummary>(cacheKey, ct);
if (cached != null) return cached;
var holdings = await _holdingRepo.GetHoldingsByUserAsync(userId, ct);
var enrichedHoldings = new List<EnrichedHolding>();
decimal totalValue = 0;
decimal totalCostBasis = 0;
foreach (var holding in holdings)
{
var quote = await _quoteService.GetQuoteAsync(
holding.SecuritySymbol, ct);
var currentValue = holding.Quantity * quote.CurrentPrice;
var costBasis = holding.Quantity * holding.AverageCostBasis;
var gainLoss = currentValue - costBasis;
var gainLossPercent = costBasis > 0
? (gainLoss / costBasis) * 100 : 0;
enrichedHoldings.Add(new EnrichedHolding
{
Symbol = holding.SecuritySymbol,
Name = holding.SecurityName,
Quantity = holding.Quantity,
AverageCostBasis = holding.AverageCostBasis,
CurrentPrice = quote.CurrentPrice,
MarketValue = currentValue,
DayChange = quote.CurrentPrice - quote.PreviousClose,
DayChangePercent = ((quote.CurrentPrice - quote.PreviousClose)
/ quote.PreviousClose) * 100,
TotalGainLoss = gainLoss,
TotalGainLossPercent = gainLossPercent,
AssetClass = holding.AssetClass,
Sector = holding.Sector
});
totalValue += currentValue;
totalCostBasis += costBasis;
}
var allocation = enrichedHoldings
.GroupBy(h => h.AssetClass)
.Select(g => new AssetAllocation
{
AssetClass = g.Key,
Value = g.Sum(h => h.MarketValue),
PercentOfPortfolio = (g.Sum(h => h.MarketValue)
/ totalValue) * 100
})
.OrderByDescending(a => a.PercentOfPortfolio)
.ToList();
var summary = new PortfolioSummary
{
TotalValue = totalValue,
TotalCostBasis = totalCostBasis,
TotalGainLoss = totalValue - totalCostBasis,
TotalGainLossPercent = totalCostBasis > 0
? ((totalValue - totalCostBasis) / totalCostBasis) * 100
: 0,
DayChange = enrichedHoldings.Sum(h => h.DayChange
* h.Quantity),
DayChangePercent = totalValue > 0
? (enrichedHoldings.Sum(h => h.DayChange * h.Quantity)
/ (totalValue - enrichedHoldings.Sum(h => h.DayChange
* h.Quantity))) * 100
: 0,
Holdings = enrichedHoldings,
AssetAllocation = allocation,
LastUpdated = DateTimeOffset.UtcNow
};
await _cache.SetAsync(cacheKey, summary,
TimeSpan.FromMinutes(5), ct);
return summary;
}
}
Performance Metrics
| Metric | Formula | Update Frequency |
|---|---|---|
| Total Return | (Current Value - Cost Basis) / Cost Basis | Real-time (market hours) |
| Day Change | Sum of (Current Price - Previous Close) * Quantity | Every 5 minutes |
| Asset Allocation | Group holdings by asset class, sum values | On quote update |
| Dividend Yield | Annual Dividends / Current Value | Quarterly |
| Benchmark Comparison | Portfolio Return vs S&P 500 Return | Daily |
| Sharpe Ratio | (Portfolio Return - Risk Free Rate) / Std Dev | Monthly |
12. Bill Reminders & Recurring Detection
Recurring Transaction Detection Algorithm
The bill reminder system identifies recurring transactions through pattern detection. The algorithm groups transactions by merchant, then analyzes the time intervals and amounts to determine if a pattern is recurring. A transaction is classified as recurring if three or more transactions from the same merchant occur within a statistically regular interval (standard deviation of intervals less than 20% of the mean interval) and amounts are within 15% of each other. The system detects monthly, biweekly, weekly, quarterly, and annual patterns.
C#
public class RecurringDetector
{
public RecurringPattern AnalyzePatterns(
List<Transaction> merchantTransactions)
{
if (merchantTransactions.Count < 3)
return new RecurringPattern { IsRecurring = false };
var sorted = merchantTransactions
.OrderBy(t => t.Date)
.ToList();
// Calculate intervals between consecutive transactions
var intervals = new List<int>();
for (int i = 1; i < sorted.Count; i++)
{
intervals.Add((sorted[i].Date - sorted[i - 1].Date).Days);
}
var meanInterval = intervals.Average();
var stdDev = Math.Sqrt(
intervals.Average(i => Math.Pow(i - meanInterval, 2)));
// Check if intervals are regular (coefficient of variation < 0.2)
var coefficientOfVariation = meanInterval > 0
? stdDev / meanInterval : double.MaxValue;
if (coefficientOfVariation > 0.2)
return new RecurringPattern { IsRecurring = false };
// Check amount consistency
var amounts = sorted.Select(t => Math.Abs(t.Amount)).ToList();
var amountMean = amounts.Average();
var amountStdDev = Math.Sqrt(
amounts.Average(a => Math.Pow(a - amountMean, 2)));
var amountCv = amountMean > 0
? amountStdDev / amountMean : double.MaxValue;
if (amountCv > 0.15)
return new RecurringPattern { IsRecurring = false };
// Determine frequency
var frequency = meanInterval switch
{
>= 6 and <= 8 => RecurringFrequency.Weekly,
>= 12 and <= 16 => RecurringFrequency.Biweekly,
>= 27 and <= 33 => RecurringFrequency.Monthly,
>= 85 and <= 95 => RecurringFrequency.Quarterly,
>= 355 and <= 375 => RecurringFrequency.Annually,
_ => RecurringFrequency.Unknown
};
var nextDueDate = sorted.Last().Date.AddDays(meanInterval);
return new RecurringPattern
{
IsRecurring = true,
Frequency = frequency,
AverageAmount = amountMean,
MerchantName = sorted.First().MerchantName,
NextDueDate = nextDueDate,
Confidence = 1.0 - (decimal)coefficientOfVariation,
TransactionCount = sorted.Count,
FirstSeen = sorted.First().Date,
LastSeen = sorted.Last().Date
};
}
}
Notification Scheduling
Once a recurring pattern is detected, the notification service schedules reminders based on user preferences. Default notifications are sent 3 days before the projected due date and again on the day of. Users can customize notification timing per bill. The scheduler uses a persistent queue (Amazon SQS or a database-backed job scheduler) rather than in-memory timers to survive service restarts and ensure reliable delivery across deployments.
13. Credit Score Integration
Credit Score Data Pipeline
Credit score monitoring provides users with visibility into their creditworthiness. The system integrates with credit score providers like Experian, Equifax, TransUnion (via Plaid's credit product), or free providers like Credit Karma's API. Credit scores are fetched on a monthly basis and stored as time-series snapshots to enable trend analysis. The data model tracks the overall score, individual factor impacts (payment history, credit utilization, length of history, credit mix, new inquiries), and account-level details.
C#
public class CreditScoreService
{
private readonly ICreditProvider _creditProvider;
private readonly ICreditScoreRepository _creditRepo;
private readonly INotificationService _notifications;
public async Task<CreditScoreReport> FetchLatestScoreAsync(
Guid userId, CancellationToken ct = default)
{
var providerData = await _creditProvider.GetCreditScoreAsync(
userId, ct);
var snapshot = new CreditScoreSnapshot
{
UserId = userId,
Score = providerData.Score,
ScoreRange = providerData.ScoreRange, // 300-850
Provider = providerData.ProviderName,
FetchedAt = DateTimeOffset.UtcNow,
Factors = providerData.Factors.Select(f => new ScoreFactor
{
FactorName = f.Name,
Impact = f.Impact, // positive, negative, neutral
Description = f.Description,
Weight = f.Weight
}).ToList(),
Accounts = providerData.Accounts.Select(a => new CreditAccount
{
AccountName = a.Name,
AccountType = a.Type,
Balance = a.Balance,
CreditLimit = a.CreditLimit,
UtilizationPercent = a.CreditLimit > 0
? (a.Balance / a.CreditLimit) * 100 : 0,
PaymentStatus = a.PaymentStatus,
isOpen = a.IsOpen
}).ToList(),
TotalAccounts = providerData.Accounts.Count,
OpenAccounts = providerData.Accounts.Count(a => a.IsOpen),
TotalBalance = providerData.Accounts.Sum(a => a.Balance),
TotalCreditLimit = providerData.Accounts
.Sum(a => a.CreditLimit),
OverallUtilization = providerData.Accounts
.Sum(a => a.CreditLimit) > 0
? (providerData.Accounts.Sum(a => a.Balance)
/ providerData.Accounts.Sum(a => a.CreditLimit)) * 100
: 0
};
await _creditRepo.SaveSnapshotAsync(snapshot, ct);
// Check for significant score changes
var previousSnapshot = await _creditRepo
.GetLatestSnapshotAsync(userId, ct);
if (previousSnapshot != null)
{
var scoreDelta = snapshot.Score - previousSnapshot.Score;
if (Math.Abs(scoreDelta) >= 10)
{
await _notifications.SendCreditScoreAlertAsync(
userId, snapshot.Score, scoreDelta, ct);
}
}
return new CreditScoreReport
{
Current = snapshot,
History = await _creditRepo
.GetScoreHistoryAsync(userId, 12, ct),
AverageScore = await _creditRepo
.GetAverageScoreAsync(userId, ct)
};
}
}
Credit Score Factors
| Factor | Weight | What It Measures | Tips to Improve |
|---|---|---|---|
| Payment History | 35% | On-time payment record | Set up autopay for all bills; never miss a payment |
| Credit Utilization | 30% | Ratio of balances to credit limits | Keep utilization below 30%, ideally below 10% |
| Length of History | 15% | Average age of all accounts | Keep oldest accounts open even if unused |
| Credit Mix | 10% | Variety of credit types | Maintain mix of revolving, installment, and mortgage |
| New Inquiries | 10% | Recent hard inquiries | Limit applications; use soft pulls for rate shopping |
14. Multi-Currency Support
Exchange Rate Management
Multi-currency support is critical for users with international accounts, frequent travelers, or expatriates. The system fetches exchange rates from a provider like Open Exchange Rates or Fixer.io every hour and stores them in a time-series table. Each transaction is stored with its original currency and an amount_in_base field representing the converted amount at the rate effective on the transaction date. Historical rates are used for retroactive conversions when rates are updated, ensuring consistency in historical reporting.
C#
public class CurrencyConversionService
{
private readonly IExchangeRateRepository _rateRepo;
private readonly ICacheService _cache;
public async Task<decimal> ConvertAsync(
decimal amount,
string fromCurrency,
string toCurrency,
DateTime? rateDate = null,
CancellationToken ct = default)
{
if (fromCurrency == toCurrency) return amount;
var rate = await GetRateAsync(fromCurrency, toCurrency,
rateDate ?? DateTime.UtcNow, ct);
return Math.Round(amount * rate, 4);
}
private async Task<decimal> GetRateAsync(
string from, string to, DateTime date, CancellationToken ct)
{
var cacheKey = $"fx:{from}:{to}:{date:yyyyMMdd}";
var cached = await _cache.GetAsync<decimal?>(cacheKey, ct);
if (cached.HasValue) return cached.Value;
// Try direct pair first
var rate = await _rateRepo.GetRateAsync(from, to, date, ct);
if (rate == null)
{
// Cross rate through USD
var fromToUsd = await _rateRepo.GetRateAsync(
from, "USD", date, ct)
?? throw new ExchangeRateNotFoundException(from, "USD", date);
var usdToTarget = await _rateRepo.GetRateAsync(
"USD", to, date, ct)
?? throw new ExchangeRateNotFoundException("USD", to, date);
rate = fromToUsd * usdToTarget;
}
await _cache.SetAsync(cacheKey, rate.Value,
TimeSpan.FromHours(1), ct);
return rate.Value;
}
public async Task<NetWorthSummary> CalculateNetWorthAsync(
Guid userId, string baseCurrency, CancellationToken ct)
{
var balances = await _balanceRepo
.GetLatestBalancesAsync(userId, ct);
decimal totalInBase = 0;
var byCurrency = new Dictionary<string, decimal>();
foreach (var balance in balances)
{
var converted = await ConvertAsync(
balance.CurrentBalance,
balance.CurrencyCode,
baseCurrency,
null, ct);
totalInBase += converted;
if (!byCurrency.ContainsKey(balance.CurrencyCode))
byCurrency[balance.CurrencyCode] = 0;
byCurrency[balance.CurrencyCode] += converted;
}
return new NetWorthSummary
{
TotalInBaseCurrency = totalInBase,
BaseCurrency = baseCurrency,
BreakdownByCurrency = byCurrency,
CalculatedAt = DateTimeOffset.UtcNow
};
}
}
Currency Conversion Data Flow
15. Reporting & Charts Engine
Report Generation Pipeline
The reporting engine generates spending breakdowns, income vs expense trends, net worth growth, category comparisons, and custom date-range analyses. For performance, reports are computed using pre-aggregated materialized views rather than scanning raw transaction tables. The system maintains daily, weekly, and monthly aggregation tables that are updated asynchronously when new transactions arrive. Complex custom reports trigger real-time queries against the raw data with query timeout protection and progressive result delivery.
C#
public class ReportingService
{
private readonly IAggregationRepository _aggRepo;
private readonly ITransactionRepository _txnRepo;
private readonly ICacheService _cache;
public async Task<SpendingBreakdownReport> GetSpendingBreakdownAsync(
Guid userId, DateTime startDate, DateTime endDate,
CancellationToken ct = default)
{
var cacheKey = $"report:spending:{userId}:{startDate:yyyyMMdd}:{endDate:yyyyMMdd}";
var cached = await _cache.GetAsync<SpendingBreakdownReport>(
cacheKey, ct);
if (cached != null) return cached;
var dailyAggregates = await _aggRepo.GetDailyAggregatesAsync(
userId, startDate, endDate, ct);
var categoryBreakdown = dailyAggregates
.GroupBy(d => new { d.CategoryId, d.CategoryName })
.Select(g => new CategorySpending
{
CategoryId = g.Key.CategoryId,
CategoryName = g.Key.CategoryName,
TotalSpent = g.Sum(d => d.ExpenseAmount),
TotalIncome = g.Sum(d => d.IncomeAmount),
TransactionCount = g.Sum(d => d.TransactionCount),
DailyAverage = g.Sum(d => d.ExpenseAmount)
/ Math.Max(1, (endDate - startDate).Days),
PercentOfTotal = 0 // computed below
})
.OrderByDescending(c => c.TotalSpent)
.ToList();
var totalExpenses = categoryBreakdown.Sum(c => c.TotalSpent);
foreach (var cat in categoryBreakdown)
{
cat.PercentOfTotal = totalExpenses > 0
? (cat.TotalSpent / totalExpenses) * 100 : 0;
}
var dailyTrend = dailyAggregates
.GroupBy(d => d.Date)
.Select(g => new DailyTrend
{
Date = g.Key,
Income = g.Sum(d => d.IncomeAmount),
Expenses = g.Sum(d => d.ExpenseAmount),
NetCashFlow = g.Sum(d => d.IncomeAmount)
- g.Sum(d => d.ExpenseAmount)
})
.OrderBy(d => d.Date)
.ToList();
var report = new SpendingBreakdownReport
{
PeriodStart = startDate,
PeriodEnd = endDate,
TotalExpenses = totalExpenses,
TotalIncome = dailyAggregates.Sum(d => d.IncomeAmount),
NetCashFlow = dailyAggregates.Sum(d => d.IncomeAmount)
- totalExpenses,
CategoryBreakdown = categoryBreakdown,
DailyTrend = dailyTrend,
TopMerchants = await GetTopMerchantsAsync(
userId, startDate, endDate, ct),
GeneratedAt = DateTimeOffset.UtcNow
};
await _cache.SetAsync(cacheKey, report,
TimeSpan.FromMinutes(30), ct);
return report;
}
}
Chart Types and Data Formats
| Chart Type | Use Case | Data Format | Library |
|---|---|---|---|
| Pie / Donut | Spending by category | Category, Amount, Percentage | Chart.js / Recharts |
| Stacked Bar | Monthly spending trends | Month, Category breakdown | Chart.js / D3.js |
| Line Chart | Net worth over time | Date, Value | Chart.js / Plotly |
| Area Chart | Income vs Expenses | Date, Income, Expenses | Chart.js / Recharts |
| Waterfall | Cash flow breakdown | Category, Amount, Cumulative | D3.js / Nivo |
| Heatmap | Daily spending intensity | Day of week, Hour, Amount | D3.js / Calendar Heatmap |
| Gauge | Budget usage | Used, Limit, Percentage | Custom SVG / Chart.js |
16. Security, Encryption & Compliance
Defense-in-Depth Architecture
Financial data demands a zero-trust security architecture. The system implements defense-in-depth across seven layers: network security (VPC isolation, WAF, DDoS protection), transport encryption (TLS 1.3 everywhere), authentication (JWT with short-lived access tokens, refresh token rotation, mandatory MFA), authorization (RBAC with fine-grained permissions), application security (input validation, parameterized queries, CSP headers), data encryption (AES-256-GCM at field level for sensitive data), and audit logging (immutable audit trail for all data access).
C#
public class SensitiveDataEncryptionService
{
private readonly IKmsClient _kms;
private readonly byte[] _keyCache;
public async Task<EncryptedField> EncryptFieldAsync(
string plainText, string fieldContext)
{
var plaintextBytes = Encoding.UTF8.GetBytes(plainText);
var nonce = new byte[12]; // 96-bit nonce for AES-GCM
RandomNumberGenerator.Fill(nonce);
var plaintextHash = SHA256.HashData(plaintextBytes);
var associatedData = Encoding.UTF8.GetBytes(fieldContext);
var ciphertext = new byte[plaintextBytes.Length];
var tag = new byte[16]; // 128-bit authentication tag
using var aesGcm = new AesGcm(_keyCache, 16);
aesGcm.Encrypt(nonce, plaintextBytes, ciphertext, tag,
associatedData);
return new EncryptedField
{
Ciphertext = Convert.ToBase64String(ciphertext),
Nonce = Convert.ToBase64String(nonce),
Tag = Convert.ToBase64String(tag),
KeyVersion = await _kms.GetCurrentKeyVersionAsync(),
Context = fieldContext,
CreatedAt = DateTimeOffset.UtcNow
};
}
public async Task<string> DecryptFieldAsync(
EncryptedField field)
{
var key = await _kms.GetKeyAsync(field.KeyVersion);
var ciphertext = Convert.FromBase64String(field.Ciphertext);
var nonce = Convert.FromBase64String(field.Nonce);
var tag = Convert.FromBase64String(field.Tag);
var associatedData = Encoding.UTF8.GetBytes(field.Context);
var plaintext = new byte[ciphertext.Length];
using var aesGcm = new AesGcm(key, 16);
aesGcm.Decrypt(nonce, ciphertext, tag, plaintext,
associatedData);
return Encoding.UTF8.GetString(plaintext);
}
}
Compliance Requirements
| Standard | Requirements | Implementation |
|---|---|---|
| SOC 2 Type II | Access controls, monitoring, incident response | Audit logs, RBAC, PagerDuty integration, annual audit |
| PCI DSS L1 | Card data handling, network segmentation | Tokenization via Plaid (no raw card data stored), segmented network |
| GDPR | Data portability, right to deletion, consent | Data export API, soft-delete with 30-day purge, consent tracking |
| CCPA | Disclosure of data collection, opt-out | Privacy dashboard, data deletion API |
| GLBA | Financial privacy, safeguarding customer data | Encryption, access logging, employee training |
| PSD2 (EU) | Strong customer authentication, open banking | SCA via MFA, Plaid's PSD2 compliance layer |
Plaid Access Token Rotation
Access tokens are rotated on a 90-day cycle using Plaid's token rotation endpoint. The rotation process decrypts the current token, calls Plaid's /item/access_token/invalidate to get a new token, encrypts and stores the new token, and updates the cursor. During rotation, a brief read-lock prevents concurrent sync operations on the affected account. Failed rotations trigger a re-authentication flow and alert the user.
17. Data Aggregation Pipeline
End-to-End Data Flow
The data aggregation pipeline is the backbone of the personal finance app. It connects to external financial institutions through Plaid, normalizes the data, applies business rules, and stores it in a consistent format. The pipeline is built as an event-driven architecture using Apache Kafka to decouple ingestion from processing and ensure at-least-once delivery guarantees.
Scheduling Strategy
The sync scheduler determines how frequently each account is refreshed. Rather than polling all accounts on a fixed interval, the system uses a priority-based scheduling algorithm. High-activity accounts (those with daily transactions like checking accounts) are synced every 4 hours. Medium-activity accounts (credit cards updated every 1-2 days) are synced every 8 hours. Low-activity accounts (investment accounts, savings) are synced once per day. Accounts flagged for re-authentication are excluded from the schedule. The scheduler runs as a separate worker process with a distributed lock (Redis-based) to prevent duplicate syncs across multiple instances.
C#
public class SyncScheduler
{
private readonly ILinkedAccountRepository _accountRepo;
private readonly IDistributedLock _lock;
private readonly IKafkaProducer _producer;
public async Task<int> ScheduleSyncsAsync(
CancellationToken ct = default)
{
var accounts = await _accountRepo
.GetSyncableAccountsAsync(ct);
var now = DateTimeOffset.UtcNow;
var scheduled = 0;
foreach (var account in accounts)
{
var lockKey = $"sync_lock:{account.AccountId}";
var hasLock = await _lock.TryAcquireAsync(
lockKey, TimeSpan.FromMinutes(30), ct);
if (!hasLock) continue;
var syncInterval = GetSyncInterval(account);
var lastSync = account.LastSyncedAt
?? account.CreatedAt;
if (now - lastSync >= syncInterval)
{
await _producer.PublishAsync("sync-requests",
new SyncRequestMessage
{
AccountId = account.AccountId,
UserId = account.UserId,
Priority = GetPriority(account),
RequestedAt = now
}, ct);
scheduled++;
}
}
return scheduled;
}
private TimeSpan GetSyncInterval(LinkedAccount account)
{
return account.AccountType switch
{
"checking" => TimeSpan.FromHours(4),
"savings" => TimeSpan.FromHours(8),
"credit" => TimeSpan.FromHours(6),
"investment" => TimeSpan.FromHours(12),
_ => TimeSpan.FromHours(8)
};
}
private int GetPriority(LinkedAccount account)
{
if (account.AccountType == "checking") return 1;
if (account.AccountType == "credit") return 2;
return 3;
}
}
Deduplication Strategy
Financial institutions sometimes report the same transaction with slightly different metadata across sync cycles. The deduplication service uses a composite key of account_id + plaid_transaction_id as the primary dedup mechanism, supplemented by a fuzzy matching algorithm for transactions without Plaid IDs. The fuzzy matcher compares merchant name, amount, date (within 3-day window), and account to identify likely duplicates with a confidence score. Confirmed duplicates are flagged in the is_duplicate field rather than deleted, preserving a complete audit trail.
18. AI-Powered Financial Insights
Insight Categories
The AI insights engine generates personalized financial recommendations by analyzing transaction patterns, spending trends, and goal progress. Insights fall into four categories: anomaly detection (unusual transactions that deviate from spending patterns), savings opportunities (subscriptions that could be cancelled, categories where spending exceeds peers), cash flow forecasting (projected account balances based on historical income and expense patterns), and goal acceleration (recommendations to reach savings goals faster).
C#
public class InsightEngine
{
private readonly ITransactionRepository _txnRepo;
private readonly IAnomalyDetector _anomalyDetector;
private readonly IForecastEngine _forecastEngine;
private readonly IPeerComparisonService _peerService;
private readonly IInsightRepository _insightRepo;
public async Task<List<Insight>> GenerateInsightsAsync(
Guid userId, CancellationToken ct = default)
{
var insights = new List<Insight>();
var transactions = await _txnRepo
.GetTransactionsAsync(userId,
DateTime.UtcNow.AddMonths(-6),
DateTime.UtcNow, ct);
// 1. Anomaly Detection
var anomalies = await _anomalyDetector.DetectAsync(
transactions, ct);
foreach (var anomaly in anomalies)
{
insights.Add(new Insight
{
UserId = userId,
Type = InsightType.Anomaly,
Severity = anomaly.Severity,
Title = $"Unusual ${anomaly.Amount:F2} charge at {anomaly.Merchant}",
Description = anomaly.Description,
ActionUrl = $"/transactions/{anomaly.TransactionId}",
ActionLabel = "View Transaction",
GeneratedAt = DateTimeOffset.UtcNow,
ExpiresAt = DateTimeOffset.UtcNow.AddDays(7)
});
}
// 2. Subscription Audit
var subscriptions = await DetectSubscriptionsAsync(
transactions, ct);
var redundantSubs = subscriptions
.Where(s => s.UsageFrequency < 0.1) // Used less than 10% of months
.ToList();
if (redundantSubs.Any())
{
var potentialSavings = redundantSubs.Sum(s => s.MonthlyAmount);
insights.Add(new Insight
{
UserId = userId,
Type = InsightType.SavingsOpportunity,
Severity = InsightSeverity.Medium,
Title = $"Save ${potentialSavings:F2}/month by cancelling unused subscriptions",
Description = $"We found {redundantSubs.Count} subscriptions " +
$"you rarely use: {string.Join(", ", redundantSubs.Select(s => s.Name))}",
ActionUrl = "/bills/subscriptions",
ActionLabel = "Review Subscriptions",
GeneratedAt = DateTimeOffset.UtcNow,
ExpiresAt = DateTimeOffset.UtcNow.AddDays(30)
});
}
// 3. Cash Flow Forecast
var forecast = await _forecastEngine.ForecastAsync(
transactions, 90, ct);
if (forecast.PredictedDeficitMonths.Any())
{
insights.Add(new Insight
{
UserId = userId,
Type = InsightType.CashFlowWarning,
Severity = InsightSeverity.High,
Title = $"Cash flow deficit predicted for {forecast.PredictedDeficitMonths.First():MMM yyyy}",
Description = $"Based on your spending patterns, your projected balance " +
$"will drop below your comfort threshold. Consider reducing discretionary spending.",
ActionUrl = "/reports/forecast",
ActionLabel = "View Forecast",
GeneratedAt = DateTimeOffset.UtcNow,
ExpiresAt = DateTimeOffset.UtcNow.AddDays(14)
});
}
// 4. Spending Comparison
var peerComparison = await _peerService.CompareAsync(
userId, transactions, ct);
foreach (var deviation in peerComparison.NotableDeviations)
{
insights.Add(new Insight
{
UserId = userId,
Type = InsightType.PeerComparison,
Severity = InsightSeverity.Low,
Title = $"Your {deviation.Category} spending is {deviation.PercentAboveAverage:F0}% above average",
Description = $"Compared to similar users, you spend more on {deviation.Category}. " +
$"Average: ${deviation.PeerAverage:F0}/month. Yours: ${deviation.YourSpend:F0}/month.",
ActionUrl = $"/reports/spending?category={deviation.CategoryId}",
ActionLabel = "View Details",
GeneratedAt = DateTimeOffset.UtcNow,
ExpiresAt = DateTimeOffset.UtcNow.AddDays(14)
});
}
await _insightRepo.SaveInsightsAsync(insights, ct);
return insights;
}
}
Cash Flow Forecasting Model
Insight Metrics
| Metric | Target | Description |
|---|---|---|
| Insight Accuracy | > 85% | Percentage of insights users find relevant |
| False Positive Rate | < 15% | Insights incorrectly flagged as relevant |
| Anomaly Detection Precision | > 90% | Correctly identified unusual transactions |
| Forecast Accuracy (30-day) | Within 10% | Predicted vs actual balance deviation |
| Insight Engagement Rate | > 25% | Users who click on or act on insights |
| Generation Latency | < 5 seconds | Time to generate insights for a user |
19. Monitoring & Observability
Observability Stack
The monitoring infrastructure follows the three pillars of observability: metrics (Prometheus + Grafana), logs (ELK Stack), and traces (Jaeger or OpenTelemetry). Financial systems require especially rigorous monitoring because data inaccuracies directly impact user trust and regulatory compliance. The system implements custom business metrics alongside infrastructure metrics to track data freshness, categorization accuracy, and sync health.
Key Business Metrics
C#
public class FinanceMetrics
{
private readonly Counter _transactionsIngested;
private readonly Counter _transactionsCategorized;
private readonly Histogram _syncDuration;
private readonly Gauge _activeLinkedAccounts;
private readonly Counter _syncErrors;
private readonly Histogram _categorizationConfidence;
private readonly Counter _insightsGenerated;
private readonly Gauge _dataFreshnessLag;
public FinanceMetrics(IMetricsFactory metrics)
{
_transactionsIngested = metrics.CreateCounter(
"finance.transactions.ingested.total",
"Total transactions ingested");
_transactionsCategorized = metrics.CreateCounter(
"finance.transactions.categorized.total",
"Total transactions categorized",
new[] { "source", "category" });
_syncDuration = metrics.CreateHistogram(
"finance.sync.duration.seconds",
"Plaid sync operation duration");
_activeLinkedAccounts = metrics.CreateGauge(
"finance.accounts.linked.active",
"Number of actively linked accounts");
_syncErrors = metrics.CreateCounter(
"finance.sync.errors.total",
"Sync errors by type",
new[] { "error_type", "institution" });
_categorizationConfidence = metrics.CreateHistogram(
"finance.categorization.confidence",
"Categorization confidence distribution");
_insightsGenerated = metrics.CreateCounter(
"finance.insights.generated.total",
"Insights generated by type",
new[] { "insight_type" });
_dataFreshnessLag = metrics.CreateGauge(
"finance.data.freshness.lag.hours",
"Hours since last successful sync per account");
}
}
Alert Rules
| Alert | Condition | Severity | Response |
|---|---|---|---|
| Sync Failure Spike | Error rate > 5% for 15 minutes | Critical | Page on-call engineer, check Plaid status |
| Data Freshness Degradation | Average lag > 12 hours | Warning | Check sync scheduler health, check Kafka lag |
| Categorization Accuracy Drop | Confidence < 0.7 for > 20% of transactions | Warning | Check ML model, review recent training data |
| Database Connection Pool Exhaustion | Active connections > 90% of max | Critical | Scale read replicas, check for connection leaks |
| Kafka Consumer Lag | Lag > 100,000 messages for 10 minutes | Warning | Scale consumer instances, check for slow consumers |
| Plaid Rate Limit Hit | 429 responses > 10 per minute | Warning | Back off sync frequency, request rate limit increase |
| Encryption Key Expiry | Key age > 85 days | Info | Initiate key rotation process |
20. Cost Estimation
Monthly Infrastructure Costs (5M Users)
| Component | Specification | Monthly Cost |
|---|---|---|
| AWS RDS PostgreSQL | db.r6g.2xlarge x 2 (primary + replica), 2 TB gp3 | $2,800 |
| AWS ElastiCache Redis | r6g.xlarge cluster, 64 GB | $1,200 |
| AWS MSK (Kafka) | kafka.m5.large x 6 brokers, 2 TB storage | $2,400 |
| EKS Kubernetes | m6i.xlarge x 12 nodes | $3,500 |
| Plaid API | 5M accounts x $0.30/month average | $1,500,000 |
| Elasticsearch | r6g.xlarge.search x 3 nodes, 1 TB | $1,800 |
| S3 Storage | 100 TB with Intelligent-Tiering | $2,300 |
| CloudFront CDN | 50 TB transfer, 1B requests | $4,500 |
| AWS WAF | 10 rules, 100M requests | $700 |
| DataDog / Monitoring | 50 hosts, 100 custom metrics | $2,500 |
| PagerDuty | 10 users, Business plan | $400 |
| Email / SendGrid | 5M emails/month | $900 |
| Exchange Rate API | Business plan, 100K requests/month | $200 |
| Credit Score API | 5M checks/month | $250,000 |
| ML Compute (SageMaker) | ml.m5.xlarge inference, training jobs | $1,500 |
| SSL Certificates | Wildcard certificates | $100 |
| Backup & Disaster Recovery | Cross-region snapshots, 100 TB | $1,000 |
| Total Infrastructure | $1,575,600 |
Cost Optimization Strategies
- Plaid Usage Optimization: Use Plaid's batch endpoint to fetch multiple accounts in a single API call, reducing per-account overhead. Cache Plaid responses aggressively and only fetch when data is stale.
- Reserved Instances: Purchase 1-year reserved instances for RDS, ElastiCache, and EKS to save 30-40% over on-demand pricing.
- Spot Instances for Workers: Run sync workers and ML training jobs on spot instances to reduce compute costs by 60-70%.
- Tiered Storage: Move transactions older than 12 months to S3 Standard-IA, reducing storage costs by 50%. Use Athena for ad-hoc queries on historical data.
- Read Replica Scaling: Use Aurora Serverless v2 for read replicas that scale to zero during off-peak hours, reducing database costs by 30%.
21. Testing Strategy
Test Pyramid
The testing strategy follows the test pyramid with emphasis on integration tests for financial data integrity. Unit tests cover business logic in isolation (budget calculations, categorization rules, goal allocation algorithms). Integration tests verify database operations, Kafka event flows, and API contracts. Contract tests ensure compatibility with Plaid API responses using recorded fixtures. End-to-end tests simulate complete user workflows from account linking to report generation. Financial correctness tests validate that transaction amounts, balances, and calculations never lose precision or produce incorrect results.
C#
[TestClass]
public class BudgetCalculationTests
{
[TestMethod]
public async Task GetBudgetStatus_ShouldCalculateSpendingCorrectly()
{
// Arrange
var userId = Guid.NewGuid();
var categoryId = Guid.NewGuid();
var mockTxnRepo = new Mock<ITransactionRepository>();
mockTxnRepo.Setup(r => r.SumByCategoryAsync(
userId, categoryId,
It.IsAny<DateTime>(),
It.IsAny<DateTime>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(350.75m);
var mockBudgetRepo = new Mock<IBudgetRepository>();
mockBudgetRepo.Setup(r => r.GetActiveBudgetsByUserAsync(
userId, It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Budget>
{
new Budget
{
BudgetId = Guid.NewGuid(),
UserId = userId,
CategoryId = categoryId,
Amount = 500m,
AlertThreshold = 0.80m
}
});
var service = new BudgetService(
mockBudgetRepo.Object,
mockTxnRepo.Object,
Mock.Of<ICacheService>(),
Mock.Of<INotificationService>());
// Act
var result = await service.GetBudgetStatusAsync(userId);
// Assert
var budget = result.Budgets.Single();
Assert.AreEqual(500m, budget.BudgetAmount);
Assert.AreEqual(350.75m, budget.SpentAmount);
Assert.AreEqual(149.25m, budget.RemainingAmount);
Assert.AreEqual(70.15m, budget.PercentUsed);
Assert.IsFalse(budget.IsOverspent);
Assert.IsFalse(budget.IsNearLimit);
}
[TestMethod]
public async Task GetBudgetStatus_ShouldDetectOverspend()
{
var userId = Guid.NewGuid();
var categoryId = Guid.NewGuid();
var mockTxnRepo = new Mock<ITransactionRepository>();
mockTxnRepo.Setup(r => r.SumByCategoryAsync(
userId, categoryId,
It.IsAny<DateTime>(),
It.IsAny<DateTime>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(525.50m);
var mockBudgetRepo = new Mock<IBudgetRepository>();
mockBudgetRepo.Setup(r => r.GetActiveBudgetsByUserAsync(
userId, It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Budget>
{
new Budget
{
BudgetId = Guid.NewGuid(),
UserId = userId,
CategoryId = categoryId,
Amount = 500m,
AlertThreshold = 0.80m
}
});
var service = new BudgetService(
mockBudgetRepo.Object,
mockTxnRepo.Object,
Mock.Of<ICacheService>(),
Mock.Of<INotificationService>());
var result = await service.GetBudgetStatusAsync(userId);
var budget = result.Budgets.Single();
Assert.IsTrue(budget.IsOverspent);
Assert.AreEqual(-25.50m, budget.RemainingAmount);
Assert.IsTrue(budget.PercentUsed > 100);
}
[TestMethod]
public async Task CalculateAutoAllocation_ShouldRespectPriority()
{
var service = new SavingsGoalService(
Mock.Of<IGoalRepository>(),
Mock.Of<ITransactionRepository>());
// Test with mock goals
var availableAmount = 1000m;
// Act would be called with specific goals
// Assert would verify priority-based allocation
Assert.AreEqual(1000m, availableAmount);
}
[TestMethod]
public void RecurringDetector_ShouldIdentifyMonthlyPattern()
{
var detector = new RecurringDetector();
var transactions = new List<Transaction>
{
CreateTxn("Netflix", 15.99m, DateTime.UtcNow.AddDays(-90)),
CreateTxn("Netflix", 15.99m, DateTime.UtcNow.AddDays(-60)),
CreateTxn("Netflix", 15.99m, DateTime.UtcNow.AddDays(-30)),
CreateTxn("Netflix", 15.99m, DateTime.UtcNow.AddDays(0)),
};
var result = detector.AnalyzePatterns(transactions);
Assert.IsTrue(result.IsRecurring);
Assert.AreEqual(RecurringFrequency.Monthly, result.Frequency);
Assert.AreEqual(15.99m, result.AverageAmount);
Assert.IsTrue(result.Confidence > 0.9m);
}
}
Financial Accuracy Tests
Financial calculations must never use floating-point arithmetic for monetary values. All tests verify that decimal precision is maintained to at least 4 decimal places for amounts and 8 decimal places for exchange rates. Rounding is always performed at the display layer, never in the storage or calculation layers. Tests include edge cases like transactions at exactly midnight, timezone boundary crossings, and currency conversions that produce repeating decimals.
22. Interview Q&A
Q1: How would you handle a situation where Plaid's transaction sync misses transactions?
A: The system maintains an idempotent sync process. If Plaid's cursor-based sync misses transactions due to cursor expiration or API errors, the system falls back to a full /transactions/get call with a date range covering the gap. Each transaction is deduplicated using the Plaid transaction ID as the primary key. The system also monitors sync completeness by comparing the number of expected transactions (based on historical patterns) with actual ingested transactions. If the discrepancy exceeds a threshold, the system triggers a reconciliation job and alerts the data quality team.
Q2: How do you ensure transaction categorization accuracy across different banks?
A: Banks provide inconsistent transaction descriptions and merchant names. The categorization engine uses a three-layer approach: rule-based matching for known patterns, a curated merchant database of 500,000+ merchants for known merchants, and an ML classifier for remaining transactions. The ML model is trained on anonymized user corrections and achieves 92% top-1 accuracy. For banks that provide MCC (Merchant Category Code) data, we use it as an additional feature. User corrections flow back into the system through a feedback loop that updates both the merchant database and the ML training pipeline weekly.
Q3: How would you design the budget system to handle shared budgets between partners?
A: Shared budgets require a many-to-many relationship between users and budgets. A budget_shares table tracks which users have access to each budget. When a user creates a shared budget, all participants can view and edit it. Transaction spending against a shared budget aggregates from all linked accounts of all participants. Conflict resolution uses last-write-wins for budget amount changes with optimistic concurrency control. Real-time sync of budget status uses WebSocket connections or Server-Sent Events to keep all participants' dashboards updated within seconds of a transaction.
Q4: How would you handle data consistency when a bank corrects a previously reported transaction?
A: Plaid's sync API supports modified and removed transaction events. When a bank corrects a transaction, Plaid delivers a modified event with the updated data. The system applies the correction to the existing transaction record, preserving the original values in an audit table. If the correction changes the category or amount, the system recalculates affected budgets, goals, and reports. For removed transactions (bounced checks, reversed charges), the system marks the transaction as reversed and adjusts all downstream calculations. The correction audit trail ensures compliance and allows users to see the history of changes.
Q5: How would you handle the system scaling from 5 million to 50 million users?
A: The architecture scales horizontally at every layer. The database tier moves from a single primary to a sharded cluster with user-based sharding (consistent hashing on user_id). Plaid API costs become the primary bottleneck, so we would negotiate enterprise pricing, implement more aggressive caching, and reduce sync frequency for low-activity accounts. The Kafka layer scales by adding partitions and consumer instances. The ML inference tier moves from batch processing to real-time inference using GPU-optimized instances. The reporting tier adds a dedicated OLAP database (ClickHouse or Apache Druid) for complex analytical queries, removing analytical workloads from the transactional database.
Q6: How do you handle the regulatory challenge of storing Plaid access tokens securely?
A: Access tokens are encrypted using AES-256-GCM with keys managed in AWS KMS using envelope encryption. The master key is stored in a Hardware Security Module (HSM) and never leaves the HSM boundary. Each token is encrypted with a data key derived from the master key. The encryption key version is stored alongside the ciphertext so decryption can use the correct key version even after key rotation. Tokens are never stored in logs, error messages, or monitoring systems. Access to the token store is restricted to the Account Linking Service and Sync Service via network policies and IAM roles. All access is audited with immutable logs retained for 7 years.
Q7: How would you implement real-time net worth updates when stock prices change?
A: Investment holdings are enriched with real-time quotes during market hours via WebSocket connections to market data providers. Price updates are published to a Kafka topic and consumed by a portfolio aggregation service that updates a Redis cache with the latest portfolio values. The dashboard polls the Redis-backed API every 30 seconds during market hours and every 5 minutes outside market hours. For push notifications on significant portfolio moves (daily change exceeding a user-defined threshold), the aggregation service publishes alerts when the threshold is breached. The entire pipeline from price update to dashboard refresh completes in under 2 seconds.
Q8: How do you test financial calculations end-to-end without using real bank accounts?
A: We use Plaid's sandbox environment with predefined test credentials that return deterministic transaction data. For categorization testing, we maintain a gold-standard dataset of 10,000 manually categorized transactions that serves as the evaluation benchmark. Budget and goal calculations are tested with unit tests using mock transaction data that covers edge cases like midnight transactions, timezone transitions, and multi-currency conversions. End-to-end tests use a test account fixture that simulates a complete user lifecycle: linking accounts, syncing transactions, creating budgets, setting goals, and generating reports. The test environment uses a separate Plaid sandbox account and a dedicated PostgreSQL database that is reset between test runs.