tango-app-api-payment-subscription 3.5.7 → 3.5.9
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 +2 -2
- package/scripts/seed-payment-reminders.js +82 -0
- package/scripts/send-reminder-test-emails.js +70 -0
- package/src/controllers/brandsBilling.controller.js +136 -13
- package/src/controllers/estimate.controller.js +203 -67
- package/src/controllers/invoice.controller.js +93 -13
- package/src/controllers/paymentReminderTrigger.controller.js +194 -0
- package/src/hbs/estimateEmail.hbs +78 -0
- package/src/hbs/estimatePdf.hbs +1632 -118
- package/src/hbs/invoicePdf.hbs +1711 -1779
- package/src/hbs/partials/invoiceSummaryTable.hbs +33 -0
- package/src/hbs/reminderBeforeDue.hbs +62 -0
- package/src/hbs/reminderDeactivated.hbs +62 -0
- package/src/hbs/reminderOnDue.hbs +62 -0
- package/src/hbs/reminderOnHold.hbs +62 -0
- package/src/hbs/reminderSuspended.hbs +62 -0
- package/src/routes/billing.routes.js +5 -0
- package/src/routes/invoice.routes.js +5 -2
- package/src/services/estimate.service.js +4 -0
- package/src/services/paymentReminder.service.js +4 -0
- package/src/utils/currency.js +1 -0
|
@@ -1,12 +1,77 @@
|
|
|
1
1
|
import * as estimateService from '../services/estimate.service.js';
|
|
2
2
|
import * as clientService from '../services/clientPayment.services.js';
|
|
3
|
+
import * as billingService from '../services/billing.service.js';
|
|
3
4
|
import dayjs from 'dayjs';
|
|
4
|
-
import { logger, download } from 'tango-app-api-middleware';
|
|
5
|
+
import { logger, download, sendEmailWithSES } from 'tango-app-api-middleware';
|
|
5
6
|
import Handlebars from '../utils/validations/helper/handlebar.helper.js';
|
|
6
7
|
import fs from 'fs';
|
|
7
8
|
import path from 'path';
|
|
8
9
|
import htmlpdf from 'html-pdf-node';
|
|
9
10
|
import { symbolFor } from '../utils/currency.js';
|
|
11
|
+
import { getInvoiceCcEmails } from './invoice.controller.js';
|
|
12
|
+
|
|
13
|
+
// Build the estimate PDF buffer + the template data + a safe filename. Shared
|
|
14
|
+
// by the download and send flows so both render the identical document.
|
|
15
|
+
async function buildEstimatePdf( estimate ) {
|
|
16
|
+
const e = estimate._doc || estimate;
|
|
17
|
+
const currencyType = symbolFor( e.currency );
|
|
18
|
+
const fmt = ( n ) => Number( n || 0 ).toLocaleString( 'en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 } );
|
|
19
|
+
|
|
20
|
+
const products = ( e.products || [] ).map( ( p, i ) => {
|
|
21
|
+
let name = String( p.productName || '' ).replace( /([a-z])([A-Z])/g, '$1 $2' );
|
|
22
|
+
name = name.charAt( 0 ).toUpperCase() + name.slice( 1 );
|
|
23
|
+
return {
|
|
24
|
+
index: i + 1,
|
|
25
|
+
productName: name,
|
|
26
|
+
description: p.description || '',
|
|
27
|
+
hsn: p.hsn || p.hsnCode || '998314',
|
|
28
|
+
storeCount: p.storeCount || e.stores || '',
|
|
29
|
+
price: fmt( p.price ),
|
|
30
|
+
amount: fmt( p.amount ),
|
|
31
|
+
};
|
|
32
|
+
} );
|
|
33
|
+
const tax = ( e.tax || [] ).map( ( t ) => ( {
|
|
34
|
+
type: t.type || t.taxName || t.name || 'GST',
|
|
35
|
+
value: t.value ?? t.taxPercentage ?? t.percentage ?? '',
|
|
36
|
+
taxAmount: fmt( t.taxAmount ),
|
|
37
|
+
} ) );
|
|
38
|
+
|
|
39
|
+
const statusLabelMap = { pending: 'Pending', sent: 'Sent', accepted: 'Accepted', declined: 'Declined', expired: 'Expired' };
|
|
40
|
+
const data = {
|
|
41
|
+
estimate: e.estimate,
|
|
42
|
+
status: e.status,
|
|
43
|
+
statusLabel: statusLabelMap[e.status] || e.status,
|
|
44
|
+
companyName: e.companyName || '',
|
|
45
|
+
companyAddress: e.companyAddress || '',
|
|
46
|
+
GSTNumber: e.GSTNumber || '',
|
|
47
|
+
PlaceOfSupply: e.PlaceOfSupply || '',
|
|
48
|
+
groupName: e.groupName || '',
|
|
49
|
+
period: e.period || '',
|
|
50
|
+
createdDate: e.createdDate ? dayjs( e.createdDate ).format( 'DD/MM/YYYY' ) : '',
|
|
51
|
+
validTill: e.validTill ? dayjs( e.validTill ).format( 'DD/MM/YYYY' ) : '',
|
|
52
|
+
currencyType,
|
|
53
|
+
amount: fmt( e.amount ),
|
|
54
|
+
totalAmount: fmt( e.totalAmount ),
|
|
55
|
+
products,
|
|
56
|
+
tax,
|
|
57
|
+
notes: e.notes || '',
|
|
58
|
+
logo: `${JSON.parse( process.env.URL ).apiDomain}/logo.png`,
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const templateHtml = fs.readFileSync( path.resolve( path.dirname( '' ) ) + '/src/hbs/estimatePdf.hbs', 'utf8' );
|
|
62
|
+
const html = Handlebars.compile( templateHtml )( data );
|
|
63
|
+
const options = {
|
|
64
|
+
executablePath: '/usr/bin/chromium',
|
|
65
|
+
args: [ '--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage', '--no-zygote', '--single-process' ],
|
|
66
|
+
format: 'A4',
|
|
67
|
+
margin: { top: '0.5in', right: '0.5in', bottom: '0.5in', left: '0.5in' },
|
|
68
|
+
printBackground: true,
|
|
69
|
+
preferCSSPageSize: true,
|
|
70
|
+
};
|
|
71
|
+
const pdfBuffer = await htmlpdf.generatePdf( { content: html }, options );
|
|
72
|
+
const filename = ( e.estimate + '-' + ( e.companyName || 'estimate' ) + '.pdf' ).split( '/' ).join( '_' ).split( '"' ).join( '' ).trim();
|
|
73
|
+
return { pdfBuffer, filename, data };
|
|
74
|
+
}
|
|
10
75
|
|
|
11
76
|
// ---------------------------------------------------------------------------
|
|
12
77
|
// Estimates (quotations). A lightweight pre-invoice document with its own
|
|
@@ -43,7 +108,7 @@ async function nextEstimateNumber() {
|
|
|
43
108
|
// flips to 'expired' so the list reflects reality without a cron.
|
|
44
109
|
async function expireOverdue( clientId ) {
|
|
45
110
|
await estimateService.updateOne(
|
|
46
|
-
{ clientId, status: { $in: [ '
|
|
111
|
+
{ clientId, status: { $in: [ 'pending', 'sent' ] }, validTill: { $lt: new Date() } },
|
|
47
112
|
{ $set: { status: 'expired' } },
|
|
48
113
|
);
|
|
49
114
|
}
|
|
@@ -119,7 +184,7 @@ export async function estimateList( req, res ) {
|
|
|
119
184
|
{ $match: { clientId } },
|
|
120
185
|
{ $group: { _id: '$status', count: { $sum: 1 } } },
|
|
121
186
|
] );
|
|
122
|
-
const counts = {
|
|
187
|
+
const counts = { pending: 0, sent: 0, accepted: 0, declined: 0, expired: 0, total: 0 };
|
|
123
188
|
statusAgg.forEach( ( s ) => {
|
|
124
189
|
if ( counts[s._id] != null ) {
|
|
125
190
|
counts[s._id] = s.count;
|
|
@@ -175,7 +240,7 @@ export async function createEstimate( req, res ) {
|
|
|
175
240
|
amount,
|
|
176
241
|
totalAmount,
|
|
177
242
|
currency: b.currency || ( client?.paymentInvoice?.currencyType === 'dollar' ? 'dollar' : 'inr' ),
|
|
178
|
-
status: b.status === 'sent' ? 'sent' : '
|
|
243
|
+
status: b.status === 'sent' ? 'sent' : 'pending',
|
|
179
244
|
createdDate,
|
|
180
245
|
validTill,
|
|
181
246
|
createdBy: req.user?.email || req.user?.userName || '',
|
|
@@ -191,6 +256,69 @@ export async function createEstimate( req, res ) {
|
|
|
191
256
|
}
|
|
192
257
|
}
|
|
193
258
|
|
|
259
|
+
export async function updateEstimate( req, res ) {
|
|
260
|
+
try {
|
|
261
|
+
const b = req.body || {};
|
|
262
|
+
const estimateId = b._id || b.estimateId || req.params.estimateId;
|
|
263
|
+
if ( !estimateId ) {
|
|
264
|
+
return res.sendError( 'estimateId is required', 400 );
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const existing = await estimateService.findOne( { _id: estimateId } );
|
|
268
|
+
if ( !existing ) {
|
|
269
|
+
return res.sendError( 'Estimate not found', 404 );
|
|
270
|
+
}
|
|
271
|
+
// Accepted estimates are locked — mirrors the delete guard.
|
|
272
|
+
if ( ( existing._doc || existing ).status === 'accepted' ) {
|
|
273
|
+
return res.sendError( 'An accepted estimate cannot be edited.', 409 );
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const amount = Math.round( Number( b.amount ) || 0 );
|
|
277
|
+
let totalAmount = Math.round( Number( b.totalAmount ) || 0 );
|
|
278
|
+
if ( !totalAmount && amount ) {
|
|
279
|
+
totalAmount = Math.round( amount * 1.18 );
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// Only the editable fields are updated. The estimate number, index,
|
|
283
|
+
// clientId and createdDate are immutable so the document keeps its
|
|
284
|
+
// identity and financial-year sequence.
|
|
285
|
+
const update = {
|
|
286
|
+
companyName: b.companyName ?? existing.companyName,
|
|
287
|
+
companyAddress: b.companyAddress ?? existing.companyAddress,
|
|
288
|
+
PlaceOfSupply: b.PlaceOfSupply ?? existing.PlaceOfSupply,
|
|
289
|
+
GSTNumber: b.GSTNumber ?? existing.GSTNumber,
|
|
290
|
+
groupId: b.groupId || existing.groupId,
|
|
291
|
+
groupName: b.groupName || existing.groupName,
|
|
292
|
+
stores: Number( b.stores ) || 0,
|
|
293
|
+
products: Array.isArray( b.products ) ? b.products : existing.products,
|
|
294
|
+
tax: Array.isArray( b.tax ) ? b.tax : existing.tax,
|
|
295
|
+
amount,
|
|
296
|
+
totalAmount,
|
|
297
|
+
currency: b.currency || existing.currency,
|
|
298
|
+
notes: b.notes ?? existing.notes,
|
|
299
|
+
updatedBy: req.user?.email || req.user?.userName || '',
|
|
300
|
+
};
|
|
301
|
+
if ( b.validTill ) {
|
|
302
|
+
update.validTill = new Date( b.validTill );
|
|
303
|
+
}
|
|
304
|
+
if ( b.period ) {
|
|
305
|
+
update.period = b.period;
|
|
306
|
+
}
|
|
307
|
+
// Allow a status nudge (e.g. draft → sent) on save, but never to accepted.
|
|
308
|
+
if ( b.status && [ 'pending', 'sent' ].includes( b.status ) ) {
|
|
309
|
+
update.status = b.status;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
await estimateService.updateOne( { _id: estimateId }, { $set: update } );
|
|
313
|
+
const saved = await estimateService.findOne( { _id: estimateId } );
|
|
314
|
+
logger.info?.( { function: 'updateEstimate', estimateId } );
|
|
315
|
+
return res.sendSuccess( saved );
|
|
316
|
+
} catch ( error ) {
|
|
317
|
+
logger.error( { error: error, function: 'updateEstimate' } );
|
|
318
|
+
return res.sendError( error, 500 );
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
194
322
|
export async function getEstimate( req, res ) {
|
|
195
323
|
try {
|
|
196
324
|
const estimate = await estimateService.findOne( { _id: req.params.estimateId } );
|
|
@@ -207,7 +335,7 @@ export async function getEstimate( req, res ) {
|
|
|
207
335
|
export async function estimateStatusUpdate( req, res ) {
|
|
208
336
|
try {
|
|
209
337
|
const { estimateId, status } = req.body || {};
|
|
210
|
-
const allowed = [ '
|
|
338
|
+
const allowed = [ 'pending', 'sent', 'accepted', 'declined', 'expired' ];
|
|
211
339
|
if ( !estimateId || !allowed.includes( status ) ) {
|
|
212
340
|
return res.sendError( 'estimateId and a valid status are required', 400 );
|
|
213
341
|
}
|
|
@@ -230,66 +358,7 @@ export async function downloadEstimate( req, res ) {
|
|
|
230
358
|
if ( !estimate ) {
|
|
231
359
|
return res.sendError( 'Estimate not found', 404 );
|
|
232
360
|
}
|
|
233
|
-
const
|
|
234
|
-
const currencyType = symbolFor( e.currency );
|
|
235
|
-
const fmt = ( n ) => Number( n || 0 ).toLocaleString( 'en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 } );
|
|
236
|
-
|
|
237
|
-
const products = ( e.products || [] ).map( ( p, i ) => {
|
|
238
|
-
let name = String( p.productName || '' ).replace( /([a-z])([A-Z])/g, '$1 $2' );
|
|
239
|
-
name = name.charAt( 0 ).toUpperCase() + name.slice( 1 );
|
|
240
|
-
return {
|
|
241
|
-
index: i + 1,
|
|
242
|
-
productName: name,
|
|
243
|
-
description: p.description || '',
|
|
244
|
-
hsn: p.hsn || p.hsnCode || '998314',
|
|
245
|
-
storeCount: p.storeCount || e.stores || '',
|
|
246
|
-
price: fmt( p.price ),
|
|
247
|
-
amount: fmt( p.amount ),
|
|
248
|
-
};
|
|
249
|
-
} );
|
|
250
|
-
const tax = ( e.tax || [] ).map( ( t ) => ( {
|
|
251
|
-
type: t.type || t.taxName || t.name || 'GST',
|
|
252
|
-
value: t.value ?? t.taxPercentage ?? t.percentage ?? '',
|
|
253
|
-
taxAmount: fmt( t.taxAmount ),
|
|
254
|
-
} ) );
|
|
255
|
-
|
|
256
|
-
const statusLabelMap = { draft: 'Draft', sent: 'Sent', accepted: 'Accepted', declined: 'Declined', expired: 'Expired' };
|
|
257
|
-
const data = {
|
|
258
|
-
estimate: e.estimate,
|
|
259
|
-
status: e.status,
|
|
260
|
-
statusLabel: statusLabelMap[e.status] || e.status,
|
|
261
|
-
companyName: e.companyName || '',
|
|
262
|
-
companyAddress: e.companyAddress || '',
|
|
263
|
-
GSTNumber: e.GSTNumber || '',
|
|
264
|
-
PlaceOfSupply: e.PlaceOfSupply || '',
|
|
265
|
-
groupName: e.groupName || '',
|
|
266
|
-
period: e.period || '',
|
|
267
|
-
createdDate: e.createdDate ? dayjs( e.createdDate ).format( 'DD/MM/YYYY' ) : '',
|
|
268
|
-
validTill: e.validTill ? dayjs( e.validTill ).format( 'DD/MM/YYYY' ) : '',
|
|
269
|
-
currencyType,
|
|
270
|
-
amount: fmt( e.amount ),
|
|
271
|
-
totalAmount: fmt( e.totalAmount ),
|
|
272
|
-
products,
|
|
273
|
-
tax,
|
|
274
|
-
notes: e.notes || '',
|
|
275
|
-
logo: `${JSON.parse( process.env.URL ).apiDomain}/logo.png`,
|
|
276
|
-
};
|
|
277
|
-
|
|
278
|
-
const templateHtml = fs.readFileSync( path.resolve( path.dirname( '' ) ) + '/src/hbs/estimatePdf.hbs', 'utf8' );
|
|
279
|
-
const template = Handlebars.compile( templateHtml );
|
|
280
|
-
const html = template( data );
|
|
281
|
-
const file = { content: html };
|
|
282
|
-
const options = {
|
|
283
|
-
executablePath: '/usr/bin/chromium',
|
|
284
|
-
args: [ '--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage', '--no-zygote', '--single-process' ],
|
|
285
|
-
format: 'A4',
|
|
286
|
-
margin: { top: '0.5in', right: '0.5in', bottom: '0.5in', left: '0.5in' },
|
|
287
|
-
printBackground: true,
|
|
288
|
-
preferCSSPageSize: true,
|
|
289
|
-
};
|
|
290
|
-
const pdfBuffer = await htmlpdf.generatePdf( file, options );
|
|
291
|
-
|
|
292
|
-
const filename = ( e.estimate + '-' + ( e.companyName || 'estimate' ) + '.pdf' ).split( '/' ).join( '_' ).split( '"' ).join( '' ).trim();
|
|
361
|
+
const { pdfBuffer, filename } = await buildEstimatePdf( estimate );
|
|
293
362
|
res.set( 'Content-Type', 'application/pdf' );
|
|
294
363
|
res.set( 'Content-Disposition', `attachment; filename="${filename}"` );
|
|
295
364
|
return res.send( pdfBuffer );
|
|
@@ -299,6 +368,71 @@ export async function downloadEstimate( req, res ) {
|
|
|
299
368
|
}
|
|
300
369
|
}
|
|
301
370
|
|
|
371
|
+
// Approve & send an estimate: render the PDF, email it (with the PDF attached)
|
|
372
|
+
// to the billing group's recipients — same recipient logic as invoice send —
|
|
373
|
+
// then flip the status to 'sent'. Triggered by the Approve action's confirm.
|
|
374
|
+
export async function sendEstimate( req, res ) {
|
|
375
|
+
try {
|
|
376
|
+
const estimateId = req.body?.estimateId || req.params?.estimateId;
|
|
377
|
+
if ( !estimateId ) {
|
|
378
|
+
return res.sendError( 'estimateId is required', 400 );
|
|
379
|
+
}
|
|
380
|
+
const estimate = await estimateService.findOne( { _id: estimateId } );
|
|
381
|
+
if ( !estimate ) {
|
|
382
|
+
return res.sendError( 'Estimate not found', 404 );
|
|
383
|
+
}
|
|
384
|
+
const e = estimate._doc || estimate;
|
|
385
|
+
if ( e.status === 'accepted' ) {
|
|
386
|
+
return res.sendError( 'An accepted estimate cannot be re-sent.', 409 );
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// Recipients: billing group's generateInvoiceTo (TO) + invoice heads /
|
|
390
|
+
// assigned CSMs (CC) — identical to the invoice send flow.
|
|
391
|
+
let toEmails = [];
|
|
392
|
+
if ( e.groupId ) {
|
|
393
|
+
const group = await billingService.findOne( { _id: e.groupId } );
|
|
394
|
+
toEmails = ( group?.generateInvoiceTo || [] ).map( ( x ) => String( x || '' ).trim() ).filter( Boolean );
|
|
395
|
+
}
|
|
396
|
+
toEmails = [ ...new Set( toEmails ) ];
|
|
397
|
+
if ( !toEmails.length ) {
|
|
398
|
+
return res.sendError( 'To Email not Found — configure recipients on the billing group.', 400 );
|
|
399
|
+
}
|
|
400
|
+
const ccRaw = await getInvoiceCcEmails( e.clientId );
|
|
401
|
+
const toSet = new Set( toEmails.map( ( x ) => x.toLowerCase() ) );
|
|
402
|
+
const ccEmails = [ ...new Set( ( ccRaw || [] ).map( ( x ) => String( x || '' ).trim() ).filter( Boolean ) ) ]
|
|
403
|
+
.filter( ( x ) => !toSet.has( x.toLowerCase() ) );
|
|
404
|
+
|
|
405
|
+
const { pdfBuffer, filename, data } = await buildEstimatePdf( estimate );
|
|
406
|
+
|
|
407
|
+
const client = await clientService.findOne( { clientId: e.clientId }, { clientName: 1 } );
|
|
408
|
+
const emailData = {
|
|
409
|
+
...data,
|
|
410
|
+
clientName: client?.clientName || e.companyName || 'Customer',
|
|
411
|
+
// The email is sent FROM Tango, so the sign-off is the sender — not the
|
|
412
|
+
// client's registered company name carried on the estimate.
|
|
413
|
+
companyName: 'Team Tango',
|
|
414
|
+
};
|
|
415
|
+
const emailHtml = Handlebars.compile(
|
|
416
|
+
fs.readFileSync( path.resolve( path.dirname( '' ) ) + '/src/hbs/estimateEmail.hbs', 'utf8' ),
|
|
417
|
+
)( emailData );
|
|
418
|
+
|
|
419
|
+
const SES = JSON.parse( process.env.SES );
|
|
420
|
+
const fromEmail = SES.accountsEmail || SES.adminEmail;
|
|
421
|
+
const subject = `Estimate ${e.estimate} - Tango/${client?.clientName || e.companyName || ''}`;
|
|
422
|
+
const attachment = { filename, content: pdfBuffer, contentType: 'application/pdf' };
|
|
423
|
+
|
|
424
|
+
await sendEmailWithSES( toEmails, subject, emailHtml, attachment, fromEmail, ccEmails.length ? ccEmails : undefined );
|
|
425
|
+
|
|
426
|
+
// Mark sent only after a successful send.
|
|
427
|
+
await estimateService.updateOne( { _id: estimateId }, { $set: { status: 'sent' } } );
|
|
428
|
+
logger.info?.( { function: 'sendEstimate', estimateId, to: toEmails, cc: ccEmails } );
|
|
429
|
+
return res.sendSuccess( { estimateId, status: 'sent', sentTo: toEmails } );
|
|
430
|
+
} catch ( error ) {
|
|
431
|
+
logger.error( { error: error, function: 'sendEstimate' } );
|
|
432
|
+
return res.sendError( error, 500 );
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
302
436
|
export async function deleteEstimate( req, res ) {
|
|
303
437
|
try {
|
|
304
438
|
const estimate = await estimateService.findOne( { _id: req.params.estimateId } );
|
|
@@ -308,8 +442,10 @@ export async function deleteEstimate( req, res ) {
|
|
|
308
442
|
if ( estimate.status === 'accepted' ) {
|
|
309
443
|
return res.sendError( 'An accepted estimate cannot be deleted.', 409 );
|
|
310
444
|
}
|
|
311
|
-
|
|
312
|
-
|
|
445
|
+
// Hard delete — remove the document from the collection (not a soft
|
|
446
|
+
// status change).
|
|
447
|
+
await estimateService.deleteOne( { _id: req.params.estimateId } );
|
|
448
|
+
return res.sendSuccess( 'Estimate deleted' );
|
|
313
449
|
} catch ( error ) {
|
|
314
450
|
logger.error( { error: error, function: 'deleteEstimate' } );
|
|
315
451
|
return res.sendError( error, 500 );
|
|
@@ -24,7 +24,7 @@ import { getUsdInrRate } from './brandsBilling.controller.js';
|
|
|
24
24
|
// userAssignedStore (clientId + tangoUserType:'csm'). Returns a flat de-duped
|
|
25
25
|
// array suitable for the SES CC field. Returns [] on any failure — invoice
|
|
26
26
|
// email send should never fail because of missing settings or DB hiccups.
|
|
27
|
-
async function getInvoiceCcEmails( clientId ) {
|
|
27
|
+
export async function getInvoiceCcEmails( clientId ) {
|
|
28
28
|
const collected = [];
|
|
29
29
|
|
|
30
30
|
// 1. Configured heads (global, applies to every invoice).
|
|
@@ -142,7 +142,8 @@ export async function createInvoice( req, res ) {
|
|
|
142
142
|
clientId: req.body.clientId,
|
|
143
143
|
groupId: req.body.groupId || undefined,
|
|
144
144
|
groupName: req.body.groupName || '',
|
|
145
|
-
|
|
145
|
+
// Company (registered) name is always stored uppercase on invoices.
|
|
146
|
+
companyName: ( req.body.companyName || '' ).toUpperCase(),
|
|
146
147
|
companyAddress: req.body.companyAddress || '',
|
|
147
148
|
GSTNumber: req.body.GSTNumber || '',
|
|
148
149
|
PlaceOfSupply: req.body.PlaceOfSupply || '',
|
|
@@ -272,9 +273,15 @@ export async function createInvoice( req, res ) {
|
|
|
272
273
|
|
|
273
274
|
] );
|
|
274
275
|
|
|
275
|
-
|
|
276
|
+
// billingDate is the actual invoice (creation) date — even for advance
|
|
277
|
+
// invoices. The advance MONTH is reflected only in the product lines /
|
|
278
|
+
// monthOfbilling (driven by baseDate), not in the billing date itself.
|
|
279
|
+
// (Regenerate keeps the original invoice's billingDate.)
|
|
280
|
+
const creationDate = dayjs();
|
|
281
|
+
let invoicedate = req.body.invoiceId ? dayjs( findInvoice.billingDate ).format( 'YYYY-MM-DD' ) : creationDate.format( 'YYYY-MM-DD' );
|
|
276
282
|
let daysExtend = group?.paymentTerm ? group?.paymentTerm : 30;
|
|
277
|
-
|
|
283
|
+
// Due date is relative to the billing (creation) date.
|
|
284
|
+
let dueDate = creationDate.add( daysExtend, 'days' );
|
|
278
285
|
console.log( 'group.currencygroup.currency', group.currency );
|
|
279
286
|
let data = {
|
|
280
287
|
groupName: group.groupName,
|
|
@@ -285,7 +292,7 @@ export async function createInvoice( req, res ) {
|
|
|
285
292
|
amount: Math.round( amount ),
|
|
286
293
|
invoiceIndex: req.body.invoiceId ? findInvoice.invoiceIndex : invoiceNo,
|
|
287
294
|
tax: taxList,
|
|
288
|
-
companyName: group.registeredCompanyName,
|
|
295
|
+
companyName: ( group.registeredCompanyName || '' ).toUpperCase(),
|
|
289
296
|
companyAddress: address,
|
|
290
297
|
PlaceOfSupply: group.placeOfSupply,
|
|
291
298
|
GSTNumber: group.gst,
|
|
@@ -372,6 +379,10 @@ async function buildAnnexureRows( invoiceInfo, getgroup ) {
|
|
|
372
379
|
const billingMonthEnd = new Date( billingMonth.endOf( 'month' ).toISOString() );
|
|
373
380
|
const monthDays = billingMonth.daysInMonth();
|
|
374
381
|
const invoiceCurrency = symbolFor( invoiceInfo.currency );
|
|
382
|
+
// basepricing negotiatePrice is stored in INR. For non-INR invoices the
|
|
383
|
+
// annexure must convert it to the invoice currency, otherwise the INR number
|
|
384
|
+
// is shown verbatim under a $ symbol (e.g. ₹1650 rendered as "$1,650").
|
|
385
|
+
const annexFx = invoiceInfo.currency === 'dollar' ? ( await getUsdInrRate() ) : 1;
|
|
375
386
|
|
|
376
387
|
const annexClient = await clientService.findOne( { clientId: invoiceInfo.clientId }, { 'planDetails.product': 1 } );
|
|
377
388
|
const billingTypeMap = {};
|
|
@@ -432,7 +443,9 @@ async function buildAnnexureRows( invoiceInfo, getgroup ) {
|
|
|
432
443
|
units = s.trafficCameraCount;
|
|
433
444
|
}
|
|
434
445
|
}
|
|
435
|
-
|
|
446
|
+
// Convert the INR negotiatePrice into the invoice currency (annexFx = 1 for
|
|
447
|
+
// INR invoices, = USD→INR rate for dollar invoices, so divide).
|
|
448
|
+
const price = ( Number( s.standard?.negotiatePrice ) || 0 ) / annexFx;
|
|
436
449
|
const runningCost = s.workingdays >= monthDays ?
|
|
437
450
|
Math.round( price * units * 100 ) / 100 :
|
|
438
451
|
Math.round( ( price / monthDays ) * s.workingdays * units * 100 ) / 100;
|
|
@@ -526,6 +539,10 @@ export async function invoiceDownload( req, res ) {
|
|
|
526
539
|
billingCurrency: virtualAccount?.currency,
|
|
527
540
|
virtualaccountNumber: virtualAccount ? virtualAccount?.accountNumber : '',
|
|
528
541
|
virtualifsc: virtualAccount ? virtualAccount?.ifsc : '',
|
|
542
|
+
// GST applies only to domestic (INR) invoices. Gate the tax block on the
|
|
543
|
+
// invoice's OWN currency — not the payment-account currency, which is
|
|
544
|
+
// null/non-inr for many INR invoices and was dropping the GST rows.
|
|
545
|
+
gstApplicable: invoiceInfo.currency === 'inr',
|
|
529
546
|
};
|
|
530
547
|
|
|
531
548
|
if ( invoiceData?.tax?.length ) {
|
|
@@ -590,9 +607,20 @@ export async function invoiceDownload( req, res ) {
|
|
|
590
607
|
// Load configured CSM + Finance heads PLUS the per-client CSMs from
|
|
591
608
|
// userAssignedStore as CC recipients on the invoice mail.
|
|
592
609
|
const ccEmails = await getInvoiceCcEmails( invoiceInfo.clientId );
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
610
|
+
// De-duplicate recipients so nobody gets the invoice 2-3 times:
|
|
611
|
+
// unique TO list, and drop from CC anyone already in TO (overlap
|
|
612
|
+
// between generateInvoiceTo, invoice heads and assigned CSMs was the
|
|
613
|
+
// cause of duplicate mails).
|
|
614
|
+
const toEmails = [ ...new Set(
|
|
615
|
+
( getgroup.generateInvoiceTo || [] ).map( ( e ) => String( e || '' ).trim() ).filter( Boolean ),
|
|
616
|
+
) ];
|
|
617
|
+
const toSet = new Set( toEmails.map( ( e ) => e.toLowerCase() ) );
|
|
618
|
+
const dedupedCc = [ ...new Set(
|
|
619
|
+
( ccEmails || [] ).map( ( e ) => String( e || '' ).trim() ).filter( Boolean ),
|
|
620
|
+
) ].filter( ( e ) => !toSet.has( e.toLowerCase() ) );
|
|
621
|
+
console.log( fromEmail, toEmails, dedupedCc, attachments );
|
|
622
|
+
|
|
623
|
+
const result = await sendEmailWithSES( toEmails, mailSubject, mailbody, attachments, fromEmail, dedupedCc.length ? dedupedCc : undefined );
|
|
596
624
|
console.log( result );
|
|
597
625
|
let logObj = {
|
|
598
626
|
userName: req.user?.userName,
|
|
@@ -707,6 +735,8 @@ async function buildInvoicePdfBuffer( invoiceId ) {
|
|
|
707
735
|
billingCurrency: virtualAccount?.currency,
|
|
708
736
|
virtualaccountNumber: virtualAccount ? virtualAccount?.accountNumber : '',
|
|
709
737
|
virtualifsc: virtualAccount ? virtualAccount?.ifsc : '',
|
|
738
|
+
// GST applies only to domestic (INR) invoices; gate on the invoice currency.
|
|
739
|
+
gstApplicable: invoiceInfo.currency === 'inr',
|
|
710
740
|
};
|
|
711
741
|
|
|
712
742
|
if ( invoiceData?.tax?.length ) {
|
|
@@ -818,6 +848,24 @@ export async function invoiceDownloadBulk( req, res ) {
|
|
|
818
848
|
}
|
|
819
849
|
}
|
|
820
850
|
|
|
851
|
+
// Bank/beneficiary details for the invoice preview. Fixed beneficiary account
|
|
852
|
+
// — hardcoded to match the PDF exactly (NOT read from the DB).
|
|
853
|
+
export async function invoiceBankDetails( req, res ) {
|
|
854
|
+
try {
|
|
855
|
+
return res.sendSuccess( {
|
|
856
|
+
beneficiaryName: 'Tango IT Solutions India Private Limited',
|
|
857
|
+
accountNumber: '50200027441433',
|
|
858
|
+
ifsc: 'HDFC0000386',
|
|
859
|
+
swift: 'HDFCINBBCHE',
|
|
860
|
+
branch: 'Santhome, Chennai',
|
|
861
|
+
paymentType: 'Online',
|
|
862
|
+
} );
|
|
863
|
+
} catch ( error ) {
|
|
864
|
+
logger.error( { error: error, function: 'invoiceBankDetails', invoiceId: req.params.invoiceId } );
|
|
865
|
+
return res.sendError( error, 500 );
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
|
|
821
869
|
export async function invoiceAnnexure( req, res ) {
|
|
822
870
|
try {
|
|
823
871
|
const invoiceInfo = await invoiceService.findOne( { _id: req.params.invoiceId } );
|
|
@@ -1290,8 +1338,12 @@ async function standardPrice( group, getClient, baseDate ) {
|
|
|
1290
1338
|
return product;
|
|
1291
1339
|
} );
|
|
1292
1340
|
|
|
1293
|
-
// Combine overallStore and eachStore products
|
|
1294
|
-
|
|
1341
|
+
// Combine overallStore and eachStore products. Sort by product name so the
|
|
1342
|
+
// persisted order is deterministic — MongoDB $group output order isn't
|
|
1343
|
+
// guaranteed, which made products "interchange" between the Plans view, the
|
|
1344
|
+
// stored invoice and regenerated invoices / PDF.
|
|
1345
|
+
return [ ...products, ...eachStoreProducts ]
|
|
1346
|
+
.sort( ( a, b ) => String( a.productName || '' ).localeCompare( String( b.productName || '' ) ) );
|
|
1295
1347
|
}
|
|
1296
1348
|
|
|
1297
1349
|
|
|
@@ -1425,7 +1477,10 @@ async function stepPrice( group, getClient ) {
|
|
|
1425
1477
|
},
|
|
1426
1478
|
},
|
|
1427
1479
|
{
|
|
1480
|
+
// productName first so order is deterministic across views/PDF, then
|
|
1481
|
+
// workingdays so step rows stay grouped consistently.
|
|
1428
1482
|
$sort: {
|
|
1483
|
+
productName: 1,
|
|
1429
1484
|
workingdays: -1,
|
|
1430
1485
|
},
|
|
1431
1486
|
},
|
|
@@ -1854,6 +1909,24 @@ export async function clientInvoiceList( req, res ) {
|
|
|
1854
1909
|
}
|
|
1855
1910
|
|
|
1856
1911
|
if ( req.body.export ) {
|
|
1912
|
+
// Due Status — mirrors the UI cell (getDueStatus) exactly so the column
|
|
1913
|
+
// matches what reviewers see on screen. Paid / no-due-date show a dash;
|
|
1914
|
+
// otherwise it's overdue / due today / due in N days from TODAY at
|
|
1915
|
+
// day-granularity (time-of-day ignored on both ends).
|
|
1916
|
+
const today = dayjs().startOf( 'day' );
|
|
1917
|
+
const dueStatusOf = ( inv ) => {
|
|
1918
|
+
if ( inv.paymentStatus === 'paid' || !inv.dueDate ) {
|
|
1919
|
+
return '—';
|
|
1920
|
+
}
|
|
1921
|
+
const days = dayjs( inv.dueDate ).startOf( 'day' ).diff( today, 'day' );
|
|
1922
|
+
if ( days < 0 ) {
|
|
1923
|
+
return `Overdue by ${-days} day${days === -1 ? '' : 's'}`;
|
|
1924
|
+
}
|
|
1925
|
+
if ( days === 0 ) {
|
|
1926
|
+
return 'Due today';
|
|
1927
|
+
}
|
|
1928
|
+
return `Due in ${days} day${days === 1 ? '' : 's'}`;
|
|
1929
|
+
};
|
|
1857
1930
|
const exportdata = [];
|
|
1858
1931
|
count.forEach( ( element ) => {
|
|
1859
1932
|
exportdata.push( {
|
|
@@ -1862,9 +1935,13 @@ export async function clientInvoiceList( req, res ) {
|
|
|
1862
1935
|
'Invoice #': element.invoice,
|
|
1863
1936
|
'Billing date': dayjs( element.billingDate ).format( 'DD MMM, YYYY' ),
|
|
1864
1937
|
'Due Date': element.dueDate ? dayjs( element.dueDate ).format( 'DD MMM, YYYY' ) : '',
|
|
1938
|
+
'Due Status': dueStatusOf( element ),
|
|
1865
1939
|
'Group Name': element.groupName,
|
|
1866
1940
|
'Amount Excl. GST': element.amount,
|
|
1867
|
-
|
|
1941
|
+
// GST only applies to domestic (INR) invoices. International invoices
|
|
1942
|
+
// (dollar / euro / etc.) are billed without GST — show a dash to
|
|
1943
|
+
// match the on-screen column rather than a misleading 0.
|
|
1944
|
+
'GST Amount': element.currency === 'inr' ? element.gstAmount : '—',
|
|
1868
1945
|
'Amount Incl. GST': element.totalAmount,
|
|
1869
1946
|
'Stores': element.stores,
|
|
1870
1947
|
'Payment Status': element.paymentStatus,
|
|
@@ -2468,7 +2545,10 @@ async function transitionInvoiceStatus( req, res, fromStatus, toStatus ) {
|
|
|
2468
2545
|
return res.sendError( 'Invoice not found', 404 );
|
|
2469
2546
|
}
|
|
2470
2547
|
|
|
2471
|
-
|
|
2548
|
+
// Legacy 'pending' invoices are equivalent to the first CSM stage, so the
|
|
2549
|
+
// CSM transition accepts either 'pendingCsm' or 'pending' as the source.
|
|
2550
|
+
const acceptedFrom = fromStatus === 'pendingCsm' ? [ 'pendingCsm', 'pending' ] : [ fromStatus ];
|
|
2551
|
+
if ( !acceptedFrom.includes( invoice.status ) ) {
|
|
2472
2552
|
return res.sendError(
|
|
2473
2553
|
`Invoice is currently at status '${invoice.status}', not '${fromStatus}'. Another user may have advanced it.`,
|
|
2474
2554
|
409,
|