tango-app-api-payment-subscription 3.5.28 → 3.5.30
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/superpowers/specs/2026-07-09-quarterly-invoices-449-design.md +108 -0
- package/package.json +2 -2
- package/src/controllers/brandsBilling.controller.js +15 -0
- package/src/controllers/invoice.controller.js +356 -69
- package/src/hbs/invoiceBulkEmail.hbs +70 -0
- package/src/hbs/invoicepaymentemail.hbs +817 -856
- package/src/routes/invoice.routes.js +27 -25
|
@@ -128,19 +128,11 @@ export async function createInvoice( req, res ) {
|
|
|
128
128
|
return res.sendError( 'clientId is required for customInvoice', 400 );
|
|
129
129
|
}
|
|
130
130
|
const Finacialyear = getCurrentFinancialYear();
|
|
131
|
-
//
|
|
132
|
-
//
|
|
133
|
-
|
|
134
|
-
const
|
|
135
|
-
|
|
136
|
-
{},
|
|
137
|
-
{ invoiceIndex: -1 },
|
|
138
|
-
);
|
|
139
|
-
let invoiceNo = '00001';
|
|
140
|
-
if ( previousinvoice && previousinvoice.length > 0 ) {
|
|
141
|
-
invoiceNo = Number( previousinvoice[0].invoiceIndex ) + 1;
|
|
142
|
-
invoiceNo = invoiceNo.toString().padStart( 5, '0' );
|
|
143
|
-
}
|
|
131
|
+
// Draft numbering: custom invoices get a temporary DRAFT-<FY>-<seq>
|
|
132
|
+
// number. The real INV/TINV number is minted at finance approval. See
|
|
133
|
+
// nextTempInvoiceIndex / formatTempInvoice and transitionInvoiceStatus.
|
|
134
|
+
const tempSeq = await nextTempInvoiceIndex( Finacialyear );
|
|
135
|
+
const tempInvoiceNo = formatTempInvoice( Finacialyear, tempSeq );
|
|
144
136
|
const baseDate = req.body.billingDate ? dayjs( req.body.billingDate ) : dayjs();
|
|
145
137
|
let customProducts = Array.isArray( req.body.products ) ? req.body.products : [];
|
|
146
138
|
|
|
@@ -173,8 +165,9 @@ export async function createInvoice( req, res ) {
|
|
|
173
165
|
roundAmount( Number( req.body.totalAmount ) || 0, customCurrency );
|
|
174
166
|
|
|
175
167
|
const data = {
|
|
176
|
-
invoice:
|
|
177
|
-
invoiceIndex:
|
|
168
|
+
invoice: tempInvoiceNo,
|
|
169
|
+
invoiceIndex: null,
|
|
170
|
+
tempIndex: tempSeq,
|
|
178
171
|
clientId: req.body.clientId,
|
|
179
172
|
groupId: req.body.groupId || undefined,
|
|
180
173
|
groupName: req.body.groupName || '',
|
|
@@ -198,24 +191,11 @@ export async function createInvoice( req, res ) {
|
|
|
198
191
|
advanceInvoice: req.body.advanceInvoice || false,
|
|
199
192
|
advancePeriod: req.body.advanceInvoice ? ( req.body.advancePeriod || 'monthly' ) : undefined,
|
|
200
193
|
advanceMonths: customAdvanceMonths,
|
|
201
|
-
// Purchase order
|
|
202
|
-
|
|
194
|
+
// Purchase order can only be attached AFTER the real invoice number is
|
|
195
|
+
// minted (finance approval); drafts never carry a PO.
|
|
196
|
+
purchaseOrderNumber: undefined,
|
|
203
197
|
};
|
|
204
198
|
const created = await invoiceService.create( data );
|
|
205
|
-
// Map to a PO (deduct its remaining balance + log) when one was picked.
|
|
206
|
-
if ( data.purchaseOrderNumber ) {
|
|
207
|
-
try {
|
|
208
|
-
await applyInvoiceToPurchaseOrder( {
|
|
209
|
-
clientId: data.clientId,
|
|
210
|
-
purchaseOrderNumber: data.purchaseOrderNumber,
|
|
211
|
-
invoice: data.invoice,
|
|
212
|
-
amount: data.totalAmount,
|
|
213
|
-
req,
|
|
214
|
-
} );
|
|
215
|
-
} catch ( poErr ) {
|
|
216
|
-
logger.error( { error: poErr, function: 'createInvoice.applyPO', invoice: data.invoice } );
|
|
217
|
-
}
|
|
218
|
-
}
|
|
219
199
|
const logObj = {
|
|
220
200
|
userName: req.user?.userName,
|
|
221
201
|
email: req.user?.email,
|
|
@@ -236,6 +216,11 @@ export async function createInvoice( req, res ) {
|
|
|
236
216
|
return res.sendSuccess( { data: created } );
|
|
237
217
|
}
|
|
238
218
|
|
|
219
|
+
// Running DRAFT sequence for this generation run. Auto-generated invoices
|
|
220
|
+
// are drafts until finance approval; seed once from the DB max, then
|
|
221
|
+
// increment per created draft so a single run yields unique DRAFT numbers.
|
|
222
|
+
let runTempSeq = await nextTempInvoiceIndex( getCurrentFinancialYear() );
|
|
223
|
+
|
|
239
224
|
for ( let group of invoiceGroupList ) {
|
|
240
225
|
let Finacialyear = getCurrentFinancialYear();
|
|
241
226
|
|
|
@@ -265,22 +250,16 @@ export async function createInvoice( req, res ) {
|
|
|
265
250
|
const isAdvance = group.advanceInvoice && !advanceCoverProducts;
|
|
266
251
|
|
|
267
252
|
// Quarterly/half-yearly/yearly advance invoices use the separate TINV-
|
|
268
|
-
// series
|
|
253
|
+
// series; monthly advance + normal stay INV-. Still used below to decide
|
|
254
|
+
// whether to capture the full store list for TINV invoices.
|
|
269
255
|
const invPrefix = invoicePrefixFor( isAdvance, group.advancePeriod );
|
|
270
|
-
|
|
271
|
-
//
|
|
272
|
-
//
|
|
273
|
-
//
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
{ invoiceIndex: -1 },
|
|
278
|
-
);
|
|
279
|
-
let invoiceNo = '00001';
|
|
280
|
-
if ( previousinvoice && previousinvoice.length > 0 ) {
|
|
281
|
-
invoiceNo = Number( previousinvoice[0].invoiceIndex ) + 1;
|
|
282
|
-
invoiceNo = invoiceNo.toString().padStart( 5, '0' );
|
|
283
|
-
}
|
|
256
|
+
|
|
257
|
+
// Draft numbering: assign a temporary DRAFT-<FY>-<seq> number from the
|
|
258
|
+
// running counter. Real INV/TINV number is minted at finance approval.
|
|
259
|
+
// (isAdvance/advance fields are still stored on the doc for the mint.)
|
|
260
|
+
const tempInvoiceNo = formatTempInvoice( Finacialyear, runTempSeq );
|
|
261
|
+
const tempSeqForThis = runTempSeq;
|
|
262
|
+
runTempSeq += 1;
|
|
284
263
|
|
|
285
264
|
let address = group.addressLineOne + group.addressLineTwo + group.city + ',' + group.state + ',' + group.country + ' -' + group.pinCode;
|
|
286
265
|
let getClient = await clientService.findOne( { clientId: group.clientId, status: 'active' } );
|
|
@@ -575,11 +554,12 @@ export async function createInvoice( req, res ) {
|
|
|
575
554
|
let data = {
|
|
576
555
|
groupName: group.groupName,
|
|
577
556
|
groupId: group._id,
|
|
578
|
-
invoice: req.body.invoiceId ? req.body.invoiceId :
|
|
557
|
+
invoice: req.body.invoiceId ? req.body.invoiceId : tempInvoiceNo,
|
|
579
558
|
products: products,
|
|
580
559
|
status: 'pendingCsm',
|
|
581
560
|
amount: roundAmount( amount, group.currency ),
|
|
582
|
-
invoiceIndex: req.body.invoiceId ? findInvoice.invoiceIndex :
|
|
561
|
+
invoiceIndex: req.body.invoiceId ? findInvoice.invoiceIndex : null,
|
|
562
|
+
tempIndex: req.body.invoiceId ? undefined : tempSeqForThis,
|
|
583
563
|
tax: taxList,
|
|
584
564
|
companyName: ( group.registeredCompanyName || '' ).toUpperCase(),
|
|
585
565
|
companyAddress: address,
|
|
@@ -676,6 +656,27 @@ function invoicePrefixFor( advanceInvoice, advancePeriod ) {
|
|
|
676
656
|
return 'INV-';
|
|
677
657
|
}
|
|
678
658
|
|
|
659
|
+
// Temporary invoice number helpers. New invoices carry a DRAFT-<FY>-<seq>
|
|
660
|
+
// number until finance approval mints the real INV/TINV number. Draft docs
|
|
661
|
+
// store their sequence in `tempIndex` and keep `invoiceIndex` null, so drafts
|
|
662
|
+
// never participate in the real max(invoiceIndex)+1 allocation and deleting a
|
|
663
|
+
// draft leaves no gap in the real series.
|
|
664
|
+
function formatTempInvoice( FY, seq ) {
|
|
665
|
+
return `DRAFT-${FY}-${String( seq ).padStart( 5, '0' )}`;
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
// Next temp sequence for a FY: max(tempIndex among ^DRAFT-<FY>-) + 1, else 1.
|
|
669
|
+
async function nextTempInvoiceIndex( FY ) {
|
|
670
|
+
const previous = await invoiceService.findandsort(
|
|
671
|
+
{ invoice: { $regex: `^DRAFT-${FY}-` } },
|
|
672
|
+
{},
|
|
673
|
+
{ tempIndex: -1 },
|
|
674
|
+
);
|
|
675
|
+
if ( previous && previous.length > 0 && previous[0].tempIndex != null ) {
|
|
676
|
+
return Number( previous[0].tempIndex ) + 1;
|
|
677
|
+
}
|
|
678
|
+
return 1;
|
|
679
|
+
}
|
|
679
680
|
|
|
680
681
|
// ---------------------------------------------------------------------------
|
|
681
682
|
// Shared annexure builder. Anchored to the invoice's BILLING month and
|
|
@@ -1213,6 +1214,235 @@ export async function invoiceDownloadBulk( req, res ) {
|
|
|
1213
1214
|
}
|
|
1214
1215
|
}
|
|
1215
1216
|
|
|
1217
|
+
// Bulk email — PREVIEW. Filters the selected invoices down to approved-only,
|
|
1218
|
+
// computes the combined totals + per-invoice rows, and returns the union of
|
|
1219
|
+
// each involved billing group's generateInvoiceTo (TO) plus getInvoiceCcEmails
|
|
1220
|
+
// (CC) to prefill the Send Email popup. Does NOT send anything.
|
|
1221
|
+
export async function invoiceBulkEmailPreview( req, res ) {
|
|
1222
|
+
try {
|
|
1223
|
+
const invoiceIds = req.body?.invoiceIds;
|
|
1224
|
+
if ( !Array.isArray( invoiceIds ) || invoiceIds.length === 0 ) {
|
|
1225
|
+
return res.sendError( 'invoiceIds must be a non-empty array', 400 );
|
|
1226
|
+
}
|
|
1227
|
+
|
|
1228
|
+
const approved = [];
|
|
1229
|
+
for ( const id of invoiceIds ) {
|
|
1230
|
+
const inv = await invoiceService.findOne( { _id: id } );
|
|
1231
|
+
if ( inv && inv.status === 'approved' ) approved.push( inv );
|
|
1232
|
+
}
|
|
1233
|
+
const skippedCount = invoiceIds.length - approved.length;
|
|
1234
|
+
if ( approved.length === 0 ) {
|
|
1235
|
+
return res.sendError( 'No approved invoices in selection', 400 );
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
const currency = approved[0].currency;
|
|
1239
|
+
const mixedCurrency = approved.some( ( inv ) => inv.currency !== currency );
|
|
1240
|
+
if ( mixedCurrency ) {
|
|
1241
|
+
logger.error( { function: 'invoiceBulkEmailPreview.mixedCurrency', invoiceIds } );
|
|
1242
|
+
}
|
|
1243
|
+
const currencyType = symbolFor( currency );
|
|
1244
|
+
const fractionDigits = currency === 'inr' ? 0 : 2;
|
|
1245
|
+
const moneyFmt = { minimumFractionDigits: fractionDigits, maximumFractionDigits: fractionDigits };
|
|
1246
|
+
|
|
1247
|
+
let totalExclNum = 0;
|
|
1248
|
+
let totalInclNum = 0;
|
|
1249
|
+
const rows = approved.map( ( inv ) => {
|
|
1250
|
+
const excl = roundAmount( inv.amount, inv.currency );
|
|
1251
|
+
const incl = roundAmount( inv.totalAmount, inv.currency );
|
|
1252
|
+
totalExclNum += excl;
|
|
1253
|
+
totalInclNum += incl;
|
|
1254
|
+
return {
|
|
1255
|
+
invoice: inv.invoice,
|
|
1256
|
+
amountExcl: excl.toLocaleString( 'en-IN', moneyFmt ),
|
|
1257
|
+
amountIncl: incl.toLocaleString( 'en-IN', moneyFmt ),
|
|
1258
|
+
};
|
|
1259
|
+
} );
|
|
1260
|
+
|
|
1261
|
+
// TO = union of every involved billing group's generateInvoiceTo.
|
|
1262
|
+
const toSet = new Set();
|
|
1263
|
+
const groupIds = [ ...new Set( approved.map( ( inv ) => String( inv.groupId || '' ) ).filter( Boolean ) ) ];
|
|
1264
|
+
for ( const gid of groupIds ) {
|
|
1265
|
+
const group = await billingService.findOne( { _id: gid } );
|
|
1266
|
+
( group?.generateInvoiceTo || [] ).forEach( ( e ) => {
|
|
1267
|
+
const v = String( e || '' ).trim();
|
|
1268
|
+
if ( v ) toSet.add( v );
|
|
1269
|
+
} );
|
|
1270
|
+
}
|
|
1271
|
+
const toEmails = [ ...toSet ];
|
|
1272
|
+
const toLower = new Set( toEmails.map( ( e ) => e.toLowerCase() ) );
|
|
1273
|
+
|
|
1274
|
+
// CC = configured heads + per-client CSMs, deduped, minus anyone in TO.
|
|
1275
|
+
const clientId = approved[0].clientId;
|
|
1276
|
+
const ccRaw = await getInvoiceCcEmails( clientId );
|
|
1277
|
+
const ccEmails = [ ...new Set( ( ccRaw || [] ).map( ( e ) => String( e || '' ).trim() ).filter( Boolean ) ) ]
|
|
1278
|
+
.filter( ( e ) => !toLower.has( e.toLowerCase() ) );
|
|
1279
|
+
|
|
1280
|
+
let brandSlug = 'invoices';
|
|
1281
|
+
const firstClient = await clientService.findOne( { clientId } );
|
|
1282
|
+
if ( firstClient?.clientName ) {
|
|
1283
|
+
brandSlug = firstClient.clientName.toLowerCase().replace( /[^a-z0-9]+/g, '-' ).replace( /^-|-$/g, '' ) || 'invoices';
|
|
1284
|
+
}
|
|
1285
|
+
const zipFilename = `invoices-${brandSlug}-${dayjs().format( 'YYYY-MM-DD' )}.zip`;
|
|
1286
|
+
|
|
1287
|
+
return res.sendSuccess( {
|
|
1288
|
+
approvedIds: approved.map( ( inv ) => String( inv._id ) ),
|
|
1289
|
+
skippedCount,
|
|
1290
|
+
rows,
|
|
1291
|
+
totalExcl: totalExclNum.toLocaleString( 'en-IN', moneyFmt ),
|
|
1292
|
+
totalIncl: totalInclNum.toLocaleString( 'en-IN', moneyFmt ),
|
|
1293
|
+
currencyType,
|
|
1294
|
+
toEmails,
|
|
1295
|
+
ccEmails,
|
|
1296
|
+
zipFilename,
|
|
1297
|
+
} );
|
|
1298
|
+
} catch ( error ) {
|
|
1299
|
+
logger.error( { error: error, function: 'invoiceBulkEmailPreview' } );
|
|
1300
|
+
return res.sendError( error, 500 );
|
|
1301
|
+
}
|
|
1302
|
+
}
|
|
1303
|
+
|
|
1304
|
+
// Builds the ZIP in memory (archiver piped into a buffer accumulator) so it
|
|
1305
|
+
// can be attached to the SES email rather than streamed to the HTTP response.
|
|
1306
|
+
function buildZipBuffer( items ) {
|
|
1307
|
+
return new Promise( ( resolve, reject ) => {
|
|
1308
|
+
const archive = archiver( 'zip', { zlib: { level: 6 } } );
|
|
1309
|
+
const chunks = [];
|
|
1310
|
+
archive.on( 'data', ( c ) => chunks.push( c ) );
|
|
1311
|
+
archive.on( 'error', reject );
|
|
1312
|
+
archive.on( 'end', () => resolve( Buffer.concat( chunks ) ) );
|
|
1313
|
+
for ( const it of items ) {
|
|
1314
|
+
archive.append( it.content, { name: it.name } );
|
|
1315
|
+
}
|
|
1316
|
+
archive.finalize();
|
|
1317
|
+
} );
|
|
1318
|
+
}
|
|
1319
|
+
|
|
1320
|
+
// Bulk email — SEND. Re-filters to approved-only (authoritative), builds one
|
|
1321
|
+
// ZIP of the approved PDFs, renders invoiceBulkEmail.hbs, and sends a single
|
|
1322
|
+
// SES email to the user-confirmed recipients. Does NOT change invoice status.
|
|
1323
|
+
export async function invoiceBulkEmailSend( req, res ) {
|
|
1324
|
+
try {
|
|
1325
|
+
const { invoiceIds, toEmails, ccEmails } = req.body || {};
|
|
1326
|
+
if ( !Array.isArray( invoiceIds ) || invoiceIds.length === 0 ) {
|
|
1327
|
+
return res.sendError( 'invoiceIds must be a non-empty array', 400 );
|
|
1328
|
+
}
|
|
1329
|
+
const to = [ ...new Set( ( toEmails || [] ).map( ( e ) => String( e || '' ).trim() ).filter( Boolean ) ) ];
|
|
1330
|
+
if ( to.length === 0 ) {
|
|
1331
|
+
return res.sendError( 'At least one recipient is required', 400 );
|
|
1332
|
+
}
|
|
1333
|
+
|
|
1334
|
+
const approved = [];
|
|
1335
|
+
for ( const id of invoiceIds ) {
|
|
1336
|
+
const inv = await invoiceService.findOne( { _id: id } );
|
|
1337
|
+
if ( inv && inv.status === 'approved' ) approved.push( inv );
|
|
1338
|
+
}
|
|
1339
|
+
if ( approved.length === 0 ) {
|
|
1340
|
+
return res.sendError( 'No approved invoices in selection', 400 );
|
|
1341
|
+
}
|
|
1342
|
+
|
|
1343
|
+
const currency = approved[0].currency;
|
|
1344
|
+
const currencyType = symbolFor( currency );
|
|
1345
|
+
const fractionDigits = currency === 'inr' ? 0 : 2;
|
|
1346
|
+
const moneyFmt = { minimumFractionDigits: fractionDigits, maximumFractionDigits: fractionDigits };
|
|
1347
|
+
|
|
1348
|
+
// Build each PDF; collect failures without aborting the whole send.
|
|
1349
|
+
const zipItems = [];
|
|
1350
|
+
const rows = [];
|
|
1351
|
+
let totalExclNum = 0;
|
|
1352
|
+
let totalInclNum = 0;
|
|
1353
|
+
let skippedBuildCount = 0;
|
|
1354
|
+
for ( const inv of approved ) {
|
|
1355
|
+
try {
|
|
1356
|
+
const { pdfBuffer, filename } = await buildInvoicePdfBuffer( String( inv._id ) );
|
|
1357
|
+
zipItems.push( { content: pdfBuffer, name: filename } );
|
|
1358
|
+
const excl = roundAmount( inv.amount, inv.currency );
|
|
1359
|
+
const incl = roundAmount( inv.totalAmount, inv.currency );
|
|
1360
|
+
totalExclNum += excl;
|
|
1361
|
+
totalInclNum += incl;
|
|
1362
|
+
rows.push( {
|
|
1363
|
+
invoice: inv.invoice,
|
|
1364
|
+
amountExcl: excl.toLocaleString( 'en-IN', moneyFmt ),
|
|
1365
|
+
amountIncl: incl.toLocaleString( 'en-IN', moneyFmt ),
|
|
1366
|
+
} );
|
|
1367
|
+
} catch ( err ) {
|
|
1368
|
+
skippedBuildCount++;
|
|
1369
|
+
logger.error( { error: err, function: 'invoiceBulkEmailSend.buildPdf', invoiceId: inv._id } );
|
|
1370
|
+
}
|
|
1371
|
+
}
|
|
1372
|
+
if ( zipItems.length === 0 ) {
|
|
1373
|
+
return res.sendError( 'Failed to build any invoice PDF', 500 );
|
|
1374
|
+
}
|
|
1375
|
+
|
|
1376
|
+
const clientId = approved[0].clientId;
|
|
1377
|
+
const clientDetails = await clientService.findOne( { clientId } );
|
|
1378
|
+
const virtualAccount = await paymentAccountService.findOneAccount( { clientId } );
|
|
1379
|
+
|
|
1380
|
+
// Subject month is derived from the first approved invoice's billing month,
|
|
1381
|
+
// mirroring the single-invoice email convention (billingDate, else
|
|
1382
|
+
// monthOfbilling 'MM'). Falls back to empty string if neither is present.
|
|
1383
|
+
const subjectMonth = approved[0].billingDate ?
|
|
1384
|
+
dayjs( approved[0].billingDate ).format( 'MMMM' ) :
|
|
1385
|
+
( approved[0].monthOfbilling ? dayjs( approved[0].monthOfbilling, 'MM' ).format( 'MMMM' ) : '' );
|
|
1386
|
+
|
|
1387
|
+
let brandSlug = 'invoices';
|
|
1388
|
+
if ( clientDetails?.clientName ) {
|
|
1389
|
+
brandSlug = clientDetails.clientName.toLowerCase().replace( /[^a-z0-9]+/g, '-' ).replace( /^-|-$/g, '' ) || 'invoices';
|
|
1390
|
+
}
|
|
1391
|
+
const zipFilename = `invoices-${brandSlug}-${dayjs().format( 'YYYY-MM-DD' )}.zip`;
|
|
1392
|
+
const zipBuffer = await buildZipBuffer( zipItems );
|
|
1393
|
+
|
|
1394
|
+
const templateHtml = fs.readFileSync( path.resolve( path.dirname( '' ) ) + '/src/hbs/invoiceBulkEmail.hbs', 'utf8' );
|
|
1395
|
+
const template = Handlebars.compile( templateHtml );
|
|
1396
|
+
const mailbody = template( {
|
|
1397
|
+
clientName: clientDetails?.clientName || '',
|
|
1398
|
+
currencyType,
|
|
1399
|
+
totalExcl: totalExclNum.toLocaleString( 'en-IN', moneyFmt ),
|
|
1400
|
+
totalIncl: totalInclNum.toLocaleString( 'en-IN', moneyFmt ),
|
|
1401
|
+
virtualaccountNumber: virtualAccount ? virtualAccount.accountNumber : '',
|
|
1402
|
+
virtualifsc: virtualAccount ? virtualAccount.ifsc : '',
|
|
1403
|
+
uidomain: `${JSON.parse( process.env.URL ).domain}`,
|
|
1404
|
+
logo: `${JSON.parse( process.env.URL ).apiDomain}/logo.png`,
|
|
1405
|
+
rows,
|
|
1406
|
+
} );
|
|
1407
|
+
|
|
1408
|
+
const attachment = {
|
|
1409
|
+
filename: zipFilename,
|
|
1410
|
+
content: zipBuffer,
|
|
1411
|
+
contentType: 'application/zip',
|
|
1412
|
+
};
|
|
1413
|
+
const SES = JSON.parse( process.env.SES );
|
|
1414
|
+
const fromEmail = SES.accountsEmail;
|
|
1415
|
+
const toLower = new Set( to.map( ( e ) => e.toLowerCase() ) );
|
|
1416
|
+
const cc = [ ...new Set( ( ccEmails || [] ).map( ( e ) => String( e || '' ).trim() ).filter( Boolean ) ) ]
|
|
1417
|
+
.filter( ( e ) => !toLower.has( e.toLowerCase() ) );
|
|
1418
|
+
|
|
1419
|
+
const mailSubject = `Invoice for ${subjectMonth} - Tango/${clientDetails?.clientName || ''}`;
|
|
1420
|
+
const result = await sendEmailWithSES( to, mailSubject, mailbody, attachment, fromEmail, cc.length ? cc : undefined );
|
|
1421
|
+
|
|
1422
|
+
const logObj = {
|
|
1423
|
+
userName: req.user?.userName,
|
|
1424
|
+
email: req.user?.email,
|
|
1425
|
+
clientId,
|
|
1426
|
+
logSubType: 'invoiceBulkEmail',
|
|
1427
|
+
logType: 'invoice',
|
|
1428
|
+
date: new Date(),
|
|
1429
|
+
changes: [ `Bulk-emailed ${rows.length} invoice(s): ${rows.map( ( r ) => r.invoice ).join( ', ' )}` ],
|
|
1430
|
+
eventType: '',
|
|
1431
|
+
timestamp: new Date(),
|
|
1432
|
+
showTo: [ 'tango' ],
|
|
1433
|
+
};
|
|
1434
|
+
insertOpenSearchData( JSON.parse( process.env.OPENSEARCH ).activityLog, logObj );
|
|
1435
|
+
|
|
1436
|
+
if ( !result ) {
|
|
1437
|
+
return res.sendError( 'Failed to send email', 500 );
|
|
1438
|
+
}
|
|
1439
|
+
return res.sendSuccess( { emailedCount: rows.length, skippedBuildCount } );
|
|
1440
|
+
} catch ( error ) {
|
|
1441
|
+
logger.error( { error: error, function: 'invoiceBulkEmailSend' } );
|
|
1442
|
+
return res.sendError( error, 500 );
|
|
1443
|
+
}
|
|
1444
|
+
}
|
|
1445
|
+
|
|
1216
1446
|
// Bank/beneficiary details for the invoice preview. Fixed beneficiary account
|
|
1217
1447
|
// — hardcoded to match the PDF exactly (NOT read from the DB).
|
|
1218
1448
|
export async function invoiceBankDetails( req, res ) {
|
|
@@ -2078,38 +2308,54 @@ export async function clientInvoiceList( req, res ) {
|
|
|
2078
2308
|
let filterEndDate = '';
|
|
2079
2309
|
|
|
2080
2310
|
if ( !hasMonthYear ) {
|
|
2311
|
+
// Start = start-of-month (00:00:00); End = end-of-month (23:59:59.999)
|
|
2312
|
+
// via .toDate() so invoices on the last day (with any time) are included.
|
|
2081
2313
|
if ( req.body?.filter && req.body.filter == 'current' ) {
|
|
2082
|
-
filterStartDate =
|
|
2083
|
-
filterEndDate =
|
|
2314
|
+
filterStartDate = dayjs().startOf( 'month' ).toDate();
|
|
2315
|
+
filterEndDate = dayjs().endOf( 'month' ).toDate();
|
|
2084
2316
|
}
|
|
2085
2317
|
if ( req.body?.filter && req.body.filter == 'prev' ) {
|
|
2086
|
-
filterStartDate =
|
|
2087
|
-
filterEndDate =
|
|
2318
|
+
filterStartDate = dayjs().subtract( 1, 'month' ).startOf( 'month' ).toDate();
|
|
2319
|
+
filterEndDate = dayjs().subtract( 1, 'month' ).endOf( 'month' ).toDate();
|
|
2088
2320
|
}
|
|
2089
2321
|
// Rolling windows. 'last3' = the prototype's "Last 3 Months" (was the
|
|
2090
2322
|
// legacy 'last' id, which silently meant 12 months and made every other
|
|
2091
2323
|
// filter look broken). 'last' is kept as an alias of 'last3' so older
|
|
2092
2324
|
// clients don't break mid-deploy.
|
|
2093
2325
|
if ( req.body?.filter && ( req.body.filter == 'last3' || req.body.filter == 'last' ) ) {
|
|
2094
|
-
filterStartDate =
|
|
2095
|
-
filterEndDate =
|
|
2326
|
+
filterStartDate = dayjs().subtract( 3, 'month' ).startOf( 'month' ).toDate();
|
|
2327
|
+
filterEndDate = dayjs().endOf( 'month' ).toDate();
|
|
2096
2328
|
}
|
|
2097
2329
|
if ( req.body?.filter && req.body.filter == 'last6' ) {
|
|
2098
|
-
filterStartDate =
|
|
2099
|
-
filterEndDate =
|
|
2330
|
+
filterStartDate = dayjs().subtract( 6, 'month' ).startOf( 'month' ).toDate();
|
|
2331
|
+
filterEndDate = dayjs().endOf( 'month' ).toDate();
|
|
2100
2332
|
}
|
|
2101
2333
|
if ( req.body?.filter && req.body.filter == 'last12' ) {
|
|
2102
|
-
filterStartDate =
|
|
2103
|
-
filterEndDate =
|
|
2334
|
+
filterStartDate = dayjs().subtract( 12, 'month' ).startOf( 'month' ).toDate();
|
|
2335
|
+
filterEndDate = dayjs().endOf( 'month' ).toDate();
|
|
2104
2336
|
}
|
|
2105
2337
|
|
|
2106
2338
|
if ( req.body?.filter ) {
|
|
2339
|
+
// billingDate may be stored as a Date OR a string on legacy rows. A raw
|
|
2340
|
+
// { billingDate: { $gte: <Date> } } range compares across BSON types for
|
|
2341
|
+
// string rows and silently fails to filter — so the Duration filter
|
|
2342
|
+
// appeared to "show all". Coerce to a Date in an $expr, mirroring the
|
|
2343
|
+
// Month/Year filter below.
|
|
2344
|
+
const billingDateExpr = {
|
|
2345
|
+
$cond: [
|
|
2346
|
+
{ $eq: [ { $type: '$billingDate' }, 'date' ] },
|
|
2347
|
+
'$billingDate',
|
|
2348
|
+
{ $toDate: '$billingDate' },
|
|
2349
|
+
],
|
|
2350
|
+
};
|
|
2107
2351
|
query.push( {
|
|
2108
2352
|
$match: {
|
|
2109
|
-
$
|
|
2110
|
-
|
|
2111
|
-
|
|
2112
|
-
|
|
2353
|
+
$expr: {
|
|
2354
|
+
$and: [
|
|
2355
|
+
{ $gte: [ billingDateExpr, filterStartDate ] },
|
|
2356
|
+
{ $lte: [ billingDateExpr, filterEndDate ] },
|
|
2357
|
+
],
|
|
2358
|
+
},
|
|
2113
2359
|
},
|
|
2114
2360
|
} );
|
|
2115
2361
|
}
|
|
@@ -2326,6 +2572,13 @@ export async function clientInvoiceList( req, res ) {
|
|
|
2326
2572
|
if ( inv.paymentStatus === 'paid' ) {
|
|
2327
2573
|
continue;
|
|
2328
2574
|
}
|
|
2575
|
+
// Only invoices with a real (generated) invoice number count toward the
|
|
2576
|
+
// Outstanding / Overdue / Pending Payment cards. Pre-finance drafts carry
|
|
2577
|
+
// a temporary DRAFT- number and are excluded until the real INV/TINV
|
|
2578
|
+
// number is minted at finance approval.
|
|
2579
|
+
if ( String( inv.invoice || '' ).startsWith( 'DRAFT-' ) ) {
|
|
2580
|
+
continue;
|
|
2581
|
+
}
|
|
2329
2582
|
const fx = rateByCurrency[String( inv.currency || 'inr' )] || 1;
|
|
2330
2583
|
const total = Number( inv.totalAmount ) || Number( inv.amount ) || 0;
|
|
2331
2584
|
const paid = Number( inv.paidAmount ) || 0;
|
|
@@ -2803,6 +3056,15 @@ export async function updateInvoice( req, res ) {
|
|
|
2803
3056
|
return res.sendError( 'Cannot edit a final-approved invoice.', 409 );
|
|
2804
3057
|
}
|
|
2805
3058
|
|
|
3059
|
+
// A Purchase Order can only be attached once the invoice has a real
|
|
3060
|
+
// number (minted at finance approval). Reject PO attach on a DRAFT-.
|
|
3061
|
+
if ( req.body.purchaseOrderNumber && String( invoice.invoice || '' ).startsWith( 'DRAFT-' ) ) {
|
|
3062
|
+
return res.sendError(
|
|
3063
|
+
'Attach a PO only after the invoice number is finalised (finance approval).',
|
|
3064
|
+
409,
|
|
3065
|
+
);
|
|
3066
|
+
}
|
|
3067
|
+
|
|
2806
3068
|
let updateData = {};
|
|
2807
3069
|
const allowedFields = [
|
|
2808
3070
|
'companyName', 'companyAddress', 'GSTNumber', 'PlaceOfSupply',
|
|
@@ -2930,10 +3192,13 @@ export async function deleteInvoice( req, res ) {
|
|
|
2930
3192
|
return res.sendError( 'Invoice not found', 404 );
|
|
2931
3193
|
}
|
|
2932
3194
|
|
|
2933
|
-
//
|
|
2934
|
-
//
|
|
2935
|
-
|
|
2936
|
-
|
|
3195
|
+
// Once a real invoice number has been minted (at finance approval), the
|
|
3196
|
+
// invoice can no longer be deleted — deleting it would reopen a gap in the
|
|
3197
|
+
// gapless INV/TINV series. Only pre-finance drafts (number still starts
|
|
3198
|
+
// with DRAFT-) are deletable. This supersedes the old approved-only lock
|
|
3199
|
+
// (a pendingApproval invoice already carries a real number).
|
|
3200
|
+
if ( !String( invoice.invoice || '' ).startsWith( 'DRAFT-' ) ) {
|
|
3201
|
+
return res.sendError( 'Cannot delete an invoice once its invoice number has been generated.', 409 );
|
|
2937
3202
|
}
|
|
2938
3203
|
|
|
2939
3204
|
await invoiceService.deleteRecord( { _id: invoiceId } );
|
|
@@ -2978,7 +3243,29 @@ async function transitionInvoiceStatus( req, res, fromStatus, toStatus ) {
|
|
|
2978
3243
|
);
|
|
2979
3244
|
}
|
|
2980
3245
|
|
|
2981
|
-
|
|
3246
|
+
// Mint the real INV/TINV number at finance approval. Before this, the
|
|
3247
|
+
// invoice carries a temporary DRAFT-<FY>-<seq> number; here we assign the
|
|
3248
|
+
// next real number = max(invoiceIndex among ^prefix<FY>-)+1. Guarded on the
|
|
3249
|
+
// DRAFT- prefix so an already-real invoice (existing in-flight, or a
|
|
3250
|
+
// re-run) is never renumbered — idempotent.
|
|
3251
|
+
const update = { status: toStatus };
|
|
3252
|
+
if ( fromStatus === 'pendingFinance' && String( invoice.invoice || '' ).startsWith( 'DRAFT-' ) ) {
|
|
3253
|
+
const FY = getCurrentFinancialYear();
|
|
3254
|
+
const prefix = invoicePrefixFor( invoice.advanceInvoice, invoice.advancePeriod );
|
|
3255
|
+
const previous = await invoiceService.findandsort(
|
|
3256
|
+
{ invoice: { $regex: `^${prefix}${FY}-` } },
|
|
3257
|
+
{},
|
|
3258
|
+
{ invoiceIndex: -1 },
|
|
3259
|
+
);
|
|
3260
|
+
let realNo = 1;
|
|
3261
|
+
if ( previous && previous.length > 0 && previous[0].invoiceIndex != null ) {
|
|
3262
|
+
realNo = Number( previous[0].invoiceIndex ) + 1;
|
|
3263
|
+
}
|
|
3264
|
+
update.invoice = `${prefix}${FY}-${String( realNo ).padStart( 5, '0' )}`;
|
|
3265
|
+
update.invoiceIndex = realNo;
|
|
3266
|
+
update.tempIndex = null;
|
|
3267
|
+
}
|
|
3268
|
+
await invoiceService.updateOne( { _id: invoiceId }, update );
|
|
2982
3269
|
|
|
2983
3270
|
insertOpenSearchData( JSON.parse( process.env.OPENSEARCH ).activityLog, {
|
|
2984
3271
|
userName: req.user?.userName,
|
|
@@ -2987,13 +3274,13 @@ async function transitionInvoiceStatus( req, res, fromStatus, toStatus ) {
|
|
|
2987
3274
|
logSubType: 'invoiceStatusTransition',
|
|
2988
3275
|
logType: 'invoice',
|
|
2989
3276
|
date: new Date(),
|
|
2990
|
-
changes: [ `Invoice ${invoice.invoice} advanced from ${fromStatus} to ${toStatus} by ${req.user?.email}` ],
|
|
3277
|
+
changes: [ `Invoice ${update.invoice || invoice.invoice} advanced from ${fromStatus} to ${toStatus} by ${req.user?.email}` ],
|
|
2991
3278
|
eventType: '',
|
|
2992
3279
|
timestamp: new Date(),
|
|
2993
3280
|
showTo: [ 'tango' ],
|
|
2994
3281
|
} );
|
|
2995
3282
|
|
|
2996
|
-
return res.sendSuccess( { invoiceId, fromStatus, status: toStatus } );
|
|
3283
|
+
return res.sendSuccess( { invoiceId, fromStatus, status: toStatus, invoice: update.invoice || invoice.invoice } );
|
|
2997
3284
|
} catch ( error ) {
|
|
2998
3285
|
logger.error( { error: error, function: 'transitionInvoiceStatus', fromStatus, toStatus, invoiceId: req.body?.invoiceId } );
|
|
2999
3286
|
return res.sendError( error, 500 );
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head><meta charset="utf-8" /><meta content="width=device-width" name="viewport" />
|
|
4
|
+
<title>Invoices from Tango Eye</title></head>
|
|
5
|
+
<body style="margin:0;padding:0;background-color:#b1b1b1;font-family:Inter,Arial,sans-serif;">
|
|
6
|
+
<table width="100%" cellpadding="0" cellspacing="0" border="0"><tr><td align="center">
|
|
7
|
+
<table bgcolor="#ffffff" width="640" cellspacing="0" cellpadding="0" border="0"
|
|
8
|
+
style="margin:0 auto;border:1px solid #d0d5dd;border-radius:12px;">
|
|
9
|
+
<tr><td style="padding:40px 52px;">
|
|
10
|
+
<img src="{{logo}}" width="143" style="display:block;margin-bottom:24px;" />
|
|
11
|
+
<div style="color:#121a26;font-weight:700;font-size:24px;line-height:140%;">
|
|
12
|
+
Invoices from Tango Eye — attached (ZIP)
|
|
13
|
+
</div>
|
|
14
|
+
<div style="height:24px;"></div>
|
|
15
|
+
<div style="color:#384860;font-size:16px;line-height:150%;">
|
|
16
|
+
Dear {{clientName}},<br><br>
|
|
17
|
+
Thank you for choosing Tango Eye services.<br><br>
|
|
18
|
+
Attached are your invoices for the services provided by Tango Eye. The total amount due
|
|
19
|
+
across all invoices is <b>{{currencyType}}{{totalIncl}}</b>. Please refer to the summary
|
|
20
|
+
below for individual invoice amounts.
|
|
21
|
+
</div>
|
|
22
|
+
<div style="height:24px;"></div>
|
|
23
|
+
|
|
24
|
+
<div style="background-color:#f1f5f9;border:1px solid #e2e8f0;border-radius:8px;padding:20px 28px;">
|
|
25
|
+
<div style="color:#121a26;font-weight:700;font-size:18px;">Virtual Account Details</div>
|
|
26
|
+
<div style="height:12px;"></div>
|
|
27
|
+
<div style="color:#121a26;font-weight:500;font-size:16px;">Account Number - {{virtualaccountNumber}}</div>
|
|
28
|
+
<div style="color:#121a26;font-weight:500;font-size:16px;">IFSC Code - {{virtualifsc}}</div>
|
|
29
|
+
</div>
|
|
30
|
+
<div style="height:24px;"></div>
|
|
31
|
+
|
|
32
|
+
<table width="100%" cellspacing="0" cellpadding="0" border="0" style="border-collapse:collapse;">
|
|
33
|
+
<tr style="background-color:#f9fafb;">
|
|
34
|
+
<td style="padding:12px 16px;color:#667085;font-weight:600;font-size:14px;border-bottom:1px solid #eaecf0;">Invoice Number</td>
|
|
35
|
+
<td align="right" style="padding:12px 16px;color:#667085;font-weight:600;font-size:14px;border-bottom:1px solid #eaecf0;">Amount (excl. tax)</td>
|
|
36
|
+
<td align="right" style="padding:12px 16px;color:#667085;font-weight:600;font-size:14px;border-bottom:1px solid #eaecf0;">Amount (incl. tax)</td>
|
|
37
|
+
</tr>
|
|
38
|
+
{{#each rows}}
|
|
39
|
+
<tr>
|
|
40
|
+
<td style="padding:12px 16px;color:#475467;font-size:14px;border-bottom:1px solid #eaecf0;">#{{this.invoice}}</td>
|
|
41
|
+
<td align="right" style="padding:12px 16px;color:#475467;font-size:14px;border-bottom:1px solid #eaecf0;">{{../currencyType}}{{this.amountExcl}}</td>
|
|
42
|
+
<td align="right" style="padding:12px 16px;color:#475467;font-size:14px;border-bottom:1px solid #eaecf0;">{{../currencyType}}{{this.amountIncl}}</td>
|
|
43
|
+
</tr>
|
|
44
|
+
{{/each}}
|
|
45
|
+
<tr>
|
|
46
|
+
<td style="padding:12px 16px;color:#121a26;font-weight:700;font-size:14px;">Total</td>
|
|
47
|
+
<td align="right" style="padding:12px 16px;color:#121a26;font-weight:700;font-size:14px;">{{currencyType}}{{totalExcl}}</td>
|
|
48
|
+
<td align="right" style="padding:12px 16px;color:#121a26;font-weight:700;font-size:14px;">{{currencyType}}{{totalIncl}}</td>
|
|
49
|
+
</tr>
|
|
50
|
+
</table>
|
|
51
|
+
<div style="height:24px;"></div>
|
|
52
|
+
|
|
53
|
+
<div style="color:#384860;font-size:16px;line-height:150%;">
|
|
54
|
+
Currently, we do not support RBL to RBL Internal Fund Transfer (IIFT) transactions on the
|
|
55
|
+
above account number.<br><br>
|
|
56
|
+
Cheque deposits are not available for the above account number.
|
|
57
|
+
</div>
|
|
58
|
+
<div style="height:24px;"></div>
|
|
59
|
+
|
|
60
|
+
<div style="color:#384860;font-size:16px;line-height:150%;">
|
|
61
|
+
Should you have any inquiries or require assistance, feel free to reach out.<br><br>
|
|
62
|
+
Thank you for your prompt attention to this matter.<br><br>
|
|
63
|
+
Best regards,<br><br>Team Tango
|
|
64
|
+
</div>
|
|
65
|
+
<div style="height:24px;border-top:1px solid #eaecf0;margin-top:24px;"></div>
|
|
66
|
+
<div style="color:#202b3c;font-size:12px;">2024 © Tango Eye. All rights reserved.</div>
|
|
67
|
+
</td></tr>
|
|
68
|
+
</table>
|
|
69
|
+
</td></tr></table>
|
|
70
|
+
</body></html>
|