tango-app-api-payment-subscription 3.5.5 → 3.5.7

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.
@@ -17,6 +17,7 @@ import { symbolFor } from '../utils/currency.js';
17
17
  import { invoiceStatusEnum } from '../dtos/validation.dtos.js';
18
18
  import { findOneApplicationDefault } from '../services/applicationDefault.service.js';
19
19
  import * as assignedStoreService from '../services/assignedStore.service.js';
20
+ import { getUsdInrRate } from './brandsBilling.controller.js';
20
21
 
21
22
  // Pulls CSM + Finance head emails (stored under applicationDefault
22
23
  // type=invoice, subType=heads) AND the per-client CSMs assigned via
@@ -360,6 +361,103 @@ function getCurrentFinancialYear() {
360
361
  }
361
362
 
362
363
 
364
+ // ---------------------------------------------------------------------------
365
+ // Shared annexure builder. Anchored to the invoice's BILLING month and
366
+ // mirroring invoice generation's billing-type rules: perZone / perCamera
367
+ // products multiply by the store's zone / camera count (see standardPrice).
368
+ // Returns the per-store rows plus the grand total shown at the end.
369
+ // ---------------------------------------------------------------------------
370
+ async function buildAnnexureRows( invoiceInfo, getgroup ) {
371
+ const billingMonth = invoiceInfo.billingDate ? dayjs( invoiceInfo.billingDate ) : dayjs();
372
+ const billingMonthEnd = new Date( billingMonth.endOf( 'month' ).toISOString() );
373
+ const monthDays = billingMonth.daysInMonth();
374
+ const invoiceCurrency = symbolFor( invoiceInfo.currency );
375
+
376
+ const annexClient = await clientService.findOne( { clientId: invoiceInfo.clientId }, { 'planDetails.product': 1 } );
377
+ const billingTypeMap = {};
378
+ ( annexClient?.planDetails?.product || [] ).forEach( ( p ) => {
379
+ billingTypeMap[p.productName] = p.billingType || 'perStore';
380
+ } );
381
+
382
+ const rows = await dailyPricingService.aggregate( [
383
+ { $match: { clientId: invoiceInfo.clientId, dateISO: { $lte: billingMonthEnd } } },
384
+ { $sort: { dateISO: -1 } },
385
+ { $limit: 1 },
386
+ { $project: { stores: { $filter: { input: '$stores', as: 'item', cond: { $in: [ '$$item.storeId', getgroup?.stores ?? [] ] } } } } },
387
+ { $unwind: { path: '$stores', preserveNullAndEmptyArrays: false } },
388
+ { $unwind: { path: '$stores.products', preserveNullAndEmptyArrays: false } },
389
+ { $project: {
390
+ productName: '$stores.products.productName',
391
+ storeId: '$stores.storeId',
392
+ storeName: '$stores.storeName',
393
+ edgefirstFileDate: { $ifNull: [ '$stores.edgefirstFileDate', '$stores.processfirstFileDate' ] },
394
+ workingdays: '$stores.products.workingdays',
395
+ zoneCount: { $ifNull: [ '$stores.zoneCount', 0 ] },
396
+ trafficCameraCount: { $ifNull: [ '$stores.trafficCameraCount', 0 ] },
397
+ zoneCameraCount: { $ifNull: [ '$stores.zoneCameraCount', 0 ] },
398
+ } },
399
+ { $match: { workingdays: { $gt: 0 } } },
400
+ { $sort: { productName: 1, workingdays: -1 } },
401
+ { $lookup: {
402
+ from: 'basepricings',
403
+ let: { clientId: invoiceInfo.clientId },
404
+ pipeline: [
405
+ { $match: { $expr: { $eq: [ '$clientId', '$$clientId' ] } } },
406
+ { $project: { standard: 1 } },
407
+ ],
408
+ as: 'basepricing',
409
+ } },
410
+ { $unwind: { path: '$basepricing', preserveNullAndEmptyArrays: true } },
411
+ { $project: {
412
+ productName: 1, workingdays: 1, storeName: 1, storeId: 1, edgefirstFileDate: 1,
413
+ zoneCount: 1, trafficCameraCount: 1, zoneCameraCount: 1,
414
+ standard: { $filter: { input: '$basepricing.standard', as: 'standard', cond: { $eq: [ '$$standard.productName', '$productName' ] } } },
415
+ } },
416
+ { $unwind: { path: '$standard', preserveNullAndEmptyArrays: true } },
417
+ ] );
418
+
419
+ const data = rows.map( ( s ) => {
420
+ const billingType = billingTypeMap[s.productName] || 'perStore';
421
+ // Same units rule as invoice generation: perZone / perCamera multiply by
422
+ // the store's zone / camera count; anything else stays per-store.
423
+ let units = 1;
424
+ if ( s.productName === 'tangoZone' ) {
425
+ if ( billingType === 'perZone' && s.zoneCount > 0 ) {
426
+ units = s.zoneCount;
427
+ } else if ( billingType === 'perCamera' && s.zoneCameraCount > 0 ) {
428
+ units = s.zoneCameraCount;
429
+ }
430
+ } else if ( s.productName === 'tangoTraffic' ) {
431
+ if ( billingType === 'perCamera' && s.trafficCameraCount > 0 ) {
432
+ units = s.trafficCameraCount;
433
+ }
434
+ }
435
+ const price = Number( s.standard?.negotiatePrice ) || 0;
436
+ const runningCost = s.workingdays >= monthDays ?
437
+ Math.round( price * units * 100 ) / 100 :
438
+ Math.round( ( price / monthDays ) * s.workingdays * units * 100 ) / 100;
439
+ return {
440
+ productName: s.productName ? s.productName.charAt( 0 ).toUpperCase() + s.productName.slice( 1 ) : '',
441
+ currencyType: invoiceCurrency,
442
+ workingdays: s.workingdays,
443
+ storeName: s.storeName,
444
+ storeId: s.storeId,
445
+ edgefirstFileDate: s.edgefirstFileDate ? dayjs( s.edgefirstFileDate ).format( 'YYYY-MM-DD' ) : '',
446
+ period: s.workingdays < monthDays ? 'prorate' : 'fullmonth',
447
+ billingType,
448
+ // Human label for the unit-price column: what one unit means.
449
+ unitBasis: billingType === 'perCamera' ? 'Per Camera' : billingType === 'perZone' ? 'Per Zone' : 'Per Store',
450
+ units,
451
+ standardPrice: price,
452
+ runningCost,
453
+ };
454
+ } );
455
+
456
+ const totalAmount = Math.round( data.reduce( ( a, x ) => a + ( x.runningCost || 0 ), 0 ) * 100 ) / 100;
457
+ const totalFormatted = totalAmount.toLocaleString( 'en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 } );
458
+ return { data, totalAmount, totalFormatted };
459
+ }
460
+
363
461
  export async function invoiceDownload( req, res ) {
364
462
  try {
365
463
  let invoiceData;
@@ -438,145 +536,10 @@ export async function invoiceDownload( req, res ) {
438
536
  }
439
537
  } );
440
538
  }
