tango-app-api-payment-subscription 3.5.16 → 3.5.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tango-app-api-payment-subscription",
3
- "version": "3.5.16",
3
+ "version": "3.5.17",
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.34",
32
+ "tango-api-schema": "^2.6.35",
33
33
  "tango-app-api-middleware": "^3.6.18",
34
34
  "winston": "^3.12.0",
35
35
  "winston-daily-rotate-file": "^5.0.0",
@@ -2270,3 +2270,330 @@ export async function additionalProducts( req, res ) {
2270
2270
  return res.sendError( error, 500 );
2271
2271
  }
2272
2272
  }
2273
+
2274
+ // POST a JSON payload to a Lambda Function URL and return the parsed JSON
2275
+ // (or false on failure). Node's fetch forbids a body on GET, so POST is used.
2276
+ async function lamdaServiceCall( url, data ) {
2277
+ try {
2278
+ const response = await fetch( url, {
2279
+ method: 'POST',
2280
+ headers: { 'Content-Type': 'application/json' },
2281
+ body: JSON.stringify( data ),
2282
+ signal: AbortSignal.timeout( 300000 ),
2283
+ } );
2284
+ if ( !response.ok ) {
2285
+ throw new Error( `Response status: ${response.status}` );
2286
+ }
2287
+ return await response.json();
2288
+ } catch ( error ) {
2289
+ logger.error( { error: error, message: data, function: 'lamdaServiceCall' } );
2290
+ return false;
2291
+ }
2292
+ }
2293
+
2294
+ // Unit price for a product from the latest billing_details OpenSearch record.
2295
+ // productName match is case-insensitive. Returns 0 if not found / unavailable.
2296
+ async function billingDetailsPrice( clientId, productName ) {
2297
+ try {
2298
+ const osRes = await getOpenSearchData( BILLING_DETAILS_INDEX, {
2299
+ size: 1,
2300
+ query: { term: { 'client_id': clientId } },
2301
+ sort: [ { date_string: { order: 'desc' } } ],
2302
+ } );
2303
+ const hits = osRes?.body?.hits?.hits || osRes?.hits?.hits || [];
2304
+ const match = ( hits[0]?._source?.products || [] )
2305
+ .find( ( p ) => String( p.productName ).toLowerCase() === String( productName ).toLowerCase() );
2306
+ return Number( match?.price ) || 0;
2307
+ } catch ( osErr ) {
2308
+ logger.error( { error: osErr, function: 'billingDetailsPrice', clientId, productName } );
2309
+ return 0;
2310
+ }
2311
+ }
2312
+
2313
+ // Build the Lambda-backed export rows (VMS / Run AI / etc.). Calls the product's
2314
+ // Lambda for store_names (storeIds), maps them to the stores collection for
2315
+ // name + country (Zone), with current-month days and the billing_details price.
2316
+ async function lambdaStoreExport( clientId, lambdaUrl, billingProductName ) {
2317
+ const toDate = dayjs().format( 'DD-MM-YYYY' );
2318
+ const lambdaResult = await lamdaServiceCall( lambdaUrl, { to_date: toDate, client_id: clientId } );
2319
+ console.log( '🚀 ~ lambdaStoreExport ~ lambdaResult:', lambdaResult );
2320
+ // These Lambdas return the storeIds under either `store_ids` (Run AI) or
2321
+ // `store_names` (VMS) with no status_code. Accept whichever is present; data
2322
+ // availability = a non-empty array.
2323
+ const rawIds = ( lambdaResult && Array.isArray( lambdaResult.store_ids ) ) ? lambdaResult.store_ids :
2324
+ ( ( lambdaResult && Array.isArray( lambdaResult.store_names ) ) ? lambdaResult.store_names : [] );
2325
+ const storeIds = rawIds.map( ( s ) => String( s ) );
2326
+ if ( !storeIds.length ) {
2327
+ return [];
2328
+ }
2329
+ const price = await billingDetailsPrice( clientId, billingProductName );
2330
+ const daysInMonth = dayjs().daysInMonth();
2331
+ const stores = await storeService.find(
2332
+ { clientId: clientId, storeId: { $in: storeIds } },
2333
+ { 'storeId': 1, 'storeName': 1, 'storeProfile.country': 1 },
2334
+ );
2335
+ const byId = new Map( stores.map( ( s ) => [ String( s.storeId ), s ] ) );
2336
+ return storeIds.map( ( id ) => {
2337
+ const s = byId.get( String( id ) );
2338
+ return {
2339
+ 'Store Names': s?.storeName || id,
2340
+ 'No of Days to be billed': daysInMonth,
2341
+ 'Final Amount': price,
2342
+ 'Zone': s?.storeProfile?.country || '',
2343
+ };
2344
+ } );
2345
+ }
2346
+
2347
+ // tangoTraffic working days per store, from the latest daily-pricing doc.
2348
+ // Map<storeId, workingdays>; stores with no tangoTraffic record are absent.
2349
+ async function tangoTrafficDaysByStore( clientId ) {
2350
+ const rows = await dailyPriceService.aggregate( [
2351
+ { $match: { clientId: clientId } },
2352
+ { $sort: { dateISO: -1 } },
2353
+ { $limit: 1 },
2354
+ { $unwind: '$stores' },
2355
+ { $unwind: '$stores.products' },
2356
+ { $match: { 'stores.products.productName': 'tangoTraffic' } },
2357
+ { $group: { _id: '$stores.storeId', days: { $max: '$stores.products.workingdays' } } },
2358
+ ] );
2359
+ const map = new Map();
2360
+ for ( const r of rows ) {
2361
+ map.set( String( r._id ), r.days || 0 );
2362
+ }
2363
+ return map;
2364
+ }
2365
+
2366
+ // Per-product detailed Excel export for the Additional Products section.
2367
+ // product selected via req.body.product / req.query.product. Eyetest is the
2368
+ // first; more products plug into the switch as their queries arrive.
2369
+ export async function additionalProductExport( req, res ) {
2370
+ try {
2371
+ const clientId = String( req.body?.clientId ?? req.query?.clientId ?? '' );
2372
+ const product = String( req.body?.product ?? req.query?.product ?? '' ).toLowerCase();
2373
+ // Optional date (YYYY-MM-DD) passed from the UI; defaults to today. Used by
2374
+ // products that report by a specific day (e.g. Remote Optum).
2375
+ const reqDate = String( req.body?.date ?? req.query?.date ?? '' ) || dayjs().format( 'YYYY-MM-DD' );
2376
+ if ( !clientId ) {
2377
+ return res.sendError( 'clientId is required', 400 );
2378
+ }
2379
+
2380
+ if ( product === 'eyetest' ) {
2381
+ // Eye-test streams joined to their store, grouped per store to a stream
2382
+ // count; enriched with store name, country (Zone) and the store's
2383
+ // tangoTraffic working days (No of Days to be billed).
2384
+ const streamRows = await cameraService.aggregate( [
2385
+ { $match: { $and: [
2386
+ { clientId: clientId },
2387
+ { isEyeTestStream: true },
2388
+ { qrCode: { $exists: true } },
2389
+ ] } },
2390
+ { $lookup: {
2391
+ from: 'stores',
2392
+ let: { storeId: '$storeId' },
2393
+ pipeline: [ { $match: { $expr: { $and: [ { $eq: [ '$storeId', '$$storeId' ] } ] } } } ],
2394
+ as: 'stores',
2395
+ } },
2396
+ { $unwind: { path: '$stores', preserveNullAndEmptyArrays: true } },
2397
+ { $group: {
2398
+ _id: '$storeId',
2399
+ streamCount: { $sum: 1 },
2400
+ storeName: { $first: '$stores.storeName' },
2401
+ country: { $first: '$stores.storeProfile.country' },
2402
+ } },
2403
+ { $sort: { storeName: 1 } },
2404
+ ] );
2405
+
2406
+ const daysMap = await tangoTrafficDaysByStore( clientId );
2407
+ const price = ADDITIONAL_PRODUCT_PRICES.eyetest;
2408
+ const exportData = streamRows.map( ( r ) => {
2409
+ const streamCount = r.streamCount || 0;
2410
+ const days = daysMap.get( String( r._id ) ) || 0;
2411
+ // Amount = streams x price (no proration).
2412
+ return {
2413
+ 'storeId': r._id,
2414
+ 'storeName': r.storeName || '',
2415
+ 'Stream Count': streamCount,
2416
+ 'No of Days to be billed': days,
2417
+ 'Amount': streamCount * price,
2418
+ 'Zone': r.country || '',
2419
+ };
2420
+ } );
2421
+
2422
+ if ( !exportData.length ) {
2423
+ return res.sendError( 'No data', 204 );
2424
+ }
2425
+ await download( exportData, res );
2426
+ return;
2427
+ }
2428
+
2429
+ if ( product === 'planogram' ) {
2430
+ // Unique storeName from the planograms collection, enriched with the
2431
+ // store's country (Zone) and tangoTraffic working days. Amount is the
2432
+ // flat planogram price per store.
2433
+ const planoRows = await planogramService.aggregate( [
2434
+ { $match: { clientId: clientId } },
2435
+ { $group: { _id: '$storeName', storeId: { $first: '$storeId' } } },
2436
+ { $lookup: {
2437
+ from: 'stores',
2438
+ let: { sid: '$storeId' },
2439
+ pipeline: [ { $match: { $expr: { $eq: [ '$storeId', '$$sid' ] } } } ],
2440
+ as: 'store',
2441
+ } },
2442
+ { $unwind: { path: '$store', preserveNullAndEmptyArrays: true } },
2443
+ { $project: { _id: 0, storeName: '$_id', storeId: 1, country: '$store.storeProfile.country' } },
2444
+ { $sort: { storeName: 1 } },
2445
+ ] );
2446
+
2447
+ const price = ADDITIONAL_PRODUCT_PRICES.planogram;
2448
+ // Planogram bills the full current month — every store shows the number of
2449
+ // days in the current calendar month (not per-store working days).
2450
+ const daysInMonth = dayjs().daysInMonth();
2451
+ const exportData = planoRows.map( ( r ) => {
2452
+ // Amount = flat planogram price per store (no proration).
2453
+ return {
2454
+ 'storeName': r.storeName || '',
2455
+ 'No of Days to be billed': daysInMonth,
2456
+ 'Amount': price,
2457
+ 'Zone': r.country || '',
2458
+ };
2459
+ } );
2460
+
2461
+ if ( !exportData.length ) {
2462
+ return res.sendError( 'No data', 204 );
2463
+ }
2464
+ await download( exportData, res );
2465
+ return;
2466
+ }
2467
+
2468
+ if ( product === 'aimanager' ) {
2469
+ // AI Manager bills the same stores as tangoTraffic running > 1 day. One
2470
+ // row per such store with its tangoTraffic working days (No of Days to be
2471
+ // billed), the flat AI Manager price, and the store's country (Zone).
2472
+ const aiRows = await dailyPriceService.aggregate( [
2473
+ { $match: { clientId: clientId } },
2474
+ { $sort: { dateISO: -1 } },
2475
+ { $limit: 1 },
2476
+ { $unwind: '$stores' },
2477
+ { $unwind: '$stores.products' },
2478
+ { $match: { 'stores.products.productName': 'tangoTraffic', 'stores.products.workingdays': { $gt: 1 } } },
2479
+ { $group: {
2480
+ _id: '$stores.storeId',
2481
+ storeName: { $first: '$stores.storeName' },
2482
+ days: { $max: '$stores.products.workingdays' },
2483
+ } },
2484
+ { $lookup: {
2485
+ from: 'stores',
2486
+ let: { sid: '$_id' },
2487
+ pipeline: [ { $match: { $expr: { $eq: [ '$storeId', '$$sid' ] } } } ],
2488
+ as: 'store',
2489
+ } },
2490
+ { $unwind: { path: '$store', preserveNullAndEmptyArrays: true } },
2491
+ { $project: { _id: 0, storeId: '$_id', storeName: 1, days: 1, country: '$store.storeProfile.country' } },
2492
+ { $sort: { storeName: 1 } },
2493
+ ] );
2494
+
2495
+ const price = ADDITIONAL_PRODUCT_PRICES.aiManager;
2496
+ const exportData = aiRows.map( ( r ) => {
2497
+ // Amount = flat AI Manager price per store (no proration).
2498
+ return {
2499
+ 'Store Name': r.storeName || '',
2500
+ 'No of Days to be billed': r.days || 0,
2501
+ 'Final Amount': price,
2502
+ 'Zone': r.country || '',
2503
+ };
2504
+ } );
2505
+
2506
+ if ( !exportData.length ) {
2507
+ return res.sendError( 'No data', 204 );
2508
+ }
2509
+ await download( exportData, res );
2510
+ return;
2511
+ }
2512
+
2513
+ if ( product === 'vms' ) {
2514
+ const exportData = await lambdaStoreExport(
2515
+ clientId,
2516
+ 'https://ppf3l3mxc2lorh5hkrsj6zwyim0bupxw.lambda-url.ap-south-1.on.aws/',
2517
+ 'VMS',
2518
+ );
2519
+ if ( !exportData.length ) {
2520
+ return res.sendError( 'No data', 204 );
2521
+ }
2522
+ await download( exportData, res );
2523
+ return;
2524
+ }
2525
+
2526
+ if ( product === 'runai' ) {
2527
+ const exportData = await lambdaStoreExport(
2528
+ clientId,
2529
+ 'https://mdm3mf7wuficgv3jjspkws2nu40azlsc.lambda-url.ap-south-1.on.aws/',
2530
+ 'Run AI',
2531
+ );
2532
+ console.log( '🚀 ~ additionalProductExport ~ exportData:', exportData );
2533
+ if ( !exportData.length ) {
2534
+ return res.sendError( 'No data', 204 );
2535
+ }
2536
+ await download( exportData, res );
2537
+ return;
2538
+ }
2539
+
2540
+ if ( product === 'remoteoptum' ) {
2541
+ // Detailed optom-audit export from the remote_optom_steps_summary index
2542
+ // for the given date (passed from the UI, defaults to today). Each record
2543
+ // is one row; the nested `steps` object is flattened into TRUE/FALSE
2544
+ // columns in a fixed (alphabetical) order.
2545
+ const STEP_COLUMNS = [
2546
+ 'Adjust-Phoropter', 'DuoChrome-Test', 'Explanation', 'Final-Prescription',
2547
+ 'Handover', 'History-Taking', 'JCC', 'Near-Vision', 'Personal-Intro',
2548
+ 'Subjective-Refraction', 'Trial-Frame', 'VA-Check',
2549
+ ];
2550
+ let optomHits = [];
2551
+ try {
2552
+ const osRes = await getOpenSearchData( 'remote_optom_steps_summary', {
2553
+ // A single day is well under the 10k window; pull up to the cap.
2554
+ // NOTE: do NOT sort on StartTime — it's an analyzed text field, and
2555
+ // sorting on it makes OpenSearch return ZERO hits. The day filter is
2556
+ // enough; rows come back in index order.
2557
+ size: 10000,
2558
+ query: { term: { 'Date': reqDate } },
2559
+ } );
2560
+ optomHits = osRes?.body?.hits?.hits || osRes?.hits?.hits || [];
2561
+ } catch ( osErr ) {
2562
+ logger.error( { error: osErr, function: 'additionalProductExport.remoteOptum', clientId } );
2563
+ return res.sendError( 'Failed to fetch Remote Optum data', 502 );
2564
+ }
2565
+
2566
+ const exportData = optomHits.map( ( h ) => {
2567
+ const s = h._source || {};
2568
+ const steps = s.steps || {};
2569
+ const row = {
2570
+ 'storeName': s.storeName || '',
2571
+ 'engagementId': s.engagementId || '',
2572
+ 'queue_id': s.queue_id || '',
2573
+ 'optm_name': s.optm_name || '',
2574
+ 'optm_id': s.optm_id || '',
2575
+ 'optm_Emailid': s.optm_Emailid || '',
2576
+ 'Date': s.Date || '',
2577
+ 'StartTime': s.StartTime || '',
2578
+ 'EndTime': s.EndTime || '',
2579
+ 'Duration (min)': s['Duration (min)'] != null ? s['Duration (min)'] : '',
2580
+ };
2581
+ for ( const col of STEP_COLUMNS ) {
2582
+ row[col] = steps[col] === true ? 'TRUE' : 'FALSE';
2583
+ }
2584
+ return row;
2585
+ } );
2586
+
2587
+ if ( !exportData.length ) {
2588
+ return res.sendError( 'No data', 204 );
2589
+ }
2590
+ await download( exportData, res );
2591
+ return;
2592
+ }
2593
+
2594
+ return res.sendError( `Unsupported product: ${product}`, 400 );
2595
+ } catch ( error ) {
2596
+ logger.error( { error: error, function: 'additionalProductExport' } );
2597
+ return res.sendError( error, 500 );
2598
+ }
2599
+ }
@@ -292,8 +292,11 @@ export async function createInvoice( req, res ) {
292
292
  // multi-month expansion so it's billed ONCE per invoice (not per month).
293
293
  // It's a normal line item, so it lands in the taxable subtotal below.
294
294
  try {
295
+ // Use the group's basepricing doc when group-wise pricing applies,
296
+ // else the brand-level doc.
297
+ const { query: oneTimeQuery } = await resolveBasePricingScope( group, getClient );
295
298
  const oneTimeBp = await basepricingService.findOne(
296
- { clientId: group.clientId },
299
+ oneTimeQuery,
297
300
  { oneTimeFeePerStore: 1 },
298
301
  );
299
302
  const oneTimeFeePerStore = Number( oneTimeBp?.oneTimeFeePerStore ) || 0;
@@ -526,7 +529,7 @@ async function buildAnnexureRows( invoiceInfo, getgroup ) {
526
529
  // converting here would make the annexure unit price disagree with the actual
527
530
  // billed amount (e.g. a $45 negotiated price wrongly shown as $0.48).
528
531
 
529
- const annexClient = await clientService.findOne( { clientId: invoiceInfo.clientId }, { 'planDetails.product': 1, 'priceType': 1 } );
532
+ const annexClient = await clientService.findOne( { clientId: invoiceInfo.clientId }, { 'planDetails.product': 1, 'priceType': 1, 'billingGroupWisePricing': 1 } );
530
533
  const billingTypeMap = {};
531
534
  ( annexClient?.planDetails?.product || [] ).forEach( ( p ) => {
532
535
  billingTypeMap[p.productName] = p.billingType || 'perStore';
@@ -538,7 +541,18 @@ async function buildAnnexureRows( invoiceInfo, getgroup ) {
538
541
  // appear once per tier (e.g. the same store listed at $45 and again at $40).
539
542
  // Instead we pull the tiers here and assign each store a single tier price
540
543
  // below, in JS, by its 1-based index within the product.
541
- const pricingDoc = await basepricingService.findOne( { clientId: invoiceInfo.clientId }, { standard: 1, step: 1 } );
544
+ // Group-wise pricing: read the billing group's own doc when enabled and one
545
+ // exists; otherwise the brand-level doc.
546
+ let pricingDocQuery = { clientId: invoiceInfo.clientId, groupId: { $exists: false } };
547
+ if ( annexClient?.billingGroupWisePricing && getgroup?._id ) {
548
+ const grpDoc = await basepricingService.findOne(
549
+ { clientId: invoiceInfo.clientId, groupId: String( getgroup._id ) }, { _id: 1 },
550
+ );
551
+ if ( grpDoc ) {
552
+ pricingDocQuery = { clientId: invoiceInfo.clientId, groupId: String( getgroup._id ) };
553
+ }
554
+ }
555
+ const pricingDoc = await basepricingService.findOne( pricingDocQuery, { standard: 1, step: 1 } );
542
556
  const isStep = annexClient?.priceType === 'step';
543
557
  const tiersByProduct = {};
544
558
  ( ( isStep ? pricingDoc?.step : pricingDoc?.standard ) || [] ).forEach( ( p ) => {
@@ -1080,6 +1094,25 @@ function inWords( num ) {
1080
1094
  }
1081
1095
 
1082
1096
 
1097
+ // Resolve which basepricing doc applies to a billing group. When the client
1098
+ // has billingGroupWisePricing enabled AND a doc exists for this group, returns
1099
+ // that group's doc; otherwise falls back to the brand-level doc (groupId unset).
1100
+ // Returns { query, groupId } where query is the mongo filter for the chosen doc
1101
+ // and groupId is the group id to use in aggregation lookups (or null for brand).
1102
+ async function resolveBasePricingScope( group, getClient ) {
1103
+ const brandQuery = { clientId: group.clientId, groupId: { $exists: false } };
1104
+ if ( getClient?.billingGroupWisePricing && group?._id ) {
1105
+ const groupIdStr = String( group._id );
1106
+ const groupDoc = await basepricingService.findOne(
1107
+ { clientId: group.clientId, groupId: groupIdStr }, { _id: 1 },
1108
+ );
1109
+ if ( groupDoc ) {
1110
+ return { query: { clientId: group.clientId, groupId: groupIdStr }, groupId: groupIdStr };
1111
+ }
1112
+ }
1113
+ return { query: brandQuery, groupId: null };
1114
+ }
1115
+
1083
1116
  async function standardPrice( group, getClient, baseDate ) {
1084
1117
  console.log( '🚀 ~ standardPrice ~ baseDate:', baseDate.format( 'MMM YYYY' ) );
1085
1118
  const currentMonthDays = dayjs().daysInMonth();
@@ -1088,6 +1121,9 @@ async function standardPrice( group, getClient, baseDate ) {
1088
1121
  // Computed once so the aggregation pipelines can inline a $literal.
1089
1122
  const isFlatPricing = group.proRata === 'flat';
1090
1123
  console.log( '🚀 ~ standardPrice ~ isFlatPricing:', isFlatPricing );
1124
+ // Which basepricing doc applies (group-wise vs brand-level). pricingGroupId is
1125
+ // used in the $lookup pipeline so the join picks the right doc.
1126
+ const { groupId: pricingGroupId } = await resolveBasePricingScope( group, getClient );
1091
1127
  let billingTypeMap = {};
1092
1128
  if ( getClient?.planDetails?.product ) {
1093
1129
  getClient.planDetails.product.forEach( ( p ) => {
@@ -1224,7 +1260,14 @@ async function standardPrice( group, getClient, baseDate ) {
1224
1260
  {
1225
1261
  $match: {
1226
1262
  $expr: {
1227
- $eq: [ '$clientId', '$$clientId' ],
1263
+ $and: [
1264
+ { $eq: [ '$clientId', '$$clientId' ] },
1265
+ // Match the resolved doc: a specific group's doc when group-wise
1266
+ // pricing applies, else the brand-level doc (no groupId).
1267
+ pricingGroupId ?
1268
+ { $eq: [ '$groupId', pricingGroupId ] } :
1269
+ { $not: [ { $ifNull: [ '$groupId', false ] } ] },
1270
+ ],
1228
1271
  },
1229
1272
  },
1230
1273
  },
@@ -1419,7 +1462,12 @@ async function standardPrice( group, getClient, baseDate ) {
1419
1462
  from: 'basepricings',
1420
1463
  let: { clientId: group.clientId },
1421
1464
  pipeline: [
1422
- { $match: { $expr: { $eq: [ '$clientId', '$$clientId' ] } } },
1465
+ { $match: { $expr: { $and: [
1466
+ { $eq: [ '$clientId', '$$clientId' ] },
1467
+ pricingGroupId ?
1468
+ { $eq: [ '$groupId', pricingGroupId ] } :
1469
+ { $not: [ { $ifNull: [ '$groupId', false ] } ] },
1470
+ ] } } },
1423
1471
  { $project: { standard: 1 } },
1424
1472
  ],
1425
1473
  as: 'basepricing',
@@ -1527,6 +1575,8 @@ async function stepPrice( group, getClient ) {
1527
1575
  // 'flat' => every store billed for full month.
1528
1576
  // 'prorate' => actual working days. See standardPrice for the same flag.
1529
1577
  const isFlatPricing = group.proRata === 'flat';
1578
+ // Which basepricing doc applies (group-wise vs brand-level).
1579
+ const { query: pricingDocQuery, groupId: pricingGroupId } = await resolveBasePricingScope( group, getClient );
1530
1580
  let billingTypeMap = {};
1531
1581
  if ( getClient?.planDetails?.product ) {
1532
1582
  getClient.planDetails.product.forEach( ( p ) => {
@@ -1628,7 +1678,12 @@ async function stepPrice( group, getClient ) {
1628
1678
  from: 'basepricings',
1629
1679
  let: { clientId: group.clientId },
1630
1680
  pipeline: [
1631
- { $match: { $expr: { $eq: [ '$clientId', '$$clientId' ] } } },
1681
+ { $match: { $expr: { $and: [
1682
+ { $eq: [ '$clientId', '$$clientId' ] },
1683
+ pricingGroupId ?
1684
+ { $eq: [ '$groupId', pricingGroupId ] } :
1685
+ { $not: [ { $ifNull: [ '$groupId', false ] } ] },
1686
+ ] } } },
1632
1687
  { $project: { step: 1 } },
1633
1688
  ],
1634
1689
  as: 'basepricing',
@@ -1637,7 +1692,7 @@ async function stepPrice( group, getClient ) {
1637
1692
  { $unwind: { path: '$basepricing', preserveNullAndEmptyArrays: true } },
1638
1693
  ] );
1639
1694
 
1640
- let stepPriceData = await basepricingService.findOne( { clientId: group.clientId } );
1695
+ let stepPriceData = await basepricingService.findOne( pricingDocQuery );
1641
1696
  let pricingRanges = stepPriceData?.step || [];
1642
1697
  let defaultPrice = pricingRanges.length > 0 ? pricingRanges[0].negotiatePrice : 0;
1643
1698
 
@@ -1682,7 +1737,7 @@ async function stepPrice( group, getClient ) {
1682
1737
  // Drop eachStore products — those are handled by the per-store branch above.
1683
1738
  perStoreRows = perStoreRows.filter( ( p ) => !eachStoreProductNames.includes( p.productName ) );
1684
1739
 
1685
- const stepPriceRecord = await basepricingService.findOne( { clientId: group.clientId } );
1740
+ const stepPriceRecord = await basepricingService.findOne( pricingDocQuery );
1686
1741
  // Tiers ordered by range start so per-store tier assignment walks low-to-high.
1687
1742
  const pricing = ( stepPriceRecord?.step || [] ).slice().sort( ( a, b ) => {
1688
1743
  const aStart = parseInt( String( a.storeRange || '0' ).split( '-' )[0], 10 ) || 0;
@@ -85,6 +85,7 @@ export const clientBillingSubscriptionInfo = async ( req, res, next ) => {
85
85
  billingDetails: 1,
86
86
  price: 1,
87
87
  priceType: 1,
88
+ billingGroupWisePricing: 1,
88
89
  virtualAccount: 1,
89
90
  paymentInvoice: 1,
90
91
  },
@@ -259,6 +260,7 @@ export const clientBillingSubscriptionInfo = async ( req, res, next ) => {
259
260
  currentPlanInfo.dueLimitReached = getPI;
260
261
  currentPlanInfo.price = clientInfo[0].price || '--';
261
262
  currentPlanInfo.priceType = clientInfo[0].priceType || '--';
263
+ currentPlanInfo.billingGroupWisePricing = clientInfo[0].billingGroupWisePricing || false;
262
264
  currentPlanInfo.subscriptionType = clientInfo[0].planDetails.subscriptionType || '--';
263
265
  currentPlanInfo.subscriptionPeriod = clientInfo[0].planDetails.subscriptionPeriod || '--';
264
266
  currentPlanInfo.storeCount = storeCount || '--';
@@ -2246,7 +2248,15 @@ export const invoiceList = async ( req, res ) => {
2246
2248
 
2247
2249
  export const priceList = async ( req, res ) => {
2248
2250
  try {
2249
- let pricingDetails = await basePricingService.findOne( { clientId: { $exists: true }, clientId: req.body.clientId }, { standard: 1, step: 1, oneTimeFeePerStore: 1 } );
2251
+ // Group-wise pricing: when a groupId is sent, read that group's own
2252
+ // basepricing doc; otherwise read the brand-level doc (groupId unset).
2253
+ const priceQuery = { clientId: req.body.clientId };
2254
+ if ( req.body.groupId ) {
2255
+ priceQuery.groupId = req.body.groupId;
2256
+ } else {
2257
+ priceQuery.groupId = { $exists: false };
2258
+ }
2259
+ let pricingDetails = await basePricingService.findOne( priceQuery, { standard: 1, step: 1, oneTimeFeePerStore: 1 } );
2250
2260
  if ( !pricingDetails ) {
2251
2261
  return res.sendError( 'no data found', 204 );
2252
2262
  }
@@ -2360,7 +2370,15 @@ export const priceList = async ( req, res ) => {
2360
2370
 
2361
2371
  export const pricingListUpdate = async ( req, res ) => {
2362
2372
  try {
2363
- let getPriceInfo = await basePricingService.findOne( { clientId: { $exists: true }, clientId: req.body.clientId }, { standard: 1, step: 1 } );
2373
+ // Group-wise pricing: target the group's own basepricing doc when a groupId
2374
+ // is sent; otherwise the brand-level doc (groupId unset).
2375
+ const priceQuery = { clientId: req.body.clientId };
2376
+ if ( req.body.groupId ) {
2377
+ priceQuery.groupId = req.body.groupId;
2378
+ } else {
2379
+ priceQuery.groupId = { $exists: false };
2380
+ }
2381
+ let getPriceInfo = await basePricingService.findOne( priceQuery, { standard: 1, step: 1 } );
2364
2382
  let findClient = await paymentService.findOneClient( { clientId: req.body.clientId } );
2365
2383
 
2366
2384
  console.log( getPriceInfo );
@@ -2370,8 +2388,10 @@ export const pricingListUpdate = async ( req, res ) => {
2370
2388
  pricingType: findClient.priceType,
2371
2389
  };
2372
2390
 
2373
- if ( findClient.priceType==='standard' ) {
2374
- getPriceInfo.standard.map( ( item ) => {
2391
+ // Old-data snapshot for the audit log. Guard on getPriceInfo: a brand-new
2392
+ // billing-group doc has none yet, and the no-doc path is handled below.
2393
+ if ( getPriceInfo && findClient.priceType==='standard' ) {
2394
+ ( getPriceInfo.standard || [] ).map( ( item ) => {
2375
2395
  oldData = {
2376
2396
  ...oldData,
2377
2397
  [item.productName+' '+'negotiatePrice']: item.negotiatePrice,
@@ -2383,8 +2403,8 @@ export const pricingListUpdate = async ( req, res ) => {
2383
2403
  };
2384
2404
  }
2385
2405
  } );
2386
- } else {
2387
- getPriceInfo.step.map( ( item ) => {
2406
+ } else if ( getPriceInfo ) {
2407
+ ( getPriceInfo.step || [] ).map( ( item ) => {
2388
2408
  oldData = {
2389
2409
  ...oldData,
2390
2410
  [item.productName+' '+'negotiatePrice']: item.negotiatePrice,
@@ -2496,6 +2516,13 @@ export const pricingListUpdate = async ( req, res ) => {
2496
2516
  if ( req.body.oneTimeFeePerStore != null && req.body.oneTimeFeePerStore !== '' ) {
2497
2517
  getPriceInfo.oneTimeFeePerStore = Number( req.body.oneTimeFeePerStore ) || 0;
2498
2518
  }
2519
+ // Keep the group identity on the doc when saving group-wise pricing.
2520
+ if ( req.body.groupId ) {
2521
+ getPriceInfo.groupId = req.body.groupId;
2522
+ if ( req.body.groupName ) {
2523
+ getPriceInfo.groupName = req.body.groupName;
2524
+ }
2525
+ }
2499
2526
  getPriceInfo.save().then( async () => {
2500
2527
  let clientDetails = await paymentService.findOne( { clientId: req.body.clientId }, { priceType: 1, paymentInvoice: 1, planDetails: 1 } );
2501
2528
  clientDetails.priceType = req.body.type;
@@ -2566,7 +2593,15 @@ export const pricingListUpdate = async ( req, res ) => {
2566
2593
 
2567
2594
  async function updatePricing( req, res, update ) {
2568
2595
  let baseProduct = await basePricingService.findOne( { clientId: { $exists: false } }, { basePricing: 1 } );
2569
- let getPriceInfo = await basePricingService.findOne( { clientId: { $exists: true }, clientId: req.body.clientId }, { standard: 1, step: 1 } );
2596
+ // Group-wise pricing: scope the doc to the group when a groupId is sent;
2597
+ // otherwise the brand-level doc (groupId unset).
2598
+ const pricingDocQuery = { clientId: req.body.clientId };
2599
+ if ( req.body.groupId ) {
2600
+ pricingDocQuery.groupId = req.body.groupId;
2601
+ } else {
2602
+ pricingDocQuery.groupId = { $exists: false };
2603
+ }
2604
+ let getPriceInfo = await basePricingService.findOne( pricingDocQuery, { standard: 1, step: 1 } );
2570
2605
  let clientDetails = await paymentService.findOne( { clientId: req.body.clientId } );
2571
2606
  if ( clientDetails ) {
2572
2607
  let products = clientDetails.planDetails.product.map( ( item ) => item.productName );
@@ -2637,12 +2672,20 @@ async function updatePricing( req, res, update ) {
2637
2672
  step: stepList,
2638
2673
  clientId: req.body.clientId,
2639
2674
  };
2675
+ // Stamp the group identity so group-wise pricing docs are distinguishable
2676
+ // from the brand-level doc.
2677
+ if ( req.body.groupId ) {
2678
+ data.groupId = req.body.groupId;
2679
+ if ( req.body.groupName ) {
2680
+ data.groupName = req.body.groupName;
2681
+ }
2682
+ }
2640
2683
  console.log( '🚀 ~ updatePricing ~ data:', data );
2641
2684
  if ( !getPriceInfo ) {
2642
2685
  await basePricingService.create( data );
2643
2686
  } else {
2644
2687
  delete data.clientId;
2645
- await basePricingService.updateOne( { clientId: req.body.clientId }, data );
2688
+ await basePricingService.updateOne( pricingDocQuery, data );
2646
2689
  }
2647
2690
  let product = [];
2648
2691
  let clientId = req.body.clientId;
@@ -4235,3 +4278,20 @@ export async function getClientDocuments( req, res ) {
4235
4278
  }
4236
4279
  }
4237
4280
 
4281
+ // Toggle billing-group-wise pricing on the client. When enabled, pricing is
4282
+ // maintained per billing group (separate basepricing docs keyed by groupId).
4283
+ export async function setBillingGroupWisePricing( req, res ) {
4284
+ try {
4285
+ const clientId = String( req.body?.clientId || '' );
4286
+ const enabled = req.body?.enabled === true || req.body?.enabled === 'true';
4287
+ if ( !clientId ) {
4288
+ return res.sendError( 'clientId is required', 400 );
4289
+ }
4290
+ await paymentService.updateOne( { clientId }, { billingGroupWisePricing: enabled } );
4291
+ return res.sendSuccess( { clientId, billingGroupWisePricing: enabled } );
4292
+ } catch ( error ) {
4293
+ logger.error( { error: error, function: 'setBillingGroupWisePricing' } );
4294
+ return res.sendError( error, 500 );
4295
+ }
4296
+ }
4297
+
@@ -154,6 +154,10 @@ export const validatePriceSchema = joi.object( {
154
154
  clientId: joi.string().required(),
155
155
  products: joi.array().optional(),
156
156
  oneTimeFeePerStore: joi.number().optional().allow( null, '' ),
157
+ // Billing-group-wise pricing: when present, the pricing is saved to / read
158
+ // from the group's own basepricing doc instead of the brand-level one.
159
+ groupId: joi.string().optional().allow( null, '' ),
160
+ groupName: joi.string().optional().allow( null, '' ),
157
161
  pricing: joi.array().items( joi.object( {
158
162
  productName: joi.string().required(),
159
163
  negotiatePrice: joi.number().required(),
@@ -178,6 +182,8 @@ export const revisedParams = {
178
182
  export const validatePriceListSchema = joi.object( {
179
183
  priceType: joi.string().required(),
180
184
  clientId: joi.string().required(),
185
+ // Optional billing group filter for group-wise pricing.
186
+ groupId: joi.string().optional().allow( null, '' ),
181
187
  } );
182
188
 
183
189
  export const validatePriceListParams = {
@@ -1,6 +1,6 @@
1
1
 
2
2
  import express from 'express';
3
- import { brandsBillingList, brandInvoiceList, latestDailyPricing, brandBillingGroups, updateDailyPricingWorkingDays, updateDailyPricingStoreField, getClientBillingInfo, bulkDownloadBillingGroups, bulkUpdateBillingGroups, billingSummary, additionalProducts } from '../controllers/brandsBilling.controller.js';
3
+ import { brandsBillingList, brandInvoiceList, latestDailyPricing, brandBillingGroups, updateDailyPricingWorkingDays, updateDailyPricingStoreField, getClientBillingInfo, bulkDownloadBillingGroups, bulkUpdateBillingGroups, billingSummary, additionalProducts, additionalProductExport } from '../controllers/brandsBilling.controller.js';
4
4
  import { isAllowedSessionHandler, accessVerification } from 'tango-app-api-middleware';
5
5
 
6
6
  export const brandsBillingRouter = express.Router();
@@ -17,3 +17,4 @@ brandsBillingRouter.post( '/bulk-update-billing-groups', isAllowedSessionHandler
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
19
  brandsBillingRouter.get( '/additionalProducts', isAllowedSessionHandler, accessVerification( { userType: [ 'tango' ], access: [ { featureName: 'TangoAdmin', name: 'invoiceApproval', permissions: [] } ] } ), additionalProducts );
20
+ brandsBillingRouter.get( '/additionalProductExport', isAllowedSessionHandler, accessVerification( { userType: [ 'tango' ], access: [ { featureName: 'TangoAdmin', name: 'invoiceApproval', permissions: [] } ] } ), additionalProductExport );
@@ -155,4 +155,7 @@ paymentSubscriptionRouter.post( '/createDefaultbillings', paymentController.crea
155
155
  paymentSubscriptionRouter.post( '/client-document/upload', isAllowedSessionHandler, accessVerification( { userType: [ 'tango' ], access: [ { featureName: 'Global', name: 'Subscription', permissions: [ 'isEdit' ] } ] } ), documentUpload.single( 'file' ), paymentController.uploadClientDocument );
156
156
  paymentSubscriptionRouter.get( '/client-document/list', isAllowedSessionHandler, accessVerification( { userType: [ 'tango' ], access: [ { featureName: 'Global', name: 'Subscription', permissions: [] } ] } ), paymentController.getClientDocuments );
157
157
 
158
+ // Toggle billing-group-wise pricing for a client (tango only, edit perm).
159
+ paymentSubscriptionRouter.post( '/billingGroupWisePricing', isAllowedSessionHandler, accessVerification( { userType: [ 'tango' ], access: [ { featureName: 'Global', name: 'Subscription', permissions: [ 'isEdit' ] } ] } ), paymentController.setBillingGroupWisePricing );
160
+
158
161