tango-app-api-payment-subscription 3.5.15 → 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.15",
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.29",
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",
@@ -582,6 +582,11 @@ export async function brandInvoiceList( req, res ) {
582
582
  let summary = {
583
583
  totalInvoices: allInvoices.length,
584
584
  totalInvoiced: allInvoices.reduce( ( sum, inv ) => sum + ( inv.totalAmount || 0 ), 0 ),
585
+ // Footer totals over the FULL filtered set (not just the current page):
586
+ // stores, amount excl. GST and amount incl. GST.
587
+ totalStores: allInvoices.reduce( ( sum, inv ) => sum + ( inv.stores || 0 ), 0 ),
588
+ totalAmountExclGst: allInvoices.reduce( ( sum, inv ) => sum + ( inv.amount || 0 ), 0 ),
589
+ totalAmountInclGst: allInvoices.reduce( ( sum, inv ) => sum + ( inv.totalAmount || 0 ), 0 ),
585
590
  pendingApproval: allInvoices.filter( ( inv ) => [ 'pendingCsm', 'pendingFinance', 'pendingApproval' ].includes( inv.status ) ).length,
586
591
  pendingPayment: allInvoices.filter( ( inv ) => inv.paymentStatus === 'unpaid' && inv.status === 'approved' ).length,
587
592
  paid: allInvoices.filter( ( inv ) => inv.paymentStatus === 'paid' ).length,
@@ -891,6 +896,32 @@ export async function latestDailyPricing( req, res ) {
891
896
  } },
892
897
  ] );
893
898
 
899
+ // Newly onboarded stores = stores whose FIRST FILE date falls in the current
900
+ // calendar month. Counted over the FULL store list (record.stores), not the
901
+ // status/search-filtered view, so the card reflects all new onboardings.
902
+ // First-file date is edgefirstFileDate, with processfirstFileDate as the
903
+ // fallback (same rule the annexure uses); some legacy rows store it as a
904
+ // string, so coerce defensively.
905
+ const monthStart = dayjs().startOf( 'month' );
906
+ const monthEnd = dayjs().endOf( 'month' );
907
+ const newlyOnboardedStoreList = ( record.stores || [] )
908
+ .map( ( s ) => {
909
+ const raw = s.edgefirstFileDate || s.processfirstFileDate;
910
+ const ff = raw ? dayjs( raw ) : null;
911
+ return { store: s, ff };
912
+ } )
913
+ .filter( ( x ) => x.ff && x.ff.isValid() &&
914
+ x.ff.isAfter( monthStart.subtract( 1, 'millisecond' ) ) &&
915
+ x.ff.isBefore( monthEnd.add( 1, 'millisecond' ) ) )
916
+ .map( ( x ) => ( {
917
+ storeId: x.store.storeId,
918
+ storeName: x.store.storeName,
919
+ status: x.store.status,
920
+ firstFileDate: x.ff.format( 'YYYY-MM-DD' ),
921
+ } ) )
922
+ .sort( ( a, b ) => a.firstFileDate.localeCompare( b.firstFileDate ) );
923
+ const newlyOnboardedStores = newlyOnboardedStoreList.length;
924
+
894
925
  let data = {
895
926
  clientId: record.clientId,
896
927
  brandName: record.brandName,
@@ -901,6 +932,8 @@ export async function latestDailyPricing( req, res ) {
901
932
  status: record.status,
902
933
  proRate: record.proRate,
903
934
  count,
935
+ newlyOnboardedStores,
936
+ newlyOnboardedStoreList,
904
937
  data: storeList,
905
938
  monthlyBillingSummary,
906
939
  };
@@ -2237,3 +2270,330 @@ export async function additionalProducts( req, res ) {
2237
2270
  return res.sendError( error, 500 );
2238
2271
  }
2239
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
+ }