tango-app-api-payment-subscription 3.4.4 → 3.5.0

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.
@@ -4,6 +4,9 @@ import * as billingService from '../services/billing.service.js';
4
4
  import * as dailyPriceService from '../services/dailyPrice.service.js';
5
5
  import dayjs from 'dayjs';
6
6
  import { logger, checkFileExist, signedUrl, download, insertOpenSearchData } from 'tango-app-api-middleware';
7
+ import * as XLSX from 'xlsx';
8
+ import ExcelJS from 'exceljs';
9
+ import { bulkUpdateBillingGroupRowSchema } from '../dtos/validation.dtos.js';
7
10
 
8
11
  export async function brandsBillingList( req, res ) {
9
12
  try {
@@ -269,13 +272,23 @@ export async function brandInvoiceList( req, res ) {
269
272
  {
270
273
  $match: {
271
274
  $expr: {
272
- $eq: [ '$_id', '$$groupId' ],
275
+ $eq: [
276
+ '$_id',
277
+ {
278
+ $cond: [
279
+ { $eq: [ { $type: '$$groupId' }, 'objectId' ] },
280
+ '$$groupId',
281
+ { $convert: { input: '$$groupId', to: 'objectId', onError: null, onNull: null } },
282
+ ],
283
+ },
284
+ ],
273
285
  },
274
286
  },
275
287
  },
276
288
  {
277
289
  $project: {
278
290
  groupName: 1,
291
+ registeredCompanyName: 1,
279
292
  stores: { $size: { $ifNull: [ '$stores', [] ] } },
280
293
  },
281
294
  },
@@ -303,6 +316,7 @@ export async function brandInvoiceList( req, res ) {
303
316
  dueDate: 1,
304
317
  products: 1,
305
318
  billingGroupStores: '$billingGroup.stores',
319
+ registeredCompanyName: '$billingGroup.registeredCompanyName',
306
320
  },
307
321
  },
308
322
  );
@@ -335,7 +349,7 @@ export async function brandInvoiceList( req, res ) {
335
349
  let summary = {
336
350
  totalInvoices: allInvoices.length,
337
351
  totalInvoiced: allInvoices.reduce( ( sum, inv ) => sum + ( inv.totalAmount || 0 ), 0 ),
338
- pendingApproval: allInvoices.filter( ( inv ) => inv.status === 'pending' ).length,
352
+ pendingApproval: allInvoices.filter( ( inv ) => [ 'pendingCsm', 'pendingFinance', 'pendingApproval' ].includes( inv.status ) ).length,
339
353
  pendingPayment: allInvoices.filter( ( inv ) => inv.paymentStatus === 'unpaid' && inv.status === 'approved' ).length,
340
354
  paid: allInvoices.filter( ( inv ) => inv.paymentStatus === 'paid' ).length,
341
355
  };
@@ -350,7 +364,7 @@ export async function brandInvoiceList( req, res ) {
350
364
  'Generated': dayjs( element.billingDate ).format( 'DD MMM YYYY' ),
351
365
  'No of Stores': element.stores,
352
366
  'Amount': element.totalAmount,
353
- 'Status': element.status === 'pending' ? 'Pending Approval' : element.paymentStatus === 'unpaid' ? 'Pending Payment' : 'Paid',
367
+ 'Status': [ 'pendingCsm', 'pendingFinance', 'pendingApproval' ].includes( element.status ) ? 'Pending Approval' : element.paymentStatus === 'unpaid' ? 'Pending Payment' : 'Paid',
354
368
  } );
355
369
  } );
356
370
  await download( exportdata, res );
@@ -706,3 +720,368 @@ export async function getClientBillingInfo( req, res ) {
706
720
  return res.sendError( error, 500 );
707
721
  }
708
722
  }
