tango-app-api-payment-subscription 3.5.23 → 3.5.25

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tango-app-api-payment-subscription",
3
- "version": "3.5.23",
3
+ "version": "3.5.25",
4
4
  "description": "paymentSubscription",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -29,7 +29,7 @@
29
29
  "nodemon": "^3.1.0",
30
30
  "puppeteer": "^24.41.0",
31
31
  "swagger-ui-express": "^5.0.0",
32
- "tango-api-schema": "^2.6.37",
32
+ "tango-api-schema": "^2.6.43",
33
33
  "tango-app-api-middleware": "^3.6.18",
34
34
  "winston": "^3.12.0",
35
35
  "winston-daily-rotate-file": "^5.0.0",
@@ -343,14 +343,15 @@ export const updateBillingGroup = async ( req, res ) => {
343
343
 
344
344
  if ( removedStores.length ) {
345
345
  await updateOne( { _id: new mongoose.Types.ObjectId( req.body._id ) }, { $pull: { stores: { $in: removedStores } } } );
346
- await updateOne( { clientId: req.body.clientId, isPrimary: true }, { $push: { stores: { $each: removedStores } } } );
346
+ // $addToSet (not $push) so stores already in the primary group aren't duplicated.
347
+ await updateOne( { clientId: req.body.clientId, isPrimary: true }, { $addToSet: { stores: { $each: removedStores } } } );
347
348
  }
348
349
 
349
350
  const addedStores = req.body?.stores?.filter( ( val ) => !previousStores?.stores.includes( val ) );
350
351
 
351
352
  if ( addedStores.length ) {
352
353
  await updateMany( { clientId: req.body.clientId, _id: { $ne: new mongoose.Types.ObjectId( req.body._id ) } }, { $pull: { stores: { $in: req.body.stores } } } );
353
- await updateOne( { _id: new mongoose.Types.ObjectId( req.body._id ) }, { $push: { stores: { $each: addedStores } } } );
354
+ await updateOne( { _id: new mongoose.Types.ObjectId( req.body._id ) }, { $addToSet: { stores: { $each: addedStores } } } );
354
355
  }
355
356
  }
356
357
 