441
- const currentMonthDays = dayjs().daysInMonth();
442
-
443
539
  if ( getgroup?.attachAnnexure ) {
444
- let annuxureData = await dailyPricingService.aggregate( [
445
- {
446
- $match: {
447
- clientId: invoiceInfo.clientId,
448
- },
449
- },
450
- {
451
- $sort: { dateISO: -1 },
452
- },
453
- { $limit: 1 },
454
- {
455
- $project: {
456
- stores: {
457
- $filter: {
458
- input: '$stores',
459
- as: 'item',
460
- cond: { $in: [ '$$item.storeId', getgroup?.stores ] },
461
- },
462
- },
463
- },
464
- },
465
- {
466
- $unwind: {
467
- path: '$stores',
468
- preserveNullAndEmptyArrays: false,
469
- },
470
- },
471
- {
472
- $unwind: {
473
- path: '$stores.products',
474
- preserveNullAndEmptyArrays: false,
475
- },
476
- },
477
- {
478
- $project: {
479
- productName: '$stores.products.productName',
480
- storeId: '$stores.storeId',
481
- storeName: '$stores.storeName',
482
- edgefirstFileDate: '$stores.edgefirstFileDate',
483
- workingdays: '$stores.products.workingdays',
484
- currencyType: { $literal: invoiceCurrency },
485
- },
486
- },
487
- {
488
- $sort: {
489
- productName: 1,
490
- workingdays: -1,
491
- },
492
- },
493
- {
494
- $match: { workingdays: { $gt: 0 } },
495
- },
496
-
497
- {
498
- $lookup: {
499
- from: 'basepricings',
500
- let: { clientId: invoiceInfo.clientId },
501
- pipeline: [
502
- {
503
- $match: {
504
- $expr: {
505
- $eq: [ '$clientId', '$$clientId' ],
506
- },
507
- },
508
- },
509
- {
510
- $project: {
511
- standard: 1,
512
- step: 1,
513
- },
514
- },
515
- ],
516
- as: 'basepricing',
517
- },
518
- },
519
- {
520
- $unwind: { path: '$basepricing', preserveNullAndEmptyArrays: true },
521
- },
522
- {
523
- $project: {
524
- productName: 1,
525
- workingdays: 1,
526
- storeName: 1,
527
- currencyType: 1,
528
- edgefirstFileDate: { $ifNull: [ '$edgefirstFileDate', '$processfirstFileDate' ] },
529
- storeId: 1,
530
- standard: {
531
- $filter: {
532
- input: '$basepricing.standard',
533
- as: 'standard',
534
- cond: { $eq: [ '$$standard.productName', '$productName' ] },
535
- },
536
- },
537
- step: '$basepricing.step',
538
- },
539
- },
540
- {
541
- $unwind: { path: '$standard', preserveNullAndEmptyArrays: true },
542
- },
543
- {
544
- $project: {
545
- productName: {
546
- $concat: [
547
- { $toUpper: { $substr: [ '$productName', 0, 1 ] } }, // Uppercase first letter
548
- { $substr: [ '$productName', 1, { $strLenCP: '$productName' } ] }, // Rest of the string
549
- ],
550
- },
551
- currencyType: 1,
552
- workingdays: 1,
553
- storeName: 1,
554
- edgefirstFileDate: { $dateToString: { format: '%Y-%m-%d', date: '$edgefirstFileDate' } },
555
- storeId: 1,
556
- period: {
557
- $cond: {
558
- if: { $lt: [ '$workingdays', currentMonthDays ] },
559
- then: 'prorate',
560
- else: 'fullmonth',
561
- },
562
- },
563
- standardPrice: '$standard.negotiatePrice',
564
- runningCost: {
565
- $round: [
566
- {
567
- $multiply: [
568
- { $divide: [ '$standard.negotiatePrice', currentMonthDays ] },
569
- '$workingdays',
570
- ],
571
- },
572
- 2,
573
- ],
574
- },
575
- },
576
- },
577
- ],
578
- );
540
+ const { data: annuxureData, totalFormatted } = await buildAnnexureRows( invoiceInfo, getgroup );
579
541
  invoiceData.annuxureData = annuxureData;
542
+ invoiceData.annuxureTotal = totalFormatted;
580
543
  }
581
544
 
582
545
  const templateHtml = fs.readFileSync( path.resolve( path.dirname( '' ) ) + '/src/hbs/invoicePdf.hbs', 'utf8' );
@@ -755,65 +718,10 @@ async function buildInvoicePdfBuffer( invoiceId ) {
755
718
  } );
756
719
  }
757
720
 
758
- const currentMonthDays = dayjs().daysInMonth();
759
-
760
721
  if ( getgroup?.attachAnnexure ) {
761
- let annuxureData = await dailyPricingService.aggregate( [
762
- { $match: { clientId: invoiceInfo.clientId } },
763
- { $sort: { dateISO: -1 } },
764
- { $limit: 1 },
765
- { $project: { stores: { $filter: { input: '$stores', as: 'item', cond: { $in: [ '$$item.storeId', getgroup?.stores ] } } } } },
766
- { $unwind: { path: '$stores', preserveNullAndEmptyArrays: false } },
767
- { $unwind: { path: '$stores.products', preserveNullAndEmptyArrays: false } },
768
- { $project: {
769
- productName: '$stores.products.productName',
770
- storeId: '$stores.storeId',
771
- storeName: '$stores.storeName',
772
- edgefirstFileDate: '$stores.edgefirstFileDate',
773
- workingdays: '$stores.products.workingdays',
774
- currencyType: { $literal: invoiceCurrency },
775
- } },
776
- { $sort: { productName: 1, workingdays: -1 } },
777
- { $match: { workingdays: { $gt: 0 } } },
778
- { $lookup: {
779
- from: 'basepricings',
780
- let: { clientId: invoiceInfo.clientId },
781
- pipeline: [
782
- { $match: { $expr: { $eq: [ '$clientId', '$$clientId' ] } } },
783
- { $project: { standard: 1, step: 1 } },
784
- ],
785
- as: 'basepricing',
786
- } },
787
- { $unwind: { path: '$basepricing', preserveNullAndEmptyArrays: true } },
788
- { $project: {
789
- productName: 1,
790
- workingdays: 1,
791
- storeName: 1,
792
- currencyType: 1,
793
- edgefirstFileDate: { $ifNull: [ '$edgefirstFileDate', '$processfirstFileDate' ] },
794
- storeId: 1,
795
- standard: { $filter: { input: '$basepricing.standard', as: 'standard', cond: { $eq: [ '$$standard.productName', '$productName' ] } } },
796
- step: '$basepricing.step',
797
- } },
798
- { $unwind: { path: '$standard', preserveNullAndEmptyArrays: true } },
799
- { $project: {
800
- productName: {
801
- $concat: [
802
- { $toUpper: { $substr: [ '$productName', 0, 1 ] } },
803
- { $substr: [ '$productName', 1, { $strLenCP: '$productName' } ] },
804
- ],
805
- },
806
- currencyType: 1,
807
- workingdays: 1,
808
- storeName: 1,
809
- edgefirstFileDate: { $dateToString: { format: '%Y-%m-%d', date: '$edgefirstFileDate' } },
810
- storeId: 1,
811
- period: { $cond: { if: { $lt: [ '$workingdays', currentMonthDays ] }, then: 'prorate', else: 'fullmonth' } },
812
- standardPrice: '$standard.negotiatePrice',
813
- runningCost: { $round: [ { $multiply: [ { $divide: [ '$standard.negotiatePrice', currentMonthDays ] }, '$workingdays' ] }, 2 ] },
814
- } },
815
- ] );
722
+ const { data: annuxureData, totalFormatted } = await buildAnnexureRows( invoiceInfo, getgroup );
816
723
  invoiceData.annuxureData = annuxureData;
724
+ invoiceData.annuxureTotal = totalFormatted;
817
725
  }
