tango-app-api-payment-subscription 3.5.14 → 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.14",
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",
@@ -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';
@@ -580,6 +582,11 @@ export async function brandInvoiceList( req, res ) {
580
582
  let summary = {
581
583
  totalInvoices: allInvoices.length,
582
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 ),
583
590
  pendingApproval: allInvoices.filter( ( inv ) => [ 'pendingCsm', 'pendingFinance', 'pendingApproval' ].includes( inv.status ) ).length,
584
591
  pendingPayment: allInvoices.filter( ( inv ) => inv.paymentStatus === 'unpaid' && inv.status === 'approved' ).length,
585
592
  paid: allInvoices.filter( ( inv ) => inv.paymentStatus === 'paid' ).length,
@@ -889,6 +896,32 @@ export async function latestDailyPricing( req, res ) {
889
896
  } },
890
897
  ] );
891
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
+
892
925
  let data = {
893
926
  clientId: record.clientId,
894
927
  brandName: record.brandName,
@@ -899,6 +932,8 @@ export async function latestDailyPricing( req, res ) {
899
932
  status: record.status,
900
933
  proRate: record.proRate,
901
934
  count,
935
+ newlyOnboardedStores,
936
+ newlyOnboardedStoreList,
902
937
  data: storeList,
903
938
  monthlyBillingSummary,
904
939
  };
@@ -1694,6 +1729,11 @@ export async function getUsdInrRate() {
1694
1729
  export async function billingSummary( req, res ) {
1695
1730
  try {
1696
1731
  const now = dayjs();
1732
+ const nowDate = now.toDate();
1733
+ // Today's USD->INR rate. Hoisted here (cached) so both the aging buckets and
1734
+ // the revenue columns convert dollar invoices consistently.
1735
+ const usdRate = await getUsdInrRate();
1736
+ const usdRateForAging = usdRate;
1697
1737
  const months = [];
1698
1738
  for ( let i = 4; i >= 0; i-- ) {
1699
1739
  const m = now.subtract( i, 'month' );
@@ -1753,6 +1793,54 @@ export async function billingSummary( req, res ) {
1753
1793
  } );
1754
1794
  const clientById = new Map( clients.map( ( c ) => [ String( c.clientId ), c ] ) );
1755
1795
 
1796
+ // AR aging — outstanding balance per client, bucketed by days past the
1797
+ // invoice DUE DATE. Scope: APPROVED invoices that are not fully paid
1798
+ // (paymentStatus unpaid/partial). Amount per invoice = balance due
1799
+ // (totalAmount - paidAmount). Dollar invoices are converted to INR at
1800
+ // today's rate so the buckets are comparable across currencies (matching the
1801
+ // revenue columns). This intentionally spans ALL time, not the 5-month
1802
+ // window, because overdue balances can be older than the revenue window.
1803
+ const agingRows = await invoiceService.aggregate( [
1804
+ { $match: {
1805
+ status: 'approved',
1806
+ paymentStatus: { $in: [ 'unpaid', 'partial', 'due' ] },
1807
+ } },
1808
+ { $addFields: {
1809
+ dueDateD: { $cond: [
1810
+ { $eq: [ { $type: '$dueDate' }, 'date' ] },
1811
+ '$dueDate',
1812
+ { $convert: { input: '$dueDate', to: 'date', onError: null, onNull: null } },
1813
+ ] },
1814
+ balance: { $subtract: [
1815
+ { $ifNull: [ '$totalAmount', 0 ] },
1816
+ { $ifNull: [ '$paidAmount', 0 ] },
1817
+ ] },
1818
+ isDollar: { $eq: [ '$currency', 'dollar' ] },
1819
+ } },
1820
+ // Only invoices with a positive balance still owed.
1821
+ { $match: { balance: { $gt: 0 } } },
1822
+ { $addFields: {
1823
+ // Days past due. Missing/invalid dueDate -> treat as 0 days overdue so
1824
+ // the amount still shows in the youngest bucket rather than vanishing.
1825
+ daysPastDue: { $cond: [
1826
+ { $eq: [ '$dueDateD', null ] },
1827
+ 0,
1828
+ { $dateDiff: { startDate: '$dueDateD', endDate: nowDate, unit: 'day' } },
1829
+ ] },
1830
+ } },
1831
+ { $group: {
1832
+ _id: '$clientId',
1833
+ // Bucket the BALANCE (INR-normalized) by age. < 30, 30-60, > 60.
1834
+ b0to30: { $sum: { $cond: [ { $lt: [ '$daysPastDue', 30 ] },
1835
+ { $cond: [ '$isDollar', { $multiply: [ '$balance', usdRateForAging ] }, '$balance' ] }, 0 ] } },
1836
+ b30to60: { $sum: { $cond: [ { $and: [ { $gte: [ '$daysPastDue', 30 ] }, { $lte: [ '$daysPastDue', 60 ] } ] },
1837
+ { $cond: [ '$isDollar', { $multiply: [ '$balance', usdRateForAging ] }, '$balance' ] }, 0 ] } },
1838
+ bOver60: { $sum: { $cond: [ { $gt: [ '$daysPastDue', 60 ] },
1839
+ { $cond: [ '$isDollar', { $multiply: [ '$balance', usdRateForAging ] }, '$balance' ] }, 0 ] } },
1840
+ } },
1841
+ ] );
1842
+ const agingByClient = new Map( agingRows.map( ( a ) => [ String( a._id ), a ] ) );
1843
+
1756
1844
  // Registered Entity comes from the billings collection (each billing group's
1757
1845
  // registeredCompanyName). A client can have multiple groups/names, so
1758
1846
  // collect the distinct list per client — the UI shows the first and reveals
@@ -1767,7 +1855,7 @@ export async function billingSummary( req, res ) {
1767
1855
 
1768
1856
  // Per-client CSM (userAssignedStore, tangoUserType 'csm'); display the
1769
1857
  // email's local part since the collection carries no display name.
1770
- const usdRate = await getUsdInrRate();
1858
+ // usdRate already fetched at the top of the function.
1771
1859
 
1772
1860
  // Current month's store count comes from dailyPricing — counted the same
1773
1861
  // way as Brands & Billing's "Billing Stores": distinct ACTIVE stores that
@@ -1984,11 +2072,21 @@ export async function billingSummary( req, res ) {
1984
2072
  // a client has no billing-group registered name.
1985
2073
  const regNames = regNamesByClient.get( r.clientId ) || [];
1986
2074
  const registeredEntity = regNames[0] || r.registeredEntity || '';
2075
+ // AR aging buckets (INR-normalized). Rounded; outstanding = sum of buckets.
2076
+ const ag = agingByClient.get( r.clientId );
2077
+ const aging0to30 = ag ? Math.round( ag.b0to30 || 0 ) : 0;
2078
+ const aging30to60 = ag ? Math.round( ag.b30to60 || 0 ) : 0;
2079
+ const agingOver60 = ag ? Math.round( ag.bOver60 || 0 ) : 0;
2080
+ const outstanding = aging0to30 + aging30to60 + agingOver60;
1987
2081
  return {
1988
2082
  clientId: r.clientId,
1989
2083
  clientName: r.clientName || registeredEntity || r.clientId,
1990
2084
  registeredEntity,
1991
2085
  registeredEntities: regNames.length ? regNames : ( r.registeredEntity ? [ r.registeredEntity ] : [] ),
2086
+ outstanding,
2087
+ aging0to30,
2088
+ aging30to60,
2089
+ agingOver60,
1992
2090
  status: r.status,
1993
2091
  products: r.products,
1994
2092
  csm: r.csm,
@@ -2048,3 +2146,127 @@ export async function billingSummary( req, res ) {
2048
2146
  return res.sendError( error, 500 );
2049
2147
  }
2050
2148
  }
2149
+
2150
+ // ---------------------------------------------------------------------------
2151
+ // Additional Products table (Billing Breakdown). These are extra, non-standard
2152
+ // products billed per store for a SPECIFIC client only. Currently scoped to
2153
+ // clientId '11'; for any other client this returns an empty list so the table
2154
+ // simply doesn't render.
2155
+ //
2156
+ // Both products and their unit prices are hardcoded here by design (they're not
2157
+ // in the basepricing catalog). Quantity = distinct store count from each
2158
+ // product's own source collection:
2159
+ // - Eyetest -> cameras collection (eye-test streams with a QR code)
2160
+ // - Planogram -> planograms collection (distinct storeName)
2161
+ // Total = Quantity * unit price (INR).
2162
+ // ---------------------------------------------------------------------------
2163
+ const ADDITIONAL_PRODUCTS_CLIENT_ID = '11';
2164
+ // Agreed INR unit prices for client 11.
2165
+ const ADDITIONAL_PRODUCT_PRICES = {
2166
+ eyetest: 450,
2167
+ planogram: 770,
2168
+ aiManager: 500,
2169
+ };
2170
+ // OpenSearch index holding extra billed products (VMS, Run AI, etc.). Each
2171
+ // record is a daily snapshot carrying its own quantity + price per product.
2172
+ const BILLING_DETAILS_INDEX = ( () => {
2173
+ try {
2174
+ return JSON.parse( process.env.OPENSEARCH || '{}' ).billingDetails || 'billing_details';
2175
+ } catch ( e ) {
2176
+ return 'billing_details';
2177
+ }
2178
+ } )();
2179
+
2180
+ // Compute the additional-products list for a client. Returns [] for any client
2181
+ // that has none (currently only clientId 11). Shared by the Billing Breakdown
2182
+ // table (additionalProducts controller) AND invoice generation, so both stay in
2183
+ // sync. Each entry: { productName, quantity, price, total }.
2184
+ export async function getAdditionalProducts( clientId ) {
2185
+ clientId = String( clientId ?? '' );
2186
+ if ( clientId !== ADDITIONAL_PRODUCTS_CLIENT_ID ) {
2187
+ return [];
2188
+ }
2189
+
2190
+ // Eyetest: distinct stores in the cameras collection that have an eye-test
2191
+ // stream with a QR code.
2192
+ const eyetestRows = await cameraService.aggregate( [
2193
+ { $match: { $and: [
2194
+ { clientId: clientId },
2195
+ { isEyeTestStream: true },
2196
+ { qrCode: { $exists: true } },
2197
+ ] } },
2198
+ { $group: { _id: '$storeId' } },
2199
+ { $count: 'stores' },
2200
+ ] );
2201
+ const eyetestQty = eyetestRows?.[0]?.stores || 0;
2202
+
2203
+ // Planogram: distinct storeName in the planograms collection.
2204
+ const planogramRows = await planogramService.aggregate( [
2205
+ { $match: { clientId: clientId } },
2206
+ { $group: { _id: null, storecount: { $addToSet: '$storeName' } } },
2207
+ { $project: { count: { $size: '$storecount' } } },
2208
+ ] );
2209
+ const planogramQty = planogramRows?.[0]?.count || 0;
2210
+
2211
+ // AI Manager: same count as tangoTraffic running stores — distinct stores in
2212
+ // the latest daily-pricing doc whose tangoTraffic product ran more than one
2213
+ // working day (a single-day appearance is transient and not billed).
2214
+ const aiManagerRows = await dailyPriceService.aggregate( [
2215
+ { $match: { clientId: clientId } },
2216
+ { $sort: { dateISO: -1 } },
2217
+ { $limit: 1 },
2218
+ { $unwind: '$stores' },
2219
+ { $unwind: '$stores.products' },
2220
+ { $match: { 'stores.products.productName': 'tangoTraffic', 'stores.products.workingdays': { $gt: 1 } } },
2221
+ { $group: { _id: '$stores.storeId' } },
2222
+ { $count: 'stores' },
2223
+ ] );
2224
+ const aiManagerQty = aiManagerRows?.[0]?.stores || 0;
2225
+
2226
+ const build = ( productName, quantity, price ) => ( {
2227
+ productName,
2228
+ quantity,
2229
+ price,
2230
+ total: Math.round( quantity * price * 100 ) / 100,
2231
+ } );
2232
+
2233
+ const products = [
2234
+ build( 'Eyetest', eyetestQty, ADDITIONAL_PRODUCT_PRICES.eyetest ),
2235
+ build( 'Planogram', planogramQty, ADDITIONAL_PRODUCT_PRICES.planogram ),
2236
+ build( 'AI Manager', aiManagerQty, ADDITIONAL_PRODUCT_PRICES.aiManager ),
2237
+ ];
2238
+
2239
+ // Extra products from the billing_details OpenSearch index — the LATEST daily
2240
+ // snapshot for this client. quantity/price are stored as strings, so coerce
2241
+ // them. If OpenSearch is unreachable we just skip these rows rather than
2242
+ // failing the whole list.
2243
+ try {
2244
+ const osRes = await getOpenSearchData( BILLING_DETAILS_INDEX, {
2245
+ size: 1,
2246
+ query: { term: { 'client_id': clientId } },
2247
+ sort: [ { date_string: { order: 'desc' } } ],
2248
+ } );
2249
+ const hits = osRes?.body?.hits?.hits || osRes?.hits?.hits || [];
2250
+ const osProducts = hits[0]?._source?.products || [];
2251
+ for ( const p of osProducts ) {
2252
+ const quantity = Number( p.quantity ) || 0;
2253
+ const price = Number( p.price ) || 0;
2254
+ products.push( build( p.productName, quantity, price ) );
2255
+ }
2256
+ } catch ( osError ) {
2257
+ logger.error( { error: osError, function: 'getAdditionalProducts:openSearch' } );
2258
+ }
2259
+
2260
+ return products;
2261
+ }
2262
+
2263
+ export async function additionalProducts( req, res ) {
2264
+ try {
2265
+ const clientId = String( req.query?.clientId ?? req.body?.clientId ?? '' );
2266
+ const products = await getAdditionalProducts( clientId );
2267
+ return res.sendSuccess( { currency: 'inr', products } );
2268
+ } catch ( error ) {
2269
+ logger.error( { error: error, function: 'additionalProducts' } );
2270
+ return res.sendError( error, 500 );
2271
+ }
2272
+ }
@@ -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)
@@ -266,6 +286,58 @@ export async function createInvoice( req, res ) {
266
286
  products = expanded;
267
287
  }
268
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
+
269
341
  let amount = products.reduce( ( sum, product ) => sum + product.amount, 0 );
270
342
  let taxList = [];
271
343
  let totalAmount = 0;
@@ -454,12 +526,56 @@ async function buildAnnexureRows( invoiceInfo, getgroup ) {
454
526
  // converting here would make the annexure unit price disagree with the actual
455
527
  // billed amount (e.g. a $45 negotiated price wrongly shown as $0.48).
456
528
 
457
- const annexClient = await clientService.findOne( { clientId: invoiceInfo.clientId }, { 'planDetails.product': 1 } );
529
+ const annexClient = await clientService.findOne( { clientId: invoiceInfo.clientId }, { 'planDetails.product': 1, 'priceType': 1 } );
458
530
  const billingTypeMap = {};
459
531
  ( annexClient?.planDetails?.product || [] ).forEach( ( p ) => {
460
532
  billingTypeMap[p.productName] = p.billingType || 'perStore';
461
533
  } );
462
534
 
535
+ // Pricing for this client. For STEP clients the price depends on the store's
536
+ // position within the product (tier ranges), so we can't join a single price
537
+ // per store in the aggregation — that join is what caused every store to
538
+ // appear once per tier (e.g. the same store listed at $45 and again at $40).
539
+ // Instead we pull the tiers here and assign each store a single tier price
540
+ // below, in JS, by its 1-based index within the product.
541
+ const pricingDoc = await basepricingService.findOne( { clientId: invoiceInfo.clientId }, { standard: 1, step: 1 } );
542
+ const isStep = annexClient?.priceType === 'step';
543
+ const tiersByProduct = {};
544
+ ( ( isStep ? pricingDoc?.step : pricingDoc?.standard ) || [] ).forEach( ( p ) => {
545
+ if ( !tiersByProduct[p.productName] ) {
546
+ tiersByProduct[p.productName] = [];
547
+ }
548
+ tiersByProduct[p.productName].push( p );
549
+ } );
550
+ // Step tiers must be ordered by their range start so index lookups are correct.
551
+ Object.keys( tiersByProduct ).forEach( ( name ) => {
552
+ tiersByProduct[name].sort( ( a, b ) => {
553
+ const aStart = parseInt( String( a.storeRange || '0' ).split( '-' )[0], 10 ) || 0;
554
+ const bStart = parseInt( String( b.storeRange || '0' ).split( '-' )[0], 10 ) || 0;
555
+ return aStart - bStart;
556
+ } );
557
+ } );
558
+
559
+ // Resolve the per-store price. Standard: first (only) tier's price. Step: the
560
+ // tier whose range contains this store's 1-based position within the product.
561
+ const priceForStore = ( productName, positionInProduct ) => {
562
+ const tiers = tiersByProduct[productName] || [];
563
+ if ( !tiers.length ) {
564
+ return 0;
565
+ }
566
+ if ( !isStep ) {
567
+ return Number( tiers[0].negotiatePrice ) || 0;
568
+ }
569
+ for ( const tier of tiers ) {
570
+ const [ min, max ] = String( tier.storeRange || '' ).split( '-' ).map( Number );
571
+ if ( positionInProduct >= min && positionInProduct <= max ) {
572
+ return Number( tier.negotiatePrice ) || 0;
573
+ }
574
+ }
575
+ // Beyond the last defined tier: fall back to the last tier's price.
576
+ return Number( tiers[tiers.length - 1].negotiatePrice ) || 0;
577
+ };
578
+
463
579
  const rows = await dailyPricingService.aggregate( [
464
580
  { $match: { clientId: invoiceInfo.clientId, dateISO: { $lte: billingMonthEnd } } },
465
581
  { $sort: { dateISO: -1 } },
@@ -478,25 +594,12 @@ async function buildAnnexureRows( invoiceInfo, getgroup ) {
478
594
  zoneCameraCount: { $ifNull: [ '$stores.zoneCameraCount', 0 ] },
479
595
  } },
480
596
  { $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 } },
597
+ // Deterministic ordering so step-tier assignment is stable run to run.
598
+ { $sort: { productName: 1, storeId: 1 } },
498
599
  ] );
499
600
 
601
+ // Track each store's 1-based position within its product to pick the step tier.
602
+ const productPosition = {};
500
603
  const data = rows.map( ( s ) => {
501
604
  const billingType = billingTypeMap[s.productName] || 'perStore';
502
605
  // Same units rule as invoice generation: perZone / perCamera multiply by
@@ -513,9 +616,11 @@ async function buildAnnexureRows( invoiceInfo, getgroup ) {
513
616
  units = s.trafficCameraCount;
514
617
  }
515
618
  }
619
+ productPosition[s.productName] = ( productPosition[s.productName] || 0 ) + 1;
516
620
  // negotiatePrice is already in the invoice currency — use it verbatim,
517
- // matching invoice generation.
518
- const price = Number( s.standard?.negotiatePrice ) || 0;
621
+ // matching invoice generation. For step clients the tier is chosen by the
622
+ // store's position within the product.
623
+ const price = priceForStore( s.productName, productPosition[s.productName] );
519
624
  const runningCost = s.workingdays >= monthDays ?
520
625
  Math.round( price * units * 100 ) / 100 :
521
626
  Math.round( ( price / monthDays ) * s.workingdays * units * 100 ) / 100;
@@ -1428,134 +1533,40 @@ async function stepPrice( group, getClient ) {
1428
1533
  billingTypeMap[p.productName] = p.billingType || 'perStore';
1429
1534
  } );
1430
1535
  }
1431
- let products = await dailyPricingService.aggregate( [
1432
- {
1433
- $match: {
1434
- clientId: group.clientId,
1435
- },
1436
- },
1437
- {
1438
- $sort: { dateISO: -1 },
1439
- },
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 } },
1440
1541
  { $limit: 1 },
1441
- {
1442
- $project: {
1443
- stores: {
1444
- $filter: {
1445
- input: '$stores',
1446
- as: 'item',
1447
- cond: { $in: [ '$$item.storeId', group.stores ] },
1448
- },
1449
- },
1450
- },
1451
- },
1452
- {
1453
- $unwind: {
1454
- path: '$stores',
1455
- preserveNullAndEmptyArrays: false,
1456
- },
1457
- },
1458
- {
1459
- $unwind: {
1460
- path: '$stores.products',
1461
- preserveNullAndEmptyArrays: false,
1462
- },
1463
- },
1464
- {
1465
- $project: {
1466
- productName: '$stores.products.productName',
1467
- storeId: '$stores.storeId',
1468
- workingDays: '$stores.products.workingdays',
1469
- storeStatus: '$stores.status',
1470
- zoneCount: { $ifNull: [ '$stores.zoneCount', 0 ] },
1471
- cameraCount: { $ifNull: [ '$stores.cameraCount', 0 ] },
1472
- // Pull per-store camera splits; same fix as standardPrice — without
1473
- // these the second $group sums missing fields and the perCamera
1474
- // branch in the downstream map silently falls back to perStore.
1475
- trafficCameraCount: { $ifNull: [ '$stores.trafficCameraCount', 0 ] },
1476
- zoneCameraCount: { $ifNull: [ '$stores.zoneCameraCount', 0 ] },
1477
- },
1478
- },
1479
- {
1480
- $match: {
1481
- workingDays: { $gt: 0 },
1482
- },
1483
- },
1484
- {
1485
- $project: {
1486
- productName: 1,
1487
- storeId: 1,
1488
- // Group-level flag baked into every doc; consumed by the next
1489
- // $project's $cond.
1490
- isFlatPricing: { $literal: isFlatPricing },
1491
- workingDays: 1,
1492
- storeStatus: 1,
1493
- zoneCount: 1,
1494
- cameraCount: 1,
1495
- trafficCameraCount: 1,
1496
- zoneCameraCount: 1,
1497
- },
1498
- },
1499
- {
1500
- $project: {
1501
- productName: 1,
1502
- storeId: 1,
1503
- // Flat => full month per store. Prorate => actual working days.
1504
- workingDays: {
1505
- $cond: { if: '$isFlatPricing', then: currentMonthDays, else: '$workingDays' },
1506
- },
1507
- storeStatus: 1,
1508
- zoneCount: 1,
1509
- cameraCount: 1,
1510
- trafficCameraCount: 1,
1511
- zoneCameraCount: 1,
1512
- },
1513
- }, {
1514
- $group: {
1515
- _id: {
1516
- productName: '$productName',
1517
- storeId: '$storeId',
1518
- },
1519
- workingdays: { $first: '$workingDays' },
1520
- zoneCount: { $first: '$zoneCount' },
1521
- cameraCount: { $first: '$cameraCount' },
1522
- trafficCameraCount: { $first: '$trafficCameraCount' },
1523
- zoneCameraCount: { $first: '$zoneCameraCount' },
1524
- },
1525
- },
1526
- {
1527
- $group: {
1528
- _id: {
1529
- productName: '$_id.productName',
1530
- workingdays: '$workingdays',
1531
- },
1532
- storeCount: { $sum: 1 },
1533
- totalZoneCount: { $sum: '$zoneCount' },
1534
- totalTrafficCameraCount: { $sum: '$trafficCameraCount' },
1535
- totalZoneCameraCount: { $sum: '$zoneCameraCount' },
1536
- },
1537
- },
1538
- {
1539
- $project: {
1540
- _id: 0,
1541
- productName: '$_id.productName',
1542
- workingdays: '$_id.workingdays',
1543
- storeCount: '$storeCount',
1544
- totalZoneCount: '$totalZoneCount',
1545
- totalTrafficCameraCount: '$totalTrafficCameraCount',
1546
- totalZoneCameraCount: '$totalZoneCameraCount',
1547
- },
1548
- },
1549
- {
1550
- // productName first so order is deterministic across views/PDF, then
1551
- // workingdays so step rows stay grouped consistently.
1552
- $sort: {
1553
- productName: 1,
1554
- workingdays: -1,
1555
- },
1556
- },
1557
-
1558
-
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 } },
1559
1570
  ] );
