tango-app-api-payment-subscription 3.5.6 → 3.5.8
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 +261 -15
- package/src/controllers/estimate.controller.js +63 -0
- package/src/controllers/invoice.controller.js +98 -34
- package/src/controllers/paymentReminder.controller.js +81 -0
- package/src/controllers/paymentReminderTrigger.controller.js +194 -0
- package/src/hbs/invoicePdf.hbs +1779 -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 +10 -0
- package/src/routes/brandsBilling.routes.js +1 -0
- package/src/routes/invoice.routes.js +2 -1
- package/src/services/paymentReminder.service.js +13 -0
- package/src/utils/currency.js +1 -0
|
@@ -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 { getUsdInrRate } from './brandsBilling.controller.js';
|
|
20
21
|
|
|
21
22
|
// Pulls CSM + Finance head emails (stored under applicationDefault
|
|
22
23
|
// type=invoice, subType=heads) AND the per-client CSMs assigned via
|
|
@@ -141,7 +142,8 @@ export async function createInvoice( req, res ) {
|
|
|
141
142
|
clientId: req.body.clientId,
|
|
142
143
|
groupId: req.body.groupId || undefined,
|
|
143
144
|
groupName: req.body.groupName || '',
|
|
144
|
-
|
|
145
|
+
// Company (registered) name is always stored uppercase on invoices.
|
|
146
|
+
companyName: ( req.body.companyName || '' ).toUpperCase(),
|
|
145
147
|
companyAddress: req.body.companyAddress || '',
|
|
146
148
|
GSTNumber: req.body.GSTNumber || '',
|
|
147
149
|
PlaceOfSupply: req.body.PlaceOfSupply || '',
|
|
@@ -284,7 +286,7 @@ export async function createInvoice( req, res ) {
|
|
|
284
286
|
amount: Math.round( amount ),
|
|
285
287
|
invoiceIndex: req.body.invoiceId ? findInvoice.invoiceIndex : invoiceNo,
|
|
286
288
|
tax: taxList,
|
|
287
|
-
companyName: group.registeredCompanyName,
|
|
289
|
+
companyName: ( group.registeredCompanyName || '' ).toUpperCase(),
|
|
288
290
|
companyAddress: address,
|
|
289
291
|
PlaceOfSupply: group.placeOfSupply,
|
|
290
292
|
GSTNumber: group.gst,
|
|
@@ -371,6 +373,10 @@ async function buildAnnexureRows( invoiceInfo, getgroup ) {
|
|
|
371
373
|
const billingMonthEnd = new Date( billingMonth.endOf( 'month' ).toISOString() );
|
|
372
374
|
const monthDays = billingMonth.daysInMonth();
|
|
373
375
|
const invoiceCurrency = symbolFor( invoiceInfo.currency );
|
|
376
|
+
// basepricing negotiatePrice is stored in INR. For non-INR invoices the
|
|
377
|
+
// annexure must convert it to the invoice currency, otherwise the INR number
|
|
378
|
+
// is shown verbatim under a $ symbol (e.g. ₹1650 rendered as "$1,650").
|
|
379
|
+
const annexFx = invoiceInfo.currency === 'dollar' ? ( await getUsdInrRate() ) : 1;
|
|
374
380
|
|
|
375
381
|
const annexClient = await clientService.findOne( { clientId: invoiceInfo.clientId }, { 'planDetails.product': 1 } );
|
|
376
382
|
const billingTypeMap = {};
|
|
@@ -431,7 +437,9 @@ async function buildAnnexureRows( invoiceInfo, getgroup ) {
|
|
|
431
437
|
units = s.trafficCameraCount;
|
|
432
438
|
}
|
|
433
439
|
}
|
|
434
|
-
|
|
440
|
+
// Convert the INR negotiatePrice into the invoice currency (annexFx = 1 for
|
|
441
|
+
// INR invoices, = USD→INR rate for dollar invoices, so divide).
|
|
442
|
+
const price = ( Number( s.standard?.negotiatePrice ) || 0 ) / annexFx;
|
|
435
443
|
const runningCost = s.workingdays >= monthDays ?
|
|
436
444
|
Math.round( price * units * 100 ) / 100 :
|
|
437
445
|
Math.round( ( price / monthDays ) * s.workingdays * units * 100 ) / 100;
|
|
@@ -525,6 +533,10 @@ export async function invoiceDownload( req, res ) {
|
|
|
525
533
|
billingCurrency: virtualAccount?.currency,
|
|
526
534
|
virtualaccountNumber: virtualAccount ? virtualAccount?.accountNumber : '',
|
|
527
535
|
virtualifsc: virtualAccount ? virtualAccount?.ifsc : '',
|
|
536
|
+
// GST applies only to domestic (INR) invoices. Gate the tax block on the
|
|
537
|
+
// invoice's OWN currency — not the payment-account currency, which is
|
|
538
|
+
// null/non-inr for many INR invoices and was dropping the GST rows.
|
|
539
|
+
gstApplicable: invoiceInfo.currency === 'inr',
|
|
528
540
|
};
|
|
529
541
|
|
|
530
542
|
if ( invoiceData?.tax?.length ) {
|
|
@@ -589,9 +601,20 @@ export async function invoiceDownload( req, res ) {
|
|
|
589
601
|
// Load configured CSM + Finance heads PLUS the per-client CSMs from
|
|
590
602
|
// userAssignedStore as CC recipients on the invoice mail.
|
|
591
603
|
const ccEmails = await getInvoiceCcEmails( invoiceInfo.clientId );
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
604
|
+
// De-duplicate recipients so nobody gets the invoice 2-3 times:
|
|
605
|
+
// unique TO list, and drop from CC anyone already in TO (overlap
|
|
606
|
+
// between generateInvoiceTo, invoice heads and assigned CSMs was the
|
|
607
|
+
// cause of duplicate mails).
|
|
608
|
+
const toEmails = [ ...new Set(
|
|
609
|
+
( getgroup.generateInvoiceTo || [] ).map( ( e ) => String( e || '' ).trim() ).filter( Boolean ),
|
|
610
|
+
) ];
|
|
611
|
+
const toSet = new Set( toEmails.map( ( e ) => e.toLowerCase() ) );
|
|
612
|
+
const dedupedCc = [ ...new Set(
|
|
613
|
+
( ccEmails || [] ).map( ( e ) => String( e || '' ).trim() ).filter( Boolean ),
|
|
614
|
+
) ].filter( ( e ) => !toSet.has( e.toLowerCase() ) );
|
|
615
|
+
console.log( fromEmail, toEmails, dedupedCc, attachments );
|
|
616
|
+
|
|
617
|
+
const result = await sendEmailWithSES( toEmails, mailSubject, mailbody, attachments, fromEmail, dedupedCc.length ? dedupedCc : undefined );
|
|
595
618
|
console.log( result );
|
|
596
619
|
let logObj = {
|
|
597
620
|
userName: req.user?.userName,
|
|
@@ -706,6 +729,8 @@ async function buildInvoicePdfBuffer( invoiceId ) {
|
|
|
706
729
|
billingCurrency: virtualAccount?.currency,
|
|
707
730
|
virtualaccountNumber: virtualAccount ? virtualAccount?.accountNumber : '',
|
|
708
731
|
virtualifsc: virtualAccount ? virtualAccount?.ifsc : '',
|
|
732
|
+
// GST applies only to domestic (INR) invoices; gate on the invoice currency.
|
|
733
|
+
gstApplicable: invoiceInfo.currency === 'inr',
|
|
709
734
|
};
|
|
710
735
|
|
|
711
736
|
if ( invoiceData?.tax?.length ) {
|
|
@@ -1289,8 +1314,12 @@ async function standardPrice( group, getClient, baseDate ) {
|
|
|
1289
1314
|
return product;
|
|
1290
1315
|
} );
|
|
1291
1316
|
|
|
1292
|
-
// Combine overallStore and eachStore products
|
|
1293
|
-
|
|
1317
|
+
// Combine overallStore and eachStore products. Sort by product name so the
|
|
1318
|
+
// persisted order is deterministic — MongoDB $group output order isn't
|
|
1319
|
+
// guaranteed, which made products "interchange" between the Plans view, the
|
|
1320
|
+
// stored invoice and regenerated invoices / PDF.
|
|
1321
|
+
return [ ...products, ...eachStoreProducts ]
|
|
1322
|
+
.sort( ( a, b ) => String( a.productName || '' ).localeCompare( String( b.productName || '' ) ) );
|
|
1294
1323
|
}
|
|
1295
1324
|
|
|
1296
1325
|
|
|
@@ -1424,7 +1453,10 @@ async function stepPrice( group, getClient ) {
|
|
|
1424
1453
|
},
|
|
1425
1454
|
},
|
|
1426
1455
|
{
|
|
1456
|
+
// productName first so order is deterministic across views/PDF, then
|
|
1457
|
+
// workingdays so step rows stay grouped consistently.
|
|
1427
1458
|
$sort: {
|
|
1459
|
+
productName: 1,
|
|
1428
1460
|
workingdays: -1,
|
|
1429
1461
|
},
|
|
1430
1462
|
},
|
|
@@ -1853,6 +1885,24 @@ export async function clientInvoiceList( req, res ) {
|
|
|
1853
1885
|
}
|
|
1854
1886
|
|
|
1855
1887
|
if ( req.body.export ) {
|
|
1888
|
+
// Due Status — mirrors the UI cell (getDueStatus) exactly so the column
|
|
1889
|
+
// matches what reviewers see on screen. Paid / no-due-date show a dash;
|
|
1890
|
+
// otherwise it's overdue / due today / due in N days from TODAY at
|
|
1891
|
+
// day-granularity (time-of-day ignored on both ends).
|
|
1892
|
+
const today = dayjs().startOf( 'day' );
|
|
1893
|
+
const dueStatusOf = ( inv ) => {
|
|
1894
|
+
if ( inv.paymentStatus === 'paid' || !inv.dueDate ) {
|
|
1895
|
+
return '—';
|
|
1896
|
+
}
|
|
1897
|
+
const days = dayjs( inv.dueDate ).startOf( 'day' ).diff( today, 'day' );
|
|
1898
|
+
if ( days < 0 ) {
|
|
1899
|
+
return `Overdue by ${-days} day${days === -1 ? '' : 's'}`;
|
|
1900
|
+
}
|
|
1901
|
+
if ( days === 0 ) {
|
|
1902
|
+
return 'Due today';
|
|
1903
|
+
}
|
|
1904
|
+
return `Due in ${days} day${days === 1 ? '' : 's'}`;
|
|
1905
|
+
};
|
|
1856
1906
|
const exportdata = [];
|
|
1857
1907
|
count.forEach( ( element ) => {
|
|
1858
1908
|
exportdata.push( {
|
|
@@ -1861,9 +1911,13 @@ export async function clientInvoiceList( req, res ) {
|
|
|
1861
1911
|
'Invoice #': element.invoice,
|
|
1862
1912
|
'Billing date': dayjs( element.billingDate ).format( 'DD MMM, YYYY' ),
|
|
1863
1913
|
'Due Date': element.dueDate ? dayjs( element.dueDate ).format( 'DD MMM, YYYY' ) : '',
|
|
1914
|
+
'Due Status': dueStatusOf( element ),
|
|
1864
1915
|
'Group Name': element.groupName,
|
|
1865
1916
|
'Amount Excl. GST': element.amount,
|
|
1866
|
-
|
|
1917
|
+
// GST only applies to domestic (INR) invoices. International invoices
|
|
1918
|
+
// (dollar / euro / etc.) are billed without GST — show a dash to
|
|
1919
|
+
// match the on-screen column rather than a misleading 0.
|
|
1920
|
+
'GST Amount': element.currency === 'inr' ? element.gstAmount : '—',
|
|
1867
1921
|
'Amount Incl. GST': element.totalAmount,
|
|
1868
1922
|
'Stores': element.stores,
|
|
1869
1923
|
'Payment Status': element.paymentStatus,
|
|
@@ -1895,35 +1949,42 @@ export async function clientInvoiceList( req, res ) {
|
|
|
1895
1949
|
}
|
|
1896
1950
|
}
|
|
1897
1951
|
|
|
1898
|
-
// Card totals — computed over the
|
|
1899
|
-
//
|
|
1900
|
-
//
|
|
1901
|
-
//
|
|
1902
|
-
//
|
|
1903
|
-
//
|
|
1952
|
+
// Card totals — computed over the CURRENTLY-FILTERED result set (`count`
|
|
1953
|
+
// holds every invoice matching the active filters, pre-pagination), so the
|
|
1954
|
+
// cards reflect exactly what the filters select. Dollar invoices are
|
|
1955
|
+
// converted to INR at today's rate so all three totals are a single ₹
|
|
1956
|
+
// figure. Outstanding = unpaid remaining (totalAmount - paidAmount);
|
|
1957
|
+
// Overdue = past-due unpaid subset; Pending Payment = approved-but-unpaid.
|
|
1958
|
+
const usdRate = await getUsdInrRate();
|
|
1904
1959
|
const now = new Date();
|
|
1905
|
-
const
|
|
1906
|
-
{ $ifNull: [ '$totalAmount', { $ifNull: [ '$amount', 0 ] } ] },
|
|
1907
|
-
{ $ifNull: [ '$paidAmount', 0 ] },
|
|
1908
|
-
] };
|
|
1909
|
-
const cardsAggregate = await invoiceService.aggregate( [
|
|
1910
|
-
{ $match: { clientId: { $in: findClients }, paymentStatus: { $ne: 'paid' } } },
|
|
1911
|
-
{ $group: {
|
|
1912
|
-
_id: null,
|
|
1913
|
-
outstandingAmount: { $sum: remaining },
|
|
1914
|
-
outstandingCount: { $sum: 1 },
|
|
1915
|
-
overdueAmount: { $sum: { $cond: [ { $lt: [ '$dueDate', now ] }, remaining, 0 ] } },
|
|
1916
|
-
overdueCount: { $sum: { $cond: [ { $lt: [ '$dueDate', now ] }, 1, 0 ] } },
|
|
1917
|
-
pendingPaymentAmount: { $sum: { $cond: [ { $eq: [ '$status', 'approved' ] }, remaining, 0 ] } },
|
|
1918
|
-
pendingPaymentCount: { $sum: { $cond: [ { $eq: [ '$status', 'approved' ] }, 1, 0 ] } },
|
|
1919
|
-
} },
|
|
1920
|
-
] );
|
|
1921
|
-
const cards = cardsAggregate[0] || {
|
|
1960
|
+
const cards = {
|
|
1922
1961
|
outstandingAmount: 0, outstandingCount: 0,
|
|
1923
1962
|
overdueAmount: 0, overdueCount: 0,
|
|
1924
1963
|
pendingPaymentAmount: 0, pendingPaymentCount: 0,
|
|
1925
1964
|
};
|
|
1926
|
-
|
|
1965
|
+
for ( const inv of count ) {
|
|
1966
|
+
if ( inv.paymentStatus === 'paid' ) {
|
|
1967
|
+
continue;
|
|
1968
|
+
}
|
|
1969
|
+
const fx = inv.currency === 'dollar' ? usdRate : 1;
|
|
1970
|
+
const total = Number( inv.totalAmount ) || Number( inv.amount ) || 0;
|
|
1971
|
+
const paid = Number( inv.paidAmount ) || 0;
|
|
1972
|
+
const remaining = Math.max( 0, total - paid ) * fx;
|
|
1973
|
+
|
|
1974
|
+
cards.outstandingAmount += remaining;
|
|
1975
|
+
cards.outstandingCount += 1;
|
|
1976
|
+
if ( inv.dueDate && new Date( inv.dueDate ) < now ) {
|
|
1977
|
+
cards.overdueAmount += remaining;
|
|
1978
|
+
cards.overdueCount += 1;
|
|
1979
|
+
}
|
|
1980
|
+
if ( inv.status === 'approved' ) {
|
|
1981
|
+
cards.pendingPaymentAmount += remaining;
|
|
1982
|
+
cards.pendingPaymentCount += 1;
|
|
1983
|
+
}
|
|
1984
|
+
}
|
|
1985
|
+
cards.outstandingAmount = Math.round( cards.outstandingAmount * 100 ) / 100;
|
|
1986
|
+
cards.overdueAmount = Math.round( cards.overdueAmount * 100 ) / 100;
|
|
1987
|
+
cards.pendingPaymentAmount = Math.round( cards.pendingPaymentAmount * 100 ) / 100;
|
|
1927
1988
|
|
|
1928
1989
|
res.sendSuccess( { count: count.length, data: invoiceList, cards } );
|
|
1929
1990
|
} catch ( error ) {
|
|
@@ -2460,7 +2521,10 @@ async function transitionInvoiceStatus( req, res, fromStatus, toStatus ) {
|
|
|
2460
2521
|
return res.sendError( 'Invoice not found', 404 );
|
|
2461
2522
|
}
|
|
2462
2523
|
|
|
2463
|
-
|
|
2524
|
+
// Legacy 'pending' invoices are equivalent to the first CSM stage, so the
|
|
2525
|
+
// CSM transition accepts either 'pendingCsm' or 'pending' as the source.
|
|
2526
|
+
const acceptedFrom = fromStatus === 'pendingCsm' ? [ 'pendingCsm', 'pending' ] : [ fromStatus ];
|
|
2527
|
+
if ( !acceptedFrom.includes( invoice.status ) ) {
|
|
2464
2528
|
return res.sendError(
|
|
2465
2529
|
`Invoice is currently at status '${invoice.status}', not '${fromStatus}'. Another user may have advanced it.`,
|
|
2466
2530
|
409,
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import * as paymentReminderService from '../services/paymentReminder.service.js';
|
|
2
|
+
import { logger } from 'tango-app-api-middleware';
|
|
3
|
+
|
|
4
|
+
// Payment reminder config (Billing Settings page). One document per brand
|
|
5
|
+
// (clientId): recipient emails + five toggleable reminder templates. Returns
|
|
6
|
+
// sensible defaults when a brand has no config saved yet.
|
|
7
|
+
const DEFAULTS = () => ( {
|
|
8
|
+
reminderEmails: [],
|
|
9
|
+
templates: {
|
|
10
|
+
preDue: { enabled: true, daysBefore: 3 },
|
|
11
|
+
onDue: { enabled: true },
|
|
12
|
+
onHold: { enabled: true },
|
|
13
|
+
suspend: { enabled: true },
|
|
14
|
+
deactivated: { enabled: false },
|
|
15
|
+
},
|
|
16
|
+
} );
|
|
17
|
+
|
|
18
|
+
export async function getPaymentReminder( req, res ) {
|
|
19
|
+
try {
|
|
20
|
+
const clientId = req.params.clientId || req.query.clientId;
|
|
21
|
+
if ( !clientId ) {
|
|
22
|
+
return res.sendError( 'clientId is required', 400 );
|
|
23
|
+
}
|
|
24
|
+
const existing = await paymentReminderService.findOne( { clientId } );
|
|
25
|
+
if ( !existing ) {
|
|
26
|
+
return res.sendSuccess( { clientId, ...DEFAULTS(), isDefault: true } );
|
|
27
|
+
}
|
|
28
|
+
return res.sendSuccess( existing );
|
|
29
|
+
} catch ( error ) {
|
|
30
|
+
logger.error( { error: error, function: 'getPaymentReminder' } );
|
|
31
|
+
return res.sendError( error, 500 );
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function savePaymentReminder( req, res ) {
|
|
36
|
+
try {
|
|
37
|
+
const b = req.body || {};
|
|
38
|
+
if ( !b.clientId ) {
|
|
39
|
+
return res.sendError( 'clientId is required', 400 );
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Normalize recipients: trim, drop blanks, de-dupe.
|
|
43
|
+
const emails = Array.isArray( b.reminderEmails ) ? b.reminderEmails : [];
|
|
44
|
+
const reminderEmails = [ ...new Set(
|
|
45
|
+
emails.map( ( e ) => String( e || '' ).trim() ).filter( Boolean ),
|
|
46
|
+
) ];
|
|
47
|
+
|
|
48
|
+
const t = b.templates || {};
|
|
49
|
+
const bool = ( v, d ) => ( typeof v === 'boolean' ? v : d );
|
|
50
|
+
let daysBefore = Number( t.preDue?.daysBefore );
|
|
51
|
+
if ( !Number.isFinite( daysBefore ) ) {
|
|
52
|
+
daysBefore = 3;
|
|
53
|
+
}
|
|
54
|
+
daysBefore = Math.min( 365, Math.max( 1, Math.round( daysBefore ) ) );
|
|
55
|
+
|
|
56
|
+
const templates = {
|
|
57
|
+
preDue: { enabled: bool( t.preDue?.enabled, true ), daysBefore },
|
|
58
|
+
onDue: { enabled: bool( t.onDue?.enabled, true ) },
|
|
59
|
+
onHold: { enabled: bool( t.onHold?.enabled, true ) },
|
|
60
|
+
suspend: { enabled: bool( t.suspend?.enabled, true ) },
|
|
61
|
+
deactivated: { enabled: bool( t.deactivated?.enabled, false ) },
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
await paymentReminderService.upsert(
|
|
65
|
+
{ clientId: b.clientId },
|
|
66
|
+
{
|
|
67
|
+
clientId: b.clientId,
|
|
68
|
+
reminderEmails,
|
|
69
|
+
templates,
|
|
70
|
+
updatedBy: req.user?.email || req.user?.userName || '',
|
|
71
|
+
},
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
const saved = await paymentReminderService.findOne( { clientId: b.clientId } );
|
|
75
|
+
logger.info?.( { function: 'savePaymentReminder', clientId: b.clientId } );
|
|
76
|
+
return res.sendSuccess( saved );
|
|
77
|
+
} catch ( error ) {
|
|
78
|
+
logger.error( { error: error, function: 'savePaymentReminder' } );
|
|
79
|
+
return res.sendError( error, 500 );
|
|
80
|
+
}
|
|
81
|
+
}
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import * as paymentReminderService from '../services/paymentReminder.service.js';
|
|
2
|
+
import * as invoiceService from '../services/invoice.service.js';
|
|
3
|
+
import * as clientService from '../services/clientPayment.services.js';
|
|
4
|
+
import dayjs from 'dayjs';
|
|
5
|
+
import fs from 'fs';
|
|
6
|
+
import path from 'path';
|
|
7
|
+
import Handlebars from '../utils/validations/helper/handlebar.helper.js';
|
|
8
|
+
import { logger, sendEmailWithSES } from 'tango-app-api-middleware';
|
|
9
|
+
import { symbolFor } from '../utils/currency.js';
|
|
10
|
+
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
// Payment reminder sender. Triggered by a cron job (POST /…/trigger). For each
|
|
13
|
+
// brand that has a reminder config, it finds the unpaid invoices and decides
|
|
14
|
+
// which single reminder stage applies based on the OLDEST unpaid invoice's
|
|
15
|
+
// days-past-due, then emails the enabled template to the configured recipients.
|
|
16
|
+
//
|
|
17
|
+
// Stage When Template
|
|
18
|
+
// beforeDue due in N days (config daysBefore) reminderBeforeDue
|
|
19
|
+
// onDue due today reminderOnDue
|
|
20
|
+
// onHold 1..29 days overdue reminderOnHold
|
|
21
|
+
// suspend 30..59 days overdue reminderSuspended
|
|
22
|
+
// deactivated 60+ days overdue reminderDeactivated
|
|
23
|
+
//
|
|
24
|
+
// One email per brand per run (the most severe applicable stage), listing all
|
|
25
|
+
// that brand's unpaid invoices. Disabled stages are skipped.
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
|
|
28
|
+
const HBS_DIR = path.resolve( path.dirname( '' ) ) + '/src/hbs';
|
|
29
|
+
let partialsRegistered = false;
|
|
30
|
+
function ensurePartials() {
|
|
31
|
+
if ( partialsRegistered ) {
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
const partial = fs.readFileSync( `${HBS_DIR}/partials/invoiceSummaryTable.hbs`, 'utf8' );
|
|
35
|
+
Handlebars.registerPartial( 'invoiceSummaryTable', partial );
|
|
36
|
+
partialsRegistered = true;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const TEMPLATE_FILE = {
|
|
40
|
+
beforeDue: 'reminderBeforeDue.hbs',
|
|
41
|
+
onDue: 'reminderOnDue.hbs',
|
|
42
|
+
onHold: 'reminderOnHold.hbs',
|
|
43
|
+
suspend: 'reminderSuspended.hbs',
|
|
44
|
+
deactivated: 'reminderDeactivated.hbs',
|
|
45
|
+
};
|
|
46
|
+
const SUBJECT = {
|
|
47
|
+
beforeDue: ( v ) => `Payment reminder — due on ${v.dueDate}`,
|
|
48
|
+
onDue: ( v ) => `Payment due today — ${v.totalDue} outstanding`,
|
|
49
|
+
onHold: () => 'Action needed: payment overdue — your account is on hold',
|
|
50
|
+
suspend: () => 'Important: account suspended — payment 30+ days overdue',
|
|
51
|
+
deactivated: () => 'Final notice: account deactivated — payment 60+ days overdue',
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const templateCache = {};
|
|
55
|
+
function renderTemplate( stage, data ) {
|
|
56
|
+
ensurePartials();
|
|
57
|
+
if ( !templateCache[stage] ) {
|
|
58
|
+
const html = fs.readFileSync( `${HBS_DIR}/${TEMPLATE_FILE[stage]}`, 'utf8' );
|
|
59
|
+
templateCache[stage] = Handlebars.compile( html );
|
|
60
|
+
}
|
|
61
|
+
return templateCache[stage]( data );
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Resolve the single applicable stage from days-past-due (negative = not yet
|
|
65
|
+
// due) of the OLDEST unpaid invoice, honoring which stages are enabled.
|
|
66
|
+
function resolveStage( daysPastDue, templates ) {
|
|
67
|
+
const t = templates || {};
|
|
68
|
+
if ( daysPastDue >= 60 ) {
|
|
69
|
+
return t.deactivated?.enabled ? 'deactivated' : null;
|
|
70
|
+
}
|
|
71
|
+
if ( daysPastDue >= 30 ) {
|
|
72
|
+
return t.suspend?.enabled ? 'suspend' : null;
|
|
73
|
+
}
|
|
74
|
+
if ( daysPastDue >= 1 ) {
|
|
75
|
+
return t.onHold?.enabled ? 'onHold' : null;
|
|
76
|
+
}
|
|
77
|
+
if ( daysPastDue === 0 ) {
|
|
78
|
+
return t.onDue?.enabled ? 'onDue' : null;
|
|
79
|
+
}
|
|
80
|
+
// Not yet due — only fire the pre-due heads-up on exactly the configured
|
|
81
|
+
// lead day (e.g. 3 days before), so the cron doesn't email every day.
|
|
82
|
+
const lead = Number( t.preDue?.daysBefore ) || 3;
|
|
83
|
+
if ( t.preDue?.enabled && daysPastDue === -lead ) {
|
|
84
|
+
return 'beforeDue';
|
|
85
|
+
}
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export async function triggerPaymentReminders( req, res ) {
|
|
90
|
+
try {
|
|
91
|
+
const today = dayjs().startOf( 'day' );
|
|
92
|
+
// Optional dry-run: ?dryRun=true renders + reports but sends nothing.
|
|
93
|
+
const dryRun = String( req.query?.dryRun || req.body?.dryRun || '' ) === 'true';
|
|
94
|
+
const SES = JSON.parse( process.env.SES );
|
|
95
|
+
const fromEmail = SES.accountsEmail || SES.adminEmail;
|
|
96
|
+
const logo = `${JSON.parse( process.env.URL ).apiDomain}/logo.png`;
|
|
97
|
+
|
|
98
|
+
const configs = await paymentReminderService.find( {} );
|
|
99
|
+
const summary = { brandsProcessed: 0, emailsSent: 0, skipped: 0, byStage: {}, errors: [] };
|
|
100
|
+
|
|
101
|
+
for ( const cfg of configs ) {
|
|
102
|
+
summary.brandsProcessed++;
|
|
103
|
+
const clientId = cfg.clientId;
|
|
104
|
+
const recipients = ( cfg.reminderEmails || [] ).filter( Boolean );
|
|
105
|
+
if ( !clientId || !recipients.length ) {
|
|
106
|
+
summary.skipped++;
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Unpaid invoices for this brand (unpaid OR partial — anything with a
|
|
111
|
+
// remaining balance). Newest data only; paid invoices excluded.
|
|
112
|
+
const invoices = await invoiceService.find(
|
|
113
|
+
{ clientId, paymentStatus: { $ne: 'paid' } },
|
|
114
|
+
{ invoice: 1, billingDate: 1, dueDate: 1, amount: 1, totalAmount: 1, paidAmount: 1, currency: 1, companyName: 1 },
|
|
115
|
+
);
|
|
116
|
+
if ( !invoices.length ) {
|
|
117
|
+
summary.skipped++;
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Oldest due date drives the stage.
|
|
122
|
+
let oldestDue = null;
|
|
123
|
+
for ( const inv of invoices ) {
|
|
124
|
+
if ( !inv.dueDate ) {
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
const d = dayjs( inv.dueDate ).startOf( 'day' );
|
|
128
|
+
if ( !oldestDue || d.isBefore( oldestDue ) ) {
|
|
129
|
+
oldestDue = d;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
if ( !oldestDue ) {
|
|
133
|
+
summary.skipped++;
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
const daysPastDue = today.diff( oldestDue, 'day' );
|
|
137
|
+
const stage = resolveStage( daysPastDue, cfg.templates );
|
|
138
|
+
if ( !stage ) {
|
|
139
|
+
summary.skipped++;
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Build the invoice rows + total (convert nothing — show each invoice in
|
|
144
|
+
// its own currency symbol; total assumes a single currency per brand).
|
|
145
|
+
let total = 0;
|
|
146
|
+
let currency = 'inr';
|
|
147
|
+
const rows = invoices.map( ( inv ) => {
|
|
148
|
+
const sym = symbolFor( inv.currency );
|
|
149
|
+
currency = inv.currency || currency;
|
|
150
|
+
const remaining = Math.max( 0, ( Number( inv.totalAmount ) || Number( inv.amount ) || 0 ) - ( Number( inv.paidAmount ) || 0 ) );
|
|
151
|
+
total += remaining;
|
|
152
|
+
return {
|
|
153
|
+
invoiceNumber: inv.invoice,
|
|
154
|
+
invoiceDate: inv.billingDate ? dayjs( inv.billingDate ).format( 'DD MMM YYYY' ) : '',
|
|
155
|
+
dueDate: inv.dueDate ? dayjs( inv.dueDate ).format( 'DD MMM YYYY' ) : '',
|
|
156
|
+
amountDue: `${sym} ${remaining.toLocaleString( 'en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 } )}`,
|
|
157
|
+
};
|
|
158
|
+
} );
|
|
159
|
+
const totalDue = `${symbolFor( currency )} ${total.toLocaleString( 'en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 } )}`;
|
|
160
|
+
|
|
161
|
+
const client = await clientService.findOne( { clientId }, { clientName: 1 } );
|
|
162
|
+
const data = {
|
|
163
|
+
clientName: client?.clientName || invoices[0]?.companyName || 'Customer',
|
|
164
|
+
companyName: 'Team Tango',
|
|
165
|
+
dueDate: oldestDue.format( 'DD MMM YYYY' ),
|
|
166
|
+
totalDue,
|
|
167
|
+
invoices: rows,
|
|
168
|
+
logo,
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
const html = renderTemplate( stage, data );
|
|
172
|
+
const subject = SUBJECT[stage]( data );
|
|
173
|
+
|
|
174
|
+
summary.byStage[stage] = ( summary.byStage[stage] || 0 ) + 1;
|
|
175
|
+
if ( dryRun ) {
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
try {
|
|
179
|
+
await sendEmailWithSES( recipients, subject, html, '', fromEmail );
|
|
180
|
+
console.log( '🚀 ~ triggerPaymentReminders ~ recipients:', recipients );
|
|
181
|
+
summary.emailsSent++;
|
|
182
|
+
} catch ( sendErr ) {
|
|
183
|
+
logger.error( { error: sendErr, function: 'triggerPaymentReminders.send', clientId } );
|
|
184
|
+
summary.errors.push( { clientId, error: String( sendErr?.message || sendErr ) } );
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
logger.info?.( { function: 'triggerPaymentReminders', dryRun, summary } );
|
|
189
|
+
return res.sendSuccess( summary );
|
|
190
|
+
} catch ( error ) {
|
|
191
|
+
logger.error( { error: error, function: 'triggerPaymentReminders' } );
|
|
192
|
+
return res.sendError( error, 500 );
|
|
193
|
+
}
|
|
194
|
+
}
|