tango-app-api-payment-subscription 3.5.7 → 3.5.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,12 +1,77 @@
1
1
  import * as estimateService from '../services/estimate.service.js';
2
2
  import * as clientService from '../services/clientPayment.services.js';
3
+ import * as billingService from '../services/billing.service.js';
3
4
  import dayjs from 'dayjs';
4
- import { logger, download } from 'tango-app-api-middleware';
5
+ import { logger, download, sendEmailWithSES } from 'tango-app-api-middleware';
5
6
  import Handlebars from '../utils/validations/helper/handlebar.helper.js';
6
7
  import fs from 'fs';
7
8
  import path from 'path';
8
9
  import htmlpdf from 'html-pdf-node';
9
10
  import { symbolFor } from '../utils/currency.js';
11
+ import { getInvoiceCcEmails } from './invoice.controller.js';
12
+
13
+ // Build the estimate PDF buffer + the template data + a safe filename. Shared
14
+ // by the download and send flows so both render the identical document.
15
+ async function buildEstimatePdf( estimate ) {
16
+ const e = estimate._doc || estimate;
17
+ const currencyType = symbolFor( e.currency );
18
+ const fmt = ( n ) => Number( n || 0 ).toLocaleString( 'en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 } );
19
+
20
+ const products = ( e.products || [] ).map( ( p, i ) => {
21
+ let name = String( p.productName || '' ).replace( /([a-z])([A-Z])/g, '$1 $2' );
22
+ name = name.charAt( 0 ).toUpperCase() + name.slice( 1 );
23
+ return {
24
+ index: i + 1,
25
+ productName: name,
26
+ description: p.description || '',
27
+ hsn: p.hsn || p.hsnCode || '998314',
28
+ storeCount: p.storeCount || e.stores || '',
29
+ price: fmt( p.price ),
30
+ amount: fmt( p.amount ),
31
+ };
32
+ } );
33
+ const tax = ( e.tax || [] ).map( ( t ) => ( {
34
+ type: t.type || t.taxName || t.name || 'GST',
35
+ value: t.value ?? t.taxPercentage ?? t.percentage ?? '',
36
+ taxAmount: fmt( t.taxAmount ),
37
+ } ) );
38
+
39
+ const statusLabelMap = { pending: 'Pending', sent: 'Sent', accepted: 'Accepted', declined: 'Declined', expired: 'Expired' };
40
+ const data = {
41
+ estimate: e.estimate,
42
+ status: e.status,
43
+ statusLabel: statusLabelMap[e.status] || e.status,
44
+ companyName: e.companyName || '',
45
+ companyAddress: e.companyAddress || '',
46
+ GSTNumber: e.GSTNumber || '',
47
+ PlaceOfSupply: e.PlaceOfSupply || '',
48
+ groupName: e.groupName || '',
49
+ period: e.period || '',
50
+ createdDate: e.createdDate ? dayjs( e.createdDate ).format( 'DD/MM/YYYY' ) : '',
51
+ validTill: e.validTill ? dayjs( e.validTill ).format( 'DD/MM/YYYY' ) : '',
52
+ currencyType,
53
+ amount: fmt( e.amount ),
54
+ totalAmount: fmt( e.totalAmount ),
55
+ products,
56
+ tax,
57
+ notes: e.notes || '',
58
+ logo: `${JSON.parse( process.env.URL ).apiDomain}/logo.png`,
59
+ };
60
+
61
+ const templateHtml = fs.readFileSync( path.resolve( path.dirname( '' ) ) + '/src/hbs/estimatePdf.hbs', 'utf8' );
62
+ const html = Handlebars.compile( templateHtml )( data );
63
+ const options = {
64
+ executablePath: '/usr/bin/chromium',
65
+ args: [ '--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage', '--no-zygote', '--single-process' ],
66
+ format: 'A4',
67
+ margin: { top: '0.5in', right: '0.5in', bottom: '0.5in', left: '0.5in' },
68
+ printBackground: true,
69
+ preferCSSPageSize: true,
70
+ };
71
+ const pdfBuffer = await htmlpdf.generatePdf( { content: html }, options );
72
+ const filename = ( e.estimate + '-' + ( e.companyName || 'estimate' ) + '.pdf' ).split( '/' ).join( '_' ).split( '"' ).join( '' ).trim();
73
+ return { pdfBuffer, filename, data };
74
+ }
10
75
 
11
76
  // ---------------------------------------------------------------------------
12
77
  // Estimates (quotations). A lightweight pre-invoice document with its own
@@ -43,7 +108,7 @@ async function nextEstimateNumber() {
43
108
  // flips to 'expired' so the list reflects reality without a cron.
44
109
  async function expireOverdue( clientId ) {
45
110
  await estimateService.updateOne(
46
- { clientId, status: { $in: [ 'draft', 'sent' ] }, validTill: { $lt: new Date() } },
111
+ { clientId, status: { $in: [ 'pending', 'sent' ] }, validTill: { $lt: new Date() } },
47
112
  { $set: { status: 'expired' } },
48
113
  );
49
114
  }
@@ -119,7 +184,7 @@ export async function estimateList( req, res ) {
119
184
  { $match: { clientId } },
120
185
  { $group: { _id: '$status', count: { $sum: 1 } } },
121
186
  ] );
122
- const counts = { draft: 0, sent: 0, accepted: 0, declined: 0, expired: 0, total: 0 };
187
+ const counts = { pending: 0, sent: 0, accepted: 0, declined: 0, expired: 0, total: 0 };
123
188
  statusAgg.forEach( ( s ) => {
124
189
  if ( counts[s._id] != null ) {
125
190
  counts[s._id] = s.count;
@@ -175,7 +240,7 @@ export async function createEstimate( req, res ) {
175
240
  amount,
176
241
  totalAmount,
177
242
  currency: b.currency || ( client?.paymentInvoice?.currencyType === 'dollar' ? 'dollar' : 'inr' ),
178
- status: b.status === 'sent' ? 'sent' : 'draft',
243
+ status: b.status === 'sent' ? 'sent' : 'pending',
179
244
  createdDate,
180
245
  validTill,
181
246
  createdBy: req.user?.email || req.user?.userName || '',
@@ -191,6 +256,69 @@ export async function createEstimate( req, res ) {
191
256
  }
192
257
  }
193
258
 
259
+ export async function updateEstimate( req, res ) {
260
+ try {
261
+ const b = req.body || {};
262
+ const estimateId = b._id || b.estimateId || req.params.estimateId;
263
+ if ( !estimateId ) {
264
+ return res.sendError( 'estimateId is required', 400 );
265
+ }
266
+
267
+ const existing = await estimateService.findOne( { _id: estimateId } );
268
+ if ( !existing ) {
269
+ return res.sendError( 'Estimate not found', 404 );
270
+ }
271
+ // Accepted estimates are locked — mirrors the delete guard.
272
+ if ( ( existing._doc || existing ).status === 'accepted' ) {
273
+ return res.sendError( 'An accepted estimate cannot be edited.', 409 );
274
+ }
275
+
276
+ const amount = Math.round( Number( b.amount ) || 0 );
277
+ let totalAmount = Math.round( Number( b.totalAmount ) || 0 );
278
+ if ( !totalAmount && amount ) {
279
+ totalAmount = Math.round( amount * 1.18 );
280
+ }
281
+
282
+ // Only the editable fields are updated. The estimate number, index,
283
+ // clientId and createdDate are immutable so the document keeps its
284
+ // identity and financial-year sequence.
285
+ const update = {
286
+ companyName: b.companyName ?? existing.companyName,
287
+ companyAddress: b.companyAddress ?? existing.companyAddress,
288
+ PlaceOfSupply: b.PlaceOfSupply ?? existing.PlaceOfSupply,
289
+ GSTNumber: b.GSTNumber ?? existing.GSTNumber,
290
+ groupId: b.groupId || existing.groupId,
291
+ groupName: b.groupName || existing.groupName,
292
+ stores: Number( b.stores ) || 0,
293
+ products: Array.isArray( b.products ) ? b.products : existing.products,
294
+ tax: Array.isArray( b.tax ) ? b.tax : existing.tax,
295
+ amount,
296
+ totalAmount,
297
+ currency: b.currency || existing.currency,
298
+ notes: b.notes ?? existing.notes,
299
+ updatedBy: req.user?.email || req.user?.userName || '',
300
+ };
301
+ if ( b.validTill ) {
302
+ update.validTill = new Date( b.validTill );
303
+ }
304
+ if ( b.period ) {
305
+ update.period = b.period;
306
+ }
307
+ // Allow a status nudge (e.g. draft → sent) on save, but never to accepted.
308
+ if ( b.status && [ 'pending', 'sent' ].includes( b.status ) ) {
309
+ update.status = b.status;
310
+ }
311
+
312
+ await estimateService.updateOne( { _id: estimateId }, { $set: update } );
313
+ const saved = await estimateService.findOne( { _id: estimateId } );
314
+ logger.info?.( { function: 'updateEstimate', estimateId } );
315
+ return res.sendSuccess( saved );
316
+ } catch ( error ) {
317
+ logger.error( { error: error, function: 'updateEstimate' } );
318
+ return res.sendError( error, 500 );
319
+ }
320
+ }
321
+
194
322
  export async function getEstimate( req, res ) {
195
323
  try {
196
324
  const estimate = await estimateService.findOne( { _id: req.params.estimateId } );
@@ -207,7 +335,7 @@ export async function getEstimate( req, res ) {
207
335
  export async function estimateStatusUpdate( req, res ) {
208
336
  try {
209
337
  const { estimateId, status } = req.body || {};
210
- const allowed = [ 'draft', 'sent', 'accepted', 'declined', 'expired' ];
338
+ const allowed = [ 'pending', 'sent', 'accepted', 'declined', 'expired' ];
211
339
  if ( !estimateId || !allowed.includes( status ) ) {
212
340
  return res.sendError( 'estimateId and a valid status are required', 400 );
213
341
  }
@@ -230,66 +358,7 @@ export async function downloadEstimate( req, res ) {
230
358
  if ( !estimate ) {
231
359
  return res.sendError( 'Estimate not found', 404 );
232
360
  }
233
- const e = estimate._doc || estimate;
234
- const currencyType = symbolFor( e.currency );
235
- const fmt = ( n ) => Number( n || 0 ).toLocaleString( 'en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 } );
236
-
237
- const products = ( e.products || [] ).map( ( p, i ) => {
238
- let name = String( p.productName || '' ).replace( /([a-z])([A-Z])/g, '$1 $2' );
239
- name = name.charAt( 0 ).toUpperCase() + name.slice( 1 );
240
- return {
241
- index: i + 1,
242
- productName: name,
243
- description: p.description || '',
244
- hsn: p.hsn || p.hsnCode || '998314',
245
- storeCount: p.storeCount || e.stores || '',
246
- price: fmt( p.price ),
247
- amount: fmt( p.amount ),
248
- };
249
- } );
250
- const tax = ( e.tax || [] ).map( ( t ) => ( {
251
- type: t.type || t.taxName || t.name || 'GST',
252
- value: t.value ?? t.taxPercentage ?? t.percentage ?? '',
253
- taxAmount: fmt( t.taxAmount ),
254
- } ) );
255
-
256
- const statusLabelMap = { draft: 'Draft', sent: 'Sent', accepted: 'Accepted', declined: 'Declined', expired: 'Expired' };
257
- const data = {
258
- estimate: e.estimate,
259
- status: e.status,
260
- statusLabel: statusLabelMap[e.status] || e.status,
261
- companyName: e.companyName || '',
262
- companyAddress: e.companyAddress || '',
263
- GSTNumber: e.GSTNumber || '',
264
- PlaceOfSupply: e.PlaceOfSupply || '',
265
- groupName: e.groupName || '',
266
- period: e.period || '',
267
- createdDate: e.createdDate ? dayjs( e.createdDate ).format( 'DD/MM/YYYY' ) : '',
268
- validTill: e.validTill ? dayjs( e.validTill ).format( 'DD/MM/YYYY' ) : '',
269
- currencyType,
270
- amount: fmt( e.amount ),
271
- totalAmount: fmt( e.totalAmount ),
272
- products,
273
- tax,
274
- notes: e.notes || '',
275
- logo: `${JSON.parse( process.env.URL ).apiDomain}/logo.png`,
276
- };
277
-
278
- const templateHtml = fs.readFileSync( path.resolve( path.dirname( '' ) ) + '/src/hbs/estimatePdf.hbs', 'utf8' );
279
- const template = Handlebars.compile( templateHtml );
280
- const html = template( data );
281
- const file = { content: html };
282
- const options = {
283
- executablePath: '/usr/bin/chromium',
284
- args: [ '--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage', '--no-zygote', '--single-process' ],
285
- format: 'A4',
286
- margin: { top: '0.5in', right: '0.5in', bottom: '0.5in', left: '0.5in' },
287
- printBackground: true,
288
- preferCSSPageSize: true,
289
- };
290
- const pdfBuffer = await htmlpdf.generatePdf( file, options );
291
-
292
- const filename = ( e.estimate + '-' + ( e.companyName || 'estimate' ) + '.pdf' ).split( '/' ).join( '_' ).split( '"' ).join( '' ).trim();
361
+ const { pdfBuffer, filename } = await buildEstimatePdf( estimate );
293
362
  res.set( 'Content-Type', 'application/pdf' );
294
363
  res.set( 'Content-Disposition', `attachment; filename="${filename}"` );
295
364
  return res.send( pdfBuffer );
@@ -299,6 +368,71 @@ export async function downloadEstimate( req, res ) {
299
368
  }
300
369
  }
301
370
 
371
+ // Approve & send an estimate: render the PDF, email it (with the PDF attached)
372
+ // to the billing group's recipients — same recipient logic as invoice send —
373
+ // then flip the status to 'sent'. Triggered by the Approve action's confirm.
374
+ export async function sendEstimate( req, res ) {
375
+ try {
376
+ const estimateId = req.body?.estimateId || req.params?.estimateId;
377
+ if ( !estimateId ) {
378
+ return res.sendError( 'estimateId is required', 400 );
379
+ }
380
+ const estimate = await estimateService.findOne( { _id: estimateId } );
381
+ if ( !estimate ) {
382
+ return res.sendError( 'Estimate not found', 404 );
383
+ }
384
+ const e = estimate._doc || estimate;
385
+ if ( e.status === 'accepted' ) {
386
+ return res.sendError( 'An accepted estimate cannot be re-sent.', 409 );
387
+ }
388
+
389
+ // Recipients: billing group's generateInvoiceTo (TO) + invoice heads /
390
+ // assigned CSMs (CC) — identical to the invoice send flow.
391
+ let toEmails = [];
392
+ if ( e.groupId ) {
393
+ const group = await billingService.findOne( { _id: e.groupId } );
394
+ toEmails = ( group?.generateInvoiceTo || [] ).map( ( x ) => String( x || '' ).trim() ).filter( Boolean );
395
+ }
396
+ toEmails = [ ...new Set( toEmails ) ];
397
+ if ( !toEmails.length ) {
398
+ return res.sendError( 'To Email not Found — configure recipients on the billing group.', 400 );
399
+ }
400
+ const ccRaw = await getInvoiceCcEmails( e.clientId );
401
+ const toSet = new Set( toEmails.map( ( x ) => x.toLowerCase() ) );
402
+ const ccEmails = [ ...new Set( ( ccRaw || [] ).map( ( x ) => String( x || '' ).trim() ).filter( Boolean ) ) ]
403
+ .filter( ( x ) => !toSet.has( x.toLowerCase() ) );
404
+
405
+ const { pdfBuffer, filename, data } = await buildEstimatePdf( estimate );
406
+
407
+ const client = await clientService.findOne( { clientId: e.clientId }, { clientName: 1 } );
408
+ const emailData = {
409
+ ...data,
410
+ clientName: client?.clientName || e.companyName || 'Customer',
411
+ // The email is sent FROM Tango, so the sign-off is the sender — not the
412
+ // client's registered company name carried on the estimate.
413
+ companyName: 'Team Tango',
414
+ };
415
+ const emailHtml = Handlebars.compile(
416
+ fs.readFileSync( path.resolve( path.dirname( '' ) ) + '/src/hbs/estimateEmail.hbs', 'utf8' ),
417
+ )( emailData );
418
+
419
+ const SES = JSON.parse( process.env.SES );
420
+ const fromEmail = SES.accountsEmail || SES.adminEmail;
421
+ const subject = `Estimate ${e.estimate} - Tango/${client?.clientName || e.companyName || ''}`;
422
+ const attachment = { filename, content: pdfBuffer, contentType: 'application/pdf' };
423
+
424
+ await sendEmailWithSES( toEmails, subject, emailHtml, attachment, fromEmail, ccEmails.length ? ccEmails : undefined );
425
+
426
+ // Mark sent only after a successful send.
427
+ await estimateService.updateOne( { _id: estimateId }, { $set: { status: 'sent' } } );
428
+ logger.info?.( { function: 'sendEstimate', estimateId, to: toEmails, cc: ccEmails } );
429
+ return res.sendSuccess( { estimateId, status: 'sent', sentTo: toEmails } );
430
+ } catch ( error ) {
431
+ logger.error( { error: error, function: 'sendEstimate' } );
432
+ return res.sendError( error, 500 );
433
+ }
434
+ }
435
+
302
436
  export async function deleteEstimate( req, res ) {
303
437
  try {
304
438
  const estimate = await estimateService.findOne( { _id: req.params.estimateId } );
@@ -308,8 +442,10 @@ export async function deleteEstimate( req, res ) {
308
442
  if ( estimate.status === 'accepted' ) {
309
443
  return res.sendError( 'An accepted estimate cannot be deleted.', 409 );
310
444
  }
311
- await estimateService.updateOne( { _id: req.params.estimateId }, { $set: { status: 'declined' } } );
312
- return res.sendSuccess( 'Estimate removed' );
445
+ // Hard delete remove the document from the collection (not a soft
446
+ // status change).
447
+ await estimateService.deleteOne( { _id: req.params.estimateId } );
448
+ return res.sendSuccess( 'Estimate deleted' );
313
449
  } catch ( error ) {
314
450
  logger.error( { error: error, function: 'deleteEstimate' } );
315
451
  return res.sendError( error, 500 );
@@ -24,7 +24,7 @@ import { getUsdInrRate } from './brandsBilling.controller.js';
24
24
  // userAssignedStore (clientId + tangoUserType:'csm'). Returns a flat de-duped
25
25
  // array suitable for the SES CC field. Returns [] on any failure — invoice
26
26
  // email send should never fail because of missing settings or DB hiccups.
27
- async function getInvoiceCcEmails( clientId ) {
27
+ export async function getInvoiceCcEmails( clientId ) {
28
28
  const collected = [];
29
29
 
30
30
  // 1. Configured heads (global, applies to every invoice).
@@ -142,7 +142,8 @@ export async function createInvoice( req, res ) {
142
142
  clientId: req.body.clientId,
143
143
  groupId: req.body.groupId || undefined,
144
144
  groupName: req.body.groupName || '',
145
- companyName: req.body.companyName || '',
145
+ // Company (registered) name is always stored uppercase on invoices.
146
+ companyName: ( req.body.companyName || '' ).toUpperCase(),
146
147
  companyAddress: req.body.companyAddress || '',
147
148
  GSTNumber: req.body.GSTNumber || '',
148
149
  PlaceOfSupply: req.body.PlaceOfSupply || '',
@@ -272,9 +273,15 @@ export async function createInvoice( req, res ) {
272
273
 
273
274
  ] );
274
275
 
275
- let invoicedate = req.body.invoiceId ? dayjs( findInvoice.billingDate ).format( 'YYYY-MM-DD' ) : baseDate.format( 'YYYY-MM-DD' );
276
+ // billingDate is the actual invoice (creation) date even for advance
277
+ // invoices. The advance MONTH is reflected only in the product lines /
278
+ // monthOfbilling (driven by baseDate), not in the billing date itself.
279
+ // (Regenerate keeps the original invoice's billingDate.)
280
+ const creationDate = dayjs();
281
+ let invoicedate = req.body.invoiceId ? dayjs( findInvoice.billingDate ).format( 'YYYY-MM-DD' ) : creationDate.format( 'YYYY-MM-DD' );
276
282
  let daysExtend = group?.paymentTerm ? group?.paymentTerm : 30;
277
- let dueDate = baseDate.add( daysExtend, 'days' );
283
+ // Due date is relative to the billing (creation) date.
284
+ let dueDate = creationDate.add( daysExtend, 'days' );
278
285
  console.log( 'group.currencygroup.currency', group.currency );
279
286
  let data = {
280
287
  groupName: group.groupName,
@@ -285,7 +292,7 @@ export async function createInvoice( req, res ) {
285
292
  amount: Math.round( amount ),
286
293
  invoiceIndex: req.body.invoiceId ? findInvoice.invoiceIndex : invoiceNo,
287
294
  tax: taxList,
288
- companyName: group.registeredCompanyName,
295
+ companyName: ( group.registeredCompanyName || '' ).toUpperCase(),
289
296
  companyAddress: address,
290
297
  PlaceOfSupply: group.placeOfSupply,
291
298
  GSTNumber: group.gst,
@@ -372,6 +379,10 @@ async function buildAnnexureRows( invoiceInfo, getgroup ) {
372
379
  const billingMonthEnd = new Date( billingMonth.endOf( 'month' ).toISOString() );
373
380
  const monthDays = billingMonth.daysInMonth();
374
381
  const invoiceCurrency = symbolFor( invoiceInfo.currency );
382
+ // basepricing negotiatePrice is stored in INR. For non-INR invoices the
383
+ // annexure must convert it to the invoice currency, otherwise the INR number
384
+ // is shown verbatim under a $ symbol (e.g. ₹1650 rendered as "$1,650").
385
+ const annexFx = invoiceInfo.currency === 'dollar' ? ( await getUsdInrRate() ) : 1;
375
386
 
376
387
  const annexClient = await clientService.findOne( { clientId: invoiceInfo.clientId }, { 'planDetails.product': 1 } );
377
388
  const billingTypeMap = {};
@@ -432,7 +443,9 @@ async function buildAnnexureRows( invoiceInfo, getgroup ) {
432
443
  units = s.trafficCameraCount;
433
444
  }
434
445
  }
435
- const price = Number( s.standard?.negotiatePrice ) || 0;
446
+ // Convert the INR negotiatePrice into the invoice currency (annexFx = 1 for
447
+ // INR invoices, = USD→INR rate for dollar invoices, so divide).
448
+ const price = ( Number( s.standard?.negotiatePrice ) || 0 ) / annexFx;
436
449
  const runningCost = s.workingdays >= monthDays ?
437
450
  Math.round( price * units * 100 ) / 100 :
438
451
  Math.round( ( price / monthDays ) * s.workingdays * units * 100 ) / 100;
@@ -526,6 +539,10 @@ export async function invoiceDownload( req, res ) {
526
539
  billingCurrency: virtualAccount?.currency,
527
540
  virtualaccountNumber: virtualAccount ? virtualAccount?.accountNumber : '',
528
541
  virtualifsc: virtualAccount ? virtualAccount?.ifsc : '',
542
+ // GST applies only to domestic (INR) invoices. Gate the tax block on the
543
+ // invoice's OWN currency — not the payment-account currency, which is
544
+ // null/non-inr for many INR invoices and was dropping the GST rows.
545
+ gstApplicable: invoiceInfo.currency === 'inr',
529
546
  };
530
547
 
531
548
  if ( invoiceData?.tax?.length ) {
@@ -590,9 +607,20 @@ export async function invoiceDownload( req, res ) {
590
607
  // Load configured CSM + Finance heads PLUS the per-client CSMs from
591
608
  // userAssignedStore as CC recipients on the invoice mail.
592
609
  const ccEmails = await getInvoiceCcEmails( invoiceInfo.clientId );
593
- console.log( fromEmail, getgroup.generateInvoiceTo, ccEmails, attachments );
594
-
595
- const result = await sendEmailWithSES( getgroup.generateInvoiceTo, mailSubject, mailbody, attachments, fromEmail, ccEmails.length ? ccEmails : undefined );
610
+ // De-duplicate recipients so nobody gets the invoice 2-3 times:
611
+ // unique TO list, and drop from CC anyone already in TO (overlap
612
+ // between generateInvoiceTo, invoice heads and assigned CSMs was the
613
+ // cause of duplicate mails).
614
+ const toEmails = [ ...new Set(
615
+ ( getgroup.generateInvoiceTo || [] ).map( ( e ) => String( e || '' ).trim() ).filter( Boolean ),
616
+ ) ];
617
+ const toSet = new Set( toEmails.map( ( e ) => e.toLowerCase() ) );
618
+ const dedupedCc = [ ...new Set(
619
+ ( ccEmails || [] ).map( ( e ) => String( e || '' ).trim() ).filter( Boolean ),
620
+ ) ].filter( ( e ) => !toSet.has( e.toLowerCase() ) );
621
+ console.log( fromEmail, toEmails, dedupedCc, attachments );
622
+
623
+ const result = await sendEmailWithSES( toEmails, mailSubject, mailbody, attachments, fromEmail, dedupedCc.length ? dedupedCc : undefined );
596
624
  console.log( result );
597
625
  let logObj = {
598
626
  userName: req.user?.userName,
@@ -707,6 +735,8 @@ async function buildInvoicePdfBuffer( invoiceId ) {
707
735
  billingCurrency: virtualAccount?.currency,
708
736
  virtualaccountNumber: virtualAccount ? virtualAccount?.accountNumber : '',
709
737
  virtualifsc: virtualAccount ? virtualAccount?.ifsc : '',
738
+ // GST applies only to domestic (INR) invoices; gate on the invoice currency.
739
+ gstApplicable: invoiceInfo.currency === 'inr',
710
740
  };
711
741
 
712
742
  if ( invoiceData?.tax?.length ) {
@@ -818,6 +848,24 @@ export async function invoiceDownloadBulk( req, res ) {
818
848
  }
819
849
  }
820
850
 
851
+ // Bank/beneficiary details for the invoice preview. Fixed beneficiary account
852
+ // — hardcoded to match the PDF exactly (NOT read from the DB).
853
+ export async function invoiceBankDetails( req, res ) {
854
+ try {
855
+ return res.sendSuccess( {
856
+ beneficiaryName: 'Tango IT Solutions India Private Limited',
857
+ accountNumber: '50200027441433',
858
+ ifsc: 'HDFC0000386',
859
+ swift: 'HDFCINBBCHE',
860
+ branch: 'Santhome, Chennai',
861
+ paymentType: 'Online',
862
+ } );
863
+ } catch ( error ) {
864
+ logger.error( { error: error, function: 'invoiceBankDetails', invoiceId: req.params.invoiceId } );
865
+ return res.sendError( error, 500 );
866
+ }
867
+ }
868
+
821
869
  export async function invoiceAnnexure( req, res ) {
822
870
  try {
823
871
  const invoiceInfo = await invoiceService.findOne( { _id: req.params.invoiceId } );
@@ -1290,8 +1338,12 @@ async function standardPrice( group, getClient, baseDate ) {
1290
1338
  return product;
1291
1339
  } );
1292
1340
 
1293
- // Combine overallStore and eachStore products
1294
- return [ ...products, ...eachStoreProducts ];
1341
+ // Combine overallStore and eachStore products. Sort by product name so the
1342
+ // persisted order is deterministic — MongoDB $group output order isn't
1343
+ // guaranteed, which made products "interchange" between the Plans view, the
1344
+ // stored invoice and regenerated invoices / PDF.
1345
+ return [ ...products, ...eachStoreProducts ]
1346
+ .sort( ( a, b ) => String( a.productName || '' ).localeCompare( String( b.productName || '' ) ) );
1295
1347
  }
1296
1348
 
1297
1349
 
@@ -1425,7 +1477,10 @@ async function stepPrice( group, getClient ) {
1425
1477
  },
1426
1478
  },
1427
1479
  {
1480
+ // productName first so order is deterministic across views/PDF, then
1481
+ // workingdays so step rows stay grouped consistently.
1428
1482
  $sort: {
1483
+ productName: 1,
1429
1484
  workingdays: -1,
1430
1485
  },
1431
1486
  },
@@ -1854,6 +1909,24 @@ export async function clientInvoiceList( req, res ) {
1854
1909
  }
1855
1910
 
1856
1911
  if ( req.body.export ) {
1912
+ // Due Status — mirrors the UI cell (getDueStatus) exactly so the column
1913
+ // matches what reviewers see on screen. Paid / no-due-date show a dash;
1914
+ // otherwise it's overdue / due today / due in N days from TODAY at
1915
+ // day-granularity (time-of-day ignored on both ends).
1916
+ const today = dayjs().startOf( 'day' );
1917
+ const dueStatusOf = ( inv ) => {
1918
+ if ( inv.paymentStatus === 'paid' || !inv.dueDate ) {
1919
+ return '—';
1920
+ }
1921
+ const days = dayjs( inv.dueDate ).startOf( 'day' ).diff( today, 'day' );
1922
+ if ( days < 0 ) {
1923
+ return `Overdue by ${-days} day${days === -1 ? '' : 's'}`;
1924
+ }
1925
+ if ( days === 0 ) {
1926
+ return 'Due today';
1927
+ }
1928
+ return `Due in ${days} day${days === 1 ? '' : 's'}`;
1929
+ };
1857
1930
  const exportdata = [];
1858
1931
  count.forEach( ( element ) => {
1859
1932
  exportdata.push( {
@@ -1862,9 +1935,13 @@ export async function clientInvoiceList( req, res ) {
1862
1935
  'Invoice #': element.invoice,
1863
1936
  'Billing date': dayjs( element.billingDate ).format( 'DD MMM, YYYY' ),
1864
1937
  'Due Date': element.dueDate ? dayjs( element.dueDate ).format( 'DD MMM, YYYY' ) : '',
1938
+ 'Due Status': dueStatusOf( element ),
1865
1939
  'Group Name': element.groupName,
1866
1940
  'Amount Excl. GST': element.amount,
1867
- 'GST Amount': element.gstAmount,
1941
+ // GST only applies to domestic (INR) invoices. International invoices
1942
+ // (dollar / euro / etc.) are billed without GST — show a dash to
1943
+ // match the on-screen column rather than a misleading 0.
1944
+ 'GST Amount': element.currency === 'inr' ? element.gstAmount : '—',
1868
1945
  'Amount Incl. GST': element.totalAmount,
1869
1946
  'Stores': element.stores,
1870
1947
  'Payment Status': element.paymentStatus,
@@ -2468,7 +2545,10 @@ async function transitionInvoiceStatus( req, res, fromStatus, toStatus ) {
2468
2545
  return res.sendError( 'Invoice not found', 404 );
2469
2546
  }
2470
2547
 
2471
- if ( invoice.status !== fromStatus ) {
2548
+ // Legacy 'pending' invoices are equivalent to the first CSM stage, so the
2549
+ // CSM transition accepts either 'pendingCsm' or 'pending' as the source.
2550
+ const acceptedFrom = fromStatus === 'pendingCsm' ? [ 'pendingCsm', 'pending' ] : [ fromStatus ];
2551
+ if ( !acceptedFrom.includes( invoice.status ) ) {
2472
2552
  return res.sendError(
2473
2553
  `Invoice is currently at status '${invoice.status}', not '${fromStatus}'. Another user may have advanced it.`,
2474
2554
  409,