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 +2 -2
- package/src/controllers/brandsBilling.controller.js +251 -24
- package/src/controllers/invoice.controller.js +10 -1
- package/src/controllers/paymentSubscription.controllers.js +82 -2
- package/src/controllers/purchaseOrder.controller.js +112 -4
- package/src/hbs/invoicepaymentemail.hbs +4 -0
- package/src/routes/billing.routes.js +7 -8
- package/src/routes/paymentSubscription.routes.js +5 -7
- package/src/services/clientPayment.services.js +21 -0
- package/src/services/purchaseOrder.service.js +4 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tango-app-api-payment-subscription",
|
|
3
|
-
"version": "3.5.
|
|
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
|
-
|
|
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.
|
|
2260
|
-
//
|
|
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
|
|
2285
|
-
|
|
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
|
-
|
|
2335
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
2561
|
-
//
|
|
2562
|
-
//
|
|
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
|
-
|
|
2571
|
-
|
|
2572
|
-
|
|
2573
|
-
|
|
2574
|
-
|
|
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: {
|
|
2782
|
+
query: { range: { 'Date': { gte: fromDate, lte: reqDate } } },
|
|
2577
2783
|
} );
|
|
2578
|
-
|
|
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
|
-
|
|
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; //
|
|
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.
|
|
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 (
|
|
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.
|
|
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
|
-
//
|
|
46
|
-
//
|
|
47
|
-
//
|
|
48
|
-
|
|
49
|
-
billingRouter.
|
|
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
|
-
|
|
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
|
};
|