System Plan · Draft 2 · 4 August 2026

Household Ledger

A private, self-hosted budget system for Matthew and Mariel. Replaces the twenty-tab Google Sheet with a real database, phone alerts that warn you early enough to act, a categorization engine that learns from corrections, and a receipt reader that splits a Walmart run into groceries, cleaning, and pet food.

Host budget.matthewmcmanness.com Access two email addresses, nothing else Stack Next.js · TypeScript · Postgres · Ollama

01What this has to do

The Google Sheet has been abandoned four times: mid-2024, mid-2025, and again in March 2026. Each time the same way. Recurring bills kept getting logged because there are only a dozen of them and they are predictable. Groceries, gas, and food stopped getting logged because typing every purchase into a phone spreadsheet is more work than anyone sustains.

So the design target is less work per month than the spreadsheet, with better answers. Every decision below is measured against that.

In scope

  • One continuous ledger replacing the tab-per-month structure. Trends across years become possible for the first time.
  • Bank CSV import that categorizes itself from a rules table you never hand-edit.
  • Receipt photos read by a local vision model, split into line items, categorized per item.
  • A review queue where a correction takes seconds and never has to be made twice.
  • Phone alerts that fire while there is still month left to act, driven by projection and drift rather than by thresholds crossed after the fact.
  • A thirty-second daily glance that replaces awareness-through-typing with awareness-through-looking.
  • Budgets proposed from what you actually spend.
  • Reachable only by two people, over a tunnel, with no open port.

Explicitly out of scope

  • Investment tracking, net worth, retirement projections.
  • Bill pay, transfers, or anything that moves money.
  • Multi-currency. Everything is USD, stored as integer cents.
  • Anyone but the two of you. No sharing, no invites, no roles beyond "both of you can do everything."
The measure of success

Six months from now, the ledger has continuous data with no gaps, and the weekly review takes under two minutes. If it needs more than that, the design failed regardless of how good the charts look.

02Deployment topology

Same shape as the hub dashboard already running at hub.resilientwebsolutions.com, with a different schema and a different allow-list. That system is the working proof this pattern holds.

ComponentChoiceNotes
Hostnamebudget.matthewmcmanness.comPersonal data on the personal domain, deliberately separate from RWS and client infrastructure
IngressNamed Cloudflare tunnel budget-appOutbound dial only. No inbound port, no public origin
IdentityCloudflare Access, email policyExactly two addresses. Enforced at the edge, before the request reaches the box
AppNext.js 14 App Router, TypeScript, TailwindHouse standard; same stack as every other thing running here
DatabaseDedicated Postgres 16 container budget-postgresNot published to the host. Reachable only on the Docker network
MigrationsDrizzle KitSame workflow as the AM785 booking system
VisionOllama at host.docker.internal:11434qwen3-vl:8b primary, gemma4:26b escalation
ImagesDocker volume, served through an authenticated routeNever a public static path
DeployDokploy apps budget-prod and budget-stagingBranches Production and staging, per the standing git rules
ChartsRechartsBoring, works, no CDN dependency

Model choice is settled by measurement rather than preference. Both models were tested against a synthetic Walmart receipt carrying genuine abbreviated item names. Both extracted 14 of 14 items and every price exactly, summing to the subtotal to the cent. qwen3-vl:8b ran in 88 seconds and got 12 of 14 categories right. gemma4:26b ran in 419 seconds and got all 14, but it is 17 GB against 12 GB of VRAM, so it spills to CPU. The small model fits entirely in VRAM and can share the card with ComfyUI.

That is why the pipeline runs the small model first and escalates only on arithmetic failure. Seven minutes is acceptable when it happens unattended, twice a month.

03Security model

Financial data for a household, so the posture is stricter than a client marketing site.

  • Edge authentication. Cloudflare Access holds a policy allowing exactly matthewmcmanness@gmail.com and MarielDryton@gmail.com. Anything else never reaches the origin.
  • Origin verification anyway. Middleware validates the Cf-Access-Jwt-Assertion header against the team JWKS, checking the audience tag for this specific app. Defense in depth: a request that somehow bypassed the edge still gets a 403. The hub dashboard's middleware already does this and is the template.
  • No open ports. The tunnel dials out. Unlike the AM785 database, which publishes 5433 to the host, this Postgres stays on the Docker network only.
  • Authenticated image serving. Receipt photos contain names, card last-four, and purchase history. They are served through a route handler that re-checks the JWT, never from a static directory.
  • Identity for attribution. The JWT tells the app which of you is acting, which populates created_by and the audit log. It gates nothing, because you both have full access by design.
  • Secrets in Dokploy environment variables. Never in the repository. The repo holds site code only, per the standing rules.
  • Audit log. Every category change, rule creation, and import records actor, timestamp, before, and after. Useful for "why is this in the wrong bucket" six weeks later.
Deliberate non-goal

No application-level password, no session management, no password reset flow. Adding one would mean writing and maintaining auth code that Cloudflare already does better. If Access is down the app is unreachable, and that is the correct failure mode for a household budget.

04Data model

