tango-app-api-payment-subscription 3.5.15 → 3.5.16
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 +33 -0
- package/src/controllers/invoice.controller.js +151 -261
- package/src/controllers/paymentSubscription.controllers.js +116 -11
- package/src/dtos/validation.dtos.js +1 -0
- package/src/routes/paymentSubscription.routes.js +9 -0
- package/src/services/clientPayment.services.js +5 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tango-app-api-payment-subscription",
|
|
3
|
-
"version": "3.5.
|
|
3
|
+
"version": "3.5.16",
|
|
4
4
|
"description": "paymentSubscription",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"type": "module",
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
"nodemon": "^3.1.0",
|
|
30
30
|
"puppeteer": "^24.41.0",
|
|
31
31
|
"swagger-ui-express": "^5.0.0",
|
|
32
|
-
"tango-api-schema": "^2.6.
|
|
32
|
+
"tango-api-schema": "^2.6.34",
|
|
33
33
|
"tango-app-api-middleware": "^3.6.18",
|
|
34
34
|
"winston": "^3.12.0",
|
|
35
35
|
"winston-daily-rotate-file": "^5.0.0",
|
|
@@ -582,6 +582,11 @@ export async function brandInvoiceList( req, res ) {
|
|
|
582
582
|
let summary = {
|
|
583
583
|
totalInvoices: allInvoices.length,
|
|
584
584
|
totalInvoiced: allInvoices.reduce( ( sum, inv ) => sum + ( inv.totalAmount || 0 ), 0 ),
|
|
585
|
+
// Footer totals over the FULL filtered set (not just the current page):
|
|
586
|
+
// stores, amount excl. GST and amount incl. GST.
|
|
587
|
+
totalStores: allInvoices.reduce( ( sum, inv ) => sum + ( inv.stores || 0 ), 0 ),
|
|
588
|
+
totalAmountExclGst: allInvoices.reduce( ( sum, inv ) => sum + ( inv.amount || 0 ), 0 ),
|
|
589
|
+
totalAmountInclGst: allInvoices.reduce( ( sum, inv ) => sum + ( inv.totalAmount || 0 ), 0 ),
|
|
585
590
|
pendingApproval: allInvoices.filter( ( inv ) => [ 'pendingCsm', 'pendingFinance', 'pendingApproval' ].includes( inv.status ) ).length,
|
|
586
591
|
pendingPayment: allInvoices.filter( ( inv ) => inv.paymentStatus === 'unpaid' && inv.status === 'approved' ).length,
|
|
587
592
|
paid: allInvoices.filter( ( inv ) => inv.paymentStatus === 'paid' ).length,
|
|
@@ -891,6 +896,32 @@ export async function latestDailyPricing( req, res ) {
|
|
|
891
896
|
} },
|
|
892
897
|
] );
|
|
893
898
|
|
|
899
|
+
// Newly onboarded stores = stores whose FIRST FILE date falls in the current
|
|
900
|
+
// calendar month. Counted over the FULL store list (record.stores), not the
|
|
901
|
+
// status/search-filtered view, so the card reflects all new onboardings.
|
|
902
|
+
// First-file date is edgefirstFileDate, with processfirstFileDate as the
|
|
903
|
+
// fallback (same rule the annexure uses); some legacy rows store it as a
|
|
904
|
+
// string, so coerce defensively.
|
|
905
|
+
const monthStart = dayjs().startOf( 'month' );
|
|
906
|
+
const monthEnd = dayjs().endOf( 'month' );
|
|
907
|
+
const newlyOnboardedStoreList = ( record.stores || [] )
|
|
908
|
+
.map( ( s ) => {
|
|
909
|
+
const raw = s.edgefirstFileDate || s.processfirstFileDate;
|
|
910
|
+
const ff = raw ? dayjs( raw ) : null;
|
|
911
|
+
return { store: s, ff };
|
|
912
|
+
} )
|
|
913
|
+
.filter( ( x ) => x.ff && x.ff.isValid() &&
|
|
914
|
+
x.ff.isAfter( monthStart.subtract( 1, 'millisecond' ) ) &&
|
|
915
|
+
x.ff.isBefore( monthEnd.add( 1, 'millisecond' ) ) )
|
|
916
|
+
.map( ( x ) => ( {
|
|
917
|
+
storeId: x.store.storeId,
|
|
918
|
+
storeName: x.store.storeName,
|
|
919
|
+
status: x.store.status,
|
|
920
|
+
firstFileDate: x.ff.format( 'YYYY-MM-DD' ),
|
|
921
|
+
} ) )
|
|
922
|
+
.sort( ( a, b ) => a.firstFileDate.localeCompare( b.firstFileDate ) );
|
|
923
|
+
const newlyOnboardedStores = newlyOnboardedStoreList.length;
|
|
924
|
+
|
|
894
925
|
let data = {
|
|
895
926
|
clientId: record.clientId,
|
|
896
927
|
brandName: record.brandName,
|
|
@@ -901,6 +932,8 @@ export async function latestDailyPricing( req, res ) {
|
|
|
901
932
|
status: record.status,
|
|
902
933
|
proRate: record.proRate,
|
|
903
934
|
count,
|
|
935
|
+
newlyOnboardedStores,
|
|
936
|
+
newlyOnboardedStoreList,
|
|
904
937
|
data: storeList,
|
|
905
938
|
monthlyBillingSummary,
|
|
906
939
|
};
|
|
@@ -286,6 +286,58 @@ 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
|
+
const oneTimeBp = await basepricingService.findOne(
|
|
296
|
+
{ clientId: group.clientId },
|
|
297
|
+
{ oneTimeFeePerStore: 1 },
|
|
298
|
+
);
|
|
299
|
+
const oneTimeFeePerStore = Number( oneTimeBp?.oneTimeFeePerStore ) || 0;
|
|
300
|
+
if ( oneTimeFeePerStore > 0 && Array.isArray( group.stores ) && group.stores.length ) {
|
|
301
|
+
const monthStart = new Date( baseDate.startOf( 'month' ).toISOString() );
|
|
302
|
+
const monthEnd = new Date( baseDate.endOf( 'month' ).toISOString() );
|
|
303
|
+
// Distinct group stores whose first file date is within the billing
|
|
304
|
+
// month. First file = edgefirstFileDate, fallback processfirstFileDate.
|
|
305
|
+
const newStoreAgg = await dailyPricingService.aggregate( [
|
|
306
|
+
{ $match: { clientId: group.clientId } },
|
|
307
|
+
{ $sort: { dateISO: -1 } },
|
|
308
|
+
{ $limit: 1 },
|
|
309
|
+
{ $project: { stores: { $filter: {
|
|
310
|
+
input: '$stores', as: 'item', cond: { $in: [ '$$item.storeId', group.stores ] },
|
|
311
|
+
} } } },
|
|
312
|
+
{ $unwind: '$stores' },
|
|
313
|
+
{ $addFields: { ff: {
|
|
314
|
+
$convert: {
|
|
315
|
+
input: { $ifNull: [ '$stores.edgefirstFileDate', '$stores.processfirstFileDate' ] },
|
|
316
|
+
to: 'date', onError: null, onNull: null,
|
|
317
|
+
},
|
|
318
|
+
} } },
|
|
319
|
+
{ $match: { ff: { $ne: null, $gte: monthStart, $lte: monthEnd } } },
|
|
320
|
+
{ $group: { _id: '$stores.storeId' } },
|
|
321
|
+
{ $count: 'newStores' },
|
|
322
|
+
] );
|
|
323
|
+
const newStoreCount = newStoreAgg?.[0]?.newStores || 0;
|
|
324
|
+
if ( newStoreCount > 0 ) {
|
|
325
|
+
products.push( {
|
|
326
|
+
productName: 'oneTimeFee',
|
|
327
|
+
period: 'fullmonth',
|
|
328
|
+
storeCount: newStoreCount,
|
|
329
|
+
amount: Math.round( newStoreCount * oneTimeFeePerStore * 100 ) / 100,
|
|
330
|
+
price: oneTimeFeePerStore,
|
|
331
|
+
description: `One-Time Fee - ${newStoreCount} stores`,
|
|
332
|
+
HsnNumber: '998314',
|
|
333
|
+
month: baseDate.format( 'MMM YYYY' ),
|
|
334
|
+
} );
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
} catch ( oneTimeErr ) {
|
|
338
|
+
logger.error( { error: oneTimeErr, function: 'createInvoice.oneTimeFee', clientId: group.clientId } );
|
|
339
|
+
}
|
|
340
|
+
|
|
289
341
|
let amount = products.reduce( ( sum, product ) => sum + product.amount, 0 );
|
|
290
342
|
let taxList = [];
|
|
291
343
|
let totalAmount = 0;
|
|
@@ -1481,134 +1533,40 @@ async function stepPrice( group, getClient ) {
|
|
|
1481
1533
|
billingTypeMap[p.productName] = p.billingType || 'perStore';
|
|
1482
1534
|
} );
|
|
1483
1535
|
}
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
},
|
|
1490
|
-
{
|
|
1491
|
-
$sort: { dateISO: -1 },
|
|
1492
|
-
},
|
|
1536
|
+
// PER-STORE rows (not bucketed) so step tiers can be assigned to individual
|
|
1537
|
+
// stores and each store charged tierPrice x its EXACT camera/zone count.
|
|
1538
|
+
let perStoreRows = await dailyPricingService.aggregate( [
|
|
1539
|
+
{ $match: { clientId: group.clientId } },
|
|
1540
|
+
{ $sort: { dateISO: -1 } },
|
|
1493
1541
|
{ $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
|
-
|
|
1542
|
+
{ $project: { stores: { $filter: {
|
|
1543
|
+
input: '$stores', as: 'item', cond: { $in: [ '$$item.storeId', group.stores ] },
|
|
1544
|
+
} } } },
|
|
1545
|
+
{ $unwind: { path: '$stores', preserveNullAndEmptyArrays: false } },
|
|
1546
|
+
{ $unwind: { path: '$stores.products', preserveNullAndEmptyArrays: false } },
|
|
1547
|
+
{ $project: {
|
|
1548
|
+
productName: '$stores.products.productName',
|
|
1549
|
+
storeId: '$stores.storeId',
|
|
1550
|
+
storeName: '$stores.storeName',
|
|
1551
|
+
workingDays: '$stores.products.workingdays',
|
|
1552
|
+
zoneCount: { $ifNull: [ '$stores.zoneCount', 0 ] },
|
|
1553
|
+
trafficCameraCount: { $ifNull: [ '$stores.trafficCameraCount', 0 ] },
|
|
1554
|
+
zoneCameraCount: { $ifNull: [ '$stores.zoneCameraCount', 0 ] },
|
|
1555
|
+
} },
|
|
1556
|
+
{ $match: { workingDays: { $gt: 0 } } },
|
|
1557
|
+
// Flat pricing => bill every store for the full month.
|
|
1558
|
+
{ $project: {
|
|
1559
|
+
_id: 0,
|
|
1560
|
+
productName: 1,
|
|
1561
|
+
storeId: 1,
|
|
1562
|
+
storeName: 1,
|
|
1563
|
+
workingDays: { $cond: { if: { $literal: isFlatPricing }, then: currentMonthDays, else: '$workingDays' } },
|
|
1564
|
+
zoneCount: 1,
|
|
1565
|
+
trafficCameraCount: 1,
|
|
1566
|
+
zoneCameraCount: 1,
|
|
1567
|
+
} },
|
|
1568
|
+
// Deterministic order so per-store tier assignment is stable run to run.
|
|
1569
|
+
{ $sort: { productName: 1, storeId: 1 } },
|
|
1612
1570
|
] );
|
|
1613
1571
|
// Build billingMethod map from group.products
|
|
1614
1572
|
let billingMethodMap = {};
|
|
@@ -1721,168 +1679,100 @@ async function stepPrice( group, getClient ) {
|
|
|
1721
1679
|
}
|
|
1722
1680
|
}
|
|
1723
1681
|
|
|
1724
|
-
//
|
|
1725
|
-
|
|
1682
|
+
// Drop eachStore products — those are handled by the per-store branch above.
|
|
1683
|
+
perStoreRows = perStoreRows.filter( ( p ) => !eachStoreProductNames.includes( p.productName ) );
|
|
1726
1684
|
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
|
|
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
|
-
} );
|
|
1746
|
-
|
|
1747
|
-
let stepPriceRecord = await basepricingService.findOne( { clientId: group.clientId } );
|
|
1748
|
-
let data = products;
|
|
1749
|
-
// Tiers must be ordered by range start for the cumulative step assignment in
|
|
1750
|
-
// processArray to walk them low-to-high.
|
|
1751
|
-
let pricing = ( stepPriceRecord.step || [] ).slice().sort( ( a, b ) => {
|
|
1685
|
+
const stepPriceRecord = await basepricingService.findOne( { clientId: group.clientId } );
|
|
1686
|
+
// Tiers ordered by range start so per-store tier assignment walks low-to-high.
|
|
1687
|
+
const pricing = ( stepPriceRecord?.step || [] ).slice().sort( ( a, b ) => {
|
|
1752
1688
|
const aStart = parseInt( String( a.storeRange || '0' ).split( '-' )[0], 10 ) || 0;
|
|
1753
1689
|
const bStart = parseInt( String( b.storeRange || '0' ).split( '-' )[0], 10 ) || 0;
|
|
1754
1690
|
return aStart - bStart;
|
|
1755
1691
|
} );
|
|
1756
|
-
|
|
1757
|
-
const
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
|
|
1768
|
-
|
|
1692
|
+
// Tiers grouped per product (only one product set here, but keep it general).
|
|
1693
|
+
const tiersByProduct = {};
|
|
1694
|
+
for ( const t of pricing ) {
|
|
1695
|
+
if ( !tiersByProduct[t.productName] ) {
|
|
1696
|
+
tiersByProduct[t.productName] = [];
|
|
1697
|
+
}
|
|
1698
|
+
tiersByProduct[t.productName].push( t );
|
|
1699
|
+
}
|
|
1700
|
+
// Resolve the tier price for a store's 1-based position within its product.
|
|
1701
|
+
const tierPriceForPosition = ( productName, position ) => {
|
|
1702
|
+
const tiers = tiersByProduct[productName] || [];
|
|
1703
|
+
if ( !tiers.length ) {
|
|
1704
|
+
return 0;
|
|
1705
|
+
}
|
|
1706
|
+
for ( const t of tiers ) {
|
|
1707
|
+
const [ min, max ] = String( t.storeRange || '' ).split( '-' ).map( Number );
|
|
1708
|
+
if ( position >= min && position <= max ) {
|
|
1709
|
+
return Number( t.negotiatePrice ) || 0;
|
|
1769
1710
|
}
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
negotiatePrice: item.price,
|
|
1774
|
-
perstorecost: ( ( item.price / currentMonthDays ) * item.workingdays ).toFixed( 2 ),
|
|
1775
|
-
};
|
|
1776
|
-
} );
|
|
1711
|
+
}
|
|
1712
|
+
// Beyond the last defined tier -> last tier's price.
|
|
1713
|
+
return Number( tiers[tiers.length - 1].negotiatePrice ) || 0;
|
|
1777
1714
|
};
|
|
1778
|
-
console.log( '---->', data, pricing );
|
|
1779
1715
|
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
for ( let range of array2 ) {
|
|
1797
|
-
const [ min, max ] = range.storeRange.split( '-' ).map( Number );
|
|
1798
|
-
if ( remainingStores <= 0 ) {
|
|
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;
|
|
1716
|
+
// EXACT per-store pricing: assign each store a 1-based position within its
|
|
1717
|
+
// product (tier boundary counted in STORES), then charge tierPrice x that
|
|
1718
|
+
// store's EXACT billable units — cameras (perCamera) / zones (perZone) / 1
|
|
1719
|
+
// (perStore) — prorated by working days. Group lines by product + period +
|
|
1720
|
+
// tier price so each tier collapses into a single line item.
|
|
1721
|
+
const productPosition = {};
|
|
1722
|
+
const grouped = {};
|
|
1723
|
+
for ( const s of perStoreRows ) {
|
|
1724
|
+
const billingType = billingTypeMap[s.productName] || 'perStore';
|
|
1725
|
+
let units = 1;
|
|
1726
|
+
if ( s.productName === 'tangoZone' ) {
|
|
1727
|
+
if ( billingType === 'perZone' && s.zoneCount > 0 ) {
|
|
1728
|
+
units = s.zoneCount;
|
|
1729
|
+
} else if ( billingType === 'perCamera' && s.zoneCameraCount > 0 ) {
|
|
1730
|
+
units = s.zoneCameraCount;
|
|
1817
1731
|
}
|
|
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;
|
|
1732
|
+
} else if ( s.productName === 'tangoTraffic' ) {
|
|
1733
|
+
if ( billingType === 'perCamera' && s.trafficCameraCount > 0 ) {
|
|
1734
|
+
units = s.trafficCameraCount;
|
|
1829
1735
|
}
|
|
1830
|
-
|
|
1831
|
-
assignedByProduct[item.productName] = assigned;
|
|
1832
1736
|
}
|
|
1833
|
-
|
|
1737
|
+
productPosition[s.productName] = ( productPosition[s.productName] || 0 ) + 1;
|
|
1738
|
+
const price = tierPriceForPosition( s.productName, productPosition[s.productName] );
|
|
1739
|
+
const fullMonth = s.workingDays >= currentMonthDays;
|
|
1740
|
+
const amount = fullMonth ?
|
|
1741
|
+
Math.round( units * price * 100 ) / 100 :
|
|
1742
|
+
Math.round( ( units * ( price / currentMonthDays ) * s.workingDays ) * 100 ) / 100;
|
|
1743
|
+
const period = fullMonth ? 'fullMonth' : 'proRate';
|
|
1744
|
+
const key = `${s.productName}_${period}_${price}`;
|
|
1745
|
+
if ( !grouped[key] ) {
|
|
1746
|
+
grouped[key] = { productName: s.productName, period, price, totalAmount: 0, unitCount: 0 };
|
|
1747
|
+
}
|
|
1748
|
+
grouped[key].totalAmount += amount;
|
|
1749
|
+
grouped[key].unitCount += units;
|
|
1834
1750
|
}
|
|
1835
1751
|
|
|
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 ) => {
|
|
1752
|
+
const finalresult = Object.values( grouped ).map( ( grp ) => {
|
|
1865
1753
|
let description = '';
|
|
1866
1754
|
if ( grp.productName === 'tangoTraffic' ) {
|
|
1867
1755
|
description = 'Customer Footfall Analytics';
|
|
1868
1756
|
} else if ( grp.productName === 'tangoZone' ) {
|
|
1869
1757
|
description = 'Product category/section analytics';
|
|
1870
|
-
} else {
|
|
1871
|
-
description = '';
|
|
1872
1758
|
}
|
|
1759
|
+
const amount = Math.round( grp.totalAmount * 100 ) / 100;
|
|
1873
1760
|
return {
|
|
1874
1761
|
productName: grp.productName,
|
|
1875
1762
|
period: grp.period,
|
|
1876
|
-
|
|
1763
|
+
// Quantity = billable units (cameras for perCamera, zones for perZone,
|
|
1764
|
+
// stores otherwise). For full-month lines price = tier price; for prorated
|
|
1765
|
+
// lines it's the effective per-unit cost (amount / units).
|
|
1766
|
+
storeCount: Math.round( grp.unitCount * 100 ) / 100,
|
|
1877
1767
|
description: description,
|
|
1878
1768
|
HsnNumber: '998314',
|
|
1879
|
-
amount:
|
|
1769
|
+
amount: amount,
|
|
1880
1770
|
month: dayjs().format( 'MMM YYYY' ),
|
|
1881
|
-
price:
|
|
1771
|
+
price: grp.unitCount ? ( amount / grp.unitCount ).toFixed( 2 ) : '0.00',
|
|
1882
1772
|
};
|
|
1883
1773
|
} );
|
|
1884
1774
|
|
|
1885
|
-
// Combine overallStore and eachStore products
|
|
1775
|
+
// Combine overallStore (per-store tiered) and eachStore products.
|
|
1886
1776
|
return [ ...finalresult, ...eachStoreProducts ];
|
|
1887
1777
|
}
|
|
1888
1778
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
|
|
2
2
|
/* eslint-disable new-cap */
|
|
3
|
-
import { logger, download, sendEmailWithSES, insertOpenSearchData, getOpenSearchData, updateOpenSearchData, getOpenSearchById } from 'tango-app-api-middleware';
|
|
3
|
+
import { logger, download, sendEmailWithSES, insertOpenSearchData, getOpenSearchData, updateOpenSearchData, getOpenSearchById, fileUpload, customSignedUrl } from 'tango-app-api-middleware';
|
|
4
4
|
import * as paymentService from '../services/clientPayment.services.js';
|
|
5
5
|
import * as basePriceService from '../services/basePrice.service.js';
|
|
6
6
|
import * as storeService from '../services/store.service.js';
|
|
@@ -2246,7 +2246,7 @@ export const invoiceList = async ( req, res ) => {
|
|
|
2246
2246
|
|
|
2247
2247
|
export const priceList = async ( req, res ) => {
|
|
2248
2248
|
try {
|
|
2249
|
-
let pricingDetails = await basePricingService.findOne( { clientId: { $exists: true }, clientId: req.body.clientId }, { standard: 1, step: 1 } );
|
|
2249
|
+
let pricingDetails = await basePricingService.findOne( { clientId: { $exists: true }, clientId: req.body.clientId }, { standard: 1, step: 1, oneTimeFeePerStore: 1 } );
|
|
2250
2250
|
if ( !pricingDetails ) {
|
|
2251
2251
|
return res.sendError( 'no data found', 204 );
|
|
2252
2252
|
}
|
|
@@ -2301,19 +2301,25 @@ export const priceList = async ( req, res ) => {
|
|
|
2301
2301
|
product.showImg = true;
|
|
2302
2302
|
product.showEditDelete = true;
|
|
2303
2303
|
}
|
|
2304
|
-
|
|
2304
|
+
// Last tier absorbs whatever stores remain after the earlier tiers.
|
|
2305
|
+
// Never let it go negative when the actual store count is smaller
|
|
2306
|
+
// than the defined tier ranges (e.g. 83 stores across 1-50 / 51-100
|
|
2307
|
+
// left the last tier at 83 - 100 = -17).
|
|
2308
|
+
product.storeCount = Math.max( item.storeCount, 0 );
|
|
2305
2309
|
product.lastIndex = true;
|
|
2306
|
-
} else if ( index == 0 ) {
|
|
2307
|
-
product.storeCount = 100;
|
|
2308
|
-
item.storeCount = item.storeCount - 100;
|
|
2309
2310
|
} else {
|
|
2310
|
-
|
|
2311
|
-
|
|
2311
|
+
// Non-last tier: use its own range size (end - start + 1), but cap at
|
|
2312
|
+
// the stores still remaining so a tier can't claim more stores than
|
|
2313
|
+
// exist. The first tier previously hardcoded 100, which overcounted
|
|
2314
|
+
// (and pushed the last tier negative) whenever the range wasn't 1-100.
|
|
2315
|
+
product.showImg = index != 0;
|
|
2316
|
+
let rangeArray = String( product.storeRange || '' ).split( '-' );
|
|
2312
2317
|
let startNumber = parseInt( rangeArray[0] );
|
|
2313
2318
|
let endNumber = parseInt( rangeArray[1] );
|
|
2314
|
-
let diff = endNumber - startNumber + 1;
|
|
2315
|
-
|
|
2316
|
-
|
|
2319
|
+
let diff = ( endNumber - startNumber + 1 ) || 0;
|
|
2320
|
+
let assigned = Math.max( Math.min( diff, item.storeCount ), 0 );
|
|
2321
|
+
product.storeCount = assigned;
|
|
2322
|
+
item.storeCount = item.storeCount - assigned;
|
|
2317
2323
|
}
|
|
2318
2324
|
}
|
|
2319
2325
|
// let discountPrice = product.basePrice * ( product.discountPercentage / 100 );
|
|
@@ -2335,6 +2341,7 @@ export const priceList = async ( req, res ) => {
|
|
|
2335
2341
|
let finalValue = parseFloat( discountTotalPrice ) + gstAmount;
|
|
2336
2342
|
let result = {
|
|
2337
2343
|
product: data,
|
|
2344
|
+
oneTimeFeePerStore: pricingDetails.oneTimeFeePerStore != null ? pricingDetails.oneTimeFeePerStore : null,
|
|
2338
2345
|
totalActualPrice: originalTotalPrice,
|
|
2339
2346
|
totalNegotiatePrice: discountTotalPrice.toFixed( 2 ),
|
|
2340
2347
|
actualPrice: totalProductPrice,
|
|
@@ -2484,6 +2491,11 @@ export const pricingListUpdate = async ( req, res ) => {
|
|
|
2484
2491
|
} else {
|
|
2485
2492
|
getPriceInfo.step = req.body.products;
|
|
2486
2493
|
}
|
|
2494
|
+
// Brand-level one-time fee per store. Only overwrite when the request
|
|
2495
|
+
// actually carries a value so other save paths don't wipe it.
|
|
2496
|
+
if ( req.body.oneTimeFeePerStore != null && req.body.oneTimeFeePerStore !== '' ) {
|
|
2497
|
+
getPriceInfo.oneTimeFeePerStore = Number( req.body.oneTimeFeePerStore ) || 0;
|
|
2498
|
+
}
|
|
2487
2499
|
getPriceInfo.save().then( async () => {
|
|
2488
2500
|
let clientDetails = await paymentService.findOne( { clientId: req.body.clientId }, { priceType: 1, paymentInvoice: 1, planDetails: 1 } );
|
|
2489
2501
|
clientDetails.priceType = req.body.type;
|
|
@@ -4130,3 +4142,96 @@ export async function createDefaultbillings( req, res ) {
|
|
|
4130
4142
|
}
|
|
4131
4143
|
}
|
|
4132
4144
|
|
|
4145
|
+
// ---------------------------------------------------------------------------
|
|
4146
|
+
// Brand documents (Plans & Subscription > Documents accordion).
|
|
4147
|
+
// Upload a single PDF for a client, store it in the assets bucket under
|
|
4148
|
+
// <clientId>/documents/, and record { documentName, path } on the client's
|
|
4149
|
+
// additionalDocuments array. List returns the docs with signed URLs.
|
|
4150
|
+
// ---------------------------------------------------------------------------
|
|
4151
|
+
export async function uploadClientDocument( req, res ) {
|
|
4152
|
+
try {
|
|
4153
|
+
const clientId = String( req.body?.clientId || '' );
|
|
4154
|
+
const documentName = String( req.body?.documentName || '' ).trim();
|
|
4155
|
+
const expiryDateRaw = req.body?.expiryDate;
|
|
4156
|
+
const file = req.file; // multer single('file')
|
|
4157
|
+
|
|
4158
|
+
if ( !clientId ) {
|
|
4159
|
+
return res.sendError( 'clientId is required', 400 );
|
|
4160
|
+
}
|
|
4161
|
+
if ( !documentName ) {
|
|
4162
|
+
return res.sendError( 'documentName is required', 400 );
|
|
4163
|
+
}
|
|
4164
|
+
if ( !file ) {
|
|
4165
|
+
return res.sendError( 'file is required', 400 );
|
|
4166
|
+
}
|
|
4167
|
+
// PDF only.
|
|
4168
|
+
if ( file.mimetype !== 'application/pdf' ) {
|
|
4169
|
+
return res.sendError( 'Only PDF files are allowed', 400 );
|
|
4170
|
+
}
|
|
4171
|
+
|
|
4172
|
+
const client = await paymentService.findOneClient( { clientId }, { clientId: 1 } );
|
|
4173
|
+
if ( !client ) {
|
|
4174
|
+
return res.sendError( 'Client not found', 404 );
|
|
4175
|
+
}
|
|
4176
|
+
|
|
4177
|
+
const bucket = JSON.parse( process.env.BUCKET );
|
|
4178
|
+
// Unique-ish file name so re-uploads with the same display name don't clash.
|
|
4179
|
+
const safeName = documentName.replace( /[^a-zA-Z0-9-_]/g, '_' );
|
|
4180
|
+
const fileName = `${safeName}_${Date.now()}.pdf`;
|
|
4181
|
+
const key = `${clientId}/documents/`;
|
|
4182
|
+
|
|
4183
|
+
await fileUpload( {
|
|
4184
|
+
Bucket: bucket.assets,
|
|
4185
|
+
Key: key,
|
|
4186
|
+
fileName: fileName,
|
|
4187
|
+
ContentType: file.mimetype,
|
|
4188
|
+
body: file.buffer,
|
|
4189
|
+
} );
|
|
4190
|
+
|
|
4191
|
+
const storedPath = `${key}${fileName}`;
|
|
4192
|
+
const expiryDate = expiryDateRaw ? new Date( expiryDateRaw ) : null;
|
|
4193
|
+
await paymentService.pushAdditionalDocument( { clientId }, {
|
|
4194
|
+
documentName: documentName,
|
|
4195
|
+
path: storedPath,
|
|
4196
|
+
expiryDate: ( expiryDate && !isNaN( expiryDate.getTime() ) ) ? expiryDate : null,
|
|
4197
|
+
uploadedAt: new Date(),
|
|
4198
|
+
} );
|
|
4199
|
+
|
|
4200
|
+
return res.sendSuccess( { documentName, path: storedPath, expiryDate } );
|
|
4201
|
+
} catch ( error ) {
|
|
4202
|
+
logger.error( { error: error, function: 'uploadClientDocument' } );
|
|
4203
|
+
return res.sendError( error, 500 );
|
|
4204
|
+
}
|
|
4205
|
+
}
|
|
4206
|
+
|
|
4207
|
+
export async function getClientDocuments( req, res ) {
|
|
4208
|
+
try {
|
|
4209
|
+
const clientId = String( req.query?.clientId || req.params?.clientId || '' );
|
|
4210
|
+
if ( !clientId ) {
|
|
4211
|
+
return res.sendError( 'clientId is required', 400 );
|
|
4212
|
+
}
|
|
4213
|
+
const client = await paymentService.findOneClient( { clientId }, { additionalDocuments: 1 } );
|
|
4214
|
+
const bucket = JSON.parse( process.env.BUCKET );
|
|
4215
|
+
const docs = await Promise.all( ( client?.additionalDocuments || [] ).map( async ( d ) => {
|
|
4216
|
+
let url = '';
|
|
4217
|
+
try {
|
|
4218
|
+
url = await customSignedUrl( { Bucket: bucket.assets, file_path: d.path }, 8 );
|
|
4219
|
+
} catch ( e ) {
|
|
4220
|
+
url = '';
|
|
4221
|
+
}
|
|
4222
|
+
return {
|
|
4223
|
+
_id: d._id,
|
|
4224
|
+
documentName: d.documentName,
|
|
4225
|
+
path: d.path,
|
|
4226
|
+
expiryDate: d.expiryDate,
|
|
4227
|
+
uploadedAt: d.uploadedAt,
|
|
4228
|
+
url,
|
|
4229
|
+
};
|
|
4230
|
+
} ) );
|
|
4231
|
+
return res.sendSuccess( { documents: docs } );
|
|
4232
|
+
} catch ( error ) {
|
|
4233
|
+
logger.error( { error: error, function: 'getClientDocuments' } );
|
|
4234
|
+
return res.sendError( error, 500 );
|
|
4235
|
+
}
|
|
4236
|
+
}
|
|
4237
|
+
|
|
@@ -153,6 +153,7 @@ export const validatePriceSchema = joi.object( {
|
|
|
153
153
|
type: joi.string().optional(),
|
|
154
154
|
clientId: joi.string().required(),
|
|
155
155
|
products: joi.array().optional(),
|
|
156
|
+
oneTimeFeePerStore: joi.number().optional().allow( null, '' ),
|
|
156
157
|
pricing: joi.array().items( joi.object( {
|
|
157
158
|
productName: joi.string().required(),
|
|
158
159
|
negotiatePrice: joi.number().required(),
|
|
@@ -1,11 +1,16 @@
|
|
|
1
1
|
|
|
2
2
|
import express from 'express';
|
|
3
|
+
import multer from 'multer';
|
|
3
4
|
import * as paymentController from '../controllers/paymentSubscription.controllers.js';
|
|
4
5
|
import { validate, isAllowedSessionHandler, accessVerification } from 'tango-app-api-middleware';
|
|
5
6
|
import * as validationDtos from '../dtos/validation.dtos.js';
|
|
6
7
|
import { validateClient } from '../utils/validations/client.validation.js';
|
|
7
8
|
export const paymentSubscriptionRouter = express.Router();
|
|
8
9
|
|
|
10
|
+
// PDF document upload (Plans & Subscription > Documents). In-memory storage,
|
|
11
|
+
// 10MB cap; the controller streams the buffer to the assets S3 bucket.
|
|
12
|
+
const documentUpload = multer( { storage: multer.memoryStorage(), limits: { fileSize: 10 * 1024 * 1024 } } );
|
|
13
|
+
|
|
9
14
|
paymentSubscriptionRouter.post( '/addBilling', isAllowedSessionHandler, accessVerification( {
|
|
10
15
|
userType: [ 'tango', 'client' ], access: [
|
|
11
16
|
{ featureName: 'Global', name: 'Subscription', permissions: [ 'isAdd' ] },
|
|
@@ -146,4 +151,8 @@ paymentSubscriptionRouter.put( '/pushNotification/update/:notificationId', isAll
|
|
|
146
151
|
paymentSubscriptionRouter.post( '/updateRemind/:notificationId', isAllowedSessionHandler, validate( validationDtos.validateId ), paymentController.updateRemind );
|
|
147
152
|
paymentSubscriptionRouter.post( '/createDefaultbillings', paymentController.createDefaultbillings );
|
|
148
153
|
|
|
154
|
+
// Brand documents (Plans & Subscription > Documents accordion).
|
|
155
|
+
paymentSubscriptionRouter.post( '/client-document/upload', isAllowedSessionHandler, accessVerification( { userType: [ 'tango' ], access: [ { featureName: 'Global', name: 'Subscription', permissions: [ 'isEdit' ] } ] } ), documentUpload.single( 'file' ), paymentController.uploadClientDocument );
|
|
156
|
+
paymentSubscriptionRouter.get( '/client-document/list', isAllowedSessionHandler, accessVerification( { userType: [ 'tango' ], access: [ { featureName: 'Global', name: 'Subscription', permissions: [] } ] } ), paymentController.getClientDocuments );
|
|
157
|
+
|
|
149
158
|
|
|
@@ -16,6 +16,11 @@ export const updateOne = ( query = {}, record = {} ) => {
|
|
|
16
16
|
return model.clientModel.updateOne( query, { $set: record } );
|
|
17
17
|
};
|
|
18
18
|
|
|
19
|
+
// Push a document into the client's additionalDocuments array.
|
|
20
|
+
export const pushAdditionalDocument = ( query = {}, document = {} ) => {
|
|
21
|
+
return model.clientModel.updateOne( query, { $push: { additionalDocuments: document } } );
|
|
22
|
+
};
|
|
23
|
+
|
|
19
24
|
export const aggregate = ( query = [] ) => {
|
|
20
25
|
return model.clientModel.aggregate( query );
|
|
21
26
|
};
|