1560
1571
  // Build billingMethod map from group.products
1561
1572
  let billingMethodMap = {};
@@ -1668,133 +1679,100 @@ async function stepPrice( group, getClient ) {
1668
1679
  }
1669
1680
  }
1670
1681
 
1671
- // Filter out eachStore products from aggregated results
1672
- 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 ) );
1673
1684
 
1674
- // Adjust storeCount based on billingType for tangoZone and tangoTraffic (overallStore only)
1675
- products = products.map( ( product ) => {
1676
- let productBillingType = billingTypeMap[product.productName] || 'perStore';
1677
- if ( product.productName === 'tangoZone' ) {
1678
- if ( productBillingType === 'perZone' && product.totalZoneCount > 0 ) {
1679
- product.storeCount = product.totalZoneCount;
1680
- } else if ( productBillingType === 'perCamera' && product.totalZoneCameraCount > 0 ) {
1681
- product.storeCount = product.totalZoneCameraCount;
1682
- }
1683
- } else if ( product.productName === 'tangoTraffic' ) {
1684
- if ( productBillingType === 'perCamera' && product.totalTrafficCameraCount > 0 ) {
1685
- product.storeCount = product.totalTrafficCameraCount;
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 ) => {
1688
+ const aStart = parseInt( String( a.storeRange || '0' ).split( '-' )[0], 10 ) || 0;
1689
+ const bStart = parseInt( String( b.storeRange || '0' ).split( '-' )[0], 10 ) || 0;
1690
+ return aStart - bStart;
1691
+ } );
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;
1686
1710
  }
1687
1711
  }