Postgres via Drizzle. Money is stored as integer cents everywhere, never floating point. This directly fixes a real defect in the current sheet, where amounts are floats and the category column is free text that silently drops a transaction from every total when it is mistyped.

TableKey columnsPurpose
usersemail, display_namePopulated from the Access JWT on first sight
accountsname, institution, kind, external_id, last_imported_atAlly checking, Ally savings, any credit card
categoriesname, slug, parent_id, kind, archived, sort_orderHierarchical. kind is expense, income, or transfer
transactionsaccount_id, date, amount_cents, description, merchant_norm, category_id, source, external_id, parent_id, is_split, needs_review, review_reason, created_byThe ledger. One row per movement of money
receiptsimage_path, image_sha256, merchant, purchased_at, subtotal_cents, tax_cents, total_cents, status, extraction_model, attempts, raw_response, matched_transaction_idOne row per photo
receipt_line_itemsreceipt_id, line_no, raw_text, name, amount_cents, category_id, confidence, matched_rule_idThe itemization. This is what makes cleaning separable from groceries
rulesfield, match_type, pattern, pattern_norm, category_id, priority, source, hit_count, last_hit_at, archivedThe learning surface. Grows only from your corrections
import_batchesaccount_id, filename, sha256, row_count, inserted_count, duplicate_count, statusMakes a bad import reversible
budget_periodsmonthOne row per month
budget_linesperiod_id, category_id, planned_cents, rollover_enabledPlanned amounts
sinking_fundscategory_id, target_cents, monthly_cents, balance_centsFor lumpy costs: car insurance, vet, home repair
alertsmonth, category_id, kind, projected_cents, budget_cents, median_cents, fired_at, acknowledged_at, acknowledged_byAlert history. The UNIQUE (month, category_id, kind) constraint is what enforces once-per-category-per-month at the database level rather than in application logic
audit_logactor_email, action, entity, entity_id, before, after, atAppend-only

Constraints that matter

  • UNIQUE (account_id, external_id) on transactions. This is the deduplication guarantee for repeated CSV imports.
  • UNIQUE (image_sha256) on receipts. Photographing the same receipt twice is a no-op.
  • category_id is a foreign key. A typo cannot create a category, which is the failure mode that loses money in the current sheet.
  • A transaction with is_split = true is excluded from all totals; its children carry the amounts. The parent stays for provenance.

05Architecture

Five layers, with dependencies pointing inward only. The domain layer knows nothing about Postgres, Ollama, HTTP, or Next.js. Services depend on interfaces rather than concrete adapters. Wiring happens in one composition root.

The practical payoff is that the interesting logic (money splitting, rule matching, receipt reconciliation, transaction splitting) stays pure and testable without a database, a network, or a running model.

graph TD
    UI["Delivery: routes, server actions, React screens"]
    SVC["Services: use cases"]
    PORT["Ports: interfaces"]
    DOM["Domain: entities and value objects"]
    ADP["Adapters: Drizzle, Ollama, disk, gws"]

    UI --> SVC
    SVC --> PORT
    SVC --> DOM
    ADP -.implements.-> PORT
  

Domain layer

Immutable value objects and entities with behavior. No I/O, no framework imports, no async.

MoneyValue object

Integer cents, immutable. Exists so no dollar amount is ever a float, and so a receipt total can be divided across categories without losing pennies.

MethodTakesReturnsBehavior
fromCentsnumberMoneyStatic. Throws on non-integer
fromDecimalstring | numberMoneyStatic. Parses "12.31", rounds half-even, throws on NaN
plus / minusMoneyMoneyNew instance
timesnumberMoneyRounds half-even
negated / absnothingMoneySign operations
allocatenumber[] weightsMoney[]Splits so the parts sum exactly to the whole. Remainder cents go to the largest weights first. Used for prorating tax across receipt categories
equals / isZero / isNegativeMoneybooleanComparison
toDecimalStringnothingstring"12.31" for display
DateOnlyValue object

A calendar date with no time and no timezone. Exists because the current sheet is set to America/Mexico_City, which silently shifts date functions for a household in Topeka.

MethodTakesReturnsBehavior
fromISOstringDateOnlyStatic. Strict "YYYY-MM-DD"
fromJsDateDate, tzDateOnlyStatic. Resolves the wall-clock date in the given zone
todaytz = America/ChicagoDateOnlyStatic
iso / monthKeynothingstring"2026-08-04" and "2026-08"
firstOfMonth / addDaysnumberDateOnlyNavigation
isBetweenDateOnly, DateOnlybooleanInclusive
compareDateOnly-1 | 0 | 1For sorting
MerchantStringValue object

Normalizes the noisy merchant text banks emit, so WAL-MART #1234 TOPEKA KS and WAL-MART SUPERCENTER resolve to one thing the rules table can match.

MethodTakesReturnsBehavior
ofstringMerchantStringStatic factory, keeps the raw value
normalizednothingstringUppercase, strip punctuation, collapse whitespace, drop store numbers, drop trailing city and state, strip processor prefixes such as SQ * and POS DEBIT
tokensnothingstring[]Normalized words, for partial matching
TransactionEntity

