import type { FastifyInstance, FastifyReply, FastifyRequest, preHandlerHookHandler } from 'fastify'; import { SimpleFINClient, PortfolioRepository, noopLogger } from '../../domains/shared/index.js'; import { PersonalFinanceAnalyzer, ScreenerEngine } from '../screener/index.js'; import { PortfolioAdvisor } from '../portfolio/PortfolioAdvisor.js'; import type { PortfolioHolding } from '../../domains/shared/index.js'; import { holdingSchema } from '../../domains/shared/types/schemas.js'; import type { TokenPayload } from '../auth/index.js'; interface FinanceControllerOptions { authGuard?: preHandlerHookHandler; traderGuard?: preHandlerHookHandler; } type AuthRequest = FastifyRequest & { user?: TokenPayload }; function userId(req: FastifyRequest): string { return (req as AuthRequest).user?.sub ?? ''; } export class FinanceController { // All portfolio routes only need a valid login — data is already user-scoped by user_id. // No role restriction needed; any registered user can manage their own portfolio. readonly #authGuards: preHandlerHookHandler[]; constructor( private readonly engine: ScreenerEngine, private readonly repo: PortfolioRepository, private readonly advisor: PortfolioAdvisor, options: FinanceControllerOptions = {}, ) { this.#authGuards = options.authGuard ? [options.authGuard] : []; } register(app: FastifyInstance): void { app.get('/api/finance/portfolio', { preHandler: this.#authGuards }, this.portfolio.bind(this)); app.post( '/api/finance/holdings', { schema: holdingSchema, preHandler: this.#authGuards, }, this.addHolding.bind(this), ); app.delete( '/api/finance/holdings/:ticker', { preHandler: this.#authGuards, }, this.removeHolding.bind(this), ); app.get('/api/finance/market-context', this.marketContext.bind(this)); } private async portfolio(req: FastifyRequest, _reply: FastifyReply) { const uid = userId(req); const { holdings } = this.repo.exists(uid) ? this.repo.read(uid) : { holdings: [] }; let personalFinance = null; if (process.env.SIMPLEFIN_ACCESS_URL) { const client = new SimpleFINClient({ logger: noopLogger }); const { accounts } = await client.getAccounts(); personalFinance = new PersonalFinanceAnalyzer().analyze(accounts); } const screenable = holdings .filter((h) => (h.type ?? 'stock') !== 'crypto') .map((h) => h.ticker.toUpperCase()); const results = screenable.length > 0 ? await this.engine.screenTickers(screenable) : { STOCK: [], ETF: [], BOND: [], ERROR: [], marketContext: {} as any }; const advice = await this.advisor.advise(holdings, results); return { advice, personalFinance, marketContext: results.marketContext }; } private async addHolding(req: FastifyRequest, reply: FastifyReply) { const uid = userId(req); const { ticker, shares, costBasis = 0, type = 'stock', source = 'Manual', } = req.body as PortfolioHolding; const entry = this.repo.upsert({ ticker, shares, costBasis, type, source }, uid); return reply.code(201).send(entry); } private async removeHolding(req: FastifyRequest, reply: FastifyReply) { const uid = userId(req); const ticker = (req.params as { ticker: string }).ticker.toUpperCase(); const removed = this.repo.remove(ticker, uid); if (!removed) return reply.code(404).send({ error: 'Holding not found' }); return { ok: true }; } private async marketContext() { return this.engine.getMarketContext(); } }