tango-app-api-payment-subscription 3.5.7 → 3.5.9

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tango-app-api-payment-subscription",
3
- "version": "3.5.7",
3
+ "version": "3.5.9",
4
4
  "description": "paymentSubscription",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -29,7 +29,7 @@
29
29
  "nodemon": "^3.1.0",
30
30
  "puppeteer": "^24.41.0",
31
31
  "swagger-ui-express": "^5.0.0",
32
- "tango-api-schema": "^2.6.26",
32
+ "tango-api-schema": "^2.6.28",
33
33
  "tango-app-api-middleware": "^3.6.18",
34
34
  "winston": "^3.12.0",
35
35
  "winston-daily-rotate-file": "^5.0.0",
@@ -0,0 +1,82 @@
1
+ // One-shot seeder: creates a paymentreminders config for every ACTIVE client
2
+ // (clients.status === 'active'), with a single static recipient email and the
3
+ // app's default template toggles. Idempotent — re-running upserts the same
4
+ // docs, so existing configs are overwritten with the static email + defaults.
5
+ //
6
+ // Usage (from the API project root):
7
+ // node scripts/seed-payment-reminders.js
8
+ // node scripts/seed-payment-reminders.js someone@else.com # override email
9
+ // node scripts/seed-payment-reminders.js --dry-run # report only
10
+ //
11
+ // Reads MONGO connection + env from .env (same as the app).
12
+
13
+ import dotenv from 'dotenv';
14
+ import mongoose from 'mongoose';
15
+ import model from 'tango-api-schema';
16
+ import { getConnection } from '../config/database/database.js';
17
+
18
+ dotenv.config();
19
+
20
+ const args = process.argv.slice( 2 );
21
+ const dryRun = args.includes( '--dry-run' );
22
+ const STATIC_EMAIL = args.find( ( a ) => a.includes( '@' ) ) || 'ayyanarkalusulingam13@gmail.com';
23
+
24
+ // Mirrors the app's DEFAULTS() in paymentReminder.controller.js.
25
+ const DEFAULT_TEMPLATES = {
26
+ preDue: { enabled: true, daysBefore: 3 },
27
+ onDue: { enabled: true },
28
+ onHold: { enabled: true },
29
+ suspend: { enabled: true },
30
+ deactivated: { enabled: false },
31
+ };
32
+
33
+ async function run() {
34
+ // Reuse the app's own connection builder (reads mongo_* env config).
35
+ const { uri, options } = getConnection();
36
+ await mongoose.connect( uri, options );
37
+ console.log( `Connected. Static recipient: ${STATIC_EMAIL}${dryRun ? ' (DRY RUN — no writes)' : ''}\n` );
38
+
39
+ const clients = await model.clientModel.find(
40
+ { status: 'active' },
41
+ { clientId: 1, clientName: 1 },
42
+ ).lean();
43
+ console.log( `Active clients found: ${clients.length}\n` );
44
+
45
+ const summary = { total: clients.length, upserted: 0, skipped: 0 };
46
+
47
+ for ( const c of clients ) {
48
+ if ( !c.clientId ) {
49
+ summary.skipped++;
50
+ console.log( ` - skip (no clientId): ${c.clientName || c._id}` );
51
+ continue;
52
+ }
53
+ if ( dryRun ) {
54
+ summary.upserted++;
55
+ console.log( ` • would upsert: ${c.clientName || c.clientId}` );
56
+ continue;
57
+ }
58
+ await model.paymentReminderModel.updateOne(
59
+ { clientId: c.clientId },
60
+ {
61
+ $set: {
62
+ clientId: c.clientId,
63
+ reminderEmails: [ STATIC_EMAIL ],
64
+ templates: DEFAULT_TEMPLATES,
65
+ updatedBy: 'seed-script',
66
+ },
67
+ },
68
+ { upsert: true },
69
+ );
70
+ summary.upserted++;
71
+ console.log( ` ✓ ${c.clientName || c.clientId}` );
72
+ }
73
+
74
+ console.log( `\nDone. ${summary.upserted} upserted, ${summary.skipped} skipped (of ${summary.total} active).` );
75
+ await mongoose.disconnect();
76
+ process.exit( 0 );
77
+ }
78
+
79
+ run().catch( ( err ) => {
80
+ console.error( 'Failed:', err?.message || err );
81
+ process.exit( 1 );
82
+ } );
@@ -0,0 +1,70 @@
1
+ // One-shot tester: renders all 5 payment-reminder templates with sample
2
+ // invoice data and emails each one to a single recipient, so you can see
3
+ // exactly what every reminder stage looks like. Uses the real SES setup
4
+ // (reads AWS_CONFIG / SES from .env, same as the app).
5
+ //
6
+ // Usage (from the API project root):
7
+ // node scripts/send-reminder-test-emails.js
8
+ // node scripts/send-reminder-test-emails.js someone@else.com
9
+ //
10
+ // The recipient defaults to the address below; pass an arg to override.
11
+
12
+ import dotenv from 'dotenv';
13
+ import fs from 'fs';
14
+ import path from 'path';
15
+ import { fileURLToPath } from 'url';
16
+ import Handlebars from '../src/utils/validations/helper/handlebar.helper.js';
17
+ import { sendEmailWithSES } from 'tango-app-api-middleware';
18
+
19
+ dotenv.config();
20
+
21
+ const TO = process.argv[2] || 'ayyanarkalusulingam13@gmail.com';
22
+
23
+ const __dirname = path.dirname( fileURLToPath( import.meta.url ) );
24
+ const HBS_DIR = path.resolve( __dirname, '../src/hbs' );
25
+ Handlebars.registerPartial(
26
+ 'invoiceSummaryTable',
27
+ fs.readFileSync( `${HBS_DIR}/partials/invoiceSummaryTable.hbs`, 'utf8' ),
28
+ );
29
+
30
+ // Sample data — a representative client with two unpaid invoices.
31
+ const data = {
32
+ clientName: 'Cashify',
33
+ companyName: 'Team Tango',
34
+ dueDate: '17 Jun 2026',
35
+ totalDue: '₹ 2,70,106.00',
36
+ logo: `${JSON.parse( process.env.URL ).apiDomain}/logo.png`,
37
+ invoices: [
38
+ { invoiceNumber: 'INV-26-27-00210', invoiceDate: '02 Jun 2026', dueDate: '17 Jun 2026', amountDue: '₹ 1,42,947.00' },
39
+ { invoiceNumber: 'INV-26-27-00205', invoiceDate: '02 May 2026', dueDate: '17 May 2026', amountDue: '₹ 1,27,159.00' },
40
+ ],
41
+ };
42
+
43
+ const STAGES = [
44
+ { file: 'reminderBeforeDue.hbs', subject: `[TEST] Payment reminder — due on ${data.dueDate}` },
45
+ { file: 'reminderOnDue.hbs', subject: `[TEST] Payment due today — ${data.totalDue} outstanding` },
46
+ { file: 'reminderOnHold.hbs', subject: '[TEST] Action needed: payment overdue — your account is on hold' },
47
+ { file: 'reminderSuspended.hbs', subject: '[TEST] Important: account suspended — payment 30+ days overdue' },
48
+ { file: 'reminderDeactivated.hbs', subject: '[TEST] Final notice: account deactivated — payment 60+ days overdue' },
49
+ ];
50
+
51
+ async function run() {
52
+ const SES = JSON.parse( process.env.SES );
53
+ const fromEmail = SES.accountsEmail || SES.adminEmail;
54
+ console.log( `Sending ${STAGES.length} reminder emails to ${TO} (from ${fromEmail})\n` );
55
+
56
+ for ( const stage of STAGES ) {
57
+ const tpl = Handlebars.compile( fs.readFileSync( `${HBS_DIR}/${stage.file}`, 'utf8' ) );
58
+ const html = tpl( data );
59
+ try {
60
+ await sendEmailWithSES( TO, stage.subject, html, '', fromEmail );
61
+ console.log( ` ✓ sent: ${stage.file}` );
62
+ } catch ( err ) {
63
+ console.error( ` ✗ failed: ${stage.file} —`, err?.message || err );
64
+ }
65
+ }
66
+ console.log( '\nDone.' );
67
+ process.exit( 0 );
68
+ }
69
+
70
+ run();
@@ -15,6 +15,12 @@ export async function brandsBillingList( req, res ) {
15
15
  try {
16
16
  let query = [];
17
17
 
18
+ // Billing-store count is taken from dailyPricing for the current billing
19
+ // month: a store counts only if it actually RAN on more than one day in
20
+ // the month (a store that appears on a single day is transient — e.g. a
21
+ // late onboard or a one-off reading — and isn't billed for the period).
22
+ const billingMonthStart = new Date( dayjs().startOf( 'month' ).toISOString() );
23
+
18
24
  if ( req.body.status && req.body.status.length > 0 ) {
19
25
  query.push( {
20
26
  $match: {
@@ -38,9 +44,15 @@ export async function brandsBillingList( req, res ) {
38
44
  let: { clientId: '$clientId' },
39
45
  pipeline: [
40
46
  {
47
+ // Count only ACTIVE stores — the 'stores' collection also holds
48
+ // deactive/suspended docs which inflated the No. of Stores
49
+ // column and made it inconsistent with Billing Stores.
41
50
  $match: {
42
51
  $expr: {
43
- $eq: [ '$clientId', '$$clientId' ],
52
+ $and: [
53
+ { $eq: [ '$clientId', '$$clientId' ] },
54
+ { $eq: [ '$status', 'active' ] },
55
+ ],
44
56
  },
45
57
  },
46
58
  },
@@ -66,7 +78,6 @@ export async function brandsBillingList( req, res ) {
66
78
  {
67
79
  $group: {
68
80
  _id: null,
69
- billingStores: { $sum: { $size: { $ifNull: [ '$stores', [] ] } } },
70
81
  products: { $addToSet: '$products' },
71
82
  nextBillingDate: { $min: '$nextBillingDate' },
72
83
  },
@@ -75,6 +86,35 @@ export async function brandsBillingList( req, res ) {
75
86
  as: 'billingData',
76
87
  },
77
88
  },
89
+ {
90
+ // Billing stores from dailyPricing: distinct active stores that ran
91
+ // on MORE THAN ONE day in the current billing month. We unwind the
92
+ // per-day store rows, keep active ones, collect the distinct dates
93
+ // each store was active, then count only the stores seen on >1 day.
94
+ $lookup: {
95
+ from: 'dailypricings',
96
+ let: { clientId: '$clientId' },
97
+ pipeline: [
98
+ {
99
+ $match: {
100
+ $expr: { $eq: [ '$clientId', '$$clientId' ] },
101
+ dateISO: { $gte: billingMonthStart },
102
+ },
103
+ },
104
+ { $unwind: { path: '$stores', preserveNullAndEmptyArrays: false } },
105
+ { $match: { 'stores.status': 'active' } },
106
+ {
107
+ $group: {
108
+ _id: '$stores.storeId',
109
+ days: { $addToSet: { $dateToString: { format: '%Y-%m-%d', date: '$dateISO' } } },
110
+ },
111
+ },
112
+ { $match: { $expr: { $gt: [ { $size: '$days' }, 1 ] } } },
113
+ { $count: 'billingStores' },
114
+ ],
115
+ as: 'dailyBillingData',
116
+ },
117
+ },
78
118
  {
79
119
  $lookup: {
80
120
  from: 'invoices',
@@ -91,9 +131,14 @@ export async function brandsBillingList( req, res ) {
91
131
  },
92
132
  },
93
133
  {
134
+ // Split unpaid totals by currency so dollar invoices can be
135
+ // converted to INR after aggregation (the $lookup can't call
136
+ // getUsdInrRate). Invoice totalAmount is stored in NATIVE
137
+ // currency, so summing blindly mislabelled USD as INR.
94
138
  $group: {
95
139
  _id: null,
96
- totalDue: { $sum: '$totalAmount' },
140
+ dueInr: { $sum: { $cond: [ { $eq: [ '$currency', 'dollar' ] }, 0, '$totalAmount' ] } },
141
+ dueUsd: { $sum: { $cond: [ { $eq: [ '$currency', 'dollar' ] }, '$totalAmount', 0 ] } },
97
142
  },
98
143
  },
99
144
  ],
@@ -106,13 +151,16 @@ export async function brandsBillingList( req, res ) {
106
151
  {
107
152
  $unwind: { path: '$billingData', preserveNullAndEmptyArrays: true },
108
153
  },
154
+ {
155
+ $unwind: { path: '$dailyBillingData', preserveNullAndEmptyArrays: true },
156
+ },
109
157
  {
110
158
  $unwind: { path: '$invoiceData', preserveNullAndEmptyArrays: true },
111
159
  },
112
160
  {
113
161
  $addFields: {
114
162
  totalStores: { $ifNull: [ '$storeData.totalStores', 0 ] },
115
- billingStores: { $ifNull: [ '$billingData.billingStores', 0 ] },
163
+ billingStores: { $ifNull: [ '$dailyBillingData.billingStores', 0 ] },
116
164
  productsAdded: {
117
165
  $size: {
118
166
  $filter: {
@@ -122,8 +170,24 @@ export async function brandsBillingList( req, res ) {
122
170
  },
123
171
  },
124
172
  },
125
- billAmountDue: { $ifNull: [ '$invoiceData.totalDue', 0 ] },
173
+ dueInr: { $ifNull: [ '$invoiceData.dueInr', 0 ] },
174
+ dueUsd: { $ifNull: [ '$invoiceData.dueUsd', 0 ] },
126
175
  nextBillingDate: { $ifNull: [ '$billingData.nextBillingDate', null ] },
176
+ // Whether billing is SET UP — true when a billing group exists (or
177
+ // live products are configured). Drives the Setup-Billing vs View
178
+ // button; must NOT depend on the volatile current-month run count.
179
+ billingConfigured: {
180
+ $or: [
181
+ { $ne: [ '$billingData', null ] },
182
+ { $gt: [ {
183
+ $size: { $filter: {
184
+ input: { $ifNull: [ '$planDetails.product', [] ] },
185
+ as: 'prod',
186
+ cond: { $eq: [ '$$prod.status', 'live' ] },
187
+ } },
188
+ }, 0 ] },
189
+ ],
190
+ },
127
191
  },
128
192
  },
129
193
  {
@@ -135,7 +199,9 @@ export async function brandsBillingList( req, res ) {
135
199
  totalStores: 1,
136
200
  billingStores: 1,
137
201
  productsAdded: 1,
138
- billAmountDue: 1,
202
+ billingConfigured: 1,
203
+ dueInr: 1,
204
+ dueUsd: 1,
139
205
  status: 1,
140
206
  paymentStatus: '$planDetails.paymentStatus',
141
207
  nextBillingDate: 1,
@@ -164,6 +230,17 @@ export async function brandsBillingList( req, res ) {
164
230
 
165
231
  let allData = await clientService.aggregate( query );
166
232
 
233
+ // Bill Amount Due in INR: dollar invoice totals converted at today's rate
234
+ // and added to the INR totals, so the column and the ₹-labelled Total Bill
235
+ // Due chip are a single, additive currency. (Previously USD was shown as ₹
236
+ // and the grand total summed INR+USD raw.)
237
+ const billDueRate = await getUsdInrRate();
238
+ for ( const c of allData ) {
239
+ c.billAmountDue = Math.round( ( ( c.dueInr || 0 ) + ( c.dueUsd || 0 ) * billDueRate ) * 100 ) / 100;
240
+ delete c.dueInr;
241
+ delete c.dueUsd;
242
+ }
243
+
167
244
  // Lifecycle + payment counts over the FULL client population (no status /
168
245
  // paymentStatus filter), so the overview cards stay stable regardless of
169
246
  // which lifecycle tab is selected — and so Hold / Suspended / Deactive
@@ -190,6 +267,7 @@ export async function brandsBillingList( req, res ) {
190
267
  { case: { $eq: [ '$$ps', 'free' ] }, then: 'free' },
191
268
  { case: { $and: [ { $eq: [ '$$ps', 'paid' ] }, '$$hasTrialProduct' ] }, then: 'trialPaid' },
192
269
  { case: { $eq: [ '$$ps', 'paid' ] }, then: 'paid' },
270
+ { case: { $eq: [ '$$ps', 'unbilled' ] }, then: 'unbilled' },
193
271
  ],
194
272
  default: 'other',
195
273
  },
@@ -206,7 +284,7 @@ export async function brandsBillingList( req, res ) {
206
284
  // regardless of the selected tab, and show even when the Active tab is
207
285
  // empty.
208
286
  const lifecycle = { active: 0, hold: 0, suspended: 0, deactive: 0 };
209
- const payTotals = { trial: 0, paid: 0, free: 0, trialPaid: 0 };
287
+ const payTotals = { trial: 0, paid: 0, free: 0, trialPaid: 0, unbilled: 0 };
210
288
  const paymentByStatus = {};
211
289
  let totalBrands = 0;
212
290
  matrixAgg.forEach( ( row ) => {
@@ -221,7 +299,7 @@ export async function brandsBillingList( req, res ) {
221
299
  payTotals[pay] += n;
222
300
  }
223
301
  if ( !paymentByStatus[st] ) {
224
- paymentByStatus[st] = { trial: 0, paid: 0, free: 0, trialPaid: 0 };
302
+ paymentByStatus[st] = { trial: 0, paid: 0, free: 0, trialPaid: 0, unbilled: 0 };
225
303
  }
226
304
  if ( paymentByStatus[st][pay] != null ) {
227
305
  paymentByStatus[st][pay] += n;
@@ -238,6 +316,7 @@ export async function brandsBillingList( req, res ) {
238
316
  paid: payTotals.paid,
239
317
  free: payTotals.free,
240
318
  trialPaid: payTotals.trialPaid,
319
+ unbilled: payTotals.unbilled,
241
320
  paymentByStatus,
242
321
  // Money/store totals stay tied to the filtered view so they match the
243
322
  // rows on screen.
@@ -1476,6 +1555,12 @@ export async function billingSummary( req, res ) {
1476
1555
  installationInr: { $sum: { $cond: [ '$isDollar', 0, '$installation' ] } },
1477
1556
  installationUsd: { $sum: { $cond: [ '$isDollar', '$installation', 0 ] } },
1478
1557
  companyName: { $last: '$companyName' },
1558
+ // Track the actual invoice currencies so the row currency reflects how
1559
+ // the client is really billed — not the (sometimes stale)
1560
+ // paymentInvoice.currencyType. e.g. Sundora is flagged 'dollar' but
1561
+ // every invoice is INR.
1562
+ dollarInvoices: { $sum: { $cond: [ '$isDollar', 1, 0 ] } },
1563
+ inrInvoices: { $sum: { $cond: [ '$isDollar', 0, 1 ] } },
1479
1564
  } },
1480
1565
  ] );
1481
1566
 
@@ -1492,14 +1577,22 @@ export async function billingSummary( req, res ) {
1492
1577
  // email's local part since the collection carries no display name.
1493
1578
  const usdRate = await getUsdInrRate();
1494
1579
 
1495
- // Current month's store count comes from dailyPricing (latest reading
1496
- // this month) invoices for the running month usually don't exist yet.
1580
+ // Current month's store count comes from dailyPricing counted the same
1581
+ // way as Brands & Billing's "Billing Stores": distinct ACTIVE stores that
1582
+ // RAN on more than one day in the month (a single-day appearance is
1583
+ // transient and isn't billed). Invoices for the running month usually don't
1584
+ // exist yet, so this is the current-month source.
1497
1585
  const curMonthStart = new Date( now.startOf( 'month' ).toISOString() );
1498
1586
  const latestDp = await dailyPriceService.aggregate( [
1499
1587
  { $match: { dateISO: { $gte: curMonthStart } } },
1500
- { $project: { clientId: 1, activeStores: 1, dateISO: 1 } },
1501
- { $sort: { dateISO: 1 } },
1502
- { $group: { _id: '$clientId', stores: { $last: '$activeStores' } } },
1588
+ { $unwind: { path: '$stores', preserveNullAndEmptyArrays: false } },
1589
+ { $match: { 'stores.status': 'active' } },
1590
+ { $group: {
1591
+ _id: { clientId: '$clientId', storeId: '$stores.storeId' },
1592
+ days: { $addToSet: { $dateToString: { format: '%Y-%m-%d', date: '$dateISO' } } },
1593
+ } },
1594
+ { $match: { $expr: { $gt: [ { $size: '$days' }, 1 ] } } },
1595
+ { $group: { _id: '$_id.clientId', stores: { $sum: 1 } } },
1503
1596
  ] );
1504
1597
  const curStoresByClient = new Map( latestDp.map( ( d ) => [ String( d._id ), d.stores || 0 ] ) );
1505
1598
 
@@ -1556,11 +1649,15 @@ export async function billingSummary( req, res ) {
1556
1649
  // to (status 'live') — trials are excluded.
1557
1650
  liveProductSet: new Set( products.filter( ( p ) => p.status === 'live' )
1558
1651
  .map( ( p ) => String( p.productName || '' ).toLowerCase() ) ),
1652
+ // Fallback only — overridden below from actual invoice currencies
1653
+ // when the client has invoices.
1559
1654
  currency: c?.paymentInvoice?.currencyType === 'dollar' ? 'dollar' : 'inr',
1560
1655
  csm: [ ...( csmByClient.get( key ) || [] ) ].join( ', ' ),
1561
1656
  revenueMonths: {},
1562
1657
  billedStoresMonths: {},
1563
1658
  installationFee: 0,
1659
+ invDollar: 0,
1660
+ invInr: 0,
1564
1661
  } );
1565
1662
  }
1566
1663
  return rows.get( key );
@@ -1571,11 +1668,37 @@ export async function billingSummary( req, res ) {
1571
1668
  r.revenueMonths[inv._id.ym] = Math.round( ( ( inv.revenueInr || 0 ) + ( inv.revenueUsd || 0 ) * usdRate ) * 100 ) / 100;
1572
1669
  r.billedStoresMonths[inv._id.ym] = inv.stores || 0;
1573
1670
  r.installationFee += ( inv.installationInr || 0 ) + ( inv.installationUsd || 0 ) * usdRate;
1671
+ r.invDollar += ( inv.dollarInvoices || 0 );
1672
+ r.invInr += ( inv.inrInvoices || 0 );
1574
1673
  if ( inv.companyName ) {
1575
1674
  r.registeredEntity = inv.companyName;
1576
1675
  }
1577
1676
  }
1578
1677
 
1678
+ // Resolve each row's currency from its actual invoices: 'dollar' only when
1679
+ // the client has dollar invoices and NO inr invoices; otherwise 'inr'. The
1680
+ // paymentInvoice.currencyType fallback set above stands only for clients
1681
+ // with no invoices in the window.
1682
+ for ( const r of rows.values() ) {
1683
+ if ( r.invDollar > 0 || r.invInr > 0 ) {
1684
+ r.currency = ( r.invDollar > 0 && r.invInr === 0 ) ? 'dollar' : 'inr';
1685
+ }
1686
+ delete r.invDollar;
1687
+ delete r.invInr;
1688
+ }
1689
+
1690
+ // A client may be actively billing yet have no invoice in the 5-month
1691
+ // window (e.g. invoice for the running month not generated yet). Seed a
1692
+ // row for any client that has a current-month store reading in
1693
+ // dailyPricing, so the list reflects every brand under billing — not just
1694
+ // the ones with a recent invoice. (Without this, brands silently vanish
1695
+ // from the summary until their next invoice lands.)
1696
+ for ( const [ clientId ] of curStoresByClient ) {
1697
+ if ( clientById.has( clientId ) ) {
1698
+ rowOf( clientId );
1699
+ }
1700
+ }
1701
+
1579
1702
  const curKey = months[4].key;
1580
1703
  const prevKey = months[3].key;
1581
1704
  const data = [ ...rows.values() ].map( ( r ) => {