One movement of money. The aggregate root for the ledger.

MethodTakesReturnsBehavior
createTransactionPropsTransactionStatic. Validates amount non-zero, date present, account exists
assignCategorycategoryId, ProvenancevoidSets the category and records how it was decided: rule, history, model, or human. Clears the review flag when the provenance is confident
flagForReviewreason: stringvoidSets needs_review with a human-readable reason
resolveReviewnothingvoidClears the flag
splitSplitPart[]Transaction[]Throws unless the parts sum exactly to this amount. Marks self is_split, returns children carrying parent_id. Splits are excluded from totals so nothing double-counts
isDuplicateOfTransactionbooleanTrue on matching external_id, or on same date, same amount, and same normalized merchant
signedAmountCategoryMoneyNormalizes direction so expenses are negative and income positive regardless of how the bank signed it
ReceiptEntity

A photographed receipt and its extracted line items. Owns the arithmetic gate that decides whether an extraction can be trusted.

MethodTakesReturnsBehavior
createimagePath, sha256, uploadedByReceiptStatic. Status PENDING
attachExtractionExtractionResult, modelvoidPopulates merchant, date, totals, line items. Increments attempts, records which model produced it, moves to EXTRACTED
reconcilestolerance = 2 centsbooleanThe gate. True when line items sum to the subtotal within tolerance. In testing both models hit this exactly, so a failure is a strong signal something was misread or missed
discrepancynothingMoneySubtotal minus the item sum, for the failure message
unresolvedItemsnothingLineItem[]Items no rule matched, which is what lands in the review queue
categoryTotalsnothingMap<string, Money>The actual answer to the original question: how much of this trip was groceries versus cleaning versus pets. Tax is prorated across categories with Money.allocate
toTransactionDraftsaccountIdTransactionDraft[]One draft per category rather than per item, so the ledger stays readable. Each draft keeps its item list for drill-down
markFailed / markPostedreason or txIdsvoidTerminal state transitions
RuleEntity

A deterministic mapping from text to category. The reason review work shrinks over time instead of repeating forever.

MethodTakesReturnsBehavior
manualfield, matchType, pattern, categoryId, actorRuleStatic. Hand-authored from the rules screen
learnedCorrectionRuleStatic. Derived from a review-queue correction, marked source = learned
matchesstringbooleanNormalizes both sides, then applies exact, contains, or regex per match_type
specificitynothingnumberLonger patterns and exact matches score higher. Resolves CHICKEN against CHICKEN FEED so the more specific rule wins
conflictsWithRulebooleanTrue when patterns overlap but categories disagree. Surfaced to you rather than resolved silently
recordHitnothingvoidIncrements the counter so dead rules become visible
SpendingPaceValue object

Answers whether a category is ahead of the calendar. Pure arithmetic over a budget, a spend total, and a position in the month.

MethodTakesReturnsBehavior
ofspent, budget, dayOfMonth, daysInMonthSpendingPaceStatic. Throws when the day is outside the month
burnRationothingnumberFraction of budget consumed
calendarRationothingnumberFraction of the month elapsed
isAheadtolerance = 0.05booleanBurn is running faster than the calendar by more than the tolerance
dailyRatenothingMoneyAverage spend per elapsed day, the input to a projection
ProjectionValue object

Where a category lands on the last day of the month if the current rate holds. The basis for every overspend alert, because a threshold crossed arrives too late to act on.

MethodTakesReturnsBehavior
fromPaceSpendingPaceProjectionStatic. Linear extrapolation of the daily rate across remaining days
projectedTotalnothingMoneyEstimated month-end figure
overspendnothingMoneyProjected total minus budget. Zero or negative means on track
daysRemainingnothingnumberUsed in the alert copy so the message states the action window
exceedsByratio, floor: MoneybooleanTrue only when both the percentage and the absolute dollar floor are breached, which is what keeps small categories quiet
DriftSignalValue object

Compares this month against what is normal for this household. Catches creeping increases that never breach a budget line because the budget was set too loose in the first place.

MethodTakesReturnsBehavior
computecurrent: Money, history: Money[]DriftSignalStatic. Median rather than mean, so one holiday month does not distort the baseline
mediannothingMoneyThe trailing normal, also drawn as the band on the trend chart
deltaRationothingnumberSigned fraction above or below normal
isAnomalousthreshold = 0.30, minSamples = 4booleanFalse when history is too thin to have a normal yet, which prevents noise in the first months
LineItem · Category · BudgetLineEntity

Supporting entities.

MethodTakesReturnsBehavior
LineItem.applyCategorizationCategorizationResultvoidStores category, confidence, and the rule that decided it
LineItem.isUnresolvednothingbooleanNo category, or confidence below threshold
Category.isExpense / isIncomenothingbooleanDrives sign normalization
Category.pathnothingstring[]Ancestor chain for display, such as Home then Cleaning
BudgetLine.varianceMoney actualMoneyPlanned minus actual. Negative means over

Ports

Interfaces the services depend on. Every one has a Drizzle or network adapter in production and a trivial in-memory fake in tests.

