tango-app-api-payment-subscription 3.4.4 → 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.
- package/docs/invoice-approval-pipeline.md +44 -0
- package/package.json +7 -2
- package/scripts/grant-tango-approval-permissions.js +84 -0
- package/scripts/migrate-invoice-status-pipeline.js +61 -0
- package/src/controllers/applicationDefault.controllers.js +51 -0
- package/src/controllers/billing.controllers.js +2 -1
- package/src/controllers/brandsBilling.controller.js +382 -3
- package/src/controllers/invoice.controller.js +455 -9
- package/src/controllers/payment.controller.js +4 -3
- package/src/controllers/paymentSubscription.controllers.js +23 -9
- package/src/dtos/validation.dtos.js +55 -0
- package/src/hbs/invoicePdf.hbs +8 -0
- package/src/routes/brandsBilling.routes.js +17 -1
- package/src/routes/invoice.routes.js +11 -2
- package/src/services/applicationDefault.service.js +13 -0
- package/src/utils/currency.js +14 -0
|
@@ -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: '
|
|
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'
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
|
@@ -1821,3 +2217,53 @@ export async function deleteInvoice( req, res ) {
|
|
|
1821
2217
|
}
|
|
1822
2218
|
}
|
|
1823
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
|
+
|
|
@@ -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
|
|
380
|
-
'Credit': transaction?.transactionType === 'credit' ? `${transaction?.currency
|
|
381
|
-
'Balance credit': `${transaction?.balanceCreditCurrency
|
|
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
|
|
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
|
|
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
|
|
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'
|
|
2979
|
-
item.price = item.price.toLocaleString( 'en-IN'
|
|
2980
|
-
item.currency = clientDetails?.paymentInvoice?.currencyType
|
|
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
|
|
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
|
-
|
|
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
|
|
3869
|
+
currencyType: symbolFor( clientDetails?.paymentInvoice?.currencyType ),
|
|
3856
3870
|
total: amount.toFixed( 2 ),
|
|
3857
3871
|
invoiceDate,
|
|
3858
3872
|
dueDate,
|