tango-app-api-payment-subscription 3.5.4 → 3.5.6

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.
@@ -208,7 +208,14 @@ export async function createInvoice( req, res ) {
208
208
  let amount = products.reduce( ( sum, product ) => sum + product.amount, 0 );
209
209
  let taxList = [];
210
210
  let totalAmount = 0;
211
- if ( group.gst && group.gst.slice( 0, 2 ) == '33' ) {
211
+ // International billing groups: skip tax calculation entirely. The
212
+ // tax array stays empty so the PDF won't render a tax line and
213
+ // totalAmount equals subtotal. Default 'domestic' preserves the
214
+ // existing GST/IGST/CGST/SGST behavior for legacy records that have
215
+ // no taxCalculationType set yet.
216
+ if ( group.taxCalculationType === 'international' ) {
217
+ totalAmount = Math.round( amount );
218
+ } else if ( group.gst && group.gst.slice( 0, 2 ) == '33' ) {
212
219
  let taxAmount = ( amount * 18 ) / 100;
213
220
  totalAmount = Math.round( amount + taxAmount );
214
221
  taxList.push(
@@ -353,6 +360,103 @@ function getCurrentFinancialYear() {
353
360
  }
354
361
 
355
362
 
363
+ // ---------------------------------------------------------------------------
364
+ // Shared annexure builder. Anchored to the invoice's BILLING month and
365
+ // mirroring invoice generation's billing-type rules: perZone / perCamera
366
+ // products multiply by the store's zone / camera count (see standardPrice).
367
+ // Returns the per-store rows plus the grand total shown at the end.
368
+ // ---------------------------------------------------------------------------
369
+ async function buildAnnexureRows( invoiceInfo, getgroup ) {
370
+ const billingMonth = invoiceInfo.billingDate ? dayjs( invoiceInfo.billingDate ) : dayjs();
371
+ const billingMonthEnd = new Date( billingMonth.endOf( 'month' ).toISOString() );
372
+ const monthDays = billingMonth.daysInMonth();
373
+ const invoiceCurrency = symbolFor( invoiceInfo.currency );
374
+
375
+ const annexClient = await clientService.findOne( { clientId: invoiceInfo.clientId }, { 'planDetails.product': 1 } );
376
+ const billingTypeMap = {};
377
+ ( annexClient?.planDetails?.product || [] ).forEach( ( p ) => {
378
+ billingTypeMap[p.productName] = p.billingType || 'perStore';
379
+ } );
380
+
381
+ const rows = await dailyPricingService.aggregate( [
382
+ { $match: { clientId: invoiceInfo.clientId, dateISO: { $lte: billingMonthEnd } } },
383
+ { $sort: { dateISO: -1 } },
384
+ { $limit: 1 },
385
+ { $project: { stores: { $filter: { input: '$stores', as: 'item', cond: { $in: [ '$$item.storeId', getgroup?.stores ?? [] ] } } } } },
386
+ { $unwind: { path: '$stores', preserveNullAndEmptyArrays: false } },
387
+ { $unwind: { path: '$stores.products', preserveNullAndEmptyArrays: false } },
388
+ { $project: {
389
+ productName: '$stores.products.productName',
390
+ storeId: '$stores.storeId',
391
+ storeName: '$stores.storeName',
392
+ edgefirstFileDate: { $ifNull: [ '$stores.edgefirstFileDate', '$stores.processfirstFileDate' ] },
393
+ workingdays: '$stores.products.workingdays',
394
+ zoneCount: { $ifNull: [ '$stores.zoneCount', 0 ] },
395
+ trafficCameraCount: { $ifNull: [ '$stores.trafficCameraCount', 0 ] },
396
+ zoneCameraCount: { $ifNull: [ '$stores.zoneCameraCount', 0 ] },
397
+ } },
398
+ { $match: { workingdays: { $gt: 0 } } },
399
+ { $sort: { productName: 1, workingdays: -1 } },
400
+ { $lookup: {
401
+ from: 'basepricings',
402
+ let: { clientId: invoiceInfo.clientId },
403
+ pipeline: [
404
+ { $match: { $expr: { $eq: [ '$clientId', '$$clientId' ] } } },
405
+ { $project: { standard: 1 } },
406
+ ],
407
+ as: 'basepricing',
408
+ } },
409
+ { $unwind: { path: '$basepricing', preserveNullAndEmptyArrays: true } },
410
+ { $project: {
411
+ productName: 1, workingdays: 1, storeName: 1, storeId: 1, edgefirstFileDate: 1,
412
+ zoneCount: 1, trafficCameraCount: 1, zoneCameraCount: 1,
413
+ standard: { $filter: { input: '$basepricing.standard', as: 'standard', cond: { $eq: [ '$$standard.productName', '$productName' ] } } },
414
+ } },
415
+ { $unwind: { path: '$standard', preserveNullAndEmptyArrays: true } },
416
+ ] );
417
+
418
+ const data = rows.map( ( s ) => {
419
+ const billingType = billingTypeMap[s.productName] || 'perStore';
420
+ // Same units rule as invoice generation: perZone / perCamera multiply by
421
+ // the store's zone / camera count; anything else stays per-store.
422
+ let units = 1;
423
+ if ( s.productName === 'tangoZone' ) {
424
+ if ( billingType === 'perZone' && s.zoneCount > 0 ) {
425
+ units = s.zoneCount;
426
+ } else if ( billingType === 'perCamera' && s.zoneCameraCount > 0 ) {
427
+ units = s.zoneCameraCount;
428
+ }
429
+ } else if ( s.productName === 'tangoTraffic' ) {
430
+ if ( billingType === 'perCamera' && s.trafficCameraCount > 0 ) {
431
+ units = s.trafficCameraCount;
432
+ }
433
+ }
434
+ const price = Number( s.standard?.negotiatePrice ) || 0;
435
+ const runningCost = s.workingdays >= monthDays ?
436
+ Math.round( price * units * 100 ) / 100 :
437
+ Math.round( ( price / monthDays ) * s.workingdays * units * 100 ) / 100;
438
+ return {
439
+ productName: s.productName ? s.productName.charAt( 0 ).toUpperCase() + s.productName.slice( 1 ) : '',
440
+ currencyType: invoiceCurrency,
441
+ workingdays: s.workingdays,
442
+ storeName: s.storeName,
443
+ storeId: s.storeId,
444
+ edgefirstFileDate: s.edgefirstFileDate ? dayjs( s.edgefirstFileDate ).format( 'YYYY-MM-DD' ) : '',
445
+ period: s.workingdays < monthDays ? 'prorate' : 'fullmonth',
446
+ billingType,
447
+ // Human label for the unit-price column: what one unit means.
448
+ unitBasis: billingType === 'perCamera' ? 'Per Camera' : billingType === 'perZone' ? 'Per Zone' : 'Per Store',
449
+ units,
450
+ standardPrice: price,
451
+ runningCost,
452
+ };
453
+ } );
454
+
455
+ const totalAmount = Math.round( data.reduce( ( a, x ) => a + ( x.runningCost || 0 ), 0 ) * 100 ) / 100;
456
+ const totalFormatted = totalAmount.toLocaleString( 'en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 } );
457
+ return { data, totalAmount, totalFormatted };
458
+ }
459
+
356
460
  export async function invoiceDownload( req, res ) {
357
461
  try {
358
462
  let invoiceData;
@@ -369,7 +473,7 @@ export async function invoiceDownload( req, res ) {
369
473
  let [ firstWord, secondWord ] = item.productName.replace( /([a-z])([A-Z])/g, '$1 $2' ).split( ' ' );
370
474
  firstWord = firstWord.charAt( 0 ).toUpperCase() + firstWord.slice( 1 );
371
475
  item.productName = firstWord + ' ' + secondWord;
372
- item.price = Math.round( item.price ).toLocaleString( 'en-IN' );
476
+ item.price = item.price .toLocaleString( 'en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 } );
373
477
  item.amount = item.amount.toLocaleString( 'en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 } );
374
478
  item.currency = invoiceCurrency;
375
479
  } );
@@ -431,145 +535,10 @@ export async function invoiceDownload( req, res ) {
431
535
  }
432
536
  } );
433
537
  }
434
- const currentMonthDays = dayjs().daysInMonth();
435
-
436
538
  if ( getgroup?.attachAnnexure ) {
437
- let annuxureData = await dailyPricingService.aggregate( [
438
- {
439
- $match: {
440
- clientId: invoiceInfo.clientId,
441
- },
442
- },
443
- {
444
- $sort: { dateISO: -1 },
445
- },
446
- { $limit: 1 },
447
- {
448
- $project: {
449
- stores: {
450
- $filter: {
451
- input: '$stores',
452
- as: 'item',
453
- cond: { $in: [ '$$item.storeId', getgroup?.stores ] },
454
- },
455
- },
456
- },
457
- },
458
- {
459
- $unwind: {
460
- path: '$stores',
461
- preserveNullAndEmptyArrays: false,
462
- },
463
- },
464
- {
465
- $unwind: {
466
- path: '$stores.products',
467
- preserveNullAndEmptyArrays: false,
468
- },
469
- },
470
- {
471
- $project: {
472
- productName: '$stores.products.productName',
473
- storeId: '$stores.storeId',
474
- storeName: '$stores.storeName',
475
- edgefirstFileDate: '$stores.edgefirstFileDate',
476
- workingdays: '$stores.products.workingdays',
477
- currencyType: { $literal: invoiceCurrency },
478
- },
479
- },
480
- {
481
- $sort: {
482
- productName: 1,
483
- workingdays: -1,
484
- },
485
- },
486
- {
487
- $match: { workingdays: { $gt: 0 } },
488
- },
489
-
490
- {
491
- $lookup: {
492
- from: 'basepricings',
493
- let: { clientId: invoiceInfo.clientId },
494
- pipeline: [
495
- {
496
- $match: {
497
- $expr: {
498
- $eq: [ '$clientId', '$$clientId' ],
499
- },
500
- },
501
- },
502
- {
503
- $project: {
504
- standard: 1,
505
- step: 1,
506
- },
507
- },
508
- ],
509
- as: 'basepricing',
510
- },
511
- },
512
- {
513
- $unwind: { path: '$basepricing', preserveNullAndEmptyArrays: true },
514
- },
515
- {
516
- $project: {
517
- productName: 1,
518
- workingdays: 1,
519
- storeName: 1,
520
- currencyType: 1,
521
- edgefirstFileDate: { $ifNull: [ '$edgefirstFileDate', '$processfirstFileDate' ] },
522
- storeId: 1,
523
- standard: {
524
- $filter: {
525
- input: '$basepricing.standard',
526
- as: 'standard',
527
- cond: { $eq: [ '$$standard.productName', '$productName' ] },
528
- },
529
- },
530
- step: '$basepricing.step',
531
- },
532
- },
533
- {
534
- $unwind: { path: '$standard', preserveNullAndEmptyArrays: true },
535
- },
536
- {
537
- $project: {
538
- productName: {
539
- $concat: [
540
- { $toUpper: { $substr: [ '$productName', 0, 1 ] } }, // Uppercase first letter
541
- { $substr: [ '$productName', 1, { $strLenCP: '$productName' } ] }, // Rest of the string
542
- ],
543
- },
544
- currencyType: 1,
545
- workingdays: 1,
546
- storeName: 1,
547
- edgefirstFileDate: { $dateToString: { format: '%Y-%m-%d', date: '$edgefirstFileDate' } },
548
- storeId: 1,
549
- period: {
550
- $cond: {
551
- if: { $lt: [ '$workingdays', currentMonthDays ] },
552
- then: 'prorate',
553
- else: 'fullmonth',
554
- },
555
- },
556
- standardPrice: '$standard.negotiatePrice',
557
- runningCost: {
558
- $round: [
559
- {
560
- $multiply: [
561
- { $divide: [ '$standard.negotiatePrice', currentMonthDays ] },
562
- '$workingdays',
563
- ],
564
- },
565
- 2,
566
- ],
567
- },
568
- },
569
- },
570
- ],
571
- );
539
+ const { data: annuxureData, totalFormatted } = await buildAnnexureRows( invoiceInfo, getgroup );
572
540
  invoiceData.annuxureData = annuxureData;
541
+ invoiceData.annuxureTotal = totalFormatted;
573
542
  }
574
543
 
575
544
  const templateHtml = fs.readFileSync( path.resolve( path.dirname( '' ) ) + '/src/hbs/invoicePdf.hbs', 'utf8' );
@@ -748,65 +717,10 @@ async function buildInvoicePdfBuffer( invoiceId ) {
748
717
  } );
749
718
  }
750
719
 
751
- const currentMonthDays = dayjs().daysInMonth();
752
-
753
720
  if ( getgroup?.attachAnnexure ) {
754
- let annuxureData = await dailyPricingService.aggregate( [
755
- { $match: { clientId: invoiceInfo.clientId } },
756
- { $sort: { dateISO: -1 } },
757
- { $limit: 1 },
758
- { $project: { stores: { $filter: { input: '$stores', as: 'item', cond: { $in: [ '$$item.storeId', getgroup?.stores ] } } } } },
759
- { $unwind: { path: '$stores', preserveNullAndEmptyArrays: false } },
760
- { $unwind: { path: '$stores.products', preserveNullAndEmptyArrays: false } },
761
- { $project: {
762
- productName: '$stores.products.productName',
763
- storeId: '$stores.storeId',
764
- storeName: '$stores.storeName',
765
- edgefirstFileDate: '$stores.edgefirstFileDate',
766
- workingdays: '$stores.products.workingdays',
767
- currencyType: { $literal: invoiceCurrency },
768
- } },
769
- { $sort: { productName: 1, workingdays: -1 } },
770
- { $match: { workingdays: { $gt: 0 } } },
771
- { $lookup: {
772
- from: 'basepricings',
773
- let: { clientId: invoiceInfo.clientId },
774
- pipeline: [
775
- { $match: { $expr: { $eq: [ '$clientId', '$$clientId' ] } } },
776
- { $project: { standard: 1, step: 1 } },
777
- ],
778
- as: 'basepricing',
779
- } },
780
- { $unwind: { path: '$basepricing', preserveNullAndEmptyArrays: true } },
781
- { $project: {
782
- productName: 1,
783
- workingdays: 1,
784
- storeName: 1,
785
- currencyType: 1,
786
- edgefirstFileDate: { $ifNull: [ '$edgefirstFileDate', '$processfirstFileDate' ] },
787
- storeId: 1,
788
- standard: { $filter: { input: '$basepricing.standard', as: 'standard', cond: { $eq: [ '$$standard.productName', '$productName' ] } } },
789
- step: '$basepricing.step',
790
- } },
791
- { $unwind: { path: '$standard', preserveNullAndEmptyArrays: true } },
792
- { $project: {
793
- productName: {
794
- $concat: [
795
- { $toUpper: { $substr: [ '$productName', 0, 1 ] } },
796
- { $substr: [ '$productName', 1, { $strLenCP: '$productName' } ] },
797
- ],
798
- },
799
- currencyType: 1,
800
- workingdays: 1,
801
- storeName: 1,
802
- edgefirstFileDate: { $dateToString: { format: '%Y-%m-%d', date: '$edgefirstFileDate' } },
803
- storeId: 1,
804
- period: { $cond: { if: { $lt: [ '$workingdays', currentMonthDays ] }, then: 'prorate', else: 'fullmonth' } },
805
- standardPrice: '$standard.negotiatePrice',
806
- runningCost: { $round: [ { $multiply: [ { $divide: [ '$standard.negotiatePrice', currentMonthDays ] }, '$workingdays' ] }, 2 ] },
807
- } },
808
- ] );
721
+ const { data: annuxureData, totalFormatted } = await buildAnnexureRows( invoiceInfo, getgroup );
809
722
  invoiceData.annuxureData = annuxureData;
723
+ invoiceData.annuxureTotal = totalFormatted;
810
724
  }
811
725
 
812
726
  const templateHtml = fs.readFileSync( path.resolve( path.dirname( '' ) ) + '/src/hbs/invoicePdf.hbs', 'utf8' );
@@ -919,68 +833,8 @@ export async function invoiceAnnexure( req, res ) {
919
833
  return res.sendSuccess( { data: [] } );
920
834
  }
921
835
 
922
- const currentMonthDays = dayjs().daysInMonth();
923
- // Annexure must show the same currency as the invoice it accompanies —
924
- // see invoiceDownload / buildInvoicePdfBuffer for the same pattern.
925
- const invoiceCurrency = symbolFor( invoiceInfo.currency );
926
-
927
- const annexureData = await dailyPricingService.aggregate( [
928
- { $match: { clientId: invoiceInfo.clientId } },
929
- { $sort: { dateISO: -1 } },
930
- { $limit: 1 },
931
- { $project: { stores: { $filter: { input: '$stores', as: 'item', cond: { $in: [ '$$item.storeId', getgroup?.stores ?? [] ] } } } } },
932
- { $unwind: { path: '$stores', preserveNullAndEmptyArrays: false } },
933
- { $unwind: { path: '$stores.products', preserveNullAndEmptyArrays: false } },
934
- { $project: {
935
- productName: '$stores.products.productName',
936
- storeId: '$stores.storeId',
937
- storeName: '$stores.storeName',
938
- edgefirstFileDate: '$stores.edgefirstFileDate',
939
- workingdays: '$stores.products.workingdays',
940
- currencyType: { $literal: invoiceCurrency },
941
- } },
942
- { $sort: { productName: 1, workingdays: -1 } },
943
- { $match: { workingdays: { $gt: 0 } } },
944
- { $lookup: {
945
- from: 'basepricings',
946
- let: { clientId: invoiceInfo.clientId },
947
- pipeline: [
948
- { $match: { $expr: { $eq: [ '$clientId', '$$clientId' ] } } },
949
- { $project: { standard: 1, step: 1 } },
950
- ],
951
- as: 'basepricing',
952
- } },
953
- { $unwind: { path: '$basepricing', preserveNullAndEmptyArrays: true } },
954
- { $project: {
955
- productName: 1,
956
- workingdays: 1,
957
- storeName: 1,
958
- currencyType: 1,
959
- edgefirstFileDate: { $ifNull: [ '$edgefirstFileDate', '$processfirstFileDate' ] },
960
- storeId: 1,
961
- standard: { $filter: { input: '$basepricing.standard', as: 'standard', cond: { $eq: [ '$$standard.productName', '$productName' ] } } },
962
- step: '$basepricing.step',
963
- } },
964
- { $unwind: { path: '$standard', preserveNullAndEmptyArrays: true } },
965
- { $project: {
966
- productName: {
967
- $concat: [
968
- { $toUpper: { $substr: [ '$productName', 0, 1 ] } },
969
- { $substr: [ '$productName', 1, { $strLenCP: '$productName' } ] },
970
- ],
971
- },
972
- currencyType: 1,
973
- workingdays: 1,
974
- storeName: 1,
975
- edgefirstFileDate: { $dateToString: { format: '%Y-%m-%d', date: '$edgefirstFileDate' } },
976
- storeId: 1,
977
- period: { $cond: { if: { $lt: [ '$workingdays', currentMonthDays ] }, then: 'prorate', else: 'fullmonth' } },
978
- standardPrice: '$standard.negotiatePrice',
979
- runningCost: { $round: [ { $multiply: [ { $divide: [ '$standard.negotiatePrice', currentMonthDays ] }, '$workingdays' ] }, 2 ] },
980
- } },
981
- ] );
982
-
983
- return res.sendSuccess( { data: annexureData } );
836
+ const { data, totalAmount } = await buildAnnexureRows( invoiceInfo, getgroup );
837
+ return res.sendSuccess( { data, totalAmount } );
984
838
  } catch ( error ) {
985
839
  logger.error( { error: error, function: 'invoiceAnnexure', invoiceId: req.params.invoiceId } );
986
840
  return res.sendError( error, 500 );
@@ -1005,6 +859,11 @@ function inWords( num ) {
1005
859
  async function standardPrice( group, getClient, baseDate ) {
1006
860
  console.log( '🚀 ~ standardPrice ~ baseDate:', baseDate.format( 'MMM YYYY' ) );
1007
861
  const currentMonthDays = dayjs().daysInMonth();
862
+ // Pricing method: 'flat' => bill every store for the full month
863
+ // regardless of working days. 'prorate' => bill for actual working days.
864
+ // Computed once so the aggregation pipelines can inline a $literal.
865
+ const isFlatPricing = group.proRata === 'flat';
866
+ console.log( '🚀 ~ standardPrice ~ isFlatPricing:', isFlatPricing );
1008
867
  let billingTypeMap = {};
1009
868
  if ( getClient?.planDetails?.product ) {
1010
869
  getClient.planDetails.product.forEach( ( p ) => {
@@ -1052,6 +911,13 @@ async function standardPrice( group, getClient, baseDate ) {
1052
911
  storeStatus: '$stores.status',
1053
912
  zoneCount: { $ifNull: [ '$stores.zoneCount', 0 ] },
1054
913
  cameraCount: { $ifNull: [ '$stores.cameraCount', 0 ] },
914
+ // Pull per-store camera splits off the daily-pricing store doc so the
915
+ // downstream perCamera branch (see below) can multiply price by the
916
+ // actual camera count. Without these the second $group would $sum
917
+ // missing fields and totalTraffic/ZoneCameraCount would always be 0
918
+ // — silently degrading perCamera billing to perStore.
919
+ trafficCameraCount: { $ifNull: [ '$stores.trafficCameraCount', 0 ] },
920
+ zoneCameraCount: { $ifNull: [ '$stores.zoneCameraCount', 0 ] },
1055
921
  },
1056
922
  },
1057
923
  {
@@ -1063,23 +929,31 @@ async function standardPrice( group, getClient, baseDate ) {
1063
929
  $project: {
1064
930
  productName: 1,
1065
931
  storeId: 1,
1066
- prorate: { $literal: group.proRata },
932
+ // isFlatPricing is the group-level pricing flag, baked into every
933
+ // doc so the next $project's $cond can read it.
934
+ isFlatPricing: { $literal: isFlatPricing },
1067
935
  workingDays: 1,
1068
936
  storeStatus: 1,
1069
937
  zoneCount: 1,
1070
938
  cameraCount: 1,
939
+ trafficCameraCount: 1,
940
+ zoneCameraCount: 1,
1071
941
  },
1072
942
  },
1073
943
  {
1074
944
  $project: {
1075
945
  productName: 1,
1076
946
  storeId: 1,
947
+ // Flat pricing => every store billed for the full month.
948
+ // Prorate => keep the actual workingDays.
1077
949
  workingDays: {
1078
- $cond: { if: { $and: [ { $gte: [ '$workingDays', 15 ] }, { $eq: [ '$storeStatus', 'active' ] }, { $eq: [ '$prorate', 'before15' ] } ] }, then: currentMonthDays, else: '$workingDays' },
950
+ $cond: { if: '$isFlatPricing', then: currentMonthDays, else: '$workingDays' },
1079
951
  },
1080
952
  storeStatus: 1,
1081
953
  zoneCount: 1,
1082
954
  cameraCount: 1,
955
+ trafficCameraCount: 1,
956
+ zoneCameraCount: 1,
1083
957
  },
1084
958
  },
1085
959
  {
@@ -1091,6 +965,8 @@ async function standardPrice( group, getClient, baseDate ) {
1091
965
  workingdays: { $first: '$workingDays' },
1092
966
  zoneCount: { $first: '$zoneCount' },
1093
967
  cameraCount: { $first: '$cameraCount' },
968
+ trafficCameraCount: { $first: '$trafficCameraCount' },
969
+ zoneCameraCount: { $first: '$zoneCameraCount' },
1094
970
  },
1095
971
  },
1096
972
  {
@@ -1294,16 +1170,24 @@ async function standardPrice( group, getClient, baseDate ) {
1294
1170
  storeStatus: '$stores.status',
1295
1171
  zoneCount: { $ifNull: [ '$stores.zoneCount', 0 ] },
1296
1172
  cameraCount: { $ifNull: [ '$stores.cameraCount', 0 ] },
1173
+ // perCamera (eachStore branch) reads these on the per-store record
1174
+ // below; projecting them through is required for the camera-count
1175
+ // multiplier to fire.
1176
+ trafficCameraCount: { $ifNull: [ '$stores.trafficCameraCount', 0 ] },
1177
+ zoneCameraCount: { $ifNull: [ '$stores.zoneCameraCount', 0 ] },
1297
1178
  },
1298
1179
  },
1299
1180
  { $match: { workingDays: { $gt: 0 }, productName: { $in: eachStoreProductNames } } },
1300
1181
  {
1301
1182
  $project: {
1302
1183
  productName: 1, storeId: 1, storeName: 1,
1184
+ // Flat pricing => full month per store. Prorate => actual working
1185
+ // days. Same rule as the overall-store branch above.
1303
1186
  workingDays: {
1304
- $cond: { if: { $and: [ { $gte: [ '$workingDays', 15 ] }, { $eq: [ '$storeStatus', 'active' ] }, { $eq: [ { $literal: group.proRata }, 'before15' ] } ] }, then: currentMonthDays, else: '$workingDays' },
1187
+ $cond: { if: { $literal: isFlatPricing }, then: currentMonthDays, else: '$workingDays' },
1305
1188
  },
1306
1189
  zoneCount: 1, cameraCount: 1,
1190
+ trafficCameraCount: 1, zoneCameraCount: 1,
1307
1191
  },
1308
1192
  },
1309
1193
  {
@@ -1331,6 +1215,11 @@ async function standardPrice( group, getClient, baseDate ) {
1331
1215
  {
1332
1216
  $project: {
1333
1217
  productName: 1, storeId: 1, storeName: 1, workingDays: 1, zoneCount: 1, cameraCount: 1,
1218
+ // Final whitelist project — must carry trafficCameraCount and
1219
+ // zoneCameraCount through to the JS consumer. Without these the
1220
+ // perCamera branch in the for-loop below sees undefined and falls
1221
+ // back to storeCount=1.
1222
+ trafficCameraCount: 1, zoneCameraCount: 1,
1334
1223
  price: '$matchedStandard.negotiatePrice',
1335
1224
  period: { $cond: { if: { $lt: [ '$workingDays', currentMonthDays ] }, then: 'prorate', else: 'fullmonth' } },
1336
1225
  },
@@ -1339,6 +1228,7 @@ async function standardPrice( group, getClient, baseDate ) {
1339
1228
 
1340
1229
  for ( let store of perStoreData ) {
1341
1230
  let productBillingType = billingTypeMap[store.productName] || 'perStore';
1231
+
1342
1232
  let storeCount = 1;
1343
1233
  if ( store.productName === 'tangoZone' ) {
1344
1234
  if ( productBillingType === 'perZone' && store.zoneCount > 0 ) {
@@ -1406,6 +1296,9 @@ async function standardPrice( group, getClient, baseDate ) {
1406
1296
 
1407
1297
  async function stepPrice( group, getClient ) {
1408
1298
  const currentMonthDays = dayjs().daysInMonth();
1299
+ // 'flat' => every store billed for full month.
1300
+ // 'prorate' => actual working days. See standardPrice for the same flag.
1301
+ const isFlatPricing = group.proRata === 'flat';
1409
1302
  let billingTypeMap = {};
1410
1303
  if ( getClient?.planDetails?.product ) {
1411
1304
  getClient.planDetails.product.forEach( ( p ) => {
@@ -1453,6 +1346,11 @@ async function stepPrice( group, getClient ) {
1453
1346
  storeStatus: '$stores.status',
1454
1347
  zoneCount: { $ifNull: [ '$stores.zoneCount', 0 ] },
1455
1348
  cameraCount: { $ifNull: [ '$stores.cameraCount', 0 ] },
1349
+ // Pull per-store camera splits; same fix as standardPrice — without
1350
+ // these the second $group sums missing fields and the perCamera
1351
+ // branch in the downstream map silently falls back to perStore.
1352
+ trafficCameraCount: { $ifNull: [ '$stores.trafficCameraCount', 0 ] },
1353
+ zoneCameraCount: { $ifNull: [ '$stores.zoneCameraCount', 0 ] },
1456
1354
  },
1457
1355
  },
1458
1356
  {
@@ -1464,23 +1362,30 @@ async function stepPrice( group, getClient ) {
1464
1362
  $project: {
1465
1363
  productName: 1,
1466
1364
  storeId: 1,
1467
- prorate: { $literal: group.proRata },
1365
+ // Group-level flag baked into every doc; consumed by the next
1366
+ // $project's $cond.
1367
+ isFlatPricing: { $literal: isFlatPricing },
1468
1368
  workingDays: 1,
1469
1369
  storeStatus: 1,
1470
1370
  zoneCount: 1,
1471
1371
  cameraCount: 1,
1372
+ trafficCameraCount: 1,
1373
+ zoneCameraCount: 1,
1472
1374
  },
1473
1375
  },
1474
1376
  {
1475
1377
  $project: {
1476
1378
  productName: 1,
1477
1379
  storeId: 1,
1380
+ // Flat => full month per store. Prorate => actual working days.
1478
1381
  workingDays: {
1479
- $cond: { if: { $and: [ { $gte: [ '$workingDays', 15 ] }, { $eq: [ '$storeStatus', 'active' ] }, { $eq: [ '$prorate', 'before15' ] } ] }, then: currentMonthDays, else: '$workingDays' },
1382
+ $cond: { if: '$isFlatPricing', then: currentMonthDays, else: '$workingDays' },
1480
1383
  },
1481
1384
  storeStatus: 1,
1482
1385
  zoneCount: 1,
1483
1386
  cameraCount: 1,
1387
+ trafficCameraCount: 1,
1388
+ zoneCameraCount: 1,
1484
1389
  },
1485
1390
  }, {
1486
1391
  $group: {
@@ -1491,6 +1396,8 @@ async function stepPrice( group, getClient ) {
1491
1396
  workingdays: { $first: '$workingDays' },
1492
1397
  zoneCount: { $first: '$zoneCount' },
1493
1398
  cameraCount: { $first: '$cameraCount' },
1399
+ trafficCameraCount: { $first: '$trafficCameraCount' },
1400
+ zoneCameraCount: { $first: '$zoneCameraCount' },
1494
1401
  },
1495
1402
  },
1496
1403
  {
@@ -1559,16 +1466,24 @@ async function stepPrice( group, getClient ) {
1559
1466
  storeStatus: '$stores.status',
1560
1467
  zoneCount: { $ifNull: [ '$stores.zoneCount', 0 ] },
1561
1468
  cameraCount: { $ifNull: [ '$stores.cameraCount', 0 ] },
1469
+ // perCamera (eachStore branch) reads these on the per-store record
1470
+ // below; projecting them through is required for the camera-count
1471
+ // multiplier to fire.
1472
+ trafficCameraCount: { $ifNull: [ '$stores.trafficCameraCount', 0 ] },
1473
+ zoneCameraCount: { $ifNull: [ '$stores.zoneCameraCount', 0 ] },
1562
1474
  },
1563
1475
  },
1564
1476
  { $match: { workingDays: { $gt: 0 }, productName: { $in: eachStoreProductNames } } },
1565
1477
  {
1566
1478
  $project: {
1567
1479
  productName: 1, storeId: 1, storeName: 1,
1480
+ // Flat pricing => full month per store. Prorate => actual working
1481
+ // days. Same rule as the overall-store branch above.
1568
1482
  workingDays: {
1569
- $cond: { if: { $and: [ { $gte: [ '$workingDays', 15 ] }, { $eq: [ '$storeStatus', 'active' ] }, { $eq: [ { $literal: group.proRata }, 'before15' ] } ] }, then: currentMonthDays, else: '$workingDays' },
1483
+ $cond: { if: { $literal: isFlatPricing }, then: currentMonthDays, else: '$workingDays' },
1570
1484
  },
1571
1485
  zoneCount: 1, cameraCount: 1,
1486
+ trafficCameraCount: 1, zoneCameraCount: 1,
1572
1487
  },
1573
1488
  },
1574
1489
  {
@@ -1770,35 +1685,76 @@ export async function clientInvoiceList( req, res ) {
1770
1685
  clientId: { $in: findClients },
1771
1686
  },
1772
1687
  } ];
1688
+
1689
+ // If the user picked an explicit Month or Year, ignore the Duration
1690
+ // filter — otherwise the two ranges conflict (e.g. "current month" +
1691
+ // April would always return zero results). Mirrors brand-invoices.
1692
+ const hasMonthYear = ( req.body.monthFilter && String( req.body.monthFilter ) !== '' ) ||
1693
+ ( req.body.yearFilter && String( req.body.yearFilter ) !== '' );
1694
+
1773
1695
  let filterStartDate = '';
1774
1696
  let filterEndDate = '';
1775
1697
 
1776
- if ( req.body?.filter && req.body.filter == 'current' ) {
1777
- filterStartDate = new Date( dayjs().startOf( 'month' ).format( 'YYYY-MM-DD' ) );
1778
- filterEndDate = new Date( dayjs().endOf( 'month' ).format( 'YYYY-MM-DD' ) );
1779
- }
1780
- if ( req.body?.filter && req.body.filter == 'prev' ) {
1781
- filterStartDate = new Date( dayjs().subtract( 1, 'month' ).startOf( 'month' ).format( 'YYYY-MM-DD' ) );
1782
- filterEndDate = new Date( dayjs().subtract( 1, 'month' ).endOf( 'month' ).format( 'YYYY-MM-DD' ) );
1783
- }
1784
- if ( req.body?.filter && req.body.filter == 'last' ) {
1785
- filterStartDate = new Date( dayjs().subtract( 12, 'month' ).startOf( 'month' ).format( 'YYYY-MM-DD' ) );
1786
- filterEndDate = new Date( dayjs().endOf( 'month' ).format( 'YYYY-MM-DD' ) );
1787
- }
1788
- console.log( filterStartDate, filterEndDate );
1698
+ if ( !hasMonthYear ) {
1699
+ if ( req.body?.filter && req.body.filter == 'current' ) {
1700
+ filterStartDate = new Date( dayjs().startOf( 'month' ).format( 'YYYY-MM-DD' ) );
1701
+ filterEndDate = new Date( dayjs().endOf( 'month' ).format( 'YYYY-MM-DD' ) );
1702
+ }
1703
+ if ( req.body?.filter && req.body.filter == 'prev' ) {
1704
+ filterStartDate = new Date( dayjs().subtract( 1, 'month' ).startOf( 'month' ).format( 'YYYY-MM-DD' ) );
1705
+ filterEndDate = new Date( dayjs().subtract( 1, 'month' ).endOf( 'month' ).format( 'YYYY-MM-DD' ) );
1706
+ }
1707
+ // Rolling windows. 'last3' = the prototype's "Last 3 Months" (was the
1708
+ // legacy 'last' id, which silently meant 12 months and made every other
1709
+ // filter look broken). 'last' is kept as an alias of 'last3' so older
1710
+ // clients don't break mid-deploy.
1711
+ if ( req.body?.filter && ( req.body.filter == 'last3' || req.body.filter == 'last' ) ) {
1712
+ filterStartDate = new Date( dayjs().subtract( 3, 'month' ).startOf( 'month' ).format( 'YYYY-MM-DD' ) );
1713
+ filterEndDate = new Date( dayjs().endOf( 'month' ).format( 'YYYY-MM-DD' ) );
1714
+ }
1715
+ if ( req.body?.filter && req.body.filter == 'last6' ) {
1716
+ filterStartDate = new Date( dayjs().subtract( 6, 'month' ).startOf( 'month' ).format( 'YYYY-MM-DD' ) );
1717
+ filterEndDate = new Date( dayjs().endOf( 'month' ).format( 'YYYY-MM-DD' ) );
1718
+ }
1719
+ if ( req.body?.filter && req.body.filter == 'last12' ) {
1720
+ filterStartDate = new Date( dayjs().subtract( 12, 'month' ).startOf( 'month' ).format( 'YYYY-MM-DD' ) );
1721
+ filterEndDate = new Date( dayjs().endOf( 'month' ).format( 'YYYY-MM-DD' ) );
1722
+ }
1789
1723
 
1790
- if ( req.body?.filter ) {
1791
- query.push(
1792
- {
1793
- $match: {
1794
- $and: [
1795
- { billingDate: { $gte: filterStartDate } },
1796
- { billingDate: { $lte: filterEndDate } },
1797
- ],
1798
- },
1724
+ if ( req.body?.filter ) {
1725
+ query.push( {
1726
+ $match: {
1727
+ $and: [
1728
+ { billingDate: { $gte: filterStartDate } },
1729
+ { billingDate: { $lte: filterEndDate } },
1730
+ ],
1799
1731
  },
1732
+ } );
1733
+ }
1734
+ }
1800
1735
 
1801
- );
1736
+ // Month / Year filters (independent of durationFilter). Both 1-based:
1737
+ // monthFilter '1'..'12', yearFilter four-digit string like '2026'.
1738
+ // billingDate may be stored as Date OR string in some legacy rows, so
1739
+ // coerce inside the pipeline before $year/$month.
1740
+ const monthNum = req.body.monthFilter ? parseInt( req.body.monthFilter, 10 ) : null;
1741
+ const yearNum = req.body.yearFilter ? parseInt( req.body.yearFilter, 10 ) : null;
1742
+ if ( ( monthNum && monthNum >= 1 && monthNum <= 12 ) || yearNum ) {
1743
+ const billingDateExpr = {
1744
+ $cond: [
1745
+ { $eq: [ { $type: '$billingDate' }, 'date' ] },
1746
+ '$billingDate',
1747
+ { $toDate: '$billingDate' },
1748
+ ],
1749
+ };
1750
+ const expr = { $and: [] };
1751
+ if ( yearNum ) {
1752
+ expr.$and.push( { $eq: [ { $year: billingDateExpr }, yearNum ] } );
1753
+ }
1754
+ if ( monthNum && monthNum >= 1 && monthNum <= 12 ) {
1755
+ expr.$and.push( { $eq: [ { $month: billingDateExpr }, monthNum ] } );
1756
+ }
1757
+ query.push( { $match: { $expr: expr } } );
1802
1758
  }
1803
1759
 
1804
1760
 
@@ -1835,6 +1791,7 @@ export async function clientInvoiceList( req, res ) {
1835
1791
  clientName: '$clientDetails.clientName',
1836
1792
  logo: '$clientDetails.logo',
1837
1793
  currencyType: '$currency',
1794
+ currency: 1,
1838
1795
  invoice: 1,
1839
1796
  stores: 1,
1840
1797
  amount: 1,
@@ -1842,8 +1799,17 @@ export async function clientInvoiceList( req, res ) {
1842
1799
  groupName: 1,
1843
1800
  status: 1,
1844
1801
  paymentStatus: 1,
1802
+ paidAmount: { $ifNull: [ '$paidAmount', 0 ] },
1845
1803
  clientId: 1,
1846
1804
  billingDate: 1,
1805
+ companyName: 1,
1806
+ dueDate: 1,
1807
+ // GST column shown in the table. Derived rather than stored because
1808
+ // dollar-currency invoices have totalAmount === amount (no GST).
1809
+ gstAmount: { $subtract: [
1810
+ { $ifNull: [ '$totalAmount', 0 ] },
1811
+ { $ifNull: [ '$amount', 0 ] },
1812
+ ] },
1847
1813
  },
1848
1814
  },
1849
1815
 
@@ -1854,6 +1820,8 @@ export async function clientInvoiceList( req, res ) {
1854
1820
  $or: [
1855
1821
  { groupName: { $regex: req.body.searchValue, $options: 'i' } },
1856
1822
  { clientName: { $regex: req.body.searchValue, $options: 'i' } },
1823
+ { companyName: { $regex: req.body.searchValue, $options: 'i' } },
1824
+ { invoice: { $regex: req.body.searchValue, $options: 'i' } },
1857
1825
  { paymentStatus: { $regex: req.body.searchValue, $options: 'i' } },
1858
1826
  { status: { $regex: req.body.searchValue, $options: 'i' } },
1859
1827
  ],
@@ -1888,11 +1856,15 @@ export async function clientInvoiceList( req, res ) {
1888
1856
  const exportdata = [];
1889
1857
  count.forEach( ( element ) => {
1890
1858
  exportdata.push( {
1891
- 'Client Name': element.clientName,
1859
+ 'Brand Name': element.clientName,
1860
+ 'Registered Name': element.companyName || '',
1892
1861
  'Invoice #': element.invoice,
1893
1862
  'Billing date': dayjs( element.billingDate ).format( 'DD MMM, YYYY' ),
1863
+ 'Due Date': element.dueDate ? dayjs( element.dueDate ).format( 'DD MMM, YYYY' ) : '',
1894
1864
  'Group Name': element.groupName,
1895
- 'Amount': element.totalAmount,
1865
+ 'Amount Excl. GST': element.amount,
1866
+ 'GST Amount': element.gstAmount,
1867
+ 'Amount Incl. GST': element.totalAmount,
1896
1868
  'Stores': element.stores,
1897
1869
  'Payment Status': element.paymentStatus,
1898
1870
  'Approval Status': element.status,
@@ -1922,7 +1894,38 @@ export async function clientInvoiceList( req, res ) {
1922
1894
  client.logo = '';
1923
1895
  }
1924
1896
  }
1925
- res.sendSuccess( { count: count.length, data: invoiceList } );
1897
+
1898
+ // Card totals — computed over the user's full client scope (findClients),
1899
+ // NOT the currently-filtered/paged view, so the cards stay stable as the
1900
+ // user narrows the list with filters. Outstanding is everything unpaid;
1901
+ // Overdue is the past-due subset; Pending Payment is the approved-but-
1902
+ // unpaid subset. "Outstanding amount" uses totalAmount - paidAmount so a
1903
+ // partially-paid invoice contributes only its remaining balance.
1904
+ const now = new Date();
1905
+ const remaining = { $subtract: [
1906
+ { $ifNull: [ '$totalAmount', { $ifNull: [ '$amount', 0 ] } ] },
1907
+ { $ifNull: [ '$paidAmount', 0 ] },
1908
+ ] };
1909
+ const cardsAggregate = await invoiceService.aggregate( [
1910
+ { $match: { clientId: { $in: findClients }, paymentStatus: { $ne: 'paid' } } },
1911
+ { $group: {
1912
+ _id: null,
1913
+ outstandingAmount: { $sum: remaining },
1914
+ outstandingCount: { $sum: 1 },
1915
+ overdueAmount: { $sum: { $cond: [ { $lt: [ '$dueDate', now ] }, remaining, 0 ] } },
1916
+ overdueCount: { $sum: { $cond: [ { $lt: [ '$dueDate', now ] }, 1, 0 ] } },
1917
+ pendingPaymentAmount: { $sum: { $cond: [ { $eq: [ '$status', 'approved' ] }, remaining, 0 ] } },
1918
+ pendingPaymentCount: { $sum: { $cond: [ { $eq: [ '$status', 'approved' ] }, 1, 0 ] } },
1919
+ } },
1920
+ ] );
1921
+ const cards = cardsAggregate[0] || {
1922
+ outstandingAmount: 0, outstandingCount: 0,
1923
+ overdueAmount: 0, overdueCount: 0,
1924
+ pendingPaymentAmount: 0, pendingPaymentCount: 0,
1925
+ };
1926
+ delete cards._id;
1927
+
1928
+ res.sendSuccess( { count: count.length, data: invoiceList, cards } );
1926
1929
  } catch ( error ) {
1927
1930
  logger.error( { error: error, function: 'clientInvoiceList' } );
1928
1931
  return res.sendError( error, 500 );
@@ -2147,6 +2150,105 @@ export async function PaymentStatusChange( req, res ) {
2147
2150
  }
2148
2151
  }
2149
2152
 
2153
+ // Record a (full or partial) payment against an invoice. Appends to
2154
+ // paymentHistory, increments paidAmount, and recomputes paymentStatus
2155
+ // (unpaid → partial → paid). Body: { invoiceId, amount, date, method?,
2156
+ // reference?, notes? }. invoiceId is the human-readable invoice number
2157
+ // (e.g. INV-26-27-00077), matching the legacy PaymentStatusChange contract.
2158
+ export async function recordPayment( req, res ) {
2159
+ try {
2160
+ const { invoiceId, amount, date, method, reference, notes } = req.body;
2161
+ if ( !invoiceId ) {
2162
+ return res.sendError( 'invoiceId is required', 400 );
2163
+ }
2164
+ const amountNum = Number( amount );
2165
+ if ( !Number.isFinite( amountNum ) || amountNum <= 0 ) {
2166
+ return res.sendError( 'amount must be a positive number', 400 );
2167
+ }
2168
+
2169
+ const invoice = await invoiceService.findOne( { invoice: invoiceId } );
2170
+ if ( !invoice ) {
2171
+ return res.sendError( 'Invoice not found', 404 );
2172
+ }
2173
+
2174
+ const previousPaid = Number( invoice.paidAmount ) || 0;
2175
+ const newPaid = Math.round( ( previousPaid + amountNum ) * 100 ) / 100;
2176
+ const totalAmount = Number( invoice.totalAmount ) || 0;
2177
+
2178
+ // Reject overpayment — finance teams want this caught early. They can
2179
+ // record an exact final payment that brings the running total to the
2180
+ // invoice total; anything beyond that is a data-entry error.
2181
+ if ( totalAmount > 0 && newPaid > totalAmount + 0.01 ) {
2182
+ return res.sendError(
2183
+ `Payment exceeds outstanding balance. Outstanding: ${( totalAmount - previousPaid ).toFixed( 2 )}`,
2184
+ 400,
2185
+ );
2186
+ }
2187
+
2188
+ let derivedStatus = 'unpaid';
2189
+ if ( newPaid >= totalAmount - 0.01 && totalAmount > 0 ) {
2190
+ derivedStatus = 'paid';
2191
+ } else if ( newPaid > 0 ) {
2192
+ derivedStatus = 'partial';
2193
+ }
2194
+
2195
+ const historyEntry = {
2196
+ amount: amountNum,
2197
+ date: date ? new Date( date ) : new Date(),
2198
+ method: method || undefined,
2199
+ reference: reference || undefined,
2200
+ notes: notes || undefined,
2201
+ recordedBy: req.user?.email || req.user?.userName || undefined,
2202
+ recordedAt: new Date(),
2203
+ };
2204
+
2205
+ const update = {
2206
+ $set: {
2207
+ paidAmount: newPaid,
2208
+ paymentStatus: derivedStatus,
2209
+ ...( derivedStatus === 'paid' ? { paidDate: new Date() } : {} ),
2210
+ },
2211
+ $push: { paymentHistory: historyEntry },
2212
+ };
2213
+
2214
+ // Use invoiceUpdateOne — it passes the raw update object straight to
2215
+ // Mongoose. invoiceService.updateOne (without "invoice" prefix) wraps
2216
+ // its second arg in $set, which would turn our { $set, $push } into
2217
+ // { $set: { $set, $push } } and Mongoose silently drops both as
2218
+ // unknown fields under strict mode. (That's why an earlier version
2219
+ // of this controller responded 200 but didn't actually persist.)
2220
+ const result = await invoiceService.invoiceUpdateOne( { invoice: invoiceId }, update );
2221
+ logger.info?.( { function: 'recordPayment', invoiceId, matched: result?.matchedCount, modified: result?.modifiedCount } );
2222
+
2223
+ try {
2224
+ const logObj = {
2225
+ userName: req.user?.userName,
2226
+ email: req.user?.email,
2227
+ clientId: invoice.clientId,
2228
+ logSubType: 'paymentRecorded',
2229
+ logType: 'invoice',
2230
+ date: new Date(),
2231
+ changes: [ `Payment of ${amountNum} recorded for ${invoiceId}. Status: ${derivedStatus} (${newPaid}/${totalAmount}).` ],
2232
+ eventType: 'update',
2233
+ timestamp: new Date(),
2234
+ showTo: [ 'tango' ],
2235
+ };
2236
+ insertOpenSearchData( JSON.parse( process.env.OPENSEARCH ).activityLog, logObj );
2237
+ } catch ( logErr ) {
2238
+ logger.error( { error: logErr, function: 'recordPayment.log' } );
2239
+ }
2240
+
2241
+ return res.sendSuccess( {
2242
+ paidAmount: newPaid,
2243
+ pendingAmount: Math.max( 0, totalAmount - newPaid ),
2244
+ paymentStatus: derivedStatus,
2245
+ } );
2246
+ } catch ( error ) {
2247
+ logger.error( { error: error, function: 'recordPayment' } );
2248
+ return res.sendError( error, 500 );
2249
+ }
2250
+ }
2251
+
2150
2252
  export async function checkPaymentStatus( req, res ) {
2151
2253
  try {
2152
2254
  let findInvoice = await invoiceService.find( { status: 'approved', paymentStatus: { $ne: 'paid' } } );