Skip to content

Sankey Diagram: Architecture and Design

Last Updated: 2026-09-30 Location: Spending page, #page-overview in index.html, below the category breakdown. /sankey redirects there. API Endpoint: GET /api/sankey?start_date=YYYY-MM-DD&end_date=YYYY-MM-DD


What It Shows

Where income goes, from gross pay to spend categories. Employers on the left are drawn at gross pay for every deposit a pay stub explains. Their taxes and deductions branch off before BofA Checking; the net flows into checking and on to the mortgage, the cards, savings and spend categories.

BravoLt ───────┬──► Taxes ─────────► Federal Income Tax, Social Security, Medicare, State Income Tax
(gross pay)    ├──► Benefits ──────► Medical, Dental, Vision, Life & AD&D
               ├──► Retirement
               └──► BofA Checking ◄── Optomi, Priority Payment, Other Income
                         │
                         ├──► Mortgage
                         ├──► Chase CC ──────┐
                         ├──► BofA CC 8144 ──┼──► Food & Grocery
                         ├──► BofA CC 7503 ──┼──► Utilities
                         ├──► Capital One ───┬──► Retirement
                         ├──► BofA Savings   ├──► Investments
                         │                   ├──► Checks
                         │                   └──► Other Capital One Payouts
                         └──► Utilities, Travel, ... (direct debits)

Capital One does not stop at the transfer that funds it. It pays out to Edward Jones and by check, and those flows leave the Capital One node. The Edward Jones pulls split into Retirement (IRA contributions) and Investments (the 529 plans, the money market and the brokerage account).

Retirement is one node however the money got there: the 401(k) deduction on a paycheck and the IRA contributions from Capital One both end on it. It has no outflow, so Plotly draws it in the last column.

Plotly justifies every node with no outflow into the last column, so tax and benefit buckets draw beside the spend categories: one column holds every final destination of gross pay.

A deposit with no linked stub flows in at its net amount only, so an employer node is gross pay for stubbed deposits plus face value for the rest. The link above the chart, Paystubs: X of Y payroll deposits, says how much of the period that covers. It turns amber when coverage is incomplete and goes to the Paychecks page.


Database Views

All five are defined in heezy-containers/dockerfiles/heezy-finance/schema.sql, which the app replays on every pod start. All use bank_transactions JOIN bank_statements ON statement_id. Account classification is driven by bank, account_type and account_last4 from bank_statements.

vw_income_deposits

One row per credit to BofA checking: id, date, amount, source, account_last4, description. The only place a deposit is mapped to an employer.

Sources are BravoLt, Optomi, Priority Payment and Other Income, matched on bt.description, not merchant_normalized: every payroll deposit normalizes to the single merchant payroll, so the raw ACH string is the only thing that tells employers apart. Other Income is inbound Venmo/Zelle/PayPal and ATM deposits, not salary.

Pay stubs link to these rows, and a stub's employer must be one of these source names to link at all. vw_sankey_income depends on this view, so changing its columns needs DROP VIEW ... CASCADE first.

vw_sankey_income

vw_income_deposits summed by month and source.

vw_sankey_transfers

Debits from BofA checking, classified by destination. Includes both category = 'Transfer' and category = 'Mortgage' rows: mortgage payments are categorized Mortgage, not Transfer.

Each branch matches on merchant_normalized or a description pattern. The description fallbacks cover rows whose merchant_normalized predates the rules that would set it. Branches are evaluated in order, so the two last4-specific BofA CC branches must stay above the generic one.

Destination label Match logic
Mortgage merchant_normalized = 'mortgage (fifth third)', or description LIKE %5/3 MORTGAGE% or %FIFTH THIRD%
Chase CC merchant_normalized = 'chase credit card', or description LIKE %CHASE CREDIT CRD%
BofA CC 8144 merchant_normalized IN ('credit card payment', 'cc payment') AND description LIKE %CRD 8144%
BofA CC 7503 merchant_normalized IN ('credit card payment', 'cc payment') AND description LIKE %CRD 7503%
BofA CC (other) merchant_normalized IN ('credit card payment', 'cc payment', 'boa credit card'), or description LIKE %CREDIT CARD%. Catches payments with no last4 in the description
Capital One merchant_normalized = 'capital one transfer', or description LIKE %CAPITAL ONE%
BofA Savings merchant_normalized IN ('scheduled transfer to sav', 'banking transfer to sav'), or description LIKE %TRANSFER TO SAV%
Personal Transfers merchant_normalized IN ('venmo', 'paypal', 'zelle'), or description LIKE %VENMO%, %PAYPAL% or %ZELLE%
Other Transfer Everything else. Empty, and that is the correct state

Other Transfer is a bug indicator, not a category. Every dollar leaving BofA checking under Transfer/Mortgage has a real destination, so anything landing here means a branch above stopped matching. These branches hardcode merchant_normalized values that MERCHANT_RULES in scan_statements.py owns, and nothing links the two: changing a merchant name there silently dumps that merchant into Other Transfer. Check SELECT dest, sum(total) FROM vw_sankey_transfers GROUP BY 1 after any MERCHANT_RULES edit.

