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