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.
- package/package.json +2 -2
- package/src/controllers/bankTransaction.controller.js +617 -0
- package/src/controllers/brandsBilling.controller.js +443 -7
- package/src/controllers/estimate.controller.js +317 -0
- package/src/controllers/invoice.controller.js +172 -260
- package/src/controllers/paymentReminder.controller.js +81 -0
- package/src/controllers/paymentSubscription.controllers.js +55 -3
- package/src/dtos/validation.dtos.js +6 -0
- package/src/hbs/estimatePdf.hbs +125 -0
- package/src/hbs/invoicePdf.hbs +27 -0
- package/src/routes/billing.routes.js +5 -0
- package/src/routes/brandsBilling.routes.js +3 -1
- package/src/routes/invoice.routes.js +18 -0
- package/src/services/bankTransaction.service.js +21 -0
- package/src/services/estimate.service.js +25 -0
- package/src/services/paymentReminder.service.js +9 -0
|
@@ -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
|
-
|
|
166
|
-
|
|
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
|
|
171
|
-
active:
|
|
172
|
-
|
|
173
|
-
|
|
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
|
+
}
|