tango-app-api-payment-subscription 3.5.15 → 3.5.17
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/brandsBilling.controller.js +360 -0
- package/src/controllers/invoice.controller.js +212 -267
- package/src/controllers/paymentSubscription.controllers.js +183 -18
- package/src/dtos/validation.dtos.js +7 -0
- package/src/routes/brandsBilling.routes.js +2 -1
- package/src/routes/paymentSubscription.routes.js +12 -0
- package/src/services/clientPayment.services.js +5 -0
|
@@ -286,6 +286,61 @@ export async function createInvoice( req, res ) {
|
|
|
286
286
|
products = expanded;
|
|
287
287
|
}
|
|
288
288
|
|
|
289
|
+
// One-time onboarding fee: stores in THIS billing group whose first file
|
|
290
|
+
// date falls in the invoice's billing month, charged a flat
|
|
291
|
+
// oneTimeFeePerStore (from the client's basepricing). Added AFTER the
|
|
292
|
+
// multi-month expansion so it's billed ONCE per invoice (not per month).
|
|
293
|
+
// It's a normal line item, so it lands in the taxable subtotal below.
|
|
294
|
+
try {
|
|
295
|
+
// Use the group's basepricing doc when group-wise pricing applies,
|
|
296
|
+
// else the brand-level doc.
|
|
297
|
+
const { query: oneTimeQuery } = await resolveBasePricingScope( group, getClient );
|
|
298
|
+
const oneTimeBp = await basepricingService.findOne(
|
|
299
|
+
oneTimeQuery,
|
|
300
|
+
{ oneTimeFeePerStore: 1 },
|
|
301
|
+
);
|
|
302
|
+
const oneTimeFeePerStore = Number( oneTimeBp?.oneTimeFeePerStore ) || 0;
|
|
303
|
+
if ( oneTimeFeePerStore > 0 && Array.isArray( group.stores ) && group.stores.length ) {
|
|
304
|
+
const monthStart = new Date( baseDate.startOf( 'month' ).toISOString() );
|
|
305
|
+
const monthEnd = new Date( baseDate.endOf( 'month' ).toISOString() );
|
|
306
|
+
// Distinct group stores whose first file date is within the billing
|
|
307
|
+
// month. First file = edgefirstFileDate, fallback processfirstFileDate.
|
|
308
|
+
const newStoreAgg = await dailyPricingService.aggregate( [
|
|
309
|
+
{ $match: { clientId: group.clientId } },
|
|
310
|
+
{ $sort: { dateISO: -1 } },
|
|
311
|
+
{ $limit: 1 },
|
|
312
|
+
{ $project: { stores: { $filter: {
|
|
313
|
+
input: '$stores', as: 'item', cond: { $in: [ '$$item.storeId', group.stores ] },
|
|
314
|
+
} } } },
|
|
315
|
+
{ $unwind: '$stores' },
|
|
316
|
+
{ $addFields: { ff: {
|
|
317
|
+
$convert: {
|
|
318
|
+
input: { $ifNull: [ '$stores.edgefirstFileDate', '$stores.processfirstFileDate' ] },
|
|
319
|
+
to: 'date', onError: null, onNull: null,
|
|
320
|
+
},
|
|
321
|
+
} } },
|
|
322
|
+
{ $match: { ff: { $ne: null, $gte: monthStart, $lte: monthEnd } } },
|
|
323
|
+
{ $group: { _id: '$stores.storeId' } },
|
|
324
|
+
{ $count: 'newStores' },
|
|
325
|
+
] );
|
|
326
|
+
const newStoreCount = newStoreAgg?.[0]?.newStores || 0;
|
|
327
|
+
if ( newStoreCount > 0 ) {
|
|
328
|
+
products.push( {
|
|
329
|
+
productName: 'oneTimeFee',
|
|
330
|
+
period: 'fullmonth',
|
|
331
|
+
storeCount: newStoreCount,
|
|
332
|
+
amount: Math.round( newStoreCount * oneTimeFeePerStore * 100 ) / 100,
|
|
333
|
+
price: oneTimeFeePerStore,
|
|
334
|
+
description: `One-Time Fee - ${newStoreCount} stores`,
|
|
335
|
+
HsnNumber: '998314',
|
|
336
|
+
month: baseDate.format( 'MMM YYYY' ),
|
|
337
|
+
} );
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
} catch ( oneTimeErr ) {
|
|
341
|
+
logger.error( { error: oneTimeErr, function: 'createInvoice.oneTimeFee', clientId: group.clientId } );
|
|
342
|
+
}
|
|
343
|
+
|
|
289
344
|
let amount = products.reduce( ( sum, product ) => sum + product.amount, 0 );
|
|
290
345
|
let taxList = [];
|
|
291
346
|
let totalAmount = 0;
|
|
@@ -474,7 +529,7 @@ async function buildAnnexureRows( invoiceInfo, getgroup ) {
|
|
|
474
529
|
// converting here would make the annexure unit price disagree with the actual
|
|
475
530
|
// billed amount (e.g. a $45 negotiated price wrongly shown as $0.48).
|
|
476
531
|
|
|
477
|
-
const annexClient = await clientService.findOne( { clientId: invoiceInfo.clientId }, { 'planDetails.product': 1, 'priceType': 1 } );
|
|
532
|
+
const annexClient = await clientService.findOne( { clientId: invoiceInfo.clientId }, { 'planDetails.product': 1, 'priceType': 1, 'billingGroupWisePricing': 1 } );
|
|
478
533
|
const billingTypeMap = {};
|
|
479
534
|
( annexClient?.planDetails?.product || [] ).forEach( ( p ) => {
|
|
480
535
|
billingTypeMap[p.productName] = p.billingType || 'perStore';
|
|
@@ -486,7 +541,18 @@ async function buildAnnexureRows( invoiceInfo, getgroup ) {
|
|
|
486
541
|
// appear once per tier (e.g. the same store listed at $45 and again at $40).
|
|
487
542
|
// Instead we pull the tiers here and assign each store a single tier price
|
|
488
543
|
// below, in JS, by its 1-based index within the product.
|
|
489
|
-
|
|
544
|
+
// Group-wise pricing: read the billing group's own doc when enabled and one
|
|
545
|
+
// exists; otherwise the brand-level doc.
|
|
546
|
+
let pricingDocQuery = { clientId: invoiceInfo.clientId, groupId: { $exists: false } };
|
|
547
|
+
if ( annexClient?.billingGroupWisePricing && getgroup?._id ) {
|
|
548
|
+
const grpDoc = await basepricingService.findOne(
|
|
549
|
+
{ clientId: invoiceInfo.clientId, groupId: String( getgroup._id ) }, { _id: 1 },
|
|
550
|
+
);
|
|
551
|
+
if ( grpDoc ) {
|
|
552
|
+
pricingDocQuery = { clientId: invoiceInfo.clientId, groupId: String( getgroup._id ) };
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
const pricingDoc = await basepricingService.findOne( pricingDocQuery, { standard: 1, step: 1 } );
|
|
490
556
|
const isStep = annexClient?.priceType === 'step';
|
|
491
557
|
const tiersByProduct = {};
|
|
492
558
|
( ( isStep ? pricingDoc?.step : pricingDoc?.standard ) || [] ).forEach( ( p ) => {
|
|
@@ -1028,6 +1094,25 @@ function inWords( num ) {
|
|
|
1028
1094
|
}
|
|
1029
1095
|
|
|
1030
1096
|
|
|
1097
|
+
// Resolve which basepricing doc applies to a billing group. When the client
|
|
1098
|
+
// has billingGroupWisePricing enabled AND a doc exists for this group, returns
|
|
1099
|
+
// that group's doc; otherwise falls back to the brand-level doc (groupId unset).
|
|
1100
|
+
// Returns { query, groupId } where query is the mongo filter for the chosen doc
|
|
1101
|
+
// and groupId is the group id to use in aggregation lookups (or null for brand).
|
|
1102
|
+
async function resolveBasePricingScope( group, getClient ) {
|
|
1103
|
+
const brandQuery = { clientId: group.clientId, groupId: { $exists: false } };
|
|
1104
|
+
if ( getClient?.billingGroupWisePricing && group?._id ) {
|
|
1105
|
+
const groupIdStr = String( group._id );
|
|
1106
|
+
const groupDoc = await basepricingService.findOne(
|
|
1107
|
+
{ clientId: group.clientId, groupId: groupIdStr }, { _id: 1 },
|
|
1108
|
+
);
|
|
1109
|
+
if ( groupDoc ) {
|
|
1110
|
+
return { query: { clientId: group.clientId, groupId: groupIdStr }, groupId: groupIdStr };
|
|
1111
|
+
}
|
|
1112
|
+
}
|
|
1113
|
+
return { query: brandQuery, groupId: null };
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1031
1116
|
async function standardPrice( group, getClient, baseDate ) {
|
|
1032
1117
|
console.log( '🚀 ~ standardPrice ~ baseDate:', baseDate.format( 'MMM YYYY' ) );
|
|
1033
1118
|
const currentMonthDays = dayjs().daysInMonth();
|
|
@@ -1036,6 +1121,9 @@ async function standardPrice( group, getClient, baseDate ) {
|
|
|
1036
1121
|
// Computed once so the aggregation pipelines can inline a $literal.
|
|
1037
1122
|
const isFlatPricing = group.proRata === 'flat';
|
|
1038
1123
|
console.log( '🚀 ~ standardPrice ~ isFlatPricing:', isFlatPricing );
|
|
1124
|
+
// Which basepricing doc applies (group-wise vs brand-level). pricingGroupId is
|
|
1125
|
+
// used in the $lookup pipeline so the join picks the right doc.
|
|
1126
|
+
const { groupId: pricingGroupId } = await resolveBasePricingScope( group, getClient );
|
|
1039
1127
|
let billingTypeMap = {};
|
|
1040
1128
|
if ( getClient?.planDetails?.product ) {
|
|
1041
1129
|
getClient.planDetails.product.forEach( ( p ) => {
|
|
@@ -1172,7 +1260,14 @@ async function standardPrice( group, getClient, baseDate ) {
|
|
|
1172
1260
|
{
|
|
1173
1261
|
$match: {
|
|
1174
1262
|
$expr: {
|
|
1175
|
-
$
|
|
1263
|
+
$and: [
|
|
1264
|
+
{ $eq: [ '$clientId', '$$clientId' ] },
|
|
1265
|
+
// Match the resolved doc: a specific group's doc when group-wise
|
|
1266
|
+
// pricing applies, else the brand-level doc (no groupId).
|
|
1267
|
+
pricingGroupId ?
|
|
1268
|
+
{ $eq: [ '$groupId', pricingGroupId ] } :
|
|
1269
|
+
{ $not: [ { $ifNull: [ '$groupId', false ] } ] },
|
|
1270
|
+
],
|
|
1176
1271
|
},
|
|
1177
1272
|
},
|
|
1178
1273
|
},
|
|
@@ -1367,7 +1462,12 @@ async function standardPrice( group, getClient, baseDate ) {
|
|
|
1367
1462
|
from: 'basepricings',
|
|
1368
1463
|
let: { clientId: group.clientId },
|
|
1369
1464
|
pipeline: [
|
|
1370
|
-
{ $match: { $expr: { $
|
|
1465
|
+
{ $match: { $expr: { $and: [
|
|
1466
|
+
{ $eq: [ '$clientId', '$$clientId' ] },
|
|
1467
|
+
pricingGroupId ?
|
|
1468
|
+
{ $eq: [ '$groupId', pricingGroupId ] } :
|
|
1469
|
+
{ $not: [ { $ifNull: [ '$groupId', false ] } ] },
|
|
1470
|
+
] } } },
|
|
1371
1471
|
{ $project: { standard: 1 } },
|
|
1372
1472
|
],
|
|
1373
1473
|
as: 'basepricing',
|
|
@@ -1475,140 +1575,48 @@ async function stepPrice( group, getClient ) {
|
|
|
1475
1575
|
// 'flat' => every store billed for full month.
|
|
1476
1576
|
// 'prorate' => actual working days. See standardPrice for the same flag.
|
|
1477
1577
|
const isFlatPricing = group.proRata === 'flat';
|
|
1578
|
+
// Which basepricing doc applies (group-wise vs brand-level).
|
|
1579
|
+
const { query: pricingDocQuery, groupId: pricingGroupId } = await resolveBasePricingScope( group, getClient );
|
|
1478
1580
|
let billingTypeMap = {};
|
|
1479
1581
|
if ( getClient?.planDetails?.product ) {
|
|
1480
1582
|
getClient.planDetails.product.forEach( ( p ) => {
|
|
1481
1583
|
billingTypeMap[p.productName] = p.billingType || 'perStore';
|
|
1482
1584
|
} );
|
|
1483
1585
|
}
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
},
|
|
1490
|
-
{
|
|
1491
|
-
$sort: { dateISO: -1 },
|
|
1492
|
-
},
|
|
1586
|
+
// PER-STORE rows (not bucketed) so step tiers can be assigned to individual
|
|
1587
|
+
// stores and each store charged tierPrice x its EXACT camera/zone count.
|
|
1588
|
+
let perStoreRows = await dailyPricingService.aggregate( [
|
|
1589
|
+
{ $match: { clientId: group.clientId } },
|
|
1590
|
+
{ $sort: { dateISO: -1 } },
|
|
1493
1591
|
{ $limit: 1 },
|
|
1494
|
-
{
|
|
1495
|
-
$
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
$
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
},
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
storeStatus: '$stores.status',
|
|
1523
|
-
zoneCount: { $ifNull: [ '$stores.zoneCount', 0 ] },
|
|
1524
|
-
cameraCount: { $ifNull: [ '$stores.cameraCount', 0 ] },
|
|
1525
|
-
// Pull per-store camera splits; same fix as standardPrice — without
|
|
1526
|
-
// these the second $group sums missing fields and the perCamera
|
|
1527
|
-
// branch in the downstream map silently falls back to perStore.
|
|
1528
|
-
trafficCameraCount: { $ifNull: [ '$stores.trafficCameraCount', 0 ] },
|
|
1529
|
-
zoneCameraCount: { $ifNull: [ '$stores.zoneCameraCount', 0 ] },
|
|
1530
|
-
},
|
|
1531
|
-
},
|
|
1532
|
-
{
|
|
1533
|
-
$match: {
|
|
1534
|
-
workingDays: { $gt: 0 },
|
|
1535
|
-
},
|
|
1536
|
-
},
|
|
1537
|
-
{
|
|
1538
|
-
$project: {
|
|
1539
|
-
productName: 1,
|
|
1540
|
-
storeId: 1,
|
|
1541
|
-
// Group-level flag baked into every doc; consumed by the next
|
|
1542
|
-
// $project's $cond.
|
|
1543
|
-
isFlatPricing: { $literal: isFlatPricing },
|
|
1544
|
-
workingDays: 1,
|
|
1545
|
-
storeStatus: 1,
|
|
1546
|
-
zoneCount: 1,
|
|
1547
|
-
cameraCount: 1,
|
|
1548
|
-
trafficCameraCount: 1,
|
|
1549
|
-
zoneCameraCount: 1,
|
|
1550
|
-
},
|
|
1551
|
-
},
|
|
1552
|
-
{
|
|
1553
|
-
$project: {
|
|
1554
|
-
productName: 1,
|
|
1555
|
-
storeId: 1,
|
|
1556
|
-
// Flat => full month per store. Prorate => actual working days.
|
|
1557
|
-
workingDays: {
|
|
1558
|
-
$cond: { if: '$isFlatPricing', then: currentMonthDays, else: '$workingDays' },
|
|
1559
|
-
},
|
|
1560
|
-
storeStatus: 1,
|
|
1561
|
-
zoneCount: 1,
|
|
1562
|
-
cameraCount: 1,
|
|
1563
|
-
trafficCameraCount: 1,
|
|
1564
|
-
zoneCameraCount: 1,
|
|
1565
|
-
},
|
|
1566
|
-
}, {
|
|
1567
|
-
$group: {
|
|
1568
|
-
_id: {
|
|
1569
|
-
productName: '$productName',
|
|
1570
|
-
storeId: '$storeId',
|
|
1571
|
-
},
|
|
1572
|
-
workingdays: { $first: '$workingDays' },
|
|
1573
|
-
zoneCount: { $first: '$zoneCount' },
|
|
1574
|
-
cameraCount: { $first: '$cameraCount' },
|
|
1575
|
-
trafficCameraCount: { $first: '$trafficCameraCount' },
|
|
1576
|
-
zoneCameraCount: { $first: '$zoneCameraCount' },
|
|
1577
|
-
},
|
|
1578
|
-
},
|
|
1579
|
-
{
|
|
1580
|
-
$group: {
|
|
1581
|
-
_id: {
|
|
1582
|
-
productName: '$_id.productName',
|
|
1583
|
-
workingdays: '$workingdays',
|
|
1584
|
-
},
|
|
1585
|
-
storeCount: { $sum: 1 },
|
|
1586
|
-
totalZoneCount: { $sum: '$zoneCount' },
|
|
1587
|
-
totalTrafficCameraCount: { $sum: '$trafficCameraCount' },
|
|
1588
|
-
totalZoneCameraCount: { $sum: '$zoneCameraCount' },
|
|
1589
|
-
},
|
|
1590
|
-
},
|
|
1591
|
-
{
|
|
1592
|
-
$project: {
|
|
1593
|
-
_id: 0,
|
|
1594
|
-
productName: '$_id.productName',
|
|
1595
|
-
workingdays: '$_id.workingdays',
|
|
1596
|
-
storeCount: '$storeCount',
|
|
1597
|
-
totalZoneCount: '$totalZoneCount',
|
|
1598
|
-
totalTrafficCameraCount: '$totalTrafficCameraCount',
|
|
1599
|
-
totalZoneCameraCount: '$totalZoneCameraCount',
|
|
1600
|
-
},
|
|
1601
|
-
},
|
|
1602
|
-
{
|
|
1603
|
-
// productName first so order is deterministic across views/PDF, then
|
|
1604
|
-
// workingdays so step rows stay grouped consistently.
|
|
1605
|
-
$sort: {
|
|
1606
|
-
productName: 1,
|
|
1607
|
-
workingdays: -1,
|
|
1608
|
-
},
|
|
1609
|
-
},
|
|
1610
|
-
|
|
1611
|
-
|
|
1592
|
+
{ $project: { stores: { $filter: {
|
|
1593
|
+
input: '$stores', as: 'item', cond: { $in: [ '$$item.storeId', group.stores ] },
|
|
1594
|
+
} } } },
|
|
1595
|
+
{ $unwind: { path: '$stores', preserveNullAndEmptyArrays: false } },
|
|
1596
|
+
{ $unwind: { path: '$stores.products', preserveNullAndEmptyArrays: false } },
|
|
1597
|
+
{ $project: {
|
|
1598
|
+
productName: '$stores.products.productName',
|
|
1599
|
+
storeId: '$stores.storeId',
|
|
1600
|
+
storeName: '$stores.storeName',
|
|
1601
|
+
workingDays: '$stores.products.workingdays',
|
|
1602
|
+
zoneCount: { $ifNull: [ '$stores.zoneCount', 0 ] },
|
|
1603
|
+
trafficCameraCount: { $ifNull: [ '$stores.trafficCameraCount', 0 ] },
|
|
1604
|
+
zoneCameraCount: { $ifNull: [ '$stores.zoneCameraCount', 0 ] },
|
|
1605
|
+
} },
|
|
1606
|
+
{ $match: { workingDays: { $gt: 0 } } },
|
|
1607
|
+
// Flat pricing => bill every store for the full month.
|
|
1608
|
+
{ $project: {
|
|
1609
|
+
_id: 0,
|
|
1610
|
+
productName: 1,
|
|
1611
|
+
storeId: 1,
|
|
1612
|
+
storeName: 1,
|
|
1613
|
+
workingDays: { $cond: { if: { $literal: isFlatPricing }, then: currentMonthDays, else: '$workingDays' } },
|
|
1614
|
+
zoneCount: 1,
|
|
1615
|
+
trafficCameraCount: 1,
|
|
1616
|
+
zoneCameraCount: 1,
|
|
1617
|
+
} },
|
|
1618
|
+
// Deterministic order so per-store tier assignment is stable run to run.
|
|
1619
|
+
{ $sort: { productName: 1, storeId: 1 } },
|
|
1612
1620
|
] );
|
|
1613
1621
|
// Build billingMethod map from group.products
|
|
1614
1622
|
let billingMethodMap = {};
|
|
@@ -1670,7 +1678,12 @@ async function stepPrice( group, getClient ) {
|
|
|
1670
1678
|
from: 'basepricings',
|
|
1671
1679
|
let: { clientId: group.clientId },
|
|
1672
1680
|
pipeline: [
|
|
1673
|
-
{ $match: { $expr: { $
|
|
1681
|
+
{ $match: { $expr: { $and: [
|
|
1682
|
+
{ $eq: [ '$clientId', '$$clientId' ] },
|
|
1683
|
+
pricingGroupId ?
|
|
1684
|
+
{ $eq: [ '$groupId', pricingGroupId ] } :
|
|
1685
|
+
{ $not: [ { $ifNull: [ '$groupId', false ] } ] },
|
|
1686
|
+
] } } },
|
|
1674
1687
|
{ $project: { step: 1 } },
|
|
1675
1688
|
],
|
|
1676
1689
|
as: 'basepricing',
|
|
@@ -1679,7 +1692,7 @@ async function stepPrice( group, getClient ) {
|
|
|
1679
1692
|
{ $unwind: { path: '$basepricing', preserveNullAndEmptyArrays: true } },
|
|
1680
1693
|
] );
|
|
1681
1694
|
|
|
1682
|
-
let stepPriceData = await basepricingService.findOne(
|
|
1695
|
+
let stepPriceData = await basepricingService.findOne( pricingDocQuery );
|
|
1683
1696
|
let pricingRanges = stepPriceData?.step || [];
|
|
1684
1697
|
let defaultPrice = pricingRanges.length > 0 ? pricingRanges[0].negotiatePrice : 0;
|
|
1685
1698
|
|
|
@@ -1721,168 +1734,100 @@ async function stepPrice( group, getClient ) {
|
|
|
1721
1734
|
}
|
|
1722
1735
|
}
|
|
1723
1736
|
|
|
1724
|
-
//
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
// Adjust storeCount based on billingType for tangoZone and tangoTraffic (overallStore only)
|
|
1728
|
-
products = products.map( ( product ) => {
|
|
1729
|
-
let productBillingType = billingTypeMap[product.productName] || 'perStore';
|
|
1730
|
-
if ( product.productName === 'tangoZone' ) {
|
|
1731
|
-
if ( productBillingType === 'perZone' && product.totalZoneCount > 0 ) {
|
|
1732
|
-
product.storeCount = product.totalZoneCount;
|
|
1733
|
-
} else if ( productBillingType === 'perCamera' && product.totalZoneCameraCount > 0 ) {
|
|
1734
|
-
product.storeCount = product.totalZoneCameraCount;
|
|
1735
|
-
}
|
|
1736
|
-
} else if ( product.productName === 'tangoTraffic' ) {
|
|
1737
|
-
if ( productBillingType === 'perCamera' && product.totalTrafficCameraCount > 0 ) {
|
|
1738
|
-
product.storeCount = product.totalTrafficCameraCount;
|
|
1739
|
-
}
|
|
1740
|
-
}
|
|
1741
|
-
delete product.totalZoneCount;
|
|
1742
|
-
delete product.totalTrafficCameraCount;
|
|
1743
|
-
delete product.totalZoneCameraCount;
|
|
1744
|
-
return product;
|
|
1745
|
-
} );
|
|
1737
|
+
// Drop eachStore products — those are handled by the per-store branch above.
|
|
1738
|
+
perStoreRows = perStoreRows.filter( ( p ) => !eachStoreProductNames.includes( p.productName ) );
|
|
1746
1739
|
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
// processArray to walk them low-to-high.
|
|
1751
|
-
let pricing = ( stepPriceRecord.step || [] ).slice().sort( ( a, b ) => {
|
|
1740
|
+
const stepPriceRecord = await basepricingService.findOne( pricingDocQuery );
|
|
1741
|
+
// Tiers ordered by range start so per-store tier assignment walks low-to-high.
|
|
1742
|
+
const pricing = ( stepPriceRecord?.step || [] ).slice().sort( ( a, b ) => {
|
|
1752
1743
|
const aStart = parseInt( String( a.storeRange || '0' ).split( '-' )[0], 10 ) || 0;
|
|
1753
1744
|
const bStart = parseInt( String( b.storeRange || '0' ).split( '-' )[0], 10 ) || 0;
|
|
1754
1745
|
return aStart - bStart;
|
|
1755
1746
|
} );
|
|
1756
|
-
|
|
1757
|
-
const
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
|
|
1768
|
-
|
|
1747
|
+
// Tiers grouped per product (only one product set here, but keep it general).
|
|
1748
|
+
const tiersByProduct = {};
|
|
1749
|
+
for ( const t of pricing ) {
|
|
1750
|
+
if ( !tiersByProduct[t.productName] ) {
|
|
1751
|
+
tiersByProduct[t.productName] = [];
|
|
1752
|
+
}
|
|
1753
|
+
tiersByProduct[t.productName].push( t );
|
|
1754
|
+
}
|
|
1755
|
+
// Resolve the tier price for a store's 1-based position within its product.
|
|
1756
|
+
const tierPriceForPosition = ( productName, position ) => {
|
|
1757
|
+
const tiers = tiersByProduct[productName] || [];
|
|
1758
|
+
if ( !tiers.length ) {
|
|
1759
|
+
return 0;
|
|
1760
|
+
}
|
|
1761
|
+
for ( const t of tiers ) {
|
|
1762
|
+
const [ min, max ] = String( t.storeRange || '' ).split( '-' ).map( Number );
|
|
1763
|
+
if ( position >= min && position <= max ) {
|
|
1764
|
+
return Number( t.negotiatePrice ) || 0;
|
|
1769
1765
|
}
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
negotiatePrice: item.price,
|
|
1774
|
-
perstorecost: ( ( item.price / currentMonthDays ) * item.workingdays ).toFixed( 2 ),
|
|
1775
|
-
};
|
|
1776
|
-
} );
|
|
1766
|
+
}
|
|
1767
|
+
// Beyond the last defined tier -> last tier's price.
|
|
1768
|
+
return Number( tiers[tiers.length - 1].negotiatePrice ) || 0;
|
|
1777
1769
|
};
|
|
1778
|
-
console.log( '---->', data, pricing );
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
function processArray( array1, array2 ) {
|
|
1782
|
-
let updatedArray = [];
|
|
1783
1770
|
|
|
1784
|
-
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
break;
|
|
1800
|
-
}
|
|
1801
|
-
// How many of this tier's slots are still free given everything already
|
|
1802
|
-
// assigned to this product across previous buckets.
|
|
1803
|
-
const tierCapacityLeft = max - Math.max( min - 1, assigned );
|
|
1804
|
-
if ( tierCapacityLeft <= 0 ) {
|
|
1805
|
-
continue;
|
|
1806
|
-
}
|
|
1807
|
-
const applicableStores = Math.min( remainingStores, tierCapacityLeft );
|
|
1808
|
-
updatedArray.push( {
|
|
1809
|
-
productName: item.productName,
|
|
1810
|
-
workingdays: item.workingdays,
|
|
1811
|
-
storeCount: applicableStores,
|
|
1812
|
-
// Each tier is charged at its own range's negotiated price.
|
|
1813
|
-
price: range.negotiatePrice,
|
|
1814
|
-
} );
|
|
1815
|
-
remainingStores -= applicableStores;
|
|
1816
|
-
assigned += applicableStores;
|
|
1771
|
+
// EXACT per-store pricing: assign each store a 1-based position within its
|
|
1772
|
+
// product (tier boundary counted in STORES), then charge tierPrice x that
|
|
1773
|
+
// store's EXACT billable units — cameras (perCamera) / zones (perZone) / 1
|
|
1774
|
+
// (perStore) — prorated by working days. Group lines by product + period +
|
|
1775
|
+
// tier price so each tier collapses into a single line item.
|
|
1776
|
+
const productPosition = {};
|
|
1777
|
+
const grouped = {};
|
|
1778
|
+
for ( const s of perStoreRows ) {
|
|
1779
|
+
const billingType = billingTypeMap[s.productName] || 'perStore';
|
|
1780
|
+
let units = 1;
|
|
1781
|
+
if ( s.productName === 'tangoZone' ) {
|
|
1782
|
+
if ( billingType === 'perZone' && s.zoneCount > 0 ) {
|
|
1783
|
+
units = s.zoneCount;
|
|
1784
|
+
} else if ( billingType === 'perCamera' && s.zoneCameraCount > 0 ) {
|
|
1785
|
+
units = s.zoneCameraCount;
|
|
1817
1786
|
}
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
updatedArray.push( {
|
|
1822
|
-
productName: item.productName,
|
|
1823
|
-
workingdays: item.workingdays,
|
|
1824
|
-
storeCount: remainingStores,
|
|
1825
|
-
price: array2[array2.length - 1].negotiatePrice,
|
|
1826
|
-
} );
|
|
1827
|
-
assigned += remainingStores;
|
|
1828
|
-
remainingStores = 0;
|
|
1787
|
+
} else if ( s.productName === 'tangoTraffic' ) {
|
|
1788
|
+
if ( billingType === 'perCamera' && s.trafficCameraCount > 0 ) {
|
|
1789
|
+
units = s.trafficCameraCount;
|
|
1829
1790
|
}
|
|
1830
|
-
|
|
1831
|
-
assignedByProduct[item.productName] = assigned;
|
|
1832
1791
|
}
|
|
1833
|
-
|
|
1792
|
+
productPosition[s.productName] = ( productPosition[s.productName] || 0 ) + 1;
|
|
1793
|
+
const price = tierPriceForPosition( s.productName, productPosition[s.productName] );
|
|
1794
|
+
const fullMonth = s.workingDays >= currentMonthDays;
|
|
1795
|
+
const amount = fullMonth ?
|
|
1796
|
+
Math.round( units * price * 100 ) / 100 :
|
|
1797
|
+
Math.round( ( units * ( price / currentMonthDays ) * s.workingDays ) * 100 ) / 100;
|
|
1798
|
+
const period = fullMonth ? 'fullMonth' : 'proRate';
|
|
1799
|
+
const key = `${s.productName}_${period}_${price}`;
|
|
1800
|
+
if ( !grouped[key] ) {
|
|
1801
|
+
grouped[key] = { productName: s.productName, period, price, totalAmount: 0, unitCount: 0 };
|
|
1802
|
+
}
|
|
1803
|
+
grouped[key].totalAmount += amount;
|
|
1804
|
+
grouped[key].unitCount += units;
|
|
1834
1805
|
}
|
|
1835
1806
|
|
|
1836
|
-
|
|
1837
|
-
console.log( '++++++++++++', resultarray );
|
|
1838
|
-
const result = applyPricing( resultarray, pricing );
|
|
1839
|
-
console.log( '***********', result );
|
|
1840
|
-
// Group by product + period + PRICE TIER so each step tier is its own line
|
|
1841
|
-
// item, and all stores within a tier+period collapse into a single line:
|
|
1842
|
-
// - full-month stores of a tier -> one line (sum cost, sum count)
|
|
1843
|
-
// - prorated stores of a tier -> one averaged line (sum cost, sum count)
|
|
1844
|
-
// Keying on price (not storeCount) is what merges the leftover prorated stores
|
|
1845
|
-
// — which span several working-days buckets — into one $40 line instead of one
|
|
1846
|
-
// line per working-days value.
|
|
1847
|
-
const groupedData = result.reduce( ( acc, item ) => {
|
|
1848
|
-
const { productName, period, runningCost } = item;
|
|
1849
|
-
const key = `${productName}_${period}_${item.price}`;
|
|
1850
|
-
if ( !acc[key] ) {
|
|
1851
|
-
acc[key] = {
|
|
1852
|
-
productName,
|
|
1853
|
-
period,
|
|
1854
|
-
totalRunningCost: 0,
|
|
1855
|
-
count: 0,
|
|
1856
|
-
};
|
|
1857
|
-
}
|
|
1858
|
-
acc[key].totalRunningCost += parseFloat( runningCost );
|
|
1859
|
-
acc[key].count += item.storeCount;
|
|
1860
|
-
return acc;
|
|
1861
|
-
}, {} );
|
|
1862
|
-
console.log( groupedData );
|
|
1863
|
-
// Calculating average running cost
|
|
1864
|
-
const finalresult = Object.values( groupedData ).map( ( grp ) => {
|
|
1807
|
+
const finalresult = Object.values( grouped ).map( ( grp ) => {
|
|
1865
1808
|
let description = '';
|
|
1866
1809
|
if ( grp.productName === 'tangoTraffic' ) {
|
|
1867
1810
|
description = 'Customer Footfall Analytics';
|
|
1868
1811
|
} else if ( grp.productName === 'tangoZone' ) {
|
|
1869
1812
|
description = 'Product category/section analytics';
|
|
1870
|
-
} else {
|
|
1871
|
-
description = '';
|
|
1872
1813
|
}
|
|
1814
|
+
const amount = Math.round( grp.totalAmount * 100 ) / 100;
|
|
1873
1815
|
return {
|
|
1874
1816
|
productName: grp.productName,
|
|
1875
1817
|
period: grp.period,
|
|
1876
|
-
|
|
1818
|
+
// Quantity = billable units (cameras for perCamera, zones for perZone,
|
|
1819
|
+
// stores otherwise). For full-month lines price = tier price; for prorated
|
|
1820
|
+
// lines it's the effective per-unit cost (amount / units).
|
|
1821
|
+
storeCount: Math.round( grp.unitCount * 100 ) / 100,
|
|
1877
1822
|
description: description,
|
|
1878
1823
|
HsnNumber: '998314',
|
|
1879
|
-
amount:
|
|
1824
|
+
amount: amount,
|
|
1880
1825
|
month: dayjs().format( 'MMM YYYY' ),
|
|
1881
|
-
price:
|
|
1826
|
+
price: grp.unitCount ? ( amount / grp.unitCount ).toFixed( 2 ) : '0.00',
|
|
1882
1827
|
};
|
|
1883
1828
|
} );
|
|
1884
1829
|
|
|
1885
|
-
// Combine overallStore and eachStore products
|
|
1830
|
+
// Combine overallStore (per-store tiered) and eachStore products.
|
|
1886
1831
|
return [ ...finalresult, ...eachStoreProducts ];
|
|
1887
1832
|
}
|
|
1888
1833
|
|