vw_sankey_payouts

Debits from Capital One checking that leave Capital One, by month and destination.

Destination label Match logic
Retirement Withdrawal from EDWARD JONES% that is on the ira_pulls list. The label is shared with the paycheck group, see below
Investments Every other Withdrawal from EDWARD JONES%
Checks description LIKE %CHECK #%
Other Capital One Payouts Everything else. Like Other Transfer, a bug indicator: a real payee gets its own branch

A Withdrawal to <account> XXXX1234 is left out when <account> is the account_name of another Capital One account (Holla to Our Savings): the money is still in the bank.

How the Edward Jones split is decided. The Capital One statement names the firm, not the account, so the split comes from the Edward Jones statements. Each IRA statement reports contributions by tax year, and the two Traditional IRAs (...6225, ...6368) each show $7,000 for 2025 and nothing for 2026: the two $7,000 pulls of 2026-03-04. The Roth IRAs show none. Every other pull matches a 529 ($500 each on the 1st), the money market ($1,000 a month to April plus the $75,000 of 2026-04-13, which sums to its $79,000 year to date) or the brokerage account ($2,000 a month since May).

The IRA pulls are the ira_pulls list inside the view. A contribution to an IRA looks like any other pull on the Capital One side, so a new one is an edit to that list, taken from the IRA statement that reports it; anything not listed is Investments. It is not a transaction category: recategorizing the rows to Retirement Funds would move them into spend totals that only exclude Transfer and Income.

Edward Jones matches on the description, not merchant_normalized. ej transfer also stamps the labelled moves between Capital One's own accounts (Edward Jones - Withdrawal to Holla ...), and those are not payouts.

Checking only, because the savings account carries copies of the checking account's rows from an earlier ingest. Summing both counts every Edward Jones pull twice. Nothing real leaves Capital One from savings; it moves to checking.

vw_sankey_spend

Charges on each credit card plus non-transfer direct debits from BofA checking. Has an account column so spend routes to the right card node.

-- account values, first match wins:
'Chase CC'        -- credit_card AND bank = 'Chase'
'BofA CC 8144'    -- credit_card AND account_last4 = '8144'
'BofA CC 7503'    -- credit_card AND account_last4 = '7503'
'Capital One CC'  -- credit_card AND bank = 'Capital One'
'Other CC'        -- any other credit_card
'Direct Debit'    -- BofA checking debits

Row scope is type = 'debit', category NOT IN (Transfer, Income, Mortgage), and the statement is either any credit card or BofA checking.

api_sankey() does not read this view: it cannot see receipt item splits. The same CASE is repeated in api_sankey(), per transaction, so splits can be applied in Python. The two must stay in step.


The Paycheck Layer

paystubs.sankey_stubs() returns every stub linked to a deposit whose month falls in the range, with its tax and deduction lines. Filing by the deposit's date, not the pay date, makes the layer cover exactly the deposits the income side draws. An unlinked stub is not drawn; linking is covered on the Paychecks page.

paystubs.paycheck_flows() turns those stubs into three sets of links, all tagged layer: "paycheck":

Link Value
employer → group (Taxes, Benefits, Retirement, Other Deductions) the group's lines, summed
group → bucket (Federal Income Tax, Medical, ...) the bucket's lines, summed. Not drawn for Retirement
employer → Deposited Elsewhere net pay the linked checking deposit does not account for (a split deposit)

Gross pay a stub does not itemise (gross less net less its lines, when positive) is added under Other Deductions → Other Deduction. With that and Deposited Elsewhere, an employer's outflows add up to gross pay: deposit plus deductions.

Retirement is a terminal group (paystubs.TERMINAL_GROUPS). The Capital One payouts to the IRAs use the same name, so the group and that destination are one node, and its 401(k), Roth 401(k) and 401(k) Loan buckets fold into it. Drawn as a group with buckets, the IRA money would appear to flow on into 401(k). The Paychecks page keeps the buckets.

A stub whose employer has no deposit in the period is dropped. Group and bucket names never collide with a spend category or a transfer node; a test asserts it, because nodes are keyed by name.

Group colours are Tailwind 600s (rose, teal, amber, fuchsia) that pass the dataviz palette validator against the chart surface, BofA Checking's indigo included. A bucket draws in its group's hue at lower alpha.

The paycheck queries run inside a savepoint. The tables come from an Ansible run the app does not control; if they are missing or a query fails, the diagram draws without the layer.


API (api_sankey in app.py)

api_sankey() runs the queries; build_sankey() is the pure builder, unit tested in tests/test_paystubs.py and tests/test_sankey_payouts.py. Node order, which is also Plotly's column order:

  1. Employers and Other Income
  2. BofA Checking, then the paycheck groups
  3. Transfer destinations and card nodes
  4. Capital One payout destinations
  5. Spend categories, then paycheck buckets and Deposited Elsewhere

