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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tango-app-api-payment-subscription",
3
- "version": "3.5.0",
3
+ "version": "3.5.1",
4
4
  "description": "paymentSubscription",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -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
- if ( req.body.durationFilter && req.body.durationFilter !== '' ) {
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
- 'Is Primary (read-only)',
750
- 'Stores (count, read-only)',
751
- 'Invoice Receivers (count, read-only)',
774
+ 'Store ID',
775
+ 'Store Name (read-only)',
752
776
  ];
753
777
 
754
778
  const COLUMN_WIDTHS = [
755
- 28, 14, 24, 22, 12, 28, 18, 22, 22, 16, 16, 16, 12, 20, 14, 14, 18, 12, 16, 18, 16, 22, 18, 16, 18, 20, 28,
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: trimOrEmpty( rawRow['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
- placeOfSupply: trimOrEmpty( rawRow['Place Of Supply'] ),
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
- const rows = groups.map( ( g ) => ( {
829
- [COLUMN_HEADERS[0]]: String( g._id || '' ),
830
- [COLUMN_HEADERS[1]]: g.clientId || '',
831
- [COLUMN_HEADERS[2]]: g._client?.clientName || '',
832
- [COLUMN_HEADERS[3]]: g.groupName || '',
833
- [COLUMN_HEADERS[4]]: g.groupTag || '',
834
- [COLUMN_HEADERS[5]]: g.registeredCompanyName || '',
835
- [COLUMN_HEADERS[6]]: g.gst || '',
836
- [COLUMN_HEADERS[7]]: g.addressLineOne || '',
837
- [COLUMN_HEADERS[8]]: g.addressLineTwo || '',
838
- [COLUMN_HEADERS[9]]: g.city || '',
839
- [COLUMN_HEADERS[10]]: g.state || '',
840
- [COLUMN_HEADERS[11]]: g.country || '',
841
- [COLUMN_HEADERS[12]]: g.pinCode || '',
842
- [COLUMN_HEADERS[13]]: g.placeOfSupply || '',
843
- [COLUMN_HEADERS[14]]: g.po || '',
844
- [COLUMN_HEADERS[15]]: g.proRata || '',
845
- [COLUMN_HEADERS[16]]: g.paymentCategory || '',
846
- [COLUMN_HEADERS[17]]: g.currency || '',
847
- [COLUMN_HEADERS[18]]: g.paymentCycle || '',
848
- [COLUMN_HEADERS[19]]: g.paymentTerm ?? '',
849
- [COLUMN_HEADERS[20]]: g.installationFee ?? '',
850
- [COLUMN_HEADERS[21]]: boolToYesNo( g.isInstallationOneTime ),
851
- [COLUMN_HEADERS[22]]: boolToYesNo( g.attachAnnexure ),
852
- [COLUMN_HEADERS[23]]: boolToYesNo( g.advanceInvoice ),
853
- [COLUMN_HEADERS[24]]: boolToYesNo( g.isPrimary ),
854
- [COLUMN_HEADERS[25]]: Array.isArray( g.stores ) ? g.stores.length : 0,
855
- [COLUMN_HEADERS[26]]: Array.isArray( g.generateInvoiceTo ) ? g.generateInvoiceTo.length : 0,
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: 0 (_id), 1 (Client ID), 2 (Brand),
861
- // 24 (Is Primary), 25 (Stores count),
862
- // 26 (Invoice Receivers count).
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[COLUMN_HEADERS[21]] );
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[COLUMN_HEADERS[22]] );
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[COLUMN_HEADERS[23]] );
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
- _id: row._id,
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: verify each _id exists in the DB AND belongs to clientId.
1002
- const idsToCheck = normalized
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._id );
1005
- const foundDocs = idsToCheck.length > 0 ?
1006
- await billingService.find( { _id: { $in: idsToCheck }, clientId } ) :
1093
+ .map( ( r ) => r.groupName ) ) );
1094
+ const foundDocs = groupNamesToCheck.length > 0 ?
1095
+ await billingService.find( { groupName: { $in: groupNamesToCheck }, clientId } ) :
1007
1096
  [];
