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.
@@ -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
- companyName: req.body.companyName || '',
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
- const price = Number( s.standard?.negotiatePrice ) || 0;
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
- console.log( fromEmail, getgroup.generateInvoiceTo, ccEmails, attachments );
593
-
594
- const result = await sendEmailWithSES( getgroup.generateInvoiceTo, mailSubject, mailbody, attachments, fromEmail, ccEmails.length ? ccEmails : undefined );
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
- return [ ...products, ...eachStoreProducts ];
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
- 'GST Amount': element.gstAmount,
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 user's full client scope (findClients),
1899
- // NOT the currently-filtered/paged view, so the cards stay stable as the
1900
- // user narrows the list with filters. Outstanding is everything unpaid;
1901
- // Overdue is the past-due subset; Pending Payment is the approved-but-
1902
- // unpaid subset. "Outstanding amount" uses totalAmount - paidAmount so a
1903
- // partially-paid invoice contributes only its remaining balance.
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 remaining = { $subtract: [
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
- delete cards._id;
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
- if ( invoice.status !== fromStatus ) {
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
+ }