diff --git a/.github/workflows/pr-auto-review.yml b/.github/workflows/pr-auto-review.yml index 82a47fe..423a4bc 100644 --- a/.github/workflows/pr-auto-review.yml +++ b/.github/workflows/pr-auto-review.yml @@ -7,6 +7,7 @@ on: jobs: assign-reviewers: runs-on: ubuntu-latest + timeout-minutes: 15 # Skip bot-authored PRs entirely if: > github.actor != 'dependabot[bot]' && @@ -32,5 +33,24 @@ jobs: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} run: | - claude --dangerously-skip-permissions \ - -p "Read commands/assign-reviewers.md and execute the full protocol for this PR: ${{ github.event.pull_request.html_url }}" + prompt="Read commands/assign-reviewers.md and execute the full protocol for this PR: ${{ github.event.pull_request.html_url }}" + max_attempts=3 + attempt=1 + + while [ "$attempt" -le "$max_attempts" ]; do + echo "Attempt $attempt/$max_attempts: running assign-reviewers" + if claude --dangerously-skip-permissions -p "$prompt"; then + echo "assign-reviewers completed successfully" + exit 0 + fi + + if [ "$attempt" -lt "$max_attempts" ]; then + sleep_seconds=$((attempt * 30)) + echo "assign-reviewers failed on attempt $attempt; retrying in ${sleep_seconds}s..." + sleep "$sleep_seconds" + fi + attempt=$((attempt + 1)) + done + + echo "assign-reviewers failed after $max_attempts attempts" + exit 1 diff --git a/CHANGELOG.md b/CHANGELOG.md index 742b152..b7f9499 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,116 @@ # Changelog +## 2026-04-07 — peer-review-012 blocker fixes (issue-012) + +- **Privacy contract enforcement (server):** [`apps/money-mirror/src/app/api/guided-review/outcome/route.ts`](apps/money-mirror/src/app/api/guided-review/outcome/route.ts) now forces `commitment_text = NULL` whenever `dismissed = true`, regardless of client payload. Telemetry uses the server-derived value. Regression test added at [`__tests__/route.test.ts`](apps/money-mirror/src/app/api/guided-review/outcome/__tests__/route.test.ts). +- **Cluster rollup truthfulness:** New helper `fetchClusterMerchantAggregates()` in [`dashboard-helpers.ts`](apps/money-mirror/src/lib/dashboard-helpers.ts) runs a full-scope SQL aggregate bound by `merchant_key = ANY(ALL_CLUSTER_MERCHANT_KEYS)` (no LIMIT). [`frequency-clusters/route.ts`](apps/money-mirror/src/app/api/insights/frequency-clusters/route.ts) now feeds cluster rollups from this query while keeping the LIMIT-30 list only as the UI top-merchants preview. Fixes the issue-010-class anti-pattern where cluster totals undercounted whenever a clustered merchant fell outside the top 30. New test in [`frequency-clusters/__tests__/route.test.ts`](apps/money-mirror/src/app/api/insights/frequency-clusters/__tests__/route.test.ts) proves cluster totals include merchants absent from the top-N sample. +- **Scope-keyed SLA timers:** [`DashboardClient.tsx`](apps/money-mirror/src/app/dashboard/DashboardClient.tsx) now resets `mountTimeRef`, `dashboardReadyFiredRef`, and `advisoryReadyFiredRef` whenever `dashboardScopeKey` changes, so `dashboard_ready_ms` and `time_to_first_advisory_ms` re-fire per scope rather than only on first mount. +- **Prompt autopsy applied:** [`agents/backend-engineer-agent.md`](agents/backend-engineer-agent.md) §5 (server-authoritative consent flags), [`agents/backend-architect-agent.md`](agents/backend-architect-agent.md) checklist item 15 (full-scope rollup truthfulness), [`agents/frontend-engineer-agent.md`](agents/frontend-engineer-agent.md) §6 (scope-keyed SLA timers). +- **Verification:** `npm test` 159/159 ✅, `npm run lint` ✅, `npm run build` ✅ (apps/money-mirror). +- **Source:** [`experiments/results/peer-review-012.md`](experiments/results/peer-review-012.md) (BLOCKED → fixes for both blockers + medium). + +--- + +## 2026-04-07 — `/linear-sync plan` issue-012 + +- **Linear:** Project state **Planned**; root [**VIJ-52**](https://linear.app/vijaypmworkspace/issue/VIJ-52/issue-012-gen-z-clarity-loop-emotional-ux-frequency-perf-slas) → **Todo** (Planning); document [issue-012 Plan Snapshot](https://linear.app/vijaypmworkspace/document/issue-012-plan-snapshot-7032c79bc5b0); child tasks **VIJ-53**–**VIJ-64** from [`experiments/plans/manifest-012.json`](experiments/plans/manifest-012.json). +- **Repo:** [`experiments/linear-sync/issue-012.json`](experiments/linear-sync/issue-012.json) task map + `manifest-012.json` `linear.tasks` identifiers. + +--- + +## 2026-04-07 — `/create-plan` issue-012 (Gen Z clarity loop) + +- **Artifacts:** [`experiments/plans/plan-012.md`](experiments/plans/plan-012.md), [`experiments/plans/manifest-012.json`](experiments/plans/manifest-012.json) +- **Themes:** T0 emotional UX + performance-to-insight SLAs; T1 frequency-first + deterministic merchant clusters; T2 guided review + proactive/recap copy; schema addition **`guided_review_outcomes`** (opt-in commitment storage). +- **Linear:** root [**VIJ-52**](https://linear.app/vijaypmworkspace/issue/VIJ-52/issue-012-gen-z-clarity-loop-emotional-ux-frequency-perf-slas); **`/linear-sync plan`** checkpoint to sync PRD + child tasks. +- **Next pipeline step:** `/execute-plan` (start **phase-t0**). + +--- + +## 2026-04-07 — Issue Created: issue-012 + +- **Type**: Enhancement +- **Title**: MoneyMirror — Gen Z clarity loop: emotional UX, frequency-first insights, and performance-to-insight SLAs +- **App**: apps/money-mirror +- **Status**: Discovery +- **Linear**: root [VIJ-52](https://linear.app/vijaypmworkspace/issue/VIJ-52/issue-012-gen-z-clarity-loop-emotional-ux-frequency-perf-slas), project [issue-012 — Gen Z clarity loop](https://linear.app/vijaypmworkspace/project/issue-012-gen-z-clarity-loop-emotional-ux-frequency-perf-slas-1bbe50903d79) +- **Source**: `.cursor/plans/money_mirror_10x_issue_34a61725.plan.md` + +--- + +## 2026-04-07 — issue-011 QA harness (env, tests, README) + +- **App:** `apps/money-mirror` +- **Paywall visibility for local QA:** `.env.local.example` sets `NEXT_PUBLIC_PAYWALL_PROMPT_ENABLED=1` (copy still opt-in per deploy); local `.env.local` can mirror for manual Overview testing (gitignored). +- **Regression script:** `npm run test:issue-011` runs Vitest for P4 advisories, MoM compare, rate limits, user plan, merchant normalize, `POST /api/chat`, compare-months API, and new `__tests__/api/proactive.test.ts` (WhatsApp opt-in stub + push subscription). +- **README:** Testing table + short manual QA note for P4-E thresholds (points to `bad-pattern-signals.ts`). + +--- + +## 2026-04-06 — `/execute-plan` issue-011: P4-D/B/C/H completion (proactive, ingestion trust, chat, hardening) + +- **App:** `apps/money-mirror` +- **Proactive channels (P4-D):** added `POST /api/proactive/whatsapp-opt-in` (auth required, env-gated provider relay, telemetry-only stub fallback) and `POST /api/proactive/push-subscription`. +- **Ingestion trust (P4-B):** Upload panel now shows an explicit **Retry upload** CTA on parse errors; README adds golden PDF regression runbook. +- **Facts-only chat (P4-C):** added `POST /api/chat` with dashboard-scope parsing, Layer A facts grounding, bounded transaction context, strict JSON response parsing, citation validation, and daily chat rate limiting (`chat_query_submitted`, `chat_response_rendered`, `chat_rate_limited`). +- **Hardening (P4-H):** introduced shared `src/lib/rate-limit.ts`; enforced per-user heavy-read limits on `GET /api/dashboard`, `GET /api/transactions`, and `GET /api/insights/merchants`; emits `rate_limit_hit`. +- **Docs/env/tests:** `.env.local.example` now includes `WHATSAPP_API_URL` / `WHATSAPP_API_TOKEN`; README API + analytics tables updated. Added tests: `__tests__/api/chat.test.ts`, `src/lib/__tests__/rate-limit.test.ts`. Validation: `npm --prefix apps/money-mirror test` (105/105), `npm --prefix apps/money-mirror run lint`, `npm --prefix apps/money-mirror run build` (Sentry sourcemap upload warning under sandbox only). + +--- + +## 2026-04-06 — `/execute-plan` issue-011: P4-G (VIJ-46) monetization + Product Hunt hooks + +- **App:** `apps/money-mirror` +- **Schema:** `profiles.plan` (`free` \| `pro`, default `free`); idempotent DDL in `schema.sql` + `schema-upgrades.ts`. +- **API:** `GET /api/dashboard` and `POST /api/statement/parse` responses include `plan`; `normalizeUserPlan` in `src/lib/user-plan.ts`. +- **UI:** `PaywallPrompt` on Overview after the Money Mirror block is visible (IntersectionObserver) when `NEXT_PUBLIC_PAYWALL_PROMPT_ENABLED=1`; dismiss stored in `localStorage`. +- **Telemetry:** client `paywall_prompt_seen`, `upgrade_intent_tapped` via `posthog-browser.ts` (requires `NEXT_PUBLIC_POSTHOG_KEY`). +- **Docs:** README Product Hunt section + analytics table rows; `.env.local.example` documents `NEXT_PUBLIC_PAYWALL_PROMPT_ENABLED`. +- **Tests:** Vitest includes `user-plan.test.ts`. **Next (issue-011):** P4-F / VIJ-47 per `manifest-011.json`. + +--- + +## 2026-04-06 — `/execute-plan` issue-011: P4-E (VIJ-45) bad-pattern detection + +- **App:** `apps/money-mirror` +- **Advisories:** `MICRO_UPI_DRAIN`, `REPEAT_MERCHANT_NOISE`, `CC_MIN_DUE_INCOME_STRESS` in `advisory-engine.ts` (thresholds in `bad-pattern-signals.ts`); dashboard aggregates extended in `dashboard-unified.ts` / `dashboard-legacy.ts` (micro-UPI sums, top repeat `merchant_key`, CC min-due vs scope). +- **API:** `GET /api/transactions?upi_micro=1` filters small UPI debits (≤₹500 with VPA); `transactions_filter_applied` includes `upi_micro` in `filter_types` when set. +- **UI:** `AdvisoryFeed` CTA → `/dashboard?tab=transactions` with scope-preserving `merchant_key` / `upi_micro`; `TxnFilterBar` banner for micro-UPI filter. +- **Telemetry:** `bad_pattern_advisory_shown` / `bad_pattern_advisory_clicked` via `posthog-browser.ts` (requires `NEXT_PUBLIC_POSTHOG_KEY`). Layer A facts: `micro_upi_debit_paisa`, `repeat_merchant_noise_paisa`, `cc_minimum_due_income_ratio`. +- **Tests:** 92 passing. **Next (issue-011):** P4-G / VIJ-46 per `manifest-011.json`. + +--- + +## 2026-04-06 — `/execute-plan` issue-011: P4-A (VIJ-44) merchant + UPI visibility + +- **App:** `apps/money-mirror` +- **Schema:** `transactions.upi_handle`; tables `user_merchant_aliases`, `merchant_label_suggestions` (+ indexes); idempotent DDL in `schema.sql` and `src/lib/schema-upgrades.ts`. +- **Parse:** `extractUpiHandle` + persist on insert (`persist-statement.ts`); `merchant-normalize` exports `formatMerchantKeyForDisplay`. +- **API:** `GET|POST|DELETE /api/merchants/alias`, `POST /api/merchants/suggest-accept`, `GET /api/cron/merchant-enrich` (weekly cron in `vercel.json`); `GET /api/transactions` and merchant rollups join aliases; rollups return `display_label` + optional Gemini suggestion fields. +- **UI:** `TxnRow` UPI chip + alias-aware label; `MerchantRollups` rename modal + “Use AI suggestion”. +- **Telemetry:** `merchant_alias_saved`, `merchant_suggestion_accepted` (server-side). Tests: 87 passing. +- **Docs:** `apps/money-mirror/README.md` updated (endpoints, analytics, schema). **Next (issue-011):** P4-E / VIJ-45 per `manifest-011.json`. + +--- + +## 2026-04-06 — `/create-plan` issue-011 (MoneyMirror Phase 4) + +- **Artifacts:** [`experiments/plans/plan-011.md`](experiments/plans/plan-011.md) (PRD, UX, architecture, DB, AC per epic P4-A–P4-H); [`experiments/plans/manifest-011.json`](experiments/plans/manifest-011.json) (phases, tasks, `posthog_events`, env vars). +- **Linear:** `/linear-sync plan` — project **Planned**; [issue-011 Plan Snapshot](https://linear.app/vijaypmworkspace/document/issue-011-plan-snapshot-f8ddb7ff33ef); child epics **VIJ-44** (P4-A) through **VIJ-51** (P4-C), **VIJ-50** (P4-H); root **VIJ-43** updated. Sync map [`experiments/linear-sync/issue-011.json`](experiments/linear-sync/issue-011.json). +- **State:** [`project-state.md`](project-state.md) — stage `execute-plan`, quality gate `create_plan` done; next `/execute-plan` from **VIJ-44**. + +--- + +## 2026-04-06 — Issue Created: issue-011 + +- **Type**: Enhancement +- **Title**: MoneyMirror Phase 4 — Merchant-native visibility, proactive coaching, and growth readiness +- **App**: apps/money-mirror +- **Status**: Discovery +- **Linear**: Project **issue-011 — MoneyMirror Phase 4 — merchant visibility & growth**; root [**VIJ-43**](https://linear.app/vijaypmworkspace/issue/VIJ-43/issue-011-moneymirror-phase-4-merchant-native-visibility-proactive) + +--- + ## 2026-04-06 — MoneyMirror: E2E documentation pass, `CODEBASE-CONTEXT` refresh, verification **App:** `apps/money-mirror` diff --git a/agents/analytics-agent.md b/agents/analytics-agent.md index f889d06..0323f85 100644 --- a/agents/analytics-agent.md +++ b/agents/analytics-agent.md @@ -149,3 +149,7 @@ Every feature must have measurable outcomes. Avoid vanity metrics. Prioritize metrics tied to user value. + +**Canonical event dictionary is required**: Every metric-plan output must include a final table mapping each metric to (1) canonical implementation event name, (2) single authoritative emitter (client or server), and (3) required properties. Event aliases or intent labels that do not match implemented event IDs must be marked as non-canonical and excluded from final KPI calculations. + +# Added: 2026-04-07 — MoneyMirror issue-012 diff --git a/agents/backend-architect-agent.md b/agents/backend-architect-agent.md index a5f4a8d..d2af3f8 100644 --- a/agents/backend-architect-agent.md +++ b/agents/backend-architect-agent.md @@ -251,19 +251,27 @@ Before finalizing the architecture, answer all of the following. Any gap must be # Added: 2026-04-04 — MoneyMirror Phase 2 -15. **Financial headline metrics (aggregates vs lists)**: For any finance dashboard, advisory pipeline, or AI facts layer: +15. **Full-scope rollup truthfulness**: For any derived financial rollup or cluster shown with exact counts or currency totals, specify a full-scope SQL aggregation strategy. + → LIMIT-capped merchant or transaction lists may drive UI previews, but they must never be reused as the source of displayed rollup totals. + → If cluster/group membership is a static set (e.g., curated keys), bound the aggregate query with `WHERE col = ANY()` instead of LIMIT. + → If the rollup is dynamic, compute it in SQL with `GROUP BY` over the entire scope and present the top-N as a separate query. + → A spec that derives "exact" totals from a top-N sample is a blocking gap — exact-looking numbers must be exact. + + # Added: 2026-04-07 — peer-review-012 fix + +16. **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): +17. **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: +18. **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) diff --git a/agents/backend-engineer-agent.md b/agents/backend-engineer-agent.md index 258490d..54e31cf 100644 --- a/agents/backend-engineer-agent.md +++ b/agents/backend-engineer-agent.md @@ -109,6 +109,8 @@ database errors Explain how errors are handled. +**Privacy / consent flag enforcement (server-authoritative):** When a route accepts a privacy or consent flag (for example `dismissed`, `opt_in`, `save_preference`) alongside optional free text, the server must enforce the contract itself by nullifying or rejecting the text whenever the flag says it should not be stored. Never rely on the client payload to preserve privacy boundaries — derive the persisted value from the flag on the server, then use that derived value in both the DB write and any telemetry properties. + --- ## 6 Security Considerations @@ -179,3 +181,7 @@ Experiment Integrity & Telemetry: Ensure cryptographic salts for A/B testing are Infra gaps discovered at `/deploy-check` are Backend Engineer failures. Ship infra, not just code. # Added: 2026-04-03 — Shift-left infra validation (issue-009 postmortem pattern) + +**Optional foreign IDs on write routes**: For any write endpoint that accepts an optional entity ID (`statement_id`, `order_id`, `session_id`, etc.), validate shape and verify authenticated ownership before any insert/update. If the ID is not owned by the caller, fail closed (`404` or `403`). Add at least one negative test proving cross-user IDs are rejected. + +# Added: 2026-04-07 — MoneyMirror issue-012 diff --git a/agents/code-review-agent.md b/agents/code-review-agent.md index 4e76b45..115db5e 100644 --- a/agents/code-review-agent.md +++ b/agents/code-review-agent.md @@ -151,6 +151,12 @@ For every API route that writes a parent record followed by child records: # Added: 2026-04-05 — MoneyMirror Phase 3 (issue-010) +**Aggregate-to-detail integrity**: For any UI/API drill-through launched from an aggregate row (cluster/category/rollup), verify the downstream filter semantics preserve the full aggregate scope. If implementation maps aggregate navigation to a single representative row, flag as **HIGH**. + +**Completion on non-2xx guard**: For any user flow that displays a completion/success state after a mutation request, verify success is gated on explicit HTTP success (`response.ok` or equivalent). If non-2xx can still show success, flag as **HIGH**. + +# Added: 2026-04-07 — MoneyMirror issue-012 + --- ## 5 Performance Risks diff --git a/agents/deploy-agent.md b/agents/deploy-agent.md index 0618fc8..6570f70 100644 --- a/agents/deploy-agent.md +++ b/agents/deploy-agent.md @@ -95,6 +95,12 @@ performance metrics # Added: 2026-04-05 — MoneyMirror Phase 3 (issue-010) +**ENV optionality source of truth**: Treat `.env.local.example` inline annotations as canonical. A variable is optional only when explicitly marked near that key (for example `# Optional ...` adjacent to the variable). If optional behavior exists in code but annotation is missing, require updating `.env.local.example` in the same cycle. + +**Schema verification fallback**: If MCP schema verification is unavailable (auth/tool outage), attempt direct DB verification using the app's configured DB client and `DATABASE_URL` before blocking. Record the exact `information_schema.tables` evidence in deploy-check output. + +# Added: 2026-04-07 — MoneyMirror issue-012 + --- ## 5 Rollback Plan diff --git a/agents/frontend-engineer-agent.md b/agents/frontend-engineer-agent.md index f87a93e..63ed853 100644 --- a/agents/frontend-engineer-agent.md +++ b/agents/frontend-engineer-agent.md @@ -120,6 +120,8 @@ lazy loading loading states error handling +**Scope-keyed SLA timers:** When a feature introduces product timing SLAs tied to route params or dashboard scope (e.g., `dashboard_ready_ms`, `time_to_first_advisory_ms`), reset the timing origin whenever the canonical scope changes. A one-time measurement on initial mount is insufficient for scope-driven dashboards — emit per-scope timings by keying the reset effect on the scope identifier and clearing one-shot fired refs along with the timer origin. (Added 2026-04-07 — peer-review-012 fix) + --- # Output Format @@ -160,6 +162,12 @@ Browser Storage & Network Safety: Always wrap `JSON.parse` of `localStorage`/`se # Added: 2026-04-05 — MoneyMirror Phase 3 (issue-010) +**Aggregate drill-through fidelity**: When a user taps an aggregate UI item (cluster/category/rollup), the downstream URL/API filter must preserve the full aggregate scope (`merchant_keys[]`, cluster filter, or equivalent). Never substitute a single representative row (`items[0]`) as the drill-through target. + +**Completion-state correctness**: For mutating actions, transition UI to success only when the HTTP response is explicitly successful (`response.ok` or equivalent contract). Non-2xx responses must keep the user in-flow and surface a retryable error state; never show completion on failed persistence. + +# Added: 2026-04-07 — MoneyMirror issue-012 + **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/apps/money-mirror/CODEBASE-CONTEXT.md b/apps/money-mirror/CODEBASE-CONTEXT.md index d0acb5d..e234db2 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-06 (post–issue-010 hardening: schema upgrades, SCHEMA_DRIFT, Web Vitals, E2E) +Last updated: 2026-04-07 (issue-012 learning closeout: frequency clusters, guided review outcomes, deploy/postmortem hardening) ## What This App Does @@ -10,72 +10,82 @@ MoneyMirror is a mobile-first PWA AI financial coach for Gen Z Indians (₹20K - **Frontend**: Next.js 16 App Router (RSC by default, `"use client"` for interactive panels). `next/font` loads Inter + Space Grotesk (see `src/app/layout.tsx`). Optional client `WebVitalsReporter` sends Core Web Vitals to PostHog when `NEXT_PUBLIC_POSTHOG_KEY` / `NEXT_PUBLIC_POSTHOG_HOST` are set. 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. +- **Database**: Neon Postgres. Core tables: `profiles`, `statements`, `transactions`, `advisory_feed`; Phase 4 P4-A adds `user_merchant_aliases`, `merchant_label_suggestions`, and `transactions.upi_handle`; issue-012 adds `guided_review_outcomes` for privacy-minimal guided review persistence (see `schema.sql` / `schema-upgrades.ts`). `profiles` persists monthly income and perceived spend and **P4-G** `plan` (`free` \| `pro`, default `free`); `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, (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. - **Schema upgrades**: Idempotent DDL lives in `src/lib/schema-upgrades.ts` (`applyIdempotentSchemaUpgrades`) and is invoked by (1) `npm run db:upgrade` → `scripts/apply-schema-upgrades.ts`, (2) `runAutoSchemaUpgradeOnBoot` from `src/instrumentation.ts` on Node server start when `DATABASE_URL` is set (skip with `MONEYMIRROR_SKIP_AUTO_SCHEMA=1`). Keeps older Neon DBs aligned with `schema.sql` tail (e.g. `transactions.merchant_key`, statement label columns). -- **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. +- **Analytics**: PostHog server-side (`posthog-node`) plus optional client (`posthog-js` when `NEXT_PUBLIC_POSTHOG_KEY` set) for CWV, bad-pattern advisory engagement, and P4-G `paywall_prompt_seen` / `upgrade_intent_tapped` from `PaywallPrompt`. Core server 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/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` | `attachCoachingFactsOnly` for fast `GET /api/dashboard`; `attachCoachingLayer` for `GET /api/dashboard/advisories` — Gemini 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. | -| `src/lib/schema-upgrades.ts` | Shared idempotent `ALTER` / `CREATE INDEX` for DBs missing Phase 2/3 columns; keep in sync with `schema.sql` tail. | -| `scripts/apply-schema-upgrades.ts` | CLI entry for `npm run db:upgrade` (uses `main()`, not top-level `await`, for `tsx` compatibility). | -| `src/lib/run-schema-upgrade-on-boot.ts` | Called from `instrumentation.ts` to apply the same DDL once per boot when `DATABASE_URL` is set. | -| `src/lib/pg-errors.ts` | `isUndefinedColumnError` (Postgres `42703` / message match); `SCHEMA_UPGRADE_HINT` for JSON error `detail` when routes detect schema drift. | -| `src/components/WebVitalsReporter.tsx` | Client-only `web_vital` reporting to PostHog when public PostHog keys are configured. | -| `playwright.config.ts` + `e2e/` | Smoke E2E: production build + static server on port 3333; hits `/` and `/login`. Run via `npm run test:e2e`. | +| 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). **P4-E:** `MICRO_UPI_DRAIN`, `REPEAT_MERCHANT_NOISE`, `CC_MIN_DUE_INCOME_STRESS` with optional `cta` for Transactions deep link. Thresholds in `bad-pattern-signals.ts`. | +| `src/lib/bad-pattern-signals.ts` | Deterministic paisa/count thresholds for P4-E bad-pattern advisories. | +| `src/lib/posthog-browser.ts` | Lazy `posthog-js` client + `bad_pattern_advisory_*` event names (Insights `AdvisoryFeed`). | +| `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` | `attachCoachingFactsOnly` for fast `GET /api/dashboard`; `attachCoachingLayer` for `GET /api/dashboard/advisories` — Gemini 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. | +| `src/lib/schema-upgrades.ts` | Shared idempotent `ALTER` / `CREATE INDEX` for DBs missing Phase 2/3 columns; keep in sync with `schema.sql` tail. | +| `scripts/apply-schema-upgrades.ts` | CLI entry for `npm run db:upgrade` (uses `main()`, not top-level `await`, for `tsx` compatibility). | +| `src/lib/run-schema-upgrade-on-boot.ts` | Called from `instrumentation.ts` to apply the same DDL once per boot when `DATABASE_URL` is set. | +| `src/lib/pg-errors.ts` | `isUndefinedColumnError` (Postgres `42703` / message match); `SCHEMA_UPGRADE_HINT` for JSON error `detail` when routes detect schema drift. | +| `src/components/WebVitalsReporter.tsx` | Client-only `web_vital` reporting to PostHog when public PostHog keys are configured. | +| `src/lib/merchant-clusters.ts` | Deterministic cluster registry (quick commerce, food delivery, entertainment, transport, shopping) used for frequency-first insights and cluster drill-through semantics. | +| `src/app/api/insights/frequency-clusters/route.ts` | Issue-012 frequency endpoint: top debit-frequency merchants + full-scope cluster aggregates; enforces auth, scope validation, and rate limits. | +| `src/components/GuidedReviewSheet.tsx` | Issue-012 3-step guided review UI with retry-safe non-2xx handling and event instrumentation (`guided_review_started`, completion/save flows). | +| `playwright.config.ts` + `e2e/` | Smoke E2E: production build + static server on port 3333; hits `/` and `/login`. Run via `npm run test:e2e`. | ## 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). Optional `merchant_key` (TEXT) for rollups; set on insert via `normalizeMerchantKey(description)` in `persist-statement.ts`. +- **transactions**: Many per statement. All amounts in paisa (BIGINT). `category` CHECK: `needs | wants | investment | debt | other` (lowercase). Optional `merchant_key` (TEXT) for rollups; optional `upi_handle` (TEXT) when VPA detected; set on insert via `merchant-normalize` 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 | Fast path: Layer A `coaching_facts`, rule-based advisories (no Gemini). 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. On undefined-column errors, may return **500** with `code: SCHEMA_DRIFT` and `detail` hinting `npm run db:upgrade` / boot DDL | -| GET | `/api/insights/merchants` | Neon session cookie | Top merchants (debit rollups) for same scope as transactions. Same **SCHEMA_DRIFT** behavior as transactions when columns are missing | -| 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 | Gemini coaching narratives; 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 | +| 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 | Fast path: Layer A `coaching_facts`, rule-based advisories (no Gemini). 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. On undefined-column errors, may return **500** with `code: SCHEMA_DRIFT` and `detail` hinting `npm run db:upgrade` / boot DDL | +| GET | `/api/insights/merchants` | Neon session cookie | Top merchants (debit rollups) for same scope as transactions; includes `display_label` and optional Gemini `suggested_*` fields. Same **SCHEMA_DRIFT** behavior as transactions when columns are missing | +| GET/POST/DELETE | `/api/merchants/alias` | Neon session cookie | List / upsert / delete user display labels per `merchant_key` (`merchant_alias_saved` on POST) | +| POST | `/api/merchants/suggest-accept` | Neon session cookie | Apply stored Gemini suggestion to alias (`merchant_suggestion_accepted`) | +| GET | `/api/cron/merchant-enrich` | Cron secret | Weekly batch: Gemini label suggestions into `merchant_label_suggestions` (not on upload path) | +| POST | `/api/insights/merchant-click` | Neon session cookie | `merchant_rollup_clicked` PostHog (bucketed key) | +| GET | `/api/insights/frequency-clusters` | Neon session cookie | Issue-012 frequency-first endpoint: debit-frequency leaders + deterministic cluster rollups with aggregate-safe merchant key sets | +| POST | `/api/guided-review/outcome` | Neon session cookie | Persist guided review completion (`dismissed` or optional commitment save) with server-enforced ownership/privacy contracts | +| 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 | Gemini coaching narratives; 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 @@ -96,7 +106,7 @@ MoneyMirror is a mobile-first PWA AI financial coach for Gen Z Indians (₹20K - 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. +- **P4-H**: Per-user in-memory rate limits added to all heavy-read routes: `GET /api/dashboard` (40 req/60 s), `GET /api/insights/merchants` (40 req/60 s), `GET /api/transactions` (60 req/60 s), `POST /api/chat` (10 req/day). All limits reset on cold start / process restart — they are best-effort throttles, not durable quotas. Multi-instance Vercel deployments may allow up to N×limit before a shared Redis layer is added. - 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 (includes merchant rollup reconciliation unit tests). diff --git a/apps/money-mirror/README.md b/apps/money-mirror/README.md index 466016d..b1e75e5 100644 --- a/apps/money-mirror/README.md +++ b/apps/money-mirror/README.md @@ -51,25 +51,28 @@ cp .env.local.example .env.local Fill in these values: -| Variable | Required | Description | -| ------------------------------ | -------- | ------------------------------------------------------------------------------------------------------------------------------- | -| `DATABASE_URL` | Yes | Neon Postgres connection string | -| `NEON_AUTH_BASE_URL` | Yes | Base URL for your Neon Auth project | -| `NEON_AUTH_COOKIE_SECRET` | No | Optional only if Neon explicitly gives one for your project/runtime | -| `GEMINI_API_KEY` | Yes | Google AI Studio API key | -| `RESEND_API_KEY` | Yes | Resend API key | -| `POSTHOG_KEY` | Yes | Server-side PostHog key | -| `POSTHOG_HOST` | Yes | PostHog host URL | -| `NEXT_PUBLIC_POSTHOG_KEY` | No | Same key as `POSTHOG_KEY` — enables client `web_vital` (CWV) events | -| `NEXT_PUBLIC_POSTHOG_HOST` | No | Defaults to `https://app.posthog.com` if unset | -| `NEXT_PUBLIC_APP_URL` | Yes | Public app URL used in recap links | -| `CRON_SECRET` | Yes | Shared secret for cron routes | -| `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 | -| `MONEYMIRROR_SKIP_AUTO_SCHEMA` | No | Set to `1` to skip automatic idempotent DDL on server boot (`instrumentation.ts`); default is to run when `DATABASE_URL` is set | +| Variable | Required | Description | +| ------------------------------------ | -------- | ------------------------------------------------------------------------------------------------------------------------------------- | +| `DATABASE_URL` | Yes | Neon Postgres connection string | +| `NEON_AUTH_BASE_URL` | Yes | Base URL for your Neon Auth project | +| `NEON_AUTH_COOKIE_SECRET` | No | Optional only if Neon explicitly gives one for your project/runtime | +| `GEMINI_API_KEY` | Yes | Google AI Studio API key | +| `RESEND_API_KEY` | Yes | Resend API key | +| `POSTHOG_KEY` | Yes | Server-side PostHog key | +| `POSTHOG_HOST` | Yes | PostHog host URL | +| `NEXT_PUBLIC_POSTHOG_KEY` | No | Same key as `POSTHOG_KEY` — enables client `web_vital` (CWV) events | +| `NEXT_PUBLIC_POSTHOG_HOST` | No | Defaults to `https://app.posthog.com` if unset | +| `NEXT_PUBLIC_PAYWALL_PROMPT_ENABLED` | No | `.env.local.example` sets `1` so local QA sees the Phase 4 soft paywall on Overview; unset or `0` in production if you do not want it | +| `NEXT_PUBLIC_APP_URL` | Yes | Public app URL used in recap links | +| `CRON_SECRET` | Yes | Shared secret for cron routes | +| `WHATSAPP_API_URL` | No | Optional webhook endpoint for WhatsApp opt-in relay (P4-D spike); when unset, endpoint runs in telemetry-only stub mode | +| `WHATSAPP_API_TOKEN` | No | Optional bearer token for WhatsApp relay endpoint | +| `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 | +| `MONEYMIRROR_SKIP_AUTO_SCHEMA` | No | Set to `1` to skip automatic idempotent DDL on server boot (`instrumentation.ts`); default is to run when `DATABASE_URL` is set | ### 3. Create Neon project and enable Neon Auth @@ -102,6 +105,8 @@ Tables created: - `statements` - `transactions` - `advisory_feed` +- `user_merchant_aliases` — user-defined display label per `merchant_key` (Phase 4 P4-A) +- `merchant_label_suggestions` — optional async Gemini label suggestions (Phase 4 P4-A; cron-filled) Indexes created: @@ -109,9 +114,10 @@ Indexes created: - `idx_transactions_user_statement` - `idx_transactions_user_date` (user_id, date DESC) - `idx_transactions_user_merchant` (partial) where `merchant_key` is not null +- `idx_transactions_user_upi` (partial) where `upi_handle` 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). +Transaction rows include optional `merchant_key` (heuristic normalization for rollups; see `src/lib/merchant-normalize.ts`) and optional **`upi_handle`** (extracted VPA when the description matches UPI patterns). New parses persist both on insert; existing rows can be backfilled for `merchant_key` via `POST /api/transactions/backfill-merchant-keys` (authenticated). Re-upload or a future backfill can populate `upi_handle` for older rows. ### 5. Run locally @@ -137,14 +143,17 @@ First-run failure looks like: ## Testing -| Command | What it runs | -| ------------------ | -------------------------------------------------------------------------- | -| `npm run test` | Vitest — API routes, libs, parsers | -| `npm run test:e2e` | Playwright — builds, serves on port **3333**, smoke-tests `/` and `/login` | +| Command | What it runs | +| ------------------------ | ---------------------------------------------------------------------------------------------------------------- | +| `npm run test` | Vitest — API routes, libs, parsers | +| `npm run test:issue-011` | Vitest — P4 regression (bad-pattern advisories, MoM compare, rate limits, paywall helpers, chat, proactive APIs) | +| `npm run test:e2e` | Playwright — builds, serves on port **3333**, smoke-tests `/` and `/login` | + +**Manual QA (issue-011 / P4-E):** Bad-pattern cards (`MICRO_UPI_DRAIN`, `REPEAT_MERCHANT_NOISE`, `CC_MIN_DUE_INCOME_STRESS`) only appear when aggregates in [`src/lib/bad-pattern-signals.ts`](./src/lib/bad-pattern-signals.ts) are met (e.g. micro-UPI debit total ≥ ₹1,500, or ≥15 micro-UPI debits, or repeat-merchant noise thresholds). Upload statements with enough small UPI debits / repeat merchants / CC min-due vs income, or run `npm run test:issue-011` to assert engine logic. First-time E2E setup: `npx playwright install chromium`. See [`docs/PERFORMANCE-REVIEW.md`](./docs/PERFORMANCE-REVIEW.md) for Lighthouse and performance notes. -Optional: set `NEXT_PUBLIC_POSTHOG_KEY` (same project as `POSTHOG_KEY`) to send **Core Web Vitals** (`web_vital` events) from the browser. +Optional: set `NEXT_PUBLIC_POSTHOG_KEY` (same project as `POSTHOG_KEY`) to send **Core Web Vitals** (`web_vital` events) and **bad-pattern advisory** engagement (`bad_pattern_advisory_shown` / `bad_pattern_advisory_clicked`) from the browser. ## API @@ -222,6 +231,43 @@ Same query parameters as `GET /api/dashboard`. Returns `{ advisories, coaching_f **Auth**: Neon Auth session cookie required. +### `GET /api/dashboard/compare-months` + +Returns scope-aligned **current vs previous-period** totals for debits and credits. The previous window is automatically computed to match the current window length. + +**Auth**: Neon Auth session cookie required. + +**Query (same scope model as dashboard):** + +- **Legacy (single statement):** `?statement_id=` optional — if omitted, uses the latest processed statement and its `period_start`/`period_end`. +- **Unified scope:** `?date_from=YYYY-MM-DD&date_to=YYYY-MM-DD` plus optional `statement_ids=`. + +**Returns**: `{ scope, current, previous, delta }` where `delta` includes absolute paisa change and percentage change (`null` when previous is 0). + +### `POST /api/chat` + +Facts-only chat over the active dashboard scope. The backend composes Layer A facts plus a bounded recent-transaction context window, then returns a concise answer with cited fact IDs. + +**Auth**: Neon Auth session cookie required. + +**Query**: same scope model as `GET /api/dashboard` (`statement_id` legacy or `date_from` + `date_to` + optional `statement_ids`). + +**Body**: + +```json +{ "message": "Where am I overspending this month?" } +``` + +**Returns**: + +```json +{ + "answer": "Your wants share is elevated versus needs in this scope. Focus on recurring low-value debits first.", + "cited_fact_ids": ["wants_paisa", "discretionary_paisa"], + "facts": { "version": 1, "generated_at": "..." } +} +``` + ### `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). @@ -246,19 +292,20 @@ Paginated transactions for the authenticated user. Joins `statements` for nickna **Query params** (all optional except pagination defaults): -| 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 }`. +| 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 | +| `upi_micro` | `1` = only **debit** rows with a non-empty `upi_handle` and amount ≤ ₹500 (same cap as dashboard micro-UPI signal); omit or invalid value → **400** | + +**Returns**: `{ transactions, total, limit, offset }`. Each transaction includes `upi_handle` (nullable) and `merchant_alias_label` (nullable; from `user_merchant_aliases` when set). ### `POST /api/transactions/view-opened` @@ -292,7 +339,51 @@ Top merchants by **debit** spend for the current user, scoped like `GET /api/tra | `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). +**Returns**: `{ merchants: [{ merchant_key, display_label, debit_paisa, txn_count, suggested_label, suggestion_confidence }], scope_total_debit_paisa, keyed_debit_paisa }` — `display_label` prefers the user alias, else a formatted `merchant_key`. `suggested_label` / `suggestion_confidence` are set when the async merchant-enrich cron has stored a Gemini suggestion (see `GET /api/cron/merchant-enrich`). `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). + +### `GET /api/merchants/alias` + +Lists all saved merchant display labels for the current user. + +**Auth**: Neon Auth session cookie required. + +**Returns**: `{ aliases: [{ merchant_key, display_label, updated_at }] }`. + +### `POST /api/merchants/alias` + +Upserts a display label for a normalized `merchant_key`. + +**Auth**: Neon Auth session cookie required. + +**Body**: `{ "merchant_key": string, "display_label": string }` (1–128 / 1–120 chars after trim). + +**Returns**: `{ ok: true, merchant_key, display_label }`. Fires PostHog `merchant_alias_saved` (server-side, fire-and-forget). + +### `DELETE /api/merchants/alias?merchant_key=` + +Removes a user-defined label for that key (UI falls back to formatted `merchant_key`). + +**Auth**: Neon Auth session cookie required. + +**Returns**: `{ ok: true }`. + +### `POST /api/merchants/suggest-accept` + +Copies a stored Gemini suggestion into `user_merchant_aliases` for the given key. + +**Auth**: Neon Auth session cookie required. + +**Body**: `{ "merchant_key": string }`. + +**Returns**: `{ ok: true, merchant_key, display_label }`. Fires PostHog `merchant_suggestion_accepted` (server-side, fire-and-forget). + +### `GET /api/cron/merchant-enrich` + +Batch job (scheduled weekly in [`vercel.json`](./vercel.json)) that creates `merchant_label_suggestions` rows for `merchant_key` values missing suggestions. Requires `GEMINI_API_KEY`; otherwise returns `{ skipped: true }`. **Not on the statement upload path.** + +**Auth**: `authorization: Bearer ` or `x-cron-secret: `. + +**Returns**: `{ ok: true, processed: [{ user_id, merchant_key, status }] }`. ### `POST /api/insights/merchant-click` @@ -329,6 +420,72 @@ Sends one recap email for one user. { "userId": "user-id" } ``` +### `POST /api/proactive/whatsapp-opt-in` + +Captures explicit WhatsApp opt-in for the authenticated user. + +**Auth**: Neon Auth session cookie required. + +**Body**: + +```json +{ "phone_e164": "+919876543210" } +``` + +If `WHATSAPP_API_URL` and `WHATSAPP_API_TOKEN` are configured, the route forwards an opt-in payload to that provider endpoint. Otherwise it returns `mode: "stub"` and records telemetry only. + +### `POST /api/proactive/push-subscription` + +Captures successful web-push subscription grants from the client. + +**Auth**: Neon Auth session cookie required. + +**Body**: + +```json +{ "endpoint": "https://push.example/...", "user_agent": "Mozilla/5.0 ..." } +``` + +### `GET /api/insights/frequency-clusters` + +Returns top merchants by debit frequency and deterministic cluster rollups (quick commerce, food delivery, etc.) for the given scope. + +**Auth**: Neon Auth session cookie required. + +**Query params**: `date_from`, `date_to` (required; unified scope only), `statement_ids` (optional comma-separated UUIDs). + +**Response**: + +```json +{ + "top_merchants": [{ "merchant_key": "zomato", "debit_count": 15, "debit_paisa": 120000 }], + "clusters": [ + { + "cluster": { "id": "food_delivery", "label": "Food delivery" }, + "totalDebitPaisa": 120000, + "debitCount": 15, + "merchantKeys": ["zomato"] + } + ] +} +``` + +### `POST /api/guided-review/outcome` + +Saves a guided review outcome (dismissed or optional saved commitment). Commitment text is opt-in only. + +**Auth**: Neon Auth session cookie required. + +**Body**: + +```json +{ + "dismissed": true, + "statement_id": "uuid-or-null", + "commitment_text": "optional string (max 500)" +} +``` + ### `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`. @@ -341,26 +498,58 @@ Intentional error route to verify Sentry server-side capture (`SentryExampleAPIE ## Analytics -| Event | Where | Properties | -| ------------------------------ | ---------------------------------------------- | ------------------------------------------------------------------------------------- | -| `onboarding_completed` | `/api/onboarding/complete` | `money_health_score`, `perceived_spend_paisa` | -| `statement_parse_started` | `/api/statement/parse` | `pdf_text_length` | -| `statement_parse_rate_limited` | `/api/statement/parse` | `uploads_today`, `limit` | -| `statement_parse_success` | `/api/statement/parse` | `latency_ms`, `transaction_count`, `period_start`, `period_end`, `total_debits_paisa` | -| `statement_parse_timeout` | `/api/statement/parse` | `timeout_ms` | -| `statement_parse_failed` | `/api/statement/parse` and persistence helpers | `error_type`, optional context | -| `weekly_recap_triggered` | `/api/cron/weekly-recap` | `user_count` | -| `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` | +| Event | Where | Properties | +| ------------------------------ | --------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------- | +| `onboarding_completed` | `/api/onboarding/complete` | `money_health_score`, `perceived_spend_paisa` | +| `statement_parse_started` | `/api/statement/parse` | `pdf_text_length` | +| `statement_parse_rate_limited` | `/api/statement/parse` | `uploads_today`, `limit` | +| `statement_parse_success` | `/api/statement/parse` | `latency_ms`, `transaction_count`, `period_start`, `period_end`, `total_debits_paisa` | +| `statement_parse_timeout` | `/api/statement/parse` | `timeout_ms` | +| `statement_parse_failed` | `/api/statement/parse` and persistence helpers | `error_type`, optional context | +| `weekly_recap_triggered` | `/api/cron/weekly-recap` | `user_count` | +| `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` | +| `merchant_alias_saved` | `POST /api/merchants/alias` | `merchant_key_bucket` | +| `merchant_suggestion_accepted` | `POST /api/merchants/suggest-accept` | `merchant_key_bucket`, optional `confidence` | +| `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` | +| `bad_pattern_advisory_shown` | Insights `AdvisoryFeed` (client, `posthog-js` when `NEXT_PUBLIC_POSTHOG_KEY` set) | `trigger`, `advisory_id` — once per trigger per mount | +| `bad_pattern_advisory_clicked` | Insights `AdvisoryFeed` CTA → Transactions (client) | `trigger`, `advisory_id`, `preset` (`micro_upi` \| `merchant_key` \| `scope_only`) | +| `paywall_prompt_seen` | Overview `PaywallPrompt` (client, when `NEXT_PUBLIC_PAYWALL_PROMPT_ENABLED=1` and mirror section visible) | `surface` (`overview_mirror`) — once per browser session | +| `upgrade_intent_tapped` | Overview `PaywallPrompt` primary CTA (client) | `surface` (`overview_mirror`) | +| `whatsapp_opt_in_completed` | `POST /api/proactive/whatsapp-opt-in` | `provider_configured`, `country_code` | +| `chat_query_submitted` | `POST /api/chat` | `message_length`, `txn_context_count`, `scope_kind` | +| `chat_response_rendered` | `POST /api/chat` | `latency_ms`, `cited_fact_count` | +| `chat_rate_limited` | `POST /api/chat` | `retry_after_sec` | +| `rate_limit_hit` | Heavy read endpoints (`/api/dashboard`, `/api/transactions`, `/api/insights/merchants`) | `route`, `retry_after_sec` | +| `push_subscription_granted` | `POST /api/proactive/push-subscription` | `endpoint_hash`, `user_agent` | + +## Golden PDF regression + +Keep known-good statement fixtures in `apps/money-mirror/__tests__/fixtures/` and run targeted parse regressions before parser changes: + +```bash +npm --prefix apps/money-mirror test -- __tests__/api/parse.test.ts +``` + +## Product Hunt launch hooks + +Use this checklist when preparing a Product Hunt submission (complements [`experiments/results/production-launch-checklist-010.md`](../../experiments/results/production-launch-checklist-010.md)): + +| Asset / field | Suggested source | +| ----------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | +| **Tagline** | “Statement-native Money Mirror for India — see where your salary actually goes.” | +| **Link** | Production URL: `https://money-mirror-rho.vercel.app` (update if custom domain). | +| **Gallery** | 3–5 screenshots: login → upload PDF → Overview (Money Mirror + category breakdown) → Insights advisory → Transactions with merchant/UPI. | +| **First comment** | Short founder story: Gen Z India, PDF-only privacy, merchant + UPI legibility, no investment advice; invite feedback on bank coverage. | +| **Demo video** | 30–60s screen recording: OTP → one bank PDF → Overview mirror + one tap to Transactions filtered by advisory. | ## Key design decisions @@ -392,11 +581,18 @@ Intentional error route to verify Sentry server-side capture (`SentryExampleAPIE - 5 advisory triggers + expanded categorizer - Weekly recap email via Resend (Monday 8:00 AM IST Vercel cron) - 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) +- Phase 4 P4-A: `transactions.upi_handle`, `user_merchant_aliases`, `merchant_label_suggestions`; merchant rename + UPI chips in UI; async `GET /api/cron/merchant-enrich` for Gemini label suggestions (non-blocking vs uploads) +- Phase 4 P4-E: deterministic bad-pattern advisories (`MICRO_UPI_DRAIN`, `REPEAT_MERCHANT_NOISE`, `CC_MIN_DUE_INCOME_STRESS`) in `advisory-engine.ts`; Transactions deep link via `upi_micro=1` or `merchant_key`; client PostHog for bad-pattern CTA +- Phase 4 P4-G: `profiles.plan` (`free` \| `pro`, default `free`); soft paywall prompt on Overview when `NEXT_PUBLIC_PAYWALL_PROMPT_ENABLED=1`; client PostHog `paywall_prompt_seen` / `upgrade_intent_tapped` +- 15+ PostHog analytics events (server + optional client for CWV, bad-pattern engagement, and optional paywall intent) +- Issue-012 T0: Skeleton-first dashboard loading; performance marks (`dashboard_ready_ms`, `time_to_first_advisory_ms`); progressive disclosure for month-compare; shame-safe empty/loading copy; `COACHING-TONE.md` Gen Z / income-transition subsection +- Issue-012 T1: Deterministic merchant cluster mapping (`src/lib/merchant-clusters.ts`: quick_commerce, food_delivery, entertainment, transport, shopping); `GET /api/insights/frequency-clusters`; frequency + cluster UI on Insights tab; `frequency_insight_opened` / `merchant_cluster_clicked` events +- Issue-012 T2: `guided_review_outcomes` table; `POST /api/guided-review/outcome`; 3-step `GuidedReviewSheet` (acknowledge → optional commitment → finish); `guided_review_started` / `guided_review_completed` / `commitment_saved` events; fact-specific weekly recap email copy + +**Schema migration (issue-012):** Run `npm run db:upgrade` or restart dev server to create the `guided_review_outcomes` table on existing Neon DBs. -**Not shipped (Sprint 4 backlog):** +**Not shipped (backlog):** -- Spend-trend comparison across months (F3) - Multi-account aggregated spend view (G2–G3) - In-app coaching tone personalization (H3) - Inbox ingestion from email, WhatsApp/WATI delivery, gamification, Warikoo Priority Ladder goal gating diff --git a/apps/money-mirror/__tests__/api/chat.test.ts b/apps/money-mirror/__tests__/api/chat.test.ts new file mode 100644 index 0000000..7974e07 --- /dev/null +++ b/apps/money-mirror/__tests__/api/chat.test.ts @@ -0,0 +1,99 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { NextRequest } from 'next/server'; + +const mockGetSessionUser = vi.fn(); +const mockEnsureProfile = vi.fn(); +const mockFetchDashboardData = vi.fn(); +const mockBuildLayerAFacts = vi.fn(); +const mockFactIdsFromLayerA = vi.fn(); +const mockSerializeFactsForPrompt = vi.fn(); +const mockCaptureServerEvent = vi.fn(); +const mockCheckRateLimit = vi.fn(); +const mockGenerateContent = vi.fn(); + +vi.mock('@/lib/auth/session', () => ({ getSessionUser: mockGetSessionUser })); +vi.mock('@/lib/db', () => ({ ensureProfile: mockEnsureProfile, getDb: () => vi.fn() })); +vi.mock('@/lib/dashboard', () => ({ fetchDashboardData: mockFetchDashboardData })); +vi.mock('@/lib/coaching-facts', () => ({ + buildLayerAFacts: mockBuildLayerAFacts, + factIdsFromLayerA: mockFactIdsFromLayerA, + serializeFactsForPrompt: mockSerializeFactsForPrompt, +})); +vi.mock('@/lib/posthog', () => ({ captureServerEvent: mockCaptureServerEvent })); +vi.mock('@/lib/rate-limit', () => ({ checkRateLimit: mockCheckRateLimit })); +vi.mock('@google/genai', () => ({ + GoogleGenAI: class { + models = { generateContent: mockGenerateContent }; + }, +})); + +async function getPost() { + const mod = await import('@/app/api/chat/route'); + return mod.POST; +} + +function makeRequest(url: string, body: unknown) { + return new NextRequest(url, { + method: 'POST', + body: JSON.stringify(body), + headers: { 'content-type': 'application/json' }, + }); +} + +describe('POST /api/chat', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockGetSessionUser.mockResolvedValue({ id: 'u1', email: 'u@example.com', name: 'User' }); + mockEnsureProfile.mockResolvedValue(undefined); + mockCheckRateLimit.mockReturnValue({ ok: true, remaining: 9, resetAt: Date.now() + 1_000 }); + mockFetchDashboardData.mockResolvedValue({ + scope: { + kind: 'single_statement', + included_statement_ids: ['11111111-1111-4111-8111-111111111111'], + }, + }); + mockBuildLayerAFacts.mockReturnValue({ facts: [{ id: 'total_debits_paisa' }] }); + mockFactIdsFromLayerA.mockReturnValue(new Set(['total_debits_paisa'])); + mockSerializeFactsForPrompt.mockReturnValue('[]'); + mockCaptureServerEvent.mockResolvedValue(undefined); + mockGenerateContent.mockResolvedValue({ + candidates: [ + { + content: { + parts: [ + { + text: '{"answer":"You spent more on wants this period.","cited_fact_ids":["total_debits_paisa"]}', + }, + ], + }, + }, + ], + }); + }); + + it('returns 401 when unauthenticated', async () => { + mockGetSessionUser.mockResolvedValueOnce(null); + const POST = await getPost(); + const res = await POST(makeRequest('http://localhost/api/chat', { message: 'hello' })); + expect(res.status).toBe(401); + }); + + it('returns 400 on missing message', async () => { + const POST = await getPost(); + const res = await POST(makeRequest('http://localhost/api/chat', { message: '' })); + expect(res.status).toBe(400); + }); + + it('returns 429 when chat rate limit is exceeded', async () => { + mockCheckRateLimit.mockReturnValueOnce({ + ok: false, + retryAfterSec: 120, + resetAt: Date.now() + 120_000, + }); + const POST = await getPost(); + const res = await POST( + makeRequest('http://localhost/api/chat', { message: 'how much did I spend?' }) + ); + expect(res.status).toBe(429); + }); +}); diff --git a/apps/money-mirror/__tests__/api/compare-months.test.ts b/apps/money-mirror/__tests__/api/compare-months.test.ts new file mode 100644 index 0000000..2095eec --- /dev/null +++ b/apps/money-mirror/__tests__/api/compare-months.test.ts @@ -0,0 +1,99 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { NextRequest } from 'next/server'; + +const mockGetSessionUser = vi.fn(); +const mockFetchCompareMonthsData = vi.fn(); +const mockCaptureException = vi.fn(); + +vi.mock('@/lib/auth/session', () => ({ + getSessionUser: mockGetSessionUser, +})); + +vi.mock('@/lib/dashboard-compare', () => ({ + fetchCompareMonthsData: mockFetchCompareMonthsData, +})); + +vi.mock('@sentry/nextjs', () => ({ + captureException: mockCaptureException, +})); + +async function getGet() { + const mod = await import('@/app/api/dashboard/compare-months/route'); + return mod.GET; +} + +function makeRequest(url: string) { + return new NextRequest(url); +} + +describe('GET /api/dashboard/compare-months', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockGetSessionUser.mockResolvedValue({ + id: 'user-123', + email: 'u@example.com', + name: 'User', + }); + mockFetchCompareMonthsData.mockResolvedValue({ + current: { total_debits_paisa: 120000, total_credits_paisa: 200000 }, + previous: { total_debits_paisa: 100000, total_credits_paisa: 180000 }, + delta: { + debits_paisa: 20000, + credits_paisa: 20000, + debits_pct: 20, + credits_pct: 11.11, + }, + }); + }); + + it('returns 401 when unauthenticated', async () => { + mockGetSessionUser.mockResolvedValueOnce(null); + const GET = await getGet(); + const res = await GET(makeRequest('http://localhost/api/dashboard/compare-months')); + expect(res.status).toBe(401); + }); + + it('returns 400 when date range query is incomplete', async () => { + const GET = await getGet(); + const res = await GET( + makeRequest('http://localhost/api/dashboard/compare-months?date_from=2026-03-01') + ); + expect(res.status).toBe(400); + }); + + it('returns 200 for a valid compare request', async () => { + const GET = await getGet(); + const res = await GET( + makeRequest( + 'http://localhost/api/dashboard/compare-months?date_from=2026-03-01&date_to=2026-03-31' + ) + ); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.current.total_debits_paisa).toBe(120000); + expect(mockFetchCompareMonthsData).toHaveBeenCalledOnce(); + }); + + it('returns 404 when compare data is unavailable', async () => { + mockFetchCompareMonthsData.mockResolvedValueOnce(null); + const GET = await getGet(); + const res = await GET( + makeRequest( + 'http://localhost/api/dashboard/compare-months?date_from=2026-03-01&date_to=2026-03-31' + ) + ); + expect(res.status).toBe(404); + }); + + it('returns 500 when compare service fails', async () => { + mockFetchCompareMonthsData.mockRejectedValueOnce(new Error('db down')); + const GET = await getGet(); + const res = await GET( + makeRequest( + 'http://localhost/api/dashboard/compare-months?date_from=2026-03-01&date_to=2026-03-31' + ) + ); + expect(res.status).toBe(500); + expect(mockCaptureException).toHaveBeenCalledOnce(); + }); +}); diff --git a/apps/money-mirror/__tests__/api/frequency-clusters.test.ts b/apps/money-mirror/__tests__/api/frequency-clusters.test.ts new file mode 100644 index 0000000..30286ee --- /dev/null +++ b/apps/money-mirror/__tests__/api/frequency-clusters.test.ts @@ -0,0 +1,112 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { NextRequest } from 'next/server'; + +const mockGetSessionUser = vi.fn(); +const mockGetDb = vi.fn(); +const mockCaptureServerEvent = vi.fn(); +const mockCheckRateLimit = vi.fn(); + +vi.mock('@/lib/auth/session', () => ({ getSessionUser: mockGetSessionUser })); +vi.mock('@/lib/db', () => ({ getDb: mockGetDb, toNumber: (v: unknown) => Number(v) })); +vi.mock('@/lib/posthog', () => ({ captureServerEvent: mockCaptureServerEvent })); +vi.mock('@/lib/rate-limit', () => ({ checkRateLimit: mockCheckRateLimit })); +vi.mock('@sentry/nextjs', () => ({ captureException: vi.fn() })); + +async function getGET() { + const mod = await import('@/app/api/insights/frequency-clusters/route'); + return mod.GET; +} + +function makeRequest(url: string) { + return new NextRequest(url, { method: 'GET' }); +} + +describe('GET /api/insights/frequency-clusters', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockGetSessionUser.mockResolvedValue({ id: 'u1', email: 'u@example.com', name: 'User' }); + mockCheckRateLimit.mockReturnValue({ ok: true, remaining: 39, resetAt: Date.now() + 1_000 }); + mockCaptureServerEvent.mockResolvedValue(undefined); + }); + + it('returns 401 when not authenticated', async () => { + mockGetSessionUser.mockResolvedValue(null); + const GET = await getGET(); + const res = await GET( + makeRequest( + 'http://localhost/api/insights/frequency-clusters?date_from=2026-01-01&date_to=2026-01-31' + ) + ); + expect(res.status).toBe(401); + }); + + it('returns 400 without unified scope params', async () => { + const GET = await getGET(); + const res = await GET(makeRequest('http://localhost/api/insights/frequency-clusters')); + expect(res.status).toBe(400); + }); + + it('returns 400 for legacy single-statement scope', async () => { + const GET = await getGET(); + const res = await GET( + makeRequest('http://localhost/api/insights/frequency-clusters?statement_id=abc') + ); + expect(res.status).toBe(400); + }); + + it('returns 429 when rate limited', async () => { + mockCheckRateLimit.mockReturnValue({ + ok: false, + remaining: 0, + retryAfterSec: 30, + resetAt: Date.now() + 30_000, + }); + const GET = await getGET(); + const res = await GET( + makeRequest( + 'http://localhost/api/insights/frequency-clusters?date_from=2026-01-01&date_to=2026-01-31' + ) + ); + expect(res.status).toBe(429); + }); + + it('returns top merchants and clusters on success', async () => { + const mockSql = vi.fn().mockResolvedValue([ + { merchant_key: 'zomato', debit_count: '15', debit_paisa: '120000' }, + { merchant_key: 'blinkit', debit_count: '8', debit_paisa: '40000' }, + { merchant_key: 'uber', debit_count: '5', debit_paisa: '60000' }, + { merchant_key: 'random_shop', debit_count: '3', debit_paisa: '20000' }, + ]); + mockGetDb.mockReturnValue(mockSql); + + const GET = await getGET(); + const res = await GET( + makeRequest( + 'http://localhost/api/insights/frequency-clusters?date_from=2026-01-01&date_to=2026-01-31' + ) + ); + expect(res.status).toBe(200); + + const body = await res.json(); + expect(body.top_merchants).toHaveLength(4); + expect(body.clusters).toBeDefined(); + expect(body.clusters.length).toBeGreaterThanOrEqual(2); + + const fdCluster = body.clusters.find( + (c: { cluster: { id: string } }) => c.cluster.id === 'food_delivery' + ); + expect(fdCluster).toBeDefined(); + expect(fdCluster.debitCount).toBe(15); + }); + + it('returns 500 on DB error', async () => { + mockGetDb.mockReturnValue(vi.fn().mockRejectedValue(new Error('DB down'))); + const GET = await getGET(); + const res = await GET( + makeRequest( + 'http://localhost/api/insights/frequency-clusters?date_from=2026-01-01&date_to=2026-01-31' + ) + ); + expect(res.status).toBe(500); + }); +}); diff --git a/apps/money-mirror/__tests__/api/guided-review-outcome.test.ts b/apps/money-mirror/__tests__/api/guided-review-outcome.test.ts new file mode 100644 index 0000000..157d1d6 --- /dev/null +++ b/apps/money-mirror/__tests__/api/guided-review-outcome.test.ts @@ -0,0 +1,145 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { NextRequest } from 'next/server'; + +const mockGetSessionUser = vi.fn(); +const mockGetDb = vi.fn(); +const mockCaptureServerEvent = vi.fn(); + +vi.mock('@/lib/auth/session', () => ({ getSessionUser: mockGetSessionUser })); +vi.mock('@/lib/db', () => ({ getDb: mockGetDb })); +vi.mock('@/lib/posthog', () => ({ captureServerEvent: mockCaptureServerEvent })); +vi.mock('@sentry/nextjs', () => ({ captureException: vi.fn() })); + +async function getPOST() { + const mod = await import('@/app/api/guided-review/outcome/route'); + return mod.POST; +} + +function makeRequest(body: unknown) { + return new NextRequest('http://localhost/api/guided-review/outcome', { + method: 'POST', + body: JSON.stringify(body), + headers: { 'content-type': 'application/json' }, + }); +} + +describe('POST /api/guided-review/outcome', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockGetSessionUser.mockResolvedValue({ id: 'u1', email: 'u@example.com', name: 'User' }); + mockCaptureServerEvent.mockResolvedValue(undefined); + }); + + it('returns 401 when not authenticated', async () => { + mockGetSessionUser.mockResolvedValue(null); + const POST = await getPOST(); + const res = await POST(makeRequest({ dismissed: true })); + expect(res.status).toBe(401); + }); + + it('returns 400 when dismissed is missing', async () => { + const POST = await getPOST(); + const res = await POST(makeRequest({})); + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.error).toContain('dismissed'); + }); + + it('returns 400 when statement_id is invalid UUID', async () => { + const POST = await getPOST(); + const res = await POST(makeRequest({ dismissed: true, statement_id: 'not-a-uuid' })); + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.error).toContain('UUID'); + }); + + it('returns 400 when commitment_text exceeds max length', async () => { + const POST = await getPOST(); + const longText = 'a'.repeat(501); + const res = await POST(makeRequest({ dismissed: false, commitment_text: longText })); + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.error).toContain('500'); + }); + + it('saves dismissed outcome successfully', async () => { + const mockSql = vi.fn().mockResolvedValue([]); + mockGetDb.mockReturnValue(mockSql); + const POST = await getPOST(); + + const res = await POST(makeRequest({ dismissed: true })); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.ok).toBe(true); + expect(mockSql).toHaveBeenCalledTimes(1); + }); + + it('saves commitment with text successfully', async () => { + const mockSql = vi + .fn() + .mockResolvedValueOnce([{ ok: 1 }]) + .mockResolvedValueOnce([]); + mockGetDb.mockReturnValue(mockSql); + const POST = await getPOST(); + + const res = await POST( + makeRequest({ + dismissed: false, + commitment_text: 'Audit one recurring charge', + statement_id: '550e8400-e29b-41d4-a716-446655440000', + }) + ); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.ok).toBe(true); + expect(mockSql).toHaveBeenCalledTimes(2); + }); + + it('returns 404 when statement is not owned by the user', async () => { + const mockSql = vi.fn().mockResolvedValueOnce([]); + mockGetDb.mockReturnValue(mockSql); + const POST = await getPOST(); + + const res = await POST( + makeRequest({ + dismissed: false, + statement_id: '550e8400-e29b-41d4-a716-446655440000', + commitment_text: 'Audit one recurring charge', + }) + ); + + expect(res.status).toBe(404); + const body = await res.json(); + expect(body.error).toBe('Statement not found'); + expect(mockSql).toHaveBeenCalledTimes(1); + }); + + it('fires guided_review_completed for dismissed', async () => { + mockGetDb.mockReturnValue(vi.fn().mockResolvedValue([])); + const POST = await getPOST(); + await POST(makeRequest({ dismissed: true })); + expect(mockCaptureServerEvent).toHaveBeenCalledWith( + 'u1', + 'guided_review_completed', + expect.objectContaining({ dismissed: true }) + ); + }); + + it('fires commitment_saved for non-dismissed', async () => { + mockGetDb.mockReturnValue(vi.fn().mockResolvedValue([])); + const POST = await getPOST(); + await POST(makeRequest({ dismissed: false, commitment_text: 'Save 10%' })); + expect(mockCaptureServerEvent).toHaveBeenCalledWith( + 'u1', + 'commitment_saved', + expect.objectContaining({ dismissed: false, has_commitment: true }) + ); + }); + + it('returns 500 on DB error', async () => { + mockGetDb.mockReturnValue(vi.fn().mockRejectedValue(new Error('DB down'))); + const POST = await getPOST(); + const res = await POST(makeRequest({ dismissed: true })); + expect(res.status).toBe(500); + }); +}); diff --git a/apps/money-mirror/__tests__/api/parse.test.ts b/apps/money-mirror/__tests__/api/parse.test.ts index d224c93..e21e47c 100644 --- a/apps/money-mirror/__tests__/api/parse.test.ts +++ b/apps/money-mirror/__tests__/api/parse.test.ts @@ -7,22 +7,29 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { NextRequest } from 'next/server'; +const mockExtractPdfText = vi.fn().mockResolvedValue({ + text: '01/03/2026 SWIGGY 450.00 Dr\n02/03/2026 SALARY 50000.00 Cr', + pageCount: 1, +}); + +class MockPdfExtractionError extends Error { + code: string; + + constructor(message: string, code: string) { + super(message); + this.code = code; + } +} + vi.mock('@/lib/pdf-parser', () => ({ - extractPdfText: vi.fn().mockResolvedValue({ - text: '01/03/2026 SWIGGY 450.00 Dr\n02/03/2026 SALARY 50000.00 Cr', - pageCount: 1, - }), - PdfExtractionError: class PdfExtractionError extends Error { - code: string; - constructor(message: string, code: string) { - super(message); - this.code = code; - } - }, + extractPdfText: mockExtractPdfText, + PdfExtractionError: MockPdfExtractionError, })); +const mockCaptureServerEvent = vi.fn().mockResolvedValue(undefined); + vi.mock('@/lib/posthog', () => ({ - captureServerEvent: vi.fn().mockResolvedValue(undefined), + captureServerEvent: mockCaptureServerEvent, })); const mockGetSessionUser = vi.fn(); @@ -32,9 +39,13 @@ vi.mock('@/lib/auth/session', () => ({ const mockCountUserStatementsSince = vi.fn(); const mockEnsureProfile = vi.fn(); +/** Plan lookup at end of successful parse (`SELECT plan FROM profiles`). */ +const mockPlanSql = vi.fn(async () => [{ plan: 'free' }]); +const mockGetDb = vi.fn(() => mockPlanSql); vi.mock('@/lib/db', () => ({ countUserStatementsSince: mockCountUserStatementsSince, ensureProfile: mockEnsureProfile, + getDb: mockGetDb, })); const mockPersistStatement = vi.fn(); @@ -86,6 +97,10 @@ describe('POST /api/statement/parse', () => { beforeEach(() => { vi.clearAllMocks(); + mockExtractPdfText.mockResolvedValue({ + text: '01/03/2026 SWIGGY 450.00 Dr\n02/03/2026 SALARY 50000.00 Cr', + pageCount: 1, + }); mockGetSessionUser.mockResolvedValue({ id: 'user-123', email: 'vijay@example.com', @@ -133,6 +148,7 @@ describe('POST /api/statement/parse', () => { expect(res.status).toBe(200); expect(body).toMatchObject({ statement_id: 'stmt-abc', + plan: 'free', institution_name: 'HDFC Bank', statement_type: 'bank_account', period_start: '2026-03-01', @@ -231,6 +247,7 @@ describe('POST /api/statement/parse', () => { expect(res.status).toBe(200); expect(body).toMatchObject({ + plan: 'free', institution_name: 'SBI Card', statement_type: 'credit_card', due_date: '2026-04-18', @@ -239,4 +256,34 @@ describe('POST /api/statement/parse', () => { credit_limit_paisa: 15000000, }); }); + + it('returns 422 for parser failures and emits parser diagnostics', async () => { + mockExtractPdfText.mockRejectedValueOnce( + new MockPdfExtractionError( + 'Failed to parse PDF: DOMMatrix is not defined in server runtime', + 'PARSE_FAILED' + ) + ); + + const POST = await getRoute(); + const res = await POST(makeRequest(makePdfFile('statement.pdf', 1024))); + const body = await res.json(); + + expect(res.status).toBe(422); + expect(body).toMatchObject({ + error: 'Failed to read the PDF. Please ensure it is a valid bank statement.', + }); + expect(mockCaptureServerEvent).toHaveBeenCalledWith( + 'user-123', + 'statement_parse_failed', + expect.objectContaining({ + error_type: 'PARSE_FAILED', + file_name: expect.any(String), + statement_type: 'bank_account', + parser_stage: 'pdf_text_extraction', + parser_detail: 'Failed to parse PDF: DOMMatrix is not defined in server runtime', + }) + ); + expect(mockGenerateContent).not.toHaveBeenCalled(); + }); }); diff --git a/apps/money-mirror/__tests__/api/proactive.test.ts b/apps/money-mirror/__tests__/api/proactive.test.ts new file mode 100644 index 0000000..b119c15 --- /dev/null +++ b/apps/money-mirror/__tests__/api/proactive.test.ts @@ -0,0 +1,108 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { NextRequest } from 'next/server'; + +const mockGetSessionUser = vi.fn(); +const mockCaptureServerEvent = vi.fn(); + +vi.mock('@/lib/auth/session', () => ({ getSessionUser: mockGetSessionUser })); +vi.mock('@/lib/posthog', () => ({ + captureServerEvent: (...args: unknown[]) => mockCaptureServerEvent(...args), +})); + +describe('POST /api/proactive/whatsapp-opt-in', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockCaptureServerEvent.mockImplementation(() => Promise.resolve()); + process.env.WHATSAPP_API_URL = ''; + process.env.WHATSAPP_API_TOKEN = ''; + }); + + it('returns 401 when unauthenticated', async () => { + mockGetSessionUser.mockResolvedValue(null); + const { POST } = await import('@/app/api/proactive/whatsapp-opt-in/route'); + const req = new NextRequest('http://localhost/api/proactive/whatsapp-opt-in', { + method: 'POST', + body: JSON.stringify({ phone_e164: '+919876543210' }), + headers: { 'content-type': 'application/json' }, + }); + const res = await POST(req); + expect(res.status).toBe(401); + }); + + it('returns 400 for invalid E.164', async () => { + mockGetSessionUser.mockResolvedValue({ id: 'u1', email: 'u@example.com', name: 'User' }); + const { POST } = await import('@/app/api/proactive/whatsapp-opt-in/route'); + const req = new NextRequest('http://localhost/api/proactive/whatsapp-opt-in', { + method: 'POST', + body: JSON.stringify({ phone_e164: 'invalid' }), + headers: { 'content-type': 'application/json' }, + }); + const res = await POST(req); + expect(res.status).toBe(400); + }); + + it('returns stub ok when provider env is unset (telemetry-only)', async () => { + mockGetSessionUser.mockResolvedValue({ id: 'u1', email: 'u@example.com', name: 'User' }); + const { POST } = await import('@/app/api/proactive/whatsapp-opt-in/route'); + const req = new NextRequest('http://localhost/api/proactive/whatsapp-opt-in', { + method: 'POST', + body: JSON.stringify({ phone_e164: '+919876543210' }), + headers: { 'content-type': 'application/json' }, + }); + const res = await POST(req); + expect(res.status).toBe(200); + const body = (await res.json()) as { ok?: boolean; mode?: string }; + expect(body.ok).toBe(true); + expect(body.mode).toBe('stub'); + expect(mockCaptureServerEvent).toHaveBeenCalled(); + }); +}); + +describe('POST /api/proactive/push-subscription', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockCaptureServerEvent.mockImplementation(() => Promise.resolve()); + }); + + it('returns 401 when unauthenticated', async () => { + mockGetSessionUser.mockResolvedValue(null); + const { POST } = await import('@/app/api/proactive/push-subscription/route'); + const req = new NextRequest('http://localhost/api/proactive/push-subscription', { + method: 'POST', + body: JSON.stringify({ endpoint: 'https://push.example/ep' }), + headers: { 'content-type': 'application/json' }, + }); + const res = await POST(req); + expect(res.status).toBe(401); + }); + + it('returns 400 when endpoint missing', async () => { + mockGetSessionUser.mockResolvedValue({ id: 'u1', email: 'u@example.com', name: 'User' }); + const { POST } = await import('@/app/api/proactive/push-subscription/route'); + const req = new NextRequest('http://localhost/api/proactive/push-subscription', { + method: 'POST', + body: JSON.stringify({}), + headers: { 'content-type': 'application/json' }, + }); + const res = await POST(req); + expect(res.status).toBe(400); + }); + + it('returns ok and fires telemetry when endpoint present', async () => { + mockGetSessionUser.mockResolvedValue({ id: 'u1', email: 'u@example.com', name: 'User' }); + const { POST } = await import('@/app/api/proactive/push-subscription/route'); + const req = new NextRequest('http://localhost/api/proactive/push-subscription', { + method: 'POST', + body: JSON.stringify({ + endpoint: 'https://fcm.googleapis.com/fake-endpoint-here', + user_agent: 'Mozilla/5.0', + }), + headers: { 'content-type': 'application/json' }, + }); + const res = await POST(req); + expect(res.status).toBe(200); + const body = (await res.json()) as { ok?: boolean }; + expect(body.ok).toBe(true); + expect(mockCaptureServerEvent).toHaveBeenCalled(); + }); +}); diff --git a/apps/money-mirror/__tests__/lib/pdf-parser.test.ts b/apps/money-mirror/__tests__/lib/pdf-parser.test.ts index 001f75d..ffd3fd6 100644 --- a/apps/money-mirror/__tests__/lib/pdf-parser.test.ts +++ b/apps/money-mirror/__tests__/lib/pdf-parser.test.ts @@ -6,38 +6,49 @@ */ // @vitest-environment node -import { describe, it, expect, vi, beforeEach } from 'vitest'; - -// ─── Hoisted mock fns ───────────────────────────────────────── -const { mockGetText, mockDestroy, MockPDFParse } = vi.hoisted(() => { - const mockGetText = vi.fn(); - const mockDestroy = vi.fn().mockResolvedValue(undefined); - function MockPDFParse(opts: unknown) { - void opts; - return { getText: mockGetText, destroy: mockDestroy }; - } - return { mockGetText, mockDestroy, MockPDFParse }; -}); +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +type PdfParserModule = typeof import('@/lib/pdf-parser'); -vi.mock('pdf-parse', () => ({ - PDFParse: MockPDFParse, -})); +const mockGetText = vi.fn(); +const mockDestroy = vi.fn(); +const mockPdfParseConstructor = vi.fn(function MockPDFParse(opts: unknown) { + void opts; + return { getText: mockGetText, destroy: mockDestroy }; +}); +const mockPdfParseFactory = vi.fn(async () => { + return { + PDFParse: mockPdfParseConstructor, + }; +}); -import { extractPdfText, PdfExtractionError } from '@/lib/pdf-parser'; +async function loadPdfParserModule(): Promise { + vi.resetModules(); + vi.doMock('pdf-parse', mockPdfParseFactory); + return import('@/lib/pdf-parser'); +} describe('extractPdfText', () => { beforeEach(() => { vi.clearAllMocks(); mockDestroy.mockResolvedValue(undefined); + delete (globalThis as Record).DOMMatrix; + delete (globalThis as Record).ImageData; + delete (globalThis as Record).Path2D; }); it('throws EMPTY_FILE when buffer is empty', async () => { + const { extractPdfText } = await loadPdfParserModule(); + await expect(extractPdfText(Buffer.alloc(0))).rejects.toMatchObject({ code: 'EMPTY_FILE', }); + expect(mockPdfParseFactory).not.toHaveBeenCalled(); }); it('throws PARSE_FAILED when getText throws', async () => { + const { extractPdfText } = await loadPdfParserModule(); + mockGetText.mockRejectedValue(new Error('Invalid PDF structure')); const garbage = Buffer.from('not a pdf', 'utf-8'); await expect(extractPdfText(garbage)).rejects.toMatchObject({ @@ -46,6 +57,8 @@ describe('extractPdfText', () => { }); it('throws EMPTY_TEXT when PDF yields blank text', async () => { + const { extractPdfText } = await loadPdfParserModule(); + mockGetText.mockResolvedValue({ text: ' ', total: 1 }); const fakeBuffer = Buffer.from('fake pdf content'); await expect(extractPdfText(fakeBuffer)).rejects.toMatchObject({ @@ -54,6 +67,8 @@ describe('extractPdfText', () => { }); it('returns text and pageCount on success', async () => { + const { extractPdfText } = await loadPdfParserModule(); + const fakeText = '01/03/26 SWIGGY 500.00 49500.00\n'; mockGetText.mockResolvedValue({ text: fakeText, total: 3 }); const fakeBuffer = Buffer.from('fake pdf content'); @@ -65,10 +80,47 @@ describe('extractPdfText', () => { }); it('returns a PdfExtractionError instance on failure', async () => { + const { extractPdfText, PdfExtractionError } = await loadPdfParserModule(); + try { await extractPdfText(Buffer.alloc(0)); } catch (e) { expect(e).toBeInstanceOf(PdfExtractionError); } }); + + it('lazy-loads pdf-parse only after server polyfills are installed', async () => { + mockPdfParseFactory.mockImplementationOnce(async () => { + expect(globalThis.DOMMatrix).toBeTypeOf('function'); + expect(globalThis.ImageData).toBeTypeOf('function'); + expect(globalThis.Path2D).toBeTypeOf('function'); + + return { + PDFParse: function MockPDFParse() { + return { getText: mockGetText, destroy: mockDestroy }; + }, + }; + }); + + const { extractPdfText } = await loadPdfParserModule(); + mockGetText.mockResolvedValue({ text: 'statement text', total: 2 }); + + await expect(extractPdfText(Buffer.from('fake pdf content'))).resolves.toMatchObject({ + text: 'statement text', + pageCount: 2, + }); + }); + + it('maps parser initialization failures to PARSE_FAILED', async () => { + mockPdfParseConstructor.mockImplementation(function MockPDFParseFailure() { + throw new Error('DOMMatrix is not defined'); + }); + + const { extractPdfText } = await loadPdfParserModule(); + + await expect(extractPdfText(Buffer.from('fake pdf content'))).rejects.toMatchObject({ + code: 'PARSE_FAILED', + message: 'Failed to parse PDF: DOMMatrix is not defined', + }); + }); }); diff --git a/apps/money-mirror/docs/COACHING-TONE.md b/apps/money-mirror/docs/COACHING-TONE.md index e72cc8b..ea9e6eb 100644 --- a/apps/money-mirror/docs/COACHING-TONE.md +++ b/apps/money-mirror/docs/COACHING-TONE.md @@ -27,6 +27,20 @@ Educational, India-first personal finance copy. Used for in-app insights and any - 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.) +## Gen Z and income-transition users + +Users in this segment may be between jobs, dependent on family support, earning irregularly from gigs, or spending micro-amounts frequently through UPI and quick-commerce. Copy must never assume a stable salary or a traditional budgeting frame. + +### Rules + +1. **No salary assumption** — Use "money coming in" or "credits" rather than "salary" or "income" when the source is unclear. +2. **Frequency over totals** — "12 Swiggy orders this month" is more vivid than "₹X on food delivery." Lead with how often, then connect to what it adds up to. +3. **No shame for dependence** — Receiving money from family or friends is normal, not a failure. Never frame credits from individuals as "you should be earning this yourself." +4. **Micro-spend visibility** — Small UPI debits feel invisible individually; surface them as patterns ("52 transactions under ₹200"), not as moral failures. +5. **Transition-safe framing** — "This is where things stand" is better than "You spent too much." Observations, not verdicts. +6. **One next step, not a lecture** — Each advisory suggests one concrete, low-friction action — not a life overhaul. +7. **Empty states are safe** — When no patterns fire, celebrate awareness: "Nothing to flag — clarity starts with knowing." + ## Archetypes (optional user preference) - **Educator** — Neutral, step-by-step. diff --git a/apps/money-mirror/package.json b/apps/money-mirror/package.json index 5ada77b..6bba670 100644 --- a/apps/money-mirror/package.json +++ b/apps/money-mirror/package.json @@ -9,6 +9,7 @@ "start": "next start", "lint": "eslint", "test": "vitest run", + "test:issue-011": "vitest run src/lib/advisory-engine.test.ts src/lib/__tests__/dashboard-compare.test.ts src/lib/__tests__/rate-limit.test.ts src/lib/__tests__/user-plan.test.ts src/lib/__tests__/merchant-normalize.test.ts __tests__/api/chat.test.ts __tests__/api/compare-months.test.ts __tests__/api/proactive.test.ts", "test:watch": "vitest", "test:coverage": "vitest run --coverage", "test:e2e": "npm run build && playwright test", diff --git a/apps/money-mirror/schema.sql b/apps/money-mirror/schema.sql index 4a028db..7dea361 100644 --- a/apps/money-mirror/schema.sql +++ b/apps/money-mirror/schema.sql @@ -46,6 +46,7 @@ CREATE TABLE IF NOT EXISTS public.transactions ( category TEXT NOT NULL CHECK (category IN ('needs', 'wants', 'investment', 'debt', 'other')), is_recurring BOOLEAN NOT NULL DEFAULT false, merchant_key TEXT, + upi_handle TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); @@ -84,3 +85,55 @@ 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; +ALTER TABLE public.transactions ADD COLUMN IF NOT EXISTS upi_handle TEXT; + +CREATE TABLE IF NOT EXISTS public.user_merchant_aliases ( + user_id TEXT NOT NULL REFERENCES public.profiles(id) ON DELETE CASCADE, + merchant_key TEXT NOT NULL, + display_label TEXT NOT NULL, + updated_at TIMESTAMPTZ NOT NULL DEFAULT timezone('utc', now()), + CONSTRAINT user_merchant_aliases_label_nonempty CHECK (length(trim(display_label)) > 0), + PRIMARY KEY (user_id, merchant_key) +); + +CREATE INDEX IF NOT EXISTS idx_user_merchant_aliases_user + ON public.user_merchant_aliases(user_id); + +CREATE TABLE IF NOT EXISTS public.merchant_label_suggestions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id TEXT NOT NULL REFERENCES public.profiles(id) ON DELETE CASCADE, + merchant_key TEXT NOT NULL, + suggested_label TEXT NOT NULL, + confidence NUMERIC, + source TEXT NOT NULL DEFAULT 'gemini', + model TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT timezone('utc', now()), + CONSTRAINT merchant_label_suggestions_label_nonempty CHECK (length(trim(suggested_label)) > 0), + CONSTRAINT merchant_label_suggestions_unique_per_user_key UNIQUE (user_id, merchant_key) +); + +CREATE INDEX IF NOT EXISTS idx_merchant_label_suggestions_user + ON public.merchant_label_suggestions(user_id); + +CREATE INDEX IF NOT EXISTS idx_transactions_user_upi + ON public.transactions(user_id, upi_handle) + WHERE upi_handle IS NOT NULL; + +-- P4-G: subscription tier (default free = full access until payments ship) +ALTER TABLE public.profiles ADD COLUMN IF NOT EXISTS plan TEXT NOT NULL DEFAULT 'free'; +ALTER TABLE public.profiles DROP CONSTRAINT IF EXISTS profiles_plan_check; +ALTER TABLE public.profiles ADD CONSTRAINT profiles_plan_check CHECK (plan IN ('free', 'pro')); + +-- Issue-012: guided review outcomes (optional saved commitment) +-- Privacy: commitment_text is opt-in only; CASCADE on profile delete covers cleanup. +CREATE TABLE IF NOT EXISTS public.guided_review_outcomes ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id TEXT NOT NULL REFERENCES public.profiles(id) ON DELETE CASCADE, + statement_id UUID REFERENCES public.statements(id) ON DELETE SET NULL, + dismissed BOOLEAN NOT NULL DEFAULT false, + commitment_text TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT timezone('utc', now()) +); + +CREATE INDEX IF NOT EXISTS idx_guided_review_outcomes_user_created + ON public.guided_review_outcomes(user_id, created_at DESC); diff --git a/apps/money-mirror/src/app/api/chat/route.ts b/apps/money-mirror/src/app/api/chat/route.ts new file mode 100644 index 0000000..c275537 --- /dev/null +++ b/apps/money-mirror/src/app/api/chat/route.ts @@ -0,0 +1,199 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { GoogleGenAI } from '@google/genai'; +import { getSessionUser } from '@/lib/auth/session'; +import { ensureProfile, getDb } from '@/lib/db'; +import { fetchDashboardData, type DashboardFetchInput } from '@/lib/dashboard'; +import { parseDashboardScopeFromSearchParams } from '@/lib/scope'; +import { buildLayerAFacts, factIdsFromLayerA, serializeFactsForPrompt } from '@/lib/coaching-facts'; +import { captureServerEvent } from '@/lib/posthog'; +import { checkRateLimit } from '@/lib/rate-limit'; + +const CHAT_LIMIT = { limit: 10, windowMs: 24 * 60 * 60 * 1000 }; +const CHAT_TIMEOUT_MS = 9_000; +const MAX_PROMPT_TXNS = 20; + +type ChatResponsePayload = { + answer: string; + cited_fact_ids: string[]; +}; + +function toDashboardInput(req: NextRequest): DashboardFetchInput | { error: string } { + const parsed = parseDashboardScopeFromSearchParams(req.nextUrl.searchParams); + if ('error' in parsed) { + return { error: parsed.error }; + } + if (parsed.variant === 'unified') { + return { + variant: 'unified', + dateFrom: parsed.scope.dateFrom, + dateTo: parsed.scope.dateTo, + statementIds: parsed.scope.statementIds, + }; + } + return { variant: 'legacy', statementId: parsed.statementId }; +} + +async function fetchPromptTransactions( + userId: string, + dashboard: Awaited> +) { + if (!dashboard) { + return []; + } + const sql = getDb(); + const statementIds = dashboard.scope.included_statement_ids; + const rows = (await sql` + SELECT date, description, type, category, amount_paisa + FROM transactions + WHERE user_id = ${userId} + AND statement_id = ANY(${statementIds}::uuid[]) + ORDER BY date DESC, created_at DESC + LIMIT ${MAX_PROMPT_TXNS} + `) as Array<{ + date: string; + description: string; + type: 'debit' | 'credit'; + category: 'needs' | 'wants' | 'investment' | 'debt' | 'other'; + amount_paisa: number; + }>; + return rows.map((row) => ({ + date: row.date, + description: row.description.slice(0, 140), + type: row.type, + category: row.category, + amount_paisa: row.amount_paisa, + })); +} + +function buildChatPrompt( + facts: ReturnType, + txnRows: Awaited>, + message: string +): string { + return `You are MoneyMirror's educational finance coach for India. +Only use the provided facts and transactions. Do not invent numbers. +If the user asks beyond available evidence, say what is missing. + +Facts JSON: +${serializeFactsForPrompt(facts)} + +Recent transactions JSON: +${JSON.stringify(txnRows)} + +Return strict JSON: +{"answer":"string","cited_fact_ids":["id1","id2"]} + +Rules: +- Keep answer concise (max 5 sentences), clear, and non-judgmental. +- No investment advice or security recommendations. +- cited_fact_ids must be non-empty and drawn from Facts JSON ids only. + +User question: +${message}`; +} + +export async function POST(req: NextRequest): Promise { + const user = await getSessionUser(); + if (!user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const rate = checkRateLimit(`chat:post:${user.id}`, CHAT_LIMIT); + if (!rate.ok) { + // Single emission source: server-side in /api/chat + captureServerEvent(user.id, 'chat_rate_limited', { + retry_after_sec: rate.retryAfterSec, + }).catch(() => {}); + return NextResponse.json( + { error: 'Daily chat limit reached. Try again tomorrow.' }, + { status: 429, headers: { 'Retry-After': String(rate.retryAfterSec) } } + ); + } + + const body = (await req.json().catch(() => null)) as { message?: string } | null; + const message = body?.message?.trim() ?? ''; + if (!message || message.length > 500) { + return NextResponse.json({ error: 'message is required (1-500 chars).' }, { status: 400 }); + } + + const input = toDashboardInput(req); + if ('error' in input) { + return NextResponse.json({ error: input.error }, { status: 400 }); + } + + await ensureProfile({ id: user.id, email: user.email }); + const dashboard = await fetchDashboardData(user.id, input); + if (!dashboard) { + return NextResponse.json( + { error: 'Dashboard data unavailable for this scope.' }, + { status: 404 } + ); + } + + const facts = buildLayerAFacts(dashboard); + const allowedFactIds = factIdsFromLayerA(facts); + const txnRows = await fetchPromptTransactions(user.id, dashboard); + + captureServerEvent(user.id, 'chat_query_submitted', { + message_length: message.length, + txn_context_count: txnRows.length, + scope_kind: dashboard.scope.kind, + }).catch(() => {}); + + if (!process.env.GEMINI_API_KEY) { + return NextResponse.json({ error: 'Chat is currently unavailable.' }, { status: 503 }); + } + + const genai = new GoogleGenAI({ apiKey: process.env.GEMINI_API_KEY }); + const prompt = buildChatPrompt(facts, txnRows, message); + + const started = Date.now(); + try { + const llmPromise = genai.models + .generateContent({ + model: 'gemini-2.5-flash', + config: { responseMimeType: 'application/json', thinkingConfig: { thinkingBudget: 0 } }, + contents: [{ role: 'user', parts: [{ text: prompt }] }], + }) + .then((res) => { + const text = res.candidates?.[0]?.content?.parts?.find((p) => p.text)?.text ?? ''; + const clean = text + .replace(/^```(?:json)?\s*/i, '') + .replace(/```\s*$/i, '') + .trim(); + return JSON.parse(clean) as ChatResponsePayload; + }); + const timeout = new Promise((_, reject) => + setTimeout(() => reject(new Error('CHAT_TIMEOUT')), CHAT_TIMEOUT_MS) + ); + const parsed = await Promise.race([llmPromise, timeout]); + + const cited = Array.isArray(parsed.cited_fact_ids) ? parsed.cited_fact_ids : []; + if ( + !parsed.answer?.trim() || + cited.length === 0 || + cited.some((id) => !allowedFactIds.has(id)) + ) { + return NextResponse.json({ error: 'Failed to validate chat response.' }, { status: 502 }); + } + + captureServerEvent(user.id, 'chat_response_rendered', { + latency_ms: Date.now() - started, + cited_fact_count: cited.length, + }).catch(() => {}); + + return NextResponse.json({ + answer: parsed.answer.trim(), + cited_fact_ids: cited, + facts, + }); + } catch (err) { + if (err instanceof Error && err.message === 'CHAT_TIMEOUT') { + return NextResponse.json( + { error: 'Chat request timed out. Please try again.' }, + { status: 504 } + ); + } + return NextResponse.json({ error: 'Chat request failed.' }, { status: 502 }); + } +} diff --git a/apps/money-mirror/src/app/api/cron/merchant-enrich/route.ts b/apps/money-mirror/src/app/api/cron/merchant-enrich/route.ts new file mode 100644 index 0000000..0fa53fe --- /dev/null +++ b/apps/money-mirror/src/app/api/cron/merchant-enrich/route.ts @@ -0,0 +1,113 @@ +/** + * GET /api/cron/merchant-enrich + * + * Batch job: create merchant_label_suggestions for merchant_keys missing suggestions. + * Auth: same as other crons (Bearer CRON_SECRET or x-cron-secret). + * Does not block uploads; safe to skip when GEMINI_API_KEY is unset (200 + skipped). + */ + +import * as Sentry from '@sentry/nextjs'; +import { NextRequest, NextResponse } from 'next/server'; +import { getDb } from '@/lib/db'; +import { suggestMerchantLabelFromSamples } from '@/lib/merchant-label-enrich'; + +const MAX_KEYS_PER_RUN = 6; + +function isAuthorizedCronRequest(req: NextRequest): boolean { + const expectedSecret = process.env.CRON_SECRET; + if (!expectedSecret) { + return false; + } + const bearerToken = req.headers.get('authorization'); + if (bearerToken === `Bearer ${expectedSecret}`) { + return true; + } + return req.headers.get('x-cron-secret') === expectedSecret; +} + +export async function GET(req: NextRequest): Promise { + if (!isAuthorizedCronRequest(req)) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + if (!process.env.GEMINI_API_KEY) { + return NextResponse.json({ ok: true, skipped: true, reason: 'GEMINI_API_KEY unset' }); + } + + const sql = getDb(); + const processed: { user_id: string; merchant_key: string; status: string }[] = []; + + try { + const candidates = (await sql` + SELECT DISTINCT ON (t.user_id, t.merchant_key) t.user_id, t.merchant_key + FROM transactions t + WHERE t.merchant_key IS NOT NULL + AND NOT EXISTS ( + SELECT 1 + FROM merchant_label_suggestions m + WHERE m.user_id = t.user_id AND m.merchant_key = t.merchant_key + ) + ORDER BY t.user_id, t.merchant_key, t.created_at DESC + LIMIT ${MAX_KEYS_PER_RUN} + `) as { user_id: string; merchant_key: string }[]; + + for (const c of candidates) { + const samples = (await sql` + SELECT description + FROM transactions + WHERE user_id = ${c.user_id} AND merchant_key = ${c.merchant_key} + ORDER BY date DESC + LIMIT 8 + `) as { description: string }[]; + + const texts = samples.map((s) => s.description).filter(Boolean); + const out = await suggestMerchantLabelFromSamples(c.merchant_key, texts); + if (!out.ok) { + processed.push({ + user_id: c.user_id, + merchant_key: c.merchant_key, + status: out.code, + }); + continue; + } + + await sql` + INSERT INTO merchant_label_suggestions ( + user_id, + merchant_key, + suggested_label, + confidence, + source, + model, + created_at + ) + VALUES ( + ${c.user_id}, + ${c.merchant_key}, + ${out.suggested_label}, + ${out.confidence}, + 'gemini', + 'gemini-2.5-flash', + timezone('utc', now()) + ) + ON CONFLICT (user_id, merchant_key) DO UPDATE SET + suggested_label = EXCLUDED.suggested_label, + confidence = EXCLUDED.confidence, + model = EXCLUDED.model, + created_at = timezone('utc', now()) + `; + + processed.push({ + user_id: c.user_id, + merchant_key: c.merchant_key, + status: 'ok', + }); + } + + return NextResponse.json({ ok: true, processed }); + } catch (e) { + Sentry.captureException(e); + console.error('[cron/merchant-enrich] failed:', e); + return NextResponse.json({ error: 'merchant-enrich failed' }, { status: 500 }); + } +} diff --git a/apps/money-mirror/src/app/api/cron/weekly-recap/worker/route.ts b/apps/money-mirror/src/app/api/cron/weekly-recap/worker/route.ts index eebee71..5dd2098 100644 --- a/apps/money-mirror/src/app/api/cron/weekly-recap/worker/route.ts +++ b/apps/money-mirror/src/app/api/cron/weekly-recap/worker/route.ts @@ -60,41 +60,57 @@ export async function POST(req: NextRequest): Promise { const topCategory = Object.entries(categoryTotals).sort((a, b) => b[1] - a[1])[0]; const totalSpent = Math.round(statement.total_debits_paisa / 100).toLocaleString('en-IN'); + const totalCredits = Math.round(statement.total_credits_paisa / 100).toLocaleString('en-IN'); const periodLabel = statement.period_start ? `${statement.period_start} → ${statement.period_end}` - : 'last month'; + : 'your latest statement'; const topCatLabel = topCategory ? `${topCategory[0]} (₹${Math.round(topCategory[1] / 100).toLocaleString('en-IN')})` - : '—'; + : null; + const appUrl = process.env.NEXT_PUBLIC_APP_URL ?? 'https://moneymirror.in'; + + const topCatBlock = topCatLabel + ? `
+
Biggest spending bucket
+
${topCatLabel}
+
` + : ''; // ── Send email via Resend ───────────────────────────────────────── try { await resend.emails.send({ from: 'MoneyMirror ', to: email, - subject: `Your MoneyMirror weekly recap 🪞`, + subject: `Your week in numbers — ₹${totalSpent} across ${periodLabel}`, html: `
-

MoneyMirror Weekly Recap

+

Your Money Mirror

${periodLabel}

-
-
Total Spent
-
₹${totalSpent}
+
+
+
Spent
+
₹${totalSpent}
+
+
+
Received
+
₹${totalCredits}
+
-
-
Biggest Category
-
${topCatLabel}
-
+ ${topCatBlock} + +

+ No judgement — just the numbers from your statement. Open your dashboard to see frequency patterns, merchant clusters, and where the small stuff adds up. +

- - See Your Full Mirror → + + Open Your Mirror →

- Your data is private and never shared. Unsubscribe + Your data is private and never shared. Unsubscribe

`, diff --git a/apps/money-mirror/src/app/api/dashboard/compare-months/route.ts b/apps/money-mirror/src/app/api/dashboard/compare-months/route.ts new file mode 100644 index 0000000..3d160f1 --- /dev/null +++ b/apps/money-mirror/src/app/api/dashboard/compare-months/route.ts @@ -0,0 +1,48 @@ +import * as Sentry from '@sentry/nextjs'; +import { NextRequest, NextResponse } from 'next/server'; +import { getSessionUser } from '@/lib/auth/session'; +import { parseDashboardScopeFromSearchParams } from '@/lib/scope'; +import { fetchCompareMonthsData } from '@/lib/dashboard-compare'; +import type { DashboardFetchInput } from '@/lib/dashboard-types'; + +export async function GET(req: NextRequest): Promise { + 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 { + const compare = await fetchCompareMonthsData(user.id, input); + if (!compare) { + return NextResponse.json( + { error: 'Comparison unavailable for active scope.' }, + { status: 404 } + ); + } + return NextResponse.json(compare); + } catch (err) { + Sentry.captureException(err); + console.error('[GET /api/dashboard/compare-months]', err); + return NextResponse.json({ error: 'Failed to load month comparison.' }, { status: 500 }); + } +} diff --git a/apps/money-mirror/src/app/api/dashboard/route.ts b/apps/money-mirror/src/app/api/dashboard/route.ts index ded0e7d..d1c865c 100644 --- a/apps/money-mirror/src/app/api/dashboard/route.ts +++ b/apps/money-mirror/src/app/api/dashboard/route.ts @@ -3,15 +3,31 @@ import { NextRequest, NextResponse } from 'next/server'; import { getSessionUser } from '@/lib/auth/session'; import { ensureProfile } from '@/lib/db'; import { attachCoachingFactsOnly } from '@/lib/coaching-enrich'; +import { captureServerEvent } from '@/lib/posthog'; +import { checkRateLimit } from '@/lib/rate-limit'; import { fetchDashboardData, type DashboardFetchInput } from '@/lib/dashboard'; import { parseDashboardScopeFromSearchParams } from '@/lib/scope'; +const HEAVY_READ_LIMIT = { limit: 40, windowMs: 60_000 }; + export async function GET(req: NextRequest): Promise { const user = await getSessionUser(); if (!user) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); } + const rate = checkRateLimit(`dashboard:get:${user.id}`, HEAVY_READ_LIMIT); + if (!rate.ok) { + void captureServerEvent(user.id, 'rate_limit_hit', { + route: '/api/dashboard', + retry_after_sec: rate.retryAfterSec, + }).catch(() => {}); + return NextResponse.json( + { error: 'Too many requests. Please wait before retrying.' }, + { status: 429, headers: { 'Retry-After': String(rate.retryAfterSec) } } + ); + } + const parsed = parseDashboardScopeFromSearchParams(req.nextUrl.searchParams); if ('error' in parsed) { return NextResponse.json({ error: parsed.error }, { status: 400 }); diff --git a/apps/money-mirror/src/app/api/guided-review/outcome/__tests__/route.test.ts b/apps/money-mirror/src/app/api/guided-review/outcome/__tests__/route.test.ts new file mode 100644 index 0000000..29b1a0b --- /dev/null +++ b/apps/money-mirror/src/app/api/guided-review/outcome/__tests__/route.test.ts @@ -0,0 +1,95 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { NextRequest } from 'next/server'; + +const mockGetSessionUser = vi.fn(); +const mockCaptureServerEvent = vi.fn(); +const mockSql = vi.fn(); + +vi.mock('@/lib/auth/session', () => ({ + getSessionUser: mockGetSessionUser, +})); + +vi.mock('@/lib/db', () => ({ + getDb: () => mockSql, +})); + +vi.mock('@/lib/posthog', () => ({ + captureServerEvent: mockCaptureServerEvent, +})); + +async function getHandlers() { + const mod = await import('@/app/api/guided-review/outcome/route'); + return { POST: mod.POST }; +} + +function makeReq(body: unknown): NextRequest { + return new NextRequest('http://localhost/api/guided-review/outcome', { + method: 'POST', + body: JSON.stringify(body), + }); +} + +describe('/api/guided-review/outcome', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockGetSessionUser.mockResolvedValue({ + id: 'user-1', + email: 'u@example.com', + name: 'U', + }); + mockCaptureServerEvent.mockResolvedValue(undefined); + mockSql.mockResolvedValue(undefined); + }); + + it('returns 401 when unauthenticated', async () => { + mockGetSessionUser.mockResolvedValueOnce(null); + const { POST } = await getHandlers(); + const res = await POST(makeReq({ dismissed: true })); + expect(res.status).toBe(401); + }); + + it('returns 400 when dismissed flag is missing', async () => { + const { POST } = await getHandlers(); + const res = await POST(makeReq({ commitment_text: 'x' })); + expect(res.status).toBe(400); + }); + + it('persists commitment_text when not dismissed', async () => { + const { POST } = await getHandlers(); + const res = await POST( + makeReq({ dismissed: false, commitment_text: 'cut food delivery this month' }) + ); + expect(res.status).toBe(200); + // The INSERT call is the only sql call (no statement_id provided, so no ownership check). + const insertCall = mockSql.mock.calls.find((call) => { + const strings = call[0] as readonly string[]; + return strings.some((s) => s.includes('INSERT INTO guided_review_outcomes')); + }); + expect(insertCall).toBeDefined(); + // Tagged template arg order: [strings, user_id, statement_id, dismissed, commitment_text] + expect(insertCall![4]).toBe('cut food delivery this month'); + expect(mockCaptureServerEvent).toHaveBeenCalledWith( + 'user-1', + 'commitment_saved', + expect.objectContaining({ has_commitment: true }) + ); + }); + + it('forces commitment_text to NULL when dismissed=true even if client sends text', async () => { + const { POST } = await getHandlers(); + const res = await POST(makeReq({ dismissed: true, commitment_text: 'malicious leak attempt' })); + expect(res.status).toBe(200); + const insertCall = mockSql.mock.calls.find((call) => { + const strings = call[0] as readonly string[]; + return strings.some((s) => s.includes('INSERT INTO guided_review_outcomes')); + }); + expect(insertCall).toBeDefined(); + // commitment_text bind value must be null on the dismiss path. + expect(insertCall![4]).toBeNull(); + expect(mockCaptureServerEvent).toHaveBeenCalledWith( + 'user-1', + 'guided_review_completed', + expect.objectContaining({ has_commitment: false }) + ); + }); +}); diff --git a/apps/money-mirror/src/app/api/guided-review/outcome/route.ts b/apps/money-mirror/src/app/api/guided-review/outcome/route.ts new file mode 100644 index 0000000..3340a8e --- /dev/null +++ b/apps/money-mirror/src/app/api/guided-review/outcome/route.ts @@ -0,0 +1,84 @@ +import * as Sentry from '@sentry/nextjs'; +import { NextRequest, NextResponse } from 'next/server'; +import { getSessionUser } from '@/lib/auth/session'; +import { getDb } from '@/lib/db'; +import { captureServerEvent } from '@/lib/posthog'; +import { isValidUuid } from '@/lib/scope'; + +const MAX_COMMITMENT_LENGTH = 500; + +interface OutcomeBody { + statement_id?: string | null; + dismissed: boolean; + commitment_text?: string | null; +} + +export async function POST(req: NextRequest): Promise { + const user = await getSessionUser(); + if (!user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + let body: OutcomeBody; + try { + body = await req.json(); + } catch { + return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 }); + } + + if (typeof body.dismissed !== 'boolean') { + return NextResponse.json({ error: 'dismissed (boolean) is required' }, { status: 400 }); + } + + const statementId = body.statement_id ?? null; + if (statementId && !isValidUuid(statementId)) { + return NextResponse.json({ error: 'statement_id must be a valid UUID' }, { status: 400 }); + } + + // Privacy contract: when the user dismisses the review, never persist + // commitment text — even if the client mistakenly (or maliciously) sends it. + // The server is the source of truth for opt-in storage. + const commitmentText = body.dismissed ? null : body.commitment_text?.trim() || null; + if (commitmentText && commitmentText.length > MAX_COMMITMENT_LENGTH) { + return NextResponse.json( + { error: `commitment_text exceeds ${MAX_COMMITMENT_LENGTH} characters` }, + { status: 400 } + ); + } + + try { + const sql = getDb(); + + if (statementId) { + const ownedRows = await sql` + SELECT 1 AS ok + FROM statements + WHERE id = ${statementId}::uuid + AND user_id = ${user.id} + LIMIT 1 + `; + if ((ownedRows as { ok: number }[]).length === 0) { + return NextResponse.json({ error: 'Statement not found' }, { status: 404 }); + } + } + + await sql` + INSERT INTO guided_review_outcomes (user_id, statement_id, dismissed, commitment_text) + VALUES (${user.id}, ${statementId}, ${body.dismissed}, ${commitmentText}) + `; + // Telemetry uses the server-derived value, never the raw client field. + + const eventName = body.dismissed ? 'guided_review_completed' : 'commitment_saved'; + void captureServerEvent(user.id, eventName, { + dismissed: body.dismissed, + has_commitment: Boolean(commitmentText), + statement_id: statementId, + }).catch(() => {}); + + return NextResponse.json({ ok: true }); + } catch (err) { + Sentry.captureException(err); + console.error('[POST /api/guided-review/outcome]', err); + return NextResponse.json({ error: 'Failed to save review outcome' }, { status: 500 }); + } +} diff --git a/apps/money-mirror/src/app/api/insights/frequency-clusters/__tests__/route.test.ts b/apps/money-mirror/src/app/api/insights/frequency-clusters/__tests__/route.test.ts new file mode 100644 index 0000000..3999e84 --- /dev/null +++ b/apps/money-mirror/src/app/api/insights/frequency-clusters/__tests__/route.test.ts @@ -0,0 +1,106 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { NextRequest } from 'next/server'; + +const mockGetSessionUser = vi.fn(); +const mockSql = vi.fn(); +const mockCheckRateLimit = vi.fn(); +const mockCaptureServerEvent = vi.fn(); + +vi.mock('@/lib/auth/session', () => ({ + getSessionUser: mockGetSessionUser, +})); + +vi.mock('@/lib/db', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + getDb: () => mockSql, + }; +}); + +vi.mock('@/lib/rate-limit', () => ({ + checkRateLimit: mockCheckRateLimit, +})); + +vi.mock('@/lib/posthog', () => ({ + captureServerEvent: mockCaptureServerEvent, +})); + +async function getGet() { + const mod = await import('@/app/api/insights/frequency-clusters/route'); + return mod.GET; +} + +function makeRequest() { + return new NextRequest( + 'http://localhost/api/insights/frequency-clusters?date_from=2026-04-01&date_to=2026-04-30' + ); +} + +describe('GET /api/insights/frequency-clusters', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockGetSessionUser.mockResolvedValue({ + id: 'user-1', + email: 'u@example.com', + name: 'U', + }); + mockCheckRateLimit.mockReturnValue({ ok: true }); + mockCaptureServerEvent.mockResolvedValue(undefined); + }); + + it('cluster totals come from full-scope aggregate, not the LIMIT-30 top merchants', async () => { + // Top-merchants query (LIMIT 30): only returns one clustered merchant. + // Cluster aggregate query (no LIMIT, ANY(keys)): returns the *full* set of + // clustered merchants, including ones that ranked outside the top 30. + mockSql.mockImplementation(async (strings: TemplateStringsArray) => { + const query = strings.join(''); + if (query.includes('merchant_key = ANY')) { + // full-scope cluster aggregate + return [ + { merchant_key: 'zomato', debit_count: '2', debit_paisa: '6000' }, + { merchant_key: 'swiggy', debit_count: '4', debit_paisa: '12000' }, + { merchant_key: 'blinkit', debit_count: '3', debit_paisa: '9000' }, + ]; + } + // top merchants by frequency (LIMIT 30) — pretend only zomato made the cut + return [{ merchant_key: 'zomato', debit_count: '2', debit_paisa: '6000' }]; + }); + + const GET = await getGet(); + const res = await GET(makeRequest()); + expect(res.status).toBe(200); + const body = (await res.json()) as { + top_merchants: Array<{ merchant_key: string }>; + clusters: Array<{ + cluster: { id: string }; + debitCount: number; + totalDebitPaisa: number; + }>; + }; + + // Top merchants list reflects the LIMIT-capped sample. + expect(body.top_merchants).toHaveLength(1); + expect(body.top_merchants[0].merchant_key).toBe('zomato'); + + // Food delivery cluster total must include BOTH zomato and swiggy + // even though swiggy is not in top_merchants — proving cluster rollups + // aggregate full scope, not the top-N sample. + const food = body.clusters.find((c) => c.cluster.id === 'food_delivery'); + expect(food).toBeDefined(); + expect(food!.debitCount).toBe(6); // 2 + 4 + expect(food!.totalDebitPaisa).toBe(18000); // 6000 + 12000 + + const quick = body.clusters.find((c) => c.cluster.id === 'quick_commerce'); + expect(quick).toBeDefined(); + expect(quick!.debitCount).toBe(3); + expect(quick!.totalDebitPaisa).toBe(9000); + }); + + it('returns 401 when unauthenticated', async () => { + mockGetSessionUser.mockResolvedValueOnce(null); + const GET = await getGet(); + const res = await GET(makeRequest()); + expect(res.status).toBe(401); + }); +}); diff --git a/apps/money-mirror/src/app/api/insights/frequency-clusters/route.ts b/apps/money-mirror/src/app/api/insights/frequency-clusters/route.ts new file mode 100644 index 0000000..eb84177 --- /dev/null +++ b/apps/money-mirror/src/app/api/insights/frequency-clusters/route.ts @@ -0,0 +1,95 @@ +import * as Sentry from '@sentry/nextjs'; +import { NextRequest, NextResponse } from 'next/server'; +import { getSessionUser } from '@/lib/auth/session'; +import { getDb } from '@/lib/db'; +import { checkRateLimit } from '@/lib/rate-limit'; +import { captureServerEvent } from '@/lib/posthog'; +import { parseDashboardScopeFromSearchParams } from '@/lib/scope'; +import { + fetchClusterMerchantAggregates, + fetchTopMerchantsByFrequency, + type FrequencyMerchantRow, +} from '@/lib/dashboard-helpers'; +import { + ALL_CLUSTER_MERCHANT_KEYS, + buildClusterRollups, + type ClusterRollup, +} from '@/lib/merchant-clusters'; + +const HEAVY_READ_LIMIT = { limit: 40, windowMs: 60_000 }; + +export interface FrequencyClustersResponse { + top_merchants: FrequencyMerchantRow[]; + clusters: ClusterRollup[]; +} + +export async function GET(req: NextRequest): Promise { + const user = await getSessionUser(); + if (!user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const rate = checkRateLimit(`freq-clusters:get:${user.id}`, HEAVY_READ_LIMIT); + if (!rate.ok) { + void captureServerEvent(user.id, 'rate_limit_hit', { + route: '/api/insights/frequency-clusters', + retry_after_sec: rate.retryAfterSec, + }).catch(() => {}); + return NextResponse.json( + { error: 'Too many requests. Please wait before retrying.' }, + { status: 429, headers: { 'Retry-After': String(rate.retryAfterSec) } } + ); + } + + const parsed = parseDashboardScopeFromSearchParams(req.nextUrl.searchParams); + if ('error' in parsed) { + return NextResponse.json({ error: parsed.error }, { status: 400 }); + } + + let dateFrom: string; + let dateTo: string; + let statementIds: string[] | null; + + if (parsed.variant === 'unified') { + dateFrom = parsed.scope.dateFrom; + dateTo = parsed.scope.dateTo; + statementIds = parsed.scope.statementIds; + } else { + return NextResponse.json( + { error: 'Frequency clusters require unified scope (date_from + date_to).' }, + { status: 400 } + ); + } + + try { + const sql = getDb(); + // top_merchants powers the UI preview list, so a LIMIT is fine here. + // Cluster rollups, however, must reflect full scope — they are derived + // from a separate query bounded by the static cluster key set, never + // from the LIMIT-capped top-N sample. + const [topMerchants, clusterMerchantRows] = await Promise.all([ + fetchTopMerchantsByFrequency(sql, user.id, dateFrom, dateTo, statementIds), + fetchClusterMerchantAggregates( + sql, + user.id, + dateFrom, + dateTo, + statementIds, + ALL_CLUSTER_MERCHANT_KEYS + ), + ]); + + const clusters = buildClusterRollups(clusterMerchantRows); + + const body: FrequencyClustersResponse = { + top_merchants: topMerchants.slice(0, 10), + clusters, + }; + + return NextResponse.json(body); + } catch (err) { + Sentry.captureException(err); + console.error('[GET /api/insights/frequency-clusters]', err); + return NextResponse.json({ error: 'Failed to load frequency data' }, { status: 500 }); + } +} diff --git a/apps/money-mirror/src/app/api/insights/merchants/route.ts b/apps/money-mirror/src/app/api/insights/merchants/route.ts index 6a00444..7a28908 100644 --- a/apps/money-mirror/src/app/api/insights/merchants/route.ts +++ b/apps/money-mirror/src/app/api/insights/merchants/route.ts @@ -17,6 +17,8 @@ import { type MerchantRollupParams, } from '@/lib/merchant-rollups'; import { parseStatementIdsParam } from '@/lib/scope'; +import { checkRateLimit } from '@/lib/rate-limit'; +import { captureServerEvent } from '@/lib/posthog'; function parseDateOnly(raw: string | null): string | null { if (!raw) { @@ -42,12 +44,26 @@ function parseUuid(raw: string | null): string | null { return raw; } +const HEAVY_READ_LIMIT = { limit: 40, windowMs: 60_000 }; + export async function GET(req: NextRequest): Promise { const user = await getSessionUser(); if (!user) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); } + const rate = checkRateLimit(`insights:merchants:get:${user.id}`, HEAVY_READ_LIMIT); + if (!rate.ok) { + void captureServerEvent(user.id, 'rate_limit_hit', { + route: '/api/insights/merchants', + retry_after_sec: rate.retryAfterSec, + }).catch(() => {}); + return NextResponse.json( + { error: 'Too many requests. Please wait before retrying.' }, + { status: 429, headers: { 'Retry-After': String(rate.retryAfterSec) } } + ); + } + const sp = req.nextUrl.searchParams; const limitRaw = sp.get('limit'); const limit = Math.min( diff --git a/apps/money-mirror/src/app/api/merchants/alias/__tests__/route.test.ts b/apps/money-mirror/src/app/api/merchants/alias/__tests__/route.test.ts new file mode 100644 index 0000000..177e0bb --- /dev/null +++ b/apps/money-mirror/src/app/api/merchants/alias/__tests__/route.test.ts @@ -0,0 +1,78 @@ +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 getHandlers() { + const mod = await import('@/app/api/merchants/alias/route'); + return { GET: mod.GET, POST: mod.POST, DELETE: mod.DELETE }; +} + +describe('/api/merchants/alias', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockGetSessionUser.mockResolvedValue({ + id: 'user-1', + email: 'u@example.com', + name: 'U', + }); + mockEnsureProfile.mockResolvedValue(undefined); + mockCaptureServerEvent.mockResolvedValue(undefined); + mockSql.mockResolvedValue([]); + }); + + it('GET returns 401 when unauthenticated', async () => { + mockGetSessionUser.mockResolvedValueOnce(null); + const { GET } = await getHandlers(); + const res = await GET(); + expect(res.status).toBe(401); + }); + + it('POST returns 400 for empty display_label', async () => { + const { POST } = await getHandlers(); + const req = new NextRequest('http://localhost/api/merchants/alias', { + method: 'POST', + body: JSON.stringify({ merchant_key: 'zomato', display_label: ' ' }), + }); + const res = await POST(req); + expect(res.status).toBe(400); + }); + + it('POST upserts alias and fires telemetry', async () => { + mockSql.mockResolvedValue(undefined); + const { POST } = await getHandlers(); + const req = new NextRequest('http://localhost/api/merchants/alias', { + method: 'POST', + body: JSON.stringify({ merchant_key: 'zomato', display_label: 'Zomato' }), + }); + const res = await POST(req); + expect(res.status).toBe(200); + expect(mockCaptureServerEvent).toHaveBeenCalledWith( + 'user-1', + 'merchant_alias_saved', + expect.objectContaining({ merchant_key_bucket: expect.any(String) }) + ); + }); + + it('DELETE returns 400 without merchant_key', async () => { + const { DELETE } = await getHandlers(); + const res = await DELETE(new NextRequest('http://localhost/api/merchants/alias')); + expect(res.status).toBe(400); + }); +}); diff --git a/apps/money-mirror/src/app/api/merchants/alias/route.ts b/apps/money-mirror/src/app/api/merchants/alias/route.ts new file mode 100644 index 0000000..d129e1a --- /dev/null +++ b/apps/money-mirror/src/app/api/merchants/alias/route.ts @@ -0,0 +1,156 @@ +/** + * GET/POST/DELETE /api/merchants/alias + * + * User-defined display labels for normalized merchant_key values. + * Single emission source: server-side `merchant_alias_saved` on successful POST. + */ + +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 { isUndefinedColumnError, SCHEMA_UPGRADE_HINT } from '@/lib/pg-errors'; +import { captureServerEvent } from '@/lib/posthog'; + +function bucketMerchantKey(key: string): string { + if (key.length <= 8) { + return key; + } + return `${key.slice(0, 4)}…${key.slice(-4)}`; +} + +function validateMerchantKey(raw: unknown): string | null { + if (typeof raw !== 'string') { + return null; + } + const s = raw.trim(); + if (s.length < 1 || s.length > 128) { + return null; + } + return s; +} + +function validateDisplayLabel(raw: unknown): string | null { + if (typeof raw !== 'string') { + return null; + } + const s = raw.trim(); + if (s.length < 1 || s.length > 120) { + return null; + } + return s; +} + +export async function GET(): Promise { + const user = await getSessionUser(); + if (!user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + try { + await ensureProfile({ id: user.id, email: user.email }); + const sql = getDb(); + const rows = (await sql` + SELECT merchant_key, display_label, updated_at + FROM user_merchant_aliases + WHERE user_id = ${user.id} + ORDER BY updated_at DESC + `) as { merchant_key: string; display_label: string; updated_at: string }[]; + + return NextResponse.json({ aliases: rows }); + } catch (e) { + Sentry.captureException(e); + console.error('[merchants/alias] GET failed:', e); + if (isUndefinedColumnError(e)) { + return NextResponse.json( + { error: "Can't load aliases", code: 'SCHEMA_DRIFT', detail: SCHEMA_UPGRADE_HINT }, + { status: 500 } + ); + } + return NextResponse.json({ error: 'Failed to load aliases' }, { status: 500 }); + } +} + +export async function POST(req: NextRequest): Promise { + const user = await getSessionUser(); + if (!user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + let body: unknown; + try { + body = await req.json(); + } catch { + return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 }); + } + + const b = body as Record; + const merchantKey = validateMerchantKey(b.merchant_key); + const displayLabel = validateDisplayLabel(b.display_label); + if (!merchantKey || !displayLabel) { + return NextResponse.json( + { error: 'Invalid merchant_key or display_label (1–128 / 1–120 chars).' }, + { status: 400 } + ); + } + + try { + await ensureProfile({ id: user.id, email: user.email }); + const sql = getDb(); + await sql` + INSERT INTO user_merchant_aliases (user_id, merchant_key, display_label, updated_at) + VALUES (${user.id}, ${merchantKey}, ${displayLabel}, timezone('utc', now())) + ON CONFLICT (user_id, merchant_key) DO UPDATE SET + display_label = EXCLUDED.display_label, + updated_at = timezone('utc', now()) + `; + + void captureServerEvent(user.id, 'merchant_alias_saved', { + merchant_key_bucket: bucketMerchantKey(merchantKey), + }).catch(() => {}); + + return NextResponse.json({ ok: true, merchant_key: merchantKey, display_label: displayLabel }); + } catch (e) { + Sentry.captureException(e); + console.error('[merchants/alias] POST failed:', e); + if (isUndefinedColumnError(e)) { + return NextResponse.json( + { error: "Can't save alias", code: 'SCHEMA_DRIFT', detail: SCHEMA_UPGRADE_HINT }, + { status: 500 } + ); + } + return NextResponse.json({ error: 'Failed to save alias' }, { status: 500 }); + } +} + +export async function DELETE(req: NextRequest): Promise { + const user = await getSessionUser(); + if (!user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const merchantKey = validateMerchantKey(req.nextUrl.searchParams.get('merchant_key')); + if (!merchantKey) { + return NextResponse.json({ error: 'Invalid or missing merchant_key.' }, { status: 400 }); + } + + try { + await ensureProfile({ id: user.id, email: user.email }); + const sql = getDb(); + await sql` + DELETE FROM user_merchant_aliases + WHERE user_id = ${user.id} AND merchant_key = ${merchantKey} + `; + return NextResponse.json({ ok: true }); + } catch (e) { + Sentry.captureException(e); + console.error('[merchants/alias] DELETE failed:', e); + if (isUndefinedColumnError(e)) { + return NextResponse.json( + { error: "Can't remove alias", code: 'SCHEMA_DRIFT', detail: SCHEMA_UPGRADE_HINT }, + { status: 500 } + ); + } + return NextResponse.json({ error: 'Failed to remove alias' }, { status: 500 }); + } +} diff --git a/apps/money-mirror/src/app/api/merchants/suggest-accept/route.ts b/apps/money-mirror/src/app/api/merchants/suggest-accept/route.ts new file mode 100644 index 0000000..8b7707a --- /dev/null +++ b/apps/money-mirror/src/app/api/merchants/suggest-accept/route.ts @@ -0,0 +1,93 @@ +/** + * POST /api/merchants/suggest-accept + * + * Applies a stored Gemini suggestion as the user’s merchant display label. + * Single emission source: server-side `merchant_suggestion_accepted`. + */ + +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 { isUndefinedColumnError, SCHEMA_UPGRADE_HINT } from '@/lib/pg-errors'; +import { captureServerEvent } from '@/lib/posthog'; + +function bucketMerchantKey(key: string): string { + if (key.length <= 8) { + return key; + } + return `${key.slice(0, 4)}…${key.slice(-4)}`; +} + +function validateMerchantKey(raw: unknown): string | null { + if (typeof raw !== 'string') { + return null; + } + const s = raw.trim(); + if (s.length < 1 || s.length > 128) { + return null; + } + return s; +} + +export async function POST(req: NextRequest): Promise { + const user = await getSessionUser(); + if (!user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + let body: unknown; + try { + body = await req.json(); + } catch { + return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 }); + } + + const merchantKey = validateMerchantKey((body as { merchant_key?: unknown }).merchant_key); + if (!merchantKey) { + return NextResponse.json({ error: 'Invalid merchant_key.' }, { status: 400 }); + } + + try { + await ensureProfile({ id: user.id, email: user.email }); + const sql = getDb(); + const rows = (await sql` + SELECT suggested_label, confidence + FROM merchant_label_suggestions + WHERE user_id = ${user.id} AND merchant_key = ${merchantKey} + LIMIT 1 + `) as { suggested_label: string; confidence: string | number | null }[]; + + const row = rows[0]; + if (!row?.suggested_label?.trim()) { + return NextResponse.json({ error: 'No suggestion for this merchant.' }, { status: 404 }); + } + + const label = row.suggested_label.trim(); + + await sql` + INSERT INTO user_merchant_aliases (user_id, merchant_key, display_label, updated_at) + VALUES (${user.id}, ${merchantKey}, ${label}, timezone('utc', now())) + ON CONFLICT (user_id, merchant_key) DO UPDATE SET + display_label = EXCLUDED.display_label, + updated_at = timezone('utc', now()) + `; + + void captureServerEvent(user.id, 'merchant_suggestion_accepted', { + merchant_key_bucket: bucketMerchantKey(merchantKey), + confidence: row.confidence != null ? Number(row.confidence) : undefined, + }).catch(() => {}); + + return NextResponse.json({ ok: true, merchant_key: merchantKey, display_label: label }); + } catch (e) { + Sentry.captureException(e); + console.error('[merchants/suggest-accept] POST failed:', e); + if (isUndefinedColumnError(e)) { + return NextResponse.json( + { error: "Can't apply suggestion", code: 'SCHEMA_DRIFT', detail: SCHEMA_UPGRADE_HINT }, + { status: 500 } + ); + } + return NextResponse.json({ error: 'Failed to apply suggestion' }, { status: 500 }); + } +} diff --git a/apps/money-mirror/src/app/api/proactive/push-subscription/route.ts b/apps/money-mirror/src/app/api/proactive/push-subscription/route.ts new file mode 100644 index 0000000..417ef34 --- /dev/null +++ b/apps/money-mirror/src/app/api/proactive/push-subscription/route.ts @@ -0,0 +1,27 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getSessionUser } from '@/lib/auth/session'; +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 }); + } + + const body = (await req.json().catch(() => null)) as { + endpoint?: string; + user_agent?: string; + } | null; + const endpoint = body?.endpoint?.trim() ?? ''; + if (!endpoint) { + return NextResponse.json({ error: 'endpoint is required.' }, { status: 400 }); + } + + // Single emission source: server-side in /api/proactive/push-subscription + captureServerEvent(user.id, 'push_subscription_granted', { + endpoint_hash: endpoint.slice(-24), + user_agent: body?.user_agent?.slice(0, 120) ?? null, + }).catch(() => {}); + + return NextResponse.json({ ok: true }); +} diff --git a/apps/money-mirror/src/app/api/proactive/whatsapp-opt-in/route.ts b/apps/money-mirror/src/app/api/proactive/whatsapp-opt-in/route.ts new file mode 100644 index 0000000..a0f12bb --- /dev/null +++ b/apps/money-mirror/src/app/api/proactive/whatsapp-opt-in/route.ts @@ -0,0 +1,67 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getSessionUser } from '@/lib/auth/session'; +import { captureServerEvent } from '@/lib/posthog'; + +interface WhatsAppOptInPayload { + phone_e164?: string; +} + +function isValidE164(raw: string): boolean { + return /^\+[1-9]\d{7,14}$/.test(raw); +} + +export async function POST(req: NextRequest): Promise { + const user = await getSessionUser(); + if (!user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const body = (await req.json().catch(() => null)) as WhatsAppOptInPayload | null; + const phone = body?.phone_e164?.trim() ?? ''; + if (!phone || !isValidE164(phone)) { + return NextResponse.json( + { error: 'phone_e164 must be a valid E.164 number.' }, + { status: 400 } + ); + } + + const apiUrl = process.env.WHATSAPP_API_URL?.trim(); + const apiToken = process.env.WHATSAPP_API_TOKEN?.trim(); + + // Single emission source: server-side in /api/proactive/whatsapp-opt-in + captureServerEvent(user.id, 'whatsapp_opt_in_completed', { + provider_configured: Boolean(apiUrl && apiToken), + country_code: phone.slice(0, 3), + }).catch(() => {}); + + if (!apiUrl || !apiToken) { + return NextResponse.json({ + ok: true, + mode: 'stub', + message: 'WhatsApp provider not configured. Opt-in captured for telemetry only.', + }); + } + + const providerResp = await fetch(apiUrl, { + method: 'POST', + headers: { + Authorization: `Bearer ${apiToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + user_id: user.id, + phone_e164: phone, + source: 'money_mirror_opt_in', + }), + }).catch((e: unknown) => { + console.error('[whatsapp-opt-in] provider request failed:', e); + return null; + }); + + if (!providerResp?.ok) { + console.error('[whatsapp-opt-in] provider returned non-ok:', providerResp?.status); + return NextResponse.json({ error: 'WhatsApp provider registration failed.' }, { status: 502 }); + } + + return NextResponse.json({ ok: true, mode: 'provider' }); +} 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 f6dc4e8..0378ccc 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,6 +1,7 @@ import { randomUUID } from 'node:crypto'; +import * as Sentry from '@sentry/nextjs'; import { getDb, getProfileFinancialSnapshot } from '@/lib/db'; -import { normalizeMerchantKey } from '@/lib/merchant-normalize'; +import { extractUpiHandle, normalizeMerchantKey } from '@/lib/merchant-normalize'; import { captureServerEvent } from '@/lib/posthog'; import type { StatementType } from '@/lib/statements'; @@ -60,6 +61,7 @@ export async function persistStatement( const profile = await getProfileFinancialSnapshot(userId); const transactionQueries = categorized.map((tx) => { const merchantKey = normalizeMerchantKey(tx.description); + const upiHandle = extractUpiHandle(tx.description); return sql` INSERT INTO transactions ( id, @@ -71,7 +73,8 @@ export async function persistStatement( type, category, is_recurring, - merchant_key + merchant_key, + upi_handle ) VALUES ( ${randomUUID()}, @@ -83,7 +86,8 @@ export async function persistStatement( ${tx.type}, ${tx.category}, ${tx.is_recurring}, - ${merchantKey} + ${merchantKey}, + ${upiHandle} ) `; }); @@ -140,10 +144,10 @@ export async function persistStatement( `, ]); } catch (error) { - await captureServerEvent(userId, 'statement_parse_failed', { + Sentry.captureException(error); + captureServerEvent(userId, 'statement_parse_failed', { error_type: 'DB_TRANSACTION_FAILED', - }); - console.error('[persist-statement] transaction failed:', error); + }).catch(() => {}); return { statement_id: '', error: 'Failed to save statement data.' }; } diff --git a/apps/money-mirror/src/app/api/statement/parse/route.ts b/apps/money-mirror/src/app/api/statement/parse/route.ts index 30febbd..66d8298 100644 --- a/apps/money-mirror/src/app/api/statement/parse/route.ts +++ b/apps/money-mirror/src/app/api/statement/parse/route.ts @@ -23,7 +23,8 @@ import { NextRequest, NextResponse } from 'next/server'; import { getSessionUser } from '@/lib/auth/session'; -import { countUserStatementsSince, ensureProfile } from '@/lib/db'; +import { countUserStatementsSince, ensureProfile, getDb } from '@/lib/db'; +import { normalizeUserPlan } from '@/lib/user-plan'; import { extractPdfText, PdfExtractionError } from '@/lib/pdf-parser'; import { categorizeCreditCardTransaction, @@ -124,9 +125,14 @@ export async function POST(req: NextRequest): Promise { fileBuffer = null; const code = err instanceof PdfExtractionError ? err.code : 'PARSE_FAILED'; + const parserDetail = + err instanceof Error ? err.message.slice(0, 200) : String(err).slice(0, 200); captureServerEvent(userId, 'statement_parse_failed', { error_type: code, file_name: fileName, + statement_type: statementType, + parser_stage: 'pdf_text_extraction', + parser_detail: parserDetail, }).catch(() => {}); const errorMessage = code === 'EMPTY_TEXT' @@ -231,8 +237,14 @@ export async function POST(req: NextRequest): Promise { summary.total_debits > 0 ? Math.round((summary.investment / summary.total_debits) * 100) : 0, }).catch(() => {}); + const sql = getDb(); + const planRows = (await sql` + SELECT plan FROM profiles WHERE id = ${userId} LIMIT 1 + `) as { plan: string | null }[]; + return NextResponse.json({ statement_id, + plan: normalizeUserPlan(planRows[0]?.plan), institution_name: parsedStatement.institution_name, statement_type: parsedStatement.statement_type, period_start: parsedStatement.period_start, 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 index 4aa9ac6..15cdfe1 100644 --- a/apps/money-mirror/src/app/api/transactions/__tests__/route.test.ts +++ b/apps/money-mirror/src/app/api/transactions/__tests__/route.test.ts @@ -63,6 +63,8 @@ describe('GET /api/transactions', () => { category: 'wants', is_recurring: false, merchant_key: 'zomato', + upi_handle: null, + merchant_alias_label: null, statement_nickname: 'Main', statement_institution_name: 'HDFC', }, @@ -100,6 +102,8 @@ describe('GET /api/transactions', () => { category: 'wants', is_recurring: false, merchant_key: null, + upi_handle: null, + merchant_alias_label: null, statement_nickname: null, statement_institution_name: 'HDFC', }, @@ -114,6 +118,45 @@ describe('GET /api/transactions', () => { expect(body.total).toBe(1); }); + it('accepts merchant_keys and applies them in the transaction query', async () => { + let call = 0; + mockSql.mockImplementation((strings: TemplateStringsArray, ...values: readonly unknown[]) => { + call += 1; + if (call === 1) { + return Promise.resolve([{ c: '1' }]); + } + expect(values).toContainEqual(['blinkit', 'instamart']); + 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', + upi_handle: null, + merchant_alias_label: null, + statement_nickname: 'Main', + statement_institution_name: 'HDFC', + }, + ]); + }); + + const GET = await getGet(); + const res = await GET( + makeRequest( + 'http://localhost/api/transactions?merchant_keys=blinkit,instamart&date_from=2026-01-01&date_to=2026-01-31' + ) + ); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.total).toBe(1); + expect(body.transactions).toHaveLength(1); + }); + it('returns 404 when statement_id is not owned', async () => { mockSql.mockImplementationOnce(() => Promise.resolve([])); @@ -125,4 +168,24 @@ describe('GET /api/transactions', () => { ); expect(res.status).toBe(404); }); + + it('returns 400 for invalid upi_micro', async () => { + const GET = await getGet(); + const res = await GET(makeRequest('http://localhost/api/transactions?upi_micro=yes')); + expect(res.status).toBe(400); + }); + + it('accepts upi_micro=1', async () => { + mockSql.mockImplementation((strings: TemplateStringsArray) => { + const q = strings[0]?.slice(0, 120) ?? ''; + if (q.includes('COUNT(*)')) { + return Promise.resolve([{ c: '0' }]); + } + return Promise.resolve([]); + }); + + const GET = await getGet(); + const res = await GET(makeRequest('http://localhost/api/transactions?upi_micro=1')); + expect(res.status).toBe(200); + }); }); diff --git a/apps/money-mirror/src/app/api/transactions/route.ts b/apps/money-mirror/src/app/api/transactions/route.ts index 3493945..9512bb0 100644 --- a/apps/money-mirror/src/app/api/transactions/route.ts +++ b/apps/money-mirror/src/app/api/transactions/route.ts @@ -18,9 +18,11 @@ import { } from '@/lib/transactions-list'; import { isUndefinedColumnError, SCHEMA_UPGRADE_HINT } from '@/lib/pg-errors'; import { parseStatementIdsParam } from '@/lib/scope'; +import { checkRateLimit } from '@/lib/rate-limit'; const CATEGORIES = new Set(['needs', 'wants', 'investment', 'debt', 'other']); const TYPES = new Set(['debit', 'credit']); +const HEAVY_READ_LIMIT = { limit: 60, windowMs: 60_000 }; function parseDateOnly(raw: string | null): string | null { if (!raw) { @@ -46,12 +48,43 @@ function parseUuid(raw: string | null): string | null { return raw; } +function parseMerchantKeys(raw: string | null): string[] | null | 'invalid' { + if (raw == null || raw.trim() === '') { + return null; + } + const keys = raw + .split(',') + .map((key) => key.trim()) + .filter(Boolean); + if (keys.length === 0) { + return null; + } + for (const key of keys) { + if (key.length > 128) { + return 'invalid'; + } + } + return keys; +} + export async function GET(req: NextRequest): Promise { const user = await getSessionUser(); if (!user) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); } + const rate = checkRateLimit(`transactions:get:${user.id}`, HEAVY_READ_LIMIT); + if (!rate.ok) { + void captureServerEvent(user.id, 'rate_limit_hit', { + route: '/api/transactions', + retry_after_sec: rate.retryAfterSec, + }).catch(() => {}); + return NextResponse.json( + { error: 'Too many requests. Please wait before retrying.' }, + { status: 429, headers: { 'Retry-After': String(rate.retryAfterSec) } } + ); + } + const sp = req.nextUrl.searchParams; const limitRaw = sp.get('limit'); const offsetRaw = sp.get('offset'); @@ -84,6 +117,11 @@ export async function GET(req: NextRequest): Promise { statementId = null; } + const merchantKeysParsed = parseMerchantKeys(sp.get('merchant_keys')); + if (merchantKeysParsed === 'invalid') { + return NextResponse.json({ error: 'Invalid merchant_keys.' }, { status: 400 }); + } + const category = sp.get('category'); if (category && !CATEGORIES.has(category)) { return NextResponse.json({ error: 'Invalid category.' }, { status: 400 }); @@ -106,7 +144,14 @@ export async function GET(req: NextRequest): Promise { if (merchantKeyRaw && merchantKeyRaw.length > 128) { return NextResponse.json({ error: 'Invalid merchant_key.' }, { status: 400 }); } - const merchantKey = merchantKeyRaw || null; + const merchantKey = + merchantKeysParsed && merchantKeysParsed.length > 0 ? null : merchantKeyRaw || null; + + const upiMicroRaw = sp.get('upi_micro'); + if (upiMicroRaw !== null && upiMicroRaw !== '' && upiMicroRaw !== '1') { + return NextResponse.json({ error: 'Invalid upi_micro (use 1 or omit).' }, { status: 400 }); + } + const upiMicro = upiMicroRaw === '1'; const params: ListTransactionsParams = { userId: user.id, @@ -118,6 +163,8 @@ export async function GET(req: NextRequest): Promise { type: type || null, search, merchantKey, + merchantKeys: merchantKeysParsed && merchantKeysParsed.length > 0 ? merchantKeysParsed : null, + upiMicro, limit, offset, }; @@ -163,7 +210,9 @@ export async function GET(req: NextRequest): Promise { category || type || search || - merchantKey + merchantKey || + (params.merchantKeys && params.merchantKeys.length > 0) || + upiMicro ); if (hasFilters) { const filterTypes: string[] = []; @@ -187,6 +236,12 @@ export async function GET(req: NextRequest): Promise { if (merchantKey) { filterTypes.push('merchant_key'); } + if (params.merchantKeys && params.merchantKeys.length > 0) { + filterTypes.push('merchant_keys'); + } + if (upiMicro) { + filterTypes.push('upi_micro'); + } void captureServerEvent(user.id, 'transactions_filter_applied', { filter_types: filterTypes, scope: diff --git a/apps/money-mirror/src/app/dashboard/DashboardClient.tsx b/apps/money-mirror/src/app/dashboard/DashboardClient.tsx index 5910da0..ba9bbfe 100644 --- a/apps/money-mirror/src/app/dashboard/DashboardClient.tsx +++ b/apps/money-mirror/src/app/dashboard/DashboardClient.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useMemo } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { ScopeBar } from '@/components/ScopeBar'; import { UploadPanel } from './UploadPanel'; import { ParsingPanel } from './ParsingPanel'; @@ -11,13 +11,27 @@ import { StatementFilters } from './StatementFilters'; import { DashboardLoadingSkeleton } from './DashboardLoadingSkeleton'; import { DashboardBrandBar } from './DashboardBrandBar'; import { TransactionsPanel } from './TransactionsPanel'; +import { GuidedReviewSheet } from '@/components/GuidedReviewSheet'; +import { normalizeUserPlan } from '@/lib/user-plan'; +import { + DASHBOARD_READY_MS, + TIME_TO_FIRST_ADVISORY_MS, + getPosthogBrowser, +} from '@/lib/posthog-browser'; import { useDashboardState, tabFromSearchParams } from './useDashboardState'; export function DashboardClient() { + const mountTimeRef = useRef(0); + const dashboardReadyFiredRef = useRef(false); + const advisoryReadyFiredRef = useRef(false); + const [deferredReady, setDeferredReady] = useState(false); + const [reviewOpen, setReviewOpen] = useState(false); + const { router, searchParams, isUnifiedUrl, + dashboardScopeKey, result, advisories, coachingFacts, @@ -40,6 +54,49 @@ export function DashboardClient() { } = useDashboardState(); const tab = useMemo(() => tabFromSearchParams(searchParams), [searchParams]); + const paywallFeatureEnabled = process.env.NEXT_PUBLIC_PAYWALL_PROMPT_ENABLED === '1'; + + // Reset SLA timers whenever the canonical dashboard scope changes so that + // dashboard_ready_ms / time_to_first_advisory_ms reflect *current* scope — + // not just the first mount. The timer origin moves with the scope key. + // (deferredReady intentionally stays true once set — it gates fade-in only, + // not telemetry, so resetting it would unmount content during scope swaps.) + useEffect(() => { + mountTimeRef.current = performance.now(); + dashboardReadyFiredRef.current = false; + advisoryReadyFiredRef.current = false; + }, [dashboardScopeKey]); + + useEffect(() => { + if (isLoadingDashboard || !result) return; + if (!dashboardReadyFiredRef.current) { + dashboardReadyFiredRef.current = true; + const durationMs = Math.round(performance.now() - mountTimeRef.current); + void getPosthogBrowser().then((ph) => { + if (!ph) return; + ph.capture(DASHBOARD_READY_MS, { duration_ms: durationMs }); + }); + requestAnimationFrame(() => setDeferredReady(true)); + } + }, [isLoadingDashboard, result]); + + const handleAdvisoryFeedRendered = useCallback( + ({ advisory_count }: { advisory_count: number }) => { + if (advisoryReadyFiredRef.current) { + return; + } + advisoryReadyFiredRef.current = true; + const durationMs = Math.round(performance.now() - mountTimeRef.current); + void getPosthogBrowser().then((ph) => { + if (!ph) return; + ph.capture(TIME_TO_FIRST_ADVISORY_MS, { + duration_ms: durationMs, + advisory_count, + }); + }); + }, + [] + ); const hasStatements = Boolean(statements && statements.length > 0); const showTabs = hasStatements && !isLoadingDashboard; @@ -79,11 +136,7 @@ export function DashboardClient() { active={tab} onChange={(t) => { const q = new URLSearchParams(searchParams.toString()); - if (t === 'overview') { - q.delete('tab'); - } else { - q.set('tab', t); - } + q.set('tab', t); router.replace(`/dashboard?${q.toString()}`, { scroll: false }); if (t !== 'upload') { setError(null); @@ -117,6 +170,22 @@ export function DashboardClient() { {tab === 'overview' && scopeAndFilters && result && ( <> {scopeAndFilters} + {deferredReady && ( + + )} )} @@ -146,6 +219,7 @@ export function DashboardClient() { txnScope={txnScope} coachingFacts={coachingFacts} isLoadingNarratives={isLoadingNarratives} + onAdvisoryFeedRendered={handleAdvisoryFeedRendered} /> )} @@ -156,6 +230,11 @@ export function DashboardClient() { )} + setReviewOpen(false)} + statementId={effectiveSelectedId} + /> ); } diff --git a/apps/money-mirror/src/app/dashboard/DashboardLoadingSkeleton.tsx b/apps/money-mirror/src/app/dashboard/DashboardLoadingSkeleton.tsx index b131b08..291075a 100644 --- a/apps/money-mirror/src/app/dashboard/DashboardLoadingSkeleton.tsx +++ b/apps/money-mirror/src/app/dashboard/DashboardLoadingSkeleton.tsx @@ -1,18 +1,57 @@ +/** + * Skeleton-first loading state that matches the final Overview layout + * to prevent layout shift when Mirror + totals hydrate (issue-012 T0.2). + */ export function DashboardLoadingSkeleton() { return (
-
-
+ {/* Mirror heading placeholder */} +
+
+
+
+
+ + {/* Perceived vs Actual mirror card */} +
+ + {/* Total Spent / Total Income 2×1 grid */} +
+
+
+
+ + {/* Previous period comparison card */} +
+ + {/* Category breakdown rows */} +
+
+
+ {[1, 2, 3, 4, 5].map((i) => ( +
+ ))} +
+
); } diff --git a/apps/money-mirror/src/app/dashboard/FrequencyClusterSection.tsx b/apps/money-mirror/src/app/dashboard/FrequencyClusterSection.tsx new file mode 100644 index 0000000..d1dccfa --- /dev/null +++ b/apps/money-mirror/src/app/dashboard/FrequencyClusterSection.tsx @@ -0,0 +1,150 @@ +'use client'; + +import { useState } from 'react'; +import type { FrequencyMerchantRow } from '@/lib/dashboard-helpers'; +import type { ClusterRollup } from '@/lib/merchant-clusters'; +import { formatMerchantKeyForDisplay } from '@/lib/merchant-normalize'; + +export function FrequencyClusterSection({ + topMerchants, + clusters, + loading, + onClusterClick, + onFrequencyOpen, +}: { + topMerchants: FrequencyMerchantRow[]; + clusters: ClusterRollup[]; + loading: boolean; + onClusterClick: (c: ClusterRollup) => void; + onFrequencyOpen: () => void; +}) { + const [expanded, setExpanded] = useState(false); + + if (loading) { + return ( +
+
+
+
+ ); + } + + if (topMerchants.length === 0 && clusters.length === 0) return null; + + const top5 = topMerchants.slice(0, 5); + + return ( +
+ {clusters.length > 0 && ( +
+

+ Spending clusters +

+
+ {clusters.map((c) => ( + + ))} +
+
+ )} + + {top5.length > 0 && ( +
+ + + {expanded && ( +
+ {top5.map((m) => ( +
+ + {formatMerchantKeyForDisplay(m.merchant_key)} + + + {m.debit_count}× • ₹{Math.round(m.debit_paisa / 100).toLocaleString('en-IN')} + +
+ ))} +
+ )} +
+ )} +
+ ); +} diff --git a/apps/money-mirror/src/app/dashboard/InsightsPanel.tsx b/apps/money-mirror/src/app/dashboard/InsightsPanel.tsx index f6a2ddb..61bbebf 100644 --- a/apps/money-mirror/src/app/dashboard/InsightsPanel.tsx +++ b/apps/money-mirror/src/app/dashboard/InsightsPanel.tsx @@ -1,17 +1,38 @@ 'use client'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import { useRouter, useSearchParams } from 'next/navigation'; 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 { + FREQUENCY_INSIGHT_OPENED, + MERCHANT_CLUSTER_CLICKED, + getPosthogBrowser, +} from '@/lib/posthog-browser'; +import type { FrequencyMerchantRow } from '@/lib/dashboard-helpers'; +import type { ClusterRollup } from '@/lib/merchant-clusters'; import type { TxnScope } from './TransactionsPanel'; +import { FrequencyClusterSection } from './FrequencyClusterSection'; interface InsightsPanelProps { advisories: Advisory[]; txnScope: TxnScope | null; coachingFacts: LayerAFacts | null; - /** True while Gemini coaching narratives load (after fast dashboard). */ isLoadingNarratives?: boolean; + onAdvisoryFeedRendered?: (payload: { advisory_count: number }) => void; +} + +function buildScopeParams(scope: TxnScope): string { + if (scope.mode === 'unified') { + const q = new URLSearchParams(); + q.set('date_from', scope.dateFrom); + q.set('date_to', scope.dateTo); + if (scope.statementIds?.length) q.set('statement_ids', scope.statementIds.join(',')); + return `?${q.toString()}`; + } + return `?statement_id=${scope.mode === 'legacy' ? scope.statementId : ''}`; } export function InsightsPanel({ @@ -19,7 +40,91 @@ export function InsightsPanel({ txnScope, coachingFacts, isLoadingNarratives = false, + onAdvisoryFeedRendered, }: InsightsPanelProps) { + const router = useRouter(); + const searchParams = useSearchParams(); + const [topMerchants, setTopMerchants] = useState([]); + const [clusters, setClusters] = useState([]); + const [freqLoading, setFreqLoading] = useState(false); + const abortRef = useRef(null); + const fetchedScopeRef = useRef(null); + const advisoryRenderedScopeRef = useRef(null); + + const scopeKey = txnScope ? JSON.stringify(txnScope) : null; + + const loadFrequencyData = useCallback(async () => { + if (!txnScope || txnScope.mode !== 'unified') return; + const key = JSON.stringify(txnScope); + if (fetchedScopeRef.current === key) return; + + abortRef.current?.abort(); + const ac = new AbortController(); + abortRef.current = ac; + setFreqLoading(true); + + try { + const params = buildScopeParams(txnScope); + const resp = await fetch(`/api/insights/frequency-clusters${params}`, { signal: ac.signal }); + if (!resp.ok) return; + const data = await resp.json(); + setTopMerchants(data.top_merchants ?? []); + setClusters(data.clusters ?? []); + fetchedScopeRef.current = key; + } catch (err) { + if (err instanceof Error && err.name === 'AbortError') return; + } finally { + setFreqLoading(false); + } + }, [txnScope]); + + useEffect(() => { + fetchedScopeRef.current = null; + }, [scopeKey]); + + useEffect(() => { + if (!scopeKey || !txnScope || !onAdvisoryFeedRendered) { + return; + } + if (advisories.length === 0 && isLoadingNarratives) { + return; + } + if (advisoryRenderedScopeRef.current === scopeKey) { + return; + } + advisoryRenderedScopeRef.current = scopeKey; + onAdvisoryFeedRendered({ advisory_count: advisories.length }); + }, [advisories.length, isLoadingNarratives, onAdvisoryFeedRendered, scopeKey, txnScope]); + + useEffect(() => { + void loadFrequencyData(); + }, [loadFrequencyData]); + + const handleClusterClick = (cluster: ClusterRollup) => { + void getPosthogBrowser().then((ph) => { + if (!ph) return; + ph.capture(MERCHANT_CLUSTER_CLICKED, { + cluster_id: cluster.cluster.id, + cluster_label: cluster.cluster.label, + merchant_count: cluster.merchantKeys.length, + }); + }); + const q = new URLSearchParams(searchParams.toString()); + q.set('tab', 'transactions'); + q.delete('merchant_key'); + q.set('merchant_keys', cluster.merchantKeys.join(',')); + router.push(`/dashboard?${q.toString()}`); + }; + + const handleFrequencyOpen = () => { + void getPosthogBrowser().then((ph) => { + if (!ph) return; + ph.capture(FREQUENCY_INSIGHT_OPENED, { + top_merchant_count: topMerchants.length, + }); + }); + }; + return (
+ {txnScope?.mode === 'unified' && ( + + )} + {txnScope ? : null}

(null); const totalSpent = Math.round(summary.total_debits_paisa / 100).toLocaleString('en-IN'); const totalIncome = Math.round(summary.total_credits_paisa / 100).toLocaleString('en-IN'); const creditsLabel = getCreditsLabel(statement_type); @@ -90,7 +111,6 @@ export function ResultsPanel({ metaBits.push(card_network.trim()); } const purpose = purposeLabel(account_purpose); - return (
- + +
+ + -
-
-
- Total Spent -
-
- ₹{totalSpent} -
-
-
-
- {creditsLabel} -
-
- ₹{totalIncome} -
-
-
+ + + {statement_type === 'credit_card' && ( -
- {payment_due_paisa !== null && ( -
-
- Payment Due -
-
- ₹{Math.round(payment_due_paisa / 100).toLocaleString('en-IN')} -
-
- )} - {minimum_due_paisa !== null && ( -
-
- Minimum Due -
-
- ₹{Math.round(minimum_due_paisa / 100).toLocaleString('en-IN')} -
-
- )} - {due_date && ( -
-
- Due Date -
-
- {formatStatementDate(due_date)} -
-
- )} - {credit_limit_paisa !== null && ( -
-
- Credit Limit -
-
- ₹{Math.round(credit_limit_paisa / 100).toLocaleString('en-IN')} -
-
- )} -
+ )}
@@ -257,33 +211,7 @@ export function ResultsPanel({

-
- {typeof navigator !== 'undefined' && navigator.share && ( - - )} -

- Share anonymously — your data stays private. -

-
+
); } diff --git a/apps/money-mirror/src/app/dashboard/TransactionsPanel.tsx b/apps/money-mirror/src/app/dashboard/TransactionsPanel.tsx index 08f38ba..451e7ad 100644 --- a/apps/money-mirror/src/app/dashboard/TransactionsPanel.tsx +++ b/apps/money-mirror/src/app/dashboard/TransactionsPanel.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useCallback, useEffect, useRef, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useRouter, useSearchParams } from 'next/navigation'; import { TxnFilterBar } from './TxnFilterBar'; import { TxnRow, type TxRow } from './TxnRow'; @@ -24,7 +24,25 @@ interface TransactionsPanelProps { export function TransactionsPanel({ txnScope }: TransactionsPanelProps) { const router = useRouter(); const searchParams = useSearchParams(); + const merchantKeysParam = searchParams.get('merchant_keys')?.trim() ?? ''; + const merchantKeysFromUrl = useMemo( + () => + merchantKeysParam + ? merchantKeysParam + .split(',') + .map((key) => key.trim()) + .filter(Boolean) + : [], + [merchantKeysParam] + ); const merchantFromUrl = searchParams.get('merchant_key')?.trim() ?? ''; + const merchantFilterLabel = useMemo(() => { + if (merchantKeysFromUrl.length > 0) { + return merchantKeysFromUrl.map((key) => key.replace(/_/g, ' ')).join(', '); + } + return merchantFromUrl.replace(/_/g, ' '); + }, [merchantFromUrl, merchantKeysFromUrl]); + const upiMicroFromUrl = searchParams.get('upi_micro') === '1'; const [rows, setRows] = useState([]); const [total, setTotal] = useState(0); const [loading, setLoading] = useState(true); @@ -75,7 +93,12 @@ export function TransactionsPanel({ txnScope }: TransactionsPanelProps) { 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); + if (merchantKeysFromUrl.length > 0) { + q.set('merchant_keys', merchantKeysFromUrl.join(',')); + } else if (merchantFromUrl) { + q.set('merchant_key', merchantFromUrl); + } + if (upiMicroFromUrl) q.set('upi_micro', '1'); try { const resp = await fetch(`/api/transactions?${q.toString()}`, { signal: ac.signal }); @@ -104,18 +127,42 @@ export function TransactionsPanel({ txnScope }: TransactionsPanelProps) { setLoading(false); } }, - [txnScope, category, type, debouncedSearch, merchantFromUrl] + [ + txnScope, + category, + type, + debouncedSearch, + merchantFromUrl, + merchantKeysFromUrl, + upiMicroFromUrl, + ] ); 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]); + }, [ + txnScope, + category, + type, + debouncedSearch, + merchantFromUrl, + merchantKeysFromUrl, + upiMicroFromUrl, + load, + ]); const clearMerchantFilter = useCallback(() => { const q = new URLSearchParams(searchParams.toString()); q.delete('merchant_key'); + q.delete('merchant_keys'); + router.replace(`/dashboard?${q.toString()}`, { scroll: false }); + }, [router, searchParams]); + + const clearUpiMicroFilter = useCallback(() => { + const q = new URLSearchParams(searchParams.toString()); + q.delete('upi_micro'); router.replace(`/dashboard?${q.toString()}`, { scroll: false }); }, [router, searchParams]); @@ -133,8 +180,10 @@ export function TransactionsPanel({ txnScope }: TransactionsPanelProps) { onCategoryChange={setCategory} type={type} onTypeChange={setType} - merchantFromUrl={merchantFromUrl} + merchantFromUrl={merchantFilterLabel} onClearMerchant={clearMerchantFilter} + upiMicroFromUrl={upiMicroFromUrl} + onClearUpiMicro={clearUpiMicroFilter} /> {error && ( diff --git a/apps/money-mirror/src/app/dashboard/TxnFilterBar.tsx b/apps/money-mirror/src/app/dashboard/TxnFilterBar.tsx index 30c3161..0b34e80 100644 --- a/apps/money-mirror/src/app/dashboard/TxnFilterBar.tsx +++ b/apps/money-mirror/src/app/dashboard/TxnFilterBar.tsx @@ -22,6 +22,8 @@ interface TxnFilterBarProps { onTypeChange: (v: string) => void; merchantFromUrl: string; onClearMerchant: () => void; + upiMicroFromUrl: boolean; + onClearUpiMicro: () => void; } export function TxnFilterBar({ @@ -33,12 +35,42 @@ export function TxnFilterBar({ onTypeChange, merchantFromUrl, onClearMerchant, + upiMicroFromUrl, + onClearUpiMicro, }: TxnFilterBarProps) { return (
+ {upiMicroFromUrl ? ( +
+ + Filtered by small UPI (≤₹500, with VPA) + + +
+ ) : null} {merchantFromUrl ? (
{tx.date} · {tx.category} - {tx.merchant_key ? ( - · {tx.merchant_key} + {merchantLabel ? ( + · {merchantLabel} + ) : null} + {tx.upi_handle ? ( + + UPI {tx.upi_handle} + ) : null}
diff --git a/apps/money-mirror/src/app/dashboard/UploadPanel.tsx b/apps/money-mirror/src/app/dashboard/UploadPanel.tsx index 9e65438..0ea6b7d 100644 --- a/apps/money-mirror/src/app/dashboard/UploadPanel.tsx +++ b/apps/money-mirror/src/app/dashboard/UploadPanel.tsx @@ -60,8 +60,8 @@ export function UploadPanel({ Upload your statement

- Upload a password-free PDF from your bank account or credit card. We'll add it to - your dashboard so you can switch between statements anytime. + Drop a password-free bank or card PDF — we'll show you where it all went, no + judgement. Upload more statements over time to see the full picture.

@@ -174,9 +174,21 @@ export function UploadPanel({ padding: '12px 16px', borderRadius: '12px', fontSize: '0.85rem', + display: 'flex', + flexDirection: 'column', + gap: '10px', + alignItems: 'center', }} > - {error} + {error} +
)} diff --git a/apps/money-mirror/src/app/dashboard/__tests__/InsightsPanel.test.tsx b/apps/money-mirror/src/app/dashboard/__tests__/InsightsPanel.test.tsx new file mode 100644 index 0000000..7029aad --- /dev/null +++ b/apps/money-mirror/src/app/dashboard/__tests__/InsightsPanel.test.tsx @@ -0,0 +1,173 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { InsightsPanel } from '@/app/dashboard/InsightsPanel'; + +const mockPush = vi.fn(); + +vi.mock('next/navigation', () => ({ + useRouter: () => ({ push: mockPush, replace: vi.fn() }), + useSearchParams: () => new URLSearchParams('tab=insights'), +})); + +vi.mock('@/lib/posthog-browser', () => ({ + getPosthogBrowser: vi.fn(async () => null), + FREQUENCY_INSIGHT_OPENED: 'frequency_insight_opened', + MERCHANT_CLUSTER_CLICKED: 'merchant_cluster_clicked', +})); + +vi.mock('@/components/AdvisoryFeed', () => ({ + AdvisoryFeed: () =>
, +})); + +vi.mock('@/components/MerchantRollups', () => ({ + MerchantRollups: () =>
, +})); + +vi.mock('@/app/dashboard/FrequencyClusterSection', () => ({ + FrequencyClusterSection: ({ + clusters, + onClusterClick, + }: { + clusters: Array<{ + cluster: { id: string; label: string }; + merchantKeys: string[]; + }>; + onClusterClick: (cluster: { + cluster: { id: string; label: string }; + merchantKeys: string[]; + }) => void; + }) => + clusters.length > 0 ? ( + + ) : null, +})); + +describe('InsightsPanel', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('reports the first advisory render when the feed or empty state is actually visible', async () => { + const onAdvisoryFeedRendered = vi.fn(); + + const { rerender } = render( + + ); + + expect(onAdvisoryFeedRendered).not.toHaveBeenCalled(); + + rerender( + + ); + + await waitFor(() => { + expect(onAdvisoryFeedRendered).toHaveBeenCalledWith({ advisory_count: 0 }); + }); + + rerender( + + ); + + expect(onAdvisoryFeedRendered).toHaveBeenCalledTimes(1); + }); + + it('drills through clusters using all merchant keys', async () => { + vi.stubGlobal( + 'fetch', + vi.fn( + async () => + new Response( + JSON.stringify({ + top_merchants: [], + clusters: [ + { + cluster: { + id: 'food_delivery', + label: 'Food delivery', + description: 'Restaurant and food ordering platforms', + }, + totalDebitPaisa: 1500, + debitCount: 3, + merchantKeys: ['blinkit', 'instamart'], + }, + ], + }), + { + status: 200, + headers: { 'content-type': 'application/json' }, + } + ) + ) + ); + + render( + + ); + + const button = await screen.findByRole('button', { name: 'Open cluster' }); + fireEvent.click(button); + + expect(mockPush).toHaveBeenCalledTimes(1); + const pushed = mockPush.mock.calls[0]?.[0] as string; + const url = new URL(pushed, 'http://localhost'); + expect(url.pathname).toBe('/dashboard'); + expect(url.searchParams.get('tab')).toBe('transactions'); + expect(url.searchParams.get('merchant_keys')).toBe('blinkit,instamart'); + expect(url.searchParams.get('merchant_key')).toBeNull(); + }); +}); diff --git a/apps/money-mirror/src/app/dashboard/__tests__/ResultsPanel.test.tsx b/apps/money-mirror/src/app/dashboard/__tests__/ResultsPanel.test.tsx new file mode 100644 index 0000000..d8a9936 --- /dev/null +++ b/apps/money-mirror/src/app/dashboard/__tests__/ResultsPanel.test.tsx @@ -0,0 +1,58 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import { ResultsPanel } from '@/app/dashboard/ResultsPanel'; + +describe('ResultsPanel comparison copy', () => { + it('uses previous-period language and shows both compared ranges', () => { + render( + + ); + + expect(screen.getByText('Previous period')).toBeTruthy(); + expect(screen.queryByText('Month-over-month')).toBeNull(); + expect(screen.getByText('1 Mar 2026 – 31 Mar 2026 vs 1 Feb 2026 – 28 Feb 2026')).toBeTruthy(); + }); +}); diff --git a/apps/money-mirror/src/app/dashboard/__tests__/dashboard-tab-params.test.ts b/apps/money-mirror/src/app/dashboard/__tests__/dashboard-tab-params.test.ts new file mode 100644 index 0000000..5059caa --- /dev/null +++ b/apps/money-mirror/src/app/dashboard/__tests__/dashboard-tab-params.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest'; +import { tabFromSearchParams } from '@/app/dashboard/dashboard-tab-params'; + +function makeSearchParams(raw: string) { + return new URLSearchParams(raw); +} + +describe('tabFromSearchParams', () => { + it('defaults to overview when tab is missing', () => { + expect(tabFromSearchParams(makeSearchParams(''))).toBe('overview'); + }); + + it('returns the tab when valid', () => { + expect(tabFromSearchParams(makeSearchParams('tab=insights'))).toBe('insights'); + expect(tabFromSearchParams(makeSearchParams('tab=transactions'))).toBe('transactions'); + expect(tabFromSearchParams(makeSearchParams('tab=upload'))).toBe('upload'); + expect(tabFromSearchParams(makeSearchParams('tab=overview'))).toBe('overview'); + }); + + it('falls back to overview when tab is invalid', () => { + expect(tabFromSearchParams(makeSearchParams('tab=bad'))).toBe('overview'); + }); +}); 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 75f57e0..7011f1d 100644 --- a/apps/money-mirror/src/app/dashboard/dashboard-result-types.ts +++ b/apps/money-mirror/src/app/dashboard/dashboard-result-types.ts @@ -1,5 +1,7 @@ import type { LayerAFacts } from '@/lib/coaching-facts'; +import type { DashboardMonthCompare } from '@/lib/dashboard-types'; import type { StatementType } from '@/lib/statements'; +import type { UserPlan } from '@/lib/user-plan'; /** Dashboard API + parse response shape used by DashboardClient */ export interface DashboardResult { @@ -34,6 +36,10 @@ export interface DashboardResult { included_statement_ids: string[]; }; perceived_is_profile_baseline?: boolean; + /** P4-G: from profiles.plan; omit on partial responses (defaults to free in UI). */ + plan?: UserPlan; /** Layer A facts (issue-010 T4); server-built, facts-grounded coaching. */ coaching_facts?: LayerAFacts; + /** P4-F: scope-aligned month-over-month totals. */ + month_compare?: DashboardMonthCompare | null; } diff --git a/apps/money-mirror/src/app/dashboard/results-panel-sections.tsx b/apps/money-mirror/src/app/dashboard/results-panel-sections.tsx new file mode 100644 index 0000000..8be440f --- /dev/null +++ b/apps/money-mirror/src/app/dashboard/results-panel-sections.tsx @@ -0,0 +1,252 @@ +'use client'; + +import { formatPeriodRange, formatStatementDate } from '@/lib/format-date'; + +export interface MonthCompare { + current: { + date_from: string; + date_to: string; + total_debits_paisa: number; + total_credits_paisa: number; + }; + previous: { + date_from: string; + date_to: string; + total_debits_paisa: number; + total_credits_paisa: number; + }; + delta: { + debits_paisa: number; + credits_paisa: number; + debits_pct: number | null; + credits_pct: number | null; + }; +} + +function formatAmount(paisa: number): string { + return Math.round(paisa / 100).toLocaleString('en-IN'); +} + +export function SummaryStatCards({ + totalSpent, + totalIncome, + creditsLabel, +}: { + totalSpent: string; + totalIncome: string; + creditsLabel: string; +}) { + return ( +
+
+
+ Total Spent +
+
+ ₹{totalSpent} +
+
+
+
+ {creditsLabel} +
+
+ ₹{totalIncome} +
+
+
+ ); +} + +export function PreviousPeriodCard({ + monthCompare, + isLoadingMonthCompare, +}: { + monthCompare: MonthCompare | null; + isLoadingMonthCompare: boolean; +}) { + const compareDebitsDelta = monthCompare?.delta.debits_paisa ?? 0; + const compareCreditsDelta = monthCompare?.delta.credits_paisa ?? 0; + const compareDebitsSign = compareDebitsDelta > 0 ? '+' : ''; + const compareCreditsSign = compareCreditsDelta > 0 ? '+' : ''; + const currentCompareRange = monthCompare + ? formatPeriodRange(monthCompare.current.date_from, monthCompare.current.date_to) + : null; + const previousCompareRange = monthCompare + ? formatPeriodRange(monthCompare.previous.date_from, monthCompare.previous.date_to) + : null; + + return ( +
+

+ Previous period +

+ {isLoadingMonthCompare ? ( +

+ Loading comparison... +

+ ) : !monthCompare ? ( +

+ Comparison unavailable for this scope. +

+ ) : ( +
+

+ {currentCompareRange} vs {previousCompareRange} +

+
+
+

+ Spend change +

+

+ {compareDebitsSign}₹{formatAmount(compareDebitsDelta)} +

+
+
+

+ Credits change +

+

+ {compareCreditsSign}₹{formatAmount(compareCreditsDelta)} +

+
+
+
+ )} +
+ ); +} + +export function CreditCardDetailsGrid({ + payment_due_paisa, + minimum_due_paisa, + due_date, + credit_limit_paisa, +}: { + payment_due_paisa: number | null; + minimum_due_paisa: number | null; + due_date: string | null; + credit_limit_paisa: number | null; +}) { + return ( +
+ {payment_due_paisa !== null && ( +
+
+ Payment Due +
+
+ ₹{formatAmount(payment_due_paisa)} +
+
+ )} + {minimum_due_paisa !== null && ( +
+
+ Minimum Due +
+
+ ₹{formatAmount(minimum_due_paisa)} +
+
+ )} + {due_date && ( +
+
+ Due Date +
+
{formatStatementDate(due_date)}
+
+ )} + {credit_limit_paisa !== null && ( +
+
+ Credit Limit +
+
+ ₹{formatAmount(credit_limit_paisa)} +
+
+ )} +
+ ); +} + +export function ShareSection({ totalSpent }: { totalSpent: string }) { + return ( +
+ {typeof navigator !== 'undefined' && navigator.share && ( + + )} +

+ Share anonymously — your data stays private. +

+
+ ); +} diff --git a/apps/money-mirror/src/app/dashboard/useDashboardState.ts b/apps/money-mirror/src/app/dashboard/useDashboardState.ts index ca0110c..db0991b 100644 --- a/apps/money-mirror/src/app/dashboard/useDashboardState.ts +++ b/apps/money-mirror/src/app/dashboard/useDashboardState.ts @@ -14,6 +14,7 @@ import { useDashboardScopeDerived } from './useDashboardScopeDerived'; import { useDashboardUrlModel } from './useDashboardUrlModel'; import { useDashboardInitialLoadEffect } from './useDashboardInitialLoadEffect'; import { useStatementUploadHandler } from './useStatementUploadHandler'; +import { tabFromSearchParams } from './dashboard-tab-params'; export { tabFromSearchParams } from './dashboard-tab-params'; @@ -27,6 +28,7 @@ export function useDashboardState() { dashboardScopeKey, dashboardApiPath, } = useDashboardUrlModel(); + const canonicalTab = tabFromSearchParams(searchParams); const [result, setResult] = useState(null); const [advisories, setAdvisories] = useState([]); @@ -114,8 +116,7 @@ export function useDashboardState() { setAdvisories(data.advisories); setCoachingFacts(data.coaching_facts); return true; - } catch (e) { - console.error('[loadCoachingNarratives]', e); + } catch { return false; } finally { setIsLoadingNarratives(false); @@ -184,9 +185,10 @@ export function useDashboardState() { const next = pool[0].id; const q = new URLSearchParams(searchParams.toString()); q.set('statement_id', next); + q.set('tab', canonicalTab); router.replace(`/dashboard?${q.toString()}`, { scroll: false }); }, - [statements, router, searchParams] + [canonicalTab, statements, router, searchParams] ); const handleStatementChange = useCallback( @@ -196,9 +198,10 @@ export function useDashboardState() { } const q = new URLSearchParams(searchParams.toString()); q.set('statement_id', statementId); + q.set('tab', canonicalTab); router.replace(`/dashboard?${q.toString()}`, { scroll: false }); }, - [router, searchParams] + [canonicalTab, router, searchParams] ); const applyUnified = useCallback( @@ -209,10 +212,7 @@ export function useDashboardState() { 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); - } + q.set('tab', canonicalTab); router.replace(`/dashboard?${q.toString()}`, { scroll: false }); const n = payload.scope.statementIds?.length ?? statements?.length ?? 0; void fetch('/api/dashboard/scope-changed', { @@ -224,20 +224,17 @@ export function useDashboardState() { }), }).catch(() => {}); }, - [router, searchParams, statements?.length] + [canonicalTab, router, 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); - } + q.set('tab', canonicalTab); router.replace(`/dashboard?${q.toString()}`, { scroll: false }); }, - [router, searchParams] + [canonicalTab, router] ); const handleUpload = useStatementUploadHandler({ @@ -254,6 +251,7 @@ export function useDashboardState() { router, searchParams, isUnifiedUrl, + dashboardScopeKey, result, advisories, coachingFacts, diff --git a/apps/money-mirror/src/components/AdvisoryFeed.tsx b/apps/money-mirror/src/components/AdvisoryFeed.tsx index 5f2d2ad..28674e7 100644 --- a/apps/money-mirror/src/components/AdvisoryFeed.tsx +++ b/apps/money-mirror/src/components/AdvisoryFeed.tsx @@ -1,9 +1,15 @@ 'use client'; -import { useState } from 'react'; +import { useRouter, useSearchParams } from 'next/navigation'; +import { useEffect, useRef, useState } from 'react'; +import { FactsDrawer } from '@/components/FactsDrawer'; import type { Advisory } from '@/lib/advisory-engine'; import type { LayerAFacts } from '@/lib/coaching-facts'; -import { FactsDrawer } from '@/components/FactsDrawer'; +import { + BAD_PATTERN_ADVISORY_CLICKED, + BAD_PATTERN_ADVISORY_SHOWN, + getPosthogBrowser, +} from '@/lib/posthog-browser'; interface AdvisoryFeedProps { advisories: Advisory[]; @@ -34,12 +40,29 @@ const SEVERITY_STYLES: Record< }, }; +function buildTransactionsHref( + searchParams: { toString: () => string }, + cta: NonNullable +): string { + const q = new URLSearchParams(searchParams.toString()); + q.set('tab', 'transactions'); + q.delete('merchant_key'); + q.delete('upi_micro'); + if (cta.preset === 'micro_upi') { + q.set('upi_micro', '1'); + } else if (cta.preset === 'merchant_key' && cta.merchant_key) { + q.set('merchant_key', cta.merchant_key); + } + return `/dashboard?${q.toString()}`; +} + interface AdvisoryCardProps { adv: Advisory; index: number; coachingFacts: LayerAFacts | null; openSourcesId: string | null; onToggleSources: (id: string | null) => void; + onCtaClick: (adv: Advisory) => void; } function AdvisoryCard({ @@ -48,11 +71,13 @@ function AdvisoryCard({ coachingFacts, openSourcesId, onToggleSources, + onCtaClick, }: AdvisoryCardProps) { const style = SEVERITY_STYLES[adv.severity]; const cited = adv.cited_fact_ids ?? []; const showSources = coachingFacts && cited.length > 0; const sourcesOpen = openSourcesId === adv.id; + const cta = adv.cta; return (
{adv.narrative ?? adv.message}

+ {cta ? ( +
+ +
+ ) : null} {showSources ? (
diff --git a/apps/money-mirror/src/components/GuidedReviewSheet.tsx b/apps/money-mirror/src/components/GuidedReviewSheet.tsx new file mode 100644 index 0000000..10f93f3 --- /dev/null +++ b/apps/money-mirror/src/components/GuidedReviewSheet.tsx @@ -0,0 +1,162 @@ +'use client'; + +import { useCallback, useEffect, useRef, useState } from 'react'; +import { GUIDED_REVIEW_STARTED, getPosthogBrowser } from '@/lib/posthog-browser'; +import { Step1, Step2, Step3, DoneState } from './GuidedReviewSteps'; + +interface GuidedReviewSheetProps { + open: boolean; + onClose: () => void; + statementId?: string | null; +} + +type Step = 1 | 2 | 3; + +export function GuidedReviewSheet({ open, onClose, statementId }: GuidedReviewSheetProps) { + const [step, setStep] = useState(1); + const [saveCommitment, setSaveCommitment] = useState(false); + const [commitmentText, setCommitmentText] = useState(''); + const [submitting, setSubmitting] = useState(false); + const [submitError, setSubmitError] = useState(null); + const [done, setDone] = useState(false); + const startedRef = useRef(false); + + useEffect(() => { + if (open && !startedRef.current) { + startedRef.current = true; + void getPosthogBrowser().then((ph) => { + if (!ph) return; + ph.capture(GUIDED_REVIEW_STARTED, { statement_id: statementId ?? null }); + }); + } + if (!open) { + startedRef.current = false; + setStep(1); + setSaveCommitment(false); + setCommitmentText(''); + setSubmitError(null); + setDone(false); + } + }, [open, statementId]); + + useEffect(() => { + if (!open) return; + const handler = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose(); + }; + document.addEventListener('keydown', handler); + return () => document.removeEventListener('keydown', handler); + }, [open, onClose]); + + const submit = useCallback( + async (dismissed: boolean) => { + setSubmitting(true); + setSubmitError(null); + try { + const response = await fetch('/api/guided-review/outcome', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + statement_id: statementId ?? null, + dismissed, + commitment_text: dismissed ? null : commitmentText.trim() || null, + }), + }); + if (!response.ok) { + setSubmitError("Couldn't save yet. Please try again."); + return; + } + setDone(true); + } catch { + setSubmitError("Couldn't save yet. Please try again."); + } finally { + setSubmitting(false); + } + }, + [statementId, commitmentText] + ); + + if (!open) return null; + + return ( +
{ + if (e.target === e.currentTarget) onClose(); + }} + > +
+
+ +

+ Step {step} of 3 +

+ + {done ? ( + + ) : step === 1 ? ( + setStep(2)} /> + ) : step === 2 ? ( + setStep(3)} + onBack={() => setStep(1)} + /> + ) : ( + submit(true)} + onSave={() => submit(false)} + onBack={() => setStep(2)} + /> + )} +
+
+ ); +} diff --git a/apps/money-mirror/src/components/GuidedReviewSteps.tsx b/apps/money-mirror/src/components/GuidedReviewSteps.tsx new file mode 100644 index 0000000..efb57ee --- /dev/null +++ b/apps/money-mirror/src/components/GuidedReviewSteps.tsx @@ -0,0 +1,232 @@ +'use client'; + +export function Step1({ onNext }: { onNext: () => void }) { + return ( + <> +

+ Let's check in +

+

+ Your statement tells a story. Spending patterns aren't good or bad — they're just + information. The goal is clarity, not judgment. +

+ + + ); +} + +export function Step2({ + saveCommitment, + commitmentText, + onToggleSave, + onTextChange, + onNext, + onBack, +}: { + saveCommitment: boolean; + commitmentText: string; + onToggleSave: (v: boolean) => void; + onTextChange: (v: string) => void; + onNext: () => void; + onBack: () => void; +}) { + return ( + <> +

+ Pick one next step +

+

+ Not a life overhaul — just one small thing for the next statement cycle. Or skip this + entirely. +

+ + + + {saveCommitment && ( +