tango-app-api-payment-subscription 3.5.7 → 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 +107 -8
- package/src/controllers/estimate.controller.js +63 -0
- package/src/controllers/invoice.controller.js +66 -10
- 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 +5 -0
- package/src/routes/invoice.routes.js +2 -1
- package/src/services/paymentReminder.service.js +4 -0
- package/src/utils/currency.js +1 -0
|
@@ -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
|
+
}
|