// routes/salesPortal.js — Sales Order module, CUSTOMER portal API (/api/sales-portal) // Customers (SAP business partners — msale's dealer_master) log in with their // SAP customer code + password. Their token is signed with a DIFFERENT secret // from employee tokens, so no employee endpoint anywhere in the ERP can ever // accept a customer token (they'd fail verifyToken), and vice versa. 'use strict'; const express = require('express'); const path = require('path'); const fs = require('fs'); const jwt = require('jsonwebtoken'); const masters = require('../services/sales/masters'); const orders = require('../services/sales/orders'); const sap = require('../services/sales/sap'); const salesRoutes = require('./sales'); const router = express.Router(); const CUSTOMER_SECRET = `${process.env.JWT_SECRET || 'sap-portal-secret'}::sales-customer`; const wrap = fn => async (req, res) => { try { const out = await fn(req, res); if (out !== undefined && !res.headersSent) res.json({ success: true, data: out }); } catch (e) { if (!res.headersSent) res.status(e.status || 500).json({ success: false, message: e.message }); } }; // Simple in-memory throttle on top of the per-account lockout: max 20 login // attempts per IP per 10 minutes. const _hits = new Map(); function throttled(ip) { const now = Date.now(); const arr = (_hits.get(ip) || []).filter(t => now - t < 600000); arr.push(now); _hits.set(ip, arr); return arr.length > 20; } router.post('/login', wrap(async (req, res) => { if (throttled(req.ip)) { res.status(429).json({ success: false, message: 'Too many login attempts — please wait a few minutes.' }); return; } const { cardCode, password } = req.body || {}; if (!cardCode || !password) { res.status(400).json({ success: false, message: 'Customer code and password are required' }); return; } const r = await masters.customerLogin(cardCode, password); if (!r.ok) { res.status(401).json({ success: false, message: r.message }); return; } const a = r.account; const token = jwt.sign({ type: 'customer', cardCode: a.cardCode, name: a.cardName }, CUSTOMER_SECRET, { expiresIn: '12h' }); return { token, customer: { cardCode: a.cardCode, cardName: a.cardName, mustChangePwd: a.mustChangePwd } }; })); // Auth for everything below. router.use(async (req, res, next) => { const h = req.headers.authorization || ''; const token = h.startsWith('Bearer ') ? h.slice(7) : ''; if (!token) return res.status(401).json({ success: false, message: 'Please log in' }); try { const p = jwt.verify(token, CUSTOMER_SECRET); if (p.type !== 'customer') throw new Error('bad token'); const acc = await masters.getCustomerAccount(p.cardCode); if (!acc || !acc.active || acc.locked) return res.status(401).json({ success: false, message: 'Your login is inactive or locked' }); req.actor = { type: 'customer', cardCode: p.cardCode, name: p.name || p.cardCode }; req.auditActor = `customer:${p.cardCode}`; next(); } catch (_e) { res.status(401).json({ success: false, message: 'Session expired — please log in again' }); } }); router.get('/me', wrap(async (req) => { const s = await masters.getSettings(); const acc = await masters.getCustomerAccount(req.actor.cardCode); const c = await sap.getCustomer(s.company, req.actor.cardCode); return { account: { cardCode: acc.cardCode, cardName: acc.cardName, email: acc.email, mustChangePwd: acc.mustChangePwd, lastLogin: acc.lastLogin }, customer: c, wallet: await orders.walletSummary(req.actor.cardCode), divisions: await masters.listDivisions(), allDivisions: await masters.listDivisions(true), settings: masters.publicSettings(s) }; })); router.post('/change-password', wrap(async (req) => { await masters.changeCustomerPassword(req.actor.cardCode, req.body.oldPassword, req.body.newPassword); return { ok: true }; })); router.get('/products', wrap(async (req) => (req.query.divisionId ? masters.listOrderableProducts(parseInt(req.query.divisionId)) : []))); // Suggested "Your order ref. no." for the next order (the customer may override it). router.get('/next-order-no', wrap(async req => ({ custOrderNo: await orders.nextCustOrderNo(req.actor.cardCode) }))); router.get('/orders', wrap(req => orders.listOrders(req.actor, req.query))); router.get('/orders/:id', wrap(req => orders.getOrder(req.params.id, req.actor))); router.post('/orders', wrap(req => orders.createOrder(req.actor, req.body))); router.put('/orders/:id/resubmit', wrap(req => orders.resubmit(req.actor, req.params.id, req.body))); router.post('/orders/:id/cancel', wrap(req => orders.customerCancel(req.actor, req.params.id, req.body.remarks))); router.post('/orders/:id/pay', wrap(req => orders.submitPayment(req.actor, req.params.id, req.body))); router.get('/ledger', wrap(async (req) => ({ summary: await orders.walletSummary(req.actor.cardCode), ledger: await orders.ledger(req.actor.cardCode, req.query) }))); router.get('/orders/:id/invoices', wrap(async (req) => { const o = await orders.getOrderRaw(req.params.id); if (!o || o.cardCode !== req.actor.cardCode) { const e = new Error('Order not found'); e.status = 404; throw e; } if (![8, 9].includes(o.status)) return { invoices: [] }; const invoices = await sap.invoicesForOrder(o.company, o.id); const batches = await sap.batchesWithCoa(o.company, invoices.map(i => i.docEntry)); const { query } = require('../services/sales/db'); const pdfs = invoices.length ? await query(`SELECT DISTINCT INVOICE_NO FROM dbo.ZSO_INVOICE_FILES WHERE INVOICE_NO IN (${invoices.map(() => '?').join(',')})`, invoices.map(i => String(i.invoiceNo))) : []; const pdfSet = new Set(pdfs.map(p => String(p.INVOICE_NO))); for (const inv of invoices) { inv.hasPdf = pdfSet.has(String(inv.invoiceNo)); inv.attachments = inv.atcEntry ? await sap.attachmentLines(o.company, inv.atcEntry) : []; inv.batches = batches.filter(b => b.invoiceDocEntry === inv.docEntry); delete inv.atcEntry; } return { invoices }; })); router.get('/attachment/:abs/:line', wrap((req, res) => salesRoutes.streamAttachment(req, res, req.actor))); router.get('/invoice-pdf/:invoiceNo', wrap((req, res) => salesRoutes.streamInvoicePdf(req, res, req.actor))); // Documents — customers may attach to their OWN orders only (PO copy, payment proof). router.post('/docs', salesRoutes.upload.single('file'), wrap(async (req) => { try { if (!req.file) throw Object.assign(new Error('No file uploaded'), { status: 400 }); const o = await orders.getOrderRaw(req.body.entityId); if (!o || o.cardCode !== req.actor.cardCode || req.body.entity !== 'order') throw Object.assign(new Error('Not allowed'), { status: 403 }); const docType = ['po_copy', 'payment', 'other'].includes(req.body.docType) ? req.body.docType : 'other'; return { id: await orders.addDoc('order', o.id, docType, req.body.title, req.file.filename, req.file.originalname, req.actor.name) }; } catch (e) { if (req.file) fs.unlink(req.file.path, () => {}); throw e; } })); router.get('/docs/:id', wrap(async (req, res) => { const d = await orders.getDoc(req.params.id); const o = d && d.ENTITY === 'order' ? await orders.getOrderRaw(d.ENTITY_ID) : null; if (!o || o.cardCode !== req.actor.cardCode) throw Object.assign(new Error('Not allowed'), { status: 403 }); const full = path.join(salesRoutes.DOC_DIR, path.basename(d.FILE_NAME)); if (!fs.existsSync(full)) throw Object.assign(new Error('File missing'), { status: 404 }); res.setHeader('Content-Disposition', `inline; filename="${(d.ORIG_NAME || d.FILE_NAME).replace(/"/g, '')}"`); res.sendFile(full); })); module.exports = router;