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.
@@ -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: [ 'draft', 'sent' ] }, validTill: { $lt: new Date() } },
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 = { draft: 0, sent: 0, accepted: 0, declined: 0, expired: 0, total: 0 };
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' : 'draft',
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 && [ 'draft', 'sent' ].includes( 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 = [ 'draft', 'sent', 'accepted', 'declined', 'expired' ];
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 e = estimate._doc || estimate;
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
- await estimateService.updateOne( { _id: req.params.estimateId }, { $set: { status: 'declined' } } );
375
- return res.sendSuccess( 'Estimate removed' );
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: Array.isArray( req.body.products ) ? req.body.products : [],
180
+ products: customProducts,
151
181
  tax: Array.isArray( req.body.tax ) ? req.body.tax : [],
152
- amount: Math.round( Number( req.body.amount ) || 0 ),
153
- totalAmount: Math.round( Number( req.body.totalAmount ) || 0 ),
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
- let invoicedate = req.body.invoiceId ? dayjs( findInvoice.billingDate ).format( 'YYYY-MM-DD' ) : baseDate.format( 'YYYY-MM-DD' );
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
- let dueDate = baseDate.add( daysExtend, 'days' );
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>