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..b3d05fe8 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); @@ -1611,6 +1691,7 @@ export const createServiceContractInvoice = async ( paymentDate: initialPayment.paymentDate, referenceNumber: initialPayment.referenceNumber, remarks: initialPayment.remarks, + cashAccountId: initialPayment.cashAccountId, } : undefined, }); @@ -1689,6 +1770,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 +1782,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 +1792,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..2d6efbf3 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); @@ -4679,13 +4794,33 @@ export class BillingService { const finalTotal = itemsTotal + (payload.visitChargeMethod === 'ADDED_TO_ESTIMATE' ? visitCharge : 0) - discount; + /** + * The customer's name, copied onto the invoice. + * + * Accounts Receivable reads `invoices.customer_name` — a denormalised column, not a + * join to CRM — so an invoice created without it shows as "Unknown Customer" on the + * AR page no matter how valid its customerId is. Every service estimate was landing + * there nameless. + */ + const customer = payload.customerId + ? await this.getCustomerDetails(payload.customerId).catch(() => null) + : null; + const customerName = + (customer as { name?: string } | null)?.name ?? + (customer as { customerName?: string } | null)?.customerName ?? + null; + const invoice = invoiceRepo.create({ invoiceNumber, customerId: payload.customerId || undefined, + customerName: customerName || undefined, branchId: payload.branchId, createdBy: payload.createdBy, serviceTicketId: payload.serviceTicketId, - saleType: payload.saleType as SaleType, + // A service job is a SERVICE sale, whatever the caller passed. ven_inv hard-coded + // 'PRODUCT_SALE' here, so every repair showed up in Accounts typed as a product + // sale — wrong in the AR list, and wrong in anything that segments revenue by type. + saleType: SaleType.SERVICE, status: payload.status as InvoiceStatus, billType: BillType.SERVICE, visitChargeAmount: visitCharge, @@ -4740,10 +4875,25 @@ export class BillingService { const labourAmount = labour ? Number(labour.unitPrice || 0) * (labour.quantity || 1) : 0; if (!labour || labourAmount <= 0) return invoice; + const waivedDescription = 'Labor Cost / Service Charge (waived — approved within validity)'; labour.unitPrice = 0; - labour.description = 'Labor Cost / Service Charge (waived — approved within validity)'; + labour.description = waivedDescription; await itemRepo.save(labour); + // The same row, in the invoice's own loaded graph. + // + // Invoice.items is declared `cascade: true`, and `invoice` was loaded WITH its items. + // Saving the invoice below therefore re-writes every item from the in-memory array — + // which still held the original price. The item update above was being applied and + // then immediately overwritten, leaving an invoice whose total said 150 while its + // own lines still added up to 550. Zeroing it here too means the cascade writes the + // waiver instead of undoing it. + const labourInGraph = (invoice.items || []).find((it) => it.id === labour.id); + if (labourInGraph) { + labourInGraph.unitPrice = 0; + labourInGraph.description = waivedDescription; + } + invoice.totalAmount = Math.max(0, Number(invoice.totalAmount) - labourAmount); const saved = await this.invoiceRepo.save(invoice); @@ -4776,17 +4926,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,28 +5006,38 @@ 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 }; } /** * Creates the lump-sum invoice for an AMC service contract at signing time, and — if an - * initial payment was collected on the spot — records it against that same invoice via the - * normal payment path so status (INVOICED/PARTIAL via ledger/PAID) and cashbook posting stay - * consistent with every other payment in the system. Later installments reuse this invoiceId - * through the existing /invoices/:id/payments or /payments/record endpoints. + * initial payment was collected on the spot — raises it as a PENDING SalePaymentRequest + * against that same invoice, exactly like every other collection in the system (sale, + * rent, visit charge). It does NOT touch the cashbook or InvoiceLedger here: whoever took + * the money at signing has no authority to post it themselves, so the contract stays + * INVOICED/unpaid until Finance approves the request. Later installments reuse this same + * invoiceId through the existing sale-payments (recordSalePayment) path. */ async createContractInvoice(payload: { customerId: string; @@ -4853,6 +5053,7 @@ export class BillingService { paymentDate?: string; referenceNumber?: string; remarks?: string; + cashAccountId?: string; }; }): Promise { const invoiceNumber = await this.invoiceRepo.generateInvoiceNumber(); @@ -4895,20 +5096,20 @@ export class BillingService { savedInvoice.items = [invoiceItem]; if (payload.initialPayment && payload.initialPayment.amount > 0) { - await this.recordPayment( - savedInvoice.id, - { - paymentMode: payload.initialPayment.paymentMode, - amount: payload.initialPayment.amount, - transactionDate: payload.initialPayment.paymentDate, - referenceNumber: payload.initialPayment.referenceNumber, - remarks: payload.initialPayment.remarks || 'Initial payment at contract signing', - bypassStatusCheck: true, - }, - payload.createdBy, - ); - const withPayment = await this.invoiceRepo.findById(savedInvoice.id); - if (withPayment) return withPayment; + await createSalePaymentRequest({ + invoiceId: savedInvoice.id, + branchId: payload.branchId, + userId: payload.createdBy, + amount: payload.initialPayment.amount, + paymentMode: payload.initialPayment.paymentMode, + paymentDate: payload.initialPayment.paymentDate + ? new Date(payload.initialPayment.paymentDate) + : new Date(), + referenceNumber: payload.initialPayment.referenceNumber, + remarks: payload.initialPayment.remarks || 'Initial payment at contract signing', + cashAccountId: payload.initialPayment.cashAccountId, + paymentContext: 'SERVICE_CONTRACT_SIGNING', + }); } return savedInvoice; diff --git a/backend/billing_service/src/services/paymentService.ts b/backend/billing_service/src/services/paymentService.ts index 0af69e78..1de0c2c7 100644 --- a/backend/billing_service/src/services/paymentService.ts +++ b/backend/billing_service/src/services/paymentService.ts @@ -6,6 +6,7 @@ import { AppError } from '../errors/appError'; import { BillingService } from './billingService'; import { loadExchangeRates, convertAmt } from '../utils/accountsShared'; import { r2SignedGetUrl } from '../utils/r2Url'; +import { SalePaymentRequest } from '../entities/salePaymentRequestEntity'; /** * Normalized payment record served to the frontend. Merges the current @@ -187,6 +188,16 @@ export class PaymentService { const totalAmount = Number(invoice.totalAmount || 0); const pendingBalance = Math.max(0, Math.round((totalAmount - totalPaid) * 100) / 100); + // Money collected but sitting in the Accounts Receipts queue, not yet approved — + // never counted in totalPaid/pendingBalance above (those only reflect posted + // PaymentTransactions). Surfaced separately so a contract/invoice can show + // "N payment(s) awaiting Finance approval" instead of looking simply unpaid. + const pendingRequests = await Source.getRepository(SalePaymentRequest).find({ + where: { invoiceId, status: 'PENDING' }, + }); + const pendingApprovalCount = pendingRequests.length; + const pendingApprovalAmount = pendingRequests.reduce((sum, r) => sum + Number(r.amount), 0); + return { invoiceId: invoice.id, invoiceNumber: invoice.invoiceNumber, @@ -196,6 +207,8 @@ export class PaymentService { totalAmount, totalPaid, pendingBalance, + pendingApprovalCount, + pendingApprovalAmount, payments, status: invoice.status, currencyWarnings, 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/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({ @@ -173,7 +224,7 @@ export const forgotPassword = async (req: Request, res: Response, next: NextFunc const user = await authService.findUserByEmail(email); if (!user) { return res.json({ - message: 'If account exists, magic link sent', + message: 'If account exists, an OTP has been sent to your email', success: true, }); } @@ -181,7 +232,7 @@ export const forgotPassword = async (req: Request, res: Response, next: NextFunc await otpService.sendOtp(email, OtpPurpose.FORGOT_PASSWORD); return res.json({ - message: 'If account exists, magic link sent', + message: 'If account exists, an OTP has been sent to your email', success: true, }); } catch (err: unknown) { @@ -212,6 +263,13 @@ export const resetPassword = async (req: Request, res: Response, next: NextFunct await authService.logoutOtherDevices(user.id, currentRefreshToken); } + // Verifying an OTP here confirms email access for a password reset, not + // a login — it must not leave (or count as re-establishing) a trusted + // device. Revoke every trusted device so the very next login asks for + // OTP again, regardless of any still-valid cookie from before the reset. + await trustedDeviceRepo.deleteAllForUser(user.id); + res.clearCookie(TRUSTED_DEVICE_COOKIE_NAME, clearTrustedDeviceCookieOptions); + return res.json({ message: 'Password reset successfully', success: true, @@ -352,6 +410,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..9cf3032e 100644 --- a/backend/employee_service/src/utils/mailer.ts +++ b/backend/employee_service/src/utils/mailer.ts @@ -1,13 +1,44 @@ 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 (not opted in yet), fall back to the same Gmail + * transport used in production rather than a silent no-op transport — + * this repo's Gmail creds are already live via the shell environment + * (not a tracked .env), so login/OTP/magic-link must keep working out of + * the box until someone deliberately configures Ethereal. + */ +const createTransporter = () => { + if ( + process.env.NODE_ENV !== 'production' && + 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({ + 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/backend/ven_inv_service/src/config/db.ts b/backend/ven_inv_service/src/config/db.ts index 8d788d7c..4db46386 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,12 +876,27 @@ export const connectWithRetry = async (initialDelayMs = 2000): Promise { try { @@ -596,6 +619,53 @@ export class ServiceController { ); } + /** + * Single source of truth for "what's covered on this ticket" — replaces the + * 3 previously-divergent per-site variants (a real contract's coverage always + * wins; otherwise RENT/LEASE_CPC/FSMA are fully free, WARRANTY/ + * LEASE_UNDER_WARRANTY/SMA are free except toner, everything else pays for + * everything). Used regardless of "track" — coverage and finance-routing are + * separate concerns now. + */ + private async resolveItemCoverage(ticket: { + contractReferenceId?: string | null; + serviceContext: ServiceContext; + }): Promise { + const contractCoverage = await this.getTicketContractCoverage(ticket.contractReferenceId); + if (contractCoverage) return contractCoverage; + if ( + ticket.serviceContext === ServiceContext.RENT || + ticket.serviceContext === ServiceContext.LEASE_CPC || + ticket.serviceContext === ServiceContext.FSMA + ) { + return { ...FULL_COVERAGE }; + } + if ( + ticket.serviceContext === ServiceContext.WARRANTY || + ticket.serviceContext === ServiceContext.LEASE_UNDER_WARRANTY || + ticket.serviceContext === ServiceContext.SMA + ) { + return { ...WARRANTY_COVERAGE }; + } + return { ...NO_COVERAGE }; + } + + /** + * An item is free when its category is covered by contract/warranty rules, + * OR when staff explicitly flagged it free (a one-way override — this can + * only ever add extra free items on top of coverage, never take coverage + * away from an item the rules say should be covered). + */ + private resolveItemIsFree( + coverage: ContractCoverage, + item: { partCategory?: string | null; partName?: string | null; isFree?: boolean }, + ): boolean { + return ( + coverageAllowsItem(coverage, { partCategory: item.partCategory, partName: item.partName }) || + !!item.isFree + ); + } + /** Maps sparePartId → part_category for coverage checks over item lists. */ private async getPartCategories( sparePartIds: Array, @@ -917,13 +987,42 @@ export class ServiceController { } const ticketRepo = Source.getRepository(ServiceTicket); + + // A machine can only be under one active repair at a time — block a new + // ticket until the open one is COMPLETED, CANCELLED, or rejected by + // customer/finance. Matched by productId when the machine is on file, + // else by serial number (e.g. "Other Machine" walk-ins with no product record). + const terminalStatuses = [ + ServiceTicketStatus.COMPLETED, + ServiceTicketStatus.CANCELLED, + ServiceTicketStatus.CUSTOMER_REJECTED, + ServiceTicketStatus.FINANCE_REJECTED, + ]; + if (productId || serialNumber) { + const openTicket = await ticketRepo.findOne({ + where: productId + ? { productId, status: Not(In(terminalStatuses)) } + : { serialNumber, status: Not(In(terminalStatuses)) }, + }); + if (openTicket) { + throw new AppError( + `This machine already has an open service ticket (${openTicket.ticketNumber}). Complete, cancel, or reject it before opening a new one.`, + 400, + ); + } + } + const now = new Date(); const year = now.getFullYear(); const month = String(now.getMonth() + 1).padStart(2, '0'); const count = await ticketRepo.count(); const ticketNumber = `ST-${year}${month}-${String(count + 1).padStart(4, '0')}`; - const status = track === 'A' ? ServiceTicketStatus.FREE_SERVICE : ServiceTicketStatus.OPEN; + // Every ticket starts OPEN and goes through the same diagnose → + // finance-approval → customer-approval → complete pipeline, regardless + // of whether it turns out to be free/covered — `track` only affects the + // WARRANTY_ONSITE jobType lock above, not the ticket lifecycle. + const status = ServiceTicketStatus.OPEN; const branchId = req.user.branchId || req.body.branchId; if (!branchId) throw new Error('Branch ID is required'); @@ -971,7 +1070,7 @@ export class ServiceController { await this.logActivity( ticket.id, 'CREATION', - `Ticket ${ticket.ticketNumber} created under context ${ticket.serviceContext} (${track === 'A' ? 'Free Track A' : 'Chargeable Track B'})`, + `Ticket ${ticket.ticketNumber} created under context ${ticket.serviceContext}${track === 'A' ? ' (covered/warranty-onsite)' : ''}`, req.user.userId, ); @@ -1252,6 +1351,7 @@ Xerocare Technical Services`; visitChargeCollected, visitChargePaymentMode, visitChargeAccountId, + visitChargeChequeNumber, transportChargeAmount, discountAmount, technicianNoteToFinance, @@ -1300,8 +1400,6 @@ Xerocare Technical Services`; } } - const isFreeContext = ticket.track === 'A'; - // Visit + transportation are free whenever the machine is covered by a // service contract (AMC/SMA/FSMA — all include travel), by warranty, or is // a company-owned RENT machine. Only uncovered machines pay these charges. @@ -1328,15 +1426,16 @@ Xerocare Technical Services`; // transactional, so a validation error thrown after ticket/estimate // writes have already landed would leave a half-committed state. if ( - !isFreeContext && visitChargeMethod === 'SEPARATE' && effectiveVisitCharge > 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, ); } @@ -1356,15 +1455,14 @@ Xerocare Technical Services`; ticket.meterReadingAtService = meterReading || 0; } - if (isFreeContext) { - ticket.status = ServiceTicketStatus.FREE_SERVICE; - } else { - ticket.status = ServiceTicketStatus.WAITING_FINANCE_APPROVAL; - ticket.visitChargeAmount = effectiveVisitCharge; - ticket.visitChargeMethod = visitChargeMethod || null; - ticket.transportChargeAmount = effectiveTransportCharge; - ticket.discountAmount = Number(discountAmount) || 0; - } + // Every context now goes through the same finance-approval pipeline — + // covered items still price at 0, finance just reviews/marks them FOC + // instead of the ticket silently bypassing finance entirely. + ticket.status = ServiceTicketStatus.WAITING_FINANCE_APPROVAL; + ticket.visitChargeAmount = effectiveVisitCharge; + ticket.visitChargeMethod = visitChargeMethod || null; + ticket.transportChargeAmount = effectiveTransportCharge; + ticket.discountAmount = Number(discountAmount) || 0; await ticketRepo.save(ticket); @@ -1390,16 +1488,12 @@ Xerocare Technical Services`; const itemRepo = Source.getRepository(ServiceTicketItem); const ticketItems: ServiceTicketItem[] = []; + let hasCustomItem = false; - // Under a contract, "free" is per item category (e.g. SMA covers spare - // parts but charges toner). Warranty mirrors SMA (toner chargeable); - // RENT + no-contract track A stay fully free. - const contractCoverage = - (await this.getTicketContractCoverage(ticket.contractReferenceId)) ?? - (ticket.serviceContext === ServiceContext.WARRANTY || - ticket.serviceContext === ServiceContext.LEASE_UNDER_WARRANTY - ? { ...WARRANTY_COVERAGE } - : null); + // Single source of truth for what's covered — real contract rules win, + // else RENT/LEASE_CPC/FSMA are fully free, WARRANTY/LEASE_UNDER_WARRANTY/ + // SMA are free except toner, everything else pays for everything. + const contractCoverage = await this.resolveItemCoverage(ticket); if (items && Array.isArray(items)) { const sparePartRepo = Source.getRepository(SparePart); @@ -1411,6 +1505,7 @@ Xerocare Technical Services`; let partCategory: string | null = null; let partBrand = itemData.customPartBrand || null; let mpn = itemData.mpn || null; + let unitCost = 0; if (itemData.itemSource === ServiceItemSource.SPARE_PART && itemData.sparePartId) { const part = await sparePartRepo.findOne({ @@ -1424,6 +1519,7 @@ Xerocare Technical Services`; partCategory = part.part_category || null; partBrand = part.brand || null; mpn = part.mpn || null; + unitCost = Number(part.purchase_price) || 0; // Check stock warnings if (part.quantity <= 5) { @@ -1436,13 +1532,18 @@ Xerocare Technical Services`; }); } } + } else if (itemData.itemSource === ServiceItemSource.CUSTOM) { + unitCost = Number(itemData.customPartCost) || 0; + hasCustomItem = true; } - const coveredByContract = contractCoverage - ? coverageAllowsItem(contractCoverage, { partCategory, partName }) - : true; - const isItemFree = isFreeContext ? coveredByContract : !!itemData.isFree; + const isItemFree = this.resolveItemIsFree(contractCoverage, { + partCategory, + partName, + isFree: itemData.isFree, + }); const finalPrice = isItemFree ? 0 : unitPrice; + const quantity = itemData.quantity || 1; const serviceItem = itemRepo.create({ ticketId: ticket.id, @@ -1456,85 +1557,37 @@ Xerocare Technical Services`; partBrand, mpn, partName, - quantity: itemData.quantity || 1, + quantity, unitPrice: finalPrice, - totalPrice: finalPrice * (itemData.quantity || 1), + totalPrice: finalPrice * quantity, isFree: isItemFree, + unitCost, + totalCost: unitCost * quantity, }); ticketItems.push(serviceItem); - - // Reserve Parts Immediately for Free Workflows - if ( - isFreeContext && - itemData.itemSource === ServiceItemSource.SPARE_PART && - itemData.sparePartId - ) { - await this.reserveSparePart(ticket.id, itemData.sparePartId, itemData.quantity || 1); - } } } ticket.items = ticketItems; await ticketRepo.save(ticket); - // For Track A, automatically generate/send FOC Estimate to billing service. - // Items excluded by the contract (e.g. toner under SMA) keep their price. - if (isFreeContext) { - const billingItems = ticketItems.map((item) => ({ - itemSource: item.itemSource, - productId: item.sparePartId || null, - sparePartId: item.sparePartId || null, - partName: item.partName, - sku: item.sku, - quantity: item.quantity, - unitPrice: Number(item.unitPrice) || 0, - totalPrice: Number(item.totalPrice) || 0, - isFree: item.isFree, - })); - try { - const token = sign( - { userId: 'ven_inv_service', role: 'ADMIN' }, - ACCESS_SECRET as string, - { - expiresIn: '1m', - }, - ); - - const response = await axios.post( - `${BILLING_SERVICE_URL}/invoices/service-quotation`, - { - customerId: ticket.customerId, - branchId: ticket.branchId, - createdBy: req.user?.userId || 'SYSTEM', - serviceTicketId: ticket.id, - items: billingItems, - saleType: 'SERVICE', - status: 'APPROVED', - }, - { - headers: { - Authorization: `Bearer ${token}`, - }, - }, - ); - if (response.data?.success) { - ticket.linkedInvoiceId = response.data.data.id; - ticket.estimateSentToFinance = true; - await ticketRepo.save(ticket); - } - } catch (billingErr) { - logger.error('Failed to post FOC estimate to billing service:', billingErr); - } - - await this.logActivity( - ticket.id, - 'ESTIMATE_RECORDED', - `FOC estimate automatically created and sent to billing service.`, - req.user?.userId, - ); + if (hasCustomItem) { + await this.notifyBranchManagerAndAdmins(ticket.branchId, { + title: 'Spare Part Procurement Needed', + message: `Ticket ${ticket.ticketNumber} used a custom (off-catalog) part — check if it needs to be procured via RFQ.`, + type: 'ACTION_REQUIRED', + referenceId: ticket.id, + referenceType: 'CUSTOM_PART_REQUEST', + }); } - // If Track B workflow with parts or labor, automatically generate a WAITING_FINANCE_APPROVAL Estimate + // Parts are reserved once the customer (or finance, for FOC-only + // estimates) actually approves the estimate — not here — so a rejected + // estimate never leaves stock wrongly held. + + // Automatically generate a WAITING_FINANCE_APPROVAL Estimate whenever + // there's anything to estimate. Covered items are already priced at 0 + // above — finance reviews and approves the FOC estimate like any other. const inputLabour = Number(labourCost) || 0; const finalLabourCost = ticket.serviceContext === ServiceContext.AMC || @@ -1548,30 +1601,25 @@ Xerocare Technical Services`; : inputLabour; if ( - !isFreeContext && - ((ticketItems && ticketItems.length > 0) || - finalLabourCost > 0 || - effectiveVisitCharge > 0 || - effectiveTransportCharge > 0) + (ticketItems && ticketItems.length > 0) || + finalLabourCost > 0 || + effectiveVisitCharge > 0 || + effectiveTransportCharge > 0 ) { - // Discount applies to the whole estimate (parts + labour + transport + - // visit charge when added to the estimate) — cap it there. - const billablePartsTotal = ticketItems.reduce( - (sum, it) => sum + (Number(it.totalPrice) || 0), - 0, - ); - const estimateGrandTotal = - billablePartsTotal + - finalLabourCost + - effectiveTransportCharge + - (visitChargeMethod === 'ADDED_TO_ESTIMATE' ? effectiveVisitCharge : 0); - if (Number(discountAmount || 0) > estimateGrandTotal) { + // Discount is a discount on the labour/service charge only — it + // cannot exceed the labour cost, regardless of parts/transport/visit charge. + if (Number(discountAmount || 0) > finalLabourCost) { throw new AppError( - `Discount of QAR ${discountAmount} exceeds the total estimate amount of QAR ${estimateGrandTotal}.`, + `Discount of QAR ${discountAmount} exceeds the labour cost of QAR ${finalLabourCost}.`, 400, ); } + const billablePartsTotal = ticketItems.reduce( + (sum, it) => sum + (Number(it.totalPrice) || 0), + 0, + ); + const estimateRepo = Source.getRepository(ServiceEstimate); const estItemRepo = Source.getRepository(ServiceEstimateItem); @@ -1608,6 +1656,8 @@ Xerocare Technical Services`; totalPrice: ticketItem.totalPrice, isFree: ticketItem.isFree, isApproved: true, + unitCost: ticketItem.unitCost, + totalCost: ticketItem.totalCost, }); estItemsToSave.push(estItem); } @@ -1700,45 +1750,26 @@ Xerocare Technical Services`; // billing immediately so it shows up in the cashbook / day book and earnings // without waiting for the estimate to be paid. if ( - !isFreeContext && 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); } } @@ -1809,6 +1840,8 @@ Xerocare Technical Services`; let calculatedTotal = Number(estimate.labourCost); const savedItems: ServiceEstimateItem[] = []; + let hasCustomItem = false; + const coverage = await this.resolveItemCoverage(ticket); if (items && Array.isArray(items)) { const sparePartRepo = Source.getRepository(SparePart); @@ -1817,6 +1850,7 @@ Xerocare Technical Services`; let sku = it.sku || ''; let basePrice = Number(it.unitPrice) || 0; let partCategory: string | null = null; + let unitCost = 0; if (it.sparePartId) { const part = await sparePartRepo.findOne({ where: { id: String(it.sparePartId) } }); @@ -1827,28 +1861,22 @@ Xerocare Technical Services`; if (basePrice === 0) { basePrice = Number(part.base_price) || 0; } + unitCost = Number(part.purchase_price) || 0; } + } else { + unitCost = Number(it.customPartCost) || 0; + hasCustomItem = true; } - let isItemFree = !!it.isFree; - if ( - ticket.serviceContext === ServiceContext.RENT || - ticket.serviceContext === ServiceContext.LEASE_CPC || - ticket.serviceContext === ServiceContext.FSMA - ) { - isItemFree = true; - } else if ( - ticket.serviceContext === ServiceContext.WARRANTY || - ticket.serviceContext === ServiceContext.LEASE_UNDER_WARRANTY - ) { - // Warranty mirrors SMA: parts free, toner chargeable - isItemFree = coverageAllowsItem(WARRANTY_COVERAGE, { partCategory, partName }); - } else if (ticket.serviceContext === ServiceContext.AMC) { - isItemFree = false; - } + const isItemFree = this.resolveItemIsFree(coverage, { + partCategory, + partName, + isFree: it.isFree, + }); const finalPrice = isItemFree ? 0 : basePrice; - const itemTotal = finalPrice * (it.quantity || 1); + const quantity = it.quantity || 1; + const itemTotal = finalPrice * quantity; calculatedTotal += itemTotal; const estItem = estItemRepo.create({ @@ -1859,16 +1887,28 @@ Xerocare Technical Services`; sparePartId: it.sparePartId || null, sku, partName, - quantity: it.quantity || 1, + quantity, unitPrice: finalPrice, totalPrice: itemTotal, isFree: isItemFree, isApproved: true, + unitCost, + totalCost: unitCost * quantity, }); savedItems.push(estItem); } } + if (hasCustomItem) { + await this.notifyBranchManagerAndAdmins(ticket.branchId, { + title: 'Spare Part Procurement Needed', + message: `Ticket ${ticket.ticketNumber} used a custom (off-catalog) part — check if it needs to be procured via RFQ.`, + type: 'ACTION_REQUIRED', + referenceId: ticket.id, + referenceType: 'CUSTOM_PART_REQUEST', + }); + } + estimate.totalCost = calculatedTotal; estimate.items = savedItems; await estimateRepo.save(estimate); @@ -2464,6 +2504,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); @@ -2543,21 +2585,15 @@ Xerocare Technical Services`; await revisionRepo.save(revision); const sparePartRepo = Source.getRepository(SparePart); - // Contract decides what stays free per item category: SMA/FSMA cover - // parts, AMC does not, and toner is only covered under FSMA. Warranty - // mirrors SMA (toner chargeable). - const revisionCoverage = - (await this.getTicketContractCoverage(ticket.contractReferenceId)) ?? - (ticket.serviceContext === ServiceContext.WARRANTY || - ticket.serviceContext === ServiceContext.LEASE_UNDER_WARRANTY - ? { ...WARRANTY_COVERAGE } - : null); + const revisionCoverage = await this.resolveItemCoverage(ticket); + let hasCustomItem = false; if (items && Array.isArray(items)) { for (const it of items) { let partName = it.partName || ''; let sku = it.sku || ''; let basePrice = Number(it.unitPrice) || 0; let partCategory: string | null = null; + let unitCost = 0; if (it.sparePartId) { const part = await sparePartRepo.findOne({ where: { id: String(it.sparePartId) } }); @@ -2568,16 +2604,21 @@ Xerocare Technical Services`; if (basePrice === 0) { basePrice = Number(part.base_price) || 0; } + unitCost = Number(part.purchase_price) || 0; } + } else { + unitCost = Number(it.customPartCost) || 0; + hasCustomItem = true; } - const isItemFree = revisionCoverage - ? coverageAllowsItem(revisionCoverage, { partCategory, partName }) - : ticket.track === 'A' - ? true - : !!it.isFree; + const isItemFree = this.resolveItemIsFree(revisionCoverage, { + partCategory, + partName, + isFree: it.isFree, + }); const finalPrice = isItemFree ? 0 : basePrice; - const itemTotal = finalPrice * (it.quantity || 1); + const quantity = it.quantity || 1; + const itemTotal = finalPrice * quantity; revisionTotal += itemTotal; const revItem = estItemRepo.create({ @@ -2588,16 +2629,28 @@ Xerocare Technical Services`; sparePartId: it.sparePartId || null, sku, partName, - quantity: it.quantity || 1, + quantity, unitPrice: finalPrice, totalPrice: itemTotal, isFree: isItemFree, isApproved: false, + unitCost, + totalCost: unitCost * quantity, }); revisionItems.push(revItem); } } + if (hasCustomItem) { + await this.notifyBranchManagerAndAdmins(ticket.branchId, { + title: 'Spare Part Procurement Needed', + message: `Ticket ${ticket.ticketNumber} used a custom (off-catalog) part — check if it needs to be procured via RFQ.`, + type: 'ACTION_REQUIRED', + referenceId: ticket.id, + referenceType: 'CUSTOM_PART_REQUEST', + }); + } + revision.totalCost = revisionTotal; revision.items = revisionItems; await revisionRepo.save(revision); @@ -2866,6 +2919,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 +3005,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); @@ -2955,89 +3059,125 @@ Xerocare Technical Services`; relations: ['items'], }); - // What the customer was actually billed for parts/consumables (used for the - // customer/UI-facing Machine History stat) — distinct from purchaseCost below, - // which is the internal cost basis used only for ServicePartUsageLog/margin tracking. - let totalPartsBilled = 0; + // The customer didn't (fully) pay on the spot — remind this branch's + // Finance team so they can chase it down, rather than it silently + // sitting outstanding until someone happens to notice. Fires once, at + // completion; staff can collect it later via collectCompletionPayment. + const collectedAtCompletion = + paymentMode && Number(collectedAmount) > 0 ? Number(collectedAmount) : 0; + const remainingDue = Math.max( + 0, + (estimate ? Number(estimate.totalCost) : 0) - collectedAtCompletion, + ); + if (remainingDue > 0 && ticket.serviceQuotationId) { + try { + const financeEmployees = await getFinanceEmployeesByBranch(ticket.branchId); + for (const financeEmployeeId of financeEmployees) { + await NotificationPublisher.publishInAppRequest({ + recipientId: financeEmployeeId, + title: 'Uncollected Service Payment', + message: `Ticket ${ticket.ticketNumber} completed with ${remainingDue.toFixed(2)} not yet collected from the customer — follow up with the technician.`, + type: 'WARNING', + referenceId: ticket.id, + referenceType: 'SERVICE', + }); + } + } catch (err) { + logger.warn('Failed to notify finance of uncollected completion balance:', err); + } + } + + // Real internal spend, regardless of what the customer was billed — this + // is what MachineServiceHistory.totalPartsSpend reflects (see below). + let totalPartsCostInternal = 0; const itemsToInspect = estimate ? estimate.items : ticket.items; const usageLogRepo = Source.getRepository(ServicePartUsageLog); for (const item of itemsToInspect) { - totalPartsBilled += - Number(item.totalPrice) || (Number(item.unitPrice) || 0) * (item.quantity || 1); - - let purchaseCost = 0; - if (item.sparePartId) { + // Prefer the cost captured at diagnosis/estimate time (covers CUSTOM + // items, which have no catalog price to re-derive here) — only fall + // back to a fresh SparePart lookup for items that predate that column. + let purchaseCost = item.unitCost != null ? Number(item.unitCost) || 0 : 0; + let partNameForLog = item.partName || ''; + let skuForLog = item.sku || ''; + let isConsumableItem = this.isConsumable(partNameForLog, skuForLog); + + if (item.unitCost == null && item.sparePartId) { const partDetails = await sparePartRepo.findOne({ where: { id: String(item.sparePartId) }, }); if (partDetails) { purchaseCost = Number(partDetails.purchase_price) || 0; - const itemCost = purchaseCost * item.quantity; - - // Yield page calculation for consumables if replaced - let yieldPages = 0; - if (this.isConsumable(partDetails.part_name, partDetails.sku)) { - const yieldRepo = Source.getRepository(ConsumableYieldHistory); - const activeYield = await yieldRepo.findOne({ - where: { - serialNumber: ticket.serialNumber, - tonerSku: item.sku || '', - replacedMeterReading: IsNull(), - }, - order: { installedDate: 'DESC' }, - }); - - if (activeYield) { - activeYield.replacedDate = new Date(); - activeYield.replacedMeterReading = meterReading || 0; - activeYield.yieldPages = Math.max( - 0, - (meterReading || 0) - activeYield.installedMeterReading, - ); - await yieldRepo.save(activeYield); - yieldPages = activeYield.yieldPages; - } + partNameForLog = partDetails.part_name; + skuForLog = partDetails.sku; + isConsumableItem = this.isConsumable(partDetails.part_name, partDetails.sku); + } + } - const newYield = yieldRepo.create({ - productId: ticket.productId || undefined, - serialNumber: ticket.serialNumber, - tonerSku: item.sku || '', - installedDate: new Date(), - installedMeterReading: meterReading || 0, - ticketId: ticket.id, - }); - await yieldRepo.save(newYield); - } + const itemCost = purchaseCost * item.quantity; + totalPartsCostInternal += itemCost; + + // Yield page calculation for consumables if replaced + let yieldPages = 0; + if (isConsumableItem) { + const yieldRepo = Source.getRepository(ConsumableYieldHistory); + const activeYield = await yieldRepo.findOne({ + where: { + serialNumber: ticket.serialNumber, + tonerSku: skuForLog || '', + replacedMeterReading: IsNull(), + }, + order: { installedDate: 'DESC' }, + }); - // Save Part Usage Log — productId is NOT NULL here (it's a per-machine - // usage record), so this is skipped entirely for a ticket with no - // matched Product (e.g. a serial number not yet in the catalog). - // Previously this passed '' as a fallback, which isn't a valid uuid - // and crashed the whole completion request with a 500 — after the - // ticket's status had already been committed as COMPLETED, silently - // skipping the rest of the function including the Manager notification. - if (ticket.productId) { - const usageLog = usageLogRepo.create({ - ticketId: ticket.id, - productId: ticket.productId, - sparePartId: item.sparePartId, - sku: item.sku || '', - partName: item.partName || '', - quantityUsed: item.quantity, - unitCost: purchaseCost, - totalCost: itemCost, - replacedAt: new Date(), - calculatedYield: yieldPages || null, - isFree: item.isFree, - isConsumable: this.isConsumable(partDetails.part_name, partDetails.sku), - meterReadingAtReplacement: meterReading || null, - linkedInvoiceId: ticket.linkedInvoiceId || null, - }); - await usageLogRepo.save(usageLog); - } + if (activeYield) { + activeYield.replacedDate = new Date(); + activeYield.replacedMeterReading = meterReading || 0; + activeYield.yieldPages = Math.max( + 0, + (meterReading || 0) - activeYield.installedMeterReading, + ); + await yieldRepo.save(activeYield); + yieldPages = activeYield.yieldPages; } + + const newYield = yieldRepo.create({ + productId: ticket.productId || undefined, + serialNumber: ticket.serialNumber, + tonerSku: skuForLog || '', + installedDate: new Date(), + installedMeterReading: meterReading || 0, + ticketId: ticket.id, + }); + await yieldRepo.save(newYield); + } + + // Save Part Usage Log — productId is NOT NULL here (it's a per-machine + // usage record), so this is skipped entirely for a ticket with no + // matched Product (e.g. a serial number not yet in the catalog). + // Previously this passed '' as a fallback, which isn't a valid uuid + // and crashed the whole completion request with a 500 — after the + // ticket's status had already been committed as COMPLETED, silently + // skipping the rest of the function including the Manager notification. + if (ticket.productId) { + const usageLog = usageLogRepo.create({ + ticketId: ticket.id, + productId: ticket.productId, + sparePartId: item.sparePartId || null, + sku: skuForLog, + partName: partNameForLog, + quantityUsed: item.quantity, + unitCost: purchaseCost, + totalCost: itemCost, + replacedAt: new Date(), + calculatedYield: yieldPages || null, + isFree: item.isFree, + isConsumable: isConsumableItem, + meterReadingAtReplacement: meterReading || null, + linkedInvoiceId: ticket.linkedInvoiceId || null, + }); + await usageLogRepo.save(usageLog); } } @@ -3051,10 +3191,14 @@ Xerocare Technical Services`; // come back first; the fallback create() path then crashed on `''` not // being a valid uuid. Skipping entirely is correct: there's no real // machine to attribute lifetime history to. - if (ticket.productId) { + // + // Keyed by serialNumber (not productId) so external machines — never + // purchased from us, no matching Product row — still get a lifetime + // spend/service-count record; productId is attached when we have it. + if (ticket.serialNumber) { const historyRepo = Source.getRepository(MachineServiceHistory); let historyRecord = await historyRepo.findOne({ - where: { productId: ticket.productId }, + where: { serialNumber: ticket.serialNumber }, }); let nextScheduledMaintenanceDate: Date | null = null; @@ -3073,21 +3217,27 @@ Xerocare Technical Services`; } historyRecord.lastServiceDate = new Date(); historyRecord.nextScheduledMaintenanceDate = nextScheduledMaintenanceDate; - historyRecord.totalPartsSpend = Number(historyRecord.totalPartsSpend) + totalPartsBilled; + historyRecord.totalPartsSpend = + Number(historyRecord.totalPartsSpend) + totalPartsCostInternal; historyRecord.totalLabourSpend = Number(historyRecord.totalLabourSpend) + labourCost; historyRecord.totalLifetimeCost = Number(historyRecord.totalPartsSpend) + Number(historyRecord.totalLabourSpend); + // Backfill productId if this machine was matched to the catalog + // after its history row was first created as an external machine. + if (!historyRecord.productId && ticket.productId) { + historyRecord.productId = ticket.productId; + } } else { historyRecord = historyRepo.create({ - productId: ticket.productId, + productId: ticket.productId || null, serialNumber: ticket.serialNumber, totalServiceVisits: 1, totalPreventativeVisits: ticket.ticketType === 'PREVENTATIVE_MAINTENANCE' ? 1 : 0, lastServiceDate: new Date(), nextScheduledMaintenanceDate, - totalPartsSpend: totalPartsBilled, + totalPartsSpend: totalPartsCostInternal, totalLabourSpend: labourCost, - totalLifetimeCost: totalPartsBilled + labourCost, + totalLifetimeCost: totalPartsCostInternal + labourCost, }); } await historyRepo.save(historyRecord); @@ -3338,6 +3488,7 @@ Xerocare Technical Services`; currentColorA4?: number; currentColorA3?: number; warrantyInfo?: unknown; + currentMeterReading?: number; }>; }> >, @@ -3362,6 +3513,7 @@ Xerocare Technical Services`; for (const inv of invoices) { for (const alloc of inv.productAllocations || []) { const product = alloc.productId ? productById.get(alloc.productId) : undefined; + alloc.currentMeterReading = product?.meter_reading ?? undefined; if (billType === 'RENT') { alloc.warrantyInfo = { isUnderWarranty: true, fullCoverage: true }; @@ -3458,31 +3610,12 @@ Xerocare Technical Services`; ); let activeContract = null; - let coverage: ContractCoverage = { ...NO_COVERAGE }; - if (details.contractReferenceId) { activeContract = await Source.getRepository(ServiceContract).findOne({ where: { id: details.contractReferenceId as string }, }); - if (activeContract) { - // Coverage rules are fixed per contract type; normalize legacy rows - coverage = normalizeCoverage( - activeContract.coverageRules || coverageForContractType(activeContract.contractType), - ); - } - } else if ( - details.serviceContext === ServiceContext.RENT || - details.serviceContext === ServiceContext.LEASE_CPC - ) { - // RENT and CPC leases are full-service — spare parts AND toner free. - coverage = { ...FULL_COVERAGE }; - } else if ( - details.serviceContext === ServiceContext.WARRANTY || - details.serviceContext === ServiceContext.LEASE_UNDER_WARRANTY - ) { - // Warranty mirrors SMA: toner chargeable - coverage = { ...WARRANTY_COVERAGE }; } + const coverage = await this.resolveItemCoverage(details); res.status(200).json({ success: true, @@ -3559,6 +3692,90 @@ Xerocare Technical Services`; } }; + /** + * GET /service/machines/:serialNumber/analytics + * + * Consolidated per-machine lifetime stats — keyed by serialNumber alone so + * it works for RENT/LEASE/SALE machines AND external machines never + * purchased from us (no matching Product row). Additive alongside + * getMachineLifetimeCost, which existing callers keep using; new frontend + * surfaces should call this one instead. + */ + getMachineAnalytics = async (req: Request, res: Response, next: NextFunction) => { + try { + const serialNumber = String(req.params.serialNumber); + const ticketRepo = Source.getRepository(ServiceTicket); + const tickets = await ticketRepo.find({ + where: { serialNumber, status: ServiceTicketStatus.COMPLETED }, + order: { completedAt: 'ASC' }, + }); + + const usageLogRepo = Source.getRepository(ServicePartUsageLog); + const estimateRepo = Source.getRepository(ServiceEstimate); + + let lifetimePartsCost = 0; + let lifetimeLabourCost = 0; + const ticketBreakdown = []; + + for (const t of tickets) { + const usageLogs = await usageLogRepo.find({ where: { ticketId: t.id } }); + const partsCostInternal = usageLogs.reduce((sum, l) => sum + (Number(l.totalCost) || 0), 0); + + const estimate = await estimateRepo.findOne({ + where: { ticketId: t.id, status: ServiceEstimateStatus.CUSTOMER_APPROVED }, + }); + const labourCost = estimate ? Number(estimate.labourCost) || 0 : 0; + + lifetimePartsCost += partsCostInternal; + lifetimeLabourCost += labourCost; + + ticketBreakdown.push({ + ticketId: t.id, + ticketNumber: t.ticketNumber, + date: t.completedAt, + serviceContext: t.serviceContext, + partsUsed: usageLogs.map((l) => ({ + partName: l.partName, + sku: l.sku, + quantity: l.quantityUsed, + unitCost: Number(l.unitCost) || 0, + totalCost: Number(l.totalCost) || 0, + isConsumable: l.isConsumable, + })), + partsCostInternal, + labourCost, + totalSpend: partsCostInternal + labourCost, + }); + } + + const yieldRepo = Source.getRepository(ConsumableYieldHistory); + const yieldHistory = await yieldRepo.find({ + where: { serialNumber }, + order: { installedDate: 'DESC' }, + }); + const replacementHistory = yieldHistory.filter((y) => y.replacedDate != null); + + res.status(200).json({ + success: true, + data: { + serialNumber, + serviceVisitCount: tickets.length, + tickets: ticketBreakdown, + toner: { + totalTonerReplacements: replacementHistory.length, + replacementHistory, + yieldHistory, + }, + lifetimePartsCost, + lifetimeLabourCost, + lifetimeSpend: lifetimePartsCost + lifetimeLabourCost, + }, + }); + } catch (error) { + next(error); + } + }; + /** * GET /service/finance/dashboard */ @@ -3837,35 +4054,15 @@ Xerocare Technical Services`; ); } - // RENT and CPC leases cover everything; warranty mirrors SMA (toner - // chargeable); contract contexts cover per item category (SMA/AMC charge - // toner, AMC charges parts too). Labour is free under all three contract types. - const baseFreeContext = - ticket.serviceContext === ServiceContext.RENT || - ticket.serviceContext === ServiceContext.LEASE_CPC; - const warrantyContext = [ - ServiceContext.WARRANTY, - ServiceContext.LEASE_UNDER_WARRANTY, - ].includes(ticket.serviceContext); - const quoteCoverage: ContractCoverage = baseFreeContext - ? { ...FULL_COVERAGE } - : warrantyContext - ? { ...WARRANTY_COVERAGE } - : ((await this.getTicketContractCoverage(ticket.contractReferenceId)) ?? { - ...NO_COVERAGE, - }); + const quoteCoverage = await this.resolveItemCoverage(ticket); const partCategories = await this.getPartCategories(ticket.items.map((it) => it.sparePartId)); - const hasContractContext = baseFreeContext || warrantyContext || !!ticket.contractReferenceId; - const items = ticket.items.map((it) => { - const itemCovered = hasContractContext - ? coverageAllowsItem(quoteCoverage, { - partCategory: it.sparePartId ? partCategories.get(it.sparePartId) : null, - partName: it.partName, - }) - : false; - const isFree = itemCovered || !!it.isFree; + const isFree = this.resolveItemIsFree(quoteCoverage, { + partCategory: it.sparePartId ? partCategories.get(it.sparePartId) : null, + partName: it.partName, + isFree: it.isFree, + }); return { description: it.partName, quantity: it.quantity, @@ -3874,7 +4071,7 @@ Xerocare Technical Services`; }; }); - const labourFree = hasContractContext && quoteCoverage.labour; + const labourFree = quoteCoverage.labour; items.push({ description: 'Labor Cost / Service Charge', quantity: 1, @@ -3882,11 +4079,10 @@ Xerocare Technical Services`; isFree: labourFree, }); - const effectiveVisitCharge = - hasContractContext && quoteCoverage.travel ? 0 : Number(visitChargeAmount) || 0; - - const allFree = items.every((it) => it.isFree) && effectiveVisitCharge === 0; + const effectiveVisitCharge = quoteCoverage.travel ? 0 : Number(visitChargeAmount) || 0; + // Every ticket goes to finance for review — covered/free items already + // price at 0 above, finance just approves the FOC estimate like any other. const billingPayload = { customerId: ticket.customerId, branchId: ticket.branchId, @@ -3898,7 +4094,7 @@ Xerocare Technical Services`; discountAmount: Number(discountAmount) || 0, technicianNoteToFinance: technicianNoteToFinance || null, saleType: 'PRODUCT_SALE', - status: allFree ? 'CUSTOMER_ACCEPTED' : 'WAITING_FINANCE_APPROVAL', + status: 'WAITING_FINANCE_APPROVAL', }; const token = sign({ userId: 'ven_inv_service', role: 'ADMIN' }, ACCESS_SECRET as string, { @@ -3916,12 +4112,7 @@ Xerocare Technical Services`; ); const quotation = quoteRes.data.data; - if (allFree) { - ticket.status = ServiceTicketStatus.CUSTOMER_APPROVED; - } else { - ticket.status = ServiceTicketStatus.WAITING_FINANCE_APPROVAL; - } - + ticket.status = ServiceTicketStatus.WAITING_FINANCE_APPROVAL; ticket.serviceQuotationId = quotation.id; ticket.visitChargeAmount = effectiveVisitCharge; ticket.visitChargeMethod = visitChargeMethod || null; @@ -3975,7 +4166,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); @@ -4273,8 +4478,28 @@ Xerocare Technical Services`; ); } - if (invoiceFetched && !approvalTravelCovered) { - // Timely approval — waive the labour line on the billing estimate. + /** + * Labour is waived because the customer ALREADY PAID a visit charge. + * + * That is the deal the system itself offers them, verbatim: "Paying the visit charge + * also covers the labour cost of the repair for one month." The waiver is the other + * half of a transaction — it is not a discount handed out for approving on time. + * + * It used to fire on every chargeable approval regardless, including tickets where + * the visit charge was ADDED_TO_ESTIMATE and so had never been collected. A job + * quoted at 550 became a 150 bill the instant the customer signed: the company + * forgave 400 of labour in exchange for a visit charge it never received, and the + * figure the customer had signed for was one the system never intended to charge. + * + * Now it reciprocates only a payment that actually happened. When the visit charge is + * folded into the estimate the customer is paying it as part of this same bill, so + * there is no prepayment to return the favour for and the quote stands as quoted. + */ + const visitChargeWasPaid = + ticket.visitChargeCollected === true || ticket.visitChargeStatus === 'COLLECTED'; + + if (invoiceFetched && !approvalTravelCovered && visitChargeWasPaid) { + // Timely approval on a ticket whose visit charge is already paid — waive labour. try { await axios.post( `${BILLING_SERVICE_URL}/invoices/${ticket.serviceQuotationId}/waive-labour`, @@ -4284,7 +4509,7 @@ Xerocare Technical Services`; await this.logActivity( ticket.id, 'LABOUR_WAIVED', - 'Customer approved within estimate validity — labour cost waived (covered by the up-front visit/estimate charge).', + 'Labour waived — covered by the visit charge the customer already paid.', actorId, ); } catch (err) { @@ -4469,7 +4694,7 @@ Xerocare Technical Services`; ticket.serviceContext === ServiceContext.CHARGEABLE && Number(ticket.visitChargeAmount) > 0 && ticket.visitChargeMethod === 'ADDED_TO_ESTIMATE' && - !ticket.visitChargeCollected + !this.isVisitChargeSettledOrPending(ticket) ); } @@ -4490,12 +4715,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 +4765,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 +4914,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 +4935,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,44 +4949,123 @@ 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); + } + + 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`, + }); + + res.status(200).json({ success: true, data: ticket }); + } catch (error) { + next(error); + } + }; + + /** + * POST /service/tickets/:id/collect-completion-payment + * + * "Not collected" at completion doesn't mean never — the customer often + * pays after the fact. This lets staff record that later payment against + * an already-COMPLETED ticket, reusing the exact same billing endpoint + * on-the-spot completion collection uses: money is never marked collected + * directly here, it goes to Accounts as a pending SalePaymentRequest like + * every other collection path in this app, and only posts to the cashbook + * once approved into the account the collector chose. + */ + collectCompletionPayment = async (req: Request, res: Response, next: NextFunction) => { + try { + const callerRole = req.user?.role; + const callerJob = req.user?.employeeJob; + const mayCollect = + callerRole === 'MANAGER' || + callerRole === 'ADMIN' || + callerRole === 'FINANCE' || + callerJob === 'SERVICE_HELP_DESK' || + callerJob === 'SERVICE_TECHNICIAN'; + if (!mayCollect) { + throw new AppError('Not authorized to collect payment for this ticket', 403); + } + + const { amount, 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) } }); + if (!ticket) throw new Error('Ticket not found'); + + if (ticket.status !== ServiceTicketStatus.COMPLETED) { + throw new AppError('This ticket must be completed before collecting its balance.', 400); + } + if (!ticket.serviceQuotationId) { + throw new AppError('This ticket has no invoice to collect against.', 400); + } + if (!(Number(amount) > 0)) { + throw new AppError('Amount must be greater than 0.', 400); + } + if (!paymentMode || (paymentMode !== 'CHEQUE' && !accountId)) { + throw new AppError( + 'paymentMode (and accountId, unless paying by cheque) are required.', + 400, + ); + } + 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 axios.post( - `${BILLING_SERVICE_URL}/invoices/service-visit-charge`, + const collectorName = await this.resolveCollectorName(req); + const payRes = await axios.post( + `${BILLING_SERVICE_URL}/invoices/${ticket.serviceQuotationId}/service-completion-payment`, { - serviceTicketId: ticket.id, - ticketNumber: ticket.ticketNumber, - customerId: ticket.customerId, - branchId: ticket.branchId, - amount: Number(ticket.visitChargeAmount), - collectedBy: req.user?.userId || 'SYSTEM', + amount: Number(amount), paymentMode, accountId, - remarks: `Service Visit Charge — Ticket ${ticket.ticketNumber} — collected before assignment/diagnosis`, + chequeNumber, + chequeBankName, + chequeDate, + branchId: ticket.branchId, + collectedBy: req.user?.userId, + remarks: `Service completion payment — Ticket ${ticket.ticketNumber} — collected after completion by ${collectorName}`, }, { 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.`, + 'COMPLETION_PAYMENT_COLLECTED', + `${amount} collected by ${paymentMode} after completion — sent to Accounts for approval (${payRes.data?.data?.requestNo ?? 'request raised'}).`, req.user?.userId, ); - res.status(200).json({ success: true, data: ticket }); + res.status(200).json({ success: true, data: payRes.data?.data }); } catch (error) { next(error); } @@ -4699,6 +5154,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); @@ -4770,8 +5227,16 @@ Xerocare Technical Services`; */ registerExternalMachine = async (req: Request, res: Response, next: NextFunction) => { try { - const { customerId, brand, modelName, serialNumber, meterReading, printColour, description } = - req.body; + const { + customerId, + brand, + modelName, + serialNumber, + meterReading, + printColour, + description, + machineType, + } = req.body; if (!customerId) throw new AppError('customerId is required.', 400); if (!brand || !String(brand).trim()) throw new AppError('Brand is required.', 400); @@ -4779,9 +5244,20 @@ Xerocare Technical Services`; throw new AppError('Model name is required.', 400); if (!serialNumber || !String(serialNumber).trim()) throw new AppError('Serial number is required.', 400); - const meter = Number(meterReading); - if (meterReading === undefined || meterReading === '' || isNaN(meter) || meter < 0) { - throw new AppError('Current meter reading is required (0 or more).', 400); + + const resolvedMachineType = Object.values(MachineType).includes(machineType as MachineType) + ? (machineType as MachineType) + : MachineType.PRINTER; + const metered = isMeteredMachine(resolvedMachineType); + + // Only a metered machine (printer/copier) needs a meter reading at all — + // a computer has no page count to track. + let meter = 0; + if (metered) { + meter = Number(meterReading); + if (meterReading === undefined || meterReading === '' || isNaN(meter) || meter < 0) { + throw new AppError('Current meter reading is required (0 or more).', 400); + } } const productRepo = Source.getRepository(Product); @@ -4807,6 +5283,7 @@ Xerocare Technical Services`; ownership: OwnershipType.EXTERNAL, customer_id: customerId, meter_reading: meter, + machine_type: resolvedMachineType, // Not our stock — keep it out of available inventory product_status: ProductStatus.SOLD, tax_rate: 0, @@ -4948,6 +5425,44 @@ Xerocare Technical Services`; if (!startDate) throw new AppError('startDate is required.', 400); const fields = this.buildContractFields(req.body); + + // A computer (or any other non-metered machine) has no page count to bill by, + // so SMA/FSMA — both priced per click — make no sense for it. Only AMC's flat + // fee applies. Printers keep all three. + const machine = await Source.getRepository(Product).findOne({ + where: { id: String(productId) }, + }); + if ( + machine && + !isMeteredMachine(machine.machine_type) && + fields.contractType !== ServiceContractType.AMC + ) { + throw new AppError( + `${machine.machine_type === MachineType.COMPUTER ? 'Computers' : 'This machine type'} can only be enrolled in an AMC contract.`, + 400, + ); + } + + // A new contract's starting meter can never be entered lower than the machine's + // own last-known reading — e.g. a renewal after a prior contract must pick up + // where that one left off, not silently roll the meter backwards. + if (machine?.meter_reading != null) { + const currentMeter = Number(machine.meter_reading); + const startTotal = + fields.contractType === ServiceContractType.FSMA && + fields.fsmaBillingMode === 'INDIVIDUAL' + ? Number(fields.startMeterBW || 0) + Number(fields.startMeterColor || 0) + : fields.startMeterReading != null + ? Number(fields.startMeterReading) + : null; + if (startTotal != null && startTotal < currentMeter) { + throw new AppError( + `Starting meter (${startTotal}) cannot be less than the machine's current meter reading (${currentMeter}).`, + 400, + ); + } + } + const start = new Date(startDate); // Contracts default to a 1-year term const end = endDate @@ -5110,6 +5625,7 @@ Xerocare Technical Services`; modelName: p.model?.model_name || p.name, brand: p.brand, serialNumber: p.serial_no, + machineType: p.machine_type, } : null, usageSummary: { @@ -5227,6 +5743,7 @@ Xerocare Technical Services`; serialNumber: product.serial_no, ownership: product.ownership || null, meterReading: product.meter_reading ?? null, + machineType: product.machine_type, } : null, readings, @@ -5260,6 +5777,21 @@ Xerocare Technical Services`; // values) so a type change can never leave stale billing fields behind. const merged = { ...contract, ...req.body }; const fields = this.buildContractFields(merged); + + const machine = await Source.getRepository(Product).findOne({ + where: { id: contract.productId }, + }); + if ( + machine && + !isMeteredMachine(machine.machine_type) && + fields.contractType !== ServiceContractType.AMC + ) { + throw new AppError( + `${machine.machine_type === MachineType.COMPUTER ? 'Computers' : 'This machine type'} can only be enrolled in an AMC contract.`, + 400, + ); + } + Object.assign(contract, fields); // A contract is bound to its customer and machine for life — reject swaps. @@ -5343,6 +5875,13 @@ Xerocare Technical Services`; throw new AppError(`Cannot record readings on a ${contract.status} contract.`, 400); } + const machine = await Source.getRepository(Product).findOne({ + where: { id: contract.productId }, + }); + if (machine && !isMeteredMachine(machine.machine_type)) { + throw new AppError('This machine has no usage meter — meter readings do not apply.', 400); + } + const readingRepo = Source.getRepository(ContractMeterReading); const lastReading = await readingRepo.findOne({ where: { contractId: contract.id }, @@ -5665,12 +6204,28 @@ Xerocare Technical Services`; const serialReadings = serialTickets.map((t) => Math.max(Number(t.meterReadingAtService) || 0, Number(t.meterReadingAtCreation) || 0), ); + const serialTicketIds = serialTickets.map((t) => t.id); + const latestEstimateCostBySerialTicket = new Map(); + if (serialTicketIds.length) { + const estimates = await Source.getRepository(ServiceEstimate).find({ + where: { ticketId: In(serialTicketIds) }, + order: { version: 'DESC' }, + }); + for (const est of estimates) { + if (!latestEstimateCostBySerialTicket.has(est.ticketId)) { + latestEstimateCostBySerialTicket.set(est.ticketId, Number(est.totalCost) || 0); + } + } + } return res.status(200).json({ success: true, data: { history: null, currentMeterReading: serialReadings.length ? Math.max(...serialReadings) : null, - tickets: serialTickets, + tickets: serialTickets.map((t) => ({ + ...t, + estimateTotalCost: latestEstimateCostBySerialTicket.get(t.id) ?? null, + })), partLogs: [], yields: [], }, @@ -5694,6 +6249,28 @@ Xerocare Technical Services`; order: { replacedAt: 'DESC' }, }); + // Per-visit cost shown in the history list should reflect the latest + // estimate's total (parts + labour + visit/transport - discount), not + // just billed ticket items — a ticket can carry its whole cost in the + // estimate (labour/visit charge) with zero items. + const ticketIds = tickets.map((t) => t.id); + const latestEstimateCostByTicket = new Map(); + if (ticketIds.length) { + const estimates = await Source.getRepository(ServiceEstimate).find({ + where: { ticketId: In(ticketIds) }, + order: { version: 'DESC' }, + }); + for (const est of estimates) { + if (!latestEstimateCostByTicket.has(est.ticketId)) { + latestEstimateCostByTicket.set(est.ticketId, Number(est.totalCost) || 0); + } + } + } + const ticketsWithEstimateCost = tickets.map((t) => ({ + ...t, + estimateTotalCost: latestEstimateCostByTicket.get(t.id) ?? null, + })); + const yieldRepo = Source.getRepository(ConsumableYieldHistory); const yields = await yieldRepo.find({ where: { productId: productId }, @@ -5726,7 +6303,7 @@ Xerocare Technical Services`; data: { history: history || null, currentMeterReading: currentMeterReading || null, - tickets, + tickets: ticketsWithEstimateCost, partLogs, yields, }, @@ -5997,6 +6574,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 +6596,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 +6634,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( @@ -6258,24 +6863,8 @@ For queries contact us at +974 4455 6677`; }); await revisionRepo.save(newRevision); - // Coverage is per item category under contracts (SMA/AMC charge toner, - // AMC charges parts). Warranty mirrors SMA (toner chargeable); RENT and - // CPC leases stay fully covered. Computed BEFORE any mutation so validation can reject early. - const baseFreeContext = - ticket.serviceContext === ServiceContext.RENT || - ticket.serviceContext === ServiceContext.LEASE_CPC; - const warrantyContext = [ - ServiceContext.WARRANTY, - ServiceContext.LEASE_UNDER_WARRANTY, - ].includes(ticket.serviceContext); - const reviseCoverage: ContractCoverage = baseFreeContext - ? { ...FULL_COVERAGE } - : warrantyContext - ? { ...WARRANTY_COVERAGE } - : ((await this.getTicketContractCoverage(ticket.contractReferenceId)) ?? { - ...NO_COVERAGE, - }); - const hasContractContext = baseFreeContext || warrantyContext || !!ticket.contractReferenceId; + // Computed BEFORE any mutation so validation can reject early. + const reviseCoverage = await this.resolveItemCoverage(ticket); const revisePartCategories = await this.getPartCategories( items.map((it: ReviseEstimateItem) => it.sparePartId), ); @@ -6293,13 +6882,11 @@ For queries contact us at +974 4455 6677`; } const billingItems = items.map((it: ReviseEstimateItem) => { - const covered = hasContractContext - ? coverageAllowsItem(reviseCoverage, { - partCategory: it.sparePartId ? revisePartCategories.get(it.sparePartId) : null, - partName: it.partName || it.description, - }) - : false; - const isFree = covered || !!it.isFree; + const isFree = this.resolveItemIsFree(reviseCoverage, { + partCategory: it.sparePartId ? revisePartCategories.get(it.sparePartId) : null, + partName: it.partName || it.description, + isFree: it.isFree, + }); const part = it.sparePartId ? revisePartsById.get(it.sparePartId) : undefined; const brand = part?.brand || it.customPartBrand || null; const mpn = part?.mpn || it.mpn || null; @@ -6315,8 +6902,7 @@ For queries contact us at +974 4455 6677`; }); // Travel-covered contexts never pay a visit charge, revisions included. - const reviseEffectiveVisitCharge = - hasContractContext && reviseCoverage.travel ? 0 : Number(visitChargeAmount) || 0; + const reviseEffectiveVisitCharge = reviseCoverage.travel ? 0 : Number(visitChargeAmount) || 0; // Discount is capped by the whole estimate total, not per-part limits. const reviseItemsTotal = billingItems.reduce( @@ -6337,8 +6923,15 @@ For queries contact us at +974 4455 6677`; const ticketItemRepo = Source.getRepository(ServiceTicketItem); await ticketItemRepo.delete({ ticketId: ticket.id }); + let reviseHasCustomItem = false; const newTicketItems = items.map((it: ReviseEstimateItem) => { const part = it.sparePartId ? revisePartsById.get(it.sparePartId) : undefined; + const quantity = Number(it.quantity) || 1; + let unitCost = part ? Number(part.purchase_price) || 0 : 0; + if (!part) { + unitCost = Number(it.customPartCost) || 0; + reviseHasCustomItem = true; + } return ticketItemRepo.create({ ticketId: ticket.id, itemSource: it.itemSource || ServiceItemSource.SPARE_PART, @@ -6347,14 +6940,26 @@ For queries contact us at +974 4455 6677`; partName: it.partName || it.description || 'Spare Part', partBrand: part?.brand || it.customPartBrand || null, mpn: part?.mpn || it.mpn || null, - quantity: it.quantity, + quantity, unitPrice: Number(it.unitPrice) || 0, - totalPrice: (Number(it.quantity) || 1) * (Number(it.unitPrice) || 0), + totalPrice: quantity * (Number(it.unitPrice) || 0), isFree: !!it.isFree, + unitCost, + totalCost: unitCost * quantity, }); }); await ticketItemRepo.save(newTicketItems); + if (reviseHasCustomItem) { + await this.notifyBranchManagerAndAdmins(ticket.branchId, { + title: 'Spare Part Procurement Needed', + message: `Ticket ${ticket.ticketNumber} used a custom (off-catalog) part — check if it needs to be procured via RFQ.`, + type: 'ACTION_REQUIRED', + referenceId: ticket.id, + referenceType: 'CUSTOM_PART_REQUEST', + }); + } + // Update ticket fields ticket.status = ServiceTicketStatus.WAITING_FINANCE_APPROVAL; ticket.visitChargeAmount = reviseEffectiveVisitCharge; diff --git a/backend/ven_inv_service/src/entities/machineServiceHistoryEntity.ts b/backend/ven_inv_service/src/entities/machineServiceHistoryEntity.ts index 337a04be..85aa8713 100644 --- a/backend/ven_inv_service/src/entities/machineServiceHistoryEntity.ts +++ b/backend/ven_inv_service/src/entities/machineServiceHistoryEntity.ts @@ -5,10 +5,12 @@ export class MachineServiceHistory { @PrimaryGeneratedColumn('uuid') id!: string; - @Column({ type: 'uuid', unique: true }) - productId!: string; // Links to Product entity + // Nullable: external machines (never purchased from us, no matching Product + // row) still get a history row, keyed by serialNumber alone. + @Column({ type: 'uuid', unique: true, nullable: true }) + productId!: string | null; - @Column({ type: 'varchar' }) + @Column({ type: 'varchar', unique: true }) serialNumber!: string; @Column({ type: 'int', default: 0 }) diff --git a/backend/ven_inv_service/src/entities/serviceEstimateItemEntity.ts b/backend/ven_inv_service/src/entities/serviceEstimateItemEntity.ts index f1f414f7..f82ffc4a 100644 --- a/backend/ven_inv_service/src/entities/serviceEstimateItemEntity.ts +++ b/backend/ven_inv_service/src/entities/serviceEstimateItemEntity.ts @@ -70,6 +70,15 @@ export class ServiceEstimateItem { @Column({ type: 'boolean', default: true }) isApproved!: boolean; + // Real internal cost — mirrors ServiceTicketItem.unitCost/totalCost, carried + // onto the estimate (and estimate revisions, via revisionId) so finance can + // see true spend even on a zero-price FOC line. + @Column({ type: 'decimal', precision: 12, scale: 2, nullable: true }) + unitCost?: number | null; + + @Column({ type: 'decimal', precision: 12, scale: 2, nullable: true }) + totalCost?: number | null; + @CreateDateColumn() created_at!: Date; 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/entities/serviceTicketItemEntity.ts b/backend/ven_inv_service/src/entities/serviceTicketItemEntity.ts index 0a63df92..4c53e3ce 100644 --- a/backend/ven_inv_service/src/entities/serviceTicketItemEntity.ts +++ b/backend/ven_inv_service/src/entities/serviceTicketItemEntity.ts @@ -73,6 +73,16 @@ export class ServiceTicketItem { @Column({ type: 'boolean', default: false }) isFree!: boolean; + // Real internal cost — what the business actually spent on this item, even + // when it's priced free to the customer (unitPrice/totalPrice above are 0). + // For catalog parts, sourced from SparePart.purchase_price; for CUSTOM + // (off-catalog) items, technician-entered at diagnosis/estimate time. + @Column({ type: 'decimal', precision: 12, scale: 2, nullable: true }) + unitCost?: number | null; + + @Column({ type: 'decimal', precision: 12, scale: 2, nullable: true }) + totalCost?: number | null; + @CreateDateColumn() created_at!: Date; diff --git a/backend/ven_inv_service/src/events/publisher/notificationPublisher.ts b/backend/ven_inv_service/src/events/publisher/notificationPublisher.ts index 3be58e54..ef6db903 100644 --- a/backend/ven_inv_service/src/events/publisher/notificationPublisher.ts +++ b/backend/ven_inv_service/src/events/publisher/notificationPublisher.ts @@ -25,7 +25,8 @@ export class NotificationPublisher { | 'SERVICE' | 'SERVICE_TICKET' | 'SERVICE_CONTRACT' - | 'STOCK_TRANSFER'; + | 'STOCK_TRANSFER' + | 'CUSTOM_PART_REQUEST'; }) { try { const channel = await getRabbitChannel(); diff --git a/backend/ven_inv_service/src/routes/brandRoute.ts b/backend/ven_inv_service/src/routes/brandRoute.ts index e98ff0f8..c4e4a8eb 100644 --- a/backend/ven_inv_service/src/routes/brandRoute.ts +++ b/backend/ven_inv_service/src/routes/brandRoute.ts @@ -4,14 +4,25 @@ import { BrandService } from '../services/brandService'; import { BrandRepository } from '../repositories/brandRepository'; import { Source } from '../config/db'; import { authMiddleware } from '../middlewares/authMiddleware'; -import { roleMiddleware } from '../middlewares/roleMiddleware'; +import { roleMiddleware, requireServiceRole } from '../middlewares/roleMiddleware'; const router = Router(); const brandRepo = new BrandRepository(Source); const brandService = new BrandService(brandRepo); const brandController = new BrandController(brandService); -router.post('/', authMiddleware, roleMiddleware(['ADMIN', 'MANAGER']), brandController.createBrand); +// Creating a brand is additive and low-risk (unlike editing/deleting one that +// may already be in use elsewhere), and is a routine sub-step of registering +// an external machine for a service ticket or contract — a flow already open +// to technicians/help desk with no role gate at all. Restricting just this +// one step to ADMIN/MANAGER blocked that flow outright; scoped to the jobs +// that actually hit it, rather than opening it to every employee. +router.post( + '/', + authMiddleware, + requireServiceRole(['SERVICE_TECHNICIAN', 'SERVICE_HELP_DESK']), + brandController.createBrand, +); router.get( '/', diff --git a/backend/ven_inv_service/src/routes/modelRoute.ts b/backend/ven_inv_service/src/routes/modelRoute.ts index 5c092cf4..30a63964 100644 --- a/backend/ven_inv_service/src/routes/modelRoute.ts +++ b/backend/ven_inv_service/src/routes/modelRoute.ts @@ -9,13 +9,22 @@ import { } from '../controllers/modelController'; import { authMiddleware } from '../middlewares/authMiddleware'; -import { roleMiddleware } from '../middlewares/roleMiddleware'; +import { roleMiddleware, requireServiceRole } from '../middlewares/roleMiddleware'; const modelRoute = Router(); modelRoute.get('/', authMiddleware, getallModels); modelRoute.get('/:id', authMiddleware, getModelById); -modelRoute.post('/', authMiddleware, roleMiddleware(['ADMIN', 'MANAGER']), addModel); +// Same reasoning as brandRoute.ts's createBrand: additive, low-risk, and a +// routine sub-step of registering an external machine for a service ticket +// or contract — scoped to the jobs that actually hit that flow rather than +// opened to every employee. +modelRoute.post( + '/', + authMiddleware, + requireServiceRole(['SERVICE_TECHNICIAN', 'SERVICE_HELP_DESK']), + addModel, +); modelRoute.put('/:id', authMiddleware, roleMiddleware(['ADMIN', 'MANAGER']), editModel); modelRoute.delete('/:id', authMiddleware, roleMiddleware(['ADMIN', 'MANAGER']), deleteModel); modelRoute.post( diff --git a/backend/ven_inv_service/src/routes/serviceRoutes.ts b/backend/ven_inv_service/src/routes/serviceRoutes.ts index 85143e33..a5db5ba9 100644 --- a/backend/ven_inv_service/src/routes/serviceRoutes.ts +++ b/backend/ven_inv_service/src/routes/serviceRoutes.ts @@ -37,6 +37,9 @@ 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); +router.post('/tickets/:id/collect-completion-payment', controller.collectCompletionPayment); +// 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']), @@ -159,6 +162,7 @@ router.get('/customers/:customerId/history', controller.getCustomerHistory); router.get('/machines/:serialNumber/lifetime-cost', controller.getMachineLifetimeCost); router.get('/machines/:serialNumber/context', controller.getMachineContext); router.get('/machines/:serialNumber/yield-history', controller.getMachineYieldHistory); +router.get('/machines/:serialNumber/analytics', controller.getMachineAnalytics); router.get('/finance/dashboard', controller.getFinanceDashboard); router.post('/spare-parts/:id/mark-damaged', controller.markSparePartDamaged); diff --git a/backend/ven_inv_service/src/services/preventativeMaintenanceJob.ts b/backend/ven_inv_service/src/services/preventativeMaintenanceJob.ts index 414b1999..046f08c9 100644 --- a/backend/ven_inv_service/src/services/preventativeMaintenanceJob.ts +++ b/backend/ven_inv_service/src/services/preventativeMaintenanceJob.ts @@ -132,6 +132,7 @@ export async function runPreventativeMaintenanceJob() { ServiceTicketStatus.ASSIGNED, ServiceTicketStatus.DIAGNOSED, ServiceTicketStatus.IN_PROGRESS, + ServiceTicketStatus.WAITING_FINANCE_APPROVAL, ServiceTicketStatus.FREE_SERVICE, ]), }, @@ -151,7 +152,7 @@ export async function runPreventativeMaintenanceJob() { serviceContext: ServiceContext.RENT, ticketType: 'PREVENTATIVE_MAINTENANCE', jobType: 'WARRANTY_ONSITE' as JobType, - status: ServiceTicketStatus.FREE_SERVICE, + status: ServiceTicketStatus.OPEN, track: 'A', issueDescription: 'Scheduled monthly preventative maintenance service.', branchId, diff --git a/backend/ven_inv_service/src/utils/emailService.ts b/backend/ven_inv_service/src/utils/emailService.ts index 814711cf..61b01eb2 100644 --- a/backend/ven_inv_service/src/utils/emailService.ts +++ b/backend/ven_inv_service/src/utils/emailService.ts @@ -1,4 +1,5 @@ import nodemailer from 'nodemailer'; +import { logger } from '../config/logger'; // Same transport pattern as employee_service/src/utils/mailer.ts — the one // actually wired to working credentials in every .env in this repo @@ -24,7 +25,7 @@ export async function sendServicePdfEmail( pdfBuffer: Buffer, filename: string, ): Promise { - await getTransporter().sendMail({ + const info = await getTransporter().sendMail({ from: `Xerocare Technical Services <${process.env.MAIL_USER}>`, to: toEmail, subject, @@ -37,6 +38,13 @@ export async function sendServicePdfEmail( }, ], }); + // sendMail resolving doesn't guarantee inbox delivery, but it does confirm + // Gmail's SMTP accepted the recipient — logging this (previously silent) + // is the only way to tell "we sent it, check spam" apart from "we never + // actually sent it" when a customer says they got nothing. + logger.info( + `Sent "${subject}" to ${toEmail} — accepted: ${JSON.stringify(info.accepted)}, rejected: ${JSON.stringify(info.rejected)}, messageId: ${info.messageId}`, + ); } /** Plain-text email, no attachment — e.g. the ticket-creation confirmation. */ @@ -45,10 +53,13 @@ export async function sendServiceEmail( subject: string, bodyText: string, ): Promise { - await getTransporter().sendMail({ + const info = await getTransporter().sendMail({ from: `Xerocare Technical Services <${process.env.MAIL_USER}>`, to: toEmail, subject, text: bodyText, }); + logger.info( + `Sent "${subject}" to ${toEmail} — accepted: ${JSON.stringify(info.accepted)}, rejected: ${JSON.stringify(info.rejected)}, messageId: ${info.messageId}`, + ); } diff --git a/frontend/app/admin/(dashboard)/customers/[id]/page.tsx b/frontend/app/admin/(dashboard)/customers/[id]/page.tsx index e8d2e799..24efb9dd 100644 --- a/frontend/app/admin/(dashboard)/customers/[id]/page.tsx +++ b/frontend/app/admin/(dashboard)/customers/[id]/page.tsx @@ -143,6 +143,7 @@ function Customer360Content() { createdByName={createdByName} createdByRole={createdByEmployee?.role} backHref="/admin/customers" + productBasePath="/admin" /> ); diff --git a/frontend/app/admin/(dashboard)/products/[id]/page.tsx b/frontend/app/admin/(dashboard)/products/[id]/page.tsx index a40517e4..2ea1f7b3 100644 --- a/frontend/app/admin/(dashboard)/products/[id]/page.tsx +++ b/frontend/app/admin/(dashboard)/products/[id]/page.tsx @@ -36,6 +36,7 @@ import Barcode from 'react-barcode'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { getProductHistory, ProductHistoryResponse, HistoryEvent } from '@/lib/productHistory'; import { resolveImageUrl } from '@/lib/imageUrl'; +import MachineServiceAnalyticsPanel from '@/components/products/MachineServiceAnalyticsPanel'; interface ProductFeature { subHeading: string; @@ -250,7 +251,12 @@ export default function ProductDetailPage() { - + @@ -721,9 +727,13 @@ function EventCard({ event }: { event: HistoryEvent }) { function LifecycleTab({ history, loading, + serialNumber, + currency, }: { history: ProductHistoryResponse | null; loading: boolean; + serialNumber?: string | null; + currency?: string; }) { if (loading) { return ( @@ -782,6 +792,15 @@ function LifecycleTab({ )} + {/* Real internal spend + per-ticket/toner breakdown — additive to the + summary above, works for external machines too (keyed by serial). */} +
+

+ Service & Spend Detail +

+ +
+ {/* Timeline */} {history.events.length === 0 ? (
diff --git a/frontend/app/employee/(dashboard)/service/contracts/[id]/page.tsx b/frontend/app/employee/(dashboard)/service/contracts/[id]/page.tsx index eaff4d98..a7fdbdbf 100644 --- a/frontend/app/employee/(dashboard)/service/contracts/[id]/page.tsx +++ b/frontend/app/employee/(dashboard)/service/contracts/[id]/page.tsx @@ -55,6 +55,7 @@ import { DollarSign, Send, Eye, + Clock, } from 'lucide-react'; const COVERAGE_LABELS: Array<{ key: keyof ContractCoverage; label: string }> = [ @@ -201,6 +202,7 @@ export default function ServiceContractDetailPage() { paymentDate: payForm.paymentDate, referenceNumber: payForm.referenceNumber || undefined, remarks: payForm.remarks || undefined, + paymentContext: 'SERVICE_CONTRACT_INSTALLMENT', // Card facts only — the processing fee is priced server-side from the // configured merchant agreement, never sent from here. ...(payForm.paymentMode === 'ONLINE_PAYMENT' @@ -448,6 +450,15 @@ export default function ServiceContractDetailPage() { Paid {currency} {paymentSummary.totalPaid.toFixed(2)} · Balance {currency}{' '} {paymentSummary.pendingBalance.toFixed(2)} + {paymentSummary.pendingApprovalCount > 0 && ( + + {paymentSummary.pendingApprovalCount === 1 + ? '1 payment' + : `${paymentSummary.pendingApprovalCount} payments`}{' '} + ({currency} {paymentSummary.pendingApprovalAmount.toFixed(2)}) awaiting Finance + approval + + )} )} @@ -637,67 +648,75 @@ export default function ServiceContractDetailPage() { - {/* Meter readings */} - - - - Meter Readings ( - {contract.readings.length}) - - - - {contract.readings.length === 0 ? ( -

No readings recorded yet.

- ) : ( -
- - - - Date - Meter - - Clicks - - - Charged - - Notes - - - - {contract.readings.map((r) => ( - - - {fmtDate(r.readingDate)} - - - {r.totalReading != null - ? Number(r.totalReading).toLocaleString() - : r.bwReading != null || r.colorReading != null - ? `BW ${Number(r.bwReading ?? 0).toLocaleString()} · Col ${Number(r.colorReading ?? 0).toLocaleString()}` - : '—'} - - - {Number(r.clicksTotal || 0).toLocaleString()} - - - {contract.contractType === 'AMC' ? ( - tracking only - ) : ( - `${currency} ${Number(r.amountCharged || 0).toFixed(2)}` - )} - - - {r.notes || '—'} - - - ))} - -
-
- )} -
-
+ {/* Meter readings — not applicable to a computer/non-metered machine, which has + no page count to track at all. */} + {contract.machine?.machineType !== 'COMPUTER' && + contract.machine?.machineType !== 'OTHER' && ( + + + + Meter Readings ( + {contract.readings.length}) + + + + {contract.readings.length === 0 ? ( +

No readings recorded yet.

+ ) : ( +
+ + + + Date + + Meter + + + Clicks + + + Charged + + + Notes + + + + + {contract.readings.map((r) => ( + + + {fmtDate(r.readingDate)} + + + {r.totalReading != null + ? Number(r.totalReading).toLocaleString() + : r.bwReading != null || r.colorReading != null + ? `BW ${Number(r.bwReading ?? 0).toLocaleString()} · Col ${Number(r.colorReading ?? 0).toLocaleString()}` + : '—'} + + + {Number(r.clicksTotal || 0).toLocaleString()} + + + {contract.contractType === 'AMC' ? ( + tracking only + ) : ( + `${currency} ${Number(r.amountCharged || 0).toFixed(2)}` + )} + + + {r.notes || '—'} + + + ))} + +
+
+ )} +
+
+ )} {/* Monthly bills — FSMA only. Generated automatically once a month by the billing sweep; casual readings in between accrue unbilled. */} @@ -864,14 +883,28 @@ export default function ServiceContractDetailPage() { {paymentSummary && ( -
- - Paid {currency} {paymentSummary.totalPaid.toFixed(2)} of {currency}{' '} - {paymentSummary.totalAmount.toFixed(2)} - - - Balance {currency} {paymentSummary.pendingBalance.toFixed(2)} - +
+
+ + Paid {currency} {paymentSummary.totalPaid.toFixed(2)} of {currency}{' '} + {paymentSummary.totalAmount.toFixed(2)} + + + Balance {currency} {paymentSummary.pendingBalance.toFixed(2)} + +
+ {paymentSummary.pendingApprovalCount > 0 && ( +
+ + + {paymentSummary.pendingApprovalCount === 1 + ? '1 payment' + : `${paymentSummary.pendingApprovalCount} payments`}{' '} + ({currency} {paymentSummary.pendingApprovalAmount.toFixed(2)}) awaiting Finance + approval — not yet reflected in the balance above. + +
+ )}
)} diff --git a/frontend/app/employee/(dashboard)/service/contracts/page.tsx b/frontend/app/employee/(dashboard)/service/contracts/page.tsx index b7016431..1d9f8400 100644 --- a/frontend/app/employee/(dashboard)/service/contracts/page.tsx +++ b/frontend/app/employee/(dashboard)/service/contracts/page.tsx @@ -4,8 +4,10 @@ import React, { useEffect, useState } from 'react'; import { getCustomers, createCustomer, Customer, CreateCustomerData } from '@/lib/customer'; import CustomerFormDialog from '@/components/employeeComponents/CustomerFormDialog'; import { getAllProducts, Product } from '@/lib/product'; -import { getBrands, createBrand, Brand } from '@/lib/brand'; -import { getAllModels, addModel, Model } from '@/lib/model'; +import { getBrands, Brand } from '@/lib/brand'; +import { getAllModels, Model } from '@/lib/model'; +import { AddBrandDialog } from '@/components/ManagerDashboardComponents/BrandComponents/AddBrandDialog'; +import { AddModelDialog } from '@/components/ManagerDashboardComponents/productComponents/AddModelDialog'; import { CustomerServiceHistory, WarrantyInfo } from '@/lib/serviceTicket'; import { getServiceContracts, @@ -26,7 +28,7 @@ import { getAccountSummary, PaymentSummary } from '@/lib/payment'; import { recordSalePayment } from '@/lib/saleWorkflow'; import { getInvoiceById, Invoice } from '@/lib/invoice'; import { InvoiceViewDialog } from '@/components/employeeComponents/InvoiceViewDialog'; -import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Table, TableBody, @@ -62,13 +64,14 @@ import { Check, X, Eye, - Loader2, Package, + Clock, } from 'lucide-react'; import { useRouter } from 'next/navigation'; import { ProductDetailModal } from '@/components/shared/ProductDetailModal'; import { getActiveCurrency } from '@/lib/currency'; +import { getUserFromToken } from '@/lib/auth'; interface CustomerMachine { id: string; modelName: string; @@ -194,6 +197,20 @@ export default function ServiceContractsPage() { const [searchTerm, setSearchTerm] = useState(''); const [filterType, setFilterType] = useState('ALL'); + // Creating/editing/deleting a contract is restricted server-side to SERVICE_HELP_DESK + // (see api_gateway requireServiceRole on POST/PUT/DELETE /i/service/contracts) — + // a technician can view this page but gets a 403 on any of those actions. Hide the + // buttons for them instead of letting them hit a permission error. + const [canManageContracts, setCanManageContracts] = useState(true); + useEffect(() => { + const user = getUserFromToken(); + setCanManageContracts( + user?.role === 'ADMIN' || + user?.role === 'MANAGER' || + user?.employeeJob !== 'SERVICE_TECHNICIAN', + ); + }, []); + // Modal State const [isModalOpen, setIsModalOpen] = useState(false); const [editingContract, setEditingContract] = useState(null); @@ -264,24 +281,14 @@ export default function ServiceContractsPage() { meterReading: '', printColour: 'BLACK_WHITE' as 'BLACK_WHITE' | 'COLOUR' | 'BOTH', description: '', + machineType: 'PRINTER' as 'PRINTER' | 'COMPUTER' | 'OTHER', }); // Brand/model catalog — same "Other Machine" pattern as service ticket raising const [brands, setBrands] = useState([]); const [models, setModels] = useState([]); - const [showCreateBrandModal, setShowCreateBrandModal] = useState(false); - const [brandForm, setBrandForm] = useState({ name: '', description: '' }); - const [creatingBrandState, setCreatingBrandState] = useState(false); - const [brandError, setBrandError] = useState(null); - const [showCreateModelModal, setShowCreateModelModal] = useState(false); - const [modelForm, setModelForm] = useState({ - model_no: '', - model_name: '', - brand_id: '', - description: '', - }); - const [creatingModelState, setCreatingModelState] = useState(false); - const [modelError, setModelError] = useState(null); + const [showAddBrandDialog, setShowAddBrandDialog] = useState(false); + const [showAddModelDialog, setShowAddModelDialog] = useState(false); const [customerIntel, setCustomerIntel] = useState(null); const [loadingIntel, setLoadingIntel] = useState(false); @@ -463,6 +470,15 @@ export default function ServiceContractsPage() { toast.error('Enter the machine current meter reading — SMA usage is counted from it.'); return; } + if ( + machineCurrentMeter != null && + Number(formState.startMeterReading) < machineCurrentMeter + ) { + toast.error( + `Starting meter cannot be less than the machine's current meter reading (${machineCurrentMeter.toLocaleString()}).`, + ); + return; + } } if (type === 'FSMA') { if (formState.fsmaBillingMode === 'INDIVIDUAL') { @@ -474,6 +490,13 @@ export default function ServiceContractsPage() { toast.error('Enter the starting B&W and colour meter readings.'); return; } + const startTotal = Number(formState.startMeterBW) + Number(formState.startMeterColor); + if (machineCurrentMeter != null && startTotal < machineCurrentMeter) { + toast.error( + `Starting meter (B&W + colour = ${startTotal.toLocaleString()}) cannot be less than the machine's current meter reading (${machineCurrentMeter.toLocaleString()}).`, + ); + return; + } } else { if (formState.ratePerClickCombined === '') { toast.error('Enter the combined per-click rate.'); @@ -483,6 +506,15 @@ export default function ServiceContractsPage() { toast.error('Enter the starting total meter reading.'); return; } + if ( + machineCurrentMeter != null && + Number(formState.startMeterReading) < machineCurrentMeter + ) { + toast.error( + `Starting meter cannot be less than the machine's current meter reading (${machineCurrentMeter.toLocaleString()}).`, + ); + return; + } } } @@ -570,6 +602,7 @@ export default function ServiceContractsPage() { paymentDate: payingForm.paymentDate, referenceNumber: payingForm.referenceNumber || undefined, remarks: payingForm.remarks || undefined, + paymentContext: 'SERVICE_CONTRACT_INSTALLMENT', }); toast.success('Payment submitted for Finance approval.'); setPayingContract(null); @@ -704,88 +737,24 @@ export default function ServiceContractsPage() { meterReading: '', printColour: 'BLACK_WHITE', description: '', + machineType: 'PRINTER', }); setExternalDialogOpen(true); }; - const handleCreateBrand = async (e: React.FormEvent) => { - e.preventDefault(); - if (!brandForm.name.trim()) return; - try { - setCreatingBrandState(true); - setBrandError(null); - const res = await createBrand({ - name: brandForm.name.trim(), - description: brandForm.description.trim() || undefined, - }); - const resBrands = await getBrands().catch(() => ({ success: false, data: [] })); - setBrands(resBrands.data || []); - - const createdBrand = res.data || res; - setExternalForm((prev) => ({ - ...prev, - brand: createdBrand.name || brandForm.name.trim(), - })); - - setShowCreateBrandModal(false); - setBrandForm({ name: '', description: '' }); - toast.success('Brand created successfully!'); - } catch (error) { - console.error('Failed to create brand:', error); - const msg = - getApiErrorMessage(error) || 'Failed to create brand. Please check if it already exists.'; - setBrandError(msg); - toast.error(msg); - } finally { - setCreatingBrandState(false); + const handleBrandCreated = async (created?: Brand) => { + const resBrands = await getBrands().catch(() => ({ success: false, data: [] })); + setBrands(resBrands.data || []); + if (created) { + setExternalForm((prev) => ({ ...prev, brand: created.name, modelName: '' })); } }; - const handleOpenCreateModel = () => { - const defaultBrand = brands.find((b) => b.name === externalForm.brand); - setModelForm({ - model_no: '', - model_name: '', - brand_id: defaultBrand ? defaultBrand.id : '', - description: '', - }); - setModelError(null); - setShowCreateModelModal(true); - }; - - const handleCreateModel = async (e: React.FormEvent) => { - e.preventDefault(); - if (!modelForm.model_name.trim() || !modelForm.model_no.trim() || !modelForm.brand_id) { - setModelError('Model Name, Model Number, and Brand are required.'); - return; - } - try { - setCreatingModelState(true); - setModelError(null); - const created = await addModel({ - model_name: modelForm.model_name.trim(), - model_no: modelForm.model_no.trim(), - brand_id: modelForm.brand_id, - description: modelForm.description.trim() || modelForm.model_name.trim(), - }); - const resModels = await getAllModels({ limit: 1000 }).catch(() => ({ data: [] })); - setModels(resModels.data || []); - - setExternalForm((prev) => ({ - ...prev, - modelName: created.model_name || modelForm.model_name.trim(), - })); - - setShowCreateModelModal(false); - setModelForm({ model_no: '', model_name: '', brand_id: '', description: '' }); - toast.success('Model created successfully!'); - } catch (error) { - console.error('Failed to create model:', error); - const msg = getApiErrorMessage(error) || 'Failed to create model. Please try again.'; - setModelError(msg); - toast.error(msg); - } finally { - setCreatingModelState(false); + const handleModelCreated = async (created?: { model_name: string }) => { + const resModels = await getAllModels({ limit: 1000 }).catch(() => ({ data: [] })); + setModels(resModels.data || []); + if (created) { + setExternalForm((prev) => ({ ...prev, modelName: created.model_name })); } }; @@ -803,7 +772,8 @@ export default function ServiceContractsPage() { toast.error('Serial number is required.'); return; } - if (externalForm.meterReading === '' || Number(externalForm.meterReading) < 0) { + const isMetered = externalForm.machineType === 'PRINTER'; + if (isMetered && (externalForm.meterReading === '' || Number(externalForm.meterReading) < 0)) { toast.error('Enter the machine current meter reading.'); return; } @@ -815,9 +785,10 @@ export default function ServiceContractsPage() { brand: externalForm.brand.trim(), modelName: externalForm.modelName.trim(), serialNumber: externalForm.serialNumber.trim(), - meterReading: Number(externalForm.meterReading), + meterReading: isMetered ? Number(externalForm.meterReading) : 0, printColour: externalForm.printColour, description: externalForm.description.trim() || undefined, + machineType: externalForm.machineType, }); toast.success(`External machine ${machine.serial_no} registered.`); setExternalDialogOpen(false); @@ -972,6 +943,11 @@ export default function ServiceContractsPage() { ? new Date(prod.warranty_end_date).toLocaleDateString() : 'N/A', activeContract: 'None', + // Assigned products come from a branch-unfiltered lookup, unlike the + // global `products` list step 6 overlays from below (which excludes + // external machines — they have no warehouse/branch). Set it here so + // an external machine's meter still shows even when step 6 can't find it. + meterReading: prod.meter_reading ?? undefined, }); } }); @@ -1039,6 +1015,27 @@ export default function ServiceContractsPage() { })); }, [formState.productId, products, editingContract]); + // A computer (or anything else non-metered) has no page count to bill by, so + // SMA/FSMA — both priced per click — don't apply. Only AMC's flat fee does. + const selectedMachineType: 'PRINTER' | 'COMPUTER' | 'OTHER' = + editingContract?.machine?.machineType || + products.find((p) => p.id === formState.productId)?.machine_type || + 'PRINTER'; + const isMeteredMachineSelected = selectedMachineType === 'PRINTER'; + + useEffect(() => { + if (!isMeteredMachineSelected && formState.contractType !== 'AMC') { + setFormState((prev) => ({ ...prev, contractType: 'AMC' })); + } + }, [isMeteredMachineSelected, formState.contractType]); + + // The machine's own last-known meter reading — a new SMA/FSMA contract's starting + // meter can never be entered lower than this (only checked at creation; once a + // contract exists this same number is partly made up of its own readings). + const machineCurrentMeter = !editingContract + ? (products.find((p) => p.id === formState.productId)?.meter_reading ?? null) + : null; + const selectedSerial = selectedRegistryMachine?.serialNumber; useEffect(() => { if ( @@ -1084,13 +1081,15 @@ export default function ServiceContractsPage() { Manage Service Agreements (FSMA, SMA, AMC) for customer and external machines.

- + {canManageContracts && ( + + )}
{/* Analytics Summary */} @@ -1225,12 +1224,17 @@ export default function ServiceContractsPage() {
- + {c.machine ? `${c.machine.brand} ${c.machine.modelName}` : product ? `${product.brand} ${product.name}` : 'Unknown Product'} + {c.machine?.machineType === 'COMPUTER' && ( + + COMPUTER + + )}
@@ -1285,12 +1289,25 @@ export default function ServiceContractsPage() { ? 'bg-amber-50 text-amber-700 border-amber-200' : 'bg-rose-50 text-rose-700 border-rose-200'; return ( - - {paid} - + <> + + {paid} + + {summary.pendingApprovalCount > 0 && ( + + + {summary.pendingApprovalCount === 1 + ? '1 pending approval' + : `${summary.pendingApprovalCount} pending approval`} + + )} + ); })() ) : ( @@ -1343,15 +1360,18 @@ export default function ServiceContractsPage() { )} - + {c.machine?.machineType !== 'COMPUTER' && + c.machine?.machineType !== 'OTHER' && ( + + )} {c.invoiceId && (contractPaymentSummaries[c.invoiceId]?.pendingBalance ?? 1) > 0 && ( )} - - {!c.invoiceId && ( + {canManageContracts && ( + + )} + {canManageContracts && !c.invoiceId && ( - - - - - - )} - - {/* CREATE MODEL MODAL */} - {showCreateModelModal && ( -
- - - - Create Model - - - Add a new model to the list and link it to a brand. - - -
- - {modelError && ( -
- {modelError} -
- )} -
- - ({ value: b.id, label: b.name }))} - value={modelForm.brand_id} - onValueChange={(val) => setModelForm({ ...modelForm, brand_id: val })} - placeholder="Select brand..." - className="h-9 w-full rounded-xl border-slate-200 bg-slate-50 text-xs font-medium text-slate-700" - /> -
- -
- - setModelForm({ ...modelForm, model_name: e.target.value })} - className="h-9 text-xs bg-slate-50 border-slate-200 rounded-xl focus-visible:ring-blue-500" - /> -
- -
- - setModelForm({ ...modelForm, model_no: e.target.value })} - className="h-9 text-xs bg-slate-50 border-slate-200 rounded-xl focus-visible:ring-blue-500 font-mono" - /> -
+ -
- - setModelForm({ ...modelForm, description: e.target.value })} - className="h-9 text-xs bg-slate-50 border-slate-200 rounded-xl focus-visible:ring-blue-500" - /> -
-
-
- - -
-
-
-
- )} + b.name === externalForm.brand)?.id} + onSuccess={handleModelCreated} + /> {/* AMC INSTALLMENT PAYMENT DIALOG */} !open && setPayingContract(null)}> @@ -2750,17 +2685,34 @@ export default function ServiceContractsPage() { {payingContract?.invoiceId && contractPaymentSummaries[payingContract.invoiceId] && ( -
- - Paid {getActiveCurrency()}{' '} - {contractPaymentSummaries[payingContract.invoiceId].totalPaid.toFixed(2)} of{' '} - {getActiveCurrency()}{' '} - {contractPaymentSummaries[payingContract.invoiceId].totalAmount.toFixed(2)} - - - Balance {getActiveCurrency()}{' '} - {contractPaymentSummaries[payingContract.invoiceId].pendingBalance.toFixed(2)} - +
+
+ + Paid {getActiveCurrency()}{' '} + {contractPaymentSummaries[payingContract.invoiceId].totalPaid.toFixed(2)} of{' '} + {getActiveCurrency()}{' '} + {contractPaymentSummaries[payingContract.invoiceId].totalAmount.toFixed(2)} + + + Balance {getActiveCurrency()}{' '} + {contractPaymentSummaries[payingContract.invoiceId].pendingBalance.toFixed(2)} + +
+ {contractPaymentSummaries[payingContract.invoiceId].pendingApprovalCount > 0 && ( +
+ + + {contractPaymentSummaries[payingContract.invoiceId].pendingApprovalCount === 1 + ? '1 payment' + : `${contractPaymentSummaries[payingContract.invoiceId].pendingApprovalCount} payments`}{' '} + ({getActiveCurrency()}{' '} + {contractPaymentSummaries[ + payingContract.invoiceId + ].pendingApprovalAmount.toFixed(2)} + ) awaiting Finance approval — not yet reflected in the balance above. + +
+ )}
)} diff --git a/frontend/app/employee/(dashboard)/service/installation-requests/page.tsx b/frontend/app/employee/(dashboard)/service/installation-requests/page.tsx index f7ac0ad4..5fa80aa4 100644 --- a/frontend/app/employee/(dashboard)/service/installation-requests/page.tsx +++ b/frontend/app/employee/(dashboard)/service/installation-requests/page.tsx @@ -697,7 +697,10 @@ export default function InstallationRequestsPage() { {/* Contract View Dialog */} !v && setViewContract(null)}> - + Contract Agreement {viewContract && ( <> @@ -814,7 +817,10 @@ export default function InstallationRequestsPage() { } }} > - + Initial Meter Readings {/* Header */} diff --git a/frontend/app/employee/(dashboard)/service/page.tsx b/frontend/app/employee/(dashboard)/service/page.tsx index 7c64711a..3dbce49b 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'; @@ -19,7 +19,7 @@ import SendDocumentModal from '@/components/SendDocumentModal'; import { RecordCustomerApprovalDialog } from '@/components/service/RecordCustomerApprovalDialog'; import { AddBrandDialog } from '@/components/ManagerDashboardComponents/BrandComponents/AddBrandDialog'; import { ModelFormModal } from '@/components/ManagerDashboardComponents/productComponents/ModelFormModal'; -import { Play, UserPlus, Send } from 'lucide-react'; +import { Play, UserPlus, Send, AlertCircle } from 'lucide-react'; import { getAllSpareParts, SparePart } from '@/lib/spare-part'; import { getServiceTickets, @@ -66,10 +66,13 @@ import { WarrantyInfo, fetchServiceCashBankAccounts, collectVisitCharge, + collectCompletionPayment, type RecordCustomerDecisionMeta, type CustomerDecisionChannel, } from '@/lib/serviceTicket'; import { ServiceContract, getServiceContracts } from '@/lib/serviceContract'; +import { getInvoiceById } from '@/lib/invoice'; +import { getSalePaymentsForInvoice } from '@/lib/saleWorkflow'; import { getStatusColor, ServiceTicketHistoryPanel, @@ -111,6 +114,7 @@ import { Laptop, FileText, Activity, + Clock, CheckCircle2, AlertTriangle, XCircle, @@ -125,6 +129,7 @@ import { } from 'lucide-react'; import { getActiveCurrency } from '@/lib/currency'; +import { getApiErrorMessage } from '@/lib/apiError'; interface AuthUser { userId: string; role: string; @@ -224,6 +229,7 @@ export default function ServiceDashboardPage() { // Modals & States const [showCreateModal, setShowCreateModal] = useState(false); + const [createTicketError, setCreateTicketError] = useState(''); const [selectedTicket, setSelectedTicket] = useState(null); const [showAssignModal, setShowAssignModal] = useState(false); const [showDiagnoseModal, setShowDiagnoseModal] = useState(false); @@ -310,11 +316,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 +340,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; @@ -332,6 +350,29 @@ export default function ServiceDashboardPage() { const [collectVCPaymentMode, setCollectVCPaymentMode] = useState(''); const [collectVCAccountId, setCollectVCAccountId] = useState(''); const [collectVCSubmitting, setCollectVCSubmitting] = useState(false); + + // Collect-completion-payment modal — "Not collected" at completion isn't + // final; this lets staff record the customer's payment later against an + // already-COMPLETED ticket. Amount due is fetched lazily on open, not per + // row, to avoid an invoice+payments fetch for every completed ticket. + const [collectCPModal, setCollectCPModal] = useState<{ + ticketId: string; + ticketNumber: string; + } | null>(null); + const [collectCPAmountDue, setCollectCPAmountDue] = useState<{ + total: number; + paid: number; + outstanding: number; + invoiceNumber?: string; + } | null>(null); + const [collectCPLoadingDue, setCollectCPLoadingDue] = useState(false); + const [collectCPAmount, setCollectCPAmount] = useState(''); + const [collectCPPaymentMode, setCollectCPPaymentMode] = useState(''); + const [collectCPAccountId, setCollectCPAccountId] = useState(''); + const [collectCPChequeNumber, setCollectCPChequeNumber] = useState(''); + const [collectCPChequeBank, setCollectCPChequeBank] = useState(''); + const [collectCPChequeDate, setCollectCPChequeDate] = useState(''); + const [collectCPSubmitting, setCollectCPSubmitting] = useState(false); // eslint-disable-next-line @typescript-eslint/no-unused-vars const [associatedLeadName, setAssociatedLeadName] = useState(''); const [leadForm, setLeadForm] = useState({ @@ -456,6 +497,51 @@ 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], + ); + const collectCPEligibleAccounts = useMemo( + () => accountsForMode(collectCPPaymentMode), + [accountsForMode, collectCPPaymentMode], + ); + + /** + * 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 +557,7 @@ export default function ServiceDashboardPage() { visitChargeCollected: boolean; visitChargePaymentMode: string; visitChargeAccountId: string; + visitChargeChequeNumber?: string; transportChargeAmount: number; discountAmount: number; technicianNoteToFinance: string; @@ -480,6 +567,7 @@ export default function ServiceDashboardPage() { customPartName: string; customPartBrand: string; customPartDescription: string; + customPartCost: number; mpn: string; partName: string; quantity: number; @@ -497,6 +585,7 @@ export default function ServiceDashboardPage() { visitChargeCollected: true, visitChargePaymentMode: '', visitChargeAccountId: '', + visitChargeChequeNumber: '', transportChargeAmount: 0, discountAmount: 0, technicianNoteToFinance: '', @@ -513,6 +602,38 @@ 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. + */ + /** + * What the customer actually owes at completion. + * + * NOT the estimate total. Approving within validity waives the labour line, so a job + * quoted at 550 can be a 150 bill — prefilling the estimate figure would have the + * technician collect 400 too much. The invoice carries the post-waiver amount, and any + * payment already taken (an up-front visit charge, a part payment) comes off it too. + */ + const [amountDue, setAmountDue] = useState<{ + total: number; + paid: number; + outstanding: number; + invoiceNumber?: string; + } | null>(null); + const [loadingDue, setLoadingDue] = useState(false); + + 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); @@ -668,21 +789,29 @@ export default function ServiceDashboardPage() { : undefined, issueDescription: newTicket.issueDescription.trim(), meterReadingAtCreation: - !isOtherMachine && newTicket.machineType === 'PRINTER' && meterReadingInput !== '' + newTicket.machineType === 'PRINTER' && meterReadingInput !== '' ? Number(meterReadingInput) : undefined, visitChargeAmount: newTicket.visitChargeAmount !== '' ? Number(newTicket.visitChargeAmount) : undefined, }; + setCreateTicketError(''); await createServiceTicket(payload); toast.success('Service ticket created — confirmation email sent to the customer.'); setShowCreateModal(false); resetTicketForm(); await fetchInitialData(); } catch (error) { - console.error('Failed to create ticket:', error); - toast.error('Error creating service ticket. Please verify inputs and connection.'); + // Expected, user-facing validation failure (e.g. duplicate open ticket) — + // shown via the inline banner below, not console.error: that flags a real + // bug to Next's dev overlay, which this is not. + setCreateTicketError( + getApiErrorMessage( + error, + 'Error creating service ticket. Please verify inputs and connection.', + ), + ); } finally { setSubmitting(false); } @@ -857,11 +986,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); @@ -871,26 +1007,83 @@ export default function ServiceDashboardPage() { } }; + const openCollectCompletionPayment = async (ticket: ServiceTicket) => { + setCollectCPModal({ + ticketId: ticket.id, + ticketNumber: ticket.ticketNumber, + }); + setCollectCPPaymentMode(''); + setCollectCPAccountId(''); + setCollectCPChequeNumber(''); + setCollectCPChequeBank(''); + setCollectCPChequeDate(''); + setCollectCPAmountDue(null); + setCollectCPAmount(''); + loadCashBankAccounts(ticket.branchId); + if (!ticket.serviceQuotationId) return; + setCollectCPLoadingDue(true); + try { + const [inv, payments] = await Promise.all([ + getInvoiceById(ticket.serviceQuotationId), + getSalePaymentsForInvoice(ticket.serviceQuotationId).catch(() => []), + ]); + const total = Number(inv?.totalAmount) || 0; + // Pending counts as paid: that money is already with Accounts awaiting + // approval, and collecting it twice is the failure to avoid. + const paid = (payments || []) + .filter((p) => p.status === 'APPROVED' || p.status === 'PENDING') + .reduce((sum, p) => sum + (Number(p.amount) || 0), 0); + const outstanding = Math.max(0, total - paid); + setCollectCPAmountDue({ total, paid, outstanding, invoiceNumber: inv?.invoiceNumber }); + setCollectCPAmount(outstanding > 0 ? String(outstanding) : ''); + } catch { + setCollectCPAmountDue(null); + } finally { + setCollectCPLoadingDue(false); + } + }; + + const handleCollectCompletionPayment = async () => { + if (!collectCPModal) return; + if (!collectCPPaymentMode || (collectCPPaymentMode !== 'CHEQUE' && !collectCPAccountId)) { + toast.error('Select a payment mode (and account, unless paying by cheque).'); + return; + } + if (!(Number(collectCPAmount) > 0)) { + toast.error('Enter an amount greater than 0.'); + return; + } + try { + setCollectCPSubmitting(true); + await collectCompletionPayment(collectCPModal.ticketId, { + amount: Number(collectCPAmount), + paymentMode: collectCPPaymentMode, + accountId: collectCPAccountId || undefined, + chequeNumber: collectCPChequeNumber.trim() || undefined, + chequeBankName: collectCPChequeBank.trim() || undefined, + chequeDate: collectCPChequeDate || undefined, + }); + toastSuccess('Payment sent to Accounts for approval.'); + setCollectCPModal(null); + await fetchInitialData(); + } catch (error) { + console.error('Failed to collect completion payment:', error); + toastError('Failed to collect payment.'); + } finally { + setCollectCPSubmitting(false); + } + }; + const handleDiagnose = async (e?: React.FormEvent, confirmed = false) => { e?.preventDefault(); if (!selectedTicket) return; - // Discount applies to the whole estimate: parts + labour + transport + - // visit charge (when added to the estimate). It cannot exceed that total. - const partsTotal = diagnosisForm.items.reduce( - (sum, item) => sum + (item.isFree ? 0 : (item.quantity || 1) * (item.unitPrice || 0)), - 0, - ); - const estimateTotalBeforeDiscount = - partsTotal + - (Number(diagnosisForm.labourCost) || 0) + - (Number(diagnosisForm.transportChargeAmount) || 0) + - (diagnosisForm.visitChargeMethod === 'ADDED_TO_ESTIMATE' - ? Number(diagnosisForm.visitChargeAmount) || 0 - : 0); - if (Number(diagnosisForm.discountAmount || 0) > estimateTotalBeforeDiscount) { + // Discount is a discount on labour/service charge only — it cannot + // exceed the labour cost, regardless of parts/transport/visit charge. + const labourCost = Number(diagnosisForm.labourCost) || 0; + if (Number(diagnosisForm.discountAmount || 0) > labourCost) { toast.error( - `Discount of ${getActiveCurrency()} ${diagnosisForm.discountAmount} exceeds the total estimate amount of ${getActiveCurrency()} ${estimateTotalBeforeDiscount.toFixed(2)}.`, + `Discount of ${getActiveCurrency()} ${diagnosisForm.discountAmount} exceeds the labour cost of ${getActiveCurrency()} ${labourCost.toFixed(2)}.`, ); return; } @@ -943,6 +1136,7 @@ export default function ServiceDashboardPage() { customPartName: it.customPartName || undefined, customPartBrand: it.customPartBrand || undefined, customPartDescription: it.customPartDescription || undefined, + customPartCost: it.itemSource === 'CUSTOM' ? Number(it.customPartCost) || 0 : undefined, mpn: it.mpn || undefined, partName: it.partName, quantity: Number(it.quantity) || 1, @@ -978,6 +1172,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, @@ -987,6 +1185,7 @@ export default function ServiceDashboardPage() { customPartName: it.customPartName || undefined, customPartBrand: it.customPartBrand || undefined, customPartDescription: it.customPartDescription || undefined, + customPartCost: it.itemSource === 'CUSTOM' ? Number(it.customPartCost) || 0 : undefined, mpn: it.mpn || undefined, partName: it.partName, quantity: Number(it.quantity) || 1, @@ -1069,6 +1268,41 @@ export default function ServiceDashboardPage() { // setConfirmOpen(true); // }; + /** + * Loads the outstanding balance when the completion form opens, and prefills the amount + * so the technician confirms a figure rather than inventing one. + */ + const loadAmountDue = useCallback(async (ticket: ServiceTicket | null) => { + if (!ticket?.serviceQuotationId) { + setAmountDue(null); + return; + } + setLoadingDue(true); + try { + const [inv, payments] = await Promise.all([ + getInvoiceById(ticket.serviceQuotationId), + getSalePaymentsForInvoice(ticket.serviceQuotationId).catch(() => []), + ]); + const total = Number(inv?.totalAmount) || 0; + // Pending counts as paid for this purpose: that money is already with Accounts + // awaiting approval, and collecting it twice is the failure to avoid. + const paid = (payments || []) + .filter((p) => p.status === 'APPROVED' || p.status === 'PENDING') + .reduce((sum, p) => sum + (Number(p.amount) || 0), 0); + const outstanding = Math.max(0, total - paid); + setAmountDue({ total, paid, outstanding, invoiceNumber: inv?.invoiceNumber }); + setCollectAmount(outstanding > 0 ? String(outstanding) : ''); + } catch { + setAmountDue(null); + } finally { + setLoadingDue(false); + } + }, []); + + useEffect(() => { + if (showCompleteModal) loadAmountDue(selectedTicket); + }, [showCompleteModal, selectedTicket, loadAmountDue]); + const handleCompleteService = async (e: React.FormEvent) => { e.preventDefault(); if (!selectedTicket) return; @@ -1085,6 +1319,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 +1337,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.'); @@ -1154,20 +1404,27 @@ export default function ServiceDashboardPage() { totalLabourCost: Number(data.history?.totalLabourSpend) || 0, totalLifetimeCost: Number(data.history?.totalLifetimeCost) || 0, currentMeterReading: data.currentMeterReading ?? null, - visitLogs: (data.tickets || []).map((t: ServiceTicket) => { - let ticketCost = 0; - t.items?.forEach((item: ServiceTicketItem) => { - ticketCost += Number(item.totalPrice) || 0; - }); - return { - ticketNumber: t.ticketNumber, - serviceContext: t.serviceContext, - status: t.status, - date: t.completedAt || t.created_at, - meterReading: t.meterReadingAtService || t.meterReadingAtCreation || 0, - cost: ticketCost, - }; - }), + visitLogs: (data.tickets || []).map( + (t: ServiceTicket & { estimateTotalCost?: number | null }) => { + // The latest estimate's total already covers parts + labour + + // visit/transport charge - discount. Fall back to summing billed + // items only for older tickets that predate estimates. + let cost = t.estimateTotalCost ?? 0; + if (t.estimateTotalCost == null) { + t.items?.forEach((item: ServiceTicketItem) => { + cost += Number(item.totalPrice) || 0; + }); + } + return { + ticketNumber: t.ticketNumber, + serviceContext: t.serviceContext, + status: t.status, + date: t.completedAt || t.created_at, + meterReading: t.meterReadingAtService || t.meterReadingAtCreation || 0, + cost, + }; + }, + ), }; const mappedYields: ConsumableYieldUI[] = (data.yields || []).map( @@ -1646,6 +1903,7 @@ export default function ServiceDashboardPage() { }; const resetTicketForm = () => { + setCreateTicketError(''); setNewTicket({ customerId: '', leadId: '', @@ -1700,6 +1958,7 @@ export default function ServiceDashboardPage() { customPartName: '', customPartBrand: '', customPartDescription: '', + customPartCost: 0, mpn: '', partName: '', quantity: 1, @@ -1817,7 +2076,10 @@ export default function ServiceDashboardPage() { {/* Action buttons based on jobs — manager has full authority in the branch */} {(isHelpDesk || isManagerOrAdmin) && ( )} - {(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') && ( )} + {/* "Not collected" at completion isn't final — the customer can + still pay later. Amount due is fetched lazily when this opens, + not per row, so a completed-tickets list doesn't fire an + invoice+payments lookup for every row. */} + {(isHelpDesk || isManagerOrAdmin || isTechnician) && + ticket.status === 'COMPLETED' && + ticket.serviceQuotationId && ( + + )} + + {/* 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 +2550,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 +2610,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' @@ -2297,6 +2627,7 @@ export default function ServiceDashboardPage() { customPartName: it.customPartName || '', customPartBrand: it.customPartBrand || '', customPartDescription: it.customPartDescription || '', + customPartCost: it.unitCost || 0, mpn: it.mpn || '', partName: it.partName || '', quantity: it.quantity || 1, @@ -2442,6 +2773,12 @@ export default function ServiceDashboardPage() {
+ {createTicketError && ( +
+ + {createTicketError} +
+ )} {/* PATH 1: EXISTING CUSTOMER FLOW */} {creationPath === 'existing' && ( @@ -2659,6 +2996,14 @@ export default function ServiceDashboardPage() { {machine.effectiveFrom} → {machine.effectiveTo}
+ {machine.meterReading != null && ( +
+ Last Reading:{' '} + + {machine.meterReading.toLocaleString()} + +
+ )} )) @@ -2750,6 +3095,14 @@ export default function ServiceDashboardPage() { )} + {machine.meterReading != null && ( +
+ Last Reading:{' '} + + {machine.meterReading.toLocaleString()} + +
+ )} )) @@ -2843,6 +3196,14 @@ export default function ServiceDashboardPage() { ) )} + {machine.meterReading != null && ( +
+ Last Reading:{' '} + + {machine.meterReading.toLocaleString()} + +
+ )} )) @@ -3211,68 +3572,68 @@ export default function ServiceDashboardPage() { {/* METER READING — copies can expire a warranty before time does. - Printer machines only; computer / other have no meter. */} - {newTicket.machineType === 'PRINTER' && - (selectedMachine.type === 'SALE' || - selectedMachine.type === 'LEASE') && ( -
- - setMeterReadingInput(e.target.value)} - // Re-check warranty/coverage only once the full number is - // entered — on blur, or on Enter — not per digit. - onBlur={() => { - if (selectedMachine?.serialNumber) { - fetchMachineContext( - selectedMachine.serialNumber, - meterReadingInput !== '' - ? Number(meterReadingInput) - : undefined, - ); - } - }} - onKeyDown={(e) => { - if (e.key === 'Enter') { - e.preventDefault(); - (e.target as HTMLInputElement).blur(); - } - }} - className="h-9 text-xs bg-white border-amber-200 rounded-xl focus-visible:ring-amber-500 font-mono" - /> - {machineContextData?.warrantyInfo && ( -

- {machineContextData.warrantyInfo.isUnderWarranty - ? `Under warranty${ - machineContextData.warrantyInfo.copiesRemaining != null - ? ` — ${machineContextData.warrantyInfo.copiesRemaining.toLocaleString()} copies remaining` - : '' - }${ - machineContextData.warrantyInfo.warrantyEndDate - ? ` (until ${new Date(machineContextData.warrantyInfo.warrantyEndDate).toLocaleDateString()})` - : '' - }` - : `Warranty expired${ - machineContextData.warrantyInfo.expiredBy - ? ` — limit hit: ${machineContextData.warrantyInfo.expiredBy}` - : '' - }. Service will be chargeable.`} -

- )} -
- )} + Printer machines only; computer / other have no meter. + Asked regardless of ownership (SALE/LEASE/RENT/EXTERNAL) so + meterReadingAtCreation is always captured for the ticket. */} + {newTicket.machineType === 'PRINTER' && ( +
+ + setMeterReadingInput(e.target.value)} + // Re-check warranty/coverage only once the full number is + // entered — on blur, or on Enter — not per digit. + onBlur={() => { + if (selectedMachine?.serialNumber) { + fetchMachineContext( + selectedMachine.serialNumber, + meterReadingInput !== '' + ? Number(meterReadingInput) + : undefined, + ); + } + }} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + (e.target as HTMLInputElement).blur(); + } + }} + className="h-9 text-xs bg-white border-amber-200 rounded-xl focus-visible:ring-amber-500 font-mono" + /> + {machineContextData?.warrantyInfo && ( +

+ {machineContextData.warrantyInfo.isUnderWarranty + ? `Under warranty${ + machineContextData.warrantyInfo.copiesRemaining != null + ? ` — ${machineContextData.warrantyInfo.copiesRemaining.toLocaleString()} copies remaining` + : '' + }${ + machineContextData.warrantyInfo.warrantyEndDate + ? ` (until ${new Date(machineContextData.warrantyInfo.warrantyEndDate).toLocaleDateString()})` + : '' + }` + : `Warranty expired${ + machineContextData.warrantyInfo.expiredBy + ? ` — limit hit: ${machineContextData.warrantyInfo.expiredBy}` + : '' + }. Service will be chargeable.`} +

+ )} +
+ )} {machineContextData?.contract && (
@@ -3409,6 +3770,25 @@ export default function ServiceDashboardPage() { )}
+ {/* METER READING — for an external/new-lead machine there's no catalogue + record to pull a prior reading from, so ask for it directly. */} + {(creationPath === 'new' || isOtherMachine) && + newTicket.machineType === 'PRINTER' && ( +
+ + setMeterReadingInput(e.target.value)} + className="h-9 text-xs bg-white border-slate-200 rounded-xl focus-visible:ring-primary font-mono" + /> +
+ )} + {/* Information Banner */} {(() => { let bannerClass = ''; @@ -4087,7 +4467,7 @@ export default function ServiceDashboardPage() { {item.itemSource === 'CUSTOM' && (
-
+
+
+ + + updateDiagnosisItem( + idx, + 'customPartCost', + parseFloat(e.target.value) || 0, + ) + } + className="h-9 text-xs bg-white border-slate-200 rounded-lg" + /> +

Brand and Model Name are pre-filled from the machine on this ticket — - edit if the part differs. + edit if the part differs. Internal cost is never shown to the customer — + it's tracked so we know what off-catalog parts actually cost us, + and flags the branch manager to consider stocking it via RFQ.

)} @@ -4257,9 +4659,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 && + 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 && ( + (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 +4722,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 +4762,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) => ( ))} @@ -4379,17 +4821,21 @@ export default function ServiceDashboardPage() { + onChange={(e) => { + const labourCost = Number(diagnosisForm.labourCost) || 0; + const entered = parseFloat(e.target.value) || 0; setDiagnosisForm({ ...diagnosisForm, - discountAmount: parseFloat(e.target.value) || 0, - }) - } + discountAmount: Math.min(entered, labourCost), + }); + }} className="h-9 text-xs bg-slate-50 border-slate-200 rounded-xl" />

