Spec / Plan

Sam's live working doc + your inbox of ideas to fold in.

Inbox

Master plan · from docs/PLAN.md

Plan — budget.msimoes.dev (working name: OurBudgetApp)

The live working plan. Read this at the start of every session.

Last updated: 2026-05-30 (post deep architecture review)


In progress

  • Verify webhook fix lands — Matt needs to visit Settings → "⚙ Repair webhooks" once. After that, watch for organic tx arriving without manual sync. If still no activity for 24h, deeper investigation.

Master plan v2 — committed work (2026-05-29 / 30 deep review)

Three subagents independently reviewed: architect (validated foundation), Cloudflare-docs research (confirmed canonical 2026 patterns), sanity-check (flagged conflicts, missing pieces). Major shifts from v1:

  • One Worker (Astro Static Assets + Hono router) — drop Cloudflare Pages entirely. Pages is in maintenance; canonical 2026 pattern is Worker + Static Assets.
  • Phase order: 0 → A → B → C → D → E → H → F → G → I → J → K. Phase H (reimbursements) moved before F+G because H changes the math F+G depend on.
  • Email batching: unified outbox + daily batcher per person — max 1 attention email/day; Sunday Sit-Down + Monthly Trends are separate ritual emails. Telegram stays real-time.
  • Email vendor: Cloudflare send_email binding (consolidate on CF stack; if deliverability bites, swap to AgentMail).
  • App name: OurBudgetApp (in-app brand only; URL stays budget.msimoes.dev).
  • Schema specifications locked for ~12 tables (section after phases).

Phase 0 — Architecture migration (BEFORE everything else)

