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.
@@ -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
- // Quarterly/half-yearly/yearly advance invoices use the separate TINV-
132
- // series with its own per-FY counter; monthly advance + normal stay INV-.
133
- const invPrefix = invoicePrefixFor( req.body.advanceInvoice, req.body.advancePeriod );
134
- const previousinvoice = await invoiceService.findandsort(
135
- { invoice: { $regex: `^${invPrefix}${Finacialyear}-` } },
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: `${invPrefix}${Finacialyear}-${invoiceNo}`,
177
- invoiceIndex: invoiceNo,
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 this invoice is mapped to (optional).
202
- purchaseOrderNumber: req.body.purchaseOrderNumber || undefined,
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 with its own per-FY counter; monthly advance + normal stay INV-.
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
- // Scope the highest-index lookup to invoices created in the current FY for
271
- // THIS series. Without the FY scope the new FY would continue the previous
272
- // year's sequence; without the prefix scope the two series would share one
273
- // counter.
274
- let previousinvoice = await invoiceService.findandsort(
275
- { invoice: { $regex: `^${invPrefix}${Finacialyear}-` } },
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 : `${invPrefix}${Finacialyear}-${invoiceNo}`,
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 : invoiceNo,
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 = new Date( dayjs().startOf( 'month' ).format( 'YYYY-MM-DD' ) );
2083
- filterEndDate = new Date( dayjs().endOf( 'month' ).format( 'YYYY-MM-DD' ) );
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 = new Date( dayjs().subtract( 1, 'month' ).startOf( 'month' ).format( 'YYYY-MM-DD' ) );
2087
- filterEndDate = new Date( dayjs().subtract( 1, 'month' ).endOf( 'month' ).format( 'YYYY-MM-DD' ) );
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 = new Date( dayjs().subtract( 3, 'month' ).startOf( 'month' ).format( 'YYYY-MM-DD' ) );
2095
- filterEndDate = new Date( dayjs().endOf( 'month' ).format( 'YYYY-MM-DD' ) );
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 = new Date( dayjs().subtract( 6, 'month' ).startOf( 'month' ).format( 'YYYY-MM-DD' ) );
2099
- filterEndDate = new Date( dayjs().endOf( 'month' ).format( 'YYYY-MM-DD' ) );
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 = new Date( dayjs().subtract( 12, 'month' ).startOf( 'month' ).format( 'YYYY-MM-DD' ) );
2103
- filterEndDate = new Date( dayjs().endOf( 'month' ).format( 'YYYY-MM-DD' ) );
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
- $and: [
2110
- { billingDate: { $gte: filterStartDate } },
2111
- { billingDate: { $lte: filterEndDate } },
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
- // Same lock the UI enforces a final-approved invoice must not be
2934
- // deletable, even by a power user hitting the API directly.
2935
- if ( invoice.status === 'approved' ) {
2936
- return res.sendError( 'Cannot delete a final-approved invoice.', 409 );
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
- await invoiceService.updateOne( { _id: invoiceId }, { status: toStatus } );
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>