diff --git a/CHANGELOG.md b/CHANGELOG.md index a5ad888..5cc54a4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,21 @@ # Changelog +## 2026-04-05 — MoneyMirror Phase 2 Linear + project-state + +**What:** Recorded MoneyMirror Phase 2 (dashboard roadmap) delivery in **Linear** and refreshed [project-state.md](project-state.md). + +**Linear (under [VIJ-11](https://linear.app/vijaypmworkspace/issue/VIJ-11/issue-009-moneymirror-ai-powered-personal-finance-coach-for-gen-z)):** + +- [VIJ-23](https://linear.app/vijaypmworkspace/issue/VIJ-23/moneymirror-phase-2-shipped-dashboard-roadmap-a-h-baseline) — **Done** — Phase 2 shipped in repo (epics A–H baseline). +- [VIJ-24](https://linear.app/vijaypmworkspace/issue/VIJ-24/moneymirror-ops-run-neon-alter-for-statement-label-columns-nickname) — **Todo** — run Neon `ALTER TABLE` for label columns if missing (`apps/money-mirror/schema.sql`). +- [VIJ-25](https://linear.app/vijaypmworkspace/issue/VIJ-25/moneymirror-backlog-post-roadmap-f3-g2g3-h3) — **Backlog** — F3, G2–G3, H3 follow-ups. + +**Repo:** [experiments/linear-sync/issue-009.json](experiments/linear-sync/issue-009.json) — `tasks` map + `last_sync_mode` updated for manual Phase 2 issue creation (not a full `/linear-sync` pipeline run). + +**Update (same day):** Full **Sprint 1–3** (VIJ-26–28, Done), **Sprint 4 / Backlog** (VIJ-25), **Epics A–H** (VIJ-29–36, Done) created in Linear and mirrored in [project-state.md](project-state.md) § MoneyMirror PM roadmap — Linear map. + +--- + ## 2026-04-04 — GitHub PR #15 + project state (repo hygiene) **What:** Pushed `feat/linear-workflow-sync` (commits `9f483ed`, `dced451`) and opened [**PR #15**](https://github.com/shadowdevcode/ai-product-os/pull/15) for review: Neon MCP secret removal, `.codex/config.toml`, CHANGELOG updates. diff --git a/apps/money-mirror/CODEBASE-CONTEXT.md b/apps/money-mirror/CODEBASE-CONTEXT.md index 536febd..70643b0 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-03 +Last updated: 2026-04-04 ## What This App Does @@ -8,11 +8,12 @@ MoneyMirror is a mobile-first PWA AI financial coach for Gen Z Indians (₹20K ## Architecture Overview -- **Frontend**: Next.js 16 App Router (RSC by default, `"use client"` for interactive panels). Key pages: `/` (landing), `/onboarding` (5-question flow), `/score` (Money Health Score reveal), `/dashboard` (Mirror + advisory feed + upload). +- **Frontend**: Next.js 16 App Router (RSC by default, `"use client"` for interactive panels). Key pages: `/` (landing), `/onboarding` (5-question flow), `/score` (Money Health Score reveal), `/dashboard` (Overview with perceived vs actual + categories, **Insights** tab for AI nudges, **Upload** tab, statement picker + month filter, URL `?statement_id=`). - **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` now tracks `institution_name`, `statement_type`, and optional credit-card due metadata. All monetary values are stored as `BIGINT` in paisa (₹ × 100) to avoid float precision errors. +- **Database**: Neon Postgres. 4 tables: `profiles`, `statements`, `transactions`, `advisory_feed`. `profiles` persists monthly income and perceived spend; `statements` tracks `institution_name`, `statement_type`, optional credit-card due metadata, optional `nickname`, `account_purpose`, `card_network` for multi-account labelling. All monetary values are stored as `BIGINT` in paisa (₹ × 100) to avoid float precision errors. - **AI Integration**: Gemini 2.5 Flash via `@google/genai`. Used for: (1) PDF text → structured bank-account or credit-card statement JSON, (2) transaction category normalization. The statement-parse route currently enforces a 25s timeout and returns JSON 504 on timeout. - **Analytics**: PostHog (server-side only, `posthog-node`). 10 events tracked: `onboarding_completed`, `statement_parse_started/rate_limited/success/timeout/failed`, `weekly_recap_triggered/completed`, `weekly_recap_email_sent/failed`. All calls fire-and-forget (`.catch(() => {})`). +- **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 @@ -24,7 +25,9 @@ MoneyMirror is a mobile-first PWA AI financial coach for Gen Z Indians (₹20K | `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` | Fires 5 advisory types based on spend ratios and thresholds. Writes to `advisory_feed` table. | +| `src/lib/advisory-engine.ts` | Rule-based advisories (perception gap, subscriptions, food, no investment, debt ratio, high Other bucket, discretionary mix, avoidable estimate, CC minimum-due risk). | +| `src/lib/format-date.ts` | UTC-safe date labels for statement periods (no raw ISO in UI). | +| `src/app/api/statements/route.ts` | Authenticated GET — list processed statements for picker + month filter. | | `src/lib/scoring.ts` | Computes Money Health Score (0–100) from 5 onboarding question responses. | | `src/lib/statements.ts` | Defines statement types, parser prompts, metadata validation, and shared display labels for bank-account and credit-card uploads. | | `src/lib/pdf-parser.ts` | Extracts raw text from PDF buffer using `pdf-parse`. Uses `result.total` (not `result.pages?.length`) for page count — v2 API. | @@ -33,21 +36,22 @@ MoneyMirror is a mobile-first PWA AI financial coach for Gen Z Indians (₹20K ## 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, and `status`. Status never set to `processed` before `transactions` child insert succeeds. +- **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). - **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`, parse with Gemini, persist to DB, return mirror data | -| GET | `/api/dashboard` | Neon session cookie | Rehydrate latest processed statement + advisory feed (refresh/deep link path) | -| GET | `/api/dashboard/advisories` | Neon session cookie | Fetch advisory_feed rows for user | -| POST | `/api/onboarding/complete` | Neon session cookie | Save onboarding income, score, and perceived spend to profiles | -| GET | `/api/cron/weekly-recap` | `authorization: Bearer ` or local `x-cron-secret` | Scheduled master fan-out | -| POST | `/api/cron/weekly-recap/worker` | `x-cron-secret` header | Worker: send one recap email; returns 502 on failure | -| ALL | `/api/auth/[...path]` | — | Neon Auth passthrough | +| Method | Path | Auth | Purpose | +| ------ | ------------------------------- | -------------------------------------------------------------- | ------------------------------------------------------------------------------------------- | +| POST | `/api/statement/parse` | Neon session cookie | Upload PDF plus `statement_type` and optional `nickname`, `account_purpose`, `card_network` | +| GET | `/api/dashboard` | Neon session cookie | Rehydrate processed statement + advisories; optional `?statement_id=` for a specific upload | +| GET | `/api/statements` | Neon session cookie | List all processed statements for the user | +| GET | `/api/dashboard/advisories` | Neon session cookie | Fetch advisory_feed rows for user | +| POST | `/api/onboarding/complete` | Neon session cookie | Save onboarding income, score, and perceived spend to profiles | +| GET | `/api/cron/weekly-recap` | `authorization: Bearer ` or local `x-cron-secret` | Scheduled master fan-out | +| POST | `/api/cron/weekly-recap/worker` | `x-cron-secret` header | Worker: send one recap email; returns 502 on failure | +| ALL | `/api/auth/[...path]` | — | Neon Auth passthrough | ## Things NOT to Change Without Reading First @@ -61,11 +65,11 @@ MoneyMirror is a mobile-first PWA AI financial coach for Gen Z Indians (₹20K ## Known Limitations -- Statement history browsing not yet implemented — dashboard always shows the latest processed statement. `GET /api/dashboard` accepts `?statement_id=` as a future extension point. +- Cross-month trend comparison and aggregated “all accounts” rollups are not implemented (single-statement view with picker only). - Password removal stays manual outside the app. Password-protected PDFs are rejected with a clear retry message. - Inbox ingestion from email is not implemented. Users must manually download the PDF and upload it. - PDF parsing reliability depends on the PDF being text-based (not scanned/image). Scanned PDFs return 400. - Rate limit for uploads is 3/day per user (in-memory, resets on server restart) — not durable across deployments. - 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. -- Current automated validation count is 45 tests across route and library coverage. +- Run `npm test` in `apps/money-mirror` for current library and API test counts. diff --git a/apps/money-mirror/README.md b/apps/money-mirror/README.md index e410515..1bbb37a 100644 --- a/apps/money-mirror/README.md +++ b/apps/money-mirror/README.md @@ -4,6 +4,8 @@ AI-powered personal finance coach for Gen Z Indians that uses Neon Auth plus Neo **Sign in with your email, upload a statement, and get a brutally honest view of where your money actually goes.** +For AI- and agent-oriented architecture, data model, and API reference, see [`CODEBASE-CONTEXT.md`](./CODEBASE-CONTEXT.md). + ## What it does 1. User signs in with Neon Auth email OTP and lands in a private onboarding flow. @@ -22,6 +24,7 @@ AI-powered personal finance coach for Gen Z Indians that uses Neon Auth plus Neo | Database | Neon Postgres (`@neondatabase/serverless`) | | AI | Google Gemini 2.5 Flash | | Analytics | PostHog (`posthog-node`) | +| Errors | Sentry (`@sentry/nextjs`) | | Email | Resend | | Hosting | Vercel | @@ -70,7 +73,7 @@ Fill in these values: ### 4. Apply database schema -Run the full contents of [`schema.sql`](/Users/vijaysehgal/Downloads/02-Portfolio/ai-product-os/apps/money-mirror/schema.sql) against your Neon database. +Run the full contents of [`schema.sql`](./schema.sql) against your Neon database. Tables created: @@ -170,7 +173,7 @@ Returns the advisory subset for the authenticated user and statement. ### `GET /api/cron/weekly-recap` -Fan-out master route that finds all users with processed statements and triggers worker jobs. This is the scheduled entrypoint configured in [`vercel.json`](/Users/vijaysehgal/Downloads/02-Portfolio/ai-product-os/apps/money-mirror/vercel.json). +Fan-out master route that finds all users with processed statements and triggers worker jobs. This is the scheduled entrypoint configured in [`vercel.json`](./vercel.json). **Auth**: `authorization: Bearer ` from Vercel Cron. Local/manual triggering may also use `x-cron-secret: `. diff --git a/apps/money-mirror/__tests__/lib/format-date.test.ts b/apps/money-mirror/__tests__/lib/format-date.test.ts new file mode 100644 index 0000000..90ccd38 --- /dev/null +++ b/apps/money-mirror/__tests__/lib/format-date.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest'; +import { + formatMonthKeyForDisplay, + formatPeriodRange, + formatStatementDate, + monthKeyFromPeriodEnd, + statementMonthLabel, +} from '@/lib/format-date'; + +describe('format-date', () => { + it('formats ISO date strings without raw T00:00:00', () => { + expect(formatStatementDate('2026-04-11T00:00:00.000Z')).toMatch(/11/); + expect(formatStatementDate('2026-04-11T00:00:00.000Z')).toMatch(/2026/); + expect(formatStatementDate('2026-04-11T00:00:00.000Z')).not.toContain('T'); + }); + + it('formats YYYY-MM-DD', () => { + expect(formatStatementDate('2026-03-22')).toMatch(/22/); + }); + + it('formatPeriodRange joins start and end', () => { + const r = formatPeriodRange('2026-02-23', '2026-03-22'); + expect(r).toContain('–'); + expect(r).not.toContain('T'); + }); + + it('statementMonthLabel uses period end', () => { + expect(statementMonthLabel('2026-03-22')).toMatch(/March/); + expect(statementMonthLabel('2026-03-22')).toMatch(/2026/); + }); + + it('monthKeyFromPeriodEnd', () => { + expect(monthKeyFromPeriodEnd('2026-03-22')).toBe('2026-03'); + }); + + it('formatMonthKeyForDisplay', () => { + expect(formatMonthKeyForDisplay('2026-03')).toMatch(/March/); + }); +}); diff --git a/apps/money-mirror/docs/COACHING-TONE.md b/apps/money-mirror/docs/COACHING-TONE.md new file mode 100644 index 0000000..0884946 --- /dev/null +++ b/apps/money-mirror/docs/COACHING-TONE.md @@ -0,0 +1,29 @@ +# MoneyMirror coaching tone + +Educational, India-first personal finance copy. Used for in-app insights and any future LLM prompts. + +## Principles + +1. **Clarity over jargon** — Explain the “why” in one sentence before the number. +2. **No shame** — Frame behaviour as common and fixable; avoid moralising. +3. **Consequence-first** — Tie spend to a concrete tradeoff (e.g. annualised food delivery, EMI load). +4. **Not advice** — Insights are observations from statement data, not buy/sell or tax guidance. + +## Disclaimers (always available in UI) + +- MoneyMirror does not provide personalised investment, tax, or legal advice. +- Users should consult a qualified professional for regulated decisions. +- Do **not** impersonate real individuals or creators; use archetypes (educator, myth-buster) if needed, not names. + +## Prompt templates (if using Gemini for narrative) + +- Structure: **Observation** → **Why it matters** → **One practical next step** (optional). +- Append: “This is general information based on your uploaded statement, not a recommendation.” + +## Archetypes (optional user preference) + +- **Educator** — Neutral, step-by-step. +- **Myth-buster** — Short reframe of a common money myth tied to their data. +- **Long-term** — Emphasise compounding and small recurring cuts. + +Do not label these after real influencers or YouTubers. diff --git a/apps/money-mirror/schema.sql b/apps/money-mirror/schema.sql index 63749b0..9fe68d4 100644 --- a/apps/money-mirror/schema.sql +++ b/apps/money-mirror/schema.sql @@ -29,6 +29,9 @@ CREATE TABLE IF NOT EXISTS public.statements ( minimum_due_paisa BIGINT, credit_limit_paisa BIGINT, status TEXT NOT NULL CHECK (status IN ('processing', 'processed', 'failed')) DEFAULT 'processing', + nickname TEXT, + account_purpose TEXT, + card_network TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); @@ -66,3 +69,8 @@ CREATE INDEX IF NOT EXISTS idx_transactions_user_statement CREATE INDEX IF NOT EXISTS idx_advisory_feed_user_created_at ON public.advisory_feed(user_id, created_at DESC); + +-- Existing Neon DBs: add statement label columns (idempotent) +ALTER TABLE public.statements ADD COLUMN IF NOT EXISTS nickname TEXT; +ALTER TABLE public.statements ADD COLUMN IF NOT EXISTS account_purpose TEXT; +ALTER TABLE public.statements ADD COLUMN IF NOT EXISTS card_network TEXT; diff --git a/apps/money-mirror/src/app/api/statement/parse/gemini-statement-parse.ts b/apps/money-mirror/src/app/api/statement/parse/gemini-statement-parse.ts new file mode 100644 index 0000000..2dc6f0f --- /dev/null +++ b/apps/money-mirror/src/app/api/statement/parse/gemini-statement-parse.ts @@ -0,0 +1,74 @@ +import { GoogleGenAI } from '@google/genai'; +import { + buildStatementParserPrompt, + validateParsedStatement, + type ParsedStatementResult, +} from '@/lib/statements'; +import type { StatementType } from '@/lib/statements'; + +const TIMEOUT_MS = 25_000; + +export type GeminiStatementParseOutcome = + | { ok: true; data: ParsedStatementResult } + | { ok: false; code: 'timeout' | 'gemini_error'; detail?: string }; + +/** + * Runs Gemini structured extraction on PDF text with a wall-clock timeout. + */ +export async function runGeminiStatementParse( + pdfText: string, + statementType: StatementType +): Promise { + const genai = new GoogleGenAI({ apiKey: process.env.GEMINI_API_KEY! }); + + try { + const geminiPromise = genai.models + .generateContent({ + model: 'gemini-2.5-flash', + config: { + thinkingConfig: { thinkingBudget: 0 }, + }, + contents: [ + { + role: 'user', + parts: [ + { text: buildStatementParserPrompt(statementType) }, + { + text: `Parse this statement and return JSON:\n\n${pdfText.slice(0, 30_000)}`, + }, + ], + }, + ], + }) + .then((res) => { + const parts = res.candidates?.[0]?.content?.parts ?? []; + const raw = + parts.find( + (p) => (p.text && p.text.trim().startsWith('{')) || p.text?.includes('"transactions"') + )?.text ?? + parts.find((p) => p.text)?.text ?? + ''; + const json = raw + .replace(/^```(?:json)?\s*/i, '') + .replace(/```\s*$/i, '') + .trim(); + return validateParsedStatement(JSON.parse(json), statementType); + }); + + const timeoutPromise = new Promise((_, reject) => + setTimeout(() => reject(new Error('GEMINI_TIMEOUT')), TIMEOUT_MS) + ); + + const data = await Promise.race([geminiPromise, timeoutPromise]); + return { ok: true, data }; + } catch (err) { + const isTimeout = err instanceof Error && err.message === 'GEMINI_TIMEOUT'; + if (isTimeout) { + return { ok: false, code: 'timeout' }; + } + const detail = err instanceof Error ? err.message : String(err); + return { ok: false, code: 'gemini_error', detail }; + } +} + +export { TIMEOUT_MS }; 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 c454568..2970383 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 @@ -31,6 +31,9 @@ interface PeriodInfo { payment_due_paisa: number | null; minimum_due_paisa: number | null; credit_limit_paisa: number | null; + nickname?: string | null; + account_purpose?: string | null; + card_network?: string | null; } interface PersistResult { @@ -99,6 +102,9 @@ export async function persistStatement( payment_due_paisa, minimum_due_paisa, credit_limit_paisa, + nickname, + account_purpose, + card_network, status ) VALUES ( @@ -116,6 +122,9 @@ export async function persistStatement( ${period.payment_due_paisa}, ${period.minimum_due_paisa}, ${period.credit_limit_paisa}, + ${period.nickname ?? null}, + ${period.account_purpose ?? null}, + ${period.card_network ?? null}, 'processing' ) `, 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 fc003a5..30febbd 100644 --- a/apps/money-mirror/src/app/api/statement/parse/route.ts +++ b/apps/money-mirror/src/app/api/statement/parse/route.ts @@ -22,7 +22,6 @@ */ import { NextRequest, NextResponse } from 'next/server'; -import { GoogleGenAI } from '@google/genai'; import { getSessionUser } from '@/lib/auth/session'; import { countUserStatementsSince, ensureProfile } from '@/lib/db'; import { extractPdfText, PdfExtractionError } from '@/lib/pdf-parser'; @@ -32,15 +31,10 @@ import { summarizeByCategory, } from '@/lib/categorizer'; import { captureServerEvent } from '@/lib/posthog'; -import { - buildStatementParserPrompt, - parseStatementType, - type ParsedStatementResult, - validateParsedStatement, -} from '@/lib/statements'; +import { parseStatementType } from '@/lib/statements'; +import { parseAccountPurpose, sanitizeCardNetwork, sanitizeNickname } from '@/lib/upload-metadata'; +import { runGeminiStatementParse, TIMEOUT_MS } from './gemini-statement-parse'; import { persistStatement } from './persist-statement'; - -const TIMEOUT_MS = 25_000; const MAX_UPLOADS_PER_DAY = 3; const MAX_FILE_SIZE_BYTES = 10 * 1024 * 1024; // 10 MB @@ -82,11 +76,18 @@ export async function POST(req: NextRequest): Promise { let fileBuffer: Buffer | null = null; let fileName = 'statement.pdf'; let statementType = parseStatementType(null); + let nickname: string | null = null; + let accountPurpose = null as ReturnType; + let cardNetwork: string | null = null; try { const formData = await req.formData(); const file = formData.get('file'); statementType = parseStatementType(formData.get('statement_type')); + nickname = sanitizeNickname(formData.get('nickname')); + accountPurpose = parseAccountPurpose(formData.get('account_purpose')); + cardNetwork = + statementType === 'credit_card' ? sanitizeCardNetwork(formData.get('card_network')) : null; if (!file || typeof file === 'string') { return NextResponse.json({ error: 'No file uploaded' }, { status: 400 }); @@ -145,55 +146,10 @@ export async function POST(req: NextRequest): Promise { }).catch(() => {}); // ── 6. Gemini extraction with timeout ──────────────────────────── - const genai = new GoogleGenAI({ apiKey: process.env.GEMINI_API_KEY! }); - let parsedStatement: ParsedStatementResult; - - try { - const geminiPromise = genai.models - .generateContent({ - model: 'gemini-2.5-flash', - config: { - // Disable thinking — structured data extraction needs speed, not reasoning - thinkingConfig: { thinkingBudget: 0 }, - }, - contents: [ - { - role: 'user', - parts: [ - { text: buildStatementParserPrompt(statementType) }, - { - text: `Parse this statement and return JSON:\n\n${pdfText.slice(0, 30_000)}`, - }, - ], - }, - ], - }) - .then((res) => { - // Find the first part with text (thinking models may have thought parts first) - const parts = res.candidates?.[0]?.content?.parts ?? []; - const raw = - parts.find( - (p) => (p.text && p.text.trim().startsWith('{')) || p.text?.includes('"transactions"') - )?.text ?? - parts.find((p) => p.text)?.text ?? - ''; - // Strip markdown code fences if present - const json = raw - .replace(/^```(?:json)?\s*/i, '') - .replace(/```\s*$/i, '') - .trim(); - return validateParsedStatement(JSON.parse(json), statementType); - }); - - const timeoutPromise = new Promise((_, reject) => - setTimeout(() => reject(new Error('GEMINI_TIMEOUT')), TIMEOUT_MS) - ); - - parsedStatement = await Promise.race([geminiPromise, timeoutPromise]); - } catch (err) { - const isTimeout = err instanceof Error && err.message === 'GEMINI_TIMEOUT'; + const geminiOutcome = await runGeminiStatementParse(pdfText, statementType); - if (isTimeout) { + if (!geminiOutcome.ok) { + if (geminiOutcome.code === 'timeout') { captureServerEvent(userId, 'statement_parse_timeout', { timeout_ms: TIMEOUT_MS, }).catch(() => {}); @@ -202,11 +158,9 @@ export async function POST(req: NextRequest): Promise { { status: 504 } ); } - - const geminiErrMsg = err instanceof Error ? err.message : String(err); captureServerEvent(userId, 'statement_parse_failed', { error_type: 'GEMINI_ERROR', - detail: geminiErrMsg.slice(0, 200), + detail: (geminiOutcome.detail ?? '').slice(0, 200), }).catch(() => {}); return NextResponse.json( { error: 'Failed to extract transactions from the PDF.' }, @@ -214,6 +168,8 @@ export async function POST(req: NextRequest): Promise { ); } + const parsedStatement = geminiOutcome.data; + // ── 7. Categorize transactions ──────────────────────────────────── const categorized = parsedStatement.transactions.map((tx) => { const amountPaisa = Math.round(tx.amount * 100); @@ -246,6 +202,9 @@ export async function POST(req: NextRequest): Promise { payment_due_paisa: parsedStatement.payment_due_paisa, minimum_due_paisa: parsedStatement.minimum_due_paisa, credit_limit_paisa: parsedStatement.credit_limit_paisa, + nickname, + account_purpose: accountPurpose, + card_network: cardNetwork, } ); diff --git a/apps/money-mirror/src/app/api/statements/route.ts b/apps/money-mirror/src/app/api/statements/route.ts new file mode 100644 index 0000000..e4571ae --- /dev/null +++ b/apps/money-mirror/src/app/api/statements/route.ts @@ -0,0 +1,19 @@ +import { NextResponse } from 'next/server'; +import { getSessionUser } from '@/lib/auth/session'; +import { ensureProfile } from '@/lib/db'; +import { listProcessedStatements } from '@/lib/statements-list'; + +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 statements = await listProcessedStatements(user.id); + return NextResponse.json({ statements }); + } catch { + return NextResponse.json({ error: 'Failed to list statements' }, { status: 500 }); + } +} diff --git a/apps/money-mirror/src/app/dashboard/DashboardBrandBar.tsx b/apps/money-mirror/src/app/dashboard/DashboardBrandBar.tsx new file mode 100644 index 0000000..a1cf23c --- /dev/null +++ b/apps/money-mirror/src/app/dashboard/DashboardBrandBar.tsx @@ -0,0 +1,24 @@ +export function DashboardBrandBar() { + return ( +
+ + MoneyMirror + +
+ ); +} diff --git a/apps/money-mirror/src/app/dashboard/DashboardClient.tsx b/apps/money-mirror/src/app/dashboard/DashboardClient.tsx new file mode 100644 index 0000000..070bce0 --- /dev/null +++ b/apps/money-mirror/src/app/dashboard/DashboardClient.tsx @@ -0,0 +1,282 @@ +'use client'; + +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useRouter, useSearchParams } from 'next/navigation'; +import type { Advisory } from '@/lib/advisory-engine'; +import type { StatementType } from '@/lib/statements'; +import type { StatementListItem } from '@/lib/statements-list'; +import { monthKeyFromPeriodEnd } from '@/lib/format-date'; +import { UploadPanel, type UploadFormMeta } from './UploadPanel'; +import { ParsingPanel } from './ParsingPanel'; +import { ResultsPanel } from './ResultsPanel'; +import { InsightsPanel } from './InsightsPanel'; +import { DashboardNav, type DashboardTab } from './DashboardNav'; +import { StatementFilters } from './StatementFilters'; +import { DashboardLoadingSkeleton } from './DashboardLoadingSkeleton'; +import { DashboardBrandBar } from './DashboardBrandBar'; +import type { DashboardResult } from './dashboard-result-types'; + +export function DashboardClient() { + const router = useRouter(); + const searchParams = useSearchParams(); + const statementIdFromUrl = searchParams.get('statement_id'); + + const [result, setResult] = useState(null); + const [advisories, setAdvisories] = useState([]); + const [error, setError] = useState(null); + const [isLoadingDashboard, setIsLoadingDashboard] = useState(true); + const [isParsing, setIsParsing] = useState(false); + const [statementType, setStatementType] = useState('bank_account'); + const [statements, setStatements] = useState(null); + const [tab, setTab] = useState('overview'); + const [monthFilter, setMonthFilter] = useState('all'); + + const loadStatements = useCallback(async () => { + const resp = await fetch('/api/statements'); + if (!resp.ok) { + setStatements([]); + return; + } + const data = (await resp.json()) as { statements?: StatementListItem[] }; + setStatements(data.statements ?? []); + }, []); + + const loadDashboard = useCallback(async (statementId: string | null) => { + setIsLoadingDashboard(true); + setError(null); + + try { + const query = statementId ? `?statement_id=${encodeURIComponent(statementId)}` : ''; + const resp = await fetch(`/api/dashboard${query}`); + + if (resp.status === 404) { + setResult(null); + setAdvisories([]); + return; + } + + if (!resp.ok) { + const body = await resp.json().catch(() => ({})); + throw new Error(body.error ?? body.detail ?? `Dashboard load failed (${resp.status})`); + } + + const data: DashboardResult & { advisories: Advisory[] } = await resp.json(); + setResult(data); + setAdvisories(data.advisories); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to load your dashboard.'); + setResult(null); + setAdvisories([]); + } finally { + setIsLoadingDashboard(false); + } + }, []); + + useEffect(() => { + loadStatements().catch(() => {}); + }, [loadStatements]); + + useEffect(() => { + if (statements === null) { + return; + } + if (statements.length === 0) { + loadDashboard(null).catch(() => {}); + return; + } + + const valid = + statementIdFromUrl && statements.some((s) => s.id === statementIdFromUrl) + ? statementIdFromUrl + : statements[0].id; + + if (valid !== statementIdFromUrl) { + router.replace(`/dashboard?statement_id=${encodeURIComponent(valid)}`, { scroll: false }); + return; + } + + loadDashboard(valid).catch(() => {}); + }, [statements, statementIdFromUrl, loadDashboard, router]); + + const filteredStatements = useMemo(() => { + if (!statements || monthFilter === 'all') { + return statements ?? []; + } + return statements.filter((s) => monthKeyFromPeriodEnd(s.period_end) === monthFilter); + }, [statements, monthFilter]); + + const effectiveSelectedId = + result?.statement_id && filteredStatements.some((s) => s.id === result.statement_id) + ? result.statement_id + : (filteredStatements[0]?.id ?? result?.statement_id ?? ''); + + const handleMonthChange = useCallback( + (key: string | 'all') => { + setMonthFilter(key); + if (!statements || statements.length === 0) { + return; + } + const pool = + key === 'all' + ? statements + : statements.filter((s) => monthKeyFromPeriodEnd(s.period_end) === key); + if (pool.length === 0) { + return; + } + const next = pool[0].id; + router.replace(`/dashboard?statement_id=${encodeURIComponent(next)}`, { scroll: false }); + }, + [statements, router] + ); + + const handleStatementChange = useCallback( + (statementId: string) => { + if (!statementId) { + return; + } + router.replace(`/dashboard?statement_id=${encodeURIComponent(statementId)}`, { + scroll: false, + }); + }, + [router] + ); + + const handleUpload = useCallback( + async (file: File, nextStatementType: StatementType, meta: UploadFormMeta) => { + setError(null); + + if (file.type && file.type !== 'application/pdf') { + setError('Please upload a PDF file.'); + return; + } + if (file.size > 10 * 1024 * 1024) { + setError('File is too large. Maximum 10 MB.'); + return; + } + + setIsParsing(true); + + try { + const formData = new FormData(); + formData.append('file', file); + formData.append('statement_type', nextStatementType); + if (meta.nickname) { + formData.append('nickname', meta.nickname); + } + if (meta.accountPurpose) { + formData.append('account_purpose', meta.accountPurpose); + } + if (nextStatementType === 'credit_card' && meta.cardNetwork) { + formData.append('card_network', meta.cardNetwork); + } + + const resp = await fetch('/api/statement/parse', { method: 'POST', body: formData }); + + if (!resp.ok) { + const body = await resp.json().catch(() => ({})); + throw new Error(body.error ?? body.detail ?? `Upload failed (${resp.status})`); + } + + const data: DashboardResult = await resp.json(); + await loadStatements(); + router.replace(`/dashboard?statement_id=${encodeURIComponent(data.statement_id)}`, { + scroll: false, + }); + await loadDashboard(data.statement_id); + setTab('overview'); + } catch (err) { + setError(err instanceof Error ? err.message : 'Something went wrong.'); + } finally { + setIsParsing(false); + } + }, + [loadDashboard, loadStatements, router] + ); + + const hasStatements = Boolean(statements && statements.length > 0); + const showTabs = hasStatements && !isLoadingDashboard; + const showEmptyUpload = + !isLoadingDashboard && statements !== null && statements.length === 0 && !isParsing; + + return ( +
+ + + {showTabs && ( + { + setTab(t); + if (t !== 'upload') { + setError(null); + } + }} + /> + )} + + {isLoadingDashboard && !isParsing && } + + {isParsing && } + + {showEmptyUpload && ( + + )} + + {hasStatements && tab === 'upload' && !isLoadingDashboard && !isParsing && ( + + )} + + {tab === 'overview' && + hasStatements && + result && + !isLoadingDashboard && + !isParsing && + statements && ( + <> + + + + )} + + {tab === 'insights' && hasStatements && result && !isLoadingDashboard && !isParsing && ( + + )} + + +
+ ); +} diff --git a/apps/money-mirror/src/app/dashboard/DashboardLoadingSkeleton.tsx b/apps/money-mirror/src/app/dashboard/DashboardLoadingSkeleton.tsx new file mode 100644 index 0000000..b131b08 --- /dev/null +++ b/apps/money-mirror/src/app/dashboard/DashboardLoadingSkeleton.tsx @@ -0,0 +1,18 @@ +export function DashboardLoadingSkeleton() { + return ( +
+
+
+
+ ); +} diff --git a/apps/money-mirror/src/app/dashboard/DashboardNav.tsx b/apps/money-mirror/src/app/dashboard/DashboardNav.tsx new file mode 100644 index 0000000..7e9e480 --- /dev/null +++ b/apps/money-mirror/src/app/dashboard/DashboardNav.tsx @@ -0,0 +1,51 @@ +'use client'; + +export type DashboardTab = 'overview' | 'insights' | 'upload'; + +interface DashboardNavProps { + active: DashboardTab; + onChange: (tab: DashboardTab) => void; +} + +const TABS: { id: DashboardTab; label: string }[] = [ + { id: 'overview', label: 'Overview' }, + { id: 'insights', label: 'Insights' }, + { id: 'upload', label: 'Upload' }, +]; + +export function DashboardNav({ active, onChange }: DashboardNavProps) { + return ( + + ); +} diff --git a/apps/money-mirror/src/app/dashboard/InsightsPanel.tsx b/apps/money-mirror/src/app/dashboard/InsightsPanel.tsx new file mode 100644 index 0000000..fff4db5 --- /dev/null +++ b/apps/money-mirror/src/app/dashboard/InsightsPanel.tsx @@ -0,0 +1,55 @@ +'use client'; + +import { AdvisoryFeed } from '@/components/AdvisoryFeed'; +import type { Advisory } from '@/lib/advisory-engine'; + +interface InsightsPanelProps { + advisories: Advisory[]; +} + +export function InsightsPanel({ advisories }: InsightsPanelProps) { + return ( +
+
+

AI insights

+

+ Plain-language nudges based on your statement data. Not personalised investment advice or + a recommendation to buy or sell any product. +

+
+ +

+ Highlights +

+ + +

+ MoneyMirror is educational. For tax, legal, or investment decisions, speak to a qualified + professional. Insights are generated from your statement patterns, not tailored advice. +

+
+ ); +} diff --git a/apps/money-mirror/src/app/dashboard/ResultsPanel.tsx b/apps/money-mirror/src/app/dashboard/ResultsPanel.tsx index e8ec66b..8e9e981 100644 --- a/apps/money-mirror/src/app/dashboard/ResultsPanel.tsx +++ b/apps/money-mirror/src/app/dashboard/ResultsPanel.tsx @@ -1,8 +1,8 @@ 'use client'; import { MirrorCard } from '@/components/MirrorCard'; -import { AdvisoryFeed } from '@/components/AdvisoryFeed'; -import type { Advisory } from '@/lib/advisory-engine'; +import { PerceivedActualMirror } from '@/components/PerceivedActualMirror'; +import { formatPeriodRange, formatStatementDate, statementMonthLabel } from '@/lib/format-date'; import { getCreditsLabel, getStatementTypeLabel, type StatementType } from '@/lib/statements'; interface ResultSummary { @@ -26,7 +26,10 @@ interface ResultsPanelProps { credit_limit_paisa: number | null; transaction_count: number; summary: ResultSummary; - advisories: Advisory[]; + perceived_spend_paisa: number; + nickname: string | null; + account_purpose: string | null; + card_network: string | null; } const CATEGORY_META = [ @@ -37,6 +40,22 @@ const CATEGORY_META = [ { key: 'other_paisa' as const, label: 'Other', color: 'var(--text-muted)', icon: '📦' }, ]; +function purposeLabel(p: string | null): string | null { + if (!p) { + return null; + } + if (p === 'spending') { + return 'Spending account'; + } + if (p === 'savings_goals') { + return 'Savings / goals'; + } + if (p === 'unspecified') { + return 'Purpose not set'; + } + return null; +} + export function ResultsPanel({ institution_name, statement_type, @@ -48,12 +67,25 @@ export function ResultsPanel({ credit_limit_paisa, transaction_count, summary, - advisories, + perceived_spend_paisa, + nickname, + account_purpose, + card_network, }: ResultsPanelProps) { 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); const statementTypeLabel = getStatementTypeLabel(statement_type); + const periodRange = formatPeriodRange(period_start, period_end); + const periodChip = statementMonthLabel(period_end); + const metaBits = [institution_name, statementTypeLabel]; + if (nickname?.trim()) { + metaBits.unshift(`“${nickname.trim()}”`); + } + if (card_network?.trim() && statement_type === 'credit_card') { + metaBits.push(card_network.trim()); + } + const purpose = purposeLabel(account_purpose); return (
-

- Your Money Mirror 🪞 +

+ Your Money Mirror

-

- {institution_name} • {statementTypeLabel} • {period_start ?? 'Unknown start'} →{' '} - {period_end ?? 'Unknown end'} • {transaction_count} transactions +

+ Statement period: {periodChip} +

+

+ {metaBits.join(' • ')} • {periodRange} • {transaction_count} transactions

+ {purpose && ( +

+ {purpose} +

+ )}
+ +
Due Date
-
{due_date}
+
+ {formatStatementDate(due_date)} +
)} {credit_limit_paisa !== null && ( @@ -189,24 +247,6 @@ export function ResultsPanel({
- {advisories.length > 0 && ( -
-

- Truth Bombs 💣 -

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

void; + onStatementChange: (statementId: string) => void; +} + +export function StatementFilters({ + statements, + selectedStatementId, + monthFilter, + onMonthChange, + onStatementChange, +}: StatementFiltersProps) { + const monthKeys = listMonthKeysFromPeriodEnds(statements.map((s) => s.period_end)); + + const filtered = + monthFilter === 'all' + ? statements + : statements.filter((s) => monthKeyFromPeriodEnd(s.period_end) === monthFilter); + + return ( +

+ + +
+ ); +} diff --git a/apps/money-mirror/src/app/dashboard/UploadPanel.tsx b/apps/money-mirror/src/app/dashboard/UploadPanel.tsx index 2869b45..9e65438 100644 --- a/apps/money-mirror/src/app/dashboard/UploadPanel.tsx +++ b/apps/money-mirror/src/app/dashboard/UploadPanel.tsx @@ -1,13 +1,19 @@ 'use client'; -import { useRef } from 'react'; +import { useRef, useState } from 'react'; import type { StatementType } from '@/lib/statements'; +export interface UploadFormMeta { + nickname: string; + accountPurpose: '' | 'spending' | 'savings_goals' | 'unspecified'; + cardNetwork: string; +} + interface UploadPanelProps { error: string | null; statementType: StatementType; onStatementTypeChange: (statementType: StatementType) => void; - onUpload: (file: File, statementType: StatementType) => void; + onUpload: (file: File, statementType: StatementType, meta: UploadFormMeta) => void; } export function UploadPanel({ @@ -17,16 +23,31 @@ export function UploadPanel({ onUpload, }: UploadPanelProps) { const fileRef = useRef(null); + const [nickname, setNickname] = useState(''); + const [accountPurpose, setAccountPurpose] = useState(''); + const [cardNetwork, setCardNetwork] = useState(''); + + const runUpload = (file: File) => { + onUpload(file, statementType, { + nickname: nickname.trim(), + accountPurpose, + cardNetwork: cardNetwork.trim(), + }); + }; const handleDrop = (e: React.DragEvent) => { e.preventDefault(); const file = e.dataTransfer.files[0]; - if (file) onUpload(file, statementType); + if (file) { + runUpload(file); + } }; const handleFileChange = (e: React.ChangeEvent) => { const file = e.target.files?.[0]; - if (file) onUpload(file, statementType); + if (file) { + runUpload(file); + } }; return ( @@ -39,8 +60,8 @@ export function UploadPanel({ Upload your statement

- Upload a password-free PDF from your bank account or credit card. We'll show you - exactly where your money is going. + 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.

@@ -61,6 +82,48 @@ export function UploadPanel({
+ + + + + {statementType === 'credit_card' && ( + + )} +
fileRef.current?.click()} onKeyDown={(e) => { - if (e.key === 'Enter' || e.key === ' ') fileRef.current?.click(); + if (e.key === 'Enter' || e.key === ' ') { + fileRef.current?.click(); + } }} style={{ border: '2px dashed var(--border)', diff --git a/apps/money-mirror/src/app/dashboard/dashboard-result-types.ts b/apps/money-mirror/src/app/dashboard/dashboard-result-types.ts new file mode 100644 index 0000000..170d6b3 --- /dev/null +++ b/apps/money-mirror/src/app/dashboard/dashboard-result-types.ts @@ -0,0 +1,29 @@ +import type { StatementType } from '@/lib/statements'; + +/** Dashboard API + parse response shape used by DashboardClient */ +export interface DashboardResult { + statement_id: string; + institution_name: string; + statement_type: StatementType; + period_start: string | null; + period_end: string | null; + due_date: string | null; + payment_due_paisa: number | null; + minimum_due_paisa: number | null; + credit_limit_paisa: number | null; + perceived_spend_paisa: number; + monthly_income_paisa: number; + nickname: string | null; + account_purpose: string | null; + card_network: string | null; + transaction_count: number; + summary: { + needs_paisa: number; + wants_paisa: number; + investment_paisa: number; + debt_paisa: number; + other_paisa: number; + total_debits_paisa: number; + total_credits_paisa: number; + }; +} diff --git a/apps/money-mirror/src/app/dashboard/page.tsx b/apps/money-mirror/src/app/dashboard/page.tsx index 1baffa5..b6b630c 100644 --- a/apps/money-mirror/src/app/dashboard/page.tsx +++ b/apps/money-mirror/src/app/dashboard/page.tsx @@ -1,222 +1,18 @@ -'use client'; +import { Suspense } from 'react'; +import { DashboardClient } from './DashboardClient'; -import { useState, useCallback, useEffect } from 'react'; -import type { Advisory } from '@/lib/advisory-engine'; -import type { StatementType } from '@/lib/statements'; -import { UploadPanel } from './UploadPanel'; -import { ParsingPanel } from './ParsingPanel'; -import { ResultsPanel } from './ResultsPanel'; - -/** - * T8a — Dashboard Page - * - * Three states: - * 1. UPLOAD → drag-and-drop PDF upload zone - * 2. PARSING → loading skeleton with progress text - * 3. RESULTS → MirrorCards breakdown + AdvisoryFeed - */ - -type DashboardState = 'upload' | 'parsing' | 'results'; - -interface DashboardResult { - statement_id: string; - institution_name: string; - statement_type: StatementType; - period_start: string | null; - period_end: string | null; - due_date: string | null; - payment_due_paisa: number | null; - minimum_due_paisa: number | null; - credit_limit_paisa: number | null; - transaction_count: number; - summary: { - needs_paisa: number; - wants_paisa: number; - investment_paisa: number; - debt_paisa: number; - other_paisa: number; - total_debits_paisa: number; - total_credits_paisa: number; - }; +function DashboardFallback() { + return ( +
+
+
+ ); } export default function DashboardPage() { - const [state, setState] = useState('upload'); - const [result, setResult] = useState(null); - const [advisories, setAdvisories] = useState([]); - const [error, setError] = useState(null); - const [isLoadingDashboard, setIsLoadingDashboard] = useState(true); - const [statementType, setStatementType] = useState('bank_account'); - - const loadDashboard = useCallback(async (statementId: string | null) => { - setIsLoadingDashboard(true); - setError(null); - - try { - const query = statementId ? `?statement_id=${encodeURIComponent(statementId)}` : ''; - const resp = await fetch(`/api/dashboard${query}`); - - if (resp.status === 404) { - setState('upload'); - setResult(null); - setAdvisories([]); - return; - } - - if (!resp.ok) { - const body = await resp.json().catch(() => ({})); - throw new Error(body.error ?? body.detail ?? `Dashboard load failed (${resp.status})`); - } - - const data: DashboardResult & { advisories: Advisory[] } = await resp.json(); - setResult(data); - setAdvisories(data.advisories); - setState('results'); - } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to load your dashboard.'); - setState('upload'); - setResult(null); - setAdvisories([]); - } finally { - setIsLoadingDashboard(false); - } - }, []); - - useEffect(() => { - loadDashboard(null).catch(() => { - // Error already reflected in state. - }); - }, [loadDashboard]); - - const handleUpload = useCallback( - async (file: File, nextStatementType: StatementType) => { - setError(null); - - if (file.type && file.type !== 'application/pdf') { - setError('Please upload a PDF file.'); - return; - } - if (file.size > 10 * 1024 * 1024) { - setError('File is too large. Maximum 10 MB.'); - return; - } - - setState('parsing'); - - try { - const formData = new FormData(); - formData.append('file', file); - formData.append('statement_type', nextStatementType); - - const resp = await fetch('/api/statement/parse', { method: 'POST', body: formData }); - - if (!resp.ok) { - const body = await resp.json().catch(() => ({})); - throw new Error(body.error ?? body.detail ?? `Upload failed (${resp.status})`); - } - - const data: DashboardResult = await resp.json(); - await loadDashboard(data.statement_id); - } catch (err) { - setError(err instanceof Error ? err.message : 'Something went wrong.'); - setState('upload'); - } - }, - [loadDashboard] - ); - - const resetToUpload = useCallback(() => { - setState('upload'); - setResult(null); - setAdvisories([]); - setError(null); - }, []); - return ( -
- {/* Header */} -
- - MoneyMirror - - {state === 'results' && ( - - )} -
- - {/* Loading skeleton */} - {isLoadingDashboard && state !== 'parsing' && ( -
-
-
-
- )} - - {state === 'upload' && !isLoadingDashboard && ( - - )} - - {state === 'parsing' && } - - {state === 'results' && result && !isLoadingDashboard && ( - - )} - - -
+ }> + + ); } diff --git a/apps/money-mirror/src/app/globals.css b/apps/money-mirror/src/app/globals.css index 7723c6c..b4bd85f 100644 --- a/apps/money-mirror/src/app/globals.css +++ b/apps/money-mirror/src/app/globals.css @@ -95,11 +95,30 @@ h1, h2, h3 { font-family: "Space Grotesk", "Inter", sans-serif; } flex-direction: column; } +.dashboard-shell { + max-width: 640px; +} + .content-center { flex: 1; display: flex; flex-direction: column; justify-content: center; } .progress-bar { height: 3px; background: var(--border); border-radius: 99px; overflow: hidden; } .progress-fill { height: 100%; background: var(--accent); border-radius: 99px; transition: width 0.4s cubic-bezier(0.4,0,0.2,1); } +select { + background: var(--bg-elevated); + border: 1px solid var(--border); + border-radius: 12px; + color: var(--text-primary); + font-size: 0.9rem; + font-family: "Inter", sans-serif; + padding: 12px 14px; + width: 100%; + outline: none; + cursor: pointer; + transition: border-color 0.2s ease; +} +select:focus { border-color: var(--border-accent); } + input[type="tel"], input[type="number"], input[type="text"] { background: var(--bg-elevated); border: 1px solid var(--border); diff --git a/apps/money-mirror/src/components/PerceivedActualMirror.tsx b/apps/money-mirror/src/components/PerceivedActualMirror.tsx new file mode 100644 index 0000000..5927f6a --- /dev/null +++ b/apps/money-mirror/src/components/PerceivedActualMirror.tsx @@ -0,0 +1,98 @@ +'use client'; + +/** + * Side-by-side perceived vs actual monthly debit total (Mirror moment on dashboard). + */ + +interface PerceivedActualMirrorProps { + perceived_spend_paisa: number; + actual_debits_paisa: number; +} + +export function PerceivedActualMirror({ + perceived_spend_paisa, + actual_debits_paisa, +}: PerceivedActualMirrorProps) { + const perceived = Math.round(perceived_spend_paisa / 100).toLocaleString('en-IN'); + const actual = Math.round(actual_debits_paisa / 100).toLocaleString('en-IN'); + const gap = actual_debits_paisa - perceived_spend_paisa; + const gapLabel = + gap === 0 + ? 'No gap' + : gap > 0 + ? `You spent ₹${Math.round(gap / 100).toLocaleString('en-IN')} more than you thought` + : `You spent ₹${Math.round(-gap / 100).toLocaleString('en-IN')} less than you thought`; + + return ( +
+
+
+ You thought +
+
+ ₹{perceived_spend_paisa > 0 ? perceived : '—'} +
+
+
+
+ Statement shows +
+
+ ₹{actual} +
+
+ {perceived_spend_paisa > 0 && ( +
+ {gapLabel} +
+ )} +
+ ); +} diff --git a/apps/money-mirror/src/lib/advisory-engine.test.ts b/apps/money-mirror/src/lib/advisory-engine.test.ts index 1539f35..faca7a0 100644 --- a/apps/money-mirror/src/lib/advisory-engine.test.ts +++ b/apps/money-mirror/src/lib/advisory-engine.test.ts @@ -19,6 +19,8 @@ describe('generateAdvisories', () => { debt_load_paisa: 0, food_delivery_paisa: 0, subscription_paisa: 0, + payment_due_paisa: null, + minimum_due_paisa: null, }); expect(advisories.some((advisory) => advisory.trigger === 'NO_INVESTMENT')).toBe(false); @@ -41,6 +43,8 @@ describe('generateAdvisories', () => { debt_load_paisa: 500000, food_delivery_paisa: 0, subscription_paisa: 0, + payment_due_paisa: null, + minimum_due_paisa: null, }); expect(advisories.some((advisory) => advisory.trigger === 'HIGH_DEBT_RATIO')).toBe(true); diff --git a/apps/money-mirror/src/lib/advisory-engine.ts b/apps/money-mirror/src/lib/advisory-engine.ts index 7c8accb..0425484 100644 --- a/apps/money-mirror/src/lib/advisory-engine.ts +++ b/apps/money-mirror/src/lib/advisory-engine.ts @@ -37,6 +37,8 @@ interface AdvisoryInput { debt_load_paisa: number; food_delivery_paisa: number; subscription_paisa: number; + payment_due_paisa: number | null; + minimum_due_paisa: number | null; } /** @@ -123,6 +125,78 @@ export function generateAdvisories(input: AdvisoryInput): Advisory[] { } } + // ── 6. HIGH_OTHER_BUCKET — uncategorized / opaque spend ───────── + if (summary.total_debits > 0) { + const otherPct = (summary.other / summary.total_debits) * 100; + if (otherPct >= 35 && summary.total_debits >= 500_000) { + advisories.push({ + id: 'high-other', + trigger: 'HIGH_OTHER_BUCKET', + severity: otherPct >= 50 ? 'warning' : 'info', + headline: `${Math.round(otherPct)}% of spending is "Other"`, + message: `₹${formatRupees(summary.other)} landed in uncategorized merchants (UPI labels, transfers, mixed narrations). Review these line items — some are likely discretionary or avoidable once you label them.`, + amount_paisa: summary.other, + }); + } + } + + // ── 7. DISCRETIONARY_RATIO — wants + uncategorized bulk ───────── + if (summary.total_debits > 0) { + const discretionary = summary.wants + summary.other; + const discPct = (discretionary / summary.total_debits) * 100; + if (discPct >= 50 && summary.total_debits >= 300_000) { + advisories.push({ + id: 'discretionary-heavy', + trigger: 'DISCRETIONARY_RATIO', + severity: discPct >= 65 ? 'warning' : 'info', + headline: `~${Math.round(discPct)}% is wants + uncategorized`, + message: `Wants and "Other" together are ₹${formatRupees(discretionary)}. That's the main pool to cut if you need to free up cash — start with subscriptions, impulse UPI, and food delivery.`, + amount_paisa: discretionary, + }); + } + } + + // ── 8. AVOIDABLE_SPEND_PROXY — explicit "what to review" ──────── + if (summary.total_debits > 0) { + const avoidable = summary.wants + Math.round(summary.other * 0.5); + const avoidPct = (avoidable / summary.total_debits) * 100; + if (avoidPct >= 30 && summary.wants + summary.other >= 500_000) { + advisories.push({ + id: 'avoidable-spend', + trigger: 'AVOIDABLE_SPEND', + severity: 'info', + headline: `Roughly ₹${formatRupees(avoidable)} may be trimmable`, + message: + 'This blends clear "wants" with half of uncategorized spend as a conservative guess — not exact, but a starting point to find unnecessary expenses. Cross-check "Other" before cutting.', + amount_paisa: avoidable, + }); + } + } + + // ── 9. CC_REVOLVING_RISK — minimum due vs statement balance ───── + const payDue = input.payment_due_paisa; + const minDue = input.minimum_due_paisa; + if ( + input.statement_type === 'credit_card' && + payDue !== null && + minDue !== null && + payDue > 0 && + minDue > 0 && + minDue < payDue + ) { + const minShare = minDue / payDue; + if (minShare <= 0.2) { + advisories.push({ + id: 'cc-revolving', + trigger: 'CC_REVOLVING_RISK', + severity: 'warning', + headline: 'Minimum due is only a small slice of total due', + message: `Your minimum due is ₹${formatRupees(minDue)} on a statement balance of ₹${formatRupees(payDue)}. Paying only the minimum racks up interest on the rest — plan full or higher payments when you can.`, + amount_paisa: payDue - minDue, + }); + } + } + // Sort: critical → warning → info const severityOrder: Record = { critical: 0, diff --git a/apps/money-mirror/src/lib/categorizer.ts b/apps/money-mirror/src/lib/categorizer.ts index 10f3185..9a43a68 100644 --- a/apps/money-mirror/src/lib/categorizer.ts +++ b/apps/money-mirror/src/lib/categorizer.ts @@ -38,15 +38,15 @@ export type CreditCardEntryKind = const RULES: Record = { needs: - /\b(rent|electricity|water|gas|broadband|internet|grocery|grocer|zepto|blinkit|bigbasket|d-mart|dmart|reliance smart|metro|more supermarket|uber|ola|rapido|namma yatri|petrol|fuel|insurance|mediclaim|lic|health|pharmacy|chemist|doctor|hospital|clinic)\b/i, + /\b(rent|electricity|water|gas|broadband|internet|grocery|grocer|zepto|blinkit|bigbasket|d-mart|dmart|reliance smart|metro|more supermarket|uber|ola|rapido|namma yatri|petrol|fuel|diesel|cng|insurance|mediclaim|lic|health|pharmacy|chemist|doctor|hospital|clinic|school|fees|tuition|child|milk|dairy|vegetable|fruits)\b/i, wants: - /\b(swiggy|zomato|eatsure|dunzo|barbeque|starbucks|cafe|restaurant|hotel|bar|pub|food|pizza|burger|kfc|mcdonald|domino|netflix|hotstar|prime video|amazon prime|spotify|gaana|youtube premium|jiocinema|zee5|sonyliv|bookmyshow|pvr|inox|myntra|ajio|nykaa|meesho|flipkart|amazon|shopping|fashion|cosmetic|saloon|salon|parlour|gaming|ludo|steam|play store|app store|travel|makemytrip|goibibo|ixigo|cleartrip|irctc|redbus|yolo)\b/i, + /\b(swiggy|zomato|eatsure|dunzo|barbeque|starbucks|cafe|restaurant|hotel|bar|pub|food|pizza|burger|kfc|mcdonald|domino|netflix|hotstar|prime video|amazon prime|spotify|gaana|youtube premium|jiocinema|zee5|sonyliv|bookmyshow|pvr|inox|cinepolis|imax|myntra|ajio|nykaa|meesho|flipkart|amazon pay|shopping|fashion|cosmetic|saloon|salon|parlour|gaming|ludo|steam|play store|app store|travel|makemytrip|goibibo|ixigo|cleartrip|irctc|redbus|yolo|cred|magicpin|eazy|eazydiner)\b/i, investment: - /\b(sip|mutual fund|groww|zerodha|kuvera|paytm money|coin by zerodha|iifl|hdfc mutual|sbi mutual|axis mutual|icici pru|nippon|mirae|navi mutual|smallcase|stock|nse|bse|gold|sovereign gold|ppf|nps|elss|fd|fixed deposit|recurring deposit|rd)\b/i, + /\b(sip|mutual fund|groww|zerodha|kuvera|paytm money|coin by zerodha|iifl|hdfc mutual|sbi mutual|axis mutual|icici pru|nippon|mirae|navi mutual|smallcase|stock|nse|bse|gold|sovereign gold|ppf|nps|elss|fd|fixed deposit|recurring deposit|rd|epf|pf|provident)\b/i, - debt: /\b(emi|equated monthly|loan|credit card|payment due|minimum due|bnpl|buy now pay later|bajaj finserv|zestmoney|zest money|lazypay|lazy pay|simpl|slice|uni card|onecard|stashfin|moneyview|creditmantri|freecharge credit|paytm postpaid|flipkart pay later|amazon pay later|hdfc card|sbi card|icici card|axis card|kotak card)\b/i, + debt: /\b(emi|equated monthly|loan|personal loan|home loan|car loan|credit card|cc payment|payment due|minimum due|bnpl|buy now pay later|bajaj finserv|zestmoney|zest money|lazypay|lazy pay|simpl|slice|uni card|onecard|stashfin|moneyview|creditmantri|freecharge credit|paytm postpaid|flipkart pay later|amazon pay later|hdfc card|sbi card|icici card|axis card|kotak card|rupay|visa|mastercard)\b/i, // "other" is the fallback — not matched by keyword other: /^$/, diff --git a/apps/money-mirror/src/lib/dashboard.ts b/apps/money-mirror/src/lib/dashboard.ts index 55c5c09..75e17f4 100644 --- a/apps/money-mirror/src/lib/dashboard.ts +++ b/apps/money-mirror/src/lib/dashboard.ts @@ -28,6 +28,11 @@ export interface DashboardData { payment_due_paisa: number | null; minimum_due_paisa: number | null; credit_limit_paisa: number | null; + perceived_spend_paisa: number; + monthly_income_paisa: number; + nickname: string | null; + account_purpose: string | null; + card_network: string | null; transaction_count: number; summary: DashboardSummary; advisories: Advisory[]; @@ -45,6 +50,9 @@ interface StatementRow { credit_limit_paisa: number | null; perceived_spend_paisa: number; monthly_income_paisa: number; + nickname: string | null; + account_purpose: string | null; + card_network: string | null; } interface TransactionRow { @@ -63,7 +71,7 @@ export async function fetchDashboardData( const statementRows = statementId ? ((await sql` - SELECT s.id, s.institution_name, s.statement_type, s.period_start, s.period_end, s.due_date, s.payment_due_paisa, s.minimum_due_paisa, s.credit_limit_paisa, s.perceived_spend_paisa, p.monthly_income_paisa + SELECT s.id, s.institution_name, s.statement_type, s.period_start, s.period_end, s.due_date, s.payment_due_paisa, s.minimum_due_paisa, s.credit_limit_paisa, s.perceived_spend_paisa, p.monthly_income_paisa, s.nickname, s.account_purpose, s.card_network FROM statements s LEFT JOIN profiles p ON p.id = s.user_id WHERE s.user_id = ${userId} @@ -82,14 +90,17 @@ export async function fetchDashboardData( credit_limit_paisa: number | string | bigint | null; perceived_spend_paisa: number | string | bigint; monthly_income_paisa: number | string | bigint | null; + nickname: string | null; + account_purpose: string | null; + card_network: string | null; }[]) : ((await sql` - SELECT s.id, s.institution_name, s.statement_type, s.period_start, s.period_end, s.due_date, s.payment_due_paisa, s.minimum_due_paisa, s.credit_limit_paisa, s.perceived_spend_paisa, p.monthly_income_paisa + SELECT s.id, s.institution_name, s.statement_type, s.period_start, s.period_end, s.due_date, s.payment_due_paisa, s.minimum_due_paisa, s.credit_limit_paisa, s.perceived_spend_paisa, p.monthly_income_paisa, s.nickname, s.account_purpose, s.card_network FROM statements s LEFT JOIN profiles p ON p.id = s.user_id WHERE s.user_id = ${userId} AND s.status = 'processed' - ORDER BY s.created_at DESC + ORDER BY s.period_end DESC NULLS LAST, s.created_at DESC LIMIT 1 `) as { id: string; @@ -103,6 +114,9 @@ export async function fetchDashboardData( credit_limit_paisa: number | string | bigint | null; perceived_spend_paisa: number | string | bigint; monthly_income_paisa: number | string | bigint | null; + nickname: string | null; + account_purpose: string | null; + card_network: string | null; }[]); const row = statementRows[0]; @@ -123,6 +137,9 @@ export async function fetchDashboardData( perceived_spend_paisa: toNumber(row.perceived_spend_paisa), monthly_income_paisa: row.monthly_income_paisa === null ? 0 : toNumber(row.monthly_income_paisa), + nickname: row.nickname ?? null, + account_purpose: row.account_purpose ?? null, + card_network: row.card_network ?? null, }; const transactionRows = (await sql` @@ -161,6 +178,11 @@ export async function fetchDashboardData( payment_due_paisa: statement.payment_due_paisa, minimum_due_paisa: statement.minimum_due_paisa, credit_limit_paisa: statement.credit_limit_paisa, + perceived_spend_paisa: statement.perceived_spend_paisa, + monthly_income_paisa: statement.monthly_income_paisa, + nickname: statement.nickname, + account_purpose: statement.account_purpose, + card_network: statement.card_network, transaction_count: transactions.length, summary, advisories, @@ -257,5 +279,7 @@ function buildAdvisories( : summary.debt_paisa, food_delivery_paisa: foodDeliveryPaisa, subscription_paisa: subscriptionPaisa, + payment_due_paisa: statement.payment_due_paisa, + minimum_due_paisa: statement.minimum_due_paisa, }); } diff --git a/apps/money-mirror/src/lib/format-date.ts b/apps/money-mirror/src/lib/format-date.ts new file mode 100644 index 0000000..d383688 --- /dev/null +++ b/apps/money-mirror/src/lib/format-date.ts @@ -0,0 +1,98 @@ +/** + * Statement dates are stored as DATE in Postgres and may serialize as ISO strings. + * Parse as UTC calendar dates and format for display to avoid off-by-one in IST. + */ + +export function parseDateInput(value: string | null): Date | null { + if (!value) { + return null; + } + const s = value.trim(); + if (/^\d{4}-\d{2}-\d{2}$/.test(s)) { + const [y, m, d] = s.split('-').map(Number); + return new Date(Date.UTC(y, m - 1, d)); + } + const t = Date.parse(s); + if (Number.isNaN(t)) { + return null; + } + return new Date(t); +} + +export function formatStatementDate(value: string | null, locale = 'en-IN'): string { + const d = parseDateInput(value); + if (!d) { + return '—'; + } + return new Intl.DateTimeFormat(locale, { + day: 'numeric', + month: 'short', + year: 'numeric', + timeZone: 'UTC', + }).format(d); +} + +export function formatPeriodRange( + start: string | null, + end: string | null, + locale = 'en-IN' +): string { + const a = formatStatementDate(start, locale); + const b = formatStatementDate(end, locale); + if (a === '—' && b === '—') { + return 'Unknown period'; + } + if (a === '—') { + return b; + } + if (b === '—') { + return a; + } + return `${a} – ${b}`; +} + +/** e.g. "March 2026" for statement period label (uses period_end) */ +export function statementMonthLabel(periodEnd: string | null, locale = 'en-IN'): string { + const d = parseDateInput(periodEnd); + if (!d) { + return 'Unknown month'; + } + return new Intl.DateTimeFormat(locale, { + month: 'long', + year: 'numeric', + timeZone: 'UTC', + }).format(d); +} + +/** YYYY-MM for filtering */ +export function monthKeyFromPeriodEnd(periodEnd: string | null): string | null { + const d = parseDateInput(periodEnd); + if (!d) { + return null; + } + return `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, '0')}`; +} + +export function listMonthKeysFromPeriodEnds(periodEnds: (string | null)[]): string[] { + const set = new Set(); + for (const pe of periodEnds) { + const k = monthKeyFromPeriodEnd(pe); + if (k) { + set.add(k); + } + } + return Array.from(set).sort().reverse(); +} + +export function formatMonthKeyForDisplay(monthKey: string, locale = 'en-IN'): string { + const [y, m] = monthKey.split('-').map(Number); + if (!y || !m) { + return monthKey; + } + const d = new Date(Date.UTC(y, m - 1, 1)); + return new Intl.DateTimeFormat(locale, { + month: 'long', + year: 'numeric', + timeZone: 'UTC', + }).format(d); +} diff --git a/apps/money-mirror/src/lib/statements-list.ts b/apps/money-mirror/src/lib/statements-list.ts new file mode 100644 index 0000000..2b9407c --- /dev/null +++ b/apps/money-mirror/src/lib/statements-list.ts @@ -0,0 +1,72 @@ +import { getDb } from '@/lib/db'; +import { statementMonthLabel } from '@/lib/format-date'; +import type { StatementType } from '@/lib/statements'; + +export interface StatementListItem { + id: string; + institution_name: string; + statement_type: StatementType; + period_start: string | null; + period_end: string | null; + created_at: string; + nickname: string | null; + account_purpose: string | null; + card_network: string | null; +} + +export async function listProcessedStatements(userId: string): Promise { + const sql = getDb(); + const rows = (await sql` + SELECT + id, + institution_name, + statement_type, + period_start, + period_end, + created_at, + nickname, + account_purpose, + card_network + FROM statements + WHERE user_id = ${userId} + AND status = 'processed' + ORDER BY period_end DESC NULLS LAST, created_at DESC + `) as { + id: string; + institution_name: string; + statement_type: StatementType; + period_start: string | null; + period_end: string | null; + created_at: Date | string; + nickname: string | null; + account_purpose: string | null; + card_network: string | null; + }[]; + + return rows.map((r) => ({ + id: r.id, + institution_name: r.institution_name, + statement_type: r.statement_type, + period_start: r.period_start, + period_end: r.period_end, + created_at: + typeof r.created_at === 'string' ? r.created_at : (r.created_at as Date).toISOString(), + nickname: r.nickname ?? null, + account_purpose: r.account_purpose ?? null, + card_network: r.card_network ?? null, + })); +} + +/** Stable label for statement picker */ +export function statementPickerLabel(item: StatementListItem): string { + const nick = item.nickname?.trim(); + const base = nick || item.institution_name; + const month = statementMonthLabel(item.period_end); + const type = + item.statement_type === 'credit_card' + ? item.card_network + ? item.card_network + : 'Card' + : 'Bank'; + return `${base} · ${type} · ${month}`; +} diff --git a/apps/money-mirror/src/lib/upload-metadata.ts b/apps/money-mirror/src/lib/upload-metadata.ts new file mode 100644 index 0000000..803f102 --- /dev/null +++ b/apps/money-mirror/src/lib/upload-metadata.ts @@ -0,0 +1,27 @@ +export type AccountPurpose = 'spending' | 'savings_goals' | 'unspecified'; + +export function parseAccountPurpose(raw: unknown): AccountPurpose | null { + if (typeof raw !== 'string') { + return null; + } + if (raw === 'spending' || raw === 'savings_goals' || raw === 'unspecified') { + return raw; + } + return null; +} + +export function sanitizeNickname(raw: unknown): string | null { + if (typeof raw !== 'string') { + return null; + } + const t = raw.trim().slice(0, 80); + return t.length ? t : null; +} + +export function sanitizeCardNetwork(raw: unknown): string | null { + if (typeof raw !== 'string') { + return null; + } + const t = raw.trim().slice(0, 40); + return t.length ? t : null; +} diff --git a/experiments/linear-sync/issue-009.json b/experiments/linear-sync/issue-009.json index afb99c0..e24e382 100644 --- a/experiments/linear-sync/issue-009.json +++ b/experiments/linear-sync/issue-009.json @@ -20,9 +20,24 @@ "feature": "70095fc1-871c-427e-bc1f-2c29b8d87aa8" }, "documents": {}, - "tasks": {}, - "last_sync_mode": "phase-1-rollout-closeout", - "last_sync_timestamp": "2026-04-04T08:22:36Z", + "tasks": { + "phase_2_shipped": "VIJ-23", + "neon_alter_statement_labels": "VIJ-24", + "sprint_4_backlog": "VIJ-25", + "sprint_1": "VIJ-26", + "sprint_2": "VIJ-27", + "sprint_3": "VIJ-28", + "epic_a": "VIJ-29", + "epic_b": "VIJ-30", + "epic_c": "VIJ-31", + "epic_d": "VIJ-32", + "epic_e": "VIJ-33", + "epic_f": "VIJ-34", + "epic_g": "VIJ-35", + "epic_h": "VIJ-36" + }, + "last_sync_mode": "phase-2-roadmap-full-map-manual", + "last_sync_timestamp": "2026-04-05T16:00:00Z", "pipeline_status": "learning", "linear_status": "Done", "closeout_document_id": "97bb3d9b-6f13-49c7-9f06-827d15ad6cd6", diff --git a/project-state.md b/project-state.md index 1fc92cf..67197cc 100644 --- a/project-state.md +++ b/project-state.md @@ -11,9 +11,9 @@ ## Current Stage - stage: execute_plan -- last_command_run: MoneyMirror Phase 1 rollout — Vercel routing/protection fix + production verify (VIJ-22, VIJ-20, VIJ-13 closed) -- status: completed -- active_issue: issue-009 / VIJ-13 (Phase 1 rollout validation Done in Linear) +- last_command_run: MoneyMirror Phase 2 — full roadmap mirrored to Linear (Sprints 1–4 + Epics A–H, VIJ-23–36 + VIJ-24 ops) (2026-04-05) +- status: in_progress +- active_issue: issue-009 — Phase 2 code shipped; **VIJ-24** Neon ALTER if label columns missing; **VIJ-25** Sprint 4 backlog ## Active Work @@ -21,7 +21,7 @@ - last_commit: 44eb797 - open_pr_link: https://github.com/shadowdevcode/ai-product-os/pull/15 - environments: local, production (`https://money-mirror-rho.vercel.app`) -- implementation_focus: Phase 1 rollout complete — Neon schema + smokes + Vercel production verified +- implementation_focus: Phase 2 — friendly dates, Overview/Insights/Upload tabs, `GET /api/statements`, month + statement picker, perceived vs actual mirror card, expanded advisories + categorizer, upload labels (`nickname` / `account_purpose` / `card_network`), `docs/COACHING-TONE.md`. Tests in `apps/money-mirror` via `npm test`. ## Quality Gates @@ -53,9 +53,44 @@ - postmortem: done — 7 systemic issues identified across 4 agents. Key rules: worker endpoint auth required at architecture stage, single emission source for North Star events, orderId-as-DB-key fidelity, localStorage+DB deduplication for simulation tools, PostHog Promise.allSettled with per-call catch in workers, README+.env.local.example as execute-plan deliverables (not deploy-check fixes), error-path telemetry required during execute-plan. Prompt autopsy targets: execute-plan (single emission, telemetry resilience, error-path events), backend-architect-agent (worker auth, URL ID fidelity, simulation idempotency), peer-review-agent (demo simulation tool reload guard), qa-agent (telemetry unavailability failure simulation). Result saved to experiments/results/postmortem-006.md. - learning: done — 7 engineering rules extracted. knowledge/engineering-lessons.md and knowledge/prompt-library.md updated. Agent files updated: backend-architect-agent.md (items 4–7 in Mandatory Pre-Approval Checklist), peer-review-agent.md (Step 5 simulation idempotency + URL ID fidelity checks), qa-agent.md (Telemetry Unavailability Test + Failure Telemetry Verification), code-review-agent.md (PostHog dual-emission critical check), commands/execute-plan.md (Sections 6–8). CODEBASE-CONTEXT.md written to apps/ozi-reorder/. Full pipeline cycle for issue-006 complete. +## MoneyMirror PM roadmap — Linear map (parent [VIJ-11](https://linear.app/vijaypmworkspace/issue/VIJ-11/issue-009-moneymirror-ai-powered-personal-finance-coach-for-gen-z)) + +All items sit in Linear project **issue-009 — MoneyMirror**. Feature work for Sprints 1–3 and Epics A–H is **Done in repo**; issues record scope and traceability. + +### Sprints (suggested ordering from plan) + +| Sprint | Linear | Status | Scope (plan) | +| ------------------ | ------------------------------------------------------------------------------------------------------------------------------- | ------- | ------------------ | +| Sprint 1 | [VIJ-26](https://linear.app/vijaypmworkspace/issue/VIJ-26/moneymirror-sprint-1-epic-a-b-dates-shell-mirror-card) | Done | Epic A + B | +| Sprint 2 | [VIJ-27](https://linear.app/vijaypmworkspace/issue/VIJ-27/moneymirror-sprint-2-epic-c-d-statement-library-month-filter) | Done | Epic C + D | +| Sprint 3 | [VIJ-28](https://linear.app/vijaypmworkspace/issue/VIJ-28/moneymirror-sprint-3-epic-e-f-insights-tab-wasteful-spend-advisories) | Done | Epic E + F (F1–F2) | +| Sprint 4 / Backlog | [VIJ-25](https://linear.app/vijaypmworkspace/issue/VIJ-25/moneymirror-sprint-4-backlog-f3-g2-g3-h3) | Backlog | F3, G2–G3, H3 | + +### Epics A–H + +| Epic | Linear | Status | Notes | +| --------------------- | -------------------------------------------------------------------------------------------------------------------------- | ------ | --------------------------- | +| A — Date polish | [VIJ-29](https://linear.app/vijaypmworkspace/issue/VIJ-29/moneymirror-epic-a-date-and-copy-polish) | Done | | +| B — Dashboard shell | [VIJ-30](https://linear.app/vijaypmworkspace/issue/VIJ-30/moneymirror-epic-b-dashboard-shell-and-information-architecture) | Done | | +| C — Statement library | [VIJ-31](https://linear.app/vijaypmworkspace/issue/VIJ-31/moneymirror-epic-c-statement-library-not-one-at-a-time) | Done | | +| D — Filters / month | [VIJ-32](https://linear.app/vijaypmworkspace/issue/VIJ-32/moneymirror-epic-d-filters-and-which-month-context) | Done | D3 compare months → backlog | +| E — Insights surface | [VIJ-33](https://linear.app/vijaypmworkspace/issue/VIJ-33/moneymirror-epic-e-dedicated-ai-insights-surface) | Done | | +| F — Wasteful spend | [VIJ-34](https://linear.app/vijaypmworkspace/issue/VIJ-34/moneymirror-epic-f-unnecessary-wasteful-spend-signals) | Done | F3 → VIJ-25 | +| G — Multi-account | [VIJ-35](https://linear.app/vijaypmworkspace/issue/VIJ-35/moneymirror-epic-g-multi-account-multi-card-india-icp) | Done | G1 shipped; G2–G3 → VIJ-25 | +| H — Coaching tone | [VIJ-36](https://linear.app/vijaypmworkspace/issue/VIJ-36/moneymirror-epic-h-coaching-tone-and-safe-ai-narrative) | Done | H1 doc; H2–H3 → VIJ-25 | + +### Roll-up + ops + +| Item | Linear | Status | +| ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | ----------- | +| Phase 2 delivery summary | [VIJ-23](https://linear.app/vijaypmworkspace/issue/VIJ-23/moneymirror-phase-2-shipped-dashboard-roadmap-a-h-baseline) | Done | +| Neon ALTER for label columns | [VIJ-24](https://linear.app/vijaypmworkspace/issue/VIJ-24/moneymirror-ops-run-neon-alter-for-statement-label-columns-nickname) | Todo (High) | + ## Pending Queue -- Credit card PDF smoke follow-up: categorisation landed 95% "Other" for bank_account — may need categoriser tuning in a future issue +- **VIJ-24 (High):** Run Neon `ALTER TABLE` for `statements.nickname`, `account_purpose`, `card_network` from [`apps/money-mirror/schema.sql`](apps/money-mirror/schema.sql) if those columns are missing — required for labeled uploads to persist. +- **VIJ-25 (Backlog):** Sprint 4 — F3, G2–G3, H3 (see table above). +- Credit card PDF smoke follow-up: categorisation can still skew high "Other" — optional F3 in backlog - Optional: confirm Neon Auth redirect / allowed origins for production OTP if sign-in fails (dashboard) ## Blockers @@ -147,6 +182,8 @@ - 2026-04-04: MoneyMirror production deploy attempt executed for VIJ-20. Created and linked Vercel project `money-mirror` in scope `vijay-sehgals-projects`, synced production env vars from app-local `.env.local` except blank Sentry values (`NEXT_PUBLIC_SENTRY_DSN`, `SENTRY_ORG`, `SENTRY_PROJECT`), and corrected `NEXT_PUBLIC_APP_URL` to `https://money-mirror-rho.vercel.app`. First deploy failed because `middleware.ts` imported `@neondatabase/auth/next/server`, which Vercel rejected in the Edge runtime. Fixed by replacing [`middleware.ts`](/Users/vijaysehgal/Downloads/02-Portfolio/ai-product-os/apps/money-mirror/middleware.ts) with [`proxy.ts`](/Users/vijaysehgal/Downloads/02-Portfolio/ai-product-os/apps/money-mirror/proxy.ts) so auth gating runs in Next 16's Node proxy runtime. Local validation after the fix: `npm test` PASS (45 tests), `npx next build --webpack` PASS, `npx tsc --noEmit` PASS after regenerating `.next/types`. Subsequent production builds succeeded and Vercel aliased the app to `https://money-mirror-rho.vercel.app`, but the release is still blocked: unauthenticated requests return Vercel Authentication `401`, and authenticated `vercel curl` requests still return `NOT_FOUND` for `/`, `/login`, `/dashboard`, and `/api/cron/weekly-recap`. Next required action: fix Vercel project/public routing configuration before VIJ-20 can be closed. - 2026-04-04: MoneyMirror Vercel production unblocked and Linear VIJ-22/VIJ-20/VIJ-13 closed. Vercel API `PATCH /v9/projects/money-mirror`: `rootDirectory: apps/money-mirror`, `framework: nextjs`, `sourceFilesOutsideRootDirectory: true`; `ssoProtection.deploymentType` changed from `all_except_custom_domains` to `preview` (production `.vercel.app` URLs public). Production redeploy `dpl_UrdwuBkS4qvSwgqY2PjTJvyKS8cW` READY. Verified: `GET /` and `/login` → 200 (Next.js HTML); `GET /api/cron/weekly-recap` → 401 without secret, 200 with `x-cron-secret`; `NEXT_PUBLIC_APP_URL` matches alias. Gitignored repo-root `.vercel/project.json` added so `vercel deploy --prod` runs from monorepo root (avoids doubled `apps/money-mirror` path when project `rootDirectory` is set). - 2026-04-04: Repo / dev-environment hygiene — removed committed Neon MCP secret (gitignore `.mcp.json`, `.mcp.json.example`), added Codex [`.codex/config.toml`](.codex/config.toml) with `NEON_API_KEY` bearer env var, documented in CHANGELOG. Opened GitHub PR [#15](https://github.com/shadowdevcode/ai-product-os/pull/15) for review. **Linear:** VIJ-11 remains **Done** (verified); `linear_last_sync` unchanged — these changes are not a MoneyMirror product milestone, so `/linear-sync` was not re-run for them. +- 2026-04-05: MoneyMirror Phase 2 (PM roadmap in `.cursor/plans/moneymirror_pm_roadmap_*.plan.md`) **implemented in repo** — not the same as automatic Linear sprint import; roadmap sprints were engineering guidance. **Linear:** created **VIJ-23** (Done) = Phase 2 delivery record under VIJ-11; **VIJ-24** (Todo, High) = Neon ALTER for label columns; **VIJ-25** (Backlog) = F3/G2–G3/H3 follow-ups. Next Codex/Claude sessions: pick **VIJ-24** first if uploads fail on missing columns; use **VIJ-25** when planning the next sprint. +- 2026-04-05 (later): **Full Linear mirror** of sprint + epic breakdown — **Sprints 1–3** VIJ-26–28 (Done), **Sprint 4 / Backlog** VIJ-25 (title updated), **Epics A–H** VIJ-29–36 (Done, with F3/G2–G3/H2–H3 called out in epic bodies → VIJ-25). See **MoneyMirror PM roadmap — Linear map** section in this file. ## Links @@ -162,10 +199,10 @@ - linear_root_issue_identifier: VIJ-11 - linear_cycle: - linear_sync_map_path: experiments/linear-sync/issue-009.json -- linear_last_sync: 2026-04-04T08:22:36Z -- linear_sync_status: success — VIJ-22, VIJ-20, VIJ-13 marked Done; Phase 1 rollout validation complete. Repo-hygiene commits (MCP/Codex, PR #15) not mirrored to Linear. -- linear_follow_up_issue_identifier: -- linear_follow_up_issue_url: https://linear.app/vijaypmworkspace/issue/VIJ-13/moneymirror-phase-1-rollout-validation +- linear_last_sync: 2026-04-05T16:00:00Z +- linear_sync_status: success — Full roadmap map: VIJ-26–28 Sprints 1–3 Done, VIJ-25 Sprint 4 Backlog, VIJ-29–36 Epics A–H Done, VIJ-23 roll-up Done, VIJ-24 ops Todo. Not a `/linear-sync` pipeline command. +- linear_follow_up_issue_identifier: VIJ-24 +- linear_follow_up_issue_url: https://linear.app/vijaypmworkspace/issue/VIJ-24/moneymirror-ops-run-neon-alter-for-statement-label-columns-nickname - docs_home: experiments/ideas/issue-007.md - demo: - analytics_dashboard: