tango-app-api-payment-subscription 3.5.4 → 3.5.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -4,6 +4,7 @@ import * as invoice from '../services/invoice.service.js';
4
4
  import { aggregatebilling, countDocuments, create, deleteOne, find, findOne, updateMany, updateOne } from '../services/billing.service.js';
5
5
  import * as invoiceService from '../services/invoice.service.js';
6
6
  import mongoose from 'mongoose';
7
+ import axios from 'axios';
7
8
  import dayjs from 'dayjs';
8
9
  import { leadGet } from '../services/lead.service.js';
9
10
  import { findOneClient } from '../services/clientPayment.services.js';
@@ -577,7 +578,6 @@ export const getInvoices = async ( req, res ) => {
577
578
  filterStartDate = new Date( dayjs().subtract( 3, 'month' ).startOf( 'month' ).format( 'YYYY-MM-DD' ) );
578
579
  filterEndDate = new Date( dayjs().endOf( 'month' ).format( 'YYYY-MM-DD' ) );
579
580
  }
580
-
581
581
  if ( req.body?.filter && !req.body?.searchValue ) {
582
582
  matchStage.$match['$and'] = [
583
583
  { billingDate: { $gte: filterStartDate } },
@@ -868,4 +868,145 @@ export async function getClientProducts( req, res ) {
868
868
  }
869
869
  }
870
870
 
871
+ // GSTIN address lookup via Sheet GST Check
872
+ // (https://sheet.gstincheck.co.in). Returns the registered company name
873
+ // + principal place of business address so the UI can auto-fill the
874
+ // billing-group form.
875
+ //
876
+ // Requires GST_LOOKUP_API_KEY in the API environment. The key is embedded
877
+ // in the provider's URL path (their auth scheme); we never log it.
878
+ //
879
+ // Caller validates GSTIN format (15 chars, alphanumeric). Server-side we
880
+ // re-check before forwarding to the provider.
881
+ export async function gstinLookup( req, res ) {
882
+ try {
883
+ const gstin = String( req.params.gstin || '' ).trim().toUpperCase();
884
+ if ( !/^[0-9A-Z]{15}$/.test( gstin ) ) {
885
+ return res.sendError( 'Invalid GSTIN format', 400 );
886
+ }
887
+
888
+ // GSTIN state-code prefix → State + Place of Supply (matches the
889
+ // table the frontend already maintains).
890
+ const stateMap = {
891
+ '01': 'Jammu and Kashmir', '02': 'Himachal Pradesh', '03': 'Punjab',
892
+ '04': 'Chandigarh', '05': 'Uttarakhand', '06': 'Haryana', '07': 'Delhi',
893
+ '08': 'Rajasthan', '09': 'Uttar Pradesh', '10': 'Bihar', '11': 'Sikkim',
894
+ '12': 'Arunachal Pradesh', '13': 'Nagaland', '14': 'Manipur',
895
+ '15': 'Mizoram', '16': 'Tripura', '17': 'Meghalaya', '18': 'Assam',
896
+ '19': 'West Bengal', '20': 'Jharkhand', '21': 'Odisha',
897
+ '22': 'Chhattisgarh', '23': 'Madhya Pradesh', '24': 'Gujarat',
898
+ '25': 'Daman and Diu', '27': 'Maharashtra', '29': 'Karnataka',
899
+ '30': 'Goa', '32': 'Kerala', '33': 'Tamil Nadu', '34': 'Puducherry',
900
+ '36': 'Telangana', '37': 'Andhra Pradesh',
901
+ };
902
+ const code = gstin.substring( 0, 2 );
903
+ const stateName = stateMap[code];
904
+ if ( !stateName ) {
905
+ return res.sendError( 'Unknown state code in GSTIN', 404 );
906
+ }
907
+
908
+ // Real lookup via Sheet GST Check (https://gstincheck.co.in/).
909
+ // Endpoint: GET https://sheet.gstincheck.co.in/check/{API_KEY}/{GSTIN}
910
+ // Auth: API key embedded in the URL path (no header). Set
911
+ // GST_LOOKUP_API_KEY in the API's environment.
912
+ const apiKey = process.env.GST_LOOKUP_API_KEY;
913
+ console.log( '🚀 ~ gstinLookup ~ apiKey:', apiKey );
914
+ if ( !apiKey ) {
915
+ // Fail explicitly rather than silently fall back — that way a missing
916
+ // key shows up immediately instead of mock data leaking into prod.
917
+ logger.error( { function: 'gstinLookup', message: 'GST_LOOKUP_API_KEY not set' } );
918
+ return res.sendError( 'GST lookup is not configured on the server', 500 );
919
+ }
920
+
921
+ let providerResponse;
922
+ try {
923
+ providerResponse = await axios.get(
924
+ `https://sheet.gstincheck.co.in/check/${encodeURIComponent( apiKey )}/${encodeURIComponent( gstin )}`,
925
+ { timeout: 8000 },
926
+ );
927
+ } catch ( axiosErr ) {
928
+ // Distinguish provider-down vs other errors so the client can show a
929
+ // useful message. 4xx from the provider is treated as a real error;
930
+ // network errors fall through to a generic 502.
931
+ const status = axiosErr?.response?.status;
932
+ logger.error( { error: axiosErr?.message, status, function: 'gstinLookup.provider', gstin } );
933
+ if ( status === 404 ) {
934
+ return res.sendError( 'GSTIN not found', 404 );
935
+ }
936
+ return res.sendError( 'GST lookup service unavailable', 502 );
937
+ }
938
+
939
+ const body = providerResponse?.data || {};
940
+ if ( !body.flag || !body.data ) {
941
+ // Provider reached but couldn't resolve this GSTIN.
942
+ return res.sendError( 'GSTIN not found', 404 );
943
+ }
944
+
945
+ // Map provider's response shape into the contract the frontend expects.
946
+ // Provider uses Indian-government short names (lgnm, tradeNam, pradr,
947
+ // sts, etc.); we normalize to our flat structure.
948
+ const src = body.data;
949
+ // TEMP: full raw response logged so we can confirm field names per
950
+ // GSTIN — remove once the mapping is verified across a few real cases.
951
+ logger.error( { function: 'gstinLookup.raw', gstin, providerData: src } );
952
+
953
+ const principal = src.pradr || src.pradr1 || {};
954
+ // The provider has used both `addr` (newer) and a flatter form
955
+ // (older). Coalesce — `addr` if present, otherwise the principal
956
+ // itself contains the address fields.
957
+ const addr = principal.addr || principal || {};
958
+
959
+ // If the provider returned a fully-formed display address string, use
960
+ // it directly for addressLineOne — that's the canonical comma-joined
961
+ // address GSTN itself displays. Otherwise build it from the
962
+ // individual fields. This avoids re-joining in a different order than
963
+ // the registry intended.
964
+ const fullAddr = ( principal.adr || principal.address || src.adr || '' ).trim();
965
+
966
+ let addressLineOne;
967
+ let addressLineTwo;
968
+ if ( fullAddr ) {
969
+ // The registry format is `bno, bnm, flno, st, loc, lndmrk, city,
970
+ // dst, stcd, pncd`. Split on commas, take the first half for line 1
971
+ // and the rest (minus city/state/pincode tail which goes to the
972
+ // separate fields) for line 2. Heuristic: split into two halves.
973
+ const parts = fullAddr.split( ',' ).map( ( s ) => s.trim() ).filter( Boolean );
974
+ const half = Math.ceil( parts.length / 2 );
975
+ addressLineOne = parts.slice( 0, half ).join( ', ' );
976
+ addressLineTwo = parts.slice( half ).join( ', ' );
977
+ } else {
978
+ const line1Parts = [ addr.bno, addr.bnm, addr.flno, addr.st ].filter( ( p ) => p && String( p ).trim() );
979
+ const line2Parts = [ addr.loc, addr.lndmrk, addr.dst ].filter( ( p ) => p && String( p ).trim() );
980
+ addressLineOne = line1Parts.join( ', ' );
981
+ addressLineTwo = line2Parts.join( ', ' );
982
+ }
983
+
984
+ // City: prefer explicit `city` / `dst`. Some records put the city
985
+ // name in `loc` and the colony / sub-area in `dst` — prefer city, but
986
+ // fall back to dst then loc when missing.
987
+ const cityValue = addr.city || addr.dst || addr.loc || '';
988
+
989
+ const data = {
990
+ gstin: src.gstin || gstin,
991
+ legalName: src.lgnm || src.legalName || '',
992
+ tradeName: src.tradeNam || src.tradeName || '',
993
+ addressLineOne,
994
+ addressLineTwo,
995
+ city: cityValue,
996
+ // Prefer provider's state name; fall back to our state-code map so a
997
+ // missing field doesn't blank out the Place of Supply.
998
+ state: addr.stcd || addr.state || stateName,
999
+ country: 'India',
1000
+ pinCode: addr.pncd ? String( addr.pncd ) : ( addr.pincode ? String( addr.pincode ) : '' ),
1001
+ placeOfSupply: `${addr.stcd || addr.state || stateName} (${code})`,
1002
+ status: src.sts || '',
1003
+ };
1004
+
1005
+ return res.sendSuccess( data );
1006
+ } catch ( error ) {
1007
+ logger.error( { error: error, function: 'gstinLookup' } );
1008
+ return res.sendError( error, 500 );
1009
+ }
1010
+ }
1011
+
871
1012
 
@@ -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';
@@ -339,6 +341,7 @@ export async function brandInvoiceList( req, res ) {
339
341
  totalAmount: 1,
340
342
  status: 1,
341
343
  paymentStatus: 1,
344
+ paidAmount: { $ifNull: [ '$paidAmount', 0 ] },
342
345
  clientId: 1,
343
346
  currency: 1,
344
347
  dueDate: 1,
@@ -1289,3 +1292,290 @@ export async function bulkUpdateBillingGroups( req, res ) {
1289
1292
  return res.sendError( error, 500 );
1290
1293
  }
1291
1294
  }
1295
+
1296
+ // ---------------------------------------------------------------------------
1297
+ // Billing Summary (landing tab): one row per client — monthly store counts,
1298
+ // revenue, price-per-store and installation fee all from the invoice
1299
+ // collection (the billed `stores` count per invoice), products + status from
1300
+ // clients, CSM from userAssignedStore. Window: last 5 calendar months
1301
+ // including the current. Filtering/sorting happen client-side (the dataset
1302
+ // is one row per client).
1303
+ // ---------------------------------------------------------------------------
1304
+ // Today's USD->INR rate for dollar-priced clients. Live rate (cached 6h)
1305
+ // with an env override (USD_INR_RATE) and a last-known/static fallback so
1306
+ // the summary never fails because a rate API is down.
1307
+ let usdRateCache = { rate: null, at: 0 };
1308
+ async function getUsdInrRate() {
1309
+ const override = Number( process.env.USD_INR_RATE );
1310
+ if ( override > 0 ) {
1311
+ return override;
1312
+ }
1313
+ if ( usdRateCache.rate && ( Date.now() - usdRateCache.at ) < 6 * 60 * 60 * 1000 ) {
1314
+ return usdRateCache.rate;
1315
+ }
1316
+ try {
1317
+ const resp = await fetch( 'https://open.er-api.com/v6/latest/USD', { signal: AbortSignal.timeout( 4000 ) } );
1318
+ const body = await resp.json();
1319
+ const rate = Number( body?.rates?.INR );
1320
+ if ( rate > 0 ) {
1321
+ usdRateCache = { rate, at: Date.now() };
1322
+ return rate;
1323
+ }
1324
+ } catch ( err ) {
1325
+ logger.error( { error: err, function: 'getUsdInrRate' } );
1326
+ }
1327
+ return usdRateCache.rate || 83.33;
1328
+ }
1329
+
1330
+ export async function billingSummary( req, res ) {
1331
+ try {
1332
+ const now = dayjs();
1333
+ const months = [];
1334
+ for ( let i = 4; i >= 0; i-- ) {
1335
+ const m = now.subtract( i, 'month' );
1336
+ months.push( { key: m.format( 'YYYY-MM' ), label: m.format( 'MMM-YY' ) } );
1337
+ }
1338
+ const windowStart = new Date( now.subtract( 4, 'month' ).startOf( 'month' ).toISOString() );
1339
+
1340
+ // Revenue (excl. GST), billed stores and installation fees per client per
1341
+ // month. billingDate is a string on some legacy rows — coerce first.
1342
+ const invoices = await invoiceService.aggregate( [
1343
+ { $addFields: { billingDateD: { $cond: [
1344
+ { $eq: [ { $type: '$billingDate' }, 'date' ] },
1345
+ '$billingDate',
1346
+ { $toDate: '$billingDate' },
1347
+ ] } } },
1348
+ { $match: { billingDateD: { $gte: windowStart } } },
1349
+ { $project: {
1350
+ clientId: 1,
1351
+ companyName: 1,
1352
+ amount: { $ifNull: [ '$amount', 0 ] },
1353
+ stores: { $ifNull: [ '$stores', 0 ] },
1354
+ isDollar: { $eq: [ '$currency', 'dollar' ] },
1355
+ ym: { $dateToString: { format: '%Y-%m', date: '$billingDateD' } },
1356
+ installation: { $sum: { $map: {
1357
+ input: { $filter: {
1358
+ input: { $ifNull: [ '$products', [] ] },
1359
+ cond: { $eq: [ '$$this.productName', 'installationFee' ] },
1360
+ } },
1361
+ in: { $ifNull: [ '$$this.amount', 0 ] },
1362
+ } } },
1363
+ } },
1364
+ // Dollar invoices are summed separately so they can be converted to
1365
+ // INR at today's rate when the months are merged below.
1366
+ { $group: {
1367
+ _id: { c: '$clientId', ym: '$ym' },
1368
+ revenueInr: { $sum: { $cond: [ '$isDollar', 0, '$amount' ] } },
1369
+ revenueUsd: { $sum: { $cond: [ '$isDollar', '$amount', 0 ] } },
1370
+ stores: { $sum: '$stores' },
1371
+ installationInr: { $sum: { $cond: [ '$isDollar', 0, '$installation' ] } },
1372
+ installationUsd: { $sum: { $cond: [ '$isDollar', '$installation', 0 ] } },
1373
+ companyName: { $last: '$companyName' },
1374
+ } },
1375
+ ] );
1376
+
1377
+ const clients = await clientService.find( {}, {
1378
+ 'clientId': 1,
1379
+ 'clientName': 1,
1380
+ 'planDetails.product.productName': 1,
1381
+ 'planDetails.product.status': 1,
1382
+ 'paymentInvoice.currencyType': 1,
1383
+ } );
1384
+ const clientById = new Map( clients.map( ( c ) => [ String( c.clientId ), c ] ) );
1385
+
1386
+ // Per-client CSM (userAssignedStore, tangoUserType 'csm'); display the
1387
+ // email's local part since the collection carries no display name.
1388
+ const usdRate = await getUsdInrRate();
1389
+
1390
+ // Current month's store count comes from dailyPricing (latest reading
1391
+ // this month) — invoices for the running month usually don't exist yet.
1392
+ const curMonthStart = new Date( now.startOf( 'month' ).toISOString() );
1393
+ const latestDp = await dailyPriceService.aggregate( [
1394
+ { $match: { dateISO: { $gte: curMonthStart } } },
1395
+ { $project: { clientId: 1, activeStores: 1, dateISO: 1 } },
1396
+ { $sort: { dateISO: 1 } },
1397
+ { $group: { _id: '$clientId', stores: { $last: '$activeStores' } } },
1398
+ ] );
1399
+ const curStoresByClient = new Map( latestDp.map( ( d ) => [ String( d._id ), d.stores || 0 ] ) );
1400
+
1401
+ // Negotiated price per store from the basepricing collection — standard
1402
+ // rows sum across products; step rows are resolved by store-count range.
1403
+ const pricingDocs = await basePriceService.find(
1404
+ { clientId: { $exists: true, $nin: [ '', null ] } },
1405
+ { clientId: 1, standard: 1, step: 1 },
1406
+ );
1407
+ const pricingByClient = new Map( pricingDocs.map( ( d ) => [ String( d.clientId ), d ] ) );
1408
+ const rangeContains = ( rangeStr, count ) => {
1409
+ const s = String( rangeStr || '' );
1410
+ const between = s.match( /(\d+)\s*-\s*(\d+)/ );
1411
+ if ( between ) {
1412
+ return count >= Number( between[1] ) && count <= Number( between[2] );
1413
+ }
1414
+ const plus = s.match( /(\d+)\s*\+/ );
1415
+ if ( plus ) {
1416
+ return count >= Number( plus[1] );
1417
+ }
1418
+ return false;
1419
+ };
1420
+
1421
+ const csms = await assignedStoreService.find( { tangoUserType: 'csm' }, { clientId: 1, userEmail: 1 } );
1422
+ const csmByClient = new Map();
1423
+ for ( const a of csms ) {
1424
+ const key = String( a.clientId ?? '' );
1425
+ const name = String( a.userEmail || '' ).split( '@' )[0];
1426
+ if ( !name ) {
1427
+ continue;
1428
+ }
1429
+ const pretty = name.charAt( 0 ).toUpperCase() + name.slice( 1 );
1430
+ if ( !csmByClient.has( key ) ) {
1431
+ csmByClient.set( key, new Set() );
1432
+ }
1433
+ csmByClient.get( key ).add( pretty );
1434
+ }
1435
+
1436
+ // Merge — a client appears when it has billed invoices in the window.
1437
+ const rows = new Map();
1438
+ const rowOf = ( clientId ) => {
1439
+ const key = String( clientId ?? '' );
1440
+ if ( !rows.has( key ) ) {
1441
+ const c = clientById.get( key );
1442
+ const products = ( c?.planDetails?.product || [] );
1443
+ rows.set( key, {
1444
+ clientId: key,
1445
+ clientName: c?.clientName || '',
1446
+ registeredEntity: '',
1447
+ status: products.length && products.every( ( p ) => p.status === 'trial' ) ? 'Trial' : 'Paid',
1448
+ products: [ ...new Set( products.map( ( p ) => String( p.productName || '' )
1449
+ .replace( /^tango/i, '' ).replace( /^./, ( ch ) => ch.toUpperCase() ) ).filter( Boolean ) ) ],
1450
+ // Price/Store only counts products the client actually subscribes
1451
+ // to (status 'live') — trials are excluded.
1452
+ liveProductSet: new Set( products.filter( ( p ) => p.status === 'live' )
1453
+ .map( ( p ) => String( p.productName || '' ).toLowerCase() ) ),
1454
+ currency: c?.paymentInvoice?.currencyType === 'dollar' ? 'dollar' : 'inr',
1455
+ csm: [ ...( csmByClient.get( key ) || [] ) ].join( ', ' ),
1456
+ revenueMonths: {},
1457
+ billedStoresMonths: {},
1458
+ installationFee: 0,
1459
+ } );
1460
+ }
1461
+ return rows.get( key );
1462
+ };
1463
+
1464
+ for ( const inv of invoices ) {
1465
+ const r = rowOf( inv._id.c );
1466
+ r.revenueMonths[inv._id.ym] = Math.round( ( ( inv.revenueInr || 0 ) + ( inv.revenueUsd || 0 ) * usdRate ) * 100 ) / 100;
1467
+ r.billedStoresMonths[inv._id.ym] = inv.stores || 0;
1468
+ r.installationFee += ( inv.installationInr || 0 ) + ( inv.installationUsd || 0 ) * usdRate;
1469
+ if ( inv.companyName ) {
1470
+ r.registeredEntity = inv.companyName;
1471
+ }
1472
+ }
1473
+
1474
+ const curKey = months[4].key;
1475
+ const prevKey = months[3].key;
1476
+ const data = [ ...rows.values() ].map( ( r ) => {
1477
+ const revPrev = r.revenueMonths[prevKey] ?? null;
1478
+ // Price per store from the basepricing collection (negotiated price,
1479
+ // base price fallback). Step pricing picks the row whose store-count
1480
+ // range covers the latest billed store count. Clients without a
1481
+ // basepricing doc fall back to revenue / stores so the column never
1482
+ // lies silently.
1483
+ let latestStores = curStoresByClient.get( r.clientId ) ?? null;
1484
+ if ( latestStores == null ) {
1485
+ for ( let i = months.length - 1; i >= 0; i-- ) {
1486
+ if ( r.billedStoresMonths[months[i].key] ) {
1487
+ latestStores = r.billedStoresMonths[months[i].key];
1488
+ break;
1489
+ }
1490
+ }
1491
+ }
1492
+ let pricePerStore = null;
1493
+ const pdoc = pricingByClient.get( r.clientId );
1494
+ const isLive = ( name ) => r.liveProductSet.has( String( name || '' ).toLowerCase() );
1495
+ if ( pdoc?.standard?.length ) {
1496
+ const liveRows = pdoc.standard.filter( ( p ) => isLive( p.productName ) );
1497
+ pricePerStore = liveRows.length ?
1498
+ liveRows.reduce( ( a, p ) => a + ( Number( p.negotiatePrice ) || Number( p.basePrice ) || 0 ), 0 ) :
1499
+ null;
1500
+ } else if ( pdoc?.step?.length ) {
1501
+ const byProduct = new Map();
1502
+ for ( const p of pdoc.step ) {
1503
+ if ( !isLive( p.productName ) ) {
1504
+ continue;
1505
+ }
1506
+ const key = p.productName || '';
1507
+ if ( !byProduct.has( key ) ) {
1508
+ byProduct.set( key, [] );
1509
+ }
1510
+ byProduct.get( key ).push( p );
1511
+ }
1512
+ let sum = 0;
1513
+ for ( const list of byProduct.values() ) {
1514
+ const hit = ( latestStores != null && list.find( ( x ) => rangeContains( x.storeRange, latestStores ) ) ) || list[0];
1515
+ sum += Number( hit?.negotiatePrice ) || Number( hit?.basePrice ) || 0;
1516
+ }
1517
+ pricePerStore = sum || null;
1518
+ }
1519
+ if ( pricePerStore == null ) {
1520
+ for ( let i = months.length - 1; i >= 0; i-- ) {
1521
+ const k = months[i].key;
1522
+ if ( r.revenueMonths[k] && r.billedStoresMonths[k] ) {
1523
+ // revenueMonths is INR — divide back for dollar clients so the
1524
+ // fallback price stays in their native currency.
1525
+ pricePerStore = r.revenueMonths[k] / r.billedStoresMonths[k] / ( r.currency === 'dollar' ? usdRate : 1 );
1526
+ break;
1527
+ }
1528
+ }
1529
+ }
1530
+ pricePerStore = pricePerStore == null ? null : Math.round( pricePerStore );
1531
+ // Current month's revenue: the invoice amount once generated; until
1532
+ // then an estimate of live store count x price per store.
1533
+ let revCur = r.revenueMonths[curKey] ?? null;
1534
+ let revCurEstimated = false;
1535
+ if ( revCur == null ) {
1536
+ const curStores = curStoresByClient.get( r.clientId );
1537
+ if ( curStores && pricePerStore != null ) {
1538
+ // Dollar clients carry a USD per-store price — convert the
1539
+ // projection to INR at today's rate.
1540
+ revCur = curStores * pricePerStore * ( r.currency === 'dollar' ? usdRate : 1 );
1541
+ revCurEstimated = true;
1542
+ }
1543
+ }
1544
+ let variance = null;
1545
+ if ( revCur != null && revPrev != null && revPrev > 0 ) {
1546
+ variance = Math.round( ( revCur - revPrev ) / revPrev * 100 );
1547
+ } else if ( revCur == null && revPrev != null ) {
1548
+ variance = -100;
1549
+ }
1550
+ // All revenue figures are already INR (dollar invoices converted at
1551
+ // aggregation time; the estimate converted above). Price/Store stays
1552
+ // in native USD for dollar clients.
1553
+ revCur = revCur == null ? null : Math.round( revCur );
1554
+ const revPrevOut = revPrev == null ? null : Math.round( revPrev );
1555
+ const installationOut = r.installationFee ? Math.round( r.installationFee ) : null;
1556
+ return {
1557
+ clientId: r.clientId,
1558
+ clientName: r.clientName || r.registeredEntity || r.clientId,
1559
+ registeredEntity: r.registeredEntity,
1560
+ status: r.status,
1561
+ products: r.products,
1562
+ csm: r.csm,
1563
+ currency: r.currency,
1564
+ stores: months.map( ( m, i ) => i === months.length - 1 ?
1565
+ ( curStoresByClient.get( r.clientId ) ?? r.billedStoresMonths[m.key] ?? null ) :
1566
+ ( r.billedStoresMonths[m.key] ?? null ) ),
1567
+ pricePerStore,
1568
+ revPrev: revPrevOut,
1569
+ revCur,
1570
+ revCurEstimated,
1571
+ variance,
1572
+ installationFee: installationOut,
1573
+ };
1574
+ } ).sort( ( a, b ) => ( b.revCur || 0 ) - ( a.revCur || 0 ) );
1575
+
1576
+ return res.sendSuccess( { months, data, usdRate } );
1577
+ } catch ( error ) {
1578
+ logger.error( { error: error, function: 'billingSummary' } );
1579
+ return res.sendError( error, 500 );
1580
+ }
1581
+ }