tango-app-api-payment-subscription 3.5.21 → 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.21",
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';
@@ -1944,7 +1944,11 @@ export async function billingSummary( req, res ) {
1944
1944
  csm: [ ...( csmByClient.get( key ) || [] ) ].join( ', ' ),
1945
1945
  revenueMonths: {},
1946
1946
  billedStoresMonths: {},
1947
- installationFee: 0,
1947
+ // Installation fee per month (keyed YYYY-MM). The summary surfaces
1948
+ // ONLY the current month's value — see installationOut below — so the
1949
+ // column stays hidden until the current-month invoice is generated
1950
+ // with an installation fee.
1951
+ installationMonths: {},
1948
1952
  invDollar: 0,
1949
1953
  invInr: 0,
1950
1954
  } );
@@ -1956,7 +1960,8 @@ export async function billingSummary( req, res ) {
1956
1960
  const r = rowOf( inv._id.c );
1957
1961
  r.revenueMonths[inv._id.ym] = Math.round( ( ( inv.revenueInr || 0 ) + ( inv.revenueUsd || 0 ) * usdRate ) * 100 ) / 100;
1958
1962
  r.billedStoresMonths[inv._id.ym] = inv.stores || 0;
1959
- r.installationFee += ( inv.installationInr || 0 ) + ( inv.installationUsd || 0 ) * usdRate;
1963
+ r.installationMonths[inv._id.ym] = ( r.installationMonths[inv._id.ym] || 0 ) +
1964
+ ( inv.installationInr || 0 ) + ( inv.installationUsd || 0 ) * usdRate;
1960
1965
  r.invDollar += ( inv.dollarInvoices || 0 );
1961
1966
  r.invInr += ( inv.inrInvoices || 0 );
1962
1967
  if ( inv.companyName ) {
@@ -2074,7 +2079,11 @@ export async function billingSummary( req, res ) {
2074
2079
  // in native USD for dollar clients.
2075
2080
  revCur = revCur == null ? null : Math.round( revCur );
2076
2081
  const revPrevOut = revPrev == null ? null : Math.round( revPrev );
2077
- const installationOut = r.installationFee ? Math.round( r.installationFee ) : null;
2082
+ // Show the installation fee ONLY for the current month, and only once
2083
+ // that month's invoice (which carries the installationFee line) exists.
2084
+ // Until then it's null so the UI hides the whole column.
2085
+ const curInstallation = r.installationMonths[curKey] || 0;
2086
+ const installationOut = curInstallation ? Math.round( curInstallation ) : null;
2078
2087
  // Registered Entity from the billings collection (distinct group names).
2079
2088
  // First name shown in the column; the full list is sent so the UI can
2080
2089
  // reveal the others on hover. Fall back to the invoice company name when
@@ -2176,6 +2185,11 @@ const ADDITIONAL_PRODUCT_PRICES = {
2176
2185
  planogram: 770,
2177
2186
  aiManager: 500,
2178
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/';
2179
2193
  // OpenSearch index holding extra billed products (VMS, Run AI, etc.). Each
2180
2194
  // record is a daily snapshot carrying its own quantity + price per product.
2181
2195
  const BILLING_DETAILS_INDEX = ( () => {
@@ -2190,12 +2204,37 @@ const BILLING_DETAILS_INDEX = ( () => {
2190
2204
  // that has none (currently only clientId 11). Shared by the Billing Breakdown
2191
2205
  // table (additionalProducts controller) AND invoice generation, so both stay in
2192
2206
  // sync. Each entry: { productName, quantity, price, total }.
2193
- 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 ) {
2194
2225
  clientId = String( clientId ?? '' );
2195
2226
  if ( clientId !== ADDITIONAL_PRODUCTS_CLIENT_ID ) {
2196
2227
  return [];
2197
2228
  }
2198
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
+
2199
2238
  // Eyetest: distinct stores in the cameras collection that have an eye-test
2200
2239
  // stream with a QR code.
2201
2240
  const eyetestRows = await cameraService.aggregate( [
@@ -2203,6 +2242,7 @@ export async function getAdditionalProducts( clientId ) {
2203
2242
  { clientId: clientId },
2204
2243
  { isEyeTestStream: true },
2205
2244
  { qrCode: { $exists: true } },
2245
+ storeMatch,
2206
2246
  ] } },
2207
2247
  { $group: { _id: '$storeId' } },
2208
2248
  { $count: 'stores' },
@@ -2211,7 +2251,7 @@ export async function getAdditionalProducts( clientId ) {
2211
2251
 
2212
2252
  // Planogram: distinct storeName in the planograms collection.
2213
2253
  const planogramRows = await planogramService.aggregate( [
2214
- { $match: { clientId: clientId } },
2254
+ { $match: { clientId: clientId, ...storeMatch } },
2215
2255
  { $group: { _id: null, storecount: { $addToSet: '$storeName' } } },
2216
2256
  { $project: { count: { $size: '$storecount' } } },
2217
2257
  ] );
@@ -2225,6 +2265,7 @@ export async function getAdditionalProducts( clientId ) {
2225
2265
  { $sort: { dateISO: -1 } },
2226
2266
  { $limit: 1 },
2227
2267
  { $unwind: '$stores' },
2268
+ ...( scopeStores ? [ { $match: unwoundStoreMatch } ] : [] ),
2228
2269
  { $unwind: '$stores.products' },
2229
2270
  { $match: { 'stores.products.productName': 'tangoTraffic', 'stores.products.workingdays': { $gt: 1 } } },
2230
2271
  { $group: { _id: '$stores.storeId' } },
@@ -2245,10 +2286,45 @@ export async function getAdditionalProducts( clientId ) {
2245
2286
  build( 'AI Manager', aiManagerQty, ADDITIONAL_PRODUCT_PRICES.aiManager ),
2246
2287
  ];
2247
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
+
2248
2319
  // Extra products from the billing_details OpenSearch index — the LATEST daily
2249
2320
  // snapshot for this client. quantity/price are stored as strings, so coerce
2250
- // them. If OpenSearch is unreachable we just skip these rows rather than
2251
- // 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();
2252
2328
  try {
2253
2329
  const osRes = await getOpenSearchData( BILLING_DETAILS_INDEX, {
2254
2330
  size: 1,
@@ -2258,6 +2334,10 @@ export async function getAdditionalProducts( clientId ) {
2258
2334
  const hits = osRes?.body?.hits?.hits || osRes?.hits?.hits || [];
2259
2335
  const osProducts = hits[0]?._source?.products || [];
2260
2336
  for ( const p of osProducts ) {
2337
+ const nameKey = String( p.productName ).toLowerCase();
2338
+ if ( lambdaSourced.has( nameKey ) || invoiceExcluded.has( nameKey ) ) {
2339
+ continue;
2340
+ }
2261
2341
  const quantity = Number( p.quantity ) || 0;
2262
2342
  const price = Number( p.price ) || 0;
2263
2343
  products.push( build( p.productName, quantity, price ) );
@@ -2272,8 +2352,13 @@ export async function getAdditionalProducts( clientId ) {
2272
2352
  export async function additionalProducts( req, res ) {
2273
2353
  try {
2274
2354
  const clientId = String( req.query?.clientId ?? req.body?.clientId ?? '' );
2275
- const products = await getAdditionalProducts( clientId );
2276
- 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 } );
2277
2362
  } catch ( error ) {
2278
2363
  logger.error( { error: error, function: 'additionalProducts' } );
2279
2364
  return res.sendError( error, 500 );
@@ -2300,6 +2385,35 @@ async function lamdaServiceCall( url, data ) {
2300
2385
  }
2301
2386
  }
2302
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
+
2303
2417
  // Unit price for a product from the latest billing_details OpenSearch record.
2304
2418
  // productName match is case-insensitive. Returns 0 if not found / unavailable.
2305
2419
  async function billingDetailsPrice( clientId, productName ) {
@@ -2319,13 +2433,82 @@ async function billingDetailsPrice( clientId, productName ) {
2319
2433
  }
2320
2434
  }
2321
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
+
2322
2500
  // Build the Lambda-backed export rows (VMS / Run AI / etc.). Calls the product's
2323
2501
  // Lambda for store_names (storeIds), maps them to the stores collection for
2324
2502
  // name + country (Zone), with current-month days and the billing_details price.
2325
- async function lambdaStoreExport( clientId, lambdaUrl, billingProductName ) {
2326
- 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' );
2327
2511
  const lambdaResult = await lamdaServiceCall( lambdaUrl, { to_date: toDate, client_id: clientId } );
2328
- console.log( '🚀 ~ lambdaStoreExport ~ lambdaResult:', lambdaResult );
2329
2512
  // These Lambdas return the storeIds under either `store_ids` (Run AI) or
2330
2513
  // `store_names` (VMS) with no status_code. Accept whichever is present; data
2331
2514
  // availability = a non-empty array.
@@ -2353,6 +2536,33 @@ async function lambdaStoreExport( clientId, lambdaUrl, billingProductName ) {
2353
2536
  } );
2354
2537
  }
2355
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
+
2356
2566
  // tangoTraffic working days per store, from the latest daily-pricing doc.
2357
2567
  // Map<storeId, workingdays>; stores with no tangoTraffic record are absent.
2358
2568
  async function tangoTrafficDaysByStore( clientId ) {
@@ -2522,8 +2732,9 @@ export async function additionalProductExport( req, res ) {
2522
2732
  if ( product === 'vms' ) {
2523
2733
  const exportData = await lambdaStoreExport(
2524
2734
  clientId,
2525
- 'https://ppf3l3mxc2lorh5hkrsj6zwyim0bupxw.lambda-url.ap-south-1.on.aws/',
2735
+ VMS_LAMBDA_URL,
2526
2736
  'VMS',
2737
+ reqDate,
2527
2738
  );
2528
2739
  if ( !exportData.length ) {
2529
2740
  return res.sendError( 'No data', 204 );
@@ -2535,10 +2746,10 @@ export async function additionalProductExport( req, res ) {
2535
2746
  if ( product === 'runai' ) {
2536
2747
  const exportData = await lambdaStoreExport(
2537
2748
  clientId,
2538
- 'https://mdm3mf7wuficgv3jjspkws2nu40azlsc.lambda-url.ap-south-1.on.aws/',
2749
+ RUN_AI_LAMBDA_URL,
2539
2750
  'Run AI',
2751
+ reqDate,
2540
2752
  );
2541
- console.log( '🚀 ~ additionalProductExport ~ exportData:', exportData );
2542
2753
  if ( !exportData.length ) {
2543
2754
  return res.sendError( 'No data', 204 );
2544
2755
  }
@@ -2547,29 +2758,54 @@ export async function additionalProductExport( req, res ) {
2547
2758
  }
2548
2759
 
2549
2760
  if ( product === 'remoteoptum' ) {
2550
- // Detailed optom-audit export from the remote_optom_steps_summary index
2551
- // for the given date (passed from the UI, defaults to today). Each record
2552
- // is one row; the nested `steps` object is flattened into TRUE/FALSE
2553
- // 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.
2554
2766
  const STEP_COLUMNS = [
2555
2767
  'Adjust-Phoropter', 'DuoChrome-Test', 'Explanation', 'Final-Prescription',
2556
2768
  'Handover', 'History-Taking', 'JCC', 'Near-Vision', 'Personal-Intro',
2557
2769
  'Subjective-Refraction', 'Trial-Frame', 'VA-Check',
2558
2770
  ];
2771
+ const fromDate = dayjs( reqDate ).startOf( 'month' ).format( 'YYYY-MM-DD' );
2559
2772
  let optomHits = [];
2773
+ let scrollId = null;
2560
2774
  try {
2561
- const osRes = await getOpenSearchData( 'remote_optom_steps_summary', {
2562
- // A single day is well under the 10k window; pull up to the cap.
2563
- // NOTE: do NOT sort on StartTime — it's an analyzed text field, and
2564
- // sorting on it makes OpenSearch return ZERO hits. The day filter is
2565
- // 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', {
2566
2781
  size: 10000,
2567
- query: { term: { 'Date': reqDate } },
2782
+ query: { range: { 'Date': { gte: fromDate, lte: reqDate } } },
2568
2783
  } );
2569
- 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
+ }
2570
2797
  } catch ( osErr ) {
2571
2798
  logger.error( { error: osErr, function: 'additionalProductExport.remoteOptum', clientId } );
2572
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
+ }
2573
2809
  }
2574
2810
 
2575
2811
  const exportData = optomHits.map( ( h ) => {
@@ -7,7 +7,7 @@ import Handlebars from '../utils/validations/helper/handlebar.helper.js';
7
7
  import fs from 'fs';
8
8
  import path from 'path';
9
9
  import htmlpdf from 'html-pdf-node';
10
- import { symbolFor } from '../utils/currency.js';
10
+ import { symbolFor, roundAmount } from '../utils/currency.js';
11
11
  import { getInvoiceCcEmails } from './invoice.controller.js';
12
12
 
13
13
  // Build the estimate PDF buffer + the template data + a safe filename. Shared
@@ -45,7 +45,9 @@ async function buildEstimatePdf( estimate ) {
45
45
  companyAddress: e.companyAddress || '',
46
46
  GSTNumber: e.GSTNumber || '',
47
47
  PlaceOfSupply: e.PlaceOfSupply || '',
48
- groupName: e.groupName || '',
48
+ // "Default Group" is the placeholder used when no real billing group was
49
+ // chosen — don't surface it on the estimate (the template hides empty).
50
+ groupName: ( e.groupName && e.groupName !== 'Default Group' ) ? e.groupName : '',
49
51
  period: e.period || '',
50
52
  createdDate: e.createdDate ? dayjs( e.createdDate ).format( 'DD/MM/YYYY' ) : '',
51
53
  validTill: e.validTill ? dayjs( e.validTill ).format( 'DD/MM/YYYY' ) : '',
@@ -211,11 +213,13 @@ export async function createEstimate( req, res ) {
211
213
  { 'clientName': 1, 'paymentInvoice.currencyType': 1 },
212
214
  );
213
215
 
214
- const amount = Math.round( Number( b.amount ) || 0 );
215
- let totalAmount = Math.round( Number( b.totalAmount ) || 0 );
216
+ // INR rounds to whole; other currencies keep 2 decimals.
217
+ const estimateCurrency = b.currency || ( client?.paymentInvoice?.currencyType === 'dollar' ? 'dollar' : 'inr' );
218
+ const amount = roundAmount( Number( b.amount ) || 0, estimateCurrency );
219
+ let totalAmount = roundAmount( Number( b.totalAmount ) || 0, estimateCurrency );
216
220
  if ( !totalAmount && amount ) {
217
221
  // Default to 18% GST when caller sends only the pre-tax amount.
218
- totalAmount = Math.round( amount * 1.18 );
222
+ totalAmount = roundAmount( amount * 1.18, estimateCurrency );
219
223
  }
220
224
 
221
225
  const { estimate, estimateIndex } = await nextEstimateNumber();
@@ -239,7 +243,7 @@ export async function createEstimate( req, res ) {
239
243
  tax: Array.isArray( b.tax ) ? b.tax : [],
240
244
  amount,
241
245
  totalAmount,
242
- currency: b.currency || ( client?.paymentInvoice?.currencyType === 'dollar' ? 'dollar' : 'inr' ),
246
+ currency: estimateCurrency,
243
247
  status: b.status === 'sent' ? 'sent' : 'pending',
244
248
  createdDate,
245
249
  validTill,
@@ -273,10 +277,12 @@ export async function updateEstimate( req, res ) {
273
277
  return res.sendError( 'An accepted estimate cannot be edited.', 409 );
274
278
  }
275
279
 
276
- const amount = Math.round( Number( b.amount ) || 0 );
277
- let totalAmount = Math.round( Number( b.totalAmount ) || 0 );
280
+ // INR rounds to whole; other currencies keep 2 decimals.
281
+ const estimateCurrency = b.currency || existing.currency;
282
+ const amount = roundAmount( Number( b.amount ) || 0, estimateCurrency );
283
+ let totalAmount = roundAmount( Number( b.totalAmount ) || 0, estimateCurrency );
278
284
  if ( !totalAmount && amount ) {
279
- totalAmount = Math.round( amount * 1.18 );
285
+ totalAmount = roundAmount( amount * 1.18, estimateCurrency );
280
286
  }
281
287
 
282
288
  // Only the editable fields are updated. The estimate number, index,
@@ -294,7 +300,7 @@ export async function updateEstimate( req, res ) {
294
300
  tax: Array.isArray( b.tax ) ? b.tax : existing.tax,
295
301
  amount,
296
302
  totalAmount,
297
- currency: b.currency || existing.currency,
303
+ currency: estimateCurrency,
298
304
  notes: b.notes ?? existing.notes,
299
305
  updatedBy: req.user?.email || req.user?.userName || '',
300
306
  };
@@ -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',
@@ -805,7 +814,7 @@ export async function invoiceDownload( req, res ) {
805
814
  let invoiceDate = dayjs( invoiceInfo.billingDate ).format( 'DD/MM/YYYY' );
806
815
 
807
816
  invoiceInfo.totalAmount = roundAmount( invoiceInfo.totalAmount, invoiceInfo.currency );
808
- let AmountinWords = inWords( invoiceInfo.totalAmount );
817
+ let AmountinWords = amountToWords( invoiceInfo.totalAmount, invoiceInfo.currency );
809
818
  let getgroup;
810
819
  let days = getgroup?.paymentTerm ? getgroup?.paymentTerm : '30';
811
820
  let dueDate = invoiceInfo?.dueDate ? dayjs( invoiceInfo?.dueDate ).format( 'DD/MM/YYYY' ) : dayjs().add( days, 'days' ).format( 'DD/MM/YYYY' );
@@ -1006,7 +1015,7 @@ async function buildInvoicePdfBuffer( invoiceId ) {
1006
1015
 
1007
1016
  let invoiceDate = dayjs( invoiceInfo.billingDate ).format( 'DD/MM/YYYY' );
1008
1017
  invoiceInfo.totalAmount = roundAmount( invoiceInfo.totalAmount, invoiceInfo.currency );
1009
- let AmountinWords = inWords( invoiceInfo.totalAmount );
1018
+ let AmountinWords = amountToWords( invoiceInfo.totalAmount, invoiceInfo.currency );
1010
1019
  let getgroup;
1011
1020
  if ( invoiceInfo.groupId ) {
1012
1021
  getgroup = await billingService.findOne( { _id: invoiceInfo.groupId } );
@@ -1218,6 +1227,32 @@ function inWords( num ) {
1218
1227
  return str.toLowerCase().split( ' ' ).map( ( word ) => word.charAt( 0 ).toUpperCase() + word.slice( 1 ) ).join( ' ' );
1219
1228
  }
1220
1229
 
1230
+ // Sub-unit (fraction) name per currency for the "Total In Words" cents part.
1231
+ // INR is whole-number only, so it has no fraction.
1232
+ const CURRENCY_FRACTION_WORDS = {
1233
+ dollar: 'Cents',
1234
+ singaporedollar: 'Cents',
1235
+ euro: 'Cents',
1236
+ aed: 'Fils',
1237
+ };
1238
+
1239
+ // Spell out an amount. INR -> whole number only. Other currencies keep 2
1240
+ // decimals, so the cents are spelled out too when non-zero
1241
+ // (e.g. 3191.10 -> "Three Thousand One Hundred And Ninety One and Ten Cents").
1242
+ function amountToWords( value, currency ) {
1243
+ const n = Number( value ) || 0;
1244
+ if ( currency === 'inr' ) {
1245
+ return inWords( Math.round( n ) );
1246
+ }
1247
+ const whole = Math.trunc( n );
1248
+ const cents = Math.round( ( n - whole ) * 100 );
1249
+ const fraction = CURRENCY_FRACTION_WORDS[currency] || 'Cents';
1250
+ if ( cents > 0 ) {
1251
+ return `${inWords( whole )} and ${inWords( cents )} ${fraction}`;
1252
+ }
1253
+ return inWords( whole );
1254
+ }
1255
+
1221
1256
 
1222
1257
  // Resolve which basepricing doc applies to a billing group. When the client
1223
1258
  // has billingGroupWisePricing enabled AND a doc exists for this group, returns
@@ -3125,7 +3125,7 @@ export const invoiceDownload = async ( req, res ) => {
3125
3125
  let dueDate = invoiceInfo?.dueDate ? dayjs( invoiceInfo?.dueDate ).format( 'DD/MM/YYYY' ) : dayjs().add( days, 'days' ).format( 'DD/MM/YYYY' );
3126
3126
 
3127
3127
  invoiceInfo.totalAmount = invoiceInfo.totalAmount;
3128
- let AmountinWords = inWords( invoiceInfo.totalAmount );
3128
+ let AmountinWords = amountToWords( invoiceInfo.totalAmount, invoicePdfCurrency );
3129
3129
  invoiceData = {
3130
3130
  ...invoiceInfo._doc,
3131
3131
  clientName: clientDetails.clientName,
@@ -3254,6 +3254,31 @@ function inWords( num ) {
3254
3254
  return str.toLowerCase().split( ' ' ).map( ( word ) => word.charAt( 0 ).toUpperCase() + word.slice( 1 ) ).join( ' ' );
3255
3255
  }
3256
3256
 
3257
+ // Sub-unit (fraction) name per currency for the "Total In Words" cents part.
3258
+ const CURRENCY_FRACTION_WORDS = {
3259
+ dollar: 'Cents',
3260
+ singaporedollar: 'Cents',
3261
+ euro: 'Cents',
3262
+ aed: 'Fils',
3263
+ };
3264
+
3265
+ // Spell out an amount. INR -> whole number only. Other currencies keep 2
3266
+ // decimals, so the cents are spelled out too when non-zero
3267
+ // (e.g. 383.04 -> "Three Hundred And Eighty Three and Four Cents").
3268
+ function amountToWords( value, currency ) {
3269
+ const n = Number( value ) || 0;
3270
+ if ( currency === 'inr' ) {
3271
+ return inWords( Math.round( n ) );
3272
+ }
3273
+ const whole = Math.trunc( n );
3274
+ const cents = Math.round( ( n - whole ) * 100 );
3275
+ const fraction = CURRENCY_FRACTION_WORDS[currency] || 'Cents';
3276
+ if ( cents > 0 ) {
3277
+ return `${inWords( whole )} and ${inWords( cents )} ${fraction}`;
3278
+ }
3279
+ return inWords( whole );
3280
+ }
3281
+
3257
3282
  export const updateInvoiceStatus = async ( req, res ) => {
3258
3283
  try {
3259
3284
  if ( !req.params?.invoiceId ) {
@@ -4225,7 +4250,7 @@ export async function uploadClientDocument( req, res ) {
4225
4250
  const clientId = String( req.body?.clientId || '' );
4226
4251
  const documentName = String( req.body?.documentName || '' ).trim();
4227
4252
  const expiryDateRaw = req.body?.expiryDate;
4228
- const file = req.file; // multer single('file')
4253
+ const file = req.files?.file; // express-fileupload → req.files.file
4229
4254
 
4230
4255
  if ( !clientId ) {
4231
4256
  return res.sendError( 'clientId is required', 400 );
@@ -4257,7 +4282,7 @@ export async function uploadClientDocument( req, res ) {
4257
4282
  Key: key,
4258
4283
  fileName: fileName,
4259
4284
  ContentType: file.mimetype,
4260
- body: file.buffer,
4285
+ body: file.data,
4261
4286
  } );
4262
4287
 
4263
4288
  const storedPath = `${key}${fileName}`;
@@ -4307,6 +4332,86 @@ export async function getClientDocuments( req, res ) {
4307
4332
  }
4308
4333
  }
4309
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
+
4310
4415
  // Toggle billing-group-wise pricing on the client. When enabled, pricing is
4311
4416
  // maintained per billing group (separate basepricing docs keyed by groupId).
4312
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.
@@ -1423,7 +1423,6 @@
1423
1423
  </div>
1424
1424
  </div>
1425
1425
  <div class="_1234"># {{estimate}}</div>
1426
- <div><span class="pill pill-{{status}}">{{statusLabel}}</span></div>
1427
1426
 
1428
1427
  </div>
1429
1428
 
@@ -881,6 +881,10 @@
881
881
  align-self: stretch;
882
882
  flex-shrink: 0;
883
883
  position: relative;
884
+ /* Keep the whole totals block (Sub Total ... Total In Words) on a
885
+ single page so the amount-in-words can't be split across pages. */
886
+ break-inside: avoid;
887
+ page-break-inside: avoid;
884
888
  }
885
889
 
886
890
  .frame-2394 {
@@ -1011,6 +1015,8 @@
1011
1015
  justify-content: flex-start;
1012
1016
  flex-shrink: 0;
1013
1017
  position: relative;
1018
+ break-inside: avoid;
1019
+ page-break-inside: avoid;
1014
1020
  }
1015
1021
 
1016
1022
  .text13 {
@@ -844,6 +844,10 @@
844
844
  align-self: stretch;
845
845
  flex-shrink: 0;
846
846
  position: relative;
847
+ /* Keep the whole totals block (Sub Total ... Total In Words) on a
848
+ single page so the amount-in-words can't be split across pages. */
849
+ break-inside: avoid;
850
+ page-break-inside: avoid;
847
851
  }
848
852
 
849
853
  .frame-2394 {
@@ -974,6 +978,8 @@
974
978
  justify-content: flex-start;
975
979
  flex-shrink: 0;
976
980
  position: relative;
981
+ break-inside: avoid;
982
+ page-break-inside: avoid;
977
983
  }
978
984
 
979
985
  .text13 {
@@ -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
  };