tango-app-api-payment-subscription 3.5.5 → 3.5.7
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 +617 -0
- package/src/controllers/brandsBilling.controller.js +443 -7
- package/src/controllers/estimate.controller.js +317 -0
- package/src/controllers/invoice.controller.js +172 -260
- package/src/controllers/paymentReminder.controller.js +81 -0
- package/src/controllers/paymentSubscription.controllers.js +55 -3
- package/src/dtos/validation.dtos.js +6 -0
- package/src/hbs/estimatePdf.hbs +125 -0
- package/src/hbs/invoicePdf.hbs +27 -0
- package/src/routes/billing.routes.js +5 -0
- package/src/routes/brandsBilling.routes.js +3 -1
- package/src/routes/invoice.routes.js +18 -0
- package/src/services/bankTransaction.service.js +21 -0
- package/src/services/estimate.service.js +25 -0
- package/src/services/paymentReminder.service.js +9 -0
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
import * as estimateService from '../services/estimate.service.js';
|
|
2
|
+
import * as clientService from '../services/clientPayment.services.js';
|
|
3
|
+
import dayjs from 'dayjs';
|
|
4
|
+
import { logger, download } from 'tango-app-api-middleware';
|
|
5
|
+
import Handlebars from '../utils/validations/helper/handlebar.helper.js';
|
|
6
|
+
import fs from 'fs';
|
|
7
|
+
import path from 'path';
|
|
8
|
+
import htmlpdf from 'html-pdf-node';
|
|
9
|
+
import { symbolFor } from '../utils/currency.js';
|
|
10
|
+
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
// Estimates (quotations). A lightweight pre-invoice document with its own
|
|
13
|
+
// lifecycle (draft → sent → accepted/declined/expired). Stored in the
|
|
14
|
+
// `estimates` collection. Per-brand (clientId) listing for the Estimate tab.
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
|
|
17
|
+
function getCurrentFinancialYear() {
|
|
18
|
+
const today = new Date();
|
|
19
|
+
const month = today.getMonth();
|
|
20
|
+
const year = today.getFullYear();
|
|
21
|
+
if ( month >= 3 ) {
|
|
22
|
+
return year.toString().slice( -2 ) + '-' + ( year + 1 ).toString().slice( -2 );
|
|
23
|
+
}
|
|
24
|
+
return ( ( year - 1 ).toString().slice( -2 ) ) + '-' + year.toString().slice( -2 );
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Next EST-<FY>-<index> for the current financial year.
|
|
28
|
+
async function nextEstimateNumber() {
|
|
29
|
+
const fy = getCurrentFinancialYear();
|
|
30
|
+
const previous = await estimateService.aggregate( [
|
|
31
|
+
{ $match: { estimate: { $regex: `^EST-${fy}-` } } },
|
|
32
|
+
{ $sort: { estimateIndex: -1 } },
|
|
33
|
+
{ $limit: 1 },
|
|
34
|
+
] );
|
|
35
|
+
const index = previous.length ? Number( previous[0].estimateIndex ) + 1 : 1;
|
|
36
|
+
return {
|
|
37
|
+
estimate: `EST-${fy}-${String( index ).padStart( 5, '0' )}`,
|
|
38
|
+
estimateIndex: index,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Expire stale estimates lazily on read: anything sent/draft past validTill
|
|
43
|
+
// flips to 'expired' so the list reflects reality without a cron.
|
|
44
|
+
async function expireOverdue( clientId ) {
|
|
45
|
+
await estimateService.updateOne(
|
|
46
|
+
{ clientId, status: { $in: [ 'draft', 'sent' ] }, validTill: { $lt: new Date() } },
|
|
47
|
+
{ $set: { status: 'expired' } },
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export async function estimateList( req, res ) {
|
|
52
|
+
try {
|
|
53
|
+
const clientId = req.body?.clientId;
|
|
54
|
+
if ( !clientId ) {
|
|
55
|
+
return res.sendError( 'clientId is required', 400 );
|
|
56
|
+
}
|
|
57
|
+
await expireOverdue( clientId );
|
|
58
|
+
|
|
59
|
+
const match = { clientId };
|
|
60
|
+
if ( req.body?.status && req.body.status !== 'All' ) {
|
|
61
|
+
match.status = req.body.status;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Optional month / year filter on the estimate period (stored as a
|
|
65
|
+
// "MMM YYYY" string; createdDate is the reliable date to range on).
|
|
66
|
+
if ( req.body?.fromDate || req.body?.toDate ) {
|
|
67
|
+
match.createdDate = {};
|
|
68
|
+
if ( req.body.fromDate ) {
|
|
69
|
+
match.createdDate.$gte = new Date( dayjs( req.body.fromDate ).startOf( 'day' ).toISOString() );
|
|
70
|
+
}
|
|
71
|
+
if ( req.body.toDate ) {
|
|
72
|
+
match.createdDate.$lte = new Date( dayjs( req.body.toDate ).endOf( 'day' ).toISOString() );
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if ( req.body?.searchValue ) {
|
|
77
|
+
match.$or = [
|
|
78
|
+
{ estimate: { $regex: req.body.searchValue, $options: 'i' } },
|
|
79
|
+
{ groupName: { $regex: req.body.searchValue, $options: 'i' } },
|
|
80
|
+
{ period: { $regex: req.body.searchValue, $options: 'i' } },
|
|
81
|
+
];
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const query = [
|
|
85
|
+
{ $match: match },
|
|
86
|
+
{ $sort: { [req.body?.sortColumName || 'createdDate']: req.body?.sortBy === 1 ? 1 : -1, _id: -1 } },
|
|
87
|
+
];
|
|
88
|
+
|
|
89
|
+
const countResult = await estimateService.aggregate( [ ...query, { $count: 'n' } ] );
|
|
90
|
+
const count = countResult[0]?.n || 0;
|
|
91
|
+
|
|
92
|
+
if ( req.body?.export ) {
|
|
93
|
+
const all = await estimateService.aggregate( query );
|
|
94
|
+
const rows = all.map( ( e ) => ( {
|
|
95
|
+
'Estimate #': e.estimate,
|
|
96
|
+
'Billing Group': e.groupName || '',
|
|
97
|
+
'Period': e.period || '',
|
|
98
|
+
'Generated': e.createdDate ? dayjs( e.createdDate ).format( 'DD MMM YYYY' ) : '',
|
|
99
|
+
'Valid Till': e.validTill ? dayjs( e.validTill ).format( 'DD MMM YYYY' ) : '',
|
|
100
|
+
'No of Stores': e.stores || 0,
|
|
101
|
+
'Amount (excl. GST)': e.amount || 0,
|
|
102
|
+
'Amount (incl. GST)': e.totalAmount || 0,
|
|
103
|
+
'Status': e.status,
|
|
104
|
+
} ) );
|
|
105
|
+
await download( rows, res );
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if ( req.body?.limit && req.body?.offset ) {
|
|
110
|
+
query.push(
|
|
111
|
+
{ $skip: ( req.body.offset - 1 ) * req.body.limit },
|
|
112
|
+
{ $limit: Number( req.body.limit ) },
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
const data = await estimateService.aggregate( query );
|
|
116
|
+
|
|
117
|
+
// Status counts over the brand's whole estimate set (filter-stable).
|
|
118
|
+
const statusAgg = await estimateService.aggregate( [
|
|
119
|
+
{ $match: { clientId } },
|
|
120
|
+
{ $group: { _id: '$status', count: { $sum: 1 } } },
|
|
121
|
+
] );
|
|
122
|
+
const counts = { draft: 0, sent: 0, accepted: 0, declined: 0, expired: 0, total: 0 };
|
|
123
|
+
statusAgg.forEach( ( s ) => {
|
|
124
|
+
if ( counts[s._id] != null ) {
|
|
125
|
+
counts[s._id] = s.count;
|
|
126
|
+
}
|
|
127
|
+
counts.total += s.count;
|
|
128
|
+
} );
|
|
129
|
+
|
|
130
|
+
return res.sendSuccess( { count, data, counts } );
|
|
131
|
+
} catch ( error ) {
|
|
132
|
+
logger.error( { error: error, function: 'estimateList' } );
|
|
133
|
+
return res.sendError( error, 500 );
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export async function createEstimate( req, res ) {
|
|
138
|
+
try {
|
|
139
|
+
const b = req.body || {};
|
|
140
|
+
if ( !b.clientId ) {
|
|
141
|
+
return res.sendError( 'clientId is required', 400 );
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const client = await clientService.findOne(
|
|
145
|
+
{ clientId: b.clientId },
|
|
146
|
+
{ 'clientName': 1, 'paymentInvoice.currencyType': 1 },
|
|
147
|
+
);
|
|
148
|
+
|
|
149
|
+
const amount = Math.round( Number( b.amount ) || 0 );
|
|
150
|
+
let totalAmount = Math.round( Number( b.totalAmount ) || 0 );
|
|
151
|
+
if ( !totalAmount && amount ) {
|
|
152
|
+
// Default to 18% GST when caller sends only the pre-tax amount.
|
|
153
|
+
totalAmount = Math.round( amount * 1.18 );
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const { estimate, estimateIndex } = await nextEstimateNumber();
|
|
157
|
+
const createdDate = b.createdDate ? new Date( b.createdDate ) : new Date();
|
|
158
|
+
// Estimates are valid for 14 days unless an explicit date is supplied.
|
|
159
|
+
const validTill = b.validTill ? new Date( b.validTill ) : dayjs( createdDate ).add( 14, 'days' ).toDate();
|
|
160
|
+
|
|
161
|
+
const doc = {
|
|
162
|
+
clientId: b.clientId,
|
|
163
|
+
estimate,
|
|
164
|
+
estimateIndex,
|
|
165
|
+
companyName: b.companyName || client?.clientName || '',
|
|
166
|
+
companyAddress: b.companyAddress || '',
|
|
167
|
+
PlaceOfSupply: b.PlaceOfSupply || '',
|
|
168
|
+
GSTNumber: b.GSTNumber || '',
|
|
169
|
+
groupId: b.groupId || undefined,
|
|
170
|
+
groupName: b.groupName || 'Default Group',
|
|
171
|
+
period: b.period || dayjs( createdDate ).format( 'MMM YYYY' ),
|
|
172
|
+
stores: Number( b.stores ) || 0,
|
|
173
|
+
products: Array.isArray( b.products ) ? b.products : [],
|
|
174
|
+
tax: Array.isArray( b.tax ) ? b.tax : [],
|
|
175
|
+
amount,
|
|
176
|
+
totalAmount,
|
|
177
|
+
currency: b.currency || ( client?.paymentInvoice?.currencyType === 'dollar' ? 'dollar' : 'inr' ),
|
|
178
|
+
status: b.status === 'sent' ? 'sent' : 'draft',
|
|
179
|
+
createdDate,
|
|
180
|
+
validTill,
|
|
181
|
+
createdBy: req.user?.email || req.user?.userName || '',
|
|
182
|
+
notes: b.notes || '',
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
const created = await estimateService.create( doc );
|
|
186
|
+
logger.info?.( { function: 'createEstimate', estimate, clientId: b.clientId } );
|
|
187
|
+
return res.sendSuccess( created );
|
|
188
|
+
} catch ( error ) {
|
|
189
|
+
logger.error( { error: error, function: 'createEstimate' } );
|
|
190
|
+
return res.sendError( error, 500 );
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export async function getEstimate( req, res ) {
|
|
195
|
+
try {
|
|
196
|
+
const estimate = await estimateService.findOne( { _id: req.params.estimateId } );
|
|
197
|
+
if ( !estimate ) {
|
|
198
|
+
return res.sendError( 'Estimate not found', 404 );
|
|
199
|
+
}
|
|
200
|
+
return res.sendSuccess( estimate );
|
|
201
|
+
} catch ( error ) {
|
|
202
|
+
logger.error( { error: error, function: 'getEstimate' } );
|
|
203
|
+
return res.sendError( error, 500 );
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export async function estimateStatusUpdate( req, res ) {
|
|
208
|
+
try {
|
|
209
|
+
const { estimateId, status } = req.body || {};
|
|
210
|
+
const allowed = [ 'draft', 'sent', 'accepted', 'declined', 'expired' ];
|
|
211
|
+
if ( !estimateId || !allowed.includes( status ) ) {
|
|
212
|
+
return res.sendError( 'estimateId and a valid status are required', 400 );
|
|
213
|
+
}
|
|
214
|
+
const result = await estimateService.updateOne( { _id: estimateId }, { $set: { status } } );
|
|
215
|
+
if ( !result?.matchedCount ) {
|
|
216
|
+
return res.sendError( 'Estimate not found', 404 );
|
|
217
|
+
}
|
|
218
|
+
return res.sendSuccess( { status } );
|
|
219
|
+
} catch ( error ) {
|
|
220
|
+
logger.error( { error: error, function: 'estimateStatusUpdate' } );
|
|
221
|
+
return res.sendError( error, 500 );
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Renders the estimate to a PDF and streams it back. Mirrors the invoice PDF
|
|
226
|
+
// flow (Handlebars template + html-pdf-node) but uses the estimate template.
|
|
227
|
+
export async function downloadEstimate( req, res ) {
|
|
228
|
+
try {
|
|
229
|
+
const estimate = await estimateService.findOne( { _id: req.params.estimateId } );
|
|
230
|
+
if ( !estimate ) {
|
|
231
|
+
return res.sendError( 'Estimate not found', 404 );
|
|
232
|
+
}
|
|
233
|
+
const e = estimate._doc || estimate;
|
|
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();
|
|
293
|
+
res.set( 'Content-Type', 'application/pdf' );
|
|
294
|
+
res.set( 'Content-Disposition', `attachment; filename="${filename}"` );
|
|
295
|
+
return res.send( pdfBuffer );
|
|
296
|
+
} catch ( error ) {
|
|
297
|
+
logger.error( { error: error, function: 'downloadEstimate' } );
|
|
298
|
+
return res.sendError( error, 500 );
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
export async function deleteEstimate( req, res ) {
|
|
303
|
+
try {
|
|
304
|
+
const estimate = await estimateService.findOne( { _id: req.params.estimateId } );
|
|
305
|
+
if ( !estimate ) {
|
|
306
|
+
return res.sendError( 'Estimate not found', 404 );
|
|
307
|
+
}
|
|
308
|
+
if ( estimate.status === 'accepted' ) {
|
|
309
|
+
return res.sendError( 'An accepted estimate cannot be deleted.', 409 );
|
|
310
|
+
}
|
|
311
|
+
await estimateService.updateOne( { _id: req.params.estimateId }, { $set: { status: 'declined' } } );
|
|
312
|
+
return res.sendSuccess( 'Estimate removed' );
|
|
313
|
+
} catch ( error ) {
|
|
314
|
+
logger.error( { error: error, function: 'deleteEstimate' } );
|
|
315
|
+
return res.sendError( error, 500 );
|
|
316
|
+
}
|
|
317
|
+
}
|