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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tango-app-api-payment-subscription",
3
- "version": "3.5.15",
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.29",
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
- let products = await dailyPricingService.aggregate( [
1485
- {
1486
- $match: {
1487
- clientId: group.clientId,
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
- $project: {
1496
- stores: {
1497
- $filter: {
1498
- input: '$stores',
1499
- as: 'item',
1500
- cond: { $in: [ '$$item.storeId', group.stores ] },
1501
- },
1502
- },
1503
- },
1504
- },
1505
- {
1506
- $unwind: {
1507
- path: '$stores',
1508
- preserveNullAndEmptyArrays: false,
1509
- },
1510
- },
1511
- {
1512
- $unwind: {
1513
- path: '$stores.products',
1514
- preserveNullAndEmptyArrays: false,
1515
- },
1516
- },
1517
- {
1518
- $project: {
1519
- productName: '$stores.products.productName',
1520
- storeId: '$stores.storeId',
1521
- workingDays: '$stores.products.workingdays',
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
- // Filter out eachStore products from aggregated results
1725
- products = products.filter( ( p ) => !eachStoreProductNames.includes( p.productName ) );
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
- // 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
- } );
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 applyPricing = ( data, pricing ) => {
1758
- let totalcount = 0;
1759
- return data.map( ( item ) => {
1760
- totalcount = totalcount + item.storeCount;
1761
-
1762
- console.log( '======>', item );
1763
- if ( item.workingdays === currentMonthDays ) {
1764
- item.period = 'fullMonth';
1765
- item.runningCost = item.storeCount * item.price;
1766
- } else {
1767
- item.period = 'proRate';
1768
- item.runningCost = ( item.storeCount * ( item.price / currentMonthDays ) * item.workingdays ).toFixed( 2 );
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
- return {
1772
- ...item,
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
- function processArray( array1, array2 ) {
1782
- let updatedArray = [];
1783
-
1784
- // Step tiers apply CUMULATIVELY across every working-days bucket of a
1785
- // product, not per bucket. Track how many stores of each product have
1786
- // already been priced so each bucket continues where the previous one left
1787
- // off. (Previously the tier counter reset for every bucket, so e.g. the 9
1788
- // prorated stores really stores 75-83, in the $40 tier — were each priced
1789
- // from tier 1 at $45, producing extra/wrong-priced line items.)
1790
- const assignedByProduct = {};
1791
-
1792
- for ( let item of array1 ) {
1793
- let remainingStores = item.storeCount;
1794
- let assigned = assignedByProduct[item.productName] || 0;
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
- // Any stores beyond the last defined tier are charged at the last tier.
1820
- if ( remainingStores > 0 && array2.length ) {
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
- return updatedArray;
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
- let resultarray = processArray( data, pricing );
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
- storeCount: grp.count,
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: grp.totalRunningCost,
1769
+ amount: amount,
1880
1770
  month: dayjs().format( 'MMM YYYY' ),
1881
- price: ( grp.totalRunningCost / grp.count ).toFixed( 2 ),
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
- product.storeCount = item.storeCount;
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
- product.showImg = true;
2311
- let rangeArray = product.storeRange.split( '-' );
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
- product.storeCount = diff;
2316
- item.storeCount = item.storeCount - diff;
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
  };