TransactionRepositoryInterface
MethodTakesReturnsUsed by
findByIdidTransaction | nullEverything
findByPeriodmonth, optsTransaction[]BudgetService, dashboard
findNeedingReviewlimit, offsetTransaction[]ReviewService
findByExternalIdsaccountId, string[]Map<string, Transaction>CsvImportService, for bulk dedupe in one query
findCandidateMatchesdate, amount, windowDaysTransaction[]ReconciliationService, matching a receipt to its bank line
sumByCategoryfrom, toMap<string, Money>BudgetService, trends
save / saveManyTransaction[]voidEverything. saveMany is transactional
Remaining portsInterface
PortKey methodsProduction adapter
RuleRepositoryfindAllActive, save, findConflictsDrizzle
ReceiptRepositoryfindById, findByStatus, findBySha256, saveDrizzle
CategoryRepositoryfindAll, findBySlug, saveDrizzle, cached per request
AccountRepositoryfindAll, findById, touchImportedDrizzle
BudgetRepositoryfindPeriod, savePeriod, findLinesDrizzle
ImageStoreput(bytes, sha), get(path)Docker volume on disk
VisionExtractorname, extract(image, prompt)Ollama, one instance per model
Mailersend(to, subject, body)gws-personal via the draft helper
Clocktoday, nowSystem, frozen in tests

Adapters

OllamaVisionExtractorimplements VisionExtractor

Constructed once per model. Two instances exist: primary and escalation.

MethodTakesReturnsBehavior
constructorbaseUrl, model, optsinstanceStores endpoint, model name, temperature 0.1, context size
extractBuffer, ExtractionPromptExtractionResultBase64-encodes the image, posts to /api/generate, extracts the JSON object from the response, validates against a Zod schema. Throws ExtractionError on unparseable output rather than returning something half-formed

ExtractionResult is { merchant, date, subtotal, tax, total, items: [{ raw, name, price, suggestedCategory }] }. The suggested category is treated as a hint only; the categorization engine still runs, and rules override it.

AllyCsvDialectimplements CsvDialect

Ally exports Date, Time, Amount, Type, Description, Balance. Other banks get their own dialect class without touching the importer.

MethodTakesReturnsBehavior
detectstring[] headerRowbooleanMatches Ally's column signature so the right dialect is chosen automatically
parsestring[][] rowsRawTxn[]Maps columns, parses amounts via Money.fromDecimal, dates via DateOnly
externalIdForRawTxnstringAlly provides no stable transaction id, so this hashes date, amount, description, and running balance. Balance makes two identical same-day charges distinguishable
Known weakness

The synthesized external id is stable only while the running balance is stable. A pending charge that later posts with a different balance can import twice. Mitigation: import posted transactions only, and let Transaction.isDuplicateOf catch the rest as a second line of defense. Worth watching during the first two months.

The categorization engine

This is the heart of the system, built as a chain of responsibility. Each strategy either answers confidently or declines and passes along. The first confident answer wins. The order is deliberate: deterministic before probabilistic, always.

interface CategorizationStrategy {
  readonly name: string
  readonly minConfidence: number
  categorize(req: CategorizationRequest): Promise<CategorizationResult | null>
}

// CategorizationRequest  { text, merchant, amount, date, source }
// CategorizationResult   { categoryId, confidence, strategy, ruleId?, rationale? }
OrderStrategyDecides onConfidence
1RuleStrategyThe rules table, sorted by priority then specificity1.00
2MerchantHistoryStrategyHow this exact normalized merchant was categorized before, needing 3 or more samples at 90% agreement= agreement
3LlmStrategyLocal model, given the item text and the allowed category list0.60 fixed
4UncategorizedStrategyAlways answers. Terminal0.00
Why the model's own confidence is ignored

Language models are badly calibrated about their own certainty, and testing showed the 8B model categorizing Tide Pods differently across two runs of the identical image. So LlmStrategy reports a fixed 0.60, which sits below the auto-accept threshold. Anything the model decides on its own goes to the review queue by design. A lookup table never changes its mind, which is the whole reason rules run first.

CategorizationEngineService
MethodTakesReturnsBehavior
constructorCategorizationStrategy[]instanceArray order is the chain order
categorizeCategorizationRequestCategorizationResultWalks the chain, returns the first result meeting that strategy's threshold
categorizeManyCategorizationRequest[]CategorizationResult[]Loads the rule set once and shares it across the batch. Used by CSV import and receipt processing
RuleLearnerService

Turns a human correction into a permanent rule. This is the mechanism that makes review work decay toward zero.

MethodTakesReturnsBehavior
proposeFromCorrectionRule | nullDerives the pattern. For receipt items, the longest stable prefix of the raw text, so GV PPR TWL 6R yields GV PPR TWL and matches the 12-roll pack too. For bank rows, the normalized merchant. Returns null when the text is too short or too generic to be safe
learnCorrection{ rule, conflicts }Checks for conflicting rules before saving and returns them for the UI to resolve. Never silently overwrites an existing rule

Services

ReceiptPipelineService

