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."
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.
| Component | Choice | Notes |
|---|---|---|
| Hostname | budget.matthewmcmanness.com | Personal data on the personal domain, deliberately separate from RWS and client infrastructure |
| Ingress | Named Cloudflare tunnel budget-app | Outbound dial only. No inbound port, no public origin |
| Identity | Cloudflare Access, email policy | Exactly two addresses. Enforced at the edge, before the request reaches the box |
| App | Next.js 14 App Router, TypeScript, Tailwind | House standard; same stack as every other thing running here |
| Database | Dedicated Postgres 16 container budget-postgres | Not published to the host. Reachable only on the Docker network |
| Migrations | Drizzle Kit | Same workflow as the AM785 booking system |
| Vision | Ollama at host.docker.internal:11434 | qwen3-vl:8b primary, gemma4:26b escalation |
| Images | Docker volume, served through an authenticated route | Never a public static path |
| Deploy | Dokploy apps budget-prod and budget-staging | Branches Production and staging, per the standing git rules |
| Charts | Recharts | Boring, 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.comandMarielDryton@gmail.com. Anything else never reaches the origin. - Origin verification anyway. Middleware validates the
Cf-Access-Jwt-Assertionheader 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_byand 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.
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.
| Table | Key columns | Purpose |
|---|---|---|
| users | email, display_name | Populated from the Access JWT on first sight |
| accounts | name, institution, kind, external_id, last_imported_at | Ally checking, Ally savings, any credit card |
| categories | name, slug, parent_id, kind, archived, sort_order | Hierarchical. kind is expense, income, or transfer |
| transactions | account_id, date, amount_cents, description, merchant_norm, category_id, source, external_id, parent_id, is_split, needs_review, review_reason, created_by | The ledger. One row per movement of money |
| receipts | image_path, image_sha256, merchant, purchased_at, subtotal_cents, tax_cents, total_cents, status, extraction_model, attempts, raw_response, matched_transaction_id | One row per photo |
| receipt_line_items | receipt_id, line_no, raw_text, name, amount_cents, category_id, confidence, matched_rule_id | The itemization. This is what makes cleaning separable from groceries |
| rules | field, match_type, pattern, pattern_norm, category_id, priority, source, hit_count, last_hit_at, archived | The learning surface. Grows only from your corrections |
| import_batches | account_id, filename, sha256, row_count, inserted_count, duplicate_count, status | Makes a bad import reversible |
| budget_periods | month | One row per month |
| budget_lines | period_id, category_id, planned_cents, rollover_enabled | Planned amounts |
| sinking_funds | category_id, target_cents, monthly_cents, balance_cents | For lumpy costs: car insurance, vet, home repair |
| alerts | month, category_id, kind, projected_cents, budget_cents, median_cents, fired_at, acknowledged_at, acknowledged_by | Alert 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_log | actor_email, action, entity, entity_id, before, after, at | Append-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_idis 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 = trueis 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.
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.
| Method | Takes | Returns | Behavior |
|---|---|---|---|
| fromCents | number | Money | Static. Throws on non-integer |
| fromDecimal | string | number | Money | Static. Parses "12.31", rounds half-even, throws on NaN |
| plus / minus | Money | Money | New instance |
| times | number | Money | Rounds half-even |
| negated / abs | nothing | Money | Sign operations |
| allocate | number[] weights | Money[] | 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 / isNegative | Money | boolean | Comparison |
| toDecimalString | nothing | string | "12.31" for display |
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.
| Method | Takes | Returns | Behavior |
|---|---|---|---|
| fromISO | string | DateOnly | Static. Strict "YYYY-MM-DD" |
| fromJsDate | Date, tz | DateOnly | Static. Resolves the wall-clock date in the given zone |
| today | tz = America/Chicago | DateOnly | Static |
| iso / monthKey | nothing | string | "2026-08-04" and "2026-08" |
| firstOfMonth / addDays | number | DateOnly | Navigation |
| isBetween | DateOnly, DateOnly | boolean | Inclusive |
| compare | DateOnly | -1 | 0 | 1 | For sorting |
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.
| Method | Takes | Returns | Behavior |
|---|---|---|---|
| of | string | MerchantString | Static factory, keeps the raw value |
| normalized | nothing | string | Uppercase, strip punctuation, collapse whitespace, drop store numbers, drop trailing city and state, strip processor prefixes such as SQ * and POS DEBIT |
| tokens | nothing | string[] | Normalized words, for partial matching |
One movement of money. The aggregate root for the ledger.
| Method | Takes | Returns | Behavior |
|---|---|---|---|
| create | TransactionProps | Transaction | Static. Validates amount non-zero, date present, account exists |
| assignCategory | categoryId, Provenance | void | Sets the category and records how it was decided: rule, history, model, or human. Clears the review flag when the provenance is confident |
| flagForReview | reason: string | void | Sets needs_review with a human-readable reason |
| resolveReview | nothing | void | Clears the flag |
| split | SplitPart[] | 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 |
| isDuplicateOf | Transaction | boolean | True on matching external_id, or on same date, same amount, and same normalized merchant |
| signedAmount | Category | Money | Normalizes direction so expenses are negative and income positive regardless of how the bank signed it |
A photographed receipt and its extracted line items. Owns the arithmetic gate that decides whether an extraction can be trusted.
| Method | Takes | Returns | Behavior |
|---|---|---|---|
| create | imagePath, sha256, uploadedBy | Receipt | Static. Status PENDING |
| attachExtraction | ExtractionResult, model | void | Populates merchant, date, totals, line items. Increments attempts, records which model produced it, moves to EXTRACTED |
| reconciles | tolerance = 2 cents | boolean | The 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 |
| discrepancy | nothing | Money | Subtotal minus the item sum, for the failure message |
| unresolvedItems | nothing | LineItem[] | Items no rule matched, which is what lands in the review queue |
| categoryTotals | nothing | Map<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 |
| toTransactionDrafts | accountId | TransactionDraft[] | One draft per category rather than per item, so the ledger stays readable. Each draft keeps its item list for drill-down |
| markFailed / markPosted | reason or txIds | void | Terminal state transitions |
A deterministic mapping from text to category. The reason review work shrinks over time instead of repeating forever.
| Method | Takes | Returns | Behavior |
|---|---|---|---|
| manual | field, matchType, pattern, categoryId, actor | Rule | Static. Hand-authored from the rules screen |
| learned | Correction | Rule | Static. Derived from a review-queue correction, marked source = learned |
| matches | string | boolean | Normalizes both sides, then applies exact, contains, or regex per match_type |
| specificity | nothing | number | Longer patterns and exact matches score higher. Resolves CHICKEN against CHICKEN FEED so the more specific rule wins |
| conflictsWith | Rule | boolean | True when patterns overlap but categories disagree. Surfaced to you rather than resolved silently |
| recordHit | nothing | void | Increments the counter so dead rules become visible |
Answers whether a category is ahead of the calendar. Pure arithmetic over a budget, a spend total, and a position in the month.
| Method | Takes | Returns | Behavior |
|---|---|---|---|
| of | spent, budget, dayOfMonth, daysInMonth | SpendingPace | Static. Throws when the day is outside the month |
| burnRatio | nothing | number | Fraction of budget consumed |
| calendarRatio | nothing | number | Fraction of the month elapsed |
| isAhead | tolerance = 0.05 | boolean | Burn is running faster than the calendar by more than the tolerance |
| dailyRate | nothing | Money | Average spend per elapsed day, the input to a projection |
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.
| Method | Takes | Returns | Behavior |
|---|---|---|---|
| fromPace | SpendingPace | Projection | Static. Linear extrapolation of the daily rate across remaining days |
| projectedTotal | nothing | Money | Estimated month-end figure |
| overspend | nothing | Money | Projected total minus budget. Zero or negative means on track |
| daysRemaining | nothing | number | Used in the alert copy so the message states the action window |
| exceedsBy | ratio, floor: Money | boolean | True only when both the percentage and the absolute dollar floor are breached, which is what keeps small categories quiet |
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.
| Method | Takes | Returns | Behavior |
|---|---|---|---|
| compute | current: Money, history: Money[] | DriftSignal | Static. Median rather than mean, so one holiday month does not distort the baseline |
| median | nothing | Money | The trailing normal, also drawn as the band on the trend chart |
| deltaRatio | nothing | number | Signed fraction above or below normal |
| isAnomalous | threshold = 0.30, minSamples = 4 | boolean | False when history is too thin to have a normal yet, which prevents noise in the first months |
Supporting entities.
| Method | Takes | Returns | Behavior |
|---|---|---|---|
| LineItem.applyCategorization | CategorizationResult | void | Stores category, confidence, and the rule that decided it |
| LineItem.isUnresolved | nothing | boolean | No category, or confidence below threshold |
| Category.isExpense / isIncome | nothing | boolean | Drives sign normalization |
| Category.path | nothing | string[] | Ancestor chain for display, such as Home then Cleaning |
| BudgetLine.variance | Money actual | Money | Planned 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.
| Method | Takes | Returns | Used by |
|---|---|---|---|
| findById | id | Transaction | null | Everything |
| findByPeriod | month, opts | Transaction[] | BudgetService, dashboard |
| findNeedingReview | limit, offset | Transaction[] | ReviewService |
| findByExternalIds | accountId, string[] | Map<string, Transaction> | CsvImportService, for bulk dedupe in one query |
| findCandidateMatches | date, amount, windowDays | Transaction[] | ReconciliationService, matching a receipt to its bank line |
| sumByCategory | from, to | Map<string, Money> | BudgetService, trends |
| save / saveMany | Transaction[] | void | Everything. saveMany is transactional |
| Port | Key methods | Production adapter |
|---|---|---|
| RuleRepository | findAllActive, save, findConflicts | Drizzle |
| ReceiptRepository | findById, findByStatus, findBySha256, save | Drizzle |
| CategoryRepository | findAll, findBySlug, save | Drizzle, cached per request |
| AccountRepository | findAll, findById, touchImported | Drizzle |
| BudgetRepository | findPeriod, savePeriod, findLines | Drizzle |
| ImageStore | put(bytes, sha), get(path) | Docker volume on disk |
| VisionExtractor | name, extract(image, prompt) | Ollama, one instance per model |
| Mailer | send(to, subject, body) | gws-personal via the draft helper |
| Clock | today, now | System, frozen in tests |
Adapters
Constructed once per model. Two instances exist: primary and escalation.
| Method | Takes | Returns | Behavior |
|---|---|---|---|
| constructor | baseUrl, model, opts | instance | Stores endpoint, model name, temperature 0.1, context size |
| extract | Buffer, ExtractionPrompt | ExtractionResult | Base64-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.
Ally exports Date, Time, Amount, Type, Description, Balance. Other banks get their own dialect class without touching the importer.
| Method | Takes | Returns | Behavior |
|---|---|---|---|
| detect | string[] headerRow | boolean | Matches Ally's column signature so the right dialect is chosen automatically |
| parse | string[][] rows | RawTxn[] | Maps columns, parses amounts via Money.fromDecimal, dates via DateOnly |
| externalIdFor | RawTxn | string | Ally provides no stable transaction id, so this hashes date, amount, description, and running balance. Balance makes two identical same-day charges distinguishable |
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? }
| Order | Strategy | Decides on | Confidence |
|---|---|---|---|
| 1 | RuleStrategy | The rules table, sorted by priority then specificity | 1.00 |
| 2 | MerchantHistoryStrategy | How this exact normalized merchant was categorized before, needing 3 or more samples at 90% agreement | = agreement |
| 3 | LlmStrategy | Local model, given the item text and the allowed category list | 0.60 fixed |
| 4 | UncategorizedStrategy | Always answers. Terminal | 0.00 |
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.
| Method | Takes | Returns | Behavior |
|---|---|---|---|
| constructor | CategorizationStrategy[] | instance | Array order is the chain order |
| categorize | CategorizationRequest | CategorizationResult | Walks the chain, returns the first result meeting that strategy's threshold |
| categorizeMany | CategorizationRequest[] | CategorizationResult[] | Loads the rule set once and shares it across the batch. Used by CSV import and receipt processing |
Turns a human correction into a permanent rule. This is the mechanism that makes review work decay toward zero.
| Method | Takes | Returns | Behavior |
|---|---|---|---|
| proposeFrom | Correction | Rule | null | Derives 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 |
| learn | Correction | { rule, conflicts } | Checks for conflicting rules before saving and returns them for the UI to resolve. Never silently overwrites an existing rule |
Services
Owns the receipt state machine. Constructed with the image store, both extractors, the engine, and the reconciler.
| Method | Takes | Returns | Behavior |
|---|---|---|---|
| ingest | Buffer, uploadedBy | Receipt | Hashes 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 |
| process | receiptId | Receipt | The state machine, described below |
| processPending | limit | ProcessSummary | Called 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.
| Method | Takes | Returns | Behavior |
|---|---|---|---|
| import | Buffer, filename, accountId, actor | ImportResult | Selects 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 |
| revert | batchId, actor | void | Deletes 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.
Prevents double-counting: the bank says one Walmart charge of $122.79, the receipt says four categories totalling the same amount.
| Method | Takes | Returns | Behavior |
|---|---|---|---|
| matchReceiptToBank | Receipt | MatchOutcome | Looks 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 |
| reconcilePending | nothing | ReconSummary | Nightly sweep. Catches receipts photographed before the charge posted, which is the common case |
The correction loop. The single most important service for whether this survives past month two.
| Method | Takes | Returns | Behavior |
|---|---|---|---|
| queue | opts | ReviewItem[] | One unified list: uncategorized transactions plus unresolved receipt items. Grouped so identical raw text collapses into a single decision covering many rows |
| applyCorrection | Correction | CorrectionOutcome | Sets 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 |
| bulkApply | ids[], categoryId, actor | CorrectionOutcome | One 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.
| Method | Takes | Returns | Behavior |
|---|---|---|---|
| periodSummary | month | PeriodSummary | Planned against actual per category, totals, savings rate, count needing review |
| rollingAverage | categoryId, months | Money | Answers "what do we normally spend on this", which the current sheet cannot do at all |
| variance | month | VarianceRow[] | Sorted by absolute overspend, so the worst line is first |
| suggestBudget | month, lookbackMonths | BudgetLine[] | Proposes each category from its trailing median. This is the direct fix for Food being budgeted at $74 against $667 of actual spend |
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.
| Method | Takes | Returns | Behavior |
|---|---|---|---|
| pace | month, categoryId | SpendingPace | Spend to date against budget and calendar position |
| projection | month, categoryId | Projection | Month-end estimate from the current rate |
| drift | month, categoryId, lookback = 6 | DriftSignal | This month against the trailing median |
| monthPulse | month | Pulse | The single headline: net position, total pace, worst category, count needing review |
| dailyGlance | date | GlanceSummary | Yesterday's transactions, month-to-date against pace, the one category furthest off track. Feeds the thirty-second phone screen |
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.
| Method | Takes | Returns | Behavior |
|---|---|---|---|
| evaluate | month | AlertCandidate[] | Runs projection and drift across every budgeted category. Pure detection, no side effects |
| shouldFire | AlertCandidate | boolean | Applies 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 |
| dispatch | AlertCandidate[] | DispatchSummary | Composes the message, sends through the Notifier port, records the row so it cannot repeat |
| acknowledge | alertId, actor | void | Called by the notification's action button. Silences that condition for the rest of the month |
| runDaily | nothing | DispatchSummary | The timer entry point: evaluate, filter, dispatch |
| Method | Takes | Returns | Behavior |
|---|---|---|---|
| Notifier.notify | PushMessage | void | The interface. A logging fake is used in tests so no message escapes |
| NtfyNotifier.constructor | serverUrl, topic, token | instance | Points at the self-hosted instance with a per-device access token |
| NtfyNotifier.notify | PushMessage | void | POSTs 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.
| Method | Takes | Returns | Behavior |
|---|---|---|---|
| DigestService.weekly | nothing | void | The 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.fromRequest | Request | AuthContext | Static. Validates the Access JWT against the cached team JWKS and the app audience. Throws on failure |
| AuthContext.email / user | nothing | string, User | Attribution 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.
"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.
| Signal | Question it answers | Computation | Where 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.
| Property | Uptime Kuma today | Budget alerts |
|---|---|---|
| Server | public ntfy.sh | Self-hosted, behind the tunnel |
| Auth | none, topic is the only secret | Access token per device |
| Priority | 5, maximum | 4 for projections, 3 for drift |
| Actions | none | "Open budget" deep link, "Acknowledge" HTTP action |
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.
| Chart | The question it answers | Screen |
|---|---|---|
| Month-to-date burn line against budget pace | Am I on track today | / |
| Category bars, actual against budget, sorted by overspend | What is the single worst line this month | / |
| Twelve-month trend per category with a median band | Is this month unusual, or is this simply what we spend | /trends |
| Income against expense by month | Are we net positive, and for how many months running | /trends |
| Rolling three-month average per category | What is normal, so budgets get set from reality | /trends |
| Cumulative surplus or deficit for the year | The 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
| Screen | Route | What 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 | /review | The 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 | /today | The 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 | /trends | Twelve-month lines per category with median bands, rolling averages, income against expense, cumulative surplus for the year |
| Transactions | /transactions | Filterable table, manual split, drill into a receipt's items |
| Rules | /rules | Every 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 | /import | Drop 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.
| Timer | When | Calls |
|---|---|---|
| budget-receipts | every 15 min | ReceiptPipeline.processPending(20) |
| budget-reconcile | nightly 02:30 | ReconciliationService.reconcilePending() |
| budget-backup | nightly 02:00 | pg_dump plus the image volume |
| budget-alerts | daily 07:00 | AlertService.runDaily() |
| budget-digest | Sunday 06:00 | DigestService.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_dumptoops/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 test | Why it earns a test |
|---|---|
| Money.allocate | Splitting must never lose or invent a cent. Property test: for any total and weights, the parts sum to the total exactly |
| Transaction.split | Double-counting is the worst possible bug in a ledger |
| Receipt.reconciles | The gate protecting every number in the system |
| Rule.matches and specificity | The CHICKEN against CHICKEN FEED case, and normalization edge cases |
| MerchantString.normalized | Fed by real Ally description strings captured during the first import |
| CategorizationEngine | Chain order and threshold behavior, using fake strategies |
| Projection.exceedsBy | The dollar floor and the percentage threshold must both bind, or small categories generate noise |
| AlertService.shouldFire | Every suppression rule, especially the once-per-month guarantee and the date window. A duplicate alert is the bug that gets the app muted |
| DriftSignal.isAnomalous | Must stay silent while history is thin, otherwise the first months are all false positives |
| AllyCsvDialect | Against 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.
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.
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.
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.
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.
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.
| Question | Why it matters | Blocks |
|---|---|---|
| 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 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.