tango-app-api-payment-subscription 3.4.4 → 3.5.1

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';
@@ -1727,6 +2116,13 @@ export async function updateInvoice( req, res ) {
1727
2116
  }
1728
2117
  } );
1729
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
+
1730
2126
  if ( req.body.billingDate ) {
1731
2127
  updateData.createdAt = new Date( req.body.billingDate );
1732
2128
  }
@@ -1814,6 +2210,20 @@ export async function deleteInvoice( req, res ) {
1814
2210
 
1815
2211
  await invoiceService.deleteRecord( { _id: invoiceId } );
1816
2212
 
2213
+ const logObj = {
2214
+ userName: req.user?.userName,
2215
+ email: req.user?.email,
2216
+ clientId: invoice.clientId,
2217
+ logSubType: 'invoiceDeleted',
2218
+ logType: 'invoice',
2219
+ date: new Date(),
2220
+ changes: [ `Invoice ${invoice.invoice} has been deleted by ${req.user?.email}` ],
2221
+ eventType: 'delete',
2222
+ timestamp: new Date(),
2223
+ showTo: [ 'tango' ],
2224
+ };
2225
+ insertOpenSearchData( JSON.parse( process.env.OPENSEARCH ).activityLog, logObj );
2226
+
1817
2227
  res.sendSuccess( { message: 'Invoice deleted successfully' } );
1818
2228
  } catch ( error ) {
1819
2229
  logger.error( { error: error, function: 'deleteInvoice' } );
@@ -1821,3 +2231,53 @@ export async function deleteInvoice( req, res ) {
1821
2231
  }
1822
2232
  }
1823
2233
 
2234
+ async function transitionInvoiceStatus( req, res, fromStatus, toStatus ) {
2235
+ try {
2236
+ const { invoiceId } = req.body;
2237
+
2238
+ const invoice = await invoiceService.findOne( { _id: invoiceId } );
2239
+ if ( !invoice ) {
2240
+ return res.sendError( 'Invoice not found', 404 );
2241
+ }
2242
+
2243
+ if ( invoice.status !== fromStatus ) {
2244
+ return res.sendError(
2245
+ `Invoice is currently at status '${invoice.status}', not '${fromStatus}'. Another user may have advanced it.`,
2246
+ 409,
2247
+ );
2248
+ }
2249
+
2250
+ await invoiceService.updateOne( { _id: invoiceId }, { status: toStatus } );
2251
+
2252
+ insertOpenSearchData( JSON.parse( process.env.OPENSEARCH ).activityLog, {
2253
+ userName: req.user?.userName,
2254
+ email: req.user?.email,
2255
+ clientId: invoice.clientId,
2256
+ logSubType: 'invoiceStatusTransition',
2257
+ logType: 'invoice',
2258
+ date: new Date(),
2259
+ changes: [ `Invoice ${invoice.invoice} advanced from ${fromStatus} to ${toStatus} by ${req.user?.email}` ],
2260
+ eventType: '',
2261
+ timestamp: new Date(),
2262
+ showTo: [ 'tango' ],
2263
+ } );
2264
+
2265
+ return res.sendSuccess( { invoiceId, fromStatus, status: toStatus } );
2266
+ } catch ( error ) {
2267
+ logger.error( { error: error, function: 'transitionInvoiceStatus', fromStatus, toStatus, invoiceId: req.body?.invoiceId } );
2268
+ return res.sendError( error, 500 );
2269
+ }
2270
+ }
2271
+
2272
+ export async function approveInvoiceCsm( req, res ) {
2273
+ return transitionInvoiceStatus( req, res, 'pendingCsm', 'pendingFinance' );
2274
+ }
2275
+
2276
+ export async function approveInvoiceFinance( req, res ) {
2277
+ return transitionInvoiceStatus( req, res, 'pendingFinance', 'pendingApproval' );
2278
+ }
2279
+
2280
+ export async function approveInvoiceApproval( req, res ) {
2281
+ return transitionInvoiceStatus( req, res, 'pendingApproval', 'approved' );
2282
+ }
2283
+
@@ -5,6 +5,7 @@ import * as invoiceService from '../services/invoice.service.js';
5
5
  import { updateOrder, verifySignature, verifyWebhook } from 'tango-app-api-middleware/src/utils/razorPay.js';
