tango-app-api-payment-subscription 3.4.4 → 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/docs/invoice-approval-pipeline.md +44 -0
- package/package.json +7 -2
- package/scripts/grant-tango-approval-permissions.js +84 -0
- package/scripts/migrate-invoice-status-pipeline.js +61 -0
- package/src/controllers/applicationDefault.controllers.js +51 -0
- package/src/controllers/billing.controllers.js +2 -1
- package/src/controllers/brandsBilling.controller.js +562 -4
- package/src/controllers/invoice.controller.js +469 -9
- package/src/controllers/payment.controller.js +4 -3
- package/src/controllers/paymentSubscription.controllers.js +23 -9
- package/src/dtos/validation.dtos.js +55 -0
- package/src/hbs/invoicePdf.hbs +8 -0
- package/src/routes/brandsBilling.routes.js +17 -1
- package/src/routes/invoice.routes.js +29 -7
- package/src/services/applicationDefault.service.js +13 -0
- package/src/utils/currency.js +14 -0
|
@@ -2,8 +2,12 @@ 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';
|
|
8
|
+
import * as XLSX from 'xlsx';
|
|
9
|
+
import ExcelJS from 'exceljs';
|
|
10
|
+
import { bulkUpdateBillingGroupRowSchema } from '../dtos/validation.dtos.js';
|
|
7
11
|
|
|
8
12
|
export async function brandsBillingList( req, res ) {
|
|
9
13
|
try {
|
|
@@ -227,7 +231,10 @@ export async function brandInvoiceList( req, res ) {
|
|
|
227
231
|
},
|
|
228
232
|
} ];
|
|
229
233
|
|
|
230
|
-
|
|
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 !== '' ) {
|
|
231
238
|
let dateFrom;
|
|
232
239
|
const now = dayjs();
|
|
233
240
|
if ( req.body.durationFilter === 'current' ) {
|
|
@@ -244,6 +251,30 @@ export async function brandInvoiceList( req, res ) {
|
|
|
244
251
|
}
|
|
245
252
|
}
|
|
246
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
|
+
|
|
247
278
|
if ( req.body.paymentStatus && req.body.paymentStatus.length > 0 ) {
|
|
248
279
|
query.push( {
|
|
249
280
|
$match: {
|
|
@@ -269,13 +300,23 @@ export async function brandInvoiceList( req, res ) {
|
|
|
269
300
|
{
|
|
270
301
|
$match: {
|
|
271
302
|
$expr: {
|
|
272
|
-
$eq: [
|
|
303
|
+
$eq: [
|
|
304
|
+
'$_id',
|
|
305
|
+
{
|
|
306
|
+
$cond: [
|
|
307
|
+
{ $eq: [ { $type: '$$groupId' }, 'objectId' ] },
|
|
308
|
+
'$$groupId',
|
|
309
|
+
{ $convert: { input: '$$groupId', to: 'objectId', onError: null, onNull: null } },
|
|
310
|
+
],
|
|
311
|
+
},
|
|
312
|
+
],
|
|
273
313
|
},
|
|
274
314
|
},
|
|
275
315
|
},
|
|
276
316
|
{
|
|
277
317
|
$project: {
|
|
278
318
|
groupName: 1,
|
|
319
|
+
registeredCompanyName: 1,
|
|
279
320
|
stores: { $size: { $ifNull: [ '$stores', [] ] } },
|
|
280
321
|
},
|
|
281
322
|
},
|
|
@@ -303,6 +344,7 @@ export async function brandInvoiceList( req, res ) {
|
|
|
303
344
|
dueDate: 1,
|
|
304
345
|
products: 1,
|
|
305
346
|
billingGroupStores: '$billingGroup.stores',
|
|
347
|
+
registeredCompanyName: '$billingGroup.registeredCompanyName',
|
|
306
348
|
},
|
|
307
349
|
},
|
|
308
350
|
);
|
|
@@ -335,7 +377,7 @@ export async function brandInvoiceList( req, res ) {
|
|
|
335
377
|
let summary = {
|
|
336
378
|
totalInvoices: allInvoices.length,
|
|
337
379
|
totalInvoiced: allInvoices.reduce( ( sum, inv ) => sum + ( inv.totalAmount || 0 ), 0 ),
|
|
338
|
-
pendingApproval: allInvoices.filter( ( inv ) =>
|
|
380
|
+
pendingApproval: allInvoices.filter( ( inv ) => [ 'pendingCsm', 'pendingFinance', 'pendingApproval' ].includes( inv.status ) ).length,
|
|
339
381
|
pendingPayment: allInvoices.filter( ( inv ) => inv.paymentStatus === 'unpaid' && inv.status === 'approved' ).length,
|
|
340
382
|
paid: allInvoices.filter( ( inv ) => inv.paymentStatus === 'paid' ).length,
|
|
341
383
|
};
|
|
@@ -350,7 +392,7 @@ export async function brandInvoiceList( req, res ) {
|
|
|
350
392
|
'Generated': dayjs( element.billingDate ).format( 'DD MMM YYYY' ),
|
|
351
393
|
'No of Stores': element.stores,
|
|
352
394
|
'Amount': element.totalAmount,
|
|
353
|
-
'Status': element.status
|
|
395
|
+
'Status': [ 'pendingCsm', 'pendingFinance', 'pendingApproval' ].includes( element.status ) ? 'Pending Approval' : element.paymentStatus === 'unpaid' ? 'Pending Payment' : 'Paid',
|
|
354
396
|
} );
|
|
355
397
|
} );
|
|
356
398
|
await download( exportdata, res );
|
|
@@ -706,3 +748,519 @@ export async function getClientBillingInfo( req, res ) {
|
|
|
706
748
|
return res.sendError( error, 500 );
|
|
707
749
|
}
|
|
708
750
|
}
|
|
751
|
+
|
|
752
|
+
const COLUMN_HEADERS = [
|
|
753
|
+
'Group Name',
|
|
754
|
+
'Group Tag',
|
|
755
|
+
'Registered Company Name',
|
|
756
|
+
'GST',
|
|
757
|
+
'Address Line 1',
|
|
758
|
+
'Address Line 2',
|
|
759
|
+
'City',
|
|
760
|
+
'State',
|
|
761
|
+
'Country',
|
|
762
|
+
'Pin Code',
|
|
763
|
+
'Place Of Supply (read-only)',
|
|
764
|
+
'PO #',
|
|
765
|
+
'Pro-Rata',
|
|
766
|
+
'Payment Category',
|
|
767
|
+
'Currency',
|
|
768
|
+
'Payment Cycle',
|
|
769
|
+
'Payment Term (days)',
|
|
770
|
+
'Installation Fee',
|
|
771
|
+
'Is Installation One-Time',
|
|
772
|
+
'Attach Annexure',
|
|
773
|
+
'Advance Invoice',
|
|
774
|
+
'Store ID',
|
|
775
|
+
'Store Name (read-only)',
|
|
776
|
+
];
|
|
777
|
+
|
|
778
|
+
const COLUMN_WIDTHS = [
|
|
779
|
+
22, 12, 28, 18, 22, 22, 16, 16, 16, 12, 22, 14, 14, 18, 12, 16, 18, 16, 22, 18, 16, 16, 28,
|
|
780
|
+
];
|
|
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
|
+
|
|
833
|
+
function boolToYesNo( v ) {
|
|
834
|
+
return v === true ? 'Yes' : 'No';
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
function yesNoToBool( v ) {
|
|
838
|
+
if ( typeof v === 'boolean' ) return v;
|
|
839
|
+
if ( v === undefined || v === null || v === '' ) return undefined;
|
|
840
|
+
const s = String( v ).trim().toLowerCase();
|
|
841
|
+
if ( [ 'yes', 'y', 'true', '1' ].includes( s ) ) return true;
|
|
842
|
+
if ( [ 'no', 'n', 'false', '0' ].includes( s ) ) return false;
|
|
843
|
+
return undefined;
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
function trimOrEmpty( v ) {
|
|
847
|
+
if ( v === undefined || v === null ) return '';
|
|
848
|
+
return String( v ).trim();
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
function numberOrUndefined( v ) {
|
|
852
|
+
if ( v === undefined || v === null || v === '' ) return undefined;
|
|
853
|
+
const n = Number( v );
|
|
854
|
+
return Number.isFinite( n ) ? n : NaN;
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
function normalizeRow( rawRow ) {
|
|
858
|
+
const gst = trimOrEmpty( rawRow['GST'] );
|
|
859
|
+
return {
|
|
860
|
+
groupName: trimOrEmpty( rawRow['Group Name'] ),
|
|
861
|
+
groupTag: trimOrEmpty( rawRow['Group Tag'] ),
|
|
862
|
+
registeredCompanyName: trimOrEmpty( rawRow['Registered Company Name'] ),
|
|
863
|
+
gst,
|
|
864
|
+
addressLineOne: trimOrEmpty( rawRow['Address Line 1'] ),
|
|
865
|
+
addressLineTwo: trimOrEmpty( rawRow['Address Line 2'] ),
|
|
866
|
+
city: trimOrEmpty( rawRow['City'] ),
|
|
867
|
+
state: trimOrEmpty( rawRow['State'] ),
|
|
868
|
+
country: trimOrEmpty( rawRow['Country'] ),
|
|
869
|
+
pinCode: trimOrEmpty( rawRow['Pin Code'] ),
|
|
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 ),
|
|
873
|
+
po: trimOrEmpty( rawRow['PO #'] ),
|
|
874
|
+
proRata: trimOrEmpty( rawRow['Pro-Rata'] ),
|
|
875
|
+
paymentCategory: trimOrEmpty( rawRow['Payment Category'] ),
|
|
876
|
+
currency: trimOrEmpty( rawRow['Currency'] ),
|
|
877
|
+
paymentCycle: trimOrEmpty( rawRow['Payment Cycle'] ),
|
|
878
|
+
paymentTerm: numberOrUndefined( rawRow['Payment Term (days)'] ),
|
|
879
|
+
installationFee: numberOrUndefined( rawRow['Installation Fee'] ),
|
|
880
|
+
isInstallationOneTime: yesNoToBool( rawRow['Is Installation One-Time'] ),
|
|
881
|
+
attachAnnexure: yesNoToBool( rawRow['Attach Annexure'] ),
|
|
882
|
+
advanceInvoice: yesNoToBool( rawRow['Advance Invoice'] ),
|
|
883
|
+
storeId: trimOrEmpty( rawRow['Store ID'] ),
|
|
884
|
+
};
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
export async function bulkDownloadBillingGroups( req, res ) {
|
|
888
|
+
try {
|
|
889
|
+
const clientId = req.query?.clientId;
|
|
890
|
+
if ( !clientId ) {
|
|
891
|
+
return res.sendError( 'clientId query parameter is required', 400 );
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
const groups = await billingService.aggregatebilling( [
|
|
895
|
+
{ $match: { clientId } },
|
|
896
|
+
{ $lookup: {
|
|
897
|
+
from: 'clientpayments',
|
|
898
|
+
localField: 'clientId',
|
|
899
|
+
foreignField: 'clientId',
|
|
900
|
+
as: '_client',
|
|
901
|
+
} },
|
|
902
|
+
{ $unwind: { path: '$_client', preserveNullAndEmptyArrays: true } },
|
|
903
|
+
{ $sort: { groupName: 1 } },
|
|
904
|
+
] );
|
|
905
|
+
|
|
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
|
+
}
|
|
954
|
+
|
|
955
|
+
// Build the workbook with ExcelJS so cell protection is actually written
|
|
956
|
+
// to the file (the community xlsx build strips per-cell styles on write).
|
|
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 ] );
|
|
960
|
+
const wb = new ExcelJS.Workbook();
|
|
961
|
+
const ws = wb.addWorksheet( 'Billing Groups' );
|
|
962
|
+
ws.columns = COLUMN_HEADERS.map( ( header, i ) => ( {
|
|
963
|
+
header,
|
|
964
|
+
key: `c${i}`,
|
|
965
|
+
width: COLUMN_WIDTHS[i],
|
|
966
|
+
} ) );
|
|
967
|
+
// Header row: bold, locked (cosmetic — protection blocks edits anyway).
|
|
968
|
+
ws.getRow( 1 ).font = { bold: true };
|
|
969
|
+
// Add data rows.
|
|
970
|
+
for ( const r of rows ) {
|
|
971
|
+
const values = {};
|
|
972
|
+
COLUMN_HEADERS.forEach( ( h, i ) => {
|
|
973
|
+
values[`c${i}`] = r[h];
|
|
974
|
+
} );
|
|
975
|
+
ws.addRow( values );
|
|
976
|
+
}
|
|
977
|
+
// Set per-cell protection on every cell (header + data).
|
|
978
|
+
// Note: ExcelJS defaults `locked: true`; we explicitly unlock editable cells.
|
|
979
|
+
const lastRow = ws.lastRow ? ws.lastRow.number : 1;
|
|
980
|
+
for ( let r = 1; r <= lastRow; r++ ) {
|
|
981
|
+
for ( let c = 1; c <= COLUMN_HEADERS.length; c++ ) {
|
|
982
|
+
const cell = ws.getCell( r, c );
|
|
983
|
+
cell.protection = { locked: READ_ONLY_COLS.has( c - 1 ) };
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
// Enable sheet protection with no password. Users can click
|
|
987
|
+
// Review → Unprotect Sheet to override (no password prompt).
|
|
988
|
+
await ws.protect( '', {
|
|
989
|
+
selectLockedCells: true,
|
|
990
|
+
selectUnlockedCells: true,
|
|
991
|
+
formatCells: false,
|
|
992
|
+
formatColumns: false,
|
|
993
|
+
formatRows: false,
|
|
994
|
+
insertColumns: false,
|
|
995
|
+
insertRows: false,
|
|
996
|
+
insertHyperlinks: false,
|
|
997
|
+
deleteColumns: false,
|
|
998
|
+
deleteRows: false,
|
|
999
|
+
sort: false,
|
|
1000
|
+
autoFilter: false,
|
|
1001
|
+
pivotTables: false,
|
|
1002
|
+
} );
|
|
1003
|
+
const buf = await wb.xlsx.writeBuffer();
|
|
1004
|
+
|
|
1005
|
+
const filename = `billing-groups-${dayjs().format( 'YYYY-MM-DD' )}.xlsx`;
|
|
1006
|
+
res.set( 'Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' );
|
|
1007
|
+
res.set( 'Content-Disposition', `attachment; filename="${filename}"` );
|
|
1008
|
+
return res.send( buf );
|
|
1009
|
+
} catch ( error ) {
|
|
1010
|
+
logger.error( { error: error, function: 'bulkDownloadBillingGroups' } );
|
|
1011
|
+
return res.sendError( error, 500 );
|
|
1012
|
+
}
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
export async function bulkUpdateBillingGroups( req, res ) {
|
|
1016
|
+
try {
|
|
1017
|
+
if ( !req.file?.buffer ) {
|
|
1018
|
+
return res.sendError( 'No file uploaded', 400 );
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
const clientId = req.body?.clientId;
|
|
1022
|
+
if ( !clientId ) {
|
|
1023
|
+
return res.sendError( 'clientId form field is required', 400 );
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
let rawRows;
|
|
1027
|
+
try {
|
|
1028
|
+
const wb = XLSX.read( req.file.buffer, { type: 'buffer' } );
|
|
1029
|
+
const sheet = wb.Sheets[wb.SheetNames[0]];
|
|
1030
|
+
rawRows = XLSX.utils.sheet_to_json( sheet, { defval: '' } );
|
|
1031
|
+
} catch ( parseErr ) {
|
|
1032
|
+
logger.error( { error: parseErr, function: 'bulkUpdateBillingGroups.parse' } );
|
|
1033
|
+
return res.sendError( 'Failed to parse XLSX file', 400 );
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
if ( !Array.isArray( rawRows ) || rawRows.length === 0 ) {
|
|
1037
|
+
return res.sendError( 'Spreadsheet has no data rows', 400 );
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
// First pass: normalize + Joi-validate every row, collecting errors.
|
|
1041
|
+
const normalized = [];
|
|
1042
|
+
const errors = [];
|
|
1043
|
+
for ( let i = 0; i < rawRows.length; i++ ) {
|
|
1044
|
+
const rowNumber = i + 2; // header is row 1
|
|
1045
|
+
const raw = rawRows[i];
|
|
1046
|
+
const row = normalizeRow( raw );
|
|
1047
|
+
const rowErrors = [];
|
|
1048
|
+
|
|
1049
|
+
// Coercion sanity (e.g. "abc" in a number column came back as NaN)
|
|
1050
|
+
if ( Number.isNaN( row.paymentTerm ) ) {
|
|
1051
|
+
rowErrors.push( 'Payment Term (days) must be a number' );
|
|
1052
|
+
row.paymentTerm = undefined;
|
|
1053
|
+
}
|
|
1054
|
+
if ( Number.isNaN( row.installationFee ) ) {
|
|
1055
|
+
rowErrors.push( 'Installation Fee must be a number' );
|
|
1056
|
+
row.installationFee = undefined;
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
// Boolean cells that the user filled in but we couldn't parse → error (not silent skip).
|
|
1060
|
+
const rawIsOneTime = trimOrEmpty( raw['Is Installation One-Time'] );
|
|
1061
|
+
if ( rawIsOneTime !== '' && row.isInstallationOneTime === undefined ) {
|
|
1062
|
+
rowErrors.push( 'Is Installation One-Time must be Yes or No' );
|
|
1063
|
+
}
|
|
1064
|
+
const rawAttachAnnexure = trimOrEmpty( raw['Attach Annexure'] );
|
|
1065
|
+
if ( rawAttachAnnexure !== '' && row.attachAnnexure === undefined ) {
|
|
1066
|
+
rowErrors.push( 'Attach Annexure must be Yes or No' );
|
|
1067
|
+
}
|
|
1068
|
+
const rawAdvanceInvoice = trimOrEmpty( raw['Advance Invoice'] );
|
|
1069
|
+
if ( rawAdvanceInvoice !== '' && row.advanceInvoice === undefined ) {
|
|
1070
|
+
rowErrors.push( 'Advance Invoice must be Yes or No' );
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
const { error: joiError } = bulkUpdateBillingGroupRowSchema.validate( row, { abortEarly: false, stripUnknown: true } );
|
|
1074
|
+
if ( joiError ) {
|
|
1075
|
+
for ( const d of joiError.details ) rowErrors.push( d.message );
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
if ( rowErrors.length > 0 ) {
|
|
1079
|
+
errors.push( {
|
|
1080
|
+
rowNumber,
|
|
1081
|
+
groupName: row.groupName,
|
|
1082
|
+
errors: rowErrors,
|
|
1083
|
+
} );
|
|
1084
|
+
normalized.push( null );
|
|
1085
|
+
continue;
|
|
1086
|
+
}
|
|
1087
|
+
normalized.push( row );
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
// Second pass: resolve each unique Group Name to a DB _id within this brand.
|
|
1091
|
+
const groupNamesToCheck = Array.from( new Set( normalized
|
|
1092
|
+
.filter( ( r ) => r !== null )
|
|
1093
|
+
.map( ( r ) => r.groupName ) ) );
|
|
1094
|
+
const foundDocs = groupNamesToCheck.length > 0 ?
|
|
1095
|
+
await billingService.find( { groupName: { $in: groupNamesToCheck }, clientId } ) :
|
|
1096
|
+
[];
|
|
1097
|
+
const idByGroupName = new Map( foundDocs.map( ( d ) => [ d.groupName, String( d._id ) ] ) );
|
|
1098
|
+
for ( let i = 0; i < normalized.length; i++ ) {
|
|
1099
|
+
const row = normalized[i];
|
|
1100
|
+
if ( !row ) continue;
|
|
1101
|
+
if ( !idByGroupName.has( row.groupName ) ) {
|
|
1102
|
+
errors.push( {
|
|
1103
|
+
rowNumber: i + 2,
|
|
1104
|
+
groupName: row.groupName,
|
|
1105
|
+
errors: [ `Billing group "${row.groupName}" not found for this brand` ],
|
|
1106
|
+
} );
|
|
1107
|
+
normalized[i] = null;
|
|
1108
|
+
}
|
|
1109
|
+
}
|
|
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
|
+
|
|
1205
|
+
if ( errors.length > 0 ) {
|
|
1206
|
+
errors.sort( ( a, b ) => a.rowNumber - b.rowNumber );
|
|
1207
|
+
return res.sendError(
|
|
1208
|
+
{ summary: { total: rawRows.length, failed: errors.length }, errors },
|
|
1209
|
+
422,
|
|
1210
|
+
);
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
const openSearchActivityLog = JSON.parse( process.env.OPENSEARCH ).activityLog;
|
|
1214
|
+
|
|
1215
|
+
// Seventh pass: serial DB writes — one per billing group.
|
|
1216
|
+
let updated = 0;
|
|
1217
|
+
let aborted = null;
|
|
1218
|
+
for ( const entry of groupedByName.values() ) {
|
|
1219
|
+
try {
|
|
1220
|
+
await billingService.updateOne( { _id: entry._id }, { ...entry.payload, stores: entry.storeIds } );
|
|
1221
|
+
updated++;
|
|
1222
|
+
insertOpenSearchData( openSearchActivityLog, {
|
|
1223
|
+
userName: req.user?.userName,
|
|
1224
|
+
email: req.user?.email,
|
|
1225
|
+
clientId,
|
|
1226
|
+
logSubType: 'billingGroupUpdate',
|
|
1227
|
+
logType: 'billing',
|
|
1228
|
+
date: new Date(),
|
|
1229
|
+
changes: [ `Billing group "${entry.groupName}" updated via bulk upload (stores=${entry.storeIds.length})` ],
|
|
1230
|
+
eventType: '',
|
|
1231
|
+
timestamp: new Date(),
|
|
1232
|
+
showTo: [ 'tango' ],
|
|
1233
|
+
} );
|
|
1234
|
+
} catch ( 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 ) };
|
|
1237
|
+
break;
|
|
1238
|
+
}
|
|
1239
|
+
}
|
|
1240
|
+
|
|
1241
|
+
insertOpenSearchData( openSearchActivityLog, {
|
|
1242
|
+
userName: req.user?.userName,
|
|
1243
|
+
email: req.user?.email,
|
|
1244
|
+
clientId: '',
|
|
1245
|
+
logSubType: 'billingGroupBulkUpdate',
|
|
1246
|
+
logType: 'billing',
|
|
1247
|
+
date: new Date(),
|
|
1248
|
+
changes: [ `Bulk billing-group update: ${updated} of ${groupedByName.size} groups updated by ${req.user?.email}` ],
|
|
1249
|
+
eventType: '',
|
|
1250
|
+
timestamp: new Date(),
|
|
1251
|
+
showTo: [ 'tango' ],
|
|
1252
|
+
} );
|
|
1253
|
+
|
|
1254
|
+
if ( aborted ) {
|
|
1255
|
+
return res.sendError(
|
|
1256
|
+
{ summary: { total: groupedByName.size, updated, remaining: groupedByName.size - updated }, aborted },
|
|
1257
|
+
207,
|
|
1258
|
+
);
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1261
|
+
return res.sendSuccess( { summary: { total: groupedByName.size, updated } } );
|
|
1262
|
+
} catch ( error ) {
|
|
1263
|
+
logger.error( { error: error, function: 'bulkUpdateBillingGroups' } );
|
|
1264
|
+
return res.sendError( error, 500 );
|
|
1265
|
+
}
|
|
1266
|
+
}
|