723
+
724
+ const COLUMN_HEADERS = [
725
+ '_id (read-only)',
726
+ 'Client ID (read-only)',
727
+ 'Brand (read-only)',
728
+ 'Group Name',
729
+ 'Group Tag',
730
+ 'Registered Company Name',
731
+ 'GST',
732
+ 'Address Line 1',
733
+ 'Address Line 2',
734
+ 'City',
735
+ 'State',
736
+ 'Country',
737
+ 'Pin Code',
738
+ 'Place Of Supply',
739
+ 'PO #',
740
+ 'Pro-Rata',
741
+ 'Payment Category',
742
+ 'Currency',
743
+ 'Payment Cycle',
744
+ 'Payment Term (days)',
745
+ 'Installation Fee',
746
+ 'Is Installation One-Time',
747
+ 'Attach Annexure',
748
+ 'Advance Invoice',
749
+ 'Is Primary (read-only)',
750
+ 'Stores (count, read-only)',
751
+ 'Invoice Receivers (count, read-only)',
752
+ ];
753
+
754
+ 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,
756
+ ];
757
+
758
+ function boolToYesNo( v ) {
759
+ return v === true ? 'Yes' : 'No';
760
+ }
761
+
762
+ function yesNoToBool( v ) {
763
+ if ( typeof v === 'boolean' ) return v;
764
+ if ( v === undefined || v === null || v === '' ) return undefined;
765
+ const s = String( v ).trim().toLowerCase();
766
+ if ( [ 'yes', 'y', 'true', '1' ].includes( s ) ) return true;
767
+ if ( [ 'no', 'n', 'false', '0' ].includes( s ) ) return false;
768
+ return undefined;
769
+ }
770
+
771
+ function trimOrEmpty( v ) {
772
+ if ( v === undefined || v === null ) return '';
773
+ return String( v ).trim();
774
+ }
775
+
776
+ function numberOrUndefined( v ) {
777
+ if ( v === undefined || v === null || v === '' ) return undefined;
778
+ const n = Number( v );
779
+ return Number.isFinite( n ) ? n : NaN;
780
+ }
781
+
782
+ function normalizeRow( rawRow ) {
783
+ return {
784
+ _id: trimOrEmpty( rawRow['_id (read-only)'] ),
785
+ groupName: trimOrEmpty( rawRow['Group Name'] ),
786
+ groupTag: trimOrEmpty( rawRow['Group Tag'] ),
787
+ registeredCompanyName: trimOrEmpty( rawRow['Registered Company Name'] ),
788
+ gst: trimOrEmpty( rawRow['GST'] ),
789
+ addressLineOne: trimOrEmpty( rawRow['Address Line 1'] ),
790
+ addressLineTwo: trimOrEmpty( rawRow['Address Line 2'] ),
791
+ city: trimOrEmpty( rawRow['City'] ),
792
+ state: trimOrEmpty( rawRow['State'] ),
793
+ country: trimOrEmpty( rawRow['Country'] ),
794
+ pinCode: trimOrEmpty( rawRow['Pin Code'] ),
795
+ placeOfSupply: trimOrEmpty( rawRow['Place Of Supply'] ),
796
+ po: trimOrEmpty( rawRow['PO #'] ),
797
+ proRata: trimOrEmpty( rawRow['Pro-Rata'] ),
798
+ paymentCategory: trimOrEmpty( rawRow['Payment Category'] ),
799
+ currency: trimOrEmpty( rawRow['Currency'] ),
800
+ paymentCycle: trimOrEmpty( rawRow['Payment Cycle'] ),
801
+ paymentTerm: numberOrUndefined( rawRow['Payment Term (days)'] ),
802
+ installationFee: numberOrUndefined( rawRow['Installation Fee'] ),
803
+ isInstallationOneTime: yesNoToBool( rawRow['Is Installation One-Time'] ),
804
+ attachAnnexure: yesNoToBool( rawRow['Attach Annexure'] ),
805
+ advanceInvoice: yesNoToBool( rawRow['Advance Invoice'] ),
806
+ };
807
+ }
808
+
809
+ export async function bulkDownloadBillingGroups( req, res ) {
810
+ try {
811
+ const clientId = req.query?.clientId;
812
+ if ( !clientId ) {
813
+ return res.sendError( 'clientId query parameter is required', 400 );
814
+ }
815
+
816
+ const groups = await billingService.aggregatebilling( [
817
+ { $match: { clientId } },
818
+ { $lookup: {
819
+ from: 'clientpayments',
820
+ localField: 'clientId',
821
+ foreignField: 'clientId',
822
+ as: '_client',
823
+ } },
824
+ { $unwind: { path: '$_client', preserveNullAndEmptyArrays: true } },
825
+ { $sort: { groupName: 1 } },
826
+ ] );
827
+
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
+ } ) );
857
+
858
+ // Build the workbook with ExcelJS so cell protection is actually written
859
+ // 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 ] );
864
+ const wb = new ExcelJS.Workbook();
865
+ const ws = wb.addWorksheet( 'Billing Groups' );
866
+ ws.columns = COLUMN_HEADERS.map( ( header, i ) => ( {
867
+ header,
868
+ key: `c${i}`,
869
+ width: COLUMN_WIDTHS[i],
870
+ } ) );
871
+ // Header row: bold, locked (cosmetic — protection blocks edits anyway).
872
+ ws.getRow( 1 ).font = { bold: true };
873
+ // Add data rows.
874
+ for ( const r of rows ) {
875
+ const values = {};
876
+ COLUMN_HEADERS.forEach( ( h, i ) => {
877
+ values[`c${i}`] = r[h];
878
+ } );
879
+ ws.addRow( values );
880
+ }
881
+ // Set per-cell protection on every cell (header + data).
882
+ // Note: ExcelJS defaults `locked: true`; we explicitly unlock editable cells.
883
+ const lastRow = ws.lastRow ? ws.lastRow.number : 1;
884
+ for ( let r = 1; r <= lastRow; r++ ) {
885
+ for ( let c = 1; c <= COLUMN_HEADERS.length; c++ ) {
886
+ const cell = ws.getCell( r, c );
887
+ cell.protection = { locked: READ_ONLY_COLS.has( c - 1 ) };
888
+ }
889
+ }
890
+ // Enable sheet protection with no password. Users can click
891
+ // Review → Unprotect Sheet to override (no password prompt).
892
+ await ws.protect( '', {
893
+ selectLockedCells: true,
894
+ selectUnlockedCells: true,
895
+ formatCells: false,
896
+ formatColumns: false,
897
+ formatRows: false,
898
+ insertColumns: false,
899
+ insertRows: false,
900
+ insertHyperlinks: false,
901
+ deleteColumns: false,
902
+ deleteRows: false,
903
+ sort: false,
904
+ autoFilter: false,
905
+ pivotTables: false,
906
+ } );
907
+ const buf = await wb.xlsx.writeBuffer();
908
+
909
+ const filename = `billing-groups-${dayjs().format( 'YYYY-MM-DD' )}.xlsx`;
910
+ res.set( 'Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' );
911
+ res.set( 'Content-Disposition', `attachment; filename="${filename}"` );
912
+ return res.send( buf );
913
+ } catch ( error ) {
914
+ logger.error( { error: error, function: 'bulkDownloadBillingGroups' } );
915
+ return res.sendError( error, 500 );
916
+ }
917
+ }
918
+
919
+ export async function bulkUpdateBillingGroups( req, res ) {
920
+ try {
921
+ if ( !req.file?.buffer ) {
922
+ return res.sendError( 'No file uploaded', 400 );
923
+ }
924
+
925
+ const clientId = req.body?.clientId;
926
+ if ( !clientId ) {
927
+ return res.sendError( 'clientId form field is required', 400 );
928
+ }
929
+
930
+ let rawRows;
931
+ try {
932
+ const wb = XLSX.read( req.file.buffer, { type: 'buffer' } );
933
+ const sheet = wb.Sheets[wb.SheetNames[0]];
934
+ rawRows = XLSX.utils.sheet_to_json( sheet, { defval: '' } );
935
+ } catch ( parseErr ) {
936
+ logger.error( { error: parseErr, function: 'bulkUpdateBillingGroups.parse' } );
937
+ return res.sendError( 'Failed to parse XLSX file', 400 );
938
+ }
939
+
940
+ if ( !Array.isArray( rawRows ) || rawRows.length === 0 ) {
941
+ return res.sendError( 'Spreadsheet has no data rows', 400 );
942
+ }
943
+
944
+ // First pass: normalize + Joi-validate every row, collecting errors.
945
+ const normalized = [];
946
+ const errors = [];
947
+ for ( let i = 0; i < rawRows.length; i++ ) {
948
+ const rowNumber = i + 2; // header is row 1
949
+ const raw = rawRows[i];
950
+ const row = normalizeRow( raw );
951
+ const rowErrors = [];
952
+
953
+ // Coercion sanity (e.g. "abc" in a number column came back as NaN)
954
+ if ( Number.isNaN( row.paymentTerm ) ) {
955
+ rowErrors.push( 'Payment Term (days) must be a number' );
956
+ row.paymentTerm = undefined;
957
+ }
958
+ if ( Number.isNaN( row.installationFee ) ) {
959
+ rowErrors.push( 'Installation Fee must be a number' );
960
+ row.installationFee = undefined;
961
+ }
962
+
963
+ // Boolean cells that the user filled in but we couldn't parse → error (not silent skip).
964
+ const rawIsOneTime = trimOrEmpty( raw[COLUMN_HEADERS[21]] );
965
+ if ( rawIsOneTime !== '' && row.isInstallationOneTime === undefined ) {
966
+ rowErrors.push( 'Is Installation One-Time must be Yes or No' );
967
+ }
968
+ const rawAttachAnnexure = trimOrEmpty( raw[COLUMN_HEADERS[22]] );
969
+ if ( rawAttachAnnexure !== '' && row.attachAnnexure === undefined ) {
970
+ rowErrors.push( 'Attach Annexure must be Yes or No' );
971
+ }
972
+ const rawAdvanceInvoice = trimOrEmpty( raw[COLUMN_HEADERS[23]] );
973
+ if ( rawAdvanceInvoice !== '' && row.advanceInvoice === undefined ) {
974
+ rowErrors.push( 'Advance Invoice must be Yes or No' );
975
+ }
976
+
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
+ const { error: joiError } = bulkUpdateBillingGroupRowSchema.validate( row, { abortEarly: false, stripUnknown: true } );
984
+ if ( joiError ) {
985
+ for ( const d of joiError.details ) rowErrors.push( d.message );
986
+ }
987
+
988
+ if ( rowErrors.length > 0 ) {
989
+ errors.push( {
990
+ rowNumber,
991
+ _id: row._id,
992
+ brand: raw[COLUMN_HEADERS[2]] || '',
993
+ errors: rowErrors,
994
+ } );
995
+ normalized.push( null );
996
+ continue;
997
+ }
998
+ normalized.push( row );
999
+ }
1000
+
1001
+ // Second pass: verify each _id exists in the DB AND belongs to clientId.
1002
+ const idsToCheck = normalized
1003
+ .filter( ( r ) => r !== null )
1004
+ .map( ( r ) => r._id );
1005
+ const foundDocs = idsToCheck.length > 0 ?
1006
+ await billingService.find( { _id: { $in: idsToCheck }, clientId } ) :
1007
+ [];
1008
+ const foundIds = new Set( foundDocs.map( ( d ) => String( d._id ) ) );
1009
+ for ( let i = 0; i < normalized.length; i++ ) {
1010
+ const row = normalized[i];
1011
+ if ( !row ) continue;
1012
+ if ( !foundIds.has( row._id ) ) {
1013
+ errors.push( {
1014
+ rowNumber: i + 2,
1015
+ _id: row._id,
1016
+ brand: rawRows[i][COLUMN_HEADERS[2]] || '',
1017
+ errors: [ 'Billing group not found' ],
1018
+ } );
1019
+ normalized[i] = null;
1020
+ }
1021
+ }
1022
+
1023
+ if ( errors.length > 0 ) {
1024
+ errors.sort( ( a, b ) => a.rowNumber - b.rowNumber );
1025
+ return res.sendError(
1026
+ { summary: { total: rawRows.length, failed: errors.length }, errors },
1027
+ 422,
1028
+ );
1029
+ }
1030
+
1031
+ const openSearchActivityLog = JSON.parse( process.env.OPENSEARCH ).activityLog;
1032
+
1033
+ // Third pass: serial DB writes.
1034
+ let updated = 0;
1035
+ 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;
1040
+ try {
1041
+ await billingService.updateOne( { _id }, payload );
1042
+ updated++;
1043
+ insertOpenSearchData( openSearchActivityLog, {
1044
+ userName: req.user?.userName,
1045
+ email: req.user?.email,
1046
+ clientId: rawRows[i][COLUMN_HEADERS[1]] || '',
1047
+ logSubType: 'billingGroupUpdate',
1048
+ logType: 'billing',
1049
+ date: new Date(),
1050
+ changes: [ `Billing group ${_id} updated via bulk upload` ],
1051
+ eventType: '',
1052
+ timestamp: new Date(),
1053
+ showTo: [ 'tango' ],
1054
+ } );
1055
+ } 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 ) };
1058
+ break;
1059
+ }
1060
+ }
1061
+
1062
+ insertOpenSearchData( openSearchActivityLog, {
1063
+ userName: req.user?.userName,
1064
+ email: req.user?.email,
1065
+ clientId: '',
1066
+ logSubType: 'billingGroupBulkUpdate',
1067
+ logType: 'billing',
1068
+ date: new Date(),
1069
+ changes: [ `Bulk billing-group update: ${updated} of ${normalized.length} groups updated by ${req.user?.email}` ],
1070
+ eventType: '',
1071
+ timestamp: new Date(),
1072
+ showTo: [ 'tango' ],
1073
+ } );
1074
+
1075
+ if ( aborted ) {
1076
+ return res.sendError(
1077
+ { summary: { total: normalized.length, updated, remaining: normalized.length - updated }, aborted },
1078
+ 207,
1079
+ );
1080
+ }
1081
+
1082
+ return res.sendSuccess( { summary: { total: normalized.length, updated } } );
1083
+ } catch ( error ) {
1084
+ logger.error( { error: error, function: 'bulkUpdateBillingGroups' } );
1085
+ return res.sendError( error, 500 );
1086
+ }
1087
+ }