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
This commit is contained in:
commit
d8ae381847
48 changed files with 1275 additions and 0 deletions
20
@packages/mcp-finances/package.json
Normal file
20
@packages/mcp-finances/package.json
Normal file
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
11
@packages/mcp-finances/src/client.ts
Normal file
11
@packages/mcp-finances/src/client.ts
Normal file
|
|
@ -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.
|
||||
34
@packages/mcp-finances/src/index.ts
Normal file
34
@packages/mcp-finances/src/index.ts
Normal file
|
|
@ -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);
|
||||
13
@packages/mcp-finances/tsconfig.json
Normal file
13
@packages/mcp-finances/tsconfig.json
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"target": "ES2022",
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
37
CLAUDE.md
Normal file
37
CLAUDE.md
Normal file
|
|
@ -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 <noreply@anthropic.com>`.
|
||||
- **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).
|
||||
7
README.md
Normal file
7
README.md
Normal file
|
|
@ -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.
|
||||
48
docs/FINANCES.md
Normal file
48
docs/FINANCES.md
Normal file
|
|
@ -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.
|
||||
43
docs/MIGRATION_FROM_LP.md
Normal file
43
docs/MIGRATION_FROM_LP.md
Normal file
|
|
@ -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 <noreply@anthropic.com>
|
||||
11
docs/STANDARDS.md
Normal file
11
docs/STANDARDS.md
Normal file
|
|
@ -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).
|
||||
10
docs/features/deploy.md
Normal file
10
docs/features/deploy.md
Normal file
|
|
@ -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.
|
||||
83
migrations/0001_financials.sql
Normal file
83
migrations/0001_financials.sql
Normal file
|
|
@ -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);
|
||||
9
nest-cli.json
Normal file
9
nest-cli.json
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true,
|
||||
"watchAssets": true
|
||||
}
|
||||
}
|
||||
41
package.json
Normal file
41
package.json
Normal file
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
7
run
Normal file
7
run
Normal file
|
|
@ -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)"
|
||||
6
scripts/install.sh
Normal file
6
scripts/install.sh
Normal file
|
|
@ -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"
|
||||
17
src/README.md
Normal file
17
src/README.md
Normal file
|
|
@ -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).
|
||||
25
src/app.module.ts
Normal file
25
src/app.module.ts
Normal file
|
|
@ -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 {}
|
||||
5
src/auth/public.decorator.ts
Normal file
5
src/auth/public.decorator.ts
Normal file
|
|
@ -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);
|
||||
48
src/auth/service-token.guard.ts
Normal file
48
src/auth/service-token.guard.ts
Normal file
|
|
@ -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<string>('FINANCES_SERVICE_TOKEN');
|
||||
}
|
||||
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
|
||||
context.getHandler(),
|
||||
context.getClass(),
|
||||
]);
|
||||
if (isPublic) return true;
|
||||
|
||||
const req = context.switchToHttp().getRequest<{ headers: Record<string, string | string[] | undefined> }>();
|
||||
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;
|
||||
}
|
||||
28
src/config/database.config.ts
Normal file
28
src/config/database.config.ts
Normal file
|
|
@ -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<string>('FINANCES_DB_HOST'),
|
||||
port: config.get<number>('FINANCES_DB_PORT', 25463),
|
||||
database: config.getOrThrow<string>('FINANCES_DB_NAME'),
|
||||
username: config.getOrThrow<string>('FINANCES_DB_USER'),
|
||||
password: config.getOrThrow<string>('FINANCES_DB_PASSWORD'),
|
||||
ssl: config.get<string>('FINANCES_DB_SSL') === 'true',
|
||||
entities: [...entities],
|
||||
migrationsRun: false,
|
||||
synchronize: false,
|
||||
logging: config.get<string>('NODE_ENV') === 'development' ? ['error', 'warn'] : ['error'],
|
||||
extra: { max: config.get<number>('FINANCES_DB_POOL_MAX', 10) },
|
||||
}),
|
||||
};
|
||||
7
src/entities/index.ts
Normal file
7
src/entities/index.ts
Normal file
|
|
@ -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, ...
|
||||
];
|
||||
41
src/financials/README.md
Normal file
41
src/financials/README.md
Normal file
|
|
@ -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).
|
||||
77
src/financials/dto/common.dto.ts
Normal file
77
src/financials/dto/common.dto.ts
Normal file
|
|
@ -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;
|
||||
}
|
||||
41
src/financials/dto/purchase.dto.ts
Normal file
41
src/financials/dto/purchase.dto.ts
Normal file
|
|
@ -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;
|
||||
}
|
||||
64
src/financials/dto/session.dto.ts
Normal file
64
src/financials/dto/session.dto.ts
Normal file
|
|
@ -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;
|
||||
}
|
||||
131
src/financials/financials.controller.ts
Normal file
131
src/financials/financials.controller.ts
Normal file
|
|
@ -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<CreateSubscriptionDto>) {
|
||||
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<CreatePendingDto>) {
|
||||
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<CreatePendingIncomeDto>) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
11
src/financials/financials.module.ts
Normal file
11
src/financials/financials.module.ts
Normal file
|
|
@ -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 {}
|
||||
83
src/financials/financials.service.ts
Normal file
83
src/financials/financials.service.ts
Normal file
|
|
@ -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<CreateSubscriptionDto>) { 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<CreatePendingDto>) { 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<CreatePendingIncomeDto>) { 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 }; }
|
||||
}
|
||||
6
src/financials/index.ts
Normal file
6
src/financials/index.ts
Normal file
|
|
@ -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';
|
||||
37
src/financials/shape.ts
Normal file
37
src/financials/shape.ts
Normal file
|
|
@ -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<string, unknown>;
|
||||
subscriptions: unknown[];
|
||||
purchases: unknown[];
|
||||
pending: unknown[];
|
||||
pendingIncome: unknown[];
|
||||
roi: { hourlyRate: number; breakEven: Array<{ label: string; amount: number }> };
|
||||
savingsPosition: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export function buildFinancialsShape(seed?: Partial<FinancialsShape>): 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,
|
||||
};
|
||||
}
|
||||
17
src/health/health.controller.ts
Normal file
17
src/health/health.controller.ts
Normal file
|
|
@ -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() };
|
||||
}
|
||||
}
|
||||
8
src/health/health.module.ts
Normal file
8
src/health/health.module.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { HealthController } from './health.controller.js';
|
||||
|
||||
@Module({
|
||||
controllers: [HealthController],
|
||||
})
|
||||
export class HealthModule {}
|
||||
50
src/main.ts
Normal file
50
src/main.ts
Normal file
|
|
@ -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<void> {
|
||||
const app = await NestFactory.create<NestExpressApplication>(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<string>('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<string>('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<number>('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);
|
||||
});
|
||||
22
tsconfig.json
Normal file
22
tsconfig.json
Normal file
|
|
@ -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"]
|
||||
}
|
||||
9
vitest.config.ts
Normal file
9
vitest.config.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'node',
|
||||
include: ['src/**/*.{test,spec}.ts'],
|
||||
},
|
||||
});
|
||||
13
web/index.html
Normal file
13
web/index.html
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Quinn Finances</title>
|
||||
<link rel="manifest" href="/manifest.webmanifest" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
22
web/package.json
Normal file
22
web/package.json
Normal file
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
9
web/public/manifest.webmanifest
Normal file
9
web/public/manifest.webmanifest
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"name": "Quinn Finances",
|
||||
"short_name": "Finances",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#ffffff",
|
||||
"theme_color": "#000000",
|
||||
"icons": []
|
||||
}
|
||||
40
web/src/App.tsx
Normal file
40
web/src/App.tsx
Normal file
|
|
@ -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 (
|
||||
<div className="finances-app" style={{ fontFamily: 'system-ui', padding: 16 }}>
|
||||
<header style={{ display: 'flex', alignItems: 'center', gap: 12, borderBottom: '1px solid #ccc', paddingBottom: 8 }}>
|
||||
<strong>Quinn Finances</strong>
|
||||
<nav style={{ display: 'flex', gap: 8 }}>
|
||||
{TABS.map((t) => (
|
||||
<a key={t} href={`#${t}`} style={{ fontWeight: current === t ? 'bold' : 'normal' }} onClick={(e) => { e.preventDefault(); navigate(t); }}>
|
||||
{t[0].toUpperCase() + t.slice(1)}
|
||||
</a>
|
||||
))}
|
||||
</nav>
|
||||
<button onClick={() => alert('PWA install stub — use Chrome menu > Install')}>Install PWA</button>
|
||||
</header>
|
||||
|
||||
<main style={{ marginTop: 16 }}>
|
||||
{current === 'dashboard' && <div><h2>Dashboard</h2><p>Stub — wire to GET /finances via api.ts. KPIs + tabs summary per LP shape.</p></div>}
|
||||
{current === 'income' && <div><h2>Income Sessions</h2><p>Stub — list + create form ported from IncomeTab.tsx + financials surface.</p></div>}
|
||||
{current === 'subscriptions' && <div><h2>Subscriptions</h2><p>Stub for recurring platform costs.</p></div>}
|
||||
{current === 'purchases' && <div><h2>Purchases</h2><p>One-time expenditures.</p></div>}
|
||||
{current === 'pending' && <div><h2>Pending</h2><p>Planned items + priority.</p></div>}
|
||||
{current === 'roi' && <div><h2>ROI</h2><p>Hourly rate + break-even.</p></div>}
|
||||
{current === 'cashflow' && <div><h2>Cash Flow</h2><p>Savings position + pending income + projects.</p></div>}
|
||||
{current === 'tour-legs' && <div><h2>Tour Legs</h2><p>Financials for tour legs (cross-ref to touring in extraction).</p></div>}
|
||||
<div style={{ marginTop: 24, opacity: 0.7, fontSize: 12 }}>
|
||||
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/* .
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
11
web/src/api.ts
Normal file
11
web/src/api.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
const API_BASE = import.meta.env.DEV ? '/api' : '/finances';
|
||||
|
||||
export async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
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<T>;
|
||||
}
|
||||
10
web/src/main.tsx
Normal file
10
web/src/main.tsx
Normal file
|
|
@ -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(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
);
|
||||
6
web/src/styles.css
Normal file
6
web/src/styles.css
Normal file
|
|
@ -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; }
|
||||
12
web/src/useHashRoute.ts
Normal file
12
web/src/useHashRoute.ts
Normal file
|
|
@ -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];
|
||||
}
|
||||
8
web/src/usePoll.ts
Normal file
8
web/src/usePoll.ts
Normal file
|
|
@ -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]);
|
||||
}
|
||||
1
web/src/vite-env.d.ts
vendored
Normal file
1
web/src/vite-env.d.ts
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
/// <reference types="vite/client" />
|
||||
16
web/tsconfig.json
Normal file
16
web/tsconfig.json
Normal file
|
|
@ -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" }]
|
||||
}
|
||||
9
web/tsconfig.node.json
Normal file
9
web/tsconfig.node.json
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
11
web/vite.config.ts
Normal file
11
web/vite.config.ts
Normal file
|
|
@ -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',
|
||||
},
|
||||
},
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue