tango-app-api-payment-subscription 3.4.3 → 3.5.0

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.
@@ -9,8 +9,58 @@ import Handlebars from '../utils/validations/helper/handlebar.helper.js';
9
9
  import fs from 'fs';
10
10
  import path from 'path';
11
11
  import htmlpdf from 'html-pdf-node';
12
+ import archiver from 'archiver';
12
13
  import * as basepricingService from '../services/basePrice.service.js';
13
14
  import * as paymentAccountService from '../services/paymentAccount.service.js';
15
+ import { symbolFor } from '../utils/currency.js';
16
+ import { invoiceStatusEnum } from '../dtos/validation.dtos.js';
17
+ import { findOneApplicationDefault } from '../services/applicationDefault.service.js';
18
+ import * as assignedStoreService from '../services/assignedStore.service.js';
19
+
20
+ // Pulls CSM + Finance head emails (stored under applicationDefault
21
+ // type=invoice, subType=heads) AND the per-client CSMs assigned via
22
+ // userAssignedStore (clientId + tangoUserType:'csm'). Returns a flat de-duped
23
+ // array suitable for the SES CC field. Returns [] on any failure — invoice
24
+ // email send should never fail because of missing settings or DB hiccups.
25
+ async function getInvoiceCcEmails( clientId ) {
26
+ const collected = [];
27
+
28
+ // 1. Configured heads (global, applies to every invoice).
29
+ try {
30
+ const doc = await findOneApplicationDefault( { type: 'invoice', subType: 'heads' } );
31
+ const rows = ( doc && Array.isArray( doc.data ) ) ? doc.data : [];
32
+ for ( const row of rows ) {
33
+ const value = row?.email;
34
+ if ( Array.isArray( value ) ) {
35
+ for ( const e of value ) {
36
+ if ( typeof e === 'string' && e.trim() !== '' ) collected.push( e.trim() );
37
+ }
38
+ } else if ( typeof value === 'string' && value.trim() !== '' ) {
39
+ collected.push( value.trim() );
40
+ }
41
+ }
42
+ } catch ( err ) {
43
+ logger.error( { error: err, function: 'getInvoiceCcEmails.heads' } );
44
+ }
45
+
46
+ // 2. Per-client CSMs from userAssignedStore.
47
+ if ( clientId ) {
48
+ try {
49
+ const assigned = await assignedStoreService.find(
50
+ { clientId, tangoUserType: 'csm' },
51
+ { userEmail: 1 },
52
+ );
53
+ for ( const a of ( assigned || [] ) ) {
54
+ const e = a?.userEmail;
55
+ if ( typeof e === 'string' && e.trim() !== '' ) collected.push( e.trim() );
56
+ }
57
+ } catch ( err ) {
58
+ logger.error( { error: err, function: 'getInvoiceCcEmails.csm', clientId } );
59
+ }
60
+ }
61
+
62
+ return Array.from( new Set( collected ) );
63
+ }
14
64
 
15
65
  export async function createInvoice( req, res ) {
16
66
  try {
@@ -129,7 +179,7 @@ export async function createInvoice( req, res ) {
129
179
  groupId: group._id,
130
180
  invoice: req.body.invoiceId ? req.body.invoiceId : `INV-${Finacialyear}-${invoiceNo}`,
131
181
  products: products,
132
- status: 'pending',
182
+ status: 'pendingCsm',
133
183
  amount: Math.round( amount ),
134
184
  invoiceIndex: req.body.invoiceId ? findInvoice.invoiceIndex : invoiceNo,
135
185
  tax: taxList,
@@ -220,9 +270,9 @@ export async function invoiceDownload( req, res ) {
220
270
  let [ firstWord, secondWord ] = item.productName.replace( /([a-z])([A-Z])/g, '$1 $2' ).split( ' ' );
221
271
  firstWord = firstWord.charAt( 0 ).toUpperCase() + firstWord.slice( 1 );
222
272
  item.productName = firstWord + ' ' + secondWord;
223
- item.price = item.price.toLocaleString( 'en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 } );
273
+ item.price = Math.round( item.price ).toLocaleString( 'en-IN' );
224
274
  item.amount = item.amount.toLocaleString( 'en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 } );
225
- item.currency = clientDetails?.paymentInvoice?.currencyType == 'dollar' ? '$' : '₹';
275
+ item.currency = symbolFor( clientDetails?.paymentInvoice?.currencyType );
226
276
  } );
227
277
 
228
278
 
@@ -254,7 +304,7 @@ export async function invoiceDownload( req, res ) {
254
304
  PoNum: getgroup?.po,
255
305
  amountwords: AmountinWords,
256
306
  Terms: `Term ${getgroup?.paymentTerm ? getgroup?.paymentTerm : '30'}`,
257
- currencyType: virtualAccount?.currency == 'dollar' ? '$' : '₹',
307
+ currencyType: symbolFor( virtualAccount?.currency ),
258
308
  totalAmount: invoiceInfo.totalAmount.toLocaleString( 'en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 } ),
259
309
  invoiceDate,
260
310
  dueDate,
@@ -325,7 +375,7 @@ export async function invoiceDownload( req, res ) {
325
375
  storeName: '$stores.storeName',
326
376
  edgefirstFileDate: '$stores.edgefirstFileDate',
327
377
  workingdays: '$stores.products.workingdays',
328
- currencyType: { $literal: clientDetails?.paymentInvoice?.currencyType == 'dollar' ? '$' : '₹' },
378
+ currencyType: { $literal: symbolFor( clientDetails?.paymentInvoice?.currencyType ) },
329
379
  },
330
380
  },
331
381
  {
@@ -430,7 +480,16 @@ export async function invoiceDownload( req, res ) {
430
480
  content: html,
431
481
  };
432
482
  let options = {
433
- format: 'A4', margin: {
483
+ executablePath: '/usr/bin/chromium',
484
+ args: [
485
+ '--no-sandbox',
486
+ '--disable-setuid-sandbox',
487
+ '--disable-dev-shm-usage',
488
+ '--no-zygote',
489
+ '--single-process',
490
+ ],
491
+ format: 'A4',
492
+ margin: {
434
493
  top: '0.5in',
435
494
  right: '0.5in',
436
495
  bottom: '0.5in',
@@ -459,9 +518,12 @@ export async function invoiceDownload( req, res ) {
459
518
  }
460
519
  const SES = JSON.parse( process.env.SES );
461
520
  let fromEmail = SES.accountsEmail;
462
- console.log( fromEmail, getgroup.generateInvoiceTo, attachments );
521
+ // Load configured CSM + Finance heads PLUS the per-client CSMs from
522
+ // userAssignedStore as CC recipients on the invoice mail.
523
+ const ccEmails = await getInvoiceCcEmails( invoiceInfo.clientId );
524
+ console.log( fromEmail, getgroup.generateInvoiceTo, ccEmails, attachments );
463
525
 
464
- const result = await sendEmailWithSES( getgroup.generateInvoiceTo, mailSubject, mailbody, attachments, fromEmail );
526
+ const result = await sendEmailWithSES( getgroup.generateInvoiceTo, mailSubject, mailbody, attachments, fromEmail, ccEmails.length ? ccEmails : undefined );
465
527
  console.log( result );
466
528
  let logObj = {
467
529
  userName: req.user?.userName,
@@ -478,11 +540,28 @@ export async function invoiceDownload( req, res ) {
478
540
  console.log( logObj );
479
541
  insertOpenSearchData( JSON.parse( process.env.OPENSEARCH ).activityLog, logObj );
480
542
  if ( result ) {
543
+ // Pipeline guard: this approve-via-download flow can only complete
544
+ // the final stage. Earlier stages must use /approveInvoiceCsm
545
+ // and /approveInvoiceFinance.
546
+ const current = await invoiceService.findOne( { _id: req.params.invoiceId } );
547
+ if ( current?.status !== 'pendingApproval' ) {
548
+ return res.sendError(
549
+ `Cannot approve via download — invoice is at status '${current?.status}'. Advance it through the approval stages first.`,
550
+ 409,
551
+ );
552
+ }
481
553
  await invoiceService.updateOne( { _id: req.params.invoiceId }, { status: req.body.status } );
482
554
  return res.sendSuccess( result );
483
555
  }
484
556
  }
485
- res.set( 'Content-Disposition', 'attachment; filename="generated-pdf.pdf"' );
557
+ const invoiceMonth = invoiceInfo.billingDate ?
558
+ dayjs( invoiceInfo.billingDate ).format( 'MMMM' ) :
559
+ ( invoiceInfo.monthOfbilling ? dayjs( invoiceInfo.monthOfbilling, 'MM' ).format( 'MMMM' ) : '' );
560
+ const brandName = getgroup?.registeredCompanyName || invoiceInfo.companyName || '';
561
+ const safeFilename = `${invoiceInfo.invoice}-${invoiceMonth}-${brandName}.pdf`
562
+ .replace( /[\r\n"\\]/g, '' )
563
+ .trim();
564
+ res.set( 'Content-Disposition', `attachment; filename="${safeFilename}"` );
486
565
  res.set( 'Content-Type', 'application/pdf' );
487
566
  res.send( pdfBuffer );
488
567
  } );
@@ -493,6 +572,316 @@ export async function invoiceDownload( req, res ) {
493
572
  return res.sendError( error, 500 );
494
573
  }
495
574
  }
575
+
576
+ async function buildInvoicePdfBuffer( invoiceId ) {
577
+ let invoiceInfo = await invoiceService.findOne( { _id: invoiceId } );
578
+ if ( !invoiceInfo ) {
579
+ throw new Error( `Invoice ${invoiceId} not found` );
580
+ }
581
+
582
+ let clientDetails = await clientService.findOne( { clientId: invoiceInfo.clientId } );
583
+ invoiceInfo.products.forEach( ( item, index ) => {
584
+ item.index = index + 1;
585
+ let [ firstWord, secondWord ] = item.productName.replace( /([a-z])([A-Z])/g, '$1 $2' ).split( ' ' );
586
+ firstWord = firstWord.charAt( 0 ).toUpperCase() + firstWord.slice( 1 );
587
+ item.productName = firstWord + ' ' + secondWord;
588
+ item.price = Math.round( item.price ).toLocaleString( 'en-IN' );
589
+ item.amount = item.amount.toLocaleString( 'en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 } );
590
+ item.currency = symbolFor( clientDetails?.paymentInvoice?.currencyType );
591
+ } );
592
+
593
+ let invoiceDate = dayjs( invoiceInfo.billingDate ).format( 'DD/MM/YYYY' );
594
+ invoiceInfo.totalAmount = Math.round( invoiceInfo.totalAmount );
595
+ let AmountinWords = inWords( invoiceInfo.totalAmount );
596
+ let getgroup;
597
+ if ( invoiceInfo.groupId ) {
598
+ getgroup = await billingService.findOne( { _id: invoiceInfo.groupId } );
599
+ }
600
+ let days = getgroup?.paymentTerm ? getgroup?.paymentTerm : '30';
601
+ let dueDate = invoiceInfo?.dueDate ? dayjs( invoiceInfo?.dueDate ).format( 'DD/MM/YYYY' ) : dayjs().add( days, 'days' ).format( 'DD/MM/YYYY' );
602
+
603
+ let virtualAccount = await paymentAccountService.findOneAccount( { clientId: invoiceInfo.clientId } );
604
+
605
+ let invoiceData = {
606
+ ...invoiceInfo._doc,
607
+ clientName: clientDetails.clientName,
608
+ amount: invoiceInfo.amount.toLocaleString( 'en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 } ),
609
+ extendDays: getgroup?.paymentTerm ? getgroup?.paymentTerm : '30',
610
+ address: clientDetails.billingDetails.billingAddress,
611
+ subtotal: invoiceInfo.amount.toLocaleString( 'en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 } ),
612
+ companyName: invoiceInfo.companyName,
613
+ companyAddress: invoiceInfo.companyAddress,
614
+ PlaceOfSupply: invoiceInfo.PlaceOfSupply,
615
+ GSTNumber: invoiceInfo.GSTNumber,
616
+ PoNum: getgroup?.po,
617
+ amountwords: AmountinWords,
618
+ Terms: `Term ${getgroup?.paymentTerm ? getgroup?.paymentTerm : '30'}`,
619
+ currencyType: symbolFor( virtualAccount?.currency ),
620
+ totalAmount: invoiceInfo.totalAmount.toLocaleString( 'en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 } ),
621
+ invoiceDate,
622
+ dueDate,
623
+ discountPercentage: invoiceInfo.discountPercentage ? invoiceInfo.discountPercentage : 0,
624
+ discountAmount: invoiceInfo.discountAmount ? invoiceInfo.discountAmount : 0,
625
+ logo: `${JSON.parse( process.env.URL ).apiDomain}/logo.png`,
626
+ uidomain: `${JSON.parse( process.env.URL ).domain}`,
627
+ attachAnnexure: getgroup?.attachAnnexure,
628
+ billingAddressLineOne: getgroup?.addressLineOne ? getgroup?.addressLineOne : invoiceInfo?.companyAddress,
629
+ billingAddressLineTwo: getgroup?.addressLineTwo,
630
+ billingCountry: getgroup?.country,
631
+ billingState: getgroup?.state,
632
+ billingCity: getgroup?.city,
633
+ billingPinCode: getgroup?.pinCode,
634
+ billingCurrency: virtualAccount?.currency,
635
+ virtualaccountNumber: virtualAccount ? virtualAccount?.accountNumber : '',
636
+ virtualifsc: virtualAccount ? virtualAccount?.ifsc : '',
637
+ };
638
+
639
+ if ( invoiceData?.tax?.length ) {
640
+ invoiceData.tax.forEach( ( item ) => {
641
+ if ( item.taxAmount ) {
642
+ item.taxAmount = Number( item.taxAmount );
643
+ item.taxAmount = item.taxAmount.toLocaleString( 'en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 } );
644
+ }
645
+ } );
646
+ }
647
+
648
+ const currentMonthDays = dayjs().daysInMonth();
649
+
650
+ if ( getgroup?.attachAnnexure ) {
651
+ let annuxureData = await dailyPricingService.aggregate( [
652
+ { $match: { clientId: invoiceInfo.clientId } },
653
+ { $sort: { dateISO: -1 } },
654
+ { $limit: 1 },
655
+ { $project: { stores: { $filter: { input: '$stores', as: 'item', cond: { $in: [ '$$item.storeId', getgroup?.stores ] } } } } },
656
+ { $unwind: { path: '$stores', preserveNullAndEmptyArrays: false } },
657
+ { $unwind: { path: '$stores.products', preserveNullAndEmptyArrays: false } },
658
+ { $project: {
659
+ productName: '$stores.products.productName',
660
+ storeId: '$stores.storeId',
661
+ storeName: '$stores.storeName',
662
+ edgefirstFileDate: '$stores.edgefirstFileDate',
663
+ workingdays: '$stores.products.workingdays',
664
+ currencyType: { $literal: symbolFor( clientDetails?.paymentInvoice?.currencyType ) },
665
+ } },
666
+ { $sort: { productName: 1, workingdays: -1 } },
667
+ { $match: { workingdays: { $gt: 0 } } },
668
+ { $lookup: {
669
+ from: 'basepricings',
670
+ let: { clientId: invoiceInfo.clientId },
671
+ pipeline: [
672
+ { $match: { $expr: { $eq: [ '$clientId', '$$clientId' ] } } },
673
+ { $project: { standard: 1, step: 1 } },
674
+ ],
675
+ as: 'basepricing',
676
+ } },
677
+ { $unwind: { path: '$basepricing', preserveNullAndEmptyArrays: true } },
678
+ { $project: {
679
+ productName: 1,
680
+ workingdays: 1,
681
+ storeName: 1,
682
+ currencyType: 1,
683
+ edgefirstFileDate: { $ifNull: [ '$edgefirstFileDate', '$processfirstFileDate' ] },
684
+ storeId: 1,
685
+ standard: { $filter: { input: '$basepricing.standard', as: 'standard', cond: { $eq: [ '$$standard.productName', '$productName' ] } } },
686
+ step: '$basepricing.step',
687
+ } },
688
+ { $unwind: { path: '$standard', preserveNullAndEmptyArrays: true } },
689
+ { $project: {
690
+ productName: {
691
+ $concat: [
692
+ { $toUpper: { $substr: [ '$productName', 0, 1 ] } },
693
+ { $substr: [ '$productName', 1, { $strLenCP: '$productName' } ] },
694
+ ],
695
+ },
696
+ currencyType: 1,
697
+ workingdays: 1,
698
+ storeName: 1,
699
+ edgefirstFileDate: { $dateToString: { format: '%Y-%m-%d', date: '$edgefirstFileDate' } },
700
+ storeId: 1,
701
+ period: { $cond: { if: { $lt: [ '$workingdays', currentMonthDays ] }, then: 'prorate', else: 'fullmonth' } },
702
+ standardPrice: '$standard.negotiatePrice',
703
+ runningCost: { $round: [ { $multiply: [ { $divide: [ '$standard.negotiatePrice', currentMonthDays ] }, '$workingdays' ] }, 2 ] },
704
+ } },
705
+ ] );
706
+ invoiceData.annuxureData = annuxureData;
707
+ }
708
+
709
+ const templateHtml = fs.readFileSync( path.resolve( path.dirname( '' ) ) + '/src/hbs/invoicePdf.hbs', 'utf8' );
710
+ const template = Handlebars.compile( templateHtml );
711
+ const html = template( { ...invoiceData } );
712
+ let file = { content: html };
713
+ let options = {
714
+ executablePath: '/usr/bin/chromium',
715
+ args: [ '--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage', '--no-zygote', '--single-process' ],
716
+ format: 'A4',
717
+ margin: { top: '0.5in', right: '0.5in', bottom: '0.5in', left: '0.5in' },
718
+ printBackground: true,
719
+ preferCSSPageSize: true,
720
+ };
721
+
722
+ const pdfBuffer = await htmlpdf.generatePdf( file, options );
723
+
724
+ const invoiceMonth = invoiceInfo.billingDate ?
725
+ dayjs( invoiceInfo.billingDate ).format( 'MMMM' ) :
726
+ ( invoiceInfo.monthOfbilling ? dayjs( invoiceInfo.monthOfbilling, 'MM' ).format( 'MMMM' ) : '' );
727
+ const brandName = getgroup?.registeredCompanyName || invoiceInfo.companyName || '';
728
+ const filename = `${invoiceInfo.invoice}-${invoiceMonth}-${brandName}.pdf`
729
+ .replace( /[\r\n"\\]/g, '' )
730
+ .trim();
731
+
732
+ return { pdfBuffer, filename };
733
+ }
734
+
735
+ export async function invoiceDownloadBulk( req, res ) {
736
+ try {
737
+ const invoiceIds = req.body?.invoiceIds;
738
+ if ( !Array.isArray( invoiceIds ) || invoiceIds.length === 0 ) {
739
+ return res.sendError( 'invoiceIds must be a non-empty array', 400 );
740
+ }
741
+
742
+ let brandSlug = 'invoices';
743
+ try {
744
+ const firstInvoice = await invoiceService.findOne( { _id: invoiceIds[0] } );
745
+ if ( firstInvoice ) {
746
+ const firstClient = await clientService.findOne( { clientId: firstInvoice.clientId } );
747
+ if ( firstClient?.clientName ) {
748
+ brandSlug = firstClient.clientName
749
+ .toLowerCase()
750
+ .replace( /[^a-z0-9]+/g, '-' )
751
+ .replace( /^-|-$/g, '' ) || 'invoices';
752
+ }
753
+ }
754
+ } catch ( e ) {
755
+ logger.error( { error: e, function: 'invoiceDownloadBulk.brandSlug' } );
756
+ }
757
+
758
+ const zipFilename = `invoices-${brandSlug}-${dayjs().format( 'YYYY-MM-DD' )}.zip`;
759
+ res.set( 'Content-Type', 'application/zip' );
760
+ res.set( 'Content-Disposition', `attachment; filename="${zipFilename}"` );
761
+
762
+ const archive = archiver( 'zip', { zlib: { level: 6 } } );
763
+ archive.on( 'error', ( err ) => {
764
+ logger.error( { error: err, function: 'invoiceDownloadBulk.archiver' } );
765
+ archive.abort();
766
+ if ( !res.headersSent ) {
767
+ return res.sendError( err, 500 );
768
+ }
769
+ res.destroy( err );
770
+ } );
771
+ archive.pipe( res );
772
+
773
+ const errors = [];
774
+ for ( const id of invoiceIds ) {
775
+ try {
776
+ const { pdfBuffer, filename } = await buildInvoicePdfBuffer( id );
777
+ archive.append( pdfBuffer, { name: filename } );
778
+ } catch ( err ) {
779
+ logger.error( { error: err, function: 'invoiceDownloadBulk', invoiceId: id } );
780
+ errors.push( { id, reason: err?.message || String( err ) } );
781
+ }
782
+ }
783
+
784
+ if ( errors.length > 0 ) {
785
+ const errorsText = errors
786
+ .map( ( e ) => `${e.id}\t${e.reason}` )
787
+ .join( '\n' );
788
+ archive.append(
789
+ `The following invoices could not be included in this archive:\n\nID\tReason\n${errorsText}\n`,
790
+ { name: 'errors.txt' },
791
+ );
792
+ }
793
+
794
+ await archive.finalize();
795
+ } catch ( error ) {
796
+ logger.error( { error: error, function: 'invoiceDownloadBulk' } );
797
+ if ( !res.headersSent ) {
798
+ return res.sendError( error, 500 );
799
+ }
800
+ }
801
+ }
802
+
803
+ export async function invoiceAnnexure( req, res ) {
804
+ try {
805
+ const invoiceInfo = await invoiceService.findOne( { _id: req.params.invoiceId } );
806
+ if ( !invoiceInfo ) {
807
+ return res.sendError( 'Invoice not found', 404 );
808
+ }
809
+
810
+ let getgroup;
811
+ if ( invoiceInfo.groupId ) {
812
+ getgroup = await billingService.findOne( { _id: invoiceInfo.groupId } );
813
+ }
814
+
815
+ if ( !getgroup?.attachAnnexure ) {
816
+ return res.sendSuccess( { data: [] } );
817
+ }
818
+
819
+ const clientDetails = await clientService.findOne( { clientId: invoiceInfo.clientId } );
820
+ const currentMonthDays = dayjs().daysInMonth();
821
+
822
+ const annexureData = await dailyPricingService.aggregate( [
823
+ { $match: { clientId: invoiceInfo.clientId } },
824
+ { $sort: { dateISO: -1 } },
825
+ { $limit: 1 },
826
+ { $project: { stores: { $filter: { input: '$stores', as: 'item', cond: { $in: [ '$$item.storeId', getgroup?.stores ?? [] ] } } } } },
827
+ { $unwind: { path: '$stores', preserveNullAndEmptyArrays: false } },
828
+ { $unwind: { path: '$stores.products', preserveNullAndEmptyArrays: false } },
829
+ { $project: {
830
+ productName: '$stores.products.productName',
831
+ storeId: '$stores.storeId',
832
+ storeName: '$stores.storeName',
833
+ edgefirstFileDate: '$stores.edgefirstFileDate',
834
+ workingdays: '$stores.products.workingdays',
835
+ currencyType: { $literal: symbolFor( clientDetails?.paymentInvoice?.currencyType ) },
836
+ } },
837
+ { $sort: { productName: 1, workingdays: -1 } },
838
+ { $match: { workingdays: { $gt: 0 } } },
839
+ { $lookup: {
840
+ from: 'basepricings',
841
+ let: { clientId: invoiceInfo.clientId },
842
+ pipeline: [
843
+ { $match: { $expr: { $eq: [ '$clientId', '$$clientId' ] } } },
844
+ { $project: { standard: 1, step: 1 } },
845
+ ],
846
+ as: 'basepricing',
847
+ } },
848
+ { $unwind: { path: '$basepricing', preserveNullAndEmptyArrays: true } },
849
+ { $project: {
850
+ productName: 1,
851
+ workingdays: 1,
852
+ storeName: 1,
853
+ currencyType: 1,
854
+ edgefirstFileDate: { $ifNull: [ '$edgefirstFileDate', '$processfirstFileDate' ] },
855
+ storeId: 1,
856
+ standard: { $filter: { input: '$basepricing.standard', as: 'standard', cond: { $eq: [ '$$standard.productName', '$productName' ] } } },
857
+ step: '$basepricing.step',
858
+ } },
859
+ { $unwind: { path: '$standard', preserveNullAndEmptyArrays: true } },
860
+ { $project: {
861
+ productName: {
862
+ $concat: [
863
+ { $toUpper: { $substr: [ '$productName', 0, 1 ] } },
864
+ { $substr: [ '$productName', 1, { $strLenCP: '$productName' } ] },
865
+ ],
866
+ },
867
+ currencyType: 1,
868
+ workingdays: 1,
869
+ storeName: 1,
870
+ edgefirstFileDate: { $dateToString: { format: '%Y-%m-%d', date: '$edgefirstFileDate' } },
871
+ storeId: 1,
872
+ period: { $cond: { if: { $lt: [ '$workingdays', currentMonthDays ] }, then: 'prorate', else: 'fullmonth' } },
873
+ standardPrice: '$standard.negotiatePrice',
874
+ runningCost: { $round: [ { $multiply: [ { $divide: [ '$standard.negotiatePrice', currentMonthDays ] }, '$workingdays' ] }, 2 ] },
875
+ } },
876
+ ] );
877
+
878
+ return res.sendSuccess( { data: annexureData } );
879
+ } catch ( error ) {
880
+ logger.error( { error: error, function: 'invoiceAnnexure', invoiceId: req.params.invoiceId } );
881
+ return res.sendError( error, 500 );
882
+ }
883
+ }
884
+
496
885
  function inWords( num ) {
497
886
  let a = [ '', 'one ', 'two ', 'three ', 'four ', 'five ', 'six ', 'seven ', 'eight ', 'nine ', 'ten ', 'eleven ', 'twelve ', 'thirteen ', 'fourteen ', 'fifteen ', 'sixteen ', 'seventeen ', 'eighteen ', 'nineteen ' ]; let b = [ '', '', 'twenty', 'thirty', 'forty', 'fifty', 'sixty', 'seventy', 'eighty', 'ninety' ];
498
887
  if ( ( num = num.toString() ).length > 9 ) return 'overflow';
@@ -607,7 +996,8 @@ async function standardPrice( group, getClient, baseDate ) {
607
996
  },
608
997
  storeCount: { $sum: 1 },
609
998
  totalZoneCount: { $sum: '$zoneCount' },
610
- totalCameraCount: { $sum: '$cameraCount' },
999
+ totalTrafficCameraCount: { $sum: '$trafficCameraCount' },
1000
+ totalZoneCameraCount: { $sum: '$zoneCameraCount' },
611
1001
  },
612
1002
  },
613
1003
  {
@@ -617,7 +1007,8 @@ async function standardPrice( group, getClient, baseDate ) {
617
1007
  workingdays: '$_id.workingdays',
618
1008
  storeCount: '$storeCount',
619
1009
  totalZoneCount: '$totalZoneCount',
620
- totalCameraCount: '$totalCameraCount',
1010
+ totalTrafficCameraCount: '$totalTrafficCameraCount',
1011
+ totalZoneCameraCount: '$totalZoneCameraCount',
621
1012
  },
622
1013
  },
623
1014
  {
@@ -651,7 +1042,8 @@ async function standardPrice( group, getClient, baseDate ) {
651
1042
  workingdays: 1,
652
1043
  storeCount: 1,
653
1044
  totalZoneCount: 1,
654
- totalCameraCount: 1,
1045
+ totalTrafficCameraCount: 1,
1046
+ totalZoneCameraCount: 1,
655
1047
  standard: {
656
1048
  $filter: {
657
1049
  input: '$basepricing.standard',
@@ -678,7 +1070,8 @@ async function standardPrice( group, getClient, baseDate ) {
678
1070
  },
679
1071
  storeCount: 1,
680
1072
  totalZoneCount: 1,
681
- totalCameraCount: 1,
1073
+ totalTrafficCameraCount: 1,
1074
+ totalZoneCameraCount: 1,
682
1075
  standardPrice: '$standard.negotiatePrice',
683
1076
  runningCost: {
684
1077
  $round: [
@@ -725,7 +1118,8 @@ async function standardPrice( group, getClient, baseDate ) {
725
1118
  },
726
1119
  storeCount: { $sum: '$storeCount' },
727
1120
  totalZoneCount: { $sum: '$totalZoneCount' },
728
- totalCameraCount: { $sum: '$totalCameraCount' },
1121
+ totalTrafficCameraCount: { $sum: '$totalTrafficCameraCount' },
1122
+ totalZoneCameraCount: { $sum: '$totalZoneCameraCount' },
729
1123
  amount: { $sum: '$perstorecost' },
730
1124
  },
731
1125
  },
@@ -739,7 +1133,8 @@ async function standardPrice( group, getClient, baseDate ) {
739
1133
  },
740
1134
  storeCount: 1,
741
1135
  totalZoneCount: 1,
742
- totalCameraCount: 1,
1136
+ totalTrafficCameraCount: 1,
1137
+ totalZoneCameraCount: 1,
743
1138
  amount: 1,
744
1139
  description: {
745
1140
  $cond: {
@@ -843,8 +1238,12 @@ async function standardPrice( group, getClient, baseDate ) {
843
1238
  if ( store.productName === 'tangoZone' ) {
844
1239
  if ( productBillingType === 'perZone' && store.zoneCount > 0 ) {
845
1240
  storeCount = store.zoneCount;
846
- } else if ( productBillingType === 'perCamera' && store.cameraCount > 0 ) {
847
- storeCount = store.cameraCount;
1241
+ } else if ( productBillingType === 'perCamera' && store.zoneCameraCount > 0 ) {
1242
+ storeCount = store.zoneCameraCount;
1243
+ }
1244
+ } else if ( store.productName === 'tangoTraffic' ) {
1245
+ if ( productBillingType === 'perCamera' && store.trafficCameraCount > 0 ) {
1246
+ storeCount = store.trafficCameraCount;
848
1247
  }
849
1248
  }
850
1249
 
@@ -872,20 +1271,26 @@ async function standardPrice( group, getClient, baseDate ) {
872
1271
  // Filter out eachStore products from aggregated results
873
1272
  products = products.filter( ( p ) => !eachStoreProductNames.includes( p.productName ) );
874
1273
 
875
- // Adjust storeCount based on billingType for tangoZone (overallStore products only)
1274
+ // Adjust storeCount based on billingType for tangoZone and tangoTraffic (overallStore products only)
876
1275
  products = products.map( ( product ) => {
877
1276
  let productBillingType = billingTypeMap[product.productName] || 'perStore';
878
1277
  if ( product.productName === 'tangoZone' ) {
879
1278
  if ( productBillingType === 'perZone' && product.totalZoneCount > 0 ) {
880
1279
  product.amount = product.price * product.totalZoneCount;
881
1280
  product.storeCount = product.totalZoneCount;
882
- } else if ( productBillingType === 'perCamera' && product.totalCameraCount > 0 ) {
883
- product.amount = product.price * product.totalCameraCount;
884
- product.storeCount = product.totalCameraCount;
1281
+ } else if ( productBillingType === 'perCamera' && product.totalZoneCameraCount > 0 ) {
1282
+ product.amount = product.price * product.totalZoneCameraCount;
1283
+ product.storeCount = product.totalZoneCameraCount;
1284
+ }
1285
+ } else if ( product.productName === 'tangoTraffic' ) {
1286
+ if ( productBillingType === 'perCamera' && product.totalTrafficCameraCount > 0 ) {
1287
+ product.amount = product.price * product.totalTrafficCameraCount;
1288
+ product.storeCount = product.totalTrafficCameraCount;
885
1289
  }
886
1290
  }
887
1291
  delete product.totalZoneCount;
888
- delete product.totalCameraCount;
1292
+ delete product.totalTrafficCameraCount;
1293
+ delete product.totalZoneCameraCount;
889
1294
  return product;
890
1295
  } );
891
1296
 
@@ -991,7 +1396,8 @@ async function stepPrice( group, getClient ) {
991
1396
  },
992
1397
  storeCount: { $sum: 1 },
993
1398
  totalZoneCount: { $sum: '$zoneCount' },
994
- totalCameraCount: { $sum: '$cameraCount' },
1399
+ totalTrafficCameraCount: { $sum: '$trafficCameraCount' },
1400
+ totalZoneCameraCount: { $sum: '$zoneCameraCount' },
995
1401
  },
996
1402
  },
997
1403
  {
@@ -1001,7 +1407,8 @@ async function stepPrice( group, getClient ) {
1001
1407
  workingdays: '$_id.workingdays',
1002
1408
  storeCount: '$storeCount',
1003
1409
  totalZoneCount: '$totalZoneCount',
1004
- totalCameraCount: '$totalCameraCount',
1410
+ totalTrafficCameraCount: '$totalTrafficCameraCount',
1411
+ totalZoneCameraCount: '$totalZoneCameraCount',
1005
1412
  },
1006
1413
  },
1007
1414
  {
@@ -1083,8 +1490,12 @@ async function stepPrice( group, getClient ) {
1083
1490
  if ( store.productName === 'tangoZone' ) {
1084
1491
  if ( productBillingType === 'perZone' && store.zoneCount > 0 ) {
1085
1492
  storeCount = store.zoneCount;
1086
- } else if ( productBillingType === 'perCamera' && store.cameraCount > 0 ) {
1087
- storeCount = store.cameraCount;
1493
+ } else if ( productBillingType === 'perCamera' && store.zoneCameraCount > 0 ) {
1494
+ storeCount = store.zoneCameraCount;
1495
+ }
1496
+ } else if ( store.productName === 'tangoTraffic' ) {
1497
+ if ( productBillingType === 'perCamera' && store.trafficCameraCount > 0 ) {
1498
+ storeCount = store.trafficCameraCount;
1088
1499
  }
1089
1500
  }
1090
1501
 
@@ -1114,18 +1525,23 @@ async function stepPrice( group, getClient ) {
1114
1525
  // Filter out eachStore products from aggregated results
1115
1526
  products = products.filter( ( p ) => !eachStoreProductNames.includes( p.productName ) );
1116
1527
 
1117
- // Adjust storeCount based on billingType for tangoZone (overallStore only)
1528
+ // Adjust storeCount based on billingType for tangoZone and tangoTraffic (overallStore only)
1118
1529
  products = products.map( ( product ) => {
1119
1530
  let productBillingType = billingTypeMap[product.productName] || 'perStore';
1120
1531
  if ( product.productName === 'tangoZone' ) {
1121
1532
  if ( productBillingType === 'perZone' && product.totalZoneCount > 0 ) {
1122
1533
  product.storeCount = product.totalZoneCount;
1123
- } else if ( productBillingType === 'perCamera' && product.totalCameraCount > 0 ) {
1124
- product.storeCount = product.totalCameraCount;
1534
+ } else if ( productBillingType === 'perCamera' && product.totalZoneCameraCount > 0 ) {
1535
+ product.storeCount = product.totalZoneCameraCount;
1536
+ }
1537
+ } else if ( product.productName === 'tangoTraffic' ) {
1538
+ if ( productBillingType === 'perCamera' && product.totalTrafficCameraCount > 0 ) {
1539
+ product.storeCount = product.totalTrafficCameraCount;
1125
1540
  }
1126
1541
  }
1127
1542
  delete product.totalZoneCount;
1128
- delete product.totalCameraCount;
1543
+ delete product.totalTrafficCameraCount;
1544
+ delete product.totalZoneCameraCount;
1129
1545
  return product;
1130
1546
  } );
1131
1547
 
@@ -1700,6 +2116,13 @@ export async function updateInvoice( req, res ) {
1700
2116
  }
1701
2117
  } );
1702
2118
 
2119
+ // Guard: updateInvoice is an admin override that can set arbitrary status
2120
+ // values (including skipping pipeline stages or moving backward). Reject
2121
+ // values outside the enum to keep the DB consistent.
2122
+ if ( updateData.status && !invoiceStatusEnum.includes( updateData.status ) ) {
2123
+ return res.sendError( `Invalid status value: ${updateData.status}. Must be one of: ${invoiceStatusEnum.join( ', ' )}`, 400 );
2124
+ }
2125
+
1703
2126
  if ( req.body.billingDate ) {
1704
2127
  updateData.createdAt = new Date( req.body.billingDate );
1705
2128
  }
@@ -1773,3 +2196,74 @@ export async function getClientBasePricing( req, res ) {
1773
2196
  }
1774
2197
  }
1775
2198
 
2199
+ export async function deleteInvoice( req, res ) {
2200
+ try {
2201
+ const { invoiceId } = req.params;
2202
+ if ( !invoiceId ) {
2203
+ return res.sendError( 'Invoice ID is required', 400 );
2204
+ }
2205
+
2206
+ const invoice = await invoiceService.findOne( { _id: invoiceId } );
2207
+ if ( !invoice ) {
2208
+ return res.sendError( 'Invoice not found', 404 );
2209
+ }
2210
+
2211
+ await invoiceService.deleteRecord( { _id: invoiceId } );
2212
+
2213
+ res.sendSuccess( { message: 'Invoice deleted successfully' } );
2214
+ } catch ( error ) {
2215
+ logger.error( { error: error, function: 'deleteInvoice' } );
2216
+ return res.sendError( error, 500 );
2217
+ }
2218
+ }
2219
+
2220
+ async function transitionInvoiceStatus( req, res, fromStatus, toStatus ) {
2221
+ try {
2222
+ const { invoiceId } = req.body;
2223
+
2224
+ const invoice = await invoiceService.findOne( { _id: invoiceId } );
2225
+ if ( !invoice ) {
2226
+ return res.sendError( 'Invoice not found', 404 );
2227
+ }
2228
+
2229
+ if ( invoice.status !== fromStatus ) {
2230
+ return res.sendError(
2231
+ `Invoice is currently at status '${invoice.status}', not '${fromStatus}'. Another user may have advanced it.`,
2232
+ 409,
2233
+ );
2234
+ }
2235
+
2236
+ await invoiceService.updateOne( { _id: invoiceId }, { status: toStatus } );
2237
+
2238
+ insertOpenSearchData( JSON.parse( process.env.OPENSEARCH ).activityLog, {
2239
+ userName: req.user?.userName,
2240
+ email: req.user?.email,
2241
+ clientId: invoice.clientId,
2242
+ logSubType: 'invoiceStatusTransition',
2243
+ logType: 'invoice',
2244
+ date: new Date(),
2245
+ changes: [ `Invoice ${invoice.invoice} advanced from ${fromStatus} to ${toStatus} by ${req.user?.email}` ],
2246
+ eventType: '',
2247
+ timestamp: new Date(),
2248
+ showTo: [ 'tango' ],
2249
+ } );
2250
+
2251
+ return res.sendSuccess( { invoiceId, fromStatus, status: toStatus } );
2252
+ } catch ( error ) {
2253
+ logger.error( { error: error, function: 'transitionInvoiceStatus', fromStatus, toStatus, invoiceId: req.body?.invoiceId } );
2254
+ return res.sendError( error, 500 );
2255
+ }
2256
+ }
2257
+
2258
+ export async function approveInvoiceCsm( req, res ) {
2259
+ return transitionInvoiceStatus( req, res, 'pendingCsm', 'pendingFinance' );
2260
+ }
2261
+
2262
+ export async function approveInvoiceFinance( req, res ) {
2263
+ return transitionInvoiceStatus( req, res, 'pendingFinance', 'pendingApproval' );
2264
+ }
2265
+
2266
+ export async function approveInvoiceApproval( req, res ) {
2267
+ return transitionInvoiceStatus( req, res, 'pendingApproval', 'approved' );
2268
+ }
2269
+