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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,6 +3,8 @@ import * as invoiceService from '../services/invoice.service.js';
3
3
  import * as billingService from '../services/billing.service.js';
4
4
  import * as dailyPriceService from '../services/dailyPrice.service.js';
5
5
  import * as storeService from '../services/store.service.js';
6
+ import * as assignedStoreService from '../services/assignedStore.service.js';
7
+ import * as basePriceService from '../services/basePrice.service.js';
6
8
  import dayjs from 'dayjs';
7
9
  import { logger, checkFileExist, signedUrl, download, insertOpenSearchData } from 'tango-app-api-middleware';
8
10
  import * as XLSX from 'xlsx';
@@ -162,19 +164,93 @@ export async function brandsBillingList( req, res ) {
162
164
 
163
165
  let allData = await clientService.aggregate( query );
164
166
 
165
- if ( allData.length == 0 ) {
166
- return res.sendError( 'No data', 204 );
167
- }
167
+ // Lifecycle + payment counts over the FULL client population (no status /
168
+ // paymentStatus filter), so the overview cards stay stable regardless of
169
+ // which lifecycle tab is selected — and so Hold / Suspended / Deactive
170
+ // show even when the default Active tab has zero rows. trialPaid is the
171
+ // derived "paid plan with at least one product still on trial" bucket.
172
+ // Bucket every client into ONE mutually-exclusive payment bucket so the
173
+ // pills (Paid / Trial / Paid-Trial / Free) sum to their lifecycle tab
174
+ // total. trialPaid = paid plan that still has a product on trial; such a
175
+ // client is trialPaid, NOT also paid.
176
+ const payBucketExpr = {
177
+ $let: {
178
+ vars: {
179
+ ps: '$planDetails.paymentStatus',
180
+ hasTrialProduct: { $gt: [ { $size: { $filter: {
181
+ input: { $ifNull: [ '$planDetails.product', [] ] },
182
+ as: 'p',
183
+ cond: { $eq: [ '$$p.status', 'trial' ] },
184
+ } } }, 0 ] },
185
+ },
186
+ in: {
187
+ $switch: {
188
+ branches: [
189
+ { case: { $eq: [ '$$ps', 'trial' ] }, then: 'trial' },
190
+ { case: { $eq: [ '$$ps', 'free' ] }, then: 'free' },
191
+ { case: { $and: [ { $eq: [ '$$ps', 'paid' ] }, '$$hasTrialProduct' ] }, then: 'trialPaid' },
192
+ { case: { $eq: [ '$$ps', 'paid' ] }, then: 'paid' },
193
+ ],
194
+ default: 'other',
195
+ },
196
+ },
197
+ },
198
+ };
199
+ const matrixAgg = await clientService.aggregate( [
200
+ { $project: { status: 1, payBucket: payBucketExpr } },
201
+ { $group: { _id: { status: '$status', pay: '$payBucket' }, count: { $sum: 1 } } },
202
+ ] );
203
+
204
+ // Lifecycle totals + a status×payment matrix. Counts are over the FULL
205
+ // population (no filter) so the overview cards and pills stay stable
206
+ // regardless of the selected tab, and show even when the Active tab is
207
+ // empty.
208
+ const lifecycle = { active: 0, hold: 0, suspended: 0, deactive: 0 };
209
+ const payTotals = { trial: 0, paid: 0, free: 0, trialPaid: 0 };
210
+ const paymentByStatus = {};
211
+ let totalBrands = 0;
212
+ matrixAgg.forEach( ( row ) => {
213
+ const st = row._id.status || 'active';
214
+ const pay = row._id.pay;
215
+ const n = row.count || 0;
216
+ totalBrands += n;
217
+ if ( lifecycle[st] != null ) {
218
+ lifecycle[st] += n;
219
+ }
220
+ if ( payTotals[pay] != null ) {
221
+ payTotals[pay] += n;
222
+ }
223
+ if ( !paymentByStatus[st] ) {
224
+ paymentByStatus[st] = { trial: 0, paid: 0, free: 0, trialPaid: 0 };
225
+ }
226
+ if ( paymentByStatus[st][pay] != null ) {
227
+ paymentByStatus[st][pay] += n;
228
+ }
229
+ } );
168
230
 
169
231
  let summary = {
170
- totalBrands: allData.length,
171
- active: allData.filter( ( c ) => c.status === 'active' ).length,
172
- trial: allData.filter( ( c ) => c.paymentStatus === 'trial' ).length,
173
- paid: allData.filter( ( c ) => c.paymentStatus === 'paid' ).length,
232
+ totalBrands,
233
+ active: lifecycle.active,
234
+ hold: lifecycle.hold,
235
+ suspended: lifecycle.suspended,
236
+ deactive: lifecycle.deactive,
237
+ trial: payTotals.trial,
238
+ paid: payTotals.paid,
239
+ free: payTotals.free,
240
+ trialPaid: payTotals.trialPaid,
241
+ paymentByStatus,
242
+ // Money/store totals stay tied to the filtered view so they match the
243
+ // rows on screen.
174
244
  totalBillDue: allData.reduce( ( sum, c ) => sum + ( c.billAmountDue || 0 ), 0 ),
175
245
  storesUnderBilling: allData.reduce( ( sum, c ) => sum + ( c.billingStores || 0 ), 0 ),
176
246
  };
177
247
 
248
+ if ( allData.length == 0 ) {
249
+ // Still return the population summary so the overview cards populate even
250
+ // when the current lifecycle tab is empty.
251
+ return res.sendSuccess( { summary, count: 0, data: [] } );
252
+ }
253
+
178
254
  if ( req.body.export ) {
179
255
  const exportdata = [];
180
256
  allData.forEach( ( element ) => {
@@ -524,6 +600,36 @@ export async function latestDailyPricing( req, res ) {
524
600
  storeList = stores.slice( skip, skip + Number( req.body.limit ) );
525
601
  }
526
602
 
603
+ // Monthly Billing Summary — one row per month of the brand's invoice
604
+ // history (stores billed + invoice amount), newest first. The UI tags the
605
+ // current/last-generated rows and computes month-over-month deltas.
606
+ // billingDate is a Date on most rows but a string on some legacy ones, so
607
+ // coerce before extracting year/month.
608
+ const monthlyBillingSummary = await invoiceService.aggregate( [
609
+ { $match: { clientId: req.body.clientId } },
610
+ { $addFields: { billingDateD: { $cond: [
611
+ { $eq: [ { $type: '$billingDate' }, 'date' ] },
612
+ '$billingDate',
613
+ { $toDate: '$billingDate' },
614
+ ] } } },
615
+ { $match: { billingDateD: { $ne: null } } },
616
+ { $group: {
617
+ _id: { year: { $year: '$billingDateD' }, month: { $month: '$billingDateD' } },
618
+ storesBilled: { $sum: { $ifNull: [ '$stores', 0 ] } },
619
+ invoiceAmount: { $sum: { $ifNull: [ '$totalAmount', 0 ] } },
620
+ currency: { $last: { $ifNull: [ '$currency', 'inr' ] } },
621
+ } },
622
+ { $sort: { '_id.year': -1, '_id.month': -1 } },
623
+ { $project: {
624
+ _id: 0,
625
+ year: '$_id.year',
626
+ month: '$_id.month',
627
+ storesBilled: 1,
628
+ invoiceAmount: { $round: [ '$invoiceAmount', 2 ] },
629
+ currency: 1,
630
+ } },
631
+ ] );
632
+
527
633
  let data = {
528
634
  clientId: record.clientId,
529
635
  brandName: record.brandName,
@@ -535,6 +641,7 @@ export async function latestDailyPricing( req, res ) {
535
641
  proRate: record.proRate,
536
642
  count,
537
643
  data: storeList,
644
+ monthlyBillingSummary,
538
645
  };
539
646
 
540
647
  res.sendSuccess( data );
@@ -1290,3 +1397,332 @@ export async function bulkUpdateBillingGroups( req, res ) {
1290
1397
  return res.sendError( error, 500 );
1291
1398
  }
1292
1399
  }
1400
+
1401
+ // ---------------------------------------------------------------------------
1402
+ // Billing Summary (landing tab): one row per client — monthly store counts,
1403
+ // revenue, price-per-store and installation fee all from the invoice
1404
+ // collection (the billed `stores` count per invoice), products + status from
1405
+ // clients, CSM from userAssignedStore. Window: last 5 calendar months
1406
+ // including the current. Filtering/sorting happen client-side (the dataset
1407
+ // is one row per client).
1408
+ // ---------------------------------------------------------------------------
1409
+ // Today's USD->INR rate for dollar-priced clients. Live rate (cached 6h)
1410
+ // with an env override (USD_INR_RATE) and a last-known/static fallback so
1411
+ // the summary never fails because a rate API is down.
1412
+ let usdRateCache = { rate: null, at: 0 };
1413
+ export async function getUsdInrRate() {
1414
+ const override = Number( process.env.USD_INR_RATE );
1415
+ if ( override > 0 ) {
1416
+ return override;
1417
+ }
1418
+ if ( usdRateCache.rate && ( Date.now() - usdRateCache.at ) < 6 * 60 * 60 * 1000 ) {
1419
+ return usdRateCache.rate;
1420
+ }
1421
+ try {
1422
+ const resp = await fetch( 'https://open.er-api.com/v6/latest/USD', { signal: AbortSignal.timeout( 4000 ) } );
1423
+ const body = await resp.json();
1424
+ const rate = Number( body?.rates?.INR );
1425
+ if ( rate > 0 ) {
1426
+ usdRateCache = { rate, at: Date.now() };
1427
+ return rate;
1428
+ }
1429
+ } catch ( err ) {
1430
+ logger.error( { error: err, function: 'getUsdInrRate' } );
1431
+ }
1432
+ return usdRateCache.rate || 83.33;
1433
+ }
1434
+
1435
+ export async function billingSummary( req, res ) {
1436
+ try {
1437
+ const now = dayjs();
1438
+ const months = [];
1439
+ for ( let i = 4; i >= 0; i-- ) {
1440
+ const m = now.subtract( i, 'month' );
1441
+ months.push( { key: m.format( 'YYYY-MM' ), label: m.format( 'MMM-YY' ) } );
1442
+ }
1443
+ const windowStart = new Date( now.subtract( 4, 'month' ).startOf( 'month' ).toISOString() );
1444
+
1445
+ // Revenue (excl. GST), billed stores and installation fees per client per
1446
+ // month. billingDate is a string on some legacy rows — coerce first.
1447
+ const invoices = await invoiceService.aggregate( [
1448
+ { $addFields: { billingDateD: { $cond: [
1449
+ { $eq: [ { $type: '$billingDate' }, 'date' ] },
1450
+ '$billingDate',
1451
+ { $toDate: '$billingDate' },
1452
+ ] } } },
1453
+ { $match: { billingDateD: { $gte: windowStart } } },
1454
+ { $project: {
1455
+ clientId: 1,
1456
+ companyName: 1,
1457
+ amount: { $ifNull: [ '$amount', 0 ] },
1458
+ stores: { $ifNull: [ '$stores', 0 ] },
1459
+ isDollar: { $eq: [ '$currency', 'dollar' ] },
1460
+ ym: { $dateToString: { format: '%Y-%m', date: '$billingDateD' } },
1461
+ installation: { $sum: { $map: {
1462
+ input: { $filter: {
1463
+ input: { $ifNull: [ '$products', [] ] },
1464
+ cond: { $eq: [ '$$this.productName', 'installationFee' ] },
1465
+ } },
1466
+ in: { $ifNull: [ '$$this.amount', 0 ] },
1467
+ } } },
1468
+ } },
1469
+ // Dollar invoices are summed separately so they can be converted to
1470
+ // INR at today's rate when the months are merged below.
1471
+ { $group: {
1472
+ _id: { c: '$clientId', ym: '$ym' },
1473
+ revenueInr: { $sum: { $cond: [ '$isDollar', 0, '$amount' ] } },
1474
+ revenueUsd: { $sum: { $cond: [ '$isDollar', '$amount', 0 ] } },
1475
+ stores: { $sum: '$stores' },
1476
+ installationInr: { $sum: { $cond: [ '$isDollar', 0, '$installation' ] } },
1477
+ installationUsd: { $sum: { $cond: [ '$isDollar', '$installation', 0 ] } },
1478
+ companyName: { $last: '$companyName' },
1479
+ } },
1480
+ ] );
1481
+
1482
+ const clients = await clientService.find( {}, {
1483
+ 'clientId': 1,
1484
+ 'clientName': 1,
1485
+ 'planDetails.product.productName': 1,
1486
+ 'planDetails.product.status': 1,
1487
+ 'paymentInvoice.currencyType': 1,
1488
+ } );
1489
+ const clientById = new Map( clients.map( ( c ) => [ String( c.clientId ), c ] ) );
1490
+
1491
+ // Per-client CSM (userAssignedStore, tangoUserType 'csm'); display the
1492
+ // email's local part since the collection carries no display name.
1493
+ const usdRate = await getUsdInrRate();
1494
+
1495
+ // Current month's store count comes from dailyPricing (latest reading
1496
+ // this month) — invoices for the running month usually don't exist yet.
1497
+ const curMonthStart = new Date( now.startOf( 'month' ).toISOString() );
1498
+ const latestDp = await dailyPriceService.aggregate( [
1499
+ { $match: { dateISO: { $gte: curMonthStart } } },
1500
+ { $project: { clientId: 1, activeStores: 1, dateISO: 1 } },
1501
+ { $sort: { dateISO: 1 } },
1502
+ { $group: { _id: '$clientId', stores: { $last: '$activeStores' } } },
1503
+ ] );
1504
+ const curStoresByClient = new Map( latestDp.map( ( d ) => [ String( d._id ), d.stores || 0 ] ) );
1505
+
1506
+ // Negotiated price per store from the basepricing collection — standard
1507
+ // rows sum across products; step rows are resolved by store-count range.
1508
+ const pricingDocs = await basePriceService.find(
1509
+ { clientId: { $exists: true, $nin: [ '', null ] } },
1510
+ { clientId: 1, standard: 1, step: 1 },
1511
+ );
1512
+ const pricingByClient = new Map( pricingDocs.map( ( d ) => [ String( d.clientId ), d ] ) );
1513
+ const rangeContains = ( rangeStr, count ) => {
1514
+ const s = String( rangeStr || '' );
1515
+ const between = s.match( /(\d+)\s*-\s*(\d+)/ );
1516
+ if ( between ) {
1517
+ return count >= Number( between[1] ) && count <= Number( between[2] );
1518
+ }
1519
+ const plus = s.match( /(\d+)\s*\+/ );
1520
+ if ( plus ) {
1521
+ return count >= Number( plus[1] );
1522
+ }
1523
+ return false;
1524
+ };
1525
+
1526
+ const csms = await assignedStoreService.find( { tangoUserType: 'csm' }, { clientId: 1, userEmail: 1 } );
1527
+ const csmByClient = new Map();
1528
+ for ( const a of csms ) {
1529
+ const key = String( a.clientId ?? '' );
1530
+ const name = String( a.userEmail || '' ).split( '@' )[0];
1531
+ if ( !name ) {
1532
+ continue;
1533
+ }
1534
+ const pretty = name.charAt( 0 ).toUpperCase() + name.slice( 1 );
1535
+ if ( !csmByClient.has( key ) ) {
1536
+ csmByClient.set( key, new Set() );
1537
+ }
1538
+ csmByClient.get( key ).add( pretty );
1539
+ }
1540
+
1541
+ // Merge — a client appears when it has billed invoices in the window.
1542
+ const rows = new Map();
1543
+ const rowOf = ( clientId ) => {
1544
+ const key = String( clientId ?? '' );
1545
+ if ( !rows.has( key ) ) {
1546
+ const c = clientById.get( key );
1547
+ const products = ( c?.planDetails?.product || [] );
1548
+ rows.set( key, {
1549
+ clientId: key,
1550
+ clientName: c?.clientName || '',
1551
+ registeredEntity: '',
1552
+ status: products.length && products.every( ( p ) => p.status === 'trial' ) ? 'Trial' : 'Paid',
1553
+ products: [ ...new Set( products.map( ( p ) => String( p.productName || '' )
1554
+ .replace( /^tango/i, '' ).replace( /^./, ( ch ) => ch.toUpperCase() ) ).filter( Boolean ) ) ],
1555
+ // Price/Store only counts products the client actually subscribes
1556
+ // to (status 'live') — trials are excluded.
1557
+ liveProductSet: new Set( products.filter( ( p ) => p.status === 'live' )
1558
+ .map( ( p ) => String( p.productName || '' ).toLowerCase() ) ),
1559
+ currency: c?.paymentInvoice?.currencyType === 'dollar' ? 'dollar' : 'inr',
1560
+ csm: [ ...( csmByClient.get( key ) || [] ) ].join( ', ' ),
1561
+ revenueMonths: {},
1562
+ billedStoresMonths: {},
1563
+ installationFee: 0,
1564
+ } );
1565
+ }
1566
+ return rows.get( key );
1567
+ };
1568
+
1569
+ for ( const inv of invoices ) {
1570
+ const r = rowOf( inv._id.c );
1571
+ r.revenueMonths[inv._id.ym] = Math.round( ( ( inv.revenueInr || 0 ) + ( inv.revenueUsd || 0 ) * usdRate ) * 100 ) / 100;
1572
+ r.billedStoresMonths[inv._id.ym] = inv.stores || 0;
1573
+ r.installationFee += ( inv.installationInr || 0 ) + ( inv.installationUsd || 0 ) * usdRate;
1574
+ if ( inv.companyName ) {
1575
+ r.registeredEntity = inv.companyName;
1576
+ }
1577
+ }
1578
+
1579
+ const curKey = months[4].key;
1580
+ const prevKey = months[3].key;
1581
+ const data = [ ...rows.values() ].map( ( r ) => {
1582
+ const revPrev = r.revenueMonths[prevKey] ?? null;
1583
+ // Price per store from the basepricing collection (negotiated price,
1584
+ // base price fallback). Step pricing picks the row whose store-count
1585
+ // range covers the latest billed store count. Clients without a
1586
+ // basepricing doc fall back to revenue / stores so the column never
1587
+ // lies silently.
1588
+ let latestStores = curStoresByClient.get( r.clientId ) ?? null;
1589
+ if ( latestStores == null ) {
1590
+ for ( let i = months.length - 1; i >= 0; i-- ) {
1591
+ if ( r.billedStoresMonths[months[i].key] ) {
1592
+ latestStores = r.billedStoresMonths[months[i].key];
1593
+ break;
1594
+ }
1595
+ }
1596
+ }
1597
+ let pricePerStore = null;
1598
+ const pdoc = pricingByClient.get( r.clientId );
1599
+ // Price/Store counts only subscribed products — and never the one-time
1600
+ // installationFee line, which isn't a per-store recurring price.
1601
+ const isLive = ( name ) => {
1602
+ const n = String( name || '' ).toLowerCase();
1603
+ return n !== 'installationfee' && r.liveProductSet.has( n );
1604
+ };
1605
+ if ( pdoc?.standard?.length ) {
1606
+ const liveRows = pdoc.standard.filter( ( p ) => isLive( p.productName ) );
1607
+ pricePerStore = liveRows.length ?
1608
+ liveRows.reduce( ( a, p ) => a + ( Number( p.negotiatePrice ) || Number( p.basePrice ) || 0 ), 0 ) :
1609
+ null;
1610
+ } else if ( pdoc?.step?.length ) {
1611
+ const byProduct = new Map();
1612
+ for ( const p of pdoc.step ) {
1613
+ if ( !isLive( p.productName ) ) {
1614
+ continue;
1615
+ }
1616
+ const key = p.productName || '';
1617
+ if ( !byProduct.has( key ) ) {
1618
+ byProduct.set( key, [] );
1619
+ }
1620
+ byProduct.get( key ).push( p );
1621
+ }
1622
+ let sum = 0;
1623
+ for ( const list of byProduct.values() ) {
1624
+ const hit = ( latestStores != null && list.find( ( x ) => rangeContains( x.storeRange, latestStores ) ) ) || list[0];
1625
+ sum += Number( hit?.negotiatePrice ) || Number( hit?.basePrice ) || 0;
1626
+ }
1627
+ pricePerStore = sum || null;
1628
+ }
1629
+ if ( pricePerStore == null ) {
1630
+ for ( let i = months.length - 1; i >= 0; i-- ) {
1631
+ const k = months[i].key;
1632
+ if ( r.revenueMonths[k] && r.billedStoresMonths[k] ) {
1633
+ // revenueMonths is INR — divide back for dollar clients so the
1634
+ // fallback price stays in their native currency.
1635
+ pricePerStore = r.revenueMonths[k] / r.billedStoresMonths[k] / ( r.currency === 'dollar' ? usdRate : 1 );
1636
+ break;
1637
+ }
1638
+ }
1639
+ }
1640
+ pricePerStore = pricePerStore == null ? null : Math.round( pricePerStore );
1641
+ // Current month's revenue: the invoice amount once generated; until
1642
+ // then an estimate of live store count x price per store.
1643
+ let revCur = r.revenueMonths[curKey] ?? null;
1644
+ let revCurEstimated = false;
1645
+ if ( revCur == null ) {
1646
+ const curStores = curStoresByClient.get( r.clientId );
1647
+ if ( curStores && pricePerStore != null ) {
1648
+ // Dollar clients carry a USD per-store price — convert the
1649
+ // projection to INR at today's rate.
1650
+ revCur = curStores * pricePerStore * ( r.currency === 'dollar' ? usdRate : 1 );
1651
+ revCurEstimated = true;
1652
+ }
1653
+ }
1654
+ let variance = null;
1655
+ if ( revCur != null && revPrev != null && revPrev > 0 ) {
1656
+ variance = Math.round( ( revCur - revPrev ) / revPrev * 100 );
1657
+ } else if ( revCur == null && revPrev != null ) {
1658
+ variance = -100;
1659
+ }
1660
+ // All revenue figures are already INR (dollar invoices converted at
1661
+ // aggregation time; the estimate converted above). Price/Store stays
1662
+ // in native USD for dollar clients.
1663
+ revCur = revCur == null ? null : Math.round( revCur );
1664
+ const revPrevOut = revPrev == null ? null : Math.round( revPrev );
1665
+ const installationOut = r.installationFee ? Math.round( r.installationFee ) : null;
1666
+ return {
1667
+ clientId: r.clientId,
1668
+ clientName: r.clientName || r.registeredEntity || r.clientId,
1669
+ registeredEntity: r.registeredEntity,
1670
+ status: r.status,
1671
+ products: r.products,
1672
+ csm: r.csm,
1673
+ currency: r.currency,
1674
+ stores: months.map( ( m, i ) => i === months.length - 1 ?
1675
+ ( curStoresByClient.get( r.clientId ) ?? r.billedStoresMonths[m.key] ?? null ) :
1676
+ ( r.billedStoresMonths[m.key] ?? null ) ),
1677
+ pricePerStore,
1678
+ revPrev: revPrevOut,
1679
+ revCur,
1680
+ revCurEstimated,
1681
+ variance,
1682
+ installationFee: installationOut,
1683
+ };
1684
+ } ).sort( ( a, b ) => ( b.revCur || 0 ) - ( a.revCur || 0 ) );
1685
+
1686
+ // Server-side filters (GET query or POST body). CSM / Product / Variance /
1687
+ // search narrow the per-client rows after they're computed, since those
1688
+ // fields are derived during the merge above.
1689
+ const f = { ...( req.query || {} ), ...( req.body || {} ) };
1690
+ const csm = f.csm && f.csm !== 'All' ? String( f.csm ) : '';
1691
+ const product = f.product && f.product !== 'All' ? String( f.product ) : '';
1692
+ const variance = f.variance && f.variance !== 'All' ? String( f.variance ) : '';
1693
+ const search = f.search ? String( f.search ).toLowerCase().trim() : '';
1694
+
1695
+ let filtered = data;
1696
+ if ( csm ) {
1697
+ filtered = filtered.filter( ( r ) => ( r.csm || '' ).split( ', ' ).includes( csm ) );
1698
+ }
1699
+ if ( product ) {
1700
+ filtered = filtered.filter( ( r ) => ( r.products || [] ).includes( product ) );
1701
+ }
1702
+ if ( variance === 'growth' ) {
1703
+ filtered = filtered.filter( ( r ) => r.variance > 0 );
1704
+ } else if ( variance === 'decline' ) {
1705
+ filtered = filtered.filter( ( r ) => r.variance < 0 );
1706
+ } else if ( variance === 'flat' ) {
1707
+ filtered = filtered.filter( ( r ) => r.variance === 0 );
1708
+ }
1709
+ if ( search ) {
1710
+ filtered = filtered.filter( ( r ) =>
1711
+ ( r.clientName || '' ).toLowerCase().includes( search ) ||
1712
+ ( r.registeredEntity || '' ).toLowerCase().includes( search ) ||
1713
+ String( r.clientId || '' ).includes( search ),
1714
+ );
1715
+ }
1716
+
1717
+ // Distinct option lists for the filter popover are derived from the FULL
1718
+ // result set (not the filtered slice) so the dropdowns don't shrink as
1719
+ // filters are applied.
1720
+ const csmOptions = [ ...new Set( data.flatMap( ( r ) => String( r.csm || '' ).split( ', ' ).filter( Boolean ) ) ) ].sort();
1721
+ const productOptions = [ ...new Set( data.flatMap( ( r ) => r.products || [] ) ) ].sort();
1722
+
1723
+ return res.sendSuccess( { months, data: filtered, total: data.length, csmOptions, productOptions, usdRate } );
1724
+ } catch ( error ) {
1725
+ logger.error( { error: error, function: 'billingSummary' } );
1726
+ return res.sendError( error, 500 );
1727
+ }
1728
+ }