tango-app-api-payment-subscription 3.5.1 → 3.5.3
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/package.json
CHANGED
|
@@ -481,25 +481,33 @@ export async function latestDailyPricing( req, res ) {
|
|
|
481
481
|
const products = store.products || [];
|
|
482
482
|
if ( products.length > 0 ) {
|
|
483
483
|
products.forEach( ( product ) => {
|
|
484
|
+
// Per-product zeroing: tangoTraffic rows only carry the traffic
|
|
485
|
+
// camera count; tangoZone rows only carry zone camera + zone count.
|
|
486
|
+
// Previously every row repeated all three values, which made
|
|
487
|
+
// tangoTraffic and tangoZone rows look identical in the export.
|
|
488
|
+
const isTraffic = product.productName === 'tangoTraffic';
|
|
489
|
+
const isZone = product.productName === 'tangoZone';
|
|
484
490
|
exportdata.push( {
|
|
485
491
|
'Store Name': store.storeName,
|
|
486
492
|
'Store ID': store.storeId,
|
|
487
493
|
'Product': product.productName,
|
|
488
|
-
'Traffic Camera Count': store.trafficCameraCount || 0,
|
|
489
|
-
'Zone Camera Count': store.zoneCameraCount || 0,
|
|
490
|
-
'Zone Count': store.zoneCount || 0,
|
|
494
|
+
'Traffic Camera Count': isTraffic ? ( store.trafficCameraCount || 0 ) : 0,
|
|
495
|
+
'Zone Camera Count': isZone ? ( store.zoneCameraCount || 0 ) : 0,
|
|
496
|
+
'Zone Count': isZone ? ( store.zoneCount || 0 ) : 0,
|
|
491
497
|
'Working Days': product.workingdays || 0,
|
|
492
498
|
'Status': store.status || '',
|
|
493
499
|
} );
|
|
494
500
|
} );
|
|
495
501
|
} else {
|
|
502
|
+
// No product attached — leave all camera/zone columns at 0 so an
|
|
503
|
+
// unattributed row never carries product-specific counts.
|
|
496
504
|
exportdata.push( {
|
|
497
505
|
'Store Name': store.storeName,
|
|
498
506
|
'Store ID': store.storeId,
|
|
499
507
|
'Product': '',
|
|
500
|
-
'Traffic Camera Count':
|
|
501
|
-
'Zone Camera Count':
|
|
502
|
-
'Zone Count':
|
|
508
|
+
'Traffic Camera Count': 0,
|
|
509
|
+
'Zone Camera Count': 0,
|
|
510
|
+
'Zone Count': 0,
|
|
503
511
|
'Working Days': 0,
|
|
504
512
|
'Status': store.status || '',
|
|
505
513
|
} );
|
|
@@ -1014,7 +1022,11 @@ export async function bulkDownloadBillingGroups( req, res ) {
|
|
|
1014
1022
|
|
|
1015
1023
|
export async function bulkUpdateBillingGroups( req, res ) {
|
|
1016
1024
|
try {
|
|
1017
|
-
|
|
1025
|
+
// The frontend sends the XLSX as base64 in JSON (see billing.service.ts).
|
|
1026
|
+
// Switching off multipart sidestepped multer's "Unexpected end of form"
|
|
1027
|
+
// failures we were seeing in production.
|
|
1028
|
+
const fileBase64 = req.body?.fileBase64;
|
|
1029
|
+
if ( !fileBase64 || typeof fileBase64 !== 'string' ) {
|
|
1018
1030
|
return res.sendError( 'No file uploaded', 400 );
|
|
1019
1031
|
}
|
|
1020
1032
|
|
|
@@ -1023,9 +1035,22 @@ export async function bulkUpdateBillingGroups( req, res ) {
|
|
|
1023
1035
|
return res.sendError( 'clientId form field is required', 400 );
|
|
1024
1036
|
}
|
|
1025
1037
|
|
|
1038
|
+
let fileBuffer;
|
|
1039
|
+
try {
|
|
1040
|
+
fileBuffer = Buffer.from( fileBase64, 'base64' );
|
|
1041
|
+
} catch ( decodeErr ) {
|
|
1042
|
+
logger.error( { error: decodeErr, function: 'bulkUpdateBillingGroups.decode' } );
|
|
1043
|
+
return res.sendError( 'Invalid file payload', 400 );
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
// Match the 5MB cap multer was enforcing.
|
|
1047
|
+
if ( fileBuffer.length > 5 * 1024 * 1024 ) {
|
|
1048
|
+
return res.sendError( 'File too large (max 5MB)', 413 );
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1026
1051
|
let rawRows;
|
|
1027
1052
|
try {
|
|
1028
|
-
const wb = XLSX.read(
|
|
1053
|
+
const wb = XLSX.read( fileBuffer, { type: 'buffer' } );
|
|
1029
1054
|
const sheet = wb.Sheets[wb.SheetNames[0]];
|
|
1030
1055
|
rawRows = XLSX.utils.sheet_to_json( sheet, { defval: '' } );
|
|
1031
1056
|
} catch ( parseErr ) {
|
|
@@ -2,6 +2,7 @@ import * as invoiceService from '../services/invoice.service.js';
|
|
|
2
2
|
import * as dailyPricingService from '../services/dailyPrice.service.js';
|
|
3
3
|
import * as clientService from '../services/clientPayment.services.js';
|
|
4
4
|
import * as billingService from '../services/billing.service.js';
|
|
5
|
+
import mongoose from 'mongoose';
|
|
5
6
|
import dayjs from 'dayjs';
|
|
6
7
|
import { logger, checkFileExist, signedUrl, download, sendEmailWithSES, insertOpenSearchData } from 'tango-app-api-middleware';
|
|
7
8
|
// import Handlebars from 'handlebars';
|
|
@@ -65,6 +66,25 @@ async function getInvoiceCcEmails( clientId ) {
|
|
|
65
66
|
export async function createInvoice( req, res ) {
|
|
66
67
|
try {
|
|
67
68
|
let invoiceGroupList = [];
|
|
69
|
+
// Optional groupIds filter — when present, only those billing groups will
|
|
70
|
+
// be processed. Lets the UI generate an invoice for a specific subset of
|
|
71
|
+
// groups instead of every group on the client. Strings are matched against
|
|
72
|
+
// billing _id.
|
|
73
|
+
const groupIdsFilter = Array.isArray( req.body.groupIds ) && req.body.groupIds.length > 0 ?
|
|
74
|
+
req.body.groupIds :
|
|
75
|
+
null;
|
|
76
|
+
const groupObjectIdFilter = groupIdsFilter ?
|
|
77
|
+
groupIdsFilter
|
|
78
|
+
.map( ( id ) => {
|
|
79
|
+
try {
|
|
80
|
+
return new mongoose.Types.ObjectId( id );
|
|
81
|
+
} catch {
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
} )
|
|
85
|
+
.filter( ( id ) => id !== null ) :
|
|
86
|
+
null;
|
|
87
|
+
|
|
68
88
|
if ( req.body.allClient ) {
|
|
69
89
|
if ( req.body.clientList && req.body.clientList.length > 0 ) {
|
|
70
90
|
req.body.clientList = req.body.clientList;
|
|
@@ -74,12 +94,12 @@ export async function createInvoice( req, res ) {
|
|
|
74
94
|
}
|
|
75
95
|
|
|
76
96
|
for ( let client of req.body.clientList ) {
|
|
97
|
+
const matchStage = { clientId: client };
|
|
98
|
+
if ( groupObjectIdFilter && groupObjectIdFilter.length ) {
|
|
99
|
+
matchStage._id = { $in: groupObjectIdFilter };
|
|
100
|
+
}
|
|
77
101
|
let invoiceGroup = await billingService.aggregatebilling( [
|
|
78
|
-
{
|
|
79
|
-
$match: {
|
|
80
|
-
clientId: client,
|
|
81
|
-
},
|
|
82
|
-
},
|
|
102
|
+
{ $match: matchStage },
|
|
83
103
|
] );
|
|
84
104
|
for ( let invGrp of invoiceGroup ) {
|
|
85
105
|
invoiceGroupList.push( invGrp );
|
|
@@ -95,7 +115,14 @@ export async function createInvoice( req, res ) {
|
|
|
95
115
|
|
|
96
116
|
for ( let group of invoiceGroupList ) {
|
|
97
117
|
let Finacialyear = getCurrentFinancialYear();
|
|
98
|
-
|
|
118
|
+
// Scope the highest-index lookup to invoices created in the current FY
|
|
119
|
+
// (invoice IDs are `INV-${FY}-${index}`). Without this scope the new FY
|
|
120
|
+
// would continue the previous year's sequence instead of resetting.
|
|
121
|
+
let previousinvoice = await invoiceService.findandsort(
|
|
122
|
+
{ invoice: { $regex: `^INV-${Finacialyear}-` } },
|
|
123
|
+
{},
|
|
124
|
+
{ invoiceIndex: -1 },
|
|
125
|
+
);
|
|
99
126
|
let invoiceNo = '00001';
|
|
100
127
|
if ( previousinvoice && previousinvoice.length > 0 ) {
|
|
101
128
|
invoiceNo = Number( previousinvoice[0].invoiceIndex ) + 1;
|
|
@@ -174,6 +201,7 @@ export async function createInvoice( req, res ) {
|
|
|
174
201
|
let invoicedate = req.body.invoiceId ? dayjs( findInvoice.billingDate ).format( 'YYYY-MM-DD' ) : baseDate.format( 'YYYY-MM-DD' );
|
|
175
202
|
let daysExtend = group?.paymentTerm ? group?.paymentTerm : 30;
|
|
176
203
|
let dueDate = baseDate.add( daysExtend, 'days' );
|
|
204
|
+
console.log( 'group.currencygroup.currency', group.currency );
|
|
177
205
|
let data = {
|
|
178
206
|
groupName: group.groupName,
|
|
179
207
|
groupId: group._id,
|
|
@@ -265,6 +293,11 @@ export async function invoiceDownload( req, res ) {
|
|
|
265
293
|
let invoiceInfo = await invoiceService.findOne( { _id: req.params.invoiceId } );
|
|
266
294
|
if ( invoiceInfo ) {
|
|
267
295
|
let clientDetails = await clientService.findOne( { clientId: invoiceInfo.clientId } );
|
|
296
|
+
// The invoice records its own currency at creation time (from the
|
|
297
|
+
// billing group). That's the source of truth for the PDF — using
|
|
298
|
+
// client.paymentInvoice or virtualAccount.currency causes historical
|
|
299
|
+
// invoices to re-render in the wrong currency if those settings change.
|
|
300
|
+
const invoiceCurrency = symbolFor( invoiceInfo.currency );
|
|
268
301
|
invoiceInfo.products.forEach( ( item, index ) => {
|
|
269
302
|
item.index = index + 1;
|
|
270
303
|
let [ firstWord, secondWord ] = item.productName.replace( /([a-z])([A-Z])/g, '$1 $2' ).split( ' ' );
|
|
@@ -272,7 +305,7 @@ export async function invoiceDownload( req, res ) {
|
|
|
272
305
|
item.productName = firstWord + ' ' + secondWord;
|
|
273
306
|
item.price = Math.round( item.price ).toLocaleString( 'en-IN' );
|
|
274
307
|
item.amount = item.amount.toLocaleString( 'en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 } );
|
|
275
|
-
item.currency =
|
|
308
|
+
item.currency = invoiceCurrency;
|
|
276
309
|
} );
|
|
277
310
|
|
|
278
311
|
|
|
@@ -304,7 +337,7 @@ export async function invoiceDownload( req, res ) {
|
|
|
304
337
|
PoNum: getgroup?.po,
|
|
305
338
|
amountwords: AmountinWords,
|
|
306
339
|
Terms: `Term ${getgroup?.paymentTerm ? getgroup?.paymentTerm : '30'}`,
|
|
307
|
-
currencyType:
|
|
340
|
+
currencyType: invoiceCurrency,
|
|
308
341
|
totalAmount: invoiceInfo.totalAmount.toLocaleString( 'en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 } ),
|
|
309
342
|
invoiceDate,
|
|
310
343
|
dueDate,
|
|
@@ -375,7 +408,7 @@ export async function invoiceDownload( req, res ) {
|
|
|
375
408
|
storeName: '$stores.storeName',
|
|
376
409
|
edgefirstFileDate: '$stores.edgefirstFileDate',
|
|
377
410
|
workingdays: '$stores.products.workingdays',
|
|
378
|
-
currencyType: { $literal:
|
|
411
|
+
currencyType: { $literal: invoiceCurrency },
|
|
379
412
|
},
|
|
380
413
|
},
|
|
381
414
|
{
|
|
@@ -580,6 +613,10 @@ async function buildInvoicePdfBuffer( invoiceId ) {
|
|
|
580
613
|
}
|
|
581
614
|
|
|
582
615
|
let clientDetails = await clientService.findOne( { clientId: invoiceInfo.clientId } );
|
|
616
|
+
// Source of truth for the PDF currency is the invoice's own `currency`
|
|
617
|
+
// field, recorded at creation from the billing group. See invoiceDownload
|
|
618
|
+
// above for the same pattern.
|
|
619
|
+
const invoiceCurrency = symbolFor( invoiceInfo.currency );
|
|
583
620
|
invoiceInfo.products.forEach( ( item, index ) => {
|
|
584
621
|
item.index = index + 1;
|
|
585
622
|
let [ firstWord, secondWord ] = item.productName.replace( /([a-z])([A-Z])/g, '$1 $2' ).split( ' ' );
|
|
@@ -587,7 +624,7 @@ async function buildInvoicePdfBuffer( invoiceId ) {
|
|
|
587
624
|
item.productName = firstWord + ' ' + secondWord;
|
|
588
625
|
item.price = Math.round( item.price ).toLocaleString( 'en-IN' );
|
|
589
626
|
item.amount = item.amount.toLocaleString( 'en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 } );
|
|
590
|
-
item.currency =
|
|
627
|
+
item.currency = invoiceCurrency;
|
|
591
628
|
} );
|
|
592
629
|
|
|
593
630
|
let invoiceDate = dayjs( invoiceInfo.billingDate ).format( 'DD/MM/YYYY' );
|
|
@@ -616,7 +653,7 @@ async function buildInvoicePdfBuffer( invoiceId ) {
|
|
|
616
653
|
PoNum: getgroup?.po,
|
|
617
654
|
amountwords: AmountinWords,
|
|
618
655
|
Terms: `Term ${getgroup?.paymentTerm ? getgroup?.paymentTerm : '30'}`,
|
|
619
|
-
currencyType:
|
|
656
|
+
currencyType: invoiceCurrency,
|
|
620
657
|
totalAmount: invoiceInfo.totalAmount.toLocaleString( 'en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 } ),
|
|
621
658
|
invoiceDate,
|
|
622
659
|
dueDate,
|
|
@@ -661,7 +698,7 @@ async function buildInvoicePdfBuffer( invoiceId ) {
|
|
|
661
698
|
storeName: '$stores.storeName',
|
|
662
699
|
edgefirstFileDate: '$stores.edgefirstFileDate',
|
|
663
700
|
workingdays: '$stores.products.workingdays',
|
|
664
|
-
currencyType: { $literal:
|
|
701
|
+
currencyType: { $literal: invoiceCurrency },
|
|
665
702
|
} },
|
|
666
703
|
{ $sort: { productName: 1, workingdays: -1 } },
|
|
667
704
|
{ $match: { workingdays: { $gt: 0 } } },
|
|
@@ -816,8 +853,10 @@ export async function invoiceAnnexure( req, res ) {
|
|
|
816
853
|
return res.sendSuccess( { data: [] } );
|
|
817
854
|
}
|
|
818
855
|
|
|
819
|
-
const clientDetails = await clientService.findOne( { clientId: invoiceInfo.clientId } );
|
|
820
856
|
const currentMonthDays = dayjs().daysInMonth();
|
|
857
|
+
// Annexure must show the same currency as the invoice it accompanies —
|
|
858
|
+
// see invoiceDownload / buildInvoicePdfBuffer for the same pattern.
|
|
859
|
+
const invoiceCurrency = symbolFor( invoiceInfo.currency );
|
|
821
860
|
|
|
822
861
|
const annexureData = await dailyPricingService.aggregate( [
|
|
823
862
|
{ $match: { clientId: invoiceInfo.clientId } },
|
|
@@ -832,7 +871,7 @@ export async function invoiceAnnexure( req, res ) {
|
|
|
832
871
|
storeName: '$stores.storeName',
|
|
833
872
|
edgefirstFileDate: '$stores.edgefirstFileDate',
|
|
834
873
|
workingdays: '$stores.products.workingdays',
|
|
835
|
-
currencyType: { $literal:
|
|
874
|
+
currencyType: { $literal: invoiceCurrency },
|
|
836
875
|
} },
|
|
837
876
|
{ $sort: { productName: 1, workingdays: -1 } },
|
|
838
877
|
{ $match: { workingdays: { $gt: 0 } } },
|
|
@@ -2098,6 +2137,13 @@ export async function updateInvoice( req, res ) {
|
|
|
2098
2137
|
return res.sendError( 'Invoice not found', 404 );
|
|
2099
2138
|
}
|
|
2100
2139
|
|
|
2140
|
+
// Lock once final-approved. The UI hides the Edit icon for these rows,
|
|
2141
|
+
// but a direct API call would otherwise still let someone mutate a
|
|
2142
|
+
// finalised invoice. Match the frontend's isInvoiceLocked() check.
|
|
2143
|
+
if ( invoice.status === 'approved' ) {
|
|
2144
|
+
return res.sendError( 'Cannot edit a final-approved invoice.', 409 );
|
|
2145
|
+
}
|
|
2146
|
+
|
|
2101
2147
|
let updateData = {};
|
|
2102
2148
|
const allowedFields = [
|
|
2103
2149
|
'companyName', 'companyAddress', 'GSTNumber', 'PlaceOfSupply',
|
|
@@ -2208,6 +2254,12 @@ export async function deleteInvoice( req, res ) {
|
|
|
2208
2254
|
return res.sendError( 'Invoice not found', 404 );
|
|
2209
2255
|
}
|
|
2210
2256
|
|
|
2257
|
+
// Same lock the UI enforces — a final-approved invoice must not be
|
|
2258
|
+
// deletable, even by a power user hitting the API directly.
|
|
2259
|
+
if ( invoice.status === 'approved' ) {
|
|
2260
|
+
return res.sendError( 'Cannot delete a final-approved invoice.', 409 );
|
|
2261
|
+
}
|
|
2262
|
+
|
|
2211
2263
|
await invoiceService.deleteRecord( { _id: invoiceId } );
|
|
2212
2264
|
|
|
2213
2265
|
const logObj = {
|
|
@@ -3772,7 +3772,15 @@ export const invoiceGenerate = async ( req, res ) => {
|
|
|
3772
3772
|
}
|
|
3773
3773
|
|
|
3774
3774
|
|
|
3775
|
-
|
|
3775
|
+
// Scope the highest-index lookup to the current financial year
|
|
3776
|
+
// (invoice IDs are `INV-${FY}-${index}`). Sort by invoiceIndex (not
|
|
3777
|
+
// _id) so the largest sequence number wins even if records were
|
|
3778
|
+
// back-dated or imported out of order.
|
|
3779
|
+
let previousinvoice = await invoiceService.findandsort(
|
|
3780
|
+
{ invoice: { $regex: `^INV-${Finacialyear}-` } },
|
|
3781
|
+
{},
|
|
3782
|
+
{ invoiceIndex: -1 },
|
|
3783
|
+
);
|
|
3776
3784
|
let invoiceNo = '00001';
|
|
3777
3785
|
if ( previousinvoice && previousinvoice.length > 0 ) {
|
|
3778
3786
|
invoiceNo = Number( previousinvoice[0].invoiceIndex ) + 1;
|
package/src/hbs/invoicePdf.hbs
CHANGED
|
@@ -1643,7 +1643,7 @@
|
|
|
1643
1643
|
</div>
|
|
1644
1644
|
<div class="frame-54">
|
|
1645
1645
|
<div class="ifsc-code">IFSC Code</div>
|
|
1646
|
-
<div class="hdfc-0000269">
|
|
1646
|
+
<div class="hdfc-0000269">HDFC0000386</div>
|
|
1647
1647
|
</div>
|
|
1648
1648
|
<div class="frame-532">
|
|
1649
1649
|
<div class="payment-type">Payment Type</div>
|
|
@@ -1,21 +1,8 @@
|
|
|
1
1
|
|
|
2
2
|
import express from 'express';
|
|
3
|
-
import multer from 'multer';
|
|
4
3
|
import { brandsBillingList, brandInvoiceList, latestDailyPricing, brandBillingGroups, updateDailyPricingWorkingDays, updateDailyPricingStoreField, getClientBillingInfo, bulkDownloadBillingGroups, bulkUpdateBillingGroups } from '../controllers/brandsBilling.controller.js';
|
|
5
4
|
import { isAllowedSessionHandler, accessVerification } from 'tango-app-api-middleware';
|
|
6
5
|
|
|
7
|
-
const bulkUpload = multer( {
|
|
8
|
-
storage: multer.memoryStorage(),
|
|
9
|
-
limits: { fileSize: 5 * 1024 * 1024 },
|
|
10
|
-
fileFilter: ( _req, file, cb ) => {
|
|
11
|
-
const ok = [
|
|
12
|
-
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
13
|
-
'application/vnd.ms-excel',
|
|
14
|
-
'application/octet-stream',
|
|
15
|
-
].includes( file.mimetype );
|
|
16
|
-
cb( ok ? null : new Error( 'Invalid file format — use XLSX' ), ok );
|
|
17
|
-
},
|
|
18
|
-
} );
|
|
19
6
|
export const brandsBillingRouter = express.Router();
|
|
20
7
|
|
|
21
8
|
brandsBillingRouter.post( '/brandsBillingList', isAllowedSessionHandler, accessVerification( { userType: [ 'tango' ], access: [ { featureName: 'TangoAdmin', name: 'invoiceApproval', permissions: [] } ] } ), brandsBillingList );
|
|
@@ -26,4 +13,4 @@ brandsBillingRouter.put( '/updateDailyPricingWorkingDays', isAllowedSessionHandl
|
|
|
26
13
|
brandsBillingRouter.put( '/updateDailyPricingStoreField', isAllowedSessionHandler, accessVerification( { userType: [ 'tango' ], access: [ { featureName: 'TangoAdmin', name: 'invoiceApproval', permissions: [ 'isEdit' ] } ] } ), updateDailyPricingStoreField );
|
|
27
14
|
brandsBillingRouter.post( '/getClientBillingInfo', isAllowedSessionHandler, accessVerification( { userType: [ 'tango' ], access: [ { featureName: 'TangoAdmin', name: 'invoiceApproval', permissions: [] } ] } ), getClientBillingInfo );
|
|
28
15
|
brandsBillingRouter.get( '/bulk-download-billing-groups', isAllowedSessionHandler, accessVerification( { userType: [ 'tango' ], access: [ { featureName: 'TangoAdmin', name: 'invoiceApproval', permissions: [ 'isEdit' ] } ] } ), bulkDownloadBillingGroups );
|
|
29
|
-
brandsBillingRouter.post( '/bulk-update-billing-groups', isAllowedSessionHandler, accessVerification( { userType: [ 'tango' ], access: [ { featureName: 'TangoAdmin', name: 'invoiceApproval', permissions: [ 'isEdit' ] } ] } ),
|
|
16
|
+
brandsBillingRouter.post( '/bulk-update-billing-groups', isAllowedSessionHandler, accessVerification( { userType: [ 'tango' ], access: [ { featureName: 'TangoAdmin', name: 'invoiceApproval', permissions: [ 'isEdit' ] } ] } ), bulkUpdateBillingGroups );
|