tango-app-api-payment-subscription 3.5.22 → 3.5.24

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.
@@ -8,7 +8,7 @@ import * as basePriceService from '../services/basePrice.service.js';
8
8
  import * as cameraService from '../services/camera.service.js';
9
9
  import * as planogramService from '../services/planogram.service.js';
10
10
  import dayjs from 'dayjs';
11
- import { logger, checkFileExist, signedUrl, download, insertOpenSearchData, getOpenSearchData } from 'tango-app-api-middleware';
11
+ import { logger, checkFileExist, signedUrl, download, insertOpenSearchData, getOpenSearchData, searchOpenSearchData, scrollResponse, clearScroll } from 'tango-app-api-middleware';
12
12
  import * as XLSX from 'xlsx';
13
13
  import ExcelJS from 'exceljs';
14
14
  import { bulkUpdateBillingGroupRowSchema } from '../dtos/validation.dtos.js';
@@ -428,6 +428,15 @@ 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 to the whole pipeline so list, count, summary and cards match.
434
+ if ( req.body.invoiceType === 'advance' ) {
435
+ query.push( { $match: { invoice: { $regex: '^TINV-' } } } );
436
+ } else if ( req.body.invoiceType === 'all' ) {
437
+ query.push( { $match: { invoice: { $not: { $regex: '^TINV-' } } } } );
438
+ }
439
+
431
440
  // Client users only ever see APPROVED invoices — the approval pipeline
432
441
  // (pendingCsm/pendingFinance/pendingApproval/pending) is internal to tango
433
442
  // and must not be exposed to clients, even in the raw list response.
@@ -580,16 +589,18 @@ export async function brandInvoiceList( req, res ) {
580
589
  }
581
590
 
582
591
  // 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 ) );