Owns the receipt state machine. Constructed with the image store, both extractors, the engine, and the reconciler.

MethodTakesReturnsBehavior
ingestBuffer, uploadedByReceiptHashes the image, returns the existing receipt when the hash is already known, otherwise stores the file and creates a PENDING record. Returns immediately; extraction happens on the timer
processreceiptIdReceiptThe state machine, described below
processPendinglimitProcessSummaryCalled by the timer. Returns counts by outcome for logging

What process does, in order: extract with the primary model, check receipt.reconciles(), on failure re-extract with the escalation model and check again, on second failure set NEEDS_MANUAL and stop without writing any transactions, otherwise categorize every line item through the engine, group into per-category drafts, hand to ReconciliationService, set POSTED.

Nothing partial ever reaches the ledger. A receipt either reconciles and posts in full, or it waits for you.

CsvImportServiceService
MethodTakesReturnsBehavior
importBuffer, filename, accountId, actorImportResultSelects the dialect by header signature, parses rows, computes external ids, bulk-checks existing ids, categorizes new rows through the engine, writes everything in one transaction, records the batch
revertbatchId, actorvoidDeletes transactions from a batch, provided none have been manually edited since. Makes a bad import safe to undo

ImportResult is { batchId, parsed, inserted, duplicates, needsReview, errors[] }. Duplicates are counted rather than treated as failures, because re-importing an overlapping date range is normal and expected.

ReconciliationServiceService

Prevents double-counting: the bank says one Walmart charge of $122.79, the receipt says four categories totalling the same amount.

MethodTakesReturnsBehavior
matchReceiptToBankReceiptMatchOutcomeLooks for a bank transaction within 3 days either side whose amount equals the receipt total. One match: split that transaction into the receipt's per-category children. No match: post the receipt's transactions flagged awaiting_bank. Several: flag for you to pick, never guess
reconcilePendingnothingReconSummaryNightly sweep. Catches receipts photographed before the charge posted, which is the common case
ReviewServiceService

The correction loop. The single most important service for whether this survives past month two.

MethodTakesReturnsBehavior
queueoptsReviewItem[]One unified list: uncategorized transactions plus unresolved receipt items. Grouped so identical raw text collapses into a single decision covering many rows
applyCorrectionCorrectionCorrectionOutcomeSets the category, optionally calls RuleLearner, then re-runs the engine across the rest of the queue so the new rule clears everything it now matches. Returns how many other items resolved as a side effect
bulkApplyids[], categoryId, actorCorrectionOutcomeOne category across a selection, with a single learned rule when the texts share a prefix

CorrectionOutcome is { updated, alsoResolved, ruleCreated?, conflicts[] }. The alsoResolved count is shown in the UI, because watching one correction clear six rows is what makes the system feel like it is working for you.

BudgetServiceService
MethodTakesReturnsBehavior
periodSummarymonthPeriodSummaryPlanned against actual per category, totals, savings rate, count needing review
rollingAveragecategoryId, monthsMoneyAnswers "what do we normally spend on this", which the current sheet cannot do at all
variancemonthVarianceRow[]Sorted by absolute overspend, so the worst line is first
suggestBudgetmonth, lookbackMonthsBudgetLine[]Proposes each category from its trailing median. This is the direct fix for Food being budgeted at $74 against $667 of actual spend
InsightServiceService

Computes the three signals. Everything the dashboard, the daily glance, and the alert evaluator display comes from here, so the numbers agree everywhere by construction.

MethodTakesReturnsBehavior
pacemonth, categoryIdSpendingPaceSpend to date against budget and calendar position
projectionmonth, categoryIdProjectionMonth-end estimate from the current rate
driftmonth, categoryId, lookback = 6DriftSignalThis month against the trailing median
monthPulsemonthPulseThe single headline: net position, total pace, worst category, count needing review
dailyGlancedateGlanceSummaryYesterday's transactions, month-to-date against pace, the one category furthest off track. Feeds the thirty-second phone screen
AlertServiceService

Decides what actually reaches your phone. The suppression rules live here, and they matter as much as the detection, because an alert you have learned to ignore is worse than no alert.

MethodTakesReturnsBehavior
evaluatemonthAlertCandidate[]Runs projection and drift across every budgeted category. Pure detection, no side effects
shouldFireAlertCandidatebooleanApplies every suppression rule: already fired for this category this month, before the 6th, after the 25th, below the dollar floor, below the percentage threshold, or already acknowledged
dispatchAlertCandidate[]DispatchSummaryComposes the message, sends through the Notifier port, records the row so it cannot repeat
acknowledgealertId, actorvoidCalled by the notification's action button. Silences that condition for the rest of the month
runDailynothingDispatchSummaryThe timer entry point: evaluate, filter, dispatch
Notifier · NtfyNotifierPort and adapter
MethodTakesReturnsBehavior
Notifier.notifyPushMessagevoidThe interface. A logging fake is used in tests so no message escapes
NtfyNotifier.constructorserverUrl, topic, tokeninstancePoints at the self-hosted instance with a per-device access token
NtfyNotifier.notifyPushMessagevoidPOSTs title, body, priority, tags, a click URL deep-linking to the relevant screen, and an Acknowledge action that calls back into AlertService

