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_emailbinding (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.
- Scaffold Worker entry alongside existing Pages. New
worker/index.tswith Hono, mount/api/health. Keep Pages live throughout. Verify withwrangler dev. - Port
_middleware.ts→ Hono middleware. CF Access JWT extraction + JWKS verification via@hono/cloudflare-access. JWKS cached in KV with 1h TTL. Verifyaud+issclaims. - Port
functions/_lib/*→worker/lib/*. Mechanical file moves; types stay. - 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(...). - Move
functions/tx/[id].ts+functions/accounts/[id].tsto Astro SSR routes with@astrojs/cloudflareadapter inservermode. Kills_lib/html.ts's inline-HTML string concatenation entirely. - Add
assetsbinding towrangler.jsoncwithrun_worker_first = true. Astro builds to./dist, Worker serves it. - Cut DNS —
budget.msimoes.devfrom Pages → Workers route. Re-point CF Access app. Verify Matt AND Rita can both log in. - Re-point Plaid + Telegram webhooks to new Worker URL. Run
/api/plaid/repair-webhookpost-cut. - Migrate to
wrangler.jsonc(canonical 2026 config format). - Bump compatibility date to 2026-05-01 +
nodejs_compatflag. - 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 explicitrenamed_classesentry. - Cut DNS Saturday morning MT (low tx volume, before Sunday Sit-Down).
Phase A — Security + foundational infrastructure
Shipped 2026-05-29 (security worker):
- ✓ Plaid webhook replay protection —
plaid_seen_webhooks+INSERT OR IGNORE. Migration 0015. - Encrypt Plaid access tokens at rest — AES-GCM via
BUDGET_AT_KEYWorker secret. Migration encrypts in place. NOT shipped yet. - Centralized auth middleware — 403 if
actorFromRequest === "unknown"on/api/*except webhooks + health. Moves to Hono middleware in Phase 0. - Verify CF Access JWT against JWKS — via
@hono/cloudflare-access(3 lines). - ✓ Append-only triggers on
transaction_events— Migration 0016. - ✓ Log scrubbing helper —
logError/logInfostrip access_token/Bearer/hex. - Verify
*.pages.devAccess policy matches custom domain (operational). - Pruning cron — daily prune of
plaid_seen_webhooks+telegram_seen_updates+request_idempotency> 24h.
New from architect review (load-bearing additions):
- 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. - Workers Analytics Engine for observability —
logEvent(env, scope, fields)helper writes structured events. Free, SQL-queryable. - Rate limiting on webhook endpoints — CF
RateLimitbinding, 10 req/s on Plaid + Telegram webhooks. request_idempotencytable + Idempotency-Key support — every mutating endpoint accepts optionalIdempotency-Keyheader; 24h TTL. Magic-link ack endpoints ALWAYS idempotent (double-tap, email-scanners).SECURITY.mdwith secret rotation runbook — dual-key overlap window forBUDGET_AT_KEY+BUDGET_EMAIL_SIGNING_KEY. Annual cadence or on suspected leak.
Schema foundations (ship as Phase A migrations):
accounts.ownercolumn —TEXT NOT NULL DEFAULT 'joint', CHECKIN ('matt', 'rita', 'joint'). Foundation for personal accounts (UI deferred).transactions.statusenum + backfill —status 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.transactions.contribution_actorcolumn —TEXT NULL, CHECKNULL OR IN ('matt','rita'). Foundation for Phase I.
Phase B — Code/architecture foundations
- vitest +
@cloudflare/vitest-pool-workers+ first ~15 tests:- Hono routes via
app.request()— middleware auth, 403 on unknown, 200 on matt/rita actions.tsstate machine — approve/discuss/unapprove/reimburse × both/either × actor matrixactorFromAccessEmailedge casesverifyPlaidJwsagainst golden fixture ingoldens/- AES-GCM wrap/unwrap roundtrip
- D1 migrations apply cleanly from empty (CI smoke)
- SweeperDO alarm fires (DO test harness)
- Hono routes via
- Centralize DB row types in
worker/types/db.ts. - Extract frontend helpers to
/public/js/dom.js—el,clear,fmtMoney,fmtAmount,fmtDate. - Move inline JS from tx detail to
/public/js/tx-detail.js(or absorb into Astro SSR per Phase 0 step 104). - Request body validation — zod schemas next to handlers.
- Validate env at request boundary —
getEnv(ctx)validates required secrets per request. - ✓
.dev.vars.example— shipped 2026-05-29. - ✓
migrations_dir = "migrations"+predevhook — shipped 2026-05-29. - CI on GitHub Actions —
tsc --noEmit+vitest run+wrangler deploy --dry-runon PR. Workers Builds auto-deploys per branch. - Queue between Plaid webhook and Telegram fanout — webhook writes to D1 + enqueues
{tx_id}totelegram-fanoutqueue. Consumer Worker posts to Telegram. Dead-letter queue for malformed payloads. - Queue for email fanout —
email-fanoutqueue + consumer.
Phase C — UX safe-ships (parallel with B)
- Replace emoji nav icons with monochrome Lucide SVG — home/checklist/target/tag/gear.
- Add utility CSS classes —
.input,.input-row,.stack-sm/md,.cluster,.section,.btn-ghost. prefers-reduced-motionoverride on shimmer keyframes.- Tabular figures —
font-feature-settings: "tnum" 1;on all money displays. - Hide sync diagnostics inside
<details>on settings — closed by default. - Replace full-width "⟳ Sync now" with inline text link.
- Time-aware greeting — "Tuesday evening, Matt."
- Audit money-color contrast at small sizes.
- Typeface system — Switzer (UI sans) + Geist Mono (numbers) + Fraunces (single h1 only). Self-hosted from
public/fonts/. ~80kb total. - Account-card top-row alignment fix (Option A) — absolute-position logo top-right; eyebrow row becomes plain text.
Split brand.css into 5 files— DEFERRED until any file crosses 400 LOC (per sanity review).
Phase D — Approval, categorization, recurring patterns
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)
Category system redesign — Option B (8 top + Other = 9):
- Home (rent, utilities, internet, repairs, furniture, household supplies)
- Food (groceries, restaurants, coffee/takeout, alcohol)
- Healthcare (medical, IVF, pharmacy, dental, insurance)
- Personal & lifestyle (gym, haircuts, clothing, gifts, personal care)
- Pets (Boo + Ziggy)
- Transportation
- Travel
- Money flow (income, transfers, savings, CC payments —
exclude_from_totals) - 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.Approval state machine —
finalrequires category + approval + note (with reimbursement-pending exception):- Tx becomes
finalonly when:category_idset + 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_pendingadded: 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.
- Tx becomes
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).
Inline "+ New subcategory" from any tx — both surfaces. New subs default
approval_mode = both.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.
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
- Category-from-Telegram — 📁 button per tx card → hierarchical picker (#42). Two taps to categorize.
- Reply-to-add-note — any Telegram reply to a bot's tx card saved as a note on that tx.
- 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.
Third decision state on every tx —
Reimburse(alongside Approve, Discuss). When marked:- Tx status →
reimburse_pending(per #41) - Approval mode forcibly elevated to
bothregardless of mode - Earlier solo approval (if under either-mode) logged but no longer satisfies the condition
- Tx status →
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).
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
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
Cancellation path — UI back to Approve/Discuss. Logged in audit. Stops reminders.
Outstanding-reimbursements badge — home hero +
/reimbursementspage.
Phase F — Weekly Sit-Down email ritual
Cloudflare
send_emailbinding setup — configure DKIM/SPF formsimoes.dev. Sender:budget@msimoes.dev.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
Magic-link ack endpoints —
/week/<digest_id>/ack/<actor>/<decision>?sig=<HMAC>. HMAC viaBUDGET_EMAIL_SIGNING_KEY. Idempotent (#12).Digest state machine:
pending_acks→ both "All good" →resolved✓pending_acks→ either "Needs discussion" →in_discussion+ immediate discussion-followup emailin_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
email_outboxtable — 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.
Follow-up cadence — reduced from 4 to 2 (per sanity review). For any digest > 24h since last contact in
pending_acksorin_discussion, queue a follow-up. Max 2 follow-ups (Mon + Wed after Sunday).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.
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"
Cross-account "safe to move" — net liquidity = checking − target_safe_threshold − unpaid CC − pending bills through next N days. Single number + 30-day curve.
Drawing-from-savings detection — tracks transfers savings → checking monthly. Distinguishes "scheduled contribution reversal" from "we ran short." Monthly net-position drives mood color.
Predictive alert emails — via outbox + magic-link ack:
- Positive opportunity (mid-month safe-to-move): dismissible
- Predictive warning (insufficient projected coverage): both must ack
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
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.
Streak resets on drawdown— DROPPED 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.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_truthstable
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.
contribution_targetstable — actor, monthly_amount_cents, active_since, archived_at. Toggleable via Settings.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)
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.
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.
"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.)
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."
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_thresholdstable (unifies what v1 duplicated across Phase G #48 and Phase J #67 per sanity review).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.
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_sentto enforce "once per threshold per month"
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."
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
Optional
/trendspage — 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
eithermode (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-orangeslate-blue accent, semantic--accentaliases) - Bottom nav + responsive mobile-first layout
- App rename: "Simoes Budget" everywhere; money logo in
--inkblack/white - Compact month picker + responsive category column (mobile wraps, desktop nowrap)
- Categories:
exclude_from_totalsflag 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.mdat 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 /profileplaceholder (photo + logout stub)/planpage renders this doc + Matt's inbox of add-on ideas
Sync diagnostics + account naming + design pass (2026-05-29 afternoon)
- Migration 0014:
accounts.display_namecolumn. 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_webhookstable +INSERT OR IGNOREdedup - Migration 0016: Append-only triggers on
transaction_events(refuse UPDATE/DELETE) functions/_lib/log.ts:logError/logInfo/scrubhelpers; replaced 24 bareconsole.errorcalls across 20 files.dev.vars.examplewith all required keyswrangler.tomlmigrations_dir = "migrations"+package.jsonpredevhook
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_totalsonce 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.