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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tango-app-api-payment-subscription",
3
- "version": "3.5.7",
3
+ "version": "3.5.8",
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.27",
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,7 +170,8 @@ 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 ] },
127
176
  },
128
177
  },
@@ -135,7 +184,8 @@ export async function brandsBillingList( req, res ) {
135
184
  totalStores: 1,
136
185
  billingStores: 1,
137
186
  productsAdded: 1,
138
- billAmountDue: 1,
187
+ dueInr: 1,
188
+ dueUsd: 1,
139
189
  status: 1,
140
190
  paymentStatus: '$planDetails.paymentStatus',
141
191
  nextBillingDate: 1,
@@ -164,6 +214,17 @@ export async function brandsBillingList( req, res ) {
164
214
 
165
215
  let allData = await clientService.aggregate( query );
166
216
 
217
+ // Bill Amount Due in INR: dollar invoice totals converted at today's rate
218
+ // and added to the INR totals, so the column and the ₹-labelled Total Bill
219
+ // Due chip are a single, additive currency. (Previously USD was shown as ₹
220
+ // and the grand total summed INR+USD raw.)
221
+ const billDueRate = await getUsdInrRate();
222
+ for ( const c of allData ) {
223
+ c.billAmountDue = Math.round( ( ( c.dueInr || 0 ) + ( c.dueUsd || 0 ) * billDueRate ) * 100 ) / 100;
224
+ delete c.dueInr;
225
+ delete c.dueUsd;
226
+ }
227
+
167
228
  // Lifecycle + payment counts over the FULL client population (no status /
168
229
  // paymentStatus filter), so the overview cards stay stable regardless of
169
230
  // which lifecycle tab is selected — and so Hold / Suspended / Deactive
@@ -190,6 +251,7 @@ export async function brandsBillingList( req, res ) {
190
251
  { case: { $eq: [ '$$ps', 'free' ] }, then: 'free' },
191
252
  { case: { $and: [ { $eq: [ '$$ps', 'paid' ] }, '$$hasTrialProduct' ] }, then: 'trialPaid' },
192
253
  { case: { $eq: [ '$$ps', 'paid' ] }, then: 'paid' },
254
+ { case: { $eq: [ '$$ps', 'unbilled' ] }, then: 'unbilled' },
193
255
  ],
194
256
  default: 'other',
195
257
  },
@@ -206,7 +268,7 @@ export async function brandsBillingList( req, res ) {
206
268
  // regardless of the selected tab, and show even when the Active tab is
207
269
  // empty.
208
270
  const lifecycle = { active: 0, hold: 0, suspended: 0, deactive: 0 };
209
- const payTotals = { trial: 0, paid: 0, free: 0, trialPaid: 0 };
271
+ const payTotals = { trial: 0, paid: 0, free: 0, trialPaid: 0, unbilled: 0 };
210
272
  const paymentByStatus = {};
211
273
  let totalBrands = 0;
212
274
  matrixAgg.forEach( ( row ) => {
@@ -221,7 +283,7 @@ export async function brandsBillingList( req, res ) {
221
283
  payTotals[pay] += n;
222
284
  }
223
285
  if ( !paymentByStatus[st] ) {
224
- paymentByStatus[st] = { trial: 0, paid: 0, free: 0, trialPaid: 0 };
286
+ paymentByStatus[st] = { trial: 0, paid: 0, free: 0, trialPaid: 0, unbilled: 0 };
225
287
  }
226
288
  if ( paymentByStatus[st][pay] != null ) {
227
289
  paymentByStatus[st][pay] += n;
@@ -238,6 +300,7 @@ export async function brandsBillingList( req, res ) {
238
300
  paid: payTotals.paid,
239
301
  free: payTotals.free,
240
302
  trialPaid: payTotals.trialPaid,
303
+ unbilled: payTotals.unbilled,
241
304
  paymentByStatus,
242
305
  // Money/store totals stay tied to the filtered view so they match the
243
306
  // rows on screen.
@@ -1476,6 +1539,12 @@ export async function billingSummary( req, res ) {
1476
1539
  installationInr: { $sum: { $cond: [ '$isDollar', 0, '$installation' ] } },
1477
1540
  installationUsd: { $sum: { $cond: [ '$isDollar', '$installation', 0 ] } },
1478
1541
  companyName: { $last: '$companyName' },
1542
+ // Track the actual invoice currencies so the row currency reflects how
1543
+ // the client is really billed — not the (sometimes stale)
1544
+ // paymentInvoice.currencyType. e.g. Sundora is flagged 'dollar' but
1545
+ // every invoice is INR.
1546
+ dollarInvoices: { $sum: { $cond: [ '$isDollar', 1, 0 ] } },
1547
+ inrInvoices: { $sum: { $cond: [ '$isDollar', 0, 1 ] } },
1479
1548
  } },
1480
1549
  ] );
1481
1550
 
@@ -1556,11 +1625,15 @@ export async function billingSummary( req, res ) {
1556
1625
  // to (status 'live') — trials are excluded.
1557
1626
  liveProductSet: new Set( products.filter( ( p ) => p.status === 'live' )
1558
1627
  .map( ( p ) => String( p.productName || '' ).toLowerCase() ) ),
1628
+ // Fallback only — overridden below from actual invoice currencies
1629
+ // when the client has invoices.
1559
1630
  currency: c?.paymentInvoice?.currencyType === 'dollar' ? 'dollar' : 'inr',
1560
1631
  csm: [ ...( csmByClient.get( key ) || [] ) ].join( ', ' ),
1561
1632
  revenueMonths: {},
1562
1633
  billedStoresMonths: {},
1563
1634
  installationFee: 0,
1635
+ invDollar: 0,
1636
+ invInr: 0,
1564
1637
  } );
1565
1638
  }
1566
1639
  return rows.get( key );
@@ -1571,11 +1644,37 @@ export async function billingSummary( req, res ) {
1571
1644
  r.revenueMonths[inv._id.ym] = Math.round( ( ( inv.revenueInr || 0 ) + ( inv.revenueUsd || 0 ) * usdRate ) * 100 ) / 100;
1572
1645
  r.billedStoresMonths[inv._id.ym] = inv.stores || 0;
1573
1646
  r.installationFee += ( inv.installationInr || 0 ) + ( inv.installationUsd || 0 ) * usdRate;
1647
+ r.invDollar += ( inv.dollarInvoices || 0 );
1648
+ r.invInr += ( inv.inrInvoices || 0 );
1574
1649
  if ( inv.companyName ) {
1575
1650
  r.registeredEntity = inv.companyName;
1576
1651
  }
1577
1652
  }
1578
1653
 
1654
+ // Resolve each row's currency from its actual invoices: 'dollar' only when
1655
+ // the client has dollar invoices and NO inr invoices; otherwise 'inr'. The
1656
+ // paymentInvoice.currencyType fallback set above stands only for clients
1657
+ // with no invoices in the window.
1658
+ for ( const r of rows.values() ) {
1659
+ if ( r.invDollar > 0 || r.invInr > 0 ) {
1660
+ r.currency = ( r.invDollar > 0 && r.invInr === 0 ) ? 'dollar' : 'inr';
1661
+ }
1662
+ delete r.invDollar;
1663
+ delete r.invInr;
1664
+ }
1665
+
1666
+ // A client may be actively billing yet have no invoice in the 5-month
1667
+ // window (e.g. invoice for the running month not generated yet). Seed a
1668
+ // row for any client that has a current-month store reading in
1669
+ // dailyPricing, so the list reflects every brand under billing — not just
1670
+ // the ones with a recent invoice. (Without this, brands silently vanish
1671
+ // from the summary until their next invoice lands.)
1672
+ for ( const [ clientId ] of curStoresByClient ) {
1673
+ if ( clientById.has( clientId ) ) {
1674
+ rowOf( clientId );
1675
+ }
1676
+ }
1677
+
1579
1678
  const curKey = months[4].key;
1580
1679
  const prevKey = months[3].key;
1581
1680
  const data = [ ...rows.values() ].map( ( r ) => {
@@ -191,6 +191,69 @@ export async function createEstimate( req, res ) {
191
191
  }
192
192
  }
193
193
 
194
+ export async function updateEstimate( req, res ) {
195
+ try {
196
+ const b = req.body || {};
197
+ const estimateId = b._id || b.estimateId || req.params.estimateId;
198
+ if ( !estimateId ) {
199
+ return res.sendError( 'estimateId is required', 400 );
200
+ }
201
+
202
+ const existing = await estimateService.findOne( { _id: estimateId } );
203
+ if ( !existing ) {
204
+ return res.sendError( 'Estimate not found', 404 );
205
+ }
206
+ // Accepted estimates are locked — mirrors the delete guard.
207
+ if ( ( existing._doc || existing ).status === 'accepted' ) {
208
+ return res.sendError( 'An accepted estimate cannot be edited.', 409 );
209
+ }
210
+
211
+ const amount = Math.round( Number( b.amount ) || 0 );
212
+ let totalAmount = Math.round( Number( b.totalAmount ) || 0 );
213
+ if ( !totalAmount && amount ) {
214
+ totalAmount = Math.round( amount * 1.18 );
215
+ }
216
+
217
+ // Only the editable fields are updated. The estimate number, index,
218
+ // clientId and createdDate are immutable so the document keeps its
219
+ // identity and financial-year sequence.
220
+ const update = {
221
+ companyName: b.companyName ?? existing.companyName,
222
+ companyAddress: b.companyAddress ?? existing.companyAddress,
223
+ PlaceOfSupply: b.PlaceOfSupply ?? existing.PlaceOfSupply,
224
+ GSTNumber: b.GSTNumber ?? existing.GSTNumber,
225
+ groupId: b.groupId || existing.groupId,
226
+ groupName: b.groupName || existing.groupName,
227
+ stores: Number( b.stores ) || 0,
228
+ products: Array.isArray( b.products ) ? b.products : existing.products,
229
+ tax: Array.isArray( b.tax ) ? b.tax : existing.tax,
230
+ amount,
231
+ totalAmount,
232
+ currency: b.currency || existing.currency,
233
+ notes: b.notes ?? existing.notes,
234
+ updatedBy: req.user?.email || req.user?.userName || '',
235
+ };
236
+ if ( b.validTill ) {
237
+ update.validTill = new Date( b.validTill );
238
+ }
239
+ if ( b.period ) {
240
+ update.period = b.period;
241
+ }
242
+ // Allow a status nudge (e.g. draft → sent) on save, but never to accepted.
243
+ if ( b.status && [ 'draft', 'sent' ].includes( b.status ) ) {
244
+ update.status = b.status;
245
+ }
246
+
247
+ await estimateService.updateOne( { _id: estimateId }, { $set: update } );
248
+ const saved = await estimateService.findOne( { _id: estimateId } );
249
+ logger.info?.( { function: 'updateEstimate', estimateId } );
250
+ return res.sendSuccess( saved );
251
+ } catch ( error ) {
252
+ logger.error( { error: error, function: 'updateEstimate' } );
253
+ return res.sendError( error, 500 );
254
+ }
255
+ }
256
+
194
257
  export async function getEstimate( req, res ) {
195
258
  try {
196
259
  const estimate = await estimateService.findOne( { _id: req.params.estimateId } );
@@ -142,7 +142,8 @@ export async function createInvoice( req, res ) {
142
142
  clientId: req.body.clientId,
143
143
  groupId: req.body.groupId || undefined,
144
144
  groupName: req.body.groupName || '',
145
- companyName: req.body.companyName || '',
145
+ // Company (registered) name is always stored uppercase on invoices.
146
+ companyName: ( req.body.companyName || '' ).toUpperCase(),
146
147
  companyAddress: req.body.companyAddress || '',
147
148
  GSTNumber: req.body.GSTNumber || '',
148
149
  PlaceOfSupply: req.body.PlaceOfSupply || '',
@@ -285,7 +286,7 @@ export async function createInvoice( req, res ) {
285
286
  amount: Math.round( amount ),
286
287
  invoiceIndex: req.body.invoiceId ? findInvoice.invoiceIndex : invoiceNo,
287
288
  tax: taxList,
288
- companyName: group.registeredCompanyName,
289
+ companyName: ( group.registeredCompanyName || '' ).toUpperCase(),
289
290
  companyAddress: address,
290
291
  PlaceOfSupply: group.placeOfSupply,
291
292
  GSTNumber: group.gst,
@@ -372,6 +373,10 @@ async function buildAnnexureRows( invoiceInfo, getgroup ) {
372
373
  const billingMonthEnd = new Date( billingMonth.endOf( 'month' ).toISOString() );
373
374
  const monthDays = billingMonth.daysInMonth();
374
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;
375
380
 
376
381
  const annexClient = await clientService.findOne( { clientId: invoiceInfo.clientId }, { 'planDetails.product': 1 } );
377
382
  const billingTypeMap = {};
@@ -432,7 +437,9 @@ async function buildAnnexureRows( invoiceInfo, getgroup ) {
432
437
  units = s.trafficCameraCount;
433
438
  }
434
439
  }
435
- 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;
436
443
  const runningCost = s.workingdays >= monthDays ?
437
444
  Math.round( price * units * 100 ) / 100 :
438
445
  Math.round( ( price / monthDays ) * s.workingdays * units * 100 ) / 100;
@@ -526,6 +533,10 @@ export async function invoiceDownload( req, res ) {
526
533
  billingCurrency: virtualAccount?.currency,
527
534
  virtualaccountNumber: virtualAccount ? virtualAccount?.accountNumber : '',
528
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',
529
540
  };
530
541
 
531
542
  if ( invoiceData?.tax?.length ) {
@@ -590,9 +601,20 @@ export async function invoiceDownload( req, res ) {
590
601
  // Load configured CSM + Finance heads PLUS the per-client CSMs from
591
602
  // userAssignedStore as CC recipients on the invoice mail.
592
603
  const ccEmails = await getInvoiceCcEmails( invoiceInfo.clientId );
593
- console.log( fromEmail, getgroup.generateInvoiceTo, ccEmails, attachments );
594
-
595
- 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 );
596
618
  console.log( result );
597
619
  let logObj = {
598
620
  userName: req.user?.userName,
@@ -707,6 +729,8 @@ async function buildInvoicePdfBuffer( invoiceId ) {
707
729
  billingCurrency: virtualAccount?.currency,
708
730
  virtualaccountNumber: virtualAccount ? virtualAccount?.accountNumber : '',
709
731
  virtualifsc: virtualAccount ? virtualAccount?.ifsc : '',
732
+ // GST applies only to domestic (INR) invoices; gate on the invoice currency.
733
+ gstApplicable: invoiceInfo.currency === 'inr',
710
734
  };
711
735
 
712
736
  if ( invoiceData?.tax?.length ) {
@@ -1290,8 +1314,12 @@ async function standardPrice( group, getClient, baseDate ) {
1290
1314
  return product;
1291
1315
  } );
1292
1316
 
1293
- // Combine overallStore and eachStore products
1294
- 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 || '' ) ) );
1295
1323
  }
1296
1324
 
1297
1325
 
@@ -1425,7 +1453,10 @@ async function stepPrice( group, getClient ) {
1425
1453
  },
1426
1454
  },
1427
1455
  {
1456
+ // productName first so order is deterministic across views/PDF, then
1457
+ // workingdays so step rows stay grouped consistently.
1428
1458
  $sort: {
1459
+ productName: 1,
1429
1460
  workingdays: -1,
1430
1461
  },
1431
1462
  },
@@ -1854,6 +1885,24 @@ export async function clientInvoiceList( req, res ) {
1854
1885
  }
1855
1886
 
1856
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
+ };
1857
1906
  const exportdata = [];
1858
1907
  count.forEach( ( element ) => {
1859
1908
  exportdata.push( {
@@ -1862,9 +1911,13 @@ export async function clientInvoiceList( req, res ) {
1862
1911
  'Invoice #': element.invoice,
1863
1912
  'Billing date': dayjs( element.billingDate ).format( 'DD MMM, YYYY' ),
1864
1913
  'Due Date': element.dueDate ? dayjs( element.dueDate ).format( 'DD MMM, YYYY' ) : '',
1914
+ 'Due Status': dueStatusOf( element ),
1865
1915
  'Group Name': element.groupName,
1866
1916
  'Amount Excl. GST': element.amount,
1867
- '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 : '—',
1868
1921
  'Amount Incl. GST': element.totalAmount,
1869
1922
  'Stores': element.stores,
1870
1923
  'Payment Status': element.paymentStatus,
@@ -2468,7 +2521,10 @@ async function transitionInvoiceStatus( req, res, fromStatus, toStatus ) {
2468
2521
  return res.sendError( 'Invoice not found', 404 );
2469
2522
  }
2470
2523
 
2471
- 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 ) ) {
2472
2528
  return res.sendError(
2473
2529
  `Invoice is currently at status '${invoice.status}', not '${fromStatus}'. Another user may have advanced it.`,
2474
2530
  409,