1688
- delete product.totalZoneCount;
1689
- delete product.totalTrafficCameraCount;
1690
- delete product.totalZoneCameraCount;
1691
- return product;
1692
- } );
1693
-
1694
- let stepPriceRecord = await basepricingService.findOne( { clientId: group.clientId } );
1695
- let data = products;
1696
- let pricing = stepPriceRecord.step;
1697
-
1698
- const applyPricing = ( data, pricing ) => {
1699
- let totalcount = 0;
1700
- return data.map( ( item ) => {
1701
- totalcount = totalcount + item.storeCount;
1712
+ // Beyond the last defined tier -> last tier's price.
1713
+ return Number( tiers[tiers.length - 1].negotiatePrice ) || 0;
1714
+ };
1702
1715
 
1703
- console.log( '======>', item );
1704
- if ( item.workingdays === currentMonthDays ) {
1705
- item.period = 'fullMonth';
1706
- item.runningCost = item.storeCount * item.price;
1707
- } else {
1708
- item.period = 'proRate';
1709
- item.runningCost = ( item.storeCount * ( item.price / currentMonthDays ) * item.workingdays ).toFixed( 2 );
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;
1710
1731
  }
1711
-
1712
- return {
1713
- ...item,
1714
- negotiatePrice: item.price,
1715
- perstorecost: ( ( item.price / currentMonthDays ) * item.workingdays ).toFixed( 2 ),
1716
- };
1717
- } );
1718
- };
1719
- console.log( '---->', data, pricing );
1720
-
1721
-
1722
- function processArray( array1, array2 ) {
1723
- let updatedArray = [];
1724
-
1725
- for ( let item of array1 ) {
1726
- let remainingStores = item.storeCount;
1727
-
1728
- 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;
1743
- }
1732
+ } else if ( s.productName === 'tangoTraffic' ) {
1733
+ if ( billingType === 'perCamera' && s.trafficCameraCount > 0 ) {
1734
+ units = s.trafficCameraCount;
1744
1735
  }
1745
1736
  }
