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.
- package/package.json +2 -2
- package/scripts/migrate-billing-prorata-pricing.js +66 -0
- package/src/controllers/bankTransaction.controller.js +617 -0
- package/src/controllers/billing.controllers.js +142 -1
- package/src/controllers/brandsBilling.controller.js +290 -0
- package/src/controllers/estimate.controller.js +317 -0
- package/src/controllers/invoice.controller.js +392 -290
- package/src/controllers/paymentSubscription.controllers.js +75 -10
- package/src/dtos/validation.dtos.js +8 -0
- package/src/hbs/estimatePdf.hbs +125 -0
- package/src/hbs/invoicePdf.hbs +27 -0
- package/src/routes/billing.routes.js +2 -1
- package/src/routes/brandsBilling.routes.js +2 -1
- package/src/routes/invoice.routes.js +20 -1
- package/src/services/bankTransaction.service.js +21 -0
- package/src/services/estimate.service.js +25 -0
- package/src/services/paymentAccount.service.js +5 -0
|
@@ -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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
923
|
-
|
|
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
|
-
|
|
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:
|
|
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: { $
|
|
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
|
-
|
|
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:
|
|
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: { $
|
|
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 (
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
|
|
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
|
-
|
|
1791
|
-
|
|
1792
|
-
{
|
|
1793
|
-
$
|
|
1794
|
-
$
|
|
1795
|
-
|
|
1796
|
-
|
|
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
|
-
'
|
|
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.
|
|
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
|
-
|
|
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' } } );
|