tango-app-api-payment-subscription 3.5.8 → 3.5.10
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/src/controllers/bankTransaction.controller.js +100 -51
- package/src/controllers/brandsBilling.controller.js +110 -21
- package/src/controllers/estimate.controller.js +141 -68
- package/src/controllers/invoice.controller.js +137 -6
- package/src/dtos/validation.dtos.js +3 -0
- package/src/hbs/estimateEmail.hbs +78 -0
- package/src/hbs/estimatePdf.hbs +1632 -118
- package/src/hbs/invoicePdf.hbs +37 -105
- package/src/routes/invoice.routes.js +4 -2
- package/src/services/estimate.service.js +4 -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 || '',
|
|
@@ -240,7 +305,7 @@ export async function updateEstimate( req, res ) {
|
|
|
240
305
|
update.period = b.period;
|
|
241
306
|
}
|
|
242
307
|
// Allow a status nudge (e.g. draft → sent) on save, but never to accepted.
|
|
243
|
-
if ( b.status && [ '
|
|
308
|
+
if ( b.status && [ 'pending', 'sent' ].includes( b.status ) ) {
|
|
244
309
|
update.status = b.status;
|
|
245
310
|
}
|
|
246
311
|
|
|
@@ -270,7 +335,7 @@ export async function getEstimate( req, res ) {
|
|
|
270
335
|
export async function estimateStatusUpdate( req, res ) {
|
|
271
336
|
try {
|
|
272
337
|
const { estimateId, status } = req.body || {};
|
|
273
|
-
const allowed = [ '
|
|
338
|
+
const allowed = [ 'pending', 'sent', 'accepted', 'declined', 'expired' ];
|
|
274
339
|
if ( !estimateId || !allowed.includes( status ) ) {
|
|
275
340
|
return res.sendError( 'estimateId and a valid status are required', 400 );
|
|
276
341
|
}
|
|
@@ -293,66 +358,7 @@ export async function downloadEstimate( req, res ) {
|
|
|
293
358
|
if ( !estimate ) {
|
|
294
359
|
return res.sendError( 'Estimate not found', 404 );
|
|
295
360
|
}
|
|
296
|
-
const
|
|
297
|
-
const currencyType = symbolFor( e.currency );
|
|
298
|
-
const fmt = ( n ) => Number( n || 0 ).toLocaleString( 'en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 } );
|
|
299
|
-
|
|
300
|
-
const products = ( e.products || [] ).map( ( p, i ) => {
|
|
301
|
-
let name = String( p.productName || '' ).replace( /([a-z])([A-Z])/g, '$1 $2' );
|
|
302
|
-
name = name.charAt( 0 ).toUpperCase() + name.slice( 1 );
|
|
303
|
-
return {
|
|
304
|
-
index: i + 1,
|
|
305
|
-
productName: name,
|
|
306
|
-
description: p.description || '',
|
|
307
|
-
hsn: p.hsn || p.hsnCode || '998314',
|
|
308
|
-
storeCount: p.storeCount || e.stores || '',
|
|
309
|
-
price: fmt( p.price ),
|
|
310
|
-
amount: fmt( p.amount ),
|
|
311
|
-
};
|
|
312
|
-
} );
|
|
313
|
-
const tax = ( e.tax || [] ).map( ( t ) => ( {
|
|
314
|
-
type: t.type || t.taxName || t.name || 'GST',
|
|
315
|
-
value: t.value ?? t.taxPercentage ?? t.percentage ?? '',
|
|
316
|
-
taxAmount: fmt( t.taxAmount ),
|
|
317
|
-
} ) );
|
|
318
|
-
|
|
319
|
-
const statusLabelMap = { draft: 'Draft', sent: 'Sent', accepted: 'Accepted', declined: 'Declined', expired: 'Expired' };
|
|
320
|
-
const data = {
|
|
321
|
-
estimate: e.estimate,
|
|
322
|
-
status: e.status,
|
|
323
|
-
statusLabel: statusLabelMap[e.status] || e.status,
|
|
324
|
-
companyName: e.companyName || '',
|
|
325
|
-
companyAddress: e.companyAddress || '',
|
|
326
|
-
GSTNumber: e.GSTNumber || '',
|
|
327
|
-
PlaceOfSupply: e.PlaceOfSupply || '',
|
|
328
|
-
groupName: e.groupName || '',
|
|
329
|
-
period: e.period || '',
|
|
330
|
-
createdDate: e.createdDate ? dayjs( e.createdDate ).format( 'DD/MM/YYYY' ) : '',
|
|
331
|
-
validTill: e.validTill ? dayjs( e.validTill ).format( 'DD/MM/YYYY' ) : '',
|
|
332
|
-
currencyType,
|
|
333
|
-
amount: fmt( e.amount ),
|
|
334
|
-
totalAmount: fmt( e.totalAmount ),
|
|
335
|
-
products,
|
|
336
|
-
tax,
|
|
337
|
-
notes: e.notes || '',
|
|
338
|
-
logo: `${JSON.parse( process.env.URL ).apiDomain}/logo.png`,
|
|
339
|
-
};
|
|
340
|
-
|
|
341
|
-
const templateHtml = fs.readFileSync( path.resolve( path.dirname( '' ) ) + '/src/hbs/estimatePdf.hbs', 'utf8' );
|
|
342
|
-
const template = Handlebars.compile( templateHtml );
|
|
343
|
-
const html = template( data );
|
|
344
|
-
const file = { content: html };
|
|
345
|
-
const options = {
|
|
346
|
-
executablePath: '/usr/bin/chromium',
|
|
347
|
-
args: [ '--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage', '--no-zygote', '--single-process' ],
|
|
348
|
-
format: 'A4',
|
|
349
|
-
margin: { top: '0.5in', right: '0.5in', bottom: '0.5in', left: '0.5in' },
|
|
350
|
-
printBackground: true,
|
|
351
|
-
preferCSSPageSize: true,
|
|
352
|
-
};
|
|
353
|
-
const pdfBuffer = await htmlpdf.generatePdf( file, options );
|
|
354
|
-
|
|
355
|
-
const filename = ( e.estimate + '-' + ( e.companyName || 'estimate' ) + '.pdf' ).split( '/' ).join( '_' ).split( '"' ).join( '' ).trim();
|
|
361
|
+
const { pdfBuffer, filename } = await buildEstimatePdf( estimate );
|
|
356
362
|
res.set( 'Content-Type', 'application/pdf' );
|
|
357
363
|
res.set( 'Content-Disposition', `attachment; filename="${filename}"` );
|
|
358
364
|
return res.send( pdfBuffer );
|
|
@@ -362,6 +368,71 @@ export async function downloadEstimate( req, res ) {
|
|
|
362
368
|
}
|
|
363
369
|
}
|
|
364
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
|
+
|
|
365
436
|
export async function deleteEstimate( req, res ) {
|
|
366
437
|
try {
|
|
367
438
|
const estimate = await estimateService.findOne( { _id: req.params.estimateId } );
|
|
@@ -371,8 +442,10 @@ export async function deleteEstimate( req, res ) {
|
|
|
371
442
|
if ( estimate.status === 'accepted' ) {
|
|
372
443
|
return res.sendError( 'An accepted estimate cannot be deleted.', 409 );
|
|
373
444
|
}
|
|
374
|
-
|
|
375
|
-
|
|
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' );
|
|
376
449
|
} catch ( error ) {
|
|
377
450
|
logger.error( { error: error, function: 'deleteEstimate' } );
|
|
378
451
|
return res.sendError( error, 500 );
|
|
@@ -17,6 +17,7 @@ import { symbolFor } from '../utils/currency.js';
|
|
|
17
17
|
import { invoiceStatusEnum } from '../dtos/validation.dtos.js';
|
|
18
18
|
import { findOneApplicationDefault } from '../services/applicationDefault.service.js';
|
|
19
19
|
import * as assignedStoreService from '../services/assignedStore.service.js';
|
|
20
|
+
import * as bankTransactionService from '../services/bankTransaction.service.js';
|
|
20
21
|
import { getUsdInrRate } from './brandsBilling.controller.js';
|
|
21
22
|
|
|
22
23
|
// Pulls CSM + Finance head emails (stored under applicationDefault
|
|
@@ -24,7 +25,7 @@ import { getUsdInrRate } from './brandsBilling.controller.js';
|
|
|
24
25
|
// userAssignedStore (clientId + tangoUserType:'csm'). Returns a flat de-duped
|
|
25
26
|
// array suitable for the SES CC field. Returns [] on any failure — invoice
|
|
26
27
|
// email send should never fail because of missing settings or DB hiccups.
|
|
27
|
-
async function getInvoiceCcEmails( clientId ) {
|
|
28
|
+
export async function getInvoiceCcEmails( clientId ) {
|
|
28
29
|
const collected = [];
|
|
29
30
|
|
|
30
31
|
// 1. Configured heads (global, applies to every invoice).
|
|
@@ -136,6 +137,35 @@ export async function createInvoice( req, res ) {
|
|
|
136
137
|
invoiceNo = invoiceNo.toString().padStart( 5, '0' );
|
|
137
138
|
}
|
|
138
139
|
const baseDate = req.body.billingDate ? dayjs( req.body.billingDate ) : dayjs();
|
|
140
|
+
let customProducts = Array.isArray( req.body.products ) ? req.body.products : [];
|
|
141
|
+
|
|
142
|
+
// Advance billing: monthly = 1, half-yearly = 6, yearly = 12. For a
|
|
143
|
+
// multi-month advance invoice, each month becomes its own line item —
|
|
144
|
+
// every posted product is repeated once per month, labelled with that
|
|
145
|
+
// month, so one invoice bills the whole period upfront (same logic as
|
|
146
|
+
// the auto-generated advance invoices).
|
|
147
|
+
const customAdvanceMonths = req.body.advanceInvoice ?
|
|
148
|
+
( { quarterly: 3, halfyearly: 6, yearly: 12 }[req.body.advancePeriod] || 1 ) : 1;
|
|
149
|
+
if ( customAdvanceMonths > 1 ) {
|
|
150
|
+
const expanded = [];
|
|
151
|
+
for ( let m = 0; m < customAdvanceMonths; m++ ) {
|
|
152
|
+
const monthLabel = baseDate.add( m, 'month' ).format( 'MMM YYYY' );
|
|
153
|
+
for ( const p of customProducts ) {
|
|
154
|
+
expanded.push( { ...p, month: monthLabel } );
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
customProducts = expanded;
|
|
158
|
+
}
|
|
159
|
+
// Recompute totals from the (possibly expanded) line items so a
|
|
160
|
+
// multi-month advance invoice bills the full period.
|
|
161
|
+
const customAmount = customAdvanceMonths > 1 ?
|
|
162
|
+
Math.round( customProducts.reduce( ( s, p ) => s + ( Number( p.amount ) || 0 ), 0 ) ) :
|
|
163
|
+
Math.round( Number( req.body.amount ) || 0 );
|
|
164
|
+
const customTotal = customAdvanceMonths > 1 ?
|
|
165
|
+
Math.round( customProducts.reduce( ( s, p ) => s + ( Number( p.amount ) || 0 ), 0 ) +
|
|
166
|
+
( Array.isArray( req.body.tax ) ? req.body.tax.reduce( ( s, t ) => s + ( Number( t.taxAmount ) || 0 ) * customAdvanceMonths, 0 ) : 0 ) ) :
|
|
167
|
+
Math.round( Number( req.body.totalAmount ) || 0 );
|
|
168
|
+
|
|
139
169
|
const data = {
|
|
140
170
|
invoice: `INV-${Finacialyear}-${invoiceNo}`,
|
|
141
171
|
invoiceIndex: invoiceNo,
|
|
@@ -147,10 +177,10 @@ export async function createInvoice( req, res ) {
|
|
|
147
177
|
companyAddress: req.body.companyAddress || '',
|
|
148
178
|
GSTNumber: req.body.GSTNumber || '',
|
|
149
179
|
PlaceOfSupply: req.body.PlaceOfSupply || '',
|
|
150
|
-
products:
|
|
180
|
+
products: customProducts,
|
|
151
181
|
tax: Array.isArray( req.body.tax ) ? req.body.tax : [],
|
|
152
|
-
amount:
|
|
153
|
-
totalAmount:
|
|
182
|
+
amount: customAmount,
|
|
183
|
+
totalAmount: customTotal,
|
|
154
184
|
stores: Number( req.body.stores ) || 0,
|
|
155
185
|
currency: req.body.currency || 'inr',
|
|
156
186
|
billingDate: baseDate.toDate(),
|
|
@@ -159,6 +189,9 @@ export async function createInvoice( req, res ) {
|
|
|
159
189
|
paymentMethod: 'Online',
|
|
160
190
|
status: 'pendingCsm',
|
|
161
191
|
paymentStatus: 'unpaid',
|
|
192
|
+
advanceInvoice: req.body.advanceInvoice || false,
|
|
193
|
+
advancePeriod: req.body.advanceInvoice ? ( req.body.advancePeriod || 'monthly' ) : undefined,
|
|
194
|
+
advanceMonths: customAdvanceMonths,
|
|
162
195
|
};
|
|
163
196
|
const created = await invoiceService.create( data );
|
|
164
197
|
const logObj = {
|
|
@@ -207,6 +240,32 @@ export async function createInvoice( req, res ) {
|
|
|
207
240
|
} else {
|
|
208
241
|
products = await stepPrice( group, getClient );
|
|
209
242
|
}
|
|
243
|
+
|
|
244
|
+
// Billing horizon in months. Advance and normal cycle are independent —
|
|
245
|
+
// only one drives the span per generation:
|
|
246
|
+
// advanceInvoice ON -> advancePeriod (advance future billing)
|
|
247
|
+
// advanceInvoice OFF -> paymentCycle (normal billing cycle)
|
|
248
|
+
// For a multi-month span, each month becomes its own line item: every
|
|
249
|
+
// product is repeated once per month, labelled with that month, at the
|
|
250
|
+
// normal monthly amount. One invoice then bills the whole period.
|
|
251
|
+
// (advancePeriod uses 'halfyearly'; paymentCycle uses 'quarter'/'halfYearly'
|
|
252
|
+
// — map both spellings.)
|
|
253
|
+
const advancePeriodMonths = { quarterly: 3, halfyearly: 6, yearly: 12 };
|
|
254
|
+
const paymentCycleMonths = { quarter: 3, quarterly: 3, halfYearly: 6, halfyearly: 6, yearly: 12 };
|
|
255
|
+
const advanceMonths = group.advanceInvoice ?
|
|
256
|
+
( advancePeriodMonths[group.advancePeriod] || 1 ) :
|
|
257
|
+
( paymentCycleMonths[group.paymentCycle] || 1 );
|
|
258
|
+
if ( advanceMonths > 1 ) {
|
|
259
|
+
const expanded = [];
|
|
260
|
+
for ( let m = 0; m < advanceMonths; m++ ) {
|
|
261
|
+
const monthLabel = baseDate.add( m, 'month' ).format( 'MMM YYYY' );
|
|
262
|
+
for ( const p of products ) {
|
|
263
|
+
expanded.push( { ...p, month: monthLabel } );
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
products = expanded;
|
|
267
|
+
}
|
|
268
|
+
|
|
210
269
|
let amount = products.reduce( ( sum, product ) => sum + product.amount, 0 );
|
|
211
270
|
let taxList = [];
|
|
212
271
|
let totalAmount = 0;
|
|
@@ -273,9 +332,15 @@ export async function createInvoice( req, res ) {
|
|
|
273
332
|
|
|
274
333
|
] );
|
|
275
334
|
|
|
276
|
-
|
|
335
|
+
// billingDate is the actual invoice (creation) date — even for advance
|
|
336
|
+
// invoices. The advance MONTH is reflected only in the product lines /
|
|
337
|
+
// monthOfbilling (driven by baseDate), not in the billing date itself.
|
|
338
|
+
// (Regenerate keeps the original invoice's billingDate.)
|
|
339
|
+
const creationDate = dayjs();
|
|
340
|
+
let invoicedate = req.body.invoiceId ? dayjs( findInvoice.billingDate ).format( 'YYYY-MM-DD' ) : creationDate.format( 'YYYY-MM-DD' );
|
|
277
341
|
let daysExtend = group?.paymentTerm ? group?.paymentTerm : 30;
|
|
278
|
-
|
|
342
|
+
// Due date is relative to the billing (creation) date.
|
|
343
|
+
let dueDate = creationDate.add( daysExtend, 'days' );
|
|
279
344
|
console.log( 'group.currencygroup.currency', group.currency );
|
|
280
345
|
let data = {
|
|
281
346
|
groupName: group.groupName,
|
|
@@ -299,6 +364,8 @@ export async function createInvoice( req, res ) {
|
|
|
299
364
|
monthOfbilling: baseDate.format( 'MM' ),
|
|
300
365
|
dueDate: dueDate,
|
|
301
366
|
advanceInvoice: group.advanceInvoice || false,
|
|
367
|
+
advancePeriod: group.advanceInvoice ? ( group.advancePeriod || 'monthly' ) : undefined,
|
|
368
|
+
advanceMonths: advanceMonths,
|
|
302
369
|
};
|
|
303
370
|
|
|
304
371
|
if ( req.body.invoiceId ) {
|
|
@@ -842,6 +909,24 @@ export async function invoiceDownloadBulk( req, res ) {
|
|
|
842
909
|
}
|
|
843
910
|
}
|
|
844
911
|
|
|
912
|
+
// Bank/beneficiary details for the invoice preview. Fixed beneficiary account
|
|
913
|
+
// — hardcoded to match the PDF exactly (NOT read from the DB).
|
|
914
|
+
export async function invoiceBankDetails( req, res ) {
|
|
915
|
+
try {
|
|
916
|
+
return res.sendSuccess( {
|
|
917
|
+
beneficiaryName: 'Tango IT Solutions India Private Limited',
|
|
918
|
+
accountNumber: '50200027441433',
|
|
919
|
+
ifsc: 'HDFC0000386',
|
|
920
|
+
swift: 'HDFCINBBCHE',
|
|
921
|
+
branch: 'Santhome, Chennai',
|
|
922
|
+
paymentType: 'Online',
|
|
923
|
+
} );
|
|
924
|
+
} catch ( error ) {
|
|
925
|
+
logger.error( { error: error, function: 'invoiceBankDetails', invoiceId: req.params.invoiceId } );
|
|
926
|
+
return res.sendError( error, 500 );
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
|
|
845
930
|
export async function invoiceAnnexure( req, res ) {
|
|
846
931
|
try {
|
|
847
932
|
const invoiceInfo = await invoiceService.findOne( { _id: req.params.invoiceId } );
|
|
@@ -2187,6 +2272,18 @@ export async function migrateInvoice( req, res ) {
|
|
|
2187
2272
|
export async function PaymentStatusChange( req, res ) {
|
|
2188
2273
|
try {
|
|
2189
2274
|
let invoice = await invoiceService.findOne( { invoice: req.body.invoiceId } );
|
|
2275
|
+
if ( !invoice ) {
|
|
2276
|
+
return res.sendError( 'Invoice not found', 404 );
|
|
2277
|
+
}
|
|
2278
|
+
// Payment can only be recorded on an APPROVED invoice — block while it's
|
|
2279
|
+
// still in the approval pipeline.
|
|
2280
|
+
if ( invoice.status !== 'approved' ) {
|
|
2281
|
+
return res.sendError( 'Payment status can be changed only after the invoice is approved.', 409 );
|
|
2282
|
+
}
|
|
2283
|
+
// A paid invoice is locked — its payment status cannot be changed.
|
|
2284
|
+
if ( invoice.paymentStatus === 'paid' ) {
|
|
2285
|
+
return res.sendError( 'This invoice is already paid; its payment status cannot be changed.', 409 );
|
|
2286
|
+
}
|
|
2190
2287
|
let updateInvoice = await invoiceService.updateOne( { invoice: req.body.invoiceId }, { paymentStatus: req.body.status } );
|
|
2191
2288
|
let logObj = {
|
|
2192
2289
|
userName: req.user?.userName,
|
|
@@ -2231,6 +2328,14 @@ export async function recordPayment( req, res ) {
|
|
|
2231
2328
|
if ( !invoice ) {
|
|
2232
2329
|
return res.sendError( 'Invoice not found', 404 );
|
|
2233
2330
|
}
|
|
2331
|
+
// Payment can only be recorded on an APPROVED invoice.
|
|
2332
|
+
if ( invoice.status !== 'approved' ) {
|
|
2333
|
+
return res.sendError( 'Payment can be recorded only after the invoice is approved.', 409 );
|
|
2334
|
+
}
|
|
2335
|
+
// A paid invoice is locked — no further payment / status change.
|
|
2336
|
+
if ( invoice.paymentStatus === 'paid' ) {
|
|
2337
|
+
return res.sendError( 'This invoice is already paid; its payment status cannot be changed.', 409 );
|
|
2338
|
+
}
|
|
2234
2339
|
|
|
2235
2340
|
const previousPaid = Number( invoice.paidAmount ) || 0;
|
|
2236
2341
|
const newPaid = Math.round( ( previousPaid + amountNum ) * 100 ) / 100;
|
|
@@ -2281,6 +2386,32 @@ export async function recordPayment( req, res ) {
|
|
|
2281
2386
|
const result = await invoiceService.invoiceUpdateOne( { invoice: invoiceId }, update );
|
|
2282
2387
|
logger.info?.( { function: 'recordPayment', invoiceId, matched: result?.matchedCount, modified: result?.modifiedCount } );
|
|
2283
2388
|
|
|
2389
|
+
// Mirror the manual payment into the Transactions section as a reconciled
|
|
2390
|
+
// row with source 'manual', so manually-recorded payments are visible
|
|
2391
|
+
// alongside bank/VA/gateway transactions.
|
|
2392
|
+
try {
|
|
2393
|
+
const clientForName = await clientService.findOne( { clientId: invoice.clientId }, { clientName: 1 } );
|
|
2394
|
+
await bankTransactionService.insertMany( [ {
|
|
2395
|
+
valueDate: historyEntry.date,
|
|
2396
|
+
narration: notes || `Manual payment recorded for ${invoiceId}`,
|
|
2397
|
+
refNo: reference || '',
|
|
2398
|
+
amount: amountNum,
|
|
2399
|
+
payer: clientForName?.clientName || invoice.companyName || '',
|
|
2400
|
+
source: 'manual',
|
|
2401
|
+
status: 'reconciled',
|
|
2402
|
+
identifiedClientId: String( invoice.clientId ?? '' ),
|
|
2403
|
+
identifiedClientName: clientForName?.clientName || invoice.companyName || '',
|
|
2404
|
+
invoice: invoice.invoice,
|
|
2405
|
+
invoiceId: String( invoice._id ),
|
|
2406
|
+
appliedInvoices: [ { invoiceId: String( invoice._id ), invoice: invoice.invoice, amount: amountNum } ],
|
|
2407
|
+
resultNote: `Manually recorded - ${derivedStatus === 'paid' ? 'invoice marked Paid' : 'partial payment'}`,
|
|
2408
|
+
fileName: 'manual-entry',
|
|
2409
|
+
} ] );
|
|
2410
|
+
} catch ( txnErr ) {
|
|
2411
|
+
// Non-fatal: the payment is already recorded; the mirror row is best-effort.
|
|
2412
|
+
logger.error( { error: txnErr, function: 'recordPayment.mirrorTransaction', invoiceId } );
|
|
2413
|
+
}
|
|
2414
|
+
|
|
2284
2415
|
try {
|
|
2285
2416
|
const logObj = {
|
|
2286
2417
|
userName: req.user?.userName,
|
|
@@ -329,6 +329,7 @@ export const createBillingGroupBody = joi.object(
|
|
|
329
329
|
attachAnnexure: joi.boolean().optional(),
|
|
330
330
|
isPrimary: joi.boolean().optional(),
|
|
331
331
|
advanceInvoice: joi.boolean().optional(),
|
|
332
|
+
advancePeriod: joi.string().valid( 'monthly', 'quarterly', 'halfyearly', 'yearly' ).optional().allow( '' ),
|
|
332
333
|
products: joi.array().items( joi.object( {
|
|
333
334
|
productName: joi.string().required(),
|
|
334
335
|
billingMethod: joi.string().valid( 'eachStore', 'overallStore' ).required(),
|
|
@@ -368,6 +369,7 @@ export const updateBillingGroupBody = joi.object(
|
|
|
368
369
|
attachAnnexure: joi.boolean().optional(),
|
|
369
370
|
isPrimary: joi.boolean().optional(),
|
|
370
371
|
advanceInvoice: joi.boolean().optional(),
|
|
372
|
+
advancePeriod: joi.string().valid( 'monthly', 'quarterly', 'halfyearly', 'yearly' ).optional().allow( '' ),
|
|
371
373
|
products: joi.array().items( joi.object( {
|
|
372
374
|
_id: joi.any().optional(),
|
|
373
375
|
productName: joi.string().required(),
|
|
@@ -401,6 +403,7 @@ export const bulkUpdateBillingGroupRowSchema = joi.object( {
|
|
|
401
403
|
isInstallationOneTime: joi.boolean().optional(),
|
|
402
404
|
attachAnnexure: joi.boolean().optional(),
|
|
403
405
|
advanceInvoice: joi.boolean().optional(),
|
|
406
|
+
advancePeriod: joi.string().valid( 'monthly', 'quarterly', 'halfyearly', 'yearly' ).optional().allow( '' ),
|
|
404
407
|
storeId: joi.string().allow( '' ).optional(),
|
|
405
408
|
} );
|
|
406
409
|
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html>
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>Estimate</title>
|
|
7
|
+
</head>
|
|
8
|
+
<body style="margin:0;padding:0;background-color:#dbe5ea;font-family:'Inter',Arial,sans-serif;">
|
|
9
|
+
<table border="0" cellpadding="0" cellspacing="0" width="100%" style="padding:0 10px;">
|
|
10
|
+
<tr>
|
|
11
|
+
<td style="padding:32px 10px 0 10px;">
|
|
12
|
+
<table border="0" cellpadding="0" cellspacing="0" width="100%" style="max-width:680px;margin:0 auto;" align="center">
|
|
13
|
+
<tr>
|
|
14
|
+
<td style="background-color:#ffffff;padding:24px 24px 0 18px;">
|
|
15
|
+
<img src="{{logo}}" width="200" height="100" alt="Tango Eye" style="vertical-align:middle;border:0;height:auto;">
|
|
16
|
+
</td>
|
|
17
|
+
</tr>
|
|
18
|
+
<tr>
|
|
19
|
+
<td style="background-color:#ffffff;padding:0 24px;">
|
|
20
|
+
<p style="border-top:1px solid #CBD5E1;margin:0;"></p>
|
|
21
|
+
</td>
|
|
22
|
+
</tr>
|
|
23
|
+
<tr>
|
|
24
|
+
<td style="background-color:#ffffff;padding:18px 24px 6px 30px;">
|
|
25
|
+
<span style="font-weight:700;color:#121A26;font-size:22px;line-height:32px;">Your estimate {{estimate}}</span>
|
|
26
|
+
</td>
|
|
27
|
+
</tr>
|
|
28
|
+
<tr>
|
|
29
|
+
<td style="background-color:#ffffff;padding:14px 24px 4px 30px;font-size:16px;line-height:150%;color:#384860;">
|
|
30
|
+
<p style="margin:0;">Hi {{clientName}},</p>
|
|
31
|
+
</td>
|
|
32
|
+
</tr>
|
|
33
|
+
<tr>
|
|
34
|
+
<td style="background-color:#ffffff;padding:10px 24px 6px 30px;font-size:16px;line-height:150%;color:#384860;">
|
|
35
|
+
<p style="margin:0;">Please find attached the estimate <strong>{{estimate}}</strong>{{#if period}} for <strong>{{period}}</strong>{{/if}}. A summary is shown below; the attached PDF has the full breakdown.</p>
|
|
36
|
+
</td>
|
|
37
|
+
</tr>
|
|
38
|
+
|
|
39
|
+
<tr>
|
|
40
|
+
<td style="background-color:#ffffff;padding:14px 24px 6px 30px;">
|
|
41
|
+
<table border="0" cellpadding="0" cellspacing="0" width="100%" style="border:1px solid #E2E8F0;border-radius:8px;border-collapse:separate;overflow:hidden;">
|
|
42
|
+
<tr style="background-color:#F1F5F9;">
|
|
43
|
+
<td style="padding:10px 14px;font-size:13px;color:#64748B;font-weight:600;">Estimate Number</td>
|
|
44
|
+
<td style="padding:10px 14px;font-size:13px;color:#121A26;font-weight:700;text-align:right;">{{estimate}}</td>
|
|
45
|
+
</tr>
|
|
46
|
+
<tr>
|
|
47
|
+
<td style="padding:10px 14px;font-size:13px;color:#64748B;font-weight:600;border-top:1px solid #E2E8F0;">Total Amount</td>
|
|
48
|
+
<td style="padding:10px 14px;font-size:13px;color:#121A26;font-weight:700;text-align:right;border-top:1px solid #E2E8F0;">{{currencyType}} {{totalAmount}}</td>
|
|
49
|
+
</tr>
|
|
50
|
+
<tr>
|
|
51
|
+
<td style="padding:10px 14px;font-size:13px;color:#64748B;font-weight:600;border-top:1px solid #E2E8F0;">Valid Till</td>
|
|
52
|
+
<td style="padding:10px 14px;font-size:13px;color:#121A26;font-weight:700;text-align:right;border-top:1px solid #E2E8F0;">{{validTill}}</td>
|
|
53
|
+
</tr>
|
|
54
|
+
</table>
|
|
55
|
+
</td>
|
|
56
|
+
</tr>
|
|
57
|
+
|
|
58
|
+
<tr>
|
|
59
|
+
<td style="background-color:#ffffff;padding:14px 24px 6px 30px;font-size:16px;line-height:150%;color:#384860;">
|
|
60
|
+
<p style="margin:0;">This is an estimate, not a tax invoice. Prices are subject to the terms agreed in the final subscription. If you'd like to proceed or have any questions, please reply to this email.</p>
|
|
61
|
+
</td>
|
|
62
|
+
</tr>
|
|
63
|
+
<tr>
|
|
64
|
+
<td style="background-color:#ffffff;padding:14px 24px 24px 30px;font-size:16px;line-height:150%;color:#384860;">
|
|
65
|
+
<p style="margin:0;">Best regards,<br>{{companyName}}</p>
|
|
66
|
+
</td>
|
|
67
|
+
</tr>
|
|
68
|
+
<tr>
|
|
69
|
+
<td style="background-color:#ffffff;padding:10px 24px 18px 30px;font-size:12px;color:#202B3C;line-height:150%;">
|
|
70
|
+
<p style="margin:0;">© Tango Eye. All rights reserved.</p>
|
|
71
|
+
</td>
|
|
72
|
+
</tr>
|
|
73
|
+
</table>
|
|
74
|
+
</td>
|
|
75
|
+
</tr>
|
|
76
|
+
</table>
|
|
77
|
+
</body>
|
|
78
|
+
</html>
|