6
6
  import { aggregateTransaction, createTransaction } from '../services/transaction.service.js';
7
7
  import dayjs from 'dayjs';
8
+ import { symbolFor } from '../utils/currency.js';
8
9
 
9
10
 
10
11
  export const getVirtualAccount = async ( req, res ) => {
@@ -376,9 +377,9 @@ export const transactionList = async ( req, res ) => {
376
377
  exportResult.push( {
377
378
  'Billing date': transaction?.billingDate ? dayjs( transaction.billingDate ).format( 'DD MMM, YYYY' ) : '',
378
379
  'Transaction': transaction?.transactionType === 'debt' ? `Consumption for ${dayjs( transaction?.billingDate ).format( 'MMM YYYY' )}- ${transaction?.groupName}` : 'Paid Credit',
379
- 'Debit': transaction?.transactionType === 'debt' ? `${transaction?.currency === 'inr' ? '₹' : '$'} ${transaction.amount}` : '',
380
- 'Credit': transaction?.transactionType === 'credit' ? `${transaction?.currency === 'inr' ? '₹' : '$'} ${transaction.amount}` : '',
381
- 'Balance credit': `${transaction?.balanceCreditCurrency === 'inr' ? '₹' : '$'} ${transaction.balanceCredit}` || '',
380
+ 'Debit': transaction?.transactionType === 'debt' ? `${symbolFor( transaction?.currency )} ${transaction.amount}` : '',
381
+ 'Credit': transaction?.transactionType === 'credit' ? `${symbolFor( transaction?.currency )} ${transaction.amount}` : '',
382
+ 'Balance credit': `${symbolFor( transaction?.balanceCreditCurrency )} ${transaction.balanceCredit}` || '',
382
383
 
383
384
  } );
384
385
  }
@@ -13,6 +13,7 @@ import * as cameraService from '../services/camera.service.js';
13
13
  import * as billingService from '../services/billing.service.js';
14
14
  import * as paymentAccountService from '../services/paymentAccount.service.js';
15
15
  import * as taggingService from '../services/tagging.service.js';
16
+ import { symbolFor } from '../utils/currency.js';
16
17
 
17
18
 
18
19
  import dayjs from 'dayjs';
@@ -2417,7 +2418,9 @@ export const pricingListUpdate = async ( req, res ) => {
2417
2418
  amount = amount + item.price;
2418
2419
  subtotal = subtotal+item.originalPrice;
2419
2420
  origPrice = origPrice + ( item.basePrice * count );
2420
- item.price = item.originalPrice.toFixed( 2 );
2421
+ item.price = String( Math.round( item.originalPrice ) );
2422
+ item.originalPrice = Math.round( item.originalPrice );
2423
+ item.basePrice = Math.round( item.basePrice );
2421
2424
  } );
2422
2425
  let discountAmount = origPrice - amount;
2423
2426
  let discountPercentage = ( discountAmount / origPrice ) * 100;