818
726
 
819
727
  const templateHtml = fs.readFileSync( path.resolve( path.dirname( '' ) ) + '/src/hbs/invoicePdf.hbs', 'utf8' );
@@ -926,68 +834,8 @@ export async function invoiceAnnexure( req, res ) {
926
834
  return res.sendSuccess( { data: [] } );
927
835
  }
928
836
 
929
- const currentMonthDays = dayjs().daysInMonth();
930
- // Annexure must show the same currency as the invoice it accompanies —
931
- // see invoiceDownload / buildInvoicePdfBuffer for the same pattern.
932
- const invoiceCurrency = symbolFor( invoiceInfo.currency );
933
-
934
- const annexureData = await dailyPricingService.aggregate( [
935
- { $match: { clientId: invoiceInfo.clientId } },
936
- { $sort: { dateISO: -1 } },
937
- { $limit: 1 },
938
- { $project: { stores: { $filter: { input: '$stores', as: 'item', cond: { $in: [ '$$item.storeId', getgroup?.stores ?? [] ] } } } } },
939
- { $unwind: { path: '$stores', preserveNullAndEmptyArrays: false } },
940
- { $unwind: { path: '$stores.products', preserveNullAndEmptyArrays: false } },
941
- { $project: {
942
- productName: '$stores.products.productName',
943
- storeId: '$stores.storeId',
944
- storeName: '$stores.storeName',
945
- edgefirstFileDate: '$stores.edgefirstFileDate',
946
- workingdays: '$stores.products.workingdays',
947
- currencyType: { $literal: invoiceCurrency },
948
- } },
949
- { $sort: { productName: 1, workingdays: -1 } },
950
- { $match: { workingdays: { $gt: 0 } } },
951
- { $lookup: {
952
- from: 'basepricings',
953
- let: { clientId: invoiceInfo.clientId },
954
- pipeline: [
955
- { $match: { $expr: { $eq: [ '$clientId', '$$clientId' ] } } },
956
- { $project: { standard: 1, step: 1 } },
957
- ],
958
- as: 'basepricing',
959
- } },
960
- { $unwind: { path: '$basepricing', preserveNullAndEmptyArrays: true } },
961
- { $project: {
962
- productName: 1,
963
- workingdays: 1,
964
- storeName: 1,
965
- currencyType: 1,
966
- edgefirstFileDate: { $ifNull: [ '$edgefirstFileDate', '$processfirstFileDate' ] },
967
- storeId: 1,
968
- standard: { $filter: { input: '$basepricing.standard', as: 'standard', cond: { $eq: [ '$$standard.productName', '$productName' ] } } },
969
- step: '$basepricing.step',
970
- } },
971
- { $unwind: { path: '$standard', preserveNullAndEmptyArrays: true } },
972
- { $project: {
973
- productName: {
974
- $concat: [
975
- { $toUpper: { $substr: [ '$productName', 0, 1 ] } },
976
- { $substr: [ '$productName', 1, { $strLenCP: '$productName' } ] },
977
- ],
978
- },
979
- currencyType: 1,
980
- workingdays: 1,
981
- storeName: 1,
982
- edgefirstFileDate: { $dateToString: { format: '%Y-%m-%d', date: '$edgefirstFileDate' } },
983
- storeId: 1,
984
- period: { $cond: { if: { $lt: [ '$workingdays', currentMonthDays ] }, then: 'prorate', else: 'fullmonth' } },
985
- standardPrice: '$standard.negotiatePrice',
986
- runningCost: { $round: [ { $multiply: [ { $divide: [ '$standard.negotiatePrice', currentMonthDays ] }, '$workingdays' ] }, 2 ] },
987
- } },
988
- ] );
989
-
990
- return res.sendSuccess( { data: annexureData } );
837
+ const { data, totalAmount } = await buildAnnexureRows( invoiceInfo, getgroup );
838
+ return res.sendSuccess( { data, totalAmount } );
991
839
  } catch ( error ) {
992
840
  logger.error( { error: error, function: 'invoiceAnnexure', invoiceId: req.params.invoiceId } );
993
841
  return res.sendError( error, 500 );
@@ -1857,7 +1705,19 @@ export async function clientInvoiceList( req, res ) {
1857
1705
  filterStartDate = new Date( dayjs().subtract( 1, 'month' ).startOf( 'month' ).format( 'YYYY-MM-DD' ) );
1858
1706
  filterEndDate = new Date( dayjs().subtract( 1, 'month' ).endOf( 'month' ).format( 'YYYY-MM-DD' ) );
1859
1707
  }
1860
- if ( req.body?.filter && req.body.filter == 'last' ) {
1708
+ // Rolling windows. 'last3' = the prototype's "Last 3 Months" (was the
1709
+ // legacy 'last' id, which silently meant 12 months and made every other
1710
+ // filter look broken). 'last' is kept as an alias of 'last3' so older
1711
+ // clients don't break mid-deploy.
1712
+ if ( req.body?.filter && ( req.body.filter == 'last3' || req.body.filter == 'last' ) ) {
1713
+ filterStartDate = new Date( dayjs().subtract( 3, 'month' ).startOf( 'month' ).format( 'YYYY-MM-DD' ) );
1714
+ filterEndDate = new Date( dayjs().endOf( 'month' ).format( 'YYYY-MM-DD' ) );
1715
+ }
1716
+ if ( req.body?.filter && req.body.filter == 'last6' ) {
1717
+ filterStartDate = new Date( dayjs().subtract( 6, 'month' ).startOf( 'month' ).format( 'YYYY-MM-DD' ) );
1718
+ filterEndDate = new Date( dayjs().endOf( 'month' ).format( 'YYYY-MM-DD' ) );
1719
+ }
1720
+ if ( req.body?.filter && req.body.filter == 'last12' ) {
1861
1721
  filterStartDate = new Date( dayjs().subtract( 12, 'month' ).startOf( 'month' ).format( 'YYYY-MM-DD' ) );
1862
1722
  filterEndDate = new Date( dayjs().endOf( 'month' ).format( 'YYYY-MM-DD' ) );
1863
1723
  }
@@ -1943,6 +1803,14 @@ export async function clientInvoiceList( req, res ) {
1943
1803
  paidAmount: { $ifNull: [ '$paidAmount', 0 ] },
1944
1804
  clientId: 1,
1945
1805
  billingDate: 1,
1806
+ companyName: 1,
1807
+ dueDate: 1,
1808
+ // GST column shown in the table. Derived rather than stored because
1809
+ // dollar-currency invoices have totalAmount === amount (no GST).
1810
+ gstAmount: { $subtract: [
1811
+ { $ifNull: [ '$totalAmount', 0 ] },
1812
+ { $ifNull: [ '$amount', 0 ] },
1813
+ ] },
1946
1814
  },
1947
1815
  },
1948
1816
 
@@ -1953,6 +1821,8 @@ export async function clientInvoiceList( req, res ) {
1953
1821
  $or: [
1954
1822
  { groupName: { $regex: req.body.searchValue, $options: 'i' } },
1955
1823
  { clientName: { $regex: req.body.searchValue, $options: 'i' } },
1824
+ { companyName: { $regex: req.body.searchValue, $options: 'i' } },
1825
+ { invoice: { $regex: req.body.searchValue, $options: 'i' } },
1956
1826
  { paymentStatus: { $regex: req.body.searchValue, $options: 'i' } },
1957
1827
  { status: { $regex: req.body.searchValue, $options: 'i' } },
1958
1828
  ],
@@ -1987,11 +1857,15 @@ export async function clientInvoiceList( req, res ) {
1987
1857
  const exportdata = [];
1988
1858
  count.forEach( ( element ) => {
1989
1859
  exportdata.push( {
1990
- 'Client Name': element.clientName,
1860
+ 'Brand Name': element.clientName,
1861
+ 'Registered Name': element.companyName || '',
1991
1862
  'Invoice #': element.invoice,
1992
1863
  'Billing date': dayjs( element.billingDate ).format( 'DD MMM, YYYY' ),
1864
+ 'Due Date': element.dueDate ? dayjs( element.dueDate ).format( 'DD MMM, YYYY' ) : '',
1993
1865
  'Group Name': element.groupName,
1994
- 'Amount': element.totalAmount,
1866
+ 'Amount Excl. GST': element.amount,
1867
+ 'GST Amount': element.gstAmount,
1868
+ 'Amount Incl. GST': element.totalAmount,
1995
1869
  'Stores': element.stores,
1996
1870
  'Payment Status': element.paymentStatus,
1997
1871
  'Approval Status': element.status,
@@ -2021,7 +1895,45 @@ export async function clientInvoiceList( req, res ) {
2021
1895
  client.logo = '';
2022
1896
  }
2023
1897
  }
2024
- res.sendSuccess( { count: count.length, data: invoiceList } );
1898
+
1899
+ // Card totals — computed over the CURRENTLY-FILTERED result set (`count`
1900
+ // holds every invoice matching the active filters, pre-pagination), so the
1901
+ // cards reflect exactly what the filters select. Dollar invoices are
1902
+ // converted to INR at today's rate so all three totals are a single ₹
1903
+ // figure. Outstanding = unpaid remaining (totalAmount - paidAmount);
1904
+ // Overdue = past-due unpaid subset; Pending Payment = approved-but-unpaid.
1905
+ const usdRate = await getUsdInrRate();
1906
+ const now = new Date();
1907
+ const cards = {
1908
+ outstandingAmount: 0, outstandingCount: 0,
1909
+ overdueAmount: 0, overdueCount: 0,
1910
+ pendingPaymentAmount: 0, pendingPaymentCount: 0,
1911
+ };
1912
+ for ( const inv of count ) {
1913
+ if ( inv.paymentStatus === 'paid' ) {
1914
+ continue;
1915
+ }
1916
+ const fx = inv.currency === 'dollar' ? usdRate : 1;
1917
+ const total = Number( inv.totalAmount ) || Number( inv.amount ) || 0;
1918
+ const paid = Number( inv.paidAmount ) || 0;
1919
+ const remaining = Math.max( 0, total - paid ) * fx;
1920
+
1921
+ cards.outstandingAmount += remaining;
1922
+ cards.outstandingCount += 1;
1923
+ if ( inv.dueDate && new Date( inv.dueDate ) < now ) {
1924
+ cards.overdueAmount += remaining;
1925
+ cards.overdueCount += 1;
1926
+ }
1927
+ if ( inv.status === 'approved' ) {
1928
+ cards.pendingPaymentAmount += remaining;
1929
+ cards.pendingPaymentCount += 1;
1930
+ }
1931
+ }
1932
+ cards.outstandingAmount = Math.round( cards.outstandingAmount * 100 ) / 100;
1933
+ cards.overdueAmount = Math.round( cards.overdueAmount * 100 ) / 100;
1934
+ cards.pendingPaymentAmount = Math.round( cards.pendingPaymentAmount * 100 ) / 100;
1935
+
1936
+ res.sendSuccess( { count: count.length, data: invoiceList, cards } );
2025
1937
  } catch ( error ) {
2026
1938
  logger.error( { error: error, function: 'clientInvoiceList' } );
2027
1939
  return res.sendError( error, 500 );
@@ -0,0 +1,81 @@
1
+ import * as paymentReminderService from '../services/paymentReminder.service.js';
2
+ import { logger } from 'tango-app-api-middleware';
3
+
4
+ // Payment reminder config (Billing Settings page). One document per brand
5
+ // (clientId): recipient emails + five toggleable reminder templates. Returns
6
+ // sensible defaults when a brand has no config saved yet.
7
+ const DEFAULTS = () => ( {
8
+ reminderEmails: [],
9
+ templates: {
10
+ preDue: { enabled: true, daysBefore: 3 },
11
+ onDue: { enabled: true },
12
+ onHold: { enabled: true },
13
+ suspend: { enabled: true },
14
+ deactivated: { enabled: false },
15
+ },
16
+ } );
17
+
18
+ export async function getPaymentReminder( req, res ) {
19
+ try {
20
+ const clientId = req.params.clientId || req.query.clientId;
21
+ if ( !clientId ) {
22
+ return res.sendError( 'clientId is required', 400 );
23
+ }
24
+ const existing = await paymentReminderService.findOne( { clientId } );
25
+ if ( !existing ) {
26
+ return res.sendSuccess( { clientId, ...DEFAULTS(), isDefault: true } );
27
+ }
28
+ return res.sendSuccess( existing );
29
+ } catch ( error ) {
30
+ logger.error( { error: error, function: 'getPaymentReminder' } );
31
+ return res.sendError( error, 500 );
32
+ }
33
+ }
34
+
35
+ export async function savePaymentReminder( req, res ) {
36
+ try {
37
+ const b = req.body || {};
38
+ if ( !b.clientId ) {
39
+ return res.sendError( 'clientId is required', 400 );
40
+ }
41
+
42
+ // Normalize recipients: trim, drop blanks, de-dupe.
43
+ const emails = Array.isArray( b.reminderEmails ) ? b.reminderEmails : [];
44
+ const reminderEmails = [ ...new Set(
45
+ emails.map( ( e ) => String( e || '' ).trim() ).filter( Boolean ),
46
+ ) ];
47
+
48
+ const t = b.templates || {};
49
+ const bool = ( v, d ) => ( typeof v === 'boolean' ? v : d );
50
+ let daysBefore = Number( t.preDue?.daysBefore );
51
+ if ( !Number.isFinite( daysBefore ) ) {
52
+ daysBefore = 3;
53
+ }
54
+ daysBefore = Math.min( 365, Math.max( 1, Math.round( daysBefore ) ) );
55
+
56
+ const templates = {
57
+ preDue: { enabled: bool( t.preDue?.enabled, true ), daysBefore },
58
+ onDue: { enabled: bool( t.onDue?.enabled, true ) },
59
+ onHold: { enabled: bool( t.onHold?.enabled, true ) },
60
+ suspend: { enabled: bool( t.suspend?.enabled, true ) },
61
+ deactivated: { enabled: bool( t.deactivated?.enabled, false ) },
62
+ };
63
+
64
+ await paymentReminderService.upsert(
65
+ { clientId: b.clientId },
66
+ {
67
+ clientId: b.clientId,
68
+ reminderEmails,
69
+ templates,
70
+ updatedBy: req.user?.email || req.user?.userName || '',
71
+ },
72
+ );
73
+
74
+ const saved = await paymentReminderService.findOne( { clientId: b.clientId } );
75
+ logger.info?.( { function: 'savePaymentReminder', clientId: b.clientId } );
76
+ return res.sendSuccess( saved );
77
+ } catch ( error ) {
78
+ logger.error( { error: error, function: 'savePaymentReminder' } );
79
+ return res.sendError( error, 500 );
80
+ }
81
+ }