tango-app-api-payment-subscription 3.5.29 → 3.5.31

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.29",
3
+ "version": "3.5.31",
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.43",
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: [
@@ -15,7 +15,7 @@ import * as basepricingService from '../services/basePrice.service.js';
15
15
  import * as paymentAccountService from '../services/paymentAccount.service.js';
16
16
  import { symbolFor, roundAmount, wordFor } from '../utils/currency.js';
17
17
  import { invoiceStatusEnum } from '../dtos/validation.dtos.js';
18
- import { findOneApplicationDefault } from '../services/applicationDefault.service.js';
18
+ import { findOneApplicationDefault, nextCounterValue } from '../services/applicationDefault.service.js';
19
19
  import * as assignedStoreService from '../services/assignedStore.service.js';
20
20
  import * as bankTransactionService from '../services/bankTransaction.service.js';
21
21
  import { getInrRate, getAdditionalProducts } from './brandsBilling.controller.js';
@@ -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 ) {
@@ -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
- // Same lock the UI enforces a final-approved invoice must not be
2950
- // deletable, even by a power user hitting the API directly.
2951
- if ( invoice.status === 'approved' ) {
2952
- 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 );
2953
3202
  }
2954
3203
 
2955
3204
  await invoiceService.deleteRecord( { _id: invoiceId } );
@@ -2994,7 +3243,43 @@ async function transitionInvoiceStatus( req, res, fromStatus, toStatus ) {
2994
3243
  );
2995
3244
  }
2996
3245
 
2997
- 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
+ // Reserve the next number ATOMICALLY. Bulk finance-approval fires many
3256
+ // requests in parallel; the old read-max-then-write approach let them all
3257
+ // read the same max and mint the SAME number. nextCounterValue uses an
3258
+ // atomic $inc on a per-series counter, so simultaneous callers each get a
3259
+ // distinct number. The counter is lazily seeded (once, on first use) from
3260
+ // the current DB max of this prefix+FY series so existing numbers are
3261
+ // never reused.
3262
+ // Numeric max of existing invoiceIndex for this series. invoiceIndex is a
3263
+ // STRING in the schema and historical values are zero-padded ('00569'),
3264
+ // so a Mongo sort would compare lexically — compute the true numeric max
3265
+ // in JS instead. Used only to seed the counter on its first use.
3266
+ const previous = await invoiceService.find(
3267
+ { invoice: { $regex: `^${prefix}${FY}-` } },
3268
+ { invoiceIndex: 1 },
3269
+ );
3270
+ let dbMax = 0;
3271
+ for ( const p of ( previous || [] ) ) {
3272
+ const n = Number( p.invoiceIndex );
3273
+ if ( !Number.isNaN( n ) && n > dbMax ) {
3274
+ dbMax = n;
3275
+ }
3276
+ }
3277
+ const realNo = await nextCounterValue( 'invoiceCounter', `${prefix}${FY}`, dbMax );
3278
+ update.invoice = `${prefix}${FY}-${String( realNo ).padStart( 5, '0' )}`;
3279
+ update.invoiceIndex = realNo;
3280
+ update.tempIndex = null;
3281
+ }
3282
+ await invoiceService.updateOne( { _id: invoiceId }, update );
2998
3283
 
2999
3284
  insertOpenSearchData( JSON.parse( process.env.OPENSEARCH ).activityLog, {
3000
3285
  userName: req.user?.userName,
@@ -3003,13 +3288,13 @@ async function transitionInvoiceStatus( req, res, fromStatus, toStatus ) {
3003
3288
  logSubType: 'invoiceStatusTransition',
3004
3289
  logType: 'invoice',
3005
3290
  date: new Date(),
3006
- changes: [ `Invoice ${invoice.invoice} advanced from ${fromStatus} to ${toStatus} by ${req.user?.email}` ],
3291
+ changes: [ `Invoice ${update.invoice || invoice.invoice} advanced from ${fromStatus} to ${toStatus} by ${req.user?.email}` ],
3007
3292
  eventType: '',
3008
3293
  timestamp: new Date(),
3009
3294
  showTo: [ 'tango' ],
3010
3295
  } );
3011
3296
 
3012
- return res.sendSuccess( { invoiceId, fromStatus, status: toStatus } );
3297
+ return res.sendSuccess( { invoiceId, fromStatus, status: toStatus, invoice: update.invoice || invoice.invoice } );
3013
3298
  } catch ( error ) {
3014
3299
  logger.error( { error: error, function: 'transitionInvoiceStatus', fromStatus, toStatus, invoiceId: req.body?.invoiceId } );
3015
3300
  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>