tango-app-api-payment-subscription 3.5.16 → 3.5.18
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/scripts/create-billing-groups-by-country.js +1 -1
- package/src/controllers/billing.controllers.js +31 -1
- package/src/controllers/brandsBilling.controller.js +332 -2
- package/src/controllers/invoice.controller.js +230 -41
- package/src/controllers/paymentSubscription.controllers.js +68 -8
- package/src/controllers/purchaseOrder.controller.js +171 -0
- package/src/dtos/validation.dtos.js +6 -0
- package/src/routes/billing.routes.js +7 -0
- package/src/routes/brandsBilling.routes.js +2 -1
- package/src/routes/paymentSubscription.routes.js +3 -0
- package/src/services/purchaseOrder.service.js +21 -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.18",
|
|
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.36",
|
|
33
33
|
"tango-app-api-middleware": "^3.6.18",
|
|
34
34
|
"winston": "^3.12.0",
|
|
35
35
|
"winston-daily-rotate-file": "^5.0.0",
|
|
@@ -36,7 +36,7 @@ import mongoose from 'mongoose';
|
|
|
36
36
|
import 'dotenv/config';
|
|
37
37
|
import { getConnection } from '../config/database/database.js';
|
|
38
38
|
|
|
39
|
-
const DEFAULT_CLIENTS = [ '387', '193' ];
|
|
39
|
+
const DEFAULT_CLIENTS = [ '387', '193', '11' ];
|
|
40
40
|
|
|
41
41
|
// Fallback billing group for stores that have no storeProfile.country.
|
|
42
42
|
const UNKNOWN_GROUP_NAME = 'Unknown';
|
|
@@ -266,7 +266,37 @@ export const getAllBillingGroups = async ( req, res ) => {
|
|
|
266
266
|
try {
|
|
267
267
|
const billingGroups = await find( { clientId: req.query.clientId } );
|
|
268
268
|
|
|
269
|
-
|
|
269
|
+
// Per group: does an ADVANCE invoice already cover the CURRENT month? If so,
|
|
270
|
+
// attach { invoice, period } so the Generate Invoice popup can show it (and
|
|
271
|
+
// signal a normal monthly invoice will be made instead of another advance).
|
|
272
|
+
const currentMonthLabel = dayjs().format( 'MMM YYYY' );
|
|
273
|
+
const enriched = await Promise.all( ( billingGroups || [] ).map( async ( g ) => {
|
|
274
|
+
const plain = g.toObject?.() || g;
|
|
275
|
+
let advanceCoverage = null;
|
|
276
|
+
try {
|
|
277
|
+
const adv = await invoiceService.findOne( {
|
|
278
|
+
'groupId': g._id,
|
|
279
|
+
'advanceInvoice': true,
|
|
280
|
+
'products.month': currentMonthLabel,
|
|
281
|
+
} );
|
|
282
|
+
if ( adv ) {
|
|
283
|
+
// Ordered distinct month labels across the advance invoice's line
|
|
284
|
+
// items → period "first to last" (e.g. "Jun-2026 to Aug-2026").
|
|
285
|
+
const months = [ ...new Set( ( adv.products || [] ).map( ( p ) => p.month ).filter( Boolean ) ) ];
|
|
286
|
+
months.sort( ( a, b ) => dayjs( a, 'MMM YYYY' ).valueOf() - dayjs( b, 'MMM YYYY' ).valueOf() );
|
|
287
|
+
const fmt = ( m ) => dayjs( m, 'MMM YYYY' ).format( 'MMM-YYYY' );
|
|
288
|
+
const period = months.length ?
|
|
289
|
+
( months.length > 1 ? `${fmt( months[0] )} to ${fmt( months[months.length - 1] )}` : fmt( months[0] ) ) :
|
|
290
|
+
'';
|
|
291
|
+
advanceCoverage = { invoice: adv.invoice, period };
|
|
292
|
+
}
|
|
293
|
+
} catch ( e ) {
|
|
294
|
+
// Coverage is advisory only — never fail the group list over it.
|
|
295
|
+
}
|
|
296
|
+
return { ...plain, advanceCoverage };
|
|
297
|
+
} ) );
|
|
298
|
+
|
|
299
|
+
return res.sendSuccess( enriched );
|
|
270
300
|
} catch ( error ) {
|
|
271
301
|
logger.error( { error: error, function: 'getBillingGroups' } );
|
|
272
302
|
return res.sendError( error, 500 );
|
|
@@ -1057,8 +1057,11 @@ export async function updateDailyPricingWorkingDays( req, res ) {
|
|
|
1057
1057
|
{ arrayFilters: [ { 'store.storeId': storeId }, { 'product.productName': productName } ] },
|
|
1058
1058
|
);
|
|
1059
1059
|
|
|
1060
|
-
|
|
1061
|
-
|
|
1060
|
+
// Only a missing record is a real failure. matchedCount > 0 with
|
|
1061
|
+
// modifiedCount 0 just means the value was already that number (e.g. saving
|
|
1062
|
+
// 0 on an already-0 row) — treat that as success, not an error.
|
|
1063
|
+
if ( ( result.matchedCount || 0 ) === 0 ) {
|
|
1064
|
+
return res.sendError( 'Record not found', 404 );
|
|
1062
1065
|
}
|
|
1063
1066
|
|
|
1064
1067
|
const logObj = {
|
|
@@ -2270,3 +2273,330 @@ export async function additionalProducts( req, res ) {
|
|
|
2270
2273
|
return res.sendError( error, 500 );
|
|
2271
2274
|
}
|
|
2272
2275
|
}
|
|
2276
|
+
|
|
2277
|
+
// POST a JSON payload to a Lambda Function URL and return the parsed JSON
|
|
2278
|
+
// (or false on failure). Node's fetch forbids a body on GET, so POST is used.
|
|
2279
|
+
async function lamdaServiceCall( url, data ) {
|
|
2280
|
+
try {
|
|
2281
|
+
const response = await fetch( url, {
|
|
2282
|
+
method: 'POST',
|
|
2283
|
+
headers: { 'Content-Type': 'application/json' },
|
|
2284
|
+
body: JSON.stringify( data ),
|
|
2285
|
+
signal: AbortSignal.timeout( 300000 ),
|
|
2286
|
+
} );
|
|
2287
|
+
if ( !response.ok ) {
|
|
2288
|
+
throw new Error( `Response status: ${response.status}` );
|
|
2289
|
+
}
|
|
2290
|
+
return await response.json();
|
|
2291
|
+
} catch ( error ) {
|
|
2292
|
+
logger.error( { error: error, message: data, function: 'lamdaServiceCall' } );
|
|
2293
|
+
return false;
|
|
2294
|
+
}
|
|
2295
|
+
}
|
|
2296
|
+
|
|
2297
|
+
// Unit price for a product from the latest billing_details OpenSearch record.
|
|
2298
|
+
// productName match is case-insensitive. Returns 0 if not found / unavailable.
|
|
2299
|
+
async function billingDetailsPrice( clientId, productName ) {
|
|
2300
|
+
try {
|
|
2301
|
+
const osRes = await getOpenSearchData( BILLING_DETAILS_INDEX, {
|
|
2302
|
+
size: 1,
|
|
2303
|
+
query: { term: { 'client_id': clientId } },
|
|
2304
|
+
sort: [ { date_string: { order: 'desc' } } ],
|
|
2305
|
+
} );
|
|
2306
|
+
const hits = osRes?.body?.hits?.hits || osRes?.hits?.hits || [];
|
|
2307
|
+
const match = ( hits[0]?._source?.products || [] )
|
|
2308
|
+
.find( ( p ) => String( p.productName ).toLowerCase() === String( productName ).toLowerCase() );
|
|
2309
|
+
return Number( match?.price ) || 0;
|
|
2310
|
+
} catch ( osErr ) {
|
|
2311
|
+
logger.error( { error: osErr, function: 'billingDetailsPrice', clientId, productName } );
|
|
2312
|
+
return 0;
|
|
2313
|
+
}
|
|
2314
|
+
}
|
|
2315
|
+
|
|
2316
|
+
// Build the Lambda-backed export rows (VMS / Run AI / etc.). Calls the product's
|
|
2317
|
+
// Lambda for store_names (storeIds), maps them to the stores collection for
|
|
2318
|
+
// name + country (Zone), with current-month days and the billing_details price.
|
|
2319
|
+
async function lambdaStoreExport( clientId, lambdaUrl, billingProductName ) {
|
|
2320
|
+
const toDate = dayjs().format( 'DD-MM-YYYY' );
|
|
2321
|
+
const lambdaResult = await lamdaServiceCall( lambdaUrl, { to_date: toDate, client_id: clientId } );
|
|
2322
|
+
console.log( '🚀 ~ lambdaStoreExport ~ lambdaResult:', lambdaResult );
|
|
2323
|
+
// These Lambdas return the storeIds under either `store_ids` (Run AI) or
|
|
2324
|
+
// `store_names` (VMS) with no status_code. Accept whichever is present; data
|
|
2325
|
+
// availability = a non-empty array.
|
|
2326
|
+
const rawIds = ( lambdaResult && Array.isArray( lambdaResult.store_ids ) ) ? lambdaResult.store_ids :
|
|
2327
|
+
( ( lambdaResult && Array.isArray( lambdaResult.store_names ) ) ? lambdaResult.store_names : [] );
|
|
2328
|
+
const storeIds = rawIds.map( ( s ) => String( s ) );
|
|
2329
|
+
if ( !storeIds.length ) {
|
|
2330
|
+
return [];
|
|
2331
|
+
}
|
|
2332
|
+
const price = await billingDetailsPrice( clientId, billingProductName );
|
|
2333
|
+
const daysInMonth = dayjs().daysInMonth();
|
|
2334
|
+
const stores = await storeService.find(
|
|
2335
|
+
{ clientId: clientId, storeId: { $in: storeIds } },
|
|
2336
|
+
{ 'storeId': 1, 'storeName': 1, 'storeProfile.country': 1 },
|
|
2337
|
+
);
|
|
2338
|
+
const byId = new Map( stores.map( ( s ) => [ String( s.storeId ), s ] ) );
|
|
2339
|
+
return storeIds.map( ( id ) => {
|
|
2340
|
+
const s = byId.get( String( id ) );
|
|
2341
|
+
return {
|
|
2342
|
+
'Store Names': s?.storeName || id,
|
|
2343
|
+
'No of Days to be billed': daysInMonth,
|
|
2344
|
+
'Final Amount': price,
|
|
2345
|
+
'Zone': s?.storeProfile?.country || '',
|
|
2346
|
+
};
|
|
2347
|
+
} );
|
|
2348
|
+
}
|
|
2349
|
+
|
|
2350
|
+
// tangoTraffic working days per store, from the latest daily-pricing doc.
|
|
2351
|
+
// Map<storeId, workingdays>; stores with no tangoTraffic record are absent.
|
|
2352
|
+
async function tangoTrafficDaysByStore( clientId ) {
|
|
2353
|
+
const rows = await dailyPriceService.aggregate( [
|
|
2354
|
+
{ $match: { clientId: clientId } },
|
|
2355
|
+
{ $sort: { dateISO: -1 } },
|
|
2356
|
+
{ $limit: 1 },
|
|
2357
|
+
{ $unwind: '$stores' },
|
|
2358
|
+
{ $unwind: '$stores.products' },
|
|
2359
|
+
{ $match: { 'stores.products.productName': 'tangoTraffic' } },
|
|
2360
|
+
{ $group: { _id: '$stores.storeId', days: { $max: '$stores.products.workingdays' } } },
|
|
2361
|
+
] );
|
|
2362
|
+
const map = new Map();
|
|
2363
|
+
for ( const r of rows ) {
|
|
2364
|
+
map.set( String( r._id ), r.days || 0 );
|
|
2365
|
+
}
|
|
2366
|
+
return map;
|
|
2367
|
+
}
|
|
2368
|
+
|
|
2369
|
+
// Per-product detailed Excel export for the Additional Products section.
|
|
2370
|
+
// product selected via req.body.product / req.query.product. Eyetest is the
|
|
2371
|
+
// first; more products plug into the switch as their queries arrive.
|
|
2372
|
+
export async function additionalProductExport( req, res ) {
|
|
2373
|
+
try {
|
|
2374
|
+
const clientId = String( req.body?.clientId ?? req.query?.clientId ?? '' );
|
|
2375
|
+
const product = String( req.body?.product ?? req.query?.product ?? '' ).toLowerCase();
|
|
2376
|
+
// Optional date (YYYY-MM-DD) passed from the UI; defaults to today. Used by
|
|
2377
|
+
// products that report by a specific day (e.g. Remote Optum).
|
|
2378
|
+
const reqDate = String( req.body?.date ?? req.query?.date ?? '' ) || dayjs().format( 'YYYY-MM-DD' );
|
|
2379
|
+
if ( !clientId ) {
|
|
2380
|
+
return res.sendError( 'clientId is required', 400 );
|
|
2381
|
+
}
|
|
2382
|
+
|
|
2383
|
+
if ( product === 'eyetest' ) {
|
|
2384
|
+
// Eye-test streams joined to their store, grouped per store to a stream
|
|
2385
|
+
// count; enriched with store name, country (Zone) and the store's
|
|
2386
|
+
// tangoTraffic working days (No of Days to be billed).
|
|
2387
|
+
const streamRows = await cameraService.aggregate( [
|
|
2388
|
+
{ $match: { $and: [
|
|
2389
|
+
{ clientId: clientId },
|
|
2390
|
+
{ isEyeTestStream: true },
|
|
2391
|
+
{ qrCode: { $exists: true } },
|
|
2392
|
+
] } },
|
|
2393
|
+
{ $lookup: {
|
|
2394
|
+
from: 'stores',
|
|
2395
|
+
let: { storeId: '$storeId' },
|
|
2396
|
+
pipeline: [ { $match: { $expr: { $and: [ { $eq: [ '$storeId', '$$storeId' ] } ] } } } ],
|
|
2397
|
+
as: 'stores',
|
|
2398
|
+
} },
|
|
2399
|
+
{ $unwind: { path: '$stores', preserveNullAndEmptyArrays: true } },
|
|
2400
|
+
{ $group: {
|
|
2401
|
+
_id: '$storeId',
|
|
2402
|
+
streamCount: { $sum: 1 },
|
|
2403
|
+
storeName: { $first: '$stores.storeName' },
|
|
2404
|
+
country: { $first: '$stores.storeProfile.country' },
|
|
2405
|
+
} },
|
|
2406
|
+
{ $sort: { storeName: 1 } },
|
|
2407
|
+
] );
|
|
2408
|
+
|
|
2409
|
+
const daysMap = await tangoTrafficDaysByStore( clientId );
|
|
2410
|
+
const price = ADDITIONAL_PRODUCT_PRICES.eyetest;
|
|
2411
|
+
const exportData = streamRows.map( ( r ) => {
|
|
2412
|
+
const streamCount = r.streamCount || 0;
|
|
2413
|
+
const days = daysMap.get( String( r._id ) ) || 0;
|
|
2414
|
+
// Amount = streams x price (no proration).
|
|
2415
|
+
return {
|
|
2416
|
+
'storeId': r._id,
|
|
2417
|
+
'storeName': r.storeName || '',
|
|
2418
|
+
'Stream Count': streamCount,
|
|
2419
|
+
'No of Days to be billed': days,
|
|
2420
|
+
'Amount': streamCount * price,
|
|
2421
|
+
'Zone': r.country || '',
|
|
2422
|
+
};
|
|
2423
|
+
} );
|
|
2424
|
+
|
|
2425
|
+
if ( !exportData.length ) {
|
|
2426
|
+
return res.sendError( 'No data', 204 );
|
|
2427
|
+
}
|
|
2428
|
+
await download( exportData, res );
|
|
2429
|
+
return;
|
|
2430
|
+
}
|
|
2431
|
+
|
|
2432
|
+
if ( product === 'planogram' ) {
|
|
2433
|
+
// Unique storeName from the planograms collection, enriched with the
|
|
2434
|
+
// store's country (Zone) and tangoTraffic working days. Amount is the
|
|
2435
|
+
// flat planogram price per store.
|
|
2436
|
+
const planoRows = await planogramService.aggregate( [
|
|
2437
|
+
{ $match: { clientId: clientId } },
|
|
2438
|
+
{ $group: { _id: '$storeName', storeId: { $first: '$storeId' } } },
|
|
2439
|
+
{ $lookup: {
|
|
2440
|
+
from: 'stores',
|
|
2441
|
+
let: { sid: '$storeId' },
|
|
2442
|
+
pipeline: [ { $match: { $expr: { $eq: [ '$storeId', '$$sid' ] } } } ],
|
|
2443
|
+
as: 'store',
|
|
2444
|
+
} },
|
|
2445
|
+
{ $unwind: { path: '$store', preserveNullAndEmptyArrays: true } },
|
|
2446
|
+
{ $project: { _id: 0, storeName: '$_id', storeId: 1, country: '$store.storeProfile.country' } },
|
|
2447
|
+
{ $sort: { storeName: 1 } },
|
|
2448
|
+
] );
|
|
2449
|
+
|
|
2450
|
+
const price = ADDITIONAL_PRODUCT_PRICES.planogram;
|
|
2451
|
+
// Planogram bills the full current month — every store shows the number of
|
|
2452
|
+
// days in the current calendar month (not per-store working days).
|
|
2453
|
+
const daysInMonth = dayjs().daysInMonth();
|
|
2454
|
+
const exportData = planoRows.map( ( r ) => {
|
|
2455
|
+
// Amount = flat planogram price per store (no proration).
|
|
2456
|
+
return {
|
|
2457
|
+
'storeName': r.storeName || '',
|
|
2458
|
+
'No of Days to be billed': daysInMonth,
|
|
2459
|
+
'Amount': price,
|
|
2460
|
+
'Zone': r.country || '',
|
|
2461
|
+
};
|
|
2462
|
+
} );
|
|
2463
|
+
|
|
2464
|
+
if ( !exportData.length ) {
|
|
2465
|
+
return res.sendError( 'No data', 204 );
|
|
2466
|
+
}
|
|
2467
|
+
await download( exportData, res );
|
|
2468
|
+
return;
|
|
2469
|
+
}
|
|
2470
|
+
|
|
2471
|
+
if ( product === 'aimanager' ) {
|
|
2472
|
+
// AI Manager bills the same stores as tangoTraffic running > 1 day. One
|
|
2473
|
+
// row per such store with its tangoTraffic working days (No of Days to be
|
|
2474
|
+
// billed), the flat AI Manager price, and the store's country (Zone).
|
|
2475
|
+
const aiRows = await dailyPriceService.aggregate( [
|
|
2476
|
+
{ $match: { clientId: clientId } },
|
|
2477
|
+
{ $sort: { dateISO: -1 } },
|
|
2478
|
+
{ $limit: 1 },
|
|
2479
|
+
{ $unwind: '$stores' },
|
|
2480
|
+
{ $unwind: '$stores.products' },
|
|
2481
|
+
{ $match: { 'stores.products.productName': 'tangoTraffic', 'stores.products.workingdays': { $gt: 1 } } },
|
|
2482
|
+
{ $group: {
|
|
2483
|
+
_id: '$stores.storeId',
|
|
2484
|
+
storeName: { $first: '$stores.storeName' },
|
|
2485
|
+
days: { $max: '$stores.products.workingdays' },
|
|
2486
|
+
} },
|
|
2487
|
+
{ $lookup: {
|
|
2488
|
+
from: 'stores',
|
|
2489
|
+
let: { sid: '$_id' },
|
|
2490
|
+
pipeline: [ { $match: { $expr: { $eq: [ '$storeId', '$$sid' ] } } } ],
|
|
2491
|
+
as: 'store',
|
|
2492
|
+
} },
|
|
2493
|
+
{ $unwind: { path: '$store', preserveNullAndEmptyArrays: true } },
|
|
2494
|
+
{ $project: { _id: 0, storeId: '$_id', storeName: 1, days: 1, country: '$store.storeProfile.country' } },
|
|
2495
|
+
{ $sort: { storeName: 1 } },
|
|
2496
|
+
] );
|
|
2497
|
+
|
|
2498
|
+
const price = ADDITIONAL_PRODUCT_PRICES.aiManager;
|
|
2499
|
+
const exportData = aiRows.map( ( r ) => {
|
|
2500
|
+
// Amount = flat AI Manager price per store (no proration).
|
|
2501
|
+
return {
|
|
2502
|
+
'Store Name': r.storeName || '',
|
|
2503
|
+
'No of Days to be billed': r.days || 0,
|
|
2504
|
+
'Final Amount': price,
|
|
2505
|
+
'Zone': r.country || '',
|
|
2506
|
+
};
|
|
2507
|
+
} );
|
|
2508
|
+
|
|
2509
|
+
if ( !exportData.length ) {
|
|
2510
|
+
return res.sendError( 'No data', 204 );
|
|
2511
|
+
}
|
|
2512
|
+
await download( exportData, res );
|
|
2513
|
+
return;
|
|
2514
|
+
}
|
|
2515
|
+
|
|
2516
|
+
if ( product === 'vms' ) {
|
|
2517
|
+
const exportData = await lambdaStoreExport(
|
|
2518
|
+
clientId,
|
|
2519
|
+
'https://ppf3l3mxc2lorh5hkrsj6zwyim0bupxw.lambda-url.ap-south-1.on.aws/',
|
|
2520
|
+
'VMS',
|
|
2521
|
+
);
|
|
2522
|
+
if ( !exportData.length ) {
|
|
2523
|
+
return res.sendError( 'No data', 204 );
|
|
2524
|
+
}
|
|
2525
|
+
await download( exportData, res );
|
|
2526
|
+
return;
|
|
2527
|
+
}
|
|
2528
|
+
|
|
2529
|
+
if ( product === 'runai' ) {
|
|
2530
|
+
const exportData = await lambdaStoreExport(
|
|
2531
|
+
clientId,
|
|
2532
|
+
'https://mdm3mf7wuficgv3jjspkws2nu40azlsc.lambda-url.ap-south-1.on.aws/',
|
|
2533
|
+
'Run AI',
|
|
2534
|
+
);
|
|
2535
|
+
console.log( '🚀 ~ additionalProductExport ~ exportData:', exportData );
|
|
2536
|
+
if ( !exportData.length ) {
|
|
2537
|
+
return res.sendError( 'No data', 204 );
|
|
2538
|
+
}
|
|
2539
|
+
await download( exportData, res );
|
|
2540
|
+
return;
|
|
2541
|
+
}
|
|
2542
|
+
|
|
2543
|
+
if ( product === 'remoteoptum' ) {
|
|
2544
|
+
// Detailed optom-audit export from the remote_optom_steps_summary index
|
|
2545
|
+
// for the given date (passed from the UI, defaults to today). Each record
|
|
2546
|
+
// is one row; the nested `steps` object is flattened into TRUE/FALSE
|
|
2547
|
+
// columns in a fixed (alphabetical) order.
|
|
2548
|
+
const STEP_COLUMNS = [
|
|
2549
|
+
'Adjust-Phoropter', 'DuoChrome-Test', 'Explanation', 'Final-Prescription',
|
|
2550
|
+
'Handover', 'History-Taking', 'JCC', 'Near-Vision', 'Personal-Intro',
|
|
2551
|
+
'Subjective-Refraction', 'Trial-Frame', 'VA-Check',
|
|
2552
|
+
];
|
|
2553
|
+
let optomHits = [];
|
|
2554
|
+
try {
|
|
2555
|
+
const osRes = await getOpenSearchData( 'remote_optom_steps_summary', {
|
|
2556
|
+
// A single day is well under the 10k window; pull up to the cap.
|
|
2557
|
+
// NOTE: do NOT sort on StartTime — it's an analyzed text field, and
|
|
2558
|
+
// sorting on it makes OpenSearch return ZERO hits. The day filter is
|
|
2559
|
+
// enough; rows come back in index order.
|
|
2560
|
+
size: 10000,
|
|
2561
|
+
query: { term: { 'Date': reqDate } },
|
|
2562
|
+
} );
|
|
2563
|
+
optomHits = osRes?.body?.hits?.hits || osRes?.hits?.hits || [];
|
|
2564
|
+
} catch ( osErr ) {
|
|
2565
|
+
logger.error( { error: osErr, function: 'additionalProductExport.remoteOptum', clientId } );
|
|
2566
|
+
return res.sendError( 'Failed to fetch Remote Optum data', 502 );
|
|
2567
|
+
}
|
|
2568
|
+
|
|
2569
|
+
const exportData = optomHits.map( ( h ) => {
|
|
2570
|
+
const s = h._source || {};
|
|
2571
|
+
const steps = s.steps || {};
|
|
2572
|
+
const row = {
|
|
2573
|
+
'storeName': s.storeName || '',
|
|
2574
|
+
'engagementId': s.engagementId || '',
|
|
2575
|
+
'queue_id': s.queue_id || '',
|
|
2576
|
+
'optm_name': s.optm_name || '',
|
|
2577
|
+
'optm_id': s.optm_id || '',
|
|
2578
|
+
'optm_Emailid': s.optm_Emailid || '',
|
|
2579
|
+
'Date': s.Date || '',
|
|
2580
|
+
'StartTime': s.StartTime || '',
|
|
2581
|
+
'EndTime': s.EndTime || '',
|
|
2582
|
+
'Duration (min)': s['Duration (min)'] != null ? s['Duration (min)'] : '',
|
|
2583
|
+
};
|
|
2584
|
+
for ( const col of STEP_COLUMNS ) {
|
|
2585
|
+
row[col] = steps[col] === true ? 'TRUE' : 'FALSE';
|
|
2586
|
+
}
|
|
2587
|
+
return row;
|
|
2588
|
+
} );
|
|
2589
|
+
|
|
2590
|
+
if ( !exportData.length ) {
|
|
2591
|
+
return res.sendError( 'No data', 204 );
|
|
2592
|
+
}
|
|
2593
|
+
await download( exportData, res );
|
|
2594
|
+
return;
|
|
2595
|
+
}
|
|
2596
|
+
|
|
2597
|
+
return res.sendError( `Unsupported product: ${product}`, 400 );
|
|
2598
|
+
} catch ( error ) {
|
|
2599
|
+
logger.error( { error: error, function: 'additionalProductExport' } );
|
|
2600
|
+
return res.sendError( error, 500 );
|
|
2601
|
+
}
|
|
2602
|
+
}
|
|
@@ -19,6 +19,7 @@ import { findOneApplicationDefault } from '../services/applicationDefault.servic
|
|
|
19
19
|
import * as assignedStoreService from '../services/assignedStore.service.js';
|
|
20
20
|
import * as bankTransactionService from '../services/bankTransaction.service.js';
|
|
21
21
|
import { getUsdInrRate, getAdditionalProducts } from './brandsBilling.controller.js';
|
|
22
|
+
import { applyInvoiceToPurchaseOrder } from './purchaseOrder.controller.js';
|
|
22
23
|
|
|
23
24
|
// Pulls CSM + Finance head emails (stored under applicationDefault
|
|
24
25
|
// type=invoice, subType=heads) AND the per-client CSMs assigned via
|
|
@@ -126,8 +127,11 @@ export async function createInvoice( req, res ) {
|
|
|
126
127
|
return res.sendError( 'clientId is required for customInvoice', 400 );
|
|
127
128
|
}
|
|
128
129
|
const Finacialyear = getCurrentFinancialYear();
|
|
130
|
+
// Quarterly/half-yearly/yearly advance invoices use the separate TINV-
|
|
131
|
+
// series with its own per-FY counter; monthly advance + normal stay INV-.
|
|
132
|
+
const invPrefix = invoicePrefixFor( req.body.advanceInvoice, req.body.advancePeriod );
|
|
129
133
|
const previousinvoice = await invoiceService.findandsort(
|
|
130
|
-
{ invoice: { $regex:
|
|
134
|
+
{ invoice: { $regex: `^${invPrefix}${Finacialyear}-` } },
|
|
131
135
|
{},
|
|
132
136
|
{ invoiceIndex: -1 },
|
|
133
137
|
);
|
|
@@ -167,7 +171,7 @@ export async function createInvoice( req, res ) {
|
|
|
167
171
|
Math.round( Number( req.body.totalAmount ) || 0 );
|
|
168
172
|
|
|
169
173
|
const data = {
|
|
170
|
-
invoice:
|
|
174
|
+
invoice: `${invPrefix}${Finacialyear}-${invoiceNo}`,
|
|
171
175
|
invoiceIndex: invoiceNo,
|
|
172
176
|
clientId: req.body.clientId,
|
|
173
177
|
groupId: req.body.groupId || undefined,
|
|
@@ -192,8 +196,24 @@ export async function createInvoice( req, res ) {
|
|
|
192
196
|
advanceInvoice: req.body.advanceInvoice || false,
|
|
193
197
|
advancePeriod: req.body.advanceInvoice ? ( req.body.advancePeriod || 'monthly' ) : undefined,
|
|
194
198
|
advanceMonths: customAdvanceMonths,
|
|
199
|
+
// Purchase order this invoice is mapped to (optional).
|
|
200
|
+
purchaseOrderNumber: req.body.purchaseOrderNumber || undefined,
|
|
195
201
|
};
|
|
196
202
|
const created = await invoiceService.create( data );
|
|
203
|
+
// Map to a PO (deduct its remaining balance + log) when one was picked.
|
|
204
|
+
if ( data.purchaseOrderNumber ) {
|
|
205
|
+
try {
|
|
206
|
+
await applyInvoiceToPurchaseOrder( {
|
|
207
|
+
clientId: data.clientId,
|
|
208
|
+
purchaseOrderNumber: data.purchaseOrderNumber,
|
|
209
|
+
invoice: data.invoice,
|
|
210
|
+
amount: data.totalAmount,
|
|
211
|
+
req,
|
|
212
|
+
} );
|
|
213
|
+
} catch ( poErr ) {
|
|
214
|
+
logger.error( { error: poErr, function: 'createInvoice.applyPO', invoice: data.invoice } );
|
|
215
|
+
}
|
|
216
|
+
}
|
|
197
217
|
const logObj = {
|
|
198
218
|
userName: req.user?.userName,
|
|
199
219
|
email: req.user?.email,
|
|
@@ -216,11 +236,41 @@ export async function createInvoice( req, res ) {
|
|
|
216
236
|
|
|
217
237
|
for ( let group of invoiceGroupList ) {
|
|
218
238
|
let Finacialyear = getCurrentFinancialYear();
|
|
219
|
-
|
|
220
|
-
//
|
|
221
|
-
//
|
|
239
|
+
|
|
240
|
+
// If an ADVANCE invoice already covers the CURRENT month for this group
|
|
241
|
+
// (i.e. it has a product line item labelled with the current MMM YYYY),
|
|
242
|
+
// we must NOT generate another advance invoice for it. Instead generate a
|
|
243
|
+
// normal monthly invoice using the store counts from that advance invoice.
|
|
244
|
+
const currentMonthLabel = dayjs().format( 'MMM YYYY' );
|
|
245
|
+
let advanceCoverProducts = null;
|
|
246
|
+
if ( group.advanceInvoice ) {
|
|
247
|
+
const existingAdvance = await invoiceService.findOne( {
|
|
248
|
+
'groupId': group._id,
|
|
249
|
+
'advanceInvoice': true,
|
|
250
|
+
'products.month': currentMonthLabel,
|
|
251
|
+
} );
|
|
252
|
+
if ( existingAdvance ) {
|
|
253
|
+
// Take this month's line items from the advance invoice (they carry the
|
|
254
|
+
// store counts + prices the advance covered) and bill them as a normal
|
|
255
|
+
// monthly invoice.
|
|
256
|
+
advanceCoverProducts = ( existingAdvance.products || [] )
|
|
257
|
+
.filter( ( p ) => p.month === currentMonthLabel )
|
|
258
|
+
.map( ( p ) => ( { ...( p._doc || p ), month: currentMonthLabel } ) );
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
// Effective advance flag: forced OFF when an advance invoice already covers
|
|
262
|
+
// this month, so the rest of the loop runs the normal monthly path.
|
|
263
|
+
const isAdvance = group.advanceInvoice && !advanceCoverProducts;
|
|
264
|
+
|
|
265
|
+
// Quarterly/half-yearly/yearly advance invoices use the separate TINV-
|
|
266
|
+
// series with its own per-FY counter; monthly advance + normal stay INV-.
|
|
267
|
+
const invPrefix = invoicePrefixFor( isAdvance, group.advancePeriod );
|
|
268
|
+
// Scope the highest-index lookup to invoices created in the current FY for
|
|
269
|
+
// THIS series. Without the FY scope the new FY would continue the previous
|
|
270
|
+
// year's sequence; without the prefix scope the two series would share one
|
|
271
|
+
// counter.
|
|
222
272
|
let previousinvoice = await invoiceService.findandsort(
|
|
223
|
-
{ invoice: { $regex:
|
|
273
|
+
{ invoice: { $regex: `^${invPrefix}${Finacialyear}-` } },
|
|
224
274
|
{},
|
|
225
275
|
{ invoiceIndex: -1 },
|
|
226
276
|
);
|
|
@@ -232,10 +282,16 @@ export async function createInvoice( req, res ) {
|
|
|
232
282
|
|
|
233
283
|
let address = group.addressLineOne + group.addressLineTwo + group.city + ',' + group.state + ',' + group.country + ' -' + group.pinCode;
|
|
234
284
|
let getClient = await clientService.findOne( { clientId: group.clientId, status: 'active' } );
|
|
235
|
-
|
|
285
|
+
// Advance bills next month; normal (incl. the advance-already-covered case)
|
|
286
|
+
// bills the current month.
|
|
287
|
+
let baseDate = isAdvance ? dayjs().add( 1, 'month' ).startOf( 'month' ) : dayjs();
|
|
236
288
|
let products;
|
|
237
289
|
|
|
238
|
-
if (
|
|
290
|
+
if ( advanceCoverProducts ) {
|
|
291
|
+
// Reuse the advance invoice's current-month line items (store counts +
|
|
292
|
+
// prices already set) and bill them as a normal monthly invoice.
|
|
293
|
+
products = advanceCoverProducts;
|
|
294
|
+
} else if ( getClient?.priceType === 'standard' ) {
|
|
239
295
|
products = await standardPrice( group, getClient, baseDate );
|
|
240
296
|
} else {
|
|
241
297
|
products = await stepPrice( group, getClient );
|
|
@@ -247,18 +303,22 @@ export async function createInvoice( req, res ) {
|
|
|
247
303
|
// store-based product lines. Added BEFORE the multi-month expansion below
|
|
248
304
|
// so they repeat per month and are included in the taxable subtotal, just
|
|
249
305
|
// like the standard products. Returns [] for any client without extras.
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
306
|
+
// Skip when reusing advance-invoice line items — those already include any
|
|
307
|
+
// additional products from when the advance invoice was generated.
|
|
308
|
+
if ( !advanceCoverProducts ) {
|
|
309
|
+
const extraProducts = await getAdditionalProducts( group.clientId );
|
|
310
|
+
for ( const ep of extraProducts ) {
|
|
311
|
+
products.push( {
|
|
312
|
+
productName: ep.productName,
|
|
313
|
+
period: 'fullmonth',
|
|
314
|
+
storeCount: ep.quantity,
|
|
315
|
+
amount: ep.total,
|
|
316
|
+
price: ep.price,
|
|
317
|
+
description: ep.productName,
|
|
318
|
+
HsnNumber: '998314',
|
|
319
|
+
month: baseDate.format( 'MMM YYYY' ),
|
|
320
|
+
} );
|
|
321
|
+
}
|
|
262
322
|
}
|
|
263
323
|
|
|
264
324
|
// Billing horizon in months. Advance and normal cycle are independent —
|
|
@@ -269,13 +329,15 @@ export async function createInvoice( req, res ) {
|
|
|
269
329
|
// product is repeated once per month, labelled with that month, at the
|
|
270
330
|
// normal monthly amount. One invoice then bills the whole period.
|
|
271
331
|
// (advancePeriod uses 'halfyearly'; paymentCycle uses 'quarter'/'halfYearly'
|
|
272
|
-
// — map both spellings.)
|
|
332
|
+
// — map both spellings.) Uses isAdvance (not group.advanceInvoice) so the
|
|
333
|
+
// advance-already-covered case bills a single normal month.
|
|
273
334
|
const advancePeriodMonths = { quarterly: 3, halfyearly: 6, yearly: 12 };
|
|
274
335
|
const paymentCycleMonths = { quarter: 3, quarterly: 3, halfYearly: 6, halfyearly: 6, yearly: 12 };
|
|
275
|
-
const advanceMonths =
|
|
336
|
+
const advanceMonths = isAdvance ?
|
|
276
337
|
( advancePeriodMonths[group.advancePeriod] || 1 ) :
|
|
277
338
|
( paymentCycleMonths[group.paymentCycle] || 1 );
|
|
278
|
-
|
|
339
|
+
// No re-expansion when reusing advance line items (already this month only).
|
|
340
|
+
if ( !advanceCoverProducts && advanceMonths > 1 ) {
|
|
279
341
|
const expanded = [];
|
|
280
342
|
for ( let m = 0; m < advanceMonths; m++ ) {
|
|
281
343
|
const monthLabel = baseDate.add( m, 'month' ).format( 'MMM YYYY' );
|
|
@@ -292,8 +354,11 @@ export async function createInvoice( req, res ) {
|
|
|
292
354
|
// multi-month expansion so it's billed ONCE per invoice (not per month).
|
|
293
355
|
// It's a normal line item, so it lands in the taxable subtotal below.
|
|
294
356
|
try {
|
|
357
|
+
// Use the group's basepricing doc when group-wise pricing applies,
|
|
358
|
+
// else the brand-level doc.
|
|
359
|
+
const { query: oneTimeQuery } = await resolveBasePricingScope( group, getClient );
|
|
295
360
|
const oneTimeBp = await basepricingService.findOne(
|
|
296
|
-
|
|
361
|
+
oneTimeQuery,
|
|
297
362
|
{ oneTimeFeePerStore: 1 },
|
|
298
363
|
);
|
|
299
364
|
const oneTimeFeePerStore = Number( oneTimeBp?.oneTimeFeePerStore ) || 0;
|
|
@@ -412,6 +477,36 @@ export async function createInvoice( req, res ) {
|
|
|
412
477
|
|
|
413
478
|
] );
|
|
414
479
|
|
|
480
|
+
// For TINV (advance quarterly/half-yearly/yearly) invoices, capture the
|
|
481
|
+
// actual store list — storeId, storeName and each store's products with
|
|
482
|
+
// working days — onto the invoice from the latest daily-pricing doc.
|
|
483
|
+
let storeDetails = [];
|
|
484
|
+
if ( invPrefix === 'TINV-' ) {
|
|
485
|
+
const dpDoc = await dailyPricingService.aggregate( [
|
|
486
|
+
{ $match: { clientId: group.clientId } },
|
|
487
|
+
{ $sort: { dateISO: -1 } },
|
|
488
|
+
{ $limit: 1 },
|
|
489
|
+
{ $project: { stores: {
|
|
490
|
+
$filter: { input: '$stores', as: 'item', cond: { $in: [ '$$item.storeId', group.stores ] } },
|
|
491
|
+
} } },
|
|
492
|
+
] );
|
|
493
|
+
const dpStores = dpDoc?.[0]?.stores || [];
|
|
494
|
+
storeDetails = dpStores
|
|
495
|
+
.map( ( s ) => ( {
|
|
496
|
+
storeId: s.storeId,
|
|
497
|
+
storeName: s.storeName,
|
|
498
|
+
// Only products that actually ran this month (workingdays > 0).
|
|
499
|
+
products: ( s.products || [] )
|
|
500
|
+
.filter( ( p ) => ( Number( p.workingdays ) || 0 ) > 0 )
|
|
501
|
+
.map( ( p ) => ( {
|
|
502
|
+
productName: p.productName,
|
|
503
|
+
workingdays: Number( p.workingdays ) || 0,
|
|
504
|
+
} ) ),
|
|
505
|
+
} ) )
|
|
506
|
+
// Drop stores left with no running products.
|
|
507
|
+
.filter( ( s ) => s.products.length > 0 );
|
|
508
|
+
}
|
|
509
|
+
|
|
415
510
|
// billingDate is the actual invoice (creation) date — even for advance
|
|
416
511
|
// invoices. The advance MONTH is reflected only in the product lines /
|
|
417
512
|
// monthOfbilling (driven by baseDate), not in the billing date itself.
|
|
@@ -425,7 +520,7 @@ export async function createInvoice( req, res ) {
|
|
|
425
520
|
let data = {
|
|
426
521
|
groupName: group.groupName,
|
|
427
522
|
groupId: group._id,
|
|
428
|
-
invoice: req.body.invoiceId ? req.body.invoiceId :
|
|
523
|
+
invoice: req.body.invoiceId ? req.body.invoiceId : `${invPrefix}${Finacialyear}-${invoiceNo}`,
|
|
429
524
|
products: products,
|
|
430
525
|
status: 'pendingCsm',
|
|
431
526
|
amount: Math.round( amount ),
|
|
@@ -439,12 +534,19 @@ export async function createInvoice( req, res ) {
|
|
|
439
534
|
clientId: group.clientId,
|
|
440
535
|
paymentMethod: 'Online',
|
|
441
536
|
billingDate: new Date( invoicedate ),
|
|
442
|
-
|
|
537
|
+
// For the advance-already-covered case, take the store count from the
|
|
538
|
+
// advance invoice's line items; otherwise the live running-store count.
|
|
539
|
+
stores: advanceCoverProducts ?
|
|
540
|
+
Math.max( 0, ...advanceCoverProducts.map( ( p ) => Number( p.storeCount ) || 0 ) ) :
|
|
541
|
+
( totalStoreCount.length ? totalStoreCount[0].stores.length : 0 ),
|
|
542
|
+
// TINV invoices carry the full store list (id, name, product workingdays).
|
|
543
|
+
storeDetails: storeDetails,
|
|
443
544
|
currency: group.currency ? group.currency : 'inr',
|
|
444
545
|
monthOfbilling: baseDate.format( 'MM' ),
|
|
445
546
|
dueDate: dueDate,
|
|
446
|
-
|
|
447
|
-
|
|
547
|
+
// Stored as a NORMAL invoice when an advance already covered this month.
|
|
548
|
+
advanceInvoice: isAdvance || false,
|
|
549
|
+
advancePeriod: isAdvance ? ( group.advancePeriod || 'monthly' ) : undefined,
|
|
448
550
|
advanceMonths: advanceMonths,
|
|
449
551
|
};
|
|
450
552
|
|
|
@@ -473,12 +575,12 @@ export async function createInvoice( req, res ) {
|
|
|
473
575
|
await invoiceService.create( data );
|
|
474
576
|
|
|
475
577
|
if ( !req.body.regenrate ) {
|
|
476
|
-
let invoiceType =
|
|
578
|
+
let invoiceType = isAdvance ? 'advance' : '';
|
|
477
579
|
let logObj = {
|
|
478
580
|
userName: req.user?.userName,
|
|
479
581
|
email: req.user?.email,
|
|
480
582
|
clientId: group.clientId,
|
|
481
|
-
logSubType:
|
|
583
|
+
logSubType: isAdvance ? 'advanceInvoiceCreated' : 'invoiceCreated',
|
|
482
584
|
logType: 'invoice',
|
|
483
585
|
date: new Date(),
|
|
484
586
|
changes: [ `${data.invoice} ${invoiceType} invoice has been generated for ${group.groupName} for ${baseDate.format( 'MMM YYYY' )}` ],
|
|
@@ -508,6 +610,17 @@ function getCurrentFinancialYear() {
|
|
|
508
610
|
}
|
|
509
611
|
}
|
|
510
612
|
|
|
613
|
+
// Invoice-number prefix. Advance invoices for quarterly / half-yearly / yearly
|
|
614
|
+
// use a separate "TINV-" series with its own counter; everything else
|
|
615
|
+
// (including MONTHLY advance) uses the normal "INV-" series.
|
|
616
|
+
function invoicePrefixFor( advanceInvoice, advancePeriod ) {
|
|
617
|
+
const multiMonth = [ 'quarterly', 'halfyearly', 'yearly' ];
|
|
618
|
+
if ( advanceInvoice && multiMonth.includes( String( advancePeriod || '' ).toLowerCase() ) ) {
|
|
619
|
+
return 'TINV-';
|
|
620
|
+
}
|
|
621
|
+
return 'INV-';
|
|
622
|
+
}
|
|
623
|
+
|
|
511
624
|
|
|
512
625
|
// ---------------------------------------------------------------------------
|
|
513
626
|
// Shared annexure builder. Anchored to the invoice's BILLING month and
|
|
@@ -526,7 +639,7 @@ async function buildAnnexureRows( invoiceInfo, getgroup ) {
|
|
|
526
639
|
// converting here would make the annexure unit price disagree with the actual
|
|
527
640
|
// billed amount (e.g. a $45 negotiated price wrongly shown as $0.48).
|
|
528
641
|
|
|
529
|
-
const annexClient = await clientService.findOne( { clientId: invoiceInfo.clientId }, { 'planDetails.product': 1, 'priceType': 1 } );
|
|
642
|
+
const annexClient = await clientService.findOne( { clientId: invoiceInfo.clientId }, { 'planDetails.product': 1, 'priceType': 1, 'billingGroupWisePricing': 1 } );
|
|
530
643
|
const billingTypeMap = {};
|
|
531
644
|
( annexClient?.planDetails?.product || [] ).forEach( ( p ) => {
|
|
532
645
|
billingTypeMap[p.productName] = p.billingType || 'perStore';
|
|
@@ -538,7 +651,18 @@ async function buildAnnexureRows( invoiceInfo, getgroup ) {
|
|
|
538
651
|
// appear once per tier (e.g. the same store listed at $45 and again at $40).
|
|
539
652
|
// Instead we pull the tiers here and assign each store a single tier price
|
|
540
653
|
// below, in JS, by its 1-based index within the product.
|
|
541
|
-
|
|
654
|
+
// Group-wise pricing: read the billing group's own doc when enabled and one
|
|
655
|
+
// exists; otherwise the brand-level doc.
|
|
656
|
+
let pricingDocQuery = { clientId: invoiceInfo.clientId, groupId: { $exists: false } };
|
|
657
|
+
if ( annexClient?.billingGroupWisePricing && getgroup?._id ) {
|
|
658
|
+
const grpDoc = await basepricingService.findOne(
|
|
659
|
+
{ clientId: invoiceInfo.clientId, groupId: String( getgroup._id ) }, { _id: 1 },
|
|
660
|
+
);
|
|
661
|
+
if ( grpDoc ) {
|
|
662
|
+
pricingDocQuery = { clientId: invoiceInfo.clientId, groupId: String( getgroup._id ) };
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
const pricingDoc = await basepricingService.findOne( pricingDocQuery, { standard: 1, step: 1 } );
|
|
542
666
|
const isStep = annexClient?.priceType === 'step';
|
|
543
667
|
const tiersByProduct = {};
|
|
544
668
|
( ( isStep ? pricingDoc?.step : pricingDoc?.standard ) || [] ).forEach( ( p ) => {
|
|
@@ -693,7 +817,7 @@ export async function invoiceDownload( req, res ) {
|
|
|
693
817
|
companyAddress: invoiceInfo.companyAddress,
|
|
694
818
|
PlaceOfSupply: invoiceInfo.PlaceOfSupply,
|
|
695
819
|
GSTNumber: invoiceInfo.GSTNumber,
|
|
696
|
-
PoNum: getgroup?.po,
|
|
820
|
+
PoNum: invoiceInfo.purchaseOrderNumber || getgroup?.po || '',
|
|
697
821
|
amountwords: AmountinWords,
|
|
698
822
|
Terms: `Term ${getgroup?.paymentTerm ? getgroup?.paymentTerm : '30'}`,
|
|
699
823
|
currencyType: invoiceCurrency,
|
|
@@ -889,7 +1013,7 @@ async function buildInvoicePdfBuffer( invoiceId ) {
|
|
|
889
1013
|
companyAddress: invoiceInfo.companyAddress,
|
|
890
1014
|
PlaceOfSupply: invoiceInfo.PlaceOfSupply,
|
|
891
1015
|
GSTNumber: invoiceInfo.GSTNumber,
|
|
892
|
-
PoNum: getgroup?.po,
|
|
1016
|
+
PoNum: invoiceInfo.purchaseOrderNumber || getgroup?.po || '',
|
|
893
1017
|
amountwords: AmountinWords,
|
|
894
1018
|
Terms: `Term ${getgroup?.paymentTerm ? getgroup?.paymentTerm : '30'}`,
|
|
895
1019
|
currencyType: invoiceCurrency,
|
|
@@ -1080,6 +1204,25 @@ function inWords( num ) {
|
|
|
1080
1204
|
}
|
|
1081
1205
|
|
|
1082
1206
|
|
|
1207
|
+
// Resolve which basepricing doc applies to a billing group. When the client
|
|
1208
|
+
// has billingGroupWisePricing enabled AND a doc exists for this group, returns
|
|
1209
|
+
// that group's doc; otherwise falls back to the brand-level doc (groupId unset).
|
|
1210
|
+
// Returns { query, groupId } where query is the mongo filter for the chosen doc
|
|
1211
|
+
// and groupId is the group id to use in aggregation lookups (or null for brand).
|
|
1212
|
+
async function resolveBasePricingScope( group, getClient ) {
|
|
1213
|
+
const brandQuery = { clientId: group.clientId, groupId: { $exists: false } };
|
|
1214
|
+
if ( getClient?.billingGroupWisePricing && group?._id ) {
|
|
1215
|
+
const groupIdStr = String( group._id );
|
|
1216
|
+
const groupDoc = await basepricingService.findOne(
|
|
1217
|
+
{ clientId: group.clientId, groupId: groupIdStr }, { _id: 1 },
|
|
1218
|
+
);
|
|
1219
|
+
if ( groupDoc ) {
|
|
1220
|
+
return { query: { clientId: group.clientId, groupId: groupIdStr }, groupId: groupIdStr };
|
|
1221
|
+
}
|
|
1222
|
+
}
|
|
1223
|
+
return { query: brandQuery, groupId: null };
|
|
1224
|
+
}
|
|
1225
|
+
|
|
1083
1226
|
async function standardPrice( group, getClient, baseDate ) {
|
|
1084
1227
|
console.log( '🚀 ~ standardPrice ~ baseDate:', baseDate.format( 'MMM YYYY' ) );
|
|
1085
1228
|
const currentMonthDays = dayjs().daysInMonth();
|
|
@@ -1088,6 +1231,9 @@ async function standardPrice( group, getClient, baseDate ) {
|
|
|
1088
1231
|
// Computed once so the aggregation pipelines can inline a $literal.
|
|
1089
1232
|
const isFlatPricing = group.proRata === 'flat';
|
|
1090
1233
|
console.log( '🚀 ~ standardPrice ~ isFlatPricing:', isFlatPricing );
|
|
1234
|
+
// Which basepricing doc applies (group-wise vs brand-level). pricingGroupId is
|
|
1235
|
+
// used in the $lookup pipeline so the join picks the right doc.
|
|
1236
|
+
const { groupId: pricingGroupId } = await resolveBasePricingScope( group, getClient );
|
|
1091
1237
|
let billingTypeMap = {};
|
|
1092
1238
|
if ( getClient?.planDetails?.product ) {
|
|
1093
1239
|
getClient.planDetails.product.forEach( ( p ) => {
|
|
@@ -1224,7 +1370,14 @@ async function standardPrice( group, getClient, baseDate ) {
|
|
|
1224
1370
|
{
|
|
1225
1371
|
$match: {
|
|
1226
1372
|
$expr: {
|
|
1227
|
-
$
|
|
1373
|
+
$and: [
|
|
1374
|
+
{ $eq: [ '$clientId', '$$clientId' ] },
|
|
1375
|
+
// Match the resolved doc: a specific group's doc when group-wise
|
|
1376
|
+
// pricing applies, else the brand-level doc (no groupId).
|
|
1377
|
+
pricingGroupId ?
|
|
1378
|
+
{ $eq: [ '$groupId', pricingGroupId ] } :
|
|
1379
|
+
{ $not: [ { $ifNull: [ '$groupId', false ] } ] },
|
|
1380
|
+
],
|
|
1228
1381
|
},
|
|
1229
1382
|
},
|
|
1230
1383
|
},
|
|
@@ -1419,7 +1572,12 @@ async function standardPrice( group, getClient, baseDate ) {
|
|
|
1419
1572
|
from: 'basepricings',
|
|
1420
1573
|
let: { clientId: group.clientId },
|
|
1421
1574
|
pipeline: [
|
|
1422
|
-
{ $match: { $expr: { $
|
|
1575
|
+
{ $match: { $expr: { $and: [
|
|
1576
|
+
{ $eq: [ '$clientId', '$$clientId' ] },
|
|
1577
|
+
pricingGroupId ?
|
|
1578
|
+
{ $eq: [ '$groupId', pricingGroupId ] } :
|
|
1579
|
+
{ $not: [ { $ifNull: [ '$groupId', false ] } ] },
|
|
1580
|
+
] } } },
|
|
1423
1581
|
{ $project: { standard: 1 } },
|
|
1424
1582
|
],
|
|
1425
1583
|
as: 'basepricing',
|
|
@@ -1527,6 +1685,8 @@ async function stepPrice( group, getClient ) {
|
|
|
1527
1685
|
// 'flat' => every store billed for full month.
|
|
1528
1686
|
// 'prorate' => actual working days. See standardPrice for the same flag.
|
|
1529
1687
|
const isFlatPricing = group.proRata === 'flat';
|
|
1688
|
+
// Which basepricing doc applies (group-wise vs brand-level).
|
|
1689
|
+
const { query: pricingDocQuery, groupId: pricingGroupId } = await resolveBasePricingScope( group, getClient );
|
|
1530
1690
|
let billingTypeMap = {};
|
|
1531
1691
|
if ( getClient?.planDetails?.product ) {
|
|
1532
1692
|
getClient.planDetails.product.forEach( ( p ) => {
|
|
@@ -1628,7 +1788,12 @@ async function stepPrice( group, getClient ) {
|
|
|
1628
1788
|
from: 'basepricings',
|
|
1629
1789
|
let: { clientId: group.clientId },
|
|
1630
1790
|
pipeline: [
|
|
1631
|
-
{ $match: { $expr: { $
|
|
1791
|
+
{ $match: { $expr: { $and: [
|
|
1792
|
+
{ $eq: [ '$clientId', '$$clientId' ] },
|
|
1793
|
+
pricingGroupId ?
|
|
1794
|
+
{ $eq: [ '$groupId', pricingGroupId ] } :
|
|
1795
|
+
{ $not: [ { $ifNull: [ '$groupId', false ] } ] },
|
|
1796
|
+
] } } },
|
|
1632
1797
|
{ $project: { step: 1 } },
|
|
1633
1798
|
],
|
|
1634
1799
|
as: 'basepricing',
|
|
@@ -1637,7 +1802,7 @@ async function stepPrice( group, getClient ) {
|
|
|
1637
1802
|
{ $unwind: { path: '$basepricing', preserveNullAndEmptyArrays: true } },
|
|
1638
1803
|
] );
|
|
1639
1804
|
|
|
1640
|
-
let stepPriceData = await basepricingService.findOne(
|
|
1805
|
+
let stepPriceData = await basepricingService.findOne( pricingDocQuery );
|
|
1641
1806
|
let pricingRanges = stepPriceData?.step || [];
|
|
1642
1807
|
let defaultPrice = pricingRanges.length > 0 ? pricingRanges[0].negotiatePrice : 0;
|
|
1643
1808
|
|
|
@@ -1682,7 +1847,7 @@ async function stepPrice( group, getClient ) {
|
|
|
1682
1847
|
// Drop eachStore products — those are handled by the per-store branch above.
|
|
1683
1848
|
perStoreRows = perStoreRows.filter( ( p ) => !eachStoreProductNames.includes( p.productName ) );
|
|
1684
1849
|
|
|
1685
|
-
const stepPriceRecord = await basepricingService.findOne(
|
|
1850
|
+
const stepPriceRecord = await basepricingService.findOne( pricingDocQuery );
|
|
1686
1851
|
// Tiers ordered by range start so per-store tier assignment walks low-to-high.
|
|
1687
1852
|
const pricing = ( stepPriceRecord?.step || [] ).slice().sort( ( a, b ) => {
|
|
1688
1853
|
const aStart = parseInt( String( a.storeRange || '0' ).split( '-' )[0], 10 ) || 0;
|
|
@@ -2466,7 +2631,14 @@ export async function checkPaymentStatus( req, res ) {
|
|
|
2466
2631
|
|
|
2467
2632
|
export async function getInvoice( req, res ) {
|
|
2468
2633
|
try {
|
|
2469
|
-
|
|
2634
|
+
// Accept either a Mongo _id or an invoice NUMBER. A 24-char hex string is
|
|
2635
|
+
// treated as an _id; anything else (e.g. "INV-2026-00012") matches the
|
|
2636
|
+
// invoice number — so the Purchase Order popup can preview by number.
|
|
2637
|
+
const idParam = String( req.params.invoiceId || '' );
|
|
2638
|
+
const isObjectId = /^[a-fA-F0-9]{24}$/.test( idParam );
|
|
2639
|
+
let invoice = await invoiceService.findOne(
|
|
2640
|
+
isObjectId ? { _id: idParam } : { invoice: idParam },
|
|
2641
|
+
);
|
|
2470
2642
|
if ( !invoice ) {
|
|
2471
2643
|
return res.sendError( 'Invoice not found', 404 );
|
|
2472
2644
|
}
|
|
@@ -2496,7 +2668,7 @@ export async function updateInvoice( req, res ) {
|
|
|
2496
2668
|
'companyName', 'companyAddress', 'GSTNumber', 'PlaceOfSupply',
|
|
2497
2669
|
'groupName', 'groupId', 'stores', 'billingDate', 'dueDate',
|
|
2498
2670
|
'currency', 'status', 'paymentStatus', 'products', 'tax',
|
|
2499
|
-
'amount', 'totalAmount', 'paymentMethod',
|
|
2671
|
+
'amount', 'totalAmount', 'paymentMethod', 'purchaseOrderNumber',
|
|
2500
2672
|
];
|
|
2501
2673
|
|
|
2502
2674
|
allowedFields.forEach( ( field ) => {
|
|
@@ -2522,6 +2694,23 @@ export async function updateInvoice( req, res ) {
|
|
|
2522
2694
|
|
|
2523
2695
|
let updatedInvoice = await invoiceService.updateOne( { _id: req.body._id }, updateData );
|
|
2524
2696
|
|
|
2697
|
+
// PO mapping on edit: when a (new) PO number is set, deduct the invoice
|
|
2698
|
+
// amount from that PO. applyInvoiceToPurchaseOrder is idempotent per
|
|
2699
|
+
// invoice+PO, so re-saving the same PO won't double-deduct.
|
|
2700
|
+
if ( updateData.purchaseOrderNumber && updateData.purchaseOrderNumber !== invoice.purchaseOrderNumber ) {
|
|
2701
|
+
try {
|
|
2702
|
+
await applyInvoiceToPurchaseOrder( {
|
|
2703
|
+
clientId: invoice.clientId,
|
|
2704
|
+
purchaseOrderNumber: updateData.purchaseOrderNumber,
|
|
2705
|
+
invoice: invoice.invoice,
|
|
2706
|
+
amount: updateData.totalAmount != null ? updateData.totalAmount : invoice.totalAmount,
|
|
2707
|
+
req,
|
|
2708
|
+
} );
|
|
2709
|
+
} catch ( poErr ) {
|
|
2710
|
+
logger.error( { error: poErr, function: 'updateInvoice.applyPO', invoice: invoice.invoice } );
|
|
2711
|
+
}
|
|
2712
|
+
}
|
|
2713
|
+
|
|
2525
2714
|
let logObj = {
|
|
2526
2715
|
userName: req.user?.userName,
|
|
2527
2716
|
email: req.user?.email,
|
|
@@ -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
|
+
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import * as purchaseOrderService from '../services/purchaseOrder.service.js';
|
|
2
|
+
import { logger, insertOpenSearchData, fileUpload, customSignedUrl } from 'tango-app-api-middleware';
|
|
3
|
+
|
|
4
|
+
// Derive PO status from how much of it has been consumed.
|
|
5
|
+
function statusFor( totalAmount, remainingAmount ) {
|
|
6
|
+
if ( remainingAmount <= 0 ) {
|
|
7
|
+
return 'fullyUsed';
|
|
8
|
+
}
|
|
9
|
+
if ( remainingAmount < totalAmount ) {
|
|
10
|
+
return 'partiallyUsed';
|
|
11
|
+
}
|
|
12
|
+
return 'open';
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function logPO( req, clientId, subType, changes ) {
|
|
16
|
+
try {
|
|
17
|
+
const logObj = {
|
|
18
|
+
userName: req.user?.userName,
|
|
19
|
+
email: req.user?.email,
|
|
20
|
+
clientId: clientId,
|
|
21
|
+
logSubType: subType,
|
|
22
|
+
logType: 'purchaseOrder',
|
|
23
|
+
date: new Date(),
|
|
24
|
+
changes: changes,
|
|
25
|
+
eventType: '',
|
|
26
|
+
timestamp: new Date(),
|
|
27
|
+
showTo: [ 'tango' ],
|
|
28
|
+
};
|
|
29
|
+
insertOpenSearchData( JSON.parse( process.env.OPENSEARCH ).activityLog, logObj );
|
|
30
|
+
} catch ( e ) {
|
|
31
|
+
logger.error( { error: e, function: 'logPO' } );
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Create a purchase order. remainingAmount starts equal to totalAmount.
|
|
36
|
+
export async function createPurchaseOrder( req, res ) {
|
|
37
|
+
try {
|
|
38
|
+
const clientId = String( req.body?.clientId || '' );
|
|
39
|
+
const companyName = String( req.body?.companyName || '' ).trim();
|
|
40
|
+
const purchaseOrderNumber = String( req.body?.purchaseOrderNumber || '' ).trim();
|
|
41
|
+
const totalAmount = Number( req.body?.totalAmount ) || 0;
|
|
42
|
+
const date = req.body?.date ? new Date( req.body.date ) : new Date();
|
|
43
|
+
|
|
44
|
+
if ( !clientId ) {
|
|
45
|
+
return res.sendError( 'clientId is required', 400 );
|
|
46
|
+
}
|
|
47
|
+
if ( !purchaseOrderNumber ) {
|
|
48
|
+
return res.sendError( 'purchaseOrderNumber is required', 400 );
|
|
49
|
+
}
|
|
50
|
+
if ( !( totalAmount > 0 ) ) {
|
|
51
|
+
return res.sendError( 'totalAmount must be greater than 0', 400 );
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Prevent duplicate PO numbers for the same client.
|
|
55
|
+
const existing = await purchaseOrderService.findOne( { clientId, purchaseOrderNumber } );
|
|
56
|
+
if ( existing ) {
|
|
57
|
+
return res.sendError( 'A purchase order with this number already exists', 409 );
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Optional PO PDF upload (multer single 'file') → assets bucket, same as
|
|
61
|
+
// brand document upload. PDF only.
|
|
62
|
+
let pdfPath = '';
|
|
63
|
+
const file = req.file;
|
|
64
|
+
if ( file ) {
|
|
65
|
+
if ( file.mimetype !== 'application/pdf' ) {
|
|
66
|
+
return res.sendError( 'Only PDF files are allowed', 400 );
|
|
67
|
+
}
|
|
68
|
+
const bucket = JSON.parse( process.env.BUCKET );
|
|
69
|
+
const safeName = purchaseOrderNumber.replace( /[^a-zA-Z0-9-_]/g, '_' );
|
|
70
|
+
const fileName = `${safeName}_${Date.now()}.pdf`;
|
|
71
|
+
const key = `${clientId}/purchase-orders/`;
|
|
72
|
+
let result = await fileUpload( {
|
|
73
|
+
Bucket: bucket.assets,
|
|
74
|
+
Key: key,
|
|
75
|
+
fileName: fileName,
|
|
76
|
+
ContentType: file.mimetype,
|
|
77
|
+
body: file.buffer,
|
|
78
|
+
} );
|
|
79
|
+
pdfPath = `${key}${fileName}`;
|
|
80
|
+
console.log( '🚀 ~ createPurchaseOrder ~ result:', result );
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const created = await purchaseOrderService.create( {
|
|
84
|
+
clientId,
|
|
85
|
+
companyName,
|
|
86
|
+
purchaseOrderNumber,
|
|
87
|
+
totalAmount,
|
|
88
|
+
remainingAmount: totalAmount,
|
|
89
|
+
date,
|
|
90
|
+
status: 'open',
|
|
91
|
+
usage: [],
|
|
92
|
+
pdfPath,
|
|
93
|
+
createdBy: req.user?.email || req.user?.userName || '',
|
|
94
|
+
} );
|
|
95
|
+
|
|
96
|
+
logPO( req, clientId, 'purchaseOrderCreated', [ `PO ${purchaseOrderNumber} created with amount ${totalAmount}` ] );
|
|
97
|
+
return res.sendSuccess( created );
|
|
98
|
+
} catch ( error ) {
|
|
99
|
+
logger.error( { error: error, function: 'createPurchaseOrder' } );
|
|
100
|
+
return res.sendError( error, 500 );
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// List a client's purchase orders (newest first).
|
|
105
|
+
export async function getPurchaseOrders( req, res ) {
|
|
106
|
+
try {
|
|
107
|
+
const clientId = String( req.query?.clientId || req.params?.clientId || '' );
|
|
108
|
+
if ( !clientId ) {
|
|
109
|
+
return res.sendError( 'clientId is required', 400 );
|
|
110
|
+
}
|
|
111
|
+
const orders = await purchaseOrderService.find( { clientId } );
|
|
112
|
+
const sorted = ( orders || [] ).sort( ( a, b ) =>
|
|
113
|
+
new Date( b.createdAt || b.date || 0 ) - new Date( a.createdAt || a.date || 0 ) );
|
|
114
|
+
// Attach a signed URL for any uploaded PO PDF (assets bucket).
|
|
115
|
+
const bucket = JSON.parse( process.env.BUCKET );
|
|
116
|
+
const withUrls = await Promise.all( sorted.map( async ( o ) => {
|
|
117
|
+
const plain = o.toObject?.() || o;
|
|
118
|
+
let pdfUrl = '';
|
|
119
|
+
if ( plain.pdfPath ) {
|
|
120
|
+
try {
|
|
121
|
+
pdfUrl = await customSignedUrl( { Bucket: bucket.assets, file_path: plain.pdfPath }, 8 );
|
|
122
|
+
} catch ( e ) {
|
|
123
|
+
pdfUrl = '';
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return { ...plain, pdfUrl };
|
|
127
|
+
} ) );
|
|
128
|
+
return res.sendSuccess( { purchaseOrders: withUrls } );
|
|
129
|
+
} catch ( error ) {
|
|
130
|
+
logger.error( { error: error, function: 'getPurchaseOrders' } );
|
|
131
|
+
return res.sendError( error, 500 );
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Map an invoice to a PO: deduct the invoice amount from the PO's remaining
|
|
136
|
+
// balance, record the usage, update status, and log it. Shared with invoice
|
|
137
|
+
// generation/edit so the deduction happens wherever an invoice gets a PO.
|
|
138
|
+
// Returns the updated PO doc, or throws on a missing PO.
|
|
139
|
+
export async function applyInvoiceToPurchaseOrder( { clientId, purchaseOrderNumber, invoice, amount, req } ) {
|
|
140
|
+
if ( !purchaseOrderNumber ) {
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
const po = await purchaseOrderService.findOne( { clientId, purchaseOrderNumber } );
|
|
144
|
+
if ( !po ) {
|
|
145
|
+
logger.error( { function: 'applyInvoiceToPurchaseOrder', message: 'PO not found', clientId, purchaseOrderNumber } );
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
// Idempotency: if this invoice is already mapped to this PO, don't double-deduct.
|
|
149
|
+
const alreadyMapped = ( po.usage || [] ).some( ( u ) => u.invoice === invoice );
|
|
150
|
+
if ( alreadyMapped ) {
|
|
151
|
+
return po;
|
|
152
|
+
}
|
|
153
|
+
const amt = Number( amount ) || 0;
|
|
154
|
+
const remainingAmount = Math.round( ( ( po.remainingAmount || 0 ) - amt ) * 100 ) / 100;
|
|
155
|
+
const usage = ( po.usage || [] ).concat( [ {
|
|
156
|
+
invoice,
|
|
157
|
+
amount: amt,
|
|
158
|
+
mappedAt: new Date(),
|
|
159
|
+
mappedBy: req?.user?.email || req?.user?.userName || '',
|
|
160
|
+
} ] );
|
|
161
|
+
const status = statusFor( po.totalAmount || 0, remainingAmount );
|
|
162
|
+
await purchaseOrderService.updateOne(
|
|
163
|
+
{ clientId, purchaseOrderNumber },
|
|
164
|
+
{ remainingAmount, usage, status },
|
|
165
|
+
);
|
|
166
|
+
if ( req ) {
|
|
167
|
+
logPO( req, clientId, 'purchaseOrderInvoiceMapped',
|
|
168
|
+
[ `Invoice ${invoice} mapped to PO ${purchaseOrderNumber}; ${amt} deducted, remaining ${remainingAmount}` ] );
|
|
169
|
+
}
|
|
170
|
+
return { ...po.toObject?.() || po, remainingAmount, usage, status };
|
|
171
|
+
}
|
|
@@ -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,8 +1,11 @@
|
|
|
1
1
|
import express from 'express';
|
|
2
|
+
import multer from 'multer';
|
|
2
3
|
export const billingRouter = express.Router();
|
|
4
|
+
const poUpload = multer( { storage: multer.memoryStorage(), limits: { fileSize: 10 * 1024 * 1024 } } );
|
|
3
5
|
import { accessVerification, isAllowedSessionHandler, validate } from 'tango-app-api-middleware';
|
|
4
6
|
import { getPaymentReminder, savePaymentReminder } from '../controllers/paymentReminder.controller.js';
|
|
5
7
|
import { triggerPaymentReminders } from '../controllers/paymentReminderTrigger.controller.js';
|
|
8
|
+
import { createPurchaseOrder, getPurchaseOrders } from '../controllers/purchaseOrder.controller.js';
|
|
6
9
|
import { createBillingGroup, deleteBillingGroup, getAllBillingGroups, getBillingGroups, getClientProducts, getInvoices, getLeadProducts, getBaseProducts, onetimePayment, subscribedStoreList, updateBillingGroup, gstinLookup } from '../controllers/billing.controllers.js';
|
|
7
10
|
import { billingGroupSchema, clientProductsValid, createBillingGroupsSchema, deleteBillingGroupsSchema, getBillingGroupsSchema, getInvoiceSchema, leadProductsValid, onetimeFeeValid, subscribedStoreListSchema, updateBillingGroupsSchema } from '../dtos/validation.dtos.js';
|
|
8
11
|
|
|
@@ -37,6 +40,10 @@ billingRouter.get( '/gst-lookup/:gstin', isAllowedSessionHandler, accessVerifica
|
|
|
37
40
|
billingRouter.get( '/payment-reminder/:clientId', isAllowedSessionHandler, accessVerification( { userType: [ 'tango' ], access: [ { featureName: 'Global', name: 'Billing', permissions: [ 'isAdd' ] } ] } ), getPaymentReminder );
|
|
38
41
|
billingRouter.post( '/payment-reminder', isAllowedSessionHandler, accessVerification( { userType: [ 'tango' ], access: [ { featureName: 'Global', name: 'Billing', permissions: [ 'isAdd' ] } ] } ), savePaymentReminder );
|
|
39
42
|
|
|
43
|
+
// Purchase Orders (brand-view Purchase Order tab).
|
|
44
|
+
billingRouter.get( '/purchase-orders', isAllowedSessionHandler, accessVerification( { userType: [ 'tango' ], access: [ { featureName: 'Global', name: 'Billing', permissions: [ 'isAdd' ] } ] } ), getPurchaseOrders );
|
|
45
|
+
billingRouter.post( '/purchase-orders', isAllowedSessionHandler, accessVerification( { userType: [ 'tango' ], access: [ { featureName: 'Global', name: 'Billing', permissions: [ 'isAdd' ] } ] } ), poUpload.single( 'file' ), createPurchaseOrder );
|
|
46
|
+
|
|
40
47
|
// Cron-triggered: sends the configured payment reminder emails. Unauthenticated
|
|
41
48
|
// like the other cron endpoints; protect at the network / scheduler layer.
|
|
42
49
|
billingRouter.post( '/payment-reminder/trigger', triggerPaymentReminders );
|
|
@@ -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
|
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import model from 'tango-api-schema';
|
|
2
|
+
|
|
3
|
+
export const find = ( query = {}, record = {} ) => {
|
|
4
|
+
return model.purchaseOrderModel.find( query, record );
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
export const findOne = ( query = {}, record = {} ) => {
|
|
8
|
+
return model.purchaseOrderModel.findOne( query, record );
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export const create = ( record ) => {
|
|
12
|
+
return model.purchaseOrderModel.create( record );
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export const updateOne = ( query, record ) => {
|
|
16
|
+
return model.purchaseOrderModel.updateOne( query, { $set: record } );
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export const aggregate = ( query = [] ) => {
|
|
20
|
+
return model.purchaseOrderModel.aggregate( query );
|
|
21
|
+
};
|