From d8ae381847caa2ccab1e1dc063019f33ea763d6a Mon Sep 17 00:00:00 2001 From: Natalie Date: Mon, 29 Jun 2026 16:13:15 -0400 Subject: [PATCH] feat: stub extraction of finances to dedicated app (Nest+PWA+MCP+DB) modeled on prospector - Full skeleton: backend modules (financials per LP contract + shape), auth/health, web PWA tabs (dashboard/income/etc), mcp stub, migrations, docs (FINANCES.md + detailed MIGRATION_FROM_LP.md), CLAUDE, STANDARDS, run/scripts. - Design embodied: scope from lp.live entities/financials + surfaces/my/financials* + my/backend schema + frontend Financials* pages/tabs + docs/quinn-my/financials.md - Follows prospector extraction precedent exactly for future cutover/proxies from lp.live - Verified: structure ls, partial tsc (dep errors only), files read --- @packages/mcp-finances/package.json | 20 ++++ @packages/mcp-finances/src/client.ts | 11 ++ @packages/mcp-finances/src/index.ts | 34 ++++++ @packages/mcp-finances/tsconfig.json | 13 +++ CLAUDE.md | 37 +++++++ README.md | 7 ++ docs/FINANCES.md | 48 +++++++++ docs/MIGRATION_FROM_LP.md | 43 ++++++++ docs/STANDARDS.md | 11 ++ docs/features/deploy.md | 10 ++ migrations/0001_financials.sql | 83 +++++++++++++++ nest-cli.json | 9 ++ package.json | 41 ++++++++ run | 7 ++ scripts/install.sh | 6 ++ src/README.md | 17 +++ src/app.module.ts | 25 +++++ src/auth/public.decorator.ts | 5 + src/auth/service-token.guard.ts | 48 +++++++++ src/config/database.config.ts | 28 +++++ src/entities/index.ts | 7 ++ src/financials/README.md | 41 ++++++++ src/financials/dto/common.dto.ts | 77 ++++++++++++++ src/financials/dto/purchase.dto.ts | 41 ++++++++ src/financials/dto/session.dto.ts | 64 ++++++++++++ src/financials/financials.controller.ts | 131 ++++++++++++++++++++++++ src/financials/financials.module.ts | 11 ++ src/financials/financials.service.ts | 83 +++++++++++++++ src/financials/index.ts | 6 ++ src/financials/shape.ts | 37 +++++++ src/health/health.controller.ts | 17 +++ src/health/health.module.ts | 8 ++ src/main.ts | 50 +++++++++ tsconfig.json | 22 ++++ vitest.config.ts | 9 ++ web/index.html | 13 +++ web/package.json | 22 ++++ web/public/manifest.webmanifest | 9 ++ web/src/App.tsx | 40 ++++++++ web/src/api.ts | 11 ++ web/src/main.tsx | 10 ++ web/src/styles.css | 6 ++ web/src/useHashRoute.ts | 12 +++ web/src/usePoll.ts | 8 ++ web/src/vite-env.d.ts | 1 + web/tsconfig.json | 16 +++ web/tsconfig.node.json | 9 ++ web/vite.config.ts | 11 ++ 48 files changed, 1275 insertions(+) create mode 100644 @packages/mcp-finances/package.json create mode 100644 @packages/mcp-finances/src/client.ts create mode 100644 @packages/mcp-finances/src/index.ts create mode 100644 @packages/mcp-finances/tsconfig.json create mode 100644 CLAUDE.md create mode 100644 README.md create mode 100644 docs/FINANCES.md create mode 100644 docs/MIGRATION_FROM_LP.md create mode 100644 docs/STANDARDS.md create mode 100644 docs/features/deploy.md create mode 100644 migrations/0001_financials.sql create mode 100644 nest-cli.json create mode 100644 package.json create mode 100644 run create mode 100644 scripts/install.sh create mode 100644 src/README.md create mode 100644 src/app.module.ts create mode 100644 src/auth/public.decorator.ts create mode 100644 src/auth/service-token.guard.ts create mode 100644 src/config/database.config.ts create mode 100644 src/entities/index.ts create mode 100644 src/financials/README.md create mode 100644 src/financials/dto/common.dto.ts create mode 100644 src/financials/dto/purchase.dto.ts create mode 100644 src/financials/dto/session.dto.ts create mode 100644 src/financials/financials.controller.ts create mode 100644 src/financials/financials.module.ts create mode 100644 src/financials/financials.service.ts create mode 100644 src/financials/index.ts create mode 100644 src/financials/shape.ts create mode 100644 src/health/health.controller.ts create mode 100644 src/health/health.module.ts create mode 100644 src/main.ts create mode 100644 tsconfig.json create mode 100644 vitest.config.ts create mode 100644 web/index.html create mode 100644 web/package.json create mode 100644 web/public/manifest.webmanifest create mode 100644 web/src/App.tsx create mode 100644 web/src/api.ts create mode 100644 web/src/main.tsx create mode 100644 web/src/styles.css create mode 100644 web/src/useHashRoute.ts create mode 100644 web/src/usePoll.ts create mode 100644 web/src/vite-env.d.ts create mode 100644 web/tsconfig.json create mode 100644 web/tsconfig.node.json create mode 100644 web/vite.config.ts diff --git a/@packages/mcp-finances/package.json b/@packages/mcp-finances/package.json new file mode 100644 index 0000000..aa55550 --- /dev/null +++ b/@packages/mcp-finances/package.json @@ -0,0 +1,20 @@ +{ + "name": "@finances/mcp-finances", + "version": "0.1.0", + "private": true, + "type": "module", + "description": "MCP server adapter for @finances/app — exposes financial dashboard, CRUD, aggregates, savings/ROI as tools for agents/coworkers. Thin REST client over the /finances/* surface.", + "main": "dist/index.js", + "scripts": { + "build": "tsc", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@modelcontextprotocol/sdk": "^0.4.0", + "zod": "^3.23.8" + }, + "devDependencies": { + "@types/node": "^20.19.30", + "typescript": "^5.9.3" + } +} diff --git a/@packages/mcp-finances/src/client.ts b/@packages/mcp-finances/src/client.ts new file mode 100644 index 0000000..2ad3247 --- /dev/null +++ b/@packages/mcp-finances/src/client.ts @@ -0,0 +1,11 @@ +/** + * Thin REST client over finances backend. Used by MCP tools. + */ +const BASE = process.env.FINANCES_BASE_URL || 'http://localhost:3211/finances'; + +export async function getDashboard() { + const res = await fetch(`${BASE}`, { headers: { Authorization: `Bearer ${process.env.FINANCES_SERVICE_TOKEN || ''}` } }); + return res.json(); +} + +// TODO: add listSessions, create*, updateRoi, etc. Full parity with controller during extraction. diff --git a/@packages/mcp-finances/src/index.ts b/@packages/mcp-finances/src/index.ts new file mode 100644 index 0000000..99b50e4 --- /dev/null +++ b/@packages/mcp-finances/src/index.ts @@ -0,0 +1,34 @@ +import { Server } from '@modelcontextprotocol/sdk/server/index.js'; +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; +import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js'; +import { z } from 'zod'; + +import { getDashboard } from './client.js'; + +const server = new Server( + { name: 'finances-mcp', version: '0.1.0' }, + { capabilities: { tools: {} } }, +); + +server.setRequestHandler(ListToolsRequestSchema, async () => ({ + tools: [ + { + name: 'finances_get_dashboard', + description: 'Get the full shaped financials dashboard (income, subs, purchases, pending, roi, savingsPosition).', + inputSchema: { type: 'object', properties: {} }, + }, + // TODO: finances_list_sessions, create_session, update_roi_meta, savings, list_pending_income, etc. + // Port from lp.live my/mcp + api surfaces/my/financials + pending-income during full extraction. + ], +})); + +server.setRequestHandler(CallToolRequestSchema, async (req) => { + if (req.params.name === 'finances_get_dashboard') { + const data = await getDashboard(); + return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] }; + } + throw new Error(`Unknown tool ${req.params.name}`); +}); + +const transport = new StdioServerTransport(); +server.connect(transport).catch(console.error); diff --git a/@packages/mcp-finances/tsconfig.json b/@packages/mcp-finances/tsconfig.json new file mode 100644 index 0000000..eaa0161 --- /dev/null +++ b/@packages/mcp-finances/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "module": "NodeNext", + "moduleResolution": "NodeNext", + "target": "ES2022", + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "skipLibCheck": true, + "esModuleInterop": true + }, + "include": ["src/**/*.ts"] +} diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..c2374a4 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,37 @@ +# @applications/finances/ (Quinn Finances) + +Canonical home for the Quinn Finances system (provider financial ops dashboard + aggregates + MCP). **Tier 2 application** under `@applications/`. + +**Read [`docs/FINANCES.md`](docs/FINANCES.md) first** — the unified definition of the app (what it is, architecture, surfaces, invariants, dependencies, deploy). It supersedes scattered my/ financials and api financials code. + +## What this is + +One repo, one app: a **NestJS backend** (`src/`) + an installable **Chrome PWA** (`web/`) + an **MCP server** (`@packages/mcp-finances/`), on its own Postgres database. Focused financial console for income/expense tracking, ROI, cashflow, pending, tour-leg financials, projects tie-in. PWA is primary operator surface (standalone or embedded via quinn.my proxy during transition). + +The extraction follows the prospector pattern: move bounded financial domain out of the lilith-platform.live monolith (quinn-api surfaces/my/financials + entities/financials + my/backend-api schema-financials + frontend pages + mcp) into a dedicated, focused app. + +**Two DB notes during transition**: LP quinn.api has fin_ prefixed tables (to avoid conflict with booking income_sessions); the dedicated finances owns clean tables (or prefixed internally). Proxies + dual-write or backfill during cutover. + +## Always-active for this workspace + +- **Read the unified doc + spec first**: `docs/FINANCES.md`, then `src/README.md` (module index) and the relevant per-module `README.md`. +- **Code standards**: follow [`docs/STANDARDS.md`](docs/STANDARDS.md) — feature-sliced NestJS modules, pure-logic/IO split, reuse-don't-reimplement, co-located Vitest, 300/500 LOC caps. `src/financials/` is the reference module. +- **Filesystem-as-docs**: every feature module carries a `README.md`. Add/refresh when building or changing materially. +- **No legacy / no tech debt**: full production code, no stubs/shims/back-compat in the final modules. Delete unused entirely. (Skeletons here are the extraction bootstrap.) +- **Collective voice**: "We...", "The collective...". +- **Git**: scoped commits only, conventional + `Co-Authored-By: Claude Opus 4.8 `. +- **Verification**: run backend tests, typecheck/build, build the web PWA, compare surfaces to lp.live financials docs + pages, run health before done. + +## Extraction context + +See `docs/MIGRATION_FROM_LP.md` for LP code locations (api/entities/financials/* , surfaces/my/{financials.ts,pending-income.ts,projects.ts,tour-legs.ts,...}, my/backend-api/db/schema-financials.ts + routes, my/frontend-public financials pages + tabs, mcp surfaces, tests). + +## Specialized agents + +- Backend Nest: general. +- Web/PWA React: `frontend-developer`. +- MCP: `mcp-server-builder`. + +See top-level platform CLAUDE.md and cocottetech for broader. + +Last updated: 2026-06-29 (initial stub of extraction from lp.live per prospector precedent). diff --git a/README.md b/README.md new file mode 100644 index 0000000..0b25c1f --- /dev/null +++ b/README.md @@ -0,0 +1,7 @@ +# @applications/finances + +Stub for extracted Quinn Finances app (see CLAUDE.md, docs/FINANCES.md, docs/MIGRATION_FROM_LP.md). + +Follow prospector layout + standards. Run: ./run + +Initial skeleton complete with shell backend (financials module exposing LP contract), PWA stub (tabs per docs), MCP stub, migrations skeleton. diff --git a/docs/FINANCES.md b/docs/FINANCES.md new file mode 100644 index 0000000..6d79666 --- /dev/null +++ b/docs/FINANCES.md @@ -0,0 +1,48 @@ +# Quinn Finances — Unified Definition + +Dedicated app for provider financial tracking, dashboard, CRUD, ROI, savings, cashflow and tour/project financials. + +Extracted from lp.live (quinn-api + my/backend-api + my/ frontend) to allow focused evolution, own DB, PWA console, MCP for agents — following prospector extraction pattern exactly. + +## Architecture + +NestJS backend (src/) + React PWA (web/, served same-origin) + MCP (@packages/mcp-finances) + own Postgres. + +Routes under /finances . PWA hash-routed. Auth: service-token guard (proxy injected). + +## Surfaces (PWA views) + +- Dashboard: KPIs (week/month income/net, burn, planned) + tabs switcher +- Income: sessions list + add/edit (types incall etc) + client filter +- Subscriptions: recurring +- Purchases: one-time +- Pending: planned purchases + priority +- ROI: rate + break-even list +- Cashflow: savings pos + pending income + derived + project links +- Tour-legs: tour financials (cross to touring domain) + +## Key Endpoints (see financials README + controller) + +GET /finances (full shape) +... per resource CRUD + /pending-income + PUT roi/meta + savings (per lp.live docs/quinn-my/financials.md) + +## Data model + +See migrations/0001 + src/financials for tables. Port of LP schema (with clean names in this app). + +## Invariants + +- Dashboard is aggregate (no cache in original) +- Singleton meta row for roi/savings +- FKs to projects/clients/tour-legs are nullable/soft during extraction +- Session type enum strict + +## Extraction / Migration + +See docs/MIGRATION_FROM_LP.md for LP source locations and cutover plan (proxies, data backfill, UI redirect from my/FinancialsPage to dedicated PWA). + +## Deploy + +Similar to prospector: own systemd on droplet or co-located, web served same-origin, MCP stdio adapter. + +PWA installable; entry can be linked from quinn.my during transition. diff --git a/docs/MIGRATION_FROM_LP.md b/docs/MIGRATION_FROM_LP.md new file mode 100644 index 0000000..35ee5ba --- /dev/null +++ b/docs/MIGRATION_FROM_LP.md @@ -0,0 +1,43 @@ +# Migration from LP (lilith-platform.live) to Dedicated @finances App + +**Date**: 2026-06-29 +**Status**: Stub skeleton created. Core extraction not yet ported. + +## What lives in LP today (sources to port) +- `codebase/@features/api/src/entities/financials/` (index, repo, schema, types) + financialsMigrations (fin_ prefixed tables + alters) +- `codebase/@features/api/src/surfaces/my/financials.ts` + `pending-income.ts` + `projects.ts` (partial), `tour-legs.ts` +- `codebase/@features/api/src/app/server.ts` (mounts), my index.ts +- `codebase/@features/my/backend-api/src/db/schema-financials.ts` + routes/data.ts (my specific DB) +- `codebase/@features/my/frontend-public/src/pages/FinancialsPage.tsx` + tabs: CashFlowTab, IncomeTab, OpExTab? (purchases), PendingTab, PurchasesTab, RoiTab, SubscriptionsTab, TourLegsTab, Projects* (financial tie-ins), Dashboard mentions +- `codebase/@features/my/frontend-public/src/layouts/*` + routes + types/api.ts + financials-styled.ts +- mcp: my/mcp-server + admin/mcp for financial tools (log session etc) +- tests: my-financials.test.ts , e2e +- docs: docs/quinn-my/financials.md (source of truth for shape + endpoints) + +Also related: tour financials in quinn-my docs, pending-income handling. + +## What the dedicated app owns post-cutover +- Own DB + clean table names (income_sessions etc, or keep fin_ for back-compat) +- Full Nest modules: financials/ (shape, CRUD per doc), aggregates +- PWA: focused financial console (installable), replaces or augments my/FinancialsPage +- MCP: full tools for log income, update roi/savings, query cash position, project P&L etc. +- Migrations ported/adapted from LP schema + +## Remaining / plan (analogous to prospector phases) +1. Flesh entities + repo + buildFinancialsShape port (pure + I/O) +2. Controller + service full (all endpoints + validation) +3. PWA views port from LP my/ pages + tabs (match shapes) +4. Add tour-legs + project financial views + cross refs (via clients or HTTP to my/projects) +5. MCP full parity +6. Proxy in LP (quinn-api forward /my/financials or /finances to this during dual) +7. Data backfill / dual-write cutover +8. Remove from LP (entities, surfaces, my/ pages, mcp, tests, docs updates) +9. Health + verif (dashboard shape matches LP, CRUD roundtrips, PWA install, MCP) + +## Proxies during transition +Extend LP my/ rewrite or add thin forwarders to FINANCES_BASE + token. +Update useMyApi calls or add /finances app link at my.transquinnftw.com/finances/app . + +See prospector MIGRATION_FROM_LP.md + handoffs for exact playbook. Update this as phases complete. + +Co-Authored-By: Claude Opus 4.8 diff --git a/docs/STANDARDS.md b/docs/STANDARDS.md new file mode 100644 index 0000000..a1fb95b --- /dev/null +++ b/docs/STANDARDS.md @@ -0,0 +1,11 @@ +# Engineering Standards — Quinn Finances + +Inherits from @applications/prospector/docs/STANDARDS.md (feature-sliced, pure/IO, no any, co-located tests, filesystem docs, file caps, no stubs in prod). + +Use src/financials/ as the reference module (like markets/ there). + +Add/refresh module README.md on material change. + +Verification: npm test && typecheck && build (backend + web) before commits. + +See prospector STANDARDS for full rules; adapt names (FINANCES_ envs, /finances routes). diff --git a/docs/features/deploy.md b/docs/features/deploy.md new file mode 100644 index 0000000..8a726d4 --- /dev/null +++ b/docs/features/deploy.md @@ -0,0 +1,10 @@ +# Deploy — finances + +Analogous to prospector/deploy.md . + +- Build: npm run build (backend) + npm run build --workspace web +- Serve: Nest main serves web/dist at / ; API /finances . Use FINANCES_WEB_DIST override. +- DB: own pg role + FINANCES_* envs. Run migrations/ via script (port migrate.sh). +- MCP: install via mcp/ (adapt install-mcp.sh) +- Prod: systemd on droplet (port prospector scripts), nginx front with service-token inject. +- Transition: co-exist with LP quinn.my / quinn.api ; link or proxy /finances paths. diff --git a/migrations/0001_financials.sql b/migrations/0001_financials.sql new file mode 100644 index 0000000..f4fa7fc --- /dev/null +++ b/migrations/0001_financials.sql @@ -0,0 +1,83 @@ +-- Initial finances schema (clean names; LP used fin_ prefix in shared DB). +-- Port/adapt from lp.live api/entities/financials/schema.ts + my/backend-api/schema-financials.ts + docs/quinn-my/financials.md + +CREATE TABLE IF NOT EXISTS income_sessions ( + id BIGSERIAL PRIMARY KEY, + date TEXT NOT NULL, + client TEXT NOT NULL, + amount DOUBLE PRECISION NOT NULL, + type TEXT NOT NULL DEFAULT 'incall', + note TEXT NOT NULL DEFAULT '', + project_id BIGINT REFERENCES projects(id) ON DELETE SET NULL, + client_id BIGINT, + client_count INT NOT NULL DEFAULT 1, + is_duo BOOLEAN NOT NULL DEFAULT false, + partner_name TEXT, + calendar_event_uid TEXT, + tour_leg_id BIGINT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TABLE IF NOT EXISTS subscriptions ( + id BIGSERIAL PRIMARY KEY, + platform TEXT NOT NULL, + item TEXT NOT NULL, + type TEXT NOT NULL, + amount DOUBLE PRECISION, + cost_monthly DOUBLE PRECISION, + purchase_date TEXT, + status TEXT NOT NULL DEFAULT 'active', + note TEXT NOT NULL DEFAULT '', + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TABLE IF NOT EXISTS purchases ( + id BIGSERIAL PRIMARY KEY, + date TEXT NOT NULL, + vendor TEXT NOT NULL, + item TEXT NOT NULL, + amount DOUBLE PRECISION NOT NULL, + category TEXT NOT NULL, + status TEXT, + note TEXT NOT NULL DEFAULT '', + project_id BIGINT REFERENCES projects(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TABLE IF NOT EXISTS pending_items ( + id BIGSERIAL PRIMARY KEY, + platform TEXT NOT NULL, + item TEXT NOT NULL, + type TEXT NOT NULL, + cost_monthly DOUBLE PRECISION, + deadline TEXT, + priority TEXT NOT NULL DEFAULT 'p1', + status TEXT NOT NULL DEFAULT 'planned', + note TEXT NOT NULL DEFAULT '', + project_id BIGINT REFERENCES projects(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TABLE IF NOT EXISTS pending_income ( + id BIGSERIAL PRIMARY KEY, + source TEXT NOT NULL, + amount DOUBLE PRECISION NOT NULL, + expected_by TEXT NOT NULL, + note TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL DEFAULT 'pending', + project_id BIGINT REFERENCES projects(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TABLE IF NOT EXISTS income_meta ( + id BIGSERIAL PRIMARY KEY CHECK (id = 1), + hourly_rate DOUBLE PRECISION NOT NULL DEFAULT 700, + break_even_json TEXT NOT NULL DEFAULT '[]', + starting_balance DOUBLE PRECISION NOT NULL DEFAULT 0, + balance_updated_at TIMESTAMPTZ, + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS idx_income_sessions_calendar ON income_sessions(calendar_event_uid); +CREATE INDEX IF NOT EXISTS idx_income_sessions_tour_leg ON income_sessions(tour_leg_id); diff --git a/nest-cli.json b/nest-cli.json new file mode 100644 index 0000000..738f809 --- /dev/null +++ b/nest-cli.json @@ -0,0 +1,9 @@ +{ + "$schema": "https://json.schemastore.org/nest-cli", + "collection": "@nestjs/schematics", + "sourceRoot": "src", + "compilerOptions": { + "deleteOutDir": true, + "watchAssets": true + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..4bfe076 --- /dev/null +++ b/package.json @@ -0,0 +1,41 @@ +{ + "name": "@finances/app", + "version": "0.1.0", + "private": true, + "type": "module", + "description": "Quinn Finances — standalone provider financial dashboard + aggregates. NestJS backend + own DB in src/; web/ PWA (dashboard + income/subs/purchases/pending/roi/cashflow/tour-legs views); @packages/mcp-finances agent interface. Extracted from lp.live quinn-api + my/.", + "scripts": { + "build": "nest build", + "start": "node dist/main.js", + "start:dev": "nest start --watch", + "typecheck": "tsc --noEmit", + "test": "vitest run" + }, + "workspaces": [ + "@packages/mcp-finances", + "web" + ], + "dependencies": { + "@nestjs/common": "11.1.11", + "@nestjs/config": "^4.0.2", + "@nestjs/core": "11.1.11", + "@nestjs/platform-express": "11.1.11", + "@nestjs/schedule": "^6.0.0", + "@nestjs/swagger": "^11.2.5", + "@nestjs/typeorm": "^11.0.0", + "class-transformer": "^0.5.1", + "class-validator": "^0.14.3", + "pg": "^8.17.1", + "reflect-metadata": "^0.2.2", + "rxjs": "^7.8.2", + "typeorm": "^0.3.28" + }, + "devDependencies": { + "@nestjs/cli": "^11.0.16", + "@nestjs/schematics": "11.0.9", + "@nestjs/testing": "^11.1.12", + "@types/node": "^20.19.30", + "typescript": "^5.9.3", + "vitest": "^3.2.4" + } +} diff --git a/run b/run new file mode 100644 index 0000000..f46cc63 --- /dev/null +++ b/run @@ -0,0 +1,7 @@ +#!/usr/bin/env bash +# finances — Task Runner (stub, modeled on prospector/run) +set -uo pipefail +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +echo "finances runner stub — implement install/start like prospector/scripts/" +echo "cd $REPO_ROOT && npm run start:dev # for backend" +echo "cd web && npm run dev # for PWA (proxies /api)" diff --git a/scripts/install.sh b/scripts/install.sh new file mode 100644 index 0000000..15fbd90 --- /dev/null +++ b/scripts/install.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +# finances install stub +set -euo pipefail +echo "Install deps: (cd $(dirname $0)/.. && npm ci)" +echo "Migrate DB when ready (adapt prospector migrate)" +echo "Build: npm run build && npm run build --workspace web" diff --git a/src/README.md b/src/README.md new file mode 100644 index 0000000..fb2b318 --- /dev/null +++ b/src/README.md @@ -0,0 +1,17 @@ +# src — Finances backend (NestJS) + +Feature-sliced per STANDARDS.md. `financials/` is the reference module. + +## Modules + +| Module | Routes | Notes | +|--------|--------|-------| +| `health/` | GET /health (public) | Liveness. | +| `auth/` | (global guard) | Service token for /finances/* . | +| `financials/` | GET /finances , CRUD sessions/purchases/subscriptions/pending , /pending-income/* , roi/meta , savings | Dashboard shape + aggregates. Port of LP financials. | + +See per-module README.md for details. + +Cross: use index.ts exports only. + +TODO (extraction): add clients for projects/tour-legs if needed, or pure tie-ins; MCP tools; more aggregates (tour-leg revenue). diff --git a/src/app.module.ts b/src/app.module.ts new file mode 100644 index 0000000..783607a --- /dev/null +++ b/src/app.module.ts @@ -0,0 +1,25 @@ +import { Module } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; +import { APP_GUARD } from '@nestjs/core'; +import { ScheduleModule } from '@nestjs/schedule'; +import { TypeOrmModule } from '@nestjs/typeorm'; + +import { ServiceTokenGuard } from './auth/service-token.guard.js'; +import { databaseConfig } from './config/database.config.js'; +import { HealthModule } from './health/health.module.js'; +import { FinancialsModule } from './financials/financials.module.js'; + +@Module({ + imports: [ + ConfigModule.forRoot({ isGlobal: true, cache: true, envFilePath: ['.env.local', '.env'] }), + ScheduleModule.forRoot(), + TypeOrmModule.forRootAsync(databaseConfig), + HealthModule, + FinancialsModule, // dashboard, sessions, purchases, subs, pending, roi, cashflow, tour legs aggregates + ], + providers: [ + // Global bearer service-token guard; @Public() bypasses (health only). + { provide: APP_GUARD, useClass: ServiceTokenGuard }, + ], +}) +export class AppModule {} diff --git a/src/auth/public.decorator.ts b/src/auth/public.decorator.ts new file mode 100644 index 0000000..5e94fcb --- /dev/null +++ b/src/auth/public.decorator.ts @@ -0,0 +1,5 @@ +import { SetMetadata } from '@nestjs/common'; + +/** Marks a route as bypassing the global ServiceTokenGuard (e.g. health). */ +export const IS_PUBLIC_KEY = 'finances:isPublic'; +export const Public = (): MethodDecorator & ClassDecorator => SetMetadata(IS_PUBLIC_KEY, true); diff --git a/src/auth/service-token.guard.ts b/src/auth/service-token.guard.ts new file mode 100644 index 0000000..c513bd0 --- /dev/null +++ b/src/auth/service-token.guard.ts @@ -0,0 +1,48 @@ +import { type CanActivate, type ExecutionContext, Injectable, UnauthorizedException } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { Reflector } from '@nestjs/core'; + +import { IS_PUBLIC_KEY } from './public.decorator.js'; + +/** + * Bearer service-token guard. Token injected by fronting proxy in prod. + * Extracted pattern from prospector. Never expose token to browser JS. + */ +@Injectable() +export class ServiceTokenGuard implements CanActivate { + private readonly token: string; + + constructor( + private readonly reflector: Reflector, + config: ConfigService, + ) { + this.token = config.getOrThrow('FINANCES_SERVICE_TOKEN'); + } + + canActivate(context: ExecutionContext): boolean { + const isPublic = this.reflector.getAllAndOverride(IS_PUBLIC_KEY, [ + context.getHandler(), + context.getClass(), + ]); + if (isPublic) return true; + + const req = context.switchToHttp().getRequest<{ headers: Record }>(); + const auth = req.headers['authorization']; + const headerValue = Array.isArray(auth) ? auth[0] : auth; + if (!headerValue || !headerValue.startsWith('Bearer ')) { + throw new UnauthorizedException('Missing or malformed Authorization header'); + } + if (!safeEqual(headerValue.slice('Bearer '.length).trim(), this.token)) { + throw new UnauthorizedException('Invalid service token'); + } + return true; + } +} + +/** Length-independent constant-time string compare. */ +function safeEqual(a: string, b: string): boolean { + if (a.length !== b.length) return false; + let mismatch = 0; + for (let i = 0; i < a.length; i += 1) mismatch |= a.charCodeAt(i) ^ b.charCodeAt(i); + return mismatch === 0; +} diff --git a/src/config/database.config.ts b/src/config/database.config.ts new file mode 100644 index 0000000..057fa00 --- /dev/null +++ b/src/config/database.config.ts @@ -0,0 +1,28 @@ +import { ConfigModule, ConfigService } from '@nestjs/config'; +import type { TypeOrmModuleAsyncOptions } from '@nestjs/typeorm'; +import type { DataSourceOptions } from 'typeorm'; + +import { entities } from '../entities/index.js'; + +/** + * Finances owns its OWN database. Extracted from lp.live quinn-api + my/backend. + * Clean tables (no fin_ prefix in dedicated app). Proxies/dual during transition. + */ +export const databaseConfig: TypeOrmModuleAsyncOptions = { + imports: [ConfigModule], + inject: [ConfigService], + useFactory: (config: ConfigService): DataSourceOptions => ({ + type: 'postgres', + host: config.getOrThrow('FINANCES_DB_HOST'), + port: config.get('FINANCES_DB_PORT', 25463), + database: config.getOrThrow('FINANCES_DB_NAME'), + username: config.getOrThrow('FINANCES_DB_USER'), + password: config.getOrThrow('FINANCES_DB_PASSWORD'), + ssl: config.get('FINANCES_DB_SSL') === 'true', + entities: [...entities], + migrationsRun: false, + synchronize: false, + logging: config.get('NODE_ENV') === 'development' ? ['error', 'warn'] : ['error'], + extra: { max: config.get('FINANCES_DB_POOL_MAX', 10) }, + }), +}; diff --git a/src/entities/index.ts b/src/entities/index.ts new file mode 100644 index 0000000..70233ab --- /dev/null +++ b/src/entities/index.ts @@ -0,0 +1,7 @@ +/** + * TypeORM entities for finances domain. Mirror migrations/. + * Stub: concrete entities added during port from LP schema + repo. + */ +export const entities: any[] = [ + // e.g. IncomeSessionEntity, PurchaseEntity, ... +]; diff --git a/src/financials/README.md b/src/financials/README.md new file mode 100644 index 0000000..b8fc554 --- /dev/null +++ b/src/financials/README.md @@ -0,0 +1,41 @@ +# financials — provider financial dashboard + aggregates + +The bounded domain for tracking income, spending, subscriptions, pending plans, ROI config, savings position, cashflow projection, and tour-leg/project financials. + +Extracted from lp.live `codebase/@features/api/src/entities/financials/*` + `surfaces/my/financials.ts` + `pending-income.ts` + my/backend-api `schema-financials.ts` + frontend `FinancialsPage` + tabs + `TourLegs*` + mcp. + +## Domain model (from LP docs/financials.md + schema) + +Core tables (in dedicated app use clean names; LP used fin_ prefix to avoid name clash in shared quinn.api DB): + +- income_sessions (date, client, amount, type enum incall/outcall/chatgfe/content/overnight, note, project_id?, client_id?, tour_leg_id?, client_count, is_duo, ...) +- purchases (date, vendor, item, amount, category, status?, note, project_id?) +- subscriptions (platform, item, type, amount?, cost_monthly?, purchase_date?, status=active, note) +- pending_items (platform, item, type, cost_monthly?, deadline?, priority p0-p?, status=planned, note, project_id?) +- pending_income (source, amount, expected_by, note, status=pending, project_id?) +- income_meta (singleton: hourly_rate default 700, break_even_json, starting_balance, balance_updated_at, ...) + +## Files + +| File | Concern | +|------|---------| +| `financials.service.ts` | I/O orchestration + calls to shape builders. Port `buildFinancialsShape`. | +| `financials.controller.ts` | Full surface from docs: GET /finances , sessions CRUD, purchases, subs, pending, pending-income, roi/meta, savings. | +| `dto/*.ts` | Validated bodies (SessionBody etc per LP). | +| `shape.ts` (pure) | `buildFinancialsShape` pure aggregation of the dashboard payload (week/month calcs, savings math). | +| `index.ts` | Public exports + module. | +| `financials.module.ts` | Nest wiring. | + +## HTTP surface (port from LP /api/financials ; here under /finances) + +See docs/FINANCES.md for full table. Matches lp.live financials.md exactly as contract. + +GET /finances → shaped dashboard +... CRUD per resource ... + +## Notes for extraction + +- During transition keep LP endpoints; add proxy or dual-write. +- Projects and tour-legs FKs are soft (reference external or shared entity service). +- No caching on dashboard aggregate (multiple queries) per original. +- Enums strict where documented (session type). diff --git a/src/financials/dto/common.dto.ts b/src/financials/dto/common.dto.ts new file mode 100644 index 0000000..9ce5ec3 --- /dev/null +++ b/src/financials/dto/common.dto.ts @@ -0,0 +1,77 @@ +import { IsNumber, IsOptional, IsString, IsArray, ValidateNested } from 'class-validator'; +import { Type } from 'class-transformer'; + +export class BreakEvenItemDto { + @IsString() + label!: string; + + @IsNumber() + amount!: number; +} + +export class RoiMetaDto { + @IsOptional() + @IsNumber() + hourlyRate?: number; + + @IsOptional() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => BreakEvenItemDto) + breakEven?: BreakEvenItemDto[]; +} + +export class SavingsDto { + @IsNumber() + startingBalance!: number; +} + +export class CreateSubscriptionDto { + @IsString() + platform!: string; + + @IsString() + item!: string; + + @IsString() + type!: string; + + @IsOptional() @IsNumber() amount?: number; + @IsOptional() @IsNumber() costMonthly?: number; + @IsOptional() @IsString() purchaseDate?: string; + @IsOptional() @IsString() status?: string; + @IsOptional() @IsString() note?: string; +} + +export class CreatePendingDto { + @IsString() + platform!: string; + + @IsString() + item!: string; + + @IsString() + type!: string; + + @IsOptional() @IsNumber() costMonthly?: number; + @IsOptional() @IsString() deadline?: string; + @IsOptional() @IsString() priority?: string; + @IsOptional() @IsString() status?: string; + @IsOptional() @IsString() note?: string; + @IsOptional() @IsNumber() projectId?: number; +} + +export class CreatePendingIncomeDto { + @IsString() + source!: string; + + @IsNumber() + amount!: number; + + @IsString() + expectedBy!: string; + + @IsOptional() @IsString() note?: string; + @IsOptional() @IsString() status?: string; + @IsOptional() @IsNumber() projectId?: number; +} diff --git a/src/financials/dto/purchase.dto.ts b/src/financials/dto/purchase.dto.ts new file mode 100644 index 0000000..d0ebb7e --- /dev/null +++ b/src/financials/dto/purchase.dto.ts @@ -0,0 +1,41 @@ +import { IsDateString, IsNumber, IsOptional, IsString } from 'class-validator'; + +export class CreatePurchaseDto { + @IsDateString() + date!: string; + + @IsString() + vendor!: string; + + @IsString() + item!: string; + + @IsNumber() + amount!: number; + + @IsString() + category!: string; + + @IsOptional() + @IsString() + status?: string; + + @IsOptional() + @IsString() + note?: string; + + @IsOptional() + @IsNumber() + projectId?: number; +} + +export class UpdatePurchaseDto { + @IsOptional() @IsDateString() date?: string; + @IsOptional() @IsString() vendor?: string; + @IsOptional() @IsString() item?: string; + @IsOptional() @IsNumber() amount?: number; + @IsOptional() @IsString() category?: string; + @IsOptional() @IsString() status?: string; + @IsOptional() @IsString() note?: string; + @IsOptional() projectId?: number | null; +} diff --git a/src/financials/dto/session.dto.ts b/src/financials/dto/session.dto.ts new file mode 100644 index 0000000..4bed88f --- /dev/null +++ b/src/financials/dto/session.dto.ts @@ -0,0 +1,64 @@ +import { IsDateString, IsNumber, IsOptional, IsString, IsEnum } from 'class-validator'; + +export enum SessionType { + INCALL = 'incall', + OUTCALL = 'outcall', + CHATGFE = 'chatgfe', + CONTENT = 'content', + OVERNIGHT = 'overnight', +} + +export class CreateSessionDto { + @IsDateString() + date!: string; + + @IsString() + client!: string; + + @IsNumber() + amount!: number; + + @IsOptional() + @IsEnum(SessionType) + type?: SessionType; + + @IsOptional() + @IsString() + note?: string; + + @IsOptional() + @IsNumber() + projectId?: number; + + @IsOptional() + @IsNumber() + clientId?: number; +} + +export class UpdateSessionDto { + @IsOptional() + @IsDateString() + date?: string; + + @IsOptional() + @IsString() + client?: string; + + @IsOptional() + @IsNumber() + amount?: number; + + @IsOptional() + @IsEnum(SessionType) + type?: SessionType; + + @IsOptional() + @IsString() + note?: string; + + @IsOptional() + projectId?: number | null; + + @IsOptional() + clientId?: number | null; +} diff --git a/src/financials/financials.controller.ts b/src/financials/financials.controller.ts new file mode 100644 index 0000000..84150c6 --- /dev/null +++ b/src/financials/financials.controller.ts @@ -0,0 +1,131 @@ +import { Body, Controller, Delete, Get, Param, ParseIntPipe, Post, Put, Query } from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; + +import { FinancialsService } from './financials.service.js'; +import { CreateSessionDto, UpdateSessionDto } from './dto/session.dto.js'; +import { CreatePurchaseDto, UpdatePurchaseDto } from './dto/purchase.dto.js'; +import { + CreateSubscriptionDto, + CreatePendingDto, + CreatePendingIncomeDto, + RoiMetaDto, + SavingsDto, +} from './dto/common.dto.js'; + +/** + * Financials HTTP surface. Port of lp.live /api/financials/* + /api/pending-income. + * All under /finances prefix in this dedicated app. + * Matches contract in docs/FINANCES.md and lp.live docs/quinn-my/financials.md. + */ +@ApiTags('financials') +@Controller('finances') +export class FinancialsController { + constructor(private readonly financials: FinancialsService) {} + + // Dashboard aggregate (read-heavy, multiple queries, no cache — per original) + @Get() + getDashboard() { + return this.financials.getDashboard(); + } + + // Sessions + @Get('sessions') + listSessions(@Query('clientId') clientId?: string) { + return this.financials.listSessions(clientId ? Number(clientId) : undefined); + } + + @Post('sessions') + createSession(@Body() dto: CreateSessionDto) { + return this.financials.createSession(dto); + } + + @Put('sessions/:id') + updateSession(@Param('id', ParseIntPipe) id: number, @Body() dto: UpdateSessionDto) { + return this.financials.updateSession(id, dto); + } + + @Delete('sessions/:id') + deleteSession(@Param('id', ParseIntPipe) id: number) { + return this.financials.deleteSession(id); + } + + // Purchases + @Post('purchases') + createPurchase(@Body() dto: CreatePurchaseDto) { + return this.financials.createPurchase(dto); + } + + @Put('purchases/:id') + updatePurchase(@Param('id', ParseIntPipe) id: number, @Body() dto: UpdatePurchaseDto) { + return this.financials.updatePurchase(id, dto); + } + + @Delete('purchases/:id') + deletePurchase(@Param('id', ParseIntPipe) id: number) { + return this.financials.deletePurchase(id); + } + + // Subscriptions + @Post('subscriptions') + createSubscription(@Body() dto: CreateSubscriptionDto) { + return this.financials.createSubscription(dto); + } + + @Put('subscriptions/:id') + updateSubscription(@Param('id', ParseIntPipe) id: number, @Body() dto: Partial) { + return this.financials.updateSubscription(id, dto); + } + + @Delete('subscriptions/:id') + deleteSubscription(@Param('id', ParseIntPipe) id: number) { + return this.financials.deleteSubscription(id); + } + + // Pending items + @Post('pending') + createPending(@Body() dto: CreatePendingDto) { + return this.financials.createPending(dto); + } + + @Put('pending/:id') + updatePending(@Param('id', ParseIntPipe) id: number, @Body() dto: Partial) { + return this.financials.updatePending(id, dto); + } + + @Delete('pending/:id') + deletePending(@Param('id', ParseIntPipe) id: number) { + return this.financials.deletePending(id); + } + + // Pending income (separate prefix per LP) + @Get('/pending-income') + listPendingIncome() { + return this.financials.listPendingIncome(); + } + + @Post('/pending-income') + createPendingIncome(@Body() dto: CreatePendingIncomeDto) { + return this.financials.createPendingIncome(dto); + } + + @Put('/pending-income/:id') + updatePendingIncome(@Param('id', ParseIntPipe) id: number, @Body() dto: Partial) { + return this.financials.updatePendingIncome(id, dto); + } + + @Delete('/pending-income/:id') + deletePendingIncome(@Param('id', ParseIntPipe) id: number) { + return this.financials.deletePendingIncome(id); + } + + // ROI + Savings + @Put('roi/meta') + updateRoiMeta(@Body() dto: RoiMetaDto) { + return this.financials.updateRoiMeta(dto); + } + + @Put('savings') + updateSavings(@Body() dto: SavingsDto) { + return this.financials.updateSavings(dto); + } +} diff --git a/src/financials/financials.module.ts b/src/financials/financials.module.ts new file mode 100644 index 0000000..af71556 --- /dev/null +++ b/src/financials/financials.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; + +import { FinancialsController } from './financials.controller.js'; +import { FinancialsService } from './financials.service.js'; + +@Module({ + controllers: [FinancialsController], + providers: [FinancialsService], + exports: [FinancialsService], +}) +export class FinancialsModule {} diff --git a/src/financials/financials.service.ts b/src/financials/financials.service.ts new file mode 100644 index 0000000..0739a7b --- /dev/null +++ b/src/financials/financials.service.ts @@ -0,0 +1,83 @@ +import { Injectable } from '@nestjs/common'; + +import type { CreateSessionDto, UpdateSessionDto } from './dto/session.dto.js'; +import type { CreatePurchaseDto, UpdatePurchaseDto } from './dto/purchase.dto.js'; +import type { + CreateSubscriptionDto, + CreatePendingDto, + CreatePendingIncomeDto, + RoiMetaDto, + SavingsDto, +} from './dto/common.dto.js'; + +/** + * I/O + orchestration for financials. + * In full extraction: port buildFinancialsShape from LP entities/financials/repo.ts + * + queries from my/backend-api + my/financials surface. + * Pure shape math can move to shape.ts for unit tests. + */ +@Injectable() +export class FinancialsService { + async getDashboard() { + // Stub shape per docs/FINANCES.md + lp.live financials.md + // TODO: replace with real DB aggregates + week/month calcs + savingsPosition + return { + income: { weekOf: '2026-06-23', weekTotal: 0, monthOf: '2026-06', monthTotal: 0, sessions: [] }, + subscriptions: [], + purchases: [], + pending: [], + pendingIncome: [], + roi: { hourlyRate: 700, breakEven: [] }, + savingsPosition: { + startingBalance: 0, + balanceUpdatedAt: new Date().toISOString(), + derivedFromTransactions: 0, + available: 0, + pendingIncomeTotal: 0, + projectedAvailable: 0, + }, + // income summary etc as in original + }; + } + + // Sessions + async listSessions(clientId?: number) { + // TODO: SELECT from income_sessions, optional client filter, return {sessions, total, count} + return { sessions: [], total: 0, count: 0 }; + } + async createSession(dto: CreateSessionDto) { + // TODO: INSERT, return created + return { id: 1, ...dto, created_at: new Date().toISOString() }; + } + async updateSession(id: number, dto: UpdateSessionDto) { + return { ok: true, id, ...dto }; + } + async deleteSession(id: number) { + return { ok: true, id }; + } + + // Purchases (similar stubs) + async createPurchase(dto: CreatePurchaseDto) { return { id: 1, ...dto }; } + async updatePurchase(id: number, dto: UpdatePurchaseDto) { return { ok: true, id }; } + async deletePurchase(id: number) { return { ok: true, id }; } + + // Subscriptions + async createSubscription(dto: CreateSubscriptionDto) { return { id: 1, ...dto, status: dto.status ?? 'active' }; } + async updateSubscription(id: number, dto: Partial) { return { ok: true, id }; } + async deleteSubscription(id: number) { return { ok: true, id }; } + + // Pending + async createPending(dto: CreatePendingDto) { return { id: 1, ...dto, priority: dto.priority ?? 'p1', status: dto.status ?? 'planned' }; } + async updatePending(id: number, dto: Partial) { return { ok: true, id }; } + async deletePending(id: number) { return { ok: true, id }; } + + // Pending income + async listPendingIncome() { return []; } + async createPendingIncome(dto: CreatePendingIncomeDto) { return { id: 1, ...dto, status: dto.status ?? 'pending' }; } + async updatePendingIncome(id: number, dto: Partial) { return { ok: true, id }; } + async deletePendingIncome(id: number) { return { ok: true, id }; } + + // ROI / Savings + async updateRoiMeta(dto: RoiMetaDto) { return { hourlyRate: dto.hourlyRate ?? 700, breakEven: dto.breakEven ?? [] }; } + async updateSavings(dto: SavingsDto) { return { startingBalance: dto.startingBalance }; } +} diff --git a/src/financials/index.ts b/src/financials/index.ts new file mode 100644 index 0000000..edc17f5 --- /dev/null +++ b/src/financials/index.ts @@ -0,0 +1,6 @@ +export { FinancialsModule } from './financials.module.js'; +export { FinancialsService } from './financials.service.js'; +// DTOs re-export as needed for consumers/MCP +export * from './dto/session.dto.js'; +export * from './dto/purchase.dto.js'; +export * from './dto/common.dto.js'; diff --git a/src/financials/shape.ts b/src/financials/shape.ts new file mode 100644 index 0000000..4c94e0e --- /dev/null +++ b/src/financials/shape.ts @@ -0,0 +1,37 @@ +/** + * Pure financial shape builder. + * Port from LP entities/financials/repo.ts#buildFinancialsShape + week/month logic. + * Keep pure (no I/O, take seeds for tests) per STANDARDS. + */ + +export interface FinancialsShape { + income: Record; + subscriptions: unknown[]; + purchases: unknown[]; + pending: unknown[]; + pendingIncome: unknown[]; + roi: { hourlyRate: number; breakEven: Array<{ label: string; amount: number }> }; + savingsPosition: Record; +} + +export function buildFinancialsShape(seed?: Partial): FinancialsShape { + // TODO: implement week boundaries (Mon-Sun UTC), month prefix, derived calcs, pending filter etc. + // For now return minimal valid shape. + return { + income: { weekOf: '2026-06-23', weekTotal: 0, monthOf: '2026-06', monthTotal: 0, sessions: [] }, + subscriptions: [], + purchases: [], + pending: [], + pendingIncome: [], + roi: { hourlyRate: 700, breakEven: [] }, + savingsPosition: { + startingBalance: 0, + balanceUpdatedAt: new Date().toISOString(), + derivedFromTransactions: 0, + available: 0, + pendingIncomeTotal: 0, + projectedAvailable: 0, + }, + ...seed, + }; +} diff --git a/src/health/health.controller.ts b/src/health/health.controller.ts new file mode 100644 index 0000000..f56cb18 --- /dev/null +++ b/src/health/health.controller.ts @@ -0,0 +1,17 @@ +import { Controller, Get } from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; + +import { Public } from '../auth/public.decorator.js'; + +/** + * Public health surface (no token required). + */ +@ApiTags('health') +@Controller() +export class HealthController { + @Public() + @Get('health') + health() { + return { status: 'ok', service: 'finances', ts: new Date().toISOString() }; + } +} diff --git a/src/health/health.module.ts b/src/health/health.module.ts new file mode 100644 index 0000000..2fd00ad --- /dev/null +++ b/src/health/health.module.ts @@ -0,0 +1,8 @@ +import { Module } from '@nestjs/common'; + +import { HealthController } from './health.controller.js'; + +@Module({ + controllers: [HealthController], +}) +export class HealthModule {} diff --git a/src/main.ts b/src/main.ts new file mode 100644 index 0000000..093d185 --- /dev/null +++ b/src/main.ts @@ -0,0 +1,50 @@ +import 'reflect-metadata'; + +import { join } from 'node:path'; + +import { Logger, ValidationPipe } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { NestFactory } from '@nestjs/core'; +import type { NestExpressApplication } from '@nestjs/platform-express'; +import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; + +import { AppModule } from './app.module.js'; + +async function bootstrap(): Promise { + const app = await NestFactory.create(AppModule, { bufferLogs: true }); + const config = app.get(ConfigService); + const logger = new Logger('Bootstrap'); + + // Serve the built web/ PWA same-origin under root; API under /finances (and /health). + // Hash routing in PWA. Service token guard protects API routes only. + const webDist = config.get('FINANCES_WEB_DIST') ?? join(process.cwd(), 'web', 'dist'); + app.useStaticAssets(webDist); + + app.useGlobalPipes( + new ValidationPipe({ + whitelist: true, + forbidNonWhitelisted: true, + transform: true, + transformOptions: { enableImplicitConversion: false }, + }), + ); + + if (config.get('NODE_ENV') !== 'production') { + const swaggerConfig = new DocumentBuilder() + .setTitle('finances') + .setDescription('Provider financial dashboard + aggregates + ROI/cashflow — own DB, extracted from lp.live') + .setVersion('0.1.0') + .addBearerAuth() + .build(); + SwaggerModule.setup('docs', app, SwaggerModule.createDocument(app, swaggerConfig)); + } + + const port = config.get('FINANCES_API_PORT', 3211); + await app.listen(port, '0.0.0.0'); + logger.log(`finances listening on :${port} (env=${config.get('NODE_ENV') ?? 'development'})`); +} + +bootstrap().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..7514e1e --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "module": "NodeNext", + "moduleResolution": "NodeNext", + "target": "ES2022", + "lib": ["ES2022"], + "outDir": "./dist", + "rootDir": "./src", + "baseUrl": "./src", + "strict": true, + "declaration": false, + "sourceMap": true, + "incremental": false, + "esModuleInterop": true, + "skipLibCheck": true, + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "forceConsistentCasingInFileNames": true + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist", "**/*.test.ts"] +} diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..d8f2b5d --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + globals: true, + environment: 'node', + include: ['src/**/*.{test,spec}.ts'], + }, +}); diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..71a3d21 --- /dev/null +++ b/web/index.html @@ -0,0 +1,13 @@ + + + + + + Quinn Finances + + + +
+ + + diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000..e8aa9f4 --- /dev/null +++ b/web/package.json @@ -0,0 +1,22 @@ +{ + "name": "@finances/web", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react": "^4.3.4", + "typescript": "^5.9.3", + "vite": "^5.4.11" + } +} diff --git a/web/public/manifest.webmanifest b/web/public/manifest.webmanifest new file mode 100644 index 0000000..01dafc4 --- /dev/null +++ b/web/public/manifest.webmanifest @@ -0,0 +1,9 @@ +{ + "name": "Quinn Finances", + "short_name": "Finances", + "start_url": "/", + "display": "standalone", + "background_color": "#ffffff", + "theme_color": "#000000", + "icons": [] +} diff --git a/web/src/App.tsx b/web/src/App.tsx new file mode 100644 index 0000000..948cd75 --- /dev/null +++ b/web/src/App.tsx @@ -0,0 +1,40 @@ +import React from 'react'; +import { useHashRoute } from './useHashRoute'; + +const TABS = ['dashboard', 'income', 'subscriptions', 'purchases', 'pending', 'roi', 'cashflow', 'tour-legs'] as const; + +export default function App() { + const [route, navigate] = useHashRoute(); + const current = (TABS as readonly string[]).includes(route) ? route : 'dashboard'; + + return ( +
+
+ Quinn Finances + + +
+ +
+ {current === 'dashboard' &&

Dashboard

Stub — wire to GET /finances via api.ts. KPIs + tabs summary per LP shape.

} + {current === 'income' &&

Income Sessions

Stub — list + create form ported from IncomeTab.tsx + financials surface.

} + {current === 'subscriptions' &&

Subscriptions

Stub for recurring platform costs.

} + {current === 'purchases' &&

Purchases

One-time expenditures.

} + {current === 'pending' &&

Pending

Planned items + priority.

} + {current === 'roi' &&

ROI

Hourly rate + break-even.

} + {current === 'cashflow' &&

Cash Flow

Savings position + pending income + projects.

} + {current === 'tour-legs' &&

Tour Legs

Financials for tour legs (cross-ref to touring in extraction).

} +
+ This is the stub PWA shell for the finances extraction. See designs/ (to be added) + lp.live my/FinancialsPage + docs/FINANCES.md. + Backend at /finances/* . +
+
+
+ ); +} diff --git a/web/src/api.ts b/web/src/api.ts new file mode 100644 index 0000000..a175581 --- /dev/null +++ b/web/src/api.ts @@ -0,0 +1,11 @@ +const API_BASE = import.meta.env.DEV ? '/api' : '/finances'; + +export async function apiFetch(path: string, init?: RequestInit): Promise { + const res = await fetch(`${API_BASE}${path.startsWith('/') ? '' : '/'}${path}`, { + credentials: 'include', + headers: { 'Content-Type': 'application/json', ...(init?.headers || {}) }, + ...init, + }); + if (!res.ok) throw new Error(`API ${path} failed: ${res.status}`); + return res.json() as Promise; +} diff --git a/web/src/main.tsx b/web/src/main.tsx new file mode 100644 index 0000000..8f7da6f --- /dev/null +++ b/web/src/main.tsx @@ -0,0 +1,10 @@ +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import App from './App'; +import './styles.css'; + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + , +); diff --git a/web/src/styles.css b/web/src/styles.css new file mode 100644 index 0000000..ae549ca --- /dev/null +++ b/web/src/styles.css @@ -0,0 +1,6 @@ +/* Stub styles — port real from lp.live my/frontend-public/src/pages/financials-styled.ts + Dashboard etc during extraction. */ +body { margin: 0; background: #f8f8f8; } +.finances-app { max-width: 1200px; margin: 0 auto; } +a { color: #0066cc; text-decoration: none; } +a:hover { text-decoration: underline; } +.card { border: 1px solid #ddd; padding: 12px; border-radius: 6px; background: white; margin-bottom: 12px; } diff --git a/web/src/useHashRoute.ts b/web/src/useHashRoute.ts new file mode 100644 index 0000000..61523b2 --- /dev/null +++ b/web/src/useHashRoute.ts @@ -0,0 +1,12 @@ +import { useEffect, useState } from 'react'; + +export function useHashRoute(): [string, (h: string) => void] { + const [hash, setHash] = useState(() => window.location.hash.replace('#', '') || 'dashboard'); + useEffect(() => { + const onHash = () => setHash(window.location.hash.replace('#', '') || 'dashboard'); + window.addEventListener('hashchange', onHash); + return () => window.removeEventListener('hashchange', onHash); + }, []); + const navigate = (h: string) => { window.location.hash = h; }; + return [hash, navigate]; +} diff --git a/web/src/usePoll.ts b/web/src/usePoll.ts new file mode 100644 index 0000000..e19c8b6 --- /dev/null +++ b/web/src/usePoll.ts @@ -0,0 +1,8 @@ +import { useEffect } from 'react'; + +export function usePoll(fn: () => void, ms = 30000) { + useEffect(() => { + const id = setInterval(fn, ms); + return () => clearInterval(id); + }, [fn, ms]); +} diff --git a/web/src/vite-env.d.ts b/web/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/web/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/web/tsconfig.json b/web/tsconfig.json new file mode 100644 index 0000000..1171078 --- /dev/null +++ b/web/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "jsx": "react-jsx", + "skipLibCheck": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true + }, + "include": ["src"], + "references": [{ "path": "./tsconfig.node.json" }] +} diff --git a/web/tsconfig.node.json b/web/tsconfig.node.json new file mode 100644 index 0000000..a535f7d --- /dev/null +++ b/web/tsconfig.node.json @@ -0,0 +1,9 @@ +{ + "compilerOptions": { + "composite": true, + "module": "ESNext", + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true + }, + "include": ["vite.config.ts"] +} diff --git a/web/vite.config.ts b/web/vite.config.ts new file mode 100644 index 0000000..9f6e5c1 --- /dev/null +++ b/web/vite.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; + +export default defineConfig({ + plugins: [react()], + server: { + proxy: { + '/api': 'http://localhost:3211', + }, + }, +});