docs/runway-burn-rate-analysis.md
When a bank account is connected, data flows through the following pipeline:
sequenceDiagram
participant Provider as Banking Provider API
participant Engine as Engine Transform
participant DB as Database
participant Worker as Background Worker
Provider->>Engine: Raw account data
Note over Engine: Normalize account type
Note over Engine: Normalize balance sign
Engine->>DB: Store normalized data
Provider->>Engine: Raw transactions
Note over Engine: Normalize amount sign
Note over Engine: Map category/method
Engine->>DB: Store transactions
Worker->>Provider: Periodic sync request
Provider->>Engine: Updated balances
Note over Engine: Re-normalize if needed
Engine->>DB: Update account balances
When displaying financial metrics:
sequenceDiagram
participant UI as Dashboard Widget
participant TRPC as tRPC Router
participant Query as DB Query Function
participant DB as Database
UI->>TRPC: Request (teamId, currency)
TRPC->>Query: getCashBalance / getNetPosition
Query->>DB: Filter by account type
Note over Query: CASH_ACCOUNT_TYPES only
DB->>Query: Account balances
Note over Query: Math.abs for credit
Note over Query: Currency conversion
Query->>TRPC: Calculated result
TRPC->>UI: Formatted response
| Type | Description | Examples |
|---|---|---|
depository | Liquid cash accounts | Checking, Savings |
other_asset | Other liquid assets | Treasury, Money Market |
credit | Credit card debt | Credit cards |
loan | Loan accounts | Business loans, Lines of credit |
other_liability | Other liabilities | - |
Runway (months) = Cash Balance / Average Monthly Burn Rate
Cash Balance includes:
depository ✅other_asset ✅credit ❌loan ❌Rationale: Runway shows how long you can operate with available cash. Debt is not cash.
Sum of expenses (negative transactions) per month.
Excluded from burn rate:
internal: true transactionsstatus: 'excluded' transactionsexcluded: true:
internal-transfercredit-card-paymentWhy credit-card-payment is excluded:
When a credit card is synced, both card transactions AND payments from checking are synced:
CC purchase: $5k (expense on CC account)
CC payment: $5k (expense on checking)
Actual spending is $5k, not $10k. Excluding payments prevents double-counting.
Net Position = Cash - Credit Card Debt
Cash includes:
depository ✅other_asset ✅Debt includes:
credit ✅loan ❌ (shown separately in Balance Sheet)Design decision: Net Position provides a simple "cash vs credit card" view. Loans are handled separately in Balance Sheet for a complete picture.
Provider balance conventions:
| Provider | Raw API Response | Stored In Database |
|---|---|---|
| Plaid | Positive (1000 = $1000 owed) | Positive |
| GoCardless | Negative (-1500 = $1500 owed) | Normalized to positive |
| EnableBanking | Positive (ISO 20022 current) | Positive (normalized as safety) |
| Teller | Positive | Positive (normalized as safety) |
Normalization Strategy:
Math.abs() ensures any legacy data with inconsistent signs is handled correctly.This dual approach ensures data consistency while maintaining backwards compatibility.
Full financial picture including all account types:
Assets:
Liabilities:
Math.abs()Math.abs()| Report | depository | other_asset | credit | loan |
|---|---|---|---|---|
| Runway (cash) | ✅ | ✅ | ❌ | ❌ |
| Cash Balance | ✅ | ✅ | ❌ | ❌ |
| Net Position (cash) | ✅ | ✅ | - | - |
| Net Position (debt) | - | - | ✅ | ❌ |
| Balance Sheet | ✅ | ✅ | ✅ | ✅ |
| Burn Rate | Via transactions | Via transactions | Via transactions | Via transactions |
| Cash Flow | Via transactions | Via transactions | Via transactions | Via transactions |
Each banking provider returns account types differently. The engine normalizes them:
| Provider | Source Type | Maps To |
|---|---|---|
| Plaid | depository | depository |
credit | credit | |
loan | loan | |
investment, brokerage, other | other_asset | |
| GoCardless (ISO 20022) | CACC, SVGS, TRAN, CASH | depository |
CARD | credit | |
LOAN | loan | |
| Enable Banking (ISO 20022) | CACC, SVGS, CASH | depository |
CARD | credit | |
LOAN | loan | |
| Teller | depository | depository |
credit | credit |
Mapping logic: apps/engine/src/utils/account.ts
Account type constants ensure consistent classification across the codebase:
// packages/banking/src/utils/account.ts
import { CASH_ACCOUNT_TYPES, DEBT_ACCOUNT_TYPES } from "@midday/banking/account";
export const CASH_ACCOUNT_TYPES = ["depository", "other_asset"] as const;
export const DEBT_ACCOUNT_TYPES = ["credit", "loan"] as const;
Use these constants instead of hardcoding account type strings.
| File | Functions / Exports |
|---|---|
apps/engine/src/utils/account.ts | getType(), CASH_ACCOUNT_TYPES, DEBT_ACCOUNT_TYPES |
apps/engine/src/providers/gocardless/transform.ts | transformAccountBalance() - normalizes negative credit balances |
apps/engine/src/providers/enablebanking/transform.ts | transformBalance() - normalizes credit balances (safety) |
apps/engine/src/providers/teller/transform.ts | transformAccountBalance() - normalizes credit balances (safety) |
apps/engine/src/providers/plaid/transform.ts | transformAccountBalance() - uses current for credit (Plaid returns positive) |
packages/db/src/queries/reports.ts | getRunway(), getBurnRate(), getBalanceSheet(), getCashFlow() |
packages/db/src/queries/bank-accounts.ts | getNetPosition(), getCashBalance() |
packages/categories/src/categories.ts | Category definitions with excluded flag |
Symptoms: Runway widget displays 0 months or a value that doesn't match expectations.
Possible Causes:
No burn rate data
All accounts disabled
depository or other_asset) is enabledCurrency mismatch
baseBalance is populated for foreign currency accountsDebug Query:
-- Check cash accounts
SELECT name, type, balance, enabled
FROM bank_accounts
WHERE team_id = 'YOUR_TEAM_ID'
AND type IN ('depository', 'other_asset');
This is correct behavior. Credit card balances are stored as positive values representing amount owed.
How it works:
Math.abs() is used as a safety net in queriesIf you see unexpected values:
balance column in bank_accounts tabletype is credit (not depository)Math.abs() handles both conventionsSymptoms: Net Position cash doesn't match sum of bank accounts.
Check these:
depository and other_asset count as cashSELECT name, type, balance
FROM bank_accounts
WHERE team_id = 'YOUR_TEAM_ID' AND enabled = true;
Disabled accounts - Disabled accounts are excluded
Treasury/Money Market - Should be other_asset type, not depository
Credit accounts incorrectly typed - Verify credit cards have type = 'credit'
Symptoms: Burn rate appears ~2x higher than expected.
Root Cause: Credit card payments not excluded.
Verify exclusion is working:
credit-card-payment category has excluded: true:SELECT slug, excluded
FROM transaction_categories
WHERE slug = 'credit-card-payment';
SELECT name, category_slug, amount
FROM transactions
WHERE category_slug = 'credit-card-payment'
AND team_id = 'YOUR_TEAM_ID';
internal-transfer is also excluded for internal movementsGoCardless showing negative balances after sync:
Math.abs() in queriesPlaid loan accounts showing as assets:
getType() now correctly maps loan to loanTo force re-sync balance normalization:
-- Update legacy negative credit balances
UPDATE bank_accounts
SET balance = ABS(balance)
WHERE type = 'credit' AND balance < 0;
WIDGET_POLLING_CONFIG)