PushMessage is { title, body, priority, tags[], clickUrl, actions[] }. Priority 4 for a projected overspend, 3 for drift, so the two read differently on the lock screen.

DigestService · AuthContextService
MethodTakesReturnsBehavior
DigestService.weeklynothingvoidThe Sunday summary, spending first and review last. Opens with where the money went, what is off pace, and what changed against normal; the categorization requests go at the bottom. An earlier draft led with the chore, which made a review queue out of what should be a report
AuthContext.fromRequestRequestAuthContextStatic. Validates the Access JWT against the cached team JWKS and the app audience. Throws on failure
AuthContext.email / usernothingstring, UserAttribution for created_by and the audit log

06Awareness and alerts

The ledger is plumbing. This section is the product.

A budget exists to change the next purchase. Manual entry used to be the mechanism that made that happen: typing a number forces you to look at it. Automating capture removes that mechanism, so the awareness has to be rebuilt deliberately out of feedback rather than out of data entry. An earlier draft of this plan buried all of it in a late phase, which was the wrong call and is corrected here.

The objection this answers

"If I automate all the tracking, I end up less aware." Fair, and true of any system that automates capture and returns nothing in exchange. The counter-evidence is the history: in March 2026 only 20 transactions were logged and no groceries, food, or gas appeared at all, so there was close to zero awareness of variable spending that month. In January, when tracking was genuinely diligent at 75 transactions, the month still finished about $262 down before accounting for the missing mortgage. Awareness one purchase at a time surfaced neither problem. The trade this system makes is less typing and more looking.

Three signals

Every alert and every headline number derives from one of three computations. They answer different questions and they fail in different ways, so all three earn their place.

SignalQuestion it answersComputationWhere it shows
Pace Am I ahead of the calendar right now spent / budget compared against elapsed / days Dashboard and daily glance, continuously
Projection Where does this category land on the last day spent + (daily rate × days remaining) Push alert when the projection exceeds budget
Drift Is this month unusual for us month total against the trailing 6-month median Push alert, and a band on the trend chart

Drift is the signal no manual system has ever given you, and it may be the most valuable of the three. Food was budgeted at $74 against roughly $667 of real monthly spending. A budget that wrong is decoration rather than a constraint, and no amount of diligent typing would have revealed it, because a running total compared against a fictional target teaches nothing. A rolling median flags it in the second week.

Projection matters because thresholds arrive too late. Being told you have reached 80% of the grocery budget on the 27th is a report. Being told on the 9th that the current rate lands about $180 over is a warning you can still act on.

Alert design

The failure mode for every notification system is teaching the recipient to ignore it. These rules exist to prevent that, and they are as much a part of the design as the computation.

  • Fire on projection rather than a raw threshold. A predicted overspend is actionable. A percentage crossed is trivia.
  • Once per category per month. The second alert about the same category is what teaches you to mute the app.
  • Nothing after the 25th. Past that point the month is decided and the message becomes a report, which belongs in the Sunday digest.
  • An absolute dollar floor. No alert for a projected $6 overage on a $40 category. Percentage-only rules produce noise on small lines.
  • A grace period at the start of the month. Nothing fires before the 6th, because three days of data projects wildly and would cry wolf every month.
  • Every alert names the action. "Groceries projects to $1,180 against $1,000. About $180 over with 19 days left" beats "Groceries alert."
  • Acknowledgeable. Tapping the alert records it, so the same condition stays quiet and the dashboard shows it as seen.

Everything fires from a single daily evaluation at 07:00, inside the window when you are already awake. One evaluation, at most a couple of messages, and silence on a normal month.

The daily glance

Separate from alerts, and the direct replacement for the awareness that manual entry used to provide. A single phone-sized screen showing yesterday's spending line by line, the month-to-date total against pace, and the one category furthest off track. Thirty seconds, no typing, and it carries far more signal than writing down a single $4.12 purchase ever did, because it shows the whole picture instead of one line of it.

Optional friction, kept where it does work

If deliberate friction turns out to matter, it belongs only on spending where a decision actually exists. Bills, mortgage, insurance, and utilities post silently, because there is nothing to influence. Discretionary purchases can require a morning tap to confirm each one: five seconds, no typing, and every discretionary purchase still gets looked at individually. Built as a setting rather than decided now.

Push delivery

Alerts go to your phone through ntfy, which already drives the Uptime Kuma alerts, so both apps are installed and the habit exists. It supports priority levels, tags, and tappable action buttons that can deep-link into a specific screen.

PropertyUptime Kuma todayBudget alerts
Serverpublic ntfy.shSelf-hosted, behind the tunnel
Authnone, topic is the only secretAccess token per device
Priority5, maximum4 for projections, 3 for drift
Actionsnone"Open budget" deep link, "Acknowledge" HTTP action
Why self-host ntfy for this