592
+ // be in a foreign currency ($, €, S$, AED). Convert each to INR at today's
593
+ // rate (per-currency, not just USD) before summing so the ₹ total is correct.
594
+ const rateByCurrency = {};
595
+ for ( const cur of new Set( allInvoices.map( ( c ) => String( c.currency || 'inr' ) ) ) ) {
596
+ rateByCurrency[cur] = await getInrRate( cur );
597
+ }
598
+ const toInr = ( inv, value ) => ( Number( value ) || 0 ) * ( rateByCurrency[String( inv.currency || 'inr' )] || 1 );
588
599
  let summary = {
589
600
  totalInvoices: allInvoices.length,
590
601
  totalInvoiced: Math.round( allInvoices.reduce( ( sum, inv ) => sum + toInr( inv, inv.totalAmount ), 0 ) ),
591
602
  // Footer totals over the FULL filtered set (not just the current page):
592
- // stores, amount excl. GST and amount incl. GST (USD → INR converted).
603
+ // stores, amount excl. GST and amount incl. GST (foreign → INR converted).
593
604
  totalStores: allInvoices.reduce( ( sum, inv ) => sum + ( inv.stores || 0 ), 0 ),
594
605
  totalAmountExclGst: Math.round( allInvoices.reduce( ( sum, inv ) => sum + toInr( inv, inv.amount ), 0 ) ),
595
606
  totalAmountInclGst: Math.round( allInvoices.reduce( ( sum, inv ) => sum + toInr( inv, inv.totalAmount ), 0 ) ),
@@ -598,6 +609,41 @@ export async function brandInvoiceList( req, res ) {
598
609
  paid: allInvoices.filter( ( inv ) => inv.paymentStatus === 'paid' ).length,
599
610
  };
600
611
 
612
+ // Header cards — Outstanding / Overdue / Pending Payment — each split into a
613
+ // GST excl (sum of `amount`) and incl (sum of `totalAmount`) figure, all
614
+ // converted to INR. Same shape/semantics as the All Invoices page cards.
615
+ const nowDate = new Date();
616
+ const cards = {
617
+ outstandingExclAmount: 0, outstandingInclAmount: 0, outstandingCount: 0,
618
+ overdueExclAmount: 0, overdueInclAmount: 0, overdueCount: 0,
619
+ pendingPaymentExclAmount: 0, pendingPaymentInclAmount: 0, pendingPaymentCount: 0,
620
+ };
621
+ for ( const inv of allInvoices ) {
622
+ if ( inv.paymentStatus === 'paid' ) {
623
+ continue;
624
+ }
625
+ const excl = toInr( inv, inv.amount );
626
+ const incl = toInr( inv, inv.totalAmount || inv.amount );
627
+ cards.outstandingExclAmount += excl;
628
+ cards.outstandingInclAmount += incl;
629
+ cards.outstandingCount += 1;
630
+ if ( inv.dueDate && new Date( inv.dueDate ) < nowDate ) {
631
+ cards.overdueExclAmount += excl;
632
+ cards.overdueInclAmount += incl;
633
+ cards.overdueCount += 1;
634
+ }
635
+ if ( inv.status === 'approved' ) {
636
+ cards.pendingPaymentExclAmount += excl;
637
+ cards.pendingPaymentInclAmount += incl;
638
+ cards.pendingPaymentCount += 1;
639
+ }
640
+ }
641
+ for ( const k of Object.keys( cards ) ) {
642
+ if ( k.endsWith( 'Amount' ) ) {
643
+ cards[k] = Math.round( cards[k] );
644
+ }
645
+ }
646
+
601
647
  if ( req.body.export ) {
602
648
  // Mirror the on-screen table exactly: same columns (excl. GST and incl.
603
649
  // GST as separate amounts), same currency symbol, and the same status
@@ -653,7 +699,7 @@ export async function brandInvoiceList( req, res ) {
653
699
  clientInfo.totalStores = billingGroups.reduce( ( sum, bg ) => sum + ( bg.stores ? bg.stores.length : 0 ), 0 );
654
700
  clientInfo.totalGroups = billingGroups.length;
655
701
 
656
- res.sendSuccess( { clientInfo, summary, count, data: invoiceList } );
702
+ res.sendSuccess( { clientInfo, summary, cards, count, data: invoiceList } );
657
703
  } catch ( error ) {
658
704
  logger.error( { error: error, function: 'brandInvoiceList' } );
659
705
  return res.sendError( error, 500 );
@@ -688,6 +734,27 @@ export async function latestDailyPricing( req, res ) {
688
734
  let stores = record.stores || [];
689
735
  let count = stores.length;
690
736
 
737
+ // The daily-pricing store objects don't carry the store's country — it lives
738
+ // in the stores collection under storeProfile.country. Build a
739
+ // storeId → country map for this client and stamp `country` onto each store
740
+ // so the UI can show it as a column and filter by it.
741
+ const countryByStore = new Map();
742
+ try {
743
+ const storeDocs = await storeService.find(
744
+ { clientId: req.body.clientId },
745
+ { 'storeId': 1, 'storeProfile.country': 1 },
746
+ );
747
+ for ( const s of ( storeDocs || [] ) ) {
748
+ countryByStore.set( String( s.storeId ), s?.storeProfile?.country || '' );
749
+ }
750
+ } catch ( cErr ) {
751
+ logger.error( { error: cErr, function: 'latestDailyPricing.country', clientId: req.body.clientId } );
752
+ }
753
+ stores = stores.map( ( s ) => {
754
+ const base = s._doc || s;
755
+ return { ...base, country: countryByStore.get( String( base.storeId ) ) || '' };
756
+ } );
757
+
691
758
  // statusFilter may be an array (multi-select) or a legacy string. An empty
692
759
  // value / empty array means "all statuses".
693
760
  const statusFilterList = Array.isArray( req.body.statusFilter ) ?
@@ -698,6 +765,15 @@ export async function latestDailyPricing( req, res ) {
698
765
  stores = stores.filter( ( s ) => allowed.has( s.status ) );
699
766
  }
700
767
 
768
+ // countryFilter may be an array (multi-select) or a string; empty = all.
769
+ const countryFilterList = Array.isArray( req.body.countryFilter ) ?
770
+ req.body.countryFilter.filter( Boolean ) :
771
+ ( req.body.countryFilter ? [ req.body.countryFilter ] : [] );
772
+ if ( countryFilterList.length ) {
773
+ const allowedCountries = new Set( countryFilterList );
774
+ stores = stores.filter( ( s ) => allowedCountries.has( s.country ) );
775
+ }
776
+
701
777
  if ( req.body.searchValue && req.body.searchValue !== '' ) {
702
778
  const searchRegex = new RegExp( req.body.searchValue, 'i' );
703
779
  stores = stores.filter( ( s ) => searchRegex.test( s.storeName ) || searchRegex.test( s.storeId ) );
@@ -928,6 +1004,11 @@ export async function latestDailyPricing( req, res ) {
928
1004
  .sort( ( a, b ) => a.firstFileDate.localeCompare( b.firstFileDate ) );
929
1005
  const newlyOnboardedStores = newlyOnboardedStoreList.length;
930
1006
 
1007
+ // Unique, sorted, non-empty countries across ALL of the client's stores
1008
+ // (from the map above) — feeds the Country filter dropdown independent of
1009
+ // the current page / status / country selection.
1010
+ const countries = [ ...new Set( [ ...countryByStore.values() ].filter( Boolean ) ) ].sort();
1011
+
931
1012
  let data = {
932
1013
  clientId: record.clientId,
933
1014
  brandName: record.brandName,
@@ -938,6 +1019,7 @@ export async function latestDailyPricing( req, res ) {
938
1019
  status: record.status,
939
1020
  proRate: record.proRate,
940
1021
  count,
1022
+ countries,
941
1023
  newlyOnboardedStores,
942
1024
  newlyOnboardedStoreList,
943
1025
  data: storeList,
@@ -1709,30 +1791,60 @@ export async function bulkUpdateBillingGroups( req, res ) {
1709
1791
  // including the current. Filtering/sorting happen client-side (the dataset
1710
1792
  // is one row per client).
1711
1793
  // ---------------------------------------------------------------------------
1794
+ // Live USD-based FX rates (cached 6h) from open.er-api.com. The response gives
1795
+ // every currency relative to USD (rates.INR, rates.EUR, rates.SGD, …), so we
1796
+ // cache the whole table and derive any currency→INR rate as rates.INR / rates.X.
1797
+ let fxRatesCache = { rates: null, at: 0 };
1798
+ async function getUsdRates() {
1799
+ if ( fxRatesCache.rates && ( Date.now() - fxRatesCache.at ) < 6 * 60 * 60 * 1000 ) {
1800
+ return fxRatesCache.rates;
1801
+ }
1802
+ try {
1803
+ const resp = await fetch( 'https://open.er-api.com/v6/latest/USD', { signal: AbortSignal.timeout( 4000 ) } );
1804
+ const body = await resp.json();
1805
+ if ( body?.rates && Number( body.rates.INR ) > 0 ) {
1806
+ fxRatesCache = { rates: body.rates, at: Date.now() };
1807
+ return body.rates;
1808
+ }
1809
+ } catch ( err ) {
1810
+ logger.error( { error: err, function: 'getUsdRates' } );
1811
+ }
1812
+ return fxRatesCache.rates; // may be null on cold-start failure
1813
+ }
1814
+
1712
1815
  // Today's USD->INR rate for dollar-priced clients. Live rate (cached 6h)
1713
1816
  // with an env override (USD_INR_RATE) and a last-known/static fallback so
1714
1817
  // the summary never fails because a rate API is down.
1715
- let usdRateCache = { rate: null, at: 0 };
1716
1818
  export async function getUsdInrRate() {
1717
1819
  const override = Number( process.env.USD_INR_RATE );
1718
1820
  if ( override > 0 ) {
1719
1821
  return override;
1720
1822
  }
1721
- if ( usdRateCache.rate && ( Date.now() - usdRateCache.at ) < 6 * 60 * 60 * 1000 ) {
1722
- return usdRateCache.rate;
1823
+ const rates = await getUsdRates();
1824
+ const rate = Number( rates?.INR );
1825
+ return rate > 0 ? rate : 83.33;
1826
+ }
1827
+
1828
+ // The app's own currency codes → ISO codes used by the FX API.
1829
+ const CURRENCY_ISO = { inr: 'INR', dollar: 'USD', usd: 'USD', euro: 'EUR', eur: 'EUR', singaporedollar: 'SGD', sgd: 'SGD', aed: 'AED' };
1830
+ // Static last-resort rates (currency→INR) if the FX API is unreachable on a
1831
+ // cold start. Approximate; only used so totals aren't wildly wrong.
1832
+ const FALLBACK_INR = { INR: 1, USD: 83.33, EUR: 90, SGD: 62, AED: 22.7 };
1833
+
1834
+ // Multiplier to convert an amount in `currency` to INR. INR → 1. Unknown
1835
+ // currencies fall back to 1 (treated as already-INR) so nothing is dropped.
1836
+ export async function getInrRate( currency ) {
1837
+ const iso = CURRENCY_ISO[String( currency || '' ).toLowerCase()];
1838
+ if ( !iso || iso === 'INR' ) {
1839
+ return 1;
1723
1840
  }
1724
- 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;
1731
- }
1732
- } catch ( err ) {
1733
- logger.error( { error: err, function: 'getUsdInrRate' } );
1841
+ const rates = await getUsdRates();
1842
+ const inr = Number( rates?.INR );
1843
+ const cur = Number( rates?.[iso] );
1844
+ if ( inr > 0 && cur > 0 ) {
1845
+ return inr / cur; // 1 <currency> = (INR per USD) / (currency per USD) INR
1734
1846
  }
1735
- return usdRateCache.rate || 83.33;
1847
+ return FALLBACK_INR[iso] || 1;
1736
1848
  }
1737
1849
 
1738
1850
  export async function billingSummary( req, res ) {
@@ -2185,6 +2297,11 @@ const ADDITIONAL_PRODUCT_PRICES = {
2185
2297
  planogram: 770,
2186
2298
  aiManager: 500,
2187
2299
  };
2300
+ // Product Lambda Function URLs (VMS / Run AI). Each returns the list of billable
2301
+ // store IDs for a client; used both for the detailed export and for the invoice
2302
+ // additional-products quantity (scoped to a billing group's stores).
2303
+ const RUN_AI_LAMBDA_URL = 'https://mdm3mf7wuficgv3jjspkws2nu40azlsc.lambda-url.ap-south-1.on.aws/';
2304
+ const VMS_LAMBDA_URL = 'https://ppf3l3mxc2lorh5hkrsj6zwyim0bupxw.lambda-url.ap-south-1.on.aws/';
2188
2305
  // OpenSearch index holding extra billed products (VMS, Run AI, etc.). Each
2189
2306
  // record is a daily snapshot carrying its own quantity + price per product.
2190
2307
  const BILLING_DETAILS_INDEX = ( () => {
@@ -2199,12 +2316,37 @@ const BILLING_DETAILS_INDEX = ( () => {
2199
2316
  // that has none (currently only clientId 11). Shared by the Billing Breakdown
2200
2317
  // table (additionalProducts controller) AND invoice generation, so both stay in
2201
2318
  // sync. Each entry: { productName, quantity, price, total }.
2202
- export async function getAdditionalProducts( clientId ) {
2319
+ //
2320
+ // storeIds (optional): when a non-empty array is passed, the Eyetest / Planogram
2321
+ // / AI Manager quantities are restricted to those stores only — used by invoice
2322
+ // generation so a billing group is billed for its assigned stores, not the whole
2323
+ // brand. When omitted (e.g. the brand-wide Billing Breakdown endpoint) the counts
2324
+ // stay client-wide.
2325
+ //
2326
+ // Run AI / VMS are sourced by caller: invoice generation (storeIds passed) uses
2327
+ // the product Lambdas (called brand-wide, then intersected with the group's
2328
+ // stores locally); the brand-wide Billing Breakdown (no storeIds) uses the
2329
+ // billing_details snapshot instead.
2330
+ //
2331
+ // Remote Optum is store-scoped for invoices too: its quantity becomes the count
2332
+ // of remote_optom_steps_summary records for the group over the latest inserted
2333
+ // month in that index (see remoteOptumRecordCount). Factory is dropped from
2334
+ // invoices entirely. The brand-wide Billing Breakdown keeps taking Remote Optum /
2335
+ // Factory from the billing_details snapshot as-is.
2336
+ export async function getAdditionalProducts( clientId, storeIds ) {
2203
2337
  clientId = String( clientId ?? '' );
2204
2338
  if ( clientId !== ADDITIONAL_PRODUCTS_CLIENT_ID ) {
2205
2339
  return [];
2206
2340
  }
2207
2341
 
2342
+ // Restrict the Mongo aggregations to the billing group's stores when provided.
2343
+ // group.stores holds storeIds, matched via $in against each collection's
2344
+ // storeId field (top-level for cameras/planograms, stores.storeId once
2345
+ // unwound for daily-pricing).
2346
+ const scopeStores = Array.isArray( storeIds ) && storeIds.length;
2347
+ const storeMatch = scopeStores ? { storeId: { $in: storeIds } } : {};
2348
+ const unwoundStoreMatch = scopeStores ? { 'stores.storeId': { $in: storeIds } } : {};
2349
+
2208
2350
  // Eyetest: distinct stores in the cameras collection that have an eye-test
2209
2351
  // stream with a QR code.
2210
2352
  const eyetestRows = await cameraService.aggregate( [
@@ -2212,6 +2354,7 @@ export async function getAdditionalProducts( clientId ) {
2212
2354
  { clientId: clientId },
2213
2355
  { isEyeTestStream: true },
2214
2356
  { qrCode: { $exists: true } },
2357
+ storeMatch,
2215
2358
  ] } },
2216
2359
  { $group: { _id: '$storeId' } },
2217
2360
  { $count: 'stores' },
@@ -2220,7 +2363,7 @@ export async function getAdditionalProducts( clientId ) {
2220
2363
 
2221
2364
  // Planogram: distinct storeName in the planograms collection.
2222
2365
  const planogramRows = await planogramService.aggregate( [
2223
- { $match: { clientId: clientId } },
2366
+ { $match: { clientId: clientId, ...storeMatch } },
2224
2367
  { $group: { _id: null, storecount: { $addToSet: '$storeName' } } },
2225
2368
  { $project: { count: { $size: '$storecount' } } },
2226
2369
  ] );
@@ -2234,6 +2377,7 @@ export async function getAdditionalProducts( clientId ) {
2234
2377
  { $sort: { dateISO: -1 } },
2235
2378
  { $limit: 1 },
2236
2379
  { $unwind: '$stores' },
2380
+ ...( scopeStores ? [ { $match: unwoundStoreMatch } ] : [] ),
2237
2381
  { $unwind: '$stores.products' },
2238
2382
  { $match: { 'stores.products.productName': 'tangoTraffic', 'stores.products.workingdays': { $gt: 1 } } },
2239
2383
  { $group: { _id: '$stores.storeId' } },
@@ -2254,10 +2398,45 @@ export async function getAdditionalProducts( clientId ) {
2254
2398
  build( 'AI Manager', aiManagerQty, ADDITIONAL_PRODUCT_PRICES.aiManager ),
2255
2399
  ];
2256
2400
 
2401
+ // Run AI and VMS quantities come from two different sources depending on the
2402
+ // caller:
2403
+ // - Invoice generation (storeIds passed): use the product Lambdas. The
2404
+ // Lambda's own store_ids filter is unreliable, so lambdaStoreCount calls it
2405
+ // brand-wide and intersects the returned store list with the group's stores
2406
+ // locally — giving a per-group scoped count. Unit price from billing_details.
2407
+ // - Brand-wide Billing Breakdown (no storeIds): use the billing_details
2408
+ // OpenSearch snapshot instead (handled by the loop below), same as Remote
2409
+ // Optum / Factory.
2410
+ // lambdaSourced marks which productNames the loop below must skip so they are
2411
+ // not counted twice — only populated in the invoice (scoped) case.
2412
+ const lambdaSourced = new Set();
2413
+ if ( scopeStores ) {
2414
+ const [ runAiQty, vmsQty, runAiPrice, vmsPrice, optumQty, optumPrice ] = await Promise.all( [
2415
+ lambdaStoreCount( clientId, RUN_AI_LAMBDA_URL, storeIds ),
2416
+ lambdaStoreCount( clientId, VMS_LAMBDA_URL, storeIds ),
2417
+ billingDetailsPrice( clientId, 'Run AI' ),
2418
+ billingDetailsPrice( clientId, 'VMS' ),
2419
+ remoteOptumRecordCount( clientId, storeIds ),
2420
+ billingDetailsPrice( clientId, 'Remote Optum' ),
2421
+ ] );
2422
+ products.push( build( 'Run AI', runAiQty, runAiPrice ) );
2423
+ products.push( build( 'VMS', vmsQty, vmsPrice ) );
2424
+ products.push( build( 'Remote Optum', optumQty, optumPrice ) );
2425
+ // Skipped in the billing_details loop below so they are not counted twice.
2426
+ lambdaSourced.add( 'run ai' );
2427
+ lambdaSourced.add( 'vms' );
2428
+ lambdaSourced.add( 'remote optum' );
2429
+ }
2430
+
2257
2431
  // Extra products from the billing_details OpenSearch index — the LATEST daily
2258
2432
  // snapshot for this client. quantity/price are stored as strings, so coerce
2259
- // them. If OpenSearch is unreachable we just skip these rows rather than
2260
- // failing the whole list.
2433
+ // them. In the invoice (scoped) case VMS / Run AI / Remote Optum are skipped
2434
+ // here (already added store-scoped above) to avoid double-counting; in the
2435
+ // brand-wide case they are taken from this snapshot like the other extra
2436
+ // products. If OpenSearch is unreachable we just skip these rows rather than
2437
+ // failing the whole list. Factory is excluded from invoices (scoped) but still
2438
+ // shown in the brand-wide Billing Breakdown table.
2439
+ const invoiceExcluded = scopeStores ? new Set( [ 'factory' ] ) : new Set();
2261
2440
  try {
2262
2441
  const osRes = await getOpenSearchData( BILLING_DETAILS_INDEX, {
2263
2442
  size: 1,
@@ -2267,6 +2446,10 @@ export async function getAdditionalProducts( clientId ) {
2267
2446
  const hits = osRes?.body?.hits?.hits || osRes?.hits?.hits || [];
2268
2447
  const osProducts = hits[0]?._source?.products || [];
2269
2448
  for ( const p of osProducts ) {
2449
+ const nameKey = String( p.productName ).toLowerCase();
2450
+ if ( lambdaSourced.has( nameKey ) || invoiceExcluded.has( nameKey ) ) {
2451
+ continue;
2452
+ }
2270
2453
  const quantity = Number( p.quantity ) || 0;
2271
2454
  const price = Number( p.price ) || 0;
2272
2455
  products.push( build( p.productName, quantity, price ) );
@@ -2281,8 +2464,13 @@ export async function getAdditionalProducts( clientId ) {
2281
2464
  export async function additionalProducts( req, res ) {
2282
2465
  try {
2283
2466
  const clientId = String( req.query?.clientId ?? req.body?.clientId ?? '' );
2284
- const products = await getAdditionalProducts( clientId );
2285
- return res.sendSuccess( { currency: 'inr', products } );
2467
+ const [ products, billingDate ] = await Promise.all( [
2468
+ getAdditionalProducts( clientId ),
2469
+ billingDetailsDate( clientId ),
2470
+ ] );
2471
+ // billingDate (YYYY-MM-DD) is the billing_details snapshot behind the counts;
2472
+ // the UI passes it back to the export endpoint so exports match the cards.
2473
+ return res.sendSuccess( { currency: 'inr', products, billingDate } );
2286
2474
  } catch ( error ) {
2287
2475
  logger.error( { error: error, function: 'additionalProducts' } );
2288
2476
  return res.sendError( error, 500 );
@@ -2309,6 +2497,35 @@ async function lamdaServiceCall( url, data ) {
2309
2497
  }
2310
2498
  }
2311
2499
 
2500
+ // Date (YYYY-MM-DD) of the latest billing_details OpenSearch snapshot for a
2501
+ // client — the snapshot whose product counts back the Additional Products cards.
2502
+ // The UI passes this back to the export endpoint so the export reflects the same
2503
+ // day as the card. Returns '' if unavailable; date_string is normalised to
2504
+ // YYYY-MM-DD (it may be stored as DD-MM-YYYY).
2505
+ async function billingDetailsDate( clientId ) {
2506
+ try {
2507
+ const osRes = await getOpenSearchData( BILLING_DETAILS_INDEX, {
2508
+ size: 1,
2509
+ query: { term: { 'client_id': clientId } },
2510
+ sort: [ { date_string: { order: 'desc' } } ],
2511
+ } );
2512
+ const hits = osRes?.body?.hits?.hits || osRes?.hits?.hits || [];
2513
+ const raw = String( hits[0]?._source?.date_string || '' ).trim();
2514
+ if ( !raw ) {
2515
+ return '';
2516
+ }
2517
+ // Accept DD-MM-YYYY or YYYY-MM-DD; return YYYY-MM-DD.
2518
+ const ddmmyyyy = raw.match( /^(\d{2})-(\d{2})-(\d{4})$/ );
2519
+ if ( ddmmyyyy ) {
2520
+ return `${ddmmyyyy[3]}-${ddmmyyyy[2]}-${ddmmyyyy[1]}`;
2521
+ }
2522
+ return raw;
2523
+ } catch ( osErr ) {
2524
+ logger.error( { error: osErr, function: 'billingDetailsDate', clientId } );
2525
+ return '';
2526
+ }
2527
+ }
2528
+
2312
2529
  // Unit price for a product from the latest billing_details OpenSearch record.
2313
2530
  // productName match is case-insensitive. Returns 0 if not found / unavailable.
2314
2531
  async function billingDetailsPrice( clientId, productName ) {
@@ -2328,13 +2545,82 @@ async function billingDetailsPrice( clientId, productName ) {
2328
2545
  }
2329
2546
  }
2330
2547
 
2548
+ // Date (YYYY-MM-DD) of the most recent record in the remote_optom_steps_summary
2549
+ // index — the latest day audit data was inserted. Date is a keyword so it sorts
2550
+ // lexically. Returns '' if the index is empty / unreachable.
2551
+ async function remoteOptumLatestDate() {
2552
+ try {
2553
+ const osRes = await getOpenSearchData( 'remote_optom_steps_summary', {
2554
+ size: 1,
2555
+ _source: [ 'Date' ],
2556
+ sort: [ { 'Date': { order: 'desc' } } ],
2557
+ } );
2558
+ const hits = osRes?.body?.hits?.hits || osRes?.hits?.hits || [];
2559
+ return String( hits[0]?._source?.Date || '' ).trim();
2560
+ } catch ( osErr ) {
2561
+ logger.error( { error: osErr, function: 'remoteOptumLatestDate' } );
2562
+ return '';
2563
+ }
2564
+ }
2565
+
2566
+ // Remote Optum billed quantity for a billing group = number of audit records in
2567
+ // the remote_optom_steps_summary index that belong to the group's stores, over
2568
+ // the range [to_date's month-start .. to_date], where to_date is the latest
2569
+ // record date in the index (e.g. to_date 2026-06-30 → from_date 2026-06-01). That
2570
+ // index keys by store NAME (no storeId), so group.stores (storeIds) are first
2571
+ // mapped to names via the stores collection, then matched with a terms filter on
2572
+ // storeName.keyword. Date is a keyword (YYYY-MM-DD per record), so the range is
2573
+ // lexical. size:0 + track_total_hits gives the exact count without paging past
2574
+ // the 10k window. Returns 0 on any failure or when the group has no matching
2575
+ // store names / the index is empty.
2576
+ async function remoteOptumRecordCount( clientId, storeIds ) {
2577
+ try {
2578
+ if ( !Array.isArray( storeIds ) || !storeIds.length ) {
2579
+ return 0;
2580
+ }
2581
+ const toDate = await remoteOptumLatestDate();
2582
+ if ( !toDate ) {
2583
+ return 0;
2584
+ }
2585
+ const fromDate = dayjs( toDate ).startOf( 'month' ).format( 'YYYY-MM-DD' );
2586
+ const stores = await storeService.find(
2587
+ { clientId: clientId, storeId: { $in: storeIds } },
2588
+ { storeName: 1 },
2589
+ );
2590
+ const storeNames = [ ...new Set(
2591
+ stores.map( ( s ) => String( s.storeName || '' ).trim() ).filter( Boolean ),
2592
+ ) ];
2593
+ if ( !storeNames.length ) {
2594
+ return 0;
2595
+ }
2596
+ const osRes = await getOpenSearchData( 'remote_optom_steps_summary', {
2597
+ size: 0,
2598
+ track_total_hits: true,
2599
+ query: { bool: { filter: [
2600
+ { range: { 'Date': { gte: fromDate, lte: toDate } } },
2601
+ { terms: { 'storeName.keyword': storeNames } },
2602
+ ] } },
2603
+ } );
2604
+ const total = osRes?.body?.hits?.total?.value ?? osRes?.hits?.total?.value ?? 0;
2605
+ return Number( total ) || 0;
2606
+ } catch ( osErr ) {
2607
+ logger.error( { error: osErr, function: 'remoteOptumRecordCount', clientId } );
2608
+ return 0;
2609
+ }
2610
+ }
2611
+
2331
2612
  // Build the Lambda-backed export rows (VMS / Run AI / etc.). Calls the product's
2332
2613
  // Lambda for store_names (storeIds), maps them to the stores collection for
2333
2614
  // name + country (Zone), with current-month days and the billing_details price.
2334
- async function lambdaStoreExport( clientId, lambdaUrl, billingProductName ) {
2335
- const toDate = dayjs().format( 'DD-MM-YYYY' );
2615
+ // reqDate (YYYY-MM-DD, optional) is the billing_details snapshot date the UI
2616
+ // picked so the export matches the card count; it is sent to the Lambda as
2617
+ // to_date (DD-MM-YYYY). Defaults to today when absent.
2618
+ async function lambdaStoreExport( clientId, lambdaUrl, billingProductName, reqDate ) {
2619
+ // reqDate is ISO YYYY-MM-DD, which dayjs parses natively; reformat to the
2620
+ // Lambda's DD-MM-YYYY. Fall back to today when absent / unparseable.
2621
+ const parsed = reqDate ? dayjs( reqDate ) : null;
2622
+ const toDate = parsed && parsed.isValid() ? parsed.format( 'DD-MM-YYYY' ) : dayjs().format( 'DD-MM-YYYY' );
2336
2623
  const lambdaResult = await lamdaServiceCall( lambdaUrl, { to_date: toDate, client_id: clientId } );
2337
- console.log( '🚀 ~ lambdaStoreExport ~ lambdaResult:', lambdaResult );
2338
2624
  // These Lambdas return the storeIds under either `store_ids` (Run AI) or
2339
2625
  // `store_names` (VMS) with no status_code. Accept whichever is present; data
2340
2626
  // availability = a non-empty array.
@@ -2362,6 +2648,33 @@ async function lambdaStoreExport( clientId, lambdaUrl, billingProductName ) {
2362
2648
  } );
2363
2649
  }
2364
2650
 
2651
+ // Count the billable stores a product Lambda (VMS / Run AI) reports, optionally
2652
+ // scoped to a billing group's stores. The Lambda's own store_ids request filter
2653
+ // is unreliable — passing store_ids returns 0 even for IDs it itself reports as
2654
+ // billable — so we always call it BRAND-WIDE (client only) to get the true store
2655
+ // list, then intersect that list with the group's stores locally. Both sides use
2656
+ // the same `clientId-n` storeId format (e.g. `11-2911`), matching how the Mongo
2657
+ // aggregations scope by group.stores. Returns 0 on any failure (Lambda
2658
+ // unreachable / empty), so the caller just omits the line rather than failing the
2659
+ // whole invoice.
2660
+ async function lambdaStoreCount( clientId, lambdaUrl, storeIds ) {
2661
+ const toDate = dayjs().format( 'DD-MM-YYYY' );
2662
+ const lambdaResult = await lamdaServiceCall( lambdaUrl, { to_date: toDate, client_id: clientId } );
2663
+ // The Lambdas return the storeIds under either `store_ids` (Run AI) or
2664
+ // `store_names` (VMS); accept whichever is present.
2665
+ const rawIds = ( lambdaResult && Array.isArray( lambdaResult.store_ids ) ) ? lambdaResult.store_ids :
2666
+ ( ( lambdaResult && Array.isArray( lambdaResult.store_names ) ) ? lambdaResult.store_names : [] );
2667
+ const ids = rawIds.map( ( s ) => String( s ) );
2668
+ // Scope to the billing group's stores when provided; otherwise (brand-wide
2669
+ // Billing Breakdown) count every store the Lambda reports.
2670
+ const scope = Array.isArray( storeIds ) && storeIds.length ? storeIds.map( ( s ) => String( s ) ) : null;
2671
+ if ( scope ) {
2672
+ const allowed = new Set( scope );
2673
+ return ids.filter( ( id ) => allowed.has( id ) ).length;
2674
+ }
2675
+ return ids.length;
2676
+ }
2677
+
2365
2678
  // tangoTraffic working days per store, from the latest daily-pricing doc.
2366
2679
  // Map<storeId, workingdays>; stores with no tangoTraffic record are absent.
2367
2680
  async function tangoTrafficDaysByStore( clientId ) {
@@ -2531,8 +2844,9 @@ export async function additionalProductExport( req, res ) {
2531
2844
  if ( product === 'vms' ) {
2532
2845
  const exportData = await lambdaStoreExport(
2533
2846
  clientId,
2534
- 'https://ppf3l3mxc2lorh5hkrsj6zwyim0bupxw.lambda-url.ap-south-1.on.aws/',
2847
+ VMS_LAMBDA_URL,
2535
2848
  'VMS',
2849
+ reqDate,
2536
2850
  );
2537
2851
  if ( !exportData.length ) {
2538
2852
  return res.sendError( 'No data', 204 );
@@ -2544,10 +2858,10 @@ export async function additionalProductExport( req, res ) {
2544
2858
  if ( product === 'runai' ) {
2545
2859
  const exportData = await lambdaStoreExport(
2546
2860
  clientId,
2547
- 'https://mdm3mf7wuficgv3jjspkws2nu40azlsc.lambda-url.ap-south-1.on.aws/',
2861
+ RUN_AI_LAMBDA_URL,
2548
2862
  'Run AI',
2863
+ reqDate,
2549
2864
  );
2550
- console.log( '🚀 ~ additionalProductExport ~ exportData:', exportData );
2551
2865
  if ( !exportData.length ) {
2552
2866
  return res.sendError( 'No data', 204 );
2553
2867
  }
@@ -2556,56 +2870,103 @@ export async function additionalProductExport( req, res ) {
2556
2870
  }
2557
2871
 
2558
2872
  if ( product === 'remoteoptum' ) {
2559
- // Detailed optom-audit export from the remote_optom_steps_summary index
2560
- // for the given date (passed from the UI, defaults to today). Each record
2561
- // is one row; the nested `steps` object is flattened into TRUE/FALSE
2562
- // columns in a fixed (alphabetical) order.
2873
+ // Detailed optom-audit export from the remote_optom_steps_summary index.
2874
+ // to_date is the date from the payload (reqDate); from_date is the first
2875
+ // day of that date's month so the export covers month-start through the
2876
+ // requested date. Each record is one row; the nested `steps` object is
2877
+ // flattened into TRUE/FALSE columns in a fixed (alphabetical) order.
2563
2878
  const STEP_COLUMNS = [
2564
2879
  'Adjust-Phoropter', 'DuoChrome-Test', 'Explanation', 'Final-Prescription',
2565
2880
  'Handover', 'History-Taking', 'JCC', 'Near-Vision', 'Personal-Intro',
2566
2881
  'Subjective-Refraction', 'Trial-Frame', 'VA-Check',
2567
2882
  ];
2568
- let optomHits = [];
2883
+ const fromDate = dayjs( reqDate ).startOf( 'month' ).format( 'YYYY-MM-DD' );
2884
+ const HEADER_COLUMNS = [
2885
+ 'storeName', 'engagementId', 'queue_id', 'optm_name', 'optm_id',
2886
+ 'optm_Emailid', 'Date', 'StartTime', 'EndTime', 'Duration (min)',
2887
+ ...STEP_COLUMNS,
2888
+ ];
2889
+ // Flatten one OpenSearch hit into a row array aligned with HEADER_COLUMNS.
2890
+ const hitToRow = ( h ) => {
2891
+ const s = h._source || {};
2892
+ const steps = s.steps || {};
2893
+ return [
2894
+ s.storeName || '', s.engagementId || '', s.queue_id || '',
2895
+ s.optm_name || '', s.optm_id || '', s.optm_Emailid || '',
2896
+ s.Date || '', s.StartTime || '', s.EndTime || '',
2897
+ s['Duration (min)'] != null ? s['Duration (min)'] : '',
2898
+ ...STEP_COLUMNS.map( ( col ) => ( steps[col] === true ? 'TRUE' : 'FALSE' ) ),
2899
+ ];
2900
+ };
2901
+
2902
+ // NOTE: do NOT sort on StartTime — it's an analyzed text field, and sorting
2903
+ // on it makes OpenSearch return ZERO hits. Date is a keyword (YYYY-MM-DD),
2904
+ // so the month-to-date span is a lexical range; rows come back in index
2905
+ // order. A whole month can be hundreds of thousands of records, so we scroll
2906
+ // through pages AND stream each page straight into an ExcelJS streaming
2907
+ // workbook — building the full .xlsx in memory (the old excel4node path)
2908
+ // blew the heap / gateway timeout at this scale, causing 502s.
2909
+ let firstRes;
2569
2910
  try {
2570
- const osRes = await getOpenSearchData( 'remote_optom_steps_summary', {
2571
- // A single day is well under the 10k window; pull up to the cap.
2572
- // NOTE: do NOT sort on StartTime — it's an analyzed text field, and
2573
- // sorting on it makes OpenSearch return ZERO hits. The day filter is
2574
- // enough; rows come back in index order.
2911
+ firstRes = await searchOpenSearchData( 'remote_optom_steps_summary', {
2575
2912
  size: 10000,
2576
- query: { term: { 'Date': reqDate } },
2913
+ query: { range: { 'Date': { gte: fromDate, lte: reqDate } } },
2577
2914
  } );
2578
- optomHits = osRes?.body?.hits?.hits || osRes?.hits?.hits || [];
2579
2915
  } catch ( osErr ) {
2580
2916
  logger.error( { error: osErr, function: 'additionalProductExport.remoteOptum', clientId } );
2581
2917
  return res.sendError( 'Failed to fetch Remote Optum data', 502 );
2582
2918
  }
2583
-
2584
- const exportData = optomHits.map( ( h ) => {
2585
- const s = h._source || {};
2586
- const steps = s.steps || {};
2587
- const row = {
2588
- 'storeName': s.storeName || '',
2589
- 'engagementId': s.engagementId || '',
2590
- 'queue_id': s.queue_id || '',
2591
- 'optm_name': s.optm_name || '',
2592
- 'optm_id': s.optm_id || '',
2593
- 'optm_Emailid': s.optm_Emailid || '',
2594
- 'Date': s.Date || '',
2595
- 'StartTime': s.StartTime || '',
2596
- 'EndTime': s.EndTime || '',
2597
- 'Duration (min)': s['Duration (min)'] != null ? s['Duration (min)'] : '',
2598
- };
2599
- for ( const col of STEP_COLUMNS ) {
2600
- row[col] = steps[col] === true ? 'TRUE' : 'FALSE';
2919
+ let body = firstRes?.body || firstRes;
2920
+ let hits = body?.hits?.hits || [];
2921
+ let scrollId = body?._scroll_id;
2922
+ // Nothing to export respond BEFORE any stream headers are sent.
2923
+ if ( !hits.length ) {
2924
+ if ( scrollId ) {
2925
+ try {
2926
+ await clearScroll( scrollId );
2927
+ } catch ( clearErr ) {
2928
+ logger.error( { error: clearErr, function: 'additionalProductExport.remoteOptum.clearScroll' } );
2929
+ }
2601
2930
  }
2602
- return row;
2603
- } );
2604
-
2605
- if ( !exportData.length ) {
2606
2931
  return res.sendError( 'No data', 204 );
2607
2932
  }
2608
- await download( exportData, res );
2933
+
2934
+ // From here the response is a streamed workbook; headers are committed, so
2935
+ // a mid-stream failure can only abort the connection, not send a new status.
2936
+ res.setHeader( 'Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' );
2937
+ res.setHeader( 'Content-Disposition', `attachment; filename="Remote Optum-${clientId}.xlsx"` );
2938
+ const workbook = new ExcelJS.stream.xlsx.WorkbookWriter( { stream: res, useStyles: false } );
2939
+ const ws = workbook.addWorksheet( 'Worksheet Name' );
2940
+ ws.addRow( HEADER_COLUMNS ).commit();
2941
+ try {
2942
+ while ( hits.length ) {
2943
+ for ( const h of hits ) {
2944
+ ws.addRow( hitToRow( h ) ).commit();
2945
+ }
2946
+ if ( !scrollId ) {
2947
+ break;
2948
+ }
2949
+ const osRes = await scrollResponse( scrollId );
2950
+ body = osRes?.body || osRes;
2951
+ hits = body?.hits?.hits || [];
2952
+ scrollId = body?._scroll_id || scrollId;
2953
+ }
2954
+ ws.commit();
2955
+ await workbook.commit();
2956
+ } catch ( streamErr ) {
2957
+ // Headers are already sent — log and tear down the connection so the
2958
+ // client sees a failed/aborted download rather than a truncated file.
2959
+ logger.error( { error: streamErr, function: 'additionalProductExport.remoteOptum.stream', clientId } );
2960
+ res.destroy( streamErr );
2961
+ } finally {
2962
+ if ( scrollId ) {
2963
+ try {
2964
+ await clearScroll( scrollId );
2965
+ } catch ( clearErr ) {
2966
+ logger.error( { error: clearErr, function: 'additionalProductExport.remoteOptum.clearScroll' } );
2967
+ }
2968
+ }
2969
+ }
2609
2970
  return;
2610
2971
  }
2611
2972