@@ -2425,7 +2428,7 @@ export const pricingListUpdate = async ( req, res ) => {
2425
2428
  ...productList,
2426
2429
  amount: amount.toFixed( 2 ),
2427
2430
  subtotal: subtotal.toFixed( 2 ),
2428
- currencyType: req.body.client.paymentInvoice.currencyType == 'dollar' ? '$' : '₹',
2431
+ currencyType: symbolFor( req.body.client.paymentInvoice.currencyType ),
2429
2432
  total: ( parseFloat( subtotal ) + parseFloat( ( amount * GST ) / 100 ) ).toFixed( 2 ),
2430
2433
  final: amount.toFixed( 2 ),
2431
2434
  discount: `${discountPercentage.toFixed( 2 )}% (-${discountAmount.toFixed( 2 )})`,
@@ -2666,7 +2669,7 @@ export const updatedRevisedPrice = async ( req, res ) => {
2666
2669
  extendDays: clientDetails.paymentInvoice.extendPaymentPeriodDays,
2667
2670
  address: clientDetails.billingDetails.billingAddress,
2668
2671
  amount: amount.toFixed( 2 ),
2669
- currencyType: clientDetails.paymentInvoice.currencyType == 'dollar' ? '$' : '₹',
2672
+ currencyType: symbolFor( clientDetails.paymentInvoice.currencyType ),
2670
2673
  discount: `${req.body.discount.toFixed( 2 )}% (-${discountAmount.toFixed( 2 )})`,
2671
2674
  total: ( parseFloat( amount ) + parseFloat( ( amount * gst ) / 100 ) ).toFixed( 2 ),
2672
2675
  final: req.body.revisedAmount.toFixed( 2 ),
@@ -2975,9 +2978,9 @@ export const invoiceDownload = async ( req, res ) => {
2975
2978
 
2976
2979
  amount = amount + item.price;
2977
2980
 
2978
- item.basePrice = item.basePrice.toLocaleString( 'en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 } );
2979
- item.price = item.price.toLocaleString( 'en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 } );
2980
- item.currency = clientDetails?.paymentInvoice?.currencyType == 'dollar' ? '$' : '₹';
2981
+ item.basePrice = Math.round( item.basePrice ).toLocaleString( 'en-IN' );
2982
+ item.price = Math.round( item.price ).toLocaleString( 'en-IN' );
2983
+ item.currency = symbolFor( clientDetails?.paymentInvoice?.currencyType );
2981
2984
  } );
2982
2985
  for ( let tax of invoiceInfo.tax ) {
2983
2986
  tax.taxAmount = tax.taxAmount.toLocaleString( 'en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 } );
@@ -3001,7 +3004,7 @@ export const invoiceDownload = async ( req, res ) => {
3001
3004
  PoNum: '',
3002
3005
  amountwords: AmountinWords,
3003
3006
  Terms: `Term ${clientDetails.paymentInvoice.extendPaymentPeriodDays}`,
3004
- currencyType: clientDetails?.paymentInvoice?.currencyType == 'dollar' ? '$' : '₹',
3007
+ currencyType: symbolFor( clientDetails?.paymentInvoice?.currencyType ),
3005
3008
  totalAmount: invoiceInfo.totalAmount.toLocaleString( 'en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 } ),
3006
3009
  invoiceDate,
3007
3010
  dueDate,
@@ -3053,7 +3056,18 @@ export const invoiceDownload = async ( req, res ) => {
3053
3056
  return res.sendSuccess( result );
3054
3057
  }
3055
3058
  }
3056
- res.set( 'Content-Disposition', 'attachment; filename="generated-pdf.pdf"' );
3059
+ const invoiceMonth = invoiceInfo.billingDate ?
3060
+ dayjs( invoiceInfo.billingDate ).format( 'MMMM' ) :
3061
+ ( invoiceInfo.monthOfbilling ? dayjs( invoiceInfo.monthOfbilling, 'MM' ).format( 'MMMM' ) : '' );
3062
+ let groupForName;
3063
+ if ( invoiceInfo.groupId ) {
3064
+ groupForName = await billingService.findOne( { _id: invoiceInfo.groupId } );
3065
+ }
3066
+ const brandName = groupForName?.registeredCompanyName || invoiceInfo.companyName || '';
3067
+ const safeFilename = `${invoiceInfo.invoice}-${invoiceMonth}-${brandName}.pdf`
3068
+ .replace( /[\r\n"\\]/g, '' )
3069
+ .trim();
3070
+ res.set( 'Content-Disposition', `attachment; filename="${safeFilename}"` );
3057
3071
  res.set( 'Content-Type', 'application/pdf' );
3058
3072
  res.send( pdfBuffer );
3059
3073
  } );
@@ -3852,7 +3866,7 @@ export const invoiceRevised = async ( req, res ) => {
3852
3866
  ...invoiceDetails._doc,
3853
3867
  amount: amount,
3854
3868
  discount: invoiceDetails.discount,
3855
- currencyType: clientDetails?.paymentInvoice?.currencyType == 'dollar' ? '$' : '₹',
3869
+ currencyType: symbolFor( clientDetails?.paymentInvoice?.currencyType ),
3856
3870
  total: amount.toFixed( 2 ),
3857
3871
  invoiceDate,
3858
3872
  dueDate,