An uptime message says a site is down, which is harmless if intercepted. A budget message says how much you spend on groceries and by how much you are over. Sending that through a public relay in plaintext, protected only by a topic string, sits oddly beside a plan whose entire premise is keeping the same data behind Cloudflare Access. ntfy is a small Go service and the deployment pattern is one you already run. Moving Uptime Kuma onto the same instance afterward is optional and would consolidate both.

One detail to verify at setup: iOS push wake-up for self-hosted ntfy servers routes through the project's upstream relay, with the phone then fetching the message body from your server. Message content stays on your infrastructure, but confirm the current behavior when configuring rather than assuming it.

Charts

Each chart earns its place by supporting a decision. Anything that only looks like a dashboard is left out.

ChartThe question it answersScreen
Month-to-date burn line against budget paceAm I on track today/
Category bars, actual against budget, sorted by overspendWhat is the single worst line this month/
Twelve-month trend per category with a median bandIs this month unusual, or is this simply what we spend/trends
Income against expense by monthAre we net positive, and for how many months running/trends
Rolling three-month average per categoryWhat is normal, so budgets get set from reality/trends
Cumulative surplus or deficit for the yearThe number that actually matters/trends

The median-band trend chart is the one the current spreadsheet structurally cannot produce. A tab per month means there is no way to compare across months without rebuilding the comparison by hand every time.

07How the pieces connect

Receipt, photograph to ledger

sequenceDiagram
    participant P as Phone
    participant API as Upload route
    participant PL as ReceiptPipeline
    participant V1 as qwen3-vl 8b
    participant V2 as gemma4 26b
    participant CE as CategorizationEngine
    participant RC as ReconciliationService
    participant DB as Postgres

    P->>API: photo (multipart)
    API->>PL: ingest(bytes, user)
    PL->>DB: store image, receipt PENDING
    Note over PL: timer fires, every 15 min
    PL->>V1: extract(image)
    V1-->>PL: items, totals
    PL->>PL: reconciles?
    alt sums to subtotal
        PL->>CE: categorizeMany(items)
        CE-->>PL: categories and confidence
    else does not sum
        PL->>V2: extract(image)
        V2-->>PL: items, totals
        PL->>PL: reconciles?
        Note over PL: still failing, NEEDS_MANUAL, stop
    end
    PL->>RC: matchReceiptToBank(receipt)
    RC->>DB: split the bank line into categories
    RC-->>PL: matched
    PL->>DB: receipt POSTED
  

Correction, and why it compounds

graph LR
    A["You change one dropdown"] --> B["ReviewService.applyCorrection"]
    B --> C["Transaction or LineItem updated"]
    B --> D["RuleLearner.proposeFrom"]
    D --> E{"Conflicts?"}
    E -->|no| F["Rule saved"]
    E -->|yes| G["Shown to you, held back"]
    F --> H["Engine re-runs over the queue"]
    H --> I["Other matching items clear themselves"]
    F --> J["Every future import matches automatically"]
  

That last edge is the entire argument for building this. You buy roughly the same hundred-odd items in rotation, so once the rules table has seen them, new items become genuinely rare. Expect around twenty-five decisions in week one, six by week four, and one to three a month by the third month.

08Screens

ScreenRouteWhat it shows
This month/Net position as one number, the month-to-date burn line against budget pace, category bars sorted by overspend, any live alerts, a chip showing how many items need review
Review/reviewThe important one. A card per decision: receipt thumbnail, raw text, suggested category, number keys to choose, one key to learn the rule, one to bulk-apply. Optimistic updates so it never feels like waiting
Daily glance/todayThe awareness ritual. Yesterday's spending line by line, month-to-date against pace, the one category furthest off track. Sized for a phone, readable in thirty seconds, nothing to type
Trends/trendsTwelve-month lines per category with median bands, rolling averages, income against expense, cumulative surplus for the year
Transactions/transactionsFilterable table, manual split, drill into a receipt's items
Rules/rulesEvery rule with its hit count, conflict warnings, and the ability to retire dead ones
Budget/budget/[month]Planned per category, with a button that fills them from trailing medians
Import/importDrop a CSV, see parsed, inserted, duplicates, and anything that failed. Revert available

The review screen gets disproportionate design effort because it is the only screen whose speed determines whether the system lives. Everything else is read-mostly.

09Scheduled jobs

systemd --user timers, matching the existing convention on this box. No crontab.

TimerWhenCalls
budget-receiptsevery 15 minReceiptPipeline.processPending(20)
budget-reconcilenightly 02:30ReconciliationService.reconcilePending()
budget-backupnightly 02:00pg_dump plus the image volume
budget-alertsdaily 07:00AlertService.runDaily()
budget-digestSunday 06:00DigestService.weekly()

Sunday 06:00 keeps clear of the 05:00 personal Gmail purge and lands inside the 4 to 6am window when you are actually up.

10Backup and recovery

This is your own data rather than a client's, so losing it carries more weight.

  • Nightly pg_dump to ops/backups/budget/, same pattern as the AM785 database dumps, with 30 days retained.
  • The receipt image volume is archived on the same schedule. Images cannot be reproduced; the database can at least be rebuilt from CSV re-imports, but a lost photo is lost.
  • One offsite copy. A local-only backup does not survive the thing most likely to destroy the server.
  • A restore is tested once, at build time, and written down. An untested backup is a guess.