@@ -436,13 +437,32 @@ export const updateBillingGroup = async ( req, res ) => {
436
437
 
437
438
  export const deleteBillingGroup = async ( req, res ) => {
438
439
  try {
439
- const previousGroup = await findOne( { _id: new mongoose.Types.ObjectId( req.query._id ) }, { stores: 1, clientId: 1, groupName: 1 } );
440
+ const previousGroup = await findOne( { _id: new mongoose.Types.ObjectId( req.query._id ) }, { stores: 1, clientId: 1, groupName: 1, isPrimary: 1 } );
441
+ console.log( '🚀 ~ deleteBillingGroup ~ previousGroup:', previousGroup );
442
+
443
+ // No such group — surface a 404 instead of dereferencing null below (which
444
+ // would throw and 500). Covers a stale / already-deleted id.
445
+ if ( !previousGroup ) {
446
+ return res.sendError( 'Billing group not found', 404 );
447
+ }
448
+ // The primary group can't be deleted (stores fall back to it). Reject
449
+ // explicitly rather than silently no-op'ing the delete.
450
+ if ( previousGroup.isPrimary ) {
451
+ return res.sendError( 'The primary billing group cannot be deleted', 400 );
452
+ }
440
453
 
441
454
  if ( previousGroup?.stores?.length ) {
442
- await updateOne( { clientId: previousGroup.clientId, isPrimary: true }, { $push: { stores: { $each: previousGroup?.stores } } } );
455
+ // Move the deleted group's stores back to the primary group. Use $addToSet
456
+ // (not $push) so stores already present in the primary group aren't
457
+ // duplicated in its stores array.
458
+ await updateOne( { clientId: previousGroup.clientId, isPrimary: true }, { $addToSet: { stores: { $each: previousGroup?.stores } } } );
443
459
  }
444
460
 
445
461
  const deletedGroup = await deleteOne( { _id: new mongoose.Types.ObjectId( req.query._id ), isPrimary: false } );
462
+ // Guard against a silent no-op (e.g. a concurrent delete removed it first).
463
+ if ( !deletedGroup?.deletedCount ) {
464
+ return res.sendError( 'Billing group could not be deleted', 404 );
465
+ }
446
466
  const updateKeys = [];
447
467
  updateKeys.push( previousGroup.groupName );
448
468
  const logObj = {
@@ -939,13 +959,12 @@ export async function getClientProducts( req, res ) {
939
959
  }
940
960
  }
941
961
 
942
- // GSTIN address lookup via Sheet GST Check
943
- // (https://sheet.gstincheck.co.in). Returns the registered company name
944
- // + principal place of business address so the UI can auto-fill the
945
- // billing-group form.
962
+ // GSTIN address lookup via GST Verify (https://gstverify.co.in). Returns
963
+ // the registered company name + principal place of business address so the
964
+ // UI can auto-fill the billing-group form.
946
965
  //
947
- // Requires GST_LOOKUP_API_KEY in the API environment. The key is embedded
948
- // in the provider's URL path (their auth scheme); we never log it.
966
+ // Requires GST_LOOKUP_API_KEY in the API environment. The key is sent in
967
+ // the `X-API-Key` request header (the provider's auth scheme); we never log it.
949
968
  //
950
969
  // Caller validates GSTIN format (15 chars, alphanumeric). Server-side we
951
970
  // re-check before forwarding to the provider.
@@ -976,12 +995,11 @@ export async function gstinLookup( req, res ) {
976
995
  return res.sendError( 'Unknown state code in GSTIN', 404 );
977
996
  }
978
997
 
979
- // Real lookup via Sheet GST Check (https://gstincheck.co.in/).
980
- // Endpoint: GET https://sheet.gstincheck.co.in/check/{API_KEY}/{GSTIN}
981
- // Auth: API key embedded in the URL path (no header). Set
982
- // GST_LOOKUP_API_KEY in the API's environment.
998
+ // Real lookup via GST Verify (https://gstverify.co.in/).
999
+ // Endpoint: GET https://gstverify.co.in/api/v1/verify/{GSTIN}
1000
+ // Auth: API key sent in the `X-API-Key` header. Set GST_LOOKUP_API_KEY
1001
+ // in the API's environment.
983
1002
  const apiKey = process.env.GST_LOOKUP_API_KEY;
984
- console.log( '🚀 ~ gstinLookup ~ apiKey:', apiKey );
985
1003
  if ( !apiKey ) {
986
1004
  // Fail explicitly rather than silently fall back — that way a missing
987
1005
  // key shows up immediately instead of mock data leaking into prod.
@@ -992,8 +1010,8 @@ export async function gstinLookup( req, res ) {
992
1010
  let providerResponse;
993
1011
  try {
994
1012
  providerResponse = await axios.get(
995
- `https://sheet.gstincheck.co.in/check/${encodeURIComponent( apiKey )}/${encodeURIComponent( gstin )}`,
996
- { timeout: 8000 },
1013
+ `https://gstverify.co.in/api/v1/verify/${encodeURIComponent( gstin )}`,
1014
+ { timeout: 8000, headers: { 'X-API-Key': apiKey } },
997
1015
  );
998
1016
  } catch ( axiosErr ) {
999
1017
  // Distinguish provider-down vs other errors so the client can show a
@@ -1008,69 +1026,84 @@ export async function gstinLookup( req, res ) {
1008
1026
  }
1009
1027
 
1010
1028
  const body = providerResponse?.data || {};
1011
- if ( !body.flag || !body.data ) {
1029
+ if ( !body.success || !body.data ) {
1012
1030
  // Provider reached but couldn't resolve this GSTIN.
1013
1031
  return res.sendError( 'GSTIN not found', 404 );
1014
1032
  }
1015
1033
 
1016
1034
  // Map provider's response shape into the contract the frontend expects.
1017
- // Provider uses Indian-government short names (lgnm, tradeNam, pradr,
1018
- // sts, etc.); we normalize to our flat structure.
1035
+ // GST Verify returns a flat object: gstin, legal_name, trade_name,
1036
+ // status, state, pan, and a single concatenated `address` string
1037
+ // (e.g. "123 Business Park, Mumbai — 400001"). We normalize to our
1038
+ // flat structure and parse the address string into the discrete fields
1039
+ // the form needs.
1019
1040
  const src = body.data;
1020
- // TEMP: full raw response logged so we can confirm field names per
1021
- // GSTIN remove once the mapping is verified across a few real cases.
1022
- logger.error( { function: 'gstinLookup.raw', gstin, providerData: src } );
1023
-
1024
- const principal = src.pradr || src.pradr1 || {};
1025
- // The provider has used both `addr` (newer) and a flatter form
1026
- // (older). Coalesce — `addr` if present, otherwise the principal
1027
- // itself contains the address fields.
1041
+
1042
+ // Some records may still expose the structured GSTN principal-address
1043
+ // object; prefer explicit fields when present, else parse the string.
1044
+ const principal = src.pradr || {};
1028
1045
  const addr = principal.addr || principal || {};
1029
1046
 
1030
- // If the provider returned a fully-formed display address string, use
1031
- // it directly for addressLineOne — that's the canonical comma-joined
1032
- // address GSTN itself displays. Otherwise build it from the
1033
- // individual fields. This avoids re-joining in a different order than
1034
- // the registry intended.
1035
- const fullAddr = ( principal.adr || principal.address || src.adr || '' ).trim();
1047
+ // Pull the display address, whether it comes back as a plain string or
1048
+ // nested under the structured principal-address object.
1049
+ const fullAddr = String(
1050
+ src.address || principal.adr || principal.address || src.adr || '',
1051
+ ).trim();
1052
+
1053
+ // Pincode: prefer an explicit field, otherwise pull the first 6-digit
1054
+ // run out of the address string.
1055
+ const pinFromString = ( fullAddr.match( /\b(\d{6})\b/ ) || [] )[1] || '';
1056
+ const pinCode = addr.pncd ? String( addr.pncd ) :
1057
+ ( addr.pincode ? String( addr.pincode ) : pinFromString );
1058
+
1059
+ // State: provider's explicit state wins; fall back to our state-code map
1060
+ // so a missing field never blanks out the Place of Supply.
1061
+ const stateValue = src.state || addr.stcd || addr.state || stateName;
1062
+
1063
+ let addressLineOne = '';
1064
+ let addressLineTwo = '';
1065
+ let cityValue = addr.city || addr.dst || addr.loc || '';
1036
1066
 
1037
- let addressLineOne;
1038
- let addressLineTwo;
1039
1067
  if ( fullAddr ) {
1040
- // The registry format is `bno, bnm, flno, st, loc, lndmrk, city,
1041
- // dst, stcd, pncd`. Split on commas, take the first half for line 1
1042
- // and the rest (minus city/state/pincode tail which goes to the
1043
- // separate fields) for line 2. Heuristic: split into two halves.
1044
- const parts = fullAddr.split( ',' ).map( ( s ) => s.trim() ).filter( Boolean );
1068
+ // Split the display address on commas / em-dash and drop the trailing
1069
+ // tokens that belong in the discrete fields (state, pincode).
1070
+ let parts = fullAddr
1071
+ .split( /[,—-]/ )
1072
+ .map( ( s ) => s.trim() )
1073
+ .filter( Boolean )
1074
+ .filter( ( p ) => !/^\d{6}$/.test( p ) )
1075
+ .filter( ( p ) => p.toLowerCase() !== String( stateValue ).toLowerCase() );
1076
+
1077
+ // Last remaining token is typically the city/district — lift it out
1078
+ // when we don't already have an explicit city.
1079
+ if ( !cityValue && parts.length ) {
1080
+ cityValue = parts[parts.length - 1];
1081
+ parts = parts.slice( 0, -1 );
1082
+ }
1083
+
1084
+ // Split the rest across the two address lines.
1045
1085
  const half = Math.ceil( parts.length / 2 );
1046
1086
  addressLineOne = parts.slice( 0, half ).join( ', ' );
1047
1087
  addressLineTwo = parts.slice( half ).join( ', ' );
1048
- } else {
1088
+ } else if ( Object.keys( addr ).length ) {
1049
1089
  const line1Parts = [ addr.bno, addr.bnm, addr.flno, addr.st ].filter( ( p ) => p && String( p ).trim() );
1050
1090
  const line2Parts = [ addr.loc, addr.lndmrk, addr.dst ].filter( ( p ) => p && String( p ).trim() );
1051
1091
  addressLineOne = line1Parts.join( ', ' );
1052
1092
  addressLineTwo = line2Parts.join( ', ' );
1053
1093
  }
1054
1094
 
1055
- // City: prefer explicit `city` / `dst`. Some records put the city
1056
- // name in `loc` and the colony / sub-area in `dst` — prefer city, but
1057
- // fall back to dst then loc when missing.
1058
- const cityValue = addr.city || addr.dst || addr.loc || '';
1059
-
1060
1095
  const data = {
1061
1096
  gstin: src.gstin || gstin,
1062
- legalName: src.lgnm || src.legalName || '',
1063
- tradeName: src.tradeNam || src.tradeName || '',
1097
+ legalName: src.legal_name || src.lgnm || src.legalName || '',
1098
+ tradeName: src.trade_name || src.tradeNam || src.tradeName || '',
1064
1099
  addressLineOne,
1065
1100
  addressLineTwo,
1066
1101
  city: cityValue,
1067
- // Prefer provider's state name; fall back to our state-code map so a
1068
- // missing field doesn't blank out the Place of Supply.
1069
- state: addr.stcd || addr.state || stateName,
1102
+ state: stateValue,
1070
1103
  country: 'India',
1071
- pinCode: addr.pncd ? String( addr.pncd ) : ( addr.pincode ? String( addr.pincode ) : '' ),
1072
- placeOfSupply: `${addr.stcd || addr.state || stateName} (${code})`,
1073
- status: src.sts || '',
1104
+ pinCode,
1105
+ placeOfSupply: `${stateValue} (${code})`,
1106
+ status: src.status || src.sts || '',
1074
1107
  };
1075
1108
 
1076
1109
  return res.sendSuccess( data );
@@ -428,6 +428,21 @@ export async function brandInvoiceList( req, res ) {
428
428
  },
429
429
  } ];
430
430
 
431
+ // Invoice type sub-toggle: 'advance' shows only advance invoices (invoice #
432
+ // starts with 'TINV-'); 'all' shows only regular invoices (NOT TINV-*).
433
+ // Applied for BOTH tango and client views. Uses $expr + $regexMatch (not the
434
+ // fragile { $not: <regex> } query operator, which was matching nothing and
435
+ // hiding every row) and treats a missing invoice # as a regular invoice.
436
+ if ( req.body.invoiceType === 'advance' ) {
437
+ query.push( { $match: { $expr: {
438
+ $regexMatch: { input: { $ifNull: [ '$invoice', '' ] }, regex: '^TINV-' },
439
+ } } } );
440
+ } else if ( req.body.invoiceType === 'all' ) {
441
+ query.push( { $match: { $expr: {
442
+ $not: { $regexMatch: { input: { $ifNull: [ '$invoice', '' ] }, regex: '^TINV-' } },
443
+ } } } );
444
+ }
445
+
431
446
  // Client users only ever see APPROVED invoices — the approval pipeline
432
447
  // (pendingCsm/pendingFinance/pendingApproval/pending) is internal to tango
433
448
  // and must not be exposed to clients, even in the raw list response.
@@ -451,7 +466,20 @@ export async function brandInvoiceList( req, res ) {
451
466
  dateFrom = now.subtract( 12, 'month' ).startOf( 'month' ).toDate();
452
467
  }
453
468
  if ( dateFrom ) {
454
- query.push( { $match: { billingDate: { $gte: dateFrom } } } );
469
+ // billingDate is stored as a STRING on many invoices (e.g.
470
+ // "2024-06-10T00:00:00.000Z"), so a plain { $gte: <Date> } compares
471
+ // string-vs-Date and matches nothing (this is why the client list came
472
+ // back empty on the default 'prev' filter). Coerce to a date first.
473
+ query.push( { $match: { $expr: {
474
+ $gte: [
475
+ { $cond: [
476
+ { $eq: [ { $type: '$billingDate' }, 'date' ] },
477
+ '$billingDate',
478
+ { $toDate: '$billingDate' },
479
+ ] },
480
+ dateFrom,
481
+ ],
482
+ } } } );
455
483
  }
456
484
  }
457
485
 
@@ -580,16 +608,18 @@ export async function brandInvoiceList( req, res ) {
580
608
  }
581
609
 
582
610
  // Footer totals are shown in a single ₹ figure, but individual invoices may
583
- // be in USD ($). Convert dollar invoices to INR at today's rate before
584
- // summing so the ₹ total is correct (mirrors the table's per-invoice symbol
585
- // one combined rupee total).
586
- const usdRate = await getUsdInrRate();
587
- const toInr = ( inv, value ) => ( inv.currency === 'dollar' ? ( Number( value ) || 0 ) * usdRate : ( Number( value ) || 0 ) );
611
+ // be in a foreign currency ($, €, S$, AED). Convert each to INR at today's
612
+ // rate (per-currency, not just USD) before summing so the ₹ total is correct.
613
+ const rateByCurrency = {};
614
+ for ( const cur of new Set( allInvoices.map( ( c ) => String( c.currency || 'inr' ) ) ) ) {
615
+ rateByCurrency[cur] = await getInrRate( cur );
616
+ }
617
+ const toInr = ( inv, value ) => ( Number( value ) || 0 ) * ( rateByCurrency[String( inv.currency || 'inr' )] || 1 );
588
618
  let summary = {
589
619
  totalInvoices: allInvoices.length,
590
620
  totalInvoiced: Math.round( allInvoices.reduce( ( sum, inv ) => sum + toInr( inv, inv.totalAmount ), 0 ) ),
591
621
  // Footer totals over the FULL filtered set (not just the current page):
592
- // stores, amount excl. GST and amount incl. GST (USD → INR converted).
622
+ // stores, amount excl. GST and amount incl. GST (foreign → INR converted).
593
623
  totalStores: allInvoices.reduce( ( sum, inv ) => sum + ( inv.stores || 0 ), 0 ),
594
624
  totalAmountExclGst: Math.round( allInvoices.reduce( ( sum, inv ) => sum + toInr( inv, inv.amount ), 0 ) ),
595
625
  totalAmountInclGst: Math.round( allInvoices.reduce( ( sum, inv ) => sum + toInr( inv, inv.totalAmount ), 0 ) ),
@@ -598,6 +628,41 @@ export async function brandInvoiceList( req, res ) {
598
628
  paid: allInvoices.filter( ( inv ) => inv.paymentStatus === 'paid' ).length,
599
629
  };
600
630
 
631
+ // Header cards — Outstanding / Overdue / Pending Payment — each split into a
632
+ // GST excl (sum of `amount`) and incl (sum of `totalAmount`) figure, all
633
+ // converted to INR. Same shape/semantics as the All Invoices page cards.
634
+ const nowDate = new Date();
635
+ const cards = {
636
+ outstandingExclAmount: 0, outstandingInclAmount: 0, outstandingCount: 0,
637
+ overdueExclAmount: 0, overdueInclAmount: 0, overdueCount: 0,
638
+ pendingPaymentExclAmount: 0, pendingPaymentInclAmount: 0, pendingPaymentCount: 0,
639
+ };
640
+ for ( const inv of allInvoices ) {
641
+ if ( inv.paymentStatus === 'paid' ) {
642
+ continue;
643
+ }
644
+ const excl = toInr( inv, inv.amount );
645
+ const incl = toInr( inv, inv.totalAmount || inv.amount );
646
+ cards.outstandingExclAmount += excl;
647
+ cards.outstandingInclAmount += incl;
648
+ cards.outstandingCount += 1;
649
+ if ( inv.dueDate && new Date( inv.dueDate ) < nowDate ) {
650
+ cards.overdueExclAmount += excl;
651
+ cards.overdueInclAmount += incl;
652
+ cards.overdueCount += 1;
653
+ }
654
+ if ( inv.status === 'approved' ) {
655
+ cards.pendingPaymentExclAmount += excl;
656
+ cards.pendingPaymentInclAmount += incl;
657
+ cards.pendingPaymentCount += 1;
658
+ }
659
+ }
660
+ for ( const k of Object.keys( cards ) ) {
661
+ if ( k.endsWith( 'Amount' ) ) {
662
+ cards[k] = Math.round( cards[k] );
663
+ }
664
+ }
665
+
601
666
  if ( req.body.export ) {
602
667
  // Mirror the on-screen table exactly: same columns (excl. GST and incl.
603
668
  // GST as separate amounts), same currency symbol, and the same status
@@ -653,7 +718,7 @@ export async function brandInvoiceList( req, res ) {
653
718
  clientInfo.totalStores = billingGroups.reduce( ( sum, bg ) => sum + ( bg.stores ? bg.stores.length : 0 ), 0 );
654
719
  clientInfo.totalGroups = billingGroups.length;
655
720
 
656
- res.sendSuccess( { clientInfo, summary, count, data: invoiceList } );
721
+ res.sendSuccess( { clientInfo, summary, cards, count, data: invoiceList } );
657
722
  } catch ( error ) {
658
723
  logger.error( { error: error, function: 'brandInvoiceList' } );
659
724
  return res.sendError( error, 500 );
@@ -688,6 +753,27 @@ export async function latestDailyPricing( req, res ) {
688
753
  let stores = record.stores || [];
689
754
  let count = stores.length;
690
755
 
756
+ // The daily-pricing store objects don't carry the store's country — it lives
757
+ // in the stores collection under storeProfile.country. Build a
758
+ // storeId → country map for this client and stamp `country` onto each store
759
+ // so the UI can show it as a column and filter by it.
760
+ const countryByStore = new Map();
761
+ try {
762
+ const storeDocs = await storeService.find(
763
+ { clientId: req.body.clientId },
764
+ { 'storeId': 1, 'storeProfile.country': 1 },
765
+ );
766
+ for ( const s of ( storeDocs || [] ) ) {
767
+ countryByStore.set( String( s.storeId ), s?.storeProfile?.country || '' );
768
+ }
769
+ } catch ( cErr ) {
770
+ logger.error( { error: cErr, function: 'latestDailyPricing.country', clientId: req.body.clientId } );
771
+ }
772
+ stores = stores.map( ( s ) => {
773
+ const base = s._doc || s;
774
+ return { ...base, country: countryByStore.get( String( base.storeId ) ) || '' };
775
+ } );
776
+
691
777
  // statusFilter may be an array (multi-select) or a legacy string. An empty
692
778
  // value / empty array means "all statuses".
693
779
  const statusFilterList = Array.isArray( req.body.statusFilter ) ?
@@ -698,6 +784,15 @@ export async function latestDailyPricing( req, res ) {
698
784
  stores = stores.filter( ( s ) => allowed.has( s.status ) );
699
785
  }
700
786
 
787
+ // countryFilter may be an array (multi-select) or a string; empty = all.
788
+ const countryFilterList = Array.isArray( req.body.countryFilter ) ?
789
+ req.body.countryFilter.filter( Boolean ) :
790
+ ( req.body.countryFilter ? [ req.body.countryFilter ] : [] );
791
+ if ( countryFilterList.length ) {
792
+ const allowedCountries = new Set( countryFilterList );
793
+ stores = stores.filter( ( s ) => allowedCountries.has( s.country ) );
794
+ }
795
+
701
796
  if ( req.body.searchValue && req.body.searchValue !== '' ) {
702
797
  const searchRegex = new RegExp( req.body.searchValue, 'i' );
703
798
  stores = stores.filter( ( s ) => searchRegex.test( s.storeName ) || searchRegex.test( s.storeId ) );
@@ -708,9 +803,22 @@ export async function latestDailyPricing( req, res ) {
708
803
  if ( req.body.sortColumName && req.body.sortColumName !== '' && req.body.sortBy ) {
709
804
  const sortKey = req.body.sortColumName;
710
805
  const sortDir = req.body.sortBy;
806
+ // Working days ('daysDifference' — the WORKING DAYS column) is not a
807
+ // top-level store field; it's per-product (products[].workingdays). So for
808
+ // that column sort by the store's MAX product working-days. Other columns
809
+ // (storeName, *CameraCount, zoneCount) sort by their top-level field.
810
+ const sortVal = ( s ) => {
811
+ if ( sortKey === 'daysDifference' ) {
812
+ return ( s.products || [] ).reduce(
813
+ ( mx, p ) => Math.max( mx, Number( p.workingdays ) || 0 ), 0 );
814
+ }
815
+ return s[sortKey];
816
+ };
711
817
  stores.sort( ( a, b ) => {
712
- if ( a[sortKey] < b[sortKey] ) return -1 * sortDir;
713
- if ( a[sortKey] > b[sortKey] ) return 1 * sortDir;
818
+ const av = sortVal( a );
819
+ const bv = sortVal( b );
820
+ if ( av < bv ) return -1 * sortDir;
821
+ if ( av > bv ) return 1 * sortDir;
714
822
  return 0;
715
823
  } );
716
824
  }
@@ -720,10 +828,16 @@ export async function latestDailyPricing( req, res ) {
720
828
  // BOTH the export and the on-screen table use the same numbers.
721
829
  const priceByProduct = {};
722
830
  let bpCurrency = 'inr';
831
+ // Step-pricing support: for STEP clients the per-store price depends on the
832
+ // store's 1-based POSITION within the product (tier ranges), so we can't use
833
+ // a single price per product. We pull the tiers here and resolve per-store
834
+ // prices by position below (mirrors invoice.controller's stepPrice).
835
+ let isStepClient = false;
836
+ const stepTiersByProduct = {};
723
837
  try {
724
838
  const bp = await basePriceService.findOne(
725
839
  { clientId: req.body.clientId },
726
- { standard: 1, currency: 1 },
840
+ { standard: 1, step: 1, currency: 1 },
727
841
  );
728
842
  bpCurrency = bp?.currency || 'inr';
729
843
  for ( const p of ( bp?.standard || [] ) ) {
@@ -732,9 +846,48 @@ export async function latestDailyPricing( req, res ) {
732
846
  priceByProduct[p.productName] = price;
733
847
  }
734
848
  }
849
+ // Client priceType drives whether we use step tiers.
850
+ const priceClient = await clientService.findOne(
851
+ { clientId: req.body.clientId }, { priceType: 1 } );
852
+ isStepClient = priceClient?.priceType === 'step';
853
+ if ( isStepClient ) {
854
+ for ( const p of ( bp?.step || [] ) ) {
855
+ if ( !p.productName ) {
856
+ continue;
857
+ }
858
+ if ( !stepTiersByProduct[p.productName] ) {
859
+ stepTiersByProduct[p.productName] = [];
860
+ }
861
+ stepTiersByProduct[p.productName].push( p );
862
+ }
863
+ // Tiers ordered by range start so the position lookup is correct.
864
+ for ( const name of Object.keys( stepTiersByProduct ) ) {
865
+ stepTiersByProduct[name].sort( ( a, b ) => {
866
+ const aStart = parseInt( String( a.storeRange || '0' ).split( '-' )[0], 10 ) || 0;
867
+ const bStart = parseInt( String( b.storeRange || '0' ).split( '-' )[0], 10 ) || 0;
868
+ return aStart - bStart;
869
+ } );
870
+ }
871
+ }
735
872
  } catch ( bpErr ) {
736
873
  logger.error( { error: bpErr, function: 'latestDailyPricing.basePrice', clientId: req.body.clientId } );
737
874
  }
875
+ // Per-store step price by 1-based position within the product. Falls back to
876
+ // the last tier's price beyond the defined ranges (mirrors invoice stepPrice).
877
+ const stepPriceForPosition = ( productName, positionInProduct ) => {
878
+ const tiers = stepTiersByProduct[productName] || [];
879
+ if ( !tiers.length ) {
880
+ return 0;
881
+ }
882
+ for ( const tier of tiers ) {
883
+ const [ min, max ] = String( tier.storeRange || '' ).split( '-' ).map( Number );
884
+ if ( positionInProduct >= min && positionInProduct <= max ) {
885
+ return Number( tier.negotiatePrice ) || Number( tier.basePrice ) || 0;
886
+ }
887
+ }
888
+ const last = tiers[tiers.length - 1];
889
+ return Number( last.negotiatePrice ) || Number( last.basePrice ) || 0;
890
+ };
738
891
  // Billing type per product (perStore / perZone / perCamera) from the client
739
892
  // plan — drives whether the amount multiplies by camera/zone count. The
740
893
  // same client doc supplies the invoice-amount currency below.
@@ -783,8 +936,31 @@ export async function latestDailyPricing( req, res ) {
783
936
  }
784
937
  return 1;
785
938
  };
939
+ // Step clients: resolve each store's per-product price by its 1-based
940
+ // POSITION within the product, over the CURRENT SORTED order (so re-sorting
941
+ // re-tiers the amounts). Built here — after the sort, over the FULL list —
942
+ // so a store on page 2 still gets the position determined by all preceding
943
+ // stores. Keyed `storeId||productName`. Standard clients use priceByProduct.
944
+ const stepPriceByStoreProduct = {};
945
+ if ( isStepClient ) {
946
+ const positionByProduct = {};
947
+ for ( const s of stores ) {
948
+ for ( const p of ( s.products || [] ) ) {
949
+ const name = p.productName;
950
+ positionByProduct[name] = ( positionByProduct[name] || 0 ) + 1;
951
+ stepPriceByStoreProduct[`${s.storeId}||${name}`] =
952
+ stepPriceForPosition( name, positionByProduct[name] );
953
+ }
954
+ }
955
+ }
956
+ const priceForStoreProduct = ( store, product ) => {
957
+ if ( isStepClient ) {
958
+ return stepPriceByStoreProduct[`${store.storeId}||${product.productName}`] || 0;
959
+ }
960
+ return priceByProduct[product.productName] || 0;
961
+ };
786
962
  const productInvoiceAmount = ( store, product ) => {
787
- const price = priceByProduct[product.productName] || 0;
963
+ const price = priceForStoreProduct( store, product );
788
964
  const workingDays = Number( product.workingdays ) || 0;
789
965
  if ( price <= 0 || workingDays <= 0 ) {
790
966
  return 0;
@@ -859,7 +1035,7 @@ export async function latestDailyPricing( req, res ) {
859
1035
  invoiceBreakdown.push( {
860
1036
  productName: product.productName,
861
1037
  label: prettyProduct( product.productName ),
862
- price: priceByProduct[product.productName] || 0,
1038
+ price: priceForStoreProduct( store, product ),
863
1039
  workingDays: Number( product.workingdays ) || 0,
864
1040
  amount,
865
1041
  } );
@@ -928,6 +1104,11 @@ export async function latestDailyPricing( req, res ) {
928
1104
  .sort( ( a, b ) => a.firstFileDate.localeCompare( b.firstFileDate ) );
929
1105
  const newlyOnboardedStores = newlyOnboardedStoreList.length;
930
1106
 
1107
+ // Unique, sorted, non-empty countries across ALL of the client's stores
1108
+ // (from the map above) — feeds the Country filter dropdown independent of
1109
+ // the current page / status / country selection.
1110
+ const countries = [ ...new Set( [ ...countryByStore.values() ].filter( Boolean ) ) ].sort();
1111
+
931
1112
  let data = {
932
1113
  clientId: record.clientId,
933
1114
  brandName: record.brandName,
@@ -938,6 +1119,7 @@ export async function latestDailyPricing( req, res ) {
938
1119
  status: record.status,
939
1120
  proRate: record.proRate,
940
1121
  count,
1122
+ countries,
941
1123
  newlyOnboardedStores,
942
1124
  newlyOnboardedStoreList,
943
1125
  data: storeList,
@@ -1709,30 +1891,77 @@ export async function bulkUpdateBillingGroups( req, res ) {
1709
1891
  // including the current. Filtering/sorting happen client-side (the dataset
1710
1892
  // is one row per client).
1711
1893
  // ---------------------------------------------------------------------------
1894
+ // Live USD-based FX rates (cached 6h) from open.er-api.com. The response gives
1895
+ // every currency relative to USD (rates.INR, rates.EUR, rates.SGD, …), so we
1896
+ // cache the whole table and derive any currency→INR rate as rates.INR / rates.X.
1897
+ let fxRatesCache = { rates: null, at: 0 };
1898
+ async function getUsdRates() {
1899
+ if ( fxRatesCache.rates && ( Date.now() - fxRatesCache.at ) < 6 * 60 * 60 * 1000 ) {
1900
+ return fxRatesCache.rates;
1901
+ }
1902
+ try {
1903
+ const resp = await fetch( 'https://open.er-api.com/v6/latest/USD', { signal: AbortSignal.timeout( 4000 ) } );
1904
+ const body = await resp.json();
1905
+ if ( body?.rates && Number( body.rates.INR ) > 0 ) {
1906
+ fxRatesCache = { rates: body.rates, at: Date.now() };
1907
+ return body.rates;
1908
+ }
1909
+ } catch ( err ) {
1910
+ logger.error( { error: err, function: 'getUsdRates' } );
1911
+ }
1912
+ return fxRatesCache.rates; // may be null on cold-start failure
1913
+ }
1914
+
1712
1915
  // Today's USD->INR rate for dollar-priced clients. Live rate (cached 6h)
1713
1916
  // with an env override (USD_INR_RATE) and a last-known/static fallback so
1714
1917
  // the summary never fails because a rate API is down.
1715
- let usdRateCache = { rate: null, at: 0 };
1716
1918
  export async function getUsdInrRate() {
1717
1919
  const override = Number( process.env.USD_INR_RATE );
1718
1920
  if ( override > 0 ) {
1719
1921
  return override;
1720
1922
  }
1721
- if ( usdRateCache.rate && ( Date.now() - usdRateCache.at ) < 6 * 60 * 60 * 1000 ) {
1722
- return usdRateCache.rate;
1923
+ const rates = await getUsdRates();
1924
+ const rate = Number( rates?.INR );
1925
+ return rate > 0 ? rate : 83.33;
1926
+ }
1927
+
1928
+ // The app's own currency codes → ISO codes used by the FX API.
1929
+ const CURRENCY_ISO = { inr: 'INR', dollar: 'USD', usd: 'USD', euro: 'EUR', eur: 'EUR', singaporedollar: 'SGD', sgd: 'SGD', aed: 'AED' };
1930
+ // Static last-resort rates (currency→INR) if the FX API is unreachable on a
1931
+ // cold start. Approximate; only used so totals aren't wildly wrong.
1932
+ const FALLBACK_INR = { INR: 1, USD: 83.33, EUR: 90, SGD: 62, AED: 22.7 };
1933
+
1934
+ // Multiplier to convert an amount in `currency` to INR. INR → 1. Unknown
1935
+ // currencies fall back to 1 (treated as already-INR) so nothing is dropped.
1936
+ export async function getInrRate( currency ) {
1937
+ const iso = CURRENCY_ISO[String( currency || '' ).toLowerCase()];
1938
+ if ( !iso || iso === 'INR' ) {
1939
+ return 1;
1940
+ }
1941
+ const rates = await getUsdRates();
1942
+ const inr = Number( rates?.INR );
1943
+ const cur = Number( rates?.[iso] );
1944
+ if ( inr > 0 && cur > 0 ) {
1945
+ return inr / cur; // 1 <currency> = (INR per USD) / (currency per USD) INR
1723
1946
  }
1947
+ return FALLBACK_INR[iso] || 1;
1948
+ }
1949
+
1950
+ // FX rates for the app's supported currencies, expressed as INR-per-1-unit.
1951
+ // The UI converts an amount from currency A to B as: amount * (rates[A] / rates[B]).
1952
+ // Reuses the cached live rates (getInrRate); inr is always 1.
1953
+ export async function fxRates( req, res ) {
1724
1954
  try {
1725
- const resp = await fetch( 'https://open.er-api.com/v6/latest/USD', { signal: AbortSignal.timeout( 4000 ) } );
1726
- const body = await resp.json();
1727
- const rate = Number( body?.rates?.INR );
1728
- if ( rate > 0 ) {
1729
- usdRateCache = { rate, at: Date.now() };
1730
- return rate;
1955
+ const codes = [ 'inr', 'dollar', 'singaporedollar', 'euro', 'aed' ];
1956
+ const rates = {};
1957
+ for ( const c of codes ) {
1958
+ rates[c] = await getInrRate( c );
1731
1959
  }
1732
- } catch ( err ) {
1733
- logger.error( { error: err, function: 'getUsdInrRate' } );
1960
+ return res.sendSuccess( { rates } );
1961
+ } catch ( error ) {
1962
+ logger.error( { error: error, function: 'fxRates' } );
1963
+ return res.sendError( error, 500 );
1734
1964
  }
1735
- return usdRateCache.rate || 83.33;
1736
1965
  }
1737
1966
 
1738
1967
  export async function billingSummary( req, res ) {
@@ -2769,36 +2998,84 @@ export async function additionalProductExport( req, res ) {
2769
2998
  'Subjective-Refraction', 'Trial-Frame', 'VA-Check',
2770
2999
  ];
2771
3000
  const fromDate = dayjs( reqDate ).startOf( 'month' ).format( 'YYYY-MM-DD' );
2772
- let optomHits = [];
2773
- let scrollId = null;
3001
+ const HEADER_COLUMNS = [
3002
+ 'storeName', 'engagementId', 'queue_id', 'optm_name', 'optm_id',
3003
+ 'optm_Emailid', 'Date', 'StartTime', 'EndTime', 'Duration (min)',
3004
+ ...STEP_COLUMNS,
3005
+ ];
3006
+ // Flatten one OpenSearch hit into a row array aligned with HEADER_COLUMNS.
3007
+ const hitToRow = ( h ) => {
3008
+ const s = h._source || {};
3009
+ const steps = s.steps || {};
3010
+ return [
3011
+ s.storeName || '', s.engagementId || '', s.queue_id || '',
3012
+ s.optm_name || '', s.optm_id || '', s.optm_Emailid || '',
3013
+ s.Date || '', s.StartTime || '', s.EndTime || '',
3014
+ s['Duration (min)'] != null ? s['Duration (min)'] : '',
3015
+ ...STEP_COLUMNS.map( ( col ) => ( steps[col] === true ? 'TRUE' : 'FALSE' ) ),
3016
+ ];
3017
+ };
3018
+
3019
+ // NOTE: do NOT sort on StartTime — it's an analyzed text field, and sorting
3020
+ // on it makes OpenSearch return ZERO hits. Date is a keyword (YYYY-MM-DD),
3021
+ // so the month-to-date span is a lexical range; rows come back in index
3022
+ // order. A whole month can be hundreds of thousands of records, so we scroll
3023
+ // through pages AND stream each page straight into an ExcelJS streaming
3024
+ // workbook — building the full .xlsx in memory (the old excel4node path)
3025
+ // blew the heap / gateway timeout at this scale, causing 502s.
3026
+ let firstRes;
2774
3027
  try {
2775
- // A month-to-date span can exceed the 10k max_result_window, so scroll
2776
- // through all pages. Date is a keyword (YYYY-MM-DD) → the span is a lexical
2777
- // range. NOTE: do NOT sort on StartTime — it's an analyzed text field, and
2778
- // sorting on it makes OpenSearch return ZERO hits. The range filter is
2779
- // enough; rows come back in index order.
2780
- let osRes = await searchOpenSearchData( 'remote_optom_steps_summary', {
3028
+ firstRes = await searchOpenSearchData( 'remote_optom_steps_summary', {
2781
3029
  size: 10000,
2782
3030
  query: { range: { 'Date': { gte: fromDate, lte: reqDate } } },
2783
3031
  } );
2784
- let body = osRes?.body || osRes;
2785
- let hits = body?.hits?.hits || [];
2786
- scrollId = body?._scroll_id;
3032
+ } catch ( osErr ) {
3033
+ logger.error( { error: osErr, function: 'additionalProductExport.remoteOptum', clientId } );
3034
+ return res.sendError( 'Failed to fetch Remote Optum data', 502 );
3035
+ }
3036
+ let body = firstRes?.body || firstRes;
3037
+ let hits = body?.hits?.hits || [];
3038
+ let scrollId = body?._scroll_id;
3039
+ // Nothing to export — respond BEFORE any stream headers are sent.
3040
+ if ( !hits.length ) {
3041
+ if ( scrollId ) {
3042
+ try {
3043
+ await clearScroll( scrollId );
3044
+ } catch ( clearErr ) {
3045
+ logger.error( { error: clearErr, function: 'additionalProductExport.remoteOptum.clearScroll' } );
3046
+ }
3047
+ }
3048
+ return res.sendError( 'No data', 204 );
3049
+ }
3050
+
3051
+ // From here the response is a streamed workbook; headers are committed, so
3052
+ // a mid-stream failure can only abort the connection, not send a new status.
3053
+ res.setHeader( 'Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' );
3054
+ res.setHeader( 'Content-Disposition', `attachment; filename="Remote Optum-${clientId}.xlsx"` );
3055
+ const workbook = new ExcelJS.stream.xlsx.WorkbookWriter( { stream: res, useStyles: false } );
3056
+ const ws = workbook.addWorksheet( 'Worksheet Name' );
3057
+ ws.addRow( HEADER_COLUMNS ).commit();
3058
+ try {
2787
3059
  while ( hits.length ) {
2788
- optomHits.push( ...hits );
3060
+ for ( const h of hits ) {
3061
+ ws.addRow( hitToRow( h ) ).commit();
3062
+ }
2789
3063
  if ( !scrollId ) {
2790
3064
  break;
2791
3065
  }
2792
- osRes = await scrollResponse( scrollId );
3066
+ const osRes = await scrollResponse( scrollId );
2793
3067
  body = osRes?.body || osRes;
2794
3068
  hits = body?.hits?.hits || [];
2795
3069
  scrollId = body?._scroll_id || scrollId;
2796
3070
  }
2797
- } catch ( osErr ) {
2798
- logger.error( { error: osErr, function: 'additionalProductExport.remoteOptum', clientId } );
2799
- return res.sendError( 'Failed to fetch Remote Optum data', 502 );
3071
+ ws.commit();
3072
+ await workbook.commit();
3073
+ } catch ( streamErr ) {
3074
+ // Headers are already sent — log and tear down the connection so the
3075
+ // client sees a failed/aborted download rather than a truncated file.
3076
+ logger.error( { error: streamErr, function: 'additionalProductExport.remoteOptum.stream', clientId } );
3077
+ res.destroy( streamErr );
2800
3078
  } finally {
2801
- // Free the scroll context; ignore failures (context may already be gone).
2802
3079
  if ( scrollId ) {
2803
3080
  try {
2804
3081
  await clearScroll( scrollId );
@@ -2807,32 +3084,6 @@ export async function additionalProductExport( req, res ) {
2807
3084
  }
2808
3085
  }
2809
3086
  }
2810
-
2811
- const exportData = optomHits.map( ( h ) => {
2812
- const s = h._source || {};
2813
- const steps = s.steps || {};
2814
- const row = {
2815
- 'storeName': s.storeName || '',
2816
- 'engagementId': s.engagementId || '',
2817
- 'queue_id': s.queue_id || '',
2818
- 'optm_name': s.optm_name || '',
2819
- 'optm_id': s.optm_id || '',
2820
- 'optm_Emailid': s.optm_Emailid || '',
2821
- 'Date': s.Date || '',
2822
- 'StartTime': s.StartTime || '',
2823
- 'EndTime': s.EndTime || '',
2824
- 'Duration (min)': s['Duration (min)'] != null ? s['Duration (min)'] : '',
2825
- };
2826
- for ( const col of STEP_COLUMNS ) {
2827
- row[col] = steps[col] === true ? 'TRUE' : 'FALSE';
2828
- }
2829
- return row;
2830
- } );
2831
-
2832
- if ( !exportData.length ) {
2833
- return res.sendError( 'No data', 204 );
2834
- }
2835
- await download( exportData, res );
2836
3087
  return;
2837
3088
  }
2838
3089
 
@@ -2,7 +2,7 @@ import * as estimateService from '../services/estimate.service.js';
2
2
  import * as clientService from '../services/clientPayment.services.js';
3
3
  import * as billingService from '../services/billing.service.js';
4
4
  import dayjs from 'dayjs';
5
- import { logger, download, sendEmailWithSES } from 'tango-app-api-middleware';
5
+ import { logger, download, sendEmailWithSES, insertOpenSearchData } from 'tango-app-api-middleware';
6
6
  import Handlebars from '../utils/validations/helper/handlebar.helper.js';
7
7
  import fs from 'fs';
8
8
  import path from 'path';
@@ -17,6 +17,9 @@ async function buildEstimatePdf( estimate ) {
17
17
  const currencyType = symbolFor( e.currency );
18
18
  const fmt = ( n ) => Number( n || 0 ).toLocaleString( 'en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 } );
19
19
 
20
+ // Month per line item, mirroring the invoice PDF's Month column: use the
21
+ // product's stored month, else fall back to the estimate's created month.
22
+ const fallbackMonth = e.createdDate ? dayjs( e.createdDate ).format( 'MMM YYYY' ) : '';
20
23
  const products = ( e.products || [] ).map( ( p, i ) => {
21
24
  let name = String( p.productName || '' ).replace( /([a-z])([A-Z])/g, '$1 $2' );
22
25
  name = name.charAt( 0 ).toUpperCase() + name.slice( 1 );
@@ -24,6 +27,7 @@ async function buildEstimatePdf( estimate ) {
24
27
  index: i + 1,
25
28
  productName: name,
26
29
  description: p.description || '',
30
+ month: p.month || fallbackMonth,
27
31
  hsn: p.hsn || p.hsnCode || '998314',
28
32
  storeCount: p.storeCount || e.stores || '',
29
33
  price: fmt( p.price ),
@@ -36,7 +40,7 @@ async function buildEstimatePdf( estimate ) {
36
40
  taxAmount: fmt( t.taxAmount ),
37
41
  } ) );
38
42
 
39
- const statusLabelMap = { pending: 'Pending', sent: 'Sent', accepted: 'Accepted', declined: 'Declined', expired: 'Expired' };
43
+ const statusLabelMap = { pendingCsm: 'Pending CSM', pendingFinance: 'Pending Finance', pendingApproval: 'Pending Approval', approved: 'Approved', pending: 'Pending CSM' };
40
44
  const data = {
41
45
  estimate: e.estimate,
42
46
  status: e.status,
@@ -106,26 +110,19 @@ async function nextEstimateNumber() {
106
110
  };
107
111
  }
108
112
 
109
- // Expire stale estimates lazily on read: anything sent/draft past validTill
110
- // flips to 'expired' so the list reflects reality without a cron.
111
- async function expireOverdue( clientId ) {
112
- await estimateService.updateOne(
113
- { clientId, status: { $in: [ 'pending', 'sent' ] }, validTill: { $lt: new Date() } },
114
- { $set: { status: 'expired' } },
115
- );
116
- }
117
-
118
113
  export async function estimateList( req, res ) {
119
114
  try {
120
115
  const clientId = req.body?.clientId;
121
116
  if ( !clientId ) {
122
117
  return res.sendError( 'clientId is required', 400 );
123
118
  }
124
- await expireOverdue( clientId );
125
119
 
126
120
  const match = { clientId };
127
121
  if ( req.body?.status && req.body.status !== 'All' ) {
128
- match.status = req.body.status;
122
+ // Legacy 'pending' estimates are equivalent to the first CSM stage.
123
+ match.status = req.body.status === 'pendingCsm' ?
124
+ { $in: [ 'pendingCsm', 'pending' ] } :
125
+ req.body.status;
129
126
  }
130
127
 
131
128
  // Optional month / year filter on the estimate period (stored as a
@@ -186,10 +183,12 @@ export async function estimateList( req, res ) {
186
183
  { $match: { clientId } },
187
184
  { $group: { _id: '$status', count: { $sum: 1 } } },
188
185
  ] );
189
- const counts = { pending: 0, sent: 0, accepted: 0, declined: 0, expired: 0, total: 0 };
186
+ const counts = { pendingCsm: 0, pendingFinance: 0, pendingApproval: 0, approved: 0, total: 0 };
190
187
  statusAgg.forEach( ( s ) => {
191
- if ( counts[s._id] != null ) {
192
- counts[s._id] = s.count;
188
+ // Fold legacy 'pending' into the CSM stage.
189
+ const key = s._id === 'pending' ? 'pendingCsm' : s._id;
190
+ if ( counts[key] != null ) {
191
+ counts[key] += s.count;
193
192
  }
194
193
  counts.total += s.count;
195
194
  } );
@@ -244,7 +243,8 @@ export async function createEstimate( req, res ) {
244
243
  amount,
245
244
  totalAmount,
246
245
  currency: estimateCurrency,
247
- status: b.status === 'sent' ? 'sent' : 'pending',
246
+ // Estimates enter the approval pipeline at the CSM stage.
247
+ status: 'pendingCsm',
248
248
  createdDate,
249
249
  validTill,
250
250
  createdBy: req.user?.email || req.user?.userName || '',
@@ -374,6 +374,100 @@ export async function downloadEstimate( req, res ) {
374
374
  }
375
375
  }
376
376
 
377
+ // Advance an estimate one stage in the approval pipeline
378
+ // (pendingCsm → pendingFinance → pendingApproval → approved), mirroring
379
+ // transitionInvoiceStatus. On the FINAL transition to 'approved' the estimate
380
+ // PDF is emailed to the billing group recipients (same logic as the old
381
+ // approve-and-send). A wrong current status returns 409.
382
+ async function transitionEstimateStatus( req, res, fromStatus, toStatus ) {
383
+ try {
384
+ const { estimateId } = req.body || {};
385
+ if ( !estimateId ) {
386
+ return res.sendError( 'estimateId is required', 400 );
387
+ }
388
+ const estimate = await estimateService.findOne( { _id: estimateId } );
389
+ if ( !estimate ) {
390
+ return res.sendError( 'Estimate not found', 404 );
391
+ }
392
+ const e = estimate._doc || estimate;
393
+
394
+ // Legacy 'pending' estimates are equivalent to the first CSM stage.
395
+ const acceptedFrom = fromStatus === 'pendingCsm' ? [ 'pendingCsm', 'pending' ] : [ fromStatus ];
396
+ if ( !acceptedFrom.includes( e.status ) ) {
397
+ return res.sendError(
398
+ `Estimate is currently at status '${e.status}', not '${fromStatus}'. Another user may have advanced it.`,
399
+ 409,
400
+ );
401
+ }
402
+
403
+ await estimateService.updateOne( { _id: estimateId }, { $set: { status: toStatus } } );
404
+
405
+ insertOpenSearchData( JSON.parse( process.env.OPENSEARCH ).activityLog, {
406
+ userName: req.user?.userName,
407
+ email: req.user?.email,
408
+ clientId: e.clientId,
409
+ logSubType: 'estimateStatusTransition',
410
+ logType: 'estimate',
411
+ date: new Date(),
412
+ changes: [ `Estimate ${e.estimate} advanced from ${fromStatus} to ${toStatus} by ${req.user?.email}` ],
413
+ eventType: '',
414
+ timestamp: new Date(),
415
+ showTo: [ 'tango' ],
416
+ } );
417
+
418
+ // Final approval → email the estimate PDF to the billing group recipients.
419
+ // Non-fatal: a send failure is logged but the approval still stands.
420
+ if ( toStatus === 'approved' ) {
421
+ try {
422
+ let toEmails = [];
423
+ if ( e.groupId ) {
424
+ const group = await billingService.findOne( { _id: e.groupId } );
425
+ toEmails = ( group?.generateInvoiceTo || [] ).map( ( x ) => String( x || '' ).trim() ).filter( Boolean );
426
+ }
427
+ toEmails = [ ...new Set( toEmails ) ];
428
+ if ( toEmails.length ) {
429
+ const ccRaw = await getInvoiceCcEmails( e.clientId );
430
+ const toSet = new Set( toEmails.map( ( x ) => x.toLowerCase() ) );
431
+ const ccEmails = [ ...new Set( ( ccRaw || [] ).map( ( x ) => String( x || '' ).trim() ).filter( Boolean ) ) ]
432
+ .filter( ( x ) => !toSet.has( x.toLowerCase() ) );
433
+ const { pdfBuffer, filename, data } = await buildEstimatePdf( estimate );
434
+ const client = await clientService.findOne( { clientId: e.clientId }, { clientName: 1 } );
435
+ const emailData = { ...data, clientName: client?.clientName || e.companyName || 'Customer', companyName: 'Team Tango' };
436
+ const emailHtml = Handlebars.compile(
437
+ fs.readFileSync( path.resolve( path.dirname( '' ) ) + '/src/hbs/estimateEmail.hbs', 'utf8' ),
438
+ )( emailData );
439
+ const SES = JSON.parse( process.env.SES );
440
+ const fromEmail = SES.accountsEmail || SES.adminEmail;
441
+ const subject = `Estimate ${e.estimate} - Tango/${client?.clientName || e.companyName || ''}`;
442
+ const attachment = { filename, content: pdfBuffer, contentType: 'application/pdf' };
443
+ await sendEmailWithSES( toEmails, subject, emailHtml, attachment, fromEmail, ccEmails.length ? ccEmails : undefined );
444
+ } else {
445
+ logger.info?.( { function: 'transitionEstimateStatus', estimateId, message: 'No recipients configured; approval email skipped.' } );
446
+ }
447
+ } catch ( mailErr ) {
448
+ logger.error( { error: mailErr, function: 'transitionEstimateStatus.email', estimateId } );
449
+ }
450
+ }
451
+
452
+ return res.sendSuccess( { estimateId, fromStatus, status: toStatus } );
453
+ } catch ( error ) {
454
+ logger.error( { error: error, function: 'transitionEstimateStatus', fromStatus, toStatus, estimateId: req.body?.estimateId } );
455
+ return res.sendError( error, 500 );
456
+ }
457
+ }
458
+
459
+ export async function approveEstimateCsm( req, res ) {
460
+ return transitionEstimateStatus( req, res, 'pendingCsm', 'pendingFinance' );
461
+ }
462
+
463
+ export async function approveEstimateFinance( req, res ) {
464
+ return transitionEstimateStatus( req, res, 'pendingFinance', 'pendingApproval' );
465
+ }
466
+
467
+ export async function approveEstimateApproval( req, res ) {
468
+ return transitionEstimateStatus( req, res, 'pendingApproval', 'approved' );
469
+ }
470
+
377
471
  // Approve & send an estimate: render the PDF, email it (with the PDF attached)
378
472
  // to the billing group's recipients — same recipient logic as invoice send —
379
473
  // then flip the status to 'sent'. Triggered by the Approve action's confirm.
@@ -18,7 +18,7 @@ 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
20
  import * as bankTransactionService from '../services/bankTransaction.service.js';
21
- import { getUsdInrRate, getAdditionalProducts } from './brandsBilling.controller.js';
21
+ import { getInrRate, getAdditionalProducts } from './brandsBilling.controller.js';
22
22
  import { applyInvoiceToPurchaseOrder } from './purchaseOrder.controller.js';
23
23
 
24
24
  // Pulls CSM + Finance head emails (stored under applicationDefault
@@ -315,7 +315,7 @@ export async function createInvoice( req, res ) {
315
315
  // Scope additional-product quantities to this billing group's assigned
316
316
  // stores (group.stores). Without this the counts span the whole brand
317
317
  // and every group's invoice would be billed for all stores.
318
- const extraProducts = await getAdditionalProducts( group.clientId, group.stores );
318
+ const extraProducts = await getAdditionalProducts( group.clientId, group.stores, baseDate );
319
319
  for ( const ep of extraProducts ) {
320
320
  // Skip additional products with no billable quantity — an Eyetest /
321
321
  // Planogram / AI Manager / VMS line that resolved to 0 stores for this
@@ -1280,7 +1280,8 @@ async function resolveBasePricingScope( group, getClient ) {
1280
1280
 
1281
1281
  async function standardPrice( group, getClient, baseDate ) {
1282
1282
  console.log( '🚀 ~ standardPrice ~ baseDate:', baseDate.format( 'MMM YYYY' ) );
1283
- const currentMonthDays = dayjs().daysInMonth();
1283
+ const currentMonthDays = 30;
1284
+ console.log( '🚀 ~ standardPrice ~ currentMonthDays:', currentMonthDays );
1284
1285
  // Pricing method: 'flat' => bill every store for the full month
1285
1286
  // regardless of working days. 'prorate' => bill for actual working days.
1286
1287
  // Computed once so the aggregation pipelines can inline a $literal.
@@ -2011,6 +2012,16 @@ export async function clientInvoiceList( req, res ) {
2011
2012
  },
2012
2013
  } ];
2013
2014
 
2015
+ // Invoice type sub-toggle: 'advance' shows only advance invoices (invoice #
2016
+ // starts with 'TINV-'); 'all' (default) shows only regular invoices (INV-*),
2017
+ // i.e. everything that is NOT a TINV-. Applied to the whole pipeline so the
2018
+ // list, count and cards all reflect the selected type.
2019
+ if ( req.body.invoiceType === 'advance' ) {
2020
+ query.push( { $match: { invoice: { $regex: '^TINV-' } } } );
2021
+ } else if ( req.body.invoiceType === 'all' ) {
2022
+ query.push( { $match: { invoice: { $not: { $regex: '^TINV-' } } } } );
2023
+ }
2024
+
2014
2025
  // If the user picked an explicit Month or Year, ignore the Duration
2015
2026
  // filter — otherwise the two ranges conflict (e.g. "current month" +
2016
2027
  // April would always return zero results). Mirrors brand-invoices.
@@ -2248,30 +2259,49 @@ export async function clientInvoiceList( req, res ) {
2248
2259
  // converted to INR at today's rate so all three totals are a single ₹
2249
2260
  // figure. Outstanding = unpaid remaining (totalAmount - paidAmount);
2250
2261
  // Overdue = past-due unpaid subset; Pending Payment = approved-but-unpaid.
2251
- const usdRate = await getUsdInrRate();
2252
2262
  const now = new Date();
2263
+ // Convert every non-INR invoice to INR so the ₹ card totals are comparable.
2264
+ // Resolve one rate per distinct currency present (dollar, euro, sgd, aed, …)
2265
+ // via getInrRate, so euro/others are converted too — not just dollar.
2266
+ const rateByCurrency = {};
2267
+ for ( const cur of new Set( count.map( ( c ) => String( c.currency || 'inr' ) ) ) ) {
2268
+ rateByCurrency[cur] = await getInrRate( cur );
2269
+ }
2270
+ // Each card carries the total remaining (incl. GST, net of payments — the
2271
+ // *Amount fields, kept for back-compat) PLUS a GST split of the GROSS invoice
2272
+ // figures: *ExclAmount = sum of `amount` (pre-tax subtotal), *InclAmount =
2273
+ // sum of `totalAmount` (with tax), matching the table's Excl/Incl columns.
2253
2274
  const cards = {
2254
- outstandingAmount: 0, outstandingCount: 0,
2255
- overdueAmount: 0, overdueCount: 0,
2256
- pendingPaymentAmount: 0, pendingPaymentCount: 0,
2275
+ outstandingAmount: 0, outstandingExclAmount: 0, outstandingInclAmount: 0, outstandingCount: 0,
2276
+ overdueAmount: 0, overdueExclAmount: 0, overdueInclAmount: 0, overdueCount: 0,
2277
+ pendingPaymentAmount: 0, pendingPaymentExclAmount: 0, pendingPaymentInclAmount: 0, pendingPaymentCount: 0,
2257
2278
  };
2258
2279
  for ( const inv of count ) {
2259
2280
  if ( inv.paymentStatus === 'paid' ) {
2260
2281
  continue;
2261
2282
  }
2262
- const fx = inv.currency === 'dollar' ? usdRate : 1;
2283
+ const fx = rateByCurrency[String( inv.currency || 'inr' )] || 1;
2263
2284
  const total = Number( inv.totalAmount ) || Number( inv.amount ) || 0;
2264
2285
  const paid = Number( inv.paidAmount ) || 0;
2265
2286
  const remaining = Math.max( 0, total - paid ) * fx;
2287
+ // Gross GST split (FX-normalised to INR): excl = subtotal, incl = with tax.
2288
+ const exclGross = ( Number( inv.amount ) || 0 ) * fx;
2289
+ const inclGross = ( Number( inv.totalAmount ) || Number( inv.amount ) || 0 ) * fx;
2266
2290
 
2267
2291
  cards.outstandingAmount += remaining;
2292
+ cards.outstandingExclAmount += exclGross;
2293
+ cards.outstandingInclAmount += inclGross;
2268
2294
  cards.outstandingCount += 1;
2269
2295
  if ( inv.dueDate && new Date( inv.dueDate ) < now ) {
2270
2296
  cards.overdueAmount += remaining;
2297
+ cards.overdueExclAmount += exclGross;
2298
+ cards.overdueInclAmount += inclGross;
2271
2299
  cards.overdueCount += 1;
2272
2300
  }
2273
2301
  if ( inv.status === 'approved' ) {
2274
2302
  cards.pendingPaymentAmount += remaining;
2303
+ cards.pendingPaymentExclAmount += exclGross;
2304
+ cards.pendingPaymentInclAmount += inclGross;
2275
2305
  cards.pendingPaymentCount += 1;
2276
2306
  }
2277
2307
  }
@@ -2280,6 +2310,12 @@ export async function clientInvoiceList( req, res ) {
2280
2310
  cards.outstandingAmount = roundAmount( cards.outstandingAmount, 'inr' );
2281
2311
  cards.overdueAmount = roundAmount( cards.overdueAmount, 'inr' );
2282
2312
  cards.pendingPaymentAmount = roundAmount( cards.pendingPaymentAmount, 'inr' );
2313
+ cards.outstandingExclAmount = roundAmount( cards.outstandingExclAmount, 'inr' );
2314
+ cards.outstandingInclAmount = roundAmount( cards.outstandingInclAmount, 'inr' );
2315
+ cards.overdueExclAmount = roundAmount( cards.overdueExclAmount, 'inr' );
2316
+ cards.overdueInclAmount = roundAmount( cards.overdueInclAmount, 'inr' );
2317
+ cards.pendingPaymentExclAmount = roundAmount( cards.pendingPaymentExclAmount, 'inr' );
2318
+ cards.pendingPaymentInclAmount = roundAmount( cards.pendingPaymentInclAmount, 'inr' );
2283
2319
 
2284
2320
  res.sendSuccess( { count: count.length, data: invoiceList, cards } );
2285
2321
  } catch ( error ) {
@@ -1540,6 +1540,18 @@
1540
1540
  </div>
1541
1541
  {{/each}}
1542
1542
  </div>
1543
+ <div class="column2">
1544
+ <div class="table-header-cell2">
1545
+ <div class="table-header2">
1546
+ <div class="text6">Month</div>
1547
+ </div>
1548
+ </div>
1549
+ {{#each products}}
1550
+ <div class="table-cell4">
1551
+ <div class="text7">{{month}}</div>
1552
+ </div>
1553
+ {{/each}}
1554
+ </div>
1543
1555
  <div class="column2">
1544
1556
  <div class="table-header-cell2">
1545
1557
  <div class="table-header2">
@@ -1,20 +1,21 @@
1
1
 
2
2
  import express from 'express';
3
- import { brandsBillingList, brandInvoiceList, latestDailyPricing, brandBillingGroups, updateDailyPricingWorkingDays, updateDailyPricingStoreField, getClientBillingInfo, bulkDownloadBillingGroups, bulkUpdateBillingGroups, billingSummary, additionalProducts, additionalProductExport } from '../controllers/brandsBilling.controller.js';
3
+ import { brandsBillingList, brandInvoiceList, latestDailyPricing, brandBillingGroups, updateDailyPricingWorkingDays, updateDailyPricingStoreField, getClientBillingInfo, bulkDownloadBillingGroups, bulkUpdateBillingGroups, billingSummary, additionalProducts, additionalProductExport, fxRates } from '../controllers/brandsBilling.controller.js';
4
4
  import { isAllowedSessionHandler, accessVerification } from 'tango-app-api-middleware';
5
5
 
6
6
  export const brandsBillingRouter = express.Router();
7
7
 
8
- brandsBillingRouter.post( '/brandsBillingList', isAllowedSessionHandler, accessVerification( { userType: [ 'tango' ], access: [ { featureName: 'TangoAdmin', name: 'invoiceApproval', permissions: [] } ] } ), brandsBillingList );
8
+ brandsBillingRouter.post( '/brandsBillingList', isAllowedSessionHandler, accessVerification( { userType: [ 'tango' ], access: [ { featureName: 'TangoAdmin', name: 'EditBilling', permissions: [] } ] } ), brandsBillingList );
9
9
  brandsBillingRouter.post( '/brandInvoiceList', isAllowedSessionHandler, accessVerification( { userType: [ 'tango', 'client' ], access: [ ] } ), brandInvoiceList );
10
- brandsBillingRouter.post( '/latestDailyPricing', isAllowedSessionHandler, accessVerification( { userType: [ 'tango' ], access: [ { featureName: 'TangoAdmin', name: 'invoiceApproval', permissions: [] } ] } ), latestDailyPricing );
11
- brandsBillingRouter.post( '/brandBillingGroups', isAllowedSessionHandler, accessVerification( { userType: [ 'tango' ], access: [ { featureName: 'TangoAdmin', name: 'invoiceApproval', permissions: [] } ] } ), brandBillingGroups );
12
- brandsBillingRouter.put( '/updateDailyPricingWorkingDays', isAllowedSessionHandler, accessVerification( { userType: [ 'tango' ], access: [ { featureName: 'TangoAdmin', name: 'invoiceApproval', permissions: [ 'isEdit' ] } ] } ), updateDailyPricingWorkingDays );
13
- brandsBillingRouter.put( '/updateDailyPricingStoreField', isAllowedSessionHandler, accessVerification( { userType: [ 'tango' ], access: [ { featureName: 'TangoAdmin', name: 'invoiceApproval', permissions: [ 'isEdit' ] } ] } ), updateDailyPricingStoreField );
14
- brandsBillingRouter.post( '/getClientBillingInfo', isAllowedSessionHandler, accessVerification( { userType: [ 'tango', 'client' ], access: [ { featureName: 'TangoAdmin', name: 'invoiceApproval', permissions: [] } ] } ), getClientBillingInfo );
15
- brandsBillingRouter.get( '/bulk-download-billing-groups', isAllowedSessionHandler, accessVerification( { userType: [ 'tango' ], access: [ { featureName: 'TangoAdmin', name: 'invoiceApproval', permissions: [ 'isEdit' ] } ] } ), bulkDownloadBillingGroups );
16
- brandsBillingRouter.post( '/bulk-update-billing-groups', isAllowedSessionHandler, accessVerification( { userType: [ 'tango' ], access: [ { featureName: 'TangoAdmin', name: 'invoiceApproval', permissions: [ 'isEdit' ] } ] } ), bulkUpdateBillingGroups );
17
- brandsBillingRouter.post( '/billingSummary', isAllowedSessionHandler, accessVerification( { userType: [ 'tango' ], access: [ { featureName: 'TangoAdmin', name: 'invoiceApproval', permissions: [] } ] } ), billingSummary );
18
- brandsBillingRouter.get( '/billingSummary', isAllowedSessionHandler, accessVerification( { userType: [ 'tango' ], access: [ { featureName: 'TangoAdmin', name: 'invoiceApproval', permissions: [] } ] } ), billingSummary );
19
- brandsBillingRouter.get( '/additionalProducts', isAllowedSessionHandler, accessVerification( { userType: [ 'tango' ], access: [ { featureName: 'TangoAdmin', name: 'invoiceApproval', permissions: [] } ] } ), additionalProducts );
20
- brandsBillingRouter.get( '/additionalProductExport', isAllowedSessionHandler, accessVerification( { userType: [ 'tango' ], access: [ { featureName: 'TangoAdmin', name: 'invoiceApproval', permissions: [] } ] } ), additionalProductExport );
10
+ brandsBillingRouter.post( '/latestDailyPricing', isAllowedSessionHandler, accessVerification( { userType: [ 'tango' ], access: [ { featureName: 'TangoAdmin', name: 'EditBilling', permissions: [] } ] } ), latestDailyPricing );
11
+ brandsBillingRouter.post( '/brandBillingGroups', isAllowedSessionHandler, accessVerification( { userType: [ 'tango' ], access: [ { featureName: 'TangoAdmin', name: 'EditBilling', permissions: [] } ] } ), brandBillingGroups );
12
+ brandsBillingRouter.put( '/updateDailyPricingWorkingDays', isAllowedSessionHandler, accessVerification( { userType: [ 'tango' ], access: [ { featureName: 'TangoAdmin', name: 'EditBilling', permissions: [ 'isEdit' ] } ] } ), updateDailyPricingWorkingDays );
13
+ brandsBillingRouter.put( '/updateDailyPricingStoreField', isAllowedSessionHandler, accessVerification( { userType: [ 'tango' ], access: [ { featureName: 'TangoAdmin', name: 'EditBilling', permissions: [ 'isEdit' ] } ] } ), updateDailyPricingStoreField );
14
+ brandsBillingRouter.post( '/getClientBillingInfo', isAllowedSessionHandler, accessVerification( { userType: [ 'tango', 'client' ], access: [ { featureName: 'Global', name: 'Billing', permissions: [] } ] } ), getClientBillingInfo );
15
+ brandsBillingRouter.get( '/bulk-download-billing-groups', isAllowedSessionHandler, accessVerification( { userType: [ 'tango' ], access: [ { featureName: 'TangoAdmin', name: 'EditBilling', permissions: [ 'isEdit' ] } ] } ), bulkDownloadBillingGroups );
16
+ brandsBillingRouter.post( '/bulk-update-billing-groups', isAllowedSessionHandler, accessVerification( { userType: [ 'tango' ], access: [ { featureName: 'TangoAdmin', name: 'EditBilling', permissions: [ 'isEdit' ] } ] } ), bulkUpdateBillingGroups );
17
+ brandsBillingRouter.post( '/billingSummary', isAllowedSessionHandler, accessVerification( { userType: [ 'tango' ], access: [ { featureName: 'TangoAdmin', name: 'EditBilling', permissions: [] } ] } ), billingSummary );
18
+ brandsBillingRouter.get( '/billingSummary', isAllowedSessionHandler, accessVerification( { userType: [ 'tango' ], access: [ { featureName: 'TangoAdmin', name: 'EditBilling', permissions: [] } ] } ), billingSummary );
19
+ brandsBillingRouter.get( '/additionalProducts', isAllowedSessionHandler, accessVerification( { userType: [ 'tango' ], access: [ { featureName: 'TangoAdmin', name: 'EditBilling', permissions: [] } ] } ), additionalProducts );
20
+ brandsBillingRouter.get( '/additionalProductExport', isAllowedSessionHandler, accessVerification( { userType: [ 'tango' ], access: [ { featureName: 'TangoAdmin', name: 'EditBilling', permissions: [] } ] } ), additionalProductExport );
21
+ brandsBillingRouter.get( '/fxRates', isAllowedSessionHandler, accessVerification( { userType: [ 'tango', 'client' ], access: [ { featureName: 'TangoAdmin', name: 'EditBilling', permissions: [] } ] } ), fxRates );
@@ -3,7 +3,7 @@ import { createInvoice, invoiceDownload, invoiceDownloadBulk, clientInvoiceList,
3
3
  import { isAllowedSessionHandler, accessVerification, validate } from 'tango-app-api-middleware';
4
4
  import { getInvoiceHeads, updateInvoiceHeads } from '../controllers/applicationDefault.controllers.js';
5
5
  import { uploadBankStatement, bankTransactionList, resolveOptions, resolveBankTransaction } from '../controllers/bankTransaction.controller.js';
6
- import { estimateList, createEstimate, updateEstimate, getEstimate, estimateStatusUpdate, deleteEstimate, downloadEstimate, sendEstimate } from '../controllers/estimate.controller.js';
6
+ import { estimateList, createEstimate, updateEstimate, getEstimate, deleteEstimate, downloadEstimate, approveEstimateCsm, approveEstimateFinance, approveEstimateApproval } from '../controllers/estimate.controller.js';
7
7
  import { updateInvoiceHeadsSchema } from '../dtos/validation.dtos.js';
8
8
  export const invoiceRouter = express.Router();
9
9
 
@@ -58,9 +58,10 @@ invoiceRouter.post( '/bankStatement/resolve', isAllowedSessionHandler, superadmi
58
58
  // lifecycle. List/get are read; create/status/delete need edit rights.
59
59
  invoiceRouter.post( '/estimate/list', isAllowedSessionHandler, superadminBypass( { userType: [ 'tango' ], access: [ { featureName: 'TangoAdmin', name: 'invoiceApproval', permissions: [] } ] } ), estimateList );
60
60
  invoiceRouter.post( '/estimate/create', isAllowedSessionHandler, superadminBypass( { userType: [ 'tango' ], access: [ { featureName: 'TangoAdmin', name: 'invoiceApproval', permissions: [ 'isEdit' ] } ] } ), createEstimate );
61
+ invoiceRouter.post( '/estimate/approveCsm', isAllowedSessionHandler, superadminBypass( { userType: [ 'tango' ], access: [ { featureName: 'TangoAdmin', name: 'csmApproval', permissions: [ 'isEdit' ] } ] } ), approveEstimateCsm );
62
+ invoiceRouter.post( '/estimate/approveFinance', isAllowedSessionHandler, superadminBypass( { userType: [ 'tango' ], access: [ { featureName: 'TangoAdmin', name: 'financeApproval', permissions: [ 'isEdit' ] } ] } ), approveEstimateFinance );
63
+ invoiceRouter.post( '/estimate/approveApproval', isAllowedSessionHandler, superadminBypass( { userType: [ 'tango' ], access: [ { featureName: 'TangoAdmin', name: 'invoiceApproval', permissions: [ 'isEdit' ] } ] } ), approveEstimateApproval );
61
64
  invoiceRouter.post( '/estimate/update', isAllowedSessionHandler, superadminBypass( { userType: [ 'tango' ], access: [ { featureName: 'TangoAdmin', name: 'invoiceApproval', permissions: [ 'isEdit' ] } ] } ), updateEstimate );
62
- invoiceRouter.post( '/estimate/send', isAllowedSessionHandler, superadminBypass( { userType: [ 'tango' ], access: [ { featureName: 'TangoAdmin', name: 'invoiceApproval', permissions: [ 'isEdit' ] } ] } ), sendEstimate );
63
65
  invoiceRouter.post( '/estimate/download/:estimateId', isAllowedSessionHandler, superadminBypass( { userType: [ 'tango' ], access: [ { featureName: 'TangoAdmin', name: 'invoiceApproval', permissions: [] } ] } ), downloadEstimate );
64
66
  invoiceRouter.get( '/estimate/:estimateId', isAllowedSessionHandler, superadminBypass( { userType: [ 'tango' ], access: [ { featureName: 'TangoAdmin', name: 'invoiceApproval', permissions: [] } ] } ), getEstimate );
65
- invoiceRouter.post( '/estimate/statusUpdate', isAllowedSessionHandler, superadminBypass( { userType: [ 'tango' ], access: [ { featureName: 'TangoAdmin', name: 'invoiceApproval', permissions: [ 'isEdit' ] } ] } ), estimateStatusUpdate );
66
67
  invoiceRouter.delete( '/estimate/:estimateId', isAllowedSessionHandler, superadminBypass( { userType: [ 'tango' ], access: [ { featureName: 'TangoAdmin', name: 'invoiceApproval', permissions: [ 'isEdit' ] } ] } ), deleteEstimate );