tango-app-api-payment-subscription 3.5.23 → 3.5.25
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/billing.controllers.js +88 -55
- package/src/controllers/brandsBilling.controller.js +319 -68
- package/src/controllers/estimate.controller.js +111 -17
- package/src/controllers/invoice.controller.js +44 -8
- package/src/hbs/estimatePdf.hbs +12 -0
- package/src/routes/brandsBilling.routes.js +14 -13
- package/src/routes/invoice.routes.js +4 -3
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.25",
|
|
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.43",
|
|
33
33
|
"tango-app-api-middleware": "^3.6.18",
|
|
34
34
|
"winston": "^3.12.0",
|
|
35
35
|
"winston-daily-rotate-file": "^5.0.0",
|
|
@@ -343,14 +343,15 @@ export const updateBillingGroup = async ( req, res ) => {
|
|
|
343
343
|
|
|
344
344
|
if ( removedStores.length ) {
|
|
345
345
|
await updateOne( { _id: new mongoose.Types.ObjectId( req.body._id ) }, { $pull: { stores: { $in: removedStores } } } );
|
|
346
|
-
|
|
346
|
+
// $addToSet (not $push) so stores already in the primary group aren't duplicated.
|
|
347
|
+
await updateOne( { clientId: req.body.clientId, isPrimary: true }, { $addToSet: { stores: { $each: removedStores } } } );
|
|
347
348
|
}
|
|
348
349
|
|
|
349
350
|
const addedStores = req.body?.stores?.filter( ( val ) => !previousStores?.stores.includes( val ) );
|
|
350
351
|
|
|
351
352
|
if ( addedStores.length ) {
|
|
352
353
|
await updateMany( { clientId: req.body.clientId, _id: { $ne: new mongoose.Types.ObjectId( req.body._id ) } }, { $pull: { stores: { $in: req.body.stores } } } );
|
|
353
|
-
await updateOne( { _id: new mongoose.Types.ObjectId( req.body._id ) }, { $
|
|
354
|
+
await updateOne( { _id: new mongoose.Types.ObjectId( req.body._id ) }, { $addToSet: { stores: { $each: addedStores } } } );
|
|
354
355
|
}
|
|
355
356
|
}
|
|
356
357
|
|
|
@@ -436,13 +437,32 @@ export const updateBillingGroup = async ( req, res ) => {
|
|
|
436
437
|
|
|
437
438
|
export const deleteBillingGroup = async ( req, res ) => {
|
|
438
439
|
try {
|
|
439
|
-
const previousGroup = await findOne( { _id: new mongoose.Types.ObjectId( req.query._id ) }, { stores: 1, clientId: 1, groupName: 1 } );
|
|
440
|
+
const previousGroup = await findOne( { _id: new mongoose.Types.ObjectId( req.query._id ) }, { stores: 1, clientId: 1, groupName: 1, isPrimary: 1 } );
|
|
441
|
+
console.log( '🚀 ~ deleteBillingGroup ~ previousGroup:', previousGroup );
|
|
442
|
+
|
|
443
|
+
// No such group — surface a 404 instead of dereferencing null below (which
|
|
444
|
+
// would throw and 500). Covers a stale / already-deleted id.
|
|
445
|
+
if ( !previousGroup ) {
|
|
446
|
+
return res.sendError( 'Billing group not found', 404 );
|
|
447
|
+
}
|
|
448
|
+
// The primary group can't be deleted (stores fall back to it). Reject
|
|
449
|
+
// explicitly rather than silently no-op'ing the delete.
|
|
450
|
+
if ( previousGroup.isPrimary ) {
|
|
451
|
+
return res.sendError( 'The primary billing group cannot be deleted', 400 );
|
|
452
|
+
}
|
|
440
453
|
|
|
441
454
|
if ( previousGroup?.stores?.length ) {
|
|
442
|
-
|
|
455
|
+
// Move the deleted group's stores back to the primary group. Use $addToSet
|
|
456
|
+
// (not $push) so stores already present in the primary group aren't
|
|
457
|
+
// duplicated in its stores array.
|
|
458
|
+
await updateOne( { clientId: previousGroup.clientId, isPrimary: true }, { $addToSet: { stores: { $each: previousGroup?.stores } } } );
|
|
443
459
|
}
|
|
444
460
|
|
|
445
461
|
const deletedGroup = await deleteOne( { _id: new mongoose.Types.ObjectId( req.query._id ), isPrimary: false } );
|
|
462
|
+
// Guard against a silent no-op (e.g. a concurrent delete removed it first).
|
|
463
|
+
if ( !deletedGroup?.deletedCount ) {
|
|
464
|
+
return res.sendError( 'Billing group could not be deleted', 404 );
|
|
465
|
+
}
|
|
446
466
|
const updateKeys = [];
|
|
447
467
|
updateKeys.push( previousGroup.groupName );
|
|
448
468
|
const logObj = {
|
|
@@ -939,13 +959,12 @@ export async function getClientProducts( req, res ) {
|
|
|
939
959
|
}
|
|
940
960
|
}
|
|
941
961
|
|
|
942
|
-
// GSTIN address lookup via
|
|
943
|
-
//
|
|
944
|
-
//
|
|
945
|
-
// billing-group form.
|
|
962
|
+
// GSTIN address lookup via GST Verify (https://gstverify.co.in). Returns
|
|
963
|
+
// the registered company name + principal place of business address so the
|
|
964
|
+
// UI can auto-fill the billing-group form.
|
|
946
965
|
//
|
|
947
|
-
// Requires GST_LOOKUP_API_KEY in the API environment. The key is
|
|
948
|
-
//
|
|
966
|
+
// Requires GST_LOOKUP_API_KEY in the API environment. The key is sent in
|
|
967
|
+
// the `X-API-Key` request header (the provider's auth scheme); we never log it.
|
|
949
968
|
//
|
|
950
969
|
// Caller validates GSTIN format (15 chars, alphanumeric). Server-side we
|
|
951
970
|
// re-check before forwarding to the provider.
|
|
@@ -976,12 +995,11 @@ export async function gstinLookup( req, res ) {
|
|
|
976
995
|
return res.sendError( 'Unknown state code in GSTIN', 404 );
|
|
977
996
|
}
|
|
978
997
|
|
|
979
|
-
// Real lookup via
|
|
980
|
-
// Endpoint: GET https://
|
|
981
|
-
// Auth: API key
|
|
982
|
-
//
|
|
998
|
+
// Real lookup via GST Verify (https://gstverify.co.in/).
|
|
999
|
+
// Endpoint: GET https://gstverify.co.in/api/v1/verify/{GSTIN}
|
|
1000
|
+
// Auth: API key sent in the `X-API-Key` header. Set GST_LOOKUP_API_KEY
|
|
1001
|
+
// in the API's environment.
|
|
983
1002
|
const apiKey = process.env.GST_LOOKUP_API_KEY;
|
|
984
|
-
console.log( '🚀 ~ gstinLookup ~ apiKey:', apiKey );
|
|
985
1003
|
if ( !apiKey ) {
|
|
986
1004
|
// Fail explicitly rather than silently fall back — that way a missing
|
|
987
1005
|
// key shows up immediately instead of mock data leaking into prod.
|
|
@@ -992,8 +1010,8 @@ export async function gstinLookup( req, res ) {
|
|
|
992
1010
|
let providerResponse;
|
|
993
1011
|
try {
|
|
994
1012
|
providerResponse = await axios.get(
|
|
995
|
-
`https://
|
|
996
|
-
{ timeout: 8000 },
|
|
1013
|
+
`https://gstverify.co.in/api/v1/verify/${encodeURIComponent( gstin )}`,
|
|
1014
|
+
{ timeout: 8000, headers: { 'X-API-Key': apiKey } },
|
|
997
1015
|
);
|
|
998
1016
|
} catch ( axiosErr ) {
|
|
999
1017
|
// Distinguish provider-down vs other errors so the client can show a
|
|
@@ -1008,69 +1026,84 @@ export async function gstinLookup( req, res ) {
|
|
|
1008
1026
|
}
|
|
1009
1027
|
|
|
1010
1028
|
const body = providerResponse?.data || {};
|
|
1011
|
-
if ( !body.
|
|
1029
|
+
if ( !body.success || !body.data ) {
|
|
1012
1030
|
// Provider reached but couldn't resolve this GSTIN.
|
|
1013
1031
|
return res.sendError( 'GSTIN not found', 404 );
|
|
1014
1032
|
}
|
|
1015
1033
|
|
|
1016
1034
|
// Map provider's response shape into the contract the frontend expects.
|
|
1017
|
-
//
|
|
1018
|
-
//
|
|
1035
|
+
// GST Verify returns a flat object: gstin, legal_name, trade_name,
|
|
1036
|
+
// status, state, pan, and a single concatenated `address` string
|
|
1037
|
+
// (e.g. "123 Business Park, Mumbai — 400001"). We normalize to our
|
|
1038
|
+
// flat structure and parse the address string into the discrete fields
|
|
1039
|
+
// the form needs.
|
|
1019
1040
|
const src = body.data;
|
|
1020
|
-
|
|
1021
|
-
//
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
const principal = src.pradr || src.pradr1 || {};
|
|
1025
|
-
// The provider has used both `addr` (newer) and a flatter form
|
|
1026
|
-
// (older). Coalesce — `addr` if present, otherwise the principal
|
|
1027
|
-
// itself contains the address fields.
|
|
1041
|
+
|
|
1042
|
+
// Some records may still expose the structured GSTN principal-address
|
|
1043
|
+
// object; prefer explicit fields when present, else parse the string.
|
|
1044
|
+
const principal = src.pradr || {};
|
|
1028
1045
|
const addr = principal.addr || principal || {};
|
|
1029
1046
|
|
|
1030
|
-
//
|
|
1031
|
-
//
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1047
|
+
// Pull the display address, whether it comes back as a plain string or
|
|
1048
|
+
// nested under the structured principal-address object.
|
|
1049
|
+
const fullAddr = String(
|
|
1050
|
+
src.address || principal.adr || principal.address || src.adr || '',
|
|
1051
|
+
).trim();
|
|
1052
|
+
|
|
1053
|
+
// Pincode: prefer an explicit field, otherwise pull the first 6-digit
|
|
1054
|
+
// run out of the address string.
|
|
1055
|
+
const pinFromString = ( fullAddr.match( /\b(\d{6})\b/ ) || [] )[1] || '';
|
|
1056
|
+
const pinCode = addr.pncd ? String( addr.pncd ) :
|
|
1057
|
+
( addr.pincode ? String( addr.pincode ) : pinFromString );
|
|
1058
|
+
|
|
1059
|
+
// State: provider's explicit state wins; fall back to our state-code map
|
|
1060
|
+
// so a missing field never blanks out the Place of Supply.
|
|
1061
|
+
const stateValue = src.state || addr.stcd || addr.state || stateName;
|
|
1062
|
+
|
|
1063
|
+
let addressLineOne = '';
|
|
1064
|
+
let addressLineTwo = '';
|
|
1065
|
+
let cityValue = addr.city || addr.dst || addr.loc || '';
|
|
1036
1066
|
|
|
1037
|
-
let addressLineOne;
|
|
1038
|
-
let addressLineTwo;
|
|
1039
1067
|
if ( fullAddr ) {
|
|
1040
|
-
//
|
|
1041
|
-
//
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1068
|
+
// Split the display address on commas / em-dash and drop the trailing
|
|
1069
|
+
// tokens that belong in the discrete fields (state, pincode).
|
|
1070
|
+
let parts = fullAddr
|
|
1071
|
+
.split( /[,—-]/ )
|
|
1072
|
+
.map( ( s ) => s.trim() )
|
|
1073
|
+
.filter( Boolean )
|
|
1074
|
+
.filter( ( p ) => !/^\d{6}$/.test( p ) )
|
|
1075
|
+
.filter( ( p ) => p.toLowerCase() !== String( stateValue ).toLowerCase() );
|
|
1076
|
+
|
|
1077
|
+
// Last remaining token is typically the city/district — lift it out
|
|
1078
|
+
// when we don't already have an explicit city.
|
|
1079
|
+
if ( !cityValue && parts.length ) {
|
|
1080
|
+
cityValue = parts[parts.length - 1];
|
|
1081
|
+
parts = parts.slice( 0, -1 );
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
// Split the rest across the two address lines.
|
|
1045
1085
|
const half = Math.ceil( parts.length / 2 );
|
|
1046
1086
|
addressLineOne = parts.slice( 0, half ).join( ', ' );
|
|
1047
1087
|
addressLineTwo = parts.slice( half ).join( ', ' );
|
|
1048
|
-
} else {
|
|
1088
|
+
} else if ( Object.keys( addr ).length ) {
|
|
1049
1089
|
const line1Parts = [ addr.bno, addr.bnm, addr.flno, addr.st ].filter( ( p ) => p && String( p ).trim() );
|
|
1050
1090
|
const line2Parts = [ addr.loc, addr.lndmrk, addr.dst ].filter( ( p ) => p && String( p ).trim() );
|
|
1051
1091
|
addressLineOne = line1Parts.join( ', ' );
|
|
1052
1092
|
addressLineTwo = line2Parts.join( ', ' );
|
|
1053
1093
|
}
|
|
1054
1094
|
|
|
1055
|
-
// City: prefer explicit `city` / `dst`. Some records put the city
|
|
1056
|
-
// name in `loc` and the colony / sub-area in `dst` — prefer city, but
|
|
1057
|
-
// fall back to dst then loc when missing.
|
|
1058
|
-
const cityValue = addr.city || addr.dst || addr.loc || '';
|
|
1059
|
-
|
|
1060
1095
|
const data = {
|
|
1061
1096
|
gstin: src.gstin || gstin,
|
|
1062
|
-
legalName: src.lgnm || src.legalName || '',
|
|
1063
|
-
tradeName: src.tradeNam || src.tradeName || '',
|
|
1097
|
+
legalName: src.legal_name || src.lgnm || src.legalName || '',
|
|
1098
|
+
tradeName: src.trade_name || src.tradeNam || src.tradeName || '',
|
|
1064
1099
|
addressLineOne,
|
|
1065
1100
|
addressLineTwo,
|
|
1066
1101
|
city: cityValue,
|
|
1067
|
-
|
|
1068
|
-
// missing field doesn't blank out the Place of Supply.
|
|
1069
|
-
state: addr.stcd || addr.state || stateName,
|
|
1102
|
+
state: stateValue,
|
|
1070
1103
|
country: 'India',
|
|
1071
|
-
pinCode
|
|
1072
|
-
placeOfSupply: `${
|
|
1073
|
-
status: src.sts || '',
|
|
1104
|
+
pinCode,
|
|
1105
|
+
placeOfSupply: `${stateValue} (${code})`,
|
|
1106
|
+
status: src.status || src.sts || '',
|
|
1074
1107
|
};
|
|
1075
1108
|
|
|
1076
1109
|
return res.sendSuccess( data );
|
|
@@ -428,6 +428,21 @@ export async function brandInvoiceList( req, res ) {
|
|
|
428
428
|
},
|
|
429
429
|
} ];
|
|
430
430
|
|
|
431
|
+
// Invoice type sub-toggle: 'advance' shows only advance invoices (invoice #
|
|
432
|
+
// starts with 'TINV-'); 'all' shows only regular invoices (NOT TINV-*).
|
|
433
|
+
// Applied for BOTH tango and client views. Uses $expr + $regexMatch (not the
|
|
434
|
+
// fragile { $not: <regex> } query operator, which was matching nothing and
|
|
435
|
+
// hiding every row) and treats a missing invoice # as a regular invoice.
|
|
436
|
+
if ( req.body.invoiceType === 'advance' ) {
|
|
437
|
+
query.push( { $match: { $expr: {
|
|
438
|
+
$regexMatch: { input: { $ifNull: [ '$invoice', '' ] }, regex: '^TINV-' },
|
|
439
|
+
} } } );
|
|
440
|
+
} else if ( req.body.invoiceType === 'all' ) {
|
|
441
|
+
query.push( { $match: { $expr: {
|
|
442
|
+
$not: { $regexMatch: { input: { $ifNull: [ '$invoice', '' ] }, regex: '^TINV-' } },
|
|
443
|
+
} } } );
|
|
444
|
+
}
|
|
445
|
+
|
|
431
446
|
// Client users only ever see APPROVED invoices — the approval pipeline
|
|
432
447
|
// (pendingCsm/pendingFinance/pendingApproval/pending) is internal to tango
|
|
433
448
|
// and must not be exposed to clients, even in the raw list response.
|
|
@@ -451,7 +466,20 @@ export async function brandInvoiceList( req, res ) {
|
|
|
451
466
|
dateFrom = now.subtract( 12, 'month' ).startOf( 'month' ).toDate();
|
|
452
467
|
}
|
|
453
468
|
if ( dateFrom ) {
|
|
454
|
-
|
|
469
|
+
// billingDate is stored as a STRING on many invoices (e.g.
|
|
470
|
+
// "2024-06-10T00:00:00.000Z"), so a plain { $gte: <Date> } compares
|
|
471
|
+
// string-vs-Date and matches nothing (this is why the client list came
|
|
472
|
+
// back empty on the default 'prev' filter). Coerce to a date first.
|
|
473
|
+
query.push( { $match: { $expr: {
|
|
474
|
+
$gte: [
|
|
475
|
+
{ $cond: [
|
|
476
|
+
{ $eq: [ { $type: '$billingDate' }, 'date' ] },
|
|
477
|
+
'$billingDate',
|
|
478
|
+
{ $toDate: '$billingDate' },
|
|
479
|
+
] },
|
|
480
|
+
dateFrom,
|
|
481
|
+
],
|
|
482
|
+
} } } );
|
|
455
483
|
}
|
|
456
484
|
}
|
|
457
485
|
|
|
@@ -580,16 +608,18 @@ export async function brandInvoiceList( req, res ) {
|
|
|
580
608
|
}
|
|
581
609
|
|
|
582
610
|
// Footer totals are shown in a single ₹ figure, but individual invoices may
|
|
583
|
-
// be in
|
|
584
|
-
// summing so the ₹ total is correct
|
|
585
|
-
|
|
586
|
-
const
|
|
587
|
-
|
|
611
|
+
// be in a foreign currency ($, €, S$, AED). Convert each to INR at today's
|
|
612
|
+
// rate (per-currency, not just USD) before summing so the ₹ total is correct.
|
|
613
|
+
const rateByCurrency = {};
|
|
614
|
+
for ( const cur of new Set( allInvoices.map( ( c ) => String( c.currency || 'inr' ) ) ) ) {
|
|
615
|
+
rateByCurrency[cur] = await getInrRate( cur );
|
|
616
|
+
}
|
|
617
|
+
const toInr = ( inv, value ) => ( Number( value ) || 0 ) * ( rateByCurrency[String( inv.currency || 'inr' )] || 1 );
|
|
588
618
|
let summary = {
|
|
589
619
|
totalInvoices: allInvoices.length,
|
|
590
620
|
totalInvoiced: Math.round( allInvoices.reduce( ( sum, inv ) => sum + toInr( inv, inv.totalAmount ), 0 ) ),
|
|
591
621
|
// Footer totals over the FULL filtered set (not just the current page):
|
|
592
|
-
// stores, amount excl. GST and amount incl. GST (
|
|
622
|
+
// stores, amount excl. GST and amount incl. GST (foreign → INR converted).
|
|
593
623
|
totalStores: allInvoices.reduce( ( sum, inv ) => sum + ( inv.stores || 0 ), 0 ),
|
|
594
624
|
totalAmountExclGst: Math.round( allInvoices.reduce( ( sum, inv ) => sum + toInr( inv, inv.amount ), 0 ) ),
|
|
595
625
|
totalAmountInclGst: Math.round( allInvoices.reduce( ( sum, inv ) => sum + toInr( inv, inv.totalAmount ), 0 ) ),
|
|
@@ -598,6 +628,41 @@ export async function brandInvoiceList( req, res ) {
|
|
|
598
628
|
paid: allInvoices.filter( ( inv ) => inv.paymentStatus === 'paid' ).length,
|
|
599
629
|
};
|
|
600
630
|
|
|
631
|
+
// Header cards — Outstanding / Overdue / Pending Payment — each split into a
|
|
632
|
+
// GST excl (sum of `amount`) and incl (sum of `totalAmount`) figure, all
|
|
633
|
+
// converted to INR. Same shape/semantics as the All Invoices page cards.
|
|
634
|
+
const nowDate = new Date();
|
|
635
|
+
const cards = {
|
|
636
|
+
outstandingExclAmount: 0, outstandingInclAmount: 0, outstandingCount: 0,
|
|
637
|
+
overdueExclAmount: 0, overdueInclAmount: 0, overdueCount: 0,
|
|
638
|
+
pendingPaymentExclAmount: 0, pendingPaymentInclAmount: 0, pendingPaymentCount: 0,
|
|
639
|
+
};
|
|
640
|
+
for ( const inv of allInvoices ) {
|
|
641
|
+
if ( inv.paymentStatus === 'paid' ) {
|
|
642
|
+
continue;
|
|
643
|
+
}
|
|
644
|
+
const excl = toInr( inv, inv.amount );
|
|
645
|
+
const incl = toInr( inv, inv.totalAmount || inv.amount );
|
|
646
|
+
cards.outstandingExclAmount += excl;
|
|
647
|
+
cards.outstandingInclAmount += incl;
|
|
648
|
+
cards.outstandingCount += 1;
|
|
649
|
+
if ( inv.dueDate && new Date( inv.dueDate ) < nowDate ) {
|
|
650
|
+
cards.overdueExclAmount += excl;
|
|
651
|
+
cards.overdueInclAmount += incl;
|
|
652
|
+
cards.overdueCount += 1;
|
|
653
|
+
}
|
|
654
|
+
if ( inv.status === 'approved' ) {
|
|
655
|
+
cards.pendingPaymentExclAmount += excl;
|
|
656
|
+
cards.pendingPaymentInclAmount += incl;
|
|
657
|
+
cards.pendingPaymentCount += 1;
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
for ( const k of Object.keys( cards ) ) {
|
|
661
|
+
if ( k.endsWith( 'Amount' ) ) {
|
|
662
|
+
cards[k] = Math.round( cards[k] );
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
|
|
601
666
|
if ( req.body.export ) {
|
|
602
667
|
// Mirror the on-screen table exactly: same columns (excl. GST and incl.
|
|
603
668
|
// GST as separate amounts), same currency symbol, and the same status
|
|
@@ -653,7 +718,7 @@ export async function brandInvoiceList( req, res ) {
|
|
|
653
718
|
clientInfo.totalStores = billingGroups.reduce( ( sum, bg ) => sum + ( bg.stores ? bg.stores.length : 0 ), 0 );
|
|
654
719
|
clientInfo.totalGroups = billingGroups.length;
|
|
655
720
|
|
|
656
|
-
res.sendSuccess( { clientInfo, summary, count, data: invoiceList } );
|
|
721
|
+
res.sendSuccess( { clientInfo, summary, cards, count, data: invoiceList } );
|
|
657
722
|
} catch ( error ) {
|
|
658
723
|
logger.error( { error: error, function: 'brandInvoiceList' } );
|
|
659
724
|
return res.sendError( error, 500 );
|
|
@@ -688,6 +753,27 @@ export async function latestDailyPricing( req, res ) {
|
|
|
688
753
|
let stores = record.stores || [];
|
|
689
754
|
let count = stores.length;
|
|
690
755
|
|
|
756
|
+
// The daily-pricing store objects don't carry the store's country — it lives
|
|
757
|
+
// in the stores collection under storeProfile.country. Build a
|
|
758
|
+
// storeId → country map for this client and stamp `country` onto each store
|
|
759
|
+
// so the UI can show it as a column and filter by it.
|
|
760
|
+
const countryByStore = new Map();
|
|
761
|
+
try {
|
|
762
|
+
const storeDocs = await storeService.find(
|
|
763
|
+
{ clientId: req.body.clientId },
|
|
764
|
+
{ 'storeId': 1, 'storeProfile.country': 1 },
|
|
765
|
+
);
|
|
766
|
+
for ( const s of ( storeDocs || [] ) ) {
|
|
767
|
+
countryByStore.set( String( s.storeId ), s?.storeProfile?.country || '' );
|
|
768
|
+
}
|
|
769
|
+
} catch ( cErr ) {
|
|
770
|
+
logger.error( { error: cErr, function: 'latestDailyPricing.country', clientId: req.body.clientId } );
|
|
771
|
+
}
|
|
772
|
+
stores = stores.map( ( s ) => {
|
|
773
|
+
const base = s._doc || s;
|
|
774
|
+
return { ...base, country: countryByStore.get( String( base.storeId ) ) || '' };
|
|
775
|
+
} );
|
|
776
|
+
|
|
691
777
|
// statusFilter may be an array (multi-select) or a legacy string. An empty
|
|
692
778
|
// value / empty array means "all statuses".
|
|
693
779
|
const statusFilterList = Array.isArray( req.body.statusFilter ) ?
|
|
@@ -698,6 +784,15 @@ export async function latestDailyPricing( req, res ) {
|
|
|
698
784
|
stores = stores.filter( ( s ) => allowed.has( s.status ) );
|
|
699
785
|
}
|
|
700
786
|
|
|
787
|
+
// countryFilter may be an array (multi-select) or a string; empty = all.
|
|
788
|
+
const countryFilterList = Array.isArray( req.body.countryFilter ) ?
|
|
789
|
+
req.body.countryFilter.filter( Boolean ) :
|
|
790
|
+
( req.body.countryFilter ? [ req.body.countryFilter ] : [] );
|
|
791
|
+
if ( countryFilterList.length ) {
|
|
792
|
+
const allowedCountries = new Set( countryFilterList );
|
|
793
|
+
stores = stores.filter( ( s ) => allowedCountries.has( s.country ) );
|
|
794
|
+
}
|
|
795
|
+
|
|
701
796
|
if ( req.body.searchValue && req.body.searchValue !== '' ) {
|
|
702
797
|
const searchRegex = new RegExp( req.body.searchValue, 'i' );
|
|
703
798
|
stores = stores.filter( ( s ) => searchRegex.test( s.storeName ) || searchRegex.test( s.storeId ) );
|
|
@@ -708,9 +803,22 @@ export async function latestDailyPricing( req, res ) {
|
|
|
708
803
|
if ( req.body.sortColumName && req.body.sortColumName !== '' && req.body.sortBy ) {
|
|
709
804
|
const sortKey = req.body.sortColumName;
|
|
710
805
|
const sortDir = req.body.sortBy;
|
|
806
|
+
// Working days ('daysDifference' — the WORKING DAYS column) is not a
|
|
807
|
+
// top-level store field; it's per-product (products[].workingdays). So for
|
|
808
|
+
// that column sort by the store's MAX product working-days. Other columns
|
|
809
|
+
// (storeName, *CameraCount, zoneCount) sort by their top-level field.
|
|
810
|
+
const sortVal = ( s ) => {
|
|
811
|
+
if ( sortKey === 'daysDifference' ) {
|
|
812
|
+
return ( s.products || [] ).reduce(
|
|
813
|
+
( mx, p ) => Math.max( mx, Number( p.workingdays ) || 0 ), 0 );
|
|
814
|
+
}
|
|
815
|
+
return s[sortKey];
|
|
816
|
+
};
|
|
711
817
|
stores.sort( ( a, b ) => {
|
|
712
|
-
|
|
713
|
-
|
|
818
|
+
const av = sortVal( a );
|
|
819
|
+
const bv = sortVal( b );
|
|
820
|
+
if ( av < bv ) return -1 * sortDir;
|
|
821
|
+
if ( av > bv ) return 1 * sortDir;
|
|
714
822
|
return 0;
|
|
715
823
|
} );
|
|
716
824
|
}
|
|
@@ -720,10 +828,16 @@ export async function latestDailyPricing( req, res ) {
|
|
|
720
828
|
// BOTH the export and the on-screen table use the same numbers.
|
|
721
829
|
const priceByProduct = {};
|
|
722
830
|
let bpCurrency = 'inr';
|
|
831
|
+
// Step-pricing support: for STEP clients the per-store price depends on the
|
|
832
|
+
// store's 1-based POSITION within the product (tier ranges), so we can't use
|
|
833
|
+
// a single price per product. We pull the tiers here and resolve per-store
|
|
834
|
+
// prices by position below (mirrors invoice.controller's stepPrice).
|
|
835
|
+
let isStepClient = false;
|
|
836
|
+
const stepTiersByProduct = {};
|
|
723
837
|
try {
|
|
724
838
|
const bp = await basePriceService.findOne(
|
|
725
839
|
{ clientId: req.body.clientId },
|
|
726
|
-
{ standard: 1, currency: 1 },
|
|
840
|
+
{ standard: 1, step: 1, currency: 1 },
|
|
727
841
|
);
|
|
728
842
|
bpCurrency = bp?.currency || 'inr';
|
|
729
843
|
for ( const p of ( bp?.standard || [] ) ) {
|
|
@@ -732,9 +846,48 @@ export async function latestDailyPricing( req, res ) {
|
|
|
732
846
|
priceByProduct[p.productName] = price;
|
|
733
847
|
}
|
|
734
848
|
}
|
|
849
|
+
// Client priceType drives whether we use step tiers.
|
|
850
|
+
const priceClient = await clientService.findOne(
|
|
851
|
+
{ clientId: req.body.clientId }, { priceType: 1 } );
|
|
852
|
+
isStepClient = priceClient?.priceType === 'step';
|
|
853
|
+
if ( isStepClient ) {
|
|
854
|
+
for ( const p of ( bp?.step || [] ) ) {
|
|
855
|
+
if ( !p.productName ) {
|
|
856
|
+
continue;
|
|
857
|
+
}
|
|
858
|
+
if ( !stepTiersByProduct[p.productName] ) {
|
|
859
|
+
stepTiersByProduct[p.productName] = [];
|
|
860
|
+
}
|
|
861
|
+
stepTiersByProduct[p.productName].push( p );
|
|
862
|
+
}
|
|
863
|
+
// Tiers ordered by range start so the position lookup is correct.
|
|
864
|
+
for ( const name of Object.keys( stepTiersByProduct ) ) {
|
|
865
|
+
stepTiersByProduct[name].sort( ( a, b ) => {
|
|
866
|
+
const aStart = parseInt( String( a.storeRange || '0' ).split( '-' )[0], 10 ) || 0;
|
|
867
|
+
const bStart = parseInt( String( b.storeRange || '0' ).split( '-' )[0], 10 ) || 0;
|
|
868
|
+
return aStart - bStart;
|
|
869
|
+
} );
|
|
870
|
+
}
|
|
871
|
+
}
|
|
735
872
|
} catch ( bpErr ) {
|
|
736
873
|
logger.error( { error: bpErr, function: 'latestDailyPricing.basePrice', clientId: req.body.clientId } );
|
|
737
874
|
}
|
|
875
|
+
// Per-store step price by 1-based position within the product. Falls back to
|
|
876
|
+
// the last tier's price beyond the defined ranges (mirrors invoice stepPrice).
|
|
877
|
+
const stepPriceForPosition = ( productName, positionInProduct ) => {
|
|
878
|
+
const tiers = stepTiersByProduct[productName] || [];
|
|
879
|
+
if ( !tiers.length ) {
|
|
880
|
+
return 0;
|
|
881
|
+
}
|
|
882
|
+
for ( const tier of tiers ) {
|
|
883
|
+
const [ min, max ] = String( tier.storeRange || '' ).split( '-' ).map( Number );
|
|
884
|
+
if ( positionInProduct >= min && positionInProduct <= max ) {
|
|
885
|
+
return Number( tier.negotiatePrice ) || Number( tier.basePrice ) || 0;
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
const last = tiers[tiers.length - 1];
|
|
889
|
+
return Number( last.negotiatePrice ) || Number( last.basePrice ) || 0;
|
|
890
|
+
};
|
|
738
891
|
// Billing type per product (perStore / perZone / perCamera) from the client
|
|
739
892
|
// plan — drives whether the amount multiplies by camera/zone count. The
|
|
740
893
|
// same client doc supplies the invoice-amount currency below.
|
|
@@ -783,8 +936,31 @@ export async function latestDailyPricing( req, res ) {
|
|
|
783
936
|
}
|
|
784
937
|
return 1;
|
|
785
938
|
};
|
|
939
|
+
// Step clients: resolve each store's per-product price by its 1-based
|
|
940
|
+
// POSITION within the product, over the CURRENT SORTED order (so re-sorting
|
|
941
|
+
// re-tiers the amounts). Built here — after the sort, over the FULL list —
|
|
942
|
+
// so a store on page 2 still gets the position determined by all preceding
|
|
943
|
+
// stores. Keyed `storeId||productName`. Standard clients use priceByProduct.
|
|
944
|
+
const stepPriceByStoreProduct = {};
|
|
945
|
+
if ( isStepClient ) {
|
|
946
|
+
const positionByProduct = {};
|
|
947
|
+
for ( const s of stores ) {
|
|
948
|
+
for ( const p of ( s.products || [] ) ) {
|
|
949
|
+
const name = p.productName;
|
|
950
|
+
positionByProduct[name] = ( positionByProduct[name] || 0 ) + 1;
|
|
951
|
+
stepPriceByStoreProduct[`${s.storeId}||${name}`] =
|
|
952
|
+
stepPriceForPosition( name, positionByProduct[name] );
|
|
953
|
+
}
|
|
954
|
+
}
|
|
955
|
+
}
|
|
956
|
+
const priceForStoreProduct = ( store, product ) => {
|
|
957
|
+
if ( isStepClient ) {
|
|
958
|
+
return stepPriceByStoreProduct[`${store.storeId}||${product.productName}`] || 0;
|
|
959
|
+
}
|
|
960
|
+
return priceByProduct[product.productName] || 0;
|
|
961
|
+
};
|
|
786
962
|
const productInvoiceAmount = ( store, product ) => {
|
|
787
|
-
const price =
|
|
963
|
+
const price = priceForStoreProduct( store, product );
|
|
788
964
|
const workingDays = Number( product.workingdays ) || 0;
|
|
789
965
|
if ( price <= 0 || workingDays <= 0 ) {
|
|
790
966
|
return 0;
|
|
@@ -859,7 +1035,7 @@ export async function latestDailyPricing( req, res ) {
|
|
|
859
1035
|
invoiceBreakdown.push( {
|
|
860
1036
|
productName: product.productName,
|
|
861
1037
|
label: prettyProduct( product.productName ),
|
|
862
|
-
price:
|
|
1038
|
+
price: priceForStoreProduct( store, product ),
|
|
863
1039
|
workingDays: Number( product.workingdays ) || 0,
|
|
864
1040
|
amount,
|
|
865
1041
|
} );
|
|
@@ -928,6 +1104,11 @@ export async function latestDailyPricing( req, res ) {
|
|
|
928
1104
|
.sort( ( a, b ) => a.firstFileDate.localeCompare( b.firstFileDate ) );
|
|
929
1105
|
const newlyOnboardedStores = newlyOnboardedStoreList.length;
|
|
930
1106
|
|
|
1107
|
+
// Unique, sorted, non-empty countries across ALL of the client's stores
|
|
1108
|
+
// (from the map above) — feeds the Country filter dropdown independent of
|
|
1109
|
+
// the current page / status / country selection.
|
|
1110
|
+
const countries = [ ...new Set( [ ...countryByStore.values() ].filter( Boolean ) ) ].sort();
|
|
1111
|
+
|
|
931
1112
|
let data = {
|
|
932
1113
|
clientId: record.clientId,
|
|
933
1114
|
brandName: record.brandName,
|
|
@@ -938,6 +1119,7 @@ export async function latestDailyPricing( req, res ) {
|
|
|
938
1119
|
status: record.status,
|
|
939
1120
|
proRate: record.proRate,
|
|
940
1121
|
count,
|
|
1122
|
+
countries,
|
|
941
1123
|
newlyOnboardedStores,
|
|
942
1124
|
newlyOnboardedStoreList,
|
|
943
1125
|
data: storeList,
|
|
@@ -1709,30 +1891,77 @@ export async function bulkUpdateBillingGroups( req, res ) {
|
|
|
1709
1891
|
// including the current. Filtering/sorting happen client-side (the dataset
|
|
1710
1892
|
// is one row per client).
|
|
1711
1893
|
// ---------------------------------------------------------------------------
|
|
1894
|
+
// Live USD-based FX rates (cached 6h) from open.er-api.com. The response gives
|
|
1895
|
+
// every currency relative to USD (rates.INR, rates.EUR, rates.SGD, …), so we
|
|
1896
|
+
// cache the whole table and derive any currency→INR rate as rates.INR / rates.X.
|
|
1897
|
+
let fxRatesCache = { rates: null, at: 0 };
|
|
1898
|
+
async function getUsdRates() {
|
|
1899
|
+
if ( fxRatesCache.rates && ( Date.now() - fxRatesCache.at ) < 6 * 60 * 60 * 1000 ) {
|
|
1900
|
+
return fxRatesCache.rates;
|
|
1901
|
+
}
|
|
1902
|
+
try {
|
|
1903
|
+
const resp = await fetch( 'https://open.er-api.com/v6/latest/USD', { signal: AbortSignal.timeout( 4000 ) } );
|
|
1904
|
+
const body = await resp.json();
|
|
1905
|
+
if ( body?.rates && Number( body.rates.INR ) > 0 ) {
|
|
1906
|
+
fxRatesCache = { rates: body.rates, at: Date.now() };
|
|
1907
|
+
return body.rates;
|
|
1908
|
+
}
|
|
1909
|
+
} catch ( err ) {
|
|
1910
|
+
logger.error( { error: err, function: 'getUsdRates' } );
|
|
1911
|
+
}
|
|
1912
|
+
return fxRatesCache.rates; // may be null on cold-start failure
|
|
1913
|
+
}
|
|
1914
|
+
|
|
1712
1915
|
// Today's USD->INR rate for dollar-priced clients. Live rate (cached 6h)
|
|
1713
1916
|
// with an env override (USD_INR_RATE) and a last-known/static fallback so
|
|
1714
1917
|
// the summary never fails because a rate API is down.
|
|
1715
|
-
let usdRateCache = { rate: null, at: 0 };
|
|
1716
1918
|
export async function getUsdInrRate() {
|
|
1717
1919
|
const override = Number( process.env.USD_INR_RATE );
|
|
1718
1920
|
if ( override > 0 ) {
|
|
1719
1921
|
return override;
|
|
1720
1922
|
}
|
|
1721
|
-
|
|
1722
|
-
|
|
1923
|
+
const rates = await getUsdRates();
|
|
1924
|
+
const rate = Number( rates?.INR );
|
|
1925
|
+
return rate > 0 ? rate : 83.33;
|
|
1926
|
+
}
|
|
1927
|
+
|
|
1928
|
+
// The app's own currency codes → ISO codes used by the FX API.
|
|
1929
|
+
const CURRENCY_ISO = { inr: 'INR', dollar: 'USD', usd: 'USD', euro: 'EUR', eur: 'EUR', singaporedollar: 'SGD', sgd: 'SGD', aed: 'AED' };
|
|
1930
|
+
// Static last-resort rates (currency→INR) if the FX API is unreachable on a
|
|
1931
|
+
// cold start. Approximate; only used so totals aren't wildly wrong.
|
|
1932
|
+
const FALLBACK_INR = { INR: 1, USD: 83.33, EUR: 90, SGD: 62, AED: 22.7 };
|
|
1933
|
+
|
|
1934
|
+
// Multiplier to convert an amount in `currency` to INR. INR → 1. Unknown
|
|
1935
|
+
// currencies fall back to 1 (treated as already-INR) so nothing is dropped.
|
|
1936
|
+
export async function getInrRate( currency ) {
|
|
1937
|
+
const iso = CURRENCY_ISO[String( currency || '' ).toLowerCase()];
|
|
1938
|
+
if ( !iso || iso === 'INR' ) {
|
|
1939
|
+
return 1;
|
|
1940
|
+
}
|
|
1941
|
+
const rates = await getUsdRates();
|
|
1942
|
+
const inr = Number( rates?.INR );
|
|
1943
|
+
const cur = Number( rates?.[iso] );
|
|
1944
|
+
if ( inr > 0 && cur > 0 ) {
|
|
1945
|
+
return inr / cur; // 1 <currency> = (INR per USD) / (currency per USD) INR
|
|
1723
1946
|
}
|
|
1947
|
+
return FALLBACK_INR[iso] || 1;
|
|
1948
|
+
}
|
|
1949
|
+
|
|
1950
|
+
// FX rates for the app's supported currencies, expressed as INR-per-1-unit.
|
|
1951
|
+
// The UI converts an amount from currency A to B as: amount * (rates[A] / rates[B]).
|
|
1952
|
+
// Reuses the cached live rates (getInrRate); inr is always 1.
|
|
1953
|
+
export async function fxRates( req, res ) {
|
|
1724
1954
|
try {
|
|
1725
|
-
const
|
|
1726
|
-
const
|
|
1727
|
-
const
|
|
1728
|
-
|
|
1729
|
-
usdRateCache = { rate, at: Date.now() };
|
|
1730
|
-
return rate;
|
|
1955
|
+
const codes = [ 'inr', 'dollar', 'singaporedollar', 'euro', 'aed' ];
|
|
1956
|
+
const rates = {};
|
|
1957
|
+
for ( const c of codes ) {
|
|
1958
|
+
rates[c] = await getInrRate( c );
|
|
1731
1959
|
}
|
|
1732
|
-
|
|
1733
|
-
|
|
1960
|
+
return res.sendSuccess( { rates } );
|
|
1961
|
+
} catch ( error ) {
|
|
1962
|
+
logger.error( { error: error, function: 'fxRates' } );
|
|
1963
|
+
return res.sendError( error, 500 );
|
|
1734
1964
|
}
|
|
1735
|
-
return usdRateCache.rate || 83.33;
|
|
1736
1965
|
}
|
|
1737
1966
|
|
|
1738
1967
|
export async function billingSummary( req, res ) {
|
|
@@ -2769,36 +2998,84 @@ export async function additionalProductExport( req, res ) {
|
|
|
2769
2998
|
'Subjective-Refraction', 'Trial-Frame', 'VA-Check',
|
|
2770
2999
|
];
|
|
2771
3000
|
const fromDate = dayjs( reqDate ).startOf( 'month' ).format( 'YYYY-MM-DD' );
|
|
2772
|
-
|
|
2773
|
-
|
|
3001
|
+
const HEADER_COLUMNS = [
|
|
3002
|
+
'storeName', 'engagementId', 'queue_id', 'optm_name', 'optm_id',
|
|
3003
|
+
'optm_Emailid', 'Date', 'StartTime', 'EndTime', 'Duration (min)',
|
|
3004
|
+
...STEP_COLUMNS,
|
|
3005
|
+
];
|
|
3006
|
+
// Flatten one OpenSearch hit into a row array aligned with HEADER_COLUMNS.
|
|
3007
|
+
const hitToRow = ( h ) => {
|
|
3008
|
+
const s = h._source || {};
|
|
3009
|
+
const steps = s.steps || {};
|
|
3010
|
+
return [
|
|
3011
|
+
s.storeName || '', s.engagementId || '', s.queue_id || '',
|
|
3012
|
+
s.optm_name || '', s.optm_id || '', s.optm_Emailid || '',
|
|
3013
|
+
s.Date || '', s.StartTime || '', s.EndTime || '',
|
|
3014
|
+
s['Duration (min)'] != null ? s['Duration (min)'] : '',
|
|
3015
|
+
...STEP_COLUMNS.map( ( col ) => ( steps[col] === true ? 'TRUE' : 'FALSE' ) ),
|
|
3016
|
+
];
|
|
3017
|
+
};
|
|
3018
|
+
|
|
3019
|
+
// NOTE: do NOT sort on StartTime — it's an analyzed text field, and sorting
|
|
3020
|
+
// on it makes OpenSearch return ZERO hits. Date is a keyword (YYYY-MM-DD),
|
|
3021
|
+
// so the month-to-date span is a lexical range; rows come back in index
|
|
3022
|
+
// order. A whole month can be hundreds of thousands of records, so we scroll
|
|
3023
|
+
// through pages AND stream each page straight into an ExcelJS streaming
|
|
3024
|
+
// workbook — building the full .xlsx in memory (the old excel4node path)
|
|
3025
|
+
// blew the heap / gateway timeout at this scale, causing 502s.
|
|
3026
|
+
let firstRes;
|
|
2774
3027
|
try {
|
|
2775
|
-
|
|
2776
|
-
// through all pages. Date is a keyword (YYYY-MM-DD) → the span is a lexical
|
|
2777
|
-
// range. NOTE: do NOT sort on StartTime — it's an analyzed text field, and
|
|
2778
|
-
// sorting on it makes OpenSearch return ZERO hits. The range filter is
|
|
2779
|
-
// enough; rows come back in index order.
|
|
2780
|
-
let osRes = await searchOpenSearchData( 'remote_optom_steps_summary', {
|
|
3028
|
+
firstRes = await searchOpenSearchData( 'remote_optom_steps_summary', {
|
|
2781
3029
|
size: 10000,
|
|
2782
3030
|
query: { range: { 'Date': { gte: fromDate, lte: reqDate } } },
|
|
2783
3031
|
} );
|
|
2784
|
-
|
|
2785
|
-
|
|
2786
|
-
|
|
3032
|
+
} catch ( osErr ) {
|
|
3033
|
+
logger.error( { error: osErr, function: 'additionalProductExport.remoteOptum', clientId } );
|
|
3034
|
+
return res.sendError( 'Failed to fetch Remote Optum data', 502 );
|
|
3035
|
+
}
|
|
3036
|
+
let body = firstRes?.body || firstRes;
|
|
3037
|
+
let hits = body?.hits?.hits || [];
|
|
3038
|
+
let scrollId = body?._scroll_id;
|
|
3039
|
+
// Nothing to export — respond BEFORE any stream headers are sent.
|
|
3040
|
+
if ( !hits.length ) {
|
|
3041
|
+
if ( scrollId ) {
|
|
3042
|
+
try {
|
|
3043
|
+
await clearScroll( scrollId );
|
|
3044
|
+
} catch ( clearErr ) {
|
|
3045
|
+
logger.error( { error: clearErr, function: 'additionalProductExport.remoteOptum.clearScroll' } );
|
|
3046
|
+
}
|
|
3047
|
+
}
|
|
3048
|
+
return res.sendError( 'No data', 204 );
|
|
3049
|
+
}
|
|
3050
|
+
|
|
3051
|
+
// From here the response is a streamed workbook; headers are committed, so
|
|
3052
|
+
// a mid-stream failure can only abort the connection, not send a new status.
|
|
3053
|
+
res.setHeader( 'Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' );
|
|
3054
|
+
res.setHeader( 'Content-Disposition', `attachment; filename="Remote Optum-${clientId}.xlsx"` );
|
|
3055
|
+
const workbook = new ExcelJS.stream.xlsx.WorkbookWriter( { stream: res, useStyles: false } );
|
|
3056
|
+
const ws = workbook.addWorksheet( 'Worksheet Name' );
|
|
3057
|
+
ws.addRow( HEADER_COLUMNS ).commit();
|
|
3058
|
+
try {
|
|
2787
3059
|
while ( hits.length ) {
|
|
2788
|
-
|
|
3060
|
+
for ( const h of hits ) {
|
|
3061
|
+
ws.addRow( hitToRow( h ) ).commit();
|
|
3062
|
+
}
|
|
2789
3063
|
if ( !scrollId ) {
|
|
2790
3064
|
break;
|
|
2791
3065
|
}
|
|
2792
|
-
osRes = await scrollResponse( scrollId );
|
|
3066
|
+
const osRes = await scrollResponse( scrollId );
|
|
2793
3067
|
body = osRes?.body || osRes;
|
|
2794
3068
|
hits = body?.hits?.hits || [];
|
|
2795
3069
|
scrollId = body?._scroll_id || scrollId;
|
|
2796
3070
|
}
|
|
2797
|
-
|
|
2798
|
-
|
|
2799
|
-
|
|
3071
|
+
ws.commit();
|
|
3072
|
+
await workbook.commit();
|
|
3073
|
+
} catch ( streamErr ) {
|
|
3074
|
+
// Headers are already sent — log and tear down the connection so the
|
|
3075
|
+
// client sees a failed/aborted download rather than a truncated file.
|
|
3076
|
+
logger.error( { error: streamErr, function: 'additionalProductExport.remoteOptum.stream', clientId } );
|
|
3077
|
+
res.destroy( streamErr );
|
|
2800
3078
|
} finally {
|
|
2801
|
-
// Free the scroll context; ignore failures (context may already be gone).
|
|
2802
3079
|
if ( scrollId ) {
|
|
2803
3080
|
try {
|
|
2804
3081
|
await clearScroll( scrollId );
|
|
@@ -2807,32 +3084,6 @@ export async function additionalProductExport( req, res ) {
|
|
|
2807
3084
|
}
|
|
2808
3085
|
}
|
|
2809
3086
|
}
|
|
2810
|
-
|
|
2811
|
-
const exportData = optomHits.map( ( h ) => {
|
|
2812
|
-
const s = h._source || {};
|
|
2813
|
-
const steps = s.steps || {};
|
|
2814
|
-
const row = {
|
|
2815
|
-
'storeName': s.storeName || '',
|
|
2816
|
-
'engagementId': s.engagementId || '',
|
|
2817
|
-
'queue_id': s.queue_id || '',
|
|
2818
|
-
'optm_name': s.optm_name || '',
|
|
2819
|
-
'optm_id': s.optm_id || '',
|
|
2820
|
-
'optm_Emailid': s.optm_Emailid || '',
|
|
2821
|
-
'Date': s.Date || '',
|
|
2822
|
-
'StartTime': s.StartTime || '',
|
|
2823
|
-
'EndTime': s.EndTime || '',
|
|
2824
|
-
'Duration (min)': s['Duration (min)'] != null ? s['Duration (min)'] : '',
|
|
2825
|
-
};
|
|
2826
|
-
for ( const col of STEP_COLUMNS ) {
|
|
2827
|
-
row[col] = steps[col] === true ? 'TRUE' : 'FALSE';
|
|
2828
|
-
}
|
|
2829
|
-
return row;
|
|
2830
|
-
} );
|
|
2831
|
-
|
|
2832
|
-
if ( !exportData.length ) {
|
|
2833
|
-
return res.sendError( 'No data', 204 );
|
|
2834
|
-
}
|
|
2835
|
-
await download( exportData, res );
|
|
2836
3087
|
return;
|
|
2837
3088
|
}
|
|
2838
3089
|
|
|
@@ -2,7 +2,7 @@ import * as estimateService from '../services/estimate.service.js';
|
|
|
2
2
|
import * as clientService from '../services/clientPayment.services.js';
|
|
3
3
|
import * as billingService from '../services/billing.service.js';
|
|
4
4
|
import dayjs from 'dayjs';
|
|
5
|
-
import { logger, download, sendEmailWithSES } from 'tango-app-api-middleware';
|
|
5
|
+
import { logger, download, sendEmailWithSES, insertOpenSearchData } from 'tango-app-api-middleware';
|
|
6
6
|
import Handlebars from '../utils/validations/helper/handlebar.helper.js';
|
|
7
7
|
import fs from 'fs';
|
|
8
8
|
import path from 'path';
|
|
@@ -17,6 +17,9 @@ async function buildEstimatePdf( estimate ) {
|
|
|
17
17
|
const currencyType = symbolFor( e.currency );
|
|
18
18
|
const fmt = ( n ) => Number( n || 0 ).toLocaleString( 'en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 } );
|
|
19
19
|
|
|
20
|
+
// Month per line item, mirroring the invoice PDF's Month column: use the
|
|
21
|
+
// product's stored month, else fall back to the estimate's created month.
|
|
22
|
+
const fallbackMonth = e.createdDate ? dayjs( e.createdDate ).format( 'MMM YYYY' ) : '';
|
|
20
23
|
const products = ( e.products || [] ).map( ( p, i ) => {
|
|
21
24
|
let name = String( p.productName || '' ).replace( /([a-z])([A-Z])/g, '$1 $2' );
|
|
22
25
|
name = name.charAt( 0 ).toUpperCase() + name.slice( 1 );
|
|
@@ -24,6 +27,7 @@ async function buildEstimatePdf( estimate ) {
|
|
|
24
27
|
index: i + 1,
|
|
25
28
|
productName: name,
|
|
26
29
|
description: p.description || '',
|
|
30
|
+
month: p.month || fallbackMonth,
|
|
27
31
|
hsn: p.hsn || p.hsnCode || '998314',
|
|
28
32
|
storeCount: p.storeCount || e.stores || '',
|
|
29
33
|
price: fmt( p.price ),
|
|
@@ -36,7 +40,7 @@ async function buildEstimatePdf( estimate ) {
|
|
|
36
40
|
taxAmount: fmt( t.taxAmount ),
|
|
37
41
|
} ) );
|
|
38
42
|
|
|
39
|
-
const statusLabelMap = {
|
|
43
|
+
const statusLabelMap = { pendingCsm: 'Pending CSM', pendingFinance: 'Pending Finance', pendingApproval: 'Pending Approval', approved: 'Approved', pending: 'Pending CSM' };
|
|
40
44
|
const data = {
|
|
41
45
|
estimate: e.estimate,
|
|
42
46
|
status: e.status,
|
|
@@ -106,26 +110,19 @@ async function nextEstimateNumber() {
|
|
|
106
110
|
};
|
|
107
111
|
}
|
|
108
112
|
|
|
109
|
-
// Expire stale estimates lazily on read: anything sent/draft past validTill
|
|
110
|
-
// flips to 'expired' so the list reflects reality without a cron.
|
|
111
|
-
async function expireOverdue( clientId ) {
|
|
112
|
-
await estimateService.updateOne(
|
|
113
|
-
{ clientId, status: { $in: [ 'pending', 'sent' ] }, validTill: { $lt: new Date() } },
|
|
114
|
-
{ $set: { status: 'expired' } },
|
|
115
|
-
);
|
|
116
|
-
}
|
|
117
|
-
|
|
118
113
|
export async function estimateList( req, res ) {
|
|
119
114
|
try {
|
|
120
115
|
const clientId = req.body?.clientId;
|
|
121
116
|
if ( !clientId ) {
|
|
122
117
|
return res.sendError( 'clientId is required', 400 );
|
|
123
118
|
}
|
|
124
|
-
await expireOverdue( clientId );
|
|
125
119
|
|
|
126
120
|
const match = { clientId };
|
|
127
121
|
if ( req.body?.status && req.body.status !== 'All' ) {
|
|
128
|
-
|
|
122
|
+
// Legacy 'pending' estimates are equivalent to the first CSM stage.
|
|
123
|
+
match.status = req.body.status === 'pendingCsm' ?
|
|
124
|
+
{ $in: [ 'pendingCsm', 'pending' ] } :
|
|
125
|
+
req.body.status;
|
|
129
126
|
}
|
|
130
127
|
|
|
131
128
|
// Optional month / year filter on the estimate period (stored as a
|
|
@@ -186,10 +183,12 @@ export async function estimateList( req, res ) {
|
|
|
186
183
|
{ $match: { clientId } },
|
|
187
184
|
{ $group: { _id: '$status', count: { $sum: 1 } } },
|
|
188
185
|
] );
|
|
189
|
-
const counts = {
|
|
186
|
+
const counts = { pendingCsm: 0, pendingFinance: 0, pendingApproval: 0, approved: 0, total: 0 };
|
|
190
187
|
statusAgg.forEach( ( s ) => {
|
|
191
|
-
|
|
192
|
-
|
|
188
|
+
// Fold legacy 'pending' into the CSM stage.
|
|
189
|
+
const key = s._id === 'pending' ? 'pendingCsm' : s._id;
|
|
190
|
+
if ( counts[key] != null ) {
|
|
191
|
+
counts[key] += s.count;
|
|
193
192
|
}
|
|
194
193
|
counts.total += s.count;
|
|
195
194
|
} );
|
|
@@ -244,7 +243,8 @@ export async function createEstimate( req, res ) {
|
|
|
244
243
|
amount,
|
|
245
244
|
totalAmount,
|
|
246
245
|
currency: estimateCurrency,
|
|
247
|
-
|
|
246
|
+
// Estimates enter the approval pipeline at the CSM stage.
|
|
247
|
+
status: 'pendingCsm',
|
|
248
248
|
createdDate,
|
|
249
249
|
validTill,
|
|
250
250
|
createdBy: req.user?.email || req.user?.userName || '',
|
|
@@ -374,6 +374,100 @@ export async function downloadEstimate( req, res ) {
|
|
|
374
374
|
}
|
|
375
375
|
}
|
|
376
376
|
|
|
377
|
+
// Advance an estimate one stage in the approval pipeline
|
|
378
|
+
// (pendingCsm → pendingFinance → pendingApproval → approved), mirroring
|
|
379
|
+
// transitionInvoiceStatus. On the FINAL transition to 'approved' the estimate
|
|
380
|
+
// PDF is emailed to the billing group recipients (same logic as the old
|
|
381
|
+
// approve-and-send). A wrong current status returns 409.
|
|
382
|
+
async function transitionEstimateStatus( req, res, fromStatus, toStatus ) {
|
|
383
|
+
try {
|
|
384
|
+
const { estimateId } = req.body || {};
|
|
385
|
+
if ( !estimateId ) {
|
|
386
|
+
return res.sendError( 'estimateId is required', 400 );
|
|
387
|
+
}
|
|
388
|
+
const estimate = await estimateService.findOne( { _id: estimateId } );
|
|
389
|
+
if ( !estimate ) {
|
|
390
|
+
return res.sendError( 'Estimate not found', 404 );
|
|
391
|
+
}
|
|
392
|
+
const e = estimate._doc || estimate;
|
|
393
|
+
|
|
394
|
+
// Legacy 'pending' estimates are equivalent to the first CSM stage.
|
|
395
|
+
const acceptedFrom = fromStatus === 'pendingCsm' ? [ 'pendingCsm', 'pending' ] : [ fromStatus ];
|
|
396
|
+
if ( !acceptedFrom.includes( e.status ) ) {
|
|
397
|
+
return res.sendError(
|
|
398
|
+
`Estimate is currently at status '${e.status}', not '${fromStatus}'. Another user may have advanced it.`,
|
|
399
|
+
409,
|
|
400
|
+
);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
await estimateService.updateOne( { _id: estimateId }, { $set: { status: toStatus } } );
|
|
404
|
+
|
|
405
|
+
insertOpenSearchData( JSON.parse( process.env.OPENSEARCH ).activityLog, {
|
|
406
|
+
userName: req.user?.userName,
|
|
407
|
+
email: req.user?.email,
|
|
408
|
+
clientId: e.clientId,
|
|
409
|
+
logSubType: 'estimateStatusTransition',
|
|
410
|
+
logType: 'estimate',
|
|
411
|
+
date: new Date(),
|
|
412
|
+
changes: [ `Estimate ${e.estimate} advanced from ${fromStatus} to ${toStatus} by ${req.user?.email}` ],
|
|
413
|
+
eventType: '',
|
|
414
|
+
timestamp: new Date(),
|
|
415
|
+
showTo: [ 'tango' ],
|
|
416
|
+
} );
|
|
417
|
+
|
|
418
|
+
// Final approval → email the estimate PDF to the billing group recipients.
|
|
419
|
+
// Non-fatal: a send failure is logged but the approval still stands.
|
|
420
|
+
if ( toStatus === 'approved' ) {
|
|
421
|
+
try {
|
|
422
|
+
let toEmails = [];
|
|
423
|
+
if ( e.groupId ) {
|
|
424
|
+
const group = await billingService.findOne( { _id: e.groupId } );
|
|
425
|
+
toEmails = ( group?.generateInvoiceTo || [] ).map( ( x ) => String( x || '' ).trim() ).filter( Boolean );
|
|
426
|
+
}
|
|
427
|
+
toEmails = [ ...new Set( toEmails ) ];
|
|
428
|
+
if ( toEmails.length ) {
|
|
429
|
+
const ccRaw = await getInvoiceCcEmails( e.clientId );
|
|
430
|
+
const toSet = new Set( toEmails.map( ( x ) => x.toLowerCase() ) );
|
|
431
|
+
const ccEmails = [ ...new Set( ( ccRaw || [] ).map( ( x ) => String( x || '' ).trim() ).filter( Boolean ) ) ]
|
|
432
|
+
.filter( ( x ) => !toSet.has( x.toLowerCase() ) );
|
|
433
|
+
const { pdfBuffer, filename, data } = await buildEstimatePdf( estimate );
|
|
434
|
+
const client = await clientService.findOne( { clientId: e.clientId }, { clientName: 1 } );
|
|
435
|
+
const emailData = { ...data, clientName: client?.clientName || e.companyName || 'Customer', companyName: 'Team Tango' };
|
|
436
|
+
const emailHtml = Handlebars.compile(
|
|
437
|
+
fs.readFileSync( path.resolve( path.dirname( '' ) ) + '/src/hbs/estimateEmail.hbs', 'utf8' ),
|
|
438
|
+
)( emailData );
|
|
439
|
+
const SES = JSON.parse( process.env.SES );
|
|
440
|
+
const fromEmail = SES.accountsEmail || SES.adminEmail;
|
|
441
|
+
const subject = `Estimate ${e.estimate} - Tango/${client?.clientName || e.companyName || ''}`;
|
|
442
|
+
const attachment = { filename, content: pdfBuffer, contentType: 'application/pdf' };
|
|
443
|
+
await sendEmailWithSES( toEmails, subject, emailHtml, attachment, fromEmail, ccEmails.length ? ccEmails : undefined );
|
|
444
|
+
} else {
|
|
445
|
+
logger.info?.( { function: 'transitionEstimateStatus', estimateId, message: 'No recipients configured; approval email skipped.' } );
|
|
446
|
+
}
|
|
447
|
+
} catch ( mailErr ) {
|
|
448
|
+
logger.error( { error: mailErr, function: 'transitionEstimateStatus.email', estimateId } );
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
return res.sendSuccess( { estimateId, fromStatus, status: toStatus } );
|
|
453
|
+
} catch ( error ) {
|
|
454
|
+
logger.error( { error: error, function: 'transitionEstimateStatus', fromStatus, toStatus, estimateId: req.body?.estimateId } );
|
|
455
|
+
return res.sendError( error, 500 );
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
export async function approveEstimateCsm( req, res ) {
|
|
460
|
+
return transitionEstimateStatus( req, res, 'pendingCsm', 'pendingFinance' );
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
export async function approveEstimateFinance( req, res ) {
|
|
464
|
+
return transitionEstimateStatus( req, res, 'pendingFinance', 'pendingApproval' );
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
export async function approveEstimateApproval( req, res ) {
|
|
468
|
+
return transitionEstimateStatus( req, res, 'pendingApproval', 'approved' );
|
|
469
|
+
}
|
|
470
|
+
|
|
377
471
|
// Approve & send an estimate: render the PDF, email it (with the PDF attached)
|
|
378
472
|
// to the billing group's recipients — same recipient logic as invoice send —
|
|
379
473
|
// then flip the status to 'sent'. Triggered by the Approve action's confirm.
|
|
@@ -18,7 +18,7 @@ import { invoiceStatusEnum } from '../dtos/validation.dtos.js';
|
|
|
18
18
|
import { findOneApplicationDefault } from '../services/applicationDefault.service.js';
|
|
19
19
|
import * as assignedStoreService from '../services/assignedStore.service.js';
|
|
20
20
|
import * as bankTransactionService from '../services/bankTransaction.service.js';
|
|
21
|
-
import {
|
|
21
|
+
import { getInrRate, getAdditionalProducts } from './brandsBilling.controller.js';
|
|
22
22
|
import { applyInvoiceToPurchaseOrder } from './purchaseOrder.controller.js';
|
|
23
23
|
|
|
24
24
|
// Pulls CSM + Finance head emails (stored under applicationDefault
|
|
@@ -315,7 +315,7 @@ export async function createInvoice( req, res ) {
|
|
|
315
315
|
// Scope additional-product quantities to this billing group's assigned
|
|
316
316
|
// stores (group.stores). Without this the counts span the whole brand
|
|
317
317
|
// and every group's invoice would be billed for all stores.
|
|
318
|
-
const extraProducts = await getAdditionalProducts( group.clientId, group.stores );
|
|
318
|
+
const extraProducts = await getAdditionalProducts( group.clientId, group.stores, baseDate );
|
|
319
319
|
for ( const ep of extraProducts ) {
|
|
320
320
|
// Skip additional products with no billable quantity — an Eyetest /
|
|
321
321
|
// Planogram / AI Manager / VMS line that resolved to 0 stores for this
|
|
@@ -1280,7 +1280,8 @@ async function resolveBasePricingScope( group, getClient ) {
|
|
|
1280
1280
|
|
|
1281
1281
|
async function standardPrice( group, getClient, baseDate ) {
|
|
1282
1282
|
console.log( '🚀 ~ standardPrice ~ baseDate:', baseDate.format( 'MMM YYYY' ) );
|
|
1283
|
-
const currentMonthDays =
|
|
1283
|
+
const currentMonthDays = 30;
|
|
1284
|
+
console.log( '🚀 ~ standardPrice ~ currentMonthDays:', currentMonthDays );
|
|
1284
1285
|
// Pricing method: 'flat' => bill every store for the full month
|
|
1285
1286
|
// regardless of working days. 'prorate' => bill for actual working days.
|
|
1286
1287
|
// Computed once so the aggregation pipelines can inline a $literal.
|
|
@@ -2011,6 +2012,16 @@ export async function clientInvoiceList( req, res ) {
|
|
|
2011
2012
|
},
|
|
2012
2013
|
} ];
|
|
2013
2014
|
|
|
2015
|
+
// Invoice type sub-toggle: 'advance' shows only advance invoices (invoice #
|
|
2016
|
+
// starts with 'TINV-'); 'all' (default) shows only regular invoices (INV-*),
|
|
2017
|
+
// i.e. everything that is NOT a TINV-. Applied to the whole pipeline so the
|
|
2018
|
+
// list, count and cards all reflect the selected type.
|
|
2019
|
+
if ( req.body.invoiceType === 'advance' ) {
|
|
2020
|
+
query.push( { $match: { invoice: { $regex: '^TINV-' } } } );
|
|
2021
|
+
} else if ( req.body.invoiceType === 'all' ) {
|
|
2022
|
+
query.push( { $match: { invoice: { $not: { $regex: '^TINV-' } } } } );
|
|
2023
|
+
}
|
|
2024
|
+
|
|
2014
2025
|
// If the user picked an explicit Month or Year, ignore the Duration
|
|
2015
2026
|
// filter — otherwise the two ranges conflict (e.g. "current month" +
|
|
2016
2027
|
// April would always return zero results). Mirrors brand-invoices.
|
|
@@ -2248,30 +2259,49 @@ export async function clientInvoiceList( req, res ) {
|
|
|
2248
2259
|
// converted to INR at today's rate so all three totals are a single ₹
|
|
2249
2260
|
// figure. Outstanding = unpaid remaining (totalAmount - paidAmount);
|
|
2250
2261
|
// Overdue = past-due unpaid subset; Pending Payment = approved-but-unpaid.
|
|
2251
|
-
const usdRate = await getUsdInrRate();
|
|
2252
2262
|
const now = new Date();
|
|
2263
|
+
// Convert every non-INR invoice to INR so the ₹ card totals are comparable.
|
|
2264
|
+
// Resolve one rate per distinct currency present (dollar, euro, sgd, aed, …)
|
|
2265
|
+
// via getInrRate, so euro/others are converted too — not just dollar.
|
|
2266
|
+
const rateByCurrency = {};
|
|
2267
|
+
for ( const cur of new Set( count.map( ( c ) => String( c.currency || 'inr' ) ) ) ) {
|
|
2268
|
+
rateByCurrency[cur] = await getInrRate( cur );
|
|
2269
|
+
}
|
|
2270
|
+
// Each card carries the total remaining (incl. GST, net of payments — the
|
|
2271
|
+
// *Amount fields, kept for back-compat) PLUS a GST split of the GROSS invoice
|
|
2272
|
+
// figures: *ExclAmount = sum of `amount` (pre-tax subtotal), *InclAmount =
|
|
2273
|
+
// sum of `totalAmount` (with tax), matching the table's Excl/Incl columns.
|
|
2253
2274
|
const cards = {
|
|
2254
|
-
outstandingAmount: 0, outstandingCount: 0,
|
|
2255
|
-
overdueAmount: 0, overdueCount: 0,
|
|
2256
|
-
pendingPaymentAmount: 0, pendingPaymentCount: 0,
|
|
2275
|
+
outstandingAmount: 0, outstandingExclAmount: 0, outstandingInclAmount: 0, outstandingCount: 0,
|
|
2276
|
+
overdueAmount: 0, overdueExclAmount: 0, overdueInclAmount: 0, overdueCount: 0,
|
|
2277
|
+
pendingPaymentAmount: 0, pendingPaymentExclAmount: 0, pendingPaymentInclAmount: 0, pendingPaymentCount: 0,
|
|
2257
2278
|
};
|
|
2258
2279
|
for ( const inv of count ) {
|
|
2259
2280
|
if ( inv.paymentStatus === 'paid' ) {
|
|
2260
2281
|
continue;
|
|
2261
2282
|
}
|
|
2262
|
-
const fx = inv.currency
|
|
2283
|
+
const fx = rateByCurrency[String( inv.currency || 'inr' )] || 1;
|
|
2263
2284
|
const total = Number( inv.totalAmount ) || Number( inv.amount ) || 0;
|
|
2264
2285
|
const paid = Number( inv.paidAmount ) || 0;
|
|
2265
2286
|
const remaining = Math.max( 0, total - paid ) * fx;
|
|
2287
|
+
// Gross GST split (FX-normalised to INR): excl = subtotal, incl = with tax.
|
|
2288
|
+
const exclGross = ( Number( inv.amount ) || 0 ) * fx;
|
|
2289
|
+
const inclGross = ( Number( inv.totalAmount ) || Number( inv.amount ) || 0 ) * fx;
|
|
2266
2290
|
|
|
2267
2291
|
cards.outstandingAmount += remaining;
|
|
2292
|
+
cards.outstandingExclAmount += exclGross;
|
|
2293
|
+
cards.outstandingInclAmount += inclGross;
|
|
2268
2294
|
cards.outstandingCount += 1;
|
|
2269
2295
|
if ( inv.dueDate && new Date( inv.dueDate ) < now ) {
|
|
2270
2296
|
cards.overdueAmount += remaining;
|
|
2297
|
+
cards.overdueExclAmount += exclGross;
|
|
2298
|
+
cards.overdueInclAmount += inclGross;
|
|
2271
2299
|
cards.overdueCount += 1;
|
|
2272
2300
|
}
|
|
2273
2301
|
if ( inv.status === 'approved' ) {
|
|
2274
2302
|
cards.pendingPaymentAmount += remaining;
|
|
2303
|
+
cards.pendingPaymentExclAmount += exclGross;
|
|
2304
|
+
cards.pendingPaymentInclAmount += inclGross;
|
|
2275
2305
|
cards.pendingPaymentCount += 1;
|
|
2276
2306
|
}
|
|
2277
2307
|
}
|
|
@@ -2280,6 +2310,12 @@ export async function clientInvoiceList( req, res ) {
|
|
|
2280
2310
|
cards.outstandingAmount = roundAmount( cards.outstandingAmount, 'inr' );
|
|
2281
2311
|
cards.overdueAmount = roundAmount( cards.overdueAmount, 'inr' );
|
|
2282
2312
|
cards.pendingPaymentAmount = roundAmount( cards.pendingPaymentAmount, 'inr' );
|
|
2313
|
+
cards.outstandingExclAmount = roundAmount( cards.outstandingExclAmount, 'inr' );
|
|
2314
|
+
cards.outstandingInclAmount = roundAmount( cards.outstandingInclAmount, 'inr' );
|
|
2315
|
+
cards.overdueExclAmount = roundAmount( cards.overdueExclAmount, 'inr' );
|
|
2316
|
+
cards.overdueInclAmount = roundAmount( cards.overdueInclAmount, 'inr' );
|
|
2317
|
+
cards.pendingPaymentExclAmount = roundAmount( cards.pendingPaymentExclAmount, 'inr' );
|
|
2318
|
+
cards.pendingPaymentInclAmount = roundAmount( cards.pendingPaymentInclAmount, 'inr' );
|
|
2283
2319
|
|
|
2284
2320
|
res.sendSuccess( { count: count.length, data: invoiceList, cards } );
|
|
2285
2321
|
} catch ( error ) {
|
package/src/hbs/estimatePdf.hbs
CHANGED
|
@@ -1540,6 +1540,18 @@
|
|
|
1540
1540
|
</div>
|
|
1541
1541
|
{{/each}}
|
|
1542
1542
|
</div>
|
|
1543
|
+
<div class="column2">
|
|
1544
|
+
<div class="table-header-cell2">
|
|
1545
|
+
<div class="table-header2">
|
|
1546
|
+
<div class="text6">Month</div>
|
|
1547
|
+
</div>
|
|
1548
|
+
</div>
|
|
1549
|
+
{{#each products}}
|
|
1550
|
+
<div class="table-cell4">
|
|
1551
|
+
<div class="text7">{{month}}</div>
|
|
1552
|
+
</div>
|
|
1553
|
+
{{/each}}
|
|
1554
|
+
</div>
|
|
1543
1555
|
<div class="column2">
|
|
1544
1556
|
<div class="table-header-cell2">
|
|
1545
1557
|
<div class="table-header2">
|
|
@@ -1,20 +1,21 @@
|
|
|
1
1
|
|
|
2
2
|
import express from 'express';
|
|
3
|
-
import { brandsBillingList, brandInvoiceList, latestDailyPricing, brandBillingGroups, updateDailyPricingWorkingDays, updateDailyPricingStoreField, getClientBillingInfo, bulkDownloadBillingGroups, bulkUpdateBillingGroups, billingSummary, additionalProducts, additionalProductExport } from '../controllers/brandsBilling.controller.js';
|
|
3
|
+
import { brandsBillingList, brandInvoiceList, latestDailyPricing, brandBillingGroups, updateDailyPricingWorkingDays, updateDailyPricingStoreField, getClientBillingInfo, bulkDownloadBillingGroups, bulkUpdateBillingGroups, billingSummary, additionalProducts, additionalProductExport, fxRates } from '../controllers/brandsBilling.controller.js';
|
|
4
4
|
import { isAllowedSessionHandler, accessVerification } from 'tango-app-api-middleware';
|
|
5
5
|
|
|
6
6
|
export const brandsBillingRouter = express.Router();
|
|
7
7
|
|
|
8
|
-
brandsBillingRouter.post( '/brandsBillingList', isAllowedSessionHandler, accessVerification( { userType: [ 'tango' ], access: [ { featureName: 'TangoAdmin', name: '
|
|
8
|
+
brandsBillingRouter.post( '/brandsBillingList', isAllowedSessionHandler, accessVerification( { userType: [ 'tango' ], access: [ { featureName: 'TangoAdmin', name: 'EditBilling', permissions: [] } ] } ), brandsBillingList );
|
|
9
9
|
brandsBillingRouter.post( '/brandInvoiceList', isAllowedSessionHandler, accessVerification( { userType: [ 'tango', 'client' ], access: [ ] } ), brandInvoiceList );
|
|
10
|
-
brandsBillingRouter.post( '/latestDailyPricing', isAllowedSessionHandler, accessVerification( { userType: [ 'tango' ], access: [ { featureName: 'TangoAdmin', name: '
|
|
11
|
-
brandsBillingRouter.post( '/brandBillingGroups', isAllowedSessionHandler, accessVerification( { userType: [ 'tango' ], access: [ { featureName: 'TangoAdmin', name: '
|
|
12
|
-
brandsBillingRouter.put( '/updateDailyPricingWorkingDays', isAllowedSessionHandler, accessVerification( { userType: [ 'tango' ], access: [ { featureName: 'TangoAdmin', name: '
|
|
13
|
-
brandsBillingRouter.put( '/updateDailyPricingStoreField', isAllowedSessionHandler, accessVerification( { userType: [ 'tango' ], access: [ { featureName: 'TangoAdmin', name: '
|
|
14
|
-
brandsBillingRouter.post( '/getClientBillingInfo', isAllowedSessionHandler, accessVerification( { userType: [ 'tango', 'client' ], access: [ { featureName: '
|
|
15
|
-
brandsBillingRouter.get( '/bulk-download-billing-groups', isAllowedSessionHandler, accessVerification( { userType: [ 'tango' ], access: [ { featureName: 'TangoAdmin', name: '
|
|
16
|
-
brandsBillingRouter.post( '/bulk-update-billing-groups', isAllowedSessionHandler, accessVerification( { userType: [ 'tango' ], access: [ { featureName: 'TangoAdmin', name: '
|
|
17
|
-
brandsBillingRouter.post( '/billingSummary', isAllowedSessionHandler, accessVerification( { userType: [ 'tango' ], access: [ { featureName: 'TangoAdmin', name: '
|
|
18
|
-
brandsBillingRouter.get( '/billingSummary', isAllowedSessionHandler, accessVerification( { userType: [ 'tango' ], access: [ { featureName: 'TangoAdmin', name: '
|
|
19
|
-
brandsBillingRouter.get( '/additionalProducts', isAllowedSessionHandler, accessVerification( { userType: [ 'tango' ], access: [ { featureName: 'TangoAdmin', name: '
|
|
20
|
-
brandsBillingRouter.get( '/additionalProductExport', isAllowedSessionHandler, accessVerification( { userType: [ 'tango' ], access: [ { featureName: 'TangoAdmin', name: '
|
|
10
|
+
brandsBillingRouter.post( '/latestDailyPricing', isAllowedSessionHandler, accessVerification( { userType: [ 'tango' ], access: [ { featureName: 'TangoAdmin', name: 'EditBilling', permissions: [] } ] } ), latestDailyPricing );
|
|
11
|
+
brandsBillingRouter.post( '/brandBillingGroups', isAllowedSessionHandler, accessVerification( { userType: [ 'tango' ], access: [ { featureName: 'TangoAdmin', name: 'EditBilling', permissions: [] } ] } ), brandBillingGroups );
|
|
12
|
+
brandsBillingRouter.put( '/updateDailyPricingWorkingDays', isAllowedSessionHandler, accessVerification( { userType: [ 'tango' ], access: [ { featureName: 'TangoAdmin', name: 'EditBilling', permissions: [ 'isEdit' ] } ] } ), updateDailyPricingWorkingDays );
|
|
13
|
+
brandsBillingRouter.put( '/updateDailyPricingStoreField', isAllowedSessionHandler, accessVerification( { userType: [ 'tango' ], access: [ { featureName: 'TangoAdmin', name: 'EditBilling', permissions: [ 'isEdit' ] } ] } ), updateDailyPricingStoreField );
|
|
14
|
+
brandsBillingRouter.post( '/getClientBillingInfo', isAllowedSessionHandler, accessVerification( { userType: [ 'tango', 'client' ], access: [ { featureName: 'Global', name: 'Billing', permissions: [] } ] } ), getClientBillingInfo );
|
|
15
|
+
brandsBillingRouter.get( '/bulk-download-billing-groups', isAllowedSessionHandler, accessVerification( { userType: [ 'tango' ], access: [ { featureName: 'TangoAdmin', name: 'EditBilling', permissions: [ 'isEdit' ] } ] } ), bulkDownloadBillingGroups );
|
|
16
|
+
brandsBillingRouter.post( '/bulk-update-billing-groups', isAllowedSessionHandler, accessVerification( { userType: [ 'tango' ], access: [ { featureName: 'TangoAdmin', name: 'EditBilling', permissions: [ 'isEdit' ] } ] } ), bulkUpdateBillingGroups );
|
|
17
|
+
brandsBillingRouter.post( '/billingSummary', isAllowedSessionHandler, accessVerification( { userType: [ 'tango' ], access: [ { featureName: 'TangoAdmin', name: 'EditBilling', permissions: [] } ] } ), billingSummary );
|
|
18
|
+
brandsBillingRouter.get( '/billingSummary', isAllowedSessionHandler, accessVerification( { userType: [ 'tango' ], access: [ { featureName: 'TangoAdmin', name: 'EditBilling', permissions: [] } ] } ), billingSummary );
|
|
19
|
+
brandsBillingRouter.get( '/additionalProducts', isAllowedSessionHandler, accessVerification( { userType: [ 'tango' ], access: [ { featureName: 'TangoAdmin', name: 'EditBilling', permissions: [] } ] } ), additionalProducts );
|
|
20
|
+
brandsBillingRouter.get( '/additionalProductExport', isAllowedSessionHandler, accessVerification( { userType: [ 'tango' ], access: [ { featureName: 'TangoAdmin', name: 'EditBilling', permissions: [] } ] } ), additionalProductExport );
|
|
21
|
+
brandsBillingRouter.get( '/fxRates', isAllowedSessionHandler, accessVerification( { userType: [ 'tango', 'client' ], access: [ { featureName: 'TangoAdmin', name: 'EditBilling', permissions: [] } ] } ), fxRates );
|
|
@@ -3,7 +3,7 @@ import { createInvoice, invoiceDownload, invoiceDownloadBulk, clientInvoiceList,
|
|
|
3
3
|
import { isAllowedSessionHandler, accessVerification, validate } from 'tango-app-api-middleware';
|
|
4
4
|
import { getInvoiceHeads, updateInvoiceHeads } from '../controllers/applicationDefault.controllers.js';
|
|
5
5
|
import { uploadBankStatement, bankTransactionList, resolveOptions, resolveBankTransaction } from '../controllers/bankTransaction.controller.js';
|
|
6
|
-
import { estimateList, createEstimate, updateEstimate, getEstimate,
|
|
6
|
+
import { estimateList, createEstimate, updateEstimate, getEstimate, deleteEstimate, downloadEstimate, approveEstimateCsm, approveEstimateFinance, approveEstimateApproval } from '../controllers/estimate.controller.js';
|
|
7
7
|
import { updateInvoiceHeadsSchema } from '../dtos/validation.dtos.js';
|
|
8
8
|
export const invoiceRouter = express.Router();
|
|
9
9
|
|
|
@@ -58,9 +58,10 @@ invoiceRouter.post( '/bankStatement/resolve', isAllowedSessionHandler, superadmi
|
|
|
58
58
|
// lifecycle. List/get are read; create/status/delete need edit rights.
|
|
59
59
|
invoiceRouter.post( '/estimate/list', isAllowedSessionHandler, superadminBypass( { userType: [ 'tango' ], access: [ { featureName: 'TangoAdmin', name: 'invoiceApproval', permissions: [] } ] } ), estimateList );
|
|
60
60
|
invoiceRouter.post( '/estimate/create', isAllowedSessionHandler, superadminBypass( { userType: [ 'tango' ], access: [ { featureName: 'TangoAdmin', name: 'invoiceApproval', permissions: [ 'isEdit' ] } ] } ), createEstimate );
|
|
61
|
+
invoiceRouter.post( '/estimate/approveCsm', isAllowedSessionHandler, superadminBypass( { userType: [ 'tango' ], access: [ { featureName: 'TangoAdmin', name: 'csmApproval', permissions: [ 'isEdit' ] } ] } ), approveEstimateCsm );
|
|
62
|
+
invoiceRouter.post( '/estimate/approveFinance', isAllowedSessionHandler, superadminBypass( { userType: [ 'tango' ], access: [ { featureName: 'TangoAdmin', name: 'financeApproval', permissions: [ 'isEdit' ] } ] } ), approveEstimateFinance );
|
|
63
|
+
invoiceRouter.post( '/estimate/approveApproval', isAllowedSessionHandler, superadminBypass( { userType: [ 'tango' ], access: [ { featureName: 'TangoAdmin', name: 'invoiceApproval', permissions: [ 'isEdit' ] } ] } ), approveEstimateApproval );
|
|
61
64
|
invoiceRouter.post( '/estimate/update', isAllowedSessionHandler, superadminBypass( { userType: [ 'tango' ], access: [ { featureName: 'TangoAdmin', name: 'invoiceApproval', permissions: [ 'isEdit' ] } ] } ), updateEstimate );
|
|
62
|
-
invoiceRouter.post( '/estimate/send', isAllowedSessionHandler, superadminBypass( { userType: [ 'tango' ], access: [ { featureName: 'TangoAdmin', name: 'invoiceApproval', permissions: [ 'isEdit' ] } ] } ), sendEstimate );
|
|
63
65
|
invoiceRouter.post( '/estimate/download/:estimateId', isAllowedSessionHandler, superadminBypass( { userType: [ 'tango' ], access: [ { featureName: 'TangoAdmin', name: 'invoiceApproval', permissions: [] } ] } ), downloadEstimate );
|
|
64
66
|
invoiceRouter.get( '/estimate/:estimateId', isAllowedSessionHandler, superadminBypass( { userType: [ 'tango' ], access: [ { featureName: 'TangoAdmin', name: 'invoiceApproval', permissions: [] } ] } ), getEstimate );
|
|
65
|
-
invoiceRouter.post( '/estimate/statusUpdate', isAllowedSessionHandler, superadminBypass( { userType: [ 'tango' ], access: [ { featureName: 'TangoAdmin', name: 'invoiceApproval', permissions: [ 'isEdit' ] } ] } ), estimateStatusUpdate );
|
|
66
67
|
invoiceRouter.delete( '/estimate/:estimateId', isAllowedSessionHandler, superadminBypass( { userType: [ 'tango' ], access: [ { featureName: 'TangoAdmin', name: 'invoiceApproval', permissions: [ 'isEdit' ] } ] } ), deleteEstimate );
|