- Applied on the total estimate amount. + Cannot exceed the labour cost ({getActiveCurrency()}{' '} + {(Number(diagnosisForm.labourCost) || 0).toFixed(2)}).

@@ -4403,19 +4849,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 +4878,7 @@ export default function ServiceDashboardPage() { laborTotal + transportTotal + visitChargeToAdd - - (diagnosisForm.discountAmount || 0), + num(diagnosisForm.discountAmount), ); return totalEstimate.toFixed(2); })()} @@ -4631,6 +5086,144 @@ 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) +

+ {loadingDue ? ( + + Loading balance… + + ) : amountDue ? ( + + Due now: {getActiveCurrency()} {amountDue.outstanding.toFixed(2)} + {amountDue.invoiceNumber ? ( + + ({amountDue.invoiceNumber}) + + ) : null} + + ) : null} +
+ + {/* The estimate and the bill can differ — labour is waived when the + customer approves within validity — so the figure is spelled out + rather than left for the technician to reconcile in their head. */} + {amountDue && amountDue.paid > 0 && ( +

+ Invoice {getActiveCurrency()} {amountDue.total.toFixed(2)} · already collected{' '} + {getActiveCurrency()} {amountDue.paid.toFixed(2)} +

+ )} + {amountDue && amountDue.outstanding === 0 && ( +

+ Nothing outstanding — this invoice is already settled. +

+ )} +
+
+ + +
+ + {collectMode && ( +
+ + setCollectAmount(e.target.value)} + placeholder={amountDue ? amountDue.outstanding.toFixed(2) : '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" + /> + {amountDue && Number(collectAmount) > amountDue.outstanding + 0.01 && ( +

+ More than the {getActiveCurrency()} {amountDue.outstanding.toFixed(2)}{' '} + outstanding. +

+ )} +
+ )} + + {collectMode && collectMode !== 'CHEQUE' && ( +
+ + ({ + value: a.id, + label: a.name, + }))} + value={collectAccountId} + onValueChange={(val) => setCollectAccountId(val)} + placeholder="Select account..." + className="w-full h-9 px-3 text-xs bg-white border-orange-200 rounded-xl text-orange-900 font-semibold" + /> +
+ )} +
+ + {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. +

+ )} +
+