11Testing

The layering exists so the valuable tests need no database and no model.

Under testWhy it earns a test
Money.allocateSplitting must never lose or invent a cent. Property test: for any total and weights, the parts sum to the total exactly
Transaction.splitDouble-counting is the worst possible bug in a ledger
Receipt.reconcilesThe gate protecting every number in the system
Rule.matches and specificityThe CHICKEN against CHICKEN FEED case, and normalization edge cases
MerchantString.normalizedFed by real Ally description strings captured during the first import
CategorizationEngineChain order and threshold behavior, using fake strategies
Projection.exceedsByThe dollar floor and the percentage threshold must both bind, or small categories generate noise
AlertService.shouldFireEvery suppression rule, especially the once-per-month guarantee and the date window. A duplicate alert is the bug that gets the app muted
DriftSignal.isAnomalousMust stay silent while history is thin, otherwise the first months are all false positives
AllyCsvDialectAgainst a real exported file, including the duplicate-balance edge case

Vitest, matching the hub dashboard's setup. The vision extractor is tested against saved fixture images and their known-correct output, so model behavior can be re-checked after any model upgrade without re-photographing anything.

12Build phases

Sequenced so something usable exists early. A half-finished app is worse than the working spreadsheet, and this budget has been abandoned four times already, so shipping a small complete thing beats a large incomplete one.

Changed from draft 1

The feedback loop has moved out of a late phase and into Phase 1. A ledger with no feedback is a filing cabinet, and shipping the capture half on its own would deliver exactly the outcome the awareness objection predicts: less typing, nothing gained.

01

The ledger and the feedback loop

Schema and migrations. Domain layer with its tests, including SpendingPace, Projection, and DriftSignal. Drizzle repositories. Ally CSV import. Rules engine with a seeded starter set. Review queue. Current-month view with the burn line. Daily glance screen. InsightService and AlertService with the full suppression rule set. Self-hosted ntfy and push alerts on your phone. Cloudflare Access, tunnel, Dokploy, both branches.

Plus the migration: three years of history imported from the sheet. This is what makes drift detection work from day one, because a median needs a past. It also means the app opens with populated charts instead of an empty state.

Done when: you can drop an Ally CSV in, see a correctly categorized month, and get a phone alert the first time a category projects over.

02

Receipts

Image store, upload route, Ollama extractor with both models, the pipeline state machine, arithmetic gate, escalation, reconciliation against bank lines, receipt drill-down in the UI.

Done when: photographing a Walmart receipt produces separate grocery, cleaning, and pet lines that reconcile to the single bank charge.

03

Depth

The full trends screen, budget suggested from trailing medians, sinking funds for lumpy costs, the Sunday digest, and the optional discretionary-confirmation setting.

Done when: the system proposes next month's budget from what you actually spend, and the Food line stops being a work of fiction.

04

Optional: automatic bank pull

SimpleFIN Bridge at $15/year replacing the manual CSV download, feeding the identical importer. Deliberately last, because the importer is built source-agnostic, so this changes nothing downstream.

Only worth doing if the monthly download turns out to be the step you forget.

13Open decisions

These need answers from you. Most are small; the first one is large.

QuestionWhy it mattersBlocks
Is Mariel switching? It is a joint budget. The best-built app fails if one of you will not open it. If she would rather stay in a spreadsheet, the better plan is the restructured sheet plus the receipt pipeline against a tool she already knows Everything
Food against Groceries The distinction is undefined today, and the two are budgeted at $1,074 against $1,312 of actual spend. Merge them, or define the line (eating out against supermarket) before importing history Phase 1
Self-host ntfy, or stay on ntfy.sh? Budget alerts carry your spending figures. The public relay sees them in plaintext with the topic string as the only protection. Self-hosting is the recommendation and reuses your existing tunnel pattern, at the cost of one more service to keep running Phase 1 alerts
Which phones? Both of you need the ntfy app. It covers iOS and Android, but the self-hosted iOS path has an extra configuration step worth confirming before committing to it Phase 1 alerts
Any card outside Ally? Changes this from one account to two. Free either way on manual import, and inside SimpleFIN's 25-institution allowance Phase 1
Is matthewmcmanness.com in your Cloudflare account? Needed for the DNS record and the Access application. I have not verified the zone is there Phase 1 deploy
Streaming against Entertainment Netflix and Hulu were Streaming through 2024 and Entertainment from 2026. Pick one and backfill, or three years of history will not compare History import
The missing mortgage January and February 2026 have no mortgage payment logged; March has $900. Either it is paid from another account or two months are understated. Worth resolving before that history becomes the basis for budget suggestions History import
The honest risk

The failure mode for this project matches the failure mode for the spreadsheet: enthusiasm, three weekends of work, then an abandoned half-built thing. Phase 1 is scoped to be complete and useful on its own for exactly that reason. If phases 2 and 3 never happen, phase 1 still beats what exists today.