From 0341758ca072d53b1d95373a39e09dccd895f6cf Mon Sep 17 00:00:00 2001 From: nadhil7 Date: Mon, 14 Sep 2026 20:40:25 +0530 Subject: [PATCH 1/7] fix: db indexing done --- backend/billing_service/src/config/dataSource.ts | 15 +++++++++++++++ frontend/components/DashboardHeader.tsx | 2 +- frontend/lib/navCounts.ts | 2 +- 3 files changed, 17 insertions(+), 2 deletions(-) diff --git a/backend/billing_service/src/config/dataSource.ts b/backend/billing_service/src/config/dataSource.ts index 5ef70c05..5153ec79 100644 --- a/backend/billing_service/src/config/dataSource.ts +++ b/backend/billing_service/src/config/dataSource.ts @@ -2257,6 +2257,21 @@ async function runPreMigrations() { logger.info( `Backfill: ${accessoryBackfill.rowCount ?? 0} pre-existing accessory allocation(s) corrected from PRODUCT to ACCESSORY.`, ); + + // nav-counts polls every open sidebar every 30s and filters invoices by + // branchId+status and usage_records by contractId/billStatus — neither had an + // index, so both were sequential scans that occasionally pushed the poll past + // the frontend's 30s timeout and surfaced a "server is taking too long" toast. + try { + await client.query(` + CREATE INDEX IF NOT EXISTS "IDX_invoices_branchId_status" ON invoices ("branchId", status); + CREATE INDEX IF NOT EXISTS "IDX_usage_records_contractId" ON usage_records ("contractId"); + CREATE INDEX IF NOT EXISTS "IDX_usage_records_billStatus" ON usage_records ("billStatus"); + `); + logger.info('nav-counts indexes ensured on invoices and usage_records.'); + } catch (err) { + logger.warn(`Could not create nav-counts indexes: ${(err as Error).message}`); + } } catch (err) { logger.error('Failed to run pre-migrations:', err); throw err; diff --git a/frontend/components/DashboardHeader.tsx b/frontend/components/DashboardHeader.tsx index d479be63..dbd079ca 100644 --- a/frontend/components/DashboardHeader.tsx +++ b/frontend/components/DashboardHeader.tsx @@ -60,7 +60,7 @@ export default function DashboardHeader({ title = 'Dashboard' }: { title?: strin const fetchNotifications = async () => { try { - const response = await api.get('/e/notifications/my'); + const response = await api.get('/e/notifications/my', { skipErrorToast: true }); const data = response.data; // Support both old array shape and new { notifications, unreadCount } shape if (Array.isArray(data)) { diff --git a/frontend/lib/navCounts.ts b/frontend/lib/navCounts.ts index 6711e50b..5bec0308 100644 --- a/frontend/lib/navCounts.ts +++ b/frontend/lib/navCounts.ts @@ -10,7 +10,7 @@ import api from './api'; export type NavCounts = Record; export const fetchNavCounts = async (): Promise => { - const res = await api.get('/b/invoices/nav-counts'); + const res = await api.get('/b/invoices/nav-counts', { skipErrorToast: true }); return res.data?.data ?? {}; }; From 65ddc84ced88063b9457af3bf9c65f38fc5e11ef Mon Sep 17 00:00:00 2001 From: nadhil7 Date: Wed, 16 Sep 2026 11:04:06 +0530 Subject: [PATCH 2/7] =?UTF-8?q?feat:=20trusted=20device=20login=20?= =?UTF-8?q?=E2=80=94=20skip=20OTP=20for=20verified=20devices?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every employee login required an OTP over Gmail, hitting Gmail's rate limit. Employee login now trusts a browser for 1 day after its first OTP verification (device token cookie, hashed at rest), so only the first login of the day needs OTP. Admin login is untouched — it has no OTP step in this codebase today. - trusted_devices table + revoke endpoints (single + all) - daily cron sweep of expired rows - dev mail now goes through Ethereal instead of Gmail, avoiding the rate limit locally, falling back to a no-op transport if Ethereal creds aren't configured yet - frontend: login skips the OTP screen when the server logs in directly; new "Trusted Devices" tab in the sessions dialog Co-Authored-By: Claude Sonnet 5 --- backend/employee_service/package.json | 2 + backend/employee_service/src/app.ts | 2 + .../src/config/cookieOptions.ts | 20 ++ .../employee_service/src/config/dataSource.ts | 32 +- .../src/controllers/authController.ts | 103 ++++++ .../repositories/trustedDeviceRepository.ts | 70 ++++ .../employee_service/src/routes/authRouter.ts | 20 ++ .../src/services/deviceCleanupCron.ts | 19 ++ .../employee_service/src/utils/deviceToken.ts | 13 + backend/employee_service/src/utils/mailer.ts | 43 ++- frontend/components/SessionsDialog.tsx | 312 +++++++++++++----- frontend/components/login-form.tsx | 69 ++-- frontend/lib/auth.ts | 25 ++ 13 files changed, 607 insertions(+), 123 deletions(-) create mode 100644 backend/employee_service/src/repositories/trustedDeviceRepository.ts create mode 100644 backend/employee_service/src/services/deviceCleanupCron.ts create mode 100644 backend/employee_service/src/utils/deviceToken.ts diff --git a/backend/employee_service/package.json b/backend/employee_service/package.json index e90b5b36..4a111300 100644 --- a/backend/employee_service/package.json +++ b/backend/employee_service/package.json @@ -23,6 +23,7 @@ "@types/multer": "^2.0.0", "@types/multer-s3": "^3.0.3", "@types/node": "^25.0.2", + "@types/node-cron": "^3.0.11", "@types/nodemailer": "^7.0.4", "@types/ua-parser-js": "^0.7.39", "nodemon": "^3.1.11", @@ -44,6 +45,7 @@ "morgan": "^1.10.1", "multer": "^2.0.2", "multer-s3": "^3.0.1", + "node-cron": "^4.4.1", "nodemailer": "^7.0.11", "pg": "^8.16.3", "reflect-metadata": "^0.2.2", diff --git a/backend/employee_service/src/app.ts b/backend/employee_service/src/app.ts index d7942991..3d0a3751 100644 --- a/backend/employee_service/src/app.ts +++ b/backend/employee_service/src/app.ts @@ -14,6 +14,7 @@ import cookieParser from 'cookie-parser'; import { getRabbitChannel } from './config/rabbitmq'; import { startWorker } from './workers/emailWorker'; import { startBranchConsumer } from './events/consumers/branchConsumer'; +import { startDeviceCleanupCron } from './services/deviceCleanupCron'; import { httpLogger } from './middleware/httplogger'; import healthRouter from './routes/health'; import { logger } from './config/logger'; @@ -97,6 +98,7 @@ const startServer = async () => { // Start the automatic email and office notification systems await startWorker(); await startBranchConsumer(); + startDeviceCleanupCron(); const PORT = process.env.EMPLOYEE_PORT || process.env.PORT || 3002; diff --git a/backend/employee_service/src/config/cookieOptions.ts b/backend/employee_service/src/config/cookieOptions.ts index 0901d766..da7f4483 100644 --- a/backend/employee_service/src/config/cookieOptions.ts +++ b/backend/employee_service/src/config/cookieOptions.ts @@ -49,3 +49,23 @@ export const clearCookieOptions: CookieOptions = { sameSite: 'lax', path: '/', }; + +export const TRUSTED_DEVICE_COOKIE_NAME = 'xc_device_token'; + +/** 1 day — staff verify with OTP once each morning, plain password the rest of the day. */ +export const TRUSTED_DEVICE_COOKIE_MAX_AGE = 24 * 60 * 60 * 1000; + +export const trustedDeviceCookieOptions: CookieOptions = { + httpOnly: true, + secure: isSecure, + sameSite: 'lax', + maxAge: TRUSTED_DEVICE_COOKIE_MAX_AGE, + path: '/', +}; + +export const clearTrustedDeviceCookieOptions: CookieOptions = { + httpOnly: true, + secure: isSecure, + sameSite: 'lax', + path: '/', +}; diff --git a/backend/employee_service/src/config/dataSource.ts b/backend/employee_service/src/config/dataSource.ts index 7c8428db..66129468 100644 --- a/backend/employee_service/src/config/dataSource.ts +++ b/backend/employee_service/src/config/dataSource.ts @@ -16,6 +16,8 @@ import { EmployeeDocument } from '../entities/employeeDocumentEntity'; import { logger } from './logger'; import { seedAdmin } from '../utils/seedAdmin'; +const EMPLOYEE_DB_POOL_MAX = Number(process.env.EMPLOYEE_DB_POOL_MAX) || 10; + export const Source = new DataSource({ type: 'postgres', url: process.env.EMPLOYEE_DATABASE_URL, @@ -34,9 +36,14 @@ export const Source = new DataSource({ LateMark, EmployeeDocument, ], - poolSize: 1, + // A pool of 1 serializes every concurrent DB-backed request onto a single + // connection — invoice-list enrichment alone fans out one employee lookup + // per unique creator per page, and those all queue up behind this one slot. + // billing_service hit the identical failure (see its dataSource.ts) before + // this was made configurable there; mirroring that fix here. + poolSize: EMPLOYEE_DB_POOL_MAX, extra: { - max: 1, + max: EMPLOYEE_DB_POOL_MAX, min: 0, connectionTimeoutMillis: 5000, keepAlive: true, @@ -298,6 +305,27 @@ export const connectWithRetry = async (initialDelayMs = 2000): Promise try { const { user } = await authService.login(req.body); + // Local E2E test escape hatch only — never set SKIP_LOGIN_OTP in a deployed + // environment. Lets the Jest E2E suite log in without polling a mailbox for OTPs. + if (process.env.SKIP_LOGIN_OTP === 'true') { + const { accessToken } = await issueTokens(user, req, res); + logger.info('login successfull (OTP skipped: SKIP_LOGIN_OTP=true)'); + return res.json({ + message: 'Login successfull', + accessToken, + data: user, + success: true, + }); + } + + // Trusted device — same browser verified with OTP within the last 24h. + // Credentials are still checked above; this only skips the second factor. + const deviceToken = req.cookies?.[TRUSTED_DEVICE_COOKIE_NAME]; + if (deviceToken) { + const trusted = await trustedDeviceRepo.findValid(hashDeviceToken(deviceToken), user.id); + if (trusted) { + await trustedDeviceRepo.touch(hashDeviceToken(deviceToken)); + const { accessToken } = await issueTokens(user, req, res); + logger.info(`login successfull (trusted device, OTP skipped) for ${user.email}`); + return res.json({ + message: 'Login successfull', + accessToken, + data: user, + success: true, + }); + } + } + otpService .sendOtp(user.email, OtpPurpose.LOGIN) .then(() => logger.info(`OTP sent successfully to ${user.email} for login`)) @@ -63,6 +101,19 @@ export const loginVerify = async (req: Request, res: Response, next: NextFunctio const user = await authService.findUserByEmail(email); const { accessToken } = await issueTokens(user, req, res); + + const rawDeviceToken = generateDeviceToken(); + const deviceName = (req.headers['user-agent'] as string) || 'Unknown Device'; + const expiresAt = new Date(Date.now() + TRUSTED_DEVICE_COOKIE_MAX_AGE); + await trustedDeviceRepo.create( + user.id, + hashDeviceToken(rawDeviceToken), + deviceName, + req.ip, + expiresAt, + ); + res.cookie(TRUSTED_DEVICE_COOKIE_NAME, rawDeviceToken, trustedDeviceCookieOptions); + logger.info('login successfull'); return res.json({ @@ -352,6 +403,58 @@ export const logoutSession = async (req: Request, res: Response, next: NextFunct } }; +/** + * List trusted devices: + * Show every browser/device that can currently log in with just + * email + password (no OTP) for this account. + */ +export const getTrustedDevices = async (req: Request, res: Response, next: NextFunction) => { + try { + const userId = req.user.userId; + const devices = await trustedDeviceRepo.listByUser(userId); + return res.json({ data: devices, success: true }); + } catch (err: unknown) { + const error = err as AuthError; + next(new AppError(error.message || 'Internal Server Error', error.statusCode || 500)); + } +}; + +/** + * Revoke one trusted device: + * That browser will need OTP again on its next login. + */ +export const revokeTrustedDevice = async (req: Request, res: Response, next: NextFunction) => { + try { + const userId = req.user.userId; + const deviceId = req.params.deviceId as string; + const deleted = await trustedDeviceRepo.deleteById(deviceId, userId); + if (!deleted) { + return next(new AppError('Trusted device not found', 404)); + } + return res.json({ message: 'Trusted device revoked', success: true }); + } catch (err: unknown) { + const error = err as AuthError; + next(new AppError(error.message || 'Internal Server Error', error.statusCode || 500)); + } +}; + +/** + * Revoke every trusted device: + * Useful if a password may be compromised — every browser, including + * this one, will need OTP again on its next login. + */ +export const revokeAllTrustedDevices = async (req: Request, res: Response, next: NextFunction) => { + try { + const userId = req.user.userId; + await trustedDeviceRepo.deleteAllForUser(userId); + res.clearCookie(TRUSTED_DEVICE_COOKIE_NAME, clearTrustedDeviceCookieOptions); + return res.json({ message: 'All trusted devices revoked', success: true }); + } catch (err: unknown) { + const error = err as AuthError; + next(new AppError(error.message || 'Internal Server Error', error.statusCode || 500)); + } +}; + /** * Get My Profile: * Retrieve the basic details (name, role, branch) for the staff member diff --git a/backend/employee_service/src/repositories/trustedDeviceRepository.ts b/backend/employee_service/src/repositories/trustedDeviceRepository.ts new file mode 100644 index 00000000..72340696 --- /dev/null +++ b/backend/employee_service/src/repositories/trustedDeviceRepository.ts @@ -0,0 +1,70 @@ +import { Source } from '../config/dataSource'; + +export interface TrustedDeviceRow { + id: string; + user_id: string; + user_type: string; + device_token_hash: string; + device_name: string | null; + ip_address: string | null; + last_used_at: string; + expires_at: string; + created_at: string; +} + +export class TrustedDeviceRepository { + async findValid(hash: string, userId: string): Promise { + const rows = await Source.query( + `SELECT * FROM trusted_devices WHERE device_token_hash = $1 AND user_id = $2 AND expires_at > NOW()`, + [hash, userId], + ); + return rows[0] || null; + } + + async touch(hash: string) { + await Source.query( + `UPDATE trusted_devices SET last_used_at = NOW() WHERE device_token_hash = $1`, + [hash], + ); + } + + async create( + userId: string, + hash: string, + deviceName: string, + ipAddress: string | undefined, + expiresAt: Date, + ) { + await Source.query( + `INSERT INTO trusted_devices (user_id, user_type, device_token_hash, device_name, ip_address, expires_at) + VALUES ($1, 'EMPLOYEE', $2, $3, $4, $5) + ON CONFLICT (device_token_hash) DO NOTHING`, + [userId, hash, deviceName, ipAddress || null, expiresAt], + ); + } + + async listByUser(userId: string): Promise { + return Source.query( + `SELECT id, device_name, ip_address, last_used_at, expires_at, created_at + FROM trusted_devices WHERE user_id = $1 ORDER BY last_used_at DESC`, + [userId], + ); + } + + /** Ownership-scoped so one user can't revoke another's device by guessing an id. */ + async deleteById(id: string, userId: string): Promise { + const rows = await Source.query( + `DELETE FROM trusted_devices WHERE id = $1 AND user_id = $2 RETURNING id`, + [id, userId], + ); + return rows.length; + } + + async deleteAllForUser(userId: string) { + await Source.query(`DELETE FROM trusted_devices WHERE user_id = $1`, [userId]); + } + + async deleteExpired() { + await Source.query(`DELETE FROM trusted_devices WHERE expires_at < NOW()`); + } +} diff --git a/backend/employee_service/src/routes/authRouter.ts b/backend/employee_service/src/routes/authRouter.ts index 23c5cfe5..ca0c1deb 100644 --- a/backend/employee_service/src/routes/authRouter.ts +++ b/backend/employee_service/src/routes/authRouter.ts @@ -13,6 +13,9 @@ import { getSessions, logoutSession, getMe, + getTrustedDevices, + revokeTrustedDevice, + revokeAllTrustedDevices, } from '../controllers/authController'; import { authMiddleware } from '../middleware/authMiddleware'; @@ -98,4 +101,21 @@ authRouter.get('/sessions', authMiddleware, getSessions); */ authRouter.post('/sessions/logout', authMiddleware, logoutSession); +// --- 5. Trusted Devices (skip-OTP) --- + +/** + * List browsers/devices that can currently log in without OTP. + */ +authRouter.get('/trusted-devices', authMiddleware, getTrustedDevices); + +/** + * Revoke one trusted device — it will need OTP again next login. + */ +authRouter.delete('/trusted-devices/:deviceId', authMiddleware, revokeTrustedDevice); + +/** + * Revoke every trusted device for this account (e.g. password compromised). + */ +authRouter.post('/revoke-trusted-devices', authMiddleware, revokeAllTrustedDevices); + export default authRouter; diff --git a/backend/employee_service/src/services/deviceCleanupCron.ts b/backend/employee_service/src/services/deviceCleanupCron.ts new file mode 100644 index 00000000..d9e6aa44 --- /dev/null +++ b/backend/employee_service/src/services/deviceCleanupCron.ts @@ -0,0 +1,19 @@ +import cron from 'node-cron'; +import { Source } from '../config/dataSource'; +import { logger } from '../config/logger'; + +/** + * Daily sweep of expired trusted-device rows. The 1-day cookie itself already + * stops the browser from sending an expired token, but the DB row would + * otherwise accumulate forever. + */ +export function startDeviceCleanupCron() { + cron.schedule('0 2 * * *', async () => { + try { + await Source.query(`DELETE FROM trusted_devices WHERE expires_at < NOW()`); + logger.info('Cleaned up expired trusted devices'); + } catch (err) { + logger.error('Failed to clean up expired trusted devices:', err); + } + }); +} diff --git a/backend/employee_service/src/utils/deviceToken.ts b/backend/employee_service/src/utils/deviceToken.ts new file mode 100644 index 00000000..13508d9a --- /dev/null +++ b/backend/employee_service/src/utils/deviceToken.ts @@ -0,0 +1,13 @@ +import crypto from 'crypto'; + +/** + * The raw token lives in the browser cookie; only its hash is ever stored or + * looked up server-side, so a DB read alone can't reconstruct a valid cookie. + */ +export function hashDeviceToken(token: string): string { + return crypto.createHash('sha256').update(token).digest('hex'); +} + +export function generateDeviceToken(): string { + return crypto.randomBytes(32).toString('hex'); +} diff --git a/backend/employee_service/src/utils/mailer.ts b/backend/employee_service/src/utils/mailer.ts index ec216fbd..3f50e287 100644 --- a/backend/employee_service/src/utils/mailer.ts +++ b/backend/employee_service/src/utils/mailer.ts @@ -1,13 +1,42 @@ import nodemailer from 'nodemailer'; import * as XLSX from 'xlsx'; -export const mailer = nodemailer.createTransport({ - service: 'gmail', - auth: { - user: process.env.MAIL_USER, - pass: process.env.MAIL_PASS, - }, -}); +/** + * Local dev hammers Gmail's send rate limit (every OTP is a real email). + * Outside production, send through Ethereal instead — a throwaway SMTP + * sandbox that never delivers anything real; view caught mail at + * https://ethereal.email using ETHEREAL_USER/ETHEREAL_PASS. + * + * If those aren't set yet (fresh dev box), fall back to `jsonTransport` + * rather than trying to authenticate with empty credentials — that would + * make every `sendMail` throw and break login locally. `jsonTransport` + * never sends anything; the composed message (OTP included) just logs. + */ +const createTransporter = () => { + if (process.env.NODE_ENV !== 'production') { + if (process.env.ETHEREAL_USER && process.env.ETHEREAL_PASS) { + return nodemailer.createTransport({ + host: 'smtp.ethereal.email', + port: 587, + auth: { + user: process.env.ETHEREAL_USER, + pass: process.env.ETHEREAL_PASS, + }, + }); + } + return nodemailer.createTransport({ jsonTransport: true }); + } + + return nodemailer.createTransport({ + service: 'gmail', + auth: { + user: process.env.MAIL_USER, + pass: process.env.MAIL_PASS, + }, + }); +}; + +export const mailer = createTransporter(); export async function sendEmployeeWelcomeMail(to: string, password: string) { await mailer.sendMail({ diff --git a/frontend/components/SessionsDialog.tsx b/frontend/components/SessionsDialog.tsx index 5591da4c..e8da7fd1 100644 --- a/frontend/components/SessionsDialog.tsx +++ b/frontend/components/SessionsDialog.tsx @@ -10,10 +10,18 @@ import { DialogDescription, } from '@/components/ui/dialog'; import { Button } from '@/components/ui/button'; -import { getSessions, logoutSession, logoutOtherDevices } from '@/lib/auth'; -import { Laptop, Smartphone, Globe, LogOut, ShieldCheck, Clock } from 'lucide-react'; +import { + getSessions, + logoutSession, + logoutOtherDevices, + getTrustedDevices, + revokeTrustedDevice, + revokeAllTrustedDevices, +} from '@/lib/auth'; +import { Laptop, Smartphone, Globe, LogOut, ShieldCheck, Clock, ShieldOff } from 'lucide-react'; import { formatDistanceToNow } from 'date-fns'; import { Badge } from '@/components/ui/badge'; +import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'; interface Session { id: string; @@ -23,6 +31,14 @@ interface Session { isCurrent: boolean; } +interface TrustedDevice { + id: string; + device_name: string | null; + ip_address: string | null; + last_used_at: string; + expires_at: string; +} + interface SessionsDialogProps { open: boolean; onOpenChange: (open: boolean) => void; @@ -35,6 +51,8 @@ interface SessionsDialogProps { export function SessionsDialog({ open, onOpenChange }: SessionsDialogProps) { const [sessions, setSessions] = useState([]); const [loading, setLoading] = useState(false); + const [trustedDevices, setTrustedDevices] = useState([]); + const [trustedLoading, setTrustedLoading] = useState(false); const fetchSessions = async () => { setLoading(true); @@ -50,12 +68,51 @@ export function SessionsDialog({ open, onOpenChange }: SessionsDialogProps) { } }; + const fetchTrustedDevices = async () => { + setTrustedLoading(true); + try { + const res = await getTrustedDevices(); + if (res.success) { + setTrustedDevices(res.data); + } + } catch { + toast.error('Failed to load trusted devices'); + } finally { + setTrustedLoading(false); + } + }; + useEffect(() => { if (open) { fetchSessions(); + fetchTrustedDevices(); } }, [open]); + const handleRevokeTrustedDevice = async (deviceId: string) => { + try { + const res = await revokeTrustedDevice(deviceId); + if (res.success) { + toast.success('Trusted device revoked'); + fetchTrustedDevices(); + } + } catch { + toast.error('Failed to revoke trusted device'); + } + }; + + const handleRevokeAllTrustedDevices = async () => { + try { + const res = await revokeAllTrustedDevices(); + if (res.success) { + toast.success('All trusted devices revoked — OTP required on next login everywhere'); + fetchTrustedDevices(); + } + } catch { + toast.error('Failed to revoke trusted devices'); + } + }; + const handleLogoutSession = async (sessionId: string) => { try { const res = await logoutSession(sessionId); @@ -148,106 +205,191 @@ export function SessionsDialog({ open, onOpenChange }: SessionsDialogProps) {
- Active Sessions + Account Security - Manage devices where your account is currently logged in. + Manage where your account is logged in and which browsers skip OTP.
-
-
- {loading ? ( -
- - Loading active sessions... -
- ) : sortedSessions.length === 0 ? ( -
- No active sessions found. -
- ) : ( - sortedSessions.map((session) => ( -
-
+ + + Active Sessions + Trusted Devices + + + +
+
+ {loading ? ( +
+ + Loading active sessions... +
+ ) : sortedSessions.length === 0 ? ( +
+ No active sessions found. +
+ ) : ( + sortedSessions.map((session) => (
- {getDeviceIcon(session.userAgent)} -
+
+
+ {getDeviceIcon(session.userAgent)} +
-
-
- - {getDeviceName(session.userAgent)} - - {session.isCurrent && ( - - Current Device - - )} -
+
+
+ + {getDeviceName(session.userAgent)} + + {session.isCurrent && ( + + Current Device + + )} +
-
- - - {session.ip || 'Unknown IP'} - - - - {session.isCurrent - ? 'Active now' - : `Last active ${formatDistanceToNow(new Date(session.createdAt), { addSuffix: true })}`} - +
+ + + {session.ip || 'Unknown IP'} + + + + {session.isCurrent + ? 'Active now' + : `Last active ${formatDistanceToNow(new Date(session.createdAt), { addSuffix: true })}`} + +
+
+ + {!session.isCurrent && ( + + )}
-
+ )) + )} +
+
- {!session.isCurrent && ( - +
+ )} + + + +
+
+ {trustedLoading ? ( +
+ + Loading trusted devices... +
+ ) : trustedDevices.length === 0 ? ( +
+ No trusted devices — every login on every browser currently requires OTP. +
+ ) : ( + trustedDevices.map((device) => ( +
- - Log out - - )} -
- )) +
+
+ {getDeviceIcon(device.device_name || undefined)} +
+ +
+ + {device.device_name + ? getDeviceName(device.device_name) + : 'Unknown Device'} + + +
+ + + {device.ip_address || 'Unknown IP'} + + + + Last used{' '} + {formatDistanceToNow(new Date(device.last_used_at), { + addSuffix: true, + })} + +
+
+
+ + +
+ )) + )} +
+
+ + {trustedDevices.length > 0 && ( +
+ +
)} -
-
- - {sortedSessions.length > 1 && ( -
- -
- )} + + ); diff --git a/frontend/components/login-form.tsx b/frontend/components/login-form.tsx index c7ec35ec..0ba60dcb 100644 --- a/frontend/components/login-form.tsx +++ b/frontend/components/login-form.tsx @@ -42,10 +42,43 @@ export function LoginForm({ className, ...props }: React.ComponentProps<'div'>) const [loading, setLoading] = useState(false); // Used to show "Processing..." on buttons const [error, setError] = useState(null); + /** + * Finish logging in: save the Digital ID Card (AccessToken) and send + * the staff member to the right department based on their job role. + * Shared by the OTP-verify path and the trusted-device (OTP-skipped) path. + */ + const completeLogin = (accessToken: string) => { + localStorage.setItem('accessToken', accessToken); + document.cookie = `accessToken=${accessToken}; path=/; max-age=86400; SameSite=Strict`; + + try { + const decoded = jwtDecode<{ role: string }>(accessToken); + const role = decoded.role; + + if (role === 'ADMIN') { + window.location.href = '/admin/dashboard'; + } else if (role === 'HR') { + window.location.href = '/hr/dashboard'; + } else if (role === 'MANAGER') { + window.location.href = '/manager/dashboard'; + } else if (role === 'FINANCE') { + window.location.href = '/finance/dashboard'; + } else if (role === 'EMPLOYEE') { + window.location.href = '/employee/dashboard'; + } else { + window.location.href = '/dashboard'; + } + } catch { + window.location.href = '/dashboard'; + } + }; + /** * STEP 1: Check Password * When the user clicks "Next" after entering their password, we send - * a verification code to their email for extra security. + * a verification code to their email for extra security — unless this + * browser was already verified within the last day, in which case the + * server logs them straight in with no OTP step at all. */ const handleCredentialsSubmit = async (e: React.FormEvent) => { e.preventDefault(); @@ -54,7 +87,11 @@ export function LoginForm({ className, ...props }: React.ComponentProps<'div'>) try { const res = await requestLoginOtp(email, password); - if (res.success) { + if (res.accessToken) { + // Trusted device — server skipped OTP and logged us in directly. + toast.success(res.message); + completeLogin(res.accessToken); + } else if (res.success) { toast.success(res.message); setStep('otp'); // Move to the "Enter Code" screen } else { @@ -93,33 +130,7 @@ export function LoginForm({ className, ...props }: React.ComponentProps<'div'>) const res = await verifyLoginOtp(email, otp); if (res.success) { toast.success(res.message); - // Save the Digital ID Card (AccessToken) in the browser - // so they stay signed in even if they refresh the page. - localStorage.setItem('accessToken', res.accessToken); - document.cookie = `accessToken=${res.accessToken}; path=/; max-age=86400; SameSite=Strict`; - - // Check the user's Job Role to decide where to send them. - try { - const decoded = jwtDecode<{ role: string }>(res.accessToken); - const role = decoded.role; - - // Redirecting to the right department: - if (role === 'ADMIN') { - window.location.href = '/admin/dashboard'; - } else if (role === 'HR') { - window.location.href = '/hr/dashboard'; - } else if (role === 'MANAGER') { - window.location.href = '/manager/dashboard'; - } else if (role === 'FINANCE') { - window.location.href = '/finance/dashboard'; - } else if (role === 'EMPLOYEE') { - window.location.href = '/employee/dashboard'; - } else { - window.location.href = '/dashboard'; - } - } catch { - window.location.href = '/dashboard'; - } + completeLogin(res.accessToken); } else { toast.error(res.message); setError(res.message); diff --git a/frontend/lib/auth.ts b/frontend/lib/auth.ts index ef5f848a..4a3f59ee 100644 --- a/frontend/lib/auth.ts +++ b/frontend/lib/auth.ts @@ -192,3 +192,28 @@ export async function getProfile() { const res = await api.get('/e/auth/me'); return res.data; } + +/** + * Lists browsers/devices that can currently log in without OTP. + */ +export async function getTrustedDevices() { + const res = await api.get('/e/auth/trusted-devices'); + return res.data; +} + +/** + * Revokes a single trusted device — it will need OTP again on its next login. + * @param deviceId The ID of the trusted device to revoke + */ +export async function revokeTrustedDevice(deviceId: string) { + const res = await api.delete(`/e/auth/trusted-devices/${deviceId}`); + return res.data; +} + +/** + * Revokes every trusted device for the current user (e.g. password compromised). + */ +export async function revokeAllTrustedDevices() { + const res = await api.post('/e/auth/revoke-trusted-devices'); + return res.data; +} From edde396fb0fc7a70c3dfbd628d088fb7e2a881a5 Mon Sep 17 00:00:00 2001 From: riyasTK8 Date: Thu, 17 Sep 2026 22:02:26 +0530 Subject: [PATCH 3/7] feat: service ticket payment collection and accounts flow --- .../src/controllers/invoiceController.ts | 25 +- .../api_gateway/src/routes/invoiceRoutes.ts | 11 + .../src/services/invoiceAggregationService.ts | 44 ++ .../src/controllers/invoiceController.ts | 98 +++- .../src/controllers/saleWorkflowController.ts | 35 ++ .../src/routes/invoiceRoutes.ts | 16 + .../src/services/billingService.ts | 193 +++++++- .../src/utils/serviceTicketSync.ts | 61 +++ backend/ven_inv_service/src/config/db.ts | 50 ++ .../src/controllers/serviceController.ts | 429 ++++++++++++---- .../src/entities/serviceTicketEntity.ts | 47 ++ .../src/routes/serviceRoutes.ts | 2 + .../app/employee/(dashboard)/service/page.tsx | 458 ++++++++++++++++-- frontend/components/Finance/ReceiptsTab.tsx | 443 ++++++++++++++++- .../service/RecordCustomerApprovalDialog.tsx | 316 ++++++------ frontend/components/ui/Modal.tsx | 15 +- frontend/lib/invoice.ts | 16 + frontend/lib/serviceTicket.ts | 15 + 18 files changed, 1959 insertions(+), 315 deletions(-) create mode 100644 backend/billing_service/src/utils/serviceTicketSync.ts diff --git a/backend/api_gateway/src/controllers/invoiceController.ts b/backend/api_gateway/src/controllers/invoiceController.ts index 3bf0987f..f9262006 100644 --- a/backend/api_gateway/src/controllers/invoiceController.ts +++ b/backend/api_gateway/src/controllers/invoiceController.ts @@ -29,7 +29,11 @@ export const getAllInvoices = async ( throw new Error('User not authenticated'); } const token = req.headers.authorization?.split(' ')[1] || ''; - const invoices = await invoiceAggregationService.getAllInvoices(user, token); + const invoices = await invoiceAggregationService.getAllInvoices( + user, + token, + req.query as Record, + ); return res.status(200).json({ success: true, data: invoices, @@ -185,6 +189,25 @@ export const financeApproveQuotation = async (req: Request, res: Response, next: /** * Employee converts an approved quotation into an active Sale/Rent/Lease transaction. */ +export const confirmServiceEstimateToAccounts = async ( + req: Request, + res: Response, + next: NextFunction, +) => { + try { + const id = req.params.id as string; + const token = req.headers.authorization?.split(' ')[1] || ''; + const invoice = await invoiceAggregationService.confirmServiceEstimateToAccounts(id, token); + return res.status(200).json({ + success: true, + data: invoice, + message: 'Service estimate taken into accounts — receivable raised.', + }); + } catch (error) { + next(error); + } +}; + export const convertToTransaction = async (req: Request, res: Response, next: NextFunction) => { try { const id = req.params.id as string; diff --git a/backend/api_gateway/src/routes/invoiceRoutes.ts b/backend/api_gateway/src/routes/invoiceRoutes.ts index a8375ab8..2c23b2b8 100644 --- a/backend/api_gateway/src/routes/invoiceRoutes.ts +++ b/backend/api_gateway/src/routes/invoiceRoutes.ts @@ -38,6 +38,7 @@ import { processReturn, financeApproveQuotation, convertToTransaction, + confirmServiceEstimateToAccounts, createDirectSale, createQuotationTemplate, getQuotationTemplates, @@ -269,6 +270,16 @@ router.post( convertToTransaction, ); +/** + * Accounts confirms a customer-accepted service estimate into the books, raising the + * receivable. Finance/Admin only — it is a bookkeeping act, not a service-desk one. + */ +router.post( + '/:id/confirm-service-to-accounts', + requireRole(UserRole.ADMIN, UserRole.FINANCE), + confirmServiceEstimateToAccounts, +); + /** * Manager or Employee approval for a next-step action. */ diff --git a/backend/api_gateway/src/services/invoiceAggregationService.ts b/backend/api_gateway/src/services/invoiceAggregationService.ts index 811f8a2e..25f4a2fb 100644 --- a/backend/api_gateway/src/services/invoiceAggregationService.ts +++ b/backend/api_gateway/src/services/invoiceAggregationService.ts @@ -247,12 +247,28 @@ export class InvoiceAggregationService { async getAllInvoices( user: { role: string; branchId?: string }, token: string, + /** + * The caller's own filters (billType, status, …). + * + * These used to stop here: the gateway read none of req.query, so billing was always + * asked for every invoice and the "service estimates awaiting approval" request came + * back with paid sales and refunds in it. Forwarding them is what makes the filter + * billing already applies actually reachable from the browser. + */ + query?: Record, ): Promise { try { + const forwarded: Record = {}; + for (const key of ['billType', 'status', 'saleType', 'customerId'] as const) { + const v = query?.[key]; + if (typeof v === 'string' && v.trim()) forwarded[key] = v.trim(); + } + const billingResponse = await axios.get<{ data: Invoice[] }>( `${BILLING_SERVICE_URL}/invoices`, { headers: { Authorization: `Bearer ${token}` }, + params: forwarded, }, ); let invoices = billingResponse.data.data; @@ -833,6 +849,34 @@ export class InvoiceAggregationService { /** * Employee converts an approved quotation into a transaction. */ + /** Accounts taking a customer-accepted service estimate into the books. */ + async confirmServiceEstimateToAccounts(id: string, token: string) { + try { + const response = await axios.post<{ data: Invoice }>( + `${BILLING_SERVICE_URL}/invoices/${id}/confirm-service-to-accounts`, + {}, + { headers: { Authorization: `Bearer ${token}` } }, + ); + return response.data.data; + } catch (error: unknown) { + // Pass billing's own status and message through. Without this the gateway turned a + // precise 400 ("this one is FINANCE_APPROVED, not customer-accepted") into a bare + // 500, which tells the person clicking nothing about why it was refused. + if (axios.isAxiosError(error)) { + logger.error('Axios error confirming service estimate to accounts', { + message: error.message, + responseStatus: error.response?.status, + responseData: error.response?.data, + }); + throw new AppError( + error.response?.data?.message || 'Failed to take the estimate into accounts', + error.response?.status || 500, + ); + } + throw new AppError('Internal Gateway Error confirming service estimate', 500); + } + } + async convertToTransaction(id: string, token: string) { try { const response = await axios.post<{ data: Invoice }>( diff --git a/backend/billing_service/src/controllers/invoiceController.ts b/backend/billing_service/src/controllers/invoiceController.ts index f4d0d809..8f035f6a 100644 --- a/backend/billing_service/src/controllers/invoiceController.ts +++ b/backend/billing_service/src/controllers/invoiceController.ts @@ -303,6 +303,71 @@ export const financeApproveQuotation = async (req: Request, res: Response, next: /** * Employee converts an approved quotation into an active Sale/Rent/Lease transaction. */ +/** + * POST /invoices/:id/confirm-service-to-accounts + * + * Accounts taking a customer-accepted service estimate into the books. + */ +export const confirmServiceEstimateToAccounts = async ( + req: Request, + res: Response, + next: NextFunction, +) => { + try { + const id = req.params.id as string; + if (!req.user?.userId) throw new AppError('User context missing', 401); + const invoice = await billingService.confirmServiceEstimateToAccounts(id, req.user.userId); + return res.status(200).json({ + success: true, + data: invoice, + message: 'Service estimate taken into accounts — receivable raised.', + }); + } catch (error) { + next(error); + } +}; + +/** + * POST /invoices/:id/service-completion-payment + * + * Internal: called by ven_inv when a technician closes a job having taken payment. + */ +export const recordServiceCompletionPayment = async ( + req: Request, + res: Response, + next: NextFunction, +) => { + try { + const id = req.params.id as string; + const { + amount, + paymentMode, + accountId, + chequeNumber, + chequeBankName, + chequeDate, + remarks, + branchId, + collectedBy, + } = req.body; + const result = await billingService.recordServiceCompletionPayment({ + invoiceId: id, + userId: collectedBy || req.user?.userId || 'SYSTEM', + amount: Number(amount) || 0, + paymentMode, + accountId, + chequeNumber, + chequeBankName, + chequeDate, + remarks, + branchId, + }); + return res.status(201).json({ success: true, data: result }); + } catch (error) { + next(error); + } +}; + export const convertToTransaction = async (req: Request, res: Response, next: NextFunction) => { try { const id = req.params.id as string; @@ -496,9 +561,24 @@ export const getAllInvoices = async (req: Request, res: Response, next: NextFunc try { const branchId = req.user?.role === 'ADMIN' ? undefined : req.user?.branchId; const invoices = await billingService.getAllInvoices(branchId); + + // `?billType=` and `?status=` were accepted and then silently ignored: this handler + // never looked at req.query, so a caller asking for "service estimates awaiting + // finance approval" was handed every invoice in the branch — paid sales, refunds and + // all. The Finance estimates page only looked right because it re-filtered the same + // list again in the browser; anything trusting the URL got the wrong rows. + const billType = (req.query.billType as string | undefined)?.trim(); + const status = (req.query.status as string | undefined)?.trim(); + + const filtered = invoices.filter((inv) => { + if (billType && inv.billType !== billType) return false; + if (status && inv.status !== status) return false; + return true; + }); + return res.status(200).json({ success: true, - data: invoices, + data: filtered, }); } catch (error) { next(error); @@ -1689,6 +1769,11 @@ export const recordServiceVisitCharge = async (req: Request, res: Response, next paymentMode, accountId, remarks, + collectedByName, + collectedByRole, + chequeNumber, + chequeBankName, + chequeDate, } = req.body; if (!serviceTicketId || !branchId) { return res.status(400).json({ @@ -1696,7 +1781,7 @@ export const recordServiceVisitCharge = async (req: Request, res: Response, next message: 'serviceTicketId and branchId are required', }); } - const invoice = await billingService.recordServiceVisitCharge({ + const { invoice, paymentRequestId } = await billingService.recordServiceVisitCharge({ serviceTicketId, ticketNumber, customerId, @@ -1706,8 +1791,15 @@ export const recordServiceVisitCharge = async (req: Request, res: Response, next paymentMode, accountId, remarks, + collectedByName, + collectedByRole, + chequeNumber, + chequeBankName, + chequeDate, }); - return res.status(201).json({ success: true, data: invoice }); + // paymentRequestId is what the caller stores on the ticket so it can follow the + // approval; the invoice alone no longer tells you whether the money has moved. + return res.status(201).json({ success: true, data: { ...invoice, paymentRequestId } }); } catch (error) { next(error); } diff --git a/backend/billing_service/src/controllers/saleWorkflowController.ts b/backend/billing_service/src/controllers/saleWorkflowController.ts index 6d7b1d40..f86c354e 100644 --- a/backend/billing_service/src/controllers/saleWorkflowController.ts +++ b/backend/billing_service/src/controllers/saleWorkflowController.ts @@ -1,4 +1,5 @@ import { Request, Response, NextFunction } from 'express'; +import { syncVisitChargeDecision } from '../utils/serviceTicketSync'; import { renderReceipt, PAGE as RECEIPT_PAGE, @@ -2043,6 +2044,7 @@ function receiptContextLabel(request: SalePaymentRequest): string | undefined { LEASE_ADVANCE: 'Lease — Advance', LEASE_PERIODIC: 'Lease — Periodic Collection', LEASE_SECURITY_DEPOSIT: 'Lease — Security Deposit', + SERVICE_VISIT_CHARGE: 'Service — Visit Charge', }; return map[ctx] ?? ctx.replace(/_/g, ' '); } @@ -2578,6 +2580,23 @@ export const approveSalePayment = async (req: Request, res: Response, next: Next } await queryRunner.commitTransaction(); + + // Mirror the decision onto the service ticket so the desk stops showing "awaiting + // approval". After the commit on purpose: the money is posted either way, and this + // must never be able to roll the approval back. + if (request.paymentContext === 'SERVICE_VISIT_CHARGE') { + const inv = await Source.getRepository(Invoice).findOne({ + where: { id: request.invoiceId }, + }); + if (inv?.serviceTicketId) { + await syncVisitChargeDecision({ + serviceTicketId: inv.serviceTicketId, + status: 'COLLECTED', + paymentRequestId: request.id, + }); + } + } + res.json({ success: true, data: { ...request, paymentTransactionId: savedTxn.id } }); } catch (err) { await queryRunner.rollbackTransaction(); @@ -2615,6 +2634,22 @@ export const rejectSalePayment = async (req: Request, res: Response, next: NextF request.rejectionReason = rejectionReason; await repo.save(request); + // A rejected visit charge is still owed — the ticket returns to "collect" so the desk + // or the technician can take it again, rather than the charge quietly vanishing. + if (request.paymentContext === 'SERVICE_VISIT_CHARGE') { + const inv = await Source.getRepository(Invoice).findOne({ + where: { id: request.invoiceId }, + }); + if (inv?.serviceTicketId) { + await syncVisitChargeDecision({ + serviceTicketId: inv.serviceTicketId, + status: 'REJECTED', + paymentRequestId: request.id, + rejectionReason: rejectionReason ?? null, + }); + } + } + res.json({ success: true, data: request }); } catch (err) { next(err); diff --git a/backend/billing_service/src/routes/invoiceRoutes.ts b/backend/billing_service/src/routes/invoiceRoutes.ts index 4514246b..9abebbe3 100644 --- a/backend/billing_service/src/routes/invoiceRoutes.ts +++ b/backend/billing_service/src/routes/invoiceRoutes.ts @@ -15,6 +15,8 @@ import { financeReject, financeApproveQuotation, convertToTransaction, + recordServiceCompletionPayment, + confirmServiceEstimateToAccounts, generateFinalInvoice, getAllInvoices, getInvoiceById, @@ -288,6 +290,20 @@ router.post( */ router.post('/:id/convert-to-transaction', authMiddleware, convertToTransaction); +/** Internal: payment a technician collected at job completion. */ +router.post('/:id/service-completion-payment', authMiddleware, recordServiceCompletionPayment); + +/** + * Accounts confirming a customer-accepted service estimate into the books. Finance only — + * this raises a real receivable, which is a bookkeeping act, not a service-desk one. + */ +router.post( + '/:id/confirm-service-to-accounts', + authMiddleware, + requireRole(EmployeeRole.ADMIN, EmployeeRole.FINANCE), + confirmServiceEstimateToAccounts, +); + /** * Record a deposit or initial payment from the customer. */ diff --git a/backend/billing_service/src/services/billingService.ts b/backend/billing_service/src/services/billingService.ts index c9afc525..1fe96025 100644 --- a/backend/billing_service/src/services/billingService.ts +++ b/backend/billing_service/src/services/billingService.ts @@ -44,6 +44,7 @@ import { BillType } from '../entities/enums/billType'; import { PaymentTiming } from '../entities/enums/paymentTiming'; import { getBranchCurrencyInfo, generatePaymentReference } from './billingHelpers'; import { createSalePaymentRequest } from './salePaymentRequestService'; +import { SalePaymentRequest } from '../entities/salePaymentRequestEntity'; const appendOpenEndedSlab = ( ranges: T[] | undefined, excessRate: number | undefined, @@ -1820,6 +1821,120 @@ export class BillingService { /** * Employee converts a finance-approved quotation into a transaction (Proforma). */ + /** + * Accounts confirming a service estimate the customer has accepted. + * + * Up to this point the whole service job lives on a QUOTATION, and the accounting + * queries only ever count `type = 'FINAL'` or a PROFORMA that is ACTIVE_CONTRACT / + * INVOICED / PAID. So a customer could accept a 1,290 job, the technician could do the + * work, and the 1,290 would never appear as a receivable anywhere — the books simply + * never learned about it. Converting is what makes the money real: + * + * QUOTATION / CUSTOMER_ACCEPTED → PROFORMA / INVOICED (QTN-… renumbered to INV-…) + * + * Deliberately a separate, explicit act by Accounts rather than something that fires on + * the customer's signature: raising a receivable is a bookkeeping decision, and the + * person who owns the ledger should be the one who makes it. + */ + async confirmServiceEstimateToAccounts(id: string, userId: string) { + const invoice = await this.invoiceRepo.findById(id); + if (!invoice) throw new AppError('Service estimate not found', 404); + if (invoice.billType !== BillType.SERVICE) { + throw new AppError('This endpoint only confirms service estimates', 400); + } + if (invoice.status !== InvoiceStatus.CUSTOMER_ACCEPTED) { + throw new AppError( + `Only a customer-accepted estimate can be taken into accounts (this one is ${invoice.status}).`, + 400, + ); + } + + // Reuse the existing conversion so the QTN→INV renumber and its audit entry stay in + // one place; it lands on DRAFT, which is still not a receivable, so finish the job. + const converted = await this.convertToTransaction(id, userId); + + const repo = Source.getRepository(Invoice); + const row = await repo.findOne({ where: { id: converted.id } }); + if (!row) throw new AppError('Invoice vanished during conversion', 500); + row.status = InvoiceStatus.INVOICED; + await repo.save(row); + + await logAudit( + row.id, + 'STATUS_CHANGE', + userId, + 'Service estimate confirmed into accounts — receivable raised.', + InvoiceStatus.CUSTOMER_ACCEPTED, + InvoiceStatus.INVOICED, + ); + + return this.invoiceRepo.findById(row.id); + } + + /** + * Money the technician collected when they finished the job. + * + * Completion used to collect nothing: the technician closed the ticket, the customer paid + * them on the spot, and the system had no idea — the invoice sat as an open receivable + * until somebody in Accounts noticed and keyed the payment in by hand. The cash in the + * technician's pocket was invisible until then. + * + * Two things have to happen together here. The estimate is still a QUOTATION at this + * point, and a quotation is not a receivable, so there is nothing for a payment to settle + * against — it is taken into accounts first. Then the collection is raised as a PENDING + * request like every other one: the technician took the money, Accounts decide whether it + * posts, and it lands in whichever cash/bank account they chose on the form. + */ + async recordServiceCompletionPayment(payload: { + invoiceId: string; + userId: string; + amount: number; + paymentMode: string; + accountId?: string; + chequeNumber?: string; + chequeBankName?: string; + chequeDate?: string; + remarks?: string; + branchId: string; + }) { + const amount = Number(payload.amount) || 0; + if (amount <= 0) throw new AppError('Collected amount must be greater than zero', 400); + + const repo = Source.getRepository(Invoice); + let invoice = await repo.findOne({ where: { id: payload.invoiceId } }); + if (!invoice) throw new AppError('Service invoice not found', 404); + + if (invoice.type === InvoiceType.QUOTATION) { + if (invoice.status !== InvoiceStatus.CUSTOMER_ACCEPTED) { + throw new AppError( + `The customer must accept the estimate before payment can be collected (this one is ${invoice.status}).`, + 400, + ); + } + await this.confirmServiceEstimateToAccounts(invoice.id, payload.userId); + invoice = await repo.findOne({ where: { id: payload.invoiceId } }); + if (!invoice) throw new AppError('Invoice vanished during conversion', 500); + } + + const request = await createSalePaymentRequest({ + invoiceId: invoice.id, + branchId: payload.branchId, + userId: payload.userId, + amount, + paymentMode: payload.paymentMode, + paymentDate: new Date(), + cashAccountId: payload.accountId, + chequeNumber: payload.chequeNumber, + chequeBankName: payload.chequeBankName, + chequeDate: payload.chequeDate ? new Date(payload.chequeDate) : undefined, + chequeDueDate: payload.chequeDate ? new Date(payload.chequeDate) : undefined, + remarks: payload.remarks || `Service completion payment — ${invoice.invoiceNumber}`, + paymentContext: 'SERVICE_COMPLETION', + }); + + return { invoice, paymentRequestId: request.id, requestNo: request.requestNo }; + } + async convertToTransaction(id: string, userId: string) { const invoice = await this.invoiceRepo.findById(id); if (!invoice) throw new AppError('Quotation not found', 404); @@ -4776,17 +4891,57 @@ export class BillingService { accountId?: string; /** Overrides the default "collected on-site by technician" audit text. */ remarks?: string; - }): Promise { + /** Cheque details, required by the approval queue when paymentMode is CHEQUE. */ + chequeNumber?: string; + chequeBankName?: string; + chequeDate?: string; + /** Name of the person who physically took the money, for the Accounts queue. */ + collectedByName?: string; + /** SERVICE_HELP_DESK | SERVICE_TECHNICIAN — which desk took it. */ + collectedByRole?: string; + }): Promise<{ invoice: Invoice; paymentRequestId: string }> { const amount = Number(payload.amount) || 0; if (amount <= 0) throw new AppError('Visit charge amount must be greater than zero', 400); const invoiceRepo = Source.getRepository(Invoice); const marker = `VISIT_CHARGE_ONSITE:${payload.serviceTicketId}`; + // Idempotent on the ticket: a retried call must not raise a SECOND charge, so the + // invoice is reused. The request is a different matter — a charge Accounts rejected is + // still owed, and collecting it again has to produce a new PENDING request. Returning + // the old rejected one would leave the desk showing "awaiting approval" against a + // request nobody will ever act on, and the money would never post. + const requestRepo = Source.getRepository(SalePaymentRequest); const existing = await invoiceRepo.findOne({ where: { serviceTicketId: payload.serviceTicketId, notes: marker }, }); - if (existing) return existing; + if (existing) { + const live = await requestRepo.findOne({ + where: [ + { invoiceId: existing.id, status: 'PENDING' }, + { invoiceId: existing.id, status: 'APPROVED' }, + ], + order: { createdAt: 'DESC' }, + }); + if (live) return { invoice: existing, paymentRequestId: live.id }; + + const retry = await createSalePaymentRequest({ + invoiceId: existing.id, + branchId: payload.branchId, + userId: payload.collectedBy, + amount, + paymentMode: payload.paymentMode || 'CASH', + paymentDate: new Date(), + cashAccountId: payload.accountId, + remarks: payload.remarks || `Service Visit Charge — Ticket ${payload.ticketNumber ?? ''}`, + paymentContext: 'SERVICE_VISIT_CHARGE', + chequeNumber: payload.chequeNumber, + chequeBankName: payload.chequeBankName, + chequeDate: payload.chequeDate ? new Date(payload.chequeDate) : undefined, + chequeDueDate: payload.chequeDate ? new Date(payload.chequeDate) : undefined, + }); + return { invoice: existing, paymentRequestId: retry.id }; + } const invoiceNumber = await this.invoiceRepo.generateInvoiceNumber(); const label = `Service Visit Charge — Ticket ${payload.ticketNumber || payload.serviceTicketId}`; @@ -4816,20 +4971,28 @@ export class BillingService { delete (invoiceItem as { invoice?: unknown }).invoice; savedInvoice.items = [invoiceItem]; - await this.recordPayment( - savedInvoice.id, - { - paymentMode: payload.paymentMode || 'CASH', - accountId: payload.accountId, - amount, - remarks: payload.remarks || `${label} — collected on-site by technician`, - bypassStatusCheck: true, - }, - payload.collectedBy, - ); + // The money is NOT posted here. It goes to the Accounts queue as a pending request, + // exactly like a sale collection, and only reaches the cashbook when Accounts approve + // it. Before this, whoever clicked "Collect" moved real cash on their own authority — + // the one control the rest of this system applies to every other collection was the + // only one missing from the visit charge. + const request = await createSalePaymentRequest({ + invoiceId: savedInvoice.id, + branchId: payload.branchId, + userId: payload.collectedBy, + amount, + paymentMode: payload.paymentMode || 'CASH', + paymentDate: new Date(), + cashAccountId: payload.accountId, + remarks: payload.remarks || label, + paymentContext: 'SERVICE_VISIT_CHARGE', + chequeNumber: payload.chequeNumber, + chequeBankName: payload.chequeBankName, + chequeDate: payload.chequeDate ? new Date(payload.chequeDate) : undefined, + chequeDueDate: payload.chequeDate ? new Date(payload.chequeDate) : undefined, + }); - const withPayment = await this.invoiceRepo.findById(savedInvoice.id); - return withPayment || savedInvoice; + return { invoice: savedInvoice, paymentRequestId: request.id }; } /** diff --git a/backend/billing_service/src/utils/serviceTicketSync.ts b/backend/billing_service/src/utils/serviceTicketSync.ts new file mode 100644 index 00000000..cdcb3dd4 --- /dev/null +++ b/backend/billing_service/src/utils/serviceTicketSync.ts @@ -0,0 +1,61 @@ +import { logger } from '../config/logger'; + +/** + * Tells the inventory service what Accounts decided about a service visit charge. + * + * The ticket lives in ven_inv but the approval happens here, so without this the desk + * would show "awaiting approval" forever after Accounts had already signed it off. The + * call is deliberately best-effort and never throws: the money has already been posted + * inside a committed transaction by the time we get here, and failing the approval + * response because a status mirror could not be delivered would leave Accounts believing + * their approval did not happen while the cash had in fact moved. + * + * The ticket's own status is a mirror, not the source of truth — the SalePaymentRequest + * is. A reconcile on read (see the service page's ticket loader) closes any gap left by a + * delivery that failed here. + */ +export async function syncVisitChargeDecision(params: { + serviceTicketId: string; + status: 'COLLECTED' | 'REJECTED'; + paymentRequestId: string; + rejectionReason?: string | null; +}): Promise { + try { + const { sign } = await import('jsonwebtoken'); + const token = sign( + { userId: 'billing_service', role: 'ADMIN' }, + process.env.ACCESS_SECRET as string, + { expiresIn: '1m' }, + ); + const base = process.env.INVENTORY_SERVICE_URL || 'http://localhost:3003'; + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), 6000); + const res = await fetch( + `${base}/service/tickets/${params.serviceTicketId}/visit-charge-decision`, + { + method: 'PATCH', + signal: controller.signal, + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + 'x-internal-service': 'billing', + }, + body: JSON.stringify({ + status: params.status, + paymentRequestId: params.paymentRequestId, + rejectionReason: params.rejectionReason ?? null, + }), + }, + ); + clearTimeout(timer); + if (!res.ok) { + logger.warn( + `Visit charge decision not mirrored to ticket ${params.serviceTicketId}: HTTP ${res.status}`, + ); + } + } catch (err) { + logger.warn( + `Visit charge decision not mirrored to ticket ${params.serviceTicketId}: ${(err as Error).message}`, + ); + } +} diff --git a/backend/ven_inv_service/src/config/db.ts b/backend/ven_inv_service/src/config/db.ts index 8d788d7c..d18a3702 100644 --- a/backend/ven_inv_service/src/config/db.ts +++ b/backend/ven_inv_service/src/config/db.ts @@ -751,12 +751,51 @@ export const connectWithRetry = async (initialDelayMs = 2000): Promise 1; + `); logger.info('Guaranteed service_estimate_revisions table exists.'); + // Repair tickets stranded by the QUOTED/FINANCE_APPROVED mismatch described in + // serviceController.financeApproved. Their estimate row already says + // FINANCE_APPROVED — only the ticket was left on QUOTED, which made the customer + // share refuse them forever. Scoped to tickets whose own estimate proves the + // approval happened, so nothing is promoted that Finance never approved. + await Source.query(` + UPDATE service_tickets t + SET status = 'FINANCE_APPROVED' + WHERE t.status = 'QUOTED' + AND EXISTS ( + SELECT 1 FROM service_estimates e + WHERE e."ticketId" = t.id AND e.status = 'FINANCE_APPROVED' + ); + `); + await Source.query(` CREATE TABLE IF NOT EXISTS service_estimate_items ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), @@ -837,6 +876,17 @@ export const connectWithRetry = async (initialDelayMs = 2000): Promise 0 && visitChargeCollected && !ticket.visitChargeCollected && - (!visitChargePaymentMode || (visitChargePaymentMode !== 'CHEQUE' && !visitChargeAccountId)) + (!visitChargePaymentMode || + (visitChargePaymentMode !== 'CHEQUE' && !visitChargeAccountId) || + (visitChargePaymentMode === 'CHEQUE' && !visitChargeChequeNumber)) ) { throw new AppError( - 'Payment mode (and account, unless paying by cheque) are required to post the visit charge.', + 'Payment mode is required to post the visit charge — plus an account, or a cheque number when paying by cheque.', 400, ); } @@ -1704,41 +1728,23 @@ Xerocare Technical Services`; visitChargeMethod === 'SEPARATE' && effectiveVisitCharge > 0 && visitChargeCollected && - !ticket.visitChargeCollected + // Covers the case the desk already took it: pending approval counts as taken, so + // the technician cannot collect the same charge a second time. + !this.isVisitChargeSettledOrPending(ticket) ) { try { - const token = sign( - { userId: 'ven_inv_service', role: 'ADMIN' }, - ACCESS_SECRET as string, - { - expiresIn: '1m', - }, - ); - await axios.post( - `${BILLING_SERVICE_URL}/invoices/service-visit-charge`, - { - serviceTicketId: ticket.id, - ticketNumber: ticket.ticketNumber, - customerId: ticket.customerId, - branchId: ticket.branchId, - amount: effectiveVisitCharge, - collectedBy: req.user?.userId || 'SYSTEM', - paymentMode: visitChargePaymentMode, - accountId: visitChargeAccountId, - }, - { headers: { Authorization: `Bearer ${token}` } }, - ); - ticket.visitChargeCollected = true; - ticket.visitChargeCollectedAt = new Date(); - await ticketRepo.save(ticket); - await this.logActivity( - ticket.id, - 'VISIT_CHARGE_COLLECTED', - `Visit charge of ${effectiveVisitCharge} collected in cash on-site and posted to accounts.`, - req.user?.userId, - ); + await this.requestVisitChargeApproval(ticket, { + paymentMode: visitChargePaymentMode, + accountId: visitChargeAccountId, + chequeNumber: visitChargeChequeNumber, + userId: req.user?.userId, + userName: await this.resolveCollectorName(req), + userRole: req.user?.employeeJob || req.user?.role, + remarks: `Service Visit Charge — Ticket ${ticket.ticketNumber} — collected on-site by technician at diagnosis`, + activityNote: `Visit charge of ${effectiveVisitCharge} collected on-site by the technician — sent to Accounts for approval.`, + }); } catch (err) { - logger.error('Failed to post on-site visit charge receipt to billing:', err); + logger.error('Failed to raise on-site visit charge approval request:', err); } } @@ -2464,6 +2470,8 @@ Xerocare Technical Services`; ticket, { collectVisitCharge, paymentMode, accountId }, req.user?.userId, + await this.resolveCollectorName(req), + req.user?.employeeJob || req.user?.role, ); } catch (err) { logger.error('Failed to collect visit charge at estimate rejection:', err); @@ -2866,6 +2874,14 @@ Xerocare Technical Services`; technicianRemarks, customerSignature, technicianSignature, + // Payment the technician took on the spot. Optional — a customer who pays later + // still closes the job, the invoice just stays outstanding for Accounts to chase. + collectedAmount, + paymentMode, + paymentAccountId, + chequeNumber, + chequeBankName, + chequeDate, } = req.body; const id = req.params.id as string; @@ -2944,6 +2960,49 @@ Xerocare Technical Services`; }); await reportRepo.save(report); + // Payment taken at the door, if any. Best-effort: the job IS finished, and failing + // the completion because the collection could not be raised would leave the + // technician unable to close a ticket for work that is demonstrably done. The + // failure is logged and the invoice simply stays outstanding. + if (paymentMode && Number(collectedAmount) > 0 && ticket.serviceQuotationId) { + try { + const payToken = sign( + { userId: 'ven_inv_service', role: 'ADMIN' }, + ACCESS_SECRET as string, + { expiresIn: '1m' }, + ); + const payRes = await axios.post( + `${BILLING_SERVICE_URL}/invoices/${ticket.serviceQuotationId}/service-completion-payment`, + { + amount: Number(collectedAmount), + paymentMode, + accountId: paymentAccountId, + chequeNumber, + chequeBankName, + chequeDate, + branchId: ticket.branchId, + collectedBy: req.user?.userId, + remarks: `Service completion payment — Ticket ${ticket.ticketNumber} — collected by technician`, + }, + { headers: { Authorization: `Bearer ${payToken}` } }, + ); + await this.logActivity( + ticket.id, + 'COMPLETION_PAYMENT_COLLECTED', + `Technician collected ${collectedAmount} by ${paymentMode} — sent to Accounts for approval (${payRes.data?.data?.requestNo ?? 'request raised'}).`, + req.user?.userId, + ); + } catch (err) { + logger.error('Failed to raise completion payment request:', err); + await this.logActivity( + ticket.id, + 'COMPLETION_PAYMENT_FAILED', + `Could not record the ${collectedAmount} collected at completion. The invoice remains outstanding — record it from Accounts.`, + req.user?.userId, + ); + } + } + // Consume Reserved Parts await this.consumeReservations(ticket.id); @@ -3975,7 +4034,21 @@ Xerocare Technical Services`; const ticket = await ticketRepo.findOne({ where: { id: String(id) } }); if (!ticket) throw new Error('Ticket not found'); - ticket.status = ServiceTicketStatus.QUOTED; + // FINANCE_APPROVED, not QUOTED. + // + // This is the cross-service half of Finance approving an estimate — billing calls it + // the moment the approval commits — and it used to leave the ticket on QUOTED while + // its sibling approveEstimateFinance (the in-service path) set FINANCE_APPROVED. The + // send-to-customer guard reads the TICKET and demands FINANCE_APPROVED, so an + // estimate approved through Accounts could never be sent: the customer share refused + // with "The estimate must be approved by Finance" about an estimate Finance had + // just approved. The estimate row said FINANCE_APPROVED; only the ticket disagreed. + // + // A re-estimate lands on the _2 state, mirroring approveRevisionFinance. + ticket.status = + ticket.status === ServiceTicketStatus.WAITING_FINANCE_APPROVAL_2 + ? ServiceTicketStatus.FINANCE_APPROVED_2 + : ServiceTicketStatus.FINANCE_APPROVED; await ticketRepo.save(ticket); const estimateRepo = Source.getRepository(ServiceEstimate); @@ -4469,7 +4542,7 @@ Xerocare Technical Services`; ticket.serviceContext === ServiceContext.CHARGEABLE && Number(ticket.visitChargeAmount) > 0 && ticket.visitChargeMethod === 'ADDED_TO_ESTIMATE' && - !ticket.visitChargeCollected + !this.isVisitChargeSettledOrPending(ticket) ); } @@ -4490,12 +4563,49 @@ Xerocare Technical Services`; ticket: ServiceTicket, body: { collectVisitCharge?: boolean; paymentMode?: string; accountId?: string }, userId?: string, + collectorName?: string, + collectorRole?: string, ): Promise { if (!body.collectVisitCharge || !this.isVisitChargeCollectionEligible(ticket)) return; + await this.requestVisitChargeApproval(ticket, { + paymentMode: body.paymentMode, + accountId: body.accountId, + userId, + userName: collectorName, + userRole: collectorRole, + remarks: `Service Visit Charge — Ticket ${ticket.ticketNumber} — collected at estimate rejection`, + activityNote: `Visit charge of ${ticket.visitChargeAmount} collected at estimate rejection — sent to Accounts for approval.`, + }); + } + + /** + * Sends a collected visit charge to Accounts for approval and marks the ticket pending. + * + * Every collection point funnels through here — the desk's up-front button, the + * technician's on-site collection at diagnosis, and collection at estimate rejection — + * so all three obey the same rule: the person takes the money, Accounts decide whether + * it posts. Previously each one called billing directly and the cash landed in the + * cashbook on the collector's own authority. + */ + private async requestVisitChargeApproval( + ticket: ServiceTicket, + opts: { + paymentMode?: string; + accountId?: string; + chequeNumber?: string; + chequeBankName?: string; + chequeDate?: string; + userId?: string; + userName?: string; + userRole?: string; + remarks?: string; + activityNote?: string; + }, + ): Promise { const token = sign({ userId: 'ven_inv_service', role: 'ADMIN' }, ACCESS_SECRET as string, { expiresIn: '1m', }); - await axios.post( + const response = await axios.post( `${BILLING_SERVICE_URL}/invoices/service-visit-charge`, { serviceTicketId: ticket.id, @@ -4503,24 +4613,133 @@ Xerocare Technical Services`; customerId: ticket.customerId, branchId: ticket.branchId, amount: Number(ticket.visitChargeAmount), - collectedBy: userId || 'SYSTEM', - paymentMode: body.paymentMode, - accountId: body.accountId, - remarks: `Service Visit Charge — Ticket ${ticket.ticketNumber} — collected at estimate rejection`, + collectedBy: opts.userId || 'SYSTEM', + collectedByName: opts.userName || null, + collectedByRole: opts.userRole || null, + paymentMode: opts.paymentMode, + accountId: opts.accountId, + chequeNumber: opts.chequeNumber, + chequeBankName: opts.chequeBankName, + chequeDate: opts.chequeDate, + remarks: opts.remarks, }, { headers: { Authorization: `Bearer ${token}` } }, ); - ticket.visitChargeCollected = true; - ticket.visitChargeCollectedAt = new Date(); + + ticket.visitChargeStatus = 'PENDING_APPROVAL'; + ticket.visitChargeRequestId = response.data?.data?.paymentRequestId ?? null; + ticket.visitChargeCollectedBy = opts.userId || null; + ticket.visitChargeCollectedByName = opts.userName || null; + ticket.visitChargeCollectedByRole = opts.userRole || null; + ticket.visitChargeRejectionReason = null; + // visitChargeCollected stays FALSE until Accounts approve — see the entity note. + if (!ticket.visitChargeMethod) ticket.visitChargeMethod = 'SEPARATE'; await Source.getRepository(ServiceTicket).save(ticket); + await this.logActivity( ticket.id, - 'VISIT_CHARGE_COLLECTED', - `Visit charge of ${ticket.visitChargeAmount} collected at estimate rejection and posted to accounts.`, - userId, + 'VISIT_CHARGE_REQUESTED', + opts.activityNote || + `Visit charge of ${ticket.visitChargeAmount} collected by ${opts.userName || 'staff'} — sent to Accounts for approval.`, + opts.userId, + ); + } + + /** + * The collector's display name for the Accounts queue and the ticket. + * + * Falls back to the email, then the role, then "Staff" — Accounts must always see a + * person against money that has been taken, and a blank name in that column is the + * thing that makes a collection impossible to chase later. + */ + private async resolveCollectorName(req: Request): Promise { + const userId = req.user?.userId; + if (userId) { + try { + const token = sign({ userId: 'ven_inv_service', role: 'ADMIN' }, ACCESS_SECRET as string, { + expiresIn: '1m', + }); + const url = `${process.env.EMPLOYEE_SERVICE_URL || 'http://localhost:3002'}/employee/${userId}`; + const res = await axios.get(url, { headers: { Authorization: `Bearer ${token}` } }); + const emp = res.data?.data ?? res.data; + // The employee record stores the name split in two snake_case columns; there is no + // single `name` field, so reading one silently yielded undefined and every + // collection was attributed to an email address instead of a person. + const full = + `${emp?.first_name || emp?.firstName || ''} ${emp?.last_name || emp?.lastName || ''}`.trim(); + if (full) return full; + if (emp?.email) return String(emp.email); + } catch { + // Name lookup is a convenience; never block a collection on it. + } + } + return req.user?.email || req.user?.employeeJob || req.user?.role || 'Staff'; + } + + /** True while the charge is taken or awaiting sign-off: no one may collect it again. */ + private isVisitChargeSettledOrPending(ticket: ServiceTicket): boolean { + return ( + ticket.visitChargeCollected || + ticket.visitChargeStatus === 'COLLECTED' || + ticket.visitChargeStatus === 'PENDING_APPROVAL' ); } + /** + * PATCH /service/tickets/:id/visit-charge-decision + * + * Called by billing when Accounts approve or reject the charge. Internal only. + */ + applyVisitChargeDecision = async (req: Request, res: Response, next: NextFunction) => { + try { + const { status, paymentRequestId, rejectionReason } = req.body || {}; + if (status !== 'COLLECTED' && status !== 'REJECTED') { + throw new AppError('status must be COLLECTED or REJECTED', 400); + } + const ticketRepo = Source.getRepository(ServiceTicket); + const ticket = await ticketRepo.findOne({ where: { id: String(req.params.id) } }); + if (!ticket) throw new AppError('Ticket not found', 404); + + // Ignore a decision for a superseded request: a charge rejected once and collected + // again has a newer request, and a late callback for the old one must not undo it. + if ( + paymentRequestId && + ticket.visitChargeRequestId && + ticket.visitChargeRequestId !== paymentRequestId + ) { + return res.status(200).json({ success: true, data: ticket, ignored: 'stale request' }); + } + + if (status === 'COLLECTED') { + ticket.visitChargeStatus = 'COLLECTED'; + ticket.visitChargeCollected = true; + ticket.visitChargeCollectedAt = new Date(); + ticket.visitChargeRejectionReason = null; + } else { + // The charge is owed again, so the ticket goes back to collectable and the + // boolean every other reader trusts stays false. + ticket.visitChargeStatus = 'REJECTED'; + ticket.visitChargeCollected = false; + ticket.visitChargeCollectedAt = null; + ticket.visitChargeRejectionReason = rejectionReason || null; + } + await ticketRepo.save(ticket); + + await this.logActivity( + ticket.id, + status === 'COLLECTED' ? 'VISIT_CHARGE_APPROVED' : 'VISIT_CHARGE_REJECTED', + status === 'COLLECTED' + ? `Accounts approved the visit charge of ${ticket.visitChargeAmount}.` + : `Accounts rejected the visit charge${rejectionReason ? `: ${rejectionReason}` : ''}. It may be collected again.`, + undefined, + ); + + res.status(200).json({ success: true, data: ticket }); + } catch (error) { + next(error); + } + }; + /** * POST /service/tickets/:id/collect-visit-charge * @@ -4543,7 +4762,7 @@ Xerocare Technical Services`; throw new AppError('Not authorized to collect payment for this ticket', 403); } - const { paymentMode, accountId } = req.body; + const { paymentMode, accountId, chequeNumber, chequeBankName, chequeDate } = req.body; const id = req.params.id as string; const ticketRepo = Source.getRepository(ServiceTicket); const ticket = await ticketRepo.findOne({ where: { id: String(id) } }); @@ -4564,8 +4783,13 @@ Xerocare Technical Services`; if (Number(ticket.visitChargeAmount) <= 0) { throw new AppError('No visit charge has been quoted on this ticket yet', 400); } - if (ticket.visitChargeCollected) { - throw new AppError('Visit charge already collected', 400); + if (this.isVisitChargeSettledOrPending(ticket)) { + throw new AppError( + ticket.visitChargeStatus === 'PENDING_APPROVAL' + ? 'This visit charge has already been collected and is awaiting Accounts approval.' + : 'Visit charge already collected', + 400, + ); } if (!paymentMode || (paymentMode !== 'CHEQUE' && !accountId)) { throw new AppError( @@ -4573,42 +4797,31 @@ Xerocare Technical Services`; 400, ); } + // Rejected up front rather than 500ing deep inside billing. The approval queue takes + // these four modes only; CREDIT_CARD is a legacy stored value, not something new + // payments may use. + const ACCEPTED_MODES = ['CASH', 'BANK_TRANSFER', 'CHEQUE']; + if (!ACCEPTED_MODES.includes(paymentMode)) { + throw new AppError( + `Unsupported payment mode "${paymentMode}". Use Cash, Bank Transfer or Cheque.`, + 400, + ); + } + if (paymentMode === 'CHEQUE' && !chequeNumber) { + throw new AppError('A cheque number is required to record a cheque payment.', 400); + } - const token = sign({ userId: 'ven_inv_service', role: 'ADMIN' }, ACCESS_SECRET as string, { - expiresIn: '1m', + await this.requestVisitChargeApproval(ticket, { + paymentMode, + accountId, + chequeNumber, + chequeBankName, + chequeDate, + userId: req.user?.userId, + userName: await this.resolveCollectorName(req), + userRole: callerJob || callerRole, + remarks: `Service Visit Charge — Ticket ${ticket.ticketNumber} — collected before assignment/diagnosis`, }); - await axios.post( - `${BILLING_SERVICE_URL}/invoices/service-visit-charge`, - { - serviceTicketId: ticket.id, - ticketNumber: ticket.ticketNumber, - customerId: ticket.customerId, - branchId: ticket.branchId, - amount: Number(ticket.visitChargeAmount), - collectedBy: req.user?.userId || 'SYSTEM', - paymentMode, - accountId, - remarks: `Service Visit Charge — Ticket ${ticket.ticketNumber} — collected before assignment/diagnosis`, - }, - { headers: { Authorization: `Bearer ${token}` } }, - ); - - ticket.visitChargeCollected = true; - ticket.visitChargeCollectedAt = new Date(); - // Mark it the same way the on-site "pay now" path does — a stand-alone - // collected charge, not deferred onto the estimate — so anything that - // keys off visitChargeMethod (finance reporting, the diagnosis-time - // "already collected" guard) treats it consistently either way. - if (!ticket.visitChargeMethod) { - ticket.visitChargeMethod = 'SEPARATE'; - } - await ticketRepo.save(ticket); - await this.logActivity( - ticket.id, - 'VISIT_CHARGE_COLLECTED', - `Visit charge of ${ticket.visitChargeAmount} collected up front and posted to accounts.`, - req.user?.userId, - ); res.status(200).json({ success: true, data: ticket }); } catch (error) { @@ -4699,6 +4912,8 @@ Xerocare Technical Services`; ticket, { collectVisitCharge, paymentMode, accountId }, req.user?.userId, + await this.resolveCollectorName(req), + req.user?.employeeJob || req.user?.role, ); } catch (err) { logger.error('Failed to collect visit charge at customer rejection:', err); @@ -5997,6 +6212,8 @@ Xerocare Technical Services`; let emailSent = false; let whatsappSent = false; + let emailError: string | null = null; + let whatsappError: string | null = null; if (emailToUse) { const subject = `Service Quotation - ${ticket.ticketNumber}`; @@ -6017,14 +6234,23 @@ ${approvalLink} Best regards, Xerocare Technical Services`; - await sendServicePdfEmail( - emailToUse, - subject, - bodyText, - pdfBuffer, - `Quotation_${ticket.ticketNumber}.pdf`, - ); - emailSent = true; + // A channel that fails must not sink the whole send. Before this, an SMTP + // problem threw past everything and the caller got a bare "Internal server + // error" — no way to tell whether the WhatsApp had gone out, whether the customer + // had been contacted at all, or what to fix. + try { + await sendServicePdfEmail( + emailToUse, + subject, + bodyText, + pdfBuffer, + `Quotation_${ticket.ticketNumber}.pdf`, + ); + emailSent = true; + } catch (err) { + emailError = describeSendFailure(err); + logger.error(`Quotation email failed for ticket ${ticket.ticketNumber}:`, err); + } } if (phoneToUse) { @@ -6046,8 +6272,25 @@ Review & approve your quotation (link valid 72 hours): ${approvalLink} For queries contact us at +974 4455 6677`; - await sendWhatsappMessage(phoneToUse, message); - whatsappSent = true; + try { + await sendWhatsappMessage(phoneToUse, message); + whatsappSent = true; + } catch (err) { + whatsappError = describeSendFailure(err); + logger.error(`Quotation WhatsApp failed for ticket ${ticket.ticketNumber}:`, err); + } + } + + // Nothing reached the customer: that is a failure, and the reason is the useful + // part of it. 502, not 500 — the request was fine, the mail/WhatsApp provider was not. + if (!emailSent && !whatsappSent) { + return res.status(502).json({ + success: false, + message: `The quotation could not be delivered. ${[emailError, whatsappError] + .filter(Boolean) + .join(' ')}`.trim(), + data: { emailSent, whatsappSent, emailError, whatsappError }, + }); } await this.logActivity( diff --git a/backend/ven_inv_service/src/entities/serviceTicketEntity.ts b/backend/ven_inv_service/src/entities/serviceTicketEntity.ts index d5f16833..fc66e8b2 100644 --- a/backend/ven_inv_service/src/entities/serviceTicketEntity.ts +++ b/backend/ven_inv_service/src/entities/serviceTicketEntity.ts @@ -225,6 +225,53 @@ export class ServiceTicket { @Column({ name: 'technician_note_to_finance', type: 'text', nullable: true, default: null }) technicianNoteToFinance!: string | null; + /** + * Where the visit charge has got to. + * + * NONE nobody has taken it yet — the collect action is offered + * PENDING_APPROVAL someone took the money; Accounts has not signed it off + * COLLECTED Accounts approved and the money is posted + * REJECTED Accounts refused it; the charge is owed again + * + * `visitChargeCollected` stays as the single boolean every existing reader already + * keys off (billing reports, the diagnosis-time "already collected" guard) and is true + * only for COLLECTED. Money that is merely awaiting approval must not read as collected + * anywhere, or the charge silently disappears from what is still owed. + */ + @Column({ name: 'visit_charge_status', type: 'varchar', length: 20, default: 'NONE' }) + visitChargeStatus!: 'NONE' | 'PENDING_APPROVAL' | 'COLLECTED' | 'REJECTED'; + + /** The SalePaymentRequest in billing that Accounts acts on. */ + @Column({ name: 'visit_charge_request_id', type: 'uuid', nullable: true, default: null }) + visitChargeRequestId!: string | null; + + // Who physically took the money, kept on the ticket so the desk can see it without a + // round trip to billing — and so a rejected charge names the person to go back to. + @Column({ name: 'visit_charge_collected_by', type: 'uuid', nullable: true, default: null }) + visitChargeCollectedBy!: string | null; + + @Column({ + name: 'visit_charge_collected_by_name', + type: 'varchar', + length: 255, + nullable: true, + default: null, + }) + visitChargeCollectedByName!: string | null; + + /** SERVICE_HELP_DESK or SERVICE_TECHNICIAN — which desk took it. */ + @Column({ + name: 'visit_charge_collected_by_role', + type: 'varchar', + length: 40, + nullable: true, + default: null, + }) + visitChargeCollectedByRole!: string | null; + + @Column({ name: 'visit_charge_rejection_reason', type: 'text', nullable: true, default: null }) + visitChargeRejectionReason!: string | null; + @Column({ name: 'visit_charge_collected', type: 'boolean', default: false }) visitChargeCollected!: boolean; diff --git a/backend/ven_inv_service/src/routes/serviceRoutes.ts b/backend/ven_inv_service/src/routes/serviceRoutes.ts index 85143e33..272a97d9 100644 --- a/backend/ven_inv_service/src/routes/serviceRoutes.ts +++ b/backend/ven_inv_service/src/routes/serviceRoutes.ts @@ -37,6 +37,8 @@ router.get('/contracts/:id/bills', controller.getContractBills); router.post('/external-machines', controller.registerExternalMachine); router.post('/tickets/:id/assign', controller.assignTechnician); router.post('/tickets/:id/collect-visit-charge', controller.collectVisitCharge); +// Internal: billing reports what Accounts decided about a collected visit charge. +router.patch('/tickets/:id/visit-charge-decision', controller.applyVisitChargeDecision); router.post( '/tickets/:id/start-diagnosis', requireServiceRole(['SERVICE_TECHNICIAN']), diff --git a/frontend/app/employee/(dashboard)/service/page.tsx b/frontend/app/employee/(dashboard)/service/page.tsx index 7c64711a..e4674291 100644 --- a/frontend/app/employee/(dashboard)/service/page.tsx +++ b/frontend/app/employee/(dashboard)/service/page.tsx @@ -1,6 +1,6 @@ 'use client'; -import React, { useCallback, useEffect, useRef, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { getUserFromToken } from '@/lib/auth'; import { getBranches, Branch } from '@/lib/branch'; import { getCustomers, Customer, createCustomer, CreateCustomerData } from '@/lib/customer'; @@ -111,6 +111,7 @@ import { Laptop, FileText, Activity, + Clock, CheckCircle2, AlertTriangle, XCircle, @@ -310,11 +311,23 @@ export default function ServiceDashboardPage() { | { kind: 'estimate'; estimateId: string; ticketNumber?: string; total?: number } | null >(null); + /** + * A charge that is taken, or taken and waiting on Accounts, is not collectable again. + * + * PENDING_APPROVAL has to count as taken everywhere: the customer has already handed + * the money over, and offering "Collect" again is how the same charge gets taken twice + * — once by the desk and once by the technician on site. + */ + const visitChargeTakenOrPending = (t: ServiceTicket) => + !!t.visitChargeCollected || + t.visitChargeStatus === 'COLLECTED' || + t.visitChargeStatus === 'PENDING_APPROVAL'; + const isVisitChargeCollectionEligible = (t: ServiceTicket) => t.serviceContext === 'CHARGEABLE' && Number(t.visitChargeAmount || 0) > 0 && t.visitChargeMethod === 'ADDED_TO_ESTIMATE' && - !t.visitChargeCollected; + !visitChargeTakenOrPending(t); // Collect-visit-charge-up-front modal — available any time before COMPLETED/ // CANCELLED, independent of diagnosis/assignment. Never gates either. @@ -322,7 +335,7 @@ export default function ServiceDashboardPage() { const canCollectVisitChargeNow = (t: ServiceTicket) => CHARGEABLE_VISIT_CONTEXTS.includes(t.serviceContext) && Number(t.visitChargeAmount || 0) > 0 && - !t.visitChargeCollected && + !visitChargeTakenOrPending(t) && !['COMPLETED', 'CANCELLED'].includes(t.status); const [collectVCModal, setCollectVCModal] = useState<{ ticketId: string; @@ -456,6 +469,47 @@ export default function ServiceDashboardPage() { } }; + const [collectVCChequeNumber, setCollectVCChequeNumber] = useState(''); + const [collectVCChequeBank, setCollectVCChequeBank] = useState(''); + const [collectVCChequeDate, setCollectVCChequeDate] = useState(''); + + /** + * The accounts that can actually receive this payment. + * + * The picker listed every cash AND bank account whatever mode was chosen, so a cash + * collection could be posted to a bank account — the money would sit in the wrong place + * on the balance sheet with nothing to flag it. The mode decides the account type, so + * the list follows it. + */ + const accountsForMode = useCallback( + (mode: string) => { + if (mode === 'CASH') return cashBankAccounts.filter((a) => a.type === 'CASH'); + if (mode === 'BANK_TRANSFER') return cashBankAccounts.filter((a) => a.type === 'BANK'); + return []; + }, + [cashBankAccounts], + ); + + const collectVCEligibleAccounts = useMemo( + () => accountsForMode(collectVCPaymentMode), + [accountsForMode, collectVCPaymentMode], + ); + + /** + * Choosing a mode picks the account when there is only one it could be. + * + * Most branches run a single cash drawer and a single bank account, so the second + * dropdown was a mandatory click with exactly one option behind it. + */ + const handleCollectVCModeChange = (mode: string) => { + setCollectVCPaymentMode(mode); + setCollectVCChequeNumber(''); + setCollectVCChequeBank(''); + setCollectVCChequeDate(''); + const eligible = accountsForMode(mode); + setCollectVCAccountId(eligible.length === 1 ? eligible[0].id : ''); + }; + const [assignForm, setAssignForm] = useState({ technicianId: '', }); @@ -471,6 +525,7 @@ export default function ServiceDashboardPage() { visitChargeCollected: boolean; visitChargePaymentMode: string; visitChargeAccountId: string; + visitChargeChequeNumber?: string; transportChargeAmount: number; discountAmount: number; technicianNoteToFinance: string; @@ -497,6 +552,7 @@ export default function ServiceDashboardPage() { visitChargeCollected: true, visitChargePaymentMode: '', visitChargeAccountId: '', + visitChargeChequeNumber: '', transportChargeAmount: 0, discountAmount: 0, technicianNoteToFinance: '', @@ -513,6 +569,22 @@ export default function ServiceDashboardPage() { const [completionNotes, setCompletionNotes] = useState(''); + /** + * Payment the technician takes at the door when they close the job. + * + * Optional on purpose — a customer who pays later still closes the ticket, the invoice + * just stays outstanding for Accounts to chase. But when the money IS handed over on + * site, capturing the mode and the destination account here is the only chance to record + * it while the technician still knows; otherwise the cash is invisible until somebody in + * Accounts keys it in from memory. + */ + const [collectAmount, setCollectAmount] = useState(''); + const [collectMode, setCollectMode] = useState(''); + const [collectAccountId, setCollectAccountId] = useState(''); + const [collectChequeNo, setCollectChequeNo] = useState(''); + const [collectChequeBank, setCollectChequeBank] = useState(''); + const [collectChequeDate, setCollectChequeDate] = useState(''); + // Intel view states const [selectedIntelCustomer, setSelectedIntelCustomer] = useState(''); const [intelData, setIntelData] = useState(null); @@ -857,11 +929,18 @@ export default function ServiceDashboardPage() { } try { setCollectVCSubmitting(true); - await collectVisitCharge(collectVCModal.ticketId, collectVCPaymentMode, collectVCAccountId); - toastSuccess('Visit charge collected.'); + await collectVisitCharge(collectVCModal.ticketId, collectVCPaymentMode, collectVCAccountId, { + chequeNumber: collectVCChequeNumber.trim() || undefined, + chequeBankName: collectVCChequeBank.trim() || undefined, + chequeDate: collectVCChequeDate || undefined, + }); + toastSuccess('Visit charge sent to Accounts for approval.'); setCollectVCModal(null); setCollectVCPaymentMode(''); setCollectVCAccountId(''); + setCollectVCChequeNumber(''); + setCollectVCChequeBank(''); + setCollectVCChequeDate(''); await fetchInitialData(); } catch (error) { console.error('Failed to collect visit charge:', error); @@ -978,6 +1057,10 @@ export default function ServiceDashboardPage() { visitChargeAccountId: isSeparateVisitChargeCollection ? diagnosisForm.visitChargeAccountId : undefined, + visitChargeChequeNumber: + isSeparateVisitChargeCollection && diagnosisForm.visitChargePaymentMode === 'CHEQUE' + ? diagnosisForm.visitChargeChequeNumber + : undefined, transportChargeAmount: Number(diagnosisForm.transportChargeAmount) || 0, discountAmount: Number(diagnosisForm.discountAmount) || 0, technicianNoteToFinance: diagnosisForm.technicianNoteToFinance || null, @@ -1085,6 +1168,12 @@ export default function ServiceDashboardPage() { technicianRemarks: completeForm.technicianRemarks || undefined, customerSignature: completeForm.customerSignature || 'Customer Signed', technicianSignature: completeForm.technicianSignature || 'Technician Signed', + collectedAmount: collectMode && collectAmount ? Number(collectAmount) : undefined, + paymentMode: collectMode || undefined, + paymentAccountId: collectMode && collectMode !== 'CHEQUE' ? collectAccountId : undefined, + chequeNumber: collectMode === 'CHEQUE' ? collectChequeNo : undefined, + chequeBankName: collectMode === 'CHEQUE' ? collectChequeBank : undefined, + chequeDate: collectMode === 'CHEQUE' ? collectChequeDate : undefined, }); setShowCompleteModal(false); setCompleteForm({ @@ -1097,8 +1186,18 @@ export default function ServiceDashboardPage() { technicianSignature: 'Technician Signed', }); setCompletionNotes(''); + setCollectAmount(''); + setCollectMode(''); + setCollectAccountId(''); + setCollectChequeNo(''); + setCollectChequeBank(''); + setCollectChequeDate(''); await fetchInitialData(); - toastSuccess('Service job completed successfully!'); + toastSuccess( + collectMode && collectAmount + ? `Job completed. ${getActiveCurrency()} ${collectAmount} sent to Accounts for approval.` + : 'Service job completed successfully!', + ); } catch (error) { console.error('Failed to complete ticket:', error); toastError('Failed to complete service job.'); @@ -2027,7 +2126,13 @@ export default function ServiceDashboardPage() { )} - {(ticket.status === 'QUOTED' || ticket.status === 'CUSTOMER_APPROVED') && ( + {/* FINANCE_APPROVED is the state a Finance approval now leaves the + ticket in; QUOTED is kept so tickets approved before that fix + still offer the share. */} + {(ticket.status === 'FINANCE_APPROVED' || + ticket.status === 'FINANCE_APPROVED_2' || + ticket.status === 'QUOTED' || + ticket.status === 'CUSTOMER_APPROVED') && ( )} + {/* What happened to the charge, once someone has taken it. The + desk used to get no feedback at all after clicking Collect — + the button simply vanished, which is indistinguishable from + the action having failed. */} + {ticket.visitChargeStatus === 'PENDING_APPROVAL' && ( + + + Awaiting Accounts Approval + + )} + {(ticket.visitChargeStatus === 'COLLECTED' || + (!ticket.visitChargeStatus && ticket.visitChargeCollected)) && ( + + + Visit Charge Collected + + )} + {ticket.visitChargeStatus === 'REJECTED' && + ticket.visitChargeRejectionReason && ( + + + Accounts Rejected + + )} + {/* Recording that the customer accepted is limited to the assigned technician and branch manager/admin — they're the ones actually present with the customer, not Help Desk. */} @@ -2220,7 +2369,7 @@ export default function ServiceDashboardPage() { rootCause: '', meterReading: 0, labourCost: 0, - visitChargeAmount: ticket.visitChargeAmount || 0, + visitChargeAmount: Number(ticket.visitChargeAmount) || 0, visitChargeMethod: 'ADDED_TO_ESTIMATE', visitChargeCollected: true, visitChargePaymentMode: '', @@ -2280,7 +2429,7 @@ export default function ServiceDashboardPage() { rootCause: ticket.rootCause || '', meterReading: ticket.meterReadingAtService || 0, labourCost: laborItem ? Number(laborItem.unitPrice) : 0, - visitChargeAmount: ticket.visitChargeAmount || 0, + visitChargeAmount: Number(ticket.visitChargeAmount) || 0, visitChargeMethod: (ticket.visitChargeMethod as | 'ADDED_TO_ESTIMATE' @@ -4257,9 +4406,30 @@ export default function ServiceDashboardPage() { + {/* Already taken by the service desk (approved, or sitting with + Accounts): the technician must not be offered the same charge + again on site, or the customer pays it twice. */} {!travelCovered && diagnosisForm.visitChargeMethod === 'SEPARATE' && - (diagnosisForm.visitChargeAmount || 0) > 0 && ( + (diagnosisForm.visitChargeAmount || 0) > 0 && + visitChargeTakenOrPending(selectedTicket) && ( +
+ +

