diff --git a/.env.example b/.env.example index 47fd111..43fe47a 100644 --- a/.env.example +++ b/.env.example @@ -26,10 +26,22 @@ CLOUDINARY_API_SECRET=your_api_secret # OpenAI OPENAI_API_KEY=sk-... +# Stripe (Payment Integration) +# Secret key from Stripe Dashboard → Developers → API keys +STRIPE_SECRET_KEY=sk_test_... +# Webhook signing secret from Stripe Dashboard → Developers → Webhooks +STRIPE_WEBHOOK_SECRET=whsec_... +# Price ID for the expert analysis product (from Stripe Dashboard → Products) +# If not set, an ad-hoc price of ₪149 is used +STRIPE_PRICE_ID=price_... # CORS – set to your frontend origin CORS_ORIGIN=http://localhost:3000 +# Bank of Israel API (optional – defaults to official BOI SDMX endpoint) +# Override only for testing or if the BOI URL changes +BOI_API_BASE_URL=https://edge.boi.gov.il/FusionEdgeServer/sdmx/v2/data/dataflow/BOI + # Email (optional) SMTP_HOST=smtp.example.com SMTP_PORT=587 diff --git a/__tests__/analysisEnhanced.test.js b/__tests__/analysisEnhanced.test.js new file mode 100644 index 0000000..58bdc6c --- /dev/null +++ b/__tests__/analysisEnhanced.test.js @@ -0,0 +1,167 @@ +/** + * Enhanced Analysis Endpoint Tests + * + * Integration tests for POST /api/v1/analysis/enhanced/:offerId + * Tests authentication, paid access, validation, and report generation. + */ + +'use strict'; + +// Mock dependencies +jest.mock('../src/config/firestore', () => { + const mockDoc = { + get: jest.fn(), + set: jest.fn().mockResolvedValue(undefined), + update: jest.fn().mockResolvedValue(undefined), + }; + const mockCollection = jest.fn(() => ({ + doc: jest.fn(() => mockDoc), + add: jest.fn().mockResolvedValue({ id: 'mock-id' }), + where: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockReturnThis(), + limit: jest.fn().mockReturnThis(), + get: jest.fn().mockResolvedValue({ empty: true, docs: [], size: 0 }), + })); + const mock = { + collection: mockCollection, + batch: jest.fn(() => ({ + set: jest.fn(), + commit: jest.fn().mockResolvedValue(undefined), + })), + _mockDoc: mockDoc, + }; + return mock; +}); + +jest.mock('../src/config/cloudinary', () => ({ + uploader: { + upload_stream: jest.fn(), + destroy: jest.fn(), + }, +})); + +jest.mock('../src/utils/jwt', () => ({ + verifyAccessToken: jest.fn(), + generateAccessToken: jest.fn(), + generateRefreshToken: jest.fn(), +})); + +jest.mock('../src/services/ratesService', () => ({ + getCurrentAverages: jest.fn().mockResolvedValue({ + fixed: 4.65, + cpi: 3.15, + prime: 6.05, + variable: 4.95, + }), + getLatestRates: jest.fn().mockResolvedValue(null), + fetchAndStoreLatestRates: jest.fn().mockResolvedValue(null), + clearCache: jest.fn(), +})); + +jest.mock('../src/cron/ratesCron', () => ({ + startRatesCron: jest.fn(), +})); + +jest.mock('../src/utils/logger', () => ({ + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), +})); + +const request = require('supertest'); +const { verifyAccessToken } = require('../src/utils/jwt'); +const db = require('../src/config/firestore'); + +// We need to require the app after mocks are set up +let app; + +beforeAll(() => { + // Suppress startup logs + app = require('../src/index'); +}); + +const mockPortfolio = { + id: 'market_standard', + name: 'Market Standard', + nameHe: 'תיק שוק סטנדרטי', + termYears: 30, + tracks: [ + { type: 'fixed', percentage: 34, rate: 4.75, rateDisplay: '4.75%' }, + { type: 'prime', percentage: 33, rate: 5.9, rateDisplay: 'P-0.15%' }, + { type: 'cpi', percentage: 33, rate: 3.2, rateDisplay: '3.20% + מדד' }, + ], + monthlyRepayment: 5200, + totalCost: 1872000, + totalInterest: 672000, +}; + +describe('POST /api/v1/analysis/enhanced/:offerId', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should return 401 without authentication', async () => { + const res = await request(app) + .post('/api/v1/analysis/enhanced/offer-123') + .send({ portfolio: mockPortfolio }); + + expect(res.status).toBe(401); + expect(res.body.success).toBe(false); + }); + + it('should return 403 when user has not paid', async () => { + // Mock auth + verifyAccessToken.mockReturnValue({ id: 'user-456' }); + + // Mock user lookup (auth middleware) + const mockUserDoc = { + exists: true, + id: 'user-456', + data: () => ({ + id: 'user-456', + email: 'test@example.com', + verified: true, + paidAnalyses: false, + }), + }; + + // The auth middleware and paidAccess middleware both call db.collection('users').doc(id).get() + db._mockDoc.get.mockResolvedValue(mockUserDoc); + + const res = await request(app) + .post('/api/v1/analysis/enhanced/offer-123') + .set('Authorization', 'Bearer valid-token') + .send({ portfolio: mockPortfolio }); + + expect(res.status).toBe(403); + expect(res.body.success).toBe(false); + expect(res.body.errorCode).toBe('PAYMENT_REQUIRED'); + }); + + it('should return 400 when portfolio is missing', async () => { + // Mock auth + paid user + verifyAccessToken.mockReturnValue({ id: 'user-456' }); + + const mockUserDoc = { + exists: true, + id: 'user-456', + data: () => ({ + id: 'user-456', + email: 'test@example.com', + verified: true, + paidAnalyses: true, + }), + }; + + db._mockDoc.get.mockResolvedValue(mockUserDoc); + + const res = await request(app) + .post('/api/v1/analysis/enhanced/offer-123') + .set('Authorization', 'Bearer valid-token') + .send({}); + + expect(res.status).toBe(400); + expect(res.body.success).toBe(false); + }); +}); diff --git a/__tests__/analysisValidator.test.js b/__tests__/analysisValidator.test.js new file mode 100644 index 0000000..be5e1b9 --- /dev/null +++ b/__tests__/analysisValidator.test.js @@ -0,0 +1,151 @@ +/** + * Analysis Validator Tests + * + * Tests for the Joi validation schemas used by the enhanced analysis endpoint. + */ + +'use strict'; + +const { enhancedAnalysisSchema } = require('../src/validators/analysisValidator'); + +const validBody = { + portfolio: { + id: 'market_standard', + name: 'Market Standard', + nameHe: 'תיק שוק סטנדרטי', + termYears: 30, + tracks: [ + { type: 'fixed', percentage: 34, rate: 4.75 }, + { type: 'prime', percentage: 33, rate: 5.9 }, + { type: 'cpi', percentage: 33, rate: 3.2 }, + ], + monthlyRepayment: 5200, + totalCost: 1872000, + totalInterest: 672000, + }, +}; + +describe('enhancedAnalysisSchema', () => { + it('should accept a valid request body', () => { + const { error } = enhancedAnalysisSchema.validate(validBody); + expect(error).toBeUndefined(); + }); + + it('should reject missing portfolio', () => { + const { error } = enhancedAnalysisSchema.validate({}); + expect(error).toBeDefined(); + expect(error.details[0].path).toContain('portfolio'); + }); + + it('should reject portfolio without id', () => { + const body = { + portfolio: { ...validBody.portfolio, id: '' }, + }; + const { error } = enhancedAnalysisSchema.validate(body); + expect(error).toBeDefined(); + }); + + it('should reject portfolio without name', () => { + const body = { + portfolio: { ...validBody.portfolio, name: '' }, + }; + const { error } = enhancedAnalysisSchema.validate(body); + expect(error).toBeDefined(); + }); + + it('should reject portfolio with invalid termYears', () => { + const body = { + portfolio: { ...validBody.portfolio, termYears: 0 }, + }; + const { error } = enhancedAnalysisSchema.validate(body); + expect(error).toBeDefined(); + }); + + it('should reject portfolio with termYears > 40', () => { + const body = { + portfolio: { ...validBody.portfolio, termYears: 50 }, + }; + const { error } = enhancedAnalysisSchema.validate(body); + expect(error).toBeDefined(); + }); + + it('should reject portfolio without tracks', () => { + const body = { + portfolio: { ...validBody.portfolio, tracks: [] }, + }; + const { error } = enhancedAnalysisSchema.validate(body); + expect(error).toBeDefined(); + }); + + it('should reject track with invalid type', () => { + const body = { + portfolio: { + ...validBody.portfolio, + tracks: [{ type: 'invalid', percentage: 100, rate: 4.5 }], + }, + }; + const { error } = enhancedAnalysisSchema.validate(body); + expect(error).toBeDefined(); + }); + + it('should reject track with percentage > 100', () => { + const body = { + portfolio: { + ...validBody.portfolio, + tracks: [{ type: 'fixed', percentage: 150, rate: 4.5 }], + }, + }; + const { error } = enhancedAnalysisSchema.validate(body); + expect(error).toBeDefined(); + }); + + it('should reject track with negative rate', () => { + const body = { + portfolio: { + ...validBody.portfolio, + tracks: [{ type: 'fixed', percentage: 100, rate: -1 }], + }, + }; + const { error } = enhancedAnalysisSchema.validate(body); + expect(error).toBeDefined(); + }); + + it('should reject portfolio with negative monthlyRepayment', () => { + const body = { + portfolio: { ...validBody.portfolio, monthlyRepayment: -100 }, + }; + const { error } = enhancedAnalysisSchema.validate(body); + expect(error).toBeDefined(); + }); + + it('should reject portfolio with negative totalInterest', () => { + const body = { + portfolio: { ...validBody.portfolio, totalInterest: -1 }, + }; + const { error } = enhancedAnalysisSchema.validate(body); + expect(error).toBeDefined(); + }); + + it('should accept portfolio with optional fields', () => { + const body = { + portfolio: { + ...validBody.portfolio, + description: 'A test portfolio', + interestSavings: 50000, + fitnessScore: 85, + recommended: true, + }, + }; + const { error } = enhancedAnalysisSchema.validate(body); + expect(error).toBeUndefined(); + }); + + it('should accept optional portfolioId field', () => { + const body = { + ...validBody, + portfolioId: 'market_standard', + }; + const { error } = enhancedAnalysisSchema.validate(body); + expect(error).toBeUndefined(); + }); +}); diff --git a/__tests__/paidAccess.test.js b/__tests__/paidAccess.test.js new file mode 100644 index 0000000..3959cbf --- /dev/null +++ b/__tests__/paidAccess.test.js @@ -0,0 +1,124 @@ +/** + * Paid Access Middleware Tests + * + * Tests for the requirePaidAccess middleware that checks + * whether a user has paid for enhanced analysis features. + */ + +'use strict'; + +jest.mock('../src/config/firestore', () => { + const mockDoc = { + get: jest.fn(), + }; + const mockCollection = jest.fn(() => ({ + doc: jest.fn(() => mockDoc), + })); + const mock = { + collection: mockCollection, + _mockDoc: mockDoc, + }; + return mock; +}); + +jest.mock('../src/utils/logger', () => ({ + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), +})); + +const httpMocks = require('node-mocks-http'); +const { requirePaidAccess } = require('../src/middleware/paidAccess'); +const db = require('../src/config/firestore'); + +describe('requirePaidAccess middleware', () => { + let req; + let res; + let next; + + beforeEach(() => { + jest.clearAllMocks(); + req = httpMocks.createRequest(); + res = httpMocks.createResponse(); + // Attach json method that supertest/express would provide + res.json = jest.fn().mockReturnValue(res); + res.status = jest.fn().mockReturnValue(res); + next = jest.fn(); + }); + + it('should return 401 when req.user is missing', async () => { + await requirePaidAccess(req, res, next); + + expect(res.status).toHaveBeenCalledWith(401); + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ success: false, message: 'Authentication required' }) + ); + expect(next).not.toHaveBeenCalled(); + }); + + it('should return 401 when user document does not exist', async () => { + req.user = { id: 'user-123' }; + db._mockDoc.get.mockResolvedValue({ exists: false }); + + await requirePaidAccess(req, res, next); + + expect(res.status).toHaveBeenCalledWith(401); + expect(next).not.toHaveBeenCalled(); + }); + + it('should return 403 when user has not paid', async () => { + req.user = { id: 'user-123' }; + db._mockDoc.get.mockResolvedValue({ + exists: true, + data: () => ({ paidAnalyses: false }), + }); + + await requirePaidAccess(req, res, next); + + expect(res.status).toHaveBeenCalledWith(403); + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ + success: false, + errorCode: 'PAYMENT_REQUIRED', + }) + ); + expect(next).not.toHaveBeenCalled(); + }); + + it('should return 403 when paidAnalyses is undefined', async () => { + req.user = { id: 'user-123' }; + db._mockDoc.get.mockResolvedValue({ + exists: true, + data: () => ({}), + }); + + await requirePaidAccess(req, res, next); + + expect(res.status).toHaveBeenCalledWith(403); + expect(next).not.toHaveBeenCalled(); + }); + + it('should call next() when user has paid', async () => { + req.user = { id: 'user-123' }; + db._mockDoc.get.mockResolvedValue({ + exists: true, + data: () => ({ paidAnalyses: true }), + }); + + await requirePaidAccess(req, res, next); + + expect(next).toHaveBeenCalled(); + expect(res.status).not.toHaveBeenCalled(); + }); + + it('should return 500 on Firestore error', async () => { + req.user = { id: 'user-123' }; + db._mockDoc.get.mockRejectedValue(new Error('Firestore unavailable')); + + await requirePaidAccess(req, res, next); + + expect(res.status).toHaveBeenCalledWith(500); + expect(next).not.toHaveBeenCalled(); + }); +}); diff --git a/__tests__/paymentController.test.js b/__tests__/paymentController.test.js new file mode 100644 index 0000000..ccb8289 --- /dev/null +++ b/__tests__/paymentController.test.js @@ -0,0 +1,255 @@ +/** + * Payment Controller Tests + * + * Tests for the Stripe payment HTTP handlers: + * - POST /api/v1/stripe/checkout + * - POST /api/v1/stripe/webhook + * - GET /api/v1/stripe/status + */ + +'use strict'; + +const httpMocks = require('node-mocks-http'); + +// ── Mock Setup ──────────────────────────────────────────────────────────────── + +const mockCreateCheckoutSession = jest.fn(); +const mockConstructWebhookEvent = jest.fn(); +const mockHandleWebhookEvent = jest.fn(); +const mockHasUserPaid = jest.fn(); +const mockGetPaymentHistory = jest.fn(); + +jest.mock('../src/services/paymentService', () => ({ + createCheckoutSession: mockCreateCheckoutSession, + constructWebhookEvent: mockConstructWebhookEvent, + handleWebhookEvent: mockHandleWebhookEvent, + hasUserPaid: mockHasUserPaid, + getPaymentHistory: mockGetPaymentHistory, +})); + +jest.mock('../src/utils/logger', () => ({ + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), +})); + +const paymentController = require('../src/controllers/paymentController'); + +// ── Test Suite ──────────────────────────────────────────────────────────────── + +describe('paymentController', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + // ── createCheckout ──────────────────────────────────────────────────────── + + describe('createCheckout', () => { + it('should create checkout session and return 200', async () => { + const req = httpMocks.createRequest({ + method: 'POST', + url: '/api/v1/stripe/checkout', + body: { + portfolioId: 'port-123', + successUrl: 'https://morty.app/success', + cancelUrl: 'https://morty.app/cancel', + }, + user: { id: 'user-123', email: 'test@example.com' }, + }); + const res = httpMocks.createResponse(); + + mockCreateCheckoutSession.mockResolvedValueOnce({ + sessionId: 'cs_test_123', + url: 'https://checkout.stripe.com/pay/cs_test_123', + }); + + await paymentController.createCheckout(req, res); + + expect(res.statusCode).toBe(200); + const data = res._getJSONData(); + expect(data.success).toBe(true); + expect(data.data.sessionId).toBe('cs_test_123'); + expect(data.data.url).toContain('checkout.stripe.com'); + }); + + it('should return error status code from service errors', async () => { + const req = httpMocks.createRequest({ + method: 'POST', + url: '/api/v1/stripe/checkout', + body: { successUrl: 'https://morty.app/success' }, + user: { id: 'user-paid', email: 'paid@example.com' }, + }); + const res = httpMocks.createResponse(); + + const err = new Error('User already has paid access'); + err.statusCode = 409; + err.errorCode = 'ALREADY_PAID'; + mockCreateCheckoutSession.mockRejectedValueOnce(err); + + await paymentController.createCheckout(req, res); + + expect(res.statusCode).toBe(409); + const data = res._getJSONData(); + expect(data.success).toBe(false); + }); + + it('should return 500 on unexpected errors', async () => { + const req = httpMocks.createRequest({ + method: 'POST', + url: '/api/v1/stripe/checkout', + body: { successUrl: 'https://morty.app/success' }, + user: { id: 'user-123', email: 'test@example.com' }, + }); + const res = httpMocks.createResponse(); + + mockCreateCheckoutSession.mockRejectedValueOnce(new Error('Unexpected')); + + await paymentController.createCheckout(req, res); + + expect(res.statusCode).toBe(500); + }); + }); + + // ── handleWebhook ───────────────────────────────────────────────────────── + + describe('handleWebhook', () => { + it('should process valid webhook event and return 200', async () => { + const req = httpMocks.createRequest({ + method: 'POST', + url: '/api/v1/stripe/webhook', + headers: { 'stripe-signature': 'sig_valid' }, + body: Buffer.from('{"type":"checkout.session.completed"}'), + }); + const res = httpMocks.createResponse(); + + const mockEvent = { id: 'evt_123', type: 'checkout.session.completed' }; + mockConstructWebhookEvent.mockReturnValueOnce(mockEvent); + mockHandleWebhookEvent.mockResolvedValueOnce({ + handled: true, + type: 'checkout.session.completed', + message: 'Paid access unlocked', + }); + + await paymentController.handleWebhook(req, res); + + expect(res.statusCode).toBe(200); + const data = res._getJSONData(); + expect(data.received).toBe(true); + expect(data.handled).toBe(true); + }); + + it('should return 400 when Stripe-Signature header is missing', async () => { + const req = httpMocks.createRequest({ + method: 'POST', + url: '/api/v1/stripe/webhook', + headers: {}, + body: Buffer.from('{}'), + }); + const res = httpMocks.createResponse(); + + await paymentController.handleWebhook(req, res); + + expect(res.statusCode).toBe(400); + const data = res._getJSONData(); + expect(data.success).toBe(false); + expect(data.message).toContain('Stripe-Signature'); + }); + + it('should return error when signature verification fails', async () => { + const req = httpMocks.createRequest({ + method: 'POST', + url: '/api/v1/stripe/webhook', + headers: { 'stripe-signature': 'sig_invalid' }, + body: Buffer.from('{}'), + }); + const res = httpMocks.createResponse(); + + const err = new Error('Webhook signature verification failed'); + err.statusCode = 400; + mockConstructWebhookEvent.mockImplementationOnce(() => { throw err; }); + + await paymentController.handleWebhook(req, res); + + expect(res.statusCode).toBe(400); + }); + + it('should return 500 when event processing fails', async () => { + const req = httpMocks.createRequest({ + method: 'POST', + url: '/api/v1/stripe/webhook', + headers: { 'stripe-signature': 'sig_valid' }, + body: Buffer.from('{}'), + }); + const res = httpMocks.createResponse(); + + const mockEvent = { id: 'evt_fail', type: 'checkout.session.completed' }; + mockConstructWebhookEvent.mockReturnValueOnce(mockEvent); + mockHandleWebhookEvent.mockRejectedValueOnce(new Error('Processing failed')); + + await paymentController.handleWebhook(req, res); + + expect(res.statusCode).toBe(500); + }); + }); + + // ── getPaymentStatus ────────────────────────────────────────────────────── + + describe('getPaymentStatus', () => { + it('should return payment status for authenticated user', async () => { + const req = httpMocks.createRequest({ + method: 'GET', + url: '/api/v1/stripe/status', + user: { id: 'user-123' }, + }); + const res = httpMocks.createResponse(); + + mockHasUserPaid.mockResolvedValueOnce(true); + mockGetPaymentHistory.mockResolvedValueOnce([ + { id: 'cs_123', status: 'completed', createdAt: '2025-01-01' }, + ]); + + await paymentController.getPaymentStatus(req, res); + + expect(res.statusCode).toBe(200); + const data = res._getJSONData(); + expect(data.success).toBe(true); + expect(data.data.hasPaid).toBe(true); + expect(data.data.payments).toHaveLength(1); + }); + + it('should return hasPaid=false for unpaid user', async () => { + const req = httpMocks.createRequest({ + method: 'GET', + url: '/api/v1/stripe/status', + user: { id: 'user-unpaid' }, + }); + const res = httpMocks.createResponse(); + + mockHasUserPaid.mockResolvedValueOnce(false); + mockGetPaymentHistory.mockResolvedValueOnce([]); + + await paymentController.getPaymentStatus(req, res); + + expect(res.statusCode).toBe(200); + const data = res._getJSONData(); + expect(data.data.hasPaid).toBe(false); + expect(data.data.payments).toHaveLength(0); + }); + + it('should return 500 on error', async () => { + const req = httpMocks.createRequest({ + method: 'GET', + url: '/api/v1/stripe/status', + user: { id: 'user-error' }, + }); + const res = httpMocks.createResponse(); + + mockHasUserPaid.mockRejectedValueOnce(new Error('DB error')); + + await paymentController.getPaymentStatus(req, res); + + expect(res.statusCode).toBe(500); + }); + }); +}); diff --git a/__tests__/paymentService.test.js b/__tests__/paymentService.test.js new file mode 100644 index 0000000..700765f --- /dev/null +++ b/__tests__/paymentService.test.js @@ -0,0 +1,447 @@ +/** + * Payment Service Tests + * + * Tests for Stripe integration including: + * - Checkout session creation + * - Webhook event handling + * - Payment status queries + * - Error handling + */ + +'use strict'; + +// ── Mock Setup ──────────────────────────────────────────────────────────────── + +// Mock Stripe +const mockStripeCheckoutCreate = jest.fn(); +const mockStripeWebhooksConstructEvent = jest.fn(); + +jest.mock('stripe', () => { + return jest.fn().mockImplementation(() => ({ + checkout: { + sessions: { + create: mockStripeCheckoutCreate, + }, + }, + webhooks: { + constructEvent: mockStripeWebhooksConstructEvent, + }, + })); +}); + +// Mock Firestore +const mockGet = jest.fn(); +const mockSet = jest.fn(); +const mockUpdate = jest.fn(); +const mockDoc = jest.fn().mockReturnValue({ + get: mockGet, + set: mockSet, + update: mockUpdate, +}); +const mockWhere = jest.fn().mockReturnThis(); +const mockOrderBy = jest.fn().mockReturnThis(); +const mockLimit = jest.fn().mockReturnThis(); +const mockCollection = jest.fn().mockReturnValue({ + doc: mockDoc, + where: mockWhere, + orderBy: mockOrderBy, + limit: mockLimit, + get: mockGet, +}); + +jest.mock('../src/config/firestore', () => ({ + collection: mockCollection, +})); + +// Mock logger +jest.mock('../src/utils/logger', () => ({ + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), +})); + +// Set env vars before requiring the module +process.env.STRIPE_SECRET_KEY = 'sk_test_mock_key'; +process.env.STRIPE_WEBHOOK_SECRET = 'whsec_mock_secret'; + +const paymentService = require('../src/services/paymentService'); + +// ── Test Suite ──────────────────────────────────────────────────────────────── + +describe('paymentService', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + // ── createCheckoutSession ───────────────────────────────────────────────── + + describe('createCheckoutSession', () => { + const validParams = { + userId: 'user-123', + userEmail: 'test@example.com', + portfolioId: 'portfolio-abc', + successUrl: 'https://morty.app/success', + cancelUrl: 'https://morty.app/cancel', + }; + + it('should create a checkout session successfully', async () => { + // User does not have paid access + mockGet.mockResolvedValueOnce({ + exists: true, + data: () => ({ paidAnalyses: false }), + }); + + // Stripe session creation + mockStripeCheckoutCreate.mockResolvedValueOnce({ + id: 'cs_test_session_123', + url: 'https://checkout.stripe.com/pay/cs_test_session_123', + }); + + // storePendingPayment – doc set + mockGet.mockResolvedValueOnce({ exists: false }); + mockSet.mockResolvedValueOnce(); + + const result = await paymentService.createCheckoutSession(validParams); + + expect(result).toHaveProperty('sessionId', 'cs_test_session_123'); + expect(result).toHaveProperty('url'); + expect(result.url).toContain('checkout.stripe.com'); + expect(mockStripeCheckoutCreate).toHaveBeenCalledTimes(1); + + // Verify session params + const sessionParams = mockStripeCheckoutCreate.mock.calls[0][0]; + expect(sessionParams.mode).toBe('payment'); + expect(sessionParams.client_reference_id).toBe('user-123'); + expect(sessionParams.metadata.userId).toBe('user-123'); + expect(sessionParams.metadata.portfolioId).toBe('portfolio-abc'); + expect(sessionParams.metadata.product).toBe('expert_analysis'); + expect(sessionParams.success_url).toBe('https://morty.app/success'); + expect(sessionParams.cancel_url).toBe('https://morty.app/cancel'); + expect(sessionParams.locale).toBe('he'); + }); + + it('should throw 409 if user already has paid access', async () => { + mockGet.mockResolvedValueOnce({ + exists: true, + data: () => ({ paidAnalyses: true }), + }); + + await expect( + paymentService.createCheckoutSession(validParams) + ).rejects.toMatchObject({ + statusCode: 409, + errorCode: 'ALREADY_PAID', + }); + + expect(mockStripeCheckoutCreate).not.toHaveBeenCalled(); + }); + + it('should throw 400 if userId is missing', async () => { + await expect( + paymentService.createCheckoutSession({ ...validParams, userId: '' }) + ).rejects.toMatchObject({ + statusCode: 400, + }); + }); + + it('should throw 400 if successUrl is missing', async () => { + await expect( + paymentService.createCheckoutSession({ ...validParams, successUrl: '' }) + ).rejects.toMatchObject({ + statusCode: 400, + }); + }); + + it('should use ad-hoc price when STRIPE_PRICE_ID is not set', async () => { + delete process.env.STRIPE_PRICE_ID; + + mockGet.mockResolvedValueOnce({ + exists: true, + data: () => ({ paidAnalyses: false }), + }); + + mockStripeCheckoutCreate.mockResolvedValueOnce({ + id: 'cs_test_adhoc', + url: 'https://checkout.stripe.com/pay/cs_test_adhoc', + }); + + mockSet.mockResolvedValueOnce(); + + await paymentService.createCheckoutSession(validParams); + + const sessionParams = mockStripeCheckoutCreate.mock.calls[0][0]; + expect(sessionParams.line_items[0]).toHaveProperty('price_data'); + expect(sessionParams.line_items[0].price_data.unit_amount).toBe(14900); + expect(sessionParams.line_items[0].price_data.currency).toBe('ils'); + }); + + it('should use STRIPE_PRICE_ID when configured', async () => { + process.env.STRIPE_PRICE_ID = 'price_test_123'; + + mockGet.mockResolvedValueOnce({ + exists: true, + data: () => ({ paidAnalyses: false }), + }); + + mockStripeCheckoutCreate.mockResolvedValueOnce({ + id: 'cs_test_price_id', + url: 'https://checkout.stripe.com/pay/cs_test_price_id', + }); + + mockSet.mockResolvedValueOnce(); + + await paymentService.createCheckoutSession(validParams); + + const sessionParams = mockStripeCheckoutCreate.mock.calls[0][0]; + expect(sessionParams.line_items[0]).toHaveProperty('price', 'price_test_123'); + expect(sessionParams.line_items[0]).not.toHaveProperty('price_data'); + + delete process.env.STRIPE_PRICE_ID; + }); + }); + + // ── constructWebhookEvent ───────────────────────────────────────────────── + + describe('constructWebhookEvent', () => { + it('should construct event from valid signature', () => { + const mockEvent = { id: 'evt_123', type: 'checkout.session.completed' }; + mockStripeWebhooksConstructEvent.mockReturnValueOnce(mockEvent); + + const result = paymentService.constructWebhookEvent('raw-body', 'sig-header'); + + expect(result).toEqual(mockEvent); + expect(mockStripeWebhooksConstructEvent).toHaveBeenCalledWith( + 'raw-body', + 'sig-header', + 'whsec_mock_secret' + ); + }); + + it('should throw 400 on invalid signature', () => { + mockStripeWebhooksConstructEvent.mockImplementationOnce(() => { + throw new Error('Invalid signature'); + }); + + expect(() => { + paymentService.constructWebhookEvent('raw-body', 'bad-sig'); + }).toThrow(); + }); + }); + + // ── handleWebhookEvent ──────────────────────────────────────────────────── + + describe('handleWebhookEvent', () => { + it('should handle checkout.session.completed and unlock paid access', async () => { + const event = { + id: 'evt_completed_123', + type: 'checkout.session.completed', + data: { + object: { + id: 'cs_test_completed', + metadata: { userId: 'user-456', portfolioId: 'port-789' }, + client_reference_id: 'user-456', + payment_intent: 'pi_test_123', + amount_total: 14900, + currency: 'ils', + customer_details: { email: 'user@example.com' }, + }, + }, + }; + + // User exists + mockGet.mockResolvedValueOnce({ + exists: true, + data: () => ({ id: 'user-456', email: 'user@example.com' }), + }); + + // User update + mockUpdate.mockResolvedValueOnce(); + + // Payment record update – doc exists + mockGet.mockResolvedValueOnce({ exists: true }); + mockUpdate.mockResolvedValueOnce(); + + const result = await paymentService.handleWebhookEvent(event); + + expect(result.handled).toBe(true); + expect(result.type).toBe('checkout.session.completed'); + expect(result.message).toContain('user-456'); + + // Verify user was updated with paidAnalyses: true + expect(mockUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + paidAnalyses: true, + stripeSessionId: 'cs_test_completed', + stripePaymentIntentId: 'pi_test_123', + }) + ); + }); + + it('should handle checkout.session.expired', async () => { + const event = { + id: 'evt_expired_123', + type: 'checkout.session.expired', + data: { + object: { + id: 'cs_test_expired', + metadata: { userId: 'user-789' }, + }, + }, + }; + + // Payment record update + mockGet.mockResolvedValueOnce({ exists: true }); + mockUpdate.mockResolvedValueOnce(); + + const result = await paymentService.handleWebhookEvent(event); + + expect(result.handled).toBe(true); + expect(result.type).toBe('checkout.session.expired'); + }); + + it('should acknowledge unhandled event types', async () => { + const event = { + id: 'evt_unknown_123', + type: 'payment_intent.created', + data: { object: {} }, + }; + + const result = await paymentService.handleWebhookEvent(event); + + expect(result.handled).toBe(false); + expect(result.type).toBe('payment_intent.created'); + }); + + it('should return handled=false when userId is missing from session', async () => { + const event = { + id: 'evt_no_user', + type: 'checkout.session.completed', + data: { + object: { + id: 'cs_no_user', + metadata: {}, + client_reference_id: null, + }, + }, + }; + + const result = await paymentService.handleWebhookEvent(event); + + expect(result.handled).toBe(false); + expect(result.message).toContain('Missing userId'); + }); + + it('should return handled=false when user not found in Firestore', async () => { + const event = { + id: 'evt_no_user_doc', + type: 'checkout.session.completed', + data: { + object: { + id: 'cs_no_user_doc', + metadata: { userId: 'nonexistent-user' }, + client_reference_id: 'nonexistent-user', + }, + }, + }; + + // User does not exist + mockGet.mockResolvedValueOnce({ exists: false }); + + const result = await paymentService.handleWebhookEvent(event); + + expect(result.handled).toBe(false); + expect(result.message).toContain('not found'); + }); + }); + + // ── hasUserPaid ─────────────────────────────────────────────────────────── + + describe('hasUserPaid', () => { + it('should return true when user has paidAnalyses flag', async () => { + mockGet.mockResolvedValueOnce({ + exists: true, + data: () => ({ paidAnalyses: true }), + }); + + const result = await paymentService.hasUserPaid('user-paid'); + expect(result).toBe(true); + }); + + it('should return false when user does not have paidAnalyses flag', async () => { + mockGet.mockResolvedValueOnce({ + exists: true, + data: () => ({ paidAnalyses: false }), + }); + + const result = await paymentService.hasUserPaid('user-unpaid'); + expect(result).toBe(false); + }); + + it('should return false when user does not exist', async () => { + mockGet.mockResolvedValueOnce({ exists: false }); + + const result = await paymentService.hasUserPaid('nonexistent'); + expect(result).toBe(false); + }); + + it('should return false when userId is empty', async () => { + const result = await paymentService.hasUserPaid(''); + expect(result).toBe(false); + }); + + it('should return false on Firestore error', async () => { + mockGet.mockRejectedValueOnce(new Error('Firestore error')); + + const result = await paymentService.hasUserPaid('user-error'); + expect(result).toBe(false); + }); + }); + + // ── buildLineItems ──────────────────────────────────────────────────────── + + describe('buildLineItems', () => { + it('should return ad-hoc price when STRIPE_PRICE_ID is not set', () => { + delete process.env.STRIPE_PRICE_ID; + + const items = paymentService.buildLineItems(); + + expect(items).toHaveLength(1); + expect(items[0]).toHaveProperty('price_data'); + expect(items[0].price_data.unit_amount).toBe(14900); + expect(items[0].price_data.currency).toBe('ils'); + expect(items[0].price_data.product_data.name).toContain('Morty'); + expect(items[0].quantity).toBe(1); + }); + + it('should return configured price when STRIPE_PRICE_ID is set', () => { + process.env.STRIPE_PRICE_ID = 'price_configured_123'; + + const items = paymentService.buildLineItems(); + + expect(items).toHaveLength(1); + expect(items[0]).toHaveProperty('price', 'price_configured_123'); + expect(items[0]).not.toHaveProperty('price_data'); + expect(items[0].quantity).toBe(1); + + delete process.env.STRIPE_PRICE_ID; + }); + }); + + // ── Constants ───────────────────────────────────────────────────────────── + + describe('constants', () => { + it('should export correct default price amount (₪149 in agorot)', () => { + expect(paymentService.DEFAULT_PRICE_AMOUNT).toBe(14900); + }); + + it('should export ILS currency', () => { + expect(paymentService.CURRENCY).toBe('ils'); + }); + + it('should export collection names', () => { + expect(paymentService.PAYMENTS_COLLECTION).toBe('payments'); + expect(paymentService.USERS_COLLECTION).toBe('users'); + }); + }); +}); diff --git a/__tests__/paymentValidator.test.js b/__tests__/paymentValidator.test.js new file mode 100644 index 0000000..ae101ee --- /dev/null +++ b/__tests__/paymentValidator.test.js @@ -0,0 +1,88 @@ +/** + * Payment Validator Tests + * + * Tests for Joi validation schemas used by Stripe payment endpoints. + */ + +'use strict'; + +const { checkoutSchema } = require('../src/validators/paymentValidator'); + +describe('paymentValidator', () => { + describe('checkoutSchema', () => { + it('should validate a complete valid request', () => { + const { error } = checkoutSchema.validate({ + successUrl: 'https://morty.app/success', + cancelUrl: 'https://morty.app/cancel', + portfolioId: 'market_standard', + }); + expect(error).toBeUndefined(); + }); + + it('should validate with only required fields', () => { + const { error } = checkoutSchema.validate({ + successUrl: 'https://morty.app/success', + }); + expect(error).toBeUndefined(); + }); + + it('should reject missing successUrl', () => { + const { error } = checkoutSchema.validate({}); + expect(error).toBeDefined(); + expect(error.details[0].path).toContain('successUrl'); + }); + + it('should reject invalid successUrl (not a URL)', () => { + const { error } = checkoutSchema.validate({ + successUrl: 'not-a-url', + }); + expect(error).toBeDefined(); + }); + + it('should reject non-HTTP/HTTPS successUrl', () => { + const { error } = checkoutSchema.validate({ + successUrl: 'ftp://morty.app/success', + }); + expect(error).toBeDefined(); + }); + + it('should accept HTTP successUrl (for development)', () => { + const { error } = checkoutSchema.validate({ + successUrl: 'http://localhost:3000/success', + }); + expect(error).toBeUndefined(); + }); + + it('should accept empty cancelUrl', () => { + const { error } = checkoutSchema.validate({ + successUrl: 'https://morty.app/success', + cancelUrl: '', + }); + expect(error).toBeUndefined(); + }); + + it('should reject invalid cancelUrl', () => { + const { error } = checkoutSchema.validate({ + successUrl: 'https://morty.app/success', + cancelUrl: 'not-a-url', + }); + expect(error).toBeDefined(); + }); + + it('should accept empty portfolioId', () => { + const { error } = checkoutSchema.validate({ + successUrl: 'https://morty.app/success', + portfolioId: '', + }); + expect(error).toBeUndefined(); + }); + + it('should reject portfolioId exceeding max length', () => { + const { error } = checkoutSchema.validate({ + successUrl: 'https://morty.app/success', + portfolioId: 'x'.repeat(201), + }); + expect(error).toBeDefined(); + }); + }); +}); diff --git a/__tests__/ratesController.test.js b/__tests__/ratesController.test.js new file mode 100644 index 0000000..9df085b --- /dev/null +++ b/__tests__/ratesController.test.js @@ -0,0 +1,137 @@ +/** + * Tests for ratesController – HTTP endpoint tests. + */ + +'use strict'; + +// Mock dependencies before requiring the controller +jest.mock('../src/services/ratesService'); +jest.mock('../src/utils/logger', () => ({ + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), +})); + +const httpMocks = require('node-mocks-http'); +const ratesController = require('../src/controllers/ratesController'); +const ratesService = require('../src/services/ratesService'); + +beforeEach(() => { + jest.clearAllMocks(); +}); + +describe('ratesController', () => { + describe('getLatestRates', () => { + it('should return 200 with rates data on success', async () => { + const mockRates = { + date: '2024-12-15T00:00:00.000Z', + tracks: { fixed: { average: 4.5 } }, + averages: { fixed: 4.5, cpi: 3.0, prime: 6.0, variable: 5.0 }, + source: 'bank_of_israel', + sourceUrl: 'https://www.boi.org.il', + updatedAt: '2024-12-15T00:00:00.000Z', + }; + + ratesService.getLatestRates.mockResolvedValue(mockRates); + + const req = httpMocks.createRequest({ method: 'GET' }); + const res = httpMocks.createResponse(); + + await ratesController.getLatestRates(req, res); + + expect(res.statusCode).toBe(200); + const body = res._getJSONData(); + expect(body.success).toBe(true); + expect(body.data).toBeTruthy(); + expect(body.data.averages.fixed).toBe(4.5); + }); + + it('should set Cache-Control header for 1 hour', async () => { + ratesService.getLatestRates.mockResolvedValue({ + date: '2024-12-15T00:00:00.000Z', + tracks: {}, + averages: {}, + source: 'bank_of_israel', + updatedAt: '2024-12-15T00:00:00.000Z', + }); + + const req = httpMocks.createRequest({ method: 'GET' }); + const res = httpMocks.createResponse(); + + await ratesController.getLatestRates(req, res); + + expect(res.getHeader('Cache-Control')).toBe('public, max-age=3600, s-maxage=3600'); + }); + + it('should return 503 when rates are unavailable', async () => { + ratesService.getLatestRates.mockResolvedValue(null); + + const req = httpMocks.createRequest({ method: 'GET' }); + const res = httpMocks.createResponse(); + + await ratesController.getLatestRates(req, res); + + expect(res.statusCode).toBe(503); + const body = res._getJSONData(); + expect(body.success).toBe(false); + }); + + it('should return 500 on service error', async () => { + ratesService.getLatestRates.mockRejectedValue(new Error('Firestore error')); + + const req = httpMocks.createRequest({ method: 'GET' }); + const res = httpMocks.createResponse(); + + await ratesController.getLatestRates(req, res); + + expect(res.statusCode).toBe(500); + const body = res._getJSONData(); + expect(body.success).toBe(false); + }); + }); + + describe('refreshRates', () => { + it('should return 200 with refreshed rates on success', async () => { + const mockRates = { + date: '2024-12-15T00:00:00.000Z', + tracks: { fixed: { average: 4.5 } }, + averages: { fixed: 4.5 }, + source: 'bank_of_israel', + }; + + ratesService.fetchAndStoreLatestRates.mockResolvedValue(mockRates); + + const req = httpMocks.createRequest({ method: 'POST' }); + const res = httpMocks.createResponse(); + + await ratesController.refreshRates(req, res); + + expect(res.statusCode).toBe(200); + const body = res._getJSONData(); + expect(body.success).toBe(true); + }); + + it('should return 502 when BOI fetch fails', async () => { + ratesService.fetchAndStoreLatestRates.mockResolvedValue(null); + + const req = httpMocks.createRequest({ method: 'POST' }); + const res = httpMocks.createResponse(); + + await ratesController.refreshRates(req, res); + + expect(res.statusCode).toBe(502); + }); + + it('should return 500 on unexpected error', async () => { + ratesService.fetchAndStoreLatestRates.mockRejectedValue(new Error('Unexpected')); + + const req = httpMocks.createRequest({ method: 'POST' }); + const res = httpMocks.createResponse(); + + await ratesController.refreshRates(req, res); + + expect(res.statusCode).toBe(500); + }); + }); +}); diff --git a/__tests__/ratesCron.test.js b/__tests__/ratesCron.test.js new file mode 100644 index 0000000..11b6273 --- /dev/null +++ b/__tests__/ratesCron.test.js @@ -0,0 +1,98 @@ +/** + * Tests for ratesCron – cron job scheduling. + */ + +'use strict'; + +jest.mock('node-cron', () => ({ + schedule: jest.fn(() => ({ + stop: jest.fn(), + })), +})); + +jest.mock('../src/services/ratesService', () => ({ + fetchAndStoreLatestRates: jest.fn().mockResolvedValue({ + source: 'bank_of_israel', + tracks: { fixed: {}, cpi: {}, prime: {}, variable: {} }, + }), +})); + +jest.mock('../src/utils/logger', () => ({ + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), +})); + +const cron = require('node-cron'); +const { startRatesCron, stopRatesCron } = require('../src/cron/ratesCron'); + +beforeEach(() => { + jest.clearAllMocks(); + // Reset the module's internal state by stopping any existing cron + stopRatesCron(); +}); + +describe('ratesCron', () => { + describe('startRatesCron', () => { + it('should schedule a cron job at 23:00 UTC', () => { + startRatesCron(); + + expect(cron.schedule).toHaveBeenCalledTimes(1); + expect(cron.schedule).toHaveBeenCalledWith( + '0 23 * * *', + expect.any(Function), + expect.objectContaining({ + scheduled: true, + timezone: 'UTC', + }) + ); + }); + + it('should not create duplicate cron jobs', () => { + startRatesCron(); + startRatesCron(); // second call should be a no-op + + expect(cron.schedule).toHaveBeenCalledTimes(1); + }); + }); + + describe('stopRatesCron', () => { + it('should stop the cron job', () => { + const task = startRatesCron(); + stopRatesCron(); + + expect(task.stop).toHaveBeenCalled(); + }); + }); + + describe('cron callback', () => { + it('should call fetchAndStoreLatestRates when triggered', async () => { + const ratesService = require('../src/services/ratesService'); + + startRatesCron(); + + // Get the callback function passed to cron.schedule + const cronCallback = cron.schedule.mock.calls[0][1]; + + // Execute the callback + await cronCallback(); + + expect(ratesService.fetchAndStoreLatestRates).toHaveBeenCalledTimes(1); + }); + + it('should handle errors gracefully without crashing', async () => { + const ratesService = require('../src/services/ratesService'); + ratesService.fetchAndStoreLatestRates.mockRejectedValueOnce( + new Error('Firestore unavailable') + ); + + startRatesCron(); + + const cronCallback = cron.schedule.mock.calls[0][1]; + + // Should not throw + await expect(cronCallback()).resolves.not.toThrow(); + }); + }); +}); diff --git a/__tests__/ratesService.test.js b/__tests__/ratesService.test.js new file mode 100644 index 0000000..ebc71cc --- /dev/null +++ b/__tests__/ratesService.test.js @@ -0,0 +1,589 @@ +/** + * Tests for ratesService – Bank of Israel mortgage rates integration. + * + * Tests cover: + * - SDMX response parsing + * - Rate formatting + * - Cache behaviour + * - Fallback rates + * - Error handling + * - Document validation + */ + +'use strict'; + +// ── Mocks ───────────────────────────────────────────────────────────────────── + +// Mock Firestore before requiring the service +const mockSet = jest.fn().mockResolvedValue(undefined); +const mockGet = jest.fn(); +const mockCommit = jest.fn().mockResolvedValue(undefined); +const mockBatch = jest.fn(() => ({ + set: mockSet, + commit: mockCommit, +})); +const mockWhere = jest.fn().mockReturnThis(); +const mockOrderBy = jest.fn().mockReturnThis(); +const mockLimit = jest.fn().mockReturnThis(); +const mockCollectionGet = jest.fn().mockResolvedValue({ docs: [] }); + +const mockDoc = jest.fn((id) => ({ + get: mockGet, + set: mockSet, + id, +})); + +const mockCollection = jest.fn(() => ({ + doc: mockDoc, + where: mockWhere, + orderBy: mockOrderBy, + limit: mockLimit, + get: mockCollectionGet, +})); + +jest.mock('../src/config/firestore', () => { + const firestoreMock = { + collection: mockCollection, + batch: mockBatch, + }; + firestoreMock.getFirestore = () => firestoreMock; + return firestoreMock; +}); + +jest.mock('axios'); +jest.mock('../src/utils/logger', () => ({ + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), +})); + +const axios = require('axios'); +const ratesService = require('../src/services/ratesService'); +const { validateMortgageRatesDocument, COLLECTIONS } = require('../src/config/collections'); + +// ── Test Data ───────────────────────────────────────────────────────────────── + +/** + * Sample SDMX-JSON response from the BOI API. + */ +const SAMPLE_SDMX_RESPONSE = { + data: { + dataSets: [ + { + series: { + '0:0:0:0': { + observations: { + '0': [3.85], + '1': [3.92], + '2': [3.78], + '3': [3.95], + '4': [4.01], + '5': [3.88], + '6': [3.82], + '7': [3.90], + '8': [3.87], + '9': [3.93], + '10': [3.80], + '11': [3.75], + }, + }, + }, + }, + ], + structure: { + dimensions: { + observation: [ + { + id: 'TIME_PERIOD', + role: 'time', + values: [ + { id: '2024-01', name: '2024-01' }, + { id: '2024-02', name: '2024-02' }, + { id: '2024-03', name: '2024-03' }, + { id: '2024-04', name: '2024-04' }, + { id: '2024-05', name: '2024-05' }, + { id: '2024-06', name: '2024-06' }, + { id: '2024-07', name: '2024-07' }, + { id: '2024-08', name: '2024-08' }, + { id: '2024-09', name: '2024-09' }, + { id: '2024-10', name: '2024-10' }, + { id: '2024-11', name: '2024-11' }, + { id: '2024-12', name: '2024-12' }, + ], + }, + ], + }, + }, + }, +}; + +// ── Setup / Teardown ────────────────────────────────────────────────────────── + +beforeEach(() => { + jest.clearAllMocks(); + ratesService.clearCache(); +}); + +// ── Tests ───────────────────────────────────────────────────────────────────── + +describe('ratesService', () => { + describe('parseSDMXResponse', () => { + it('should parse a valid SDMX-JSON response into observations', () => { + const result = ratesService.parseSDMXResponse(SAMPLE_SDMX_RESPONSE); + + expect(result).toHaveLength(12); + expect(result[0]).toEqual({ period: '2024-01', value: 3.85 }); + expect(result[11]).toEqual({ period: '2024-12', value: 3.75 }); + }); + + it('should sort observations by period ascending', () => { + const result = ratesService.parseSDMXResponse(SAMPLE_SDMX_RESPONSE); + + for (let i = 1; i < result.length; i++) { + expect(result[i].period > result[i - 1].period).toBe(true); + } + }); + + it('should return empty array for null/undefined input', () => { + expect(ratesService.parseSDMXResponse(null)).toEqual([]); + expect(ratesService.parseSDMXResponse(undefined)).toEqual([]); + }); + + it('should return empty array for malformed SDMX data', () => { + expect(ratesService.parseSDMXResponse({})).toEqual([]); + expect(ratesService.parseSDMXResponse({ data: {} })).toEqual([]); + expect(ratesService.parseSDMXResponse({ data: { dataSets: [] } })).toEqual([]); + }); + + it('should skip null/NaN observation values', () => { + const dataWithNulls = { + data: { + dataSets: [ + { + series: { + '0:0:0:0': { + observations: { + '0': [3.85], + '1': [null], + '2': [3.78], + }, + }, + }, + }, + ], + structure: { + dimensions: { + observation: [ + { + id: 'TIME_PERIOD', + role: 'time', + values: [ + { id: '2024-01', name: '2024-01' }, + { id: '2024-02', name: '2024-02' }, + { id: '2024-03', name: '2024-03' }, + ], + }, + ], + }, + }, + }, + }; + + const result = ratesService.parseSDMXResponse(dataWithNulls); + expect(result).toHaveLength(2); + expect(result[0].value).toBe(3.85); + expect(result[1].value).toBe(3.78); + }); + + it('should round values to 2 decimal places', () => { + const dataWithLongDecimals = { + data: { + dataSets: [ + { + series: { + '0:0:0:0': { + observations: { + '0': [3.856789], + }, + }, + }, + }, + ], + structure: { + dimensions: { + observation: [ + { + id: 'TIME_PERIOD', + role: 'time', + values: [{ id: '2024-01', name: '2024-01' }], + }, + ], + }, + }, + }, + }; + + const result = ratesService.parseSDMXResponse(dataWithLongDecimals); + expect(result[0].value).toBe(3.86); + }); + }); + + describe('formatRatesResponse', () => { + it('should format a rates document for API response', () => { + const doc = { + date: '2024-12-15T00:00:00.000Z', + fetchPeriod: { start: '2024-01', end: '2024-12' }, + tracks: { fixed: { average: 4.5 } }, + averages: { fixed: 4.5, cpi: 3.0, prime: 6.0, variable: 5.0 }, + source: 'bank_of_israel', + sourceUrl: 'https://www.boi.org.il', + updatedAt: '2024-12-15T00:00:00.000Z', + _isLatest: true, // internal field should be stripped + }; + + const result = ratesService.formatRatesResponse(doc); + + expect(result).toHaveProperty('date'); + expect(result).toHaveProperty('tracks'); + expect(result).toHaveProperty('averages'); + expect(result).toHaveProperty('source', 'bank_of_israel'); + expect(result).not.toHaveProperty('_isLatest'); + }); + + it('should return null for null input', () => { + expect(ratesService.formatRatesResponse(null)).toBeNull(); + }); + + it('should handle missing optional fields gracefully', () => { + const doc = { + date: '2024-12-15T00:00:00.000Z', + source: 'fallback', + }; + + const result = ratesService.formatRatesResponse(doc); + expect(result.tracks).toEqual({}); + expect(result.averages).toEqual({}); + expect(result.fetchPeriod).toBeNull(); + }); + }); + + describe('cache', () => { + it('should report cache as invalid initially', () => { + expect(ratesService.isCacheValid()).toBe(false); + }); + + it('should clear cache correctly', () => { + ratesService.clearCache(); + expect(ratesService.isCacheValid()).toBe(false); + }); + }); + + describe('getLatestRates', () => { + it('should return data from Firestore when cache is empty', async () => { + const mockData = { + date: '2024-12-15T00:00:00.000Z', + tracks: { fixed: { average: 4.5 } }, + averages: { fixed: 4.5, cpi: 3.0, prime: 6.0, variable: 5.0 }, + source: 'bank_of_israel', + sourceUrl: 'https://www.boi.org.il', + updatedAt: '2024-12-15T00:00:00.000Z', + }; + + mockGet.mockResolvedValueOnce({ + exists: true, + data: () => mockData, + }); + + const result = await ratesService.getLatestRates(); + + expect(result).toBeTruthy(); + expect(result.source).toBe('bank_of_israel'); + expect(result.averages.fixed).toBe(4.5); + expect(mockCollection).toHaveBeenCalledWith('mortgage_rates'); + }); + + it('should return cached data on subsequent calls', async () => { + const mockData = { + date: '2024-12-15T00:00:00.000Z', + tracks: { fixed: { average: 4.5 } }, + averages: { fixed: 4.5, cpi: 3.0, prime: 6.0, variable: 5.0 }, + source: 'bank_of_israel', + sourceUrl: 'https://www.boi.org.il', + updatedAt: '2024-12-15T00:00:00.000Z', + }; + + mockGet.mockResolvedValueOnce({ + exists: true, + data: () => mockData, + }); + + // First call – reads from Firestore + await ratesService.getLatestRates(); + + // Second call – should use cache + const result = await ratesService.getLatestRates(); + + expect(result).toBeTruthy(); + // mockGet should only have been called once (for the first call) + expect(mockGet).toHaveBeenCalledTimes(1); + }); + }); + + describe('getCurrentAverages', () => { + it('should return averages from latest rates', async () => { + const mockData = { + date: '2024-12-15T00:00:00.000Z', + tracks: {}, + averages: { fixed: 4.5, cpi: 3.0, prime: 6.0, variable: 5.0 }, + source: 'bank_of_israel', + updatedAt: '2024-12-15T00:00:00.000Z', + }; + + mockGet.mockResolvedValueOnce({ + exists: true, + data: () => mockData, + }); + + const averages = await ratesService.getCurrentAverages(); + + expect(averages).toEqual({ + fixed: 4.5, + cpi: 3.0, + prime: 6.0, + variable: 5.0, + }); + }); + + it('should return fallback averages when no data available', async () => { + // Mock: no data in Firestore, and BOI API fails + mockGet.mockResolvedValueOnce({ exists: false }); + axios.get.mockRejectedValue(new Error('Network error')); + + // After fetchAndStoreLatestRates fails to get BOI data, + // it falls back to storeFallbackRates which writes to Firestore + // We need to mock the batch commit + mockCommit.mockResolvedValue(undefined); + + const averages = await ratesService.getCurrentAverages(); + + expect(averages).toBeTruthy(); + expect(typeof averages.fixed).toBe('number'); + expect(typeof averages.cpi).toBe('number'); + expect(typeof averages.prime).toBe('number'); + expect(typeof averages.variable).toBe('number'); + }); + }); + + describe('fetchAndStoreLatestRates', () => { + it('should fetch rates from BOI API and store in Firestore', async () => { + // Mock all 4 track API calls to return valid data + axios.get.mockResolvedValue({ data: SAMPLE_SDMX_RESPONSE }); + + const result = await ratesService.fetchAndStoreLatestRates(); + + expect(result).toBeTruthy(); + expect(result.source).toBe('bank_of_israel'); + expect(result.tracks).toHaveProperty('fixed'); + expect(result.tracks).toHaveProperty('cpi'); + expect(result.tracks).toHaveProperty('prime'); + expect(result.tracks).toHaveProperty('variable'); + expect(result.averages).toBeTruthy(); + + // Should have called axios.get 4 times (one per track) + expect(axios.get).toHaveBeenCalledTimes(4); + + // Should have written to Firestore via batch + expect(mockBatch).toHaveBeenCalled(); + expect(mockCommit).toHaveBeenCalled(); + }); + + it('should fall back to hardcoded rates when BOI API fails', async () => { + axios.get.mockRejectedValue(new Error('Network error')); + + const result = await ratesService.fetchAndStoreLatestRates(); + + expect(result).toBeTruthy(); + expect(result.source).toBe('fallback'); + expect(result._isFallback).toBe(true); + expect(result.averages.fixed).toBe(4.65); + expect(result.averages.cpi).toBe(3.15); + expect(result.averages.prime).toBe(6.05); + expect(result.averages.variable).toBe(4.95); + }); + + it('should handle partial BOI API failures gracefully', async () => { + // First 2 tracks succeed, last 2 fail + axios.get + .mockResolvedValueOnce({ data: SAMPLE_SDMX_RESPONSE }) + .mockResolvedValueOnce({ data: SAMPLE_SDMX_RESPONSE }) + .mockRejectedValueOnce(new Error('timeout')) + .mockRejectedValueOnce(new Error('timeout')); + + const result = await ratesService.fetchAndStoreLatestRates(); + + expect(result).toBeTruthy(); + expect(result.source).toBe('bank_of_israel'); + // At least some tracks should have data + const tracksWithData = Object.values(result.tracks).filter((t) => t.count > 0); + expect(tracksWithData.length).toBeGreaterThan(0); + }); + }); + + describe('fetchSeriesFromBOI', () => { + it('should call BOI API with correct parameters', async () => { + axios.get.mockResolvedValueOnce({ data: SAMPLE_SDMX_RESPONSE }); + + await ratesService.fetchSeriesFromBOI( + 'BOI.STAT.MTRG.I_AVG.FXD_NI', + '2024-01', + '2024-12' + ); + + expect(axios.get).toHaveBeenCalledWith( + expect.stringContaining('BOI.STAT.MTRG.I_AVG.FXD_NI'), + expect.objectContaining({ + params: expect.objectContaining({ + startperiod: '2024-01', + endperiod: '2024-12', + format: 'sdmx-json', + }), + timeout: 15000, + }) + ); + }); + + it('should return empty array on API error', async () => { + axios.get.mockRejectedValueOnce(new Error('Connection refused')); + + const result = await ratesService.fetchSeriesFromBOI( + 'BOI.STAT.MTRG.I_AVG.FXD_NI', + '2024-01', + '2024-12' + ); + + expect(result).toEqual([]); + }); + + it('should return empty array on HTTP error response', async () => { + axios.get.mockRejectedValueOnce({ + response: { status: 404 }, + message: 'Not Found', + }); + + const result = await ratesService.fetchSeriesFromBOI( + 'BOI.STAT.MTRG.I_AVG.FXD_NI', + '2024-01', + '2024-12' + ); + + expect(result).toEqual([]); + }); + + it('should return empty array on timeout', async () => { + const timeoutError = new Error('timeout'); + timeoutError.code = 'ECONNABORTED'; + axios.get.mockRejectedValueOnce(timeoutError); + + const result = await ratesService.fetchSeriesFromBOI( + 'BOI.STAT.MTRG.I_AVG.FXD_NI', + '2024-01', + '2024-12' + ); + + expect(result).toEqual([]); + }); + }); + + describe('constants', () => { + it('should export BOI_RATE_SERIES with all 4 track types', () => { + expect(ratesService.BOI_RATE_SERIES).toHaveProperty('fixed'); + expect(ratesService.BOI_RATE_SERIES).toHaveProperty('cpi'); + expect(ratesService.BOI_RATE_SERIES).toHaveProperty('prime'); + expect(ratesService.BOI_RATE_SERIES).toHaveProperty('variable'); + }); + + it('should export TRACK_LABELS with Hebrew labels', () => { + expect(ratesService.TRACK_LABELS.fixed).toContain('קל"צ'); + expect(ratesService.TRACK_LABELS.cpi).toContain('צמוד'); + expect(ratesService.TRACK_LABELS.prime).toContain('פריים'); + expect(ratesService.TRACK_LABELS.variable).toContain('משתנה'); + }); + + it('should have CACHE_TTL_MS set to 1 hour', () => { + expect(ratesService.CACHE_TTL_MS).toBe(60 * 60 * 1000); + }); + }); +}); + +describe('collections – mortgage_rates', () => { + describe('COLLECTIONS constant', () => { + it('should include MORTGAGE_RATES', () => { + expect(COLLECTIONS.MORTGAGE_RATES).toBe('mortgage_rates'); + }); + }); + + describe('validateMortgageRatesDocument', () => { + it('should validate a correct document', () => { + const doc = { + date: '2024-12-15T00:00:00.000Z', + tracks: { fixed: { average: 4.5 } }, + averages: { fixed: 4.5 }, + source: 'bank_of_israel', + }; + + const result = validateMortgageRatesDocument(doc); + expect(result.valid).toBe(true); + expect(result.errors).toHaveLength(0); + }); + + it('should reject document without date', () => { + const doc = { + tracks: {}, + averages: {}, + source: 'bank_of_israel', + }; + + const result = validateMortgageRatesDocument(doc); + expect(result.valid).toBe(false); + expect(result.errors).toContain('date must be a non-empty string'); + }); + + it('should reject document without tracks', () => { + const doc = { + date: '2024-12-15T00:00:00.000Z', + averages: {}, + source: 'bank_of_israel', + }; + + const result = validateMortgageRatesDocument(doc); + expect(result.valid).toBe(false); + expect(result.errors).toContain('tracks must be an object'); + }); + + it('should reject document without source', () => { + const doc = { + date: '2024-12-15T00:00:00.000Z', + tracks: {}, + averages: {}, + }; + + const result = validateMortgageRatesDocument(doc); + expect(result.valid).toBe(false); + expect(result.errors).toContain('source must be a non-empty string'); + }); + + it('should flag unknown track types', () => { + const doc = { + date: '2024-12-15T00:00:00.000Z', + tracks: { fixed: {}, unknownTrack: {} }, + averages: {}, + source: 'bank_of_israel', + }; + + const result = validateMortgageRatesDocument(doc); + expect(result.valid).toBe(false); + expect(result.errors.some((e) => e.includes('unknownTrack'))).toBe(true); + }); + }); +}); diff --git a/__tests__/reportService.test.js b/__tests__/reportService.test.js new file mode 100644 index 0000000..7c43261 --- /dev/null +++ b/__tests__/reportService.test.js @@ -0,0 +1,614 @@ +/** + * Report Service Tests + * + * Tests for the enhanced OCR analysis report generation service. + * Covers comparison building, savings estimation, portfolio validation, + * rule-based report generation, and sanitization helpers. + */ + +'use strict'; + +// Mock dependencies before requiring the module +jest.mock('../src/config/firestore', () => { + const mockCollection = jest.fn(() => ({ + doc: jest.fn(() => ({ + get: jest.fn().mockResolvedValue({ exists: true, data: () => ({}) }), + set: jest.fn().mockResolvedValue(undefined), + update: jest.fn().mockResolvedValue(undefined), + })), + add: jest.fn().mockResolvedValue({ id: 'mock-id' }), + where: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockReturnThis(), + limit: jest.fn().mockReturnThis(), + get: jest.fn().mockResolvedValue({ empty: true, docs: [], size: 0 }), + })); + return { + collection: mockCollection, + batch: jest.fn(() => ({ + set: jest.fn(), + commit: jest.fn().mockResolvedValue(undefined), + })), + }; +}); + +jest.mock('../src/services/offerService', () => ({ + findByIdAndUserId: jest.fn(), + updateOffer: jest.fn().mockResolvedValue({}), +})); + +jest.mock('../src/services/ratesService', () => ({ + getCurrentAverages: jest.fn().mockResolvedValue({ + fixed: 4.65, + cpi: 3.15, + prime: 6.05, + variable: 4.95, + }), +})); + +jest.mock('../src/utils/logger', () => ({ + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), +})); + +const reportService = require('../src/services/reportService'); +const offerService = require('../src/services/offerService'); +const ratesService = require('../src/services/ratesService'); + +// ── Test Data ───────────────────────────────────────────────────────────────── + +const mockPortfolio = { + id: 'market_standard', + type: 'market_standard', + name: 'Market Standard', + nameHe: 'תיק שוק סטנדרטי', + termYears: 30, + tracks: [ + { type: 'fixed', percentage: 34, rate: 4.75, rateDisplay: '4.75%', amount: 408000 }, + { type: 'prime', percentage: 33, rate: 5.9, rateDisplay: 'P-0.15%', amount: 396000 }, + { type: 'cpi', percentage: 33, rate: 3.2, rateDisplay: '3.20% + מדד', amount: 396000 }, + ], + monthlyRepayment: 5200, + totalCost: 1872000, + totalInterest: 672000, +}; + +const mockAnalyzedOffer = { + id: 'offer-123', + userId: 'user-456', + originalFile: { url: 'https://example.com/file.pdf', mimetype: 'application/pdf' }, + extractedData: { + bank: 'בנק לאומי', + amount: 1200000, + rate: 5.2, + term: 25, + }, + analysis: { + recommendedRate: 4.5, + savings: 48000, + aiReasoning: 'Mock analysis reasoning', + }, + status: 'analyzed', + createdAt: '2025-01-01T00:00:00.000Z', + updatedAt: '2025-01-01T00:00:00.000Z', +}; + +const mockCurrentRates = { + fixed: 4.65, + cpi: 3.15, + prime: 6.05, + variable: 4.95, +}; + +// ── Tests ───────────────────────────────────────────────────────────────────── + +describe('reportService', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + // ── calculateWeightedRate ───────────────────────────────────────────────── + + describe('calculateWeightedRate', () => { + it('should calculate weighted average rate correctly', () => { + const tracks = [ + { type: 'fixed', percentage: 40, rate: 4.7 }, + { type: 'prime', percentage: 30, rate: 5.9 }, + { type: 'cpi', percentage: 30, rate: 3.2 }, + ]; + + const result = reportService.calculateWeightedRate(tracks); + // (4.7 * 0.4 + 5.9 * 0.3 + 3.2 * 0.3) = 1.88 + 1.77 + 0.96 = 4.61 + expect(result).toBeCloseTo(4.61, 1); + }); + + it('should return null for empty tracks', () => { + expect(reportService.calculateWeightedRate([])).toBeNull(); + expect(reportService.calculateWeightedRate(null)).toBeNull(); + }); + + it('should handle tracks with null rates', () => { + const tracks = [ + { type: 'fixed', percentage: 50, rate: 4.0 }, + { type: 'prime', percentage: 50, rate: null }, + ]; + const result = reportService.calculateWeightedRate(tracks); + expect(result).toBe(4.0); + }); + }); + + // ── calculatePMT ────────────────────────────────────────────────────────── + + describe('calculatePMT', () => { + it('should calculate monthly payment correctly', () => { + // ₪1,000,000 at 5% for 30 years + const monthly = reportService.calculatePMT(1000000, 0.05 / 12, 360); + expect(monthly).toBeCloseTo(5368.22, 0); + }); + + it('should handle zero interest rate', () => { + const monthly = reportService.calculatePMT(1200000, 0, 360); + expect(monthly).toBeCloseTo(3333.33, 0); + }); + + it('should return 0 for zero principal', () => { + expect(reportService.calculatePMT(0, 0.05 / 12, 360)).toBe(0); + }); + + it('should return 0 for zero months', () => { + expect(reportService.calculatePMT(1000000, 0.05 / 12, 0)).toBe(0); + }); + }); + + // ── estimateSavings ─────────────────────────────────────────────────────── + + describe('estimateSavings', () => { + it('should estimate savings when bank rate is higher', () => { + const result = reportService.estimateSavings(1200000, 5.2, 4.5, 25); + expect(result.monthly).toBeGreaterThan(0); + expect(result.total).toBeGreaterThan(0); + expect(result.interest).toBeGreaterThan(0); + }); + + it('should return zero savings when bank rate is lower', () => { + const result = reportService.estimateSavings(1200000, 4.0, 4.5, 25); + expect(result.monthly).toBe(0); + expect(result.total).toBe(0); + }); + + it('should return nulls when data is missing', () => { + const result = reportService.estimateSavings(1200000, null, 4.5, 25); + expect(result.monthly).toBeNull(); + expect(result.total).toBeNull(); + expect(result.interest).toBeNull(); + }); + + it('should return nulls when loan amount is zero', () => { + const result = reportService.estimateSavings(0, 5.0, 4.5, 25); + expect(result.monthly).toBeNull(); + }); + }); + + // ── buildComparison ─────────────────────────────────────────────────────── + + describe('buildComparison', () => { + it('should build a complete comparison object', () => { + const comparison = reportService.buildComparison( + mockAnalyzedOffer, + mockPortfolio, + mockCurrentRates + ); + + expect(comparison).toHaveProperty('bankOffer'); + expect(comparison).toHaveProperty('optimizedModel'); + expect(comparison).toHaveProperty('rateDifference'); + expect(comparison).toHaveProperty('potentialMonthlySavings'); + expect(comparison).toHaveProperty('potentialTotalSavings'); + expect(comparison).toHaveProperty('trackComparisons'); + expect(comparison).toHaveProperty('boiAverages'); + expect(comparison).toHaveProperty('verdict'); + + expect(comparison.bankOffer.bank).toBe('בנק לאומי'); + expect(comparison.bankOffer.rate).toBe(5.2); + expect(comparison.optimizedModel.name).toBe('Market Standard'); + expect(comparison.trackComparisons).toHaveLength(3); + }); + + it('should calculate positive rate difference when bank is more expensive', () => { + const comparison = reportService.buildComparison( + mockAnalyzedOffer, + mockPortfolio, + mockCurrentRates + ); + + // Bank rate (5.2) should be higher than portfolio weighted rate + expect(comparison.rateDifference).toBeGreaterThan(0); + }); + + it('should set verdict based on rate difference', () => { + const comparison = reportService.buildComparison( + mockAnalyzedOffer, + mockPortfolio, + mockCurrentRates + ); + + expect(['significantly_worse', 'slightly_worse', 'comparable', 'better_than_model']) + .toContain(comparison.verdict); + }); + + it('should handle missing OCR data gracefully', () => { + const offerWithMissingData = { + ...mockAnalyzedOffer, + extractedData: { bank: '', amount: null, rate: null, term: null }, + }; + + const comparison = reportService.buildComparison( + offerWithMissingData, + mockPortfolio, + mockCurrentRates + ); + + expect(comparison.rateDifference).toBeNull(); + expect(comparison.verdict).toBe('insufficient_data'); + }); + }); + + // ── buildTrackComparisons ───────────────────────────────────────────────── + + describe('buildTrackComparisons', () => { + it('should build comparisons for each portfolio track', () => { + const comparisons = reportService.buildTrackComparisons( + mockAnalyzedOffer, + mockPortfolio, + mockCurrentRates + ); + + expect(comparisons).toHaveLength(3); + expect(comparisons[0].trackType).toBe('fixed'); + expect(comparisons[1].trackType).toBe('prime'); + expect(comparisons[2].trackType).toBe('cpi'); + }); + + it('should include BOI comparison for each track', () => { + const comparisons = reportService.buildTrackComparisons( + mockAnalyzedOffer, + mockPortfolio, + mockCurrentRates + ); + + for (const comp of comparisons) { + expect(comp).toHaveProperty('boiAverage'); + expect(comp).toHaveProperty('vsBoi'); + expect(comp).toHaveProperty('vsBoiLabel'); + } + }); + + it('should include bank offer comparison when rate is available', () => { + const comparisons = reportService.buildTrackComparisons( + mockAnalyzedOffer, + mockPortfolio, + mockCurrentRates + ); + + for (const comp of comparisons) { + expect(comp).toHaveProperty('bankOfferRate', 5.2); + expect(comp).toHaveProperty('vsBank'); + expect(comp).toHaveProperty('vsBankLabel'); + } + }); + }); + + // ── validatePortfolio ───────────────────────────────────────────────────── + + describe('validatePortfolio', () => { + it('should accept a valid portfolio', () => { + expect(() => reportService.validatePortfolio(mockPortfolio)).not.toThrow(); + }); + + it('should reject null portfolio', () => { + expect(() => reportService.validatePortfolio(null)).toThrow('Portfolio data is required'); + }); + + it('should reject portfolio without id', () => { + const invalid = { ...mockPortfolio, id: '' }; + expect(() => reportService.validatePortfolio(invalid)).toThrow('valid id'); + }); + + it('should reject portfolio without tracks', () => { + const invalid = { ...mockPortfolio, tracks: [] }; + expect(() => reportService.validatePortfolio(invalid)).toThrow('at least one track'); + }); + + it('should reject portfolio with invalid termYears', () => { + const invalid = { ...mockPortfolio, termYears: 0 }; + expect(() => reportService.validatePortfolio(invalid)).toThrow('valid termYears'); + }); + + it('should reject portfolio with invalid monthlyRepayment', () => { + const invalid = { ...mockPortfolio, monthlyRepayment: -100 }; + expect(() => reportService.validatePortfolio(invalid)).toThrow('valid monthlyRepayment'); + }); + + it('should reject portfolio with invalid totalCost', () => { + const invalid = { ...mockPortfolio, totalCost: 0 }; + expect(() => reportService.validatePortfolio(invalid)).toThrow('valid totalCost'); + }); + + it('should reject portfolio with invalid track percentage', () => { + const invalid = { + ...mockPortfolio, + tracks: [{ type: 'fixed', percentage: 0, rate: 4.5 }], + }; + expect(() => reportService.validatePortfolio(invalid)).toThrow('valid percentage'); + }); + + it('should reject portfolio with track percentages not summing to 100', () => { + const invalid = { + ...mockPortfolio, + tracks: [ + { type: 'fixed', percentage: 30, rate: 4.5 }, + { type: 'prime', percentage: 30, rate: 5.9 }, + ], + }; + expect(() => reportService.validatePortfolio(invalid)).toThrow('sum to 100%'); + }); + }); + + // ── sanitizeTrick ───────────────────────────────────────────────────────── + + describe('sanitizeTrick', () => { + it('should sanitize a well-formed trick', () => { + const trick = { + nameHe: 'מסלול פיתיון', + nameEn: 'Enticement Track', + descriptionHe: 'תיאור בעברית', + descriptionEn: 'English description', + potentialSavings: 15000, + riskLevel: 'medium', + applicability: 'high', + }; + + const result = reportService.sanitizeTrick(trick); + expect(result).toEqual(trick); + }); + + it('should handle missing fields with defaults', () => { + const result = reportService.sanitizeTrick({}); + expect(result.nameHe).toBe(''); + expect(result.nameEn).toBe(''); + expect(result.potentialSavings).toBeNull(); + expect(result.riskLevel).toBe('medium'); + expect(result.applicability).toBe('medium'); + }); + + it('should reject invalid riskLevel values', () => { + const result = reportService.sanitizeTrick({ riskLevel: 'extreme' }); + expect(result.riskLevel).toBe('medium'); + }); + }); + + // ── sanitizeInsight ─────────────────────────────────────────────────────── + + describe('sanitizeInsight', () => { + it('should sanitize a well-formed insight', () => { + const insight = { + titleHe: 'כותרת', + titleEn: 'Title', + bodyHe: 'גוף', + bodyEn: 'Body', + icon: 'shield', + }; + + const result = reportService.sanitizeInsight(insight); + expect(result).toEqual(insight); + }); + + it('should handle missing fields with defaults', () => { + const result = reportService.sanitizeInsight({}); + expect(result.titleHe).toBe(''); + expect(result.icon).toBe('info'); + }); + }); + + // ── generateRuleBasedReport ─────────────────────────────────────────────── + + describe('generateRuleBasedReport', () => { + it('should generate a complete rule-based report', () => { + const comparison = reportService.buildComparison( + mockAnalyzedOffer, + mockPortfolio, + mockCurrentRates + ); + + const report = reportService.generateRuleBasedReport( + mockAnalyzedOffer, + mockPortfolio, + comparison, + mockCurrentRates + ); + + expect(report).toHaveProperty('tricks'); + expect(report).toHaveProperty('negotiationScript'); + expect(report).toHaveProperty('insights'); + expect(report).toHaveProperty('summary'); + expect(report).toHaveProperty('summaryHe'); + }); + + it('should always include the Enticement Track trick', () => { + const comparison = reportService.buildComparison( + mockAnalyzedOffer, + mockPortfolio, + mockCurrentRates + ); + + const report = reportService.generateRuleBasedReport( + mockAnalyzedOffer, + mockPortfolio, + comparison, + mockCurrentRates + ); + + const enticementTrick = report.tricks.find((t) => t.nameEn === 'Enticement Track'); + expect(enticementTrick).toBeDefined(); + expect(enticementTrick.nameHe).toBe('מסלול פיתיון'); + }); + + it('should generate a Hebrew negotiation script', () => { + const comparison = reportService.buildComparison( + mockAnalyzedOffer, + mockPortfolio, + mockCurrentRates + ); + + const report = reportService.generateRuleBasedReport( + mockAnalyzedOffer, + mockPortfolio, + comparison, + mockCurrentRates + ); + + expect(report.negotiationScript).toContain('שלום'); + expect(report.negotiationScript).toContain('בנק לאומי'); + expect(report.negotiationScript).toContain('בנק ישראל'); + }); + + it('should include BOI rate matching trick when bank rate is higher', () => { + const comparison = reportService.buildComparison( + mockAnalyzedOffer, + mockPortfolio, + mockCurrentRates + ); + + const report = reportService.generateRuleBasedReport( + mockAnalyzedOffer, + mockPortfolio, + comparison, + mockCurrentRates + ); + + const boiTrick = report.tricks.find((t) => t.nameEn === 'BOI Rate Matching'); + expect(boiTrick).toBeDefined(); + }); + + it('should generate insights with Hebrew and English content', () => { + const comparison = reportService.buildComparison( + mockAnalyzedOffer, + mockPortfolio, + mockCurrentRates + ); + + const report = reportService.generateRuleBasedReport( + mockAnalyzedOffer, + mockPortfolio, + comparison, + mockCurrentRates + ); + + expect(report.insights.length).toBeGreaterThanOrEqual(2); + for (const insight of report.insights) { + expect(insight).toHaveProperty('titleHe'); + expect(insight).toHaveProperty('titleEn'); + expect(insight).toHaveProperty('bodyHe'); + expect(insight).toHaveProperty('bodyEn'); + expect(insight).toHaveProperty('icon'); + } + }); + + it('should limit tricks to 4', () => { + const comparison = reportService.buildComparison( + mockAnalyzedOffer, + mockPortfolio, + mockCurrentRates + ); + + const report = reportService.generateRuleBasedReport( + mockAnalyzedOffer, + mockPortfolio, + comparison, + mockCurrentRates + ); + + expect(report.tricks.length).toBeLessThanOrEqual(4); + }); + }); + + // ── generateEnhancedReport (integration) ────────────────────────────────── + + describe('generateEnhancedReport', () => { + it('should throw 404 when offer is not found', async () => { + offerService.findByIdAndUserId.mockResolvedValue(null); + + await expect( + reportService.generateEnhancedReport('offer-123', 'user-456', mockPortfolio) + ).rejects.toThrow('Offer not found or access denied'); + }); + + it('should throw 400 when offer is not analyzed', async () => { + offerService.findByIdAndUserId.mockResolvedValue({ + ...mockAnalyzedOffer, + status: 'pending', + }); + + await expect( + reportService.generateEnhancedReport('offer-123', 'user-456', mockPortfolio) + ).rejects.toThrow('must be analyzed via OCR'); + }); + + it('should throw 400 for invalid portfolio', async () => { + offerService.findByIdAndUserId.mockResolvedValue(mockAnalyzedOffer); + + await expect( + reportService.generateEnhancedReport('offer-123', 'user-456', null) + ).rejects.toThrow('Portfolio data is required'); + }); + + it('should generate a complete report with rule-based fallback', async () => { + offerService.findByIdAndUserId.mockResolvedValue(mockAnalyzedOffer); + ratesService.getCurrentAverages.mockResolvedValue(mockCurrentRates); + + // OpenAI is not configured in tests, so it will fall back to rule-based + const report = await reportService.generateEnhancedReport( + 'offer-123', + 'user-456', + mockPortfolio + ); + + expect(report).toHaveProperty('offerId', 'offer-123'); + expect(report).toHaveProperty('portfolioId', 'market_standard'); + expect(report).toHaveProperty('comparison'); + expect(report).toHaveProperty('tricks'); + expect(report).toHaveProperty('negotiationScript'); + expect(report).toHaveProperty('insights'); + expect(report).toHaveProperty('summary'); + expect(report).toHaveProperty('summaryHe'); + expect(report).toHaveProperty('generatedAt'); + expect(report).toHaveProperty('processingTimeMs'); + + // Verify the report was stored + expect(offerService.updateOffer).toHaveBeenCalledWith( + 'offer-123', + expect.objectContaining({ + 'analysis.enhanced': expect.any(Object), + portfolioId: 'market_standard', + }) + ); + }); + + it('should still return report even if storage fails', async () => { + offerService.findByIdAndUserId.mockResolvedValue(mockAnalyzedOffer); + offerService.updateOffer.mockRejectedValue(new Error('Firestore write failed')); + ratesService.getCurrentAverages.mockResolvedValue(mockCurrentRates); + + const report = await reportService.generateEnhancedReport( + 'offer-123', + 'user-456', + mockPortfolio + ); + + // Report should still be returned despite storage failure + expect(report).toHaveProperty('offerId', 'offer-123'); + expect(report).toHaveProperty('tricks'); + }); + }); +}); diff --git a/__tests__/wizardService.test.js b/__tests__/wizardService.test.js new file mode 100644 index 0000000..6fb7af6 --- /dev/null +++ b/__tests__/wizardService.test.js @@ -0,0 +1,602 @@ +/** + * Tests for wizardService – Portfolio generation engine. + * + * Tests cover: + * - Scenario determination logic + * - PMT (amortization) calculation accuracy + * - Rule-based portfolio generation + * - Portfolio structure validation + * - Track percentage validation (must sum to 100%) + * - Conditional scenario inclusion (Inflation-Proof, Stability-First) + * - AI generation fallback + * - Edge cases + */ + +'use strict'; + +// ── Mocks ───────────────────────────────────────────────────────────────────── + +jest.mock('../src/config/firestore', () => { + const firestoreMock = { + collection: jest.fn(() => ({ + doc: jest.fn(() => ({ + get: jest.fn().mockResolvedValue({ exists: false }), + set: jest.fn().mockResolvedValue(undefined), + })), + })), + batch: jest.fn(() => ({ + set: jest.fn(), + commit: jest.fn().mockResolvedValue(undefined), + })), + }; + firestoreMock.getFirestore = () => firestoreMock; + return firestoreMock; +}); + +jest.mock('../src/utils/logger', () => ({ + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), +})); + +// Mock ratesService to return predictable rates +jest.mock('../src/services/ratesService', () => ({ + getCurrentAverages: jest.fn().mockResolvedValue({ + fixed: 4.65, + cpi: 3.15, + prime: 6.05, + variable: 4.95, + }), + getLatestRates: jest.fn().mockResolvedValue(null), + fetchAndStoreLatestRates: jest.fn().mockResolvedValue(null), + clearCache: jest.fn(), +})); + +// Mock OpenAI (not available in tests) +jest.mock('openai', () => { + return jest.fn().mockImplementation(() => ({ + chat: { + completions: { + create: jest.fn().mockRejectedValue(new Error('Mock: OpenAI not available')), + }, + }, + })); +}); + +const wizardService = require('../src/services/wizardService'); +const ratesService = require('../src/services/ratesService'); + +// ── Test Data ───────────────────────────────────────────────────────────────── + +const SAMPLE_RATES = { + fixed: 4.65, + cpi: 3.15, + prime: 6.05, + variable: 4.95, +}; + +const BASE_INPUTS = { + propertyPrice: 2000000, + loanAmount: 1500000, + monthlyIncome: 25000, + additionalIncome: 5000, + targetRepayment: 7000, + futureFunds: { timeframe: 'none', amount: 0 }, + stabilityPreference: 5, +}; + +// ── Setup ───────────────────────────────────────────────────────────────────── + +beforeEach(() => { + jest.clearAllMocks(); + ratesService.getCurrentAverages.mockResolvedValue(SAMPLE_RATES); +}); + +// ── Tests ───────────────────────────────────────────────────────────────────── + +describe('wizardService', () => { + describe('calculatePMT', () => { + it('should calculate correct monthly payment for a standard loan', () => { + // ₪1,000,000 at 5% annual for 30 years + const principal = 1000000; + const monthlyRate = 0.05 / 12; + const totalMonths = 30 * 12; + + const payment = wizardService.calculatePMT(principal, monthlyRate, totalMonths); + + // Expected: ~₪5,368.22 (standard PMT result) + expect(payment).toBeCloseTo(5368.22, 0); + }); + + it('should return 0 for zero principal', () => { + expect(wizardService.calculatePMT(0, 0.004, 360)).toBe(0); + }); + + it('should handle zero interest rate', () => { + // 0% interest = simple division + const payment = wizardService.calculatePMT(360000, 0, 360); + expect(payment).toBe(1000); + }); + + it('should return 0 for zero months', () => { + expect(wizardService.calculatePMT(100000, 0.004, 0)).toBe(0); + }); + + it('should calculate higher payment for shorter term', () => { + const principal = 1000000; + const monthlyRate = 0.05 / 12; + + const payment30 = wizardService.calculatePMT(principal, monthlyRate, 360); + const payment20 = wizardService.calculatePMT(principal, monthlyRate, 240); + + expect(payment20).toBeGreaterThan(payment30); + }); + + it('should calculate lower total cost for shorter term', () => { + const principal = 1000000; + const monthlyRate = 0.05 / 12; + + const payment30 = wizardService.calculatePMT(principal, monthlyRate, 360); + const payment20 = wizardService.calculatePMT(principal, monthlyRate, 240); + + const totalCost30 = payment30 * 360; + const totalCost20 = payment20 * 240; + + expect(totalCost20).toBeLessThan(totalCost30); + }); + }); + + describe('determineScenarios', () => { + it('should always include Market Standard and Fast Track', () => { + const scenarios = wizardService.determineScenarios(BASE_INPUTS, SAMPLE_RATES); + + expect(scenarios).toContain(wizardService.SCENARIO_TYPES.MARKET_STANDARD); + expect(scenarios).toContain(wizardService.SCENARIO_TYPES.FAST_TRACK); + }); + + it('should include Inflation-Proof when CPI rate is high', () => { + const highCpiRates = { ...SAMPLE_RATES, cpi: 3.5 }; + const inputs = { ...BASE_INPUTS, stabilityPreference: 2 }; + + const scenarios = wizardService.determineScenarios(inputs, highCpiRates); + + expect(scenarios).toContain(wizardService.SCENARIO_TYPES.INFLATION_PROOF); + }); + + it('should include Inflation-Proof when stability preference is moderate (4-8)', () => { + const inputs = { ...BASE_INPUTS, stabilityPreference: 5 }; + + const scenarios = wizardService.determineScenarios(inputs, SAMPLE_RATES); + + expect(scenarios).toContain(wizardService.SCENARIO_TYPES.INFLATION_PROOF); + }); + + it('should NOT include Inflation-Proof when CPI is low and stability is low', () => { + const lowCpiRates = { ...SAMPLE_RATES, cpi: 2.0 }; + const inputs = { ...BASE_INPUTS, stabilityPreference: 2 }; + + const scenarios = wizardService.determineScenarios(inputs, lowCpiRates); + + expect(scenarios).not.toContain(wizardService.SCENARIO_TYPES.INFLATION_PROOF); + }); + + it('should include Stability-First when stabilityPreference >= 7', () => { + const inputs = { ...BASE_INPUTS, stabilityPreference: 7 }; + + const scenarios = wizardService.determineScenarios(inputs, SAMPLE_RATES); + + expect(scenarios).toContain(wizardService.SCENARIO_TYPES.STABILITY_FIRST); + }); + + it('should NOT include Stability-First when stabilityPreference < 7', () => { + const inputs = { ...BASE_INPUTS, stabilityPreference: 6 }; + + const scenarios = wizardService.determineScenarios(inputs, SAMPLE_RATES); + + expect(scenarios).not.toContain(wizardService.SCENARIO_TYPES.STABILITY_FIRST); + }); + + it('should generate all 4 scenarios for high stability + high CPI', () => { + const highCpiRates = { ...SAMPLE_RATES, cpi: 3.5 }; + const inputs = { ...BASE_INPUTS, stabilityPreference: 8 }; + + const scenarios = wizardService.determineScenarios(inputs, highCpiRates); + + expect(scenarios).toHaveLength(4); + expect(scenarios).toContain(wizardService.SCENARIO_TYPES.MARKET_STANDARD); + expect(scenarios).toContain(wizardService.SCENARIO_TYPES.FAST_TRACK); + expect(scenarios).toContain(wizardService.SCENARIO_TYPES.INFLATION_PROOF); + expect(scenarios).toContain(wizardService.SCENARIO_TYPES.STABILITY_FIRST); + }); + + it('should generate only 2 scenarios for low stability + low CPI', () => { + const lowCpiRates = { ...SAMPLE_RATES, cpi: 2.0 }; + const inputs = { ...BASE_INPUTS, stabilityPreference: 2 }; + + const scenarios = wizardService.determineScenarios(inputs, lowCpiRates); + + expect(scenarios).toHaveLength(2); + }); + }); + + describe('getRuleBasedConfig', () => { + it('should return 30-year term for Market Standard', () => { + const config = wizardService.getRuleBasedConfig( + wizardService.SCENARIO_TYPES.MARKET_STANDARD, + BASE_INPUTS, + SAMPLE_RATES + ); + + expect(config.termYears).toBe(30); + expect(config.tracks).toHaveLength(3); + }); + + it('should return 20-year term for Fast Track', () => { + const config = wizardService.getRuleBasedConfig( + wizardService.SCENARIO_TYPES.FAST_TRACK, + BASE_INPUTS, + SAMPLE_RATES + ); + + expect(config.termYears).toBe(20); + }); + + it('should have no CPI tracks in Inflation-Proof', () => { + const config = wizardService.getRuleBasedConfig( + wizardService.SCENARIO_TYPES.INFLATION_PROOF, + BASE_INPUTS, + SAMPLE_RATES + ); + + const hasCpi = config.tracks.some((t) => t.type === 'cpi'); + expect(hasCpi).toBe(false); + }); + + it('should have >= 60% fixed in Stability-First', () => { + const config = wizardService.getRuleBasedConfig( + wizardService.SCENARIO_TYPES.STABILITY_FIRST, + BASE_INPUTS, + SAMPLE_RATES + ); + + const fixedPct = config.tracks + .filter((t) => t.type === 'fixed') + .reduce((sum, t) => sum + t.percentage, 0); + + expect(fixedPct).toBeGreaterThanOrEqual(60); + }); + + it('should have track percentages summing to 100% for all scenarios', () => { + const types = Object.values(wizardService.SCENARIO_TYPES); + + for (const type of types) { + const config = wizardService.getRuleBasedConfig(type, BASE_INPUTS, SAMPLE_RATES); + const totalPct = config.tracks.reduce((sum, t) => sum + t.percentage, 0); + expect(totalPct).toBe(100); + } + }); + }); + + describe('buildPortfolio', () => { + it('should build a complete portfolio with all required fields', () => { + const config = wizardService.getRuleBasedConfig( + wizardService.SCENARIO_TYPES.MARKET_STANDARD, + BASE_INPUTS, + SAMPLE_RATES + ); + + const portfolio = wizardService.buildPortfolio( + config, + wizardService.SCENARIO_TYPES.MARKET_STANDARD, + BASE_INPUTS, + SAMPLE_RATES + ); + + expect(portfolio).toHaveProperty('id', 'market_standard'); + expect(portfolio).toHaveProperty('type', 'market_standard'); + expect(portfolio).toHaveProperty('name', 'Market Standard'); + expect(portfolio).toHaveProperty('nameHe', 'תיק שוק סטנדרטי'); + expect(portfolio).toHaveProperty('description'); + expect(portfolio).toHaveProperty('termYears', 30); + expect(portfolio).toHaveProperty('tracks'); + expect(portfolio).toHaveProperty('monthlyRepayment'); + expect(portfolio).toHaveProperty('totalCost'); + expect(portfolio).toHaveProperty('totalInterest'); + expect(portfolio).toHaveProperty('interestSavings'); + expect(portfolio).toHaveProperty('recommended'); + }); + + it('should calculate positive monthly repayment', () => { + const config = wizardService.getRuleBasedConfig( + wizardService.SCENARIO_TYPES.MARKET_STANDARD, + BASE_INPUTS, + SAMPLE_RATES + ); + + const portfolio = wizardService.buildPortfolio( + config, + wizardService.SCENARIO_TYPES.MARKET_STANDARD, + BASE_INPUTS, + SAMPLE_RATES + ); + + expect(portfolio.monthlyRepayment).toBeGreaterThan(0); + }); + + it('should have totalCost > loanAmount (interest adds up)', () => { + const config = wizardService.getRuleBasedConfig( + wizardService.SCENARIO_TYPES.MARKET_STANDARD, + BASE_INPUTS, + SAMPLE_RATES + ); + + const portfolio = wizardService.buildPortfolio( + config, + wizardService.SCENARIO_TYPES.MARKET_STANDARD, + BASE_INPUTS, + SAMPLE_RATES + ); + + expect(portfolio.totalCost).toBeGreaterThan(BASE_INPUTS.loanAmount); + }); + + it('should have totalInterest = totalCost - loanAmount', () => { + const config = wizardService.getRuleBasedConfig( + wizardService.SCENARIO_TYPES.MARKET_STANDARD, + BASE_INPUTS, + SAMPLE_RATES + ); + + const portfolio = wizardService.buildPortfolio( + config, + wizardService.SCENARIO_TYPES.MARKET_STANDARD, + BASE_INPUTS, + SAMPLE_RATES + ); + + expect(portfolio.totalInterest).toBe(portfolio.totalCost - BASE_INPUTS.loanAmount); + }); + + it('should mark Market Standard as recommended', () => { + const config = wizardService.getRuleBasedConfig( + wizardService.SCENARIO_TYPES.MARKET_STANDARD, + BASE_INPUTS, + SAMPLE_RATES + ); + + const portfolio = wizardService.buildPortfolio( + config, + wizardService.SCENARIO_TYPES.MARKET_STANDARD, + BASE_INPUTS, + SAMPLE_RATES + ); + + expect(portfolio.recommended).toBe(true); + }); + + it('should NOT mark Fast Track as recommended', () => { + const config = wizardService.getRuleBasedConfig( + wizardService.SCENARIO_TYPES.FAST_TRACK, + BASE_INPUTS, + SAMPLE_RATES + ); + + const portfolio = wizardService.buildPortfolio( + config, + wizardService.SCENARIO_TYPES.FAST_TRACK, + BASE_INPUTS, + SAMPLE_RATES + ); + + expect(portfolio.recommended).toBe(false); + }); + + it('should calculate interest savings for non-Market-Standard scenarios', () => { + const config = wizardService.getRuleBasedConfig( + wizardService.SCENARIO_TYPES.FAST_TRACK, + BASE_INPUTS, + SAMPLE_RATES + ); + + const portfolio = wizardService.buildPortfolio( + config, + wizardService.SCENARIO_TYPES.FAST_TRACK, + BASE_INPUTS, + SAMPLE_RATES + ); + + // Fast Track (20yr) should save interest vs 30yr baseline + expect(portfolio.interestSavings).toBeGreaterThan(0); + }); + + it('should have enriched track objects with all fields', () => { + const config = wizardService.getRuleBasedConfig( + wizardService.SCENARIO_TYPES.MARKET_STANDARD, + BASE_INPUTS, + SAMPLE_RATES + ); + + const portfolio = wizardService.buildPortfolio( + config, + wizardService.SCENARIO_TYPES.MARKET_STANDARD, + BASE_INPUTS, + SAMPLE_RATES + ); + + for (const track of portfolio.tracks) { + expect(track).toHaveProperty('name'); + expect(track).toHaveProperty('nameEn'); + expect(track).toHaveProperty('type'); + expect(track).toHaveProperty('percentage'); + expect(track).toHaveProperty('rate'); + expect(track).toHaveProperty('rateDisplay'); + expect(track).toHaveProperty('amount'); + expect(track).toHaveProperty('monthlyPayment'); + expect(track).toHaveProperty('totalCost'); + expect(track).toHaveProperty('totalInterest'); + } + }); + + it('should have track amounts summing to loan amount', () => { + const config = wizardService.getRuleBasedConfig( + wizardService.SCENARIO_TYPES.MARKET_STANDARD, + BASE_INPUTS, + SAMPLE_RATES + ); + + const portfolio = wizardService.buildPortfolio( + config, + wizardService.SCENARIO_TYPES.MARKET_STANDARD, + BASE_INPUTS, + SAMPLE_RATES + ); + + const totalAmount = portfolio.tracks.reduce((sum, t) => sum + t.amount, 0); + // Allow ±1 rounding error + expect(Math.abs(totalAmount - BASE_INPUTS.loanAmount)).toBeLessThanOrEqual(1); + }); + }); + + describe('generateRuleBased', () => { + it('should generate correct number of portfolios', () => { + const scenarios = [ + wizardService.SCENARIO_TYPES.MARKET_STANDARD, + wizardService.SCENARIO_TYPES.FAST_TRACK, + ]; + + const portfolios = wizardService.generateRuleBased(BASE_INPUTS, SAMPLE_RATES, scenarios); + + expect(portfolios).toHaveLength(2); + }); + + it('should generate all 4 portfolios when all scenarios requested', () => { + const scenarios = Object.values(wizardService.SCENARIO_TYPES); + + const portfolios = wizardService.generateRuleBased(BASE_INPUTS, SAMPLE_RATES, scenarios); + + expect(portfolios).toHaveLength(4); + }); + + it('should set _generationMethod to rule_based', () => { + const scenarios = [wizardService.SCENARIO_TYPES.MARKET_STANDARD]; + + const portfolios = wizardService.generateRuleBased(BASE_INPUTS, SAMPLE_RATES, scenarios); + + expect(portfolios[0]._generationMethod).toBe('rule_based'); + }); + + it('should produce Fast Track with higher monthly payment but lower total cost than Market Standard', () => { + const scenarios = [ + wizardService.SCENARIO_TYPES.MARKET_STANDARD, + wizardService.SCENARIO_TYPES.FAST_TRACK, + ]; + + const portfolios = wizardService.generateRuleBased(BASE_INPUTS, SAMPLE_RATES, scenarios); + const market = portfolios.find((p) => p.type === 'market_standard'); + const fast = portfolios.find((p) => p.type === 'fast_track'); + + expect(fast.monthlyRepayment).toBeGreaterThan(market.monthlyRepayment); + expect(fast.totalCost).toBeLessThan(market.totalCost); + }); + }); + + describe('generatePortfolios (integration)', () => { + it('should generate portfolios with metadata', async () => { + const result = await wizardService.generatePortfolios(BASE_INPUTS, true); + + expect(result).toHaveProperty('portfolios'); + expect(result).toHaveProperty('metadata'); + expect(result.portfolios.length).toBeGreaterThanOrEqual(2); + expect(result.portfolios.length).toBeLessThanOrEqual(4); + }); + + it('should include metadata with correct fields', async () => { + const result = await wizardService.generatePortfolios(BASE_INPUTS, true); + + expect(result.metadata).toHaveProperty('generatedAt'); + expect(result.metadata).toHaveProperty('ratesSource'); + expect(result.metadata).toHaveProperty('generationMethod'); + expect(result.metadata).toHaveProperty('processingTimeMs'); + expect(result.metadata).toHaveProperty('inputSummary'); + expect(result.metadata).toHaveProperty('consent', true); + }); + + it('should strip _generationMethod from returned portfolios', async () => { + const result = await wizardService.generatePortfolios(BASE_INPUTS, false); + + for (const portfolio of result.portfolios) { + expect(portfolio).not.toHaveProperty('_generationMethod'); + } + }); + + it('should include inputSummary with LTV calculation', async () => { + const result = await wizardService.generatePortfolios(BASE_INPUTS, true); + + expect(result.metadata.inputSummary.ltv).toBe(75); // 1500000/2000000 * 100 + }); + + it('should fall back to rule-based when rates service fails', async () => { + ratesService.getCurrentAverages.mockRejectedValueOnce(new Error('Firestore down')); + + const result = await wizardService.generatePortfolios(BASE_INPUTS, true); + + expect(result.portfolios.length).toBeGreaterThanOrEqual(2); + }); + + it('should generate 4 portfolios for high stability preference', async () => { + const inputs = { ...BASE_INPUTS, stabilityPreference: 8 }; + + const result = await wizardService.generatePortfolios(inputs, true); + + expect(result.portfolios).toHaveLength(4); + }); + + it('should generate 2 portfolios for low stability + low CPI', async () => { + ratesService.getCurrentAverages.mockResolvedValueOnce({ + ...SAMPLE_RATES, + cpi: 2.0, + }); + const inputs = { ...BASE_INPUTS, stabilityPreference: 2 }; + + const result = await wizardService.generatePortfolios(inputs, false); + + expect(result.portfolios).toHaveLength(2); + }); + }); + + describe('constants', () => { + it('should export all 4 scenario types', () => { + expect(wizardService.SCENARIO_TYPES).toHaveProperty('MARKET_STANDARD'); + expect(wizardService.SCENARIO_TYPES).toHaveProperty('FAST_TRACK'); + expect(wizardService.SCENARIO_TYPES).toHaveProperty('INFLATION_PROOF'); + expect(wizardService.SCENARIO_TYPES).toHaveProperty('STABILITY_FIRST'); + }); + + it('should have Hebrew names for all scenarios', () => { + for (const type of Object.values(wizardService.SCENARIO_TYPES)) { + expect(wizardService.SCENARIO_NAMES_HE[type]).toBeTruthy(); + } + }); + + it('should have English names for all scenarios', () => { + for (const type of Object.values(wizardService.SCENARIO_TYPES)) { + expect(wizardService.SCENARIO_NAMES_EN[type]).toBeTruthy(); + } + }); + + it('should have descriptions for all scenarios', () => { + for (const type of Object.values(wizardService.SCENARIO_TYPES)) { + expect(wizardService.SCENARIO_DESCRIPTIONS[type]).toBeTruthy(); + } + }); + + it('should have STABILITY_THRESHOLD set to 7', () => { + expect(wizardService.STABILITY_THRESHOLD).toBe(7); + }); + + it('should have CPI_RATE_THRESHOLD set to 2.5', () => { + expect(wizardService.CPI_RATE_THRESHOLD).toBe(2.5); + }); + }); +}); diff --git a/__tests__/wizardSubmit.test.js b/__tests__/wizardSubmit.test.js new file mode 100644 index 0000000..2dde743 --- /dev/null +++ b/__tests__/wizardSubmit.test.js @@ -0,0 +1,380 @@ +/** + * Integration tests for POST /api/v1/public/wizard/submit + * + * Tests the wizard submit endpoint using supertest against the Express app. + * The Firestore and external services are mocked. + */ + +'use strict'; + +const request = require('supertest'); + +// ── Environment setup ───────────────────────────────────────────────────────── +process.env.JWT_SECRET = 'test-jwt-secret-for-testing-only-32chars'; +process.env.JWT_REFRESH_SECRET = 'test-refresh-secret-for-testing-only-32chars'; +process.env.NODE_ENV = 'test'; + +// ── Mock Firestore ──────────────────────────────────────────────────────────── +jest.mock('../src/config/firestore', () => { + const firestoreMock = { + collection: jest.fn(() => ({ + doc: jest.fn(() => ({ + get: jest.fn().mockResolvedValue({ exists: false }), + set: jest.fn().mockResolvedValue(undefined), + })), + where: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockReturnThis(), + limit: jest.fn().mockReturnThis(), + get: jest.fn().mockResolvedValue({ docs: [] }), + })), + batch: jest.fn(() => ({ + set: jest.fn(), + commit: jest.fn().mockResolvedValue(undefined), + })), + }; + firestoreMock.getFirestore = () => firestoreMock; + return firestoreMock; +}); + +jest.mock('../src/config/firebase', () => ({ + admin: { + auth: () => ({ + verifyIdToken: jest.fn(), + }), + }, + db: { collection: jest.fn() }, + firebaseApp: {}, +})); + +jest.mock('../src/services/userService', () => ({ + createUser: jest.fn(), + findByEmail: jest.fn(), + findById: jest.fn(), + getUserById: jest.fn(), + findOrCreateByFirebaseUser: jest.fn(), + setRefreshToken: jest.fn().mockResolvedValue(undefined), + clearRefreshToken: jest.fn().mockResolvedValue(undefined), + clearRefreshTokenByValue: jest.fn().mockResolvedValue(undefined), + verifyPassword: jest.fn(), + toPublicUser: jest.fn(), +})); + +// Mock ratesService to return predictable rates +jest.mock('../src/services/ratesService', () => ({ + getCurrentAverages: jest.fn().mockResolvedValue({ + fixed: 4.65, + cpi: 3.15, + prime: 6.05, + variable: 4.95, + }), + getLatestRates: jest.fn().mockResolvedValue(null), + fetchAndStoreLatestRates: jest.fn().mockResolvedValue(null), + clearCache: jest.fn(), +})); + +const app = require('../src/index'); + +// ── Test Data ───────────────────────────────────────────────────────────────── + +const VALID_PAYLOAD = { + inputs: { + propertyPrice: 2000000, + loanAmount: 1500000, + monthlyIncome: 25000, + additionalIncome: 5000, + targetRepayment: 7000, + futureFunds: { timeframe: 'none', amount: 0 }, + stabilityPreference: 5, + }, + consent: true, +}; + +// ── Tests ───────────────────────────────────────────────────────────────────── + +describe('POST /api/v1/public/wizard/submit', () => { + describe('validation', () => { + it('should reject empty body (422)', async () => { + const res = await request(app) + .post('/api/v1/public/wizard/submit') + .send({}); + + expect(res.status).toBe(422); + expect(res.body.success).toBe(false); + expect(res.body.error.code).toBe('VALIDATION_ERROR'); + }); + + it('should reject missing inputs (422)', async () => { + const res = await request(app) + .post('/api/v1/public/wizard/submit') + .send({ consent: true }); + + expect(res.status).toBe(422); + expect(res.body.success).toBe(false); + }); + + it('should reject missing consent (422)', async () => { + const res = await request(app) + .post('/api/v1/public/wizard/submit') + .send({ inputs: VALID_PAYLOAD.inputs }); + + expect(res.status).toBe(422); + expect(res.body.success).toBe(false); + }); + + it('should reject propertyPrice below minimum (422)', async () => { + const res = await request(app) + .post('/api/v1/public/wizard/submit') + .send({ + ...VALID_PAYLOAD, + inputs: { ...VALID_PAYLOAD.inputs, propertyPrice: 50000 }, + }); + + expect(res.status).toBe(422); + }); + + it('should reject loanAmount below minimum (422)', async () => { + const res = await request(app) + .post('/api/v1/public/wizard/submit') + .send({ + ...VALID_PAYLOAD, + inputs: { ...VALID_PAYLOAD.inputs, loanAmount: 10000 }, + }); + + expect(res.status).toBe(422); + }); + + it('should reject stabilityPreference outside 1-10 range (422)', async () => { + const res = await request(app) + .post('/api/v1/public/wizard/submit') + .send({ + ...VALID_PAYLOAD, + inputs: { ...VALID_PAYLOAD.inputs, stabilityPreference: 11 }, + }); + + expect(res.status).toBe(422); + }); + + it('should reject non-integer stabilityPreference (422)', async () => { + const res = await request(app) + .post('/api/v1/public/wizard/submit') + .send({ + ...VALID_PAYLOAD, + inputs: { ...VALID_PAYLOAD.inputs, stabilityPreference: 5.5 }, + }); + + expect(res.status).toBe(422); + }); + + it('should reject invalid futureFunds timeframe (422)', async () => { + const res = await request(app) + .post('/api/v1/public/wizard/submit') + .send({ + ...VALID_PAYLOAD, + inputs: { + ...VALID_PAYLOAD.inputs, + futureFunds: { timeframe: 'invalid_value', amount: 0 }, + }, + }); + + expect(res.status).toBe(422); + }); + + it('should reject loanAmount exceeding propertyPrice (422)', async () => { + const res = await request(app) + .post('/api/v1/public/wizard/submit') + .send({ + ...VALID_PAYLOAD, + inputs: { + ...VALID_PAYLOAD.inputs, + propertyPrice: 1000000, + loanAmount: 1500000, + }, + }); + + expect(res.status).toBe(422); + expect(res.body.error.code).toBe('BUSINESS_VALIDATION_ERROR'); + }); + + it('should reject target repayment exceeding 80% of income (422)', async () => { + const res = await request(app) + .post('/api/v1/public/wizard/submit') + .send({ + ...VALID_PAYLOAD, + inputs: { + ...VALID_PAYLOAD.inputs, + monthlyIncome: 10000, + additionalIncome: 0, + targetRepayment: 9000, // 90% of income + }, + }); + + expect(res.status).toBe(422); + expect(res.body.error.code).toBe('BUSINESS_VALIDATION_ERROR'); + }); + }); + + describe('successful submission', () => { + it('should return 200 with portfolios for valid input', async () => { + const res = await request(app) + .post('/api/v1/public/wizard/submit') + .send(VALID_PAYLOAD); + + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + expect(res.body.data).toHaveProperty('portfolios'); + expect(res.body.data).toHaveProperty('communityTips'); + expect(res.body.data).toHaveProperty('metadata'); + }); + + it('should return at least 2 portfolios', async () => { + const res = await request(app) + .post('/api/v1/public/wizard/submit') + .send(VALID_PAYLOAD); + + expect(res.body.data.portfolios.length).toBeGreaterThanOrEqual(2); + }); + + it('should return at most 4 portfolios', async () => { + const res = await request(app) + .post('/api/v1/public/wizard/submit') + .send({ + ...VALID_PAYLOAD, + inputs: { ...VALID_PAYLOAD.inputs, stabilityPreference: 8 }, + }); + + expect(res.body.data.portfolios.length).toBeLessThanOrEqual(4); + }); + + it('should always include Market Standard and Fast Track', async () => { + const res = await request(app) + .post('/api/v1/public/wizard/submit') + .send(VALID_PAYLOAD); + + const types = res.body.data.portfolios.map((p) => p.type); + expect(types).toContain('market_standard'); + expect(types).toContain('fast_track'); + }); + + it('should include Stability-First for high stability preference', async () => { + const res = await request(app) + .post('/api/v1/public/wizard/submit') + .send({ + ...VALID_PAYLOAD, + inputs: { ...VALID_PAYLOAD.inputs, stabilityPreference: 9 }, + }); + + const types = res.body.data.portfolios.map((p) => p.type); + expect(types).toContain('stability_first'); + }); + + it('should return portfolios with correct structure', async () => { + const res = await request(app) + .post('/api/v1/public/wizard/submit') + .send(VALID_PAYLOAD); + + const portfolio = res.body.data.portfolios[0]; + + expect(portfolio).toHaveProperty('id'); + expect(portfolio).toHaveProperty('type'); + expect(portfolio).toHaveProperty('name'); + expect(portfolio).toHaveProperty('nameHe'); + expect(portfolio).toHaveProperty('description'); + expect(portfolio).toHaveProperty('termYears'); + expect(portfolio).toHaveProperty('tracks'); + expect(portfolio).toHaveProperty('monthlyRepayment'); + expect(portfolio).toHaveProperty('totalCost'); + expect(portfolio).toHaveProperty('totalInterest'); + expect(portfolio).toHaveProperty('interestSavings'); + expect(portfolio).toHaveProperty('recommended'); + }); + + it('should return tracks with correct structure', async () => { + const res = await request(app) + .post('/api/v1/public/wizard/submit') + .send(VALID_PAYLOAD); + + const track = res.body.data.portfolios[0].tracks[0]; + + expect(track).toHaveProperty('name'); + expect(track).toHaveProperty('nameEn'); + expect(track).toHaveProperty('type'); + expect(track).toHaveProperty('percentage'); + expect(track).toHaveProperty('rate'); + expect(track).toHaveProperty('rateDisplay'); + expect(track).toHaveProperty('amount'); + expect(track).toHaveProperty('monthlyPayment'); + expect(track).toHaveProperty('totalCost'); + expect(track).toHaveProperty('totalInterest'); + }); + + it('should return metadata with input summary', async () => { + const res = await request(app) + .post('/api/v1/public/wizard/submit') + .send(VALID_PAYLOAD); + + const meta = res.body.data.metadata; + + expect(meta).toHaveProperty('generatedAt'); + expect(meta).toHaveProperty('ratesSource'); + expect(meta).toHaveProperty('generationMethod'); + expect(meta).toHaveProperty('processingTimeMs'); + expect(meta.inputSummary).toHaveProperty('propertyPrice', 2000000); + expect(meta.inputSummary).toHaveProperty('loanAmount', 1500000); + expect(meta.inputSummary).toHaveProperty('ltv', 75); + }); + + it('should accept consent=false', async () => { + const res = await request(app) + .post('/api/v1/public/wizard/submit') + .send({ ...VALID_PAYLOAD, consent: false }); + + expect(res.status).toBe(200); + expect(res.body.data.metadata.consent).toBe(false); + }); + + it('should accept additionalIncome as optional (defaults to 0)', async () => { + const inputsWithoutAdditional = { ...VALID_PAYLOAD.inputs }; + delete inputsWithoutAdditional.additionalIncome; + + const res = await request(app) + .post('/api/v1/public/wizard/submit') + .send({ inputs: inputsWithoutAdditional, consent: true }); + + expect(res.status).toBe(200); + }); + + it('should accept futureFunds with amount when timeframe is not none', async () => { + const res = await request(app) + .post('/api/v1/public/wizard/submit') + .send({ + ...VALID_PAYLOAD, + inputs: { + ...VALID_PAYLOAD.inputs, + futureFunds: { timeframe: 'within_5_years', amount: 200000 }, + }, + }); + + expect(res.status).toBe(200); + }); + + it('should not include _generationMethod in portfolio response', async () => { + const res = await request(app) + .post('/api/v1/public/wizard/submit') + .send(VALID_PAYLOAD); + + for (const portfolio of res.body.data.portfolios) { + expect(portfolio).not.toHaveProperty('_generationMethod'); + } + }); + }); + + describe('communityTips', () => { + it('should return empty communityTips array (placeholder for future task)', async () => { + const res = await request(app) + .post('/api/v1/public/wizard/submit') + .send(VALID_PAYLOAD); + + expect(res.body.data.communityTips).toEqual([]); + }); + }); +}); diff --git a/__tests__/wizardValidator.test.js b/__tests__/wizardValidator.test.js new file mode 100644 index 0000000..3094752 --- /dev/null +++ b/__tests__/wizardValidator.test.js @@ -0,0 +1,236 @@ +/** + * Tests for wizardValidator – Joi schema and business rule validation. + */ + +'use strict'; + +const { wizardSubmitSchema, validateBusinessRules } = require('../src/validators/wizardValidator'); + +// ── Test Data ───────────────────────────────────────────────────────────────── + +const VALID_BODY = { + inputs: { + propertyPrice: 2000000, + loanAmount: 1500000, + monthlyIncome: 25000, + additionalIncome: 5000, + targetRepayment: 7000, + futureFunds: { timeframe: 'none', amount: 0 }, + stabilityPreference: 5, + }, + consent: true, +}; + +// ── Tests ───────────────────────────────────────────────────────────────────── + +describe('wizardSubmitSchema', () => { + it('should validate a correct payload', () => { + const { error } = wizardSubmitSchema.validate(VALID_BODY); + expect(error).toBeUndefined(); + }); + + it('should reject missing inputs', () => { + const { error } = wizardSubmitSchema.validate({ consent: true }); + expect(error).toBeTruthy(); + }); + + it('should reject missing consent', () => { + const { error } = wizardSubmitSchema.validate({ inputs: VALID_BODY.inputs }); + expect(error).toBeTruthy(); + }); + + it('should reject propertyPrice below 100000', () => { + const body = { + ...VALID_BODY, + inputs: { ...VALID_BODY.inputs, propertyPrice: 50000 }, + }; + const { error } = wizardSubmitSchema.validate(body); + expect(error).toBeTruthy(); + expect(error.details[0].path).toContain('propertyPrice'); + }); + + it('should reject propertyPrice above 50000000', () => { + const body = { + ...VALID_BODY, + inputs: { ...VALID_BODY.inputs, propertyPrice: 60000000 }, + }; + const { error } = wizardSubmitSchema.validate(body); + expect(error).toBeTruthy(); + }); + + it('should reject loanAmount below 50000', () => { + const body = { + ...VALID_BODY, + inputs: { ...VALID_BODY.inputs, loanAmount: 10000 }, + }; + const { error } = wizardSubmitSchema.validate(body); + expect(error).toBeTruthy(); + }); + + it('should reject monthlyIncome below 1000', () => { + const body = { + ...VALID_BODY, + inputs: { ...VALID_BODY.inputs, monthlyIncome: 500 }, + }; + const { error } = wizardSubmitSchema.validate(body); + expect(error).toBeTruthy(); + }); + + it('should reject targetRepayment below 500', () => { + const body = { + ...VALID_BODY, + inputs: { ...VALID_BODY.inputs, targetRepayment: 100 }, + }; + const { error } = wizardSubmitSchema.validate(body); + expect(error).toBeTruthy(); + }); + + it('should reject stabilityPreference below 1', () => { + const body = { + ...VALID_BODY, + inputs: { ...VALID_BODY.inputs, stabilityPreference: 0 }, + }; + const { error } = wizardSubmitSchema.validate(body); + expect(error).toBeTruthy(); + }); + + it('should reject stabilityPreference above 10', () => { + const body = { + ...VALID_BODY, + inputs: { ...VALID_BODY.inputs, stabilityPreference: 11 }, + }; + const { error } = wizardSubmitSchema.validate(body); + expect(error).toBeTruthy(); + }); + + it('should reject non-integer stabilityPreference', () => { + const body = { + ...VALID_BODY, + inputs: { ...VALID_BODY.inputs, stabilityPreference: 5.5 }, + }; + const { error } = wizardSubmitSchema.validate(body); + expect(error).toBeTruthy(); + }); + + it('should reject invalid futureFunds timeframe', () => { + const body = { + ...VALID_BODY, + inputs: { + ...VALID_BODY.inputs, + futureFunds: { timeframe: 'tomorrow', amount: 0 }, + }, + }; + const { error } = wizardSubmitSchema.validate(body); + expect(error).toBeTruthy(); + }); + + it('should accept all valid futureFunds timeframes', () => { + const timeframes = ['none', 'within_5_years', 'within_10_years', 'over_10_years']; + + for (const timeframe of timeframes) { + const body = { + ...VALID_BODY, + inputs: { + ...VALID_BODY.inputs, + futureFunds: { timeframe, amount: 100000 }, + }, + }; + const { error } = wizardSubmitSchema.validate(body); + expect(error).toBeUndefined(); + } + }); + + it('should default additionalIncome to 0 when not provided', () => { + const inputs = { ...VALID_BODY.inputs }; + delete inputs.additionalIncome; + const body = { inputs, consent: true }; + + const { error, value } = wizardSubmitSchema.validate(body); + expect(error).toBeUndefined(); + expect(value.inputs.additionalIncome).toBe(0); + }); + + it('should strip unknown fields', () => { + const body = { + ...VALID_BODY, + inputs: { ...VALID_BODY.inputs, unknownField: 'test' }, + }; + const { error, value } = wizardSubmitSchema.validate(body, { + stripUnknown: true, + allowUnknown: false, + }); + // Joi with stripUnknown should remove unknown fields + expect(value.inputs).not.toHaveProperty('unknownField'); + }); + + it('should reject consent as non-boolean', () => { + const body = { ...VALID_BODY, consent: 'yes' }; + const { error } = wizardSubmitSchema.validate(body); + expect(error).toBeTruthy(); + }); + + it('should accept consent=false', () => { + const body = { ...VALID_BODY, consent: false }; + const { error } = wizardSubmitSchema.validate(body); + expect(error).toBeUndefined(); + }); +}); + +describe('validateBusinessRules', () => { + it('should pass for valid inputs', () => { + const result = validateBusinessRules(VALID_BODY.inputs); + expect(result.valid).toBe(true); + expect(result.errors).toHaveLength(0); + }); + + it('should fail when loanAmount exceeds propertyPrice', () => { + const inputs = { ...VALID_BODY.inputs, loanAmount: 2500000 }; + const result = validateBusinessRules(inputs); + expect(result.valid).toBe(false); + expect(result.errors).toContain('Loan amount cannot exceed property price'); + }); + + it('should fail when targetRepayment exceeds 80% of total income', () => { + const inputs = { + ...VALID_BODY.inputs, + monthlyIncome: 10000, + additionalIncome: 0, + targetRepayment: 9000, + }; + const result = validateBusinessRules(inputs); + expect(result.valid).toBe(false); + expect(result.errors.some((e) => e.includes('80%'))).toBe(true); + }); + + it('should pass when targetRepayment is exactly 80% of income', () => { + const inputs = { + ...VALID_BODY.inputs, + monthlyIncome: 10000, + additionalIncome: 0, + targetRepayment: 8000, + }; + const result = validateBusinessRules(inputs); + expect(result.valid).toBe(true); + }); + + it('should pass when loanAmount equals propertyPrice', () => { + const inputs = { + ...VALID_BODY.inputs, + propertyPrice: 1500000, + loanAmount: 1500000, + }; + const result = validateBusinessRules(inputs); + expect(result.valid).toBe(true); + }); + + it('should consider additionalIncome in repayment ratio', () => { + const inputs = { + ...VALID_BODY.inputs, + monthlyIncome: 10000, + additionalIncome: 5000, + targetRepayment: 11000, // 73% of 15000 total + }; + const result = validateBusinessRules(inputs); + expect(result.valid).toBe(true); + }); +}); diff --git a/firestore.indexes.json b/firestore.indexes.json index c1de385..6757026 100644 --- a/firestore.indexes.json +++ b/firestore.indexes.json @@ -4,79 +4,26 @@ "collectionGroup": "offers", "queryScope": "COLLECTION", "fields": [ - { - "fieldPath": "userId", - "order": "ASCENDING" - }, - { - "fieldPath": "createdAt", - "order": "DESCENDING" - } + { "fieldPath": "userId", "order": "ASCENDING" }, + { "fieldPath": "createdAt", "order": "DESCENDING" } ] }, { - "collectionGroup": "offers", + "collectionGroup": "community_profiles", "queryScope": "COLLECTION", "fields": [ - { - "fieldPath": "userId", - "order": "ASCENDING" - }, - { - "fieldPath": "status", - "order": "ASCENDING" - } + { "fieldPath": "incomeBin", "order": "ASCENDING" }, + { "fieldPath": "loanBin", "order": "ASCENDING" } ] }, { - "collectionGroup": "offers", + "collectionGroup": "payments", "queryScope": "COLLECTION", "fields": [ - { - "fieldPath": "userId", - "order": "ASCENDING" - }, - { - "fieldPath": "status", - "order": "ASCENDING" - }, - { - "fieldPath": "createdAt", - "order": "DESCENDING" - } + { "fieldPath": "userId", "order": "ASCENDING" }, + { "fieldPath": "createdAt", "order": "DESCENDING" } ] } ], - "fieldOverrides": [ - { - "collectionGroup": "users", - "fieldPath": "email", - "indexes": [ - { - "order": "ASCENDING", - "queryScope": "COLLECTION" - } - ] - }, - { - "collectionGroup": "users", - "fieldPath": "refreshToken", - "indexes": [ - { - "order": "ASCENDING", - "queryScope": "COLLECTION" - } - ] - }, - { - "collectionGroup": "financials", - "fieldPath": "userId", - "indexes": [ - { - "order": "ASCENDING", - "queryScope": "COLLECTION" - } - ] - } - ] + "fieldOverrides": [] } diff --git a/package.json b/package.json index e1ba784..1031af0 100644 --- a/package.json +++ b/package.json @@ -7,26 +7,30 @@ "start": "node src/index.js", "dev": "nodemon src/index.js", "test": "jest --forceExit --detectOpenHandles", - "test:coverage": "jest --coverage --forceExit --detectOpenHandles" + "test:coverage": "jest --coverage --forceExit --detectOpenHandles", + "fetch-rates": "node scripts/fetchRates.js" }, "engines": { "node": ">=18.0.0" }, "dependencies": { + "axios": "^1.7.2", "bcryptjs": "^2.4.3", - "cloudinary": "^1.41.3", - "cors": "^2.8.5", + "cloudinary": "^2.9.0", + "cors": "^2.8.6", "dotenv": "^16.4.5", "express": "^4.19.2", "express-rate-limit": "^7.3.1", - "firebase-admin": "^12.0.0", + "firebase-admin": "^10.3.0", "helmet": "^7.1.0", "joi": "^17.13.1", "jsonwebtoken": "^9.0.2", "morgan": "^1.10.0", "multer": "^1.4.5-lts.1", - "nodemailer": "^6.9.13", + "node-cron": "^3.0.3", + "nodemailer": "^8.0.5", "openai": "^4.52.0", + "stripe": "^17.4.0", "winston": "^3.13.0" }, "devDependencies": { diff --git a/scripts/fetchRates.js b/scripts/fetchRates.js new file mode 100644 index 0000000..4027e11 --- /dev/null +++ b/scripts/fetchRates.js @@ -0,0 +1,68 @@ +#!/usr/bin/env node +/** + * Manual Bank of Israel Rates Fetch Script + * + * Triggers a one-time fetch of the latest mortgage rates from the + * Bank of Israel API and stores them in Firestore. + * + * Usage: + * node scripts/fetchRates.js + * + * This script is useful for: + * - Initial data population after deployment + * - Manual refresh when the cron job hasn't run yet + * - Testing the BOI API integration + * + * Required environment variables (same as the main app): + * FIREBASE_PROJECT_ID, FIREBASE_CLIENT_EMAIL, FIREBASE_PRIVATE_KEY + * OR GOOGLE_APPLICATION_CREDENTIALS + */ + +'use strict'; + +require('dotenv').config(); + +const ratesService = require('../src/services/ratesService'); +const logger = require('../src/utils/logger'); + +async function main() { + console.log('\n🏦 Morty – Bank of Israel Rates Fetch\n'); + console.log('─'.repeat(60)); + + try { + console.log('\nFetching latest mortgage rates from Bank of Israel...'); + const rates = await ratesService.fetchAndStoreLatestRates(); + + if (!rates) { + console.error('\n❌ Failed to fetch rates (null result)'); + process.exit(1); + } + + console.log('\n✅ Rates fetched and stored successfully!\n'); + console.log(`Source: ${rates.source}`); + console.log(`Date: ${rates.date}`); + console.log(`Period: ${rates.fetchPeriod?.start} → ${rates.fetchPeriod?.end}`); + console.log('\nAverages:'); + console.log(` Fixed (קל"צ): ${rates.averages?.fixed ?? 'N/A'}%`); + console.log(` CPI (צמוד מדד): ${rates.averages?.cpi ?? 'N/A'}%`); + console.log(` Prime (פריים): ${rates.averages?.prime ?? 'N/A'}%`); + console.log(` Variable (משתנה): ${rates.averages?.variable ?? 'N/A'}%`); + + console.log('\nTrack details:'); + for (const [track, data] of Object.entries(rates.tracks || {})) { + console.log(`\n ${track} (${data.label}):`); + console.log(` Average: ${data.average ?? 'N/A'}%`); + console.log(` Latest: ${data.latest?.value ?? 'N/A'}% (${data.latest?.period ?? 'N/A'})`); + console.log(` Data points: ${data.count}`); + } + + console.log('\n' + '─'.repeat(60)); + console.log('Done.\n'); + } catch (err) { + console.error(`\n❌ Error: ${err.message}`); + logger.error(`fetchRates script error: ${err.message}`); + process.exit(1); + } +} + +main(); diff --git a/scripts/initCollections.js b/scripts/initCollections.js index fab1275..70583ef 100644 --- a/scripts/initCollections.js +++ b/scripts/initCollections.js @@ -25,6 +25,7 @@ const db = require('../src/config/firestore'); const { COLLECTIONS, OFFER_STATUS, + RATES_SOURCE, INDEX_DEFINITIONS, } = require('../src/config/collections'); @@ -81,6 +82,44 @@ const SENTINEL_DOCS = [ _sentinel: true, }, }, + { + collection: COLLECTIONS.MORTGAGE_RATES, + id: '_sentinel', + data: { + date: new Date().toISOString(), + fetchPeriod: { start: '2024-01', end: '2025-03' }, + tracks: { + fixed: { label: 'קבועה לא צמודה (קל"צ)', average: null, latest: null, monthlyData: [], count: 0 }, + cpi: { label: 'צמוד מדד', average: null, latest: null, monthlyData: [], count: 0 }, + prime: { label: 'פריים', average: null, latest: null, monthlyData: [], count: 0 }, + variable: { label: 'משתנה לא צמודה', average: null, latest: null, monthlyData: [], count: 0 }, + }, + averages: { fixed: null, cpi: null, prime: null, variable: null }, + source: RATES_SOURCE.FALLBACK, + sourceUrl: 'https://www.boi.org.il/en/economic-roles/statistics/', + updatedAt: new Date().toISOString(), + _sentinel: true, + }, + }, + { + collection: COLLECTIONS.COMMUNITY_PROFILES, + id: '_sentinel', + data: { + profileHash: '__sentinel__', + incomeBin: 0, + loanBin: 0, + ltvBin: 0, + stabilityBin: 0, + bank: null, + branch: null, + rates: null, + weightedRate: null, + consent: true, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + _sentinel: true, + }, + }, ]; // ─── Main ───────────────────────────────────────────────────────────────────── diff --git a/src/__tests__/config/communityCollections.test.js b/src/__tests__/config/communityCollections.test.js new file mode 100644 index 0000000..5ec3732 --- /dev/null +++ b/src/__tests__/config/communityCollections.test.js @@ -0,0 +1,147 @@ +/** + * Community Profile Collection – Unit Tests + * + * Tests the community_profiles document factory and validator + * added to the collections module. + */ + +'use strict'; + +const { + COLLECTIONS, + createCommunityProfileDocument, + validateCommunityProfileDocument, +} = require('../../config/collections'); + +describe('COLLECTIONS', () => { + it('should include COMMUNITY_PROFILES', () => { + expect(COLLECTIONS.COMMUNITY_PROFILES).toBe('community_profiles'); + }); +}); + +describe('createCommunityProfileDocument', () => { + const validParams = { + profileHash: 'abc123def456', + incomeBin: 30000, + loanBin: 1200000, + ltvBin: 60, + stabilityBin: 8, + }; + + it('should create a valid document with required fields', () => { + const doc = createCommunityProfileDocument(validParams); + + expect(doc.profileHash).toBe('abc123def456'); + expect(doc.incomeBin).toBe(30000); + expect(doc.loanBin).toBe(1200000); + expect(doc.ltvBin).toBe(60); + expect(doc.stabilityBin).toBe(8); + expect(doc.bank).toBeNull(); + expect(doc.branch).toBeNull(); + expect(doc.rates).toBeNull(); + expect(doc.weightedRate).toBeNull(); + expect(doc.consent).toBe(true); + expect(doc.createdAt).toBeDefined(); + expect(doc.updatedAt).toBeDefined(); + }); + + it('should include optional bank/branch/rates when provided', () => { + const doc = createCommunityProfileDocument({ + ...validParams, + bank: 'בנק לאומי', + branch: 'הרצליה', + rates: { fixed: 4.2, cpi: 2.9 }, + weightedRate: 3.55, + }); + + expect(doc.bank).toBe('בנק לאומי'); + expect(doc.branch).toBe('הרצליה'); + expect(doc.rates).toEqual({ fixed: 4.2, cpi: 2.9 }); + expect(doc.weightedRate).toBe(3.55); + }); + + it('should throw when profileHash is missing', () => { + expect(() => createCommunityProfileDocument({ ...validParams, profileHash: '' })) + .toThrow('profileHash is required'); + }); + + it('should throw when incomeBin is missing', () => { + expect(() => createCommunityProfileDocument({ ...validParams, incomeBin: undefined })) + .toThrow('incomeBin is required'); + }); + + it('should throw when loanBin is missing', () => { + expect(() => createCommunityProfileDocument({ ...validParams, loanBin: null })) + .toThrow('loanBin is required'); + }); + + it('should throw when ltvBin is missing', () => { + expect(() => createCommunityProfileDocument({ ...validParams, ltvBin: undefined })) + .toThrow('ltvBin is required'); + }); + + it('should throw when stabilityBin is missing', () => { + expect(() => createCommunityProfileDocument({ ...validParams, stabilityBin: null })) + .toThrow('stabilityBin is required'); + }); +}); + +describe('validateCommunityProfileDocument', () => { + const validDoc = { + profileHash: 'abc123', + incomeBin: 30000, + loanBin: 1200000, + ltvBin: 60, + stabilityBin: 8, + consent: true, + rates: null, + weightedRate: null, + }; + + it('should validate a correct document', () => { + const result = validateCommunityProfileDocument(validDoc); + expect(result.valid).toBe(true); + expect(result.errors).toHaveLength(0); + }); + + it('should reject missing profileHash', () => { + const result = validateCommunityProfileDocument({ ...validDoc, profileHash: '' }); + expect(result.valid).toBe(false); + expect(result.errors).toContain('profileHash must be a non-empty string'); + }); + + it('should reject negative incomeBin', () => { + const result = validateCommunityProfileDocument({ ...validDoc, incomeBin: -1 }); + expect(result.valid).toBe(false); + }); + + it('should reject non-number loanBin', () => { + const result = validateCommunityProfileDocument({ ...validDoc, loanBin: 'abc' }); + expect(result.valid).toBe(false); + }); + + it('should reject consent !== true', () => { + const result = validateCommunityProfileDocument({ ...validDoc, consent: false }); + expect(result.valid).toBe(false); + expect(result.errors).toContain('consent must be true'); + }); + + it('should reject non-object rates', () => { + const result = validateCommunityProfileDocument({ ...validDoc, rates: 'invalid' }); + expect(result.valid).toBe(false); + }); + + it('should accept null rates and weightedRate', () => { + const result = validateCommunityProfileDocument(validDoc); + expect(result.valid).toBe(true); + }); + + it('should accept valid rates object', () => { + const result = validateCommunityProfileDocument({ + ...validDoc, + rates: { fixed: 4.2, cpi: 2.9 }, + weightedRate: 3.55, + }); + expect(result.valid).toBe(true); + }); +}); diff --git a/src/__tests__/services/communityService.test.js b/src/__tests__/services/communityService.test.js new file mode 100644 index 0000000..8be7ab7 --- /dev/null +++ b/src/__tests__/services/communityService.test.js @@ -0,0 +1,699 @@ +/** + * Community Service – Unit Tests + * + * Tests the community intelligence algorithm including: + * - Profile binning and hashing + * - Similar profile matching + * - Winning offer aggregation + * - Community tips generation + * - Anonymous profile storage + * - Cache behavior + */ + +'use strict'; + +// ── Mock Firestore ──────────────────────────────────────────────────────────── + +const mockGet = jest.fn(); +const mockAdd = jest.fn(); +const mockUpdate = jest.fn(); +const mockWhere = jest.fn(); +const mockOrderBy = jest.fn(); +const mockLimit = jest.fn(); +const mockSelect = jest.fn(); +const mockDoc = jest.fn(); +const mockCollection = jest.fn(); + +// Chain builder for Firestore queries +const queryChain = { + where: mockWhere, + orderBy: mockOrderBy, + limit: mockLimit, + select: mockSelect, + get: mockGet, +}; + +mockWhere.mockReturnValue(queryChain); +mockOrderBy.mockReturnValue(queryChain); +mockLimit.mockReturnValue(queryChain); +mockSelect.mockReturnValue(queryChain); +mockDoc.mockReturnValue({ update: mockUpdate, get: mockGet }); +mockCollection.mockReturnValue({ + where: mockWhere, + orderBy: mockOrderBy, + limit: mockLimit, + add: mockAdd, + doc: mockDoc, +}); + +jest.mock('../../config/firestore', () => ({ + collection: mockCollection, +})); + +jest.mock('../../utils/logger', () => ({ + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), +})); + +// ── Import after mocks ─────────────────────────────────────────────────────── + +const communityService = require('../../services/communityService'); + +// ── Test Data ───────────────────────────────────────────────────────────────── + +const SAMPLE_INPUTS = { + propertyPrice: 2000000, + loanAmount: 1200000, + monthlyIncome: 25000, + additionalIncome: 5000, + targetRepayment: 7000, + futureFunds: { timeframe: 'within_5_years', amount: 200000 }, + stabilityPreference: 7, +}; + +const SAMPLE_RATES = { + fixed: 4.65, + cpi: 3.15, + prime: 6.05, + variable: 4.95, +}; + +const SAMPLE_COMMUNITY_PROFILES = [ + { + id: 'profile1', + profileHash: 'hash1', + incomeBin: 30000, + loanBin: 1200000, + ltvBin: 60, + stabilityBin: 8, + bank: 'בנק לאומי', + branch: 'הרצליה', + rates: { fixed: 4.2, cpi: 2.9, prime: 5.8 }, + weightedRate: 4.3, + consent: true, + createdAt: new Date().toISOString(), + }, + { + id: 'profile2', + profileHash: 'hash2', + incomeBin: 30000, + loanBin: 1250000, + ltvBin: 60, + stabilityBin: 6, + bank: 'בנק לאומי', + branch: 'הרצליה', + rates: { fixed: 4.3, cpi: 3.0, prime: 5.7 }, + weightedRate: 4.33, + consent: true, + createdAt: new Date(Date.now() - 5 * 24 * 60 * 60 * 1000).toISOString(), + }, + { + id: 'profile3', + profileHash: 'hash3', + incomeBin: 30000, + loanBin: 1150000, + ltvBin: 55, + stabilityBin: 8, + bank: 'בנק הפועלים', + branch: 'תל אביב', + rates: { fixed: 4.5, cpi: 3.1, prime: 5.9 }, + weightedRate: 4.5, + consent: true, + createdAt: new Date(Date.now() - 20 * 24 * 60 * 60 * 1000).toISOString(), + }, + { + id: 'profile4', + profileHash: 'hash4', + incomeBin: 30000, + loanBin: 1300000, + ltvBin: 65, + stabilityBin: 8, + bank: 'בנק דיסקונט', + branch: 'רמת גן', + rates: { fixed: 4.6, cpi: 3.2, prime: 6.0 }, + weightedRate: 4.6, + consent: true, + createdAt: new Date(Date.now() - 60 * 24 * 60 * 60 * 1000).toISOString(), + }, + { + id: 'profile5', + profileHash: 'hash5', + incomeBin: 30000, + loanBin: 1200000, + ltvBin: 60, + stabilityBin: 6, + bank: 'בנק לאומי', + branch: 'הרצליה', + rates: { fixed: 4.1, cpi: 2.85, prime: 5.75 }, + weightedRate: 4.23, + consent: true, + createdAt: new Date(Date.now() - 2 * 24 * 60 * 60 * 1000).toISOString(), + }, +]; + +// ── Helper ──────────────────────────────────────────────────────────────────── + +function createMockSnapshot(profiles) { + const docs = profiles.map((p) => ({ + id: p.id, + data: () => ({ ...p }), + })); + return { + empty: profiles.length === 0, + size: profiles.length, + docs, + forEach: (cb) => docs.forEach(cb), + }; +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +describe('communityService', () => { + beforeEach(() => { + jest.clearAllMocks(); + communityService.clearCache(); + }); + + // ── binValue ────────────────────────────────────────────────────────────── + + describe('binValue', () => { + it('should bin income to nearest 5000', () => { + expect(communityService.binValue(17500, 5000)).toBe(20000); + expect(communityService.binValue(12499, 5000)).toBe(10000); + expect(communityService.binValue(12500, 5000)).toBe(15000); + expect(communityService.binValue(25000, 5000)).toBe(25000); + }); + + it('should bin loan to nearest 50000', () => { + expect(communityService.binValue(1225000, 50000)).toBe(1250000); + expect(communityService.binValue(1200000, 50000)).toBe(1200000); + expect(communityService.binValue(1174999, 50000)).toBe(1150000); + }); + + it('should bin LTV to nearest 5', () => { + expect(communityService.binValue(62, 5)).toBe(60); + expect(communityService.binValue(63, 5)).toBe(65); + expect(communityService.binValue(75, 5)).toBe(75); + }); + + it('should bin stability to nearest 2', () => { + expect(communityService.binValue(7, 2)).toBe(8); + expect(communityService.binValue(6, 2)).toBe(6); + expect(communityService.binValue(3, 2)).toBe(4); + expect(communityService.binValue(1, 2)).toBe(2); + }); + + it('should handle zero and invalid inputs', () => { + expect(communityService.binValue(0, 5000)).toBe(0); + expect(communityService.binValue(null, 5000)).toBe(0); + expect(communityService.binValue(100, 0)).toBe(0); + }); + }); + + // ── computeBinnedProfile ────────────────────────────────────────────────── + + describe('computeBinnedProfile', () => { + it('should compute binned profile from wizard inputs', () => { + const binned = communityService.computeBinnedProfile(SAMPLE_INPUTS); + + expect(binned).toHaveProperty('incomeBin'); + expect(binned).toHaveProperty('loanBin'); + expect(binned).toHaveProperty('ltvBin'); + expect(binned).toHaveProperty('stabilityBin'); + + // Total income = 25000 + 5000 = 30000 → bin 30000 + expect(binned.incomeBin).toBe(30000); + // Loan = 1200000 → bin 1200000 + expect(binned.loanBin).toBe(1200000); + // LTV = 1200000/2000000 * 100 = 60% → bin 60 + expect(binned.ltvBin).toBe(60); + // Stability = 7 → bin 8 (nearest 2) + expect(binned.stabilityBin).toBe(8); + }); + + it('should handle missing additionalIncome', () => { + const inputs = { ...SAMPLE_INPUTS, additionalIncome: undefined }; + const binned = communityService.computeBinnedProfile(inputs); + // Total income = 25000 + 0 = 25000 → bin 25000 + expect(binned.incomeBin).toBe(25000); + }); + }); + + // ── hashProfile ─────────────────────────────────────────────────────────── + + describe('hashProfile', () => { + it('should produce a deterministic SHA-256 hash', () => { + const binned = communityService.computeBinnedProfile(SAMPLE_INPUTS); + const hash1 = communityService.hashProfile(binned); + const hash2 = communityService.hashProfile(binned); + + expect(hash1).toBe(hash2); + expect(hash1).toHaveLength(64); // SHA-256 hex = 64 chars + }); + + it('should produce different hashes for different profiles', () => { + const binned1 = communityService.computeBinnedProfile(SAMPLE_INPUTS); + const binned2 = communityService.computeBinnedProfile({ + ...SAMPLE_INPUTS, + loanAmount: 800000, + }); + + const hash1 = communityService.hashProfile(binned1); + const hash2 = communityService.hashProfile(binned2); + + expect(hash1).not.toBe(hash2); + }); + + it('should be order-independent (sorted keys)', () => { + const binned = { ltvBin: 60, incomeBin: 30000, stabilityBin: 8, loanBin: 1200000 }; + const binnedReordered = { incomeBin: 30000, loanBin: 1200000, ltvBin: 60, stabilityBin: 8 }; + + expect(communityService.hashProfile(binned)) + .toBe(communityService.hashProfile(binnedReordered)); + }); + }); + + // ── aggregateWinningOffers ───────────────────────────────────────────────── + + describe('aggregateWinningOffers', () => { + it('should group profiles by bank+branch and rank by weighted rate', () => { + const ranked = communityService.aggregateWinningOffers(SAMPLE_COMMUNITY_PROFILES); + + expect(ranked.length).toBeGreaterThan(0); + // Leumi Herzliya should be first (lowest avg weighted rate) + expect(ranked[0].bank).toBe('בנק לאומי'); + expect(ranked[0].branch).toBe('הרצליה'); + expect(ranked[0].profileCount).toBe(3); + }); + + it('should compute average rates per track', () => { + const ranked = communityService.aggregateWinningOffers(SAMPLE_COMMUNITY_PROFILES); + const leumi = ranked.find((r) => r.bank === 'בנק לאומי'); + + expect(leumi.avgRates).toHaveProperty('fixed'); + expect(leumi.avgRates).toHaveProperty('cpi'); + expect(leumi.avgRates).toHaveProperty('prime'); + }); + + it('should return empty array for empty input', () => { + expect(communityService.aggregateWinningOffers([])).toEqual([]); + expect(communityService.aggregateWinningOffers(null)).toEqual([]); + }); + + it('should skip profiles without bank data', () => { + const profiles = [ + { bank: null, weightedRate: 4.0, rates: {} }, + { bank: 'בנק לאומי', branch: 'הרצליה', weightedRate: 4.2, rates: { fixed: 4.2 } }, + ]; + const ranked = communityService.aggregateWinningOffers(profiles); + expect(ranked.length).toBe(1); + expect(ranked[0].bank).toBe('בנק לאומי'); + }); + }); + + // ── computeAverageRates ─────────────────────────────────────────────────── + + describe('computeAverageRates', () => { + it('should compute averages per track type', () => { + const rates = [ + { fixed: 4.0, cpi: 3.0, prime: 5.5 }, + { fixed: 4.4, cpi: 3.2, prime: 5.9 }, + ]; + const avg = communityService.computeAverageRates(rates); + + expect(avg.fixed).toBe(4.2); + expect(avg.cpi).toBe(3.1); + expect(avg.prime).toBe(5.7); + }); + + it('should handle missing track values', () => { + const rates = [ + { fixed: 4.0 }, + { fixed: 4.4, cpi: 3.2 }, + ]; + const avg = communityService.computeAverageRates(rates); + + expect(avg.fixed).toBe(4.2); + expect(avg.cpi).toBe(3.2); + expect(avg.prime).toBeUndefined(); + }); + + it('should return empty object for empty input', () => { + expect(communityService.computeAverageRates([])).toEqual({}); + }); + }); + + // ── generateCommunityTips ───────────────────────────────────────────────── + + describe('generateCommunityTips', () => { + it('should generate winning_offer tip when profiles have bank data', () => { + const tips = communityService.generateCommunityTips( + SAMPLE_COMMUNITY_PROFILES, + SAMPLE_RATES + ); + + expect(tips.length).toBeGreaterThan(0); + const winningTip = tips.find((t) => t.type === 'winning_offer'); + expect(winningTip).toBeDefined(); + expect(winningTip.bank).toBe('בנק לאומי'); + expect(winningTip.branch).toBe('הרצליה'); + expect(winningTip.messageHe).toContain('בנק לאומי'); + expect(winningTip.messageEn).toContain('Leumi'); + }); + + it('should generate rate_comparison tip when community rates beat BOI', () => { + const tips = communityService.generateCommunityTips( + SAMPLE_COMMUNITY_PROFILES, + SAMPLE_RATES + ); + + const rateTip = tips.find((t) => t.type === 'rate_comparison'); + expect(rateTip).toBeDefined(); + expect(rateTip.comparisons).toBeDefined(); + expect(rateTip.comparisons.length).toBeGreaterThan(0); + }); + + it('should generate community_size tip when >= 5 profiles', () => { + const tips = communityService.generateCommunityTips( + SAMPLE_COMMUNITY_PROFILES, + SAMPLE_RATES + ); + + const sizeTip = tips.find((t) => t.type === 'community_size'); + expect(sizeTip).toBeDefined(); + expect(sizeTip.matchCount).toBe(5); + }); + + it('should return empty array when fewer than MIN_PROFILES_FOR_TIP', () => { + const tips = communityService.generateCommunityTips( + [SAMPLE_COMMUNITY_PROFILES[0]], + SAMPLE_RATES + ); + expect(tips).toEqual([]); + }); + + it('should return max MAX_TIPS tips', () => { + const tips = communityService.generateCommunityTips( + SAMPLE_COMMUNITY_PROFILES, + SAMPLE_RATES + ); + expect(tips.length).toBeLessThanOrEqual(communityService.MAX_TIPS); + }); + + it('should handle null currentRates gracefully', () => { + const tips = communityService.generateCommunityTips( + SAMPLE_COMMUNITY_PROFILES, + null + ); + // Should still produce winning_offer and community_size tips + expect(tips.length).toBeGreaterThan(0); + // Should NOT produce rate_comparison tip + const rateTip = tips.find((t) => t.type === 'rate_comparison'); + expect(rateTip).toBeUndefined(); + }); + }); + + // ── formatRecency ───────────────────────────────────────────────────────── + + describe('formatRecency', () => { + it('should return "השבוע" for dates within 7 days', () => { + const recent = new Date(Date.now() - 3 * 24 * 60 * 60 * 1000).toISOString(); + expect(communityService.formatRecency(recent)).toBe('השבוע'); + }); + + it('should return "החודש" for dates within 30 days', () => { + const recent = new Date(Date.now() - 15 * 24 * 60 * 60 * 1000).toISOString(); + expect(communityService.formatRecency(recent)).toBe('החודש'); + }); + + it('should return "לאחרונה" for older dates', () => { + const old = new Date(Date.now() - 60 * 24 * 60 * 60 * 1000).toISOString(); + expect(communityService.formatRecency(old)).toBe('לאחרונה'); + }); + + it('should handle invalid dates gracefully', () => { + expect(communityService.formatRecency('invalid')).toBe('לאחרונה'); + }); + }); + + // ── findSimilarProfiles ─────────────────────────────────────────────────── + + describe('findSimilarProfiles', () => { + it('should query Firestore with correct range filters', async () => { + mockGet.mockResolvedValueOnce(createMockSnapshot(SAMPLE_COMMUNITY_PROFILES)); + + const results = await communityService.findSimilarProfiles(SAMPLE_INPUTS); + + expect(mockCollection).toHaveBeenCalledWith('community_profiles'); + expect(mockWhere).toHaveBeenCalledWith('incomeBin', '>=', expect.any(Number)); + expect(mockWhere).toHaveBeenCalledWith('incomeBin', '<=', expect.any(Number)); + expect(mockOrderBy).toHaveBeenCalledWith('incomeBin', 'asc'); + expect(mockLimit).toHaveBeenCalledWith(communityService.MAX_MATCH_RESULTS); + expect(results.length).toBeGreaterThan(0); + }); + + it('should filter out sentinel documents', async () => { + const profilesWithSentinel = [ + ...SAMPLE_COMMUNITY_PROFILES, + { + id: '_sentinel', + _sentinel: true, + incomeBin: 30000, + loanBin: 1200000, + ltvBin: 60, + stabilityBin: 8, + }, + ]; + mockGet.mockResolvedValueOnce(createMockSnapshot(profilesWithSentinel)); + + const results = await communityService.findSimilarProfiles(SAMPLE_INPUTS); + + const sentinelResult = results.find((r) => r.id === '_sentinel'); + expect(sentinelResult).toBeUndefined(); + }); + + it('should return empty array on Firestore error', async () => { + mockGet.mockRejectedValueOnce(new Error('Firestore unavailable')); + + const results = await communityService.findSimilarProfiles(SAMPLE_INPUTS); + expect(results).toEqual([]); + }); + + it('should return empty array when no matches found', async () => { + mockGet.mockResolvedValueOnce(createMockSnapshot([])); + + const results = await communityService.findSimilarProfiles(SAMPLE_INPUTS); + expect(results).toEqual([]); + }); + + it('should filter by loan range in-memory', async () => { + const profiles = [ + { + id: 'match', + incomeBin: 30000, + loanBin: 1200000, + ltvBin: 60, + stabilityBin: 8, + bank: 'Test', + }, + { + id: 'no-match-loan', + incomeBin: 30000, + loanBin: 5000000, // Way outside range + ltvBin: 60, + stabilityBin: 8, + bank: 'Test', + }, + ]; + mockGet.mockResolvedValueOnce(createMockSnapshot(profiles)); + + const results = await communityService.findSimilarProfiles(SAMPLE_INPUTS); + expect(results.length).toBe(1); + expect(results[0].id).toBe('match'); + }); + }); + + // ── getCommunityTips ────────────────────────────────────────────────────── + + describe('getCommunityTips', () => { + it('should return community tips for matching profiles', async () => { + mockGet.mockResolvedValueOnce(createMockSnapshot(SAMPLE_COMMUNITY_PROFILES)); + + const tips = await communityService.getCommunityTips(SAMPLE_INPUTS, SAMPLE_RATES); + + expect(Array.isArray(tips)).toBe(true); + expect(tips.length).toBeGreaterThan(0); + }); + + it('should return empty array when no community data exists', async () => { + mockGet.mockResolvedValueOnce(createMockSnapshot([])); + + const tips = await communityService.getCommunityTips(SAMPLE_INPUTS, SAMPLE_RATES); + expect(tips).toEqual([]); + }); + + it('should use cache on second call with same inputs', async () => { + mockGet.mockResolvedValueOnce(createMockSnapshot(SAMPLE_COMMUNITY_PROFILES)); + + const tips1 = await communityService.getCommunityTips(SAMPLE_INPUTS, SAMPLE_RATES); + const tips2 = await communityService.getCommunityTips(SAMPLE_INPUTS, SAMPLE_RATES); + + // Firestore should only be called once + expect(mockGet).toHaveBeenCalledTimes(1); + expect(tips1).toEqual(tips2); + }); + + it('should degrade gracefully on error', async () => { + mockGet.mockRejectedValueOnce(new Error('Network error')); + + const tips = await communityService.getCommunityTips(SAMPLE_INPUTS, SAMPLE_RATES); + expect(tips).toEqual([]); + }); + }); + + // ── storeAnonymousProfile ───────────────────────────────────────────────── + + describe('storeAnonymousProfile', () => { + it('should store anonymized profile in Firestore', async () => { + mockAdd.mockResolvedValueOnce({ id: 'new-profile-id' }); + + const result = await communityService.storeAnonymousProfile(SAMPLE_INPUTS); + + expect(result).toBe('new-profile-id'); + expect(mockAdd).toHaveBeenCalledTimes(1); + + const storedDoc = mockAdd.mock.calls[0][0]; + expect(storedDoc.profileHash).toBeDefined(); + expect(storedDoc.incomeBin).toBe(30000); + expect(storedDoc.loanBin).toBe(1200000); + expect(storedDoc.consent).toBe(true); + // Should NOT contain PII + expect(storedDoc.monthlyIncome).toBeUndefined(); + expect(storedDoc.propertyPrice).toBeUndefined(); + }); + + it('should store bank offer data when provided', async () => { + mockAdd.mockResolvedValueOnce({ id: 'new-profile-id' }); + + const bankOffer = { + bank: 'בנק לאומי', + branch: 'הרצליה', + rates: { fixed: 4.2, cpi: 2.9, prime: 5.8 }, + }; + + await communityService.storeAnonymousProfile(SAMPLE_INPUTS, bankOffer); + + const storedDoc = mockAdd.mock.calls[0][0]; + expect(storedDoc.bank).toBe('בנק לאומי'); + expect(storedDoc.branch).toBe('הרצליה'); + expect(storedDoc.rates).toEqual({ fixed: 4.2, cpi: 2.9, prime: 5.8 }); + expect(storedDoc.weightedRate).toBeDefined(); + expect(typeof storedDoc.weightedRate).toBe('number'); + }); + + it('should return null on Firestore error', async () => { + mockAdd.mockRejectedValueOnce(new Error('Write failed')); + + const result = await communityService.storeAnonymousProfile(SAMPLE_INPUTS); + expect(result).toBeNull(); + }); + + it('should store null bank/rates when no offer provided', async () => { + mockAdd.mockResolvedValueOnce({ id: 'new-profile-id' }); + + await communityService.storeAnonymousProfile(SAMPLE_INPUTS); + + const storedDoc = mockAdd.mock.calls[0][0]; + expect(storedDoc.bank).toBeNull(); + expect(storedDoc.branch).toBeNull(); + expect(storedDoc.rates).toBeNull(); + expect(storedDoc.weightedRate).toBeNull(); + }); + }); + + // ── updateProfileWithOffer ──────────────────────────────────────────────── + + describe('updateProfileWithOffer', () => { + it('should update profile with bank offer data', async () => { + mockUpdate.mockResolvedValueOnce(); + + const result = await communityService.updateProfileWithOffer('profile-id', { + bank: 'בנק לאומי', + branch: 'הרצליה', + rates: { fixed: 4.2, cpi: 2.9, prime: 5.8 }, + }); + + expect(result).toBe(true); + expect(mockDoc).toHaveBeenCalledWith('profile-id'); + expect(mockUpdate).toHaveBeenCalledTimes(1); + }); + + it('should return false for missing required fields', async () => { + const result = await communityService.updateProfileWithOffer(null, { bank: 'Test' }); + expect(result).toBe(false); + }); + + it('should return false for missing bank', async () => { + const result = await communityService.updateProfileWithOffer('id', { bank: null }); + expect(result).toBe(false); + }); + + it('should return false on Firestore error', async () => { + mockUpdate.mockRejectedValueOnce(new Error('Update failed')); + + const result = await communityService.updateProfileWithOffer('id', { + bank: 'Test', + rates: { fixed: 4.0 }, + }); + expect(result).toBe(false); + }); + }); + + // ── Cache behavior ──────────────────────────────────────────────────────── + + describe('cache', () => { + it('should clear cache on clearCache()', () => { + // Manually set cache + communityService.clearCache(); + // No error thrown + }); + + it('should invalidate cache after storeAnonymousProfile', async () => { + // First, populate cache + mockGet.mockResolvedValueOnce(createMockSnapshot(SAMPLE_COMMUNITY_PROFILES)); + await communityService.getCommunityTips(SAMPLE_INPUTS, SAMPLE_RATES); + + // Store a new profile (should invalidate cache for this hash) + mockAdd.mockResolvedValueOnce({ id: 'new-id' }); + await communityService.storeAnonymousProfile(SAMPLE_INPUTS); + + // Next getCommunityTips should query Firestore again + mockGet.mockResolvedValueOnce(createMockSnapshot(SAMPLE_COMMUNITY_PROFILES)); + await communityService.getCommunityTips(SAMPLE_INPUTS, SAMPLE_RATES); + + // Firestore get should have been called twice + expect(mockGet).toHaveBeenCalledTimes(2); + }); + }); + + // ── Constants ───────────────────────────────────────────────────────────── + + describe('constants', () => { + it('should export expected constants', () => { + expect(communityService.BIN_SIZES).toBeDefined(); + expect(communityService.BIN_SIZES.INCOME).toBe(5000); + expect(communityService.BIN_SIZES.LOAN).toBe(50000); + expect(communityService.BIN_SIZES.LTV).toBe(5); + expect(communityService.BIN_SIZES.STABILITY).toBe(2); + + expect(communityService.MATCH_RANGES).toBeDefined(); + expect(communityService.MATCH_RANGES.INCOME_TOLERANCE).toBe(0.10); + expect(communityService.MATCH_RANGES.LOAN_TOLERANCE).toBe(0.20); + + expect(communityService.MAX_TIPS).toBe(3); + expect(communityService.MIN_PROFILES_FOR_TIP).toBe(2); + }); + }); +}); diff --git a/src/__tests__/services/portfolioEngine.test.js b/src/__tests__/services/portfolioEngine.test.js new file mode 100644 index 0000000..8dce841 --- /dev/null +++ b/src/__tests__/services/portfolioEngine.test.js @@ -0,0 +1,649 @@ +/** + * Portfolio Engine – Unit Tests + * + * Tests the conditional logic, adaptive allocation, financial calculations, + * and portfolio scoring in the portfolioEngine module. + */ + +'use strict'; + +const portfolioEngine = require('../../services/portfolioEngine'); + +// ── Test Fixtures ───────────────────────────────────────────────────────────── + +const DEFAULT_RATES = { + fixed: 4.65, + cpi: 3.15, + prime: 6.05, + variable: 4.95, +}; + +const HIGH_CPI_RATES = { + fixed: 4.65, + cpi: 3.50, // Above CPI_RATE_THRESHOLD (2.5) + prime: 6.05, + variable: 4.95, +}; + +const LOW_CPI_RATES = { + fixed: 4.65, + cpi: 2.00, // Below CPI_RATE_THRESHOLD + prime: 6.05, + variable: 4.95, +}; + +/** Base wizard inputs – moderate profile */ +function makeInputs(overrides = {}) { + return { + propertyPrice: 2000000, + loanAmount: 1200000, + monthlyIncome: 25000, + additionalIncome: 0, + targetRepayment: 6000, + futureFunds: { timeframe: 'none', amount: 0 }, + stabilityPreference: 5, + ...overrides, + }; +} + +// ── analyseUserProfile ──────────────────────────────────────────────────────── + +describe('portfolioEngine.analyseUserProfile', () => { + test('calculates LTV correctly', () => { + const inputs = makeInputs({ propertyPrice: 2000000, loanAmount: 1200000 }); + const profile = portfolioEngine.analyseUserProfile(inputs); + expect(profile.ltv).toBe(60); + expect(profile.ltvClass).toBe('moderate'); + }); + + test('classifies low LTV (<= 50%)', () => { + const inputs = makeInputs({ propertyPrice: 2000000, loanAmount: 900000 }); + const profile = portfolioEngine.analyseUserProfile(inputs); + expect(profile.ltv).toBe(45); + expect(profile.ltvClass).toBe('low'); + }); + + test('classifies high LTV (> 60%, <= 75%)', () => { + const inputs = makeInputs({ propertyPrice: 2000000, loanAmount: 1400000 }); + const profile = portfolioEngine.analyseUserProfile(inputs); + expect(profile.ltv).toBe(70); + expect(profile.ltvClass).toBe('high'); + }); + + test('classifies very high LTV (> 75%)', () => { + const inputs = makeInputs({ propertyPrice: 2000000, loanAmount: 1600000 }); + const profile = portfolioEngine.analyseUserProfile(inputs); + expect(profile.ltv).toBe(80); + expect(profile.ltvClass).toBe('very_high'); + }); + + test('calculates repayment ratio and affordability', () => { + // 6000 / 25000 = 0.24 → comfortable + const inputs = makeInputs({ targetRepayment: 6000, monthlyIncome: 25000 }); + const profile = portfolioEngine.analyseUserProfile(inputs); + expect(profile.repaymentRatio).toBe(0.24); + expect(profile.affordability).toBe('comfortable'); + }); + + test('classifies moderate affordability (25-35%)', () => { + const inputs = makeInputs({ targetRepayment: 7500, monthlyIncome: 25000 }); + const profile = portfolioEngine.analyseUserProfile(inputs); + expect(profile.repaymentRatio).toBe(0.3); + expect(profile.affordability).toBe('moderate'); + }); + + test('classifies tight affordability (35-40%)', () => { + const inputs = makeInputs({ targetRepayment: 9500, monthlyIncome: 25000 }); + const profile = portfolioEngine.analyseUserProfile(inputs); + expect(profile.repaymentRatio).toBe(0.38); + expect(profile.affordability).toBe('tight'); + }); + + test('classifies stretched affordability (> 40%)', () => { + const inputs = makeInputs({ targetRepayment: 12000, monthlyIncome: 25000 }); + const profile = portfolioEngine.analyseUserProfile(inputs); + expect(profile.repaymentRatio).toBe(0.48); + expect(profile.affordability).toBe('stretched'); + }); + + test('derives risk tolerance from stability preference', () => { + expect(portfolioEngine.analyseUserProfile(makeInputs({ stabilityPreference: 2 })).riskTolerance).toBe('risk_tolerant'); + expect(portfolioEngine.analyseUserProfile(makeInputs({ stabilityPreference: 3 })).riskTolerance).toBe('risk_tolerant'); + expect(portfolioEngine.analyseUserProfile(makeInputs({ stabilityPreference: 4 })).riskTolerance).toBe('balanced'); + expect(portfolioEngine.analyseUserProfile(makeInputs({ stabilityPreference: 6 })).riskTolerance).toBe('balanced'); + expect(portfolioEngine.analyseUserProfile(makeInputs({ stabilityPreference: 7 })).riskTolerance).toBe('risk_averse'); + expect(portfolioEngine.analyseUserProfile(makeInputs({ stabilityPreference: 10 })).riskTolerance).toBe('risk_averse'); + }); + + test('analyses future funds correctly', () => { + const noFunds = portfolioEngine.analyseUserProfile(makeInputs({ futureFunds: { timeframe: 'none', amount: 0 } })); + expect(noFunds.hasFutureFunds).toBe(false); + expect(noFunds.canPrepayEarly).toBe(false); + + const nearTerm = portfolioEngine.analyseUserProfile(makeInputs({ + futureFunds: { timeframe: 'within_5_years', amount: 200000 }, + })); + expect(nearTerm.hasFutureFunds).toBe(true); + expect(nearTerm.futureFundsNearTerm).toBe(true); + expect(nearTerm.canPrepayEarly).toBe(true); + + const midTerm = portfolioEngine.analyseUserProfile(makeInputs({ + futureFunds: { timeframe: 'within_10_years', amount: 300000 }, + })); + expect(midTerm.hasFutureFunds).toBe(true); + expect(midTerm.futureFundsNearTerm).toBe(false); + expect(midTerm.futureFundsMidTerm).toBe(true); + expect(midTerm.canPrepayEarly).toBe(false); + }); + + test('includes additional income in total', () => { + const inputs = makeInputs({ monthlyIncome: 20000, additionalIncome: 5000 }); + const profile = portfolioEngine.analyseUserProfile(inputs); + expect(profile.totalIncome).toBe(25000); + }); +}); + +// ── determineScenarios ──────────────────────────────────────────────────────── + +describe('portfolioEngine.determineScenarios', () => { + test('always includes Market Standard and Fast Track', () => { + const inputs = makeInputs({ stabilityPreference: 1 }); + const profile = portfolioEngine.analyseUserProfile(inputs); + const { scenarios } = portfolioEngine.determineScenarios(inputs, LOW_CPI_RATES, profile); + + expect(scenarios).toContain('market_standard'); + expect(scenarios).toContain('fast_track'); + }); + + test('includes Inflation-Proof when CPI rate is high', () => { + const inputs = makeInputs({ stabilityPreference: 1 }); + const profile = portfolioEngine.analyseUserProfile(inputs); + const { scenarios, reasons } = portfolioEngine.determineScenarios(inputs, HIGH_CPI_RATES, profile); + + expect(scenarios).toContain('inflation_proof'); + expect(reasons.inflation_proof).toContain('CPI rate'); + }); + + test('includes Inflation-Proof when stability preference is moderate (4-8)', () => { + const inputs = makeInputs({ stabilityPreference: 5 }); + const profile = portfolioEngine.analyseUserProfile(inputs); + const { scenarios } = portfolioEngine.determineScenarios(inputs, LOW_CPI_RATES, profile); + + expect(scenarios).toContain('inflation_proof'); + }); + + test('includes Inflation-Proof when user has mid-term future funds', () => { + const inputs = makeInputs({ + stabilityPreference: 1, + futureFunds: { timeframe: 'within_10_years', amount: 200000 }, + }); + const profile = portfolioEngine.analyseUserProfile(inputs); + const { scenarios, reasons } = portfolioEngine.determineScenarios(inputs, LOW_CPI_RATES, profile); + + expect(scenarios).toContain('inflation_proof'); + expect(reasons.inflation_proof).toContain('Future funds'); + }); + + test('includes Inflation-Proof when LTV is high', () => { + const inputs = makeInputs({ + stabilityPreference: 1, + propertyPrice: 2000000, + loanAmount: 1500000, // 75% LTV + }); + const profile = portfolioEngine.analyseUserProfile(inputs); + const { scenarios, reasons } = portfolioEngine.determineScenarios(inputs, LOW_CPI_RATES, profile); + + expect(scenarios).toContain('inflation_proof'); + expect(reasons.inflation_proof).toContain('High LTV'); + }); + + test('does NOT include Inflation-Proof when no conditions met', () => { + // Low CPI, low stability (1-3), no future funds, low LTV + const inputs = makeInputs({ + stabilityPreference: 2, + propertyPrice: 2000000, + loanAmount: 800000, // 40% LTV + futureFunds: { timeframe: 'none', amount: 0 }, + }); + const profile = portfolioEngine.analyseUserProfile(inputs); + const { scenarios } = portfolioEngine.determineScenarios(inputs, LOW_CPI_RATES, profile); + + expect(scenarios).not.toContain('inflation_proof'); + }); + + test('includes Stability-First when stabilityPreference >= 7', () => { + const inputs = makeInputs({ stabilityPreference: 7 }); + const profile = portfolioEngine.analyseUserProfile(inputs); + const { scenarios, reasons } = portfolioEngine.determineScenarios(inputs, DEFAULT_RATES, profile); + + expect(scenarios).toContain('stability_first'); + expect(reasons.stability_first).toContain('Stability preference'); + }); + + test('includes Stability-First when tight affordability + stability >= 5', () => { + const inputs = makeInputs({ + stabilityPreference: 5, + targetRepayment: 9500, // 38% of 25000 = tight + monthlyIncome: 25000, + }); + const profile = portfolioEngine.analyseUserProfile(inputs); + const { scenarios, reasons } = portfolioEngine.determineScenarios(inputs, DEFAULT_RATES, profile); + + expect(scenarios).toContain('stability_first'); + expect(reasons.stability_first).toContain('Tight affordability'); + }); + + test('includes Stability-First when no future funds + risk-averse', () => { + const inputs = makeInputs({ + stabilityPreference: 8, // risk_averse + futureFunds: { timeframe: 'none', amount: 0 }, + }); + const profile = portfolioEngine.analyseUserProfile(inputs); + const { scenarios, reasons } = portfolioEngine.determineScenarios(inputs, DEFAULT_RATES, profile); + + expect(scenarios).toContain('stability_first'); + expect(reasons.stability_first).toContain('No future funds'); + }); + + test('does NOT include Stability-First when stability < 5 and comfortable', () => { + const inputs = makeInputs({ + stabilityPreference: 3, + targetRepayment: 5000, // 20% of 25000 = comfortable + monthlyIncome: 25000, + futureFunds: { timeframe: 'within_5_years', amount: 100000 }, + }); + const profile = portfolioEngine.analyseUserProfile(inputs); + const { scenarios } = portfolioEngine.determineScenarios(inputs, DEFAULT_RATES, profile); + + expect(scenarios).not.toContain('stability_first'); + }); + + test('generates all 4 scenarios for high-stability user with high CPI', () => { + const inputs = makeInputs({ stabilityPreference: 8 }); + const profile = portfolioEngine.analyseUserProfile(inputs); + const { scenarios } = portfolioEngine.determineScenarios(inputs, HIGH_CPI_RATES, profile); + + expect(scenarios).toHaveLength(4); + expect(scenarios).toContain('market_standard'); + expect(scenarios).toContain('fast_track'); + expect(scenarios).toContain('inflation_proof'); + expect(scenarios).toContain('stability_first'); + }); + + test('generates only 2 scenarios for low-stability user with low CPI and low LTV', () => { + const inputs = makeInputs({ + stabilityPreference: 2, + propertyPrice: 2000000, + loanAmount: 800000, // 40% LTV + futureFunds: { timeframe: 'none', amount: 0 }, + }); + const profile = portfolioEngine.analyseUserProfile(inputs); + const { scenarios } = portfolioEngine.determineScenarios(inputs, LOW_CPI_RATES, profile); + + expect(scenarios).toHaveLength(2); + expect(scenarios).toEqual(['market_standard', 'fast_track']); + }); + + test('returns reasons for each scenario', () => { + const inputs = makeInputs({ stabilityPreference: 8 }); + const profile = portfolioEngine.analyseUserProfile(inputs); + const { reasons } = portfolioEngine.determineScenarios(inputs, HIGH_CPI_RATES, profile); + + expect(reasons.market_standard).toBeDefined(); + expect(reasons.fast_track).toBeDefined(); + expect(reasons.inflation_proof).toBeDefined(); + expect(reasons.stability_first).toBeDefined(); + }); +}); + +// ── Adaptive Allocation ─────────────────────────────────────────────────────── + +describe('portfolioEngine.getAdaptiveAllocation', () => { + test('Market Standard: adjusts for high stability preference', () => { + const inputs = makeInputs({ stabilityPreference: 8 }); + const profile = portfolioEngine.analyseUserProfile(inputs); + const config = portfolioEngine.getAdaptiveAllocation('market_standard', inputs, DEFAULT_RATES, profile); + + expect(config.termYears).toBe(30); + // High stability → more fixed + const fixedTrack = config.tracks.find((t) => t.type === 'fixed'); + expect(fixedTrack.percentage).toBeGreaterThan(34); + }); + + test('Market Standard: adjusts for low stability preference', () => { + const inputs = makeInputs({ stabilityPreference: 2 }); + const profile = portfolioEngine.analyseUserProfile(inputs); + const config = portfolioEngine.getAdaptiveAllocation('market_standard', inputs, DEFAULT_RATES, profile); + + // Low stability → more prime + const primeTrack = config.tracks.find((t) => t.type === 'prime'); + expect(primeTrack.percentage).toBeGreaterThan(33); + }); + + test('Fast Track: adjusts for near-term future funds', () => { + const inputs = makeInputs({ + futureFunds: { timeframe: 'within_5_years', amount: 200000 }, + }); + const profile = portfolioEngine.analyseUserProfile(inputs); + const config = portfolioEngine.getAdaptiveAllocation('fast_track', inputs, DEFAULT_RATES, profile); + + expect(config.termYears).toBe(20); + // Near-term funds → more prime + const primeTrack = config.tracks.find((t) => t.type === 'prime'); + expect(primeTrack.percentage).toBeGreaterThan(40); + }); + + test('Fast Track: adjusts for risk-averse users', () => { + const inputs = makeInputs({ stabilityPreference: 9 }); + const profile = portfolioEngine.analyseUserProfile(inputs); + const config = portfolioEngine.getAdaptiveAllocation('fast_track', inputs, DEFAULT_RATES, profile); + + // Risk-averse → more fixed even in fast track + const fixedTrack = config.tracks.find((t) => t.type === 'fixed'); + expect(fixedTrack.percentage).toBeGreaterThan(30); + }); + + test('Inflation-Proof: contains NO CPI tracks', () => { + const inputs = makeInputs({ stabilityPreference: 5 }); + const profile = portfolioEngine.analyseUserProfile(inputs); + const config = portfolioEngine.getAdaptiveAllocation('inflation_proof', inputs, DEFAULT_RATES, profile); + + const cpiTrack = config.tracks.find((t) => t.type === 'cpi'); + expect(cpiTrack).toBeUndefined(); + + // Should only have fixed, prime, variable + const trackTypes = config.tracks.map((t) => t.type); + expect(trackTypes).toEqual(expect.arrayContaining(['fixed', 'prime', 'variable'])); + trackTypes.forEach((type) => { + expect(['fixed', 'prime', 'variable']).toContain(type); + }); + }); + + test('Inflation-Proof: adjusts term by stability preference', () => { + const lowStab = portfolioEngine.analyseUserProfile(makeInputs({ stabilityPreference: 3 })); + const midStab = portfolioEngine.analyseUserProfile(makeInputs({ stabilityPreference: 5 })); + const highStab = portfolioEngine.analyseUserProfile(makeInputs({ stabilityPreference: 8 })); + + const lowConfig = portfolioEngine.getAdaptiveAllocation('inflation_proof', makeInputs({ stabilityPreference: 3 }), DEFAULT_RATES, lowStab); + const midConfig = portfolioEngine.getAdaptiveAllocation('inflation_proof', makeInputs({ stabilityPreference: 5 }), DEFAULT_RATES, midStab); + const highConfig = portfolioEngine.getAdaptiveAllocation('inflation_proof', makeInputs({ stabilityPreference: 8 }), DEFAULT_RATES, highStab); + + expect(lowConfig.termYears).toBe(30); + expect(midConfig.termYears).toBe(25); + expect(highConfig.termYears).toBe(22); + }); + + test('Stability-First: has >= 60% fixed allocation', () => { + const inputs = makeInputs({ stabilityPreference: 7 }); + const profile = portfolioEngine.analyseUserProfile(inputs); + const config = portfolioEngine.getAdaptiveAllocation('stability_first', inputs, DEFAULT_RATES, profile); + + const fixedTrack = config.tracks.find((t) => t.type === 'fixed'); + expect(fixedTrack.percentage).toBeGreaterThanOrEqual(60); + }); + + test('Stability-First: very high stability (9-10) gets 70% fixed', () => { + const inputs = makeInputs({ stabilityPreference: 10 }); + const profile = portfolioEngine.analyseUserProfile(inputs); + const config = portfolioEngine.getAdaptiveAllocation('stability_first', inputs, DEFAULT_RATES, profile); + + const fixedTrack = config.tracks.find((t) => t.type === 'fixed'); + expect(fixedTrack.percentage).toBeGreaterThanOrEqual(68); // ~70% after normalization + }); + + test('Stability-First: tight affordability extends term to 30 years', () => { + const inputs = makeInputs({ + stabilityPreference: 7, + targetRepayment: 10000, // 40% of 25000 = tight/stretched + monthlyIncome: 25000, + }); + const profile = portfolioEngine.analyseUserProfile(inputs); + const config = portfolioEngine.getAdaptiveAllocation('stability_first', inputs, DEFAULT_RATES, profile); + + expect(config.termYears).toBe(30); + }); + + test('Stability-First: comfortable affordability shortens term to 22 years', () => { + const inputs = makeInputs({ + stabilityPreference: 7, + targetRepayment: 5000, // 20% of 25000 = comfortable + monthlyIncome: 25000, + }); + const profile = portfolioEngine.analyseUserProfile(inputs); + const config = portfolioEngine.getAdaptiveAllocation('stability_first', inputs, DEFAULT_RATES, profile); + + expect(config.termYears).toBe(22); + }); + + test('all allocations sum to 100%', () => { + const scenarios = ['market_standard', 'fast_track', 'inflation_proof', 'stability_first']; + const inputs = makeInputs({ stabilityPreference: 7 }); + const profile = portfolioEngine.analyseUserProfile(inputs); + + scenarios.forEach((type) => { + const config = portfolioEngine.getAdaptiveAllocation(type, inputs, DEFAULT_RATES, profile); + const total = config.tracks.reduce((sum, t) => sum + t.percentage, 0); + expect(total).toBe(100); + }); + }); +}); + +// ── Financial Calculations ──────────────────────────────────────────────────── + +describe('portfolioEngine.calculatePMT', () => { + test('calculates correct monthly payment for standard loan', () => { + // ₪1,000,000 at 5% for 30 years + const principal = 1000000; + const monthlyRate = 0.05 / 12; + const totalMonths = 30 * 12; + const pmt = portfolioEngine.calculatePMT(principal, monthlyRate, totalMonths); + + // Expected: ~₪5,368.22 + expect(pmt).toBeCloseTo(5368.22, 0); + }); + + test('returns 0 for zero principal', () => { + expect(portfolioEngine.calculatePMT(0, 0.004, 360)).toBe(0); + }); + + test('handles zero interest rate', () => { + const pmt = portfolioEngine.calculatePMT(360000, 0, 360); + expect(pmt).toBe(1000); // 360000 / 360 + }); + + test('returns 0 for zero months', () => { + expect(portfolioEngine.calculatePMT(100000, 0.004, 0)).toBe(0); + }); +}); + +// ── Portfolio Building ──────────────────────────────────────────────────────── + +describe('portfolioEngine.buildPortfolio', () => { + test('builds a complete portfolio with all required fields', () => { + const config = { + termYears: 30, + tracks: [ + { type: 'fixed', percentage: 34, rate: 4.75, rateDisplay: '4.75%' }, + { type: 'prime', percentage: 33, rate: 5.90, rateDisplay: 'P-0.15%' }, + { type: 'cpi', percentage: 33, rate: 3.20, rateDisplay: '3.20% + מדד' }, + ], + }; + const inputs = makeInputs(); + const portfolio = portfolioEngine.buildPortfolio(config, 'market_standard', inputs, DEFAULT_RATES); + + expect(portfolio.id).toBe('market_standard'); + expect(portfolio.type).toBe('market_standard'); + expect(portfolio.name).toBe('Market Standard'); + expect(portfolio.nameHe).toBe('תיק שוק סטנדרטי'); + expect(portfolio.description).toBeDefined(); + expect(portfolio.termYears).toBe(30); + expect(portfolio.tracks).toHaveLength(3); + expect(portfolio.monthlyRepayment).toBeGreaterThan(0); + expect(portfolio.totalCost).toBeGreaterThan(inputs.loanAmount); + expect(portfolio.totalInterest).toBeGreaterThan(0); + expect(typeof portfolio.interestSavings).toBe('number'); + }); + + test('track amounts sum to loan amount', () => { + const config = { + termYears: 30, + tracks: [ + { type: 'fixed', percentage: 40, rate: 4.75, rateDisplay: '4.75%' }, + { type: 'prime', percentage: 30, rate: 5.90, rateDisplay: 'P-0.15%' }, + { type: 'cpi', percentage: 30, rate: 3.20, rateDisplay: '3.20% + מדד' }, + ], + }; + const inputs = makeInputs({ loanAmount: 1000000 }); + const portfolio = portfolioEngine.buildPortfolio(config, 'market_standard', inputs, DEFAULT_RATES); + + const totalAmount = portfolio.tracks.reduce((sum, t) => sum + t.amount, 0); + expect(totalAmount).toBe(1000000); + }); + + test('Fast Track has interest savings compared to Market Standard', () => { + const inputs = makeInputs(); + const profile = portfolioEngine.analyseUserProfile(inputs); + + const msConfig = portfolioEngine.getAdaptiveAllocation('market_standard', inputs, DEFAULT_RATES, profile); + const ftConfig = portfolioEngine.getAdaptiveAllocation('fast_track', inputs, DEFAULT_RATES, profile); + + const msPortfolio = portfolioEngine.buildPortfolio(msConfig, 'market_standard', inputs, DEFAULT_RATES); + const ftPortfolio = portfolioEngine.buildPortfolio(ftConfig, 'fast_track', inputs, DEFAULT_RATES); + + // Fast Track should have lower total interest + expect(ftPortfolio.totalInterest).toBeLessThan(msPortfolio.totalInterest); + // Fast Track should have interest savings > 0 + expect(ftPortfolio.interestSavings).toBeGreaterThan(0); + }); + + test('Market Standard has 0 interest savings (it is the baseline)', () => { + const inputs = makeInputs(); + const profile = portfolioEngine.analyseUserProfile(inputs); + const config = portfolioEngine.getAdaptiveAllocation('market_standard', inputs, DEFAULT_RATES, profile); + const portfolio = portfolioEngine.buildPortfolio(config, 'market_standard', inputs, DEFAULT_RATES); + + expect(portfolio.interestSavings).toBe(0); + }); +}); + +// ── Portfolio Scoring ───────────────────────────────────────────────────────── + +describe('portfolioEngine.scorePortfolios', () => { + function generateTestPortfolios(inputs) { + const profile = portfolioEngine.analyseUserProfile(inputs); + const { scenarios } = portfolioEngine.determineScenarios(inputs, DEFAULT_RATES, profile); + return scenarios.map((type) => { + const config = portfolioEngine.getAdaptiveAllocation(type, inputs, DEFAULT_RATES, profile); + return portfolioEngine.buildPortfolio(config, type, inputs, DEFAULT_RATES); + }); + } + + test('assigns fitness scores to all portfolios', () => { + const inputs = makeInputs({ stabilityPreference: 7 }); + const portfolios = generateTestPortfolios(inputs); + const profile = portfolioEngine.analyseUserProfile(inputs); + const scored = portfolioEngine.scorePortfolios(portfolios, inputs, profile); + + scored.forEach((p) => { + expect(p.fitnessScore).toBeDefined(); + expect(p.fitnessScore).toBeGreaterThanOrEqual(0); + expect(p.fitnessScore).toBeLessThanOrEqual(100); + }); + }); + + test('marks exactly one portfolio as recommended', () => { + const inputs = makeInputs({ stabilityPreference: 7 }); + const portfolios = generateTestPortfolios(inputs); + const profile = portfolioEngine.analyseUserProfile(inputs); + const scored = portfolioEngine.scorePortfolios(portfolios, inputs, profile); + + const recommended = scored.filter((p) => p.recommended); + expect(recommended.length).toBeGreaterThanOrEqual(1); + }); + + test('Stability-First scores highest for risk-averse user', () => { + const inputs = makeInputs({ stabilityPreference: 9 }); + const portfolios = generateTestPortfolios(inputs); + const profile = portfolioEngine.analyseUserProfile(inputs); + const scored = portfolioEngine.scorePortfolios(portfolios, inputs, profile); + + const stabilityFirst = scored.find((p) => p.type === 'stability_first'); + const marketStandard = scored.find((p) => p.type === 'market_standard'); + + // Stability-First should score higher than Market Standard for risk-averse user + if (stabilityFirst && marketStandard) { + expect(stabilityFirst.fitnessScore).toBeGreaterThanOrEqual(marketStandard.fitnessScore); + } + }); + + test('returns empty array for empty input', () => { + const inputs = makeInputs(); + const profile = portfolioEngine.analyseUserProfile(inputs); + const scored = portfolioEngine.scorePortfolios([], inputs, profile); + expect(scored).toEqual([]); + }); + + test('handles null input gracefully', () => { + const inputs = makeInputs(); + const profile = portfolioEngine.analyseUserProfile(inputs); + const scored = portfolioEngine.scorePortfolios(null, inputs, profile); + expect(scored).toBeNull(); + }); +}); + +// ── Edge Cases ──────────────────────────────────────────────────────────────── + +describe('portfolioEngine edge cases', () => { + test('handles minimum valid inputs', () => { + const inputs = makeInputs({ + propertyPrice: 100000, + loanAmount: 50000, + monthlyIncome: 1000, + targetRepayment: 500, + stabilityPreference: 1, + }); + const profile = portfolioEngine.analyseUserProfile(inputs); + const { scenarios } = portfolioEngine.determineScenarios(inputs, DEFAULT_RATES, profile); + + expect(scenarios.length).toBeGreaterThanOrEqual(2); + + scenarios.forEach((type) => { + const config = portfolioEngine.getAdaptiveAllocation(type, inputs, DEFAULT_RATES, profile); + const portfolio = portfolioEngine.buildPortfolio(config, type, inputs, DEFAULT_RATES); + expect(portfolio.monthlyRepayment).toBeGreaterThan(0); + expect(portfolio.totalCost).toBeGreaterThan(0); + }); + }); + + test('handles maximum valid inputs', () => { + const inputs = makeInputs({ + propertyPrice: 50000000, + loanAmount: 30000000, + monthlyIncome: 500000, + additionalIncome: 200000, + targetRepayment: 80000, + stabilityPreference: 10, + }); + const profile = portfolioEngine.analyseUserProfile(inputs); + const { scenarios } = portfolioEngine.determineScenarios(inputs, DEFAULT_RATES, profile); + + expect(scenarios.length).toBeGreaterThanOrEqual(2); + + scenarios.forEach((type) => { + const config = portfolioEngine.getAdaptiveAllocation(type, inputs, DEFAULT_RATES, profile); + const portfolio = portfolioEngine.buildPortfolio(config, type, inputs, DEFAULT_RATES); + expect(portfolio.monthlyRepayment).toBeGreaterThan(0); + expect(portfolio.totalCost).toBeGreaterThan(inputs.loanAmount); + }); + }); + + test('handles missing rate fields gracefully', () => { + const inputs = makeInputs(); + const profile = portfolioEngine.analyseUserProfile(inputs); + const incompleteRates = { fixed: 4.65 }; // Missing cpi, prime, variable + + const config = portfolioEngine.getAdaptiveAllocation('market_standard', inputs, incompleteRates, profile); + expect(config.tracks).toHaveLength(3); + expect(config.tracks.every((t) => t.rate > 0)).toBe(true); + }); +}); diff --git a/src/__tests__/services/wizardService.test.js b/src/__tests__/services/wizardService.test.js new file mode 100644 index 0000000..69df7f1 --- /dev/null +++ b/src/__tests__/services/wizardService.test.js @@ -0,0 +1,448 @@ +/** + * Wizard Service – Unit Tests + * + * Tests the main generatePortfolios flow, AI/rule-based generation, + * and portfolio constraint validation. + */ + +'use strict'; + +// Mock dependencies before requiring the module +jest.mock('../../services/ratesService', () => ({ + getCurrentAverages: jest.fn(), +})); + +jest.mock('openai', () => { + return jest.fn().mockImplementation(() => ({ + chat: { + completions: { + create: jest.fn(), + }, + }, + })); +}); + +const wizardService = require('../../services/wizardService'); +const ratesService = require('../../services/ratesService'); + +// ── Test Fixtures ───────────────────────────────────────────────────────────── + +const DEFAULT_RATES = { + fixed: 4.65, + cpi: 3.15, + prime: 6.05, + variable: 4.95, +}; + +function makeInputs(overrides = {}) { + return { + propertyPrice: 2000000, + loanAmount: 1200000, + monthlyIncome: 25000, + additionalIncome: 0, + targetRepayment: 6000, + futureFunds: { timeframe: 'none', amount: 0 }, + stabilityPreference: 5, + ...overrides, + }; +} + +// ── generatePortfolios ─────────────────────────────────────────────────────── + +describe('wizardService.generatePortfolios', () => { + beforeEach(() => { + ratesService.getCurrentAverages.mockResolvedValue(DEFAULT_RATES); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + test('returns portfolios and metadata', async () => { + const inputs = makeInputs(); + const result = await wizardService.generatePortfolios(inputs, true); + + expect(result).toHaveProperty('portfolios'); + expect(result).toHaveProperty('metadata'); + expect(Array.isArray(result.portfolios)).toBe(true); + expect(result.portfolios.length).toBeGreaterThanOrEqual(2); + }); + + test('always includes Market Standard and Fast Track', async () => { + const inputs = makeInputs({ stabilityPreference: 1 }); + const result = await wizardService.generatePortfolios(inputs, false); + + const types = result.portfolios.map((p) => p.type); + expect(types).toContain('market_standard'); + expect(types).toContain('fast_track'); + }); + + test('includes Stability-First for high stability preference', async () => { + const inputs = makeInputs({ stabilityPreference: 8 }); + const result = await wizardService.generatePortfolios(inputs, true); + + const types = result.portfolios.map((p) => p.type); + expect(types).toContain('stability_first'); + }); + + test('includes Inflation-Proof for moderate stability preference', async () => { + const inputs = makeInputs({ stabilityPreference: 5 }); + const result = await wizardService.generatePortfolios(inputs, true); + + const types = result.portfolios.map((p) => p.type); + expect(types).toContain('inflation_proof'); + }); + + test('generates up to 4 portfolios for qualifying user', async () => { + const inputs = makeInputs({ stabilityPreference: 8 }); + ratesService.getCurrentAverages.mockResolvedValue({ + ...DEFAULT_RATES, + cpi: 3.50, // High CPI + }); + const result = await wizardService.generatePortfolios(inputs, true); + + expect(result.portfolios.length).toBe(4); + }); + + test('metadata includes scenario reasons', async () => { + const inputs = makeInputs({ stabilityPreference: 8 }); + const result = await wizardService.generatePortfolios(inputs, true); + + expect(result.metadata.scenarioReasons).toBeDefined(); + expect(result.metadata.scenariosGenerated).toBeDefined(); + expect(result.metadata.scenariosGenerated).toContain('market_standard'); + }); + + test('metadata includes user profile analysis', async () => { + const inputs = makeInputs(); + const result = await wizardService.generatePortfolios(inputs, true); + + expect(result.metadata.inputSummary.ltvClass).toBeDefined(); + expect(result.metadata.inputSummary.affordability).toBeDefined(); + expect(result.metadata.inputSummary.riskTolerance).toBeDefined(); + expect(result.metadata.inputSummary.hasFutureFunds).toBeDefined(); + }); + + test('portfolios have fitness scores', async () => { + const inputs = makeInputs(); + const result = await wizardService.generatePortfolios(inputs, true); + + result.portfolios.forEach((p) => { + expect(p.fitnessScore).toBeDefined(); + expect(typeof p.fitnessScore).toBe('number'); + expect(p.fitnessScore).toBeGreaterThanOrEqual(0); + expect(p.fitnessScore).toBeLessThanOrEqual(100); + }); + }); + + test('exactly one portfolio is recommended', async () => { + const inputs = makeInputs(); + const result = await wizardService.generatePortfolios(inputs, true); + + const recommended = result.portfolios.filter((p) => p.recommended); + expect(recommended.length).toBeGreaterThanOrEqual(1); + }); + + test('uses fallback rates when ratesService fails', async () => { + ratesService.getCurrentAverages.mockRejectedValue(new Error('Firestore unavailable')); + + const inputs = makeInputs(); + const result = await wizardService.generatePortfolios(inputs, false); + + expect(result.portfolios.length).toBeGreaterThanOrEqual(2); + // Should still work with fallback rates + result.portfolios.forEach((p) => { + expect(p.monthlyRepayment).toBeGreaterThan(0); + }); + }); + + test('consent flag is passed through to metadata', async () => { + const inputs = makeInputs(); + + const resultTrue = await wizardService.generatePortfolios(inputs, true); + expect(resultTrue.metadata.consent).toBe(true); + + const resultFalse = await wizardService.generatePortfolios(inputs, false); + expect(resultFalse.metadata.consent).toBe(false); + }); + + test('portfolios do not contain internal fields', async () => { + const inputs = makeInputs(); + const result = await wizardService.generatePortfolios(inputs, true); + + result.portfolios.forEach((p) => { + expect(p._generationMethod).toBeUndefined(); + expect(p.scoreBreakdown).toBeUndefined(); + }); + }); + + test('each portfolio has all required fields', async () => { + const inputs = makeInputs({ stabilityPreference: 8 }); + ratesService.getCurrentAverages.mockResolvedValue({ + ...DEFAULT_RATES, + cpi: 3.50, + }); + const result = await wizardService.generatePortfolios(inputs, true); + + result.portfolios.forEach((p) => { + expect(p.id).toBeDefined(); + expect(p.type).toBeDefined(); + expect(p.name).toBeDefined(); + expect(p.nameHe).toBeDefined(); + expect(p.description).toBeDefined(); + expect(p.termYears).toBeGreaterThan(0); + expect(Array.isArray(p.tracks)).toBe(true); + expect(p.tracks.length).toBeGreaterThan(0); + expect(p.monthlyRepayment).toBeGreaterThan(0); + expect(p.totalCost).toBeGreaterThan(0); + expect(typeof p.totalInterest).toBe('number'); + expect(typeof p.interestSavings).toBe('number'); + expect(typeof p.recommended).toBe('boolean'); + expect(typeof p.fitnessScore).toBe('number'); + + // Each track has required fields + p.tracks.forEach((t) => { + expect(t.name).toBeDefined(); + expect(t.type).toBeDefined(); + expect(t.percentage).toBeGreaterThan(0); + expect(t.rate).toBeGreaterThan(0); + expect(t.rateDisplay).toBeDefined(); + expect(t.amount).toBeGreaterThan(0); + expect(t.monthlyPayment).toBeGreaterThan(0); + }); + }); + }); +}); + +// ── validatePortfolioConstraints ────────────────────────────────────────────── + +describe('wizardService.validatePortfolioConstraints', () => { + test('fixes percentages that do not sum to 100', () => { + const portfolio = { + type: 'market_standard', + tracks: [ + { type: 'fixed', percentage: 34 }, + { type: 'prime', percentage: 33 }, + { type: 'cpi', percentage: 30 }, // Sum = 97 + ], + }; + + const validated = wizardService.validatePortfolioConstraints(portfolio); + const total = validated.tracks.reduce((sum, t) => sum + t.percentage, 0); + expect(total).toBe(100); + }); + + test('removes CPI tracks from Inflation-Proof portfolio', () => { + const portfolio = { + type: 'inflation_proof', + tracks: [ + { type: 'fixed', percentage: 40 }, + { type: 'prime', percentage: 30 }, + { type: 'cpi', percentage: 30 }, // Should be removed + ], + }; + + const validated = wizardService.validatePortfolioConstraints(portfolio); + const hasCpi = validated.tracks.some((t) => t.type === 'cpi'); + expect(hasCpi).toBe(false); + + // Remaining tracks should sum to 100 + const total = validated.tracks.reduce((sum, t) => sum + t.percentage, 0); + expect(total).toBe(100); + }); + + test('ensures Stability-First has >= 60% fixed', () => { + const portfolio = { + type: 'stability_first', + tracks: [ + { type: 'fixed', percentage: 45 }, // Below 60% + { type: 'cpi', percentage: 35 }, + { type: 'prime', percentage: 20 }, + ], + }; + + const validated = wizardService.validatePortfolioConstraints(portfolio); + const fixedPct = validated.tracks + .filter((t) => t.type === 'fixed') + .reduce((sum, t) => sum + t.percentage, 0); + expect(fixedPct).toBeGreaterThanOrEqual(60); + }); + + test('does not modify valid portfolios', () => { + const portfolio = { + type: 'market_standard', + tracks: [ + { type: 'fixed', percentage: 34 }, + { type: 'prime', percentage: 33 }, + { type: 'cpi', percentage: 33 }, + ], + }; + + const validated = wizardService.validatePortfolioConstraints(portfolio); + expect(validated.tracks[0].percentage).toBe(34); + expect(validated.tracks[1].percentage).toBe(33); + expect(validated.tracks[2].percentage).toBe(33); + }); +}); + +// ── Backward Compatibility ──────────────────────────────────────────────────── + +describe('wizardService backward compatibility', () => { + test('exports determineScenarios function', () => { + expect(typeof wizardService.determineScenarios).toBe('function'); + }); + + test('exports getRuleBasedConfig function', () => { + expect(typeof wizardService.getRuleBasedConfig).toBe('function'); + }); + + test('exports calculatePMT function', () => { + expect(typeof wizardService.calculatePMT).toBe('function'); + }); + + test('exports all scenario type constants', () => { + expect(wizardService.SCENARIO_TYPES).toBeDefined(); + expect(wizardService.SCENARIO_TYPES.MARKET_STANDARD).toBe('market_standard'); + expect(wizardService.SCENARIO_TYPES.FAST_TRACK).toBe('fast_track'); + expect(wizardService.SCENARIO_TYPES.INFLATION_PROOF).toBe('inflation_proof'); + expect(wizardService.SCENARIO_TYPES.STABILITY_FIRST).toBe('stability_first'); + }); + + test('exports Hebrew and English name constants', () => { + expect(wizardService.SCENARIO_NAMES_HE).toBeDefined(); + expect(wizardService.SCENARIO_NAMES_EN).toBeDefined(); + expect(wizardService.TRACK_LABELS_HE).toBeDefined(); + }); + + test('determineScenarios returns array of scenario types', () => { + const inputs = makeInputs({ stabilityPreference: 5 }); + const scenarios = wizardService.determineScenarios(inputs, DEFAULT_RATES); + + expect(Array.isArray(scenarios)).toBe(true); + expect(scenarios).toContain('market_standard'); + expect(scenarios).toContain('fast_track'); + }); + + test('getRuleBasedConfig returns config with termYears and tracks', () => { + const inputs = makeInputs(); + const config = wizardService.getRuleBasedConfig('market_standard', inputs, DEFAULT_RATES); + + expect(config.termYears).toBeDefined(); + expect(Array.isArray(config.tracks)).toBe(true); + expect(config.tracks.length).toBeGreaterThan(0); + }); +}); + +// ── Conditional Logic Integration Tests ─────────────────────────────────────── + +describe('wizardService conditional logic integration', () => { + beforeEach(() => { + ratesService.getCurrentAverages.mockResolvedValue(DEFAULT_RATES); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + test('risk-tolerant user with low CPI gets only 2 portfolios', async () => { + ratesService.getCurrentAverages.mockResolvedValue({ + ...DEFAULT_RATES, + cpi: 2.00, // Low CPI + }); + + const inputs = makeInputs({ + stabilityPreference: 2, + propertyPrice: 2000000, + loanAmount: 800000, // 40% LTV + futureFunds: { timeframe: 'none', amount: 0 }, + }); + + const result = await wizardService.generatePortfolios(inputs, false); + expect(result.portfolios.length).toBe(2); + expect(result.portfolios.map((p) => p.type)).toEqual(['market_standard', 'fast_track']); + }); + + test('user with future funds gets Inflation-Proof', async () => { + ratesService.getCurrentAverages.mockResolvedValue({ + ...DEFAULT_RATES, + cpi: 2.00, // Low CPI + }); + + const inputs = makeInputs({ + stabilityPreference: 2, + propertyPrice: 2000000, + loanAmount: 800000, + futureFunds: { timeframe: 'within_5_years', amount: 200000 }, + }); + + const result = await wizardService.generatePortfolios(inputs, false); + const types = result.portfolios.map((p) => p.type); + expect(types).toContain('inflation_proof'); + }); + + test('tight budget user with moderate stability gets Stability-First', async () => { + const inputs = makeInputs({ + stabilityPreference: 6, + targetRepayment: 10000, // 40% of 25000 = stretched + monthlyIncome: 25000, + }); + + const result = await wizardService.generatePortfolios(inputs, true); + const types = result.portfolios.map((p) => p.type); + expect(types).toContain('stability_first'); + }); + + test('Inflation-Proof portfolio has no CPI tracks', async () => { + const inputs = makeInputs({ stabilityPreference: 5 }); + const result = await wizardService.generatePortfolios(inputs, true); + + const inflationProof = result.portfolios.find((p) => p.type === 'inflation_proof'); + if (inflationProof) { + const hasCpi = inflationProof.tracks.some((t) => t.type === 'cpi'); + expect(hasCpi).toBe(false); + } + }); + + test('Stability-First portfolio has >= 60% fixed', async () => { + const inputs = makeInputs({ stabilityPreference: 8 }); + const result = await wizardService.generatePortfolios(inputs, true); + + const stabilityFirst = result.portfolios.find((p) => p.type === 'stability_first'); + if (stabilityFirst) { + const fixedPct = stabilityFirst.tracks + .filter((t) => t.type === 'fixed') + .reduce((sum, t) => sum + t.percentage, 0); + expect(fixedPct).toBeGreaterThanOrEqual(60); + } + }); + + test('Fast Track always has 20-year term', async () => { + const inputs = makeInputs(); + const result = await wizardService.generatePortfolios(inputs, true); + + const fastTrack = result.portfolios.find((p) => p.type === 'fast_track'); + expect(fastTrack.termYears).toBe(20); + }); + + test('Market Standard always has 30-year term', async () => { + const inputs = makeInputs(); + const result = await wizardService.generatePortfolios(inputs, true); + + const marketStandard = result.portfolios.find((p) => p.type === 'market_standard'); + expect(marketStandard.termYears).toBe(30); + }); + + test('all portfolio track percentages sum to 100', async () => { + const inputs = makeInputs({ stabilityPreference: 8 }); + ratesService.getCurrentAverages.mockResolvedValue({ + ...DEFAULT_RATES, + cpi: 3.50, + }); + const result = await wizardService.generatePortfolios(inputs, true); + + result.portfolios.forEach((p) => { + const total = p.tracks.reduce((sum, t) => sum + t.percentage, 0); + expect(total).toBe(100); + }); + }); +}); diff --git a/src/config/collections.js b/src/config/collections.js index 452acbd..88b4041 100644 --- a/src/config/collections.js +++ b/src/config/collections.js @@ -10,26 +10,37 @@ * * Collections * ─────────── - * users – one document per registered user (doc ID == user UID) - * financials – one document per user (doc ID == userId) - * offers – many documents per user (auto-generated doc IDs) + * users – one document per registered user (doc ID == user UID) + * financials – one document per user (doc ID == userId) + * offers – many documents per user (auto-generated doc IDs) + * mortgage_rates – BOI average mortgage rates (doc ID == date or 'latest') + * community_profiles – anonymized user profiles for community intelligence + * payments – Stripe payment records (doc ID == Stripe session ID) * * Indexes * ─────── - * users: email (single-field, ascending) – for login lookup - * financials: userId (single-field, ascending) – for profile fetch - * offers: (userId ASC, createdAt DESC) – composite, for list queries + * users: email (single-field, ascending) – for login lookup + * financials: userId (single-field, ascending) – for profile fetch + * offers: (userId ASC, createdAt DESC) – composite, for list queries + * mortgage_rates: (date DESC) – for historical queries + * community_profiles: (incomeBin ASC) – for range queries + * (incomeBin ASC, loanBin ASC) – compound for matching + * profileHash (single-field) – for exact lookups + * payments: (userId ASC, createdAt DESC) – for payment history */ 'use strict'; // ─── Collection Names ──────────────────────────────────────────────────────── -/** @type {Readonly<{USERS: string, FINANCIALS: string, OFFERS: string}>} */ +/** @type {Readonly<{USERS: string, FINANCIALS: string, OFFERS: string, MORTGAGE_RATES: string, COMMUNITY_PROFILES: string, PAYMENTS: string}>} */ const COLLECTIONS = Object.freeze({ USERS: 'users', FINANCIALS: 'financials', OFFERS: 'offers', + MORTGAGE_RATES: 'mortgage_rates', + COMMUNITY_PROFILES: 'community_profiles', + PAYMENTS: 'payments', }); // ─── Offer Status Enum ─────────────────────────────────────────────────────── @@ -41,6 +52,23 @@ const OFFER_STATUS = Object.freeze({ ERROR: 'error', }); +// ─── Rates Source Enum ─────────────────────────────────────────────────────── + +/** @type {Readonly<{BOI: string, FALLBACK: string}>} */ +const RATES_SOURCE = Object.freeze({ + BOI: 'bank_of_israel', + FALLBACK: 'fallback', +}); + +// ─── Payment Status Enum ───────────────────────────────────────────────────── + +/** @type {Readonly<{PENDING: string, COMPLETED: string, EXPIRED: string}>} */ +const PAYMENT_STATUS = Object.freeze({ + PENDING: 'pending', + COMPLETED: 'completed', + EXPIRED: 'expired', +}); + // ─── Document Factories ────────────────────────────────────────────────────── /** @@ -67,6 +95,7 @@ function createUserDocument({ id, email, password, phone = '', verified = false phone: phone || '', verified: Boolean(verified), refreshToken: null, + paidAnalyses: false, createdAt: now, updatedAt: now, }; @@ -190,6 +219,155 @@ function createOfferDocument({ }; } +/** + * Build a new `mortgage_rates` document. + * + * @param {object} params + * @param {string} params.date - ISO date string of the fetch + * @param {object} params.fetchPeriod - Period covered + * @param {string} params.fetchPeriod.start - Start period (YYYY-MM) + * @param {string} params.fetchPeriod.end - End period (YYYY-MM) + * @param {object} params.tracks - Track data by type + * @param {object} params.averages - Flat averages { fixed, cpi, prime, variable } + * @param {string} params.source - Data source ('bank_of_israel' | 'fallback') + * @param {string} [params.sourceUrl] - URL of the data source + * @returns {object} Firestore-ready mortgage_rates document + */ +function createMortgageRatesDocument({ + date, + fetchPeriod, + tracks, + averages, + source, + sourceUrl = 'https://www.boi.org.il/en/economic-roles/statistics/', +}) { + if (!date) throw new Error('createMortgageRatesDocument: date is required'); + if (!tracks || typeof tracks !== 'object') { + throw new Error('createMortgageRatesDocument: tracks object is required'); + } + if (!averages || typeof averages !== 'object') { + throw new Error('createMortgageRatesDocument: averages object is required'); + } + if (!source) throw new Error('createMortgageRatesDocument: source is required'); + + return { + date, + fetchPeriod: fetchPeriod || null, + tracks, + averages: { + fixed: averages.fixed ?? null, + cpi: averages.cpi ?? null, + prime: averages.prime ?? null, + variable: averages.variable ?? null, + }, + source, + sourceUrl, + updatedAt: new Date().toISOString(), + }; +} + +/** + * Build a new `community_profiles` document. + * + * Stores an anonymized user profile for community intelligence matching. + * No PII is stored – only binned financial data and bank/branch/rates. + * + * @param {object} params + * @param {string} params.profileHash - SHA-256 hash of binned profile + * @param {number} params.incomeBin - Binned monthly income + * @param {number} params.loanBin - Binned loan amount + * @param {number} params.ltvBin - Binned LTV percentage + * @param {number} params.stabilityBin - Binned stability preference + * @param {string} [params.bank] - Bank name (Hebrew) + * @param {string} [params.branch] - Branch name (Hebrew) + * @param {object} [params.rates] - Actual rates received + * @param {number} [params.rates.fixed] - Fixed rate + * @param {number} [params.rates.cpi] - CPI-indexed rate + * @param {number} [params.rates.prime] - Prime rate + * @param {number} [params.rates.variable] - Variable rate + * @param {number} [params.weightedRate] - Weighted average rate + * @returns {object} Firestore-ready community_profiles document + */ +function createCommunityProfileDocument({ + profileHash, + incomeBin, + loanBin, + ltvBin, + stabilityBin, + bank = null, + branch = null, + rates = null, + weightedRate = null, +}) { + if (!profileHash) throw new Error('createCommunityProfileDocument: profileHash is required'); + if (incomeBin === undefined || incomeBin === null) { + throw new Error('createCommunityProfileDocument: incomeBin is required'); + } + if (loanBin === undefined || loanBin === null) { + throw new Error('createCommunityProfileDocument: loanBin is required'); + } + if (ltvBin === undefined || ltvBin === null) { + throw new Error('createCommunityProfileDocument: ltvBin is required'); + } + if (stabilityBin === undefined || stabilityBin === null) { + throw new Error('createCommunityProfileDocument: stabilityBin is required'); + } + + const now = new Date().toISOString(); + return { + profileHash, + incomeBin: Number(incomeBin), + loanBin: Number(loanBin), + ltvBin: Number(ltvBin), + stabilityBin: Number(stabilityBin), + bank: bank || null, + branch: branch || null, + rates: rates || null, + weightedRate: weightedRate != null ? Number(weightedRate) : null, + consent: true, + createdAt: now, + updatedAt: now, + }; +} + +/** + * Build a new `payments` document. + * + * Stores a Stripe payment record for audit trail. + * + * @param {object} params + * @param {string} params.sessionId - Stripe Checkout Session ID (also doc ID) + * @param {string} params.userId - User's Firestore ID + * @param {string} [params.portfolioId] - Optional linked portfolio ID + * @param {string} [params.product] - Product identifier (default 'expert_analysis') + * @param {string} [params.status] - Payment status (default 'pending') + * @returns {object} Firestore-ready payments document + */ +function createPaymentDocument({ + sessionId, + userId, + portfolioId = null, + product = 'expert_analysis', + status = PAYMENT_STATUS.PENDING, +}) { + if (!sessionId) throw new Error('createPaymentDocument: sessionId is required'); + if (!userId) throw new Error('createPaymentDocument: userId is required'); + if (!PAYMENT_STATUS_VALUES.includes(status)) { + throw new Error(`createPaymentDocument: invalid status '${status}'`); + } + + const now = new Date().toISOString(); + return { + sessionId, + userId, + portfolioId: portfolioId || null, + product, + status, + createdAt: now, + updatedAt: now, + }; +} + // ─── Field Validators ──────────────────────────────────────────────────────── /** @@ -255,6 +433,87 @@ function validateOfferDocument(doc) { return { valid: errors.length === 0, errors }; } +/** + * Validate a mortgage_rates document's required fields. + * + * @param {object} doc - Partial or full mortgage_rates document + * @returns {{ valid: boolean, errors: string[] }} + */ +function validateMortgageRatesDocument(doc) { + const errors = []; + if (!doc.date || typeof doc.date !== 'string') errors.push('date must be a non-empty string'); + if (!doc.tracks || typeof doc.tracks !== 'object') errors.push('tracks must be an object'); + if (!doc.averages || typeof doc.averages !== 'object') errors.push('averages must be an object'); + if (!doc.source || typeof doc.source !== 'string') errors.push('source must be a non-empty string'); + + // Validate track types if tracks exist + if (doc.tracks && typeof doc.tracks === 'object') { + const validTracks = ['fixed', 'cpi', 'prime', 'variable']; + for (const key of Object.keys(doc.tracks)) { + if (!validTracks.includes(key)) { + errors.push(`tracks contains unknown track type: ${key}`); + } + } + } + + return { valid: errors.length === 0, errors }; +} + +/** + * Validate a community_profiles document's required fields. + * + * @param {object} doc - Partial or full community_profiles document + * @returns {{ valid: boolean, errors: string[] }} + */ +function validateCommunityProfileDocument(doc) { + const errors = []; + if (!doc.profileHash || typeof doc.profileHash !== 'string') { + errors.push('profileHash must be a non-empty string'); + } + if (typeof doc.incomeBin !== 'number' || doc.incomeBin < 0) { + errors.push('incomeBin must be a non-negative number'); + } + if (typeof doc.loanBin !== 'number' || doc.loanBin < 0) { + errors.push('loanBin must be a non-negative number'); + } + if (typeof doc.ltvBin !== 'number' || doc.ltvBin < 0) { + errors.push('ltvBin must be a non-negative number'); + } + if (typeof doc.stabilityBin !== 'number') { + errors.push('stabilityBin must be a number'); + } + if (doc.consent !== true) { + errors.push('consent must be true'); + } + if (doc.rates !== null && typeof doc.rates !== 'object') { + errors.push('rates must be an object or null'); + } + if (doc.weightedRate !== null && typeof doc.weightedRate !== 'number') { + errors.push('weightedRate must be a number or null'); + } + return { valid: errors.length === 0, errors }; +} + +/** + * Validate a payments document's required fields. + * + * @param {object} doc - Partial or full payments document + * @returns {{ valid: boolean, errors: string[] }} + */ +function validatePaymentDocument(doc) { + const errors = []; + if (!doc.sessionId || typeof doc.sessionId !== 'string') { + errors.push('sessionId must be a non-empty string'); + } + if (!doc.userId || typeof doc.userId !== 'string') { + errors.push('userId must be a non-empty string'); + } + if (!PAYMENT_STATUS_VALUES.includes(doc.status)) { + errors.push(`status must be one of: ${PAYMENT_STATUS_VALUES.join(', ')}`); + } + return { valid: errors.length === 0, errors }; +} + // ─── Index Definitions (documentation) ────────────────────────────────────── /** @@ -287,6 +546,42 @@ const INDEX_DEFINITIONS = Object.freeze([ ], type: 'composite', }, + { + collection: COLLECTIONS.MORTGAGE_RATES, + description: 'Single-field index on date (DESC) for latest rates query', + fields: [{ fieldPath: 'date', order: 'DESCENDING' }], + type: 'single', + }, + { + collection: COLLECTIONS.COMMUNITY_PROFILES, + description: 'Single-field index on profileHash for exact lookups', + fields: [{ fieldPath: 'profileHash', order: 'ASCENDING' }], + type: 'single', + }, + { + collection: COLLECTIONS.COMMUNITY_PROFILES, + description: 'Composite index on incomeBin (ASC) for range queries with ordering', + fields: [{ fieldPath: 'incomeBin', order: 'ASCENDING' }], + type: 'single', + }, + { + collection: COLLECTIONS.COMMUNITY_PROFILES, + description: 'Composite index on incomeBin (ASC) + loanBin (ASC) for compound matching', + fields: [ + { fieldPath: 'incomeBin', order: 'ASCENDING' }, + { fieldPath: 'loanBin', order: 'ASCENDING' }, + ], + type: 'composite', + }, + { + collection: COLLECTIONS.PAYMENTS, + description: 'Composite index on userId (ASC) + createdAt (DESC) for payment history', + fields: [ + { fieldPath: 'userId', order: 'ASCENDING' }, + { fieldPath: 'createdAt', order: 'DESCENDING' }, + ], + type: 'composite', + }, ]); // ─── Firestore Index Configuration (firestore.indexes.json format) ─────────── @@ -305,6 +600,22 @@ const FIRESTORE_INDEXES = Object.freeze({ { fieldPath: 'createdAt', order: 'DESCENDING' }, ], }, + { + collectionGroup: COLLECTIONS.COMMUNITY_PROFILES, + queryScope: 'COLLECTION', + fields: [ + { fieldPath: 'incomeBin', order: 'ASCENDING' }, + { fieldPath: 'loanBin', order: 'ASCENDING' }, + ], + }, + { + collectionGroup: COLLECTIONS.PAYMENTS, + queryScope: 'COLLECTION', + fields: [ + { fieldPath: 'userId', order: 'ASCENDING' }, + { fieldPath: 'createdAt', order: 'DESCENDING' }, + ], + }, ], fieldOverrides: [], }); @@ -314,6 +625,12 @@ const FIRESTORE_INDEXES = Object.freeze({ /** Array of valid offer status strings (for quick includes() checks). */ const OFFER_STATUS_VALUES = Object.values(OFFER_STATUS); +/** Array of valid rates source strings. */ +const RATES_SOURCE_VALUES = Object.values(RATES_SOURCE); + +/** Array of valid payment status strings. */ +const PAYMENT_STATUS_VALUES = Object.values(PAYMENT_STATUS); + // ─── Exports ───────────────────────────────────────────────────────────────── module.exports = { @@ -324,15 +641,29 @@ module.exports = { OFFER_STATUS, OFFER_STATUS_VALUES, + // Rates source enum + RATES_SOURCE, + RATES_SOURCE_VALUES, + + // Payment status enum + PAYMENT_STATUS, + PAYMENT_STATUS_VALUES, + // Document factories createUserDocument, createFinancialDocument, createOfferDocument, + createMortgageRatesDocument, + createCommunityProfileDocument, + createPaymentDocument, // Field validators validateUserDocument, validateFinancialDocument, validateOfferDocument, + validateMortgageRatesDocument, + validateCommunityProfileDocument, + validatePaymentDocument, // Index definitions (documentation + CLI config) INDEX_DEFINITIONS, diff --git a/src/config/devConfig.js b/src/config/devConfig.js new file mode 100644 index 0000000..0cb5498 --- /dev/null +++ b/src/config/devConfig.js @@ -0,0 +1 @@ +module.exports = { USE_WIZARD_MOCK: true }; \ No newline at end of file diff --git a/src/config/firebase.js b/src/config/firebase.js index d7e09ac..a1e944f 100644 --- a/src/config/firebase.js +++ b/src/config/firebase.js @@ -28,10 +28,10 @@ const logger = require('../utils/logger'); */ function buildCredential() { // Option A: file-based service account (GOOGLE_APPLICATION_CREDENTIALS) - if (process.env.GOOGLE_APPLICATION_CREDENTIALS) { +/* if (process.env.GOOGLE_APPLICATION_CREDENTIALS) { logger.info('Firebase Admin: using GOOGLE_APPLICATION_CREDENTIALS file'); return admin.credential.applicationDefault(); - } + }*/ // Option B: individual env vars (CI / Render) const { FIREBASE_PROJECT_ID, FIREBASE_CLIENT_EMAIL, FIREBASE_PRIVATE_KEY } = process.env; diff --git a/src/controllers/analysisController.js b/src/controllers/analysisController.js index d096743..cbf7919 100644 --- a/src/controllers/analysisController.js +++ b/src/controllers/analysisController.js @@ -1,38 +1,29 @@ /** * Analysis Controller * - * Returns the full offer document (including AI analysis results) for a - * specific offer. The offer must belong to the authenticated user. + * Handles analysis-related endpoints: + * GET /api/v1/analysis/:id – get full offer analysis + * POST /api/v1/analysis/enhanced/:offerId – generate enhanced report (paid) * - * Route: - * GET /api/v1/analysis/:id – get full OfferShape for an offer - * - * Response shape (per architecture contract): - * { - * success: true, - * data: { - * id: string - * userId: string - * originalFile: { url: string, mimetype: string } - * extractedData: { bank: string, amount: number|null, rate: number|null, term: number|null } - * analysis: { recommendedRate: number|null, savings: number|null, aiReasoning: string } - * status: 'pending'|'analyzed'|'error' - * createdAt: ISO string - * updatedAt: ISO string - * } - * } + * The enhanced analysis compares a user's real bank offer (OCR-extracted) + * against their selected optimized portfolio model, generating: + * - Track-by-track comparison + * - Mortgage tricks and strategies + * - Personalized Hebrew negotiation script + * - Strategic insights */ 'use strict'; const offerService = require('../services/offerService'); +const reportService = require('../services/reportService'); const logger = require('../utils/logger'); /** * GET /api/v1/analysis/:id * * Fetches the full offer document (including analysis sub-object) from - * Firestore and returns it. Ownership is enforced via findByIdAndUserId. + * Firestore and returns it. Ownership is enforced via findByIdAndUserId. * * @param {import('express').Request} req * @param {import('express').Response} res @@ -62,3 +53,100 @@ exports.getAnalysis = async (req, res) => { return res.status(500).json({ success: false, message: 'Server error' }); } }; + +/** + * POST /api/v1/analysis/enhanced/:offerId + * + * Generates an enhanced analysis report comparing the user's real bank + * offer (OCR-extracted) to their selected optimized portfolio model. + * + * Requires: + * - Authentication (protect middleware) + * - Paid access (requirePaidAccess middleware) + * - The offer must be in 'analyzed' status (OCR completed) + * + * Request body: + * { + * portfolioId: string, // Portfolio scenario type + * portfolio: { // Full portfolio object + * id: string, + * name: string, + * nameHe: string, + * termYears: number, + * tracks: Array<{ type, percentage, rate, rateDisplay, amount }>, + * monthlyRepayment: number, + * totalCost: number, + * totalInterest: number + * } + * } + * + * Response: + * { + * success: true, + * data: { + * offerId: string, + * portfolioId: string, + * portfolioName: string, + * portfolioNameHe: string, + * generatedAt: ISO string, + * processingTimeMs: number, + * comparison: { ... }, + * tricks: Array<{ nameHe, nameEn, descriptionHe, descriptionEn, potentialSavings, riskLevel, applicability }>, + * negotiationScript: string (Hebrew), + * insights: Array<{ titleHe, titleEn, bodyHe, bodyEn, icon }>, + * summary: string, + * summaryHe: string + * } + * } + * + * @param {import('express').Request} req + * @param {import('express').Response} res + */ +exports.getEnhancedAnalysis = async (req, res) => { + try { + const userId = req.user.id; + const offerId = req.params.offerId; + + if (!offerId) { + return res.status(400).json({ + success: false, + message: 'Offer ID is required', + }); + } + + const { portfolio } = req.body; + + if (!portfolio) { + return res.status(400).json({ + success: false, + message: 'Portfolio data is required in the request body', + }); + } + + // Generate the enhanced report + const report = await reportService.generateEnhancedReport( + offerId, + userId, + portfolio + ); + + return res.status(200).json({ + success: true, + data: report, + }); + } catch (err) { + // Handle known error types with appropriate status codes + if (err.statusCode) { + return res.status(err.statusCode).json({ + success: false, + message: err.message, + }); + } + + logger.error(`analysisController.getEnhancedAnalysis error: ${err.message}`); + return res.status(500).json({ + success: false, + message: 'Failed to generate enhanced analysis report', + }); + } +}; diff --git a/src/controllers/authController.js b/src/controllers/authController.js index d81f2a3..3e4b09a 100644 --- a/src/controllers/authController.js +++ b/src/controllers/authController.js @@ -324,12 +324,15 @@ exports.googleAuth = async (req, res) => { try { const { idToken } = req.body; + // ── Step 1: Verify the Firebase ID token via Admin SDK ──────────────────── // verifyIdToken checks the token signature, audience (project ID), // issuer, and expiry. It rejects replayed or tampered tokens. let decodedToken; try { decodedToken = await admin.auth().verifyIdToken(idToken); + console.log('✅ Decoded:', decodedToken); + } catch (firebaseErr) { logger.warn( `authController.googleAuth: Firebase token verification failed – ${firebaseErr.message}` diff --git a/src/controllers/paymentController.js b/src/controllers/paymentController.js new file mode 100644 index 0000000..8c3d33f --- /dev/null +++ b/src/controllers/paymentController.js @@ -0,0 +1,188 @@ +/** + * Payment Controller + * + * Handles HTTP requests for Stripe payment integration: + * POST /api/v1/stripe/checkout – Create a Stripe Checkout Session (auth required) + * POST /api/v1/stripe/webhook – Handle Stripe webhook events (Stripe signature) + * + * The checkout endpoint creates a Stripe Checkout Session for the expert + * analysis product (₪149). After successful payment, the webhook sets + * `paidAnalyses: true` on the user's Firestore document. + * + * @module controllers/paymentController + */ + +'use strict'; + +const paymentService = require('../services/paymentService'); +const logger = require('../utils/logger'); +const { sendSuccess, sendError } = require('../utils/response'); + +/** + * POST /api/v1/stripe/checkout + * + * Creates a Stripe Checkout Session for the expert analysis product. + * Requires authentication (protect middleware). + * + * Request body: + * { + * portfolioId: string (optional), + * successUrl: string (required), + * cancelUrl: string (optional) + * } + * + * Response: + * { + * success: true, + * data: { + * sessionId: string, + * url: string + * } + * } + * + * @param {import('express').Request} req + * @param {import('express').Response} res + */ +exports.createCheckout = async (req, res) => { + try { + const userId = req.user.id; + const userEmail = req.user.email; + const { portfolioId, successUrl, cancelUrl } = req.body; + + logger.info(`paymentController.createCheckout: user ${userId} requesting checkout`, { + portfolioId: portfolioId || 'none', + }); + + const result = await paymentService.createCheckoutSession({ + userId, + userEmail, + portfolioId: portfolioId || null, + successUrl, + cancelUrl: cancelUrl || undefined, + }); + + return sendSuccess(res, result, 'Checkout session created successfully'); + } catch (err) { + // Handle known error types with appropriate status codes + if (err.statusCode) { + return sendError( + res, + err.message, + err.statusCode, + err.errorCode || 'CHECKOUT_ERROR' + ); + } + + logger.error(`paymentController.createCheckout error: ${err.message}`); + return sendError( + res, + 'Failed to create checkout session', + 500, + 'CHECKOUT_ERROR' + ); + } +}; + +/** + * POST /api/v1/stripe/webhook + * + * Handles incoming Stripe webhook events. + * Verifies the event signature using the Stripe webhook secret. + * + * IMPORTANT: This endpoint must receive the raw request body (not parsed + * as JSON) for signature verification to work. The route is configured + * with express.raw() middleware instead of express.json(). + * + * Response: Always returns 200 to acknowledge receipt (even for unhandled + * event types) to prevent Stripe from retrying. + * + * @param {import('express').Request} req + * @param {import('express').Response} res + */ +exports.handleWebhook = async (req, res) => { + const signature = req.headers['stripe-signature']; + + if (!signature) { + logger.warn('paymentController.handleWebhook: missing Stripe-Signature header'); + return res.status(400).json({ + success: false, + message: 'Missing Stripe-Signature header', + }); + } + + let event; + try { + event = paymentService.constructWebhookEvent(req.body, signature); + } catch (err) { + logger.warn(`paymentController.handleWebhook: signature verification failed: ${err.message}`); + return res.status(err.statusCode || 400).json({ + success: false, + message: err.message || 'Webhook signature verification failed', + }); + } + + try { + const result = await paymentService.handleWebhookEvent(event); + + logger.info(`paymentController.handleWebhook: ${event.type} processed`, { + handled: result.handled, + message: result.message, + }); + + // Always return 200 to acknowledge receipt + return res.status(200).json({ + received: true, + type: event.type, + handled: result.handled, + }); + } catch (err) { + logger.error(`paymentController.handleWebhook: error processing ${event.type}: ${err.message}`); + // Return 500 so Stripe retries the webhook + return res.status(500).json({ + success: false, + message: 'Webhook processing failed', + }); + } +}; + +/** + * GET /api/v1/stripe/status + * + * Check the current user's payment status. + * Requires authentication (protect middleware). + * + * Response: + * { + * success: true, + * data: { + * hasPaid: boolean, + * payments: Array + * } + * } + * + * @param {import('express').Request} req + * @param {import('express').Response} res + */ +exports.getPaymentStatus = async (req, res) => { + try { + const userId = req.user.id; + + const [hasPaid, payments] = await Promise.all([ + paymentService.hasUserPaid(userId), + paymentService.getPaymentHistory(userId), + ]); + + return sendSuccess(res, { + hasPaid, + payments, + }, 'Payment status retrieved'); + } catch (err) { + logger.error(`paymentController.getPaymentStatus error: ${err.message}`); + return sendError( + res, + 'Failed to retrieve payment status', + 500, + 'PAYMENT_STATUS_ERROR' + ); + } +}; diff --git a/src/controllers/ratesController.js b/src/controllers/ratesController.js new file mode 100644 index 0000000..4d2f5ab --- /dev/null +++ b/src/controllers/ratesController.js @@ -0,0 +1,106 @@ +/** + * Rates Controller + * + * Handles HTTP requests for Bank of Israel mortgage rate data. + * + * Routes: + * GET /api/v1/public/rates/latest – Get latest average mortgage rates + * + * Response shape (per architecture contract): + * { + * success: true, + * data: { + * date: ISO string, + * fetchPeriod: { start: string, end: string }, + * tracks: { + * fixed: { label, average, latest, monthlyData, count }, + * cpi: { ... }, + * prime: { ... }, + * variable: { ... } + * }, + * averages: { fixed: number, cpi: number, prime: number, variable: number }, + * source: 'bank_of_israel' | 'fallback', + * sourceUrl: string, + * updatedAt: ISO string + * } + * } + */ + +'use strict'; + +const ratesService = require('../services/ratesService'); +const logger = require('../utils/logger'); +const { sendSuccess, sendError } = require('../utils/response'); + +/** + * GET /api/v1/public/rates/latest + * + * Returns the latest Bank of Israel average mortgage rates. + * This is a public endpoint (no authentication required). + * Data is cached for 1 hour in-memory. + * + * @param {import('express').Request} req + * @param {import('express').Response} res + */ +exports.getLatestRates = async (req, res) => { + try { + const rates = await ratesService.getLatestRates(); + + if (!rates) { + return sendError( + res, + 'Mortgage rates data is currently unavailable. Please try again later.', + 503, + 'RATES_UNAVAILABLE' + ); + } + + // Set Cache-Control header for CDN/browser caching (1 hour) + res.set('Cache-Control', 'public, max-age=3600, s-maxage=3600'); + + return sendSuccess(res, rates, 'Latest mortgage rates retrieved successfully'); + } catch (err) { + logger.error(`ratesController.getLatestRates error: ${err.message}`); + return sendError( + res, + 'Failed to retrieve mortgage rates', + 500, + 'RATES_FETCH_ERROR' + ); + } +}; + +/** + * POST /api/v1/admin/rates/refresh (future: admin-only endpoint) + * + * Manually triggers a fresh fetch of BOI rates. + * Intended for admin use or internal cron triggers. + * + * @param {import('express').Request} req + * @param {import('express').Response} res + */ +exports.refreshRates = async (req, res) => { + try { + logger.info('ratesController.refreshRates: manual refresh triggered'); + const rates = await ratesService.fetchAndStoreLatestRates(); + + if (!rates) { + return sendError( + res, + 'Failed to fetch rates from Bank of Israel', + 502, + 'BOI_FETCH_FAILED' + ); + } + + return sendSuccess(res, rates, 'Mortgage rates refreshed successfully'); + } catch (err) { + logger.error(`ratesController.refreshRates error: ${err.message}`); + return sendError( + res, + 'Failed to refresh mortgage rates', + 500, + 'RATES_REFRESH_ERROR' + ); + } +}; diff --git a/src/controllers/wizardController.js b/src/controllers/wizardController.js new file mode 100644 index 0000000..ec25481 --- /dev/null +++ b/src/controllers/wizardController.js @@ -0,0 +1,139 @@ +/** + * Wizard Controller + * + * Handles HTTP requests for the public mortgage wizard. + * + * Routes: + * POST /api/v1/public/wizard/submit – Generate portfolio scenarios + * + * Response shape (per architecture contract): + * { + * success: true, + * data: { + * portfolios: Portfolio[], + * communityTips: CommunityTip[], + * metadata: { + * generatedAt: ISO string, + * ratesSource: string, + * generationMethod: string, + * processingTimeMs: number, + * inputSummary: { ... } + * } + * } + * } + * + * CommunityTip shape: + * { + * type: 'winning_offer' | 'rate_comparison' | 'community_size', + * priority: number, + * bank?: string, + * branch?: string, + * messageHe: string, + * messageEn: string, + * ... (type-specific fields) + * } + */ + +'use strict'; + +const wizardService = require('../services/wizardService'); +const communityService = require('../services/communityService'); +const ratesService = require('../services/ratesService'); +const { validateBusinessRules } = require('../validators/wizardValidator'); +const logger = require('../utils/logger'); +const { sendSuccess, sendError } = require('../utils/response'); + +/** + * POST /api/v1/public/wizard/submit + * + * Receives validated wizard inputs and generates up to 4 mortgage + * portfolio scenarios using Bank of Israel rates and AI. + * Also queries the community intelligence engine for hyper-local + * bank/branch recommendations from similar anonymized profiles. + * + * This is a public endpoint (no authentication required). + * Rate-limited to 5 requests per minute per IP. + * + * @param {import('express').Request} req + * @param {import('express').Response} res + */ +exports.submitWizard = async (req, res) => { + console.log('RAW BODY:', req.body); + try { + const { inputs, consent } = req.body; + + // Additional business rule validation beyond Joi schema + const businessValidation = validateBusinessRules(inputs); + if (!businessValidation.valid) { + return sendError( + res, + 'Validation failed', + 422, + 'BUSINESS_VALIDATION_ERROR', + businessValidation.errors + ); + } + + logger.info('wizardController.submitWizard: generating portfolios', { + propertyPrice: inputs.propertyPrice, + loanAmount: inputs.loanAmount, + stabilityPreference: inputs.stabilityPreference, + consent, + }); + + // Run portfolio generation and community tips in parallel for speed + const [result, currentRates] = await Promise.all([ + wizardService.generatePortfolios(inputs, consent), + ratesService.getCurrentAverages().catch((err) => { + logger.warn(`wizardController: failed to get rates for community tips: ${err.message}`); + return null; + }), + ]); + + if (!result || !result.portfolios || result.portfolios.length === 0) { + return sendError( + res, + 'Failed to generate portfolio scenarios. Please try again.', + 500, + 'PORTFOLIO_GENERATION_FAILED' + ); + } + + // Get community intelligence tips (non-blocking – degrades gracefully) + let communityTips = []; + try { + communityTips = await communityService.getCommunityTips(inputs, currentRates); + } catch (err) { + logger.warn(`wizardController.submitWizard: community tips failed: ${err.message}`); + // Continue without community tips – not critical + } + + // Store anonymous profile if user consented (fire-and-forget) + if (consent) { + communityService.storeAnonymousProfile(inputs).catch((err) => { + logger.warn(`wizardController.submitWizard: anonymous profile storage failed: ${err.message}`); + }); + } + + // Build response per architecture contract + const responseData = { + portfolios: result.portfolios, + communityTips, + metadata: result.metadata, + }; + + return sendSuccess( + res, + responseData, + 'Portfolio scenarios generated successfully' + ); + } catch (err) { + logger.error(`wizardController.submitWizard error: ${err.message}`); + return sendError( + res, + 'An error occurred while generating portfolio scenarios', + 500, + 'WIZARD_SUBMIT_ERROR' + ); + } +}; diff --git a/src/cron/ratesCron.js b/src/cron/ratesCron.js new file mode 100644 index 0000000..1bda99e --- /dev/null +++ b/src/cron/ratesCron.js @@ -0,0 +1,85 @@ +/** + * Rates Cron Job + * + * Schedules a daily fetch of Bank of Israel mortgage rates. + * Runs at 02:00 Israel Standard Time (IST = UTC+2 / IDT = UTC+3). + * + * The cron expression uses UTC time. Israel is UTC+2 (winter) or + * UTC+3 (summer/DST). We schedule at 23:00 UTC which is: + * - 01:00 IST (winter) or 02:00 IDT (summer) + * This ensures the job runs in the early morning hours in Israel. + * + * Usage: + * const { startRatesCron } = require('./cron/ratesCron'); + * startRatesCron(); // Call once at server startup + */ + +'use strict'; + +const cron = require('node-cron'); +const logger = require('../utils/logger'); +const ratesService = require('../services/ratesService'); + +let cronTask = null; + +/** + * Start the daily rates fetch cron job. + * + * Schedule: Every day at 23:00 UTC (≈ 01:00-02:00 Israel time) + * This timing ensures: + * 1. BOI has published the previous day's data + * 2. Minimal server load (off-peak hours) + * 3. Fresh data available for morning users in Israel + * + * @returns {import('node-cron').ScheduledTask} The cron task instance + */ +function startRatesCron() { + if (cronTask) { + logger.warn('ratesCron: cron job already running, skipping duplicate start'); + return cronTask; + } + + // Cron expression: minute hour day month weekday + // '0 23 * * *' = every day at 23:00 UTC + cronTask = cron.schedule('0 23 * * *', async () => { + logger.info('ratesCron: daily rates fetch triggered'); + + try { + const rates = await ratesService.fetchAndStoreLatestRates(); + if (rates) { + const source = rates.source || 'unknown'; + const trackCount = Object.keys(rates.tracks || {}).length; + logger.info( + `ratesCron: rates updated successfully (source=${source}, tracks=${trackCount})` + ); + } else { + logger.warn('ratesCron: fetchAndStoreLatestRates returned null'); + } + } catch (err) { + logger.error(`ratesCron: failed to fetch/store rates: ${err.message}`); + // Do not rethrow – cron should not crash the process + } + }, { + scheduled: true, + timezone: 'UTC', + }); + + logger.info('ratesCron: daily rates fetch scheduled (23:00 UTC / ~01:00-02:00 IST)'); + return cronTask; +} + +/** + * Stop the cron job (useful for testing and graceful shutdown). + */ +function stopRatesCron() { + if (cronTask) { + cronTask.stop(); + cronTask = null; + logger.info('ratesCron: cron job stopped'); + } +} + +module.exports = { + startRatesCron, + stopRatesCron, +}; diff --git a/src/index.js b/src/index.js index 4916013..d73022d 100644 --- a/src/index.js +++ b/src/index.js @@ -16,6 +16,11 @@ const profileRoutes = require('./routes/profile'); const offersRoutes = require('./routes/offers'); const analysisRoutes = require('./routes/analysis'); const dashboardRoutes = require('./routes/dashboard'); +const ratesRoutes = require('./routes/rates'); +const wizardRoutes = require('./routes/wizard'); +const stripeRoutes = require('./routes/stripe'); +// Cron jobs +const { startRatesCron } = require('./cron/ratesCron'); // ── App setup ──────────────────────────────────────────────────────────────── const app = express(); @@ -24,11 +29,24 @@ const app = express(); // rate-limiters, and other IP-dependent middleware work correctly. app.set('trust proxy', 1); + // Security & utility middleware (order matters) app.use(helmetMiddleware); app.use(corsMiddleware); +app.options('*', corsMiddleware); app.use(apiLimiter); app.use(morgan('combined', { stream: { write: (msg) => logger.info(msg.trim()) } })); + +// ── Stripe Webhook Route (MUST be before express.json()) ───────────────────── +// Stripe webhook signature verification requires the raw request body. +// We mount the webhook endpoint with express.raw() BEFORE the global +// express.json() middleware so the body is not parsed as JSON. +app.use( + '/api/v1/stripe/webhook', + express.raw({ type: 'application/json' }) +); + +// Global JSON body parser (for all other routes) app.use(express.json({ limit: '10mb' })); app.use(express.urlencoded({ extended: true, limit: '10mb' })); @@ -39,6 +57,13 @@ app.use('/api/v1/offers', offersRoutes); app.use('/api/v1/analysis', analysisRoutes); app.use('/api/v1/dashboard', dashboardRoutes); +// Stripe payment routes +app.use('/api/v1/stripe', stripeRoutes); + +// Public routes (no auth required) +app.use('/api/v1/public/rates', ratesRoutes); +app.use('/api/v1/public/wizard', wizardRoutes); + // Health check app.get('/health', (_req, res) => res.status(200).json({ status: 'ok', timestamp: new Date().toISOString() })); @@ -61,7 +86,7 @@ app.use((err, _req, res, _next) => { }); // ── Start ──────────────────────────────────────────────────────────────────── -const PORT = process.env.PORT || 5000; +const PORT = process.env.PORT || 5001; const start = async () => { try { @@ -77,6 +102,17 @@ const start = async () => { // Attach db to app locals so controllers can access it if needed app.locals.db = db; + + // Start cron jobs + startRatesCron(); + logger.info('Cron jobs initialised'); + + // Log Stripe configuration status + if (process.env.STRIPE_SECRET_KEY) { + logger.info('Stripe payment integration configured'); + } else { + logger.warn('Stripe payment integration NOT configured (STRIPE_SECRET_KEY missing)'); + } } catch (err) { logger.error(`Failed to initialise Firestore: ${err.message}`); // In production, exit so the process manager can restart with correct creds diff --git a/src/middleware/paidAccess.js b/src/middleware/paidAccess.js new file mode 100644 index 0000000..9f343d2 --- /dev/null +++ b/src/middleware/paidAccess.js @@ -0,0 +1,77 @@ +/** + * Paid Access Middleware + * + * Checks that the authenticated user has paid for enhanced analysis. + * Must be used AFTER the `protect` middleware (requires `req.user`). + * + * The user's paid status is stored as `paidAnalyses: true` on the + * `users` Firestore document. This flag is set by the payment webhook + * (Stripe) when a successful payment is processed. + * + * Usage: + * router.post('/analysis/enhanced/:offerId', protect, requirePaidAccess, handler); + * + * @module middleware/paidAccess + */ + +'use strict'; + +const db = require('../config/firestore'); +const logger = require('../utils/logger'); + +/** + * requirePaidAccess – Express middleware that verifies the user has + * paid for enhanced analysis features. + * + * On success: calls `next()`. + * On failure: returns 403 with an appropriate error message. + * + * @param {import('express').Request} req + * @param {import('express').Response} res + * @param {import('express').NextFunction} next + */ +const requirePaidAccess = async (req, res, next) => { + try { + if (!req.user || !req.user.id) { + return res.status(401).json({ + success: false, + message: 'Authentication required', + }); + } + + // Check the user's paid status from Firestore + // We read directly from Firestore to get the latest status + // (the req.user object from the auth middleware may be stale) + const userDoc = await db.collection('users').doc(req.user.id).get(); + + if (!userDoc.exists) { + return res.status(401).json({ + success: false, + message: 'User not found', + }); + } + + const userData = userDoc.data(); + + if (!userData.paidAnalyses) { + logger.info(`paidAccess: user ${req.user.id} attempted to access paid feature without payment`); + return res.status(403).json({ + success: false, + message: 'This feature requires a paid subscription. Please complete payment to access enhanced analysis.', + errorCode: 'PAYMENT_REQUIRED', + paymentUrl: '/paywall', + }); + } + + // User has paid – proceed + next(); + } catch (err) { + logger.error(`paidAccess middleware error: ${err.message}`); + return res.status(500).json({ + success: false, + message: 'Failed to verify payment status', + }); + } +}; + +module.exports = { requirePaidAccess }; diff --git a/src/middleware/security.js b/src/middleware/security.js index df2ad70..8963e19 100644 --- a/src/middleware/security.js +++ b/src/middleware/security.js @@ -43,7 +43,8 @@ const getAllowedOrigins = () => { 'http://localhost:3000', 'http://localhost:5173', // Vite default 'http://127.0.0.1:3000', - 'http://127.0.0.1:5173' + 'http://127.0.0.1:5173', + 'https://morty-app.onrender.com' ); } @@ -71,7 +72,7 @@ const corsMiddleware = cors({ } logger.logSecurity('CORS_BLOCKED', { origin, allowedOrigins }); - return callback(new Error(`CORS: Origin '${origin}' not allowed`)); + return callback(null, false); }, credentials: true, // Allow cookies and Authorization headers methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'], diff --git a/src/middleware/validate.js b/src/middleware/validate.js index f5aaa40..c8d2424 100644 --- a/src/middleware/validate.js +++ b/src/middleware/validate.js @@ -1,160 +1,58 @@ /** - * Joi validation middleware + * Joi validation middleware factory. * - * Exports individual schemas and a validate() middleware factory. - * All schemas are used via the validate() factory which returns a - * standard 422 response on validation failure. + * Creates an Express middleware that validates `req.body` against + * the provided Joi schema. On validation failure, returns a 400 + * response with detailed error messages. * - * The error response format is consistent with the global error handler: - * { success: false, error: { code, message, details, timestamp } } + * Usage: + * const { validate } = require('../middleware/validate'); + * const { mySchema } = require('../validators/myValidator'); + * router.post('/endpoint', validate(mySchema), controller.handler); + * + * @module middleware/validate */ 'use strict'; -const Joi = require('joi'); - -// ── Middleware factory ──────────────────────────────────────────────────────── - /** - * Middleware factory: validates req.body against the given Joi schema. - * Returns 422 with error details on failure, using the standard AppError - * response envelope for consistency. + * Create a validation middleware for the given Joi schema. * - * @param {Joi.Schema} schema - Joi schema to validate against - * @returns {import('express').RequestHandler} + * @param {import('joi').ObjectSchema} schema - Joi validation schema + * @param {string} [property='body'] - Request property to validate ('body', 'query', 'params') + * @returns {import('express').RequestHandler} Express middleware */ -const validate = (schema) => (req, res, next) => { - const { error, value } = schema.validate(req.body, { - abortEarly: false, - stripUnknown: true, - allowUnknown: false, - }); - - if (error) { - const details = error.details.map((d) => ({ - field: d.path.join('.'), - message: d.message, - })); - return res.status(422).json({ - success: false, - error: { - code: 'VALIDATION_ERROR', - message: 'Validation failed', - details, - timestamp: new Date().toISOString(), - }, +const validate = (schema, property = 'body') => { + return (req, res, next) => { + if (!schema || typeof schema.validate !== 'function') { + return next(); + } + + const { error, value } = schema.validate(req[property], { + abortEarly: false, + stripUnknown: false, + allowUnknown: true, }); - } - - req.body = value; - next(); -}; - -// ── Auth schemas ────────────────────────────────────────────────────────────── - -const registerSchema = Joi.object({ - email: Joi.string().email().lowercase().required(), - password: Joi.string().min(8).required(), - phone: Joi.string() - .pattern(/^(\+972|0)[0-9]{8,9}$/) - .optional() - .allow(''), -}); - -const loginSchema = Joi.object({ - email: Joi.string().email().lowercase().required(), - password: Joi.string().required(), -}); - -const refreshSchema = Joi.object({ - refreshToken: Joi.string().required(), -}); - -/** - * Schema for POST /auth/google - * - * Validates that the Firebase ID token is present and non-empty. - * The token itself is verified server-side via Firebase Admin SDK. - */ -const googleSchema = Joi.object({ - idToken: Joi.string().min(1).required().messages({ - 'string.base': 'Firebase ID token must be a string.', - 'string.empty': 'Firebase ID token must not be empty.', - 'string.min': 'Firebase ID token must not be empty.', - 'any.required': 'Firebase ID token is required.', - }), -}); -// ── Financial profile schemas ───────────────────────────────────────────────── + if (error) { + const details = error.details.map((d) => ({ -/** - * Shared debt item schema used in both full and partial financial schemas. - */ -const debtItemSchema = Joi.object({ - type: Joi.string().trim().max(100).required(), - amount: Joi.number().min(0).required(), -}); - -/** - * Full financial profile schema (used for PUT – upsert). - * - * All top-level fields are optional; missing fields default to 0 / []. - * This allows clients to send an empty body `{}` and receive a zeroed - * profile back (useful for initialising a new profile). - */ -const financialSchema = Joi.object({ - income: Joi.number().min(0).default(0), - additionalIncome: Joi.number().min(0).default(0), - expenses: Joi.object({ - housing: Joi.number().min(0).default(0), - loans: Joi.number().min(0).default(0), - other: Joi.number().min(0).default(0), - }).default({ housing: 0, loans: 0, other: 0 }), - assets: Joi.object({ - savings: Joi.number().min(0).default(0), - investments: Joi.number().min(0).default(0), - }).default({ savings: 0, investments: 0 }), - debts: Joi.array().items(debtItemSchema).max(20).default([]), -}); + field: d.path.join('.'), + message: d.message, + })); -/** - * Alias for backward-compatibility with existing test files and controllers - * that reference `financialDataSchema`. - */ -const financialDataSchema = financialSchema; -/** - * Partial financial profile schema (used for PATCH – partial update). - * - * At least one field must be provided. - * No defaults are applied so that only explicitly sent fields are updated. - */ -const patchFinancialSchema = Joi.object({ - income: Joi.number().min(0), - additionalIncome: Joi.number().min(0), - expenses: Joi.object({ - housing: Joi.number().min(0), - loans: Joi.number().min(0), - other: Joi.number().min(0), - }), - assets: Joi.object({ - savings: Joi.number().min(0), - investments: Joi.number().min(0), - }), - debts: Joi.array().items(debtItemSchema).max(20), -}).min(1).messages({ - 'object.min': 'At least one financial field must be provided', -}); - -// ── Exports ─────────────────────────────────────────────────────────────────── - -module.exports = { - validate, - registerSchema, - loginSchema, - refreshSchema, - googleSchema, - financialSchema, - financialDataSchema, - patchFinancialSchema, + return res.status(400).json({ + success: false, + message: 'Validation failed', + errors: details, + }); + } + + // Replace the request property with the validated (and possibly coerced) value + req[property] = value; + next(); + }; }; + +module.exports = { validate }; diff --git a/src/models/wizardMockData.js b/src/models/wizardMockData.js new file mode 100644 index 0000000..7f5549f --- /dev/null +++ b/src/models/wizardMockData.js @@ -0,0 +1,47 @@ +const mockWizardPortfolioResponse = { + success: true, + data: { + portfolios: [ + { + portfolioName: "Conservative Income", + riskLevel: "Low", + expectedReturn: "4%-6%", + description: "A stable portfolio focused on bonds, dividend stocks, and low volatility ETFs.", + allocation: [ + { asset: "US Bonds ETF", percentage: 40 }, + { asset: "Dividend Stocks", percentage: 30 }, + { asset: "S&P500 ETF", percentage: 20 }, + { asset: "Cash", percentage: 10 } + ] + }, + { + portfolioName: "Balanced Growth", + riskLevel: "Medium", + expectedReturn: "7%-10%", + description: "A balanced portfolio mixing equities and fixed income for moderate growth.", + allocation: [ + { asset: "S&P500 ETF", percentage: 35 }, + { asset: "NASDAQ ETF", percentage: 20 }, + { asset: "International ETF", percentage: 20 }, + { asset: "Bonds ETF", percentage: 15 }, + { asset: "REIT", percentage: 10 } + ] + }, + { + portfolioName: "Aggressive Wealth Builder", + riskLevel: "High", + expectedReturn: "10%-15%", + description: "Growth-oriented portfolio with emphasis on tech and emerging markets.", + allocation: [ + { asset: "NASDAQ ETF", percentage: 35 }, + { asset: "AI/Tech Stocks", percentage: 25 }, + { asset: "Emerging Markets ETF", percentage: 20 }, + { asset: "Crypto Exposure", percentage: 10 }, + { asset: "Cash", percentage: 10 } + ] + } + ] + } +}; + +module.exports = { mockWizardPortfolioResponse }; \ No newline at end of file diff --git a/src/routes/analysis.js b/src/routes/analysis.js index 8957c8f..6a3b4b8 100644 --- a/src/routes/analysis.js +++ b/src/routes/analysis.js @@ -1,12 +1,18 @@ /** * Analysis routes - * GET /api/v1/analysis/:id – get analysis results for an offer + * + * GET /api/v1/analysis/:id – get analysis results for an offer + * POST /api/v1/analysis/enhanced/:offerId – generate enhanced report (paid) */ const express = require('express'); const router = express.Router(); const analysisController = require('../controllers/analysisController'); const { protect } = require('../middleware/auth'); +const { requirePaidAccess } = require('../middleware/paidAccess'); +const { validate } = require('../middleware/validate'); +const { enhancedAnalysisSchema } = require('../validators/analysisValidator'); +// All analysis routes require authentication router.use(protect); /** @@ -16,4 +22,16 @@ router.use(protect); */ router.get('/:id', analysisController.getAnalysis); +/** + * @route POST /api/v1/analysis/enhanced/:offerId + * @desc Generate enhanced analysis report comparing real offer to optimized model + * @access Private + Paid + */ +router.post( + '/enhanced/:offerId', + requirePaidAccess, + validate(enhancedAnalysisSchema), + analysisController.getEnhancedAnalysis +); + module.exports = router; diff --git a/src/routes/rates.js b/src/routes/rates.js new file mode 100644 index 0000000..cf4eeba --- /dev/null +++ b/src/routes/rates.js @@ -0,0 +1,41 @@ +/** + * Rates routes + * + * Public endpoints for Bank of Israel mortgage rate data. + * + * GET /api/v1/public/rates/latest – Get latest average mortgage rates (public, cached 1h) + */ + +'use strict'; + +const express = require('express'); +const router = express.Router(); +const ratesController = require('../controllers/ratesController'); +const rateLimit = require('express-rate-limit'); + +/** + * Rate limiter for public rates endpoint. + * 30 requests per 15 minutes per IP – generous for a read-only cached endpoint. + */ +const ratesLimiter = rateLimit({ + windowMs: 15 * 60 * 1000, // 15 minutes + max: 30, + standardHeaders: true, + legacyHeaders: false, + message: { + success: false, + error: { + code: 'RATE_LIMIT_EXCEEDED', + message: 'Too many requests for rates data. Please try again later.', + }, + }, +}); + +/** + * @route GET /api/v1/public/rates/latest + * @desc Get latest Bank of Israel average mortgage rates + * @access Public (no auth required) + */ +router.get('/latest', ratesLimiter, ratesController.getLatestRates); + +module.exports = router; diff --git a/src/routes/stripe.js b/src/routes/stripe.js new file mode 100644 index 0000000..77e72b7 --- /dev/null +++ b/src/routes/stripe.js @@ -0,0 +1,84 @@ +/** + * Stripe Payment Routes + * + * POST /api/v1/stripe/checkout – Create a Stripe Checkout Session (auth required) + * POST /api/v1/stripe/webhook – Handle Stripe webhook events (Stripe signature) + * GET /api/v1/stripe/status – Check payment status (auth required) + * + * IMPORTANT: The webhook endpoint uses express.raw() for the request body + * (not express.json()) because Stripe signature verification requires the + * raw body bytes. This is configured in index.js where the route is mounted. + * + * @module routes/stripe + */ + +'use strict'; + +const express = require('express'); +const router = express.Router(); +const paymentController = require('../controllers/paymentController'); +const { protect } = require('../middleware/auth'); +const { validate } = require('../middleware/validate'); +const { checkoutSchema } = require('../validators/paymentValidator'); +const rateLimit = require('express-rate-limit'); + +/** + * Rate limiter for checkout endpoint. + * 10 requests per 15 minutes per user – prevents abuse. + */ +const checkoutLimiter = rateLimit({ + windowMs: 15 * 60 * 1000, // 15 minutes + max: 10, + standardHeaders: true, + legacyHeaders: false, + keyGenerator: (req) => req.user?.id || req.ip, + message: { + success: false, + error: { + code: 'RATE_LIMIT_EXCEEDED', + message: 'Too many checkout attempts. Please wait and try again.', + timestamp: new Date().toISOString(), + }, + }, +}); + +/** + * @route POST /api/v1/stripe/checkout + * @desc Create a Stripe Checkout Session for expert analysis + * @access Private (requires authentication) + * @body { portfolioId?: string, successUrl: string, cancelUrl?: string } + * @returns { sessionId: string, url: string } + */ +router.post( + '/checkout', + protect, + checkoutLimiter, + validate(checkoutSchema), + paymentController.createCheckout +); + +/** + * @route POST /api/v1/stripe/webhook + * @desc Handle Stripe webhook events (signature verified) + * @access Stripe (verified via Stripe-Signature header) + * @note This endpoint receives raw body (not JSON-parsed). + * The raw body middleware is configured in index.js. + */ +router.post( + '/webhook', + paymentController.handleWebhook +); + +/** + * @route GET /api/v1/stripe/status + * @desc Check the current user's payment status + * @access Private (requires authentication) + * @returns { hasPaid: boolean, payments: Array } + */ +router.get( + '/status', + protect, + paymentController.getPaymentStatus +); + +module.exports = router; diff --git a/src/routes/wizard.js b/src/routes/wizard.js new file mode 100644 index 0000000..747ed91 --- /dev/null +++ b/src/routes/wizard.js @@ -0,0 +1,54 @@ +/** + * Wizard routes + * + * Public endpoints for the mortgage wizard. + * + * POST /api/v1/public/wizard/submit – Generate portfolio scenarios from wizard inputs + */ + +'use strict'; + +const express = require('express'); +const router = express.Router(); +const wizardController = require('../controllers/wizardController'); +const { validate } = require('../middleware/validate'); +const { wizardSubmitSchema } = require('../validators/wizardValidator'); +const rateLimit = require('express-rate-limit'); + +/** + * Rate limiter for wizard submit endpoint. + * 5 requests per minute per IP – prevents abuse of the AI-powered endpoint. + * Per architecture: "Rate-limit 5/min/IP" + */ +const wizardLimiter = rateLimit({ + windowMs: 60 * 1000, // 1 minute + max: 5, + standardHeaders: true, + legacyHeaders: false, + message: { + success: false, + error: { + code: 'RATE_LIMIT_EXCEEDED', + message: 'Too many wizard submissions. Please wait a moment and try again.', + timestamp: new Date().toISOString(), + }, + }, +}); + +/** + * @route POST /api/v1/public/wizard/submit + * @desc Generate up to 4 mortgage portfolio scenarios based on wizard inputs + * @access Public (no auth required) + * @body { inputs: { propertyPrice, loanAmount, monthlyIncome, additionalIncome?, + * targetRepayment, futureFunds: { timeframe, amount? }, stabilityPreference }, + * consent: boolean } + * @returns { portfolios: Portfolio[], communityTips: [], metadata: {} } + */ +router.post( + '/submit', + wizardLimiter, + validate(wizardSubmitSchema), + wizardController.submitWizard +); + +module.exports = router; diff --git a/src/services/aiService.js b/src/services/aiService.js index 007b10b..4bb0666 100644 --- a/src/services/aiService.js +++ b/src/services/aiService.js @@ -9,7 +9,7 @@ */ 'use strict'; - +const { USE_WIZARD_MOCK } = require('../config/devConfig'); const OpenAI = require('openai'); const offerService = require('./offerService'); const logger = require('../utils/logger'); @@ -34,7 +34,7 @@ exports.analyzeOffer = async (offerId) => { if (!offer) throw new Error(`Offer ${offerId} not found`); try { - if (!openai) { + if (!openai || (USE_WIZARD_MOCK)) { // Mock analysis when OpenAI key is not configured (dev/test) logger.warn('OPENAI_API_KEY not set – using mock analysis'); diff --git a/src/services/communityService.js b/src/services/communityService.js new file mode 100644 index 0000000..73a1c2c --- /dev/null +++ b/src/services/communityService.js @@ -0,0 +1,811 @@ +/** + * Community Intelligence Service + * + * Implements the "Community Intelligence" engine that transitions from + * static Bank of Israel benchmarks to dynamic user-contributed data. + * + * Core Capabilities: + * 1. **Profile Hashing**: Generates a deterministic SHA-256 hash from + * normalized/binned user profile data (income, loan amount, LTV, etc.) + * to enable anonymous matching without storing PII. + * + * 2. **Similar Profile Matching**: Queries the `community_profiles` + * Firestore collection using range queries on binned fields + * (income ±10%, loan ±20%) to find users with similar financial profiles. + * + * 3. **Winning Offer Identification**: Aggregates matched profiles to + * identify which bank/branch combinations provided the best rates + * for similar users. Produces hyper-local recommendations like: + * "Users with your profile recently secured better rates at + * Leumi Herzliya." + * + * 4. **Anonymous Storage**: When users consent, stores their anonymized + * profile (binned fields + bank/branch/rates) for future matching. + * No PII is ever stored in community_profiles. + * + * Data Model (community_profiles): + * - profileHash: SHA-256 of normalized binned profile + * - incomeBin: binned monthly income (e.g., 15000) + * - loanBin: binned loan amount (e.g., 1000000) + * - ltvBin: binned LTV percentage (e.g., 60) + * - stabilityBin: binned stability preference (e.g., 7) + * - bank: bank name (Hebrew) + * - branch: branch name (Hebrew) + * - rates: { fixed, cpi, prime, variable } – actual rates received + * - weightedRate: single weighted average rate for quick comparison + * - consent: always true (only stored if user consented) + * - createdAt: ISO timestamp + * + * @module communityService + */ + +'use strict'; + +const crypto = require('crypto'); +const db = require('../config/firestore'); +const logger = require('../utils/logger'); +const { COLLECTIONS } = require('../config/collections'); + +// ── Constants ───────────────────────────────────────────────────────────────── + +/** + * Bin sizes for normalizing profile fields. + * Binning ensures that similar (but not identical) values map to the same + * bucket, enabling efficient Firestore equality queries. + */ +const BIN_SIZES = Object.freeze({ + /** Income binned to nearest ₪5,000 */ + INCOME: 5000, + /** Loan amount binned to nearest ₪50,000 */ + LOAN: 50000, + /** LTV binned to nearest 5% */ + LTV: 5, + /** Stability preference binned to nearest 2 (1-2, 3-4, 5-6, 7-8, 9-10) */ + STABILITY: 2, +}); + +/** + * Range multipliers for fuzzy matching. + * When querying similar profiles, we search within these ranges + * around the user's binned values. + */ +const MATCH_RANGES = Object.freeze({ + /** Income: ±10% (architecture spec) */ + INCOME_TOLERANCE: 0.10, + /** Loan: ±20% (architecture spec) */ + LOAN_TOLERANCE: 0.20, + /** LTV: ±10 percentage points */ + LTV_TOLERANCE: 10, + /** Stability: ±2 points */ + STABILITY_TOLERANCE: 2, +}); + +/** Maximum number of similar profiles to fetch for aggregation */ +const MAX_MATCH_RESULTS = 100; + +/** Maximum number of community tips to return */ +const MAX_TIPS = 3; + +/** Minimum number of matching profiles required to generate a tip */ +const MIN_PROFILES_FOR_TIP = 2; + +/** Cache TTL for community aggregation results: 30 minutes */ +const COMMUNITY_CACHE_TTL_MS = 30 * 60 * 1000; + +/** Firestore collection reference helper */ +const communityRef = () => db.collection(COLLECTIONS.COMMUNITY_PROFILES); + +// ── In-Memory Cache ─────────────────────────────────────────────────────────── + +/** + * Simple in-memory cache for community aggregation results. + * Keyed by profile hash, stores { tips, timestamp }. + * Prevents redundant Firestore queries for identical profiles. + */ +const _cache = new Map(); + +/** + * Clear the community tips cache. + * Useful for testing and after new profile storage. + */ +function clearCache() { + _cache.clear(); +} + +/** + * Get a cached result if still valid. + * @param {string} cacheKey - Cache key (typically profile hash) + * @returns {Array|null} Cached tips or null if expired/missing + */ +function getCached(cacheKey) { + const entry = _cache.get(cacheKey); + if (!entry) return null; + if (Date.now() - entry.timestamp > COMMUNITY_CACHE_TTL_MS) { + _cache.delete(cacheKey); + return null; + } + return entry.tips; +} + +/** + * Store a result in the cache. + * @param {string} cacheKey - Cache key + * @param {Array} tips - Community tips to cache + */ +function setCache(cacheKey, tips) { + // Limit cache size to prevent memory leaks + if (_cache.size > 1000) { + // Evict oldest entries + const keysToDelete = []; + let count = 0; + for (const [key] of _cache) { + if (count++ < 200) keysToDelete.push(key); + else break; + } + keysToDelete.forEach((k) => _cache.delete(k)); + } + _cache.set(cacheKey, { tips, timestamp: Date.now() }); +} + +// ── Binning Functions ───────────────────────────────────────────────────────── + +/** + * Bin a numeric value to the nearest multiple of the bin size. + * This normalizes values so that similar amounts map to the same bucket. + * + * @param {number} value - Raw numeric value + * @param {number} binSize - Bin size (e.g., 5000 for income) + * @returns {number} Binned value + * + * @example + * binValue(17500, 5000) // → 15000 + * binValue(18000, 5000) // → 20000 + * binValue(1250000, 50000) // → 1250000 + */ +function binValue(value, binSize) { + if (!value || !binSize || binSize <= 0) return 0; + return Math.round(value / binSize) * binSize; +} + +/** + * Compute binned profile fields from raw wizard inputs. + * + * @param {object} inputs - Wizard inputs + * @param {number} inputs.monthlyIncome - Primary monthly income + * @param {number} [inputs.additionalIncome=0] - Additional income + * @param {number} inputs.loanAmount - Requested loan amount + * @param {number} inputs.propertyPrice - Property price + * @param {number} inputs.stabilityPreference - Stability slider (1-10) + * @returns {object} Binned profile fields + */ +function computeBinnedProfile(inputs) { + const totalIncome = (inputs.monthlyIncome || 0) + (inputs.additionalIncome || 0); + const ltv = inputs.propertyPrice > 0 + ? (inputs.loanAmount / inputs.propertyPrice) * 100 + : 0; + + return { + incomeBin: binValue(totalIncome, BIN_SIZES.INCOME), + loanBin: binValue(inputs.loanAmount, BIN_SIZES.LOAN), + ltvBin: binValue(ltv, BIN_SIZES.LTV), + stabilityBin: binValue(inputs.stabilityPreference, BIN_SIZES.STABILITY), + }; +} + +// ── Profile Hashing ─────────────────────────────────────────────────────────── + +/** + * Generate a deterministic SHA-256 hash from binned profile fields. + * + * The hash is computed from a sorted, normalized string representation + * of the binned fields. This ensures: + * - Identical profiles always produce the same hash + * - No PII is recoverable from the hash + * - The hash is stable across different JS engine property orderings + * + * @param {object} binnedProfile - Binned profile from computeBinnedProfile() + * @returns {string} Hex-encoded SHA-256 hash + */ +function hashProfile(binnedProfile) { + // Sort keys for deterministic ordering + const sortedKeys = Object.keys(binnedProfile).sort(); + const normalized = sortedKeys + .map((key) => `${key}:${binnedProfile[key]}`) + .join('|'); + + return crypto + .createHash('sha256') + .update(normalized) + .digest('hex'); +} + +// ── Similar Profile Matching ────────────────────────────────────────────────── + +/** + * Find community profiles similar to the given wizard inputs. + * + * Uses a two-phase approach: + * Phase 1: Query Firestore with the primary range filter (incomeBin) + * using Firestore's native range query capability. + * Phase 2: Filter results in-memory for loanBin, ltvBin, and + * stabilityBin ranges (Firestore only supports one range + * filter per query on different fields). + * + * Architecture spec: income ±10%, loan ±20% + * + * @param {object} inputs - Wizard inputs + * @returns {Promise>} Matching community profiles + */ +async function findSimilarProfiles(inputs) { + const binned = computeBinnedProfile(inputs); + + // Calculate range bounds + const totalIncome = (inputs.monthlyIncome || 0) + (inputs.additionalIncome || 0); + const incomeMin = binValue( + totalIncome * (1 - MATCH_RANGES.INCOME_TOLERANCE), + BIN_SIZES.INCOME + ); + const incomeMax = binValue( + totalIncome * (1 + MATCH_RANGES.INCOME_TOLERANCE), + BIN_SIZES.INCOME + ); + + const loanMin = binValue( + inputs.loanAmount * (1 - MATCH_RANGES.LOAN_TOLERANCE), + BIN_SIZES.LOAN + ); + const loanMax = binValue( + inputs.loanAmount * (1 + MATCH_RANGES.LOAN_TOLERANCE), + BIN_SIZES.LOAN + ); + + const ltvMin = binned.ltvBin - MATCH_RANGES.LTV_TOLERANCE; + const ltvMax = binned.ltvBin + MATCH_RANGES.LTV_TOLERANCE; + + const stabilityMin = Math.max(1, binned.stabilityBin - MATCH_RANGES.STABILITY_TOLERANCE); + const stabilityMax = Math.min(10, binned.stabilityBin + MATCH_RANGES.STABILITY_TOLERANCE); + + logger.debug('communityService.findSimilarProfiles: query ranges', { + incomeBin: binned.incomeBin, + incomeRange: [incomeMin, incomeMax], + loanBin: binned.loanBin, + loanRange: [loanMin, loanMax], + ltvBin: binned.ltvBin, + ltvRange: [ltvMin, ltvMax], + stabilityBin: binned.stabilityBin, + stabilityRange: [stabilityMin, stabilityMax], + }); + + try { + // Phase 1: Firestore query with incomeBin range + loanBin range + // Firestore supports range filters on a single field in a compound query, + // but we can use >= and <= on the same field. + // We use incomeBin as the primary range filter since it's the most + // discriminating field, then filter the rest in-memory. + const snapshot = await communityRef() + .where('incomeBin', '>=', incomeMin) + .where('incomeBin', '<=', incomeMax) + .orderBy('incomeBin', 'asc') + .limit(MAX_MATCH_RESULTS) + .get(); + + if (snapshot.empty) { + logger.debug('communityService.findSimilarProfiles: no matches found in Firestore'); + return []; + } + + // Phase 2: In-memory filtering for remaining dimensions + const matches = []; + snapshot.forEach((doc) => { + const data = doc.data(); + // Skip sentinel documents + if (data._sentinel) return; + + // Filter by loan range + if (data.loanBin < loanMin || data.loanBin > loanMax) return; + + // Filter by LTV range + if (data.ltvBin < ltvMin || data.ltvBin > ltvMax) return; + + // Filter by stability range + if (data.stabilityBin < stabilityMin || data.stabilityBin > stabilityMax) return; + + matches.push({ id: doc.id, ...data }); + }); + + logger.info(`communityService.findSimilarProfiles: ${matches.length} matches from ${snapshot.size} candidates`); + return matches; + } catch (err) { + logger.error(`communityService.findSimilarProfiles: query error: ${err.message}`); + // Degrade gracefully – return empty array so wizard still works + return []; + } +} + +// ── Winning Offer Aggregation ───────────────────────────────────────────────── + +/** + * Aggregate matched profiles to identify winning bank/branch offers. + * + * Groups profiles by bank+branch, computes average weighted rate for + * each group, and ranks them. A "winning" offer is one where the + * average rate is lower than the overall average across all matches. + * + * @param {Array} profiles - Matched community profiles + * @returns {Array} Ranked bank/branch offers, best first + */ +function aggregateWinningOffers(profiles) { + if (!profiles || profiles.length === 0) return []; + + // Group by bank + branch + const groups = new Map(); + + for (const profile of profiles) { + if (!profile.bank) continue; + + const key = `${profile.bank}|${profile.branch || 'כללי'}`; + + if (!groups.has(key)) { + groups.set(key, { + bank: profile.bank, + branch: profile.branch || 'כללי', + rates: [], + weightedRates: [], + count: 0, + latestDate: null, + }); + } + + const group = groups.get(key); + group.count += 1; + + if (profile.weightedRate != null && !isNaN(profile.weightedRate)) { + group.weightedRates.push(profile.weightedRate); + } + + if (profile.rates) { + group.rates.push(profile.rates); + } + + // Track the most recent contribution + if (profile.createdAt) { + if (!group.latestDate || profile.createdAt > group.latestDate) { + group.latestDate = profile.createdAt; + } + } + } + + // Compute averages and rank + const ranked = []; + + for (const [, group] of groups) { + if (group.weightedRates.length === 0) continue; + + const avgWeightedRate = + group.weightedRates.reduce((sum, r) => sum + r, 0) / group.weightedRates.length; + + // Compute average rates per track type + const avgRates = computeAverageRates(group.rates); + + ranked.push({ + bank: group.bank, + branch: group.branch, + avgWeightedRate: Math.round(avgWeightedRate * 100) / 100, + avgRates, + profileCount: group.count, + latestDate: group.latestDate, + }); + } + + // Sort by average weighted rate (ascending = best first) + ranked.sort((a, b) => a.avgWeightedRate - b.avgWeightedRate); + + return ranked; +} + +/** + * Compute average rates per track type from an array of rate objects. + * + * @param {Array} ratesArray - Array of { fixed, cpi, prime, variable } objects + * @returns {object} Average rates per track type + */ +function computeAverageRates(ratesArray) { + if (!ratesArray || ratesArray.length === 0) return {}; + + const sums = { fixed: 0, cpi: 0, prime: 0, variable: 0 }; + const counts = { fixed: 0, cpi: 0, prime: 0, variable: 0 }; + + for (const rates of ratesArray) { + for (const track of ['fixed', 'cpi', 'prime', 'variable']) { + if (rates[track] != null && !isNaN(rates[track])) { + sums[track] += rates[track]; + counts[track] += 1; + } + } + } + + const averages = {}; + for (const track of ['fixed', 'cpi', 'prime', 'variable']) { + if (counts[track] > 0) { + averages[track] = Math.round((sums[track] / counts[track]) * 100) / 100; + } + } + + return averages; +} + +// ── Community Tips Generation ───────────────────────────────────────────────── + +/** + * Generate community tips from matched profiles. + * + * Produces up to 3 actionable tips based on community data: + * 1. Best bank/branch recommendation (hyper-local) + * 2. Rate comparison insight (how community rates compare to BOI averages) + * 3. Profile popularity insight (how many similar users exist) + * + * Per architecture spec: + * "If a specific bank branch provided a superior offer, explicitly state: + * 'Users with your profile recently secured better rates at + * [Bank Name] - [Branch Name].'" + * + * @param {Array} profiles - Matched community profiles + * @param {object} [currentRates] - Current BOI average rates for comparison + * @returns {Array} Community tips (max 3) + */ +function generateCommunityTips(profiles, currentRates) { + if (!profiles || profiles.length < MIN_PROFILES_FOR_TIP) { + return []; + } + + const tips = []; + const rankedOffers = aggregateWinningOffers(profiles); + + // Tip 1: Best bank/branch recommendation (hyper-local) + if (rankedOffers.length > 0) { + const best = rankedOffers[0]; + const recentLabel = best.latestDate + ? formatRecency(best.latestDate) + : 'לאחרונה'; + + tips.push({ + type: 'winning_offer', + priority: 1, + bank: best.bank, + branch: best.branch, + avgWeightedRate: best.avgWeightedRate, + avgRates: best.avgRates, + profileCount: best.profileCount, + recency: recentLabel, + messageHe: `משתמשים עם פרופיל דומה קיבלו ${recentLabel} ריבית טובה יותר ב-${best.bank} – ${best.branch}`, + messageEn: `Users with your profile recently secured better rates at ${best.bank} – ${best.branch}`, + }); + } + + // Tip 2: Rate comparison with BOI averages + if (rankedOffers.length > 0 && currentRates) { + const best = rankedOffers[0]; + const rateComparisons = []; + + for (const track of ['fixed', 'cpi', 'prime']) { + const communityRate = best.avgRates[track]; + const boiRate = currentRates[track]; + + if (communityRate != null && boiRate != null) { + const diff = Math.round((boiRate - communityRate) * 100) / 100; + if (diff > 0) { + rateComparisons.push({ + track, + communityRate, + boiRate, + savingBps: Math.round(diff * 100), + }); + } + } + } + + if (rateComparisons.length > 0) { + const bestSaving = rateComparisons.sort((a, b) => b.savingBps - a.savingBps)[0]; + const trackNameHe = { + fixed: 'קל"צ', + cpi: 'צמוד מדד', + prime: 'פריים', + variable: 'משתנה', + }; + + tips.push({ + type: 'rate_comparison', + priority: 2, + comparisons: rateComparisons, + messageHe: `משתמשים דומים קיבלו ריבית ${trackNameHe[bestSaving.track] || bestSaving.track} נמוכה ב-${(bestSaving.savingBps / 100).toFixed(2)}% מהממוצע בבנק ישראל`, + messageEn: `Similar users received ${bestSaving.track} rates ${(bestSaving.savingBps / 100).toFixed(2)}% below the Bank of Israel average`, + }); + } + } + + // Tip 3: Community size / confidence indicator + if (profiles.length >= 5) { + tips.push({ + type: 'community_size', + priority: 3, + matchCount: profiles.length, + bankCount: new Set(profiles.map((p) => p.bank).filter(Boolean)).size, + messageHe: `ניתוח מבוסס על ${profiles.length} משתמשים עם פרופיל דומה`, + messageEn: `Analysis based on ${profiles.length} users with a similar profile`, + }); + } + + // Sort by priority and limit + return tips + .sort((a, b) => a.priority - b.priority) + .slice(0, MAX_TIPS); +} + +/** + * Format a date string into a Hebrew recency label. + * + * @param {string} dateStr - ISO date string + * @returns {string} Hebrew recency label + */ +function formatRecency(dateStr) { + try { + const date = new Date(dateStr); + const now = new Date(); + const diffDays = Math.floor((now - date) / (1000 * 60 * 60 * 24)); + + if (diffDays <= 7) return 'השבוע'; + if (diffDays <= 30) return 'החודש'; + if (diffDays <= 90) return 'לאחרונה'; + return 'לאחרונה'; + } catch { + return 'לאחרונה'; + } +} + +// ── Main Entry Point ────────────────────────────────────────────────────────── + +/** + * Get community intelligence tips for the given wizard inputs. + * + * This is the main entry point called by the wizard controller. + * It orchestrates profile matching, aggregation, and tip generation. + * + * @param {object} inputs - Validated wizard inputs + * @param {object} [currentRates] - Current BOI average rates (for comparison tips) + * @returns {Promise>} Community tips (may be empty if insufficient data) + */ +async function getCommunityTips(inputs, currentRates) { + const startTime = Date.now(); + + try { + // Compute binned profile and hash for caching + const binned = computeBinnedProfile(inputs); + const profileHash = hashProfile(binned); + + // Check cache first + const cached = getCached(profileHash); + if (cached) { + logger.debug('communityService.getCommunityTips: returning cached tips'); + return cached; + } + + // Find similar profiles + const similarProfiles = await findSimilarProfiles(inputs); + + // Generate tips + const tips = generateCommunityTips(similarProfiles, currentRates); + + // Cache the result + setCache(profileHash, tips); + + const elapsed = Date.now() - startTime; + logger.info(`communityService.getCommunityTips: ${tips.length} tips generated in ${elapsed}ms from ${similarProfiles.length} matches`); + + return tips; + } catch (err) { + logger.error(`communityService.getCommunityTips: error: ${err.message}`); + // Degrade gracefully – return empty tips so wizard still works + return []; + } +} + +// ── Anonymous Profile Storage ───────────────────────────────────────────────── + +/** + * Store an anonymized community profile when the user has consented. + * + * This is called after portfolio generation when consent === true. + * The stored profile contains ONLY binned/hashed data and the + * bank/branch/rates from the user's actual offer (if available). + * + * For wizard submissions (no actual bank offer yet), we store the + * profile with placeholder bank/rates that will be updated later + * when the user uploads their actual bank offer. + * + * @param {object} inputs - Wizard inputs + * @param {object} [bankOffer] - Optional bank offer data + * @param {string} [bankOffer.bank] - Bank name + * @param {string} [bankOffer.branch] - Branch name + * @param {object} [bankOffer.rates] - Actual rates { fixed, cpi, prime, variable } + * @returns {Promise} Document ID of stored profile, or null on failure + */ +async function storeAnonymousProfile(inputs, bankOffer) { + try { + const binned = computeBinnedProfile(inputs); + const profileHash = hashProfile(binned); + + const now = new Date().toISOString(); + + // Compute weighted average rate if rates are provided + let weightedRate = null; + if (bankOffer && bankOffer.rates) { + const rates = bankOffer.rates; + const rateValues = [ + rates.fixed, + rates.cpi, + rates.prime, + rates.variable, + ].filter((r) => r != null && !isNaN(r)); + + if (rateValues.length > 0) { + weightedRate = + Math.round( + (rateValues.reduce((sum, r) => sum + r, 0) / rateValues.length) * 100 + ) / 100; + } + } + + const profileDoc = { + profileHash, + ...binned, + bank: (bankOffer && bankOffer.bank) || null, + branch: (bankOffer && bankOffer.branch) || null, + rates: (bankOffer && bankOffer.rates) || null, + weightedRate, + consent: true, + createdAt: now, + updatedAt: now, + }; + + const docRef = await communityRef().add(profileDoc); + + // Invalidate cache for this profile hash since new data was added + _cache.delete(profileHash); + + logger.info(`communityService.storeAnonymousProfile: stored profile ${docRef.id}`); + return docRef.id; + } catch (err) { + logger.error(`communityService.storeAnonymousProfile: error: ${err.message}`); + // Non-critical – don't fail the wizard flow + return null; + } +} + +/** + * Update an existing community profile with actual bank offer data. + * + * Called when a user who previously submitted the wizard later uploads + * their actual bank offer. Updates the community profile with real + * bank/branch/rates data. + * + * @param {string} profileId - Firestore document ID of the community profile + * @param {object} bankOffer - Bank offer data + * @param {string} bankOffer.bank - Bank name + * @param {string} [bankOffer.branch] - Branch name + * @param {object} bankOffer.rates - Actual rates { fixed, cpi, prime, variable } + * @returns {Promise} True if updated successfully + */ +async function updateProfileWithOffer(profileId, bankOffer) { + try { + if (!profileId || !bankOffer || !bankOffer.bank) { + logger.warn('communityService.updateProfileWithOffer: missing required fields'); + return false; + } + + const rates = bankOffer.rates || {}; + const rateValues = [ + rates.fixed, + rates.cpi, + rates.prime, + rates.variable, + ].filter((r) => r != null && !isNaN(r)); + + const weightedRate = rateValues.length > 0 + ? Math.round((rateValues.reduce((sum, r) => sum + r, 0) / rateValues.length) * 100) / 100 + : null; + + await communityRef().doc(profileId).update({ + bank: bankOffer.bank, + branch: bankOffer.branch || null, + rates: bankOffer.rates || null, + weightedRate, + updatedAt: new Date().toISOString(), + }); + + // Clear cache since community data changed + clearCache(); + + logger.info(`communityService.updateProfileWithOffer: updated profile ${profileId}`); + return true; + } catch (err) { + logger.error(`communityService.updateProfileWithOffer: error: ${err.message}`); + return false; + } +} + +// ── Statistics ───────────────────────────────────────────────────────────────── + +/** + * Get community statistics for monitoring/admin purposes. + * + * @returns {Promise} Community statistics + */ +async function getCommunityStats() { + try { + // Count total profiles (excluding sentinel) + const snapshot = await communityRef() + .where('consent', '==', true) + .select() // Only fetch document references, not data + .get(); + + const totalProfiles = snapshot.size; + + // Count profiles with bank data + const withBankSnapshot = await communityRef() + .where('consent', '==', true) + .where('bank', '!=', null) + .select() + .get(); + + const profilesWithBank = withBankSnapshot.size; + + return { + totalProfiles, + profilesWithBank, + profilesWithoutBank: totalProfiles - profilesWithBank, + cacheSize: _cache.size, + }; + } catch (err) { + logger.error(`communityService.getCommunityStats: error: ${err.message}`); + return { + totalProfiles: 0, + profilesWithBank: 0, + profilesWithoutBank: 0, + cacheSize: _cache.size, + error: err.message, + }; + } +} + +// ── Exports ─────────────────────────────────────────────────────────────────── + +module.exports = { + // Main entry points + getCommunityTips, + storeAnonymousProfile, + updateProfileWithOffer, + getCommunityStats, + + // Profile operations + findSimilarProfiles, + computeBinnedProfile, + hashProfile, + + // Aggregation + aggregateWinningOffers, + generateCommunityTips, + computeAverageRates, + + // Utilities + binValue, + formatRecency, + clearCache, + + // Constants (exported for testing) + BIN_SIZES, + MATCH_RANGES, + MAX_MATCH_RESULTS, + MAX_TIPS, + MIN_PROFILES_FOR_TIP, + COMMUNITY_CACHE_TTL_MS, +}; diff --git a/src/services/paymentService.js b/src/services/paymentService.js new file mode 100644 index 0000000..e08c82c --- /dev/null +++ b/src/services/paymentService.js @@ -0,0 +1,520 @@ +/** + * Payment Service – Stripe Integration for Expert Analysis Paywall + * + * Handles Stripe Checkout Session creation and webhook event processing. + * When a user pays for expert analysis, this service: + * 1. Creates a Stripe Checkout Session with the analysis product/price + * 2. Processes the `checkout.session.completed` webhook event + * 3. Sets `paidAnalyses: true` on the user's Firestore document + * + * SCA-compliant via Stripe Checkout (hosted payment page). + * + * Environment variables required: + * - STRIPE_SECRET_KEY: Stripe API secret key + * - STRIPE_WEBHOOK_SECRET: Stripe webhook endpoint signing secret + * - STRIPE_PRICE_ID: Stripe Price ID for the analysis product + * + * @module paymentService + */ + +'use strict'; + +const Stripe = require('stripe'); +const db = require('../config/firestore'); +const logger = require('../utils/logger'); + +// ── Stripe Client Initialisation ────────────────────────────────────────────── + +let stripe; +if (process.env.STRIPE_SECRET_KEY) { + stripe = new Stripe(process.env.STRIPE_SECRET_KEY, { + apiVersion: '2024-06-20', + appInfo: { + name: 'Morty', + version: '1.0.0', + }, + }); +} + +// ── Constants ───────────────────────────────────────────────────────────────── + +/** Default analysis price in ILS (agorot) – ₪149 */ +const DEFAULT_PRICE_AMOUNT = 14900; + +/** Currency for payments */ +const CURRENCY = 'ils'; + +/** Firestore collection for payment records */ +const PAYMENTS_COLLECTION = 'payments'; + +/** Firestore collection for users */ +const USERS_COLLECTION = 'users'; + +// ── Checkout Session Creation ───────────────────────────────────────────────── + +/** + * Create a Stripe Checkout Session for the expert analysis product. + * + * The session is configured for a one-time payment using Stripe's + * hosted payment page (SCA-compliant). Metadata includes the userId + * and optional portfolioId for post-payment processing. + * + * @param {object} params + * @param {string} params.userId - Authenticated user's Firestore ID + * @param {string} params.userEmail - User's email for Stripe customer + * @param {string} [params.portfolioId] - Optional portfolio ID to link + * @param {string} params.successUrl - URL to redirect after successful payment + * @param {string} [params.cancelUrl] - URL to redirect if payment is cancelled + * @returns {Promise<{ sessionId: string, url: string }>} Checkout session details + * @throws {Error} If Stripe is not configured or session creation fails + */ +async function createCheckoutSession({ + userId, + userEmail, + portfolioId = null, + successUrl, + cancelUrl = null, +}) { + if (!stripe) { + const err = new Error('Stripe is not configured. Set STRIPE_SECRET_KEY environment variable.'); + err.statusCode = 503; + err.errorCode = 'STRIPE_NOT_CONFIGURED'; + throw err; + } + + if (!userId) { + const err = new Error('userId is required to create a checkout session'); + err.statusCode = 400; + throw err; + } + + if (!successUrl) { + const err = new Error('successUrl is required to create a checkout session'); + err.statusCode = 400; + throw err; + } + + // Check if user already has paid access + const userDoc = await db.collection(USERS_COLLECTION).doc(userId).get(); + if (userDoc.exists && userDoc.data().paidAnalyses === true) { + const err = new Error('User already has paid access to expert analysis'); + err.statusCode = 409; + err.errorCode = 'ALREADY_PAID'; + throw err; + } + + logger.info(`paymentService.createCheckoutSession: creating session for user ${userId}`); + + try { + // Build line items – use configured Price ID or create ad-hoc price + const lineItems = buildLineItems(); + + // Build session parameters + const sessionParams = { + mode: 'payment', + payment_method_types: ['card'], + line_items: lineItems, + success_url: successUrl, + cancel_url: cancelUrl || successUrl, + client_reference_id: userId, + customer_email: userEmail || undefined, + metadata: { + userId, + portfolioId: portfolioId || '', + product: 'expert_analysis', + }, + payment_intent_data: { + metadata: { + userId, + portfolioId: portfolioId || '', + product: 'expert_analysis', + }, + }, + locale: 'he', + }; + + const session = await stripe.checkout.sessions.create(sessionParams); + + logger.info(`paymentService.createCheckoutSession: session ${session.id} created for user ${userId}`); + + // Store a pending payment record in Firestore + await storePendingPayment(userId, session.id, portfolioId); + + return { + sessionId: session.id, + url: session.url, + }; + } catch (err) { + // Re-throw known errors + if (err.statusCode) throw err; + + logger.error(`paymentService.createCheckoutSession error: ${err.message}`); + const wrappedErr = new Error('Failed to create payment session. Please try again.'); + wrappedErr.statusCode = 500; + wrappedErr.errorCode = 'CHECKOUT_SESSION_FAILED'; + throw wrappedErr; + } +} + +/** + * Build Stripe line items for the checkout session. + * + * Uses STRIPE_PRICE_ID if configured (recommended for production), + * otherwise creates an ad-hoc price_data object. + * + * @returns {Array} Stripe line items array + */ +function buildLineItems() { + const priceId = process.env.STRIPE_PRICE_ID; + + if (priceId) { + // Use pre-configured Stripe Price + return [ + { + price: priceId, + quantity: 1, + }, + ]; + } + + // Ad-hoc price (for development / when no Price ID is configured) + return [ + { + price_data: { + currency: CURRENCY, + unit_amount: DEFAULT_PRICE_AMOUNT, + product_data: { + name: 'ניתוח משכנתא מקצועי – Morty', + description: + 'ניתוח OCR מקצועי, טריקים למשכנתא, סקריפט משא ומתן בעברית, ותובנות אסטרטגיות', + }, + }, + quantity: 1, + }, + ]; +} + +// ── Webhook Processing ──────────────────────────────────────────────────────── + +/** + * Verify and construct a Stripe webhook event from the raw request body. + * + * Uses the Stripe webhook signing secret to verify the event signature, + * preventing replay attacks and ensuring the event originated from Stripe. + * + * @param {Buffer|string} rawBody - Raw request body (must NOT be parsed as JSON) + * @param {string} signature - Stripe-Signature header value + * @returns {object} Verified Stripe event object + * @throws {Error} If signature verification fails + */ +function constructWebhookEvent(rawBody, signature) { + if (!stripe) { + const err = new Error('Stripe is not configured'); + err.statusCode = 503; + throw err; + } + + const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET; + if (!webhookSecret) { + const err = new Error('STRIPE_WEBHOOK_SECRET is not configured'); + err.statusCode = 503; + throw err; + } + + try { + return stripe.webhooks.constructEvent(rawBody, signature, webhookSecret); + } catch (err) { + logger.warn(`paymentService.constructWebhookEvent: signature verification failed: ${err.message}`); + const verifyErr = new Error('Webhook signature verification failed'); + verifyErr.statusCode = 400; + verifyErr.errorCode = 'WEBHOOK_SIGNATURE_INVALID'; + throw verifyErr; + } +} + +/** + * Handle a verified Stripe webhook event. + * + * Currently handles: + * - `checkout.session.completed`: Unlocks paid analysis for the user + * - `checkout.session.expired`: Marks the payment record as expired + * + * Unknown event types are logged and acknowledged (200) to prevent + * Stripe from retrying them. + * + * @param {object} event - Verified Stripe event object + * @returns {Promise<{ handled: boolean, type: string, message: string }>} + */ +async function handleWebhookEvent(event) { + const eventType = event.type; + + logger.info(`paymentService.handleWebhookEvent: processing ${eventType} (${event.id})`); + + switch (eventType) { + case 'checkout.session.completed': + return handleCheckoutCompleted(event.data.object); + + case 'checkout.session.expired': + return handleCheckoutExpired(event.data.object); + + default: + logger.info(`paymentService.handleWebhookEvent: unhandled event type ${eventType}`); + return { + handled: false, + type: eventType, + message: `Event type ${eventType} not handled`, + }; + } +} + +/** + * Handle a completed checkout session. + * + * Sets `paidAnalyses: true` on the user's Firestore document and + * updates the payment record to 'completed' status. + * + * @param {object} session - Stripe Checkout Session object + * @returns {Promise<{ handled: boolean, type: string, message: string }>} + */ +async function handleCheckoutCompleted(session) { + const userId = session.metadata?.userId || session.client_reference_id; + const portfolioId = session.metadata?.portfolioId || null; + const sessionId = session.id; + const paymentIntentId = session.payment_intent; + const amountTotal = session.amount_total; + const currency = session.currency; + const customerEmail = session.customer_details?.email || session.customer_email; + + if (!userId) { + logger.error(`paymentService.handleCheckoutCompleted: no userId in session ${sessionId}`); + return { + handled: false, + type: 'checkout.session.completed', + message: 'Missing userId in session metadata', + }; + } + + logger.info( + `paymentService.handleCheckoutCompleted: unlocking paid access for user ${userId} ` + + `(session: ${sessionId}, amount: ${amountTotal} ${currency})` + ); + + try { + // 1. Set paidAnalyses flag on user document + const userRef = db.collection(USERS_COLLECTION).doc(userId); + const userDoc = await userRef.get(); + + if (!userDoc.exists) { + logger.error(`paymentService.handleCheckoutCompleted: user ${userId} not found in Firestore`); + return { + handled: false, + type: 'checkout.session.completed', + message: `User ${userId} not found`, + }; + } + + await userRef.update({ + paidAnalyses: true, + paidAt: new Date().toISOString(), + stripeSessionId: sessionId, + stripePaymentIntentId: paymentIntentId || null, + updatedAt: new Date().toISOString(), + }); + + logger.info(`paymentService.handleCheckoutCompleted: paidAnalyses set to true for user ${userId}`); + + // 2. Update payment record + await updatePaymentRecord(sessionId, { + status: 'completed', + paymentIntentId: paymentIntentId || null, + amountTotal, + currency, + customerEmail: customerEmail || null, + completedAt: new Date().toISOString(), + }); + + return { + handled: true, + type: 'checkout.session.completed', + message: `Paid access unlocked for user ${userId}`, + }; + } catch (err) { + logger.error( + `paymentService.handleCheckoutCompleted error for user ${userId}: ${err.message}` + ); + // Re-throw so the webhook endpoint returns 500 and Stripe retries + throw err; + } +} + +/** + * Handle an expired checkout session. + * + * Updates the payment record to 'expired' status. + * + * @param {object} session - Stripe Checkout Session object + * @returns {Promise<{ handled: boolean, type: string, message: string }>} + */ +async function handleCheckoutExpired(session) { + const sessionId = session.id; + const userId = session.metadata?.userId || session.client_reference_id; + + logger.info(`paymentService.handleCheckoutExpired: session ${sessionId} expired for user ${userId || 'unknown'}`); + + try { + await updatePaymentRecord(sessionId, { + status: 'expired', + expiredAt: new Date().toISOString(), + }); + + return { + handled: true, + type: 'checkout.session.expired', + message: `Session ${sessionId} marked as expired`, + }; + } catch (err) { + logger.warn(`paymentService.handleCheckoutExpired error: ${err.message}`); + // Non-fatal – acknowledge the event + return { + handled: true, + type: 'checkout.session.expired', + message: `Session ${sessionId} expired (record update failed)`, + }; + } +} + +// ── Payment Record Helpers ──────────────────────────────────────────────────── + +/** + * Store a pending payment record in Firestore. + * + * This creates an audit trail for all checkout attempts. + * + * @param {string} userId - User's Firestore ID + * @param {string} sessionId - Stripe Checkout Session ID + * @param {string|null} portfolioId - Optional portfolio ID + * @returns {Promise} + */ +async function storePendingPayment(userId, sessionId, portfolioId) { + try { + const now = new Date().toISOString(); + await db.collection(PAYMENTS_COLLECTION).doc(sessionId).set({ + userId, + sessionId, + portfolioId: portfolioId || null, + product: 'expert_analysis', + status: 'pending', + createdAt: now, + updatedAt: now, + }); + logger.debug(`paymentService.storePendingPayment: stored pending payment ${sessionId}`); + } catch (err) { + // Non-fatal – the payment can still proceed without the record + logger.warn(`paymentService.storePendingPayment error: ${err.message}`); + } +} + +/** + * Update a payment record in Firestore. + * + * @param {string} sessionId - Stripe Checkout Session ID (document ID) + * @param {object} updates - Fields to update + * @returns {Promise} + */ +async function updatePaymentRecord(sessionId, updates) { + try { + const docRef = db.collection(PAYMENTS_COLLECTION).doc(sessionId); + const doc = await docRef.get(); + + if (doc.exists) { + await docRef.update({ + ...updates, + updatedAt: new Date().toISOString(), + }); + } else { + // Create the record if it doesn't exist (e.g., webhook arrived before storePendingPayment) + await docRef.set({ + sessionId, + ...updates, + updatedAt: new Date().toISOString(), + }); + } + } catch (err) { + logger.warn(`paymentService.updatePaymentRecord error (${sessionId}): ${err.message}`); + } +} + +// ── Query Helpers ───────────────────────────────────────────────────────────── + +/** + * Check if a user has paid for expert analysis. + * + * Reads directly from Firestore to get the latest status. + * + * @param {string} userId - User's Firestore ID + * @returns {Promise} True if user has paid access + */ +async function hasUserPaid(userId) { + if (!userId) return false; + + try { + const userDoc = await db.collection(USERS_COLLECTION).doc(userId).get(); + if (!userDoc.exists) return false; + return userDoc.data().paidAnalyses === true; + } catch (err) { + logger.error(`paymentService.hasUserPaid error (userId=${userId}): ${err.message}`); + return false; + } +} + +/** + * Get payment history for a user. + * + * @param {string} userId - User's Firestore ID + * @returns {Promise>} Payment records sorted by createdAt desc + */ +async function getPaymentHistory(userId) { + if (!userId) return []; + + try { + const snap = await db + .collection(PAYMENTS_COLLECTION) + .where('userId', '==', userId) + .orderBy('createdAt', 'desc') + .limit(20) + .get(); + + return snap.docs.map((doc) => ({ id: doc.id, ...doc.data() })); + } catch (err) { + logger.error(`paymentService.getPaymentHistory error (userId=${userId}): ${err.message}`); + return []; + } +} + +// ── Exports ─────────────────────────────────────────────────────────────────── + +module.exports = { + // Checkout + createCheckoutSession, + buildLineItems, + + // Webhook + constructWebhookEvent, + handleWebhookEvent, + handleCheckoutCompleted, + handleCheckoutExpired, + + // Queries + hasUserPaid, + getPaymentHistory, + + // Internal helpers (exported for testing) + storePendingPayment, + updatePaymentRecord, + + // Constants + DEFAULT_PRICE_AMOUNT, + CURRENCY, + PAYMENTS_COLLECTION, + USERS_COLLECTION, +}; diff --git a/src/services/portfolioEngine.js b/src/services/portfolioEngine.js new file mode 100644 index 0000000..3f0d056 --- /dev/null +++ b/src/services/portfolioEngine.js @@ -0,0 +1,873 @@ +/** + * Portfolio Engine – Advanced Mortgage Portfolio Calculation & Scoring + * + * This module provides the core calculation engine for generating and + * evaluating mortgage portfolio scenarios. It is used by wizardService.js + * to produce the 4 strategic scenarios. + * + * Responsibilities: + * - Analyse user profile (risk tolerance, affordability, LTV classification) + * - Determine which scenarios to generate based on conditional logic + * - Build adaptive track allocations that respond to user preferences + * - Calculate financial metrics (PMT, total cost, interest savings) + * - Score and rank portfolios by fitness for the user's profile + * + * Conditional Logic Summary: + * 1. Market Standard (30y) – ALWAYS generated. Balanced mix. + * 2. Fast Track (20y) – ALWAYS generated. Shorter term, interest savings. + * 3. Inflation-Proof – CONDITIONAL: + * - Generated when CPI rate >= 2.5% (high inflation environment) + * - OR when user has moderate-to-high stability preference (4-8) + * - OR when user expects future funds (can prepay CPI-free tracks) + * 4. Stability-First – CONDITIONAL: + * - Generated when stabilityPreference >= 7 + * - OR when income-to-repayment ratio is tight (> 35% of income) + * AND stability preference >= 5 + * + * @module portfolioEngine + */ + +'use strict'; + +const logger = require('../utils/logger'); + +// ── Constants ───────────────────────────────────────────────────────────────── + +/** Portfolio scenario type identifiers */ +const SCENARIO_TYPES = Object.freeze({ + MARKET_STANDARD: 'market_standard', + FAST_TRACK: 'fast_track', + INFLATION_PROOF: 'inflation_proof', + STABILITY_FIRST: 'stability_first', +}); + +/** Hebrew names for each scenario */ +const SCENARIO_NAMES_HE = Object.freeze({ + [SCENARIO_TYPES.MARKET_STANDARD]: 'תיק שוק סטנדרטי', + [SCENARIO_TYPES.FAST_TRACK]: 'מסלול מהיר', + [SCENARIO_TYPES.INFLATION_PROOF]: 'חסין אינפלציה', + [SCENARIO_TYPES.STABILITY_FIRST]: 'יציבות קודם', +}); + +/** English names for each scenario */ +const SCENARIO_NAMES_EN = Object.freeze({ + [SCENARIO_TYPES.MARKET_STANDARD]: 'Market Standard', + [SCENARIO_TYPES.FAST_TRACK]: 'Fast Track', + [SCENARIO_TYPES.INFLATION_PROOF]: 'Inflation-Proof', + [SCENARIO_TYPES.STABILITY_FIRST]: 'Stability-First', +}); + +/** Descriptions (Hebrew) */ +const SCENARIO_DESCRIPTIONS = Object.freeze({ + [SCENARIO_TYPES.MARKET_STANDARD]: '30 שנה – תמהיל מאוזן להחזר חודשי נמוך', + [SCENARIO_TYPES.FAST_TRACK]: '20 שנה – חיסכון משמעותי בריבית', + [SCENARIO_TYPES.INFLATION_PROOF]: 'מסלולים לא צמודים בלבד – הגנה מפני עליית מדד', + [SCENARIO_TYPES.STABILITY_FIRST]: 'דגש על ריבית קבועה – החזר חודשי צפוי ויציב', +}); + +/** Track type labels (Hebrew) */ +const TRACK_LABELS_HE = Object.freeze({ + fixed: 'קבועה לא צמודה (קל"צ)', + cpi: 'צמוד מדד', + prime: 'פריים', + variable: 'משתנה לא צמודה', +}); + +/** Stability preference threshold for Stability-First scenario */ +const STABILITY_THRESHOLD = 7; + +/** CPI rate threshold above which Inflation-Proof is recommended */ +const CPI_RATE_THRESHOLD = 2.5; + +/** Maximum recommended repayment-to-income ratio */ +const MAX_REPAYMENT_RATIO = 0.40; + +/** Tight repayment-to-income ratio threshold */ +const TIGHT_REPAYMENT_RATIO = 0.35; + +/** LTV thresholds for risk classification */ +const LTV_THRESHOLDS = Object.freeze({ + LOW: 50, // <= 50% LTV = low risk + MODERATE: 60, // <= 60% LTV = moderate risk + HIGH: 75, // <= 75% LTV = high risk (Israeli regulatory max for most cases) +}); + +// ── User Profile Analysis ───────────────────────────────────────────────────── + +/** + * Analyse the user's financial profile to derive risk indicators + * and preference signals used for conditional portfolio generation. + * + * @param {object} inputs - Validated wizard inputs + * @param {number} inputs.propertyPrice - Property purchase price (₪) + * @param {number} inputs.loanAmount - Requested loan amount (₪) + * @param {number} inputs.monthlyIncome - Primary monthly income (₪) + * @param {number} [inputs.additionalIncome=0] - Additional monthly income (₪) + * @param {number} inputs.targetRepayment - Desired monthly repayment (₪) + * @param {object} inputs.futureFunds - Future funds info { timeframe, amount } + * @param {number} inputs.stabilityPreference - Stability slider value (1-10) + * @returns {object} User profile analysis + */ +function analyseUserProfile(inputs) { + const totalIncome = inputs.monthlyIncome + (inputs.additionalIncome || 0); + const ltv = (inputs.loanAmount / inputs.propertyPrice) * 100; + const equity = inputs.propertyPrice - inputs.loanAmount; + const repaymentRatio = inputs.targetRepayment / totalIncome; + + // LTV classification + let ltvClass; + if (ltv <= LTV_THRESHOLDS.LOW) { + ltvClass = 'low'; + } else if (ltv <= LTV_THRESHOLDS.MODERATE) { + ltvClass = 'moderate'; + } else if (ltv <= LTV_THRESHOLDS.HIGH) { + ltvClass = 'high'; + } else { + ltvClass = 'very_high'; + } + + // Affordability classification + let affordability; + if (repaymentRatio <= 0.25) { + affordability = 'comfortable'; + } else if (repaymentRatio <= TIGHT_REPAYMENT_RATIO) { + affordability = 'moderate'; + } else if (repaymentRatio <= MAX_REPAYMENT_RATIO) { + affordability = 'tight'; + } else { + affordability = 'stretched'; + } + + // Risk tolerance derived from stability preference + // 1-3 = risk_tolerant, 4-6 = balanced, 7-10 = risk_averse + let riskTolerance; + if (inputs.stabilityPreference <= 3) { + riskTolerance = 'risk_tolerant'; + } else if (inputs.stabilityPreference <= 6) { + riskTolerance = 'balanced'; + } else { + riskTolerance = 'risk_averse'; + } + + // Future funds analysis + const hasFutureFunds = inputs.futureFunds.timeframe !== 'none'; + const futureFundsAmount = hasFutureFunds ? (inputs.futureFunds.amount || 0) : 0; + const futureFundsNearTerm = ['within_5_years'].includes(inputs.futureFunds.timeframe); + const futureFundsMidTerm = ['within_5_years', 'within_10_years'].includes(inputs.futureFunds.timeframe); + + // Can the user benefit from early prepayment strategies? + const canPrepayEarly = hasFutureFunds && futureFundsNearTerm && futureFundsAmount > 0; + + // Is the user's target repayment achievable with a 30-year term? + // (rough estimate: loan / 360 months as minimum possible payment) + const minMonthlyEstimate = inputs.loanAmount / 360; + const targetAchievable = inputs.targetRepayment >= minMonthlyEstimate; + + return { + totalIncome, + ltv: Math.round(ltv * 100) / 100, + ltvClass, + equity, + repaymentRatio: Math.round(repaymentRatio * 1000) / 1000, + affordability, + riskTolerance, + stabilityPreference: inputs.stabilityPreference, + hasFutureFunds, + futureFundsAmount, + futureFundsNearTerm, + futureFundsMidTerm, + canPrepayEarly, + targetAchievable, + }; +} + +// ── Scenario Selection (Conditional Logic) ──────────────────────────────────── + +/** + * Determine which portfolio scenarios to generate based on user inputs, + * current rates, and derived profile analysis. + * + * Always includes: Market Standard, Fast Track + * + * Conditionally includes: + * - Inflation-Proof: when CPI rate is above threshold, OR user has + * moderate stability preference (4-8), OR user expects future funds + * (non-indexed tracks are easier to prepay without penalty) + * - Stability-First: when stability preference >= 7, OR when the + * repayment-to-income ratio is tight (> 35%) AND stability >= 5 + * (tight budgets benefit from predictable payments) + * + * @param {object} inputs - Wizard inputs + * @param {object} rates - Current BOI average rates { fixed, cpi, prime, variable } + * @param {object} profile - User profile analysis from analyseUserProfile() + * @returns {{ scenarios: string[], reasons: object }} Scenarios to generate with reasons + */ +function determineScenarios(inputs, rates, profile) { + const scenarios = [ + SCENARIO_TYPES.MARKET_STANDARD, + SCENARIO_TYPES.FAST_TRACK, + ]; + + const reasons = { + [SCENARIO_TYPES.MARKET_STANDARD]: 'Always included – balanced baseline portfolio', + [SCENARIO_TYPES.FAST_TRACK]: 'Always included – shorter term for interest savings', + }; + + // ── Inflation-Proof Conditional Logic ────────────────────────────────────── + const cpiRate = rates.cpi || 3.15; + const inflationReasons = []; + + // Condition 1: High CPI environment + if (cpiRate >= CPI_RATE_THRESHOLD) { + inflationReasons.push(`CPI rate (${cpiRate}%) >= ${CPI_RATE_THRESHOLD}% threshold`); + } + + // Condition 2: Moderate-to-high stability preference (users who want some + // protection but aren't fully risk-averse) + if (inputs.stabilityPreference >= 4 && inputs.stabilityPreference <= 8) { + inflationReasons.push(`Stability preference (${inputs.stabilityPreference}/10) in moderate-high range`); + } + + // Condition 3: User expects future funds – non-indexed tracks are easier + // to prepay without CPI linkage penalties + if (profile.hasFutureFunds && profile.futureFundsMidTerm) { + inflationReasons.push('Future funds expected – non-indexed tracks easier to prepay'); + } + + // Condition 4: High LTV – CPI-indexed tracks add inflation risk on top + // of already high leverage + if (profile.ltvClass === 'high' || profile.ltvClass === 'very_high') { + inflationReasons.push(`High LTV (${profile.ltv.toFixed(1)}%) – reducing CPI exposure recommended`); + } + + if (inflationReasons.length > 0) { + scenarios.push(SCENARIO_TYPES.INFLATION_PROOF); + reasons[SCENARIO_TYPES.INFLATION_PROOF] = inflationReasons.join('; '); + } + + // ── Stability-First Conditional Logic ────────────────────────────────────── + const stabilityReasons = []; + + // Condition 1: High stability preference (primary trigger) + if (inputs.stabilityPreference >= STABILITY_THRESHOLD) { + stabilityReasons.push(`Stability preference (${inputs.stabilityPreference}/10) >= ${STABILITY_THRESHOLD} threshold`); + } + + // Condition 2: Tight budget + moderate stability preference + // When the repayment-to-income ratio is tight, predictable payments + // are more important even if the user didn't explicitly request max stability + if ( + (profile.affordability === 'tight' || profile.affordability === 'stretched') && + inputs.stabilityPreference >= 5 + ) { + stabilityReasons.push( + `Tight affordability (${(profile.repaymentRatio * 100).toFixed(1)}% of income) with stability preference >= 5` + ); + } + + // Condition 3: No future funds + risk-averse profile + // Without future funds to fall back on, fixed rates provide safety + if (!profile.hasFutureFunds && profile.riskTolerance === 'risk_averse') { + stabilityReasons.push('No future funds expected with risk-averse profile'); + } + + if (stabilityReasons.length > 0) { + scenarios.push(SCENARIO_TYPES.STABILITY_FIRST); + reasons[SCENARIO_TYPES.STABILITY_FIRST] = stabilityReasons.join('; '); + } + + return { scenarios, reasons }; +} + +// ── Adaptive Track Allocation ───────────────────────────────────────────────── + +/** + * Get adaptive track allocation for a scenario, adjusted based on + * the user's profile. This goes beyond static percentages by + * shifting allocations based on LTV, affordability, future funds, + * and stability preference. + * + * @param {string} scenarioType - Scenario type identifier + * @param {object} inputs - Wizard inputs + * @param {object} rates - Current BOI average rates + * @param {object} profile - User profile analysis + * @returns {{ termYears: number, tracks: Array }} + */ +function getAdaptiveAllocation(scenarioType, inputs, rates, profile) { + const fixedRate = rates.fixed || 4.65; + const cpiRate = rates.cpi || 3.15; + const primeRate = rates.prime || 6.05; + const variableRate = rates.variable || 4.95; + + switch (scenarioType) { + case SCENARIO_TYPES.MARKET_STANDARD: + return buildMarketStandard(fixedRate, cpiRate, primeRate, profile); + + case SCENARIO_TYPES.FAST_TRACK: + return buildFastTrack(fixedRate, primeRate, variableRate, profile); + + case SCENARIO_TYPES.INFLATION_PROOF: + return buildInflationProof(fixedRate, primeRate, variableRate, profile); + + case SCENARIO_TYPES.STABILITY_FIRST: + return buildStabilityFirst(fixedRate, cpiRate, primeRate, profile); + + default: + logger.warn(`portfolioEngine.getAdaptiveAllocation: unknown scenario '${scenarioType}'`); + return buildMarketStandard(fixedRate, cpiRate, primeRate, profile); + } +} + +/** + * Market Standard (30 years) – Balanced mix for lowest monthly repayment. + * + * Adaptive adjustments: + * - Higher stability preference → more fixed, less prime + * - Tight affordability → more prime (lower initial rate) + * - High LTV → more fixed for safety + */ +function buildMarketStandard(fixedRate, cpiRate, primeRate, profile) { + let fixedPct = 34; + let primePct = 33; + let cpiPct = 33; + + // Adjust for stability preference + if (profile.stabilityPreference >= 7) { + fixedPct += 8; // 42% + primePct -= 5; // 28% + cpiPct -= 3; // 30% + } else if (profile.stabilityPreference <= 3) { + fixedPct -= 6; // 28% + primePct += 6; // 39% + // cpiPct stays 33% + } + + // Adjust for affordability – tight budgets benefit from lower initial prime rates + if (profile.affordability === 'tight' || profile.affordability === 'stretched') { + primePct += 5; + fixedPct -= 5; + } + + // Adjust for high LTV – more fixed for safety + if (profile.ltvClass === 'high' || profile.ltvClass === 'very_high') { + fixedPct += 4; + primePct -= 4; + } + + // Normalize to 100% + const total = fixedPct + primePct + cpiPct; + fixedPct = Math.round((fixedPct / total) * 100); + primePct = Math.round((primePct / total) * 100); + cpiPct = 100 - fixedPct - primePct; + + return { + termYears: 30, + tracks: [ + { type: 'fixed', percentage: fixedPct, rate: fixedRate + 0.1, rateDisplay: `${(fixedRate + 0.1).toFixed(2)}%` }, + { type: 'prime', percentage: primePct, rate: primeRate - 0.15, rateDisplay: 'P-0.15%' }, + { type: 'cpi', percentage: cpiPct, rate: cpiRate + 0.05, rateDisplay: `${(cpiRate + 0.05).toFixed(2)}% + מדד` }, + ], + }; +} + +/** + * Fast Track (20 years) – Shorter term for massive interest savings. + * + * Adaptive adjustments: + * - Future funds near-term → more prime (can prepay when funds arrive) + * - Risk-tolerant → more variable for lower rates + * - Risk-averse → more fixed even in fast track + */ +function buildFastTrack(fixedRate, primeRate, variableRate, profile) { + let primePct = 40; + let fixedPct = 30; + let variablePct = 30; + + // Future funds near-term: more prime (easy to prepay) + if (profile.canPrepayEarly) { + primePct += 8; // 48% + fixedPct -= 4; // 26% + variablePct -= 4; // 26% + } + + // Risk-tolerant users: more variable for lower rates + if (profile.riskTolerance === 'risk_tolerant') { + variablePct += 8; + fixedPct -= 8; + } + + // Risk-averse users: more fixed even in fast track + if (profile.riskTolerance === 'risk_averse') { + fixedPct += 10; + primePct -= 5; + variablePct -= 5; + } + + // Normalize to 100% + const total = primePct + fixedPct + variablePct; + primePct = Math.round((primePct / total) * 100); + fixedPct = Math.round((fixedPct / total) * 100); + variablePct = 100 - primePct - fixedPct; + + return { + termYears: 20, + tracks: [ + { type: 'prime', percentage: primePct, rate: primeRate - 0.2, rateDisplay: 'P-0.2%' }, + { type: 'fixed', percentage: fixedPct, rate: fixedRate + 0.05, rateDisplay: `${(fixedRate + 0.05).toFixed(2)}%` }, + { type: 'variable', percentage: variablePct, rate: variableRate, rateDisplay: `${variableRate.toFixed(2)}%` }, + ], + }; +} + +/** + * Inflation-Proof – Strictly non-indexed tracks (no CPI). + * + * Adaptive adjustments: + * - Term adjusted by stability preference (higher pref → shorter term) + * - Future funds → more prime (prepayable) + * - High stability → more fixed within the non-indexed universe + * - Risk-tolerant → more variable for lower rates + */ +function buildInflationProof(fixedRate, primeRate, variableRate, profile) { + // Term: 25 years for moderate stability, 30 for low, 22 for high + let termYears; + if (profile.stabilityPreference >= 7) { + termYears = 22; + } else if (profile.stabilityPreference >= 5) { + termYears = 25; + } else { + termYears = 30; + } + + let fixedPct = 40; + let primePct = 35; + let variablePct = 25; + + // High stability → more fixed + if (profile.stabilityPreference >= 7) { + fixedPct += 10; // 50% + primePct -= 5; // 30% + variablePct -= 5; // 20% + } + + // Risk-tolerant → more variable + if (profile.riskTolerance === 'risk_tolerant') { + variablePct += 10; + fixedPct -= 10; + } + + // Future funds near-term → more prime (easy to prepay) + if (profile.canPrepayEarly) { + primePct += 8; + fixedPct -= 4; + variablePct -= 4; + } + + // Comfortable affordability → can handle shorter term + if (profile.affordability === 'comfortable' && termYears > 22) { + termYears = Math.max(22, termYears - 3); + } + + // Normalize to 100% + const total = fixedPct + primePct + variablePct; + fixedPct = Math.round((fixedPct / total) * 100); + primePct = Math.round((primePct / total) * 100); + variablePct = 100 - fixedPct - primePct; + + return { + termYears, + tracks: [ + { type: 'fixed', percentage: fixedPct, rate: fixedRate + 0.15, rateDisplay: `${(fixedRate + 0.15).toFixed(2)}%` }, + { type: 'prime', percentage: primePct, rate: primeRate - 0.1, rateDisplay: 'P-0.1%' }, + { type: 'variable', percentage: variablePct, rate: variableRate + 0.05, rateDisplay: `${(variableRate + 0.05).toFixed(2)}%` }, + ], + }; +} + +/** + * Stability-First – Heavy fixed-rate allocation (>= 60%). + * + * Adaptive adjustments: + * - Very high stability (9-10) → up to 70% fixed + * - Tight affordability → longer term (30y) for lower payments + * - Future funds → some CPI allowed (can prepay if inflation rises) + * - Comfortable affordability → shorter term (22-25y) + */ +function buildStabilityFirst(fixedRate, cpiRate, primeRate, profile) { + // Term: 25 default, 30 for tight budgets, 22 for comfortable + let termYears = 25; + if (profile.affordability === 'tight' || profile.affordability === 'stretched') { + termYears = 30; + } else if (profile.affordability === 'comfortable') { + termYears = 22; + } + + let fixedPct = 60; + let cpiPct = 25; + let primePct = 15; + + // Very high stability preference → even more fixed + if (profile.stabilityPreference >= 9) { + fixedPct = 70; + cpiPct = 20; + primePct = 10; + } else if (profile.stabilityPreference >= 8) { + fixedPct = 65; + cpiPct = 22; + primePct = 13; + } + + // Future funds → can tolerate slightly more prime (prepayable) + if (profile.hasFutureFunds && profile.futureFundsMidTerm) { + primePct += 5; + cpiPct -= 5; + } + + // Normalize to 100% + const total = fixedPct + cpiPct + primePct; + fixedPct = Math.round((fixedPct / total) * 100); + cpiPct = Math.round((cpiPct / total) * 100); + primePct = 100 - fixedPct - cpiPct; + + return { + termYears, + tracks: [ + { type: 'fixed', percentage: fixedPct, rate: fixedRate + 0.2, rateDisplay: `${(fixedRate + 0.2).toFixed(2)}%` }, + { type: 'cpi', percentage: cpiPct, rate: cpiRate + 0.1, rateDisplay: `${(cpiRate + 0.1).toFixed(2)}% + מדד` }, + { type: 'prime', percentage: primePct, rate: primeRate - 0.1, rateDisplay: 'P-0.1%' }, + ], + }; +} + +// ── Financial Calculations ──────────────────────────────────────────────────── + +/** + * Calculate monthly payment using the standard PMT (amortization) formula. + * + * PMT = P × [r(1+r)^n] / [(1+r)^n − 1] + * + * @param {number} principal - Loan principal amount + * @param {number} monthlyRate - Monthly interest rate (decimal) + * @param {number} totalMonths - Total number of monthly payments + * @returns {number} Monthly payment amount + */ +function calculatePMT(principal, monthlyRate, totalMonths) { + if (principal <= 0) return 0; + if (monthlyRate <= 0) return principal / totalMonths; + if (totalMonths <= 0) return 0; + + const factor = Math.pow(1 + monthlyRate, totalMonths); + return principal * (monthlyRate * factor) / (factor - 1); +} + +/** + * Calculate a baseline monthly payment for a standard 30-year mixed portfolio. + * Used to compute interest savings for alternative scenarios. + * + * @param {number} loanAmount - Total loan amount + * @param {object} rates - Current BOI average rates + * @param {number} termYears - Loan term in years + * @returns {number} Baseline monthly payment + */ +function calculateBaselineMonthlyPayment(loanAmount, rates, termYears) { + const totalMonths = termYears * 12; + const fixedRate = (rates.fixed || 4.65) / 100 / 12; + const primeRate = (rates.prime || 6.05) / 100 / 12; + const cpiRate = (rates.cpi || 3.15) / 100 / 12; + + // Standard 34/33/33 split + const fixedPayment = calculatePMT(loanAmount * 0.34, fixedRate, totalMonths); + const primePayment = calculatePMT(loanAmount * 0.33, primeRate, totalMonths); + const cpiPayment = calculatePMT(loanAmount * 0.33, cpiRate, totalMonths); + + return fixedPayment + primePayment + cpiPayment; +} + +// ── Portfolio Building ──────────────────────────────────────────────────────── + +/** + * Build a complete portfolio object from a track configuration. + * + * Calculates monthly repayment, total cost, and total interest + * using standard amortization (PMT) formula for each track, + * then aggregates across all tracks. + * + * @param {object} config - Track configuration { termYears, tracks } + * @param {string} scenarioType - Scenario type identifier + * @param {object} inputs - Wizard inputs + * @param {object} rates - Current BOI average rates + * @param {string} [generationMethod='rule_based'] - How the portfolio was generated + * @returns {object} Complete portfolio object + */ +function buildPortfolio(config, scenarioType, inputs, rates, generationMethod) { + const method = generationMethod || 'rule_based'; + const { termYears, tracks } = config; + const loanAmount = inputs.loanAmount; + const totalMonths = termYears * 12; + + let totalMonthlyRepayment = 0; + let totalCost = 0; + + const enrichedTracks = tracks.map((track) => { + const trackAmount = loanAmount * (track.percentage / 100); + const monthlyRate = track.rate / 100 / 12; + const monthlyPayment = calculatePMT(trackAmount, monthlyRate, totalMonths); + const trackTotalCost = monthlyPayment * totalMonths; + const trackInterest = trackTotalCost - trackAmount; + + totalMonthlyRepayment += monthlyPayment; + totalCost += trackTotalCost; + + return { + name: TRACK_LABELS_HE[track.type] || track.type, + nameEn: track.type, + type: track.type, + percentage: track.percentage, + rate: track.rate, + rateDisplay: track.rateDisplay || `${track.rate.toFixed(2)}%`, + amount: Math.round(trackAmount), + monthlyPayment: Math.round(monthlyPayment), + totalCost: Math.round(trackTotalCost), + totalInterest: Math.round(trackInterest), + }; + }); + + totalMonthlyRepayment = Math.round(totalMonthlyRepayment); + totalCost = Math.round(totalCost); + const totalInterest = totalCost - loanAmount; + + // Calculate interest savings compared to Market Standard 30-year baseline + const baselineMonthly = calculateBaselineMonthlyPayment(loanAmount, rates, 30); + const baselineTotalCost = Math.round(baselineMonthly * 30 * 12); + const interestSavings = scenarioType !== SCENARIO_TYPES.MARKET_STANDARD + ? Math.max(0, baselineTotalCost - totalCost) + : 0; + + return { + id: scenarioType, + type: scenarioType, + name: SCENARIO_NAMES_EN[scenarioType] || scenarioType, + nameHe: SCENARIO_NAMES_HE[scenarioType] || scenarioType, + description: SCENARIO_DESCRIPTIONS[scenarioType] || '', + termYears, + tracks: enrichedTracks, + monthlyRepayment: totalMonthlyRepayment, + totalCost, + totalInterest: Math.max(0, totalInterest), + interestSavings, + recommended: false, // Set by scorePortfolios() + _generationMethod: method, + }; +} + +/** + * Enrich an AI-generated portfolio with standard fields and recalculated values. + * + * @param {object} aiPortfolio - Raw AI-generated portfolio + * @param {string} scenarioType - Scenario type identifier + * @param {object} inputs - Wizard inputs + * @param {object} rates - Current BOI average rates + * @returns {object} Enriched portfolio + */ +function enrichPortfolio(aiPortfolio, scenarioType, inputs, rates) { + const termYears = aiPortfolio.termYears || 30; + const loanAmount = inputs.loanAmount; + const totalMonths = termYears * 12; + + let totalMonthlyRepayment = 0; + let totalCost = 0; + + const enrichedTracks = (aiPortfolio.tracks || []).map((track) => { + const trackAmount = loanAmount * (track.percentage / 100); + const monthlyRate = track.rate / 100 / 12; + const monthlyPayment = calculatePMT(trackAmount, monthlyRate, totalMonths); + const trackTotalCost = monthlyPayment * totalMonths; + const trackInterest = trackTotalCost - trackAmount; + + totalMonthlyRepayment += monthlyPayment; + totalCost += trackTotalCost; + + return { + name: TRACK_LABELS_HE[track.type] || track.type, + nameEn: track.type, + type: track.type, + percentage: track.percentage, + rate: track.rate, + rateDisplay: track.rateDisplay || `${track.rate.toFixed(2)}%`, + amount: Math.round(trackAmount), + monthlyPayment: Math.round(monthlyPayment), + totalCost: Math.round(trackTotalCost), + totalInterest: Math.round(trackInterest), + }; + }); + + totalMonthlyRepayment = Math.round(totalMonthlyRepayment); + totalCost = Math.round(totalCost); + const totalInterest = totalCost - loanAmount; + + const baselineMonthly = calculateBaselineMonthlyPayment(loanAmount, rates, 30); + const baselineTotalCost = Math.round(baselineMonthly * 30 * 12); + const interestSavings = scenarioType !== SCENARIO_TYPES.MARKET_STANDARD + ? Math.max(0, baselineTotalCost - totalCost) + : 0; + + return { + id: scenarioType, + type: scenarioType, + name: SCENARIO_NAMES_EN[scenarioType] || scenarioType, + nameHe: SCENARIO_NAMES_HE[scenarioType] || scenarioType, + description: SCENARIO_DESCRIPTIONS[scenarioType] || '', + termYears, + tracks: enrichedTracks, + monthlyRepayment: totalMonthlyRepayment, + totalCost, + totalInterest: Math.max(0, totalInterest), + interestSavings, + recommended: false, + _generationMethod: 'ai', + }; +} + +// ── Portfolio Scoring & Ranking ─────────────────────────────────────────────── + +/** + * Score and rank portfolios based on how well they fit the user's profile. + * + * Scoring criteria (weighted): + * - Repayment proximity to target (30%): How close is the monthly + * repayment to the user's target? + * - Stability match (25%): Does the portfolio's fixed-rate allocation + * match the user's stability preference? + * - Interest efficiency (20%): Lower total interest = better + * - Future funds alignment (15%): Does the portfolio work well with + * expected future funds? + * - Affordability safety (10%): Is the repayment within safe income ratio? + * + * @param {Array} portfolios - Generated portfolios + * @param {object} inputs - Wizard inputs + * @param {object} profile - User profile analysis + * @returns {Array} Portfolios with fitnessScore and recommended flag + */ +function scorePortfolios(portfolios, inputs, profile) { + if (!portfolios || portfolios.length === 0) return portfolios; + + const scored = portfolios.map((portfolio) => { + const scores = {}; + + // 1. Repayment proximity to target (30%) + const repaymentDiff = Math.abs(portfolio.monthlyRepayment - inputs.targetRepayment); + const repaymentRange = inputs.targetRepayment * 0.5; // 50% tolerance + scores.repaymentProximity = Math.max(0, 1 - (repaymentDiff / repaymentRange)); + + // 2. Stability match (25%) + const fixedAllocation = portfolio.tracks + .filter((t) => t.type === 'fixed') + .reduce((sum, t) => sum + t.percentage, 0); + // Map stability preference 1-10 to expected fixed allocation 10%-70% + const expectedFixed = 10 + (inputs.stabilityPreference - 1) * (60 / 9); + const fixedDiff = Math.abs(fixedAllocation - expectedFixed); + scores.stabilityMatch = Math.max(0, 1 - (fixedDiff / 50)); + + // 3. Interest efficiency (20%) + // Compare to the highest-interest portfolio in the set + const maxInterest = Math.max(...portfolios.map((p) => p.totalInterest)); + const minInterest = Math.min(...portfolios.map((p) => p.totalInterest)); + const interestRange = maxInterest - minInterest; + scores.interestEfficiency = interestRange > 0 + ? 1 - ((portfolio.totalInterest - minInterest) / interestRange) + : 1; + + // 4. Future funds alignment (15%) + if (profile.hasFutureFunds) { + // Portfolios with more prime/variable tracks are better for prepayment + const prepayablePct = portfolio.tracks + .filter((t) => t.type === 'prime' || t.type === 'variable') + .reduce((sum, t) => sum + t.percentage, 0); + scores.futureFundsAlignment = prepayablePct / 100; + + // Shorter terms are better when future funds are expected + if (profile.futureFundsNearTerm && portfolio.termYears <= 20) { + scores.futureFundsAlignment = Math.min(1, scores.futureFundsAlignment + 0.2); + } + } else { + // No future funds: neutral score + scores.futureFundsAlignment = 0.5; + } + + // 5. Affordability safety (10%) + const repaymentRatio = portfolio.monthlyRepayment / profile.totalIncome; + if (repaymentRatio <= 0.30) { + scores.affordabilitySafety = 1.0; + } else if (repaymentRatio <= TIGHT_REPAYMENT_RATIO) { + scores.affordabilitySafety = 0.8; + } else if (repaymentRatio <= MAX_REPAYMENT_RATIO) { + scores.affordabilitySafety = 0.5; + } else { + scores.affordabilitySafety = 0.2; + } + + // Weighted total + const fitnessScore = Math.round(( + scores.repaymentProximity * 0.30 + + scores.stabilityMatch * 0.25 + + scores.interestEfficiency * 0.20 + + scores.futureFundsAlignment * 0.15 + + scores.affordabilitySafety * 0.10 + ) * 100); + + return { + ...portfolio, + fitnessScore, + scoreBreakdown: scores, + }; + }); + + // Mark the highest-scoring portfolio as recommended + const maxScore = Math.max(...scored.map((p) => p.fitnessScore)); + return scored.map((p) => ({ + ...p, + recommended: p.fitnessScore === maxScore, + })); +} + +// ── Exports ─────────────────────────────────────────────────────────────────── + +module.exports = { + // User profile analysis + analyseUserProfile, + + // Scenario selection + determineScenarios, + + // Track allocation + getAdaptiveAllocation, + + // Portfolio building + buildPortfolio, + enrichPortfolio, + + // Scoring + scorePortfolios, + + // Financial calculations + calculatePMT, + calculateBaselineMonthlyPayment, + + // Internal builders (exported for testing) + buildMarketStandard, + buildFastTrack, + buildInflationProof, + buildStabilityFirst, + + // Constants + SCENARIO_TYPES, + SCENARIO_NAMES_HE, + SCENARIO_NAMES_EN, + SCENARIO_DESCRIPTIONS, + TRACK_LABELS_HE, + STABILITY_THRESHOLD, + CPI_RATE_THRESHOLD, + MAX_REPAYMENT_RATIO, + TIGHT_REPAYMENT_RATIO, + LTV_THRESHOLDS, +}; diff --git a/src/services/ratesService.js b/src/services/ratesService.js new file mode 100644 index 0000000..0f57160 --- /dev/null +++ b/src/services/ratesService.js @@ -0,0 +1,643 @@ +/** + * Rates Service – Bank of Israel Mortgage Rates Integration + * + * Fetches average mortgage interest rates from the Bank of Israel (BOI) + * public statistics API, parses the data by mortgage track type, and + * stores the results in the Firestore `mortgage_rates` collection. + * + * Mortgage track types (Israeli market): + * - fixed (קל"צ / קבועה לא צמודה) – Fixed rate, non-indexed + * - cpi (צמוד מדד) – CPI-indexed + * - prime (פריים) – Prime-linked variable rate + * - variable (משתנה לא צמודה) – Variable rate, non-indexed + * + * Data source: Bank of Israel statistical series API + * https://edge.boi.gov.il/FusionEdgeServer/sdmx/v2/data/dataflow/BOI/BOI.STAT.MTRG/1.0 + * + * The service maintains a 1-hour in-memory cache to avoid redundant + * Firestore reads on the public /rates/latest endpoint. + * + * Cron: A daily job (02:00 IST) triggers fetchAndStoreLatestRates(). + */ + +'use strict'; + +const axios = require('axios'); +const db = require('../config/firestore'); +const logger = require('../utils/logger'); +const { COLLECTIONS } = require('../config/collections'); + +// ── Constants ───────────────────────────────────────────────────────────────── + +/** + * Bank of Israel SDMX API base URL. + * The BOI exposes statistical data via an SDMX-compliant REST API. + * We query specific series for average new-mortgage interest rates. + */ +const BOI_API_BASE = + process.env.BOI_API_BASE_URL || + 'https://edge.boi.gov.il/FusionEdgeServer/sdmx/v2/data/dataflow/BOI'; + +/** + * BOI statistical series IDs for average new-mortgage interest rates. + * + * These series represent the average interest rate on NEW mortgages + * granted in a given month, broken down by track type. + * + * Series naming convention: BOI.STAT.MTRG.RATE_AVG. + * + * Source: Bank of Israel – Mortgage Statistics + * https://www.boi.org.il/en/economic-roles/statistics/ + */ +const BOI_RATE_SERIES = { + // Fixed rate, non-indexed (קבועה לא צמודה / קל"צ) + fixed: 'BOI.STAT.MTRG.I_AVG.FXD_NI', + // CPI-indexed (צמוד מדד) + cpi: 'BOI.STAT.MTRG.I_AVG.FXD_CI', + // Prime-linked (פריים) + prime: 'BOI.STAT.MTRG.I_AVG.VAR_PRM', + // Variable, non-indexed (משתנה לא צמודה) + variable: 'BOI.STAT.MTRG.I_AVG.VAR_NI', +}; + +/** + * Hebrew labels for each track type (used in API responses). + */ +const TRACK_LABELS = { + fixed: 'קבועה לא צמודה (קל"צ)', + cpi: 'צמוד מדד', + prime: 'פריים', + variable: 'משתנה לא צמודה', +}; + +/** Cache TTL: 1 hour in milliseconds */ +const CACHE_TTL_MS = 60 * 60 * 1000; + +/** Firestore collection reference */ +const ratesRef = () => db.collection(COLLECTIONS.MORTGAGE_RATES); + +// ── In-Memory Cache ─────────────────────────────────────────────────────────── + +let _cachedRates = null; +let _cacheTimestamp = 0; + +/** + * Clear the in-memory rates cache. + * Useful for testing and after a fresh fetch. + */ +function clearCache() { + _cachedRates = null; + _cacheTimestamp = 0; +} + +/** + * Check if the in-memory cache is still valid. + * @returns {boolean} + */ +function isCacheValid() { + return _cachedRates !== null && Date.now() - _cacheTimestamp < CACHE_TTL_MS; +} + +// ── BOI API Fetching ────────────────────────────────────────────────────────── + +/** + * Fetch a single rate series from the Bank of Israel SDMX API. + * + * The BOI API returns data in SDMX-JSON format. We extract the + * observation values (monthly averages) for the last 12 months. + * + * @param {string} seriesId - BOI statistical series identifier + * @param {string} startPeriod - Start period in YYYY-MM format + * @param {string} endPeriod - End period in YYYY-MM format + * @returns {Promise>} Monthly rate observations + */ +async function fetchSeriesFromBOI(seriesId, startPeriod, endPeriod) { + const url = `${BOI_API_BASE}/${seriesId}/1.0`; + + try { + const response = await axios.get(url, { + params: { + startperiod: startPeriod, + endperiod: endPeriod, + format: 'sdmx-json', + }, + timeout: 15000, + headers: { + Accept: 'application/json', + 'User-Agent': 'Morty-Backend/1.0', + }, + }); + + return parseSDMXResponse(response.data); + } catch (err) { + // If the BOI API is unavailable, log and return empty array + // so the service degrades gracefully + if (err.response) { + logger.warn( + `ratesService.fetchSeriesFromBOI: BOI API returned ${err.response.status} for series ${seriesId}` + ); + } else if (err.code === 'ECONNABORTED') { + logger.warn(`ratesService.fetchSeriesFromBOI: timeout fetching series ${seriesId}`); + } else { + logger.error(`ratesService.fetchSeriesFromBOI: error fetching series ${seriesId}: ${err.message}`); + } + return []; + } +} + +/** + * Parse an SDMX-JSON response from the BOI API. + * + * SDMX-JSON structure (simplified): + * { + * data: { + * dataSets: [{ + * series: { + * "0:0:0:0": { + * observations: { + * "0": [3.85], + * "1": [3.92], + * ... + * } + * } + * } + * }], + * structure: { + * dimensions: { + * observation: [{ + * values: [ + * { id: "2024-01", name: "2024-01" }, + * { id: "2024-02", name: "2024-02" }, + * ... + * ] + * }] + * } + * } + * } + * } + * + * @param {object} sdmxData - Raw SDMX-JSON response + * @returns {Array<{period: string, value: number}>} + */ +function parseSDMXResponse(sdmxData) { + const observations = []; + + try { + const dataSets = sdmxData?.data?.dataSets; + const structure = sdmxData?.data?.structure; + + if (!dataSets || !dataSets.length || !structure) { + logger.warn('ratesService.parseSDMXResponse: unexpected SDMX structure'); + return observations; + } + + // Get time dimension values (period labels) + const timeDimension = structure.dimensions?.observation?.find( + (dim) => dim.id === 'TIME_PERIOD' || dim.role === 'time' + ); + const timeValues = timeDimension?.values || []; + + // Get the first (and usually only) series + const seriesObj = dataSets[0]?.series; + if (!seriesObj) return observations; + + const seriesKeys = Object.keys(seriesObj); + if (!seriesKeys.length) return observations; + + const series = seriesObj[seriesKeys[0]]; + const obs = series?.observations || {}; + + for (const [obsIndex, obsValues] of Object.entries(obs)) { + const idx = parseInt(obsIndex, 10); + const period = timeValues[idx]?.id || timeValues[idx]?.name || `unknown-${idx}`; + const value = Array.isArray(obsValues) ? obsValues[0] : obsValues; + + if (value !== null && value !== undefined && !isNaN(Number(value))) { + observations.push({ + period, + value: Math.round(Number(value) * 100) / 100, // Round to 2 decimal places + }); + } + } + + // Sort by period ascending + observations.sort((a, b) => a.period.localeCompare(b.period)); + } catch (err) { + logger.error(`ratesService.parseSDMXResponse: parse error: ${err.message}`); + } + + return observations; +} + +// ── Core Business Logic ─────────────────────────────────────────────────────── + +/** + * Fetch the latest year of mortgage rates from the Bank of Israel + * for all track types and store them in Firestore. + * + * This is the main entry point called by the daily cron job and + * the manual fetch-rates script. + * + * @returns {Promise} The stored rates document, or null on failure + */ +async function fetchAndStoreLatestRates() { + logger.info('ratesService.fetchAndStoreLatestRates: starting BOI rates fetch'); + + const now = new Date(); + const endPeriod = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`; + + // Fetch last 13 months to ensure we have a full year of data + // (current month may not have data yet) + const startDate = new Date(now); + startDate.setMonth(startDate.getMonth() - 13); + const startPeriod = `${startDate.getFullYear()}-${String(startDate.getMonth() + 1).padStart(2, '0')}`; + + logger.info(`ratesService: fetching BOI rates from ${startPeriod} to ${endPeriod}`); + + // Fetch all track series in parallel + const trackNames = Object.keys(BOI_RATE_SERIES); + const fetchPromises = trackNames.map((track) => + fetchSeriesFromBOI(BOI_RATE_SERIES[track], startPeriod, endPeriod) + ); + + const results = await Promise.allSettled(fetchPromises); + + // Build tracks object with monthly data and computed averages + const tracks = {}; + let hasAnyData = false; + + for (let i = 0; i < trackNames.length; i++) { + const trackName = trackNames[i]; + const result = results[i]; + + if (result.status === 'fulfilled' && result.value.length > 0) { + const monthlyData = result.value; + const values = monthlyData.map((d) => d.value); + const average = Math.round((values.reduce((sum, v) => sum + v, 0) / values.length) * 100) / 100; + const latest = monthlyData[monthlyData.length - 1]; + + tracks[trackName] = { + label: TRACK_LABELS[trackName], + seriesId: BOI_RATE_SERIES[trackName], + average, + latest: { + period: latest.period, + value: latest.value, + }, + monthlyData, + count: monthlyData.length, + }; + hasAnyData = true; + } else { + logger.warn(`ratesService: no data for track '${trackName}'`); + tracks[trackName] = { + label: TRACK_LABELS[trackName], + seriesId: BOI_RATE_SERIES[trackName], + average: null, + latest: null, + monthlyData: [], + count: 0, + }; + } + } + + // If we got no data at all from BOI, fall back to hardcoded recent averages + // so the wizard can still function. These are updated periodically. + if (!hasAnyData) { + logger.warn('ratesService: BOI API returned no data – using fallback rates'); + return storeFallbackRates(); + } + + // Build the rates document + const ratesDoc = { + date: now.toISOString(), + fetchPeriod: { + start: startPeriod, + end: endPeriod, + }, + tracks, + // Convenience: flat averages object for quick access + averages: { + fixed: tracks.fixed?.average ?? null, + cpi: tracks.cpi?.average ?? null, + prime: tracks.prime?.average ?? null, + variable: tracks.variable?.average ?? null, + }, + source: 'bank_of_israel', + sourceUrl: 'https://www.boi.org.il/en/economic-roles/statistics/', + updatedAt: now.toISOString(), + }; + + // Store in Firestore + try { + await storeRatesDocument(ratesDoc); + logger.info('ratesService.fetchAndStoreLatestRates: rates stored successfully'); + + // Invalidate cache so next read picks up fresh data + clearCache(); + + return ratesDoc; + } catch (err) { + logger.error(`ratesService.fetchAndStoreLatestRates: Firestore write error: ${err.message}`); + throw err; + } +} + +/** + * Store a rates document in Firestore. + * + * Uses a date-based document ID (YYYY-MM-DD) so we keep a history + * of daily snapshots. Also updates a `latest` document for O(1) reads. + * + * @param {object} ratesDoc - The rates document to store + * @returns {Promise} + */ +async function storeRatesDocument(ratesDoc) { + const dateId = ratesDoc.date.substring(0, 10); // YYYY-MM-DD + + const batch = db.batch(); + + // Write the dated snapshot + const snapshotRef = ratesRef().doc(dateId); + batch.set(snapshotRef, ratesDoc); + + // Write/overwrite the "latest" convenience document + const latestRef = ratesRef().doc('latest'); + batch.set(latestRef, { ...ratesDoc, _isLatest: true }); + + await batch.commit(); +} + +/** + * Store fallback rates when the BOI API is unavailable. + * + * These rates are based on recent Bank of Israel published averages + * and are updated periodically in the codebase. They ensure the + * wizard can still generate meaningful portfolios even when the + * live API is down. + * + * Last updated: 2025-Q1 averages + * + * @returns {Promise} The stored fallback rates document + */ +async function storeFallbackRates() { + const now = new Date(); + + const ratesDoc = { + date: now.toISOString(), + fetchPeriod: { + start: '2024-01', + end: '2025-03', + }, + tracks: { + fixed: { + label: TRACK_LABELS.fixed, + seriesId: BOI_RATE_SERIES.fixed, + average: 4.65, + latest: { period: '2025-03', value: 4.55 }, + monthlyData: [ + { period: '2024-04', value: 4.80 }, + { period: '2024-05', value: 4.75 }, + { period: '2024-06', value: 4.70 }, + { period: '2024-07', value: 4.72 }, + { period: '2024-08', value: 4.68 }, + { period: '2024-09', value: 4.65 }, + { period: '2024-10', value: 4.60 }, + { period: '2024-11', value: 4.58 }, + { period: '2024-12', value: 4.55 }, + { period: '2025-01', value: 4.52 }, + { period: '2025-02', value: 4.50 }, + { period: '2025-03', value: 4.55 }, + ], + count: 12, + }, + cpi: { + label: TRACK_LABELS.cpi, + seriesId: BOI_RATE_SERIES.cpi, + average: 3.15, + latest: { period: '2025-03', value: 3.10 }, + monthlyData: [ + { period: '2024-04', value: 3.30 }, + { period: '2024-05', value: 3.25 }, + { period: '2024-06', value: 3.20 }, + { period: '2024-07', value: 3.22 }, + { period: '2024-08', value: 3.18 }, + { period: '2024-09', value: 3.15 }, + { period: '2024-10', value: 3.12 }, + { period: '2024-11', value: 3.10 }, + { period: '2024-12', value: 3.08 }, + { period: '2025-01', value: 3.05 }, + { period: '2025-02', value: 3.08 }, + { period: '2025-03', value: 3.10 }, + ], + count: 12, + }, + prime: { + label: TRACK_LABELS.prime, + seriesId: BOI_RATE_SERIES.prime, + average: 6.05, + latest: { period: '2025-03', value: 5.90 }, + monthlyData: [ + { period: '2024-04', value: 6.25 }, + { period: '2024-05', value: 6.20 }, + { period: '2024-06', value: 6.15 }, + { period: '2024-07', value: 6.10 }, + { period: '2024-08', value: 6.10 }, + { period: '2024-09', value: 6.05 }, + { period: '2024-10', value: 6.00 }, + { period: '2024-11', value: 5.98 }, + { period: '2024-12', value: 5.95 }, + { period: '2025-01', value: 5.92 }, + { period: '2025-02', value: 5.90 }, + { period: '2025-03', value: 5.90 }, + ], + count: 12, + }, + variable: { + label: TRACK_LABELS.variable, + seriesId: BOI_RATE_SERIES.variable, + average: 4.95, + latest: { period: '2025-03', value: 4.85 }, + monthlyData: [ + { period: '2024-04', value: 5.10 }, + { period: '2024-05', value: 5.08 }, + { period: '2024-06', value: 5.05 }, + { period: '2024-07', value: 5.00 }, + { period: '2024-08', value: 4.98 }, + { period: '2024-09', value: 4.95 }, + { period: '2024-10', value: 4.92 }, + { period: '2024-11', value: 4.90 }, + { period: '2024-12', value: 4.88 }, + { period: '2025-01', value: 4.85 }, + { period: '2025-02', value: 4.85 }, + { period: '2025-03', value: 4.85 }, + ], + count: 12, + }, + }, + averages: { + fixed: 4.65, + cpi: 3.15, + prime: 6.05, + variable: 4.95, + }, + source: 'fallback', + sourceUrl: 'https://www.boi.org.il/en/economic-roles/statistics/', + updatedAt: now.toISOString(), + _isFallback: true, + }; + + try { + await storeRatesDocument(ratesDoc); + logger.info('ratesService.storeFallbackRates: fallback rates stored'); + clearCache(); + return ratesDoc; + } catch (err) { + logger.error(`ratesService.storeFallbackRates: Firestore write error: ${err.message}`); + throw err; + } +} + +// ── Read Operations ─────────────────────────────────────────────────────────── + +/** + * Get the latest mortgage rates. + * + * Returns cached data if available and fresh (< 1 hour old). + * Otherwise reads from Firestore's `latest` document. + * If no data exists in Firestore, triggers a fresh fetch. + * + * @returns {Promise} Latest rates document + */ +async function getLatestRates() { + // Check in-memory cache first + if (isCacheValid()) { + logger.debug('ratesService.getLatestRates: returning cached rates'); + return _cachedRates; + } + + try { + const snap = await ratesRef().doc('latest').get(); + + if (snap.exists) { + const data = snap.data(); + // Update cache + _cachedRates = formatRatesResponse(data); + _cacheTimestamp = Date.now(); + return _cachedRates; + } + + // No rates in Firestore yet – trigger initial fetch + logger.info('ratesService.getLatestRates: no rates found, triggering initial fetch'); + const freshRates = await fetchAndStoreLatestRates(); + if (freshRates) { + _cachedRates = formatRatesResponse(freshRates); + _cacheTimestamp = Date.now(); + return _cachedRates; + } + + return null; + } catch (err) { + logger.error(`ratesService.getLatestRates: error: ${err.message}`); + + // If Firestore read fails but we have stale cache, return it + if (_cachedRates) { + logger.warn('ratesService.getLatestRates: returning stale cache due to error'); + return _cachedRates; + } + + throw err; + } +} + +/** + * Get historical rates for a specific date range. + * + * @param {string} startDate - Start date in YYYY-MM-DD format + * @param {string} endDate - End date in YYYY-MM-DD format + * @returns {Promise>} Array of rates documents + */ +async function getRatesHistory(startDate, endDate) { + try { + let query = ratesRef() + .where('date', '>=', startDate) + .where('date', '<=', endDate + 'T23:59:59.999Z') + .orderBy('date', 'desc') + .limit(365); // Max 1 year of daily snapshots + + const snapshot = await query.get(); + return snapshot.docs + .filter((doc) => doc.id !== 'latest' && doc.id !== '_sentinel') + .map((doc) => formatRatesResponse(doc.data())); + } catch (err) { + logger.error(`ratesService.getRatesHistory: error: ${err.message}`); + throw err; + } +} + +/** + * Get the average rates object (flat) for use by other services + * (e.g., wizardService for portfolio generation). + * + * @returns {Promise<{fixed: number|null, cpi: number|null, prime: number|null, variable: number|null}>} + */ +async function getCurrentAverages() { + const rates = await getLatestRates(); + if (!rates || !rates.averages) { + // Return fallback averages if no data available + return { + fixed: 4.65, + cpi: 3.15, + prime: 6.05, + variable: 4.95, + }; + } + return rates.averages; +} + +// ── Formatting ──────────────────────────────────────────────────────────────── + +/** + * Format a raw Firestore rates document for API response. + * Strips internal fields and ensures consistent shape. + * + * @param {object} doc - Raw Firestore document data + * @returns {object} Formatted rates response + */ +function formatRatesResponse(doc) { + if (!doc) return null; + + return { + date: doc.date, + fetchPeriod: doc.fetchPeriod || null, + tracks: doc.tracks || {}, + averages: doc.averages || {}, + source: doc.source || 'unknown', + sourceUrl: doc.sourceUrl || null, + updatedAt: doc.updatedAt || doc.date, + }; +} + +// ── Exports ─────────────────────────────────────────────────────────────────── + +module.exports = { + // Core operations + fetchAndStoreLatestRates, + getLatestRates, + getRatesHistory, + getCurrentAverages, + + // Internal helpers (exported for testing) + fetchSeriesFromBOI, + parseSDMXResponse, + storeFallbackRates, + storeRatesDocument, + formatRatesResponse, + clearCache, + isCacheValid, + + // Constants (exported for testing and other services) + BOI_RATE_SERIES, + TRACK_LABELS, + CACHE_TTL_MS, +}; diff --git a/src/services/reportService.js b/src/services/reportService.js new file mode 100644 index 0000000..dc9cb71 --- /dev/null +++ b/src/services/reportService.js @@ -0,0 +1,796 @@ +/** + * Report Service – Enhanced OCR Analysis for Paid Users + * + * Compares a user's real bank offer (extracted via OCR from offerService) + * against their selected optimized portfolio model (from wizardService). + * + * Generates a comprehensive AI-powered report containing: + * 1. **OCR vs Model Comparison**: Track-by-track rate comparison showing + * where the bank offer is better/worse than the optimized model. + * 2. **Mortgage Tricks**: Strategic suggestions like the "Enticement Track" + * (מסלול פיתיון) – taking a high-interest track to lower others and + * refinancing later. + * 3. **Negotiation Script**: A personalized, word-for-word Hebrew script + * for the bank meeting, referencing specific rates and savings. + * 4. **Strategic Insights**: Explanations of the "Why" behind suggestions, + * matching tracks to expected future funds, risk profile, etc. + * + * The report is stored in the offer document under `analysis.enhanced` + * for future retrieval. + * + * @module reportService + */ + +'use strict'; + +const OpenAI = require('openai'); +const offerService = require('./offerService'); +const ratesService = require('./ratesService'); +const logger = require('../utils/logger'); + +let openai; +if (process.env.OPENAI_API_KEY) { + openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY }); +} + +// ── Constants ───────────────────────────────────────────────────────────────── + +/** Track type labels in Hebrew for report generation */ +const TRACK_LABELS_HE = Object.freeze({ + fixed: 'קבועה לא צמודה (קל"צ)', + cpi: 'צמוד מדד', + prime: 'פריים', + variable: 'משתנה לא צמודה', +}); + +/** Track type labels in English */ +const TRACK_LABELS_EN = Object.freeze({ + fixed: 'Fixed (Non-Indexed)', + cpi: 'CPI-Indexed', + prime: 'Prime', + variable: 'Variable (Non-Indexed)', +}); + +// ── Main Entry Point ────────────────────────────────────────────────────────── + +/** + * Generate an enhanced analysis report comparing a real bank offer + * to the user's selected optimized portfolio. + * + * Flow: + * 1. Fetch the offer document (must be analyzed via OCR already) + * 2. Validate the portfolio data + * 3. Fetch current BOI rates for context + * 4. Build the comparison data structure + * 5. Generate AI-powered report (tricks, script, insights) + * 6. Store the enhanced report in the offer document + * 7. Return the complete report + * + * @param {string} offerId - Firestore document ID of the analyzed offer + * @param {string} userId - Authenticated user's ID (for ownership check) + * @param {object} portfolio - The user's selected portfolio from the wizard + * @param {string} portfolio.id - Portfolio scenario type (e.g., 'market_standard') + * @param {string} portfolio.name - Portfolio name + * @param {string} [portfolio.nameHe] - Hebrew name + * @param {number} portfolio.termYears - Loan term in years + * @param {Array} portfolio.tracks - Track breakdown + * @param {number} portfolio.monthlyRepayment - Monthly payment (₪) + * @param {number} portfolio.totalCost - Total cost over loan term (₪) + * @param {number} portfolio.totalInterest - Total interest paid (₪) + * @returns {Promise} Enhanced analysis report + */ +async function generateEnhancedReport(offerId, userId, portfolio) { + const startTime = Date.now(); + + // 1. Fetch and validate the offer + const offer = await offerService.findByIdAndUserId(offerId, userId); + if (!offer) { + const err = new Error('Offer not found or access denied'); + err.statusCode = 404; + throw err; + } + + if (offer.status !== 'analyzed') { + const err = new Error( + 'Offer must be analyzed via OCR before generating an enhanced report. ' + + 'Current status: ' + offer.status + ); + err.statusCode = 400; + throw err; + } + + // 2. Validate portfolio data + validatePortfolio(portfolio); + + // 3. Fetch current BOI rates for context + let currentRates; + try { + currentRates = await ratesService.getCurrentAverages(); + } catch (err) { + logger.warn(`reportService: failed to get current rates: ${err.message}`); + currentRates = { fixed: 4.65, cpi: 3.15, prime: 6.05, variable: 4.95 }; + } + + // 4. Build comparison data + const comparison = buildComparison(offer, portfolio, currentRates); + + // 5. Generate AI-powered report + let aiReport; + try { + aiReport = await generateAIReport(offer, portfolio, comparison, currentRates); + logger.info(`reportService: AI report generated for offer ${offerId}`); + } catch (err) { + logger.warn(`reportService: AI report generation failed, using rule-based: ${err.message}`); + aiReport = generateRuleBasedReport(offer, portfolio, comparison, currentRates); + } + + // 6. Build the complete enhanced report + const elapsed = Date.now() - startTime; + const enhancedReport = { + offerId, + portfolioId: portfolio.id, + portfolioName: portfolio.name, + portfolioNameHe: portfolio.nameHe || portfolio.name, + generatedAt: new Date().toISOString(), + processingTimeMs: elapsed, + comparison, + tricks: aiReport.tricks || [], + negotiationScript: aiReport.negotiationScript || '', + insights: aiReport.insights || [], + summary: aiReport.summary || '', + summaryHe: aiReport.summaryHe || '', + }; + + // 7. Store the enhanced report in the offer document + try { + await offerService.updateOffer(offerId, { + 'analysis.enhanced': enhancedReport, + portfolioId: portfolio.id, + }); + logger.info(`reportService: enhanced report stored for offer ${offerId}`); + } catch (err) { + logger.error(`reportService: failed to store enhanced report: ${err.message}`); + // Non-fatal – still return the report even if storage fails + } + + return enhancedReport; +} + +// ── Comparison Builder ──────────────────────────────────────────────────────── + +/** + * Build a structured comparison between the bank offer and the optimized portfolio. + * + * @param {object} offer - The analyzed offer document + * @param {object} portfolio - The selected portfolio + * @param {object} currentRates - Current BOI average rates + * @returns {object} Comparison data structure + */ +function buildComparison(offer, portfolio, currentRates) { + const extracted = offer.extractedData || {}; + const offerRate = extracted.rate; + const offerAmount = extracted.amount; + const offerTerm = extracted.term; + const offerBank = extracted.bank || 'לא ידוע'; + + // Calculate portfolio weighted average rate + const portfolioWeightedRate = calculateWeightedRate(portfolio.tracks); + + // Rate difference (positive = bank offer is more expensive) + const rateDifference = offerRate != null && portfolioWeightedRate != null + ? Math.round((offerRate - portfolioWeightedRate) * 100) / 100 + : null; + + // Estimate potential savings + const potentialSavings = estimateSavings( + offerAmount || portfolio.tracks.reduce((sum, t) => sum + (t.amount || 0), 0), + offerRate, + portfolioWeightedRate, + offerTerm || portfolio.termYears + ); + + // Track-by-track comparison (where possible) + const trackComparisons = buildTrackComparisons(offer, portfolio, currentRates); + + return { + bankOffer: { + bank: offerBank, + amount: offerAmount, + rate: offerRate, + term: offerTerm, + recommendedRate: offer.analysis?.recommendedRate || null, + }, + optimizedModel: { + name: portfolio.name, + nameHe: portfolio.nameHe || portfolio.name, + termYears: portfolio.termYears, + monthlyRepayment: portfolio.monthlyRepayment, + totalCost: portfolio.totalCost, + totalInterest: portfolio.totalInterest, + weightedRate: portfolioWeightedRate, + tracks: portfolio.tracks.map((t) => ({ + type: t.type, + name: TRACK_LABELS_HE[t.type] || t.type, + percentage: t.percentage, + rate: t.rate, + rateDisplay: t.rateDisplay || `${t.rate}%`, + })), + }, + rateDifference, + potentialMonthlySavings: potentialSavings.monthly, + potentialTotalSavings: potentialSavings.total, + potentialInterestSavings: potentialSavings.interest, + trackComparisons, + boiAverages: currentRates, + verdict: rateDifference != null + ? (rateDifference > 0.3 ? 'significantly_worse' + : rateDifference > 0 ? 'slightly_worse' + : rateDifference > -0.3 ? 'comparable' + : 'better_than_model') + : 'insufficient_data', + }; +} + +/** + * Calculate the weighted average rate across portfolio tracks. + * + * @param {Array} tracks - Portfolio tracks with percentage and rate + * @returns {number|null} Weighted average rate, or null if no valid tracks + */ +function calculateWeightedRate(tracks) { + if (!tracks || tracks.length === 0) return null; + + let totalWeight = 0; + let weightedSum = 0; + + for (const track of tracks) { + if (track.rate != null && track.percentage != null) { + weightedSum += track.rate * (track.percentage / 100); + totalWeight += track.percentage / 100; + } + } + + if (totalWeight === 0) return null; + return Math.round((weightedSum / totalWeight) * 100) / 100; +} + +/** + * Estimate potential savings between the bank offer rate and the portfolio rate. + * + * @param {number} loanAmount - Loan principal + * @param {number|null} offerRate - Bank offer rate (%) + * @param {number|null} portfolioRate - Portfolio weighted rate (%) + * @param {number} termYears - Loan term in years + * @returns {{ monthly: number|null, total: number|null, interest: number|null }} + */ +function estimateSavings(loanAmount, offerRate, portfolioRate, termYears) { + if (offerRate == null || portfolioRate == null || !loanAmount || !termYears) { + return { monthly: null, total: null, interest: null }; + } + + const months = termYears * 12; + + const offerMonthly = calculatePMT(loanAmount, offerRate / 100 / 12, months); + const portfolioMonthly = calculatePMT(loanAmount, portfolioRate / 100 / 12, months); + + const monthlySavings = Math.round(offerMonthly - portfolioMonthly); + const totalSavings = monthlySavings * months; + const interestSavings = totalSavings; // Simplified: all savings are interest savings + + return { + monthly: Math.max(0, monthlySavings), + total: Math.max(0, totalSavings), + interest: Math.max(0, interestSavings), + }; +} + +/** + * Standard PMT (amortization) formula. + * + * @param {number} principal - Loan principal + * @param {number} monthlyRate - Monthly interest rate (decimal) + * @param {number} totalMonths - Total number of payments + * @returns {number} Monthly payment + */ +function calculatePMT(principal, monthlyRate, totalMonths) { + if (principal <= 0) return 0; + if (monthlyRate <= 0) return principal / totalMonths; + if (totalMonths <= 0) return 0; + + const factor = Math.pow(1 + monthlyRate, totalMonths); + return principal * (monthlyRate * factor) / (factor - 1); +} + +/** + * Build track-by-track comparisons between the bank offer and BOI averages. + * + * Since OCR typically extracts a single blended rate, we compare it against + * each track type in the portfolio and the BOI averages. + * + * @param {object} offer - The analyzed offer + * @param {object} portfolio - The selected portfolio + * @param {object} currentRates - Current BOI averages + * @returns {Array} Track comparison entries + */ +function buildTrackComparisons(offer, portfolio, currentRates) { + const comparisons = []; + const offerRate = offer.extractedData?.rate; + + for (const track of portfolio.tracks) { + const boiRate = currentRates[track.type] || null; + const portfolioRate = track.rate; + + const entry = { + trackType: track.type, + trackName: TRACK_LABELS_HE[track.type] || track.type, + trackNameEn: TRACK_LABELS_EN[track.type] || track.type, + percentage: track.percentage, + portfolioRate, + boiAverage: boiRate, + rateDisplay: track.rateDisplay || `${portfolioRate}%`, + }; + + // Compare portfolio rate to BOI average + if (boiRate != null && portfolioRate != null) { + entry.vsBoi = Math.round((portfolioRate - boiRate) * 100) / 100; + entry.vsBoiLabel = entry.vsBoi > 0 ? 'above_average' : entry.vsBoi < 0 ? 'below_average' : 'at_average'; + } + + // If we have the bank offer rate, compare it too + if (offerRate != null && portfolioRate != null) { + entry.bankOfferRate = offerRate; + entry.vsBank = Math.round((offerRate - portfolioRate) * 100) / 100; + entry.vsBankLabel = entry.vsBank > 0 ? 'bank_higher' : entry.vsBank < 0 ? 'bank_lower' : 'equal'; + } + + comparisons.push(entry); + } + + return comparisons; +} + +// ── AI-Powered Report Generation ────────────────────────────────────────────── + +/** + * Generate the enhanced report sections using OpenAI GPT-4o. + * + * Produces: + * - Mortgage tricks (strategic suggestions) + * - Negotiation script (Hebrew, word-for-word) + * - Strategic insights (explanations) + * - Summary (Hebrew + English) + * + * @param {object} offer - The analyzed offer + * @param {object} portfolio - The selected portfolio + * @param {object} comparison - The comparison data + * @param {object} currentRates - Current BOI averages + * @returns {Promise} AI-generated report sections + */ +async function generateAIReport(offer, portfolio, comparison, currentRates) { + if (!openai) { + throw new Error('OpenAI client not initialized (OPENAI_API_KEY not set)'); + } + + const extracted = offer.extractedData || {}; + const bankName = extracted.bank || 'הבנק'; + const offerRate = extracted.rate; + const offerAmount = extracted.amount; + const offerTerm = extracted.term; + + // Build portfolio tracks description + const tracksDesc = portfolio.tracks.map((t) => { + const heLabel = TRACK_LABELS_HE[t.type] || t.type; + return `${heLabel}: ${t.percentage}% at ${t.rate}% (${t.rateDisplay || t.rate + '%'})`; + }).join('\n'); + + // Build comparison summary + const compSummary = comparison.rateDifference != null + ? `Bank offer rate: ${offerRate}%. Optimized model weighted rate: ${comparison.optimizedModel.weightedRate}%. Difference: ${comparison.rateDifference > 0 ? '+' : ''}${comparison.rateDifference}%.` + : 'Bank offer rate not fully extracted from OCR.'; + + const savingsSummary = comparison.potentialTotalSavings != null + ? `Potential total savings: ₪${comparison.potentialTotalSavings.toLocaleString()}. Monthly savings: ₪${comparison.potentialMonthlySavings.toLocaleString()}.` + : 'Savings calculation not available due to incomplete data.'; + + const prompt = `You are an expert Israeli mortgage consultant generating a professional analysis report in Hebrew. + +Context: +- Bank: ${bankName} +- Bank Offer: Rate ${offerRate != null ? offerRate + '%' : 'unknown'}, Amount ₪${offerAmount != null ? offerAmount.toLocaleString() : 'unknown'}, Term ${offerTerm != null ? offerTerm + ' years' : 'unknown'} +- Optimized Portfolio ("${portfolio.nameHe || portfolio.name}"): +${tracksDesc} + Monthly Repayment: ₪${portfolio.monthlyRepayment.toLocaleString()} + Total Cost: ₪${portfolio.totalCost.toLocaleString()} + Total Interest: ₪${portfolio.totalInterest.toLocaleString()} +- Comparison: ${compSummary} +- Savings: ${savingsSummary} +- Current BOI Averages: Fixed ${currentRates.fixed}%, CPI ${currentRates.cpi}%, Prime ${currentRates.prime}%, Variable ${currentRates.variable}% + +Generate a comprehensive report with these sections: + +1. **tricks** (Array of 2-4 mortgage tricks/strategies): + Each trick should have: + - nameHe: Hebrew name (e.g., "מסלול פיתיון") + - nameEn: English name (e.g., "Enticement Track") + - descriptionHe: Hebrew explanation (2-3 sentences) + - descriptionEn: English explanation (2-3 sentences) + - potentialSavings: estimated savings in ₪ (number or null) + - riskLevel: "low", "medium", or "high" + - applicability: "high", "medium", or "low" (how relevant to this specific case) + + MUST include the "Enticement Track" (מסלול פיתיון) strategy: taking a high-interest track to lower the rates on other tracks, then refinancing that track later. + +2. **negotiationScript** (String): A complete, word-for-word Hebrew script for the bank meeting. Must: + - Start with a greeting and introduction + - Reference specific rates from the comparison + - Mention BOI averages as leverage + - Include specific asks (rate reductions per track) + - Be polite but firm + - Be 150-300 words in Hebrew + +3. **insights** (Array of 2-4 strategic insights): + Each insight should have: + - titleHe: Hebrew title + - titleEn: English title + - bodyHe: Hebrew explanation (2-3 sentences) + - bodyEn: English explanation (2-3 sentences) + - icon: suggested icon name (e.g., "shield", "trending-down", "calendar", "target") + +4. **summary**: English summary (2-3 sentences) +5. **summaryHe**: Hebrew summary (2-3 sentences) + +Respond ONLY with valid JSON (no markdown, no explanation): +{ + "tricks": [...], + "negotiationScript": "...", + "insights": [...], + "summary": "...", + "summaryHe": "..." +}`; + + const response = await openai.chat.completions.create({ + model: 'gpt-4o-mini', + messages: [ + { + role: 'system', + content: 'You are an expert Israeli mortgage consultant. You generate professional, actionable reports in Hebrew and English. All financial advice must be practical and specific to the user\'s situation. Respond only with valid JSON.', + }, + { role: 'user', content: prompt }, + ], + max_tokens: 3000, + temperature: 0.4, + response_format: { type: 'json_object' }, + }); + + const content = response.choices[0].message.content; + const parsed = JSON.parse(content); + + // Validate the response structure + if (!parsed.tricks || !Array.isArray(parsed.tricks)) { + throw new Error('AI response missing tricks array'); + } + if (!parsed.negotiationScript || typeof parsed.negotiationScript !== 'string') { + throw new Error('AI response missing negotiationScript'); + } + + return { + tricks: parsed.tricks.map(sanitizeTrick), + negotiationScript: parsed.negotiationScript, + insights: Array.isArray(parsed.insights) ? parsed.insights.map(sanitizeInsight) : [], + summary: parsed.summary || '', + summaryHe: parsed.summaryHe || '', + }; +} + +// ── Rule-Based Report Generation (Fallback) ─────────────────────────────────── + +/** + * Generate a rule-based report when AI is unavailable. + * + * Produces deterministic tricks, a template negotiation script, + * and basic insights based on the comparison data. + * + * @param {object} offer - The analyzed offer + * @param {object} portfolio - The selected portfolio + * @param {object} comparison - The comparison data + * @param {object} currentRates - Current BOI averages + * @returns {object} Rule-based report sections + */ +function generateRuleBasedReport(offer, portfolio, comparison, currentRates) { + const extracted = offer.extractedData || {}; + const bankName = extracted.bank || 'הבנק'; + const offerRate = extracted.rate; + const rateDiff = comparison.rateDifference; + const savings = comparison.potentialTotalSavings; + + // ── Tricks ────────────────────────────────────────────────────────────────── + const tricks = []; + + // Trick 1: Enticement Track (always included per spec) + tricks.push({ + nameHe: 'מסלול פיתיון', + nameEn: 'Enticement Track', + descriptionHe: + 'קחו מסלול אחד בריבית גבוהה יותר (למשל פריים) כדי להוריד את הריבית במסלולים האחרים. ' + + 'לאחר שנה-שנתיים, בצעו מיחזור של המסלול היקר בלבד. ' + + 'הבנקים מוכנים להוריד ריבית במסלולים אחרים כשהם מרוויחים יותר במסלול אחד.', + descriptionEn: + 'Accept a higher rate on one track (e.g., prime) to negotiate lower rates on other tracks. ' + + 'After 1-2 years, refinance only the expensive track. ' + + 'Banks are willing to lower rates on other tracks when they profit more on one.', + potentialSavings: savings != null ? Math.round(savings * 0.15) : null, + riskLevel: 'medium', + applicability: 'high', + }); + + // Trick 2: Track splitting + if (portfolio.tracks.length >= 2) { + tricks.push({ + nameHe: 'פיצול מסלולים', + nameEn: 'Track Splitting', + descriptionHe: + 'בקשו לפצל את המשכנתא ליותר מסלולים ממה שהבנק מציע. ' + + 'פיצול מאפשר גמישות רבה יותר במיחזור עתידי ומפחית סיכון ריכוז.', + descriptionEn: + 'Request splitting the mortgage into more tracks than the bank offers. ' + + 'Splitting provides more flexibility for future refinancing and reduces concentration risk.', + potentialSavings: null, + riskLevel: 'low', + applicability: 'medium', + }); + } + + // Trick 3: Rate matching with BOI data + if (rateDiff != null && rateDiff > 0) { + tricks.push({ + nameHe: 'התאמת ריבית לנתוני בנק ישראל', + nameEn: 'BOI Rate Matching', + descriptionHe: + `הריבית שהוצעה לכם (${offerRate}%) גבוהה מהממוצע בבנק ישראל. ` + + `הציגו את נתוני בנק ישראל (קל"צ: ${currentRates.fixed}%, פריים: ${currentRates.prime}%) ` + + 'ובקשו התאמה לממוצע השוק.', + descriptionEn: + `Your offered rate (${offerRate}%) is above the Bank of Israel average. ` + + `Present BOI data (Fixed: ${currentRates.fixed}%, Prime: ${currentRates.prime}%) ` + + 'and request market-rate matching.', + potentialSavings: savings, + riskLevel: 'low', + applicability: 'high', + }); + } + + // Trick 4: Early prepayment leverage + tricks.push({ + nameHe: 'מינוף פירעון מוקדם', + nameEn: 'Early Prepayment Leverage', + descriptionHe: + 'ציינו בפני הבנק שאתם שוקלים פירעון מוקדם חלקי בעתיד. ' + + 'זה מעודד את הבנק להציע ריבית טובה יותר כדי לשמור אתכם כלקוחות לטווח ארוך.', + descriptionEn: + 'Mention to the bank that you are considering partial early repayment in the future. ' + + 'This encourages the bank to offer better rates to retain you as a long-term customer.', + potentialSavings: null, + riskLevel: 'low', + applicability: 'medium', + }); + + // ── Negotiation Script ────────────────────────────────────────────────────── + const rateStr = offerRate != null ? `${offerRate}%` : 'הריבית שהוצעה'; + const targetRate = comparison.optimizedModel.weightedRate != null + ? `${comparison.optimizedModel.weightedRate}%` + : 'ריבית תחרותית יותר'; + + const negotiationScript = + `שלום, שמי [שם]. אני מעוניין/ת במשכנתא ועשיתי מחקר מקיף לפני הפגישה.\n\n` + + `בדקתי את נתוני בנק ישראל העדכניים וראיתי שהממוצע לריבית קבועה לא צמודה עומד על ${currentRates.fixed}%, ` + + `ולפריים על ${currentRates.prime}%.\n\n` + + `ההצעה שקיבלתי מ-${bankName} עומדת על ${rateStr}, ` + + `שזה ${rateDiff != null && rateDiff > 0 ? `${rateDiff}% מעל הממוצע בשוק` : 'קרוב לממוצע בשוק'}.\n\n` + + `על בסיס הניתוח שלי, אני מבקש/ת להגיע לריבית משוקללת של ${targetRate}. ` + + `${savings != null ? `הפער הנוכחי מייצג חיסכון פוטנציאלי של כ-₪${savings.toLocaleString()} לאורך חיי ההלוואה.` : ''}\n\n` + + `אני פתוח/ה לדון בתמהיל המסלולים – למשל, אני מוכן/ה לשקול ריבית מעט גבוהה יותר במסלול אחד ` + + `אם זה יאפשר הורדה משמעותית במסלולים האחרים.\n\n` + + `קיבלתי הצעות גם מבנקים אחרים, ואשמח לתת ל-${bankName} את ההזדמנות להציע את התנאים הטובים ביותר.\n\n` + + `תודה רבה.`; + + // ── Insights ──────────────────────────────────────────────────────────────── + const insights = []; + + // Insight 1: Rate comparison + if (rateDiff != null) { + insights.push({ + titleHe: rateDiff > 0 ? 'הריבית שלכם גבוהה מהממוצע' : 'הריבית שלכם תחרותית', + titleEn: rateDiff > 0 ? 'Your Rate is Above Average' : 'Your Rate is Competitive', + bodyHe: rateDiff > 0 + ? `הריבית שהוצעה לכם גבוהה ב-${rateDiff}% מהמודל האופטימלי שלנו. יש מקום למשא ומתן משמעותי.` + : `הריבית שהוצעה לכם קרובה למודל האופטימלי. עדיין ניתן לנסות לשפר בנקודות ספציפיות.`, + bodyEn: rateDiff > 0 + ? `Your offered rate is ${rateDiff}% above our optimized model. There is significant room for negotiation.` + : `Your offered rate is close to the optimized model. You can still try to improve on specific points.`, + icon: rateDiff > 0 ? 'trending-down' : 'check-circle', + }); + } + + // Insight 2: Portfolio strategy + insights.push({ + titleHe: 'אסטרטגיית תמהיל', + titleEn: 'Portfolio Strategy', + bodyHe: + `התיק "${portfolio.nameHe || portfolio.name}" מבוסס על תמהיל של ${portfolio.tracks.length} מסלולים ` + + `לתקופה של ${portfolio.termYears} שנים. ` + + `תמהיל זה מאזן בין עלות כוללת להחזר חודשי נוח.`, + bodyEn: + `The "${portfolio.name}" portfolio is based on a mix of ${portfolio.tracks.length} tracks ` + + `over ${portfolio.termYears} years. ` + + `This mix balances total cost with comfortable monthly payments.`, + icon: 'target', + }); + + // Insight 3: Market timing + insights.push({ + titleHe: 'תזמון שוק', + titleEn: 'Market Timing', + bodyHe: + `ריביות בנק ישראל הנוכחיות: קל"צ ${currentRates.fixed}%, צמוד ${currentRates.cpi}%, פריים ${currentRates.prime}%. ` + + 'השתמשו בנתונים אלה כמנוף במשא ומתן.', + bodyEn: + `Current BOI rates: Fixed ${currentRates.fixed}%, CPI ${currentRates.cpi}%, Prime ${currentRates.prime}%. ` + + 'Use these figures as leverage in negotiations.', + icon: 'calendar', + }); + + // ── Summary ───────────────────────────────────────────────────────────────── + const summaryHe = rateDiff != null && rateDiff > 0 + ? `ההצעה מ-${bankName} גבוהה ב-${rateDiff}% מהמודל האופטימלי. ${savings != null ? `חיסכון פוטנציאלי: ₪${savings.toLocaleString()}.` : ''} מומלץ לנהל משא ומתן.` + : `ההצעה מ-${bankName} קרובה למודל האופטימלי. עדיין ניתן לשפר בנקודות ספציפיות.`; + + const summary = rateDiff != null && rateDiff > 0 + ? `The offer from ${bankName} is ${rateDiff}% above the optimized model. ${savings != null ? `Potential savings: ₪${savings.toLocaleString()}.` : ''} Negotiation recommended.` + : `The offer from ${bankName} is close to the optimized model. Minor improvements may still be possible.`; + + return { + tricks: tricks.slice(0, 4), + negotiationScript, + insights, + summary, + summaryHe, + }; +} + +// ── Sanitization Helpers ────────────────────────────────────────────────────── + +/** + * Sanitize an AI-generated trick object to ensure consistent shape. + * + * @param {object} trick - Raw trick from AI + * @returns {object} Sanitized trick + */ +function sanitizeTrick(trick) { + return { + nameHe: String(trick.nameHe || trick.name || ''), + nameEn: String(trick.nameEn || trick.name || ''), + descriptionHe: String(trick.descriptionHe || trick.description || ''), + descriptionEn: String(trick.descriptionEn || trick.description || ''), + potentialSavings: typeof trick.potentialSavings === 'number' ? trick.potentialSavings : null, + riskLevel: ['low', 'medium', 'high'].includes(trick.riskLevel) ? trick.riskLevel : 'medium', + applicability: ['low', 'medium', 'high'].includes(trick.applicability) ? trick.applicability : 'medium', + }; +} + +/** + * Sanitize an AI-generated insight object to ensure consistent shape. + * + * @param {object} insight - Raw insight from AI + * @returns {object} Sanitized insight + */ +function sanitizeInsight(insight) { + return { + titleHe: String(insight.titleHe || insight.title || ''), + titleEn: String(insight.titleEn || insight.title || ''), + bodyHe: String(insight.bodyHe || insight.body || ''), + bodyEn: String(insight.bodyEn || insight.body || ''), + icon: String(insight.icon || 'info'), + }; +} + +// ── Validation ──────────────────────────────────────────────────────────────── + +/** + * Validate the portfolio object structure. + * + * @param {object} portfolio - Portfolio to validate + * @throws {Error} If portfolio is invalid + */ +function validatePortfolio(portfolio) { + if (!portfolio || typeof portfolio !== 'object') { + const err = new Error('Portfolio data is required'); + err.statusCode = 400; + throw err; + } + + if (!portfolio.id || typeof portfolio.id !== 'string') { + const err = new Error('Portfolio must have a valid id'); + err.statusCode = 400; + throw err; + } + + if (!Array.isArray(portfolio.tracks) || portfolio.tracks.length === 0) { + const err = new Error('Portfolio must have at least one track'); + err.statusCode = 400; + throw err; + } + + if (typeof portfolio.termYears !== 'number' || portfolio.termYears <= 0) { + const err = new Error('Portfolio must have a valid termYears'); + err.statusCode = 400; + throw err; + } + + if (typeof portfolio.monthlyRepayment !== 'number' || portfolio.monthlyRepayment <= 0) { + const err = new Error('Portfolio must have a valid monthlyRepayment'); + err.statusCode = 400; + throw err; + } + + if (typeof portfolio.totalCost !== 'number' || portfolio.totalCost <= 0) { + const err = new Error('Portfolio must have a valid totalCost'); + err.statusCode = 400; + throw err; + } + + if (typeof portfolio.totalInterest !== 'number' || portfolio.totalInterest < 0) { + const err = new Error('Portfolio must have a valid totalInterest'); + err.statusCode = 400; + throw err; + } + + // Validate each track + for (const track of portfolio.tracks) { + if (!track.type || typeof track.type !== 'string') { + const err = new Error('Each track must have a valid type'); + err.statusCode = 400; + throw err; + } + if (typeof track.percentage !== 'number' || track.percentage <= 0 || track.percentage > 100) { + const err = new Error('Each track must have a valid percentage (1-100)'); + err.statusCode = 400; + throw err; + } + if (typeof track.rate !== 'number' || track.rate < 0) { + const err = new Error('Each track must have a valid rate'); + err.statusCode = 400; + throw err; + } + } + + // Validate percentages sum to ~100 + const totalPct = portfolio.tracks.reduce((sum, t) => sum + t.percentage, 0); + if (totalPct < 98 || totalPct > 102) { + const err = new Error(`Track percentages must sum to 100% (got ${totalPct}%)`); + err.statusCode = 400; + throw err; + } +} + +// ── Exports ─────────────────────────────────────────────────────────────────── + +module.exports = { + // Main entry point + generateEnhancedReport, + + // Internal helpers (exported for testing) + buildComparison, + calculateWeightedRate, + estimateSavings, + calculatePMT, + buildTrackComparisons, + generateAIReport, + generateRuleBasedReport, + sanitizeTrick, + sanitizeInsight, + validatePortfolio, + + // Constants + TRACK_LABELS_HE, + TRACK_LABELS_EN, +}; diff --git a/src/services/wizardService.js b/src/services/wizardService.js new file mode 100644 index 0000000..5e413fd --- /dev/null +++ b/src/services/wizardService.js @@ -0,0 +1,443 @@ +/** + * Wizard Service – Portfolio Generation Engine + * + * Generates up to 4 distinct mortgage portfolio scenarios based on: + * - User wizard inputs (6 steps) + * - Bank of Israel current average rates (from ratesService) + * - AI-powered optimization (OpenAI GPT-4o-mini) + * - User profile analysis (LTV, affordability, risk tolerance) + * + * Portfolio Scenarios: + * 1. "Market Standard" (30 years) – Always generated. Generic mix for lowest monthly repayment. + * 2. "Fast Track" (20 years) – Always generated. Shorter term for massive interest savings. + * 3. "Inflation-Proof" (conditional) – Non-indexed tracks only. Generated when CPI rates + * are high, user has moderate stability preference, expects future funds, or has high LTV. + * 4. "Stability-First" (conditional) – Fixed-rate heavy. Generated when user's + * stabilityPreference >= 7, or tight affordability with moderate stability preference, + * or risk-averse profile without future funds. + * + * Each portfolio contains: + * - id, name (Hebrew + English), description + * - tracks: array of { name, nameHe, type, percentage, rate, termYears } + * - monthlyRepayment: average monthly payment (₪) + * - totalCost: principal + total interest over full term (₪) + * - totalInterest: total interest paid (₪) + * - termYears: loan duration + * - fitnessScore: 0-100 score indicating how well the portfolio fits the user + * - recommended: boolean flag for the best-fit portfolio + * + * The service first attempts AI-powered generation via OpenAI. + * If OpenAI is unavailable, it falls back to deterministic rule-based generation + * using the portfolioEngine module. + */ + +'use strict'; + +const OpenAI = require('openai'); +const ratesService = require('./ratesService'); +const portfolioEngine = require('./portfolioEngine'); +const logger = require('../utils/logger'); + +let openai; +if (process.env.OPENAI_API_KEY) { + openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY }); +} + +// Re-export constants from portfolioEngine for backward compatibility +const { + SCENARIO_TYPES, + SCENARIO_NAMES_HE, + SCENARIO_NAMES_EN, + SCENARIO_DESCRIPTIONS, + TRACK_LABELS_HE, + STABILITY_THRESHOLD, + CPI_RATE_THRESHOLD, +} = portfolioEngine; + +// ── Main Entry Point ────────────────────────────────────────────────────────── + +/** + * Generate up to 4 mortgage portfolio scenarios for the given wizard inputs. + * + * Flow: + * 1. Fetch current BOI rates + * 2. Analyse user profile (LTV, affordability, risk tolerance, future funds) + * 3. Determine which scenarios to generate (conditional logic) + * 4. Generate portfolios (AI-first, rule-based fallback) + * 5. Score and rank portfolios by fitness for user profile + * 6. Return portfolios with metadata + * + * @param {object} inputs - Validated wizard inputs + * @param {number} inputs.propertyPrice - Property purchase price (₪) + * @param {number} inputs.loanAmount - Requested loan amount (₪) + * @param {number} inputs.monthlyIncome - Primary monthly income (₪) + * @param {number} [inputs.additionalIncome=0] - Additional monthly income (₪) + * @param {number} inputs.targetRepayment - Desired monthly repayment (₪) + * @param {object} inputs.futureFunds - Future funds info { timeframe, amount } + * @param {number} inputs.stabilityPreference - Stability slider value (1-10) + * @param {boolean} consent - Whether user consented to anonymous data storage + * @returns {Promise<{portfolios: Array, metadata: object}>} + */ +async function generatePortfolios(inputs, consent) { + const startTime = Date.now(); + + // 1. Fetch current BOI rates + let rates; + try { + rates = await ratesService.getCurrentAverages(); + } catch (err) { + logger.error(`wizardService.generatePortfolios: failed to get rates: ${err.message}`); + // Use hardcoded fallback rates + rates = { fixed: 4.65, cpi: 3.15, prime: 6.05, variable: 4.95 }; + } + + // 2. Analyze user profile + const profile = portfolioEngine.analyseUserProfile(inputs); + + logger.info('wizardService.generatePortfolios: generating portfolios', { + loanAmount: inputs.loanAmount, + stabilityPreference: inputs.stabilityPreference, + ltvClass: profile.ltvClass, + affordability: profile.affordability, + riskTolerance: profile.riskTolerance, + hasFutureFunds: profile.hasFutureFunds, + rates, + }); + + // 3. Determine which scenarios to generate (conditional logic) + const { scenarios: scenarioTypes, reasons } = portfolioEngine.determineScenarios( + inputs, + rates, + profile + ); + + logger.info('wizardService.generatePortfolios: scenarios determined', { + scenarioTypes, + reasons, + }); + + // 4. Try AI-powered generation first, fall back to rule-based + let portfolios; + let generationMethod = 'rule_based'; + try { + portfolios = await generateWithAI(inputs, rates, scenarioTypes, profile); + generationMethod = 'ai'; + logger.info('wizardService.generatePortfolios: AI generation succeeded'); + } catch (err) { + logger.warn(`wizardService.generatePortfolios: AI generation failed, using rule-based: ${err.message}`); + portfolios = generateRuleBased(inputs, rates, scenarioTypes, profile); + } + + // 5. Score and rank portfolios + const scoredPortfolios = portfolioEngine.scorePortfolios(portfolios, inputs, profile); + + const elapsed = Date.now() - startTime; + logger.info(`wizardService.generatePortfolios: completed in ${elapsed}ms, ${scoredPortfolios.length} portfolios`); + + // 6. Build metadata + const metadata = { + generatedAt: new Date().toISOString(), + ratesSource: rates ? 'bank_of_israel' : 'fallback', + generationMethod, + processingTimeMs: elapsed, + scenariosGenerated: scenarioTypes, + scenarioReasons: reasons, + inputSummary: { + propertyPrice: inputs.propertyPrice, + loanAmount: inputs.loanAmount, + ltv: Math.round(profile.ltv), + ltvClass: profile.ltvClass, + stabilityPreference: inputs.stabilityPreference, + totalIncome: profile.totalIncome, + targetRepayment: inputs.targetRepayment, + repaymentRatio: profile.repaymentRatio, + affordability: profile.affordability, + riskTolerance: profile.riskTolerance, + hasFutureFunds: profile.hasFutureFunds, + futureFundsTimeframe: inputs.futureFunds.timeframe, + }, + consent, + }; + + // Strip internal fields from portfolios + const cleanPortfolios = scoredPortfolios.map((p) => { + const { _generationMethod, scoreBreakdown, ...clean } = p; + return clean; + }); + + return { portfolios: cleanPortfolios, metadata }; +} + +// ── AI-Powered Generation ───────────────────────────────────────────────────── + +/** + * Generate portfolios using OpenAI GPT-4o-mini. + * + * The AI is given the user's inputs, current BOI rates, profile analysis, + * and instructions to produce specific portfolio scenarios with track breakdowns. + * + * @param {object} inputs - Wizard inputs + * @param {object} rates - Current BOI average rates + * @param {string[]} scenarioTypes - Which scenarios to generate + * @param {object} profile - User profile analysis + * @returns {Promise>} Generated portfolios + */ +async function generateWithAI(inputs, rates, scenarioTypes, profile) { + if (!openai) { + throw new Error('OpenAI client not initialized (OPENAI_API_KEY not set)'); + } + + const totalIncome = profile.totalIncome; + const ltv = profile.ltv; + const equity = profile.equity; + + const scenarioDescriptions = scenarioTypes.map((type) => { + switch (type) { + case SCENARIO_TYPES.MARKET_STANDARD: + return '"Market Standard" (30 years): A balanced mix of tracks aimed at the lowest possible monthly repayment. Use a mix of fixed, prime, and CPI-indexed tracks.'; + case SCENARIO_TYPES.FAST_TRACK: + return '"Fast Track" (20 years): A shorter duration aimed at massive interest savings. Favor prime and variable tracks for lower rates.'; + case SCENARIO_TYPES.INFLATION_PROOF: + return '"Inflation-Proof" (חסין אינפלציה): A portfolio consisting STRICTLY of non-indexed tracks only (fixed/קל"צ, prime/פריים, variable/משתנה). NO CPI-indexed tracks allowed.'; + case SCENARIO_TYPES.STABILITY_FIRST: + return '"Stability-First" (יציבות קודם): A mix heavily prioritizing fixed rates (קל"צ) for predictable monthly repayment. At least 60% should be fixed-rate.'; + default: + return ''; + } + }).filter(Boolean); + + // Build future funds context + let futureFundsContext; + if (inputs.futureFunds.timeframe === 'none') { + futureFundsContext = 'None expected'; + } else { + const amount = inputs.futureFunds.amount || 0; + const timeframe = inputs.futureFunds.timeframe;//.replace(/_/g, ' '); + futureFundsContext = `₪${amount.toLocaleString()} expected ${timeframe}`; + if (profile.canPrepayEarly) { + futureFundsContext += ' (near-term – consider prepayable tracks like prime)'; + } + } + + // Build profile context for AI + const profileContext = [ + `Risk Tolerance: ${profile.riskTolerance} (stability pref ${inputs.stabilityPreference}/10)`, + `Affordability: ${profile.affordability} (repayment is ${(profile.repaymentRatio * 100).toFixed(1)}% of income)`, + `LTV: ${ltv.toFixed(1)}% (${profile.ltvClass})`, + profile.hasFutureFunds ? 'Has future funds – consider prepayable tracks' : 'No future funds expected', + ].join('\n'); + + const prompt = `You are an Israeli mortgage portfolio advisor. Generate ${scenarioTypes.length} distinct mortgage portfolio scenarios based on the following user profile and current Bank of Israel rates. + +User Profile: +- Property Price: ₪${inputs.propertyPrice.toLocaleString()} +- Loan Amount: ₪${inputs.loanAmount.toLocaleString()} +- Equity: ₪${equity.toLocaleString()} (LTV: ${ltv.toFixed(1)}%) +- Monthly Income: ₪${totalIncome.toLocaleString()} +- Target Monthly Repayment: ₪${inputs.targetRepayment.toLocaleString()} +- Future Funds: ${futureFundsContext} +- Stability Preference: ${inputs.stabilityPreference}/10 + +Profile Analysis: +${profileContext} + +Current Bank of Israel Average Rates: +- Fixed (קל"צ): ${rates.fixed}% +- CPI-Indexed (צמוד מדד): ${rates.cpi}% +- Prime (פריים): ${rates.prime}% (Prime rate = Bank of Israel base rate + bank spread) +- Variable (משתנה): ${rates.variable}% + +Generate these specific scenarios: +${scenarioDescriptions.map((d, i) => `${i + 1}. ${d}`).join('\n')} + +For each portfolio, provide: +- Track breakdown: each track with type (fixed/cpi/prime/variable), percentage allocation (must sum to 100%), and the interest rate +- The term in years +- Calculate the approximate monthly repayment using standard amortization +- Calculate total cost (principal + total interest) +- Calculate total interest paid + +IMPORTANT RULES: +- All percentages in a portfolio must sum to exactly 100% +- Use realistic rates close to the BOI averages (banks add 0-0.5% spread) +- Monthly repayment calculation should use standard PMT formula +- For prime tracks, express rate as "P-X%" or "P+X%" relative to prime rate +- Adapt allocations to the user's profile: ${profile.riskTolerance} risk tolerance, ${profile.affordability} affordability +- For Inflation-Proof: ONLY use fixed, prime, and variable tracks (NO cpi tracks) +- For Stability-First: At least 60% must be fixed-rate tracks + +Respond ONLY with valid JSON in this exact format (no markdown, no explanation): +{ + "portfolios": [ + { + "type": "market_standard", + "tracks": [ + { "type": "fixed", "percentage": 40, "rate": 4.7, "rateDisplay": "4.70%" }, + { "type": "prime", "percentage": 30, "rate": 5.9, "rateDisplay": "P-0.1%" }, + { "type": "cpi", "percentage": 30, "rate": 3.2, "rateDisplay": "3.20% + מדד" } + ], + "termYears": 30, + "monthlyRepayment": 5200, + "totalCost": 1872000, + "totalInterest": 672000 + } + ] +}`; + + const response = await openai.chat.completions.create({ + model: 'gpt-4o-mini', + messages: [ + { + role: 'system', + content: 'You are an expert Israeli mortgage advisor. You respond only with valid JSON. All calculations must be mathematically accurate using standard amortization formulas. Adapt your recommendations to the user\'s risk profile and financial situation.', + }, + { role: 'user', content: prompt }, + ], + max_tokens: 2500, + temperature: 0.3, + response_format: { type: 'json_object' }, + }); + + const content = response.choices[0].message.content; + const parsed = JSON.parse(content); + + + if (!parsed.portfolios || !Array.isArray(parsed.portfolios)) { + throw new Error('AI response missing portfolios array'); + } + + // Validate and enrich AI-generated portfolios + const enriched = parsed.portfolios.map((aiPortfolio, index) => { + const scenarioType = scenarioTypes[index] || SCENARIO_TYPES.MARKET_STANDARD; + return portfolioEngine.enrichPortfolio(aiPortfolio, scenarioType, inputs, rates); + }); + + // Validate portfolio constraints + return enriched.map((portfolio) => validatePortfolioConstraints(portfolio)); +} + +// ── Rule-Based Generation (Fallback) ────────────────────────────────────────── + +/** + * Generate portfolios using deterministic rules when AI is unavailable. + * + * Uses the portfolioEngine's adaptive allocation system which adjusts + * track percentages based on user profile analysis. + * + * @param {object} inputs - Wizard inputs + * @param {object} rates - Current BOI average rates + * @param {string[]} scenarioTypes - Which scenarios to generate + * @param {object} profile - User profile analysis + * @returns {Array} Generated portfolios + */ +function generateRuleBased(inputs, rates, scenarioTypes, profile) { + return scenarioTypes.map((type) => { + const config = portfolioEngine.getAdaptiveAllocation(type, inputs, rates, profile); + const portfolio = portfolioEngine.buildPortfolio(config, type, inputs, rates, 'rule_based'); + return portfolio; + }); +} + +// ── Portfolio Validation ────────────────────────────────────────────────────── + +/** + * Validate that a portfolio meets its scenario-specific constraints. + * Fixes minor issues (e.g., percentages not summing to 100) and logs warnings. + * + * @param {object} portfolio - Portfolio to validate + * @returns {object} Validated (and possibly corrected) portfolio + */ +function validatePortfolioConstraints(portfolio) { + const tracks = portfolio.tracks || []; + + // Check percentages sum to 100 + const totalPct = tracks.reduce((sum, t) => sum + t.percentage, 0); + if (totalPct !== 100 && tracks.length > 0) { + logger.warn(`wizardService.validatePortfolioConstraints: ${portfolio.type} tracks sum to ${totalPct}%, adjusting`); + // Adjust the last track to make it sum to 100 + const diff = 100 - totalPct; + tracks[tracks.length - 1].percentage += diff; + } + + // Inflation-Proof: must not contain CPI tracks + if (portfolio.type === SCENARIO_TYPES.INFLATION_PROOF) { + const hasCpi = tracks.some((t) => t.type === 'cpi'); + if (hasCpi) { + logger.warn('wizardService.validatePortfolioConstraints: Inflation-Proof contains CPI track, removing'); + // Redistribute CPI allocation to fixed and prime + const cpiTracks = tracks.filter((t) => t.type === 'cpi'); + const cpiPct = cpiTracks.reduce((sum, t) => sum + t.percentage, 0); + const nonCpiTracks = tracks.filter((t) => t.type !== 'cpi'); + + if (nonCpiTracks.length > 0) { + const addPerTrack = Math.floor(cpiPct / nonCpiTracks.length); + nonCpiTracks.forEach((t) => { t.percentage += addPerTrack; }); + // Handle remainder + const remainder = cpiPct - (addPerTrack * nonCpiTracks.length); + nonCpiTracks[0].percentage += remainder; + portfolio.tracks = nonCpiTracks; + } + } + } + + // Stability-First: must have >= 60% fixed + if (portfolio.type === SCENARIO_TYPES.STABILITY_FIRST) { + const fixedPct = tracks + .filter((t) => t.type === 'fixed') + .reduce((sum, t) => sum + t.percentage, 0); + if (fixedPct < 60) { + logger.warn(`wizardService.validatePortfolioConstraints: Stability-First has only ${fixedPct}% fixed, adjusting`); + // Increase fixed allocation by taking from the largest non-fixed track + const deficit = 60 - fixedPct; + const nonFixedTracks = tracks.filter((t) => t.type !== 'fixed'); + nonFixedTracks.sort((a, b) => b.percentage - a.percentage); + + if (nonFixedTracks.length > 0) { + const takeFrom = nonFixedTracks[0]; + const canTake = Math.min(deficit, takeFrom.percentage - 5); // Keep at least 5% + takeFrom.percentage -= canTake; + const fixedTrack = tracks.find((t) => t.type === 'fixed'); + if (fixedTrack) { + fixedTrack.percentage += canTake; + } + } + } + } + + return portfolio; +} + +// ── Exports ─────────────────────────────────────────────────────────────────── + +module.exports = { + // Main entry point + generatePortfolios, + + // Internal helpers (exported for testing) + generateWithAI, + generateRuleBased, + validatePortfolioConstraints, + + // Re-exported from portfolioEngine for backward compatibility + determineScenarios: (inputs, rates) => { + const profile = portfolioEngine.analyseUserProfile(inputs); + return portfolioEngine.determineScenarios(inputs, rates, profile).scenarios; + }, + getRuleBasedConfig: (type, inputs, rates) => { + const profile = portfolioEngine.analyseUserProfile(inputs); + return portfolioEngine.getAdaptiveAllocation(type, inputs, rates, profile); + }, + buildPortfolio: portfolioEngine.buildPortfolio, + enrichPortfolio: portfolioEngine.enrichPortfolio, + calculatePMT: portfolioEngine.calculatePMT, + calculateBaselineMonthlyPayment: portfolioEngine.calculateBaselineMonthlyPayment, + + // Constants (re-exported for backward compatibility) + SCENARIO_TYPES, + SCENARIO_NAMES_HE, + SCENARIO_NAMES_EN, + SCENARIO_DESCRIPTIONS, + TRACK_LABELS_HE, + STABILITY_THRESHOLD, + CPI_RATE_THRESHOLD, +}; diff --git a/src/validators/analysisValidator.js b/src/validators/analysisValidator.js new file mode 100644 index 0000000..d071831 --- /dev/null +++ b/src/validators/analysisValidator.js @@ -0,0 +1,138 @@ +/** + * Joi validation schemas for analysis endpoints. + * + * Validates the request body for the enhanced analysis endpoint + * to ensure the portfolio data is well-formed before processing. + */ + +'use strict'; + +const Joi = require('joi'); + +/** + * Schema for a single portfolio track. + */ +const trackSchema = Joi.object({ + type: Joi.string() + .valid('fixed', 'cpi', 'prime', 'variable') + .required() + .messages({ + 'any.only': 'Track type must be one of: fixed, cpi, prime, variable', + 'any.required': 'Track type is required', + }), + name: Joi.string().max(200).optional(), + nameEn: Joi.string().max(200).optional(), + percentage: Joi.number() + .min(1) + .max(100) + .required() + .messages({ + 'number.min': 'Track percentage must be at least 1%', + 'number.max': 'Track percentage cannot exceed 100%', + 'any.required': 'Track percentage is required', + }), + rate: Joi.number() + .min(0) + .max(30) + .required() + .messages({ + 'number.min': 'Track rate cannot be negative', + 'number.max': 'Track rate cannot exceed 30%', + 'any.required': 'Track rate is required', + }), + rateDisplay: Joi.string().max(50).optional(), + amount: Joi.number().min(0).optional(), + monthlyPayment: Joi.number().min(0).optional(), + totalCost: Joi.number().min(0).optional(), + totalInterest: Joi.number().min(0).optional(), +}).options({ allowUnknown: true }); + +/** + * Schema for the portfolio object in the enhanced analysis request. + */ +const portfolioSchema = Joi.object({ + id: Joi.string() + .trim() + .min(1) + .max(100) + .required() + .messages({ + 'string.empty': 'Portfolio ID cannot be empty', + 'any.required': 'Portfolio ID is required', + }), + type: Joi.string().max(100).optional(), + name: Joi.string() + .trim() + .min(1) + .max(200) + .required() + .messages({ + 'string.empty': 'Portfolio name cannot be empty', + 'any.required': 'Portfolio name is required', + }), + nameHe: Joi.string().max(200).optional(), + description: Joi.string().max(1000).optional(), + termYears: Joi.number() + .integer() + .min(1) + .max(40) + .required() + .messages({ + 'number.min': 'Term must be at least 1 year', + 'number.max': 'Term cannot exceed 40 years', + 'any.required': 'Term years is required', + }), + tracks: Joi.array() + .items(trackSchema) + .min(1) + .max(10) + .required() + .messages({ + 'array.min': 'Portfolio must have at least one track', + 'array.max': 'Portfolio cannot have more than 10 tracks', + 'any.required': 'Portfolio tracks are required', + }), + monthlyRepayment: Joi.number() + .min(1) + .required() + .messages({ + 'number.min': 'Monthly repayment must be positive', + 'any.required': 'Monthly repayment is required', + }), + totalCost: Joi.number() + .min(1) + .required() + .messages({ + 'number.min': 'Total cost must be positive', + 'any.required': 'Total cost is required', + }), + totalInterest: Joi.number() + .min(0) + .required() + .messages({ + 'number.min': 'Total interest cannot be negative', + 'any.required': 'Total interest is required', + }), + interestSavings: Joi.number().min(0).optional(), + fitnessScore: Joi.number().min(0).max(100).optional(), + recommended: Joi.boolean().optional(), +}).options({ allowUnknown: true }); + +/** + * Schema for POST /api/v1/analysis/enhanced/:offerId + * + * Validates the request body containing the portfolio data + * to compare against the OCR-extracted bank offer. + */ +const enhancedAnalysisSchema = Joi.object({ + portfolio: portfolioSchema.required().messages({ + 'any.required': 'Portfolio data is required', + }), + portfolioId: Joi.string().max(100).optional(), +}); + +module.exports = { + enhancedAnalysisSchema, + portfolioSchema, + trackSchema, +}; diff --git a/src/validators/paymentValidator.js b/src/validators/paymentValidator.js new file mode 100644 index 0000000..30233a0 --- /dev/null +++ b/src/validators/paymentValidator.js @@ -0,0 +1,51 @@ +/** + * Joi validation schemas for Stripe payment endpoints. + * + * Validates the request body for the checkout session creation endpoint + * to ensure required fields are present and well-formed. + * + * @module validators/paymentValidator + */ + +'use strict'; + +const Joi = require('joi'); + +/** + * Schema for POST /api/v1/stripe/checkout + * + * Validates the checkout request body: + * - successUrl: Required. Must be a valid URI. This is where Stripe + * redirects after successful payment. + * - cancelUrl: Optional. URI for cancellation redirect. + * - portfolioId: Optional. Links the payment to a specific portfolio. + */ +const checkoutSchema = Joi.object({ + successUrl: Joi.string() + .uri({ scheme: ['http', 'https'] }) + .required() + .messages({ + 'string.uri': 'successUrl must be a valid HTTP/HTTPS URL', + 'any.required': 'successUrl is required', + 'string.empty': 'successUrl cannot be empty', + }), + cancelUrl: Joi.string() + .uri({ scheme: ['http', 'https'] }) + .optional() + .allow('') + .messages({ + 'string.uri': 'cancelUrl must be a valid HTTP/HTTPS URL', + }), + portfolioId: Joi.string() + .trim() + .max(200) + .optional() + .allow('') + .messages({ + 'string.max': 'portfolioId cannot exceed 200 characters', + }), +}); + +module.exports = { + checkoutSchema, +}; diff --git a/src/validators/wizardValidator.js b/src/validators/wizardValidator.js new file mode 100644 index 0000000..701de28 --- /dev/null +++ b/src/validators/wizardValidator.js @@ -0,0 +1,171 @@ +/** + * Joi validation schemas for the public wizard endpoint. + * + * Validates the 6-step wizard inputs: + * 1. propertyPrice – Total property purchase price (₪) + * 2. loanAmount – Requested mortgage loan amount (₪) + * 3. monthlyIncome – Combined net monthly income (₪) + * 4. targetRepayment – Desired monthly repayment (₪) + * 5. futureFunds – Expected future lump-sum funds + * 6. stabilityPreference – Risk/stability slider (1-10) + * + * Also validates the consent flag for anonymous data storage. + */ + +'use strict'; + +const Joi = require('joi'); + +/** + * Schema for the future funds object. + * "none" means no expected future funds. + * Otherwise, a timeframe and optional amount are provided. + */ +const futureFundsSchema = Joi.object({ + timeframe: Joi.string() + .valid('none', 'within_5_years', 'within_10_years', 'over_10_years') + .required() + .messages({ + 'any.only': 'timeframe must be one of: none, within_5_years, within_10_years, over_10_years', + 'any.required': 'Future funds timeframe is required', + }), + amount: Joi.number() + .min(0) + .max(50000000) + .when('timeframe', { + is: 'none', + then: Joi.optional().default(0), + otherwise: Joi.optional().default(0), + }) + .messages({ + 'number.base': 'Future funds amount must be a number', + 'number.min': 'Future funds amount cannot be negative', + 'number.max': 'Future funds amount exceeds maximum allowed value', + }), +}); + +/** + * Main wizard submission schema. + * + * Validates the complete wizard input payload. + * All monetary values are in Israeli Shekels (₪). + */ +const wizardSubmitSchema = Joi.object({ + inputs: Joi.object({ + propertyPrice: Joi.number() + .min(100000) + .max(50000000) + .required() + .messages({ + 'number.base': 'Property price must be a number', + 'number.min': 'Property price must be at least ₪100,000', + 'number.max': 'Property price cannot exceed ₪50,000,000', + 'any.required': 'Property price is required', + }), + + loanAmount: Joi.number() + .min(50000) + .max(50000000) + .required() + .messages({ + 'number.base': 'Loan amount must be a number', + 'number.min': 'Loan amount must be at least ₪50,000', + 'number.max': 'Loan amount cannot exceed ₪50,000,000', + 'any.required': 'Loan amount is required', + }), + + monthlyIncome: Joi.number() + .min(1000) + .max(1000000) + .required() + .messages({ + 'number.base': 'Monthly income must be a number', + 'number.min': 'Monthly income must be at least ₪1,000', + 'number.max': 'Monthly income cannot exceed ₪1,000,000', + 'any.required': 'Monthly income is required', + }), + + additionalIncome: Joi.number() + .min(0) + .max(1000000) + .optional() + .default(0) + .messages({ + 'number.base': 'Additional income must be a number', + 'number.min': 'Additional income cannot be negative', + 'number.max': 'Additional income cannot exceed ₪1,000,000', + }), + + targetRepayment: Joi.number() + .min(500) + .max(100000) + .required() + .messages({ + 'number.base': 'Target repayment must be a number', + 'number.min': 'Target repayment must be at least ₪500', + 'number.max': 'Target repayment cannot exceed ₪100,000', + 'any.required': 'Target repayment is required', + }), + + futureFunds: futureFundsSchema.required().messages({ + 'any.required': 'Future funds information is required', + }), + + stabilityPreference: Joi.number() + .integer() + .min(1) + .max(10) + .required() + .messages({ + 'number.base': 'Stability preference must be a number', + 'number.integer': 'Stability preference must be a whole number', + 'number.min': 'Stability preference must be between 1 and 10', + 'number.max': 'Stability preference must be between 1 and 10', + 'any.required': 'Stability preference is required', + }), + }).required().messages({ + 'any.required': 'Wizard inputs are required', + }), + + consent: Joi.boolean() + .required() + .messages({ + 'boolean.base': 'Consent must be a boolean value', + 'any.required': 'Consent flag is required', + }), +}); + +/** + * Custom validation: loanAmount must not exceed propertyPrice. + * Applied as a post-validation check in the controller. + * + * @param {object} inputs - Validated wizard inputs + * @returns {{ valid: boolean, errors: string[] }} + */ +function validateBusinessRules(inputs) { + const errors = []; + + if (inputs.loanAmount > inputs.propertyPrice) { + errors.push('Loan amount cannot exceed property price'); + } + + // LTV check (informational, not blocking) + const ltv = (inputs.loanAmount / inputs.propertyPrice) * 100; + if (ltv > 75) { + // Not an error, but we note it for the response + } + + // Repayment-to-income ratio sanity check + const totalIncome = inputs.monthlyIncome + (inputs.additionalIncome || 0); + if (inputs.targetRepayment > totalIncome * 0.8) { + errors.push('Target repayment exceeds 80% of total income – this is unrealistic'); + } + + return { valid: errors.length === 0, errors }; +} + +module.exports = { + wizardSubmitSchema, + futureFundsSchema, + validateBusinessRules, +};