tango-app-api-payment-subscription 3.5.0 → 3.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json
CHANGED
|
@@ -2,6 +2,7 @@ import * as clientService from '../services/clientPayment.services.js';
|
|
|
2
2
|
import * as invoiceService from '../services/invoice.service.js';
|
|
3
3
|
import * as billingService from '../services/billing.service.js';
|
|
4
4
|
import * as dailyPriceService from '../services/dailyPrice.service.js';
|
|
5
|
+
import * as storeService from '../services/store.service.js';
|
|
5
6
|
import dayjs from 'dayjs';
|
|
6
7
|
import { logger, checkFileExist, signedUrl, download, insertOpenSearchData } from 'tango-app-api-middleware';
|
|
7
8
|
import * as XLSX from 'xlsx';
|
|
@@ -230,7 +231,10 @@ export async function brandInvoiceList( req, res ) {
|
|
|
230
231
|
},
|
|
231
232
|
} ];
|
|
232
233
|
|
|
233
|
-
|
|
234
|
+
// If the user picked an explicit Month or Year, ignore durationFilter so the
|
|
235
|
+
// two ranges don't conflict (e.g. "current month" + April would always 0).
|
|
236
|
+
const hasMonthYear = ( req.body.monthFilter && String( req.body.monthFilter ) !== '' ) || ( req.body.yearFilter && String( req.body.yearFilter ) !== '' );
|
|
237
|
+
if ( !hasMonthYear && req.body.durationFilter && req.body.durationFilter !== '' ) {
|
|
234
238
|
let dateFrom;
|
|
235
239
|
const now = dayjs();
|
|
236
240
|
if ( req.body.durationFilter === 'current' ) {
|
|
@@ -247,6 +251,30 @@ export async function brandInvoiceList( req, res ) {
|
|
|
247
251
|
}
|
|
248
252
|
}
|
|
249
253
|
|
|
254
|
+
// Month / Year filters (independent of durationFilter). Both are 1-based:
|
|
255
|
+
// monthFilter '1'..'12', yearFilter four-digit string like '2026'.
|
|
256
|
+
// billingDate may be stored as Date OR as a string in some legacy rows, so
|
|
257
|
+
// coerce to date inside the pipeline before $year/$month.
|
|
258
|
+
const monthNum = req.body.monthFilter ? parseInt( req.body.monthFilter, 10 ) : null;
|
|
259
|
+
const yearNum = req.body.yearFilter ? parseInt( req.body.yearFilter, 10 ) : null;
|
|
260
|
+
if ( ( monthNum && monthNum >= 1 && monthNum <= 12 ) || yearNum ) {
|
|
261
|
+
const billingDateExpr = {
|
|
262
|
+
$cond: [
|
|
263
|
+
{ $eq: [ { $type: '$billingDate' }, 'date' ] },
|
|
264
|
+
'$billingDate',
|
|
265
|
+
{ $toDate: '$billingDate' },
|
|
266
|
+
],
|
|
267
|
+
};
|
|
268
|
+
const expr = { $and: [] };
|
|
269
|
+
if ( yearNum ) {
|
|
270
|
+
expr.$and.push( { $eq: [ { $year: billingDateExpr }, yearNum ] } );
|
|
271
|
+
}
|
|
272
|
+
if ( monthNum && monthNum >= 1 && monthNum <= 12 ) {
|
|
273
|
+
expr.$and.push( { $eq: [ { $month: billingDateExpr }, monthNum ] } );
|
|
274
|
+
}
|
|
275
|
+
query.push( { $match: { $expr: expr } } );
|
|
276
|
+
}
|
|
277
|
+
|
|
250
278
|
if ( req.body.paymentStatus && req.body.paymentStatus.length > 0 ) {
|
|
251
279
|
query.push( {
|
|
252
280
|
$match: {
|
|
@@ -722,9 +750,6 @@ export async function getClientBillingInfo( req, res ) {
|
|
|
722
750
|
}
|
|
723
751
|
|
|
724
752
|
const COLUMN_HEADERS = [
|
|
725
|
-
'_id (read-only)',
|
|
726
|
-
'Client ID (read-only)',
|
|
727
|
-
'Brand (read-only)',
|
|
728
753
|
'Group Name',
|
|
729
754
|
'Group Tag',
|
|
730
755
|
'Registered Company Name',
|
|
@@ -735,7 +760,7 @@ const COLUMN_HEADERS = [
|
|
|
735
760
|
'State',
|
|
736
761
|
'Country',
|
|
737
762
|
'Pin Code',
|
|
738
|
-
'Place Of Supply',
|
|
763
|
+
'Place Of Supply (read-only)',
|
|
739
764
|
'PO #',
|
|
740
765
|
'Pro-Rata',
|
|
741
766
|
'Payment Category',
|
|
@@ -746,15 +771,65 @@ const COLUMN_HEADERS = [
|
|
|
746
771
|
'Is Installation One-Time',
|
|
747
772
|
'Attach Annexure',
|
|
748
773
|
'Advance Invoice',
|
|
749
|
-
'
|
|
750
|
-
'
|
|
751
|
-
'Invoice Receivers (count, read-only)',
|
|
774
|
+
'Store ID',
|
|
775
|
+
'Store Name (read-only)',
|
|
752
776
|
];
|
|
753
777
|
|
|
754
778
|
const COLUMN_WIDTHS = [
|
|
755
|
-
|
|
779
|
+
22, 12, 28, 18, 22, 22, 16, 16, 16, 12, 22, 14, 14, 18, 12, 16, 18, 16, 22, 18, 16, 16, 28,
|
|
756
780
|
];
|
|
757
781
|
|
|
782
|
+
// GSTIN state-code prefix → Place of Supply (mirrors the UI map).
|
|
783
|
+
const GST_STATE_CODE_MAP = {
|
|
784
|
+
'01': 'Jammu and Kashmir',
|
|
785
|
+
'02': 'Himachal Pradesh',
|
|
786
|
+
'03': 'Punjab',
|
|
787
|
+
'04': 'Chandigarh',
|
|
788
|
+
'05': 'Uttarakhand',
|
|
789
|
+
'06': 'Haryana',
|
|
790
|
+
'07': 'Delhi',
|
|
791
|
+
'08': 'Rajasthan',
|
|
792
|
+
'09': 'Uttar Pradesh',
|
|
793
|
+
'10': 'Bihar',
|
|
794
|
+
'11': 'Sikkim',
|
|
795
|
+
'12': 'Arunachal Pradesh',
|
|
796
|
+
'13': 'Nagaland',
|
|
797
|
+
'14': 'Manipur',
|
|
798
|
+
'15': 'Mizoram',
|
|
799
|
+
'16': 'Tripura',
|
|
800
|
+
'17': 'Meghalaya',
|
|
801
|
+
'18': 'Assam',
|
|
802
|
+
'19': 'West Bengal',
|
|
803
|
+
'20': 'Jharkhand',
|
|
804
|
+
'21': 'Odisha',
|
|
805
|
+
'22': 'Chhattisgarh',
|
|
806
|
+
'23': 'Madhya Pradesh',
|
|
807
|
+
'24': 'Gujarat',
|
|
808
|
+
'25': 'Daman and Diu',
|
|
809
|
+
'26': 'Dadra and Nagar Haveli and Daman and Diu',
|
|
810
|
+
'27': 'Maharashtra',
|
|
811
|
+
'28': 'Andhra Pradesh (Old)',
|
|
812
|
+
'29': 'Karnataka',
|
|
813
|
+
'30': 'Goa',
|
|
814
|
+
'31': 'Lakshadweep',
|
|
815
|
+
'32': 'Kerala',
|
|
816
|
+
'33': 'Tamil Nadu',
|
|
817
|
+
'34': 'Puducherry',
|
|
818
|
+
'35': 'Andaman and Nicobar Islands',
|
|
819
|
+
'36': 'Telangana',
|
|
820
|
+
'37': 'Andhra Pradesh',
|
|
821
|
+
'38': 'Ladakh',
|
|
822
|
+
'97': 'Other Territory',
|
|
823
|
+
'99': 'Centre Jurisdiction',
|
|
824
|
+
};
|
|
825
|
+
|
|
826
|
+
function placeOfSupplyFromGst( gst ) {
|
|
827
|
+
if ( !gst ) return '';
|
|
828
|
+
const code = String( gst ).trim().toUpperCase().substring( 0, 2 );
|
|
829
|
+
const state = GST_STATE_CODE_MAP[code];
|
|
830
|
+
return state ? `${state} (${code})` : '';
|
|
831
|
+
}
|
|
832
|
+
|
|
758
833
|
function boolToYesNo( v ) {
|
|
759
834
|
return v === true ? 'Yes' : 'No';
|
|
760
835
|
}
|
|
@@ -780,19 +855,21 @@ function numberOrUndefined( v ) {
|
|
|
780
855
|
}
|
|
781
856
|
|
|
782
857
|
function normalizeRow( rawRow ) {
|
|
858
|
+
const gst = trimOrEmpty( rawRow['GST'] );
|
|
783
859
|
return {
|
|
784
|
-
_id: trimOrEmpty( rawRow['_id (read-only)'] ),
|
|
785
860
|
groupName: trimOrEmpty( rawRow['Group Name'] ),
|
|
786
861
|
groupTag: trimOrEmpty( rawRow['Group Tag'] ),
|
|
787
862
|
registeredCompanyName: trimOrEmpty( rawRow['Registered Company Name'] ),
|
|
788
|
-
gst
|
|
863
|
+
gst,
|
|
789
864
|
addressLineOne: trimOrEmpty( rawRow['Address Line 1'] ),
|
|
790
865
|
addressLineTwo: trimOrEmpty( rawRow['Address Line 2'] ),
|
|
791
866
|
city: trimOrEmpty( rawRow['City'] ),
|
|
792
867
|
state: trimOrEmpty( rawRow['State'] ),
|
|
793
868
|
country: trimOrEmpty( rawRow['Country'] ),
|
|
794
869
|
pinCode: trimOrEmpty( rawRow['Pin Code'] ),
|
|
795
|
-
|
|
870
|
+
// Place of Supply is derived from the GST prefix on the server, ignoring
|
|
871
|
+
// whatever the cell holds (the column is read-only in the template).
|
|
872
|
+
placeOfSupply: placeOfSupplyFromGst( gst ),
|
|
796
873
|
po: trimOrEmpty( rawRow['PO #'] ),
|
|
797
874
|
proRata: trimOrEmpty( rawRow['Pro-Rata'] ),
|
|
798
875
|
paymentCategory: trimOrEmpty( rawRow['Payment Category'] ),
|
|
@@ -803,6 +880,7 @@ function normalizeRow( rawRow ) {
|
|
|
803
880
|
isInstallationOneTime: yesNoToBool( rawRow['Is Installation One-Time'] ),
|
|
804
881
|
attachAnnexure: yesNoToBool( rawRow['Attach Annexure'] ),
|
|
805
882
|
advanceInvoice: yesNoToBool( rawRow['Advance Invoice'] ),
|
|
883
|
+
storeId: trimOrEmpty( rawRow['Store ID'] ),
|
|
806
884
|
};
|
|
807
885
|
}
|
|
808
886
|
|
|
@@ -825,42 +903,60 @@ export async function bulkDownloadBillingGroups( req, res ) {
|
|
|
825
903
|
{ $sort: { groupName: 1 } },
|
|
826
904
|
] );
|
|
827
905
|
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
906
|
+
// Build storeId → storeName lookup for this brand so we can show the name
|
|
907
|
+
// alongside the (editable) store id in the template.
|
|
908
|
+
const allStoreIds = new Set();
|
|
909
|
+
groups.forEach( ( g ) => {
|
|
910
|
+
if ( Array.isArray( g.stores ) ) g.stores.forEach( ( sid ) => allStoreIds.add( String( sid ) ) );
|
|
911
|
+
} );
|
|
912
|
+
const storeDocs = allStoreIds.size > 0 ?
|
|
913
|
+
await storeService.find( { clientId, storeId: { $in: Array.from( allStoreIds ) } }, { storeId: 1, storeName: 1 } ) :
|
|
914
|
+
[];
|
|
915
|
+
const storeNameById = new Map( storeDocs.map( ( s ) => [ String( s.storeId ), s.storeName || '' ] ) );
|
|
916
|
+
|
|
917
|
+
// Emit one row per store; group columns repeat. Groups with no stores
|
|
918
|
+
// still produce a single row (with empty store fields) so the user can
|
|
919
|
+
// see the group and add stores by editing the Store ID cell.
|
|
920
|
+
const rows = [];
|
|
921
|
+
for ( const g of groups ) {
|
|
922
|
+
const groupBase = {
|
|
923
|
+
[COLUMN_HEADERS[0]]: g.groupName || '',
|
|
924
|
+
[COLUMN_HEADERS[1]]: g.groupTag || '',
|
|
925
|
+
[COLUMN_HEADERS[2]]: g.registeredCompanyName || '',
|
|
926
|
+
[COLUMN_HEADERS[3]]: g.gst || '',
|
|
927
|
+
[COLUMN_HEADERS[4]]: g.addressLineOne || '',
|
|
928
|
+
[COLUMN_HEADERS[5]]: g.addressLineTwo || '',
|
|
929
|
+
[COLUMN_HEADERS[6]]: g.city || '',
|
|
930
|
+
[COLUMN_HEADERS[7]]: g.state || '',
|
|
931
|
+
[COLUMN_HEADERS[8]]: g.country || '',
|
|
932
|
+
[COLUMN_HEADERS[9]]: g.pinCode || '',
|
|
933
|
+
[COLUMN_HEADERS[10]]: g.placeOfSupply || placeOfSupplyFromGst( g.gst ),
|
|
934
|
+
[COLUMN_HEADERS[11]]: g.po || '',
|
|
935
|
+
[COLUMN_HEADERS[12]]: g.proRata || '',
|
|
936
|
+
[COLUMN_HEADERS[13]]: g.paymentCategory || '',
|
|
937
|
+
[COLUMN_HEADERS[14]]: g.currency || '',
|
|
938
|
+
[COLUMN_HEADERS[15]]: g.paymentCycle || '',
|
|
939
|
+
[COLUMN_HEADERS[16]]: g.paymentTerm ?? '',
|
|
940
|
+
[COLUMN_HEADERS[17]]: g.installationFee ?? '',
|
|
941
|
+
[COLUMN_HEADERS[18]]: boolToYesNo( g.isInstallationOneTime ),
|
|
942
|
+
[COLUMN_HEADERS[19]]: boolToYesNo( g.attachAnnexure ),
|
|
943
|
+
[COLUMN_HEADERS[20]]: boolToYesNo( g.advanceInvoice ),
|
|
944
|
+
};
|
|
945
|
+
const storeIds = Array.isArray( g.stores ) && g.stores.length > 0 ? g.stores : [ '' ];
|
|
946
|
+
for ( const sid of storeIds ) {
|
|
947
|
+
rows.push( {
|
|
948
|
+
...groupBase,
|
|
949
|
+
[COLUMN_HEADERS[21]]: sid || '',
|
|
950
|
+
[COLUMN_HEADERS[22]]: sid ? ( storeNameById.get( String( sid ) ) || '' ) : '',
|
|
951
|
+
} );
|
|
952
|
+
}
|
|
953
|
+
}
|
|
857
954
|
|
|
858
955
|
// Build the workbook with ExcelJS so cell protection is actually written
|
|
859
956
|
// to the file (the community xlsx build strips per-cell styles on write).
|
|
860
|
-
// Read-only columns by index:
|
|
861
|
-
//
|
|
862
|
-
|
|
863
|
-
const READ_ONLY_COLS = new Set( [ 0, 1, 2, 24, 25, 26 ] );
|
|
957
|
+
// Read-only columns by index: 10 (Place Of Supply — derived from GST),
|
|
958
|
+
// 22 (Store Name).
|
|
959
|
+
const READ_ONLY_COLS = new Set( [ 10, 22 ] );
|
|
864
960
|
const wb = new ExcelJS.Workbook();
|
|
865
961
|
const ws = wb.addWorksheet( 'Billing Groups' );
|
|
866
962
|
ws.columns = COLUMN_HEADERS.map( ( header, i ) => ( {
|
|
@@ -961,25 +1057,19 @@ export async function bulkUpdateBillingGroups( req, res ) {
|
|
|
961
1057
|
}
|
|
962
1058
|
|
|
963
1059
|
// Boolean cells that the user filled in but we couldn't parse → error (not silent skip).
|
|
964
|
-
const rawIsOneTime = trimOrEmpty( raw[
|
|
1060
|
+
const rawIsOneTime = trimOrEmpty( raw['Is Installation One-Time'] );
|
|
965
1061
|
if ( rawIsOneTime !== '' && row.isInstallationOneTime === undefined ) {
|
|
966
1062
|
rowErrors.push( 'Is Installation One-Time must be Yes or No' );
|
|
967
1063
|
}
|
|
968
|
-
const rawAttachAnnexure = trimOrEmpty( raw[
|
|
1064
|
+
const rawAttachAnnexure = trimOrEmpty( raw['Attach Annexure'] );
|
|
969
1065
|
if ( rawAttachAnnexure !== '' && row.attachAnnexure === undefined ) {
|
|
970
1066
|
rowErrors.push( 'Attach Annexure must be Yes or No' );
|
|
971
1067
|
}
|
|
972
|
-
const rawAdvanceInvoice = trimOrEmpty( raw[
|
|
1068
|
+
const rawAdvanceInvoice = trimOrEmpty( raw['Advance Invoice'] );
|
|
973
1069
|
if ( rawAdvanceInvoice !== '' && row.advanceInvoice === undefined ) {
|
|
974
1070
|
rowErrors.push( 'Advance Invoice must be Yes or No' );
|
|
975
1071
|
}
|
|
976
1072
|
|
|
977
|
-
// Cross-brand guard: every row must belong to the brand the user is on.
|
|
978
|
-
const rowClientId = trimOrEmpty( raw[COLUMN_HEADERS[1]] );
|
|
979
|
-
if ( rowClientId !== '' && rowClientId !== clientId ) {
|
|
980
|
-
rowErrors.push( `Row belongs to client ${rowClientId}, not ${clientId}. Upload only rows for the current brand.` );
|
|
981
|
-
}
|
|
982
|
-
|
|
983
1073
|
const { error: joiError } = bulkUpdateBillingGroupRowSchema.validate( row, { abortEarly: false, stripUnknown: true } );
|
|
984
1074
|
if ( joiError ) {
|
|
985
1075
|
for ( const d of joiError.details ) rowErrors.push( d.message );
|
|
@@ -988,8 +1078,7 @@ export async function bulkUpdateBillingGroups( req, res ) {
|
|
|
988
1078
|
if ( rowErrors.length > 0 ) {
|
|
989
1079
|
errors.push( {
|
|
990
1080
|
rowNumber,
|
|
991
|
-
|
|
992
|
-
brand: raw[COLUMN_HEADERS[2]] || '',
|
|
1081
|
+
groupName: row.groupName,
|
|
993
1082
|
errors: rowErrors,
|
|
994
1083
|
} );
|
|
995
1084
|
normalized.push( null );
|
|
@@ -998,28 +1087,121 @@ export async function bulkUpdateBillingGroups( req, res ) {
|
|
|
998
1087
|
normalized.push( row );
|
|
999
1088
|
}
|
|
1000
1089
|
|
|
1001
|
-
// Second pass:
|
|
1002
|
-
const
|
|
1090
|
+
// Second pass: resolve each unique Group Name to a DB _id within this brand.
|
|
1091
|
+
const groupNamesToCheck = Array.from( new Set( normalized
|
|
1003
1092
|
.filter( ( r ) => r !== null )
|
|
1004
|
-
.map( ( r ) => r.
|
|
1005
|
-
const foundDocs =
|
|
1006
|
-
await billingService.find( {
|
|
1093
|
+
.map( ( r ) => r.groupName ) ) );
|
|
1094
|
+
const foundDocs = groupNamesToCheck.length > 0 ?
|
|
1095
|
+
await billingService.find( { groupName: { $in: groupNamesToCheck }, clientId } ) :
|
|
1007
1096
|
[];
|
|
1008
|
-
const
|
|
1097
|
+
const idByGroupName = new Map( foundDocs.map( ( d ) => [ d.groupName, String( d._id ) ] ) );
|
|
1009
1098
|
for ( let i = 0; i < normalized.length; i++ ) {
|
|
1010
1099
|
const row = normalized[i];
|
|
1011
1100
|
if ( !row ) continue;
|
|
1012
|
-
if ( !
|
|
1101
|
+
if ( !idByGroupName.has( row.groupName ) ) {
|
|
1013
1102
|
errors.push( {
|
|
1014
1103
|
rowNumber: i + 2,
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
errors: [ 'Billing group not found' ],
|
|
1104
|
+
groupName: row.groupName,
|
|
1105
|
+
errors: [ `Billing group "${row.groupName}" not found for this brand` ],
|
|
1018
1106
|
} );
|
|
1019
1107
|
normalized[i] = null;
|
|
1020
1108
|
}
|
|
1021
1109
|
}
|
|
1022
1110
|
|
|
1111
|
+
// Third pass: aggregate rows by Group Name. Each group's stores[] is the
|
|
1112
|
+
// union of the Store ID cells across all of its rows. Group-level fields
|
|
1113
|
+
// are taken from the FIRST row for that group.
|
|
1114
|
+
const groupedByName = new Map(); // groupName → { _id, firstRowIndex, payload, storeIds: [] }
|
|
1115
|
+
for ( let i = 0; i < normalized.length; i++ ) {
|
|
1116
|
+
const row = normalized[i];
|
|
1117
|
+
if ( !row ) continue;
|
|
1118
|
+
const key = row.groupName;
|
|
1119
|
+
// eslint-disable-next-line no-unused-vars
|
|
1120
|
+
const { storeId, ...groupFields } = row;
|
|
1121
|
+
if ( !groupedByName.has( key ) ) {
|
|
1122
|
+
groupedByName.set( key, {
|
|
1123
|
+
_id: idByGroupName.get( key ),
|
|
1124
|
+
groupName: key,
|
|
1125
|
+
firstRowIndex: i,
|
|
1126
|
+
payload: groupFields,
|
|
1127
|
+
storeIds: [],
|
|
1128
|
+
storeIdSet: new Set(),
|
|
1129
|
+
} );
|
|
1130
|
+
}
|
|
1131
|
+
const entry = groupedByName.get( key );
|
|
1132
|
+
if ( storeId ) {
|
|
1133
|
+
if ( entry.storeIdSet.has( storeId ) ) {
|
|
1134
|
+
errors.push( {
|
|
1135
|
+
rowNumber: i + 2,
|
|
1136
|
+
groupName: key,
|
|
1137
|
+
errors: [ `Store ID ${storeId} is listed more than once for this billing group` ],
|
|
1138
|
+
} );
|
|
1139
|
+
continue;
|
|
1140
|
+
}
|
|
1141
|
+
entry.storeIdSet.add( storeId );
|
|
1142
|
+
entry.storeIds.push( storeId );
|
|
1143
|
+
}
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
// Fourth pass: cross-group uniqueness — same Store ID may NOT appear in
|
|
1147
|
+
// two different groups in the upload.
|
|
1148
|
+
const storeIdToGroup = new Map(); // storeId → groupName
|
|
1149
|
+
for ( const entry of groupedByName.values() ) {
|
|
1150
|
+
for ( const sid of entry.storeIds ) {
|
|
1151
|
+
if ( storeIdToGroup.has( sid ) && storeIdToGroup.get( sid ) !== entry.groupName ) {
|
|
1152
|
+
errors.push( {
|
|
1153
|
+
rowNumber: entry.firstRowIndex + 2,
|
|
1154
|
+
groupName: entry.groupName,
|
|
1155
|
+
errors: [ `Store ID ${sid} is assigned to another billing group in this upload ("${storeIdToGroup.get( sid )}")` ],
|
|
1156
|
+
} );
|
|
1157
|
+
} else {
|
|
1158
|
+
storeIdToGroup.set( sid, entry.groupName );
|
|
1159
|
+
}
|
|
1160
|
+
}
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
// Fifth pass: store existence — every Store ID must exist for this clientId.
|
|
1164
|
+
const allStoreIds = Array.from( storeIdToGroup.keys() );
|
|
1165
|
+
const existingStoreDocs = allStoreIds.length > 0 ?
|
|
1166
|
+
await storeService.find( { clientId, storeId: { $in: allStoreIds } }, { storeId: 1 } ) :
|
|
1167
|
+
[];
|
|
1168
|
+
const existingStoreIds = new Set( existingStoreDocs.map( ( s ) => String( s.storeId ) ) );
|
|
1169
|
+
for ( const entry of groupedByName.values() ) {
|
|
1170
|
+
const missing = entry.storeIds.filter( ( sid ) => !existingStoreIds.has( String( sid ) ) );
|
|
1171
|
+
if ( missing.length > 0 ) {
|
|
1172
|
+
errors.push( {
|
|
1173
|
+
rowNumber: entry.firstRowIndex + 2,
|
|
1174
|
+
groupName: entry.groupName,
|
|
1175
|
+
errors: [ `Store ID${missing.length > 1 ? 's' : ''} ${missing.join( ', ' )} not found for this brand` ],
|
|
1176
|
+
} );
|
|
1177
|
+
}
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1180
|
+
// Sixth pass: cross-group conflict with OTHER existing groups in the DB.
|
|
1181
|
+
// A store already assigned to a different billing group cannot be claimed
|
|
1182
|
+
// by this upload.
|
|
1183
|
+
const idsInUpload = Array.from( groupedByName.values() ).map( ( e ) => e._id );
|
|
1184
|
+
if ( allStoreIds.length > 0 ) {
|
|
1185
|
+
const conflictDocs = await billingService.find(
|
|
1186
|
+
{ clientId, stores: { $in: allStoreIds }, _id: { $nin: idsInUpload } },
|
|
1187
|
+
{ _id: 1, groupName: 1, stores: 1 },
|
|
1188
|
+
);
|
|
1189
|
+
for ( const doc of conflictDocs ) {
|
|
1190
|
+
const conflictIds = ( doc.stores || [] ).filter( ( sid ) => allStoreIds.includes( String( sid ) ) );
|
|
1191
|
+
for ( const sid of conflictIds ) {
|
|
1192
|
+
const claimingGroupName = storeIdToGroup.get( String( sid ) );
|
|
1193
|
+
const claimingEntry = groupedByName.get( claimingGroupName );
|
|
1194
|
+
if ( claimingEntry ) {
|
|
1195
|
+
errors.push( {
|
|
1196
|
+
rowNumber: claimingEntry.firstRowIndex + 2,
|
|
1197
|
+
groupName: claimingEntry.groupName,
|
|
1198
|
+
errors: [ `Store ID ${sid} is already assigned to billing group "${doc.groupName}"` ],
|
|
1199
|
+
} );
|
|
1200
|
+
}
|
|
1201
|
+
}
|
|
1202
|
+
}
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1023
1205
|
if ( errors.length > 0 ) {
|
|
1024
1206
|
errors.sort( ( a, b ) => a.rowNumber - b.rowNumber );
|
|
1025
1207
|
return res.sendError(
|
|
@@ -1030,31 +1212,28 @@ export async function bulkUpdateBillingGroups( req, res ) {
|
|
|
1030
1212
|
|
|
1031
1213
|
const openSearchActivityLog = JSON.parse( process.env.OPENSEARCH ).activityLog;
|
|
1032
1214
|
|
|
1033
|
-
//
|
|
1215
|
+
// Seventh pass: serial DB writes — one per billing group.
|
|
1034
1216
|
let updated = 0;
|
|
1035
1217
|
let aborted = null;
|
|
1036
|
-
for (
|
|
1037
|
-
const row = normalized[i];
|
|
1038
|
-
// eslint-disable-next-line no-unused-vars
|
|
1039
|
-
const { _id, ...payload } = row;
|
|
1218
|
+
for ( const entry of groupedByName.values() ) {
|
|
1040
1219
|
try {
|
|
1041
|
-
await billingService.updateOne( { _id }, payload );
|
|
1220
|
+
await billingService.updateOne( { _id: entry._id }, { ...entry.payload, stores: entry.storeIds } );
|
|
1042
1221
|
updated++;
|
|
1043
1222
|
insertOpenSearchData( openSearchActivityLog, {
|
|
1044
1223
|
userName: req.user?.userName,
|
|
1045
1224
|
email: req.user?.email,
|
|
1046
|
-
clientId
|
|
1225
|
+
clientId,
|
|
1047
1226
|
logSubType: 'billingGroupUpdate',
|
|
1048
1227
|
logType: 'billing',
|
|
1049
1228
|
date: new Date(),
|
|
1050
|
-
changes: [ `Billing group ${
|
|
1229
|
+
changes: [ `Billing group "${entry.groupName}" updated via bulk upload (stores=${entry.storeIds.length})` ],
|
|
1051
1230
|
eventType: '',
|
|
1052
1231
|
timestamp: new Date(),
|
|
1053
1232
|
showTo: [ 'tango' ],
|
|
1054
1233
|
} );
|
|
1055
1234
|
} catch ( writeErr ) {
|
|
1056
|
-
logger.error( { error: writeErr, function: 'bulkUpdateBillingGroups.write',
|
|
1057
|
-
aborted = { rowNumber:
|
|
1235
|
+
logger.error( { error: writeErr, function: 'bulkUpdateBillingGroups.write', groupName: entry.groupName } );
|
|
1236
|
+
aborted = { rowNumber: entry.firstRowIndex + 2, groupName: entry.groupName, message: writeErr?.message || String( writeErr ) };
|
|
1058
1237
|
break;
|
|
1059
1238
|
}
|
|
1060
1239
|
}
|
|
@@ -1066,7 +1245,7 @@ export async function bulkUpdateBillingGroups( req, res ) {
|
|
|
1066
1245
|
logSubType: 'billingGroupBulkUpdate',
|
|
1067
1246
|
logType: 'billing',
|
|
1068
1247
|
date: new Date(),
|
|
1069
|
-
changes: [ `Bulk billing-group update: ${updated} of ${
|
|
1248
|
+
changes: [ `Bulk billing-group update: ${updated} of ${groupedByName.size} groups updated by ${req.user?.email}` ],
|
|
1070
1249
|
eventType: '',
|
|
1071
1250
|
timestamp: new Date(),
|
|
1072
1251
|
showTo: [ 'tango' ],
|
|
@@ -1074,12 +1253,12 @@ export async function bulkUpdateBillingGroups( req, res ) {
|
|
|
1074
1253
|
|
|
1075
1254
|
if ( aborted ) {
|
|
1076
1255
|
return res.sendError(
|
|
1077
|
-
{ summary: { total:
|
|
1256
|
+
{ summary: { total: groupedByName.size, updated, remaining: groupedByName.size - updated }, aborted },
|
|
1078
1257
|
207,
|
|
1079
1258
|
);
|
|
1080
1259
|
}
|
|
1081
1260
|
|
|
1082
|
-
return res.sendSuccess( { summary: { total:
|
|
1261
|
+
return res.sendSuccess( { summary: { total: groupedByName.size, updated } } );
|
|
1083
1262
|
} catch ( error ) {
|
|
1084
1263
|
logger.error( { error: error, function: 'bulkUpdateBillingGroups' } );
|
|
1085
1264
|
return res.sendError( error, 500 );
|
|
@@ -2210,6 +2210,20 @@ export async function deleteInvoice( req, res ) {
|
|
|
2210
2210
|
|
|
2211
2211
|
await invoiceService.deleteRecord( { _id: invoiceId } );
|
|
2212
2212
|
|
|
2213
|
+
const logObj = {
|
|
2214
|
+
userName: req.user?.userName,
|
|
2215
|
+
email: req.user?.email,
|
|
2216
|
+
clientId: invoice.clientId,
|
|
2217
|
+
logSubType: 'invoiceDeleted',
|
|
2218
|
+
logType: 'invoice',
|
|
2219
|
+
date: new Date(),
|
|
2220
|
+
changes: [ `Invoice ${invoice.invoice} has been deleted by ${req.user?.email}` ],
|
|
2221
|
+
eventType: 'delete',
|
|
2222
|
+
timestamp: new Date(),
|
|
2223
|
+
showTo: [ 'tango' ],
|
|
2224
|
+
};
|
|
2225
|
+
insertOpenSearchData( JSON.parse( process.env.OPENSEARCH ).activityLog, logObj );
|
|
2226
|
+
|
|
2213
2227
|
res.sendSuccess( { message: 'Invoice deleted successfully' } );
|
|
2214
2228
|
} catch ( error ) {
|
|
2215
2229
|
logger.error( { error: error, function: 'deleteInvoice' } );
|
|
@@ -372,7 +372,6 @@ export const updateBillingGroupsSchema = {
|
|
|
372
372
|
};
|
|
373
373
|
|
|
374
374
|
export const bulkUpdateBillingGroupRowSchema = joi.object( {
|
|
375
|
-
_id: joi.string().length( 24 ).hex().required(),
|
|
376
375
|
groupName: joi.string().required(),
|
|
377
376
|
groupTag: joi.string().required(),
|
|
378
377
|
registeredCompanyName: joi.string().required(),
|
|
@@ -394,6 +393,7 @@ export const bulkUpdateBillingGroupRowSchema = joi.object( {
|
|
|
394
393
|
isInstallationOneTime: joi.boolean().optional(),
|
|
395
394
|
attachAnnexure: joi.boolean().optional(),
|
|
396
395
|
advanceInvoice: joi.boolean().optional(),
|
|
396
|
+
storeId: joi.string().allow( '' ).optional(),
|
|
397
397
|
} );
|
|
398
398
|
|
|
399
399
|
export const deleteBillingGroupQuery = {
|
|
@@ -5,6 +5,19 @@ import { getInvoiceHeads, updateInvoiceHeads } from '../controllers/applicationD
|
|
|
5
5
|
import { updateInvoiceHeadsSchema } from '../dtos/validation.dtos.js';
|
|
6
6
|
export const invoiceRouter = express.Router();
|
|
7
7
|
|
|
8
|
+
// Superadmin bypass: if the authenticated user has role 'superadmin', skip the
|
|
9
|
+
// granular access check that follows. Place between isAllowedSessionHandler and
|
|
10
|
+
// accessVerification so req.user is populated.
|
|
11
|
+
function superadminBypass( accessConfig ) {
|
|
12
|
+
const verify = accessVerification( accessConfig );
|
|
13
|
+
return function( req, res, next ) {
|
|
14
|
+
if ( req.user && req.user.role === 'superadmin' ) {
|
|
15
|
+
return next();
|
|
16
|
+
}
|
|
17
|
+
return verify( req, res, next );
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
8
21
|
|
|
9
22
|
invoiceRouter.post( '/createInvoice', isAllowedSessionHandler, createInvoice );
|
|
10
23
|
invoiceRouter.post( '/regerateInvoice', isAllowedSessionHandler, createInvoice );
|
|
@@ -13,19 +26,19 @@ invoiceRouter.post( '/invoiceDownload/:invoiceId', isAllowedSessionHandler, invo
|
|
|
13
26
|
invoiceRouter.post( '/clientInvoiceList', isAllowedSessionHandler, accessVerification( { userType: [ 'tango' ], access: [ { featureName: 'TangoAdmin', name: 'invoiceApproval', permissions: [] } ] } ), clientInvoiceList );
|
|
14
27
|
invoiceRouter.post( '/creditTransactionlist', isAllowedSessionHandler, accessVerification( { userType: [ 'tango' ], access: [ { featureName: 'TangoAdmin', name: 'invoiceApproval', permissions: [] } ] } ), creditTransactionlist );
|
|
15
28
|
invoiceRouter.post( '/pendingInvoices', isAllowedSessionHandler, pendingInvoices );
|
|
16
|
-
invoiceRouter.post( '/applyDiscount', isAllowedSessionHandler,
|
|
29
|
+
invoiceRouter.post( '/applyDiscount', isAllowedSessionHandler, superadminBypass( { userType: [ 'tango' ], access: [ { featureName: 'TangoAdmin', name: 'invoiceApproval', permissions: [ 'isEdit' ] } ] } ), applyDiscount );
|
|
17
30
|
invoiceRouter.post( '/migrateInvoice', migrateInvoice );
|
|
18
|
-
invoiceRouter.post( '/PaymentStatusChange', isAllowedSessionHandler,
|
|
31
|
+
invoiceRouter.post( '/PaymentStatusChange', isAllowedSessionHandler, superadminBypass( { userType: [ 'tango' ], access: [ { featureName: 'TangoAdmin', name: 'invoiceApproval', permissions: [ 'isEdit' ] } ] } ), PaymentStatusChange );
|
|
19
32
|
invoiceRouter.post( '/checkPaymentStatus', checkPaymentStatus );
|
|
20
|
-
invoiceRouter.get( '/getInvoice/:invoiceId', isAllowedSessionHandler,
|
|
21
|
-
invoiceRouter.get( '/invoiceAnnexure/:invoiceId', isAllowedSessionHandler,
|
|
22
|
-
invoiceRouter.put( '/updateInvoice', isAllowedSessionHandler,
|
|
33
|
+
invoiceRouter.get( '/getInvoice/:invoiceId', isAllowedSessionHandler, superadminBypass( { userType: [ 'tango' ], access: [ { featureName: 'TangoAdmin', name: 'invoiceApproval', permissions: [] } ] } ), getInvoice );
|
|
34
|
+
invoiceRouter.get( '/invoiceAnnexure/:invoiceId', isAllowedSessionHandler, superadminBypass( { userType: [ 'tango' ], access: [ { featureName: 'TangoAdmin', name: 'invoiceApproval', permissions: [] } ] } ), invoiceAnnexure );
|
|
35
|
+
invoiceRouter.put( '/updateInvoice', isAllowedSessionHandler, superadminBypass( { userType: [ 'tango' ], access: [ { featureName: 'TangoAdmin', name: 'invoiceApproval', permissions: [ 'isEdit' ] } ] } ), updateInvoice );
|
|
23
36
|
invoiceRouter.get( '/getClientBasePricing/:clientId', isAllowedSessionHandler, getClientBasePricing );
|
|
24
|
-
invoiceRouter.delete( '/deleteInvoice/:invoiceId', isAllowedSessionHandler,
|
|
37
|
+
invoiceRouter.delete( '/deleteInvoice/:invoiceId', isAllowedSessionHandler, superadminBypass( { userType: [ 'tango' ], access: [ { featureName: 'TangoAdmin', name: 'invoiceApproval', permissions: [ 'isEdit' ] } ] } ), deleteInvoice );
|
|
25
38
|
|
|
26
|
-
invoiceRouter.post( '/approveInvoiceCsm', isAllowedSessionHandler,
|
|
27
|
-
invoiceRouter.post( '/approveInvoiceFinance', isAllowedSessionHandler,
|
|
28
|
-
invoiceRouter.post( '/approveInvoiceApproval', isAllowedSessionHandler,
|
|
39
|
+
invoiceRouter.post( '/approveInvoiceCsm', isAllowedSessionHandler, superadminBypass( { userType: [ 'tango' ], access: [ { featureName: 'TangoAdmin', name: 'csmApproval', permissions: [ 'isEdit' ] } ] } ), approveInvoiceCsm );
|
|
40
|
+
invoiceRouter.post( '/approveInvoiceFinance', isAllowedSessionHandler, superadminBypass( { userType: [ 'tango' ], access: [ { featureName: 'TangoAdmin', name: 'financeApproval', permissions: [ 'isEdit' ] } ] } ), approveInvoiceFinance );
|
|
41
|
+
invoiceRouter.post( '/approveInvoiceApproval', isAllowedSessionHandler, superadminBypass( { userType: [ 'tango' ], access: [ { featureName: 'TangoAdmin', name: 'invoiceApproval', permissions: [ 'isEdit' ] } ] } ), approveInvoiceApproval );
|
|
29
42
|
|
|
30
43
|
invoiceRouter.get( '/getInvoiceHeads', isAllowedSessionHandler, getInvoiceHeads );
|
|
31
44
|
invoiceRouter.post( '/updateInvoiceHeads', isAllowedSessionHandler, validate( updateInvoiceHeadsSchema ), updateInvoiceHeads );
|