tango-app-api-payment-subscription 3.5.22 → 3.5.23

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.22",
3
+ "version": "3.5.23",
4
4
  "description": "paymentSubscription",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -20,12 +20,12 @@
20
20
  "dotenv": "^16.4.5",
21
21
  "exceljs": "^4.4.0",
22
22
  "express": "^4.18.3",
23
+ "express-fileupload": "^1.5.2",
23
24
  "handlebars": "^4.7.8",
24
25
  "html-pdf-node": "^1.0.8",
25
26
  "joi-to-swagger": "^6.2.0",
26
27
  "jsdom": "^24.0.0",
27
28
  "mongodb": "^6.4.0",
28
- "multer": "^1.4.5-lts.1",
29
29
  "nodemon": "^3.1.0",
30
30
  "puppeteer": "^24.41.0",
31
31
  "swagger-ui-express": "^5.0.0",
@@ -8,7 +8,7 @@ import * as basePriceService from '../services/basePrice.service.js';
8
8
  import * as cameraService from '../services/camera.service.js';
9
9
  import * as planogramService from '../services/planogram.service.js';
10
10
  import dayjs from 'dayjs';
11
- import { logger, checkFileExist, signedUrl, download, insertOpenSearchData, getOpenSearchData } from 'tango-app-api-middleware';
11
+ import { logger, checkFileExist, signedUrl, download, insertOpenSearchData, getOpenSearchData, searchOpenSearchData, scrollResponse, clearScroll } from 'tango-app-api-middleware';
12
12
  import * as XLSX from 'xlsx';
13
13
  import ExcelJS from 'exceljs';
14
14
  import { bulkUpdateBillingGroupRowSchema } from '../dtos/validation.dtos.js';
@@ -2185,6 +2185,11 @@ const ADDITIONAL_PRODUCT_PRICES = {
2185
2185
  planogram: 770,
2186
2186
  aiManager: 500,
2187
2187
  };
2188
+ // Product Lambda Function URLs (VMS / Run AI). Each returns the list of billable
2189
+ // store IDs for a client; used both for the detailed export and for the invoice
2190
+ // additional-products quantity (scoped to a billing group's stores).
2191
+ const RUN_AI_LAMBDA_URL = 'https://mdm3mf7wuficgv3jjspkws2nu40azlsc.lambda-url.ap-south-1.on.aws/';
2192
+ const VMS_LAMBDA_URL = 'https://ppf3l3mxc2lorh5hkrsj6zwyim0bupxw.lambda-url.ap-south-1.on.aws/';
2188
2193
  // OpenSearch index holding extra billed products (VMS, Run AI, etc.). Each
2189
2194
  // record is a daily snapshot carrying its own quantity + price per product.
