tango-app-api-payment-subscription 3.5.29 → 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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tango-app-api-payment-subscription",
|
|
3
|
-
"version": "3.5.
|
|
3
|
+
"version": "3.5.30",
|
|
4
4
|
"description": "paymentSubscription",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"type": "module",
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
"nodemon": "^3.1.0",
|
|
30
30
|
"puppeteer": "^24.41.0",
|
|
31
31
|
"swagger-ui-express": "^5.0.0",
|
|
32
|
-
"tango-api-schema": "^2.6.
|
|
32
|
+
"tango-api-schema": "^2.6.46",
|
|
33
33
|
"tango-app-api-middleware": "^3.6.18",
|
|
34
34
|
"winston": "^3.12.0",
|
|
35
35
|
"winston-daily-rotate-file": "^5.0.0",
|
|
@@ -641,6 +641,13 @@ export async function brandInvoiceList( req, res ) {
|
|
|
641
641
|
if ( inv.paymentStatus === 'paid' ) {
|
|
642
642
|
continue;
|
|
643
643
|
}
|
|
644
|
+
// Only invoices with a real (generated) invoice number count toward the
|
|
645
|
+
// Outstanding / Overdue / Pending Payment cards. Pre-finance drafts carry
|
|
646
|
+
// a temporary DRAFT- number and must be excluded until the real INV/TINV
|
|
647
|
+
// number is minted at finance approval.
|
|
648
|
+
if ( String( inv.invoice || '' ).startsWith( 'DRAFT-' ) ) {
|
|
649
|
+
continue;
|
|
650
|
+
}
|
|
644
651
|
const excl = toInr( inv, inv.amount );
|
|
645
652
|
const incl = toInr( inv, inv.totalAmount || inv.amount );
|
|
646
653
|
cards.outstandingExclAmount += excl;
|
|
@@ -2042,6 +2049,14 @@ export async function billingSummary( req, res ) {
|
|
|
2042
2049
|
{ $match: {
|
|
2043
2050
|
status: 'approved',
|
|
2044
2051
|
paymentStatus: { $in: [ 'unpaid', 'partial', 'due' ] },
|
|
2052
|
+
// Outstanding aging counts only regular invoices. Advance invoices use
|
|
2053
|
+
// the separate TINV- series (quarterly/half-yearly/yearly) and must be
|
|
2054
|
+
// excluded so their balances don't inflate the aging buckets/outstanding
|
|
2055
|
+
// total. Matches the invoice-list 'all' filter idiom. A missing invoice
|
|
2056
|
+
// # is treated as a regular invoice.
|
|
2057
|
+
$expr: { $not: { $regexMatch: {
|
|
2058
|
+
input: { $ifNull: [ '$invoice', '' ] }, regex: '^TINV-',
|
|
2059
|
+
} } },
|
|
2045
2060
|
} },
|
|
2046
2061
|
{ $addFields: {
|
|
2047
2062
|
dueDateD: { $cond: [
|
|
@@ -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 ) {
|
|
@@ -2342,6 +2572,13 @@ export async function clientInvoiceList( req, res ) {
|
|
|
2342
2572
|
if ( inv.paymentStatus === 'paid' ) {
|
|
2343
2573
|
continue;
|
|
2344
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
|
+
}
|
|
2345
2582
|
const fx = rateByCurrency[String( inv.currency || 'inr' )] || 1;
|
|
2346
2583
|
const total = Number( inv.totalAmount ) || Number( inv.amount ) || 0;
|
|
2347
2584
|
const paid = Number( inv.paidAmount ) || 0;
|
|
@@ -2819,6 +3056,15 @@ export async function updateInvoice( req, res ) {
|
|
|
2819
3056
|
return res.sendError( 'Cannot edit a final-approved invoice.', 409 );
|
|
2820
3057
|
}
|
|
2821
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
|
+
|
|
2822
3068
|
let updateData = {};
|
|
2823
3069
|
const allowedFields = [
|
|
2824
3070
|
'companyName', 'companyAddress', 'GSTNumber', 'PlaceOfSupply',
|
|
@@ -2946,10 +3192,13 @@ export async function deleteInvoice( req, res ) {
|
|
|
2946
3192
|
return res.sendError( 'Invoice not found', 404 );
|
|
2947
3193
|
}
|
|
2948
3194
|
|
|
2949
|
-
//
|
|
2950
|
-
//
|
|
2951
|
-
|
|
2952
|
-
|
|
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 );
|
|
2953
3202
|
}
|
|
2954
3203
|
|
|
2955
3204
|
await invoiceService.deleteRecord( { _id: invoiceId } );
|
|
@@ -2994,7 +3243,29 @@ async function transitionInvoiceStatus( req, res, fromStatus, toStatus ) {
|
|
|
2994
3243
|
);
|
|
2995
3244
|
}
|
|
2996
3245
|
|
|
2997
|
-
|
|
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 );
|
|
2998
3269
|
|
|
2999
3270
|
insertOpenSearchData( JSON.parse( process.env.OPENSEARCH ).activityLog, {
|
|
3000
3271
|
userName: req.user?.userName,
|
|
@@ -3003,13 +3274,13 @@ async function transitionInvoiceStatus( req, res, fromStatus, toStatus ) {
|
|
|
3003
3274
|
logSubType: 'invoiceStatusTransition',
|
|
3004
3275
|
logType: 'invoice',
|
|
3005
3276
|
date: new Date(),
|
|
3006
|
-
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}` ],
|
|
3007
3278
|
eventType: '',
|
|
3008
3279
|
timestamp: new Date(),
|
|
3009
3280
|
showTo: [ 'tango' ],
|
|
3010
3281
|
} );
|
|
3011
3282
|
|
|
3012
|
-
return res.sendSuccess( { invoiceId, fromStatus, status: toStatus } );
|
|
3283
|
+
return res.sendSuccess( { invoiceId, fromStatus, status: toStatus, invoice: update.invoice || invoice.invoice } );
|
|
3013
3284
|
} catch ( error ) {
|
|
3014
3285
|
logger.error( { error: error, function: 'transitionInvoiceStatus', fromStatus, toStatus, invoiceId: req.body?.invoiceId } );
|
|
3015
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>
|