+ {selectedTicket.visitChargeStatus === 'PENDING_APPROVAL' + ? 'Visit charge already collected by the service desk — awaiting Accounts approval. Do not collect it again.' + : 'Visit charge already collected. Do not collect it again.'} + {selectedTicket.visitChargeCollectedByName + ? ` Collected by ${selectedTicket.visitChargeCollectedByName}.` + : ''} +

+
+ )} + + {!travelCovered && + diagnosisForm.visitChargeMethod === 'SEPARATE' && + (diagnosisForm.visitChargeAmount || 0) > 0 && + !visitChargeTakenOrPending(selectedTicket) && ( <>
- Cash collected on-site — post {getActiveCurrency()}{' '} + Cash collected on-site — send {getActiveCurrency()}{' '} {Number(diagnosisForm.visitChargeAmount || 0).toFixed(2)} to - accounts now + Accounts for approval
@@ -4299,15 +4469,32 @@ export default function ServiceDashboardPage() { visitChargeAccountId: '', }) } - className="w-full h-9 px-3 text-xs bg-slate-50 border border-slate-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-primary" + className="w-full h-9 px-3 text-xs bg-orange-50/60 border border-orange-200 rounded-xl text-orange-900 font-semibold focus:outline-none focus:ring-2 focus:ring-orange-400 focus:border-orange-400" > - + {diagnosisForm.visitChargePaymentMode === 'CHEQUE' && ( +
+ + + setDiagnosisForm({ + ...diagnosisForm, + visitChargeChequeNumber: e.target.value, + }) + } + placeholder="e.g. CHQ-004512" + className="w-full h-9 px-3 text-xs bg-orange-50/60 border border-orange-200 rounded-xl text-orange-900 font-semibold focus:outline-none focus:ring-2 focus:ring-orange-400" + /> +
+ )} {diagnosisForm.visitChargePaymentMode && diagnosisForm.visitChargePaymentMode !== 'CHEQUE' && (
@@ -4322,12 +4509,14 @@ export default function ServiceDashboardPage() { visitChargeAccountId: e.target.value, }) } - className="w-full h-9 px-3 text-xs bg-slate-50 border border-slate-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-primary" + className="w-full h-9 px-3 text-xs bg-orange-50/60 border border-orange-200 rounded-xl text-orange-900 font-semibold focus:outline-none focus:ring-2 focus:ring-orange-400 focus:border-orange-400" > - {cashBankAccounts.map((a) => ( + {accountsForMode( + diagnosisForm.visitChargePaymentMode, + ).map((a) => ( ))} @@ -4403,19 +4592,28 @@ export default function ServiceDashboardPage() { {getActiveCurrency()} {(() => { + // Every term is coerced before it is added. These fields are + // TYPED as number but arrive as strings: the ticket comes from + // the API, where a Postgres numeric column serialises as + // "150.00", and prefilling the form put that string straight + // into state. `+` then concatenated instead of adding, so + // labour 100 and a 150.00 visit charge displayed as 100150.00 — + // a number the technician would read out to the customer. + const num = (v: unknown) => Number(v) || 0; const partsTotal = diagnosisForm.items.reduce( (sum, item) => - sum + (item.isFree ? 0 : item.quantity * item.unitPrice), + sum + + (item.isFree ? 0 : num(item.quantity) * num(item.unitPrice)), 0, ); - const laborTotal = diagnosisForm.labourCost || 0; + const laborTotal = num(diagnosisForm.labourCost); const transportTotal = travelCovered ? 0 - : diagnosisForm.transportChargeAmount || 0; + : num(diagnosisForm.transportChargeAmount); const visitChargeToAdd = !travelCovered && diagnosisForm.visitChargeMethod === 'ADDED_TO_ESTIMATE' - ? diagnosisForm.visitChargeAmount || 0 + ? num(diagnosisForm.visitChargeAmount) : 0; const totalEstimate = Math.max( 0, @@ -4423,7 +4621,7 @@ export default function ServiceDashboardPage() { laborTotal + transportTotal + visitChargeToAdd - - (diagnosisForm.discountAmount || 0), + num(diagnosisForm.discountAmount), ); return totalEstimate.toFixed(2); })()} @@ -4631,6 +4829,109 @@ export default function ServiceDashboardPage() { className="h-9 text-xs bg-slate-50 border-slate-200 rounded-xl" />
+ + {/* Payment taken at the door. Orange, matching the other money sections on + this page, and gated behind choosing a mode so a technician who took + nothing is not asked to fill anything in. */} +
+

+ Payment collected on site (optional) +

+
+
+ + +
+ + {collectMode && ( +
+ + setCollectAmount(e.target.value)} + placeholder="0.00" + className="w-full h-9 px-3 text-xs bg-white border border-orange-200 rounded-xl text-orange-900 font-semibold focus:outline-none focus:ring-2 focus:ring-orange-400" + /> +
+ )} + + {collectMode && collectMode !== 'CHEQUE' && ( +
+ + +
+ )} +
+ + {collectMode === 'CHEQUE' && ( +
+ setCollectChequeNo(e.target.value)} + placeholder="Cheque No. *" + className="h-9 px-3 text-xs bg-white border border-orange-200 rounded-xl text-orange-900 font-semibold focus:outline-none focus:ring-2 focus:ring-orange-400" + /> + setCollectChequeBank(e.target.value)} + placeholder="Bank" + className="h-9 px-3 text-xs bg-white border border-orange-200 rounded-xl text-orange-900 font-semibold focus:outline-none focus:ring-2 focus:ring-orange-400" + /> + setCollectChequeDate(e.target.value)} + className="h-9 px-3 text-xs bg-white border border-orange-200 rounded-xl text-orange-900 font-semibold focus:outline-none focus:ring-2 focus:ring-orange-400" + /> +
+ )} + + {collectMode && ( +

+ Goes to Accounts for approval. The money posts to the account selected here + once they approve it — nothing reaches the cashbook before that. +

+ )} +
diff --git a/frontend/components/Finance/ReceiptsTab.tsx b/frontend/components/Finance/ReceiptsTab.tsx index 0842aa92..62ad27f8 100644 --- a/frontend/components/Finance/ReceiptsTab.tsx +++ b/frontend/components/Finance/ReceiptsTab.tsx @@ -35,7 +35,15 @@ import { type ActionType as ChequeActionType, } from '@/components/accounts/ChequeDetailModal'; import { useQuery } from '@tanstack/react-query'; -import { getInvoiceById, Invoice } from '@/lib/invoice'; +import { + getInvoiceById, + getPendingServiceEstimates, + getCustomerAcceptedServiceEstimates, + confirmServiceEstimateToAccounts, + financeApproveQuotation, + financeRejectInvoice, + Invoice, +} from '@/lib/invoice'; import { getApiErrorMessage } from '@/lib/apiError'; import { getActiveCurrency } from '@/lib/currency'; import { toast } from 'sonner'; @@ -63,6 +71,7 @@ import { Dialog, DialogContent, DialogTitle, + DialogHeader, DialogFooter, DialogDescription, } from '@/components/ui/dialog'; @@ -470,6 +479,103 @@ export default function ReceiptsTab({ branchIds }: { branchIds?: string } = {}) setDirectCardQuoteError(null); }; + /** + * Service estimates a technician has sent up for Finance sign-off. + * + * They are Invoices, not SalePaymentRequests, so they can never arrive in `payments` + * above — nothing has been collected yet, which is why Accounts saw "No payments in this + * category" while a 270 estimate sat waiting. They are rendered as rows of THIS table + * rather than a panel of their own: Accounts work one approval queue, and a second list + * under the first is a second place to forget to look. + * + * Only shown on PENDING, because that is the only tab whose meaning they share. + */ + const [viewEstimate, setViewEstimate] = useState(null); + const [estimateBusyId, setEstimateBusyId] = useState(null); + const [rejectEstimate, setRejectEstimate] = useState(null); + const [rejectEstimateReason, setRejectEstimateReason] = useState(''); + + const { data: pendingEstimates = [], refetch: refetchEstimates } = useQuery({ + queryKey: ['pending-service-estimates'], + queryFn: getPendingServiceEstimates, + staleTime: 30_000, + }); + + /** + * Estimates the customer has already accepted, waiting for Accounts to raise the + * receivable. A second, later queue than the one above: Finance prices the job, the + * customer accepts it, and only then is there money to put in the books. + */ + const { data: acceptedEstimates = [], refetch: refetchAccepted } = useQuery({ + queryKey: ['customer-accepted-service-estimates'], + queryFn: getCustomerAcceptedServiceEstimates, + staleTime: 30_000, + }); + + const takeIntoAccounts = async (inv: Invoice) => { + setEstimateBusyId(inv.id); + try { + const updated = await confirmServiceEstimateToAccounts(inv.id); + toast.success(`Receivable raised for ${updated.invoiceNumber ?? inv.invoiceNumber}`, { + description: 'The estimate is now an invoice and shows in Accounts Receivable.', + }); + await Promise.all([refetchAccepted(), loadData()]); + } catch (err) { + toast.error('Could not take this into accounts', { description: getApiErrorMessage(err) }); + } finally { + setEstimateBusyId(null); + } + }; + + /** + * What the estimate is worth. + * + * `totalAmount` is the figure Finance approves against, so it wins. It is only + * recomputed from the parts when it is missing or zero but the estimate plainly has + * value — a sync gap between the ticket and its invoice should not show Accounts a + * blank cheque to approve. + */ + const estimateAmount = (inv: Invoice): number => { + const stored = Number(inv.totalAmount) || 0; + if (stored > 0) return stored; + const items = (inv.items || []).reduce( + (sum, it) => sum + (Number(it.quantity) || 0) * (Number(it.unitPrice) || 0), + 0, + ); + const rebuilt = + items + (Number(inv.visitChargeAmount) || 0) - (Number(inv.discountAmount) || 0); + return rebuilt > 0 ? rebuilt : stored; + }; + + const approveEstimate = async (inv: Invoice) => { + setEstimateBusyId(inv.id); + try { + await financeApproveQuotation(inv.id); + toast.success(`Service estimate ${inv.invoiceNumber} approved`); + await refetchEstimates(); + } catch (err) { + toast.error('Failed to approve estimate', { description: getApiErrorMessage(err) }); + } finally { + setEstimateBusyId(null); + } + }; + + const submitEstimateRejection = async () => { + if (!rejectEstimate || rejectEstimateReason.trim().length < 5) return; + setEstimateBusyId(rejectEstimate.id); + try { + await financeRejectInvoice(rejectEstimate.id, rejectEstimateReason.trim()); + toast.success(`Service estimate ${rejectEstimate.invoiceNumber} rejected`); + setRejectEstimate(null); + setRejectEstimateReason(''); + await refetchEstimates(); + } catch (err) { + toast.error('Failed to reject estimate', { description: getApiErrorMessage(err) }); + } finally { + setEstimateBusyId(null); + } + }; + const loadData = useCallback(async () => { setIsLoading(true); try { @@ -830,8 +936,16 @@ export default function ReceiptsTab({ branchIds }: { branchIds?: string } = {}) const sumAmount = (list: SalePaymentRequest[]) => list.reduce((s, p) => s + Number(p.amount || 0), 0); + const showEstimateRows = tab === 'PENDING' && pendingEstimates.length > 0; + const showAcceptedRows = tab === 'PENDING' && acceptedEstimates.length > 0; + const counts = { - PENDING: filteredBase.filter((p) => p.status === 'PENDING').length, + // Estimates count toward Pending too — the headline must agree with the rows below it, + // or "0 Awaiting approval" sits above a list of things plainly awaiting approval. + PENDING: + filteredBase.filter((p) => p.status === 'PENDING').length + + pendingEstimates.length + + acceptedEstimates.length, APPROVED: filteredBase.filter((p) => p.status === 'APPROVED').length, REJECTED: filteredBase.filter((p) => p.status === 'REJECTED').length, ALL: filteredBase.length, @@ -1320,7 +1434,7 @@ export default function ReceiptsTab({ branchIds }: { branchIds?: string } = {})
- ) : filtered.length === 0 ? ( + ) : filtered.length === 0 && !showEstimateRows && !showAcceptedRows ? (

No payments in this category

@@ -1342,6 +1456,149 @@ export default function ReceiptsTab({ branchIds }: { branchIds?: string } = {}) + {showEstimateRows && + pendingEstimates.map((inv) => ( + + + {inv.invoiceNumber} + + + — + + + {inv.customerName || '—'} + + + + Service Estimate + + + + {/* An estimate has no payment mode because no money has moved. + Saying so beats a bare dash, which reads as missing data. */} + + Not Collected + + + + {formatCurrency(estimateAmount(inv), currency)} + + + {inv.createdAt + ? new Date(inv.createdAt).toLocaleDateString('en-GB') + : '—'} + + + {inv.employeeName || '—'} + + + + Awaiting Approval + + + +
+ + + +
+
+
+ ))} + {showAcceptedRows && + acceptedEstimates.map((inv) => ( + + + {inv.invoiceNumber} + + + — + + + {inv.customerName || '—'} + + + + Customer Accepted + + + + + Not Collected + + + + {formatCurrency(estimateAmount(inv), currency)} + + + {inv.createdAt + ? new Date(inv.createdAt).toLocaleDateString('en-GB') + : '—'} + + + {inv.employeeName || '—'} + + + + Raise Receivable + + + +
+ + +
+
+
+ ))} {filtered.map((pmt) => ( @@ -1510,6 +1767,186 @@ export default function ReceiptsTab({ branchIds }: { branchIds?: string } = {}) )} + {/* What Finance is actually approving: the technician's costing, broken out. + Approving a bare total means approving a number nobody has checked. */} + {viewEstimate && ( + setViewEstimate(null)}> + + + + Service Estimate {viewEstimate.invoiceNumber} + + Awaiting Approval + + + + +
+
+
+

+ Customer +

+

{viewEstimate.customerName || '—'}

+
+
+

+ Raised By +

+

{viewEstimate.employeeName || '—'}

+
+
+

+ Date +

+

+ {viewEstimate.createdAt + ? new Date(viewEstimate.createdAt).toLocaleDateString('en-GB') + : '—'} +

+
+
+

+ Service Ticket +

+

+ {viewEstimate.serviceTicketId + ? viewEstimate.serviceTicketId.slice(0, 8).toUpperCase() + : '—'} +

+
+
+ +
+
+ Costing +
+
+ {(viewEstimate.items || []).length === 0 && ( +
+ No line items recorded on this estimate. +
+ )} + {(viewEstimate.items || []).map((it, i) => ( +
+ + {it.description} + {Number(it.quantity) > 1 ? ` × ${it.quantity}` : ''} + + + {formatCurrency( + (Number(it.quantity) || 0) * (Number(it.unitPrice) || 0), + currency, + )} + +
+ ))} + {Number(viewEstimate.visitChargeAmount) > 0 && ( +
+ Visit Charge + + {formatCurrency(Number(viewEstimate.visitChargeAmount), currency)} + +
+ )} + {Number(viewEstimate.discountAmount) > 0 && ( +
+ Discount + + − {formatCurrency(Number(viewEstimate.discountAmount), currency)} + +
+ )} +
+ + Total + + + {formatCurrency(estimateAmount(viewEstimate), currency)} + +
+
+
+ + {viewEstimate.technicianNoteToFinance && ( +
+

+ Technician Note to Finance +

+

+ {viewEstimate.technicianNoteToFinance} +

+
+ )} +
+ + + + + + +
+
+ )} + + {/* Reject dialog for a service estimate — the reason goes back to the technician, + who can then revise and resubmit. */} + {rejectEstimate && ( + setRejectEstimate(null)}> + + + Reject {rejectEstimate.invoiceNumber} + +
+

+ The technician sees this reason and can revise the estimate. +

+