Links, each tagged with a layer that the page's views are built from:

Stage Link layer
0 employer → group → bucket paycheck
1 income sources → BofA Checking deposit
2 BofA Checking → transfer destinations transfer
2b BofA Checking → direct debit spend categories direct
2c Capital One → Retirement, Investments, Checks, ... payout
3 card nodes → spend categories card

Capital One is drawn whenever it has payouts, including a month BofA sent it nothing, when it pays from its own balance.

Node colours are server-supplied in node_colors (SANKEY_COLORS in app.py, plus paystubs.GROUP_COLORS).

The response also carries total_income, total_spend, total_transfers, total_payouts and a paycheck object:

Field Meaning
gross, net, deposits Totals over the linked stubs
Taxes, Benefits, Retirement, Other Deductions Group totals
elsewhere Net pay drawn to Deposited Elsewhere
stub_count Linked stubs drawn
payroll_deposits, covered_deposits, coverage Employer deposits in the period, how many have a stub, per employer

The date filter is month-granular, not day-granular

The views pre-aggregate to month_start. api_sankey filters with month_start >= DATE_TRUNC('month', start_date) and <= DATE_TRUNC('month', end_date), so a range of 2026-03-15 to 2026-04-10 returns all of March and all of April. Every other endpoint on the page honours exact days, so the Sankey will not tie out against the KPI strip on a partial month. With no dates supplied the label is All Time.

The query runs with SET LOCAL statement_timeout = '8000' in an explicit transaction. The payout query runs inside a savepoint like the paycheck layer, so a missing view draws the diagram without it.


Views and toggles (client-side)

The buttons in the section header redraw the cached _sankeyData response without another API call.

Control States Effect
View Combined (default) / Accounts / Categories See below. The choice is kept in localStorage.heezySankeyView
Paycheck Gross Pay (default) / Deposits Only Drops every layer: "paycheck" link. Hidden when the period has none
Cards CC Detail (default) / Combined CCs Merges every card node into one Credit Cards node. Hidden in the Categories view, which has no card nodes
View Draws
Combined Every link as sent: accounts and categories
Accounts Money moving between accounts. Drops card links; direct links fold into one Direct Debit Spend node so checking still shows what left it
Categories Where money ends up. BofA Checking, every card and Capital One become one Total Income hub, so income reaches categories, the mortgage, savings and payouts with no account between. A link from the hub to itself (a card payment, the BofA to Capital One transfer) moves money between accounts and is dropped

In Categories the hub's outflow can exceed its inflow: Capital One pays from its own balance, not only from the BofA transfer.

Both go through sankeyView() in index.html, which rebuilds the graph by node name: links are re-keyed by name, dropped or merged, summed and re-indexed, and a node nothing links to is dropped. Combined CCs keeps direct debit categories, and both shares of a category paid by card and by direct debit.

With the paycheck layer on, the subtitle reads gross (the left column's total), each group, the amount deposited, and spend.

The card node set is defined in two places. Keep them in sync when adding a card. Both hold {Chase CC, BofA CC 8144, BofA CC 7503, BofA CC (other), Capital One CC, Other CC}:

  1. SANKEY_CC_NODES in app.py
  2. CC_NODES in index.html

An account node that is not a card has to be added to isAccount() in sankeyView(), or it survives as a category in the Categories view.


Adding a New Account or Employer

A checking account: BofA checking credits appear in vw_income_deposits automatically, and its debits in vw_sankey_transfers.

A credit card: charges appear under Other CC (or Capital One CC) until it gets its own node. For its own node: a CASE branch in vw_sankey_spend and the matching one in api_sankey(), an entry in both card node sets, and a colour in SANKEY_COLORS.

An employer: a branch in vw_income_deposits and a colour in SANKEY_COLORS. For its stubs to be recognised on upload, a marker in EMPLOYER_MARKERS in paystub_parser.py. A test asserts every marker name is a source the view emits.


Known Limitations

  • BofA CC (other) catches generic boa credit card payment descriptions that carry no last4. They show as a transfer from checking, but their spend has no matching card statement loaded.
  • Capital One payouts are read from checking only. Purchases made with a Capital One credit card still route through Capital One CC.
  • Capital One payees other than Edward Jones are not identified: Checks is every check cashed (the statement names no payee).
  • Capital One months with no statement on file have no payouts: July is missing, and September's is not issued. The Edward Jones statements show those pulls ($500 to each 529 on the 1st and $2,000 to the brokerage account in July and September) but they are not added to the diagram without the Capital One rows.
  • The diagram only covers months with BofA checking statements loaded. Gaps in statement coverage are gaps in the Sankey, and a stub whose deposit is not loaded yet is not drawn.
  • Node and link balance holds for the paycheck groups, not for checking or the card nodes: a month's card payments rarely equal that month's card spend.