diff --git a/CHANGELOG.md b/CHANGELOG.md index 8cd820f..f496ee1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,30 @@ # Changelog +## 2026-04-05 — MoneyMirror Phase 3 T4 (VIJ-41): facts-grounded AI coaching + +**App:** `apps/money-mirror` + +**What:** Layer A facts (`src/lib/coaching-facts.ts`, Zod + `buildLayerAFacts`), Gemini 2.5 Flash structured narratives with `cited_fact_ids` validation (`src/lib/gemini-coaching-narrative.ts`), `attachCoachingLayer` (`src/lib/coaching-enrich.ts`) wired into `GET /api/dashboard` and `GET /api/dashboard/advisories`, `POST /api/dashboard/coaching-facts-expanded` for `coaching_facts_expanded` telemetry, `FactsDrawer` + updated `AdvisoryFeed`, `dashboard.signals` for food/subscription heuristics, dependency `zod`. README, `docs/COACHING-TONE.md`, `CODEBASE-CONTEXT.md`, `manifest-010.json` PostHog list updated. + +--- + +## 2026-04-05 — MoneyMirror Phase 3 T2 (VIJ-39): unified dashboard scope + +**App:** `apps/money-mirror` + +**What:** Implemented the unified scope model from `experiments/plans/plan-010.md` Phase B: shared `src/lib/scope.ts`, extended `GET /api/dashboard` and `fetchDashboardData` (legacy single statement vs `date_from`/`date_to`/`statement_ids`), `statement_ids` filter on `GET /api/transactions`, `ScopeBar` UI with `POST /api/dashboard/scope-changed` for PostHog `scope_changed`, and aligned `TransactionsPanel` with the same scope. Mirror “perceived” uses `profiles.perceived_spend_paisa` (single baseline). README and `project-state.md` updated. + +--- + +## 2026-04-05 — Issue Created: issue-010 + +- **Type**: Enhancement +- **Title**: MoneyMirror Phase 3 — Unified multi-source dashboard, transaction-native insights, and expert AI coaching +- **App**: apps/money-mirror +- **Status**: Discovery + +--- + ## 2026-04-05 — Docs: MoneyMirror added to root README + app README updated for Phase 2 **What:** Closed remaining documentation gaps after Phase 2 ship and VIJ-24 closure. diff --git a/agents/backend-architect-agent.md b/agents/backend-architect-agent.md index 3971cc8..a5f4a8d 100644 --- a/agents/backend-architect-agent.md +++ b/agents/backend-architect-agent.md @@ -248,8 +248,26 @@ Before finalizing the architecture, answer all of the following. Any gap must be → Server must return HTTP 4xx on invalid enum input — not silently sanitize to null or a default. Silent sanitization gives users false confidence their input was saved. → Schema must include a CHECK constraint for the column. → Specify all three in the architecture spec: (1) client control type, (2) server validation response code, (3) schema CHECK constraint. + # Added: 2026-04-04 — MoneyMirror Phase 2 +15. **Financial headline metrics (aggregates vs lists)**: For any finance dashboard, advisory pipeline, or AI facts layer: + → The plan must state that totals, category sums, and inputs to rules/AI are computed from **database aggregates** over the full user scope (`SUM` / `COUNT` with the same filters as scope), **not** from `LIMIT`-capped row scans. + → List/pagination queries for UI tables are separate from aggregate queries for headline numbers — never reuse the list query result as the source of summed totals. + + # Added: 2026-04-05 — MoneyMirror Phase 3 (issue-010) + +16. **Batch repair / backfill termination**: For any maintenance route that fixes nullable derived fields in batches (cursor + loop): + → Document **termination proof**: cursor advances monotonically; rows that cannot be processed in one pass (e.g., normalization returns null permanently) are skipped or marked so they are not re-selected forever. + → "Process until no rows" without poison-row handling is a blocking omission. + + # Added: 2026-04-05 — MoneyMirror Phase 3 (issue-010) + +17. **Heavy authenticated read APIs**: For any authenticated endpoint that scans large row sets, runs expensive `GROUP BY`, or could be abused by rapid UI actions: + → State an explicit strategy: pagination/cursor guarantees, per-user rate limits, query caps, or an explicit **MVP / trusted-client** assumption with documented risk acceptance. + → Auth + ownership alone are not sufficient when the query is O(n) in user data. + # Added: 2026-04-05 — MoneyMirror Phase 3 (issue-010) + # Added: 2026-03-19 — SMB Feature Bundling Engine # Updated: 2026-03-21 — Ozi Reorder Experiment (items 4–7) @@ -260,6 +278,8 @@ Before finalizing the architecture, answer all of the following. Any gap must be # Updated: 2026-04-04 — MoneyMirror Phase 2 (items 13–14) +# Updated: 2026-04-05 — MoneyMirror Phase 3 (items 15–17) + --- ## Anti-Sycophancy Mandate diff --git a/agents/backend-engineer-agent.md b/agents/backend-engineer-agent.md index cac1a3d..258490d 100644 --- a/agents/backend-engineer-agent.md +++ b/agents/backend-engineer-agent.md @@ -163,13 +163,17 @@ Experiment Integrity & Telemetry: Ensure cryptographic salts for A/B testing are # Added: 2026-04-03 — MoneyMirror (issue-009) +**Monetary totals vs list queries**: Never use `LIMIT` on the query whose rows are aggregated into headline totals, category sums, advisory inputs, or AI fact inputs. Paged list endpoints may use `LIMIT`; totals must use a separate aggregate query (SQL `SUM`/`COUNT`) over the full scope. + +# Added: 2026-04-05 — MoneyMirror Phase 3 (issue-010) + **Infrastructure Provisioning is a hard deliverable** — not a README suggestion. Before execute-plan can be marked DONE, the Backend Engineer must confirm all of the following are complete: 1. **Database project exists** — Neon/Supabase project created and `DATABASE_URL` is a real connection string in `.env.local` (not a placeholder). 2. **Schema applied** — `schema.sql` has been run against the live DB. Verify by querying `information_schema.tables` — every expected table must exist. 3. **Auth provider provisioned** — If the app uses Neon Auth, `NEON_AUTH_BASE_URL` must be obtained from the Neon console Auth section and filled in `.env.local`. OTP login must work locally before execute-plan closes. 4. **All non-optional env vars filled** — Every variable in `.env.local.example` that is not explicitly marked `# Optional` must have a real value in `.env.local`. Empty strings (`VAR=`) are a blocking violation. -5. **Sentry project created** — Create a Sentry project (free tier), run `npx @sentry/wizard@latest -i nextjs`, and fill `NEXT_PUBLIC_SENTRY_DSN`, `SENTRY_AUTH_TOKEN`, `SENTRY_ORG`, `SENTRY_PROJECT` in `.env.local`. This is a backend setup task, not a deploy-check task. +5. **Sentry project created** — Unless `project-state.md` Decisions Log documents monitoring keys as optional/out of scope for the current gate: create a Sentry project (free tier), run `npx @sentry/wizard@latest -i nextjs`, and fill `NEXT_PUBLIC_SENTRY_DSN`, `SENTRY_AUTH_TOKEN`, `SENTRY_ORG`, `SENTRY_PROJECT` in `.env.local`. When the PM has explicitly deferred Sentry, do not treat empty DSN/org/project as an execute-plan blocker. 6. **`npm run dev` boots clean** — The app starts without errors and the core user flow works end-to-end. Auth, DB reads/writes, and the primary feature must all function before the task is closed. Infra gaps discovered at `/deploy-check` are Backend Engineer failures. Ship infra, not just code. diff --git a/agents/code-review-agent.md b/agents/code-review-agent.md index a88b785..4e76b45 100644 --- a/agents/code-review-agent.md +++ b/agents/code-review-agent.md @@ -134,6 +134,23 @@ For every API route that writes a parent record followed by child records: # Added: 2026-04-03 — MoneyMirror (issue-009) +**Financial copy vs active scope** (required when reviewing money-related UI, advisories, or analytics strings): + +- Verify phrases like `/mo`, `per year`, `this month`, and any ×12 annualization match the **active date scope** (single calendar month vs multi-month vs arbitrary range). +- Flag **HIGH** if copy implies a monthly frame when the scope is not a single month, unless the string explicitly derives from a monthly estimate stated in the spec. + +# Added: 2026-04-05 — MoneyMirror Phase 3 (issue-010) + +**Competing async loads on scope/filter change** (required for `"use client"` data loads): + +- For any `fetch` driven by scope, date range, or filter changes that can fire in quick succession, verify `AbortController` (or equivalent stale-response guard) so an older response cannot overwrite newer UI state. Ignore `AbortError` in handlers. + +# Added: 2026-04-05 — MoneyMirror Phase 3 (issue-010) + +**Repair / backfill loops**: For any `while` or `for` loop that batches DB updates until "done," verify rows that cannot be processed are skipped or marked so the loop cannot run until timeout on the same poison rows. + +# Added: 2026-04-05 — MoneyMirror Phase 3 (issue-010) + --- ## 5 Performance Risks diff --git a/agents/deploy-agent.md b/agents/deploy-agent.md index d521897..0618fc8 100644 --- a/agents/deploy-agent.md +++ b/agents/deploy-agent.md @@ -91,6 +91,10 @@ error logging request logging performance metrics +**Optional monitoring keys**: When verifying Sentry (or similar), check `project-state.md` Decisions Log and the active deploy-check artifact for a **documented PM exception** that exempts specific env vars from the blocking gate. If exempted keys are listed, empty values for those keys must not alone justify a Block Deployment verdict. + +# Added: 2026-04-05 — MoneyMirror Phase 3 (issue-010) + --- ## 5 Rollback Plan diff --git a/agents/design-agent.md b/agents/design-agent.md index 28802c3..3f23877 100644 --- a/agents/design-agent.md +++ b/agents/design-agent.md @@ -140,3 +140,7 @@ Minimize number of screens. Avoid unnecessary UI complexity. Focus on fast user success. + +**Date range and labels**: When wireframes or component copy include money or time rates (`/mo`, `per year`, `this month`), specify whether the screen assumes a **single month** or a **variable range**. For variable ranges, prefer neutral period language in mocks so engineering does not inherit a misleading monthly frame. + +# Added: 2026-04-05 — MoneyMirror Phase 3 (issue-010) diff --git a/agents/frontend-engineer-agent.md b/agents/frontend-engineer-agent.md index 1aa8e17..f87a93e 100644 --- a/agents/frontend-engineer-agent.md +++ b/agents/frontend-engineer-agent.md @@ -155,3 +155,11 @@ Optimize for fast MVP development. Browser Storage & Network Safety: Always wrap `JSON.parse` of `localStorage`/`sessionStorage` in a try/catch block. Always use an `AbortController` for asynchronous `fetch` calls triggered by user input (e.g., search) to prevent network race conditions. Clean up AbortController on component unmount or before issuing a new request to prevent memory leaks. # Added: 2026-03-28 — Nykaa Personalisation (issue-008) + +**URL as canonical scope**: When filters, date range, or statement scope are encoded in the URL (search params), any modal, drawer, or inline editor that edits that scope must **re-initialize local form state from parsed search params** whenever the canonical scope in the URL changes. Never let modal defaults diverge from the active URL. + +# Added: 2026-04-05 — MoneyMirror Phase 3 (issue-010) + +**Dashboard and scope loads**: Treat main data loads triggered by scope changes like search — use `AbortController`, abort the prior request when issuing a new one, and ignore `AbortError` so stale responses cannot overwrite the UI. + +# Added: 2026-04-05 — MoneyMirror Phase 3 (issue-010) diff --git a/agents/peer-review-agent.md b/agents/peer-review-agent.md index 453193d..e98f500 100644 --- a/agents/peer-review-agent.md +++ b/agents/peer-review-agent.md @@ -67,6 +67,10 @@ large user growth heavy AI processing database bottlenecks +**Heavy authenticated reads**: For APIs that scan large per-user tables or run expensive aggregations (`GROUP BY`, unbounded filters), verify the architecture documents a strategy per **backend-architect-agent** Mandatory Pre-Approval Checklist item 17 (pagination, rate limits, caps, or explicit MVP trusted-client assumption). If none is stated, file a scalability finding — non-blocking only if the review explicitly accepts MVP risk with documented rationale. + +# Added: 2026-04-05 — MoneyMirror Phase 3 (issue-010) + --- ## 3 Edge Case Analysis @@ -210,14 +214,15 @@ Each stance must produce at least one finding. If a stance produces no findings, **Step 4 — Prompt Autopsy Check** For each agent prompt gap identified: + - Name the exact agent file (e.g., agents/backend-architect-agent.md) - Name the exact section to modify (e.g., ## 6 Technical Risks) - Write the exact text to add — not a direction, but the actual sentence or rule Format as: - File: agents/[agent-name]-agent.md - Section: [section name] - Add: "[exact text]" +File: agents/[agent-name]-agent.md +Section: [section name] +Add: "[exact text]" This output is consumed directly by /learning to update agent files. Vague directions ("add a timeout rule") are not acceptable outputs. diff --git a/agents/product-agent.md b/agents/product-agent.md index 9901ca0..cd13737 100644 --- a/agents/product-agent.md +++ b/agents/product-agent.md @@ -165,3 +165,7 @@ Avoid over engineering. Prefer experiments over full product builds. Always clarify assumptions. + +**Scope-aware copy**: If the MVP supports user-configurable or multi-month date ranges, default analytics and coaching copy must use **period-neutral** labels (`this period`, `in your selected range`) unless the screen is explicitly scoped to a single calendar month. Do not standardize on `/mo`, `this month`, or ×12 annualization in template strings without a **Scope → Copy** rule that states which phrases apply to which scope shapes. + +# Added: 2026-04-05 — MoneyMirror Phase 3 (issue-010) diff --git a/apps/money-mirror/CODEBASE-CONTEXT.md b/apps/money-mirror/CODEBASE-CONTEXT.md index 548fcb7..e6d12d0 100644 --- a/apps/money-mirror/CODEBASE-CONTEXT.md +++ b/apps/money-mirror/CODEBASE-CONTEXT.md @@ -1,6 +1,6 @@ # Codebase Context: MoneyMirror -Last updated: 2026-04-04 +Last updated: 2026-04-05 (Phase 3 learning; aggregate invariants) ## What This App Does @@ -8,51 +8,67 @@ MoneyMirror is a mobile-first PWA AI financial coach for Gen Z Indians (₹20K ## Architecture Overview -- **Frontend**: Next.js 16 App Router (RSC by default, `"use client"` for interactive panels). Key pages: `/` (landing), `/onboarding` (5-question flow), `/score` (Money Health Score reveal), `/dashboard` (Overview with perceived vs actual + categories, **Insights** tab for AI nudges, **Upload** tab, statement picker + month filter, URL `?statement_id=`). Dashboard shell is `DashboardClient.tsx` (client orchestrator) composed of `ResultsPanel`, `InsightsPanel`, `UploadPanel`, `ParsingPanel`, `DashboardNav`, `StatementFilters`, `DashboardBrandBar`. +- **Frontend**: Next.js 16 App Router (RSC by default, `"use client"` for interactive panels). Key pages: `/` (landing), `/onboarding` (5-question flow), `/score` (Money Health Score reveal), `/dashboard` (Overview with perceived vs actual + categories, **Insights** tab for AI nudges + **top merchants** (`MerchantRollups`) + **Sources** drawer (`FactsDrawer`) for Layer A facts behind Gemini narratives, **Transactions** tab for paginated txn list + filters + optional `merchant_key` deep link, **Upload** tab). URL: optional `?statement_id=` (legacy single-statement), or unified scope `?date_from=&date_to=` + optional `statement_ids=`; optional `?tab=` for `overview` \| `insights` \| `transactions` \| `upload`; optional `merchant_key=` on dashboard URL (filters Transactions list). Dashboard shell is `DashboardClient.tsx` composed of `ScopeBar` (T2), `ResultsPanel`, `InsightsPanel`, `TransactionsPanel`, `UploadPanel`, `ParsingPanel`, `DashboardNav`, `StatementFilters` (hidden when unified URL), `DashboardBrandBar`. - **Backend**: Next.js API routes under `src/app/api/`. Neon Auth for session auth, Neon Postgres for persistence, Gemini 2.5 Flash for PDF parse + categorization, Resend for weekly recap emails, PostHog for server-side telemetry. - **Database**: Neon Postgres. 4 tables: `profiles`, `statements`, `transactions`, `advisory_feed`. `profiles` persists monthly income and perceived spend; `statements` tracks `institution_name`, `statement_type`, optional credit-card due metadata, optional `nickname`, `account_purpose`, `card_network` for multi-account labelling. All monetary values are stored as `BIGINT` in paisa (₹ × 100) to avoid float precision errors. -- **AI Integration**: Gemini 2.5 Flash via `@google/genai`. Used for: (1) PDF text → structured bank-account or credit-card statement JSON, (2) transaction category normalization. The statement-parse route currently enforces a 25s timeout and returns JSON 504 on timeout. -- **Analytics**: PostHog (server-side only, `posthog-node`). 10 events tracked: `onboarding_completed`, `statement_parse_started/rate_limited/success/timeout/failed`, `weekly_recap_triggered/completed`, `weekly_recap_email_sent/failed`. All calls fire-and-forget (`.catch(() => {})`). +- **AI Integration**: Gemini 2.5 Flash via `@google/genai`. Used for: (1) PDF text → structured bank-account or credit-card statement JSON, (2) transaction category normalization, (3) **Insights** narrative rewrite only — input is Layer A facts JSON + advisory headlines; structured JSON output with `cited_fact_ids` validated against `coaching_facts` (see `src/lib/gemini-coaching-narrative.ts`, `src/lib/coaching-facts.ts`, `src/lib/coaching-enrich.ts`). The statement-parse route currently enforces a 25s timeout and returns JSON 504 on timeout. +- **Analytics**: PostHog (server-side only, `posthog-node`). Core events: `onboarding_completed`, `statement_parse_started/rate_limited/success/timeout/failed`, `weekly_recap_triggered/completed`, `weekly_recap_email_sent/failed`, plus Phase 3 `transactions_view_opened`, `transactions_filter_applied`, `scope_changed`, `merchant_rollup_clicked`, `coaching_narrative_completed/timeout/failed`, `coaching_facts_expanded` (see `README.md`). All calls fire-and-forget (`.catch(() => {})`) where not awaiting success paths. - **Error tracking**: Sentry via `@sentry/nextjs` (`sentry.server.config.ts`, `sentry.edge.config.ts`, `src/instrumentation.ts`, `src/instrumentation-client.ts`). Uses `NEXT_PUBLIC_SENTRY_DSN` plus org/project/auth token vars as in app `README.md` / `.env.local.example`. ## Key Files -| File | Purpose | -| -------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `src/app/api/statement/parse/route.ts` | Core pipeline: PDF upload → statement-type-aware Gemini parse → DB persist → advisory generation. Fail-closed: deletes parent statement row if transactions insert fails. | -| `src/app/api/statement/parse/persist-statement.ts` | Extracted helper: writes statements + transactions atomically; returns failure if child insert fails. | -| `src/app/api/dashboard/route.ts` | Authenticated GET — rehydrates the latest processed statement + transactions + advisory feed from DB. Called on every dashboard first-load (refresh, deep link, email CTA). | -| `src/app/api/dashboard/advisories/route.ts` | Authenticated GET — returns advisory_feed rows for the current user via the active Neon session cookie. | -| `src/app/api/cron/weekly-recap/route.ts` | Master cron: scheduled GET entrypoint for Vercel Cron; accepts Bearer `CRON_SECRET` or local `x-cron-secret`, paginates users in 1000-row batches, and fans out to the worker via Promise.allSettled. | -| `src/app/api/cron/weekly-recap/worker/route.ts` | Worker: sends Resend email per user. Returns HTTP 502 on failure so master counts it correctly. | -| `src/lib/advisory-engine.ts` | Rule-based advisories (perception gap, subscriptions, food, no investment, debt ratio, high Other bucket, discretionary mix, avoidable estimate, CC minimum-due risk). | -| `src/lib/format-date.ts` | UTC-safe date labels for statement periods (no raw ISO in UI). | -| `src/app/api/statements/route.ts` | Authenticated GET — list processed statements for picker + month filter. | -| `src/lib/scoring.ts` | Computes Money Health Score (0–100) from 5 onboarding question responses. | -| `src/lib/statements.ts` | Defines statement types, parser prompts, metadata validation, and shared display labels for bank-account and credit-card uploads. | -| `src/lib/pdf-parser.ts` | Extracts raw text from PDF buffer using `pdf-parse`. Uses `result.total` (not `result.pages?.length`) for page count — v2 API. | -| `src/lib/posthog.ts` | Server-side PostHog singleton. Reads `POSTHOG_KEY` and `POSTHOG_HOST` (server-only, no `NEXT_PUBLIC_` prefix). | -| `docs/COACHING-TONE.md` | AI narrative guardrails for advisory copy — defines consequence-first framing, banned phrases, and tone rules for all Gemini-generated coaching language. | +| File | Purpose | +| -------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `src/app/api/statement/parse/route.ts` | Core pipeline: PDF upload → statement-type-aware Gemini parse → DB persist → advisory generation. Fail-closed: deletes parent statement row if transactions insert fails. | +| `src/app/api/statement/parse/persist-statement.ts` | Extracted helper: writes statements + transactions atomically; returns failure if child insert fails. | +| `src/lib/dashboard.ts` | **Headline totals and coaching inputs** — use full-scope SQL aggregates where applicable; do not derive overview/advisory math from row-capped transaction lists. | +| `src/app/api/dashboard/route.ts` | Authenticated GET — rehydrates dashboard + advisories. **Legacy:** `?statement_id=` (optional; default latest). **Unified:** `?date_from=&date_to=` + optional `statement_ids=` (comma UUIDs; omit for all processed statements). Response includes `scope` + `perceived_is_profile_baseline`. | +| `src/app/api/dashboard/scope-changed/route.ts` | POST — fires PostHog `scope_changed` (`date_preset`, `source_count`). | +| `src/lib/scope.ts` | Shared dashboard scope parsing, URL builders, date presets (client + server). | +| `src/lib/merchant-rollups.ts` | SQL helpers: top merchants by debit for scope (`GROUP BY merchant_key`), scope vs keyed debit sums for reconciliation. | +| `src/components/ScopeBar.tsx` | Date range + source inclusion UI; drives unified URL params. | +| `src/components/MerchantRollups.tsx` | Insights: loads `GET /api/insights/merchants`, “See transactions” deep link + `POST /api/insights/merchant-click`. | +| `src/app/api/dashboard/advisories/route.ts` | Authenticated GET — returns advisory_feed rows for the current user via the active Neon session cookie. | +| `src/app/api/cron/weekly-recap/route.ts` | Master cron: scheduled GET entrypoint for Vercel Cron; accepts Bearer `CRON_SECRET` or local `x-cron-secret`, paginates users in 1000-row batches, and fans out to the worker via Promise.allSettled. | +| `src/app/api/cron/weekly-recap/worker/route.ts` | Worker: sends Resend email per user. Returns HTTP 502 on failure so master counts it correctly. | +| `src/lib/advisory-engine.ts` | Rule-based advisories (perception gap, subscriptions, food, no investment, debt ratio, high Other bucket, discretionary mix, avoidable estimate, CC minimum-due risk). | +| `src/lib/coaching-facts.ts` | Layer A Zod schema + `buildLayerAFacts` from `DashboardData` (deterministic server math only). | +| `src/lib/gemini-coaching-narrative.ts` | Gemini structured JSON narratives + `cited_fact_ids` validation. | +| `src/lib/coaching-enrich.ts` | `attachCoachingLayer` — merges AI narratives + PostHog `coaching_narrative_*` events. | +| `src/components/FactsDrawer.tsx` | Renders cited Layer A rows for an advisory (read-only). | +| `src/lib/format-date.ts` | UTC-safe date labels for statement periods (no raw ISO in UI). | +| `src/app/api/statements/route.ts` | Authenticated GET — list processed statements for picker + month filter. | +| `src/lib/scoring.ts` | Computes Money Health Score (0–100) from 5 onboarding question responses. | +| `src/lib/statements.ts` | Defines statement types, parser prompts, metadata validation, and shared display labels for bank-account and credit-card uploads. | +| `src/lib/pdf-parser.ts` | Extracts raw text from PDF buffer using `pdf-parse`. Uses `result.total` (not `result.pages?.length`) for page count — v2 API. | +| `src/lib/posthog.ts` | Server-side PostHog singleton. Reads `POSTHOG_KEY` and `POSTHOG_HOST` (server-only, no `NEXT_PUBLIC_` prefix). | +| `docs/COACHING-TONE.md` | AI narrative guardrails for advisory copy — defines consequence-first framing, banned phrases, and tone rules for all Gemini-generated coaching language. | ## Data Model - **profiles**: One row per user. `id` = Neon Auth user id (TEXT). Stores `monthly_income_paisa`, `perceived_spend_paisa`, `target_savings_rate`, `money_health_score`. - **statements**: One per uploaded PDF. Tracks `institution_name`, `statement_type` (`bank_account` or `credit_card`), statement period, optional card due metadata, optional `nickname` / `account_purpose` / `card_network`, and `status`. Status never set to `processed` before `transactions` child insert succeeds. -- **transactions**: Many per statement. All amounts in paisa (BIGINT). `category` CHECK: `needs | wants | investment | debt | other` (lowercase). +- **transactions**: Many per statement. All amounts in paisa (BIGINT). `category` CHECK: `needs | wants | investment | debt | other` (lowercase). Optional `merchant_key` (TEXT) for rollups; set on insert via `normalizeMerchantKey(description)` in `persist-statement.ts`. - **advisory_feed**: Advisory nudges generated per statement. `trigger` identifies which advisory type fired. ## API Endpoints -| Method | Path | Auth | Purpose | -| ------ | ------------------------------- | -------------------------------------------------------------- | ------------------------------------------------------------------------------------------- | -| POST | `/api/statement/parse` | Neon session cookie | Upload PDF plus `statement_type` and optional `nickname`, `account_purpose`, `card_network` | -| GET | `/api/dashboard` | Neon session cookie | Rehydrate processed statement + advisories; optional `?statement_id=` for a specific upload | -| GET | `/api/statements` | Neon session cookie | List all processed statements for the user | -| GET | `/api/dashboard/advisories` | Neon session cookie | Fetch advisory_feed rows for user | -| POST | `/api/onboarding/complete` | Neon session cookie | Save onboarding income, score, and perceived spend to profiles | -| GET | `/api/cron/weekly-recap` | `authorization: Bearer ` or local `x-cron-secret` | Scheduled master fan-out | -| POST | `/api/cron/weekly-recap/worker` | `x-cron-secret` header | Worker: send one recap email; returns 502 on failure | -| ALL | `/api/auth/[...path]` | — | Neon Auth passthrough | +| Method | Path | Auth | Purpose | +| ------ | ------------------------------------------ | -------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | +| POST | `/api/statement/parse` | Neon session cookie | Upload PDF plus `statement_type` and optional `nickname`, `account_purpose`, `card_network` | +| GET | `/api/dashboard` | Neon session cookie | Rehydrate processed statement + advisories + `coaching_facts` + optional Gemini `narrative` fields; unified or legacy query params | +| GET | `/api/statements` | Neon session cookie | List all processed statements for the user | +| GET | `/api/transactions` | Neon session cookie | Paginated txns + filters; `statement_id` or `statement_ids` (comma); dates optional; `merchant_key` optional; ownership enforced | +| GET | `/api/insights/merchants` | Neon session cookie | Top merchants (debit rollups) for same scope as transactions | +| POST | `/api/insights/merchant-click` | Neon session cookie | `merchant_rollup_clicked` PostHog (bucketed key) | +| POST | `/api/transactions/view-opened` | Neon session cookie | `transactions_view_opened` telemetry | +| POST | `/api/transactions/backfill-merchant-keys` | Neon session cookie | Backfill `merchant_key` for current user’s rows | +| GET | `/api/dashboard/advisories` | Neon session cookie | Same scope as dashboard; returns `advisories` + `coaching_facts` | +| POST | `/api/dashboard/coaching-facts-expanded` | Neon session cookie | PostHog `coaching_facts_expanded` when user opens Sources | +| POST | `/api/onboarding/complete` | Neon session cookie | Save onboarding income, score, and perceived spend to profiles | +| GET | `/api/cron/weekly-recap` | `authorization: Bearer ` or local `x-cron-secret` | Scheduled master fan-out | +| POST | `/api/cron/weekly-recap/worker` | `x-cron-secret` header | Worker: send one recap email; returns 502 on failure | +| ALL | `/api/auth/[...path]` | — | Neon Auth passthrough | ## Things NOT to Change Without Reading First @@ -64,14 +80,16 @@ MoneyMirror is a mobile-first PWA AI financial coach for Gen Z Indians (₹20K 6. **All monetary values are stored in paisa (BIGINT)** — divide by 100 to display as rupees. Never store or compute in rupees directly. 7. **Ownership is enforced in route handlers** — this app no longer uses Supabase RLS or service-role patterns. 8. **UploadPanel metadata fields use server-side sanitization helpers** (`sanitizeNickname`, `sanitizeCardNetwork`, `parseAccountPurpose`) — if adding validation, add a CHECK constraint in `schema.sql` AND return HTTP 4xx on invalid input. Do NOT silently sanitize to null; silent sanitization gives users false confidence their label was saved. +9. **Advisory copy and user-facing analytics strings** must match the active date scope (single month vs multi-month). Do not change `/mo`, `this month`, or ×12 annualization without checking scope semantics in `src/lib/advisory-engine.ts` and related UI. ## Known Limitations -- Cross-month trend comparison and aggregated “all accounts” rollups are not implemented (single-statement view with picker only). +- Month-over-month comparison UI (T6) is deferred; unified date range + multi-statement scope is implemented (T2). - Password removal stays manual outside the app. Password-protected PDFs are rejected with a clear retry message. - Inbox ingestion from email is not implemented. Users must manually download the PDF and upload it. - PDF parsing reliability depends on the PDF being text-based (not scanned/image). Scanned PDFs return 400. - Rate limit for uploads is 3/day per user (in-memory, resets on server restart) — not durable across deployments. +- Authenticated heavy read APIs (`GET /api/transactions`, `GET /api/insights/merchants`) have no per-user throttle; pagination/caps reduce abuse but rapid UI actions can still load the DB — see backlog / peer-review notes. - Weekly recap email only triggers if the user has at least one processed statement. New users without statements are silently skipped. - Share button (`navigator.share`) is hidden on desktop browsers — only rendered when Web Share API is available. -- Run `npm test` in `apps/money-mirror` for current library and API test counts. +- Run `npm test` in `apps/money-mirror` for current library and API test counts (includes merchant rollup reconciliation unit tests). diff --git a/apps/money-mirror/README.md b/apps/money-mirror/README.md index 50b905e..ece1224 100644 --- a/apps/money-mirror/README.md +++ b/apps/money-mirror/README.md @@ -11,9 +11,10 @@ For AI- and agent-oriented architecture, data model, and API reference, see [`CO 1. User signs in with Neon Auth email OTP and lands in a private onboarding flow. 2. User completes 5 onboarding questions (including monthly income) and the app calculates a Money Health Score. 3. User uploads a password-free bank-account or credit-card statement PDF, optionally tagging it with a nickname, account purpose, and card network. Gemini extracts and categorizes transactions entirely in memory. -4. Dashboard shows three tabs: +4. Dashboard shows four tabs: - **Overview** — Mirror card (perceived vs actual spend side-by-side), summary breakdown by needs/wants/investments/debt. - - **Insights** — AI-generated category insights and expanded consequence-first advisory cards. + - **Insights** — Merchant rollups, consequence-first advisory cards, and (when `GEMINI_API_KEY` is set) facts-grounded Gemini narratives with a **Sources** drawer tied to Layer A server facts. + - **Transactions** — Paginated, filterable transaction list for the selected statement (ground truth for spend). - **Upload** — Upload new statements; statement library lists all past uploads with month and institution labels. 5. Month picker and statement picker let users filter the dashboard across multiple months and accounts. 6. Multi-account support: bank accounts and credit cards are tracked separately; credit-card-safe math prevents card payments from inflating income. @@ -61,10 +62,10 @@ Fill in these values: | `POSTHOG_HOST` | Yes | PostHog host URL | | `NEXT_PUBLIC_APP_URL` | Yes | Public app URL used in recap links | | `CRON_SECRET` | Yes | Shared secret for cron routes | -| `NEXT_PUBLIC_SENTRY_DSN` | Yes | Sentry DSN | -| `SENTRY_AUTH_TOKEN` | Yes | Sentry auth token | -| `SENTRY_ORG` | Yes | Sentry org slug | -| `SENTRY_PROJECT` | Yes | Sentry project slug | +| `NEXT_PUBLIC_SENTRY_DSN` | No | Client Sentry DSN — optional locally if you skip browser reporting | +| `SENTRY_AUTH_TOKEN` | Yes\* | \*Required for production builds that upload source maps to Sentry | +| `SENTRY_ORG` | No | Optional locally — used by Sentry CLI / webpack plugin for releases | +| `SENTRY_PROJECT` | No | Optional locally — same as above | | `CI` | No | Optional CI build flag | ### 3. Create Neon project and enable Neon Auth @@ -91,8 +92,12 @@ Indexes created: - `idx_statements_user_created_at` - `idx_transactions_user_statement` +- `idx_transactions_user_date` (user_id, date DESC) +- `idx_transactions_user_merchant` (partial) where `merchant_key` is not null - `idx_advisory_feed_user_created_at` +Transaction rows include optional `merchant_key` (heuristic normalization for rollups; see `src/lib/merchant-normalize.ts`). New parses persist `merchant_key` on insert; existing rows can be backfilled via `POST /api/transactions/backfill-merchant-keys` (authenticated). + ### 5. Run locally ```bash @@ -172,24 +177,118 @@ Returns all processed statements for the authenticated user, sorted by creation **Returns**: Array of statement records including `statement_id`, `institution_name`, `statement_type`, `period_start`, `period_end`, `nickname`, `account_purpose`, `card_network`. -### `GET /api/dashboard?statement_id=` +### `GET /api/dashboard` + +Returns the full dashboard state for the authenticated user (Overview aggregates + generated advisories + optional `coaching_facts` Layer A JSON + Gemini-enriched `narrative` / `cited_fact_ids` on each advisory when AI succeeds). + +**Auth**: Neon Auth session cookie required. + +**Query (choose one mode):** + +- **Legacy (single statement):** `?statement_id=` optional — if omitted, uses the latest processed statement. +- **Unified scope:** `?date_from=YYYY-MM-DD&date_to=YYYY-MM-DD` plus optional `statement_ids=`. Omit `statement_ids` to include **all** processed statements in the date range. Response includes `scope` metadata and `perceived_is_profile_baseline`. + +### `GET /api/dashboard/advisories` + +Same query parameters as `GET /api/dashboard`. Returns `{ advisories, coaching_facts }` (same shapes as the parent dashboard payload). + +**Auth**: Neon Auth session cookie required. + +### `POST /api/dashboard/coaching-facts-expanded` + +Fires PostHog `coaching_facts_expanded` when the user opens **Sources** on an advisory card (server-side, fire-and-forget). + +**Auth**: Neon Auth session cookie required. + +**Body**: `{ "advisory_id": string }` + +**Returns**: `{ "ok": true }` + +### `POST /api/dashboard/scope-changed` + +Body: `{ "date_preset": string | null, "source_count": number }`. Fires PostHog `scope_changed` (server-side, fire-and-forget). + +**Auth**: Neon Auth session cookie required. + +### `GET /api/transactions` + +Paginated transactions for the authenticated user. Joins `statements` for nickname and institution labels. + +**Auth**: Neon Auth session cookie required. + +**Query params** (all optional except pagination defaults): -Returns the full dashboard state for the authenticated user. +| Param | Description | +| ---------------------- | ----------------------------------------------------------------------------------------------------- | +| `limit` | Max 100, default 50 | +| `offset` | Default 0 | +| `date_from`, `date_to` | `YYYY-MM-DD` (inclusive range) | +| `statement_id` | Single UUID; must belong to the user or **404** | +| `statement_ids` | Comma-separated UUIDs; each must belong to the user or **404** (takes precedence over `statement_id`) | +| `category` | `needs` \| `wants` \| `investment` \| `debt` \| `other` | +| `type` | `debit` \| `credit` | +| `search` | Substring match on description (max 200 chars) | +| `merchant_key` | Exact match on normalized merchant key | + +**Returns**: `{ transactions, total, limit, offset }`. + +### `POST /api/transactions/view-opened` + +Fires the `transactions_view_opened` PostHog event (server-side). Call once per browser session when the user opens the Transactions tab (the client uses `sessionStorage` to dedupe). **Auth**: Neon Auth session cookie required. -### `GET /api/dashboard/advisories?statement_id=` +**Returns**: `{ ok: true }`. + +### `POST /api/transactions/backfill-merchant-keys` -Returns the advisory subset for the authenticated user and statement. +Sets `merchant_key` for the current user’s rows where it is null, using `normalizeMerchantKey(description)`. **Auth**: Neon Auth session cookie required. +**Returns**: `{ ok: true, updated: number }`. + +### `GET /api/insights/merchants` + +Top merchants by **debit** spend for the current user, scoped like `GET /api/transactions` (same `date_from` / `date_to` / `statement_id` / `statement_ids` rules). Aggregates `GROUP BY merchant_key` for debits with a non-null key. + +**Auth**: Neon Auth session cookie required. + +**Query params**: + +| Param | Description | +| ---------------------- | ------------------------------------------------------------------------------------ | +| `limit` | Max 50, default 12 | +| `min_spend_paisa` | Minimum total debit paisa per merchant row (inclusive), default 0 | +| `date_from`, `date_to` | `YYYY-MM-DD` (inclusive), optional when using `statement_id`-only legacy scope | +| `statement_id` | Single UUID; **404** if not owned | +| `statement_ids` | Comma-separated UUIDs; **404** if any missing (takes precedence over `statement_id`) | + +**Returns**: `{ merchants: [{ merchant_key, debit_paisa, txn_count }], scope_total_debit_paisa, keyed_debit_paisa }` — `keyed_debit_paisa` is the sum of debits that have a `merchant_key` (used for reconciliation; the listed rows may be a top-N subset). + +### `POST /api/insights/merchant-click` + +Body: `{ "merchant_key": string }`. Fires PostHog `merchant_rollup_clicked` with a short **bucket** label (no raw key). Single emission source for that event. + +**Auth**: Neon Auth session cookie required. + +**Returns**: `{ ok: true }`. + ### `GET /api/cron/weekly-recap` Fan-out master route that finds all users with processed statements and triggers worker jobs. This is the scheduled entrypoint configured in [`vercel.json`](./vercel.json). **Auth**: `authorization: Bearer ` from Vercel Cron. Local/manual triggering may also use `x-cron-secret: `. +**Operator smoke (expected behavior):** + +| Request | Expected | +| ----------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- | +| No `Authorization` / no `x-cron-secret` | **401** JSON `{ "error": "Unauthorized" }` — by design; blocks unauthenticated fan-out and email sends. | +| `Authorization: Bearer ` or `x-cron-secret: ` | **200** `{ ok, total, succeeded, failed }` — `total` may be `0` if no eligible users. | + +Contract is asserted in [`__tests__/api/weekly-recap.test.ts`](./__tests__/api/weekly-recap.test.ts). + ### `POST /api/cron/weekly-recap/worker` Sends one recap email for one user. @@ -202,6 +301,16 @@ Sends one recap email for one user. { "userId": "user-id" } ``` +### `GET|POST|PUT|PATCH|DELETE /api/auth/[...path]` + +Neon Auth API catch-all (email OTP, session, callbacks). Implemented via `authApiHandler()` from `@neondatabase/auth/next/server`. + +**Returns**: JSON or redirects depending on subpath; not used directly by app code except through the Neon Auth client. + +### `GET /api/sentry-example-api` + +Intentional error route to verify Sentry server-side capture (`SentryExampleAPIError`). **Returns**: throws (500) — for local or staging verification only. + ## Analytics | Event | Where | Properties | @@ -216,6 +325,14 @@ Sends one recap email for one user. | `weekly_recap_completed` | `/api/cron/weekly-recap` | `total`, `succeeded`, `failed` | | `weekly_recap_email_sent` | `/api/cron/weekly-recap/worker` | `period_start`, `period_end`, `total_debits_paisa` | | `weekly_recap_email_failed` | `/api/cron/weekly-recap/worker` | `error` | +| `transactions_view_opened` | `/api/transactions/view-opened` | `surface` | +| `transactions_filter_applied` | `/api/transactions` (GET with any filter set) | `filter_types`, `scope` | +| `merchant_rollup_clicked` | `/api/insights/merchant-click` | `merchant_key_bucket`, `key_length` | +| `scope_changed` | `POST /api/dashboard/scope-changed` | `date_preset`, `source_count` | +| `coaching_narrative_completed` | `/api/dashboard` (after Gemini) | `latency_ms`, `advisory_count` (only when Gemini ran) | +| `coaching_narrative_timeout` | `/api/dashboard` | `timeout_ms` | +| `coaching_narrative_failed` | `/api/dashboard` | `error_type`, optional `detail` | +| `coaching_facts_expanded` | `POST /api/dashboard/coaching-facts-expanded` | `advisory_id` | ## Key design decisions @@ -227,7 +344,7 @@ Sends one recap email for one user. ## Docs -- [`docs/COACHING-TONE.md`](./docs/COACHING-TONE.md) — Coaching language guide: consequence-first nudge patterns, safe AI narrative rules, tone constraints for advisory copy. +- [`docs/COACHING-TONE.md`](./docs/COACHING-TONE.md) — Coaching language guide: consequence-first nudge patterns, Layer A facts-only numerics, Gemini narrative guardrails, tone constraints for advisory copy. ## Current scope @@ -238,13 +355,14 @@ Sends one recap email for one user. - Multi-bank bank-account PDF parsing (Kotak, HDFC, and others via Gemini) - Credit-card PDF parsing with card-safe summary math - Upload labels: `nickname`, `account_purpose`, `card_network` -- 3-tab dashboard: Overview (mirror card + summary), Insights (AI advisories), Upload (statement library) +- 4-tab dashboard: Overview (mirror card + summary), Insights (AI advisories), Transactions (list + filters), Upload (statement library) - Mirror card: perceived vs actual spend side-by-side - Statement library with month picker and statement picker - Multi-account support (G1): separate tracking per bank account and credit card - 5 advisory triggers + expanded categorizer - Weekly recap email via Resend (Monday 8:00 AM IST Vercel cron) -- 10 PostHog analytics events (server-side) +- Phase 3 T4: Zod-validated **Layer A** facts (`src/lib/coaching-facts.ts`), Gemini structured narratives with `cited_fact_ids` validation, **Sources** drawer (`FactsDrawer`) +- 15+ PostHog analytics events (server-side; includes coaching narrative + sources expansion) **Not shipped (Sprint 4 backlog):** diff --git a/apps/money-mirror/__tests__/api/weekly-recap.test.ts b/apps/money-mirror/__tests__/api/weekly-recap.test.ts index e97ddc6..190d78c 100644 --- a/apps/money-mirror/__tests__/api/weekly-recap.test.ts +++ b/apps/money-mirror/__tests__/api/weekly-recap.test.ts @@ -49,6 +49,23 @@ describe('/api/cron/weekly-recap', () => { const res = await GET(makeGetRequest()); expect(res.status).toBe(401); + const body = await res.json(); + expect(body).toEqual({ error: 'Unauthorized' }); + }); + + it('returns 401 when Authorization bearer does not match CRON_SECRET', async () => { + const { GET } = await getRoute(); + const res = await GET(makeGetRequest({ authorization: 'Bearer wrong-secret' })); + expect(res.status).toBe(401); + }); + + it('accepts GET with x-cron-secret for local or manual smoke', async () => { + const { GET } = await getRoute(); + const res = await GET(makeGetRequest({ 'x-cron-secret': 'test-secret' })); + const body = await res.json(); + + expect(res.status).toBe(200); + expect(body).toEqual({ ok: true, total: 2, succeeded: 2, failed: 0 }); }); it('accepts the Vercel cron GET contract with bearer auth', async () => { diff --git a/apps/money-mirror/docs/COACHING-TONE.md b/apps/money-mirror/docs/COACHING-TONE.md index 0884946..e72cc8b 100644 --- a/apps/money-mirror/docs/COACHING-TONE.md +++ b/apps/money-mirror/docs/COACHING-TONE.md @@ -15,10 +15,17 @@ Educational, India-first personal finance copy. Used for in-app insights and any - Users should consult a qualified professional for regulated decisions. - Do **not** impersonate real individuals or creators; use archetypes (educator, myth-buster) if needed, not names. -## Prompt templates (if using Gemini for narrative) +## Layer A facts (ground truth) + +- All **rupee amounts, percentages, and counts** shown to users must come from **server-built Layer A facts** (`coaching_facts` in API responses) or directly from persisted statement/profile fields — never from free-form model output alone. +- Gemini writes **narrative prose only**; structured output lists `cited_fact_ids` that must each exist in Layer A. If validation fails, the UI falls back to rule-based `message` text (still computed from the same deterministic engine). +- **No new numbers in prose:** prompts instruct the model not to output `₹` or digit runs in narrative text; figures appear under **Sources** from facts. + +## Prompt templates (Gemini narrative) - Structure: **Observation** → **Why it matters** → **One practical next step** (optional). - Append: “This is general information based on your uploaded statement, not a recommendation.” +- **Expert-style example (pattern only):** “Your statement mix leans heavily on discretionary buckets — that matters because small recurring cuts free cash without touching fixed obligations. One next step: pick a single recurring charge to audit this month.” (No amounts in prose — user opens **Sources** for figures.) ## Archetypes (optional user preference) diff --git a/apps/money-mirror/package.json b/apps/money-mirror/package.json index 7f1a7e5..1486402 100644 --- a/apps/money-mirror/package.json +++ b/apps/money-mirror/package.json @@ -26,7 +26,8 @@ "react": "19.2.4", "react-dom": "19.2.4", "resend": "^6.10.0", - "tailwind-merge": "^3.5.0" + "tailwind-merge": "^3.5.0", + "zod": "^4.3.6" }, "devDependencies": { "@tailwindcss/postcss": "^4", diff --git a/apps/money-mirror/schema.sql b/apps/money-mirror/schema.sql index 9fe68d4..4a028db 100644 --- a/apps/money-mirror/schema.sql +++ b/apps/money-mirror/schema.sql @@ -45,6 +45,7 @@ CREATE TABLE IF NOT EXISTS public.transactions ( type TEXT NOT NULL CHECK (type IN ('debit', 'credit')), category TEXT NOT NULL CHECK (category IN ('needs', 'wants', 'investment', 'debt', 'other')), is_recurring BOOLEAN NOT NULL DEFAULT false, + merchant_key TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); @@ -67,6 +68,13 @@ CREATE INDEX IF NOT EXISTS idx_statements_user_created_at CREATE INDEX IF NOT EXISTS idx_transactions_user_statement ON public.transactions(user_id, statement_id); +CREATE INDEX IF NOT EXISTS idx_transactions_user_date + ON public.transactions(user_id, date DESC); + +CREATE INDEX IF NOT EXISTS idx_transactions_user_merchant + ON public.transactions(user_id, merchant_key) + WHERE merchant_key IS NOT NULL; + CREATE INDEX IF NOT EXISTS idx_advisory_feed_user_created_at ON public.advisory_feed(user_id, created_at DESC); @@ -74,3 +82,5 @@ CREATE INDEX IF NOT EXISTS idx_advisory_feed_user_created_at ALTER TABLE public.statements ADD COLUMN IF NOT EXISTS nickname TEXT; ALTER TABLE public.statements ADD COLUMN IF NOT EXISTS account_purpose TEXT; ALTER TABLE public.statements ADD COLUMN IF NOT EXISTS card_network TEXT; + +ALTER TABLE public.transactions ADD COLUMN IF NOT EXISTS merchant_key TEXT; diff --git a/apps/money-mirror/src/app/api/dashboard/advisories/route.ts b/apps/money-mirror/src/app/api/dashboard/advisories/route.ts index f24f8d6..faf81eb 100644 --- a/apps/money-mirror/src/app/api/dashboard/advisories/route.ts +++ b/apps/money-mirror/src/app/api/dashboard/advisories/route.ts @@ -1,36 +1,56 @@ /** - * GET /api/dashboard/advisories?statement_id= + * GET /api/dashboard/advisories * - * Fetches parsed transaction data from the DB and generates - * advisory feed items using the advisory engine. + * Same query parameters as GET /api/dashboard (legacy `statement_id` or unified + * `date_from` + `date_to` + optional `statement_ids`). */ +import * as Sentry from '@sentry/nextjs'; import { NextRequest, NextResponse } from 'next/server'; import { getSessionUser } from '@/lib/auth/session'; import { ensureProfile } from '@/lib/db'; -import { fetchDashboardData } from '@/lib/dashboard'; +import { attachCoachingLayer } from '@/lib/coaching-enrich'; +import { fetchDashboardData, type DashboardFetchInput } from '@/lib/dashboard'; +import { parseDashboardScopeFromSearchParams } from '@/lib/scope'; export async function GET(req: NextRequest): Promise { - const statementId = req.nextUrl.searchParams.get('statement_id'); - - if (!statementId) { - return NextResponse.json({ error: 'statement_id is required' }, { status: 400 }); - } - const user = await getSessionUser(); if (!user) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); } + const parsed = parseDashboardScopeFromSearchParams(req.nextUrl.searchParams); + if ('error' in parsed) { + return NextResponse.json({ error: parsed.error }, { status: 400 }); + } + + let input: DashboardFetchInput; + if (parsed.variant === 'unified') { + input = { + variant: 'unified', + dateFrom: parsed.scope.dateFrom, + dateTo: parsed.scope.dateTo, + statementIds: parsed.scope.statementIds, + }; + } else { + input = { variant: 'legacy', statementId: parsed.statementId }; + } + try { await ensureProfile({ id: user.id, email: user.email }); - const dashboard = await fetchDashboardData(user.id, statementId); + const dashboard = await fetchDashboardData(user.id, input); if (!dashboard) { return NextResponse.json({ error: 'Statement not found' }, { status: 404 }); } - return NextResponse.json({ advisories: dashboard.advisories }); - } catch { - return NextResponse.json({ error: 'Failed to fetch transactions' }, { status: 500 }); + const enriched = await attachCoachingLayer(user.id, dashboard); + return NextResponse.json({ + advisories: enriched.advisories, + coaching_facts: enriched.coaching_facts, + }); + } catch (err) { + Sentry.captureException(err); + console.error('[GET /api/dashboard/advisories]', err); + return NextResponse.json({ error: 'Failed to fetch advisories' }, { status: 500 }); } } diff --git a/apps/money-mirror/src/app/api/dashboard/coaching-facts-expanded/route.ts b/apps/money-mirror/src/app/api/dashboard/coaching-facts-expanded/route.ts new file mode 100644 index 0000000..bf80704 --- /dev/null +++ b/apps/money-mirror/src/app/api/dashboard/coaching-facts-expanded/route.ts @@ -0,0 +1,31 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getSessionUser } from '@/lib/auth/session'; +import { captureServerEvent } from '@/lib/posthog'; + +/** + * POST — fires when user expands Sources on a coaching card (single emission: server-side). + */ +export async function POST(req: NextRequest): Promise { + const user = await getSessionUser(); + if (!user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + let body: { advisory_id?: string }; + try { + body = (await req.json()) as { advisory_id?: string }; + } catch { + return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 }); + } + + const advisoryId = typeof body.advisory_id === 'string' ? body.advisory_id.trim() : ''; + if (!advisoryId) { + return NextResponse.json({ error: 'advisory_id required' }, { status: 400 }); + } + + void captureServerEvent(user.id, 'coaching_facts_expanded', { advisory_id: advisoryId }).catch( + () => {} + ); + + return NextResponse.json({ ok: true }); +} diff --git a/apps/money-mirror/src/app/api/dashboard/route.ts b/apps/money-mirror/src/app/api/dashboard/route.ts index f243eb0..3591e73 100644 --- a/apps/money-mirror/src/app/api/dashboard/route.ts +++ b/apps/money-mirror/src/app/api/dashboard/route.ts @@ -1,24 +1,46 @@ +import * as Sentry from '@sentry/nextjs'; import { NextRequest, NextResponse } from 'next/server'; import { getSessionUser } from '@/lib/auth/session'; import { ensureProfile } from '@/lib/db'; -import { fetchDashboardData } from '@/lib/dashboard'; +import { attachCoachingLayer } from '@/lib/coaching-enrich'; +import { fetchDashboardData, type DashboardFetchInput } from '@/lib/dashboard'; +import { parseDashboardScopeFromSearchParams } from '@/lib/scope'; export async function GET(req: NextRequest): Promise { - const statementId = req.nextUrl.searchParams.get('statement_id'); const user = await getSessionUser(); if (!user) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); } + const parsed = parseDashboardScopeFromSearchParams(req.nextUrl.searchParams); + if ('error' in parsed) { + return NextResponse.json({ error: parsed.error }, { status: 400 }); + } + + let input: DashboardFetchInput; + if (parsed.variant === 'unified') { + input = { + variant: 'unified', + dateFrom: parsed.scope.dateFrom, + dateTo: parsed.scope.dateTo, + statementIds: parsed.scope.statementIds, + }; + } else { + input = { variant: 'legacy', statementId: parsed.statementId }; + } + try { await ensureProfile({ id: user.id, email: user.email }); - const dashboard = await fetchDashboardData(user.id, statementId); + const dashboard = await fetchDashboardData(user.id, input); if (!dashboard) { return NextResponse.json({ error: 'Dashboard not found' }, { status: 404 }); } - return NextResponse.json(dashboard); - } catch { + const enriched = await attachCoachingLayer(user.id, dashboard); + return NextResponse.json(enriched); + } catch (err) { + Sentry.captureException(err); + console.error('[GET /api/dashboard]', err); return NextResponse.json({ error: 'Failed to load dashboard data' }, { status: 500 }); } } diff --git a/apps/money-mirror/src/app/api/dashboard/scope-changed/route.ts b/apps/money-mirror/src/app/api/dashboard/scope-changed/route.ts new file mode 100644 index 0000000..658c5a4 --- /dev/null +++ b/apps/money-mirror/src/app/api/dashboard/scope-changed/route.ts @@ -0,0 +1,43 @@ +/** + * POST /api/dashboard/scope-changed + * + * Single emission source for `scope_changed` (server-side PostHog). + */ + +import { NextRequest, NextResponse } from 'next/server'; +import { getSessionUser } from '@/lib/auth/session'; +import { ensureProfile } from '@/lib/db'; +import { captureServerEvent } from '@/lib/posthog'; + +export async function POST(req: NextRequest): Promise { + const user = await getSessionUser(); + if (!user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + let body: { date_preset?: string | null; source_count?: number }; + try { + body = (await req.json()) as { date_preset?: string | null; source_count?: number }; + } catch { + return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 }); + } + + const datePreset = + body.date_preset === null || body.date_preset === undefined + ? null + : String(body.date_preset).slice(0, 64); + const sourceCount = Number.isFinite(body.source_count) + ? Math.max(0, Math.min(500, Math.floor(body.source_count as number))) + : 0; + + try { + await ensureProfile({ id: user.id, email: user.email }); + void captureServerEvent(user.id, 'scope_changed', { + date_preset: datePreset, + source_count: sourceCount, + }).catch(() => {}); + return NextResponse.json({ ok: true }); + } catch { + return NextResponse.json({ error: 'Failed' }, { status: 500 }); + } +} diff --git a/apps/money-mirror/src/app/api/insights/merchant-click/route.ts b/apps/money-mirror/src/app/api/insights/merchant-click/route.ts new file mode 100644 index 0000000..dc71c34 --- /dev/null +++ b/apps/money-mirror/src/app/api/insights/merchant-click/route.ts @@ -0,0 +1,43 @@ +/** + * POST /api/insights/merchant-click + * Single emission source for `merchant_rollup_clicked` (server-side). + */ + +import { NextRequest, NextResponse } from 'next/server'; +import { getSessionUser } from '@/lib/auth/session'; +import { captureServerEvent } from '@/lib/posthog'; + +function bucketKey(raw: string): string { + const s = raw.trim().toLowerCase(); + if (s.length <= 2) { + return 'short'; + } + const head = s.slice(0, 8); + return head.replace(/[^a-z0-9]/g, '_'); +} + +export async function POST(req: NextRequest): Promise { + const user = await getSessionUser(); + if (!user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + let body: { merchant_key?: string }; + try { + body = (await req.json()) as { merchant_key?: string }; + } catch { + return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 }); + } + + const key = typeof body.merchant_key === 'string' ? body.merchant_key.trim() : ''; + if (!key || key.length > 128) { + return NextResponse.json({ error: 'merchant_key required' }, { status: 400 }); + } + + void captureServerEvent(user.id, 'merchant_rollup_clicked', { + merchant_key_bucket: bucketKey(key), + key_length: key.length, + }).catch(() => {}); + + return NextResponse.json({ ok: true }); +} diff --git a/apps/money-mirror/src/app/api/insights/merchants/__tests__/route.test.ts b/apps/money-mirror/src/app/api/insights/merchants/__tests__/route.test.ts new file mode 100644 index 0000000..7c8b0e1 --- /dev/null +++ b/apps/money-mirror/src/app/api/insights/merchants/__tests__/route.test.ts @@ -0,0 +1,72 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { NextRequest } from 'next/server'; + +const mockGetSessionUser = vi.fn(); +const mockEnsureProfile = vi.fn(); + +const mockSql = vi.fn(); + +vi.mock('@/lib/auth/session', () => ({ + getSessionUser: mockGetSessionUser, +})); + +vi.mock('@/lib/db', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + ensureProfile: mockEnsureProfile, + getDb: () => mockSql, + }; +}); + +async function getGet() { + const mod = await import('@/app/api/insights/merchants/route'); + return mod.GET; +} + +function makeRequest(url: string) { + return new NextRequest(url); +} + +describe('GET /api/insights/merchants', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockGetSessionUser.mockResolvedValue({ + id: 'user-123', + email: 'u@example.com', + name: 'U', + }); + mockEnsureProfile.mockResolvedValue(undefined); + + mockSql + .mockResolvedValueOnce([{ ok: 1 }]) + .mockResolvedValueOnce([{ merchant_key: 'zomato', debit_paisa: '5000', txn_count: '2' }]) + .mockResolvedValueOnce([{ s: '10000' }]) + .mockResolvedValueOnce([{ s: '5000' }]); + }); + + it('returns 401 when unauthenticated', async () => { + mockGetSessionUser.mockResolvedValueOnce(null); + const GET = await getGet(); + const res = await GET( + makeRequest( + 'http://localhost/api/insights/merchants?statement_id=00000000-0000-4000-8000-000000000001' + ) + ); + expect(res.status).toBe(401); + }); + + it('returns merchants for legacy statement scope', async () => { + const GET = await getGet(); + const res = await GET( + makeRequest( + 'http://localhost/api/insights/merchants?statement_id=00000000-0000-4000-8000-000000000001' + ) + ); + expect(res.status).toBe(200); + const body = (await res.json()) as { + merchants: { merchant_key: string; debit_paisa: number }[]; + }; + expect(body.merchants[0]?.merchant_key).toBe('zomato'); + }); +}); diff --git a/apps/money-mirror/src/app/api/insights/merchants/route.ts b/apps/money-mirror/src/app/api/insights/merchants/route.ts new file mode 100644 index 0000000..5e3fef6 --- /dev/null +++ b/apps/money-mirror/src/app/api/insights/merchants/route.ts @@ -0,0 +1,136 @@ +/** + * GET /api/insights/merchants + * + * Top merchants by debit spend for the authenticated user, scoped like GET /api/transactions. + */ + +import { NextRequest, NextResponse } from 'next/server'; +import { getSessionUser } from '@/lib/auth/session'; +import { ensureProfile, getDb } from '@/lib/db'; +import { + listMerchantRollups, + MERCHANT_ROLLUPS_MAX_LIMIT, + sumKeyedDebitPaisa, + sumScopeDebitPaisa, + type MerchantRollupParams, +} from '@/lib/merchant-rollups'; +import { parseStatementIdsParam } from '@/lib/scope'; + +function parseDateOnly(raw: string | null): string | null { + if (!raw) { + return null; + } + if (!/^\d{4}-\d{2}-\d{2}$/.test(raw)) { + return 'invalid'; + } + const d = new Date(`${raw}T00:00:00Z`); + if (Number.isNaN(d.getTime())) { + return 'invalid'; + } + return raw; +} + +function parseUuid(raw: string | null): string | null { + if (!raw) { + return null; + } + if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(raw)) { + return 'invalid'; + } + return raw; +} + +export async function GET(req: NextRequest): Promise { + const user = await getSessionUser(); + if (!user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const sp = req.nextUrl.searchParams; + const limitRaw = sp.get('limit'); + const limit = Math.min( + MERCHANT_ROLLUPS_MAX_LIMIT, + Math.max(1, limitRaw ? Number.parseInt(limitRaw, 10) || 12 : 12) + ); + + const minSpendRaw = sp.get('min_spend_paisa'); + const minDebitPaisa = Math.max(0, minSpendRaw ? Number.parseInt(minSpendRaw, 10) || 0 : 0); + + const dateFrom = parseDateOnly(sp.get('date_from')); + const dateTo = parseDateOnly(sp.get('date_to')); + if (dateFrom === 'invalid' || dateTo === 'invalid') { + return NextResponse.json( + { error: 'Invalid date_from or date_to (use YYYY-MM-DD).' }, + { status: 400 } + ); + } + + let statementId = parseUuid(sp.get('statement_id')); + if (statementId === 'invalid') { + return NextResponse.json({ error: 'Invalid statement_id.' }, { status: 400 }); + } + + const idsParsed = parseStatementIdsParam(sp.get('statement_ids')); + if (!idsParsed.ok) { + return NextResponse.json({ error: idsParsed.error }, { status: 400 }); + } + const statementIdsList = idsParsed.value; + if (statementIdsList && statementIdsList.length > 0) { + statementId = null; + } + + const params: MerchantRollupParams = { + userId: user.id, + dateFrom, + dateTo, + statementId, + statementIds: statementIdsList && statementIdsList.length > 0 ? statementIdsList : null, + minDebitPaisa, + limit, + }; + + try { + await ensureProfile({ id: user.id, email: user.email }); + const sql = getDb(); + + if (params.statementIds && params.statementIds.length > 0) { + const owned = (await sql` + SELECT id + FROM statements + WHERE user_id = ${user.id} + AND id = ANY(${params.statementIds}::uuid[]) + `) as { id: string }[]; + if (owned.length !== params.statementIds.length) { + return NextResponse.json({ error: 'Statement not found' }, { status: 404 }); + } + } else if (statementId) { + const ownsRows = await sql` + SELECT 1 AS ok + FROM statements + WHERE id = ${statementId}::uuid + AND user_id = ${user.id} + LIMIT 1 + `; + const owns = (ownsRows as { ok: number }[])[0]; + if (!owns) { + return NextResponse.json({ error: 'Statement not found' }, { status: 404 }); + } + } + + const [merchants, scope_total_debit_paisa, keyed_debit_sum] = await Promise.all([ + listMerchantRollups(sql, params), + sumScopeDebitPaisa(sql, params), + sumKeyedDebitPaisa(sql, params), + ]); + + return NextResponse.json({ + merchants, + scope_total_debit_paisa, + /** Sum of debits that have a non-null merchant_key (subset of scope_total_debit_paisa). */ + keyed_debit_paisa: keyed_debit_sum, + }); + } catch (e) { + console.error('[insights/merchants] GET failed:', e); + return NextResponse.json({ error: 'Failed to load merchant insights' }, { status: 500 }); + } +} diff --git a/apps/money-mirror/src/app/api/statement/parse/persist-statement.ts b/apps/money-mirror/src/app/api/statement/parse/persist-statement.ts index 2970383..f6dc4e8 100644 --- a/apps/money-mirror/src/app/api/statement/parse/persist-statement.ts +++ b/apps/money-mirror/src/app/api/statement/parse/persist-statement.ts @@ -1,5 +1,6 @@ import { randomUUID } from 'node:crypto'; import { getDb, getProfileFinancialSnapshot } from '@/lib/db'; +import { normalizeMerchantKey } from '@/lib/merchant-normalize'; import { captureServerEvent } from '@/lib/posthog'; import type { StatementType } from '@/lib/statements'; @@ -57,9 +58,9 @@ export async function persistStatement( try { const profile = await getProfileFinancialSnapshot(userId); - const transactionQueries = categorized.map( - (tx) => - sql` + const transactionQueries = categorized.map((tx) => { + const merchantKey = normalizeMerchantKey(tx.description); + return sql` INSERT INTO transactions ( id, statement_id, @@ -69,7 +70,8 @@ export async function persistStatement( amount_paisa, type, category, - is_recurring + is_recurring, + merchant_key ) VALUES ( ${randomUUID()}, @@ -80,10 +82,11 @@ export async function persistStatement( ${tx.amount_paisa}, ${tx.type}, ${tx.category}, - ${tx.is_recurring} + ${tx.is_recurring}, + ${merchantKey} ) - ` - ); + `; + }); await sql.transaction([ sql` diff --git a/apps/money-mirror/src/app/api/transactions/__tests__/route.test.ts b/apps/money-mirror/src/app/api/transactions/__tests__/route.test.ts new file mode 100644 index 0000000..4aa9ac6 --- /dev/null +++ b/apps/money-mirror/src/app/api/transactions/__tests__/route.test.ts @@ -0,0 +1,128 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { NextRequest } from 'next/server'; + +const mockGetSessionUser = vi.fn(); +const mockEnsureProfile = vi.fn(); +const mockCaptureServerEvent = vi.fn(); + +const mockSql = vi.fn(); + +vi.mock('@/lib/auth/session', () => ({ + getSessionUser: mockGetSessionUser, +})); + +vi.mock('@/lib/db', () => ({ + ensureProfile: mockEnsureProfile, + getDb: () => mockSql, +})); + +vi.mock('@/lib/posthog', () => ({ + captureServerEvent: mockCaptureServerEvent, +})); + +async function getGet() { + const mod = await import('@/app/api/transactions/route'); + return mod.GET; +} + +function makeRequest(url: string) { + return new NextRequest(url); +} + +describe('GET /api/transactions', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockGetSessionUser.mockResolvedValue({ + id: 'user-123', + email: 'u@example.com', + name: 'U', + }); + mockEnsureProfile.mockResolvedValue(undefined); + mockCaptureServerEvent.mockResolvedValue(undefined); + + let call = 0; + mockSql.mockImplementation(() => { + call += 1; + // ownership check for statement_id + if (call === 1) { + return Promise.resolve([{ ok: 1 }]); + } + // count + if (call === 2) { + return Promise.resolve([{ c: '2' }]); + } + // list + return Promise.resolve([ + { + id: 't1', + statement_id: 's1', + date: '2026-01-15', + description: 'Test', + amount_paisa: 100, + type: 'debit', + category: 'wants', + is_recurring: false, + merchant_key: 'zomato', + statement_nickname: 'Main', + statement_institution_name: 'HDFC', + }, + ]); + }); + }); + + it('returns 401 when unauthenticated', async () => { + mockGetSessionUser.mockResolvedValueOnce(null); + const GET = await getGet(); + const res = await GET(makeRequest('http://localhost/api/transactions')); + expect(res.status).toBe(401); + }); + + it('returns 400 for invalid category', async () => { + const GET = await getGet(); + const res = await GET(makeRequest('http://localhost/api/transactions?category=invalid')); + expect(res.status).toBe(400); + }); + + it('returns transactions for authenticated user', async () => { + mockSql.mockImplementation((strings: TemplateStringsArray) => { + const q = strings[0]?.slice(0, 80) ?? ''; + if (q.includes('COUNT(*)')) { + return Promise.resolve([{ c: '1' }]); + } + return Promise.resolve([ + { + id: 't1', + statement_id: 's1', + date: '2026-01-15', + description: 'Test', + amount_paisa: 100, + type: 'debit', + category: 'wants', + is_recurring: false, + merchant_key: null, + statement_nickname: null, + statement_institution_name: 'HDFC', + }, + ]); + }); + + const GET = await getGet(); + const res = await GET(makeRequest('http://localhost/api/transactions?limit=10')); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.transactions).toHaveLength(1); + expect(body.total).toBe(1); + }); + + it('returns 404 when statement_id is not owned', async () => { + mockSql.mockImplementationOnce(() => Promise.resolve([])); + + const GET = await getGet(); + const res = await GET( + makeRequest( + 'http://localhost/api/transactions?statement_id=00000000-0000-4000-8000-000000000001' + ) + ); + expect(res.status).toBe(404); + }); +}); diff --git a/apps/money-mirror/src/app/api/transactions/backfill-merchant-keys/route.ts b/apps/money-mirror/src/app/api/transactions/backfill-merchant-keys/route.ts new file mode 100644 index 0000000..bef4c29 --- /dev/null +++ b/apps/money-mirror/src/app/api/transactions/backfill-merchant-keys/route.ts @@ -0,0 +1,82 @@ +/** + * POST /api/transactions/backfill-merchant-keys + * + * One-time per-user backfill of merchant_key for rows where it is null. + */ + +import * as Sentry from '@sentry/nextjs'; +import { NextResponse } from 'next/server'; +import { getSessionUser } from '@/lib/auth/session'; +import { getDb } from '@/lib/db'; +import { normalizeMerchantKey } from '@/lib/merchant-normalize'; + +const BATCH = 500; + +export async function POST(): Promise { + const user = await getSessionUser(); + if (!user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const sql = getDb(); + let updated = 0; + let cursor: string | null = null; + + try { + for (;;) { + const rows = + cursor === null + ? await sql` + SELECT id, description + FROM transactions + WHERE user_id = ${user.id} + AND merchant_key IS NULL + ORDER BY id ASC + LIMIT ${BATCH} + ` + : await sql` + SELECT id, description + FROM transactions + WHERE user_id = ${user.id} + AND merchant_key IS NULL + AND id > ${cursor}::uuid + ORDER BY id ASC + LIMIT ${BATCH} + `; + const batch = rows as { id: string; description: string }[]; + if (batch.length === 0) { + break; + } + + // Accumulate normalizable pairs, then batch UPDATE in one round-trip + const ids: string[] = []; + const keys: string[] = []; + for (const row of batch) { + const key = normalizeMerchantKey(row.description); + if (key !== null) { + ids.push(row.id); + keys.push(key); + } + } + + if (ids.length > 0) { + await sql` + UPDATE transactions + SET merchant_key = data.key + FROM unnest(${ids}::uuid[], ${keys}::text[]) AS data(id, key) + WHERE transactions.id = data.id + AND transactions.user_id = ${user.id} + `; + updated += ids.length; + } + + cursor = batch[batch.length - 1]?.id ?? null; + } + + return NextResponse.json({ ok: true, updated }); + } catch (e) { + Sentry.captureException(e); + console.error('[backfill-merchant-keys] failed:', e); + return NextResponse.json({ error: 'Backfill failed' }, { status: 500 }); + } +} diff --git a/apps/money-mirror/src/app/api/transactions/route.ts b/apps/money-mirror/src/app/api/transactions/route.ts new file mode 100644 index 0000000..7588457 --- /dev/null +++ b/apps/money-mirror/src/app/api/transactions/route.ts @@ -0,0 +1,211 @@ +/** + * GET /api/transactions + * + * Paginated transactions for the authenticated user. Single emission source for + * `transactions_filter_applied` when any filter beyond pagination is active (server-side). + */ + +import * as Sentry from '@sentry/nextjs'; +import { NextRequest, NextResponse } from 'next/server'; +import { getSessionUser } from '@/lib/auth/session'; +import { ensureProfile, getDb } from '@/lib/db'; +import { captureServerEvent } from '@/lib/posthog'; +import { + TRANSACTIONS_MAX_LIMIT, + countTransactions, + listTransactions, + type ListTransactionsParams, +} from '@/lib/transactions-list'; +import { parseStatementIdsParam } from '@/lib/scope'; + +const CATEGORIES = new Set(['needs', 'wants', 'investment', 'debt', 'other']); +const TYPES = new Set(['debit', 'credit']); + +function parseDateOnly(raw: string | null): string | null { + if (!raw) { + return null; + } + if (!/^\d{4}-\d{2}-\d{2}$/.test(raw)) { + return 'invalid'; + } + const d = new Date(`${raw}T00:00:00Z`); + if (Number.isNaN(d.getTime())) { + return 'invalid'; + } + return raw; +} + +function parseUuid(raw: string | null): string | null { + if (!raw) { + return null; + } + if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(raw)) { + return 'invalid'; + } + return raw; +} + +export async function GET(req: NextRequest): Promise { + const user = await getSessionUser(); + if (!user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const sp = req.nextUrl.searchParams; + const limitRaw = sp.get('limit'); + const offsetRaw = sp.get('offset'); + const limit = Math.min( + TRANSACTIONS_MAX_LIMIT, + Math.max(1, limitRaw ? Number.parseInt(limitRaw, 10) || 50 : 50) + ); + const offset = Math.max(0, offsetRaw ? Number.parseInt(offsetRaw, 10) || 0 : 0); + + const dateFrom = parseDateOnly(sp.get('date_from')); + const dateTo = parseDateOnly(sp.get('date_to')); + if (dateFrom === 'invalid' || dateTo === 'invalid') { + return NextResponse.json( + { error: 'Invalid date_from or date_to (use YYYY-MM-DD).' }, + { status: 400 } + ); + } + + let statementId = parseUuid(sp.get('statement_id')); + if (statementId === 'invalid') { + return NextResponse.json({ error: 'Invalid statement_id.' }, { status: 400 }); + } + + const idsParsed = parseStatementIdsParam(sp.get('statement_ids')); + if (!idsParsed.ok) { + return NextResponse.json({ error: idsParsed.error }, { status: 400 }); + } + const statementIdsList = idsParsed.value; + if (statementIdsList && statementIdsList.length > 0) { + statementId = null; + } + + const category = sp.get('category'); + if (category && !CATEGORIES.has(category)) { + return NextResponse.json({ error: 'Invalid category.' }, { status: 400 }); + } + + const type = sp.get('type') as 'debit' | 'credit' | null; + if (type && !TYPES.has(type)) { + return NextResponse.json({ error: 'Invalid type.' }, { status: 400 }); + } + + let search = sp.get('search')?.trim() ?? null; + if (search && search.length > 200) { + return NextResponse.json({ error: 'search must be at most 200 characters.' }, { status: 400 }); + } + if (search === '') { + search = null; + } + + const merchantKeyRaw = sp.get('merchant_key')?.trim() ?? null; + if (merchantKeyRaw && merchantKeyRaw.length > 128) { + return NextResponse.json({ error: 'Invalid merchant_key.' }, { status: 400 }); + } + const merchantKey = merchantKeyRaw || null; + + const params: ListTransactionsParams = { + userId: user.id, + dateFrom, + dateTo, + statementId, + statementIds: statementIdsList && statementIdsList.length > 0 ? statementIdsList : null, + category: category || null, + type: type || null, + search, + merchantKey, + limit, + offset, + }; + + try { + await ensureProfile({ id: user.id, email: user.email }); + const sql = getDb(); + + if (params.statementIds && params.statementIds.length > 0) { + const owned = (await sql` + SELECT id + FROM statements + WHERE user_id = ${user.id} + AND id = ANY(${params.statementIds}::uuid[]) + `) as { id: string }[]; + if (owned.length !== params.statementIds.length) { + return NextResponse.json({ error: 'Statement not found' }, { status: 404 }); + } + } else if (statementId) { + const ownsRows = await sql` + SELECT 1 AS ok + FROM statements + WHERE id = ${statementId}::uuid + AND user_id = ${user.id} + LIMIT 1 + `; + const owns = (ownsRows as { ok: number }[])[0]; + if (!owns) { + return NextResponse.json({ error: 'Statement not found' }, { status: 404 }); + } + } + + const [total, transactions] = await Promise.all([ + countTransactions(sql, params), + listTransactions(sql, params), + ]); + + const hasFilters = Boolean( + dateFrom || + dateTo || + statementId || + (params.statementIds && params.statementIds.length > 0) || + category || + type || + search || + merchantKey + ); + if (hasFilters) { + const filterTypes: string[] = []; + if (dateFrom || dateTo) { + filterTypes.push('date_range'); + } + if (params.statementIds && params.statementIds.length > 0) { + filterTypes.push('statement_ids'); + } else if (statementId) { + filterTypes.push('statement_id'); + } + if (category) { + filterTypes.push('category'); + } + if (type) { + filterTypes.push('type'); + } + if (search) { + filterTypes.push('search'); + } + if (merchantKey) { + filterTypes.push('merchant_key'); + } + void captureServerEvent(user.id, 'transactions_filter_applied', { + filter_types: filterTypes, + scope: + params.statementIds && params.statementIds.length > 1 + ? 'multi_statement' + : statementId || (params.statementIds && params.statementIds.length === 1) + ? 'statement' + : 'all', + }).catch(() => {}); + } + + return NextResponse.json({ + transactions, + total, + limit, + offset, + }); + } catch (e) { + Sentry.captureException(e); + console.error('[transactions] GET failed:', e); + return NextResponse.json({ error: 'Failed to load transactions' }, { status: 500 }); + } +} diff --git a/apps/money-mirror/src/app/api/transactions/view-opened/route.ts b/apps/money-mirror/src/app/api/transactions/view-opened/route.ts new file mode 100644 index 0000000..21f3cd5 --- /dev/null +++ b/apps/money-mirror/src/app/api/transactions/view-opened/route.ts @@ -0,0 +1,30 @@ +/** + * POST /api/transactions/view-opened + * + * Single emission source for `transactions_view_opened` (server-side PostHog). + * Client should call once per session when the Transactions surface is shown (sessionStorage guard). + */ + +import { NextResponse } from 'next/server'; +import { getSessionUser } from '@/lib/auth/session'; +import { ensureProfile } from '@/lib/db'; +import { captureServerEvent } from '@/lib/posthog'; + +export async function POST(): Promise { + const user = await getSessionUser(); + if (!user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + try { + await ensureProfile({ id: user.id, email: user.email }); + } catch { + return NextResponse.json({ error: 'Failed to ensure profile' }, { status: 500 }); + } + + void captureServerEvent(user.id, 'transactions_view_opened', { + surface: 'dashboard_transactions', + }).catch(() => {}); + + return NextResponse.json({ ok: true }); +} diff --git a/apps/money-mirror/src/app/dashboard/DashboardClient.tsx b/apps/money-mirror/src/app/dashboard/DashboardClient.tsx index 070bce0..b9a717c 100644 --- a/apps/money-mirror/src/app/dashboard/DashboardClient.tsx +++ b/apps/money-mirror/src/app/dashboard/DashboardClient.tsx @@ -1,203 +1,71 @@ 'use client'; -import { useCallback, useEffect, useMemo, useState } from 'react'; -import { useRouter, useSearchParams } from 'next/navigation'; -import type { Advisory } from '@/lib/advisory-engine'; -import type { StatementType } from '@/lib/statements'; -import type { StatementListItem } from '@/lib/statements-list'; -import { monthKeyFromPeriodEnd } from '@/lib/format-date'; -import { UploadPanel, type UploadFormMeta } from './UploadPanel'; +import { useMemo } from 'react'; +import { ScopeBar } from '@/components/ScopeBar'; +import { UploadPanel } from './UploadPanel'; import { ParsingPanel } from './ParsingPanel'; import { ResultsPanel } from './ResultsPanel'; import { InsightsPanel } from './InsightsPanel'; -import { DashboardNav, type DashboardTab } from './DashboardNav'; +import { DashboardNav } from './DashboardNav'; import { StatementFilters } from './StatementFilters'; import { DashboardLoadingSkeleton } from './DashboardLoadingSkeleton'; import { DashboardBrandBar } from './DashboardBrandBar'; -import type { DashboardResult } from './dashboard-result-types'; +import { TransactionsPanel } from './TransactionsPanel'; +import { useDashboardState, tabFromSearchParams } from './useDashboardState'; export function DashboardClient() { - const router = useRouter(); - const searchParams = useSearchParams(); - const statementIdFromUrl = searchParams.get('statement_id'); - - const [result, setResult] = useState(null); - const [advisories, setAdvisories] = useState([]); - const [error, setError] = useState(null); - const [isLoadingDashboard, setIsLoadingDashboard] = useState(true); - const [isParsing, setIsParsing] = useState(false); - const [statementType, setStatementType] = useState('bank_account'); - const [statements, setStatements] = useState(null); - const [tab, setTab] = useState('overview'); - const [monthFilter, setMonthFilter] = useState('all'); - - const loadStatements = useCallback(async () => { - const resp = await fetch('/api/statements'); - if (!resp.ok) { - setStatements([]); - return; - } - const data = (await resp.json()) as { statements?: StatementListItem[] }; - setStatements(data.statements ?? []); - }, []); - - const loadDashboard = useCallback(async (statementId: string | null) => { - setIsLoadingDashboard(true); - setError(null); - - try { - const query = statementId ? `?statement_id=${encodeURIComponent(statementId)}` : ''; - const resp = await fetch(`/api/dashboard${query}`); - - if (resp.status === 404) { - setResult(null); - setAdvisories([]); - return; - } - - if (!resp.ok) { - const body = await resp.json().catch(() => ({})); - throw new Error(body.error ?? body.detail ?? `Dashboard load failed (${resp.status})`); - } - - const data: DashboardResult & { advisories: Advisory[] } = await resp.json(); - setResult(data); - setAdvisories(data.advisories); - } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to load your dashboard.'); - setResult(null); - setAdvisories([]); - } finally { - setIsLoadingDashboard(false); - } - }, []); - - useEffect(() => { - loadStatements().catch(() => {}); - }, [loadStatements]); - - useEffect(() => { - if (statements === null) { - return; - } - if (statements.length === 0) { - loadDashboard(null).catch(() => {}); - return; - } - - const valid = - statementIdFromUrl && statements.some((s) => s.id === statementIdFromUrl) - ? statementIdFromUrl - : statements[0].id; - - if (valid !== statementIdFromUrl) { - router.replace(`/dashboard?statement_id=${encodeURIComponent(valid)}`, { scroll: false }); - return; - } - - loadDashboard(valid).catch(() => {}); - }, [statements, statementIdFromUrl, loadDashboard, router]); - - const filteredStatements = useMemo(() => { - if (!statements || monthFilter === 'all') { - return statements ?? []; - } - return statements.filter((s) => monthKeyFromPeriodEnd(s.period_end) === monthFilter); - }, [statements, monthFilter]); - - const effectiveSelectedId = - result?.statement_id && filteredStatements.some((s) => s.id === result.statement_id) - ? result.statement_id - : (filteredStatements[0]?.id ?? result?.statement_id ?? ''); - - const handleMonthChange = useCallback( - (key: string | 'all') => { - setMonthFilter(key); - if (!statements || statements.length === 0) { - return; - } - const pool = - key === 'all' - ? statements - : statements.filter((s) => monthKeyFromPeriodEnd(s.period_end) === key); - if (pool.length === 0) { - return; - } - const next = pool[0].id; - router.replace(`/dashboard?statement_id=${encodeURIComponent(next)}`, { scroll: false }); - }, - [statements, router] - ); - - const handleStatementChange = useCallback( - (statementId: string) => { - if (!statementId) { - return; - } - router.replace(`/dashboard?statement_id=${encodeURIComponent(statementId)}`, { - scroll: false, - }); - }, - [router] - ); - - const handleUpload = useCallback( - async (file: File, nextStatementType: StatementType, meta: UploadFormMeta) => { - setError(null); - - if (file.type && file.type !== 'application/pdf') { - setError('Please upload a PDF file.'); - return; - } - if (file.size > 10 * 1024 * 1024) { - setError('File is too large. Maximum 10 MB.'); - return; - } - - setIsParsing(true); - - try { - const formData = new FormData(); - formData.append('file', file); - formData.append('statement_type', nextStatementType); - if (meta.nickname) { - formData.append('nickname', meta.nickname); - } - if (meta.accountPurpose) { - formData.append('account_purpose', meta.accountPurpose); - } - if (nextStatementType === 'credit_card' && meta.cardNetwork) { - formData.append('card_network', meta.cardNetwork); - } - - const resp = await fetch('/api/statement/parse', { method: 'POST', body: formData }); - - if (!resp.ok) { - const body = await resp.json().catch(() => ({})); - throw new Error(body.error ?? body.detail ?? `Upload failed (${resp.status})`); - } - - const data: DashboardResult = await resp.json(); - await loadStatements(); - router.replace(`/dashboard?statement_id=${encodeURIComponent(data.statement_id)}`, { - scroll: false, - }); - await loadDashboard(data.statement_id); - setTab('overview'); - } catch (err) { - setError(err instanceof Error ? err.message : 'Something went wrong.'); - } finally { - setIsParsing(false); - } - }, - [loadDashboard, loadStatements, router] - ); + const { + router, + searchParams, + isUnifiedUrl, + result, + advisories, + coachingFacts, + error, + setError, + isLoadingDashboard, + isParsing, + statementType, + setStatementType, + statements, + monthFilter, + effectiveSelectedId, + txnScope, + handleMonthChange, + handleStatementChange, + applyUnified, + applyLegacy, + handleUpload, + } = useDashboardState(); + + const tab = useMemo(() => tabFromSearchParams(searchParams), [searchParams]); const hasStatements = Boolean(statements && statements.length > 0); const showTabs = hasStatements && !isLoadingDashboard; const showEmptyUpload = !isLoadingDashboard && statements !== null && statements.length === 0 && !isParsing; + const scopeAndFilters = + hasStatements && result && !isLoadingDashboard && !isParsing && statements ? ( + <> + + {!isUnifiedUrl && ( + + )} + + ) : null; + return (
{ - setTab(t); + const q = new URLSearchParams(searchParams.toString()); + if (t === 'overview') { + q.delete('tab'); + } else { + q.set('tab', t); + } + router.replace(`/dashboard?${q.toString()}`, { scroll: false }); if (t !== 'upload') { setError(null); } @@ -239,44 +113,47 @@ export function DashboardClient() { /> )} - {tab === 'overview' && - hasStatements && - result && - !isLoadingDashboard && - !isParsing && - statements && ( - <> - - - - )} + {tab === 'overview' && scopeAndFilters && result && ( + <> + {scopeAndFilters} + + + )} - {tab === 'insights' && hasStatements && result && !isLoadingDashboard && !isParsing && ( - + {tab === 'insights' && scopeAndFilters && result && txnScope && ( + <> + {scopeAndFilters} + + )} - + {tab === 'transactions' && scopeAndFilters && result && txnScope && ( + <> + {scopeAndFilters} + + + )}
); } diff --git a/apps/money-mirror/src/app/dashboard/DashboardNav.tsx b/apps/money-mirror/src/app/dashboard/DashboardNav.tsx index 7e9e480..6a89f92 100644 --- a/apps/money-mirror/src/app/dashboard/DashboardNav.tsx +++ b/apps/money-mirror/src/app/dashboard/DashboardNav.tsx @@ -1,6 +1,6 @@ 'use client'; -export type DashboardTab = 'overview' | 'insights' | 'upload'; +export type DashboardTab = 'overview' | 'insights' | 'transactions' | 'upload'; interface DashboardNavProps { active: DashboardTab; @@ -10,6 +10,7 @@ interface DashboardNavProps { const TABS: { id: DashboardTab; label: string }[] = [ { id: 'overview', label: 'Overview' }, { id: 'insights', label: 'Insights' }, + { id: 'transactions', label: 'Transactions' }, { id: 'upload', label: 'Upload' }, ]; @@ -20,7 +21,7 @@ export function DashboardNav({ active, onChange }: DashboardNavProps) { aria-label="Dashboard sections" style={{ display: 'grid', - gridTemplateColumns: 'repeat(3, 1fr)', + gridTemplateColumns: 'repeat(2, minmax(0, 1fr))', gap: '8px', marginBottom: '22px', }} diff --git a/apps/money-mirror/src/app/dashboard/InsightsPanel.tsx b/apps/money-mirror/src/app/dashboard/InsightsPanel.tsx index fff4db5..b2b4888 100644 --- a/apps/money-mirror/src/app/dashboard/InsightsPanel.tsx +++ b/apps/money-mirror/src/app/dashboard/InsightsPanel.tsx @@ -1,13 +1,18 @@ 'use client'; import { AdvisoryFeed } from '@/components/AdvisoryFeed'; +import { MerchantRollups } from '@/components/MerchantRollups'; import type { Advisory } from '@/lib/advisory-engine'; +import type { LayerAFacts } from '@/lib/coaching-facts'; +import type { TxnScope } from './TransactionsPanel'; interface InsightsPanelProps { advisories: Advisory[]; + txnScope: TxnScope | null; + coachingFacts: LayerAFacts | null; } -export function InsightsPanel({ advisories }: InsightsPanelProps) { +export function InsightsPanel({ advisories, txnScope, coachingFacts }: InsightsPanelProps) { return (
+ {txnScope ? : null} +

Highlights

- +

- Statement period: {periodChip} + {scopeKind === 'unified' ? 'Selected range' : 'Statement period'}: {periodChip}

{metaBits.join(' • ')} • {periodRange} • {transaction_count} transactions @@ -124,6 +128,12 @@ export function ResultsPanel({

diff --git a/apps/money-mirror/src/app/dashboard/TransactionsPanel.tsx b/apps/money-mirror/src/app/dashboard/TransactionsPanel.tsx new file mode 100644 index 0000000..3b7c235 --- /dev/null +++ b/apps/money-mirror/src/app/dashboard/TransactionsPanel.tsx @@ -0,0 +1,185 @@ +'use client'; + +import { useCallback, useEffect, useRef, useState } from 'react'; +import { useRouter, useSearchParams } from 'next/navigation'; +import { TxnFilterBar } from './TxnFilterBar'; +import { TxnRow, type TxRow } from './TxnRow'; + +const PAGE = 40; + +export type TxnScope = + | { mode: 'legacy'; statementId: string } + | { + mode: 'unified'; + dateFrom: string; + dateTo: string; + /** `null` = all statements (same as dashboard "all accounts") */ + statementIds: string[] | null; + }; + +interface TransactionsPanelProps { + txnScope: TxnScope; +} + +export function TransactionsPanel({ txnScope }: TransactionsPanelProps) { + const router = useRouter(); + const searchParams = useSearchParams(); + const merchantFromUrl = searchParams.get('merchant_key')?.trim() ?? ''; + const [rows, setRows] = useState([]); + const [total, setTotal] = useState(0); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [category, setCategory] = useState(''); + const [type, setType] = useState(''); + const [search, setSearch] = useState(''); + const [debouncedSearch, setDebouncedSearch] = useState(''); + const abortRef = useRef(null); + + useEffect(() => { + const t = window.setTimeout(() => setDebouncedSearch(search.trim()), 320); + return () => window.clearTimeout(t); + }, [search]); + + useEffect(() => { + try { + if (sessionStorage.getItem('mm_txn_view_logged') === '1') { + return; + } + sessionStorage.setItem('mm_txn_view_logged', '1'); + } catch { + /* ignore */ + } + void fetch('/api/transactions/view-opened', { method: 'POST' }).catch(() => {}); + }, []); + + const load = useCallback( + async (nextOffset: number, append: boolean) => { + abortRef.current?.abort(); + const ac = new AbortController(); + abortRef.current = ac; + setLoading(true); + setError(null); + + const q = new URLSearchParams(); + q.set('limit', String(PAGE)); + q.set('offset', String(nextOffset)); + if (txnScope.mode === 'legacy') { + q.set('statement_id', txnScope.statementId); + } else { + q.set('date_from', txnScope.dateFrom); + q.set('date_to', txnScope.dateTo); + if (txnScope.statementIds?.length) { + q.set('statement_ids', txnScope.statementIds.join(',')); + } + } + if (category) q.set('category', category); + if (type) q.set('type', type); + if (debouncedSearch) q.set('search', debouncedSearch); + if (merchantFromUrl) q.set('merchant_key', merchantFromUrl); + + try { + const resp = await fetch(`/api/transactions?${q.toString()}`, { signal: ac.signal }); + if (!resp.ok) { + const body = await resp.json().catch(() => ({})); + throw new Error(body.error ?? `Load failed (${resp.status})`); + } + const data = (await resp.json()) as { transactions: TxRow[]; total: number }; + setTotal(data.total); + setRows((r) => (append ? [...r, ...data.transactions] : data.transactions)); + } catch (e) { + if (e instanceof Error && e.name === 'AbortError') return; + setError(e instanceof Error ? e.message : 'Failed to load transactions.'); + if (!append) setRows([]); + } finally { + setLoading(false); + } + }, + [txnScope, category, type, debouncedSearch, merchantFromUrl] + ); + + useEffect(() => { + if (txnScope.mode === 'legacy' && !txnScope.statementId) return; + if (txnScope.mode === 'unified' && (!txnScope.dateFrom || !txnScope.dateTo)) return; + void load(0, false); + }, [txnScope, category, type, debouncedSearch, merchantFromUrl, load]); + + const clearMerchantFilter = useCallback(() => { + const q = new URLSearchParams(searchParams.toString()); + q.delete('merchant_key'); + router.replace(`/dashboard?${q.toString()}`, { scroll: false }); + }, [router, searchParams]); + + const hasMore = rows.length < total; + + return ( +
+ + + {error && ( +

+ {error} +

+ )} + + {loading && rows.length === 0 && ( +
+ )} + + {!loading && rows.length === 0 && !error && ( +

+ No transactions for this filter. +

+ )} + + {rows.length > 0 && ( +
    + {rows.map((tx) => ( + + ))} +
+ )} + + {hasMore && ( + + )} + + {total > 0 && ( +

+ Showing {rows.length} of {total} transactions +

+ )} +
+ ); +} diff --git a/apps/money-mirror/src/app/dashboard/TxnFilterBar.tsx b/apps/money-mirror/src/app/dashboard/TxnFilterBar.tsx new file mode 100644 index 0000000..30c3161 --- /dev/null +++ b/apps/money-mirror/src/app/dashboard/TxnFilterBar.tsx @@ -0,0 +1,118 @@ +const CATEGORIES = [ + { value: '', label: 'All categories' }, + { value: 'needs', label: 'Needs' }, + { value: 'wants', label: 'Wants' }, + { value: 'investment', label: 'Investment' }, + { value: 'debt', label: 'Debt' }, + { value: 'other', label: 'Other' }, +]; + +const TYPES = [ + { value: '', label: 'Debit & credit' }, + { value: 'debit', label: 'Debit' }, + { value: 'credit', label: 'Credit' }, +]; + +interface TxnFilterBarProps { + search: string; + onSearchChange: (v: string) => void; + category: string; + onCategoryChange: (v: string) => void; + type: string; + onTypeChange: (v: string) => void; + merchantFromUrl: string; + onClearMerchant: () => void; +} + +export function TxnFilterBar({ + search, + onSearchChange, + category, + onCategoryChange, + type, + onTypeChange, + merchantFromUrl, + onClearMerchant, +}: TxnFilterBarProps) { + return ( +
+ {merchantFromUrl ? ( +
+ + Filtered by merchant:{' '} + + {merchantFromUrl.replace(/_/g, ' ')} + + + +
+ ) : null} + +
+ + +
+
+ ); +} diff --git a/apps/money-mirror/src/app/dashboard/TxnRow.tsx b/apps/money-mirror/src/app/dashboard/TxnRow.tsx new file mode 100644 index 0000000..79f9be8 --- /dev/null +++ b/apps/money-mirror/src/app/dashboard/TxnRow.tsx @@ -0,0 +1,60 @@ +export type TxRow = { + id: string; + statement_id: string; + date: string; + description: string; + amount_paisa: number; + type: 'debit' | 'credit'; + category: string; + is_recurring: boolean; + merchant_key: string | null; + statement_nickname: string | null; + statement_institution_name: string; +}; + +function formatInr(paisa: number): string { + const rupees = Math.abs(paisa) / 100; + return rupees.toLocaleString('en-IN', { maximumFractionDigits: 2 }); +} + +export function TxnRow({ tx }: { tx: TxRow }) { + const sign = tx.type === 'debit' ? '−' : '+'; + const badge = tx.statement_nickname ?? tx.statement_institution_name; + return ( +
  • +
    +
    +
    + {tx.date} · {tx.category} + {tx.merchant_key ? ( + · {tx.merchant_key} + ) : null} +
    +
    + {tx.description} +
    +
    + {badge} +
    +
    +
    + {sign}₹{formatInr(tx.amount_paisa)} +
    +
    +
  • + ); +} diff --git a/apps/money-mirror/src/app/dashboard/dashboard-result-types.ts b/apps/money-mirror/src/app/dashboard/dashboard-result-types.ts index 170d6b3..75f57e0 100644 --- a/apps/money-mirror/src/app/dashboard/dashboard-result-types.ts +++ b/apps/money-mirror/src/app/dashboard/dashboard-result-types.ts @@ -1,3 +1,4 @@ +import type { LayerAFacts } from '@/lib/coaching-facts'; import type { StatementType } from '@/lib/statements'; /** Dashboard API + parse response shape used by DashboardClient */ @@ -26,4 +27,13 @@ export interface DashboardResult { total_debits_paisa: number; total_credits_paisa: number; }; + scope?: { + kind: 'single_statement' | 'unified'; + date_from: string | null; + date_to: string | null; + included_statement_ids: string[]; + }; + perceived_is_profile_baseline?: boolean; + /** Layer A facts (issue-010 T4); server-built, facts-grounded coaching. */ + coaching_facts?: LayerAFacts; } diff --git a/apps/money-mirror/src/app/dashboard/dashboard-tab-params.ts b/apps/money-mirror/src/app/dashboard/dashboard-tab-params.ts new file mode 100644 index 0000000..23ecce5 --- /dev/null +++ b/apps/money-mirror/src/app/dashboard/dashboard-tab-params.ts @@ -0,0 +1,7 @@ +export function tabFromSearchParams(searchParams: { get: (key: string) => string | null }) { + const t = searchParams.get('tab'); + if (t === 'insights' || t === 'upload' || t === 'transactions') { + return t; + } + return 'overview' as const; +} diff --git a/apps/money-mirror/src/app/dashboard/useDashboardScopeDerived.ts b/apps/money-mirror/src/app/dashboard/useDashboardScopeDerived.ts new file mode 100644 index 0000000..7ef9c20 --- /dev/null +++ b/apps/money-mirror/src/app/dashboard/useDashboardScopeDerived.ts @@ -0,0 +1,55 @@ +import { useMemo } from 'react'; +import type { StatementListItem } from '@/lib/statements-list'; +import type { DashboardResult } from './dashboard-result-types'; +import type { TxnScope } from './TransactionsPanel'; +import { monthKeyFromPeriodEnd } from '@/lib/format-date'; + +export function useDashboardScopeDerived({ + statements, + monthFilter, + result, +}: { + statements: StatementListItem[] | null; + monthFilter: string | 'all'; + result: DashboardResult | null; +}) { + const filteredStatements = useMemo(() => { + if (!statements || monthFilter === 'all') { + return statements ?? []; + } + return statements.filter((s) => monthKeyFromPeriodEnd(s.period_end) === monthFilter); + }, [statements, monthFilter]); + + const effectiveSelectedId = useMemo(() => { + return result?.statement_id && filteredStatements.some((s) => s.id === result.statement_id) + ? result.statement_id + : (filteredStatements[0]?.id ?? result?.statement_id ?? ''); + }, [result, filteredStatements]); + + const txnScope = useMemo((): TxnScope | null => { + if (!result) { + return null; + } + if (result.scope?.kind === 'unified' && result.scope.date_from && result.scope.date_to) { + const inc = result.scope.included_statement_ids; + const all = + statements && + inc.length > 0 && + statements.length > 0 && + inc.length === statements.length && + statements.every((s) => inc.includes(s.id)); + return { + mode: 'unified', + dateFrom: result.scope.date_from, + dateTo: result.scope.date_to, + statementIds: all ? null : inc.length ? inc : null, + }; + } + if (!effectiveSelectedId) { + return null; + } + return { mode: 'legacy', statementId: effectiveSelectedId }; + }, [result, statements, effectiveSelectedId]); + + return { filteredStatements, effectiveSelectedId, txnScope }; +} diff --git a/apps/money-mirror/src/app/dashboard/useDashboardState.ts b/apps/money-mirror/src/app/dashboard/useDashboardState.ts new file mode 100644 index 0000000..fdc36f4 --- /dev/null +++ b/apps/money-mirror/src/app/dashboard/useDashboardState.ts @@ -0,0 +1,280 @@ +'use client'; + +import { useCallback, useEffect, useRef, useState } from 'react'; +import { useRouter, useSearchParams } from 'next/navigation'; +import type { Advisory } from '@/lib/advisory-engine'; +import type { LayerAFacts } from '@/lib/coaching-facts'; +import type { StatementType } from '@/lib/statements'; +import type { StatementListItem } from '@/lib/statements-list'; +import { monthKeyFromPeriodEnd } from '@/lib/format-date'; +import { dashboardApiPathFromSearchParams } from '@/lib/scope'; +import type { ApplyUnifiedPayload } from '@/components/ScopeBar'; +import type { UploadFormMeta } from './UploadPanel'; +import type { DashboardResult } from './dashboard-result-types'; +import { useDashboardScopeDerived } from './useDashboardScopeDerived'; + +export { tabFromSearchParams } from './dashboard-tab-params'; + +export function useDashboardState() { + const router = useRouter(); + const searchParams = useSearchParams(); + const statementIdFromUrl = searchParams.get('statement_id'); + const isUnifiedUrl = Boolean(searchParams.get('date_from') && searchParams.get('date_to')); + + const [result, setResult] = useState(null); + const [advisories, setAdvisories] = useState([]); + const [coachingFacts, setCoachingFacts] = useState(null); + const [error, setError] = useState(null); + const [isLoadingDashboard, setIsLoadingDashboard] = useState(true); + const [isParsing, setIsParsing] = useState(false); + const [statementType, setStatementType] = useState('bank_account'); + const [statements, setStatements] = useState(null); + const [monthFilter, setMonthFilter] = useState('all'); + const dashboardAbortRef = useRef(null); + + const { filteredStatements, effectiveSelectedId, txnScope } = useDashboardScopeDerived({ + statements, + monthFilter, + result, + }); + + const loadStatements = useCallback(async () => { + const resp = await fetch('/api/statements'); + if (!resp.ok) { + setStatements([]); + return; + } + const data = (await resp.json()) as { statements?: StatementListItem[] }; + setStatements(data.statements ?? []); + }, []); + + const loadDashboard = useCallback( + async (override?: URLSearchParams) => { + // Cancel any in-flight dashboard fetch (e.g. Gemini-enriched response from old scope) + dashboardAbortRef.current?.abort(); + const ac = new AbortController(); + dashboardAbortRef.current = ac; + + setIsLoadingDashboard(true); + setError(null); + + try { + const path = dashboardApiPathFromSearchParams(override ?? searchParams); + const resp = await fetch(`/api/dashboard${path}`, { signal: ac.signal }); + + if (resp.status === 404) { + setResult(null); + setAdvisories([]); + setCoachingFacts(null); + return; + } + + if (!resp.ok) { + const body = await resp.json().catch(() => ({})); + throw new Error(body.error ?? body.detail ?? `Dashboard load failed (${resp.status})`); + } + + const data: DashboardResult & { advisories: Advisory[] } = await resp.json(); + setResult(data); + setAdvisories(data.advisories); + setCoachingFacts(data.coaching_facts ?? null); + } catch (err) { + if (err instanceof Error && err.name === 'AbortError') { + return; + } + setError(err instanceof Error ? err.message : 'Failed to load your dashboard.'); + setResult(null); + setAdvisories([]); + setCoachingFacts(null); + } finally { + setIsLoadingDashboard(false); + } + }, + [searchParams] + ); + + useEffect(() => { + loadStatements().catch(() => {}); + }, [loadStatements]); + + useEffect(() => { + if (statements === null) { + return; + } + if (statements.length === 0) { + void loadDashboard(new URLSearchParams()); + return; + } + + if (isUnifiedUrl) { + void loadDashboard(); + return; + } + + const valid = + statementIdFromUrl && statements.some((s) => s.id === statementIdFromUrl) + ? statementIdFromUrl + : statements[0].id; + + if (valid !== statementIdFromUrl) { + const q = new URLSearchParams(); + q.set('statement_id', valid); + const t = searchParams.get('tab'); + if (t && (t === 'insights' || t === 'upload' || t === 'transactions')) { + q.set('tab', t); + } + router.replace(`/dashboard?${q.toString()}`, { scroll: false }); + return; + } + + void loadDashboard(); + }, [statements, statementIdFromUrl, isUnifiedUrl, loadDashboard, router, searchParams]); + + const handleMonthChange = useCallback( + (key: string | 'all') => { + setMonthFilter(key); + if (!statements || statements.length === 0) { + return; + } + const pool = + key === 'all' + ? statements + : statements.filter((s) => monthKeyFromPeriodEnd(s.period_end) === key); + if (pool.length === 0) { + return; + } + const next = pool[0].id; + const q = new URLSearchParams(searchParams.toString()); + q.set('statement_id', next); + router.replace(`/dashboard?${q.toString()}`, { scroll: false }); + }, + [statements, router, searchParams] + ); + + const handleStatementChange = useCallback( + (statementId: string) => { + if (!statementId) { + return; + } + const q = new URLSearchParams(searchParams.toString()); + q.set('statement_id', statementId); + router.replace(`/dashboard?${q.toString()}`, { scroll: false }); + }, + [router, searchParams] + ); + + const applyUnified = useCallback( + (payload: ApplyUnifiedPayload) => { + const q = new URLSearchParams(); + q.set('date_from', payload.scope.dateFrom); + q.set('date_to', payload.scope.dateTo); + if (payload.scope.statementIds?.length) { + q.set('statement_ids', payload.scope.statementIds.join(',')); + } + const t = searchParams.get('tab'); + if (t && t !== 'overview') { + q.set('tab', t); + } + router.replace(`/dashboard?${q.toString()}`, { scroll: false }); + const n = payload.scope.statementIds?.length ?? statements?.length ?? 0; + void fetch('/api/dashboard/scope-changed', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + date_preset: payload.datePreset, + source_count: n, + }), + }).catch(() => {}); + }, + [router, searchParams, statements?.length] + ); + + const applyLegacy = useCallback( + (statementId: string) => { + const q = new URLSearchParams(); + q.set('statement_id', statementId); + const t = searchParams.get('tab'); + if (t && t !== 'overview') { + q.set('tab', t); + } + router.replace(`/dashboard?${q.toString()}`, { scroll: false }); + }, + [router, searchParams] + ); + + const handleUpload = useCallback( + async (file: File, nextStatementType: StatementType, meta: UploadFormMeta) => { + setError(null); + + if (file.type && file.type !== 'application/pdf') { + setError('Please upload a PDF file.'); + return; + } + if (file.size > 10 * 1024 * 1024) { + setError('File is too large. Maximum 10 MB.'); + return; + } + + setIsParsing(true); + + try { + const formData = new FormData(); + formData.append('file', file); + formData.append('statement_type', nextStatementType); + if (meta.nickname) { + formData.append('nickname', meta.nickname); + } + if (meta.accountPurpose) { + formData.append('account_purpose', meta.accountPurpose); + } + if (nextStatementType === 'credit_card' && meta.cardNetwork) { + formData.append('card_network', meta.cardNetwork); + } + + const resp = await fetch('/api/statement/parse', { method: 'POST', body: formData }); + + if (!resp.ok) { + const body = await resp.json().catch(() => ({})); + throw new Error(body.error ?? body.detail ?? `Upload failed (${resp.status})`); + } + + const data: DashboardResult = await resp.json(); + await loadStatements(); + const next = new URLSearchParams(); + next.set('statement_id', data.statement_id); + router.replace(`/dashboard?${next.toString()}`, { scroll: false }); + await loadDashboard(next); + } catch (err) { + setError(err instanceof Error ? err.message : 'Something went wrong.'); + } finally { + setIsParsing(false); + } + }, + [loadDashboard, loadStatements, router] + ); + + return { + router, + searchParams, + isUnifiedUrl, + result, + advisories, + coachingFacts, + error, + setError, + isLoadingDashboard, + isParsing, + statementType, + setStatementType, + statements, + monthFilter, + filteredStatements, + effectiveSelectedId, + txnScope, + handleMonthChange, + handleStatementChange, + applyUnified, + applyLegacy, + handleUpload, + }; +} diff --git a/apps/money-mirror/src/app/globals.css b/apps/money-mirror/src/app/globals.css index b4bd85f..a273b60 100644 --- a/apps/money-mirror/src/app/globals.css +++ b/apps/money-mirror/src/app/globals.css @@ -136,3 +136,5 @@ input:focus { border-color: var(--border-accent); } .badge-danger { display: inline-flex; align-items: center; gap: 6px; background: var(--danger-dim); color: var(--danger); border: 1px solid rgba(255,77,109,0.2); border-radius: 99px; padding: 4px 12px; font-size: 0.78rem; font-weight: 600; } .badge-warning { display: inline-flex; align-items: center; gap: 6px; background: var(--warning-dim); color: var(--warning); border: 1px solid rgba(255,181,71,0.2); border-radius: 99px; padding: 4px 12px; font-size: 0.78rem; font-weight: 600; } + +@keyframes spin { to { transform: rotate(360deg); } } diff --git a/apps/money-mirror/src/components/AdvisoryFeed.tsx b/apps/money-mirror/src/components/AdvisoryFeed.tsx index 4f6c911..69ad035 100644 --- a/apps/money-mirror/src/components/AdvisoryFeed.tsx +++ b/apps/money-mirror/src/components/AdvisoryFeed.tsx @@ -1,13 +1,13 @@ -/** - * AdvisoryFeed — Displays advisory cards from the advisory engine - * - * Each advisory is styled by severity (critical = red, warning = amber, info = teal). - */ +'use client'; +import { useState } from 'react'; import type { Advisory } from '@/lib/advisory-engine'; +import type { LayerAFacts } from '@/lib/coaching-facts'; +import { FactsDrawer } from '@/components/FactsDrawer'; interface AdvisoryFeedProps { advisories: Advisory[]; + coachingFacts: LayerAFacts | null; } const SEVERITY_STYLES: Record< @@ -34,24 +34,103 @@ const SEVERITY_STYLES: Record< }, }; -export function AdvisoryFeed({ advisories }: AdvisoryFeedProps) { - if (advisories.length === 0) { - return ( -
    - -

    void; +} + +function AdvisoryCard({ + adv, + index, + coachingFacts, + openSourcesId, + onToggleSources, +}: AdvisoryCardProps) { + const style = SEVERITY_STYLES[adv.severity]; + const cited = adv.cited_fact_ids ?? []; + const showSources = coachingFacts && cited.length > 0; + const sourcesOpen = openSourcesId === adv.id; + + return ( +

    +
    + {style.icon} + + {adv.headline} + +
    +

    + {adv.narrative ?? adv.message} +

    + {showSources ? ( +
    + + {sourcesOpen ? ( +
    + +
    + ) : null} +
    + ) : null} +
    + ); +} + +export function AdvisoryFeed({ advisories, coachingFacts }: AdvisoryFeedProps) { + const [openSourcesId, setOpenSourcesId] = useState(null); + + if (advisories.length === 0) { + return ( +
    + +

    No red flags found. Your spending looks healthy!

    @@ -60,53 +139,16 @@ export function AdvisoryFeed({ advisories }: AdvisoryFeedProps) { return (
    - {advisories.map((adv, i) => { - const style = SEVERITY_STYLES[adv.severity]; - return ( -
    -
    - {style.icon} - - {adv.headline} - -
    -

    - {adv.message} -

    -
    - ); - })} + {advisories.map((adv, i) => ( + + ))}
    ); } diff --git a/apps/money-mirror/src/components/FactsDrawer.tsx b/apps/money-mirror/src/components/FactsDrawer.tsx new file mode 100644 index 0000000..248093d --- /dev/null +++ b/apps/money-mirror/src/components/FactsDrawer.tsx @@ -0,0 +1,50 @@ +'use client'; + +import type { CoachingFactRow, LayerAFacts } from '@/lib/coaching-facts'; + +interface FactsDrawerProps { + facts: LayerAFacts; + citedIds: string[]; +} + +/** + * Read-only Layer A facts for a coaching card (no raw JSON dump). + */ +export function FactsDrawer({ facts, citedIds }: FactsDrawerProps) { + const byId = new Map(facts.facts.map((fact) => [fact.id, fact])); + const rows = citedIds.map((id) => byId.get(id)).filter((fact): fact is CoachingFactRow => !!fact); + + if (rows.length === 0) { + return ( +

    + No linked figures for this card. +

    + ); + } + + return ( +
      + {rows.map((fact) => ( +
    • + {fact.label} + {' — '} + {fact.display_inr} + {fact.detail ? ( + ({fact.detail}) + ) : null} +
    • + ))} +
    + ); +} diff --git a/apps/money-mirror/src/components/MerchantRollups.tsx b/apps/money-mirror/src/components/MerchantRollups.tsx new file mode 100644 index 0000000..a2395f3 --- /dev/null +++ b/apps/money-mirror/src/components/MerchantRollups.tsx @@ -0,0 +1,188 @@ +'use client'; + +import { useCallback, useEffect, useState } from 'react'; +import { useRouter, useSearchParams } from 'next/navigation'; +import type { TxnScope } from '@/app/dashboard/TransactionsPanel'; + +type MerchantRow = { + merchant_key: string; + debit_paisa: number; + txn_count: number; +}; + +function formatInr(paisa: number): string { + const rupees = Math.abs(paisa) / 100; + return rupees.toLocaleString('en-IN', { maximumFractionDigits: 2 }); +} + +function merchantsQuery(txnScope: TxnScope): string { + const q = new URLSearchParams(); + if (txnScope.mode === 'legacy') { + q.set('statement_id', txnScope.statementId); + } else { + q.set('date_from', txnScope.dateFrom); + q.set('date_to', txnScope.dateTo); + if (txnScope.statementIds?.length) { + q.set('statement_ids', txnScope.statementIds.join(',')); + } + } + q.set('limit', '12'); + return q.toString(); +} + +interface MerchantRollupsProps { + txnScope: TxnScope; +} + +export function MerchantRollups({ txnScope }: MerchantRollupsProps) { + const router = useRouter(); + const searchParams = useSearchParams(); + const [rows, setRows] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const load = useCallback(async () => { + setLoading(true); + setError(null); + try { + const qs = merchantsQuery(txnScope); + const resp = await fetch(`/api/insights/merchants?${qs}`); + if (!resp.ok) { + const body = await resp.json().catch(() => ({})); + throw new Error(body.error ?? `Load failed (${resp.status})`); + } + const data = (await resp.json()) as { + merchants: MerchantRow[]; + }; + setRows(data.merchants ?? []); + } catch (e) { + setError(e instanceof Error ? e.message : 'Failed to load merchants.'); + setRows([]); + } finally { + setLoading(false); + } + }, [txnScope]); + + useEffect(() => { + void load(); + }, [load]); + + const onSeeTransactions = (merchantKey: string) => { + void fetch('/api/insights/merchant-click', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ merchant_key: merchantKey }), + }).catch(() => {}); + + const q = new URLSearchParams(searchParams.toString()); + q.set('tab', 'transactions'); + q.set('merchant_key', merchantKey); + router.push(`/dashboard?${q.toString()}`); + }; + + return ( +
    +
    +

    + Top merchants +

    +

    + Debit spend by normalized merchant label for this scope. Tap to open matching + transactions. +

    +
    + + {loading && ( +
    + )} + + {error && ( +

    + {error} +

    + )} + + {!loading && !error && rows.length === 0 && ( +

    + No labeled merchants in this scope yet. Upload statements or run merchant key backfill. +

    + )} + + {rows.length > 0 && ( +
      + {rows.map((r) => ( +
    • +
      +
      + {r.merchant_key.replace(/_/g, ' ')} +
      +
      + {r.txn_count} debit{r.txn_count === 1 ? '' : 's'} +
      +
      +
      + + ₹{formatInr(r.debit_paisa)} + + +
      +
    • + ))} +
    + )} +
    + ); +} diff --git a/apps/money-mirror/src/components/PerceivedActualMirror.tsx b/apps/money-mirror/src/components/PerceivedActualMirror.tsx index 5927f6a..acea622 100644 --- a/apps/money-mirror/src/components/PerceivedActualMirror.tsx +++ b/apps/money-mirror/src/components/PerceivedActualMirror.tsx @@ -7,11 +7,16 @@ interface PerceivedActualMirrorProps { perceived_spend_paisa: number; actual_debits_paisa: number; + /** Default: "Statement shows" */ + actualCaption?: string; + perceivedFootnote?: string | null; } export function PerceivedActualMirror({ perceived_spend_paisa, actual_debits_paisa, + actualCaption = 'Statement shows', + perceivedFootnote, }: PerceivedActualMirrorProps) { const perceived = Math.round(perceived_spend_paisa / 100).toLocaleString('en-IN'); const actual = Math.round(actual_debits_paisa / 100).toLocaleString('en-IN'); @@ -66,7 +71,7 @@ export function PerceivedActualMirror({ marginBottom: '8px', }} > - Statement shows + {actualCaption}
    )} + {perceivedFootnote ? ( +

    + {perceivedFootnote} +

    + ) : null}
    ); } diff --git a/apps/money-mirror/src/components/ScopeBar.tsx b/apps/money-mirror/src/components/ScopeBar.tsx new file mode 100644 index 0000000..4ed634a --- /dev/null +++ b/apps/money-mirror/src/components/ScopeBar.tsx @@ -0,0 +1,270 @@ +'use client'; + +import { useCallback, useMemo, useState } from 'react'; +import type { StatementListItem } from '@/lib/statements-list'; +import { statementPickerLabel } from '@/lib/statements-list'; +import { + type DatePresetId, + parseDashboardScopeFromSearchParams, + presetToRange, + type UnifiedScopeInput, +} from '@/lib/scope'; + +export type ApplyUnifiedPayload = { + scope: UnifiedScopeInput; + /** Telemetry: preset id or `custom` */ + datePreset: string | null; +}; + +type Props = { + statements: StatementListItem[]; + searchParams: URLSearchParams; + onApplyUnified: (payload: ApplyUnifiedPayload) => void; + onApplyLegacy: (statementId: string) => void; +}; + +export function ScopeBar({ statements, searchParams, onApplyUnified, onApplyLegacy }: Props) { + const [open, setOpen] = useState(false); + const parsed = useMemo(() => parseDashboardScopeFromSearchParams(searchParams), [searchParams]); + + const isUnified = !('error' in parsed) && parsed.variant === 'unified'; + + const summary = useMemo(() => { + if ('error' in parsed) { + return null; + } + if (parsed.variant === 'unified') { + const idCount = + parsed.scope.statementIds === null || parsed.scope.statementIds.length === 0 + ? statements.length + : parsed.scope.statementIds.length; + const src = + parsed.scope.statementIds === null || parsed.scope.statementIds.length === 0 + ? 'All accounts' + : `${parsed.scope.statementIds.length} account${parsed.scope.statementIds.length === 1 ? '' : 's'}`; + return `${parsed.scope.dateFrom} → ${parsed.scope.dateTo} · ${src} (${idCount} statement${idCount === 1 ? '' : 's'})`; + } + const sid = parsed.statementId; + const st = sid ? statements.find((s) => s.id === sid) : statements[0]; + return st ? `Single statement · ${statementPickerLabel(st)}` : 'Single statement'; + }, [parsed, statements]); + + const [preset, setPreset] = useState('last_30'); + const [dateFrom, setDateFrom] = useState(() => presetToRange('last_30').dateFrom); + const [dateTo, setDateTo] = useState(() => presetToRange('last_30').dateTo); + const [allSources, setAllSources] = useState(true); + const [selected, setSelected] = useState>({}); + + const hydrateEditorFromCurrentScope = useCallback(() => { + if ('error' in parsed) { + return; + } + + if (parsed.variant === 'unified') { + setPreset('custom'); + setDateFrom(parsed.scope.dateFrom); + setDateTo(parsed.scope.dateTo); + if (parsed.scope.statementIds === null || parsed.scope.statementIds.length === 0) { + setAllSources(true); + setSelected({}); + return; + } + setAllSources(false); + setSelected(Object.fromEntries(parsed.scope.statementIds.map((id) => [id, true]))); + return; + } + + const defaultRange = presetToRange('last_30'); + setPreset('last_30'); + setDateFrom(defaultRange.dateFrom); + setDateTo(defaultRange.dateTo); + setAllSources(true); + setSelected({}); + }, [parsed]); + + const toggleStatement = useCallback((id: string) => { + setSelected((prev) => ({ ...prev, [id]: !prev[id] })); + }, []); + + const applyPreset = useCallback((p: DatePresetId) => { + setPreset(p); + if (p === 'custom') { + return; + } + const r = presetToRange(p); + setDateFrom(r.dateFrom); + setDateTo(r.dateTo); + }, []); + + const handleApply = useCallback(() => { + let statementIds: string[] | null = null; + if (!allSources) { + const ids = statements.filter((s) => selected[s.id]).map((s) => s.id); + if (ids.length === 0) { + return; + } + statementIds = ids; + } + const scope: UnifiedScopeInput = { + dateFrom, + dateTo, + statementIds, + }; + const datePresetLabel = preset === 'custom' ? 'custom' : preset; + onApplyUnified({ scope, datePreset: datePresetLabel }); + setOpen(false); + }, [allSources, dateFrom, dateTo, onApplyUnified, preset, selected, statements]); + + const handleLegacyLatest = useCallback(() => { + if (statements[0]) { + onApplyLegacy(statements[0].id); + } + setOpen(false); + }, [onApplyLegacy, statements]); + + if (statements.length === 0) { + return null; + } + + return ( +
    +
    + + SCOPE + + + {summary ?? '—'} + + +
    + + {open && ( +
    + + + {preset === 'custom' && ( +
    + + +
    + )} + +
    + + {!allSources && ( +
    + {statements.map((s) => ( + + ))} +
    + )} +
    + +
    + + +
    +

    + Overview and Transactions use the same date range and sources. Perceived spend is your + monthly estimate from onboarding (not per account). +

    +
    + )} +
    + ); +} diff --git a/apps/money-mirror/src/lib/__tests__/coaching-facts.test.ts b/apps/money-mirror/src/lib/__tests__/coaching-facts.test.ts new file mode 100644 index 0000000..5beeaf0 --- /dev/null +++ b/apps/money-mirror/src/lib/__tests__/coaching-facts.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from 'vitest'; +import { buildLayerAFacts, factIdsFromLayerA, validateCitedFactIds } from '@/lib/coaching-facts'; +import type { DashboardData } from '@/lib/dashboard'; + +function minimalDashboard(overrides: Partial = {}): DashboardData { + return { + statement_id: '00000000-0000-4000-8000-000000000001', + institution_name: 'Test Bank', + statement_type: 'bank_account', + period_start: '2026-01-01', + period_end: '2026-01-31', + due_date: null, + payment_due_paisa: null, + minimum_due_paisa: null, + credit_limit_paisa: null, + perceived_spend_paisa: 100_000, + monthly_income_paisa: 500_000, + nickname: null, + account_purpose: null, + card_network: null, + transaction_count: 3, + summary: { + needs_paisa: 50_000, + wants_paisa: 30_000, + investment_paisa: 0, + debt_paisa: 20_000, + other_paisa: 10_000, + total_debits_paisa: 110_000, + total_credits_paisa: 0, + }, + signals: { food_delivery_paisa: 15_000, subscription_paisa: 5_000 }, + advisories: [], + scope: { + kind: 'single_statement', + date_from: null, + date_to: null, + included_statement_ids: ['00000000-0000-4000-8000-000000000001'], + }, + perceived_is_profile_baseline: true, + ...overrides, + }; +} + +describe('buildLayerAFacts', () => { + it('parses through Zod and includes core fact ids', () => { + const facts = buildLayerAFacts(minimalDashboard()); + expect(facts.version).toBe(1); + const ids = factIdsFromLayerA(facts); + expect(ids.has('total_debits_paisa')).toBe(true); + expect(ids.has('wants_paisa')).toBe(true); + expect(ids.has('food_delivery_signal_paisa')).toBe(true); + }); + + it('adds credit-card facts when statement is credit_card', () => { + const facts = buildLayerAFacts( + minimalDashboard({ + statement_type: 'credit_card', + payment_due_paisa: 50_000_00, + minimum_due_paisa: 5_000_00, + }) + ); + const ids = factIdsFromLayerA(facts); + expect(ids.has('cc_payment_due_paisa')).toBe(true); + expect(ids.has('cc_minimum_due_paisa')).toBe(true); + }); +}); + +describe('validateCitedFactIds', () => { + it('rejects unknown ids', () => { + const facts = buildLayerAFacts(minimalDashboard()); + const allowed = factIdsFromLayerA(facts); + const v = validateCitedFactIds(['total_debits_paisa', 'nope'], allowed); + expect(v.ok).toBe(false); + }); + + it('rejects empty citations', () => { + const facts = buildLayerAFacts(minimalDashboard()); + const allowed = factIdsFromLayerA(facts); + const v = validateCitedFactIds([], allowed); + expect(v.ok).toBe(false); + }); + + it('accepts non-empty subset', () => { + const facts = buildLayerAFacts(minimalDashboard()); + const allowed = factIdsFromLayerA(facts); + const v = validateCitedFactIds(['total_debits_paisa'], allowed); + expect(v.ok).toBe(true); + }); +}); diff --git a/apps/money-mirror/src/lib/__tests__/merchant-normalize.test.ts b/apps/money-mirror/src/lib/__tests__/merchant-normalize.test.ts new file mode 100644 index 0000000..6d8c9f7 --- /dev/null +++ b/apps/money-mirror/src/lib/__tests__/merchant-normalize.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest'; +import { normalizeMerchantKey } from '@/lib/merchant-normalize'; + +describe('normalizeMerchantKey', () => { + it('maps known brands', () => { + expect(normalizeMerchantKey('ZOMATO ORDER 450')).toBe('zomato'); + expect(normalizeMerchantKey('Paid SWIGGY')).toBe('swiggy'); + }); + + it('extracts UPI handle prefix', () => { + expect(normalizeMerchantKey('UPI merchant.name@oksbi')).toContain('upi_merchant'); + }); + + it('returns null for empty', () => { + expect(normalizeMerchantKey(' ')).toBeNull(); + }); + + it('slugifies first segment when no brand match', () => { + const k = normalizeMerchantKey('LOCAL STORE MUMBAI DR'); + expect(k).toBeTruthy(); + expect(k?.length).toBeGreaterThan(2); + }); +}); diff --git a/apps/money-mirror/src/lib/__tests__/merchant-rollups.test.ts b/apps/money-mirror/src/lib/__tests__/merchant-rollups.test.ts new file mode 100644 index 0000000..2fbb506 --- /dev/null +++ b/apps/money-mirror/src/lib/__tests__/merchant-rollups.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest'; + +/** + * Reconciliation smoke (T3): keyed merchant-grouped debits are a subset of all debits + * in the same scope; unmapped debits (null merchant_key) explain any gap. + */ +describe('merchant rollup reconciliation', () => { + it('sum of non-overlapping merchant groups is at most keyed debit total', () => { + const keyedDebitTotal = 8000; + const groups = [ + { merchant_key: 'zomato', debit_paisa: 5000 }, + { merchant_key: 'swiggy', debit_paisa: 2000 }, + ]; + const sumGroups = groups.reduce((a, g) => a + g.debit_paisa, 0); + expect(sumGroups).toBeLessThanOrEqual(keyedDebitTotal); + }); + + it('keyed debit total is at most scope debit total', () => { + const scopeDebitTotal = 10_000; + const keyedDebitTotal = 7500; + expect(keyedDebitTotal).toBeLessThanOrEqual(scopeDebitTotal); + }); +}); diff --git a/apps/money-mirror/src/lib/__tests__/scope.test.ts b/apps/money-mirror/src/lib/__tests__/scope.test.ts new file mode 100644 index 0000000..3da5f90 --- /dev/null +++ b/apps/money-mirror/src/lib/__tests__/scope.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from 'vitest'; +import { + isValidIsoDate, + parseDashboardScopeFromSearchParams, + parseStatementIdsParam, + presetToRange, +} from '@/lib/scope'; + +describe('scope', () => { + it('parseStatementIdsParam: null → all sources', () => { + expect(parseStatementIdsParam(null)).toEqual({ ok: true, value: null }); + }); + + it('parseStatementIdsParam: single valid uuid', () => { + const id = '550e8400-e29b-41d4-a716-446655440000'; + expect(parseStatementIdsParam(id)).toEqual({ ok: true, value: [id] }); + }); + + it('parseStatementIdsParam: rejects bad uuid', () => { + const r = parseStatementIdsParam('not-a-uuid'); + expect(r.ok).toBe(false); + }); + + it('parseDashboardScopeFromSearchParams: legacy statement_id', () => { + const sp = new URLSearchParams(); + sp.set('statement_id', '550e8400-e29b-41d4-a716-446655440000'); + expect(parseDashboardScopeFromSearchParams(sp)).toEqual({ + variant: 'legacy', + statementId: '550e8400-e29b-41d4-a716-446655440000', + }); + }); + + it('parseDashboardScopeFromSearchParams: unified range', () => { + const sp = new URLSearchParams(); + sp.set('date_from', '2026-01-01'); + sp.set('date_to', '2026-01-31'); + const r = parseDashboardScopeFromSearchParams(sp); + expect(r).toEqual({ + variant: 'unified', + scope: { + dateFrom: '2026-01-01', + dateTo: '2026-01-31', + statementIds: null, + }, + }); + }); + + it('parseDashboardScopeFromSearchParams: rejects partial dates', () => { + const sp = new URLSearchParams(); + sp.set('date_from', '2026-01-01'); + expect(parseDashboardScopeFromSearchParams(sp)).toHaveProperty('error'); + }); + + it('isValidIsoDate', () => { + expect(isValidIsoDate('2026-02-01')).toBe(true); + expect(isValidIsoDate('2026-13-01')).toBe(false); + }); + + it('presetToRange last_30 spans 30 days', () => { + const fixed = new Date('2026-04-05T12:00:00Z'); + const { dateFrom, dateTo } = presetToRange('last_30', fixed); + expect(dateTo).toBe('2026-04-05'); + expect(dateFrom).toBe('2026-03-07'); + }); +}); diff --git a/apps/money-mirror/src/lib/advisory-engine.ts b/apps/money-mirror/src/lib/advisory-engine.ts index 0425484..fe7daf5 100644 --- a/apps/money-mirror/src/lib/advisory-engine.ts +++ b/apps/money-mirror/src/lib/advisory-engine.ts @@ -25,8 +25,13 @@ export interface Advisory { trigger: string; severity: AdvisorySeverity; headline: string; + /** Rule-based body; always present as safe fallback when Gemini is off or fails. */ message: string; amount_paisa?: number; + /** Facts-grounded Gemini narrative (T4); UI prefers this over `message` when set. */ + narrative?: string; + /** Layer A fact ids the narrative is tied to; drives Sources drawer. */ + cited_fact_ids?: string[]; } interface AdvisoryInput { @@ -76,7 +81,7 @@ export function generateAdvisories(input: AdvisoryInput): Advisory[] { id: 'subscription-leak', trigger: 'SUBSCRIPTION_LEAK', severity: input.subscription_paisa > 500000 ? 'warning' : 'info', - headline: `₹${formatRupees(input.subscription_paisa)}/mo in subscriptions`, + headline: `₹${formatRupees(input.subscription_paisa)} in subscriptions this period`, message: 'Netflix, Spotify, YouTube Premium, gym membership — they feel small individually but compound fast. Are you actively using all of them?', amount_paisa: input.subscription_paisa, @@ -92,7 +97,7 @@ export function generateAdvisories(input: AdvisoryInput): Advisory[] { trigger: 'FOOD_DELIVERY', severity: foodPct > 25 ? 'critical' : 'warning', headline: `${Math.round(foodPct)}% of your spending is food delivery`, - message: `You spent ₹${formatRupees(input.food_delivery_paisa)} on Swiggy, Zomato, and restaurants. That's ₹${formatRupees(input.food_delivery_paisa * 12)} per year just on convenience.`, + message: `You spent ₹${formatRupees(input.food_delivery_paisa)} on Swiggy, Zomato, and restaurants this period. Review how much of this was genuine necessity vs convenience.`, amount_paisa: input.food_delivery_paisa, }); } @@ -104,7 +109,7 @@ export function generateAdvisories(input: AdvisoryInput): Advisory[] { id: 'no-investment', trigger: 'NO_INVESTMENT', severity: 'warning', - headline: 'No investments detected this month', + headline: 'No investments detected in this period', message: 'Your statement shows zero SIP, mutual fund, or recurring investment transactions. Even ₹500/month in an index fund compounds significantly over 10 years.', }); diff --git a/apps/money-mirror/src/lib/coaching-enrich.ts b/apps/money-mirror/src/lib/coaching-enrich.ts new file mode 100644 index 0000000..7c0d506 --- /dev/null +++ b/apps/money-mirror/src/lib/coaching-enrich.ts @@ -0,0 +1,45 @@ +import type { DashboardData } from '@/lib/dashboard'; +import { buildLayerAFacts, type LayerAFacts } from '@/lib/coaching-facts'; +import { runGeminiCoachingNarratives } from '@/lib/gemini-coaching-narrative'; +import { captureServerEvent } from '@/lib/posthog'; + +export type DashboardWithCoaching = DashboardData & { coaching_facts: LayerAFacts }; + +/** + * Layer A facts + optional Gemini narratives (T4). Safe fallback: rule `message` stays when AI fails. + */ +export async function attachCoachingLayer( + userId: string, + dashboard: DashboardData +): Promise { + const coaching_facts = buildLayerAFacts(dashboard); + const narrativeResult = await runGeminiCoachingNarratives(dashboard.advisories, coaching_facts); + + if (narrativeResult.ok) { + if (narrativeResult.latency_ms > 0) { + // Single emission source: server-side coaching narrative telemetry + void captureServerEvent(userId, 'coaching_narrative_completed', { + latency_ms: narrativeResult.latency_ms, + advisory_count: dashboard.advisories.length, + }).catch(() => {}); + } + return { + ...dashboard, + advisories: narrativeResult.advisories, + coaching_facts, + }; + } + + if (narrativeResult.code === 'timeout') { + void captureServerEvent(userId, 'coaching_narrative_timeout', { timeout_ms: 9_000 }).catch( + () => {} + ); + } else { + void captureServerEvent(userId, 'coaching_narrative_failed', { + error_type: narrativeResult.code, + detail: narrativeResult.detail, + }).catch(() => {}); + } + + return { ...dashboard, coaching_facts }; +} diff --git a/apps/money-mirror/src/lib/coaching-facts.ts b/apps/money-mirror/src/lib/coaching-facts.ts new file mode 100644 index 0000000..6f42f06 --- /dev/null +++ b/apps/money-mirror/src/lib/coaching-facts.ts @@ -0,0 +1,249 @@ +import { z } from 'zod'; +import type { DashboardData } from '@/lib/dashboard'; + +/** + * Layer A — server-only facts JSON for facts-grounded coaching (issue-010 T4). + * All currency values are paisa (BIGINT-safe integers). Display strings are pre-formatted for UI + Gemini prompts. + */ + +export const coachingFactRowSchema = z.object({ + id: z.string().min(1), + label: z.string().min(1), + /** Canonical amount in paisa when this fact is monetary */ + value_paisa: z.number().int().optional(), + /** Pre-formatted for India locale, e.g. ₹12,345 */ + display_inr: z.string().min(1), + /** Optional second line for ratios / context */ + detail: z.string().optional(), +}); + +export type CoachingFactRow = z.infer; + +export const layerAFactsSchema = z.object({ + version: z.literal(1), + generated_at: z.string(), + scope: z.object({ + kind: z.enum(['single_statement', 'unified']), + date_from: z.string().nullable(), + date_to: z.string().nullable(), + included_statement_ids: z.array(z.string()), + }), + statement_type: z.enum(['bank_account', 'credit_card']), + facts: z.array(coachingFactRowSchema), +}); + +export type LayerAFacts = z.infer; + +function inr(paisa: number): string { + const rupees = Math.round(paisa / 100); + return `₹${rupees.toLocaleString('en-IN')}`; +} + +function pct(part: number, whole: number): string { + if (whole <= 0) { + return '0%'; + } + return `${Math.round((part / whole) * 100)}%`; +} + +/** + * Build Layer A facts from dashboard data (DB-derived + deterministic server math only). + */ +export function buildLayerAFacts(dashboard: DashboardData): LayerAFacts { + const s = dashboard.summary; + const signals = dashboard.signals; + const perceived = dashboard.perceived_spend_paisa; + const income = dashboard.monthly_income_paisa; + const rows: CoachingFactRow[] = []; + + const push = (row: CoachingFactRow) => { + rows.push(coachingFactRowSchema.parse(row)); + }; + + push({ + id: 'total_debits_paisa', + label: 'Total debits in scope', + value_paisa: s.total_debits_paisa, + display_inr: inr(s.total_debits_paisa), + }); + + push({ + id: 'perceived_spend_paisa', + label: 'Perceived monthly spend (baseline)', + value_paisa: perceived, + display_inr: inr(perceived), + }); + + push({ + id: 'monthly_income_paisa', + label: 'Monthly income (onboarding)', + value_paisa: income, + display_inr: inr(income), + }); + + if (perceived > 0 && s.total_debits_paisa > 0) { + const gap = s.total_debits_paisa - perceived; + const gapPct = Math.round((gap / perceived) * 100); + push({ + id: 'perception_gap_paisa', + label: 'Gap (actual debits minus perceived)', + value_paisa: gap, + display_inr: inr(gap), + detail: `${gapPct}% vs perceived`, + }); + } + + push({ + id: 'needs_paisa', + label: 'Needs', + value_paisa: s.needs_paisa, + display_inr: inr(s.needs_paisa), + detail: pct(s.needs_paisa, s.total_debits_paisa) + ' of debits', + }); + push({ + id: 'wants_paisa', + label: 'Wants', + value_paisa: s.wants_paisa, + display_inr: inr(s.wants_paisa), + detail: pct(s.wants_paisa, s.total_debits_paisa) + ' of debits', + }); + push({ + id: 'investment_paisa', + label: 'Investment', + value_paisa: s.investment_paisa, + display_inr: inr(s.investment_paisa), + }); + push({ + id: 'debt_paisa', + label: 'Debt / EMI', + value_paisa: s.debt_paisa, + display_inr: inr(s.debt_paisa), + }); + push({ + id: 'other_paisa', + label: 'Other / uncategorised', + value_paisa: s.other_paisa, + display_inr: inr(s.other_paisa), + detail: pct(s.other_paisa, s.total_debits_paisa) + ' of debits', + }); + + push({ + id: 'food_delivery_signal_paisa', + label: 'Food delivery & dining (heuristic)', + value_paisa: signals.food_delivery_paisa, + display_inr: inr(signals.food_delivery_paisa), + detail: pct(signals.food_delivery_paisa, s.total_debits_paisa) + ' of debits', + }); + + push({ + id: 'subscription_signal_paisa', + label: 'Subscription-like spend (heuristic)', + value_paisa: signals.subscription_paisa, + display_inr: inr(signals.subscription_paisa), + }); + + const discretionary = s.wants_paisa + s.other_paisa; + push({ + id: 'discretionary_paisa', + label: 'Wants + Other (discretionary pool)', + value_paisa: discretionary, + display_inr: inr(discretionary), + detail: pct(discretionary, s.total_debits_paisa) + ' of debits', + }); + + if (s.total_debits_paisa > 0) { + const avoidable = s.wants_paisa + Math.round(s.other_paisa * 0.5); + push({ + id: 'avoidable_proxy_paisa', + label: 'Avoidable spend proxy (wants + ½ Other)', + value_paisa: avoidable, + display_inr: inr(avoidable), + detail: pct(avoidable, s.total_debits_paisa) + ' of debits', + }); + } + + if (income > 0) { + const debtRatio = (s.debt_paisa / income) * 100; + push({ + id: 'debt_to_income_ratio_pct', + label: 'Debt + EMI to income', + display_inr: `${Math.round(debtRatio)}%`, + detail: `${inr(s.debt_paisa)} vs ${inr(income)} income`, + }); + } + + if (dashboard.statement_type === 'credit_card') { + const pay = dashboard.payment_due_paisa; + const min = dashboard.minimum_due_paisa; + if (pay !== null && pay > 0) { + push({ + id: 'cc_payment_due_paisa', + label: 'Credit card statement balance due', + value_paisa: pay, + display_inr: inr(pay), + }); + } + if (min !== null && min > 0) { + push({ + id: 'cc_minimum_due_paisa', + label: 'Credit card minimum due', + value_paisa: min, + display_inr: inr(min), + }); + } + if (pay !== null && min !== null && pay > 0 && min > 0 && min < pay) { + push({ + id: 'cc_min_share_of_balance', + label: 'Minimum due as share of balance due', + display_inr: pct(min, pay), + detail: `${inr(min)} of ${inr(pay)}`, + }); + } + } + + return layerAFactsSchema.parse({ + version: 1, + generated_at: new Date().toISOString(), + scope: { + kind: dashboard.scope.kind, + date_from: dashboard.scope.date_from, + date_to: dashboard.scope.date_to, + included_statement_ids: dashboard.scope.included_statement_ids, + }, + statement_type: dashboard.statement_type, + facts: rows, + }); +} + +export function factIdsFromLayerA(facts: LayerAFacts): Set { + return new Set(facts.facts.map((f) => f.id)); +} + +/** Validate Gemini citations are a non-empty subset of Layer A ids (when AI is used). */ +export function validateCitedFactIds( + cited: string[], + allowed: Set +): { ok: true } | { ok: false; invalid: string[] } { + const invalid = cited.filter((id) => !allowed.has(id)); + if (invalid.length > 0) { + return { ok: false, invalid }; + } + if (cited.length === 0) { + return { ok: false, invalid: [''] }; + } + return { ok: true }; +} + +export function serializeFactsForPrompt(facts: LayerAFacts): string { + return JSON.stringify( + facts.facts.map((f) => ({ + id: f.id, + label: f.label, + display_inr: f.display_inr, + value_paisa: f.value_paisa, + detail: f.detail, + })), + null, + 0 + ); +} diff --git a/apps/money-mirror/src/lib/dashboard-helpers.ts b/apps/money-mirror/src/lib/dashboard-helpers.ts new file mode 100644 index 0000000..8d6caa7 --- /dev/null +++ b/apps/money-mirror/src/lib/dashboard-helpers.ts @@ -0,0 +1,56 @@ +import { generateAdvisories, type Advisory } from '@/lib/advisory-engine'; +import { toNumber } from '@/lib/db'; +import type { + DashboardAggregateRow, + DashboardSignals, + DashboardSummary, + StatementRow, +} from '@/lib/dashboard-types'; + +export function buildDashboardSummary(aggregate: DashboardAggregateRow): DashboardSummary { + return { + needs_paisa: toNumber(aggregate.needs_paisa), + wants_paisa: toNumber(aggregate.wants_paisa), + investment_paisa: toNumber(aggregate.investment_paisa), + debt_paisa: toNumber(aggregate.debt_paisa), + other_paisa: toNumber(aggregate.other_paisa), + total_debits_paisa: toNumber(aggregate.total_debits_paisa), + total_credits_paisa: toNumber(aggregate.total_credits_paisa), + }; +} + +export function computeDebitSignals(aggregate: DashboardAggregateRow): DashboardSignals { + return { + food_delivery_paisa: toNumber(aggregate.food_delivery_paisa), + subscription_paisa: toNumber(aggregate.subscription_paisa), + }; +} + +export function buildAdvisories( + summary: DashboardSummary, + statement: StatementRow, + signals: DashboardSignals +): Advisory[] { + return generateAdvisories({ + statement_type: statement.statement_type, + summary: { + needs: summary.needs_paisa, + wants: summary.wants_paisa, + investment: summary.investment_paisa, + debt: summary.debt_paisa, + other: summary.other_paisa, + total_debits: summary.total_debits_paisa, + total_credits: summary.total_credits_paisa, + }, + perceived_spend_paisa: statement.perceived_spend_paisa, + monthly_income_paisa: statement.monthly_income_paisa, + debt_load_paisa: + statement.statement_type === 'credit_card' + ? (statement.payment_due_paisa ?? statement.minimum_due_paisa ?? summary.debt_paisa) + : summary.debt_paisa, + food_delivery_paisa: signals.food_delivery_paisa, + subscription_paisa: signals.subscription_paisa, + payment_due_paisa: statement.payment_due_paisa, + minimum_due_paisa: statement.minimum_due_paisa, + }); +} diff --git a/apps/money-mirror/src/lib/dashboard-legacy.ts b/apps/money-mirror/src/lib/dashboard-legacy.ts new file mode 100644 index 0000000..45ccbf8 --- /dev/null +++ b/apps/money-mirror/src/lib/dashboard-legacy.ts @@ -0,0 +1,172 @@ +import { getDb, toNumber } from '@/lib/db'; +import type { StatementType } from '@/lib/statements'; +import { + buildAdvisories, + buildDashboardSummary, + computeDebitSignals, +} from '@/lib/dashboard-helpers'; +import type { DashboardAggregateRow, DashboardData, StatementRow } from '@/lib/dashboard-types'; + +export async function fetchDashboardLegacy( + userId: string, + statementId: string | null +): Promise { + const sql = getDb(); + + const statementRows = statementId + ? ((await sql` + SELECT s.id, s.institution_name, s.statement_type, s.period_start, s.period_end, s.due_date, s.payment_due_paisa, s.minimum_due_paisa, s.credit_limit_paisa, p.perceived_spend_paisa, p.monthly_income_paisa, s.nickname, s.account_purpose, s.card_network + FROM statements s + LEFT JOIN profiles p ON p.id = s.user_id + WHERE s.user_id = ${userId} + AND s.status = 'processed' + AND s.id = ${statementId} + LIMIT 1 + `) as { + id: string; + institution_name: string; + statement_type: StatementType; + period_start: string | null; + period_end: string | null; + due_date: string | null; + payment_due_paisa: number | string | bigint | null; + minimum_due_paisa: number | string | bigint | null; + credit_limit_paisa: number | string | bigint | null; + perceived_spend_paisa: number | string | bigint | null; + monthly_income_paisa: number | string | bigint | null; + nickname: string | null; + account_purpose: string | null; + card_network: string | null; + }[]) + : ((await sql` + SELECT s.id, s.institution_name, s.statement_type, s.period_start, s.period_end, s.due_date, s.payment_due_paisa, s.minimum_due_paisa, s.credit_limit_paisa, p.perceived_spend_paisa, p.monthly_income_paisa, s.nickname, s.account_purpose, s.card_network + FROM statements s + LEFT JOIN profiles p ON p.id = s.user_id + WHERE s.user_id = ${userId} + AND s.status = 'processed' + ORDER BY s.period_end DESC NULLS LAST, s.created_at DESC + LIMIT 1 + `) as { + id: string; + institution_name: string; + statement_type: StatementType; + period_start: string | null; + period_end: string | null; + due_date: string | null; + payment_due_paisa: number | string | bigint | null; + minimum_due_paisa: number | string | bigint | null; + credit_limit_paisa: number | string | bigint | null; + perceived_spend_paisa: number | string | bigint | null; + monthly_income_paisa: number | string | bigint | null; + nickname: string | null; + account_purpose: string | null; + card_network: string | null; + }[]); + + const row = statementRows[0]; + if (!row) { + return null; + } + + const statement: StatementRow = { + id: row.id, + institution_name: row.institution_name, + statement_type: row.statement_type, + period_start: row.period_start, + period_end: row.period_end, + due_date: row.due_date, + payment_due_paisa: row.payment_due_paisa === null ? null : toNumber(row.payment_due_paisa), + minimum_due_paisa: row.minimum_due_paisa === null ? null : toNumber(row.minimum_due_paisa), + credit_limit_paisa: row.credit_limit_paisa === null ? null : toNumber(row.credit_limit_paisa), + perceived_spend_paisa: + row.perceived_spend_paisa === null ? 0 : toNumber(row.perceived_spend_paisa), + monthly_income_paisa: + row.monthly_income_paisa === null ? 0 : toNumber(row.monthly_income_paisa), + nickname: row.nickname ?? null, + account_purpose: row.account_purpose ?? null, + card_network: row.card_network ?? null, + }; + + const aggregateRows = (await sql` + SELECT + COALESCE(SUM(CASE WHEN type = 'debit' AND category = 'needs' THEN amount_paisa ELSE 0 END), 0)::bigint AS needs_paisa, + COALESCE(SUM(CASE WHEN type = 'debit' AND category = 'wants' THEN amount_paisa ELSE 0 END), 0)::bigint AS wants_paisa, + COALESCE(SUM(CASE WHEN type = 'debit' AND category = 'investment' THEN amount_paisa ELSE 0 END), 0)::bigint AS investment_paisa, + COALESCE(SUM(CASE WHEN type = 'debit' AND category = 'debt' THEN amount_paisa ELSE 0 END), 0)::bigint AS debt_paisa, + COALESCE(SUM(CASE WHEN type = 'debit' AND category = 'other' THEN amount_paisa ELSE 0 END), 0)::bigint AS other_paisa, + COALESCE(SUM(CASE WHEN type = 'debit' THEN amount_paisa ELSE 0 END), 0)::bigint AS total_debits_paisa, + COALESCE(SUM(CASE WHEN type = 'credit' THEN amount_paisa ELSE 0 END), 0)::bigint AS total_credits_paisa, + COALESCE(SUM(CASE + WHEN type = 'debit' AND ( + LOWER(description) LIKE '%swiggy%' OR + LOWER(description) LIKE '%zomato%' OR + LOWER(description) LIKE '%eatsure%' OR + LOWER(description) LIKE '%dunzo%' OR + LOWER(description) LIKE '%barbeque%' OR + LOWER(description) LIKE '%starbucks%' OR + LOWER(description) LIKE '%cafe%' OR + LOWER(description) LIKE '%restaurant%' OR + LOWER(description) LIKE '%food%' OR + LOWER(description) LIKE '%pizza%' OR + LOWER(description) LIKE '%burger%' OR + LOWER(description) LIKE '%kfc%' OR + LOWER(description) LIKE '%mcdonald%' OR + LOWER(description) LIKE '%domino%' + ) THEN amount_paisa + ELSE 0 + END), 0)::bigint AS food_delivery_paisa, + COALESCE(SUM(CASE + WHEN type = 'debit' AND ( + LOWER(description) LIKE '%netflix%' OR + LOWER(description) LIKE '%hotstar%' OR + LOWER(description) LIKE '%prime%' OR + LOWER(description) LIKE '%spotify%' OR + LOWER(description) LIKE '%youtube premium%' OR + LOWER(description) LIKE '%jiocinema%' OR + LOWER(description) LIKE '%zee5%' OR + LOWER(description) LIKE '%sonyliv%' OR + LOWER(description) LIKE '%subscription%' OR + LOWER(description) LIKE '%gym%' OR + LOWER(description) LIKE '%membership%' + ) THEN amount_paisa + ELSE 0 + END), 0)::bigint AS subscription_paisa, + COUNT(*)::bigint AS transaction_count + FROM transactions + WHERE statement_id = ${statement.id} + AND user_id = ${userId} + `) as DashboardAggregateRow[]; + const aggregate = aggregateRows[0]; + + const summary = buildDashboardSummary(aggregate); + const signals = computeDebitSignals(aggregate); + const advisories = buildAdvisories(summary, statement, signals); + + return { + statement_id: statement.id, + institution_name: statement.institution_name, + statement_type: statement.statement_type, + period_start: statement.period_start, + period_end: statement.period_end, + due_date: statement.due_date, + payment_due_paisa: statement.payment_due_paisa, + minimum_due_paisa: statement.minimum_due_paisa, + credit_limit_paisa: statement.credit_limit_paisa, + perceived_spend_paisa: statement.perceived_spend_paisa, + monthly_income_paisa: statement.monthly_income_paisa, + nickname: statement.nickname, + account_purpose: statement.account_purpose, + card_network: statement.card_network, + transaction_count: toNumber(aggregate.transaction_count), + summary, + signals, + advisories, + scope: { + kind: 'single_statement', + date_from: null, + date_to: null, + included_statement_ids: [statement.id], + }, + perceived_is_profile_baseline: true, + }; +} diff --git a/apps/money-mirror/src/lib/dashboard-types.ts b/apps/money-mirror/src/lib/dashboard-types.ts new file mode 100644 index 0000000..ae55312 --- /dev/null +++ b/apps/money-mirror/src/lib/dashboard-types.ts @@ -0,0 +1,83 @@ +import type { Advisory } from '@/lib/advisory-engine'; +import type { StatementType } from '@/lib/statements'; + +export interface DashboardSummary { + needs_paisa: number; + wants_paisa: number; + investment_paisa: number; + debt_paisa: number; + other_paisa: number; + total_debits_paisa: number; + total_credits_paisa: number; +} + +/** Heuristic debit signals (same regexes as advisory engine) for Layer A facts. */ +export interface DashboardSignals { + food_delivery_paisa: number; + subscription_paisa: number; +} + +export interface DashboardScopeMeta { + kind: 'single_statement' | 'unified'; + date_from: string | null; + date_to: string | null; + included_statement_ids: string[]; +} + +export interface DashboardData { + statement_id: string; + institution_name: string; + statement_type: StatementType; + period_start: string | null; + period_end: string | null; + due_date: string | null; + payment_due_paisa: number | null; + minimum_due_paisa: number | null; + credit_limit_paisa: number | null; + perceived_spend_paisa: number; + monthly_income_paisa: number; + nickname: string | null; + account_purpose: string | null; + card_network: string | null; + transaction_count: number; + summary: DashboardSummary; + signals: DashboardSignals; + advisories: Advisory[]; + scope: DashboardScopeMeta; + /** True when perceived spend comes from profiles (single monthly baseline), not per-statement. */ + perceived_is_profile_baseline: boolean; +} + +export interface StatementRow { + id: string; + institution_name: string; + statement_type: StatementType; + period_start: string | null; + period_end: string | null; + due_date: string | null; + payment_due_paisa: number | null; + minimum_due_paisa: number | null; + credit_limit_paisa: number | null; + perceived_spend_paisa: number; + monthly_income_paisa: number; + nickname: string | null; + account_purpose: string | null; + card_network: string | null; +} + +export interface DashboardAggregateRow { + needs_paisa: number | string | bigint; + wants_paisa: number | string | bigint; + investment_paisa: number | string | bigint; + debt_paisa: number | string | bigint; + other_paisa: number | string | bigint; + total_debits_paisa: number | string | bigint; + total_credits_paisa: number | string | bigint; + food_delivery_paisa: number | string | bigint; + subscription_paisa: number | string | bigint; + transaction_count: number | string | bigint; +} + +export type DashboardFetchInput = + | { variant: 'legacy'; statementId: string | null } + | { variant: 'unified'; dateFrom: string; dateTo: string; statementIds: string[] | null }; diff --git a/apps/money-mirror/src/lib/dashboard-unified.ts b/apps/money-mirror/src/lib/dashboard-unified.ts new file mode 100644 index 0000000..44ea5da --- /dev/null +++ b/apps/money-mirror/src/lib/dashboard-unified.ts @@ -0,0 +1,178 @@ +import { getDb, toNumber } from '@/lib/db'; +import type { StatementType } from '@/lib/statements'; +import { + buildAdvisories, + buildDashboardSummary, + computeDebitSignals, +} from '@/lib/dashboard-helpers'; +import type { DashboardAggregateRow, DashboardData, StatementRow } from '@/lib/dashboard-types'; + +export async function fetchDashboardUnified( + userId: string, + dateFrom: string, + dateTo: string, + statementIds: string[] | null +): Promise { + const sql = getDb(); + + let includedIds: string[]; + if (statementIds && statementIds.length > 0) { + const owned = (await sql` + SELECT id + FROM statements + WHERE user_id = ${userId} + AND status = 'processed' + AND id = ANY(${statementIds}::uuid[]) + `) as { id: string }[]; + if (owned.length !== statementIds.length) { + return null; + } + includedIds = owned.map((r) => r.id); + } else { + const all = (await sql` + SELECT id + FROM statements + WHERE user_id = ${userId} + AND status = 'processed' + ORDER BY period_end DESC NULLS LAST, created_at DESC + `) as { id: string }[]; + if (all.length === 0) { + return null; + } + includedIds = all.map((r) => r.id); + } + + const profileRows = (await sql` + SELECT perceived_spend_paisa, monthly_income_paisa + FROM profiles + WHERE id = ${userId} + LIMIT 1 + `) as { + perceived_spend_paisa: number | string | bigint | null; + monthly_income_paisa: number | string | bigint | null; + }[]; + const prof = profileRows[0]; + const perceivedProfile = + prof?.perceived_spend_paisa == null ? 0 : toNumber(prof.perceived_spend_paisa); + const monthlyIncome = + prof?.monthly_income_paisa == null ? 0 : toNumber(prof.monthly_income_paisa); + + const aggregateRows = (await sql` + SELECT + COALESCE(SUM(CASE WHEN type = 'debit' AND category = 'needs' THEN amount_paisa ELSE 0 END), 0)::bigint AS needs_paisa, + COALESCE(SUM(CASE WHEN type = 'debit' AND category = 'wants' THEN amount_paisa ELSE 0 END), 0)::bigint AS wants_paisa, + COALESCE(SUM(CASE WHEN type = 'debit' AND category = 'investment' THEN amount_paisa ELSE 0 END), 0)::bigint AS investment_paisa, + COALESCE(SUM(CASE WHEN type = 'debit' AND category = 'debt' THEN amount_paisa ELSE 0 END), 0)::bigint AS debt_paisa, + COALESCE(SUM(CASE WHEN type = 'debit' AND category = 'other' THEN amount_paisa ELSE 0 END), 0)::bigint AS other_paisa, + COALESCE(SUM(CASE WHEN type = 'debit' THEN amount_paisa ELSE 0 END), 0)::bigint AS total_debits_paisa, + COALESCE(SUM(CASE WHEN type = 'credit' THEN amount_paisa ELSE 0 END), 0)::bigint AS total_credits_paisa, + COALESCE(SUM(CASE + WHEN type = 'debit' AND ( + LOWER(description) LIKE '%swiggy%' OR + LOWER(description) LIKE '%zomato%' OR + LOWER(description) LIKE '%eatsure%' OR + LOWER(description) LIKE '%dunzo%' OR + LOWER(description) LIKE '%barbeque%' OR + LOWER(description) LIKE '%starbucks%' OR + LOWER(description) LIKE '%cafe%' OR + LOWER(description) LIKE '%restaurant%' OR + LOWER(description) LIKE '%food%' OR + LOWER(description) LIKE '%pizza%' OR + LOWER(description) LIKE '%burger%' OR + LOWER(description) LIKE '%kfc%' OR + LOWER(description) LIKE '%mcdonald%' OR + LOWER(description) LIKE '%domino%' + ) THEN amount_paisa + ELSE 0 + END), 0)::bigint AS food_delivery_paisa, + COALESCE(SUM(CASE + WHEN type = 'debit' AND ( + LOWER(description) LIKE '%netflix%' OR + LOWER(description) LIKE '%hotstar%' OR + LOWER(description) LIKE '%prime%' OR + LOWER(description) LIKE '%spotify%' OR + LOWER(description) LIKE '%youtube premium%' OR + LOWER(description) LIKE '%jiocinema%' OR + LOWER(description) LIKE '%zee5%' OR + LOWER(description) LIKE '%sonyliv%' OR + LOWER(description) LIKE '%subscription%' OR + LOWER(description) LIKE '%gym%' OR + LOWER(description) LIKE '%membership%' + ) THEN amount_paisa + ELSE 0 + END), 0)::bigint AS subscription_paisa, + COUNT(*)::bigint AS transaction_count + FROM transactions + WHERE user_id = ${userId} + AND statement_id = ANY(${includedIds}::uuid[]) + AND date >= ${dateFrom}::date + AND date <= ${dateTo}::date + `) as DashboardAggregateRow[]; + const aggregate = aggregateRows[0]; + + const metaRows = (await sql` + SELECT id, institution_name, statement_type, nickname, account_purpose, card_network + FROM statements + WHERE user_id = ${userId} + AND id = ANY(${includedIds}::uuid[]) + `) as { + id: string; + institution_name: string; + statement_type: StatementType; + nickname: string | null; + account_purpose: string | null; + card_network: string | null; + }[]; + + const institutionLabel = + metaRows.length > 1 ? 'Multiple accounts' : (metaRows[0]?.institution_name ?? '—'); + const primaryId = includedIds[0]; + const syntheticStatement: StatementRow = { + id: primaryId, + institution_name: institutionLabel, + statement_type: 'bank_account', + period_start: dateFrom, + period_end: dateTo, + due_date: null, + payment_due_paisa: null, + minimum_due_paisa: null, + credit_limit_paisa: null, + perceived_spend_paisa: perceivedProfile, + monthly_income_paisa: monthlyIncome, + nickname: metaRows.length === 1 ? (metaRows[0]?.nickname ?? null) : null, + account_purpose: metaRows.length === 1 ? (metaRows[0]?.account_purpose ?? null) : null, + card_network: null, + }; + + const summary = buildDashboardSummary(aggregate); + const signals = computeDebitSignals(aggregate); + const advisories = buildAdvisories(summary, syntheticStatement, signals); + + return { + statement_id: primaryId, + institution_name: institutionLabel, + statement_type: 'bank_account', + period_start: dateFrom, + period_end: dateTo, + due_date: null, + payment_due_paisa: null, + minimum_due_paisa: null, + credit_limit_paisa: null, + perceived_spend_paisa: perceivedProfile, + monthly_income_paisa: monthlyIncome, + nickname: syntheticStatement.nickname, + account_purpose: syntheticStatement.account_purpose, + card_network: null, + transaction_count: toNumber(aggregate.transaction_count), + summary, + signals, + advisories, + scope: { + kind: 'unified', + date_from: dateFrom, + date_to: dateTo, + included_statement_ids: includedIds, + }, + perceived_is_profile_baseline: true, + }; +} diff --git a/apps/money-mirror/src/lib/dashboard.ts b/apps/money-mirror/src/lib/dashboard.ts index 75e17f4..e3a653a 100644 --- a/apps/money-mirror/src/lib/dashboard.ts +++ b/apps/money-mirror/src/lib/dashboard.ts @@ -1,285 +1,21 @@ -import { generateAdvisories, type Advisory } from '@/lib/advisory-engine'; -import type { CategorySummary } from '@/lib/categorizer'; -import { getDb, toNumber } from '@/lib/db'; -import type { StatementType } from '@/lib/statements'; - -const FOOD_REGEX = - /\b(swiggy|zomato|eatsure|dunzo|barbeque|starbucks|cafe|restaurant|food|pizza|burger|kfc|mcdonald|domino)\b/i; -const SUBSCRIPTION_REGEX = - /\b(netflix|hotstar|prime|spotify|youtube premium|jiocinema|zee5|sonyliv|subscription|gym|membership)\b/i; - -export interface DashboardSummary { - needs_paisa: number; - wants_paisa: number; - investment_paisa: number; - debt_paisa: number; - other_paisa: number; - total_debits_paisa: number; - total_credits_paisa: number; -} - -export interface DashboardData { - statement_id: string; - institution_name: string; - statement_type: StatementType; - period_start: string | null; - period_end: string | null; - due_date: string | null; - payment_due_paisa: number | null; - minimum_due_paisa: number | null; - credit_limit_paisa: number | null; - perceived_spend_paisa: number; - monthly_income_paisa: number; - nickname: string | null; - account_purpose: string | null; - card_network: string | null; - transaction_count: number; - summary: DashboardSummary; - advisories: Advisory[]; -} - -interface StatementRow { - id: string; - institution_name: string; - statement_type: StatementType; - period_start: string | null; - period_end: string | null; - due_date: string | null; - payment_due_paisa: number | null; - minimum_due_paisa: number | null; - credit_limit_paisa: number | null; - perceived_spend_paisa: number; - monthly_income_paisa: number; - nickname: string | null; - account_purpose: string | null; - card_network: string | null; -} - -interface TransactionRow { - category: string; - amount_paisa: number; - type: 'debit' | 'credit'; - description: string; - is_recurring: boolean; -} +import { fetchDashboardLegacy } from '@/lib/dashboard-legacy'; +import { fetchDashboardUnified } from '@/lib/dashboard-unified'; +import type { DashboardData, DashboardFetchInput } from '@/lib/dashboard-types'; + +export type { + DashboardData, + DashboardFetchInput, + DashboardScopeMeta, + DashboardSignals, + DashboardSummary, +} from '@/lib/dashboard-types'; export async function fetchDashboardData( userId: string, - statementId: string | null + input: DashboardFetchInput ): Promise { - const sql = getDb(); - - const statementRows = statementId - ? ((await sql` - SELECT s.id, s.institution_name, s.statement_type, s.period_start, s.period_end, s.due_date, s.payment_due_paisa, s.minimum_due_paisa, s.credit_limit_paisa, s.perceived_spend_paisa, p.monthly_income_paisa, s.nickname, s.account_purpose, s.card_network - FROM statements s - LEFT JOIN profiles p ON p.id = s.user_id - WHERE s.user_id = ${userId} - AND s.status = 'processed' - AND s.id = ${statementId} - LIMIT 1 - `) as { - id: string; - institution_name: string; - statement_type: StatementType; - period_start: string | null; - period_end: string | null; - due_date: string | null; - payment_due_paisa: number | string | bigint | null; - minimum_due_paisa: number | string | bigint | null; - credit_limit_paisa: number | string | bigint | null; - perceived_spend_paisa: number | string | bigint; - monthly_income_paisa: number | string | bigint | null; - nickname: string | null; - account_purpose: string | null; - card_network: string | null; - }[]) - : ((await sql` - SELECT s.id, s.institution_name, s.statement_type, s.period_start, s.period_end, s.due_date, s.payment_due_paisa, s.minimum_due_paisa, s.credit_limit_paisa, s.perceived_spend_paisa, p.monthly_income_paisa, s.nickname, s.account_purpose, s.card_network - FROM statements s - LEFT JOIN profiles p ON p.id = s.user_id - WHERE s.user_id = ${userId} - AND s.status = 'processed' - ORDER BY s.period_end DESC NULLS LAST, s.created_at DESC - LIMIT 1 - `) as { - id: string; - institution_name: string; - statement_type: StatementType; - period_start: string | null; - period_end: string | null; - due_date: string | null; - payment_due_paisa: number | string | bigint | null; - minimum_due_paisa: number | string | bigint | null; - credit_limit_paisa: number | string | bigint | null; - perceived_spend_paisa: number | string | bigint; - monthly_income_paisa: number | string | bigint | null; - nickname: string | null; - account_purpose: string | null; - card_network: string | null; - }[]); - - const row = statementRows[0]; - if (!row) { - return null; - } - - const statement: StatementRow = { - id: row.id, - institution_name: row.institution_name, - statement_type: row.statement_type, - period_start: row.period_start, - period_end: row.period_end, - due_date: row.due_date, - payment_due_paisa: row.payment_due_paisa === null ? null : toNumber(row.payment_due_paisa), - minimum_due_paisa: row.minimum_due_paisa === null ? null : toNumber(row.minimum_due_paisa), - credit_limit_paisa: row.credit_limit_paisa === null ? null : toNumber(row.credit_limit_paisa), - perceived_spend_paisa: toNumber(row.perceived_spend_paisa), - monthly_income_paisa: - row.monthly_income_paisa === null ? 0 : toNumber(row.monthly_income_paisa), - nickname: row.nickname ?? null, - account_purpose: row.account_purpose ?? null, - card_network: row.card_network ?? null, - }; - - const transactionRows = (await sql` - SELECT category, amount_paisa, type, description, is_recurring - FROM transactions - WHERE statement_id = ${statement.id} - AND user_id = ${userId} - ORDER BY date DESC - LIMIT 1000 - `) as { - category: string; - amount_paisa: number | string | bigint; - type: 'debit' | 'credit'; - description: string; - is_recurring: boolean; - }[]; - - const transactions: TransactionRow[] = transactionRows.map((transactionRow) => ({ - category: transactionRow.category, - amount_paisa: toNumber(transactionRow.amount_paisa), - type: transactionRow.type, - description: transactionRow.description, - is_recurring: transactionRow.is_recurring, - })); - - const summary = buildDashboardSummary(transactions); - const advisories = buildAdvisories(summary, statement, transactions); - - return { - statement_id: statement.id, - institution_name: statement.institution_name, - statement_type: statement.statement_type, - period_start: statement.period_start, - period_end: statement.period_end, - due_date: statement.due_date, - payment_due_paisa: statement.payment_due_paisa, - minimum_due_paisa: statement.minimum_due_paisa, - credit_limit_paisa: statement.credit_limit_paisa, - perceived_spend_paisa: statement.perceived_spend_paisa, - monthly_income_paisa: statement.monthly_income_paisa, - nickname: statement.nickname, - account_purpose: statement.account_purpose, - card_network: statement.card_network, - transaction_count: transactions.length, - summary, - advisories, - }; -} - -function buildDashboardSummary(transactions: TransactionRow[]): DashboardSummary { - const summary: CategorySummary = { - needs: 0, - wants: 0, - investment: 0, - debt: 0, - other: 0, - total_debits: 0, - total_credits: 0, - }; - - for (const tx of transactions) { - if (tx.type === 'credit') { - summary.total_credits += tx.amount_paisa; - continue; - } - - summary.total_debits += tx.amount_paisa; - - if (tx.category === 'needs') { - summary.needs += tx.amount_paisa; - continue; - } - if (tx.category === 'wants') { - summary.wants += tx.amount_paisa; - continue; - } - if (tx.category === 'investment') { - summary.investment += tx.amount_paisa; - continue; - } - if (tx.category === 'debt') { - summary.debt += tx.amount_paisa; - continue; - } - - summary.other += tx.amount_paisa; + if (input.variant === 'unified') { + return fetchDashboardUnified(userId, input.dateFrom, input.dateTo, input.statementIds); } - - return { - needs_paisa: summary.needs, - wants_paisa: summary.wants, - investment_paisa: summary.investment, - debt_paisa: summary.debt, - other_paisa: summary.other, - total_debits_paisa: summary.total_debits, - total_credits_paisa: summary.total_credits, - }; -} - -function buildAdvisories( - summary: DashboardSummary, - statement: StatementRow, - transactions: TransactionRow[] -): Advisory[] { - let foodDeliveryPaisa = 0; - let subscriptionPaisa = 0; - - for (const tx of transactions) { - if (tx.type !== 'debit') { - continue; - } - - if (FOOD_REGEX.test(tx.description)) { - foodDeliveryPaisa += tx.amount_paisa; - } - if (SUBSCRIPTION_REGEX.test(tx.description)) { - subscriptionPaisa += tx.amount_paisa; - } - } - - return generateAdvisories({ - statement_type: statement.statement_type, - summary: { - needs: summary.needs_paisa, - wants: summary.wants_paisa, - investment: summary.investment_paisa, - debt: summary.debt_paisa, - other: summary.other_paisa, - total_debits: summary.total_debits_paisa, - total_credits: summary.total_credits_paisa, - }, - perceived_spend_paisa: statement.perceived_spend_paisa, - monthly_income_paisa: statement.monthly_income_paisa, - debt_load_paisa: - statement.statement_type === 'credit_card' - ? (statement.payment_due_paisa ?? statement.minimum_due_paisa ?? summary.debt_paisa) - : summary.debt_paisa, - food_delivery_paisa: foodDeliveryPaisa, - subscription_paisa: subscriptionPaisa, - payment_due_paisa: statement.payment_due_paisa, - minimum_due_paisa: statement.minimum_due_paisa, - }); + return fetchDashboardLegacy(userId, input.statementId); } diff --git a/apps/money-mirror/src/lib/gemini-coaching-narrative.ts b/apps/money-mirror/src/lib/gemini-coaching-narrative.ts new file mode 100644 index 0000000..3102c3b --- /dev/null +++ b/apps/money-mirror/src/lib/gemini-coaching-narrative.ts @@ -0,0 +1,146 @@ +import { GoogleGenAI } from '@google/genai'; +import type { Advisory } from '@/lib/advisory-engine'; +import { + factIdsFromLayerA, + serializeFactsForPrompt, + validateCitedFactIds, + type LayerAFacts, +} from '@/lib/coaching-facts'; + +// NOTE: GoogleGenAI SDK does not expose AbortSignal on generateContent, so we cannot +// cancel the underlying HTTP request on timeout — the race rejects but Gemini continues. +// Timeout capped at 9s per Engineering Lesson #15 (Vercel serverless limit). +const TIMEOUT_MS = 9_000; + +const responseJsonSchema = { + type: 'object', + additionalProperties: false, + properties: { + items: { + type: 'array', + items: { + type: 'object', + additionalProperties: false, + properties: { + advisory_id: { type: 'string' }, + narrative: { type: 'string' }, + cited_fact_ids: { + type: 'array', + items: { type: 'string' }, + }, + }, + required: ['advisory_id', 'narrative', 'cited_fact_ids'], + }, + }, + }, + required: ['items'], +} as const; + +export type CoachingNarrativeOutcome = + | { ok: true; advisories: Advisory[]; latency_ms: number } + | { ok: false; code: 'timeout' | 'gemini_error' | 'validation'; detail?: string }; + +/** + * Gemini narrative step: structured JSON only; citations must reference Layer A fact ids. + * Single emission source: server-side in /api/dashboard (not duplicated on client). + */ +export async function runGeminiCoachingNarratives( + advisories: Advisory[], + facts: LayerAFacts +): Promise { + if (!process.env.GEMINI_API_KEY || advisories.length === 0) { + return { ok: true, advisories, latency_ms: 0 }; + } + + const genai = new GoogleGenAI({ apiKey: process.env.GEMINI_API_KEY }); + const allowed = factIdsFromLayerA(facts); + const factsJson = serializeFactsForPrompt(facts); + const advisoryBrief = advisories.map((a) => ({ + id: a.id, + trigger: a.trigger, + headline: a.headline, + severity: a.severity, + })); + + const prompt = `You are a MoneyMirror coaching copywriter for Indian users (educational, not investment advice). + +Facts JSON (authoritative numbers — do not invent amounts or percentages): +${factsJson} + +Advisory cards to rewrite (one narrative each): +${JSON.stringify(advisoryBrief, null, 0)} + +Rules: +- Output JSON only matching the response schema. +- For each advisory_id, write 2–4 sentences: observation, why it matters, one gentle next step. +- Do not recommend buying or selling specific securities. +- cited_fact_ids must list the fact "id" strings your narrative depends on (non-empty subset of the facts above). +- Do not include rupee digits or ₹ in the narrative text — refer to concepts only; figures appear in Sources from facts. +- Match every advisory_id exactly once.`; + + const started = Date.now(); + + try { + const geminiPromise = genai.models + .generateContent({ + model: 'gemini-2.5-flash', + config: { + thinkingConfig: { thinkingBudget: 0 }, + responseMimeType: 'application/json', + responseJsonSchema, + }, + contents: [{ role: 'user', parts: [{ text: prompt }] }], + }) + .then((res) => { + const parts = res.candidates?.[0]?.content?.parts ?? []; + const raw = + parts.find((p) => p.text?.trim().startsWith('{'))?.text ?? + parts.find((p) => p.text)?.text ?? + ''; + const json = raw + .replace(/^```(?:json)?\s*/i, '') + .replace(/```\s*$/i, '') + .trim(); + return JSON.parse(json) as { + items: Array<{ advisory_id: string; narrative: string; cited_fact_ids: string[] }>; + }; + }); + + const timeoutPromise = new Promise((_, reject) => + setTimeout(() => reject(new Error('GEMINI_TIMEOUT')), TIMEOUT_MS) + ); + + const parsed = await Promise.race([geminiPromise, timeoutPromise]); + const latency_ms = Date.now() - started; + + const byId = new Map(parsed.items.map((i) => [i.advisory_id, i])); + const merged: Advisory[] = advisories.map((a) => { + const row = byId.get(a.id); + if (!row) { + return a; + } + const narrative = row.narrative?.trim() ?? ''; + if (narrative.length < 12 || narrative.length > 2000) { + return a; + } + const check = validateCitedFactIds(row.cited_fact_ids, allowed); + if (!check.ok) { + return a; + } + return { + ...a, + narrative, + cited_fact_ids: row.cited_fact_ids, + }; + }); + + return { ok: true, advisories: merged, latency_ms }; + } catch (err) { + const isTimeout = err instanceof Error && err.message === 'GEMINI_TIMEOUT'; + if (isTimeout) { + return { ok: false, code: 'timeout' }; + } + const detail = err instanceof Error ? err.message : String(err); + return { ok: false, code: 'gemini_error', detail }; + } +} diff --git a/apps/money-mirror/src/lib/merchant-normalize.ts b/apps/money-mirror/src/lib/merchant-normalize.ts new file mode 100644 index 0000000..b5f7acc --- /dev/null +++ b/apps/money-mirror/src/lib/merchant-normalize.ts @@ -0,0 +1,71 @@ +/** + * Deterministic merchant_key for rollup v1 (no LLM). + * Used on transaction insert and optional backfill. + */ + +const KNOWN: { pattern: RegExp; key: string }[] = [ + { pattern: /\bzomato\b/i, key: 'zomato' }, + { pattern: /\bswiggy\b/i, key: 'swiggy' }, + { pattern: /\bblinkit\b/i, key: 'blinkit' }, + { pattern: /\binstamart\b/i, key: 'instamart' }, + { pattern: /\bamazon\b/i, key: 'amazon' }, + { pattern: /\bflipkart\b/i, key: 'flipkart' }, + { pattern: /\buber\b/i, key: 'uber' }, + { pattern: /\bola\b/i, key: 'ola' }, + { pattern: /\bnetflix\b/i, key: 'netflix' }, + { pattern: /\bspotify\b/i, key: 'spotify' }, + { pattern: /\byoutube\b/i, key: 'youtube' }, + { pattern: /\bphonepe\b/i, key: 'phonepe' }, + { pattern: /\bgpay\b/i, key: 'gpay' }, + { pattern: /\bpaytm\b/i, key: 'paytm' }, + { pattern: /\bhdfc\b/i, key: 'hdfc' }, + { pattern: /\bicici\b/i, key: 'icici' }, + { pattern: /\bsbi\b/i, key: 'sbi' }, + { pattern: /\baxis\b/i, key: 'axis' }, + { pattern: /\bkotak\b/i, key: 'kotak' }, +]; + +const UPI_HANDLE = /([a-z0-9][a-z0-9._-]{1,48}@[a-z0-9._-]+)/i; + +function slugifyToken(raw: string): string | null { + const s = raw + .toLowerCase() + .replace(/[^a-z0-9]+/g, '_') + .replace(/^_+|_+$/g, '') + .slice(0, 64); + return s.length >= 2 ? s : null; +} + +/** + * Returns a stable key for grouping, or null when not confidently derivable. + */ +export function normalizeMerchantKey(description: string): string | null { + const trimmed = description.trim(); + if (!trimmed) { + return null; + } + + const lower = trimmed.toLowerCase(); + + for (const { pattern, key } of KNOWN) { + if (pattern.test(lower)) { + return key; + } + } + + const upi = trimmed.match(UPI_HANDLE); + if (upi?.[1]) { + const slug = slugifyToken(upi[1].split('@')[0] ?? ''); + if (slug) { + return `upi_${slug}`; + } + } + + // First line / first segment before pipe or tab + const firstSegment = trimmed.split(/[|\t]/)[0]?.trim() ?? trimmed; + const words = firstSegment.split(/\s+/).filter((w) => w.length > 1); + const skip = new Set(['dr', 'cr', 'debit', 'credit', 'upi', 'nfs', 'pos', 'atm', 'txn', 'ref']); + const meaningful = words.filter((w) => !skip.has(w.toLowerCase().replace(/[^a-z]/gi, ''))); + const take = meaningful.slice(0, 3).join(' '); + return slugifyToken(take || firstSegment); +} diff --git a/apps/money-mirror/src/lib/merchant-rollups.ts b/apps/money-mirror/src/lib/merchant-rollups.ts new file mode 100644 index 0000000..a7c8a09 --- /dev/null +++ b/apps/money-mirror/src/lib/merchant-rollups.ts @@ -0,0 +1,176 @@ +import { toNumber } from '@/lib/db'; + +type SqlClient = ReturnType; + +export const MERCHANT_ROLLUPS_MAX_LIMIT = 50; + +export type MerchantRollupParams = { + userId: string; + dateFrom: string | null; + dateTo: string | null; + statementId: string | null; + statementIds: string[] | null; + /** Minimum total debit paisa for a merchant row (inclusive). */ + minDebitPaisa: number; + limit: number; +}; + +export type MerchantRollupRow = { + merchant_key: string; + debit_paisa: number; + txn_count: number; +}; + +/** + * Top merchants by debit spend for the same scope as GET /api/transactions. + * Only debits with non-null `merchant_key` are included. + */ +export async function listMerchantRollups( + sql: SqlClient, + p: MerchantRollupParams +): Promise { + const useMany = p.statementIds && p.statementIds.length > 0; + const rows = useMany + ? ((await sql` + SELECT + t.merchant_key, + SUM(t.amount_paisa)::bigint AS debit_paisa, + COUNT(*)::bigint AS txn_count + FROM transactions t + INNER JOIN statements s ON s.id = t.statement_id AND s.user_id = t.user_id + WHERE t.user_id = ${p.userId} + AND t.type = 'debit' + AND t.merchant_key IS NOT NULL + AND (${p.dateFrom}::text IS NULL OR t.date >= ${p.dateFrom}::date) + AND (${p.dateTo}::text IS NULL OR t.date <= ${p.dateTo}::date) + AND t.statement_id = ANY(${p.statementIds}::uuid[]) + GROUP BY t.merchant_key + HAVING SUM(t.amount_paisa) >= ${p.minDebitPaisa} + ORDER BY debit_paisa DESC + LIMIT ${p.limit} + `) as { merchant_key: string; debit_paisa: bigint | string; txn_count: bigint | string }[]) + : p.statementId + ? ((await sql` + SELECT + t.merchant_key, + SUM(t.amount_paisa)::bigint AS debit_paisa, + COUNT(*)::bigint AS txn_count + FROM transactions t + INNER JOIN statements s ON s.id = t.statement_id AND s.user_id = t.user_id + WHERE t.user_id = ${p.userId} + AND t.type = 'debit' + AND t.merchant_key IS NOT NULL + AND (${p.dateFrom}::text IS NULL OR t.date >= ${p.dateFrom}::date) + AND (${p.dateTo}::text IS NULL OR t.date <= ${p.dateTo}::date) + AND t.statement_id = ${p.statementId}::uuid + GROUP BY t.merchant_key + HAVING SUM(t.amount_paisa) >= ${p.minDebitPaisa} + ORDER BY debit_paisa DESC + LIMIT ${p.limit} + `) as { merchant_key: string; debit_paisa: bigint | string; txn_count: bigint | string }[]) + : ((await sql` + SELECT + t.merchant_key, + SUM(t.amount_paisa)::bigint AS debit_paisa, + COUNT(*)::bigint AS txn_count + FROM transactions t + INNER JOIN statements s ON s.id = t.statement_id AND s.user_id = t.user_id + WHERE t.user_id = ${p.userId} + AND t.type = 'debit' + AND t.merchant_key IS NOT NULL + AND (${p.dateFrom}::text IS NULL OR t.date >= ${p.dateFrom}::date) + AND (${p.dateTo}::text IS NULL OR t.date <= ${p.dateTo}::date) + GROUP BY t.merchant_key + HAVING SUM(t.amount_paisa) >= ${p.minDebitPaisa} + ORDER BY debit_paisa DESC + LIMIT ${p.limit} + `) as { merchant_key: string; debit_paisa: bigint | string; txn_count: bigint | string }[]); + + return rows.map((r) => ({ + merchant_key: r.merchant_key, + debit_paisa: toNumber(r.debit_paisa), + txn_count: toNumber(r.txn_count), + })); +} + +/** + * Total debit paisa in scope (all debits, including rows without merchant_key). + * Used for reconciliation checks against grouped merchant sums. + */ +export async function sumScopeDebitPaisa(sql: SqlClient, p: MerchantRollupParams): Promise { + const useMany = p.statementIds && p.statementIds.length > 0; + const rows = useMany + ? ((await sql` + SELECT COALESCE(SUM(t.amount_paisa), 0)::bigint AS s + FROM transactions t + INNER JOIN statements s ON s.id = t.statement_id AND s.user_id = t.user_id + WHERE t.user_id = ${p.userId} + AND t.type = 'debit' + AND (${p.dateFrom}::text IS NULL OR t.date >= ${p.dateFrom}::date) + AND (${p.dateTo}::text IS NULL OR t.date <= ${p.dateTo}::date) + AND t.statement_id = ANY(${p.statementIds}::uuid[]) + `) as { s: bigint | string }[]) + : p.statementId + ? ((await sql` + SELECT COALESCE(SUM(t.amount_paisa), 0)::bigint AS s + FROM transactions t + INNER JOIN statements s ON s.id = t.statement_id AND s.user_id = t.user_id + WHERE t.user_id = ${p.userId} + AND t.type = 'debit' + AND (${p.dateFrom}::text IS NULL OR t.date >= ${p.dateFrom}::date) + AND (${p.dateTo}::text IS NULL OR t.date <= ${p.dateTo}::date) + AND t.statement_id = ${p.statementId}::uuid + `) as { s: bigint | string }[]) + : ((await sql` + SELECT COALESCE(SUM(t.amount_paisa), 0)::bigint AS s + FROM transactions t + INNER JOIN statements s ON s.id = t.statement_id AND s.user_id = t.user_id + WHERE t.user_id = ${p.userId} + AND t.type = 'debit' + AND (${p.dateFrom}::text IS NULL OR t.date >= ${p.dateFrom}::date) + AND (${p.dateTo}::text IS NULL OR t.date <= ${p.dateTo}::date) + `) as { s: bigint | string }[]); + const row = rows[0]; + return row ? toNumber(row.s) : 0; +} + +/** Sum of debit paisa where merchant_key is set (subset of total debits). */ +export async function sumKeyedDebitPaisa(sql: SqlClient, p: MerchantRollupParams): Promise { + const useMany = p.statementIds && p.statementIds.length > 0; + const rows = useMany + ? ((await sql` + SELECT COALESCE(SUM(t.amount_paisa), 0)::bigint AS s + FROM transactions t + INNER JOIN statements s ON s.id = t.statement_id AND s.user_id = t.user_id + WHERE t.user_id = ${p.userId} + AND t.type = 'debit' + AND t.merchant_key IS NOT NULL + AND (${p.dateFrom}::text IS NULL OR t.date >= ${p.dateFrom}::date) + AND (${p.dateTo}::text IS NULL OR t.date <= ${p.dateTo}::date) + AND t.statement_id = ANY(${p.statementIds}::uuid[]) + `) as { s: bigint | string }[]) + : p.statementId + ? ((await sql` + SELECT COALESCE(SUM(t.amount_paisa), 0)::bigint AS s + FROM transactions t + INNER JOIN statements s ON s.id = t.statement_id AND s.user_id = t.user_id + WHERE t.user_id = ${p.userId} + AND t.type = 'debit' + AND t.merchant_key IS NOT NULL + AND (${p.dateFrom}::text IS NULL OR t.date >= ${p.dateFrom}::date) + AND (${p.dateTo}::text IS NULL OR t.date <= ${p.dateTo}::date) + AND t.statement_id = ${p.statementId}::uuid + `) as { s: bigint | string }[]) + : ((await sql` + SELECT COALESCE(SUM(t.amount_paisa), 0)::bigint AS s + FROM transactions t + INNER JOIN statements s ON s.id = t.statement_id AND s.user_id = t.user_id + WHERE t.user_id = ${p.userId} + AND t.type = 'debit' + AND t.merchant_key IS NOT NULL + AND (${p.dateFrom}::text IS NULL OR t.date >= ${p.dateFrom}::date) + AND (${p.dateTo}::text IS NULL OR t.date <= ${p.dateTo}::date) + `) as { s: bigint | string }[]); + const row = rows[0]; + return row ? toNumber(row.s) : 0; +} diff --git a/apps/money-mirror/src/lib/scope.ts b/apps/money-mirror/src/lib/scope.ts new file mode 100644 index 0000000..3ef718d --- /dev/null +++ b/apps/money-mirror/src/lib/scope.ts @@ -0,0 +1,162 @@ +/** + * Unified dashboard scope: date range + optional statement inclusion (shared client/server). + */ + +const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + +export type DatePresetId = 'last_30' | 'this_month' | 'last_month' | 'custom'; + +export interface UnifiedScopeInput { + dateFrom: string; + dateTo: string; + /** `null` = all processed statements for the user */ + statementIds: string[] | null; +} + +export type DashboardScopeInput = + | { variant: 'legacy'; statementId: string | null } + | { variant: 'unified'; scope: UnifiedScopeInput }; + +export function isValidIsoDate(raw: string): boolean { + if (!/^\d{4}-\d{2}-\d{2}$/.test(raw)) { + return false; + } + const d = new Date(`${raw}T00:00:00Z`); + return !Number.isNaN(d.getTime()); +} + +export function isValidUuid(raw: string): boolean { + return UUID_RE.test(raw); +} + +export function parseStatementIdsParam( + raw: string | null +): { ok: true; value: string[] | null } | { ok: false; error: string } { + if (raw == null || raw.trim() === '') { + return { ok: true, value: null }; + } + const parts = raw + .split(',') + .map((s) => s.trim()) + .filter(Boolean); + if (parts.length === 0) { + return { ok: true, value: null }; + } + for (const id of parts) { + if (!isValidUuid(id)) { + return { ok: false, error: 'statement_ids must be comma-separated UUIDs.' }; + } + } + return { ok: true, value: parts }; +} + +export function parseDashboardScopeFromSearchParams( + sp: URLSearchParams +): DashboardScopeInput | { error: string } { + const dateFrom = sp.get('date_from'); + const dateTo = sp.get('date_to'); + + if (dateFrom || dateTo) { + if (!dateFrom || !dateTo) { + return { error: 'Both date_from and date_to are required for a date range.' }; + } + if (!isValidIsoDate(dateFrom) || !isValidIsoDate(dateTo)) { + return { error: 'date_from and date_to must be YYYY-MM-DD.' }; + } + if (dateFrom > dateTo) { + return { error: 'date_from must be on or before date_to.' }; + } + const idsParsed = parseStatementIdsParam(sp.get('statement_ids')); + if (!idsParsed.ok) { + return { error: idsParsed.error }; + } + return { + variant: 'unified', + scope: { + dateFrom, + dateTo, + statementIds: idsParsed.value, + }, + }; + } + + const statementId = sp.get('statement_id'); + if (statementId && !isValidUuid(statementId)) { + return { error: 'Invalid statement_id.' }; + } + + return { variant: 'legacy', statementId: statementId || null }; +} + +/** Build `/api/dashboard` query string from current URL params (returns `?…` or ``). */ +export function dashboardApiPathFromSearchParams(sp: URLSearchParams): string { + const parsed = parseDashboardScopeFromSearchParams(sp); + if ('error' in parsed) { + return ''; + } + if (parsed.variant === 'unified') { + return buildDashboardQueryString(parsed); + } + if (parsed.statementId) { + return `?statement_id=${encodeURIComponent(parsed.statementId)}`; + } + return ''; +} + +export function buildDashboardQueryString(input: DashboardScopeInput): string { + if (input.variant === 'legacy') { + if (!input.statementId) { + return ''; + } + const q = new URLSearchParams(); + q.set('statement_id', input.statementId); + return `?${q.toString()}`; + } + const q = new URLSearchParams(); + q.set('date_from', input.scope.dateFrom); + q.set('date_to', input.scope.dateTo); + if (input.scope.statementIds && input.scope.statementIds.length > 0) { + q.set('statement_ids', input.scope.statementIds.join(',')); + } + return `?${q.toString()}`; +} + +function pad2(n: number): string { + return n < 10 ? `0${n}` : String(n); +} + +/** UTC calendar date YYYY-MM-DD */ +export function isoDateUTC(d: Date): string { + return `${d.getUTCFullYear()}-${pad2(d.getUTCMonth() + 1)}-${pad2(d.getUTCDate())}`; +} + +export function presetToRange( + preset: DatePresetId, + now: Date = new Date() +): { dateFrom: string; dateTo: string; label: string } { + const end = isoDateUTC(now); + if (preset === 'last_30') { + const startD = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate())); + startD.setUTCDate(startD.getUTCDate() - 29); + return { dateFrom: isoDateUTC(startD), dateTo: end, label: 'last_30' }; + } + if (preset === 'this_month') { + const start = `${now.getUTCFullYear()}-${pad2(now.getUTCMonth() + 1)}-01`; + return { dateFrom: start, dateTo: end, label: 'this_month' }; + } + if (preset === 'last_month') { + const y = now.getUTCFullYear(); + const m = now.getUTCMonth(); + const firstThis = Date.UTC(y, m, 1); + const lastPrev = new Date(firstThis - 86400000); + const py = lastPrev.getUTCFullYear(); + const pm = lastPrev.getUTCMonth(); + const lastDay = new Date(Date.UTC(py, pm + 1, 0)).getUTCDate(); + return { + dateFrom: `${py}-${pad2(pm + 1)}-01`, + dateTo: `${py}-${pad2(pm + 1)}-${pad2(lastDay)}`, + label: 'last_month', + }; + } + return { dateFrom: end, dateTo: end, label: 'custom' }; +} diff --git a/apps/money-mirror/src/lib/transactions-list.ts b/apps/money-mirror/src/lib/transactions-list.ts new file mode 100644 index 0000000..674fffa --- /dev/null +++ b/apps/money-mirror/src/lib/transactions-list.ts @@ -0,0 +1,171 @@ +type SqlClient = ReturnType; + +export const TRANSACTIONS_MAX_LIMIT = 100; + +export type TransactionRow = { + id: string; + statement_id: string; + date: string; + description: string; + amount_paisa: number; + type: 'debit' | 'credit'; + category: string; + is_recurring: boolean; + merchant_key: string | null; + statement_nickname: string | null; + statement_institution_name: string; +}; + +export interface ListTransactionsParams { + userId: string; + dateFrom: string | null; + dateTo: string | null; + statementId: string | null; + /** When set and non-empty, restricts to these statements (takes precedence over `statementId`). */ + statementIds: string[] | null; + category: string | null; + type: 'debit' | 'credit' | null; + search: string | null; + merchantKey: string | null; + limit: number; + offset: number; +} + +export async function countTransactions( + sql: SqlClient, + p: ListTransactionsParams +): Promise { + const useMany = p.statementIds && p.statementIds.length > 0; + const rows = useMany + ? await sql` + SELECT COUNT(*)::bigint AS c + FROM transactions t + INNER JOIN statements s ON s.id = t.statement_id AND s.user_id = t.user_id + WHERE t.user_id = ${p.userId} + AND (${p.dateFrom}::text IS NULL OR t.date >= ${p.dateFrom}::date) + AND (${p.dateTo}::text IS NULL OR t.date <= ${p.dateTo}::date) + AND t.statement_id = ANY(${p.statementIds}::uuid[]) + AND (${p.category}::text IS NULL OR t.category = ${p.category}) + AND (${p.type}::text IS NULL OR t.type = ${p.type}) + AND (${p.search}::text IS NULL OR POSITION(LOWER(${p.search}) IN LOWER(t.description)) > 0) + AND (${p.merchantKey}::text IS NULL OR t.merchant_key = ${p.merchantKey}) + ` + : p.statementId + ? await sql` + SELECT COUNT(*)::bigint AS c + FROM transactions t + INNER JOIN statements s ON s.id = t.statement_id AND s.user_id = t.user_id + WHERE t.user_id = ${p.userId} + AND (${p.dateFrom}::text IS NULL OR t.date >= ${p.dateFrom}::date) + AND (${p.dateTo}::text IS NULL OR t.date <= ${p.dateTo}::date) + AND t.statement_id = ${p.statementId}::uuid + AND (${p.category}::text IS NULL OR t.category = ${p.category}) + AND (${p.type}::text IS NULL OR t.type = ${p.type}) + AND (${p.search}::text IS NULL OR POSITION(LOWER(${p.search}) IN LOWER(t.description)) > 0) + AND (${p.merchantKey}::text IS NULL OR t.merchant_key = ${p.merchantKey}) + ` + : await sql` + SELECT COUNT(*)::bigint AS c + FROM transactions t + INNER JOIN statements s ON s.id = t.statement_id AND s.user_id = t.user_id + WHERE t.user_id = ${p.userId} + AND (${p.dateFrom}::text IS NULL OR t.date >= ${p.dateFrom}::date) + AND (${p.dateTo}::text IS NULL OR t.date <= ${p.dateTo}::date) + AND (${p.category}::text IS NULL OR t.category = ${p.category}) + AND (${p.type}::text IS NULL OR t.type = ${p.type}) + AND (${p.search}::text IS NULL OR POSITION(LOWER(${p.search}) IN LOWER(t.description)) > 0) + AND (${p.merchantKey}::text IS NULL OR t.merchant_key = ${p.merchantKey}) + `; + const row = (rows as { c: bigint | string }[])[0]; + return Number(row.c); +} + +export async function listTransactions( + sql: SqlClient, + p: ListTransactionsParams +): Promise { + const useMany = p.statementIds && p.statementIds.length > 0; + const rows = useMany + ? ((await sql` + SELECT + t.id, + t.statement_id, + t.date::text AS date, + t.description, + t.amount_paisa, + t.type, + t.category, + t.is_recurring, + t.merchant_key, + s.nickname AS statement_nickname, + s.institution_name AS statement_institution_name + FROM transactions t + INNER JOIN statements s ON s.id = t.statement_id AND s.user_id = t.user_id + WHERE t.user_id = ${p.userId} + AND (${p.dateFrom}::text IS NULL OR t.date >= ${p.dateFrom}::date) + AND (${p.dateTo}::text IS NULL OR t.date <= ${p.dateTo}::date) + AND t.statement_id = ANY(${p.statementIds}::uuid[]) + AND (${p.category}::text IS NULL OR t.category = ${p.category}) + AND (${p.type}::text IS NULL OR t.type = ${p.type}) + AND (${p.search}::text IS NULL OR POSITION(LOWER(${p.search}) IN LOWER(t.description)) > 0) + AND (${p.merchantKey}::text IS NULL OR t.merchant_key = ${p.merchantKey}) + ORDER BY t.date DESC, t.id DESC + LIMIT ${p.limit} + OFFSET ${p.offset} + `) as unknown as TransactionRow[]) + : p.statementId + ? ((await sql` + SELECT + t.id, + t.statement_id, + t.date::text AS date, + t.description, + t.amount_paisa, + t.type, + t.category, + t.is_recurring, + t.merchant_key, + s.nickname AS statement_nickname, + s.institution_name AS statement_institution_name + FROM transactions t + INNER JOIN statements s ON s.id = t.statement_id AND s.user_id = t.user_id + WHERE t.user_id = ${p.userId} + AND (${p.dateFrom}::text IS NULL OR t.date >= ${p.dateFrom}::date) + AND (${p.dateTo}::text IS NULL OR t.date <= ${p.dateTo}::date) + AND t.statement_id = ${p.statementId}::uuid + AND (${p.category}::text IS NULL OR t.category = ${p.category}) + AND (${p.type}::text IS NULL OR t.type = ${p.type}) + AND (${p.search}::text IS NULL OR POSITION(LOWER(${p.search}) IN LOWER(t.description)) > 0) + AND (${p.merchantKey}::text IS NULL OR t.merchant_key = ${p.merchantKey}) + ORDER BY t.date DESC, t.id DESC + LIMIT ${p.limit} + OFFSET ${p.offset} + `) as unknown as TransactionRow[]) + : ((await sql` + SELECT + t.id, + t.statement_id, + t.date::text AS date, + t.description, + t.amount_paisa, + t.type, + t.category, + t.is_recurring, + t.merchant_key, + s.nickname AS statement_nickname, + s.institution_name AS statement_institution_name + FROM transactions t + INNER JOIN statements s ON s.id = t.statement_id AND s.user_id = t.user_id + WHERE t.user_id = ${p.userId} + AND (${p.dateFrom}::text IS NULL OR t.date >= ${p.dateFrom}::date) + AND (${p.dateTo}::text IS NULL OR t.date <= ${p.dateTo}::date) + AND (${p.category}::text IS NULL OR t.category = ${p.category}) + AND (${p.type}::text IS NULL OR t.type = ${p.type}) + AND (${p.search}::text IS NULL OR POSITION(LOWER(${p.search}) IN LOWER(t.description)) > 0) + AND (${p.merchantKey}::text IS NULL OR t.merchant_key = ${p.merchantKey}) + ORDER BY t.date DESC, t.id DESC + LIMIT ${p.limit} + OFFSET ${p.offset} + `) as unknown as TransactionRow[]); + return rows; +} diff --git a/commands/create-issue.md b/commands/create-issue.md index 7b513dd..5190a78 100644 --- a/commands/create-issue.md +++ b/commands/create-issue.md @@ -56,6 +56,14 @@ Questions to ask when needed: Keep questions brief — one message, max 3 questions, no back-and-forth. +**Issue Type (always required):** Infer from the raw idea if obvious; otherwise include as one of the clarifying questions. + +- **Feature** — new capability that didn't exist before +- **Enhancement** — improvement to an existing capability +- **Bug Fix** — something broken that needs fixing + +Store the result as `issue_type` in the issue file header. + --- ## 1 Problem Statement @@ -115,6 +123,7 @@ Return the result in this structure. --- Issue Title +Type: Feature | Enhancement | Bug Fix Problem (Current State → Desired Outcome) @@ -134,6 +143,22 @@ Risks / Open Questions (omit if none) After writing `experiments/ideas/issue-.md` and updating `project-state.md`: +## Auto-Write CHANGELOG Entry + +Append to the top of `CHANGELOG.md` immediately after writing the issue file: + +``` +## YYYY-MM-DD — Issue Created: issue-NNN +- **Type**: Feature | Enhancement | Bug Fix +- **Title**: +- **App**: +- **Status**: Discovery +``` + +Use today's date. Do not modify any other CHANGELOG content. + +--- + ## Auto-Bind Linear Immediately run `/linear-bind` for the new issue. diff --git a/commands/deploy-check.md b/commands/deploy-check.md index 3c35571..e52978f 100644 --- a/commands/deploy-check.md +++ b/commands/deploy-check.md @@ -222,6 +222,10 @@ Check: If Sentry is not configured, add it as a deployment blocker. Post-deploy debugging without error tracking is blind. +**PM-documented exceptions**: Before failing the gate on empty or missing Sentry-related variables (`NEXT_PUBLIC_SENTRY_DSN`, `SENTRY_ORG`, `SENTRY_PROJECT`, or similar), read `project-state.md` **Decisions Log** and this cycle’s deploy-check result file (e.g. `experiments/results/deploy-check-NNN.md`). If either records an explicit PM exception that lists those keys as optional for this app or cycle, **do not block** on those keys being unset. The exception must name the variable(s) exempted; vague waivers are not acceptable. + +# Added: 2026-04-05 — MoneyMirror Phase 3 (issue-010) + **Minimum setup**: ```bash diff --git a/commands/linear-bind.md b/commands/linear-bind.md index b603a88..ac3adf3 100644 --- a/commands/linear-bind.md +++ b/commands/linear-bind.md @@ -92,14 +92,23 @@ Rules: - resolve the team by explicit argument, or default to the sole team if exactly one exists - ensure the parent label group `AI Product OS` exists - ensure child labels exist: `Discovery`, `Planning`, `Execution`, `Review`, `Blocked`, `Release Ready`, `Completed` +- ensure issue type labels exist at the team level: `Feature`, `Enhancement`, `Bug` - ensure one Linear project exists for the repo issue - ensure one root Linear issue exists under that project +Issue type label mapping (read `issue_type` from the issue file header): + +| `issue_type` in issue file | Linear label to apply | +| -------------------------- | --------------------- | +| `Feature` | `Feature` | +| `Enhancement` | `Enhancement` | +| `Bug Fix` | `Bug` | + Default object shape: - project title: `issue-` - root issue title: `issue-` -- root issue labels: `Feature` and `AI Product OS/Discovery` +- root issue labels: `` and `AI Product OS/Discovery` - root issue status: `Backlog` ## Step 3 — Persist Binding Metadata @@ -123,7 +132,8 @@ Also write `experiments/linear-sync/issue-.json` with: - team id and name - project id, name, and URL - root issue id, identifier, and title -- label ids +- label ids (stage labels + `feature`, `enhancement`, `bug` type labels) +- `issue_type` field matching the value from the issue file (`Feature`, `Enhancement`, or `Bug Fix`) - empty `documents` object - empty `tasks` object - last sync mode `bind` diff --git a/experiments/exploration/exploration-010.md b/experiments/exploration/exploration-010.md new file mode 100644 index 0000000..3f89a1f --- /dev/null +++ b/experiments/exploration/exploration-010.md @@ -0,0 +1,116 @@ +# Exploration — Issue 010: MoneyMirror Phase 3 + +**Date:** 2026-04-05 +**Agent:** Research Agent +**Stage:** explore +**Issue:** [issue-010.md](../ideas/issue-010.md) — Phase 3 umbrella (unified dashboard, transaction-native insights, expert AI coaching) + +--- + +## Problem Analysis + +**Assumption (to validate in product):** Users who upload **multiple** bank and/or credit card statements over time need a **single mental model** for money: global date range, explicit **source scope**, and **line-level evidence** before category rollups and coaching feel trustworthy. + +**Verified fact (from issue-009 exploration + shipped Phases 1–2):** The core MoneyMirror wedge — perception gap, India-specific parsing, consequence-first tone — is already validated as a **hair-on-fire** problem for the ICP. Phase 3 does not re-litigate that; it addresses **credibility at scale** when data volume and heterogeneity increase. + +**Current-state pain (product, not market):** + +| Symptom | Why it hurts | +| ------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | +| Single-statement / month-picker bias | Users with 2+ uploads cannot reason “across everything I’ve given you.” | +| Bucket-only insights (“Wants is high”) | Feels like a generic budgeting app; no **merchant proof** (e.g. Zomato ₹X / N txns). | +| Generative coaching without **server facts JSON** | Model-invented numbers destroy trust; regulatory exposure if copy reads like personalized investment advice. | +| Split mental models for bank vs card | Multi-account Gen Z reality is **one wallet**; UI must expose a clear **scope model** and how **perceived vs actual** behaves under rollups. | + +**Conclusion:** The problem is **real for retained / multi-upload users** and for **north-star behaviors** (second upload, return sessions) that depend on “this matches my PDF / my life.” + +--- + +## Market Scan + +Phase 3 is primarily **depth vs Indian fintech incumbents**, not a new category entry. Findings align with [exploration-009.md](exploration-009.md): + +| Layer | Competitors / alternatives | Strength | Gap relevant to Phase 3 | +| ----------- | ---------------------------- | --------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | +| Aggregation | Jupiter / Fi, INDMoney, CRED | Scale, brand, rewards | **Coach-first + user-owned PDF truth** still rare; none optimize for **txn-level explainability** + **behavioral coaching** with India-specific tone. | +| Manual | Excel, Notion, screenshots | Full control | No parsing, no nudges, high friction. | +| AI chat | ChatGPT, etc. | Fluency | No durable ledger; **hallucination risk** on amounts — exactly what **facts-grounded** coaching avoids. | + +**Unserved gap (Phase 3 angle):** A **transaction-native** surface with **merchant rollups** and **deterministic numbers in AI** — i.e. “show your work” finance coaching for India, mobile-first. + +--- + +## User Pain Level + +**Classification: Moderate → Critical (segment-dependent).** + +| Segment | Level | Reasoning | +| ---------------------------------- | ------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------- | +| First-time / single-statement user | Moderate | Phase 2 may be “enough” until they upload again or add a card. | +| Multi-statement / bank + card user | **Critical** for _trust_ | Mismatch between app and intuition (“why doesn’t this match?”) becomes **support-grade**; without txn truth, **North Star** (repeat upload) stalls. | +| PM / owner (delivery) | Moderate | Scope overlap (VIJ-25 vs Phase 3) creates **process pain**, not user pain — solvable by merge/supersede (already logged in project state). | + +**Net:** For the **Phase 3 ICP** (multiple uploads, multi-source), pain intensity is **high** — not because budgeting is novel, but because **trust collapses** without evidence and scope clarity. + +--- + +## Opportunity Assessment + +| Dimension | Assessment | +| ------------------------ | ----------------------------------------------------------------------------------------------------- | +| **Value** | High — unlocks differentiated “guide” positioning vs bucket dashboards; reduces generic-coach feel. | +| **Market size** | Same TAM as issue-009; Phase 3 increases **retention and depth** within the wedge, not TAM expansion. | +| **Willingness to adopt** | High **if** performance stays snappy and copy stays **educational, not advice** (see risks). | +| **Distribution** | No new channel; benefits **existing** users and referrals who hit multi-statement reality. | + +**Hypothesis (from issue file, refined):** + +> If we ship **transaction surface + unified scope + merchant rollups + facts-grounded AI** for users with **multiple statements**, they will **trust** category and coaching views more, return to **upload a second statement** sooner, and reduce **“doesn’t match my PDF”** confusion because the UI always points to **line-level evidence**. + +--- + +## Proposed MVP Experiment + +**Smallest slice that validates the riskiest assumptions:** **T1 — Transaction surface + API (P0 foundation)**. + +| Element | Definition | +| -------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Core** | Server-backed **transactions list** (pagination, filters aligned with future scope), **one** “proof” path from insight → txn rows (even if merchant rollup is thin in v1). | +| **Intentionally excluded** | Full **T5–T6** (IA/growth, compare months) per PM plan; **defer** heavy **Gemini merchant normalization** until T3 patterns are clear — start with **heuristics + deterministic rollups** where possible. | +| **Learning goals** | (1) Parser **variance** exposure across issuers in the wild; (2) **query/latency** envelope for cross-statement reads; (3) whether users **engage** with txn truth before caring about richer AI. | + +**Success signals (exploration-level, not final metrics):** + +- Technical: p95 API latency acceptable on realistic account sizes; no unbounded scans. +- Product: qualitative — users can answer “where did this number come from?” via txn drill-down. + +--- + +## Risks + +| Risk | Type | Mitigation (directional) | +| ---------------------------------------------------- | ---------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **PDF / parser variance** | Technical | Issuer-specific tests; graceful degradation; never claim precision you don’t have in UI copy. | +| **Merchant normalization** (Zomato vs ZOMATO vs UPI) | Technical / cost | v1 heuristics + dedupe rules; optional Gemini **post-pass** only where ROI clear; structured outputs; cost/latency budget in plan. | +| **Regulatory / product safety** | Market / legal | Extend [`docs/COACHING-TONE.md`](../../apps/money-mirror/docs/COACHING-TONE.md) with **expert examples**; **educational, not advice**; facts JSON **only** as numeric source for generative layer. | +| **Performance** at multi-statement scale | Technical | Indexed queries, pagination, `.limit()` on lists; avoid N+1; align with architecture-guide **DB-first** and serverless limits. | +| **Perceived vs actual under global rollup** | Product | **Explicit PRD decision** before UI lock: blended vs per-account — wrong default erodes the Mirror brand. | +| **Scope duplication (VIJ-25)** | Process | **Superseded** by VIJ-37 / T1–T4 mapping (per project-state); enforce single backlog. | + +--- + +## Final Recommendation + +**Build.** + +Phase 3 is the **natural depth layer** for an already shipped product with a validated ICP. It directly attacks **trust** and **repeat engagement** — prerequisites for the North Star proxy (second-month upload) at scale. Risks are **known and plannable**; none are “kill the initiative” without mitigation paths. + +**Explore further** only as **spikes inside /create-plan**, not as a delay: merchant normalization strategy, rollup semantics for perceived spend, and performance budgets should be **decision records** in `plan-010.md`, not open-ended research. + +**Discard** — not applicable; the umbrella is coherent and aligned with repo + Linear (VIJ-37 / VIJ-38–VIJ-41). + +--- + +## Next step + +Run **`/create-plan`** to produce `experiments/plans/plan-010.md` with PRD, UX, architecture, schema, and **acceptance criteria per T1–T6** (with T5–T6 explicitly deferred/backlogged as in issue-010). diff --git a/experiments/ideas/issue-010.md b/experiments/ideas/issue-010.md new file mode 100644 index 0000000..355ea3d --- /dev/null +++ b/experiments/ideas/issue-010.md @@ -0,0 +1,77 @@ +--- +issue_type: Enhancement +linear_workflow: Option A — parent issue + child issues T1–T4 (P0/P1); T5–T6 deferred to backlog until T3 validated +source_plan: .cursor/plans/moneymirror_pm_ux_plan_08398100.plan.md (MoneyMirror PM UX Plan) +app: apps/money-mirror +--- + +# Issue Title + +**MoneyMirror Phase 3 — Unified multi-source dashboard, transaction-native insights, and expert AI coaching** + +**Type:** Enhancement + +--- + +## Problem (Current State → Desired Outcome) + +**Current state:** MoneyMirror rehydrates dashboard data around a **single-statement** mental model; filters skew toward month/statement pickers rather than a **global date range across all uploads**. There is **no transaction-level UI** as ground truth. Insights skew **bucket-level** (“Wants is high”) rather than **merchant-specific** (e.g. Zomato ₹X over N transactions). **Generative coaching** is not yet anchored on server-computed **facts JSON** with strict schema + tone guardrails. Multi-account / bank + credit card uploads are not yet unified in one **scope model** with clear **perceived vs actual** behaviour under rollups. + +**Desired outcome:** One **unified dashboard** for bank + credit card statements over time, with **date + source scope**, a **transactions API + UI** as source of truth, **merchant-level rollups** with **Overview↔Insights** coupling and deep links, and an **AI track** where rules + optional Gemini narrative only reference **Layer A facts** (no hallucinated amounts). **P0–P2** phasing: **T1→T4** first; **T5–T6** after validation (per PM plan). + +--- + +## User + +**Primary:** Gen Z Indians (₹20K–₹80K/month) using MoneyMirror on mobile, uploading **multiple** bank and/or card statements over time. + +**Secondary:** PM/IC owner (you) tracking delivery via **Linear parent + T1–T4 children** without duplicate scope vs **VIJ-25** (Sprint 4 backlog). + +--- + +## Why it Matters + +Trust requires **transaction truth** before insights and AI can feel credible. Without **unified scope**, users cannot reason across accounts; without **merchant evidence**, coaching stays generic and **North Star** nudges (e.g. second upload) lack a concrete hook. **Expert-style** coaching needs **deterministic numbers** from the server, not free-form model invention. + +--- + +## Opportunity + +Delivering Phase 3 positions MoneyMirror as **multi-statement-native**, **filter-native**, and **evidence-backed** — closer to a credible “finance guide” than a single-PDF demo. **Linear** stays PM-facing: **one umbrella issue** plus **T1–T4** children you pull **one at a time**, aligned with `/explore` → `/create-plan` → `/execute-plan` when you start implementation (**no code in this `/create-issue` run**). + +--- + +## Hypothesis + +If we ship **transaction surface + unified scope + merchant rollups + facts-grounded AI coaching** for MoneyMirror users with **multiple statements**, users will **trust** category and coaching views more, return to **upload a second statement** sooner, and **reduce support-like confusion** (“why doesn’t this match my PDF?”) because the UI always points to **line-level evidence**. + +--- + +## Risks / Open Questions (for `/explore` and `/create-plan`) + +- **PDF / parser variance** across issuers; **merchant normalization** v1 (heuristic vs Gemini post-pass — cost/latency). +- **Regulatory / product safety:** generative copy must stay **educational, not advice**; [`docs/COACHING-TONE.md`](../../apps/money-mirror/docs/COACHING-TONE.md) must extend with **expert examples** as generative surface grows. +- **Performance:** cross-statement aggregation and pagination; rate limits if queries move beyond in-memory assumptions. +- **Perceived vs actual** under **all-sources** rollup — **explicit PRD decision** (single blended number vs per-account) before UI lock-in. +- **VIJ-25 overlap:** F3, G2–G3, H3 — **merge or supersede** into Phase 3 children to avoid duplicate tickets (stakeholder checklist in source plan). + +--- + +## Task map (repo contract — T1–T6) + +| ID | Priority | Theme | +| ------ | -------- | ------------------------------------------------------------------------------ | +| **T1** | P0 | Transaction surface + API (foundation) | +| **T2** | P1 | Unified scope model — single dashboard, multi-source | +| **T3** | P1 | Merchant rollups + Overview↔Insights coupling | +| **T4** | P1 | AI / coaching upgrade — expert guide (facts + structured Gemini) | +| **T5** | P2 | IA + growth (URL-backed tabs, desktop share) — **defer** until T1–T3 validated | +| **T6** | P2 | Compare months + hygiene — **defer** | + +Detailed acceptance themes live in the source plan; formal AC belongs in **`/create-plan`** output (`experiments/plans/plan-010.md`). + +--- + +## Next step + +**`/explore` done** — see `experiments/exploration/exploration-010.md` (**Build**). Run **`/create-plan`** for PRD + architecture + **AC per T1–T6**. **No `/execute-plan`** until you explicitly start implementation. diff --git a/experiments/linear-sync/issue-009.json b/experiments/linear-sync/issue-009.json index 837cc9d..18c6ab5 100644 --- a/experiments/linear-sync/issue-009.json +++ b/experiments/linear-sync/issue-009.json @@ -23,7 +23,7 @@ "tasks": { "phase_2_shipped": "VIJ-23", "neon_alter_statement_labels": "VIJ-24", - "sprint_4_backlog": "VIJ-25", + "sprint_4_backlog": "VIJ-25 — superseded 2026-04-05: Duplicate of VIJ-37 (Phase 3); scope merged per VIJ-25 description", "sprint_1": "VIJ-26", "sprint_2": "VIJ-27", "sprint_3": "VIJ-28", diff --git a/experiments/linear-sync/issue-010.json b/experiments/linear-sync/issue-010.json new file mode 100644 index 0000000..b42d070 --- /dev/null +++ b/experiments/linear-sync/issue-010.json @@ -0,0 +1,62 @@ +{ + "issue_number": "010", + "issue_title": "MoneyMirror Phase 3 — Unified multi-source dashboard, transaction-native insights, and expert AI coaching", + "issue_type": "Enhancement", + "team_id": "70aea0d1-a706-481f-a0b7-3e636709ba77", + "team_name": "Vijaypmworkspace", + "project_id": "418bfd75-bf67-47e1-8cb3-dd48c6bd9cb3", + "project_name": "issue-010 — MoneyMirror Phase 3 — Unified dashboard, txns, expert AI", + "project_url": "https://linear.app/vijaypmworkspace/project/issue-010-moneymirror-phase-3-unified-dashboard-txns-expert-ai-ca986df457a4", + "root_issue_id": "VIJ-37", + "root_issue_identifier": "VIJ-37", + "root_issue_url": "https://linear.app/vijaypmworkspace/issue/VIJ-37/issue-010-moneymirror-phase-3-unified-multi-source-dashboard", + "labels": { + "discovery": "b05592c1-47a6-4fdd-9a08-7a6287d4f1f8", + "improvement": "24fb89fb-dd24-4e23-9741-bf5def3dce29" + }, + "tasks": { + "T1_transactions_api": "VIJ-38", + "T2_unified_scope": "VIJ-39", + "T3_merchant_insights": "VIJ-40", + "T4_ai_coaching": "VIJ-41" + }, + "related_issues": { + "prior_root_issue_009": "VIJ-11", + "sprint_4_backlog_superseded": "VIJ-25 — marked Duplicate of VIJ-37 2026-04-05; F3/G2–G3/H3 mapped to VIJ-38–VIJ-41 in VIJ-25 description" + }, + "documents": { + "plan_snapshot": { + "id": "380c47b4-6d39-4e98-becc-d7c355669b45", + "title": "issue-010 Plan Snapshot", + "url": "https://linear.app/vijaypmworkspace/document/issue-010-plan-snapshot-bea55d2ac741" + }, + "closeout_snapshot": { + "id": "d3452711-a5d7-45c9-8dcc-d0dff03424e3", + "title": "issue-010 Closeout Snapshot", + "url": "https://linear.app/vijaypmworkspace/document/issue-010-closeout-snapshot-5ef07c9db6ee" + } + }, + "last_sync_mode": "close", + "last_sync_timestamp": "2026-04-05T15:40:12Z", + "pipeline_status": "learning_complete", + "linear_status": "Done", + "project_status": "Completed", + "last_status_comment_id": "03ba364f-8bb8-4c82-99b5-b162a8ed0d3b", + "closeout_comment_id": "8d7639f5-820f-4ddc-8f8c-7acc4b93fa2b", + "review_artifacts": { + "codex": "experiments/results/review-010.md", + "peer_review": "experiments/results/peer-review-010.md" + }, + "qa_artifacts": { + "codex": "experiments/results/qa-test-010.md" + }, + "metric_plan_artifacts": { + "codex": "experiments/results/metric-plan-010.md" + }, + "manifest_task_issue_map": { + "phase-t1": "VIJ-38", + "phase-t2": "VIJ-39", + "phase-t3": "VIJ-40", + "phase-t4": "VIJ-41" + } +} diff --git a/experiments/plans/manifest-010.json b/experiments/plans/manifest-010.json new file mode 100644 index 0000000..18f0642 --- /dev/null +++ b/experiments/plans/manifest-010.json @@ -0,0 +1,216 @@ +{ + "issue": "issue-010", + "project": "money-mirror", + "linear": { + "parent": "VIJ-37", + "children": { + "T1": "VIJ-38", + "T2": "VIJ-39", + "T3": "VIJ-40", + "T4": "VIJ-41" + } + }, + "phases": [ + { + "id": "phase-t1", + "name": "T1 — Transaction surface + API (P0)", + "parallel": false, + "depends_on": [], + "linear_issue": "VIJ-38", + "tasks": [ + { + "id": "T1.1", + "name": "Schema: transactions.merchant_key + indexes", + "agent": "backend-engineer", + "files_to_create": [], + "files_to_modify": ["apps/money-mirror/schema.sql", "apps/money-mirror/README.md"], + "verification": "grep -n merchant_key apps/money-mirror/schema.sql && cd apps/money-mirror && npm run build", + "test_file": null + }, + { + "id": "T1.2", + "name": "Library: normalizeMerchantKey + unit tests", + "agent": "backend-engineer", + "files_to_create": ["apps/money-mirror/src/lib/merchant-normalize.ts"], + "files_to_modify": [], + "verification": "cd apps/money-mirror && npm test -- --testPathPattern=merchant-normalize", + "test_file": "apps/money-mirror/src/lib/__tests__/merchant-normalize.test.ts" + }, + { + "id": "T1.3", + "name": "Persist merchant_key on insert + backfill path", + "agent": "backend-engineer", + "files_to_create": [], + "files_to_modify": [ + "apps/money-mirror/src/app/api/statement/parse/persist-statement.ts" + ], + "verification": "cd apps/money-mirror && npm test && npx tsc --noEmit", + "test_file": null + }, + { + "id": "T1.4", + "name": "GET /api/transactions — auth, pagination, filters", + "agent": "backend-engineer", + "files_to_create": ["apps/money-mirror/src/app/api/transactions/route.ts"], + "files_to_modify": [], + "verification": "cd apps/money-mirror && npm test", + "test_file": "apps/money-mirror/src/app/api/transactions/__tests__/route.test.ts" + }, + { + "id": "T1.5", + "name": "Dashboard: Transactions UI + shell integration", + "agent": "frontend-engineer", + "files_to_create": [], + "files_to_modify": [ + "apps/money-mirror/src/app/dashboard", + "apps/money-mirror/src/components" + ], + "verification": "cd apps/money-mirror && npm run build", + "test_file": null + } + ] + }, + { + "id": "phase-t2", + "name": "T2 — Unified scope model", + "parallel": false, + "depends_on": ["phase-t1"], + "linear_issue": "VIJ-39", + "tasks": [ + { + "id": "T2.1", + "name": "Shared scope type + validation (client/server)", + "agent": "frontend-engineer", + "files_to_create": ["apps/money-mirror/src/lib/scope.ts"], + "files_to_modify": [], + "verification": "cd apps/money-mirror && npx tsc --noEmit", + "test_file": null + }, + { + "id": "T2.2", + "name": "Extend dashboard fetch for date range + statement_ids", + "agent": "backend-engineer", + "files_to_modify": [ + "apps/money-mirror/src/lib/dashboard.ts", + "apps/money-mirror/src/app/api/dashboard/route.ts" + ], + "files_to_create": [], + "verification": "cd apps/money-mirror && npm test", + "test_file": null + }, + { + "id": "T2.3", + "name": "ScopeBar UI + Overview alignment + PostHog scope_changed", + "agent": "frontend-engineer", + "files_to_modify": ["apps/money-mirror/src/components"], + "files_to_create": [], + "verification": "cd apps/money-mirror && npm run build", + "test_file": null + } + ] + }, + { + "id": "phase-t3", + "name": "T3 — Merchant rollups + Overview↔Insights coupling", + "parallel": false, + "depends_on": ["phase-t2"], + "linear_issue": "VIJ-40", + "tasks": [ + { + "id": "T3.1", + "name": "API: merchant rollups for scope (GROUP BY merchant_key)", + "agent": "backend-engineer", + "files_to_create": ["apps/money-mirror/src/app/api/insights/merchants/route.ts"], + "files_to_modify": [], + "verification": "cd apps/money-mirror && npm test", + "test_file": null + }, + { + "id": "T3.2", + "name": "Insights UI: MerchantRollupCard + deep link to transactions", + "agent": "frontend-engineer", + "files_to_modify": ["apps/money-mirror/src/components"], + "files_to_create": [], + "verification": "cd apps/money-mirror && npm run build", + "test_file": null + }, + { + "id": "T3.3", + "name": "Reconciliation smoke: Overview totals vs transaction sums", + "agent": "qa-agent", + "files_to_modify": [], + "files_to_create": [], + "verification": "cd apps/money-mirror && npm test", + "test_file": null + } + ] + }, + { + "id": "phase-t4", + "name": "T4 — Facts-grounded AI coaching", + "parallel": false, + "depends_on": ["phase-t3"], + "linear_issue": "VIJ-41", + "tasks": [ + { + "id": "T4.1", + "name": "Define Layer A facts Zod schema + builder from DB", + "agent": "backend-engineer", + "files_to_create": ["apps/money-mirror/src/lib/coaching-facts.ts"], + "files_to_modify": [], + "verification": "cd apps/money-mirror && npm test", + "test_file": null + }, + { + "id": "T4.2", + "name": "Gemini narrative step: structured output cites fact ids only", + "agent": "backend-engineer", + "files_to_modify": ["apps/money-mirror/src/lib/advisory-engine.ts", "apps/money-mirror/src/app/api/dashboard/advisories/route.ts"], + "files_to_create": [], + "verification": "cd apps/money-mirror && npm run build", + "test_file": null + }, + { + "id": "T4.3", + "name": "Update docs/COACHING-TONE.md + UI FactsDrawer", + "agent": "frontend-engineer", + "files_to_modify": ["apps/money-mirror/docs/COACHING-TONE.md", "apps/money-mirror/src/components"], + "files_to_create": [], + "verification": "cd apps/money-mirror && npm run build", + "test_file": null + } + ] + } + ], + "env_vars": [ + "DATABASE_URL", + "GEMINI_API_KEY", + "POSTHOG_KEY", + "POSTHOG_HOST", + "RESEND_API_KEY", + "CRON_SECRET", + "NEXT_PUBLIC_APP_URL" + ], + "schema_tables": ["profiles", "statements", "transactions", "advisory_feed"], + "posthog_events": [ + "transactions_view_opened", + "transactions_filter_applied", + "merchant_rollup_clicked", + "coaching_narrative_completed", + "coaching_narrative_timeout", + "coaching_narrative_failed", + "coaching_facts_expanded", + "scope_changed", + "onboarding_completed", + "statement_parse_started", + "statement_parse_success", + "statement_parse_failed", + "statement_parse_timeout", + "statement_parse_rate_limited", + "weekly_recap_triggered", + "weekly_recap_completed", + "weekly_recap_email_sent", + "weekly_recap_email_failed" + ], + "deferred": ["T5", "T6"] +} diff --git a/experiments/plans/plan-010.md b/experiments/plans/plan-010.md new file mode 100644 index 0000000..bd685f0 --- /dev/null +++ b/experiments/plans/plan-010.md @@ -0,0 +1,274 @@ +# Plan 010 — MoneyMirror Phase 3: Unified dashboard, transaction truth, merchant insights, facts-grounded AI + +**Issue:** 010 +**Project:** MoneyMirror (`apps/money-mirror`) +**Linear:** Parent **VIJ-37**; children **VIJ-38** (T1) → **VIJ-41** (T4). **VIJ-25** superseded → duplicate of VIJ-37. +**Stage:** plan +**Status:** approved for execution (start **VIJ-38** / T1) +**Date:** 2026-04-05 + +--- + +## 1. Plan Summary + +Phase 3 makes MoneyMirror **multi-statement-native**: a **single scope model** (date range + sources), **transaction-level truth** in the UI, **merchant-level rollups** that Overview and Insights agree on, and **expert-style coaching** where generative copy is **strictly grounded** in server-computed **Layer A facts** (no invented amounts). **T5–T6** (URL-backed tabs, compare months) stay **deferred** until T1–T3 validate. + +**Canonical stack (unchanged):** Next.js 16 App Router, Neon Auth (email OTP), Neon Postgres, Gemini 2.5 Flash (parse + structured outputs where used), Resend, PostHog (`POSTHOG_KEY` / `POSTHOG_HOST` server-side), Sentry. Ownership is **server-enforced in route handlers** (not Supabase RLS). + +--- + +## 2. Product specification (Product Agent) + +### Product goal + +Increase **trust and repeat engagement** for users with **multiple** bank/card uploads by making every headline number **traceable** to **line items** and **deterministic server math**, so category and coaching views feel credible and the **North Star proxy** (second-month / repeat statement upload) can compound. + +### Target user + +- **Primary:** Gen Z Indians (₹20K–₹80K/month) on mobile, using **multiple** statements over time (bank + credit card). +- **Secondary:** PM/owner — delivery via Linear **T1–T4** without duplicate scope vs archived Sprint 4 backlog. + +### User journey (Phase 3) + +1. **Land on dashboard** with a clear **scope bar**: date range + **which accounts/statements** are included (not only a single statement picker). +2. **See Overview** numbers that match the same scope as **Transactions** and **Insights** (no hidden filter mismatch). +3. **Open Transactions** — paginated list, filters, search — as **ground truth** for “where did this ₹ come from?” +4. **Open Insights** — merchant/category rollups with **deep links** back to filtered transaction rows. +5. **Read coaching** — copy may be fluent, but **amounts and counts** come only from **Layer A facts JSON** returned by the server. + +### MVP scope (this plan) + +| Track | In scope | Out of scope (explicit) | +| ------ | ---------------------------------------------------------------------------------------------------- | --------------------------------------------------------- | +| **T1** | Transactions **API + UI** (pagination, filters, auth, performance caps) | Full merchant AI normalization | +| **T2** | **Unified scope** on dashboard (global date range + source inclusion + consistent rollup semantics) | T5 URL-backed tab product/growth work | +| **T3** | **Merchant rollups** + Overview↔Insights **coupling** + deep links | T6 month-compare UI | +| **T4** | **Facts JSON schema** + coaching path that **only references facts**; extend `docs/COACHING-TONE.md` | Investment advice tone; bespoke portfolio recommendations | + +**Deferred (T5–T6):** IA/growth (URL-backed tabs, desktop share), **compare months** + related hygiene — backlog AC listed in §8; no execution until PM lifts deferral after T1–T3. + +### Success metrics + +| Metric | Role | Target / note | +| ------------------------- | ------------------------------ | ------------------------------------------------------------------------------------------------------------------------ | +| **North Star proxy** | Primary (issue-009 continuity) | Second-month / repeat statement upload rate ≥ **60%** (cohort-defined in `/metric-plan`) | +| **Trust proxy (Phase 3)** | Supporting | ↑ sessions where user opens **Transactions** or follows a **deep link** from Insights within 7 days of second upload | +| **Performance** | Gate | Transactions list API **p95** within agreed budget (e.g. < 2s warm) for typical account sizes; **no unbounded scans** | +| **Safety** | Gate | Zero production incidents from **hallucinated rupee amounts** in AI surfaces (facts-only numerics) | + +### Decision records (PRD — lock before UI freeze) + +1. **Perceived vs actual under “all sources” rollup** + - **Decision (recommended default):** Show **one blended “actual”** for the selected global scope (sum of included statements’ actuals in range). Show **perceived** as the **single profile baseline** (`profiles.perceived_spend_paisa`) with **inline copy** that it is a self-reported monthly estimate, **not** per-account. + - **Alternative (if PM prefers):** Per-account perceived requires **new persisted fields** (not only `profiles`) — out of scope for T2 unless explicitly added to schema/tasks. + +2. **Merchant identity v1** + - **Decision:** **Heuristic normalization** in application code (deterministic rules: UPI handle extraction, common merchant tokens, case folding). **No** Gemini merchant pass in T1. Optional **T3** spike: structured Gemini **label suggestions** stored separately with confidence + fallback to raw description — only if heuristic quality blocks the demo. + +--- + +## 3. UX design (Design Agent) + +### Information architecture + +- **Dashboard** remains the hub; add a persistent **Scope bar** (T2): date range + **Included sources** (all / subset of statements). +- **New primary surface:** **Transactions** (tab or route — follow existing `DashboardNav` patterns; prefer **route** `/dashboard/transactions` or tab inside shell per implementation, but **one** canonical entry). +- **Insights** remains the coaching/rollup surface; must **reuse the same scope** object as Overview and Transactions (single source of truth in client state + URL query sync minimum for shareable state **without** waiting for T5). + +### User flows + +1. **Transactions:** User adjusts scope → list loads with skeleton → infinite scroll or **cursor/page** pagination → tap row → optional detail sheet (description, category, statement badge). +2. **Merchant rollup → evidence:** User taps “Zomato” row in Insights → navigates to Transactions with **query preset** (`merchant_key` / filter token) → list shows matching rows. +3. **Coaching card:** Headline + body; **tap “Sources”** expands **facts bullets** (from Layer A); no rupee figure in prose without matching fact id. + +### UI components (extend existing) + +- **ScopeBar:** Date range (presets + custom), multi-select statements (nicknames), “All uploaded data in range” mode. +- **TransactionList:** Virtualized or paginated list rows; category chip; debit/credit; date; **statement badge** (nickname / institution). +- **FilterSheet / toolbar:** Category, type, text search (debounced + **AbortController** per ui-standards patterns used elsewhere). +- **MerchantRollupCard:** Merchant label, ₹ total, txn count, CTA “See transactions”. +- **FactsDrawer:** Renders structured facts array (read-only), not raw JSON dump. + +### Interaction logic + +- **Search/filter** requests cancel prior fetches (`AbortController`). +- **localStorage** usage (if any for scope prefs): **try/catch** on read/write. +- **Loading / empty / error** states for all new async views (skeleton + inline error). + +### Accessibility + +- Scope controls and list rows keyboard-focusable; filter chips labeled. + +--- + +## 4. System architecture (Backend Architect Agent) + +### Overview + +- **Monolith:** `apps/money-mirror` — new **read-heavy** routes and **optional** rollup helpers under `src/lib/`. +- **Auth:** Every new API uses `getSessionUser()` (or equivalent) and **never** trusts client `userId` from body without session match. +- **Pagination:** **Cursor or offset** with **hard cap** (e.g. `limit` ≤ 100). **Required:** `.limit()` on SQL side. +- **ID fidelity:** Any URL or query param that names `statement_id`, `transaction_id`, or `merchant_key` must drive **that** query — **no** silent fallback to “latest for user” (anti-pattern from CLAUDE). + +### New / changed API surface (conceptual) + +| Method | Path | Purpose | +| ------ | ---------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| GET | `/api/transactions` | Paginated, filtered transactions for **session user**, scoped by date + statement ids + category + type + text search | +| GET | `/api/dashboard` (extend) or GET `/api/dashboard/summary` | Return **scope-aware** aggregates for Overview (actual totals, category breakdown) — **or** extend existing `fetchDashboardData` with scope params **without breaking** current `?statement_id=` behavior | +| GET | `/api/insights/merchants` (or under `/api/dashboard`) | Top merchant rollups for current scope (limit + min spend threshold) | +| POST | `/api/coaching/facts` (optional split) or **server-only** builder used by advisory route | Build **Layer A facts** object used by Gemini narrative step | + +**Telemetry:** Fire-and-forget PostHog per existing patterns; **no** `await` flush on user-facing routes. + +### Data flow + +1. Client resolves **scope** → calls transactions / summary / merchants with same query params. +2. Server validates scope (statements **owned by user**); rejects unknown ids with **404/400**. +3. Rollups computed in SQL (GROUP BY) or **precomputed** table if needed for perf (T3 decision). + +### Infrastructure + +- No new managed services. **Schema migrations** via `schema.sql` (idempotent `ALTER` / `CREATE INDEX`). + +### Risks mitigated in design + +- **Parser variance:** Transactions API exposes **raw description** + optional `merchant_key`; UI never claims PDF line match unless we add a future “receipt” feature. +- **Performance:** Composite indexes for `(user_id, date DESC)` and `(user_id, statement_id, date)`; explain-analyze budget in `/execute-plan` verification. + +--- + +## 5. Database schema (Database Architect Agent) + +**DB:** Neon Postgres (existing). + +### Existing tables (reference) + +- `profiles`, `statements`, `transactions`, `advisory_feed` — see `apps/money-mirror/schema.sql`. + +### Proposed additions (T1–T4) + +| Change | Table | Purpose | +| ------------------------------------------------------- | -------------------------- | --------------------------------------------------------------------------------------------------------------------------- | +| **Add column** | `transactions` | `merchant_key TEXT` — normalized key for rollup v1 (nullable until backfill). | +| **Add column** (optional T3) | `transactions` | `merchant_display_label TEXT` — human label for UI; may equal `merchant_key` initially. | +| **Index** | `transactions` | `CREATE INDEX … ON transactions (user_id, date DESC)` if not redundant with existing idx. | +| **Index** | `transactions` | `CREATE INDEX … ON transactions (user_id, merchant_key)` **where** `merchant_key IS NOT NULL` (partial) for rollup queries. | +| **New table** (optional if GROUP BY too heavy at scale) | `merchant_rollups_monthly` | `(user_id, month, merchant_key, …)` — **only** if profiling shows need; default T3 = **on-the-fly** SQL. | + +### Backfill strategy + +- On deploy T1: **one-time** or **lazy** backfill: compute `merchant_key` from `description` using shared `src/lib/merchant-normalize.ts` (new). Old rows updated in migration script or on read (prefer **migration** for consistent rollups). + +### Relationships + +- `transactions.statement_id` → `statements.id` (unchanged). +- Scope queries **always** constrain `user_id` + optional `statement_id IN (…)`. + +--- + +## 6. Analytics (Analytics Framework alignment) + +Define in **execute-plan**; verify in **review**. Suggested **new** events (names snake_case): + +- `transactions_view_opened` +- `transactions_filter_applied` (properties: filter_type, scope — no raw PII) +- `merchant_rollup_clicked` (merchant_key hashed or bucketed if needed) +- `coaching_facts_expanded` +- `scope_changed` (date_preset, source_count) + +**Existing** events stay authoritative where already defined; **single emission source** per event name (no duplicate client+server for the same business event). + +North Star and funnels finalized in `/metric-plan`; this plan lists instrumentation **intent** only. + +--- + +## 7. Implementation tasks (by phase) + +Phases align with **T1→T4** and Linear **VIJ-38–VIJ-41**. + +### Phase A — T1 / VIJ-38: Transaction surface + API (P0) + +1. Add `merchant_key` (nullable) + indexes in `schema.sql`; migration notes in README. +2. Implement `normalizeMerchantKey(description: string): string | null` (pure, tested). +3. Backfill or write path: set `merchant_key` on insert + batch backfill existing rows. +4. Implement `GET /api/transactions` with auth, pagination, filters, hard limit, ownership checks. +5. Add Transactions UI (shell integration + list + empty/error). +6. Tests: API route + normalization unit tests. + +### Phase B — T2 / VIJ-39: Unified scope model + +1. Define **scope object** (Zod or TS type) shared client/server. +2. Extend dashboard data loading to accept **date_from/date_to** + **statement_ids** (validated). +3. Align Overview metrics to scope; **decision record §2** for perceived copy. +4. PostHog: `scope_changed`. + +### Phase C — T3 / VIJ-40: Merchant rollups + coupling + +1. SQL or helper: top merchants by spend for scope. +2. Insights UI: merchant cards + deep link to `/api/transactions?…`. +3. Ensure Overview totals **match** sum of transaction debits for same scope (smoke test). + +### Phase D — T4 / VIJ-41: Facts-grounded AI coaching + +1. Define **Layer A facts JSON schema** (Zod); server builds facts from DB only. +2. Gemini step: **narrative only**; prompt lists facts as input; structured output = `{ narrative, cited_fact_ids[] }` validation. +3. Update `docs/COACHING-TONE.md` with expert examples + “no new numbers” rule. +4. Wire advisory or new coaching panel to facts-backed path; feature-flag if needed. + +--- + +## 8. Acceptance criteria + +### T1 — Transaction surface + API (VIJ-38) + +- [ ] Authenticated user can open **Transactions** and see **paginated** rows for their data only. +- [ ] `GET /api/transactions` supports at least: **date range**, **statement_id** filter, **category**, **type**, **search substring** on description, **sort** by date desc. +- [ ] Response includes **statement nickname/institution** for each row (via join or batch). +- [ ] **401** unauthenticated; **400** invalid params; **no** unbounded result set. +- [ ] `merchant_key` populated for **new** inserts; backfill documented for old rows. +- [ ] PostHog: `transactions_view_opened` (single emission source agreed in implementation). + +### T2 — Unified scope (VIJ-39) + +- [ ] User can set **global date range** + **which statements** included; Overview reflects that scope. +- [ ] Transactions and Overview **use the same scope parameters** (no hidden mismatch). +- [ ] Perceived vs actual display follows **§2 Decision record** (blended actual + single perceived baseline **or** explicit PM override documented). + +### T3 — Merchant rollups + coupling (VIJ-40) + +- [ ] Insights shows **top merchants** (threshold + limit) for current scope. +- [ ] Tapping a merchant navigates to Transactions with filters applied and **matching rows** shown. +- [ ] Category/merchant totals **reconcile** to transaction sums within **₹1** rounding tolerance (paisa-aware). + +### T4 — Facts-grounded AI (VIJ-41) + +- [ ] Server exposes **Layer A facts** object per coaching response; **all** rupee figures in UI trace to facts or DB fields. +- [ ] Gemini output **validated**; if validation fails, show safe fallback copy (no invented numbers). +- [ ] `docs/COACHING-TONE.md` updated with **expert examples** and generative guardrails. + +### T5 — Deferred (IA + growth) + +- [ ] URL-backed primary tabs / shareable dashboard state (spec only until un-deferred). + +### T6 — Deferred (Compare months + hygiene) + +- [ ] Month-over-month comparison view + data hygiene (spec only until un-deferred). + +--- + +## 9. Risks + +| Risk | Mitigation | +| ------------------------------ | -------------------------------------------------------------------------------------- | +| PDF/issuer variance | Transactions show **parsed** rows; copy avoids “matches PDF line-by-line” unless true. | +| Merchant normalization quality | Heuristics v1 + iterate; optional Gemini assist **after** metrics. | +| Perf regression | Indexes, limits, explain-analyze, no N+1. | +| Regulatory / tone | Facts-only numerics; COACHING-TONE + legal disclaimer patterns from existing doc. | +| Scope creep | T5–T6 explicitly deferred; PRD decisions in §2. | + +--- + +## 10. Next step + +Run **`/execute-plan`** starting with **VIJ-38 / T1** (transactions API + UI + schema). After plan approval, run **`/linear-sync plan`** to sync PRD + child tasks to Linear (mandatory checkpoint per repo protocol). diff --git a/experiments/results/deploy-check-010.md b/experiments/results/deploy-check-010.md new file mode 100644 index 0000000..479910b --- /dev/null +++ b/experiments/results/deploy-check-010.md @@ -0,0 +1,147 @@ +# Deploy-check — issue-010 (MoneyMirror Phase 3) + +**Date:** 2026-04-05 (updated same day) +**App:** `apps/money-mirror` +**Schema source of truth:** [`apps/money-mirror/schema.sql`](../../apps/money-mirror/schema.sql) — _not_ `apps/clarity` (Clarity is a different app). + +**Verdict:** **APPROVE deployment readiness** (with PM env exception + manual smoke follow-ups below) + +--- + +## PM exception — Sentry client / org / project env vars + +Per product decision, **`NEXT_PUBLIC_SENTRY_DSN`**, **`SENTRY_ORG`**, and **`SENTRY_PROJECT`** are **out of scope for the deploy-check environment gate**. They may remain empty locally. Server-side Sentry still initializes in `sentry.server.config.ts`; client DSN is optional when unused. + +`.env.local.example` marks these three as optional for local/dev parity. + +--- + +## Local smoke test (Gate 0) + +### Automated (this session) + +- **`npm run dev`** with `next dev -H 127.0.0.1 -p 3000` — server **Ready** without errors. +- **HTTP checks** (expect HTML): + - `GET /` → **200** + - `GET /login` → **200** + - `GET /dashboard` → **200** (may redirect in browser; SSR returned 200 here) +- **Cron auth contract:** `GET /api/cron/weekly-recap` without secret → **401** + +### Manual (you still should run) + +These cannot be fully automated without real OTP/email and a PDF: + +1. **Neon Auth:** On `/login`, send OTP and complete sign-in. +2. **Onboarding:** Finish flow; confirm `profiles` row updates (DB write). +3. **Statement:** Upload a PDF; confirm parse + dashboard data. +4. **Console/terminal:** No unexpected 500s during the journey. + +--- + +## Build status + +- `npm run build` (apps/money-mirror): **PASS** (exit 0). +- Note: Sentry webpack plugin may log `sentry-cli` upload warnings if Sentry API is unreachable; build still completed. + +--- + +## Environment configuration + +Scan of `process.env.*` usage vs `apps/money-mirror/.env.local.example`: **aligned**. + +**Blocking check applies to** all non-optional vars (DB, Neon Auth, Gemini, Resend, PostHog, `NEXT_PUBLIC_APP_URL`, `CRON_SECRET`, etc.). + +**Excluded from blocking (PM):** `NEXT_PUBLIC_SENTRY_DSN`, `SENTRY_ORG`, `SENTRY_PROJECT` — empty values **do not** fail this gate. + +--- + +## Infrastructure readiness — Neon schema verification + +**Method used:** `DATABASE_URL` from local `.env.local` + `@neondatabase/serverless` (`neon` SQL) querying `information_schema.tables` for `table_schema = 'public'`. + +**Result:** All tables required by [`apps/money-mirror/schema.sql`](../../apps/money-mirror/schema.sql) are **present** on the connected Neon project: + +| Expected table | Status | +| --------------- | ------- | +| `profiles` | PRESENT | +| `statements` | PRESENT | +| `transactions` | PRESENT | +| `advisory_feed` | PRESENT | + +**Public tables observed:** `advisory_feed`, `profiles`, `statements`, `transactions` (no extra app tables required for this gate). + +### If you need to reproduce or verify on another database + +1. Open **Neon Console** → your project → **SQL Editor**. +2. Run: + +```sql +SELECT table_name +FROM information_schema.tables +WHERE table_schema = 'public' + AND table_type = 'BASE TABLE' +ORDER BY table_name; +``` + +3. Confirm the four tables above exist; if not, paste and run the full contents of `apps/money-mirror/schema.sql` (idempotent `CREATE TABLE IF NOT EXISTS` + `ALTER ... IF NOT EXISTS`). + +--- + +## Monitoring status + +- **PostHog:** Wired per README / metric plan. +- **Sentry:** Package + configs + `withSentryConfig` + `captureException` usage verified. Client DSN optional per PM. + +--- + +## Rollback plan + +- **Vercel:** Roll back to a prior deployment or redeploy a known-good commit (`vercel deploy --prod` from monorepo root per `project-state.md` notes). +- **Neon:** Schema in repo is additive; avoid destructive DDL on production without a migration plan. + +--- + +## README quality gate + +- **PASS** — includes one-liner, journey, stack, env names, schema step, dev command, API surface (including `/api/auth/[...path]` and `GET /api/sentry-example-api`), analytics table, design decisions. + +--- + +## Sentry error tracking verification + +- **Configuration:** PASS (library, configs, `withSentryConfig`, exception capture in routes). +- **PM scope:** Client/org/project env vars not required for gate (see top). + +--- + +## PR creation + +**Not executed in this run** — optional once you commit Phase 3 + experiment files and want a GitHub PR. Command protocol: `git status` clean → `gh pr create` as in `commands/deploy-check.md`. + +--- + +## Deployment decision + +### Approve deployment (readiness) + +Remaining human steps: + +1. Run **manual smoke** (OTP → onboarding → upload) on **local** or **Vercel preview/production** as you prefer. +2. **Commit and push** when ready; deploy to Vercel if you want production validation. +3. **`/linear-sync release`** after a production deploy if you need Linear updated with links. + +### Next command in pipeline (`system-orchestrator.md`) + +After you treat deploy-check as complete for this cycle: + +1. **`/postmortem`** — analyzes the full pipeline run (required **after** deploy-check passes; it is the **next** stage, not optional if you are closing the issue-010 cycle). +2. **`/learning`** — only after postmortem completes. + +So the plan is: **finish any manual smoke → commit/deploy as you like → run `/postmortem` → then `/learning`**. + +--- + +## Revision history + +- **2026-04-05 (initial):** Blocked on empty Sentry trio + no smoke + MCP schema unavailable. +- **2026-04-05 (revision):** PM excludes Sentry trio from env gate; Neon tables verified via live query; automated local HTTP + cron 401 smoke added; verdict **APPROVE** readiness; clarified schema path is `apps/money-mirror/schema.sql` only. diff --git a/experiments/results/metric-plan-010.md b/experiments/results/metric-plan-010.md new file mode 100644 index 0000000..b57d0d7 --- /dev/null +++ b/experiments/results/metric-plan-010.md @@ -0,0 +1,105 @@ +# Metric Plan — issue-010 MoneyMirror Phase 3 + +**Date:** 2026-04-05 +**Command:** `/metric-plan` +**App:** `apps/money-mirror` +**Inputs:** `experiments/ideas/issue-010.md`, `experiments/plans/plan-010.md`, `experiments/results/qa-test-010.md`, `experiments/plans/manifest-010.json`, codebase `captureServerEvent` audit + +--- + +## North Star Metric + +**Repeat statement upload rate (second upload within 60 days of first successful parse)** — cohort-defined as: among users who reach `statement_parse_success` at least once, the share who record a **second** distinct `statements` row (or second successful parse session) within **60 days** of the first. + +- **Ties to:** Issue-009 North Star proxy and `plan-010` success table (“second-month / repeat statement upload ≥ **60%**”); issue-010 hypothesis that transaction truth + scope + merchant evidence + facts-grounded coaching increase **trust** and speed **return uploads**. +- **Why this star:** Phase 3 is validated when multi-statement users **come back with more PDFs** — the behavioral signal that the dashboard is credible enough to use again. +- **Ground truth:** Neon `statements` (and/or parse success events) aggregated by `user_id` and upload timestamps; PostHog can mirror cohort reporting but DB counts are authoritative for finance-grade accuracy. + +--- + +## Supporting Metrics + +1. **Phase 3 trust proxy (composite):** Share of users with **≥2** successful statement parses who, within **7 days** of the second parse, fire **`transactions_view_opened`** OR **`merchant_rollup_clicked`** (evidence-seeking behavior aligned with `plan-010`). +2. **Unified scope adoption:** Sessions with **`scope_changed`** (non-default scope) per active user per week — indicates users are using the multi-source model, not a single-statement mental model. +3. **Facts-grounded coaching engagement:** Rate of **`coaching_facts_expanded`** per advisory impressions (and **`coaching_narrative_completed` / failed / timeout** ratio) — measures use of evidence UI and Gemini path health. +4. **Parse reliability:** **`statement_parse_success` / (`statement_parse_success` + `statement_parse_failed` + `statement_parse_timeout`)** over rolling 7 days — parser and AI stability. +5. **Read-path performance (gate):** Server-side **`transactions_filter_applied`** volume vs. error rates; monitor p95 latency for `GET /api/transactions` and `GET /api/insights/merchants` in Vercel/Neon (no unbounded scans per plan). + +--- + +## Event Tracking Plan + +| Event name | Trigger | Properties (non-PII / bucketed) | +| ------------------------------ | --------------------------------------------- | ----------------------------------------------------- | +| `onboarding_completed` | Onboarding API persists profile | As implemented in route | +| `statement_parse_started` | Parse route begins work | As implemented | +| `statement_parse_success` | Transactions + statement persist success | As implemented | +| `statement_parse_failed` | Parse or persist failure | `error_type` / sanitized context | +| `statement_parse_timeout` | Gemini timeout path | As implemented | +| `statement_parse_rate_limited` | User hits upload rate limit | As implemented | +| `transactions_view_opened` | POST `/api/transactions/view-opened` | As implemented | +| `transactions_filter_applied` | GET `/api/transactions` with active filters | `filter_type` / scope hints (no raw descriptions) | +| `scope_changed` | POST `/api/dashboard/scope-changed` | `date_preset`, `source_count`, etc. | +| `merchant_rollup_clicked` | POST `/api/insights/merchant-click` | Bucketed / hashed `merchant_key` per existing pattern | +| `coaching_facts_expanded` | POST `/api/dashboard/coaching-facts-expanded` | `advisory_id` | +| `coaching_narrative_completed` | Enrich path returns narrative | Fact counts / latency metadata as implemented | +| `coaching_narrative_timeout` | Gemini timeout (9s) | `timeout_ms` | +| `coaching_narrative_failed` | Enrich failure | Error class as implemented | +| `weekly_recap_triggered` | Cron master | System distinct id | +| `weekly_recap_completed` | Cron master completion | Success/fail counts | +| `weekly_recap_email_sent` | Worker send OK | Per user | +| `weekly_recap_email_failed` | Worker send failure | Per user | + +**Single emission source:** Each business event has one authoritative server emission (see `apps/money-mirror/src/lib/posthog.ts` and call sites). No duplicate client+server for the same event name. + +--- + +## Funnel Definition + +**A — Acquisition → first value** + +1. `onboarding_completed` +2. `statement_parse_started` → `statement_parse_success` (drop-off: `statement_parse_failed`, `statement_parse_timeout`, `statement_parse_rate_limited`) + +**B — North Star (repeat upload)** + +1. First `statement_parse_success` (cohort entry) +2. Second statement upload / second `statement_parse_success` within 60 days + - Conversion = step 2 / step 1 for eligible cohorts + +**C — Phase 3 “trust loop” (after ≥2 statements)** + +1. `transactions_view_opened` or `merchant_rollup_clicked` +2. `scope_changed` (optional refinement step) +3. `coaching_facts_expanded` (evidence UI) + +Measure drop-off at each step; segment by mobile vs desktop if needed later. + +--- + +## Success Thresholds + +| Metric | Success | Investigate (alert) | +| --------------------------------------------------------------------- | --------------------------------------------------- | -------------------------------------- | +| Repeat statement upload (60d) | ≥ **50%** early signal → target **60%** at maturity | < **35%** for 4-week rolling cohort | +| Trust proxy (7d after 2nd parse) | ≥ **40%** open txn or click merchant | < **20%** | +| Parse success rate | ≥ **92%** rolling 7d | < **85%** | +| `coaching_narrative_failed` + `timeout` share of narrative attempts | < **8%** combined | > **15%** | +| Weekly recap worker failures (`weekly_recap_email_failed` / attempts) | < **5%** | > **12%** | + +_(Thresholds are initial; calibrate after 2–4 weeks of production volume.)_ + +--- + +## Implementation Notes + +- **Tool:** PostHog via **`posthog-node`** — `captureServerEvent` in `apps/money-mirror/src/lib/posthog.ts`; env: **`POSTHOG_KEY`**, **`POSTHOG_HOST`** (server-only; see QA env audit). +- **No client `posthog-js` usage** in current codebase for custom events; pageviews/session replay can be added later under T5+ without changing server event names. +- **Dashboards:** PostHog — (1) North Star cohort trend, (2) Funnel A/B/C, (3) Parse + coaching health, (4) Cron recap reliability. +- **DB alignment:** Build cohort queries that join `statements.user_id`, `statements.created_at` (or parse time) for repeat-upload ground truth; cross-check PostHog `statement_parse_success` counts monthly. +- **Privacy:** No raw transaction descriptions or account numbers in event properties; merchant keys bucketed/hashed as already implemented. +- **Deferred (T5–T6):** URL-backed tabs / compare months may add events in a future issue; not required for this metric plan. + +--- + +**Gate:** Metric plan complete for issue-010. **`/deploy-check`** may proceed. diff --git a/experiments/results/peer-review-010.md b/experiments/results/peer-review-010.md new file mode 100644 index 0000000..ff6fcce --- /dev/null +++ b/experiments/results/peer-review-010.md @@ -0,0 +1,95 @@ +# Peer Review — issue-010 MoneyMirror Phase 3 + +**Date:** 2026-04-05 +**Scope:** Adversarial architecture review after `/review` (Codex + Claude Code). T1–T4 shipped; T5–T6 deferred per issue brief. +**Inputs:** `experiments/results/review-010.md`, `experiments/ideas/issue-010.md`, `agents/peer-review-agent.md` Challenge Mode. + +--- + +## Lens 1: Architecture & Scalability + +**No blocking issues.** + +- **Fit:** Monolith Next.js + Neon Postgres + session auth matches Phase 3 scope and expected user volume. Unified scope + SQL-backed aggregates (replacing row-capped client math) removes a real scalability ceiling for heavy accounts. +- **AI path:** Facts JSON (Layer A) → structured Gemini output with citation validation preserves a defensible boundary: amounts stay server-authoritative; model copy is constrained. Documented limitation: Google GenAI SDK does not cancel in-flight requests on timeout — timeout rejects to the caller but work may continue (cost/latency tail). Acceptable for MVP if monitored; not a ship blocker given 9s cap and rule-based fallback. +- **Complexity:** Phase 3 added several API surfaces (transactions, scope, insights, coaching expansion). The split into `useDashboardState`, `TxnFilterBar`, `TxnRow`, and lib modules is appropriate; no unnecessary microservices. + +**Non-blocking observations** + +- **Read-path cost:** `GET /api/transactions` and merchant insight routes can execute heavy `GROUP BY` / filtered scans for an authenticated user. Unlike `statement/parse`, there is no explicit rate limit on these reads. Cross-tenant abuse is mitigated by auth; self-DoS or cost amplification via rapid UI actions remains a product hardening item for a later slice (not blocking QA). + +--- + +## Lens 2: Edge Cases, Security & Reliability + +**No blocking issues.** + +- **Auth:** Code review confirmed `getSessionUser()` before data access on new routes and ownership checks on `statement_id` / `statement_ids`. +- **Races:** Dashboard load uses `AbortController` to prevent stale scope overwrites — addresses the highest-risk client race called out in review-010. +- **Partial AI failure:** Coaching narrative failure paths return structured `ok: false` outcomes; rule-based `message` on advisories remains. PostHog server events use fire-and-forget `.catch(() => {})` on hot paths per anti-patterns. +- **Persistence:** `persist-statement` fail-closed pattern preserved from prior cycles. +- **Observability:** Sentry wired in dashboard/transactions/backfill catch blocks per second review pass. + +**Non-blocking observations** + +- **Merchant normalization:** Heuristic `merchant_key` will mis-bucket noisy descriptors; issue brief already flags parser variance — aligns with deferred deeper normalization (T5–T6 territory). +- **Backfill route:** Cursor-based batching + batched `unnest` UPDATE reduces timeout risk; still a privileged maintenance path — ensure it stays cron/admin-only in operations (verify deployment posture in `/qa-test`). + +--- + +## Lens 3: Product Coherence & PM Alignment + +**No blocking issues.** + +- **Problem statement:** Unified date/source scope, transaction-native truth, merchant rollups, and facts-grounded coaching match `issue-010.md` desired outcome for T1–T4. +- **Deferred scope:** T5–T6 explicitly deferred — no spec drift in shipped code. +- **Telemetry:** New events are server-side single-emission for the review scope (`transactions_*`, `scope_changed`, `merchant_rollup_clicked`, `coaching_facts_expanded`, `coaching_narrative_*`). North Star proxy (e.g. second-month upload) remains a **`/metric-plan`** concern, not a peer-review gate failure. +- **Safety:** Generative path instructs no invented amounts, no ₹ in narrative, no securities advice — consistent with `docs/COACHING-TONE.md` direction. + +**Non-blocking observations** + +- **Perceived vs actual:** Plan/issue called out explicit PRD decision for global rollup; implementation follows blended “actual” + profile perceived baseline — consistent with documented plan; revisit if product wants per-account perceived later. + +--- + +## Verdict: APPROVED + +No blocking issues across the three lenses. Remaining items are **hardening and follow-ups** (read-path rate limits, merchant quality, metric-plan alignment), appropriate for **`/qa-test`** and **`/metric-plan`**, not for re-opening execute-plan. + +--- + +## Challenge Mode (summary) + +### Step 1 — Assumption audit (top 3) + +| Assumption | Risk | Mitigation in repo | +| --------------------------------------------------------- | ---------------------------------- | ----------------------------------------------------------------------------- | +| Heuristic merchant keys are “good enough” for coaching | Mis-ranked merchants, high “Other” | Known; exploration/issue callouts; T5–T6 deferred | +| Authenticated users won’t hammer expensive list endpoints | DB load / cost | Auth bounds cross-user abuse; per-user throttle not yet required for MVP gate | +| Gemini returns schema-valid JSON often enough | Bad JSON / hallucination | Zod + citation validation + timeout + fallback to rules | + +Counterarguments are contained; none justify blocking QA. + +### Step 2 — Anti-sycophancy + +Approval is conditional on **no new evidence** of auth bypass or cross-tenant data leak; code review + this pass did not find one. If QA finds a route regression, reopen. + +### Step 3 — Multi-perspective (min. one finding each) + +- **Reliability (3am):** Gemini timeout without true abort — monitor p95 spend and consider queue/async coaching later if costs spike. _Non-blocking._ +- **Adversarial user:** Session-only abuse of transactions API could stress own data; rate limit is product backlog. _Non-blocking._ +- **Future maintainer:** `TIMEOUT_MS` + SDK abort note in `gemini-coaching-narrative.ts` — good; keep when upgrading `@google/genai`. + +### Step 4 — Prompt autopsy (optional) + +No mandatory agent-file edits required from this review. Suggested for **`/learning`** if reinforced: + +- **File:** `agents/backend-architect-agent.md` + **Section:** Mandatory Pre-Approval Checklist + **Add:** "For finance dashboards with user-scoped heavy read APIs (aggregations, GROUP BY, unbounded filters), document either pagination/cursor guarantees, per-user rate limits, or explicit 'trusted client' assumption in the plan." + +--- + +## Linear + +- Root **VIJ-37** should receive **`/linear-sync status`** after this artifact and `project-state.md` update (peer-review PASS → still **Review** stage label until QA completes per linear-sync mapping). diff --git a/experiments/results/postmortem-010.md b/experiments/results/postmortem-010.md new file mode 100644 index 0000000..516e59c --- /dev/null +++ b/experiments/results/postmortem-010.md @@ -0,0 +1,218 @@ +# Postmortem — MoneyMirror Phase 3 (issue-010) + +**Date:** 2026-04-05 +**Agent:** Learning Agent (per `commands/postmortem.md`) +**Pipeline cycle:** issue-010 — T1–T4 shipped; deploy-check **APPROVE**; T5–T6 deferred +**Inputs:** `experiments/results/review-010.md`, `peer-review-010.md`, `qa-test-010.md`, `metric-plan-010.md`, `deploy-check-010.md`, `project-state.md` + +--- + +## Executive summary + +Phase 3 closed successfully: quality gates passed, production readiness approved. The main systemic pattern is **late discovery of correctness and scope-semantics bugs during `/review`** (two passes), after execute-plan shipped. Architecture and planning did not state **non-negotiable invariants** for financial aggregates (full-scope SQL) and **scope-aware UX copy** (multi-month vs “this month”). Residual hardening (per-user rate limits on heavy reads, true Gemini abort) was correctly deferred as non-blocking. Deploy-check initially failed on optional Sentry env + process noise; **PM exception** resolved the gate without code changes. + +--- + +## Issue PM-1 — Dashboard totals derived from row-capped transaction data + +**Issue Observed:** +Overview totals, advisory inputs, and coaching facts could diverge from truth for accounts or scopes with more than **1000** transactions in scope because math used a row-limited transaction set. + +**Root Cause:** +The plan did not explicitly require that **headline dashboard numbers** be computed via **full-scope SQL aggregates**. Implementation used a path that capped rows, which is adequate for UI lists but wrong for totals. + +**Preventative Rule:** +For any finance dashboard, **totals and category sums used for insights, advisories, and AI facts must be computed with database aggregates over the full active scope**, never from a `LIMIT`-bounded transaction scan. + +**System Improvements:** + +- `backend-architect-agent.md`: Mandatory checklist — financial headline metrics must name the aggregate strategy (SQL `SUM`/`COUNT` over scope). +- `backend-engineer-agent.md`: Hard rule — no `LIMIT` on the query path used for monetary totals. + +**Knowledge Updates:** `engineering-lessons.md` + +--- + +## Issue PM-2 — Merchant backfill could not terminate on unresolvable keys + +**Issue Observed:** +Backfill could loop until timeout when `normalizeMerchantKey()` returned `null` for rows still selected as “needing” keys. + +**Root Cause:** +Batch processing assumed every selected row could eventually be updated; there was no explicit **forward cursor + skip** rule for permanently unresolvable rows in the architecture or implementation notes. + +**Preventative Rule:** +Any cursor-based batch repair over rows with nullable derived fields must **advance the cursor past rows that cannot be normalized** in one pass, or mark them skipped, so the loop always terminates. + +**System Improvements:** + +- `backend-architect-agent.md`: Checklist item for maintenance/repair routes — termination proof (cursor monotonicity + poison-row handling). +- `code-review-agent.md`: Flag `while`/`for` repair loops without explicit exit on unprocessable rows. + +**Knowledge Updates:** `engineering-lessons.md` + +--- + +## Issue PM-3 — Scope editor UI drifted from URL canonical scope + +**Issue Observed:** +Opening “Edit scope” on a unified view showed defaults and could **overwrite** the active range on re-apply. + +**Root Cause:** +Local form state was not **re-hydrated from the URL** when the canonical scope changed; two sources of truth diverged. + +**Preventative Rule:** +When **URL/search params** define canonical scope or filters, **all edit dialogs must reset local state from parsed URL** whenever the active scope changes. + +**System Improvements:** + +- `frontend-engineer-agent.md`: Explicit pattern — sync local form state from parsed route state on scope change. + +**Knowledge Updates:** `engineering-lessons.md`, `product-lessons.md` (if framing “single source of truth” for filters) + +--- + +## Issue PM-4 — Advisory copy and math assumed a monthly mental model + +**Issues Observed:** + +- Food delivery text used **×12 annualization** on multi-month scopes (wrong magnitude). +- Subscription line used **“/mo”** and NO_INVESTMENT used **“this month”** when scope was not a single month. + +**Root Cause:** +Advisory strings were authored for a **single-month** frame without a spec rule tying copy to **actual scope duration**. Review pass two caught this; execute-plan did not encode scope-aware copy rules. + +**Preventative Rule:** +All **money and time phrases** in user-facing analytics (`/mo`, `per year`, `this month`) must be **validated against the active scope** (single month vs multi-month vs arbitrary range). **Do not multiply by 12** unless the scope is exactly one month or the copy explicitly annualizes from a monthly estimate. + +**System Improvements:** + +- `product-agent.md` / `design-agent.md`: Plan templates include **scope-neutral phrasing** (“this period”) where range varies. +- `code-review-agent.md`: Financial copy review dimension — scope consistency for time and rate phrases. + +**Knowledge Updates:** `product-lessons.md`, `engineering-lessons.md` + +--- + +## Issue PM-5 — Stale dashboard fetch race without abort + +**Issue Observed:** +Rapid scope changes could leave **stale** dashboard data on screen when an older `loadDashboard` resolved after a newer scope. + +**Root Cause:** +`fetch` had no **AbortController**; async races were not in the initial implementation checklist for dashboard reload. + +**Preventative Rule:** +Any **user-triggered reload** that can be superseded by a newer action must use **AbortController** (or equivalent) and ignore `AbortError`. + +**System Improvements:** + +- `frontend-engineer-agent.md`: Reinforce abort pattern for competing fetches (already partially in codebase; make explicit for dashboard loads). + +**Knowledge Updates:** `engineering-lessons.md` + +--- + +## Issue PM-6 — Operational / gate friction on deploy-check (non-code) + +**Issue Observed:** +First deploy-check run **blocked** on empty Sentry client/org/project and non-ideal git/PR state; second run **APPROVE** after PM excluded optional Sentry from the gate and verified Neon tables + smoke. + +**Root Cause:** +Default `deploy-check` protocol treats Sentry env as blocking; **product** had already decided optional Sentry for this app. Exception was not encoded in the gate until the revision. + +**Preventative Rule:** +When the PM records an **env exception** in project-state or deploy artifact, **deploy-check must not fail** on those vars; exceptions should be one-line checkable before blocking. + +**System Improvements:** + +- `commands/deploy-check.md`: Document handling **documented PM exceptions** for optional monitoring keys. +- `deploy-agent.md` (if present): Align gate with `project-state.md` exceptions. + +**Knowledge Updates:** `prompt-library.md` (process meta) + +--- + +## Issue PM-7 — Heavy authenticated read APIs without per-user throttle (backlog) + +**Issue Observed:** +`GET /api/transactions` and merchant insights can be expensive; **no per-user rate limit** (peer + QA non-blocking). + +**Root Cause:** +Architecture called out auth and ownership but did not require an explicit **cost/abuse posture** for self-DoS (rapid UI actions). + +**Preventative Rule:** +For **authenticated heavy read** endpoints (large scans, `GROUP BY`), the architecture should either document **pagination/cursor guarantees**, **per-user rate limits**, or an explicit **“trusted client / MVP”** assumption. + +**System Improvements:** + +- `backend-architect-agent.md`: Peer-review suggestion formalized — heavy read APIs need a stated strategy. + +**Knowledge Updates:** `engineering-lessons.md` + +--- + +# Knowledge Updates (summary) + +| File | Topics | +| ------------------------ | ---------------------------------------------------------------------------------------------- | +| `engineering-lessons.md` | Full-scope SQL totals; backfill termination; abort on dashboard fetch; heavy-read API strategy | +| `product-lessons.md` | Scope-aware copy for variable date ranges | +| `prompt-library.md` | Deploy-check PM env exceptions; issue-010 cycle summary | + +--- + +# Prompt Autopsy (required) + +**Agent:** `backend-architect-agent` +**Missed:** Did not require that dashboard headline metrics and advisory inputs use **full-scope SQL aggregates** (no truncation via row limits). +**Root cause in prompt:** Checklist emphasizes auth, fan-out, rehydration — not **financial correctness invariants** for aggregations. +**Proposed fix:** Add to Mandatory Pre-Approval Checklist: _“For any finance dashboard or advisory pipeline, the plan must state that totals, category sums, and inputs to rules/AI are computed from database aggregates over the full user scope (SUM/COUNT), not from LIMIT-capped row scans.”_ + +--- + +**Agent:** `backend-engineer-agent` +**Missed:** Implemented dashboard math via a path that could cap transactions, breaking parity with unlimited scope. +**Root cause in prompt:** No hard rule that **monetary totals** may never use the same query shape as **paged lists**. +**Proposed fix:** Add under implementation constraints: _“Never use LIMIT on a query whose results are summed into headline totals or advisory inputs; use a separate aggregate query for totals.”_ + +--- + +**Agent:** `code-review-agent` +**Missed (first pass):** Scope-dependent advisory copy errors and food annualization; dashboard `loadDashboard` abort race; batched UPDATE optimization — caught in a **second** pass. +**Root cause in prompt:** Review checklist lacks a **financial copy vs scope** dimension and explicit **async race** checklist for competing fetches on scope change. +**Proposed fix:** Add Step: _“For money-related UI copy and advisories, verify time phrases and annualization match the active date scope (single month vs multi-month). For any fetch driven by scope/filter changes, verify AbortController or equivalent stale-response guard.”_ + +--- + +**Agent:** `frontend-engineer-agent` +**Missed:** `ScopeBar` local state not reset from URL on scope change; initial `loadDashboard` without abort. +**Root cause in prompt:** URL-as-source-of-truth sync for **edit** flows not emphasized enough. +**Proposed fix:** _“When filters or scope are encoded in the URL, re-initialize modal/local editor state from parsed search params whenever the canonical scope changes.”_ + +--- + +**Agent:** `product-agent` / `design-agent` +**Missed:** Copy templates defaulted to monthly language while Phase 3 shipped **unified multi-month** scopes. +**Root cause in prompt:** Plans do not require **neutral period language** when date range is user-configurable. +**Proposed fix:** _“If the product supports arbitrary or multi-month ranges, default copy must use period-neutral labels (‘this period’) unless the UI is explicitly month-scoped.”_ + +--- + +**Agent:** `deploy-check` command / `deploy-agent` +**Missed:** First gate failure on optional Sentry vars despite PM intent. +**Root cause in prompt:** Sentry verification is default-blocking without a **documented exception** hook. +**Proposed fix:** In `commands/deploy-check.md`: _“If `project-state.md` Decisions Log or the deploy artifact records a PM exception for optional Sentry (or similar) env vars, skip failing the gate on those keys.”_ + +--- + +**Agent:** `peer-review-agent` +**Missed:** N/A for blocking issues — approved with non-blocking hardening notes. +**Root cause in prompt:** Optional prompt autopsy already suggested heavy-read documentation; good alignment. +**Proposed fix:** Promote the suggested **heavy read API** checklist item from optional to **Mandatory Pre-Approval** cross-reference in `backend-architect-agent.md` (avoid duplication in peer-review file). + +--- + +# Next pipeline command + +Per `system-orchestrator.md`: **`/learning`** — convert this postmortem into durable knowledge + agent updates + `CODEBASE-CONTEXT.md` refresh. diff --git a/experiments/results/qa-test-010.md b/experiments/results/qa-test-010.md new file mode 100644 index 0000000..e1816e7 --- /dev/null +++ b/experiments/results/qa-test-010.md @@ -0,0 +1,65 @@ +# QA Test — issue-010 MoneyMirror Phase 3 + +**Date:** 2026-04-05 +**Command:** `/qa-test` +**Reviewer:** Codex +**Inputs:** `experiments/plans/plan-010.md`, `experiments/results/review-010.md`, `experiments/results/peer-review-010.md`, `apps/money-mirror/CODEBASE-CONTEXT.md` + +--- + +## Functional Tests + +- `npm test` in `apps/money-mirror`: **PASS** (`76/76`) +- `npm run lint` in `apps/money-mirror`: **PASS** +- `npm run build` in `apps/money-mirror`: **PASS** +- Code-path verification: authenticated dashboard rehydration, unified scope parsing, transactions list filters, merchant rollup deep link flow, and facts-grounded coaching all align with the shipped Phase 3 architecture. +- Cron contract verification: weekly recap master requires `CRON_SECRET`, fans out to the worker, and counts failures by worker HTTP status instead of assuming success. + +--- + +## Edge Cases + +- Env var key name cross-check: **PASS** + - Source audit of `process.env.*` usage matches `.env.local.example` for user-supplied keys. + - `NEXT_RUNTIME` appears in source but is a Next.js platform runtime flag used by `src/instrumentation.ts`, not a user-managed `.env.local` key. +- Dashboard scope validation: code correctly rejects partial unified scope (`date_from` without `date_to`, invalid UUIDs, invalid dates) via `parseDashboardScopeFromSearchParams`. +- Merchant backfill edge case: previously unresolvable `merchant_key` rows could spin forever; current cursor-based implementation avoids that failure mode. +- Upload edge checks remain present: PDF-only, 10 MB max, scanned/password-protected failure messaging, and 3/day rate-limit path. + +--- + +## Failure Scenarios + +- PostHog unavailability on cron paths: **PASS by code-path inspection** + - Master route telemetry is wrapped in `.catch(...)` and does not control the HTTP result. + - Worker success/failure telemetry is also wrapped in `.catch(...)`; email success is not downgraded to 500 by PostHog failure. +- Failure telemetry verification for weekly recap worker: **PASS by code-path inspection** + - Worker emits `weekly_recap_email_failed` in the send-email catch path. + - Worker returns HTTP `502` on send failure, allowing the master to count the run as failed. +- Dashboard/API reliability: dashboard fetch path now uses `AbortController` to avoid stale-scope overwrites when users change filters/ranges quickly. + +--- + +## Performance Risks + +- No blocking performance defect found in this QA pass. +- Residual hardening item: authenticated heavy read routes (`/api/transactions`, `/api/insights/merchants`) do not yet have explicit per-user throttling. This is consistent with the non-blocking peer-review note and should be treated as backlog hardening, not a QA blocker for the current gate. + +--- + +## UX Issues + +- No blocking UX reliability issue found in this pass. +- Error surfaces are present on the main async panels reviewed (`TransactionsPanel`, `MerchantRollups`, dashboard load path), and the prior stale-scope race noted in review has already been fixed. +- Known product limitation remains unchanged: desktop share is intentionally absent because `navigator.share` is mobile-only; this matches the documented limitation and is not a regression. + +--- + +## Final QA Verdict + +**PASS** + +- Blocking findings: 0 +- Medium findings: 0 +- Low findings: 0 +- Ready for next gated step: `/metric-plan` diff --git a/experiments/results/review-010.md b/experiments/results/review-010.md new file mode 100644 index 0000000..cb48a4c --- /dev/null +++ b/experiments/results/review-010.md @@ -0,0 +1,145 @@ +# Code Review — issue-010 MoneyMirror + +**Date:** 2026-04-05 +**Reviewer:** Codex +**Plan reference:** experiments/plans/plan-010.md +**Scope:** Phase 3 review pass after `/deslop`; focus on T1–T4 implementation and review-stage blockers before `/peer-review` + +--- + +## Looks Clean + +- Authenticated Phase 3 routes consistently gate on `getSessionUser()` and preserve the app’s session-cookie auth model. +- PostHog single-emission discipline holds for the new review-scope events: `transactions_view_opened`, `transactions_filter_applied`, `scope_changed`, `merchant_rollup_clicked`, `coaching_facts_expanded`, and `coaching_narrative_*`. +- Facts-grounded coaching still preserves the safe fallback path: rule-based advisory `message` remains available when Gemini output is absent or invalid. +- Transactions, merchant rollups, and scope parsing all retain server-side ownership checks for `statement_id` / `statement_ids`. + +--- + +## Issues Found (Codex pass) + +### Fixed in this pass + +**[HIGH]** `apps/money-mirror/src/lib/dashboard.ts` — dashboard math was capped to the first 1000 transactions in scope. + +- Impact: Overview totals, advisory inputs, and coaching facts could diverge from Transactions and merchant rollups for larger accounts or broader unified ranges. +- Fix applied: replaced row-limited dashboard math with full-scope SQL aggregates for category totals, debit/credit totals, heuristic signals, and `transaction_count`. + +**[HIGH]** `apps/money-mirror/src/app/api/transactions/backfill-merchant-keys/route.ts` — merchant backfill could loop forever on rows whose `normalizeMerchantKey()` returns `null`. + +- Impact: the route could repeatedly re-read unresolved `merchant_key IS NULL` rows until timeout. +- Fix applied: changed the batch walk to a forward cursor over a stable ordered snapshot and skip updates when the normalized key is `null`. + +**[MEDIUM]** `apps/money-mirror/src/components/ScopeBar.tsx` — scope editor state drifted from the active URL scope. + +- Impact: opening “Edit scope” on an existing unified view showed defaults and could silently overwrite the active range on re-apply. +- Fix applied: hydrate local scope form state from the parsed URL scope whenever the active scope changes. + +### Residual observations + +- Touched-file lint passes: `src/lib/dashboard.ts`, `src/app/api/transactions/backfill-merchant-keys/route.ts`, `src/components/ScopeBar.tsx` +- `npm test` passes. +- `npm run build` starts cleanly but was not observed to completion within this review session, so build status is not claimed as a verified pass from this artifact alone. + +--- + +## Summary + +- Files reviewed deeply in this pass: dashboard aggregation, transactions backfill, unified scope UI, review-stage telemetry/auth surfaces +- CRITICAL issues: 0 +- HIGH issues: 2 (both fixed) +- MEDIUM issues: 1 (fixed) +- LOW issues: 0 +- Codex verdict: **No remaining critical/high blockers from this pass** +- Workflow note: review stage remains open until the PM finishes any additional model review passes and ingests those findings before `/peer-review` + +--- + +## Second Pass — Claude Code (Full Codebase Review) + +**Date:** 2026-04-05 +**Reviewer:** Claude Code (claude-sonnet-4-6) +**Scope:** 26 files across Phase 3 T1–T4. Full-pass review of all new API routes, lib modules, and components. The three Codex fixes (H1 dashboard math, H2 backfill loop, M1 scope URL drift) were confirmed resolved before this pass began. + +--- + +### Looks Clean (second pass) + +- Auth guard on every new route — `getSessionUser()` called before any data access on all 9 new API routes ✓ +- Ownership enforcement in `/api/transactions` — `WHERE user_id = ${user.id}` in both single and multi-statement ownership checks ✓ +- Input validation — date format (YYYY-MM-DD regex), UUID regex, category/type allowlists, search length cap, merchant_key length cap all enforced before DB ✓ +- Atomic write preserved — `persist-statement.ts` fail-closed pattern unchanged ✓ +- Cursor-based backfill pagination — `AND id > cursor ORDER BY id ASC LIMIT 500` terminates correctly ✓ +- PostHog fire-and-forget — all server-side telemetry uses `.catch(() => {})` in hot paths ✓ +- No PostHog dual-emission — `transactions_filter_applied`, `transactions_view_opened`, `scope_changed`, `coaching_facts_expanded`, `merchant_rollup_clicked`, `coaching_narrative_*` are server-side only ✓ +- AbortController in TransactionsPanel — search and filter requests cancel in-flight fetches correctly ✓ +- SessionStorage guard on view-opened — fires once per session, wrapped in try/catch ✓ +- Gemini timeout at 9s — `TIMEOUT_MS = 9_000` with note explaining AbortSignal limitation ✓ +- Zod schema validation on Layer A facts — `buildLayerAFacts` validates every row, full output validated via `layerAFactsSchema.parse()` ✓ +- Citation validation — `validateCitedFactIds` ensures Gemini only cites fact IDs that exist in Layer A ✓ +- No `console.log` in production code (only `console.error`) ✓ +- No `any` types or `@ts-ignore` in reviewed files ✓ +- No hardcoded secrets or API keys ✓ +- Gemini JSON sanitization — strips markdown codeblocks before `JSON.parse` ✓ +- Fan-out worker contract preserved — worker returns HTTP 502 on failure ✓ + +--- + +### Issues Found and Fixed (second pass) + +**[HIGH] `advisory-engine.ts:100` — wrong per-year food delivery annualization for multi-month scopes** + +- Impact: `food_delivery_paisa * 12` on a 3-month unified scope produced a 36× annual estimate (3× inflated); users saw incorrect ₹ figures in advisory cards. +- Fix applied: Dropped "That's ₹X per year" sentence entirely. Message now reads "…this period. Review how much of this was genuine necessity vs convenience." + +**[HIGH] `DashboardClient.tsx:60-95` — `loadDashboard` had no AbortController** + +- Impact: Rapid scope changes (e.g. Last 30d → This month → Last month) could produce a race where a slow Gemini-enriched response from an old scope resolved last and overwrote `result`, `advisories`, and `coachingFacts` with stale data. +- Fix applied: Added `dashboardAbortRef = useRef(null)`. Each `loadDashboard` call aborts the previous in-flight fetch, creates a new controller, passes `{ signal: ac.signal }` to `fetch`, and returns early on `AbortError`. + +**[MEDIUM] Missing `Sentry.captureException` in 4 API catch blocks** + +- Files: `api/dashboard/route.ts`, `api/dashboard/advisories/route.ts`, `api/transactions/route.ts`, `api/transactions/backfill-merchant-keys/route.ts` +- Fix applied: Added `import * as Sentry from '@sentry/nextjs'` and `Sentry.captureException(err)` before `console.error` in each catch block. + +**[MEDIUM] Advisory copy wrong for multi-month unified scopes** + +- `advisory-engine.ts:84` — Subscription headline said `₹X/mo in subscriptions` (`/mo` implied monthly when scope may span months). +- `advisory-engine.ts:111` — NO_INVESTMENT headline said "No investments detected this month" ("this month" wrong for multi-month or arbitrary date ranges). +- Fix applied: Headline → `₹X in subscriptions this period`; NO_INVESTMENT → "No investments detected in this period". + +**[MEDIUM] N+1 UPDATE queries in `backfill-merchant-keys/route.ts`** + +- Impact: 500 normalizable rows in a batch = 500 individual `UPDATE` round-trips. On large accounts this could exhaust the Vercel serverless timeout. +- Fix applied: Accumulates `(ids[], keys[])` arrays for the batch, then issues a single `UPDATE … SET merchant_key = data.key FROM unnest($ids::uuid[], $keys::text[]) AS data(id, key) WHERE …` per batch page. + +**[MEDIUM] `DashboardClient.tsx` — 457 lines (exceeded 300-line limit)** + +- Fix applied: Extracted all state, effects, memos, and handler callbacks to `useDashboardState.ts` hook. `DashboardClient.tsx` is now 159 lines (shell + JSX only). Repeated `ScopeBar + StatementFilters` block extracted to a local `scopeAndFilters` variable. + +**[MEDIUM] `TransactionsPanel.tsx` — 342 lines (exceeded 300-line limit)** + +- Fix applied: Extracted filter UI to `TxnFilterBar.tsx` (111L) and row renderer to `TxnRow.tsx` (61L). `TransactionsPanel.tsx` is now 166 lines. + +**[LOW] Inline `