Goal: consolidate onto one Cloudflare Worker. Drop Pages entirely. ~7-8 hours of Claude time, parallelizable.

  1. Scaffold Worker entry alongside existing Pages. New worker/index.ts with Hono, mount /api/health. Keep Pages live throughout. Verify with wrangler dev.
  2. Port _middleware.ts → Hono middleware. CF Access JWT extraction + JWKS verification via @hono/cloudflare-access. JWKS cached in KV with 1h TTL. Verify aud + iss claims.
  3. Port functions/_lib/*worker/lib/*. Mechanical file moves; types stay.
  4. Port API handlers in dependency order: health → whoami → categories → accounts → tx → plaid (sync/webhook/exchange/diagnostics/repair-webhook) → telegram/webhook → goals → todos → dashboard → plan/inbox. Each becomes app.get/post(...).
  5. Move functions/tx/[id].ts + functions/accounts/[id].ts to Astro SSR routes with @astrojs/cloudflare adapter in server mode. Kills _lib/html.ts's inline-HTML string concatenation entirely.
  6. Add assets binding to wrangler.jsonc with run_worker_first = true. Astro builds to ./dist, Worker serves it.
  7. Cut DNSbudget.msimoes.dev from Pages → Workers route. Re-point CF Access app. Verify Matt AND Rita can both log in.
  8. Re-point Plaid + Telegram webhooks to new Worker URL. Run /api/plaid/repair-webhook post-cut.
  9. Migrate to wrangler.jsonc (canonical 2026 config format).
  10. Bump compatibility date to 2026-05-01 + nodejs_compat flag.
  11. Tear down Pages project once green for 48h. D1 untouched throughout.

Critical risk mitigations BEFORE step 100:

  • Run wrangler d1 export budget --remote --output=backup-pre-migration.sql; verify restore into scratch DB.
  • DO migration class name (SweeperDO) locked at first deploy — never rename without explicit renamed_classes entry.
  • Cut DNS Saturday morning MT (low tx volume, before Sunday Sit-Down).

Phase A — Security + foundational infrastructure

Shipped 2026-05-29 (security worker):

  1. Plaid webhook replay protectionplaid_seen_webhooks + INSERT OR IGNORE. Migration 0015.
  2. Encrypt Plaid access tokens at rest — AES-GCM via BUDGET_AT_KEY Worker secret. Migration encrypts in place. NOT shipped yet.
  3. Centralized auth middleware — 403 if actorFromRequest === "unknown" on /api/* except webhooks + health. Moves to Hono middleware in Phase 0.
  4. Verify CF Access JWT against JWKS — via @hono/cloudflare-access (3 lines).
  5. Append-only triggers on transaction_events — Migration 0016.
  6. Log scrubbing helperlogError/logInfo strip access_token/Bearer/hex.
  7. Verify *.pages.dev Access policy matches custom domain (operational).
  8. Pruning cron — daily prune of plaid_seen_webhooks + telegram_seen_updates + request_idempotency > 24h.

New from architect review (load-bearing additions):

  1. D1 nightly backup to R2 — daily cron exports D1 to versioned R2 object (backups/budget-YYYY-MM-DD.sql). 30-day rolling retention. Single most important safety net.
  2. Workers Analytics Engine for observabilitylogEvent(env, scope, fields) helper writes structured events. Free, SQL-queryable.
  3. Rate limiting on webhook endpoints — CF RateLimit binding, 10 req/s on Plaid + Telegram webhooks.
  4. request_idempotency table + Idempotency-Key support — every mutating endpoint accepts optional Idempotency-Key header; 24h TTL. Magic-link ack endpoints ALWAYS idempotent (double-tap, email-scanners).
  5. SECURITY.md with secret rotation runbook — dual-key overlap window for BUDGET_AT_KEY + BUDGET_EMAIL_SIGNING_KEY. Annual cadence or on suspected leak.

Schema foundations (ship as Phase A migrations):

  1. accounts.owner columnTEXT NOT NULL DEFAULT 'joint', CHECK IN ('matt', 'rita', 'joint'). Foundation for personal accounts (UI deferred).
  2. transactions.status enum + backfillstatus TEXT NOT NULL DEFAULT 'pending', CHECK in ('pending', 'approved_incomplete', 'reimburse_pending', 'final', 'discuss', 'dismissed'). Six states, sixth (reimburse_pending) added per sanity review to handle reimbursement-pending tx — see Phase H. Backfill: fully approved + categorized → final; discuss → discuss; rest → pending.
  3. transactions.contribution_actor columnTEXT NULL, CHECK NULL OR IN ('matt','rita'). Foundation for Phase I.

Phase B — Code/architecture foundations

  1. vitest + @cloudflare/vitest-pool-workers + first ~15 tests:
    • Hono routes via app.request() — middleware auth, 403 on unknown, 200 on matt/rita
    • actions.ts state machine — approve/discuss/unapprove/reimburse × both/either × actor matrix
    • actorFromAccessEmail edge cases
    • verifyPlaidJws against golden fixture in goldens/
    • AES-GCM wrap/unwrap roundtrip
    • D1 migrations apply cleanly from empty (CI smoke)
    • SweeperDO alarm fires (DO test harness)
  2. Centralize DB row types in worker/types/db.ts.
  3. Extract frontend helpers to /public/js/dom.jsel, clear, fmtMoney, fmtAmount, fmtDate.
  4. Move inline JS from tx detail to /public/js/tx-detail.js (or absorb into Astro SSR per Phase 0 step 104).
  5. Request body validation — zod schemas next to handlers.
  6. Validate env at request boundarygetEnv(ctx) validates required secrets per request.
  7. .dev.vars.example — shipped 2026-05-29.
  8. migrations_dir = "migrations" + predev hook — shipped 2026-05-29.
  9. CI on GitHub Actionstsc --noEmit + vitest run + wrangler deploy --dry-run on PR. Workers Builds auto-deploys per branch.
  10. Queue between Plaid webhook and Telegram fanout — webhook writes to D1 + enqueues {tx_id} to telegram-fanout queue. Consumer Worker posts to Telegram. Dead-letter queue for malformed payloads.
  11. Queue for email fanoutemail-fanout queue + consumer.

Phase C — UX safe-ships (parallel with B)

  1. Replace emoji nav icons with monochrome Lucide SVG — home/checklist/target/tag/gear.
  2. Add utility CSS classes.input, .input-row, .stack-sm/md, .cluster, .section, .btn-ghost.
  3. prefers-reduced-motion override on shimmer keyframes.
  4. Tabular figuresfont-feature-settings: "tnum" 1; on all money displays.
  5. Hide sync diagnostics inside <details> on settings — closed by default.
  6. Replace full-width "⟳ Sync now" with inline text link.
  7. Time-aware greeting — "Tuesday evening, Matt."
  8. Audit money-color contrast at small sizes.
  9. Typeface system — Switzer (UI sans) + Geist Mono (numbers) + Fraunces (single h1 only). Self-hosted from public/fonts/. ~80kb total.
  10. Account-card top-row alignment fix (Option A) — absolute-position logo top-right; eyebrow row becomes plain text.
  11. Split brand.css into 5 filesDEFERRED until any file crosses 400 LOC (per sanity review).

Phase D — Approval, categorization, recurring patterns

  1. Sweeper Durable Object (single DO, SQLite-backed, one repeating alarm every 5 min). Responsibilities:

    • (a) 24h re-nudge any pending tx
    • (b) daily 8am Other-category overflow check (>10 → Telegram nudge to refine)
    • (c) daily 7am email batcher tick (per Phase F #59)
    • (d) monthly day-1 8am first-of-month Telegram post (per Phase I #75)
    • (e) projection refresh safety net at 3am (per Phase G #62)
  2. Category system redesign — Option B (8 top + Other = 9):

    1. Home (rent, utilities, internet, repairs, furniture, household supplies)
    2. Food (groceries, restaurants, coffee/takeout, alcohol)
    3. Healthcare (medical, IVF, pharmacy, dental, insurance)
    4. Personal & lifestyle (gym, haircuts, clothing, gifts, personal care)
    5. Pets (Boo + Ziggy)
    6. Transportation
    7. Travel
    8. Money flow (income, transfers, savings, CC payments — exclude_from_totals)
    9. Other — temporary catchall; sweeper DO surfaces overflow nudges

    Migration includes category_migration_v2(old_id, new_top_id, new_sub_id) mapping table. Existing tx remapped via the mapping table; no manual re-categorization.

  3. Approval state machine — final requires category + approval + note (with reimbursement-pending exception):

    • Tx becomes final only when: category_id set + approval flags satisfied per mode + note present + no active Reimburse flag (or active Reimburse settled)
    • Recurring-matched tx exempt from note requirement (auto-cat carries implicit context)
    • Everything else needs a real note. Typing is the conversation.
    • Sixth state reimburse_pending added: tx approved (cat + note + both-approval) but awaiting reimbursement settlement. Final approval blocked until settlement. From Phase H.
    • Pending count on home = anything not final. Inbox-zero only when count = 0.
  4. Hierarchical category browse — both surfaces. Web: top-only by default, click to expand subs. Telegram bot: 📁 → tops-only keyboard (9 buttons) → tap top → subs + "Use [Top] directly" + "↩ Back". Two taps minimum. The original "top-6 most-used" Telegram pattern is KILLED (per sanity review — hierarchical only).

  5. Inline "+ New subcategory" from any tx — both surfaces. New subs default approval_mode = both.

  6. Recurring pattern system (rule-based): user toggles "recurring + auto-cat" on a tx. Stores merchant fingerprint + amount range (±5%) + day-of-month range (±3) + source account + category. Incoming tx → match all four → auto-categorize (NOT auto-approve). Telegram card shows "✨ Auto-cat as — confirm or change."

    • AI fallback for unstable merchants — deferred to future ideas.
  7. Retire bill-reminder goals; recurring patterns single source of truth. Existing bill-reminders migrate one-time to patterns. Home hero adds "expected this month" line from patterns.

Phase E — Telegram bot expansion

  1. Category-from-Telegram — 📁 button per tx card → hierarchical picker (#42). Two taps to categorize.
  2. Reply-to-add-note — any Telegram reply to a bot's tx card saved as a note on that tx.
  3. Slash commands in private chat/pending, /balances, /month, /find <merchant>.

NOT shipping (rejected): emoji reactions as approve signal, Telegram Web Apps, per-tx DOs.

Phase H — Reimbursement layer (MOVED before F+G — changes math F+G depend on)

The piece v1's name pointed at ("couples accountability + reimbursement app") but never built.

  1. Third decision state on every tx — Reimburse (alongside Approve, Discuss). When marked:

    • Tx status → reimburse_pending (per #41)
    • Approval mode forcibly elevated to both regardless of mode
    • Earlier solo approval (if under either-mode) logged but no longer satisfies the condition
  2. Per-person split on reimbursement — modal: "How much from each?" Presets: 50/50, Matt 100%, Rita 100%, custom. Total doesn't have to equal tx (partial-joint scenarios).

  3. Settlement detection — manual now, auto later:

    • v1: one of you taps "I sent it" + optional date/method note ("Venmo 5/14")
    • Future (with personal accounts): Plaid sees inbound transfer to joint ±3 days matching expected amount → auto-flag settlement candidate
    • Partial payments tracked
  4. Email cadence — via outbox batcher (Phase F #59):

    • Initial Reimburse-flag → email entry to outbox (delivered next batch)
    • 48h delay before daily reminder entries start
    • Unlimited daily reminders until settled or cancelled
    • Sunday Sit-Down includes "Outstanding reimbursements" section
  5. Cancellation path — UI back to Approve/Discuss. Logged in audit. Stops reminders.

  6. Outstanding-reimbursements badge — home hero + /reimbursements page.

Phase F — Weekly Sit-Down email ritual

  1. Cloudflare send_email binding setup — configure DKIM/SPF for msimoes.dev. Sender: budget@msimoes.dev.

  2. Weekly Sit-Down digest email — Cron Trigger Sunday 9am MT (UTC = 0 15 * * 0). HTML email with:

    • Total spent (vs 4-week avg, ▲/▼ delta)
    • Top 3 categories with vs-typical delta (informational, no real-time alerts)
    • Inbox count (unresolved tx, Other count)
    • Goals delta (per goal weekly contribution)
    • Outstanding reimbursements section (Phase H)
    • Contribution status (Phase I)
    • Buttons: ✓ All good / 💬 Needs discussion
    • Recipients: m.seeroy@gmail.com + r27simoes@gmail.com
    • Subject: Weekly Sit-Down — Week of May 19
  3. Magic-link ack endpoints/week/<digest_id>/ack/<actor>/<decision>?sig=<HMAC>. HMAC via BUDGET_EMAIL_SIGNING_KEY. Idempotent (#12).

  4. Digest state machine:

    • pending_acks → both "All good" → resolved
    • pending_acks → either "Needs discussion" → in_discussion + immediate discussion-followup email
    • in_discussion → both "Approve" on followup → resolved
    • Lapsed after 2 follow-ups → lapsed; next Sunday digest notes at top
    • Old digest buttons go inert once in_discussion
  5. email_outbox table — unified email batcher (the single most important addition from sanity review):

    • Every email trigger from ANY phase writes a row, not sent immediately
    • Daily 7am cron per person: SELECT unsent outbox rows for that recipient
      • 0 → no email
      • 1 → standalone email with clear subject
      • 2+ → batched "X items need your attention" email with each item as a section + own action buttons
    • Exceptions (their own emails, NOT batched): Sunday Sit-Down (#56), Monthly Trends (Phase K)
    • Telegram NOT batched — real-time. Email-only batching.
    • Strict batching (Option A) — no real-time exceptions, not even discussion-followup. Discussion email lands next batch.
    • Result: max ~6 emails/person/week worst-case; realistic 1-3.
  6. Follow-up cadence — reduced from 4 to 2 (per sanity review). For any digest > 24h since last contact in pending_acks or in_discussion, queue a follow-up. Max 2 follow-ups (Mon + Wed after Sunday).

  7. Discussion follow-up email — queued to outbox immediately when "Needs discussion" clicked. Single button: "✓ Approve (we discussed)". Subject: Discussion needed — Matt flagged Week of May 19 (subject names flagger).

Phase G — Cash flow & emotional truth (ships AFTER D matures 2-3 weeks)

The most important phase. Matt 2026-05-29: "We need to FEEL that damn we are drawing from savings instead of adding this month." Math exists in service of emotional visibility.

  1. Per-account cash flow projection — materialized table:

    • cash_flow_projections(account_id, for_date, projected_balance_cents, refreshed_at) — one row per account per day-forward, 30 days out
    • Inputs: current balance + matched recurring patterns + reimbursement-aware adjustments. NOT linear projection of variable spend.
    • Event-triggered refresh: Plaid sync batch completes, user categorizes a tx, user marks Reimburse, reimbursement settles
    • Nightly 3am cron as safety net (no-op if already fresh)
    • Realistic ~3-6 refreshes/day; home hero shows "Projected as of 8:42am"
  2. Cross-account "safe to move" — net liquidity = checking − target_safe_threshold − unpaid CC − pending bills through next N days. Single number + 30-day curve.

  3. Drawing-from-savings detection — tracks transfers savings → checking monthly. Distinguishes "scheduled contribution reversal" from "we ran short." Monthly net-position drives mood color.

  4. Predictive alert emails — via outbox + magic-link ack:

    • Positive opportunity (mid-month safe-to-move): dismissible
    • Predictive warning (insufficient projected coverage): both must ack
  5. Emotional visibility on home page hero — mood color:

    • Calm — projected positive, adding to savings
    • Cautious — approaching drawdown territory
    • Drawdown — actively pulled FROM savings this month
  6. Persistent drawdown banner — when in Drawdown state, banner across top of every page: "We drew $750 from savings this month. — Let's talk about why." Stays until both tap "Acknowledged" + add a note. Can't dismiss without note.

  7. Streak resets on drawdownDROPPED per sanity review. Banner IS the consequence; double-punishment fights the emotional-truth ethos. Streak (Item 7 home hero) stays as quiet "days clean of unresolved tx" counter; resets on >48h pending only.

  8. Month-end resolution card — last day of month, distinct from drawdown banner:

    • Banner runs LIVE during the month while in Drawdown state
    • Card is the CEREMONIAL close, regardless of state
    • Net positive → quiet celebration: "Added $1,240 to savings this month ✓"
    • Net negative → sit with it: "Drew $750 net. Here's why" — logged to monthly_truths table

AI consideration deferred. Sam-narrated explanations of the math is the natural future addition.

Phase I — Contribution commitment tracking

Matt's reframe: not lifetime scorekeeping — monthly commitment. Each person has a monthly contribution target to joint checking. Tracks whether each hits the number THIS MONTH.

  1. contribution_targets table — actor, monthly_amount_cents, active_since, archived_at. Toggleable via Settings.

  2. Actor-attributed inflows to joint checking:

    • Rita's: auto-detected via direct deposit pattern (CHILDRENS HOSPITAL payroll merchant) — recurring pattern from Phase D
    • Matt's: attributed at moment-of-action — whoever's logged in when initiating transfer, or manually marked on the inflow
    • Stored on transactions.contribution_actor (column from Phase A #16)
  3. Monthly contribution view — home hero + Sunday Sit-Down email:

    May contributions
    Matt: $2,300 of $2,600 · $300 short · 2 days left
    Rita: $2,600 of $2,600 ✓
    

    Each month its own page. No accumulation. No "who's carrying it" framing.

  4. Reimbursement-contribution integration — active reimbursement owed by Matt adds to his effective monthly target for that month. Reimbursement settled = money to joint = counts toward both.

  5. "Tended together" counter on home page — subtle line: "You and Rita have approved 1,847 transactions together." (Sanity reviewer called it vanity; Matt approved — keeping.)

  6. First-of-month moment:

    • Telegram bot post to household group on the 1st at 8am: "First of May. Last month: $4,120. Fresh start."
    • Website: regular time-aware greeting naturally includes the date. No special 5-day variant (simplified per sanity review).

Phase J — Monthly account target tracking

Matt's reframe: granular per-category anomaly nudges KILLED. Replaced with account-level targets. Aligns with central goal: "keep joint expenses low, improve joint savings."

  1. Per-account monthly target ceilings/floors — Settings → per-account targets (joint CC: $2,000 ceiling; joint checking: minimum balance). Personal accounts same when shipped. Stored in account_thresholds table (unifies what v1 duplicated across Phase G #48 and Phase J #67 per sanity review).

  2. Reimbursement-aware projection input — handled by Phase G's projection (#62). Reimbursement-flagged tx don't trigger ceiling alerts because amount is netted against expected reimbursement inflow.

  3. Threshold alerts — three levels, once per threshold per month:

    • 80% soft: "Joint CC on pace for $1,650 — heading toward your $2,000 target."
    • 100% harder: "$2,150 — $150 over target. Worth talking?"
    • 110% urgent: pairs with Phase G drawdown banner
    • Stored in threshold_alerts_sent to enforce "once per threshold per month"
  4. Alert channel by account type:

    • Joint → Telegram bot to household group (real-time)
    • Personal (when shipped) → email via outbox batcher (deliberate, private)

Phase K — Monthly Trends email

Matt 2026-05-29: "A nice email to see stuff in real numbers."

  1. Monthly Trends email — Cron Trigger 1st of month at 8am MT. Year-to-date totals + on-pace-for-year:

    • Per-merchant top 10 ("$3,799 Chewy YTD, on pace for $6,495")
    • Per-category top 5-10
    • Account-level monthly target performance, trailing 6 months
    • Pure SQL, no AI, deterministic
    • Informational only — no prescriptive copy
  2. Optional /trends page — same data, browsable any time. 30 min extra after email built.


Schema specifications (locked 2026-05-30)

-- Phase A
plaid_seen_webhooks(body_sha TEXT PRIMARY KEY, seen_at INTEGER NOT NULL)  -- ✓ shipped

request_idempotency(
  key TEXT PRIMARY KEY,
  actor TEXT,
  response_json TEXT,
  created_at INTEGER NOT NULL
)

-- Phase A column adds
accounts: + owner TEXT NOT NULL DEFAULT 'joint' CHECK(owner IN ('matt','rita','joint'))
transactions: + status TEXT NOT NULL DEFAULT 'pending'
  CHECK(status IN ('pending','approved_incomplete','reimburse_pending','final','discuss','dismissed'))
transactions: + contribution_actor TEXT
  CHECK(contribution_actor IS NULL OR contribution_actor IN ('matt','rita'))

-- Phase D
recurring_patterns(
  id INTEGER PRIMARY KEY,
  merchant_fingerprint TEXT NOT NULL,
  amount_min_cents INTEGER NOT NULL,
  amount_max_cents INTEGER NOT NULL,
  day_of_month_min INTEGER NOT NULL,
  day_of_month_max INTEGER NOT NULL,
  account_id TEXT NOT NULL,
  category_id INTEGER NOT NULL,
  created_at INTEGER NOT NULL,
  created_by TEXT NOT NULL CHECK(created_by IN ('matt','rita')),
  archived_at INTEGER,
  FOREIGN KEY(account_id) REFERENCES accounts(id),
  FOREIGN KEY(category_id) REFERENCES categories(id)
)

category_migration_v2(
  old_id INTEGER PRIMARY KEY,
  new_top_id INTEGER,
  new_sub_id INTEGER
)

-- Phase F
weekly_digests(
  id TEXT PRIMARY KEY,               -- UUID
  week_start INTEGER NOT NULL,
  week_end INTEGER NOT NULL,
  sent_at INTEGER NOT NULL,
  status TEXT NOT NULL CHECK(status IN ('pending_acks','in_discussion','resolved','lapsed')),
  html_content TEXT NOT NULL,
  discussion_flagger TEXT CHECK(discussion_flagger IS NULL OR discussion_flagger IN ('matt','rita')),
  follow_up_count INTEGER NOT NULL DEFAULT 0
)

digest_acks(
  digest_id TEXT NOT NULL,
  actor TEXT NOT NULL CHECK(actor IN ('matt','rita')),
  decision TEXT NOT NULL CHECK(decision IN ('ok','discuss','approve_after_discussion')),
  email_type TEXT NOT NULL CHECK(email_type IN ('digest','followup','discussion_followup')),
  acked_at INTEGER NOT NULL,
  PRIMARY KEY(digest_id, actor, email_type),
  FOREIGN KEY(digest_id) REFERENCES weekly_digests(id)
)

email_outbox(
  id INTEGER PRIMARY KEY,
  recipient TEXT NOT NULL CHECK(recipient IN ('matt','rita')),
  kind TEXT NOT NULL,                -- 'reimbursement_reminder' | 'predictive_warning' | etc
  ref_id TEXT,                       -- optional FK-like (reimbursement_id, digest_id, etc.)
  subject_fragment TEXT NOT NULL,
  body_html TEXT NOT NULL,
  action_url TEXT,                   -- magic link if any
  priority INTEGER NOT NULL DEFAULT 5,
  queued_at INTEGER NOT NULL,
  batched_into_id TEXT,              -- nullable; references actually-sent message id
  sent_at INTEGER
)

-- Phase G
cash_flow_projections(
  account_id TEXT NOT NULL,
  for_date INTEGER NOT NULL,         -- unix day timestamp
  projected_balance_cents INTEGER NOT NULL,
  refreshed_at INTEGER NOT NULL,
  PRIMARY KEY(account_id, for_date),
  FOREIGN KEY(account_id) REFERENCES accounts(id)
)

monthly_truths(
  month TEXT PRIMARY KEY,            -- 'YYYY-MM'
  net_savings_change_cents INTEGER NOT NULL,
  drew_from_savings_total_cents INTEGER NOT NULL DEFAULT 0,
  acknowledged_by_matt_at INTEGER,
  acknowledged_by_rita_at INTEGER,
  matt_note TEXT,
  rita_note TEXT,
  closed_at INTEGER
)

account_thresholds(
  account_id TEXT NOT NULL,
  threshold_type TEXT NOT NULL CHECK(threshold_type IN ('safe_min_balance','monthly_ceiling','monthly_floor')),
  amount_cents INTEGER NOT NULL,
  set_at INTEGER NOT NULL,
  set_by TEXT NOT NULL CHECK(set_by IN ('matt','rita')),
  PRIMARY KEY(account_id, threshold_type),
  FOREIGN KEY(account_id) REFERENCES accounts(id)
)

threshold_alerts_sent(
  account_id TEXT NOT NULL,
  month TEXT NOT NULL,
  level INTEGER NOT NULL CHECK(level IN (80,100,110)),
  sent_at INTEGER NOT NULL,
  PRIMARY KEY(account_id, month, level)
)

-- Phase H
reimbursements(
  id INTEGER PRIMARY KEY,
  tx_id TEXT NOT NULL,
  marked_by TEXT NOT NULL CHECK(marked_by IN ('matt','rita')),
  marked_at INTEGER NOT NULL,
  total_owed_cents INTEGER NOT NULL,
  cancelled_at INTEGER,
  cancelled_by TEXT CHECK(cancelled_by IS NULL OR cancelled_by IN ('matt','rita')),
  cancelled_reason TEXT,
  FOREIGN KEY(tx_id) REFERENCES transactions(id)
)

reimbursement_shares(
  reimbursement_id INTEGER NOT NULL,
  actor TEXT NOT NULL CHECK(actor IN ('matt','rita')),
  owed_cents INTEGER NOT NULL,
  settled_cents INTEGER NOT NULL DEFAULT 0,
  settled_at INTEGER,
  settled_note TEXT,
  settled_tx_id TEXT,                -- nullable; for future Plaid auto-match
  PRIMARY KEY(reimbursement_id, actor),
  FOREIGN KEY(reimbursement_id) REFERENCES reimbursements(id)
)

-- Phase I
contribution_targets(
  actor TEXT NOT NULL CHECK(actor IN ('matt','rita')),
  monthly_amount_cents INTEGER NOT NULL,
  active_since INTEGER NOT NULL,
  archived_at INTEGER,
  PRIMARY KEY(actor, active_since)
)

Open discussion items

Still open — not yet locked into the master plan.

  • Inbound Email Workers — receive forwarded receipts/statements, parse, attach to tx by amount+date match. Pre-wire MX record now; defer Worker code.

Deferred to "Future ideas, evaluate after 1-2 weeks of real use"

Matt's call: ship the discipline first, see what gaps emerge, then add intelligence to fill real needs.

  • Workers AI for category suggestions — REJECTED (categorizing every item IS the discipline)
  • Workers AI fallback for unstable recurring merchant strings (Amazon, Apple, Uber) — wait and see
  • Sam-narrated explanations on cash flow projections / month-end card / drawdown banner — wait and see
  • Monthly close + narrative summary (Sam from numbers? Cloudflare Kimi 2.6 on free daily cap? Claude headless on Mini cron scripts?) — decide after 1-2 weeks
  • AI Gateway in front of any future AI calls (set up when there are AI calls to gate)
  • Trend spotting / anomaly narrative ("you spent $600 on dining") — math is pure SQL; voice TBD
  • "Explain this charge" feature
  • Recurring transaction auto-APPROVAL for either mode (auto-cat IS in; auto-approve stays manual)
  • Personal accounts isolation UI (schema foundation in Phase A #14; UI deferred until Matt's ready)
  • Personalized merchant memory in-app (folded into Monthly Trends email Phase K)
  • Receipt photos via Telegram + R2 — REJECTED per Matt 2026-05-29 ("we don't keep receipts")
  • Vacation mode toggle — REJECTED. Spike IS the signal.
  • Telegram emoji reactions as approve signal — REJECTED (keeps approval surface explicit)
  • Telegram Web Apps — REJECTED (existing app covers everything)
  • Per-transaction Durable Objects with own alarms — REJECTED (sweeper DO is the right scale)
  • Split brand.css into 5 files — deferred until any file crosses 400 LOC
  • Streak auto-reset on drawdown — DROPPED (drawdown banner IS the consequence; double-punishment fights ethos)
  • Per-category anomaly nudges — REJECTED. Replaced with Phase J account-level targets.

Shipped

Foundation (Apr–May 2026)

  • Astro + CF Pages scaffold, CF Access gate (Matt + Rita allowlist), D1 schema (transactions, accounts, audit log, balance snapshots)
  • Plaid Trial integration — BoA + Wealthfront, /transactions/sync, balance refresh inline with sync
  • Initial home page: account cards (per-type spans, live balances, institution logos), recent activity feed, per-account filter chips
  • Transaction detail page + approve/discuss/unapprove (functions/tx/[id].ts)
  • Telegram bot — webhook, group posting, inline approve/discuss buttons, callback_query routing
  • Categories CRUD with parent/sub hierarchy (one level deep), default-approval per category, archive flag
  • Categories breakdown page with month picker (compact arrows + label), master-detail layout, bulk-categorize on the detail side

Households & accountability (May 2026)

  • Per-person pending counts on home page (Matt blue / Rita warm / Discuss red badges, clickable to filter)
  • Account drill-in /accounts/<id> with status chip filters + person filter (?for=matt|rita)
  • Telegram MarkdownV2 message formatting, /actions in private group
  • Audit log writes on every status/category change
  • Approval mode: per-category override + global default ("both" vs "either")
  • Sync endpoint consolidates transactions + balances in one call

Visual polish (May 2026)

  • Light + dark themes, theme toggle in nav, persisted via localStorage
  • Custom brand palette (--cf-orange slate-blue accent, semantic --accent aliases)
  • Bottom nav + responsive mobile-first layout
  • App rename: "Simoes Budget" everywhere; money logo in --ink black/white
  • Compact month picker + responsive category column (mobile wraps, desktop nowrap)
  • Categories: exclude_from_totals flag hides transfer/CC-payment categories from breakdown entirely

Categories + goals (May 2026)

  • IVF as sub of Healthcare (manual archived-row reactivation due to UNIQUE constraint)
  • Bulk-tag transactions endpoint + UI
  • Goals page — Floor (account-linked min), Bill reminder (monthly + paid toggle + 6-month dot history), Savings (target + progress + optional account-link for live balance)
  • Migration 0012 (goals + goal_payments)

Project hygiene (2026-05-29 morning)

  • CLAUDE.md at root — stack, invariants, recipes, commands
  • This docs/PLAN.md — live plan we work off of
  • Hamburger menu + slide-out drawer (Home, Categories, Todos, Goals, Settings, Spec/Plan, Profile)
  • Renamed /connect/settings, accounts management lives there
  • /profile placeholder (photo + logout stub)
  • /plan page renders this doc + Matt's inbox of add-on ideas

Sync diagnostics + account naming + design pass (2026-05-29 afternoon)

  • Migration 0014: accounts.display_name column. Seeded BoA Checking / BoA Credit Card / BoA Savings / WF Savings / WF Investing. Used in home cards, filter chips, account drill-in, tx detail, settings.
  • /api/plaid/diagnostics (GET) — read-only health check: webhook URL Plaid has on file per item, last webhook delivery, last successful sync, cached signing-key count, 14-day tx activity histogram. Returns a verdict (HEALTHY / SUSPICIOUS / BROKEN).
  • /api/plaid/repair-webhook (POST) — force-sets webhook URL on every plaid_items row via /item/webhook/update. Idempotent.
  • Settings page: "Sync diagnostics" card auto-loads, with Refresh + Repair buttons.
  • Impeccable design scan applied: card vertical rhythm, name promoted, grid spacing, bottom-nav active indicator + min-height, theme-toggle sized to match hamburger, "just us" hides below 480px, recent-activity top-border separator, goal edit pencil sized up, goal-card negative-margin smells cleaned.

Security worker batch (2026-05-29 evening)

  • Migration 0015: Plaid webhook replay protection via plaid_seen_webhooks table + INSERT OR IGNORE dedup
  • Migration 0016: Append-only triggers on transaction_events (refuse UPDATE/DELETE)
  • functions/_lib/log.ts: logError/logInfo/scrub helpers; replaced 24 bare console.error calls across 20 files
  • .dev.vars.example with all required keys
  • wrangler.toml migrations_dir = "migrations" + package.json predev hook

Backlog

Things we've explicitly agreed to do, just not started yet.

  • Targets for Travel + Healthcare savings goals — Rita waiting on conversation with Matt before setting numbers
  • Manual balance refresh button for accounts that lag Plaid (Apple charges, etc.) — currently consolidated into the single Sync; consider whether per-account refresh is worth surfacing
  • Excel-sheet style page Rita wants — she'll share a reference picture once iMessage/AirDrop is set up on the Mini
  • Better error message when creating a category whose name collides with an archived row (currently returns vague "invalid request")
  • Un-hide flagged transfer categories — no in-app UI to undo exclude_from_totals once set; Sam toggles manually for now

Future ideas

Not committed; ideas to revisit when we want to pick the next thing up.

  • Tax-prep export (deductible categories → CSV by year)
  • Multi-currency display (CDMX trip showed this gap — pesos vs USD)
  • Net-worth tracker pulling all account balances over time

Inbox notes (read at session start)

Items Matt adds via the /plan page show up in the inbox API and on the page itself with new / seen / merged toggles. When working on the app, check /api/plan/inbox for new items — fold them into Backlog or Future ideas above, then mark them merged.