tango-app-api-payment-subscription 3.5.14 → 3.5.15

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.14",
3
+ "version": "3.5.15",
4
4
  "description": "paymentSubscription",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -5,8 +5,10 @@ import * as dailyPriceService from '../services/dailyPrice.service.js';
5
5
  import * as storeService from '../services/store.service.js';
6
6
  import * as assignedStoreService from '../services/assignedStore.service.js';
7
7
  import * as basePriceService from '../services/basePrice.service.js';
8
+ import * as cameraService from '../services/camera.service.js';
9
+ import * as planogramService from '../services/planogram.service.js';
8
10
  import dayjs from 'dayjs';
9
- import { logger, checkFileExist, signedUrl, download, insertOpenSearchData } from 'tango-app-api-middleware';
11
+ import { logger, checkFileExist, signedUrl, download, insertOpenSearchData, getOpenSearchData } from 'tango-app-api-middleware';
10
12
  import * as XLSX from 'xlsx';
11
13
  import ExcelJS from 'exceljs';
12
14
  import { bulkUpdateBillingGroupRowSchema } from '../dtos/validation.dtos.js';
@@ -1694,6 +1696,11 @@ export async function getUsdInrRate() {
1694
1696
  export async function billingSummary( req, res ) {
1695
1697
  try {
1696
1698
  const now = dayjs();
1699
+ const nowDate = now.toDate();
1700
+ // Today's USD->INR rate. Hoisted here (cached) so both the aging buckets and
1701
+ // the revenue columns convert dollar invoices consistently.
1702
+ const usdRate = await getUsdInrRate();
1703
+ const usdRateForAging = usdRate;
1697
1704
  const months = [];
1698
1705
  for ( let i = 4; i >= 0; i-- ) {
1699
1706
  const m = now.subtract( i, 'month' );
@@ -1753,6 +1760,54 @@ export async function billingSummary( req, res ) {
1753
1760
  } );
1754
1761
  const clientById = new Map( clients.map( ( c ) => [ String( c.clientId ), c ] ) );
1755
1762
 
1763
+ // AR aging — outstanding balance per client, bucketed by days past the
1764
+ // invoice DUE DATE. Scope: APPROVED invoices that are not fully paid
1765
+ // (paymentStatus unpaid/partial). Amount per invoice = balance due
1766
+ // (totalAmount - paidAmount). Dollar invoices are converted to INR at
1767
+ // today's rate so the buckets are comparable across currencies (matching the
1768
+ // revenue columns). This intentionally spans ALL time, not the 5-month
1769
+ // window, because overdue balances can be older than the revenue window.
1770
+ const agingRows = await invoiceService.aggregate( [
1771
+ { $match: {
1772
+ status: 'approved',
1773
+ paymentStatus: { $in: [ 'unpaid', 'partial', 'due' ] },
1774
+ } },
1775
+ { $addFields: {
1776
+ dueDateD: { $cond: [
1777
+ { $eq: [ { $type: '$dueDate' }, 'date' ] },
1778
+ '$dueDate',
1779
+ { $convert: { input: '$dueDate', to: 'date', onError: null, onNull: null } },
1780
+ ] },
1781
+ balance: { $subtract: [
1782
+ { $ifNull: [ '$totalAmount', 0 ] },
1783
+ { $ifNull: [ '$paidAmount', 0 ] },
1784
+ ] },
1785
+ isDollar: { $eq: [ '$currency', 'dollar' ] },
1786
+ } },
1787
+ // Only invoices with a positive balance still owed.
1788
+ { $match: { balance: { $gt: 0 } } },
1789
+ { $addFields: {
1790
+ // Days past due. Missing/invalid dueDate -> treat as 0 days overdue so
1791
+ // the amount still shows in the youngest bucket rather than vanishing.
1792
+ daysPastDue: { $cond: [
1793
+ { $eq: [ '$dueDateD', null ] },
1794
+ 0,
1795
+ { $dateDiff: { startDate: '$dueDateD', endDate: nowDate, unit: 'day' } },
1796
+ ] },
1797
+ } },
1798
+ { $group: {
1799
+ _id: '$clientId',
1800
+ // Bucket the BALANCE (INR-normalized) by age. < 30, 30-60, > 60.
1801
+ b0to30: { $sum: { $cond: [ { $lt: [ '$daysPastDue', 30 ] },
1802
+ { $cond: [ '$isDollar', { $multiply: [ '$balance', usdRateForAging ] }, '$balance' ] }, 0 ] } },
1803
+ b30to60: { $sum: { $cond: [ { $and: [ { $gte: [ '$daysPastDue', 30 ] }, { $lte: [ '$daysPastDue', 60 ] } ] },
1804
+ { $cond: [ '$isDollar', { $multiply: [ '$balance', usdRateForAging ] }, '$balance' ] }, 0 ] } },
1805
+ bOver60: { $sum: { $cond: [ { $gt: [ '$daysPastDue', 60 ] },
1806
+ { $cond: [ '$isDollar', { $multiply: [ '$balance', usdRateForAging ] }, '$balance' ] }, 0 ] } },
1807
+ } },
1808
+ ] );
1809
+ const agingByClient = new Map( agingRows.map( ( a ) => [ String( a._id ), a ] ) );
1810
+
1756
1811
  // Registered Entity comes from the billings collection (each billing group's
1757
1812
  // registeredCompanyName). A client can have multiple groups/names, so
1758
1813
  // collect the distinct list per client — the UI shows the first and reveals
@@ -1767,7 +1822,7 @@ export async function billingSummary( req, res ) {
1767
1822
 
1768
1823
  // Per-client CSM (userAssignedStore, tangoUserType 'csm'); display the
1769
1824
  // email's local part since the collection carries no display name.
1770
- const usdRate = await getUsdInrRate();
1825
+ // usdRate already fetched at the top of the function.
1771
1826
 
1772
1827
  // Current month's store count comes from dailyPricing — counted the same
1773
1828
  // way as Brands & Billing's "Billing Stores": distinct ACTIVE stores that
@@ -1984,11 +2039,21 @@ export async function billingSummary( req, res ) {
1984
2039
  // a client has no billing-group registered name.
1985
2040
  const regNames = regNamesByClient.get( r.clientId ) || [];
1986
2041
  const registeredEntity = regNames[0] || r.registeredEntity || '';
2042
+ // AR aging buckets (INR-normalized). Rounded; outstanding = sum of buckets.
2043
+ const ag = agingByClient.get( r.clientId );
2044
+ const aging0to30 = ag ? Math.round( ag.b0to30 || 0 ) : 0;
2045
+ const aging30to60 = ag ? Math.round( ag.b30to60 || 0 ) : 0;
2046
+ const agingOver60 = ag ? Math.round( ag.bOver60 || 0 ) : 0;
2047
+ const outstanding = aging0to30 + aging30to60 + agingOver60;
1987
2048
  return {
1988
2049
  clientId: r.clientId,
1989
2050
  clientName: r.clientName || registeredEntity || r.clientId,
1990
2051
  registeredEntity,
1991
2052
  registeredEntities: regNames.length ? regNames : ( r.registeredEntity ? [ r.registeredEntity ] : [] ),
2053
+ outstanding,
2054
+ aging0to30,
2055
+ aging30to60,
2056
+ agingOver60,
1992
2057
  status: r.status,
1993
2058
  products: r.products,
1994
2059
  csm: r.csm,
@@ -2048,3 +2113,127 @@ export async function billingSummary( req, res ) {
2048
2113
  return res.sendError( error, 500 );
2049
2114
  }
2050
2115
  }
2116
+
2117
+ // ---------------------------------------------------------------------------
2118
+ // Additional Products table (Billing Breakdown). These are extra, non-standard
2119
+ // products billed per store for a SPECIFIC client only. Currently scoped to
2120
+ // clientId '11'; for any other client this returns an empty list so the table
2121
+ // simply doesn't render.
2122
+ //
2123
+ // Both products and their unit prices are hardcoded here by design (they're not
2124
+ // in the basepricing catalog). Quantity = distinct store count from each
2125
+ // product's own source collection:
2126
+ // - Eyetest -> cameras collection (eye-test streams with a QR code)
2127
+ // - Planogram -> planograms collection (distinct storeName)
2128
+ // Total = Quantity * unit price (INR).
2129
+ // ---------------------------------------------------------------------------
2130
+ const ADDITIONAL_PRODUCTS_CLIENT_ID = '11';
2131
+ // Agreed INR unit prices for client 11.
2132
+ const ADDITIONAL_PRODUCT_PRICES = {
2133
+ eyetest: 450,
2134
+ planogram: 770,
2135
+ aiManager: 500,
2136
+ };
2137
+ // OpenSearch index holding extra billed products (VMS, Run AI, etc.). Each
2138
+ // record is a daily snapshot carrying its own quantity + price per product.
2139
+ const BILLING_DETAILS_INDEX = ( () => {
2140
+ try {
2141
+ return JSON.parse( process.env.OPENSEARCH || '{}' ).billingDetails || 'billing_details';
2142
+ } catch ( e ) {
2143
+ return 'billing_details';
2144
+ }
2145
+ } )();
2146
+
2147
+ // Compute the additional-products list for a client. Returns [] for any client
2148
+ // that has none (currently only clientId 11). Shared by the Billing Breakdown
2149
+ // table (additionalProducts controller) AND invoice generation, so both stay in
2150
+ // sync. Each entry: { productName, quantity, price, total }.
2151
+ export async function getAdditionalProducts( clientId ) {
2152
+ clientId = String( clientId ?? '' );
2153
+ if ( clientId !== ADDITIONAL_PRODUCTS_CLIENT_ID ) {
2154
+ return [];
2155
+ }
2156
+
2157
+ // Eyetest: distinct stores in the cameras collection that have an eye-test
2158
+ // stream with a QR code.
2159
+ const eyetestRows = await cameraService.aggregate( [
2160
+ { $match: { $and: [
2161
+ { clientId: clientId },
2162
+ { isEyeTestStream: true },
2163
+ { qrCode: { $exists: true } },
2164
+ ] } },
2165
+ { $group: { _id: '$storeId' } },
2166
+ { $count: 'stores' },
2167
+ ] );
2168
+ const eyetestQty = eyetestRows?.[0]?.stores || 0;
2169
+
2170
+ // Planogram: distinct storeName in the planograms collection.
2171
+ const planogramRows = await planogramService.aggregate( [
2172
+ { $match: { clientId: clientId } },
2173
+ { $group: { _id: null, storecount: { $addToSet: '$storeName' } } },
2174
+ { $project: { count: { $size: '$storecount' } } },
2175
+ ] );
2176
+ const planogramQty = planogramRows?.[0]?.count || 0;
2177
+
2178
+ // AI Manager: same count as tangoTraffic running stores — distinct stores in
2179
+ // the latest daily-pricing doc whose tangoTraffic product ran more than one
2180
+ // working day (a single-day appearance is transient and not billed).
2181
+ const aiManagerRows = await dailyPriceService.aggregate( [
2182
+ { $match: { clientId: clientId } },
2183
+ { $sort: { dateISO: -1 } },
2184
+ { $limit: 1 },
2185
+ { $unwind: '$stores' },
2186
+ { $unwind: '$stores.products' },
2187
+ { $match: { 'stores.products.productName': 'tangoTraffic', 'stores.products.workingdays': { $gt: 1 } } },
2188
+ { $group: { _id: '$stores.storeId' } },
2189
+ { $count: 'stores' },
2190
+ ] );
2191
+ const aiManagerQty = aiManagerRows?.[0]?.stores || 0;
2192
+
2193
+ const build = ( productName, quantity, price ) => ( {
2194
+ productName,
2195
+ quantity,
2196
+ price,
2197
+ total: Math.round( quantity * price * 100 ) / 100,
2198
+ } );
2199
+
2200
+ const products = [
2201
+ build( 'Eyetest', eyetestQty, ADDITIONAL_PRODUCT_PRICES.eyetest ),
2202
+ build( 'Planogram', planogramQty, ADDITIONAL_PRODUCT_PRICES.planogram ),
2203
+ build( 'AI Manager', aiManagerQty, ADDITIONAL_PRODUCT_PRICES.aiManager ),
2204
+ ];
2205
+
2206
+ // Extra products from the billing_details OpenSearch index — the LATEST daily
2207
+ // snapshot for this client. quantity/price are stored as strings, so coerce
2208
+ // them. If OpenSearch is unreachable we just skip these rows rather than
2209
+ // failing the whole list.
2210
+ try {
2211
+ const osRes = await getOpenSearchData( BILLING_DETAILS_INDEX, {
2212
+ size: 1,
2213
+ query: { term: { 'client_id': clientId } },
2214
+ sort: [ { date_string: { order: 'desc' } } ],
2215
+ } );
2216
+ const hits = osRes?.body?.hits?.hits || osRes?.hits?.hits || [];
2217
+ const osProducts = hits[0]?._source?.products || [];
2218
+ for ( const p of osProducts ) {
2219
+ const quantity = Number( p.quantity ) || 0;
2220
+ const price = Number( p.price ) || 0;
2221
+ products.push( build( p.productName, quantity, price ) );
2222
+ }
2223
+ } catch ( osError ) {
2224
+ logger.error( { error: osError, function: 'getAdditionalProducts:openSearch' } );
2225
+ }
2226
+
2227
+ return products;
2228
+ }
2229
+
2230
+ export async function additionalProducts( req, res ) {
2231
+ try {
2232
+ const clientId = String( req.query?.clientId ?? req.body?.clientId ?? '' );
2233
+ const products = await getAdditionalProducts( clientId );
2234
+ return res.sendSuccess( { currency: 'inr', products } );
2235
+ } catch ( error ) {
2236
+ logger.error( { error: error, function: 'additionalProducts' } );
2237
+ return res.sendError( error, 500 );
2238
+ }
2239
+ }
@@ -18,7 +18,7 @@ import { invoiceStatusEnum } from '../dtos/validation.dtos.js';
18
18
  import { findOneApplicationDefault } from '../services/applicationDefault.service.js';
19
19
  import * as assignedStoreService from '../services/assignedStore.service.js';
20
20
  import * as bankTransactionService from '../services/bankTransaction.service.js';
21
- import { getUsdInrRate } from './brandsBilling.controller.js';
21
+ import { getUsdInrRate, getAdditionalProducts } from './brandsBilling.controller.js';
22
22
 
23
23
  // Pulls CSM + Finance head emails (stored under applicationDefault
24
24
  // type=invoice, subType=heads) AND the per-client CSMs assigned via
@@ -241,6 +241,26 @@ export async function createInvoice( req, res ) {
241
241
  products = await stepPrice( group, getClient );
242
242
  }
243
243
 
244
+ // Additional products (e.g. Lenskart / clientId 11: Eyetest, Planogram,
245
+ // AI Manager + the OpenSearch billing_details rows). Each is a single
246
+ // full-month line item (quantity x price), appended on top of the normal
247
+ // store-based product lines. Added BEFORE the multi-month expansion below
248
+ // so they repeat per month and are included in the taxable subtotal, just
249
+ // like the standard products. Returns [] for any client without extras.
250
+ const extraProducts = await getAdditionalProducts( group.clientId );
251
+ for ( const ep of extraProducts ) {
252
+ products.push( {
253
+ productName: ep.productName,
254
+ period: 'fullmonth',
255
+ storeCount: ep.quantity,
256
+ amount: ep.total,
257
+ price: ep.price,
258
+ description: ep.productName,
259
+ HsnNumber: '998314',
260
+ month: baseDate.format( 'MMM YYYY' ),
261
+ } );
262
+ }
263
+
244
264
  // Billing horizon in months. Advance and normal cycle are independent —
245
265
  // only one drives the span per generation:
246
266
  // advanceInvoice ON -> advancePeriod (advance future billing)
@@ -454,12 +474,56 @@ async function buildAnnexureRows( invoiceInfo, getgroup ) {
454
474
  // converting here would make the annexure unit price disagree with the actual
455
475
  // billed amount (e.g. a $45 negotiated price wrongly shown as $0.48).
456
476
 
457
- const annexClient = await clientService.findOne( { clientId: invoiceInfo.clientId }, { 'planDetails.product': 1 } );
477
+ const annexClient = await clientService.findOne( { clientId: invoiceInfo.clientId }, { 'planDetails.product': 1, 'priceType': 1 } );
458
478
  const billingTypeMap = {};
459
479
  ( annexClient?.planDetails?.product || [] ).forEach( ( p ) => {
460
480
  billingTypeMap[p.productName] = p.billingType || 'perStore';
461
481
  } );
462
482
 
483
+ // Pricing for this client. For STEP clients the price depends on the store's
484
+ // position within the product (tier ranges), so we can't join a single price
485
+ // per store in the aggregation — that join is what caused every store to
486
+ // appear once per tier (e.g. the same store listed at $45 and again at $40).
487
+ // Instead we pull the tiers here and assign each store a single tier price
488
+ // below, in JS, by its 1-based index within the product.
489
+ const pricingDoc = await basepricingService.findOne( { clientId: invoiceInfo.clientId }, { standard: 1, step: 1 } );
490
+ const isStep = annexClient?.priceType === 'step';
491
+ const tiersByProduct = {};
492
+ ( ( isStep ? pricingDoc?.step : pricingDoc?.standard ) || [] ).forEach( ( p ) => {
493
+ if ( !tiersByProduct[p.productName] ) {
494
+ tiersByProduct[p.productName] = [];
495
+ }
496
+ tiersByProduct[p.productName].push( p );
497
+ } );
498
+ // Step tiers must be ordered by their range start so index lookups are correct.
499
+ Object.keys( tiersByProduct ).forEach( ( name ) => {
500
+ tiersByProduct[name].sort( ( a, b ) => {
501
+ const aStart = parseInt( String( a.storeRange || '0' ).split( '-' )[0], 10 ) || 0;
502
+ const bStart = parseInt( String( b.storeRange || '0' ).split( '-' )[0], 10 ) || 0;
503
+ return aStart - bStart;
504
+ } );
505
+ } );
506
+
507
+ // Resolve the per-store price. Standard: first (only) tier's price. Step: the
508
+ // tier whose range contains this store's 1-based position within the product.
509
+ const priceForStore = ( productName, positionInProduct ) => {
510
+ const tiers = tiersByProduct[productName] || [];
511
+ if ( !tiers.length ) {
512
+ return 0;
513
+ }
514
+ if ( !isStep ) {
515
+ return Number( tiers[0].negotiatePrice ) || 0;
516
+ }
517
+ for ( const tier of tiers ) {
518
+ const [ min, max ] = String( tier.storeRange || '' ).split( '-' ).map( Number );
519
+ if ( positionInProduct >= min && positionInProduct <= max ) {
520
+ return Number( tier.negotiatePrice ) || 0;
521
+ }
522
+ }
523
+ // Beyond the last defined tier: fall back to the last tier's price.
524
+ return Number( tiers[tiers.length - 1].negotiatePrice ) || 0;
525
+ };
526
+
463
527
  const rows = await dailyPricingService.aggregate( [
464
528
  { $match: { clientId: invoiceInfo.clientId, dateISO: { $lte: billingMonthEnd } } },
465
529
  { $sort: { dateISO: -1 } },
@@ -478,25 +542,12 @@ async function buildAnnexureRows( invoiceInfo, getgroup ) {
478
542
  zoneCameraCount: { $ifNull: [ '$stores.zoneCameraCount', 0 ] },
479
543
  } },
480
544
  { $match: { workingdays: { $gt: 0 } } },
481
- { $sort: { productName: 1, workingdays: -1 } },
482
- { $lookup: {
483
- from: 'basepricings',
484
- let: { clientId: invoiceInfo.clientId },
485
- pipeline: [
486
- { $match: { $expr: { $eq: [ '$clientId', '$$clientId' ] } } },
487
- { $project: { standard: 1 } },
488
- ],
489
- as: 'basepricing',
490
- } },
491
- { $unwind: { path: '$basepricing', preserveNullAndEmptyArrays: true } },
492
- { $project: {
493
- productName: 1, workingdays: 1, storeName: 1, storeId: 1, edgefirstFileDate: 1,
494
- zoneCount: 1, trafficCameraCount: 1, zoneCameraCount: 1,
495
- standard: { $filter: { input: '$basepricing.standard', as: 'standard', cond: { $eq: [ '$$standard.productName', '$productName' ] } } },
496
- } },
497
- { $unwind: { path: '$standard', preserveNullAndEmptyArrays: true } },
545
+ // Deterministic ordering so step-tier assignment is stable run to run.
546
+ { $sort: { productName: 1, storeId: 1 } },
498
547
  ] );
499
548
 
549
+ // Track each store's 1-based position within its product to pick the step tier.
550
+ const productPosition = {};
500
551
  const data = rows.map( ( s ) => {
501
552
  const billingType = billingTypeMap[s.productName] || 'perStore';
502
553
  // Same units rule as invoice generation: perZone / perCamera multiply by
@@ -513,9 +564,11 @@ async function buildAnnexureRows( invoiceInfo, getgroup ) {
513
564
  units = s.trafficCameraCount;
514
565
  }
515
566
  }
567
+ productPosition[s.productName] = ( productPosition[s.productName] || 0 ) + 1;
516
568
  // negotiatePrice is already in the invoice currency — use it verbatim,
517
- // matching invoice generation.
518
- const price = Number( s.standard?.negotiatePrice ) || 0;
569
+ // matching invoice generation. For step clients the tier is chosen by the
570
+ // store's position within the product.
571
+ const price = priceForStore( s.productName, productPosition[s.productName] );
519
572
  const runningCost = s.workingdays >= monthDays ?
520
573
  Math.round( price * units * 100 ) / 100 :
521
574
  Math.round( ( price / monthDays ) * s.workingdays * units * 100 ) / 100;
@@ -1693,7 +1746,13 @@ async function stepPrice( group, getClient ) {
1693
1746
 
1694
1747
  let stepPriceRecord = await basepricingService.findOne( { clientId: group.clientId } );
1695
1748
  let data = products;
1696
- let pricing = stepPriceRecord.step;
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 ) => {
1752
+ const aStart = parseInt( String( a.storeRange || '0' ).split( '-' )[0], 10 ) || 0;
1753
+ const bStart = parseInt( String( b.storeRange || '0' ).split( '-' )[0], 10 ) || 0;
1754
+ return aStart - bStart;
1755
+ } );
1697
1756
 
1698
1757
  const applyPricing = ( data, pricing ) => {
1699
1758
  let totalcount = 0;
@@ -1722,26 +1781,54 @@ async function stepPrice( group, getClient ) {
1722
1781
  function processArray( array1, array2 ) {
1723
1782
  let updatedArray = [];
1724
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
+
1725
1792
  for ( let item of array1 ) {
1726
1793
  let remainingStores = item.storeCount;
1794
+ let assigned = assignedByProduct[item.productName] || 0;
1727
1795
 
1728
1796
  for ( let range of array2 ) {
1729
- let [ min, max ] = range.storeRange.split( '-' ).map( Number );
1730
-
1731
- if ( remainingStores > 0 ) {
1732
- let applicableStores = Math.min( remainingStores, max - min + 1 );
1733
- updatedArray.push( {
1734
- productName: item.productName,
1735
- workingdays: item.workingdays,
1736
- storeCount: applicableStores,
1737
- // Each tier is charged at its own range's negotiated price. (Was a
1738
- // hardcoded 1100 for every tier after the first — a placeholder
1739
- // that ignored the actual step price of the next range.)
1740
- price: range.negotiatePrice,
1741
- } );
1742
- remainingStores -= applicableStores;
1797
+ const [ min, max ] = range.storeRange.split( '-' ).map( Number );
1798
+ if ( remainingStores <= 0 ) {
1799
+ break;
1743
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;
1744
1817
  }
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;
1829
+ }
1830
+
1831
+ assignedByProduct[item.productName] = assigned;
1745
1832
  }
1746
1833
  return updatedArray;
1747
1834
  }
@@ -1750,9 +1837,16 @@ async function stepPrice( group, getClient ) {
1750
1837
  console.log( '++++++++++++', resultarray );
1751
1838
  const result = applyPricing( resultarray, pricing );
1752
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.
1753
1847
  const groupedData = result.reduce( ( acc, item ) => {
1754
1848
  const { productName, period, runningCost } = item;
1755
- const key = `${productName}_${period}_${item.storeCount}`;
1849
+ const key = `${productName}_${period}_${item.price}`;
1756
1850
  if ( !acc[key] ) {
1757
1851
  acc[key] = {
1758
1852
  productName,
@@ -1761,14 +1855,8 @@ async function stepPrice( group, getClient ) {
1761
1855
  count: 0,
1762
1856
  };
1763
1857
  }
1764
- if ( period === 'proRate' ) {
1765
- acc[key].totalRunningCost += parseFloat( runningCost );
1766
- acc[key].count += item.storeCount;
1767
- } else {
1768
- acc[key].totalRunningCost = parseFloat( runningCost );
1769
- acc[key].count = item.storeCount;
1770
- }
1771
-
1858
+ acc[key].totalRunningCost += parseFloat( runningCost );
1859
+ acc[key].count += item.storeCount;
1772
1860
  return acc;
1773
1861
  }, {} );
1774
1862
  console.log( groupedData );
@@ -1,6 +1,6 @@
1
1
 
2
2
  import express from 'express';
3
- import { brandsBillingList, brandInvoiceList, latestDailyPricing, brandBillingGroups, updateDailyPricingWorkingDays, updateDailyPricingStoreField, getClientBillingInfo, bulkDownloadBillingGroups, bulkUpdateBillingGroups, billingSummary } from '../controllers/brandsBilling.controller.js';
3
+ import { brandsBillingList, brandInvoiceList, latestDailyPricing, brandBillingGroups, updateDailyPricingWorkingDays, updateDailyPricingStoreField, getClientBillingInfo, bulkDownloadBillingGroups, bulkUpdateBillingGroups, billingSummary, additionalProducts } from '../controllers/brandsBilling.controller.js';
4
4
  import { isAllowedSessionHandler, accessVerification } from 'tango-app-api-middleware';
5
5
 
6
6
  export const brandsBillingRouter = express.Router();
@@ -16,3 +16,4 @@ brandsBillingRouter.get( '/bulk-download-billing-groups', isAllowedSessionHandle
16
16
  brandsBillingRouter.post( '/bulk-update-billing-groups', isAllowedSessionHandler, accessVerification( { userType: [ 'tango' ], access: [ { featureName: 'TangoAdmin', name: 'invoiceApproval', permissions: [ 'isEdit' ] } ] } ), bulkUpdateBillingGroups );
17
17
  brandsBillingRouter.post( '/billingSummary', isAllowedSessionHandler, accessVerification( { userType: [ 'tango' ], access: [ { featureName: 'TangoAdmin', name: 'invoiceApproval', permissions: [] } ] } ), billingSummary );
18
18
  brandsBillingRouter.get( '/billingSummary', isAllowedSessionHandler, accessVerification( { userType: [ 'tango' ], access: [ { featureName: 'TangoAdmin', name: 'invoiceApproval', permissions: [] } ] } ), billingSummary );
19
+ brandsBillingRouter.get( '/additionalProducts', isAllowedSessionHandler, accessVerification( { userType: [ 'tango' ], access: [ { featureName: 'TangoAdmin', name: 'invoiceApproval', permissions: [] } ] } ), additionalProducts );
@@ -0,0 +1,13 @@
1
+ import model from 'tango-api-schema';
2
+
3
+ export const aggregate = ( query = {} ) => {
4
+ return model.planogramModel.aggregate( query );
5
+ };
6
+
7
+ export const find = ( query = {}, record = {} ) => {
8
+ return model.planogramModel.find( query, record );
9
+ };
10
+
11
+ export const findOne = ( query = {}, record = {} ) => {
12
+ return model.planogramModel.findOne( query, record );
13
+ };