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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tango-app-api-payment-subscription",
3
- "version": "3.5.1",
3
+ "version": "3.5.3",
4
4
  "description": "paymentSubscription",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -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': store.trafficCameraCount || 0,
501
- 'Zone Camera Count': store.zoneCameraCount || 0,
502
- 'Zone Count': store.zoneCount || 0,
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
- if ( !req.file?.buffer ) {
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( req.file.buffer, { type: 'buffer' } );
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
- let previousinvoice = await invoiceService.findandsort( {}, {}, { invoiceIndex: -1 } );
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 = symbolFor( clientDetails?.paymentInvoice?.currencyType );
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: symbolFor( virtualAccount?.currency ),
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: symbolFor( clientDetails?.paymentInvoice?.currencyType ) },
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 = symbolFor( clientDetails?.paymentInvoice?.currencyType );
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: symbolFor( virtualAccount?.currency ),
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: symbolFor( clientDetails?.paymentInvoice?.currencyType ) },
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: symbolFor( clientDetails?.paymentInvoice?.currencyType ) },
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
- let previousinvoice = await invoiceService.findandsort( {}, {}, { _id: -1 } );
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;
@@ -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">HDFC0000269</div>
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' ] } ] } ), bulkUpload.single( 'file' ), bulkUpdateBillingGroups );
16
+ brandsBillingRouter.post( '/bulk-update-billing-groups', isAllowedSessionHandler, accessVerification( { userType: [ 'tango' ], access: [ { featureName: 'TangoAdmin', name: 'invoiceApproval', permissions: [ 'isEdit' ] } ] } ), bulkUpdateBillingGroups );