+ Internal Cost is what we actually spent — staff view only, never shown to + the customer. +

)} @@ -6575,36 +7181,94 @@ export default function ServiceDashboardPage() { for later (on-site or on the completion bill) — collecting it here does not affect technician assignment or diagnosis.

+

+ Recording this sends the collection to Accounts for approval. Nothing is posted to the + cashbook until they approve it, and the technician will no longer be offered this + charge on site. +

- - {collectVCPaymentMode && collectVCPaymentMode !== 'CHEQUE' && ( +
+ +
+ {collectVCPaymentMode && collectVCPaymentMode !== 'CHEQUE' && ( +
+ + +
)}
+ + {collectVCPaymentMode === 'CHEQUE' && ( +
+
+ + setCollectVCChequeNumber(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" + /> +
+
+ + setCollectVCChequeBank(e.target.value)} + placeholder="e.g. FAB" + 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" + /> +
+
+ + setCollectVCChequeDate(e.target.value)} + 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" + /> +
+
+ )}
+
+ + )} + + + setCollectCPModal(null)} + maxWidth="sm" + title="Collect Payment" + > + {collectCPModal && ( +
+

+ Ticket {collectCPModal.ticketNumber} — record a payment the customer made after the + job was completed. +

+ {collectCPLoadingDue ? ( +

Loading outstanding balance…

+ ) : collectCPAmountDue ? ( + collectCPAmountDue.outstanding > 0 ? ( +

+ Invoice {collectCPAmountDue.invoiceNumber || ''}: {getActiveCurrency()}{' '} + {collectCPAmountDue.total.toFixed(2)} total, {getActiveCurrency()}{' '} + {collectCPAmountDue.paid.toFixed(2)} already collected/pending —{' '} + + {getActiveCurrency()} {collectCPAmountDue.outstanding.toFixed(2)} outstanding + + . +

+ ) : ( +

+ Nothing outstanding — this invoice is already fully collected or pending approval. +

+ ) + ) : ( +

Could not load the invoice balance.

+ )} +

+ Recording this sends the collection to Accounts for approval. Nothing is posted to the + cashbook until they approve it, into whichever account is selected here. +

+
+
+ + setCollectCPAmount(e.target.value)} + 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" + /> + {collectCPAmountDue && + Number(collectCPAmount) > collectCPAmountDue.outstanding + 0.01 && ( +

+ More than the {getActiveCurrency()}{' '} + {collectCPAmountDue.outstanding.toFixed(2)} outstanding. +

+ )} +
+
+ + +
+
+ + {collectCPPaymentMode && collectCPPaymentMode !== 'CHEQUE' && ( +
+ + ({ value: a.id, label: a.name }))} + value={collectCPAccountId} + onValueChange={(val) => setCollectCPAccountId(val)} + placeholder={ + collectCPEligibleAccounts.length === 0 + ? `No ${collectCPPaymentMode === 'CASH' ? 'cash' : 'bank'} account configured` + : 'Select account...' + } + disabled={collectCPEligibleAccounts.length === 0} + className="w-full h-9 px-3 text-xs bg-orange-50/60 border-orange-200 rounded-xl text-orange-900 font-semibold" + /> +
+ )} + + {collectCPPaymentMode === 'CHEQUE' && ( +
+
+ + setCollectCPChequeNumber(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" + /> +
+
+ + setCollectCPChequeBank(e.target.value)} + placeholder="e.g. FAB" + 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" + /> +
+
+ + setCollectCPChequeDate(e.target.value)} + 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" + /> +
+
+ )} +
+ +
diff --git a/frontend/app/finance/(dashboard)/service-estimates/page.tsx b/frontend/app/finance/(dashboard)/service-estimates/page.tsx index 355e0a0a..c2999a2d 100644 --- a/frontend/app/finance/(dashboard)/service-estimates/page.tsx +++ b/frontend/app/finance/(dashboard)/service-estimates/page.tsx @@ -427,22 +427,22 @@ export default function FinanceServiceEstimatesPage() { !v && setDetailTarget(null)}> {/* Header band */} - +
- + Service Estimate Review - + Items, charges and technician context for this estimate.
- + {detailTarget.invoiceNumber} {(detailTarget.revisionCount || 0) > 0 && ( - + Revision {detailTarget.revisionCount} )} @@ -473,13 +473,13 @@ export default function FinanceServiceEstimatesPage() { ].map((f) => (
{f.label} ); } diff --git a/frontend/app/manager/(dashboard)/products/[id]/page.tsx b/frontend/app/manager/(dashboard)/products/[id]/page.tsx index 1cd100d0..413c8f68 100644 --- a/frontend/app/manager/(dashboard)/products/[id]/page.tsx +++ b/frontend/app/manager/(dashboard)/products/[id]/page.tsx @@ -27,6 +27,7 @@ import { useBranchCurrency } from '@/lib/hooks/useBranchCurrency'; import { toast } from 'sonner'; import Barcode from 'react-barcode'; import { resolveImageUrl } from '@/lib/imageUrl'; +import MachineServiceAnalyticsPanel from '@/components/products/MachineServiceAnalyticsPanel'; interface ProductFeature { subHeading: string; @@ -444,6 +445,15 @@ export default function ProductDetailPage() {
+ {/* Service & Spend Analytics — real internal cost, works for RENT/LEASE/ + SALE and external machines alike. Staff-only, never customer-facing. */} +
+

+ Service & Spend History +

+ +
+ {/* Image Preview Overlay Modal */} {previewImage && (
diff --git a/frontend/components/Finance/ConsolidatedStatementDialog.tsx b/frontend/components/Finance/ConsolidatedStatementDialog.tsx index 04b300a8..8ec72126 100644 --- a/frontend/components/Finance/ConsolidatedStatementDialog.tsx +++ b/frontend/components/Finance/ConsolidatedStatementDialog.tsx @@ -116,7 +116,10 @@ export default function ConsolidatedStatementDialog({ return ( !val && onClose()}> - +
diff --git a/frontend/components/Finance/ReceiptsTab.tsx b/frontend/components/Finance/ReceiptsTab.tsx index 0842aa92..b0f617c6 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'; @@ -302,10 +311,11 @@ function CustomerChequesSection({ branchIds }: { branchIds?: string }) { } // paymentContext → contract type label -const ctxType = (ctx?: string | null): 'SALE' | 'RENT' | 'LEASE' | null => { +const ctxType = (ctx?: string | null): 'SALE' | 'RENT' | 'LEASE' | 'SERVICE' | null => { if (!ctx || ctx === 'SALE') return 'SALE'; if (ctx.startsWith('RENT')) return 'RENT'; if (ctx.startsWith('LEASE')) return 'LEASE'; + if (ctx.startsWith('SERVICE_CONTRACT')) return 'SERVICE'; return null; }; @@ -373,7 +383,9 @@ export default function ReceiptsTab({ branchIds }: { branchIds?: string } = {}) const [tab, setTab] = useState('PENDING'); // Secondary filters - const [typeFilter, setTypeFilter] = useState<'ALL' | 'SALE' | 'RENT' | 'LEASE'>('ALL'); + const [typeFilter, setTypeFilter] = useState<'ALL' | 'SALE' | 'RENT' | 'LEASE' | 'SERVICE'>( + 'ALL', + ); const [modeFilter, setModeFilter] = useState< 'ALL' | 'CASH' | 'BANK_TRANSFER' | 'CHEQUE' | 'ONLINE_PAYMENT' >('ALL'); @@ -470,6 +482,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 +939,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, @@ -1056,6 +1173,7 @@ export default function ReceiptsTab({ branchIds }: { branchIds?: string } = {}) Sale Rent Lease + Service Contract
@@ -1320,7 +1438,7 @@ export default function ReceiptsTab({ branchIds }: { branchIds?: string } = {})
- ) : filtered.length === 0 ? ( + ) : filtered.length === 0 && !showEstimateRows && !showAcceptedRows ? (

No payments in this category

@@ -1342,6 +1460,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 +1771,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. +

+