2190
2195
  const BILLING_DETAILS_INDEX = ( () => {
@@ -2199,12 +2204,37 @@ const BILLING_DETAILS_INDEX = ( () => {
2199
2204
  // that has none (currently only clientId 11). Shared by the Billing Breakdown
2200
2205
  // table (additionalProducts controller) AND invoice generation, so both stay in
2201
2206
  // sync. Each entry: { productName, quantity, price, total }.
2202
- export async function getAdditionalProducts( clientId ) {
2207
+ //
2208
+ // storeIds (optional): when a non-empty array is passed, the Eyetest / Planogram
2209
+ // / AI Manager quantities are restricted to those stores only — used by invoice
2210
+ // generation so a billing group is billed for its assigned stores, not the whole
2211
+ // brand. When omitted (e.g. the brand-wide Billing Breakdown endpoint) the counts
2212
+ // stay client-wide.
2213
+ //
2214
+ // Run AI / VMS are sourced by caller: invoice generation (storeIds passed) uses
2215
+ // the product Lambdas (called brand-wide, then intersected with the group's
2216
+ // stores locally); the brand-wide Billing Breakdown (no storeIds) uses the
2217
+ // billing_details snapshot instead.
2218
+ //
2219
+ // Remote Optum is store-scoped for invoices too: its quantity becomes the count
2220
+ // of remote_optom_steps_summary records for the group over the latest inserted
2221
+ // month in that index (see remoteOptumRecordCount). Factory is dropped from
2222
+ // invoices entirely. The brand-wide Billing Breakdown keeps taking Remote Optum /
2223
+ // Factory from the billing_details snapshot as-is.
2224
+ export async function getAdditionalProducts( clientId, storeIds ) {
2203
2225
  clientId = String( clientId ?? '' );
2204
2226
  if ( clientId !== ADDITIONAL_PRODUCTS_CLIENT_ID ) {
2205
2227
  return [];
2206
2228
  }
2207
2229
 
2230
+ // Restrict the Mongo aggregations to the billing group's stores when provided.
2231
+ // group.stores holds storeIds, matched via $in against each collection's
2232
+ // storeId field (top-level for cameras/planograms, stores.storeId once
2233
+ // unwound for daily-pricing).
2234
+ const scopeStores = Array.isArray( storeIds ) && storeIds.length;
2235
+ const storeMatch = scopeStores ? { storeId: { $in: storeIds } } : {};
2236
+ const unwoundStoreMatch = scopeStores ? { 'stores.storeId': { $in: storeIds } } : {};
2237
+
2208
2238
  // Eyetest: distinct stores in the cameras collection that have an eye-test
2209
2239
  // stream with a QR code.
2210
2240
  const eyetestRows = await cameraService.aggregate( [
@@ -2212,6 +2242,7 @@ export async function getAdditionalProducts( clientId ) {
2212
2242
  { clientId: clientId },
2213
2243
  { isEyeTestStream: true },
2214
2244
  { qrCode: { $exists: true } },
2245
+ storeMatch,
2215
2246
  ] } },
2216
2247
  { $group: { _id: '$storeId' } },
2217
2248
  { $count: 'stores' },
@@ -2220,7 +2251,7 @@ export async function getAdditionalProducts( clientId ) {
2220
2251
 
2221
2252
  // Planogram: distinct storeName in the planograms collection.
2222
2253
  const planogramRows = await planogramService.aggregate( [
2223
- { $match: { clientId: clientId } },
2254
+ { $match: { clientId: clientId, ...storeMatch } },
2224
2255
  { $group: { _id: null, storecount: { $addToSet: '$storeName' } } },
2225
2256
  { $project: { count: { $size: '$storecount' } } },
2226
2257
  ] );
@@ -2234,6 +2265,7 @@ export async function getAdditionalProducts( clientId ) {
2234
2265
  { $sort: { dateISO: -1 } },
2235
2266
  { $limit: 1 },
2236
2267
  { $unwind: '$stores' },
2268
+ ...( scopeStores ? [ { $match: unwoundStoreMatch } ] : [] ),
2237
2269
  { $unwind: '$stores.products' },
2238
2270
  { $match: { 'stores.products.productName': 'tangoTraffic', 'stores.products.workingdays': { $gt: 1 } } },
2239
2271
  { $group: { _id: '$stores.storeId' } },
@@ -2254,10 +2286,45 @@ export async function getAdditionalProducts( clientId ) {
2254
2286
  build( 'AI Manager', aiManagerQty, ADDITIONAL_PRODUCT_PRICES.aiManager ),
2255
2287
  ];
2256
2288
 
2289
+ // Run AI and VMS quantities come from two different sources depending on the
2290
+ // caller:
2291
+ // - Invoice generation (storeIds passed): use the product Lambdas. The
2292
+ // Lambda's own store_ids filter is unreliable, so lambdaStoreCount calls it
2293
+ // brand-wide and intersects the returned store list with the group's stores
2294
+ // locally — giving a per-group scoped count. Unit price from billing_details.
2295
+ // - Brand-wide Billing Breakdown (no storeIds): use the billing_details
2296
+ // OpenSearch snapshot instead (handled by the loop below), same as Remote
2297
+ // Optum / Factory.
2298
+ // lambdaSourced marks which productNames the loop below must skip so they are
2299
+ // not counted twice — only populated in the invoice (scoped) case.
2300
+ const lambdaSourced = new Set();
2301
+ if ( scopeStores ) {
2302
+ const [ runAiQty, vmsQty, runAiPrice, vmsPrice, optumQty, optumPrice ] = await Promise.all( [
2303
+ lambdaStoreCount( clientId, RUN_AI_LAMBDA_URL, storeIds ),
2304
+ lambdaStoreCount( clientId, VMS_LAMBDA_URL, storeIds ),
2305
+ billingDetailsPrice( clientId, 'Run AI' ),
2306
+ billingDetailsPrice( clientId, 'VMS' ),
2307
+ remoteOptumRecordCount( clientId, storeIds ),
2308
+ billingDetailsPrice( clientId, 'Remote Optum' ),
2309
+ ] );
2310
+ products.push( build( 'Run AI', runAiQty, runAiPrice ) );
2311
+ products.push( build( 'VMS', vmsQty, vmsPrice ) );
2312
+ products.push( build( 'Remote Optum', optumQty, optumPrice ) );
2313
+ // Skipped in the billing_details loop below so they are not counted twice.
2314
+ lambdaSourced.add( 'run ai' );
2315
+ lambdaSourced.add( 'vms' );
2316
+ lambdaSourced.add( 'remote optum' );
2317
+ }
2318
+
2257
2319
  // Extra products from the billing_details OpenSearch index — the LATEST daily
2258
2320
  // snapshot for this client. quantity/price are stored as strings, so coerce
2259
- // them. If OpenSearch is unreachable we just skip these rows rather than
2260
- // failing the whole list.
2321
+ // them. In the invoice (scoped) case VMS / Run AI / Remote Optum are skipped
2322
+ // here (already added store-scoped above) to avoid double-counting; in the
2323
+ // brand-wide case they are taken from this snapshot like the other extra
2324
+ // products. If OpenSearch is unreachable we just skip these rows rather than
2325
+ // failing the whole list. Factory is excluded from invoices (scoped) but still
2326
+ // shown in the brand-wide Billing Breakdown table.
2327
+ const invoiceExcluded = scopeStores ? new Set( [ 'factory' ] ) : new Set();
2261
2328
  try {
2262
2329
  const osRes = await getOpenSearchData( BILLING_DETAILS_INDEX, {
2263
2330
  size: 1,
@@ -2267,6 +2334,10 @@ export async function getAdditionalProducts( clientId ) {
2267
2334
  const hits = osRes?.body?.hits?.hits || osRes?.hits?.hits || [];
2268
2335
  const osProducts = hits[0]?._source?.products || [];
2269
2336
  for ( const p of osProducts ) {
2337
+ const nameKey = String( p.productName ).toLowerCase();
2338
+ if ( lambdaSourced.has( nameKey ) || invoiceExcluded.has( nameKey ) ) {
2339
+ continue;
2340
+ }
2270
2341
  const quantity = Number( p.quantity ) || 0;
2271
2342
  const price = Number( p.price ) || 0;
2272
2343
  products.push( build( p.productName, quantity, price ) );
@@ -2281,8 +2352,13 @@ export async function getAdditionalProducts( clientId ) {
2281
2352
  export async function additionalProducts( req, res ) {
2282
2353
  try {
2283
2354
  const clientId = String( req.query?.clientId ?? req.body?.clientId ?? '' );
2284
- const products = await getAdditionalProducts( clientId );
2285
- return res.sendSuccess( { currency: 'inr', products } );
2355
+ const [ products, billingDate ] = await Promise.all( [
2356
+ getAdditionalProducts( clientId ),
2357
+ billingDetailsDate( clientId ),
2358
+ ] );
2359
+ // billingDate (YYYY-MM-DD) is the billing_details snapshot behind the counts;
2360
+ // the UI passes it back to the export endpoint so exports match the cards.
2361
+ return res.sendSuccess( { currency: 'inr', products, billingDate } );
2286
2362
  } catch ( error ) {
2287
2363
  logger.error( { error: error, function: 'additionalProducts' } );
2288
2364
  return res.sendError( error, 500 );
@@ -2309,6 +2385,35 @@ async function lamdaServiceCall( url, data ) {
2309
2385
  }
2310
2386
  }
2311
2387
 
2388
+ // Date (YYYY-MM-DD) of the latest billing_details OpenSearch snapshot for a
2389
+ // client — the snapshot whose product counts back the Additional Products cards.
2390
+ // The UI passes this back to the export endpoint so the export reflects the same
2391
+ // day as the card. Returns '' if unavailable; date_string is normalised to
2392
+ // YYYY-MM-DD (it may be stored as DD-MM-YYYY).
2393
+ async function billingDetailsDate( clientId ) {
2394
+ try {
2395
+ const osRes = await getOpenSearchData( BILLING_DETAILS_INDEX, {
2396
+ size: 1,
2397
+ query: { term: { 'client_id': clientId } },
2398
+ sort: [ { date_string: { order: 'desc' } } ],
2399
+ } );
2400
+ const hits = osRes?.body?.hits?.hits || osRes?.hits?.hits || [];
2401
+ const raw = String( hits[0]?._source?.date_string || '' ).trim();
2402
+ if ( !raw ) {
2403
+ return '';
2404
+ }
2405
+ // Accept DD-MM-YYYY or YYYY-MM-DD; return YYYY-MM-DD.
2406
+ const ddmmyyyy = raw.match( /^(\d{2})-(\d{2})-(\d{4})$/ );
2407
+ if ( ddmmyyyy ) {
2408
+ return `${ddmmyyyy[3]}-${ddmmyyyy[2]}-${ddmmyyyy[1]}`;
2409
+ }
2410
+ return raw;
2411
+ } catch ( osErr ) {
2412
+ logger.error( { error: osErr, function: 'billingDetailsDate', clientId } );
2413
+ return '';
2414
+ }
2415
+ }
2416
+
2312
2417
  // Unit price for a product from the latest billing_details OpenSearch record.
2313
2418
  // productName match is case-insensitive. Returns 0 if not found / unavailable.
2314
2419
  async function billingDetailsPrice( clientId, productName ) {
@@ -2328,13 +2433,82 @@ async function billingDetailsPrice( clientId, productName ) {
2328
2433
  }
2329
2434
  }
2330
2435
 
2436
+ // Date (YYYY-MM-DD) of the most recent record in the remote_optom_steps_summary
2437
+ // index — the latest day audit data was inserted. Date is a keyword so it sorts
2438
+ // lexically. Returns '' if the index is empty / unreachable.
2439
+ async function remoteOptumLatestDate() {
2440
+ try {
2441
+ const osRes = await getOpenSearchData( 'remote_optom_steps_summary', {
2442
+ size: 1,
2443
+ _source: [ 'Date' ],
2444
+ sort: [ { 'Date': { order: 'desc' } } ],
2445
+ } );
2446
+ const hits = osRes?.body?.hits?.hits || osRes?.hits?.hits || [];
2447
+ return String( hits[0]?._source?.Date || '' ).trim();
2448
+ } catch ( osErr ) {
2449
+ logger.error( { error: osErr, function: 'remoteOptumLatestDate' } );
2450
+ return '';
2451
+ }
2452
+ }
2453
+
2454
+ // Remote Optum billed quantity for a billing group = number of audit records in
2455
+ // the remote_optom_steps_summary index that belong to the group's stores, over
2456
+ // the range [to_date's month-start .. to_date], where to_date is the latest
2457
+ // record date in the index (e.g. to_date 2026-06-30 → from_date 2026-06-01). That
2458
+ // index keys by store NAME (no storeId), so group.stores (storeIds) are first
2459
+ // mapped to names via the stores collection, then matched with a terms filter on
2460
+ // storeName.keyword. Date is a keyword (YYYY-MM-DD per record), so the range is
2461
+ // lexical. size:0 + track_total_hits gives the exact count without paging past
2462
+ // the 10k window. Returns 0 on any failure or when the group has no matching
2463
+ // store names / the index is empty.
2464
+ async function remoteOptumRecordCount( clientId, storeIds ) {
2465
+ try {
2466
+ if ( !Array.isArray( storeIds ) || !storeIds.length ) {
2467
+ return 0;
2468
+ }
2469
+ const toDate = await remoteOptumLatestDate();
2470
+ if ( !toDate ) {
2471
+ return 0;
2472
+ }
2473
+ const fromDate = dayjs( toDate ).startOf( 'month' ).format( 'YYYY-MM-DD' );
2474
+ const stores = await storeService.find(
2475
+ { clientId: clientId, storeId: { $in: storeIds } },
2476
+ { storeName: 1 },
2477
+ );
2478
+ const storeNames = [ ...new Set(
2479
+ stores.map( ( s ) => String( s.storeName || '' ).trim() ).filter( Boolean ),
2480
+ ) ];
2481
+ if ( !storeNames.length ) {
2482
+ return 0;
2483
+ }
2484
+ const osRes = await getOpenSearchData( 'remote_optom_steps_summary', {
2485
+ size: 0,
2486
+ track_total_hits: true,
2487
+ query: { bool: { filter: [
2488
+ { range: { 'Date': { gte: fromDate, lte: toDate } } },
2489
+ { terms: { 'storeName.keyword': storeNames } },
2490
+ ] } },
2491
+ } );
2492
+ const total = osRes?.body?.hits?.total?.value ?? osRes?.hits?.total?.value ?? 0;
2493
+ return Number( total ) || 0;
2494
+ } catch ( osErr ) {
2495
+ logger.error( { error: osErr, function: 'remoteOptumRecordCount', clientId } );
2496
+ return 0;
2497
+ }
2498
+ }
2499
+
2331
2500
  // Build the Lambda-backed export rows (VMS / Run AI / etc.). Calls the product's
2332
2501
  // Lambda for store_names (storeIds), maps them to the stores collection for
2333
2502
  // name + country (Zone), with current-month days and the billing_details price.
2334
- async function lambdaStoreExport( clientId, lambdaUrl, billingProductName ) {
2335
- const toDate = dayjs().format( 'DD-MM-YYYY' );
2503
+ // reqDate (YYYY-MM-DD, optional) is the billing_details snapshot date the UI
2504
+ // picked so the export matches the card count; it is sent to the Lambda as
2505
+ // to_date (DD-MM-YYYY). Defaults to today when absent.
2506
+ async function lambdaStoreExport( clientId, lambdaUrl, billingProductName, reqDate ) {
2507
+ // reqDate is ISO YYYY-MM-DD, which dayjs parses natively; reformat to the
2508
+ // Lambda's DD-MM-YYYY. Fall back to today when absent / unparseable.
2509
+ const parsed = reqDate ? dayjs( reqDate ) : null;
2510
+ const toDate = parsed && parsed.isValid() ? parsed.format( 'DD-MM-YYYY' ) : dayjs().format( 'DD-MM-YYYY' );
2336
2511
  const lambdaResult = await lamdaServiceCall( lambdaUrl, { to_date: toDate, client_id: clientId } );
2337
- console.log( '🚀 ~ lambdaStoreExport ~ lambdaResult:', lambdaResult );
2338
2512
  // These Lambdas return the storeIds under either `store_ids` (Run AI) or
2339
2513
  // `store_names` (VMS) with no status_code. Accept whichever is present; data
2340
2514
  // availability = a non-empty array.
@@ -2362,6 +2536,33 @@ async function lambdaStoreExport( clientId, lambdaUrl, billingProductName ) {
2362
2536
  } );
2363
2537
  }
2364
2538
 
2539
+ // Count the billable stores a product Lambda (VMS / Run AI) reports, optionally
2540
+ // scoped to a billing group's stores. The Lambda's own store_ids request filter
2541
+ // is unreliable — passing store_ids returns 0 even for IDs it itself reports as
2542
+ // billable — so we always call it BRAND-WIDE (client only) to get the true store
2543
+ // list, then intersect that list with the group's stores locally. Both sides use
2544
+ // the same `clientId-n` storeId format (e.g. `11-2911`), matching how the Mongo
2545
+ // aggregations scope by group.stores. Returns 0 on any failure (Lambda
2546
+ // unreachable / empty), so the caller just omits the line rather than failing the
2547
+ // whole invoice.
2548
+ async function lambdaStoreCount( clientId, lambdaUrl, storeIds ) {
2549
+ const toDate = dayjs().format( 'DD-MM-YYYY' );
2550
+ const lambdaResult = await lamdaServiceCall( lambdaUrl, { to_date: toDate, client_id: clientId } );
2551
+ // The Lambdas return the storeIds under either `store_ids` (Run AI) or
2552
+ // `store_names` (VMS); accept whichever is present.
2553
+ const rawIds = ( lambdaResult && Array.isArray( lambdaResult.store_ids ) ) ? lambdaResult.store_ids :
2554
+ ( ( lambdaResult && Array.isArray( lambdaResult.store_names ) ) ? lambdaResult.store_names : [] );
2555
+ const ids = rawIds.map( ( s ) => String( s ) );
2556
+ // Scope to the billing group's stores when provided; otherwise (brand-wide
2557
+ // Billing Breakdown) count every store the Lambda reports.
2558
+ const scope = Array.isArray( storeIds ) && storeIds.length ? storeIds.map( ( s ) => String( s ) ) : null;
2559
+ if ( scope ) {
2560
+ const allowed = new Set( scope );
2561
+ return ids.filter( ( id ) => allowed.has( id ) ).length;
2562
+ }
2563
+ return ids.length;
2564
+ }
2565
+
2365
2566
  // tangoTraffic working days per store, from the latest daily-pricing doc.
2366
2567
  // Map<storeId, workingdays>; stores with no tangoTraffic record are absent.
2367
2568
  async function tangoTrafficDaysByStore( clientId ) {
@@ -2531,8 +2732,9 @@ export async function additionalProductExport( req, res ) {
2531
2732
  if ( product === 'vms' ) {
2532
2733
  const exportData = await lambdaStoreExport(
2533
2734
  clientId,
2534
- 'https://ppf3l3mxc2lorh5hkrsj6zwyim0bupxw.lambda-url.ap-south-1.on.aws/',
2735
+ VMS_LAMBDA_URL,
2535
2736
  'VMS',
2737
+ reqDate,
2536
2738
  );
2537
2739
  if ( !exportData.length ) {
2538
2740
  return res.sendError( 'No data', 204 );
@@ -2544,10 +2746,10 @@ export async function additionalProductExport( req, res ) {
2544
2746
  if ( product === 'runai' ) {
2545
2747
  const exportData = await lambdaStoreExport(
2546
2748
  clientId,
2547
- 'https://mdm3mf7wuficgv3jjspkws2nu40azlsc.lambda-url.ap-south-1.on.aws/',
2749
+ RUN_AI_LAMBDA_URL,
2548
2750
  'Run AI',
2751
+ reqDate,
2549
2752
  );
2550
- console.log( '🚀 ~ additionalProductExport ~ exportData:', exportData );
2551
2753
  if ( !exportData.length ) {
2552
2754
  return res.sendError( 'No data', 204 );
2553
2755
  }
@@ -2556,29 +2758,54 @@ export async function additionalProductExport( req, res ) {
2556
2758
  }
2557
2759
 
2558
2760
  if ( product === 'remoteoptum' ) {
2559
- // Detailed optom-audit export from the remote_optom_steps_summary index
2560
- // for the given date (passed from the UI, defaults to today). Each record
2561
- // is one row; the nested `steps` object is flattened into TRUE/FALSE
2562
- // columns in a fixed (alphabetical) order.
2761
+ // Detailed optom-audit export from the remote_optom_steps_summary index.
2762
+ // to_date is the date from the payload (reqDate); from_date is the first
2763
+ // day of that date's month so the export covers month-start through the
2764
+ // requested date. Each record is one row; the nested `steps` object is
2765
+ // flattened into TRUE/FALSE columns in a fixed (alphabetical) order.
2563
2766
  const STEP_COLUMNS = [
2564
2767
  'Adjust-Phoropter', 'DuoChrome-Test', 'Explanation', 'Final-Prescription',
2565
2768
  'Handover', 'History-Taking', 'JCC', 'Near-Vision', 'Personal-Intro',
2566
2769
  'Subjective-Refraction', 'Trial-Frame', 'VA-Check',
2567
2770
  ];
2771
+ const fromDate = dayjs( reqDate ).startOf( 'month' ).format( 'YYYY-MM-DD' );
2568
2772
  let optomHits = [];
2773
+ let scrollId = null;
2569
2774
  try {
2570
- const osRes = await getOpenSearchData( 'remote_optom_steps_summary', {
2571
- // A single day is well under the 10k window; pull up to the cap.
2572
- // NOTE: do NOT sort on StartTime — it's an analyzed text field, and
2573
- // sorting on it makes OpenSearch return ZERO hits. The day filter is
2574
- // enough; rows come back in index order.
2775
+ // A month-to-date span can exceed the 10k max_result_window, so scroll
2776
+ // through all pages. Date is a keyword (YYYY-MM-DD) the span is a lexical
2777
+ // range. NOTE: do NOT sort on StartTime — it's an analyzed text field, and
2778
+ // sorting on it makes OpenSearch return ZERO hits. The range filter is
2779
+ // enough; rows come back in index order.
2780
+ let osRes = await searchOpenSearchData( 'remote_optom_steps_summary', {
2575
2781
  size: 10000,
2576
- query: { term: { 'Date': reqDate } },
2782
+ query: { range: { 'Date': { gte: fromDate, lte: reqDate } } },
2577
2783
  } );
2578
- optomHits = osRes?.body?.hits?.hits || osRes?.hits?.hits || [];
2784
+ let body = osRes?.body || osRes;
2785
+ let hits = body?.hits?.hits || [];
2786
+ scrollId = body?._scroll_id;
2787
+ while ( hits.length ) {
2788
+ optomHits.push( ...hits );
2789
+ if ( !scrollId ) {
2790
+ break;
2791
+ }
2792
+ osRes = await scrollResponse( scrollId );
2793
+ body = osRes?.body || osRes;
2794
+ hits = body?.hits?.hits || [];
2795
+ scrollId = body?._scroll_id || scrollId;
2796
+ }
2579
2797
  } catch ( osErr ) {
2580
2798
  logger.error( { error: osErr, function: 'additionalProductExport.remoteOptum', clientId } );
2581
2799
  return res.sendError( 'Failed to fetch Remote Optum data', 502 );
2800
+ } finally {
2801
+ // Free the scroll context; ignore failures (context may already be gone).
2802
+ if ( scrollId ) {
2803
+ try {
2804
+ await clearScroll( scrollId );
2805
+ } catch ( clearErr ) {
2806
+ logger.error( { error: clearErr, function: 'additionalProductExport.remoteOptum.clearScroll' } );
2807
+ }
2808
+ }
2582
2809
  }
2583
2810
 
2584
2811
  const exportData = optomHits.map( ( h ) => {
@@ -312,8 +312,17 @@ export async function createInvoice( req, res ) {
312
312
  // Skip when reusing advance-invoice line items — those already include any
313
313
  // additional products from when the advance invoice was generated.
314
314
  if ( !advanceCoverProducts ) {
315
- const extraProducts = await getAdditionalProducts( group.clientId );
315
+ // Scope additional-product quantities to this billing group's assigned
316
+ // stores (group.stores). Without this the counts span the whole brand
317
+ // and every group's invoice would be billed for all stores.
318
+ const extraProducts = await getAdditionalProducts( group.clientId, group.stores );
316
319
  for ( const ep of extraProducts ) {
320
+ // Skip additional products with no billable quantity — an Eyetest /
321
+ // Planogram / AI Manager / VMS line that resolved to 0 stores for this
322
+ // billing group should not appear as a ₹0 row on the invoice.
323
+ if ( ( Number( ep.quantity ) || 0 ) <= 0 ) {
324
+ continue;
325
+ }
317
326
  products.push( {
318
327
  productName: ep.productName,
319
328
  period: 'fullmonth',
@@ -4250,7 +4250,7 @@ export async function uploadClientDocument( req, res ) {
4250
4250
  const clientId = String( req.body?.clientId || '' );
4251
4251
  const documentName = String( req.body?.documentName || '' ).trim();
4252
4252
  const expiryDateRaw = req.body?.expiryDate;
4253
- const file = req.file; // multer single('file')
4253
+ const file = req.files?.file; // express-fileupload → req.files.file
4254
4254
 
4255
4255
  if ( !clientId ) {
4256
4256
  return res.sendError( 'clientId is required', 400 );
@@ -4282,7 +4282,7 @@ export async function uploadClientDocument( req, res ) {
4282
4282
  Key: key,
4283
4283
  fileName: fileName,
4284
4284
  ContentType: file.mimetype,
4285
- body: file.buffer,
4285
+ body: file.data,
4286
4286
  } );
4287
4287
 
4288
4288
  const storedPath = `${key}${fileName}`;
@@ -4332,6 +4332,86 @@ export async function getClientDocuments( req, res ) {
4332
4332
  }
4333
4333
  }
4334
4334
 
4335
+ // Update a client document (name / expiry, and optionally replace the PDF).
4336
+ export async function updateClientDocument( req, res ) {
4337
+ try {
4338
+ const clientId = String( req.body?.clientId || '' );
4339
+ const documentId = String( req.body?.documentId || '' );
4340
+ const documentName = String( req.body?.documentName || '' ).trim();
4341
+ const expiryDateRaw = req.body?.expiryDate;
4342
+ const file = req.files?.file; // optional PDF replacement
4343
+
4344
+ if ( !clientId ) {
4345
+ return res.sendError( 'clientId is required', 400 );
4346
+ }
4347
+ if ( !documentId ) {
4348
+ return res.sendError( 'documentId is required', 400 );
4349
+ }
4350
+ if ( !documentName ) {
4351
+ return res.sendError( 'documentName is required', 400 );
4352
+ }
4353
+
4354
+ const client = await paymentService.findOneClient(
4355
+ { clientId, 'additionalDocuments._id': documentId },
4356
+ { clientId: 1 },
4357
+ );
4358
+ if ( !client ) {
4359
+ return res.sendError( 'Document not found', 404 );
4360
+ }
4361
+
4362
+ const expiryDate = expiryDateRaw ? new Date( expiryDateRaw ) : null;
4363
+ const fields = {
4364
+ documentName: documentName,
4365
+ expiryDate: ( expiryDate && !isNaN( expiryDate.getTime() ) ) ? expiryDate : null,
4366
+ };
4367
+
4368
+ // Optional new file → upload and point the subdoc at the new path.
4369
+ if ( file ) {
4370
+ if ( file.mimetype !== 'application/pdf' ) {
4371
+ return res.sendError( 'Only PDF files are allowed', 400 );
4372
+ }
4373
+ const bucket = JSON.parse( process.env.BUCKET );
4374
+ const safeName = documentName.replace( /[^a-zA-Z0-9-_]/g, '_' );
4375
+ const fileName = `${safeName}_${Date.now()}.pdf`;
4376
+ const key = `${clientId}/documents/`;
4377
+ await fileUpload( {
4378
+ Bucket: bucket.assets,
4379
+ Key: key,
4380
+ fileName: fileName,
4381
+ ContentType: file.mimetype,
4382
+ body: file.data,
4383
+ } );
4384
+ fields.path = `${key}${fileName}`;
4385
+ }
4386
+
4387
+ await paymentService.updateAdditionalDocument( clientId, documentId, fields );
4388
+ return res.sendSuccess( { message: 'Document updated', documentName, expiryDate: fields.expiryDate } );
4389
+ } catch ( error ) {
4390
+ logger.error( { error: error, function: 'updateClientDocument' } );
4391
+ return res.sendError( error, 500 );
4392
+ }
4393
+ }
4394
+
4395
+ // Delete a client document (removes the subdoc; the S3 object is left as-is,
4396
+ // matching the app's existing replace-without-delete behavior).
4397
+ export async function deleteClientDocument( req, res ) {
4398
+ try {
4399
+ const clientId = String( req.body?.clientId || req.query?.clientId || '' );
4400
+ const documentId = String( req.params?.documentId || req.body?.documentId || '' );
4401
+ if ( !clientId ) {
4402
+ return res.sendError( 'clientId is required', 400 );
4403
+ }
4404
+ if ( !documentId ) {
4405
+ return res.sendError( 'documentId is required', 400 );
4406
+ }
4407
+ await paymentService.pullAdditionalDocument( clientId, documentId );
4408
+ return res.sendSuccess( { message: 'Document deleted' } );
4409
+ } catch ( error ) {
4410
+ logger.error( { error: error, function: 'deleteClientDocument' } );
4411
+ return res.sendError( error, 500 );
4412
+ }
4413
+ }
4414
+
4335
4415
  // Toggle billing-group-wise pricing on the client. When enabled, pricing is
4336
4416
  // maintained per billing group (separate basepricing docs keyed by groupId).
4337
4417
  export async function setBillingGroupWisePricing( req, res ) {
@@ -57,10 +57,10 @@ export async function createPurchaseOrder( req, res ) {
57
57
  return res.sendError( 'A purchase order with this number already exists', 409 );
58
58
  }
59
59
 
60
- // Optional PO PDF upload (multer single 'file') → assets bucket, same as
61
- // brand document upload. PDF only.
60
+ // Optional PO PDF upload (express-fileupload req.files.file) → assets
61
+ // bucket, same as brand document upload. PDF only.
62
62
  let pdfPath = '';
63
- const file = req.file;
63
+ const file = req.files?.file;
64
64
  if ( file ) {
65
65
  if ( file.mimetype !== 'application/pdf' ) {
66
66
  return res.sendError( 'Only PDF files are allowed', 400 );
@@ -74,7 +74,7 @@ export async function createPurchaseOrder( req, res ) {
74
74
  Key: key,
75
75
  fileName: fileName,
76
76
  ContentType: file.mimetype,
77
- body: file.buffer,
77
+ body: file.data,
78
78
  } );
79
79
  pdfPath = `${key}${fileName}`;
80
80
  console.log( '🚀 ~ createPurchaseOrder ~ result:', result );
@@ -132,6 +132,114 @@ export async function getPurchaseOrders( req, res ) {
132
132
  }
133
133
  }
134
134
 
135
+ // Update a purchase order. Editable: companyName, purchaseOrderNumber,
136
+ // totalAmount, date and (optionally) the PO PDF. Changing totalAmount keeps the
137
+ // already-used amount intact and recomputes remainingAmount + status.
138
+ export async function updatePurchaseOrder( req, res ) {
139
+ try {
140
+ const id = String( req.params?.id || req.body?._id || '' );
141
+ if ( !id ) {
142
+ return res.sendError( 'purchase order id is required', 400 );
143
+ }
144
+ const existing = await purchaseOrderService.findOne( { _id: id } );
145
+ if ( !existing ) {
146
+ return res.sendError( 'Purchase order not found', 404 );
147
+ }
148
+
149
+ const clientId = existing.clientId;
150
+ const companyName = String( req.body?.companyName || '' ).trim();
151
+ const purchaseOrderNumber = String( req.body?.purchaseOrderNumber || '' ).trim();
152
+ const totalAmount = Number( req.body?.totalAmount );
153
+ const date = req.body?.date ? new Date( req.body.date ) : existing.date;
154
+
155
+ if ( !purchaseOrderNumber ) {
156
+ return res.sendError( 'purchaseOrderNumber is required', 400 );
157
+ }
158
+ if ( !( totalAmount > 0 ) ) {
159
+ return res.sendError( 'totalAmount must be greater than 0', 400 );
160
+ }
161
+
162
+ // Reject a number that already belongs to a DIFFERENT PO for this client.
163
+ if ( purchaseOrderNumber !== existing.purchaseOrderNumber ) {
164
+ const clash = await purchaseOrderService.findOne( { clientId, purchaseOrderNumber } );
165
+ if ( clash && String( clash._id ) !== id ) {
166
+ return res.sendError( 'A purchase order with this number already exists', 409 );
167
+ }
168
+ }
169
+
170
+ // Preserve the amount already consumed by mapped invoices; the new total
171
+ // can't drop below it.
172
+ const usedAmount = Number( existing.totalAmount ) - Number( existing.remainingAmount );
173
+ if ( totalAmount < usedAmount ) {
174
+ return res.sendError(
175
+ `totalAmount cannot be less than the amount already used (${usedAmount})`,
176
+ 400,
177
+ );
178
+ }
179
+ const remainingAmount = totalAmount - usedAmount;
180
+
181
+ const update = {
182
+ companyName,
183
+ purchaseOrderNumber,
184
+ totalAmount,
185
+ remainingAmount,
186
+ date,
187
+ status: statusFor( totalAmount, remainingAmount ),
188
+ };
189
+
190
+ // Optional PDF replacement (express-fileupload → req.files.file). PDF only.
191
+ const file = req.files?.file;
192
+ if ( file ) {
193
+ if ( file.mimetype !== 'application/pdf' ) {
194
+ return res.sendError( 'Only PDF files are allowed', 400 );
195
+ }
196
+ const bucket = JSON.parse( process.env.BUCKET );
197
+ const safeName = purchaseOrderNumber.replace( /[^a-zA-Z0-9-_]/g, '_' );
198
+ const fileName = `${safeName}_${Date.now()}.pdf`;
199
+ const key = `${clientId}/purchase-orders/`;
200
+ await fileUpload( {
201
+ Bucket: bucket.assets,
202
+ Key: key,
203
+ fileName: fileName,
204
+ ContentType: file.mimetype,
205
+ body: file.data,
206
+ } );
207
+ update.pdfPath = `${key}${fileName}`;
208
+ }
209
+
210
+ await purchaseOrderService.updateOne( { _id: id }, update );
211
+ logPO( req, clientId, 'purchaseOrderUpdated', [ `PO ${purchaseOrderNumber} updated (total ${totalAmount})` ] );
212
+ return res.sendSuccess( { message: 'Purchase order updated' } );
213
+ } catch ( error ) {
214
+ logger.error( { error: error, function: 'updatePurchaseOrder' } );
215
+ return res.sendError( error, 500 );
216
+ }
217
+ }
218
+
219
+ // Delete a purchase order. Blocked when invoices are already mapped to it
220
+ // (deleting would orphan those invoice → PO links).
221
+ export async function deletePurchaseOrder( req, res ) {
222
+ try {
223
+ const id = String( req.params?.id || '' );
224
+ if ( !id ) {
225
+ return res.sendError( 'purchase order id is required', 400 );
226
+ }
227
+ const existing = await purchaseOrderService.findOne( { _id: id } );
228
+ if ( !existing ) {
229
+ return res.sendError( 'Purchase order not found', 404 );
230
+ }
231
+ if ( ( existing.usage || [] ).length > 0 ) {
232
+ return res.sendError( 'Cannot delete a purchase order with mapped invoices', 409 );
233
+ }
234
+ await purchaseOrderService.deleteRecord( { _id: id } );
235
+ logPO( req, existing.clientId, 'purchaseOrderDeleted', [ `PO ${existing.purchaseOrderNumber} deleted` ] );
236
+ return res.sendSuccess( { message: 'Purchase order deleted' } );
237
+ } catch ( error ) {
238
+ logger.error( { error: error, function: 'deletePurchaseOrder' } );
239
+ return res.sendError( error, 500 );
240
+ }
241
+ }
242
+
135
243
  // Map an invoice to a PO: deduct the invoice amount from the PO's remaining
136
244
  // balance, record the usage, update status, and log it. Shared with invoice
137
245
  // generation/edit so the deduction happens wherever an invoice gets a PO.
@@ -652,6 +652,7 @@ img {
652
652
  </tbody></table>
653
653
  </td>
654
654
  </tr>
655
+ {{#gtZero tax.0.value}}
655
656
  <tr>
656
657
  <td height="12" style="height:12px; min-height:12px; line-height:12px;"></td>
657
658
  </tr>
@@ -673,6 +674,7 @@ img {
673
674
  <table width="100%" border="0" cellpadding="0" cellspacing="0">
674
675
  <tbody>
675
676
  {{#each tax }}
677
+ {{#gtZero value}}
676
678
  <tr>
677
679
  <td>
678
680
  <div style="line-height:24px;text-align:right;">
@@ -680,6 +682,7 @@ img {
680
682
  </div>
681
683
  </td>
682
684
  </tr>
685
+ {{/gtZero}}
683
686
  {{/each}}
684
687
 
685
688
 
@@ -689,6 +692,7 @@ img {
689
692
  </tbody></table>
690
693
  </td>
691
694
  </tr>
695
+ {{/gtZero}}
692
696
  <tr>
693
697
  <td height="12" style="height:12px; min-height:12px; line-height:12px;"></td>
694
698
  </tr>
@@ -1,11 +1,9 @@
1
1
  import express from 'express';
2
- import multer from 'multer';
3
2
  export const billingRouter = express.Router();
4
- const poUpload = multer( { storage: multer.memoryStorage(), limits: { fileSize: 10 * 1024 * 1024 } } );
5
3
  import { accessVerification, isAllowedSessionHandler, validate } from 'tango-app-api-middleware';
6
4
  import { getPaymentReminder, savePaymentReminder } from '../controllers/paymentReminder.controller.js';
7
5
  import { triggerPaymentReminders } from '../controllers/paymentReminderTrigger.controller.js';
8
- import { createPurchaseOrder, getPurchaseOrders } from '../controllers/purchaseOrder.controller.js';
6
+ import { createPurchaseOrder, getPurchaseOrders, updatePurchaseOrder, deletePurchaseOrder } from '../controllers/purchaseOrder.controller.js';
9
7
  import { createBillingGroup, deleteBillingGroup, getAllBillingGroups, getBillingGroups, getClientProducts, getInvoices, getLeadProducts, getBaseProducts, onetimePayment, subscribedStoreList, updateBillingGroup, gstinLookup } from '../controllers/billing.controllers.js';
10
8
  import { billingGroupSchema, clientProductsValid, createBillingGroupsSchema, deleteBillingGroupsSchema, getBillingGroupsSchema, getInvoiceSchema, leadProductsValid, onetimeFeeValid, subscribedStoreListSchema, updateBillingGroupsSchema } from '../dtos/validation.dtos.js';
11
9
 
@@ -42,11 +40,12 @@ billingRouter.post( '/payment-reminder', isAllowedSessionHandler, accessVerifica
42
40
 
43
41
  // Purchase Orders (brand-view Purchase Order tab).
44
42
  billingRouter.get( '/purchase-orders', isAllowedSessionHandler, accessVerification( { userType: [ 'tango' ], access: [ { featureName: 'Global', name: 'Billing', permissions: [ 'isAdd' ] } ] } ), getPurchaseOrders );
45
- // multer runs FIRST so it consumes the multipart stream off the live request.
46
- // The auth middlewares are async and do DB awaits while touching req.body;
47
- // running them before multer leaves the multipart stream unread and busboy
48
- // throws 'Unexpected end of form'. After multer, text fields are on req.body.
49
- billingRouter.post( '/purchase-orders', poUpload.single( 'file' ), isAllowedSessionHandler, accessVerification( { userType: [ 'tango' ], access: [ { featureName: 'Global', name: 'Billing', permissions: [ 'isAdd' ] } ] } ), createPurchaseOrder );
43
+ // The multipart body is parsed app-wide by express-fileupload (see app.js), so
44
+ // the uploaded PDF is on req.files.file and text fields on req.body regardless
45
+ // of middleware order no per-route upload middleware needed.
46
+ billingRouter.post( '/purchase-orders', isAllowedSessionHandler, accessVerification( { userType: [ 'tango' ], access: [ { featureName: 'Global', name: 'Billing', permissions: [ 'isAdd' ] } ] } ), createPurchaseOrder );
47
+ billingRouter.put( '/purchase-orders/:id', isAllowedSessionHandler, accessVerification( { userType: [ 'tango' ], access: [ { featureName: 'Global', name: 'Billing', permissions: [ 'isAdd' ] } ] } ), updatePurchaseOrder );
48
+ billingRouter.delete( '/purchase-orders/:id', isAllowedSessionHandler, accessVerification( { userType: [ 'tango' ], access: [ { featureName: 'Global', name: 'Billing', permissions: [ 'isAdd' ] } ] } ), deletePurchaseOrder );
50
49
 
51
50
  // Cron-triggered: sends the configured payment reminder emails. Unauthenticated
52
51
  // like the other cron endpoints; protect at the network / scheduler layer.
@@ -1,16 +1,11 @@
1
1
 
2
2
  import express from 'express';
3
- import multer from 'multer';
4
3
  import * as paymentController from '../controllers/paymentSubscription.controllers.js';
5
4
  import { validate, isAllowedSessionHandler, accessVerification } from 'tango-app-api-middleware';
6
5
  import * as validationDtos from '../dtos/validation.dtos.js';
7
6
  import { validateClient } from '../utils/validations/client.validation.js';
8
7
  export const paymentSubscriptionRouter = express.Router();
9
8
 
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
-
14
9
  paymentSubscriptionRouter.post( '/addBilling', isAllowedSessionHandler, accessVerification( {
15
10
  userType: [ 'tango', 'client' ], access: [
16
11
  { featureName: 'Global', name: 'Subscription', permissions: [ 'isAdd' ] },
@@ -151,9 +146,12 @@ paymentSubscriptionRouter.put( '/pushNotification/update/:notificationId', isAll
151
146
  paymentSubscriptionRouter.post( '/updateRemind/:notificationId', isAllowedSessionHandler, validate( validationDtos.validateId ), paymentController.updateRemind );
152
147
  paymentSubscriptionRouter.post( '/createDefaultbillings', paymentController.createDefaultbillings );
153
148
 
154
- // Brand documents (Plans & Subscription > Documents accordion).
155
- paymentSubscriptionRouter.post( '/client-document/upload', documentUpload.single( 'file' ), isAllowedSessionHandler, accessVerification( { userType: [ 'tango' ], access: [ { featureName: 'Global', name: 'Subscription', permissions: [ 'isEdit' ] } ] } ), paymentController.uploadClientDocument );
149
+ // Brand documents (Plans & Subscription > Documents accordion). The multipart
150
+ // body is parsed app-wide by express-fileupload (see app.js) req.files.file.
151
+ paymentSubscriptionRouter.post( '/client-document/upload', isAllowedSessionHandler, accessVerification( { userType: [ 'tango' ], access: [ { featureName: 'Global', name: 'Subscription', permissions: [ 'isEdit' ] } ] } ), paymentController.uploadClientDocument );
156
152
  paymentSubscriptionRouter.get( '/client-document/list', isAllowedSessionHandler, accessVerification( { userType: [ 'tango' ], access: [ { featureName: 'Global', name: 'Subscription', permissions: [] } ] } ), paymentController.getClientDocuments );
153
+ paymentSubscriptionRouter.put( '/client-document/update', isAllowedSessionHandler, accessVerification( { userType: [ 'tango' ], access: [ { featureName: 'Global', name: 'Subscription', permissions: [ 'isEdit' ] } ] } ), paymentController.updateClientDocument );
154
+ paymentSubscriptionRouter.delete( '/client-document/:documentId', isAllowedSessionHandler, accessVerification( { userType: [ 'tango' ], access: [ { featureName: 'Global', name: 'Subscription', permissions: [ 'isEdit' ] } ] } ), paymentController.deleteClientDocument );
157
155
 
158
156
  // Toggle billing-group-wise pricing for a client (tango only, edit perm).
159
157
  paymentSubscriptionRouter.post( '/billingGroupWisePricing', isAllowedSessionHandler, accessVerification( { userType: [ 'tango' ], access: [ { featureName: 'Global', name: 'Subscription', permissions: [ 'isEdit' ] } ] } ), paymentController.setBillingGroupWisePricing );
@@ -21,6 +21,27 @@ export const pushAdditionalDocument = ( query = {}, document = {} ) => {
21
21
  return model.clientModel.updateOne( query, { $push: { additionalDocuments: document } } );
22
22
  };
23
23
 
24
+ // Update fields of a single additionalDocuments subdoc (matched by _id). `fields`
25
+ // keys are the subdoc field names, e.g. { documentName, expiryDate, path }.
26
+ export const updateAdditionalDocument = ( clientId, documentId, fields = {} ) => {
27
+ const set = {};
28
+ for ( const [ k, v ] of Object.entries( fields ) ) {
29
+ set[`additionalDocuments.$.${k}`] = v;
30
+ }
31
+ return model.clientModel.updateOne(
32
+ { clientId, 'additionalDocuments._id': documentId },
33
+ { $set: set },
34
+ );
35
+ };
36
+
37
+ // Remove a single additionalDocuments subdoc (matched by _id).
38
+ export const pullAdditionalDocument = ( clientId, documentId ) => {
39
+ return model.clientModel.updateOne(
40
+ { clientId },
41
+ { $pull: { additionalDocuments: { _id: documentId } } },
42
+ );
43
+ };
44
+
24
45
  export const aggregate = ( query = [] ) => {
25
46
  return model.clientModel.aggregate( query );
26
47
  };
@@ -16,6 +16,10 @@ export const updateOne = ( query, record ) => {
16
16
  return model.purchaseOrderModel.updateOne( query, { $set: record } );
17
17
  };
18
18
 
19
+ export const deleteRecord = ( query ) => {
20
+ return model.purchaseOrderModel.deleteOne( query );
21
+ };
22
+
19
23
  export const aggregate = ( query = [] ) => {
20
24
  return model.purchaseOrderModel.aggregate( query );
21
25
  };