1008
- const foundIds = new Set( foundDocs.map( ( d ) => String( d._id ) ) );
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 ( !foundIds.has( row._id ) ) {
1101
+ if ( !idByGroupName.has( row.groupName ) ) {
1013
1102
  errors.push( {
1014
1103
  rowNumber: i + 2,
1015
- _id: row._id,
1016
- brand: rawRows[i][COLUMN_HEADERS[2]] || '',
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
- // Third pass: serial DB writes.
1215
+ // Seventh pass: serial DB writes — one per billing group.
1034
1216
  let updated = 0;
1035
1217
  let aborted = null;
1036
- for ( let i = 0; i < normalized.length; i++ ) {
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: rawRows[i][COLUMN_HEADERS[1]] || '',
1225
+ clientId,
1047
1226
  logSubType: 'billingGroupUpdate',
1048
1227
  logType: 'billing',
1049
1228
  date: new Date(),
1050
- changes: [ `Billing group ${_id} updated via bulk upload` ],
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', rowNumber: i + 2, _id } );
1057
- aborted = { rowNumber: i + 2, _id, message: writeErr?.message || String( writeErr ) };
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 ${normalized.length} groups updated by ${req.user?.email}` ],
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: normalized.length, updated, remaining: normalized.length - updated }, aborted },
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: normalized.length, updated } } );
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, accessVerification( { userType: [ 'tango' ], access: [ { featureName: 'TangoAdmin', name: 'invoiceApproval', permissions: [ 'isEdit' ] } ] } ), applyDiscount );
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, accessVerification( { userType: [ 'tango' ], access: [ { featureName: 'TangoAdmin', name: 'invoiceApproval', permissions: [ 'isEdit' ] } ] } ), PaymentStatusChange );
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, accessVerification( { userType: [ 'tango' ], access: [ { featureName: 'TangoAdmin', name: 'invoiceApproval', permissions: [] } ] } ), getInvoice );
21
- invoiceRouter.get( '/invoiceAnnexure/:invoiceId', isAllowedSessionHandler, accessVerification( { userType: [ 'tango' ], access: [ { featureName: 'TangoAdmin', name: 'invoiceApproval', permissions: [] } ] } ), invoiceAnnexure );
22
- invoiceRouter.put( '/updateInvoice', isAllowedSessionHandler, accessVerification( { userType: [ 'tango' ], access: [ { featureName: 'TangoAdmin', name: 'invoiceApproval', permissions: [ 'isEdit' ] } ] } ), updateInvoice );
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, accessVerification( { userType: [ 'tango' ], access: [ { featureName: 'TangoAdmin', name: 'invoiceApproval', permissions: [ 'isEdit' ] } ] } ), deleteInvoice );
37
+ invoiceRouter.delete( '/deleteInvoice/:invoiceId', isAllowedSessionHandler, superadminBypass( { userType: [ 'tango' ], access: [ { featureName: 'TangoAdmin', name: 'invoiceApproval', permissions: [ 'isEdit' ] } ] } ), deleteInvoice );
25
38
 
26
- invoiceRouter.post( '/approveInvoiceCsm', isAllowedSessionHandler, accessVerification( { userType: [ 'tango' ], access: [ { featureName: 'TangoAdmin', name: 'csmApproval', permissions: [ 'isEdit' ] } ] } ), approveInvoiceCsm );
27
- invoiceRouter.post( '/approveInvoiceFinance', isAllowedSessionHandler, accessVerification( { userType: [ 'tango' ], access: [ { featureName: 'TangoAdmin', name: 'financeApproval', permissions: [ 'isEdit' ] } ] } ), approveInvoiceFinance );
28
- invoiceRouter.post( '/approveInvoiceApproval', isAllowedSessionHandler, accessVerification( { userType: [ 'tango' ], access: [ { featureName: 'TangoAdmin', name: 'invoiceApproval', permissions: [ 'isEdit' ] } ] } ), approveInvoiceApproval );
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 );