1746
- 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;
1747
1750
  }
1748
1751
 
1749
- let resultarray = processArray( data, pricing );
1750
- console.log( '++++++++++++', resultarray );
1751
- const result = applyPricing( resultarray, pricing );
1752
- console.log( '***********', result );
1753
- const groupedData = result.reduce( ( acc, item ) => {
1754
- const { productName, period, runningCost } = item;
1755
- const key = `${productName}_${period}_${item.storeCount}`;
1756
- if ( !acc[key] ) {
1757
- acc[key] = {
1758
- productName,
1759
- period,
1760
- totalRunningCost: 0,
1761
- count: 0,
1762
- };
1763
- }
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
-
1772
- return acc;
1773
- }, {} );
1774
- console.log( groupedData );
1775
- // Calculating average running cost
1776
- const finalresult = Object.values( groupedData ).map( ( grp ) => {
1752
+ const finalresult = Object.values( grouped ).map( ( grp ) => {
1777
1753
  let description = '';
1778
1754
  if ( grp.productName === 'tangoTraffic' ) {
1779
1755
  description = 'Customer Footfall Analytics';
1780
1756
  } else if ( grp.productName === 'tangoZone' ) {
1781
1757
  description = 'Product category/section analytics';
1782
- } else {
1783
- description = '';
1784
1758
  }
1759
+ const amount = Math.round( grp.totalAmount * 100 ) / 100;
1785
1760
  return {
1786
1761
  productName: grp.productName,
1787
1762
  period: grp.period,
1788
- 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,
1789
1767
  description: description,
1790
1768
  HsnNumber: '998314',
1791
- amount: grp.totalRunningCost,
1769
+ amount: amount,
1792
1770
  month: dayjs().format( 'MMM YYYY' ),
1793
- price: ( grp.totalRunningCost / grp.count ).toFixed( 2 ),
1771
+ price: grp.unitCount ? ( amount / grp.unitCount ).toFixed( 2 ) : '0.00',
1794
1772
  };
1795
1773
  } );
1796
1774
 
1797
- // Combine overallStore and eachStore products
1775
+ // Combine overallStore (per-store tiered) and eachStore products.
1798
1776
  return [ ...finalresult, ...eachStoreProducts ];
1799
1777
  }
1800
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,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 );
@@ -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
  };
@@ -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
+ };