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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tango-app-api-payment-subscription",
3
- "version": "3.5.6",
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.25",
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,19 +214,106 @@ export async function brandsBillingList( req, res ) {
164
214
 
165
215
  let allData = await clientService.aggregate( query );
166
216
 
167
- if ( allData.length == 0 ) {
168
- return res.sendError( 'No data', 204 );
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;
169
226
  }
170
227
 
228
+ // Lifecycle + payment counts over the FULL client population (no status /
229
+ // paymentStatus filter), so the overview cards stay stable regardless of
230
+ // which lifecycle tab is selected — and so Hold / Suspended / Deactive
231
+ // show even when the default Active tab has zero rows. trialPaid is the
232
+ // derived "paid plan with at least one product still on trial" bucket.
233
+ // Bucket every client into ONE mutually-exclusive payment bucket so the
234
+ // pills (Paid / Trial / Paid-Trial / Free) sum to their lifecycle tab
235
+ // total. trialPaid = paid plan that still has a product on trial; such a
236
+ // client is trialPaid, NOT also paid.
237
+ const payBucketExpr = {
238
+ $let: {
239
+ vars: {
240
+ ps: '$planDetails.paymentStatus',
241
+ hasTrialProduct: { $gt: [ { $size: { $filter: {
242
+ input: { $ifNull: [ '$planDetails.product', [] ] },
243
+ as: 'p',
244
+ cond: { $eq: [ '$$p.status', 'trial' ] },
245
+ } } }, 0 ] },
246
+ },
247
+ in: {
248
+ $switch: {
249
+ branches: [
250
+ { case: { $eq: [ '$$ps', 'trial' ] }, then: 'trial' },
251
+ { case: { $eq: [ '$$ps', 'free' ] }, then: 'free' },
252
+ { case: { $and: [ { $eq: [ '$$ps', 'paid' ] }, '$$hasTrialProduct' ] }, then: 'trialPaid' },
253
+ { case: { $eq: [ '$$ps', 'paid' ] }, then: 'paid' },
254
+ { case: { $eq: [ '$$ps', 'unbilled' ] }, then: 'unbilled' },
255
+ ],
256
+ default: 'other',
257
+ },
258
+ },
259
+ },
260
+ };
261
+ const matrixAgg = await clientService.aggregate( [
262
+ { $project: { status: 1, payBucket: payBucketExpr } },
263
+ { $group: { _id: { status: '$status', pay: '$payBucket' }, count: { $sum: 1 } } },
264
+ ] );
265
+
266
+ // Lifecycle totals + a status×payment matrix. Counts are over the FULL
267
+ // population (no filter) so the overview cards and pills stay stable
268
+ // regardless of the selected tab, and show even when the Active tab is
269
+ // empty.
270
+ const lifecycle = { active: 0, hold: 0, suspended: 0, deactive: 0 };
271
+ const payTotals = { trial: 0, paid: 0, free: 0, trialPaid: 0, unbilled: 0 };
272
+ const paymentByStatus = {};
273
+ let totalBrands = 0;
274
+ matrixAgg.forEach( ( row ) => {
275
+ const st = row._id.status || 'active';
276
+ const pay = row._id.pay;
277
+ const n = row.count || 0;
278
+ totalBrands += n;
279
+ if ( lifecycle[st] != null ) {
280
+ lifecycle[st] += n;
281
+ }
282
+ if ( payTotals[pay] != null ) {
283
+ payTotals[pay] += n;
284
+ }
285
+ if ( !paymentByStatus[st] ) {
286
+ paymentByStatus[st] = { trial: 0, paid: 0, free: 0, trialPaid: 0, unbilled: 0 };
287
+ }
288
+ if ( paymentByStatus[st][pay] != null ) {
289
+ paymentByStatus[st][pay] += n;
290
+ }
291
+ } );
292
+
171
293
  let summary = {
172
- totalBrands: allData.length,
173
- active: allData.filter( ( c ) => c.status === 'active' ).length,
174
- trial: allData.filter( ( c ) => c.paymentStatus === 'trial' ).length,
175
- paid: allData.filter( ( c ) => c.paymentStatus === 'paid' ).length,
294
+ totalBrands,
295
+ active: lifecycle.active,
296
+ hold: lifecycle.hold,
297
+ suspended: lifecycle.suspended,
298
+ deactive: lifecycle.deactive,
299
+ trial: payTotals.trial,
300
+ paid: payTotals.paid,
301
+ free: payTotals.free,
302
+ trialPaid: payTotals.trialPaid,
303
+ unbilled: payTotals.unbilled,
304
+ paymentByStatus,
305
+ // Money/store totals stay tied to the filtered view so they match the
306
+ // rows on screen.
176
307
  totalBillDue: allData.reduce( ( sum, c ) => sum + ( c.billAmountDue || 0 ), 0 ),
177
308
  storesUnderBilling: allData.reduce( ( sum, c ) => sum + ( c.billingStores || 0 ), 0 ),
178
309
  };
179
310
 
311
+ if ( allData.length == 0 ) {
312
+ // Still return the population summary so the overview cards populate even
313
+ // when the current lifecycle tab is empty.
314
+ return res.sendSuccess( { summary, count: 0, data: [] } );
315
+ }
316
+
180
317
  if ( req.body.export ) {
181
318
  const exportdata = [];
182
319
  allData.forEach( ( element ) => {
@@ -526,6 +663,36 @@ export async function latestDailyPricing( req, res ) {
526
663
  storeList = stores.slice( skip, skip + Number( req.body.limit ) );
527
664
  }
528
665
 
666
+ // Monthly Billing Summary — one row per month of the brand's invoice
667
+ // history (stores billed + invoice amount), newest first. The UI tags the
668
+ // current/last-generated rows and computes month-over-month deltas.
669
+ // billingDate is a Date on most rows but a string on some legacy ones, so
670
+ // coerce before extracting year/month.
671
+ const monthlyBillingSummary = await invoiceService.aggregate( [
672
+ { $match: { clientId: req.body.clientId } },
673
+ { $addFields: { billingDateD: { $cond: [
674
+ { $eq: [ { $type: '$billingDate' }, 'date' ] },
675
+ '$billingDate',
676
+ { $toDate: '$billingDate' },
677
+ ] } } },
678
+ { $match: { billingDateD: { $ne: null } } },
679
+ { $group: {
680
+ _id: { year: { $year: '$billingDateD' }, month: { $month: '$billingDateD' } },
681
+ storesBilled: { $sum: { $ifNull: [ '$stores', 0 ] } },
682
+ invoiceAmount: { $sum: { $ifNull: [ '$totalAmount', 0 ] } },
683
+ currency: { $last: { $ifNull: [ '$currency', 'inr' ] } },
684
+ } },
685
+ { $sort: { '_id.year': -1, '_id.month': -1 } },
686
+ { $project: {
687
+ _id: 0,
688
+ year: '$_id.year',
689
+ month: '$_id.month',
690
+ storesBilled: 1,
691
+ invoiceAmount: { $round: [ '$invoiceAmount', 2 ] },
692
+ currency: 1,
693
+ } },
694
+ ] );
695
+
529
696
  let data = {
530
697
  clientId: record.clientId,
531
698
  brandName: record.brandName,
@@ -537,6 +704,7 @@ export async function latestDailyPricing( req, res ) {
537
704
  proRate: record.proRate,
538
705
  count,
539
706
  data: storeList,
707
+ monthlyBillingSummary,
540
708
  };
541
709
 
542
710
  res.sendSuccess( data );
@@ -1305,7 +1473,7 @@ export async function bulkUpdateBillingGroups( req, res ) {
1305
1473
  // with an env override (USD_INR_RATE) and a last-known/static fallback so
1306
1474
  // the summary never fails because a rate API is down.
1307
1475
  let usdRateCache = { rate: null, at: 0 };
1308
- async function getUsdInrRate() {
1476
+ export async function getUsdInrRate() {
1309
1477
  const override = Number( process.env.USD_INR_RATE );
1310
1478
  if ( override > 0 ) {
1311
1479
  return override;
@@ -1371,6 +1539,12 @@ export async function billingSummary( req, res ) {
1371
1539
  installationInr: { $sum: { $cond: [ '$isDollar', 0, '$installation' ] } },
1372
1540
  installationUsd: { $sum: { $cond: [ '$isDollar', '$installation', 0 ] } },
1373
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 ] } },
1374
1548
  } },
1375
1549
  ] );
1376
1550
 
@@ -1451,11 +1625,15 @@ export async function billingSummary( req, res ) {
1451
1625
  // to (status 'live') — trials are excluded.
1452
1626
  liveProductSet: new Set( products.filter( ( p ) => p.status === 'live' )
1453
1627
  .map( ( p ) => String( p.productName || '' ).toLowerCase() ) ),
1628
+ // Fallback only — overridden below from actual invoice currencies
1629
+ // when the client has invoices.
1454
1630
  currency: c?.paymentInvoice?.currencyType === 'dollar' ? 'dollar' : 'inr',
1455
1631
  csm: [ ...( csmByClient.get( key ) || [] ) ].join( ', ' ),
1456
1632
  revenueMonths: {},
1457
1633
  billedStoresMonths: {},
1458
1634
  installationFee: 0,
1635
+ invDollar: 0,
1636
+ invInr: 0,
1459
1637
  } );
1460
1638
  }
1461
1639
  return rows.get( key );
@@ -1466,11 +1644,37 @@ export async function billingSummary( req, res ) {
1466
1644
  r.revenueMonths[inv._id.ym] = Math.round( ( ( inv.revenueInr || 0 ) + ( inv.revenueUsd || 0 ) * usdRate ) * 100 ) / 100;
1467
1645
  r.billedStoresMonths[inv._id.ym] = inv.stores || 0;
1468
1646
  r.installationFee += ( inv.installationInr || 0 ) + ( inv.installationUsd || 0 ) * usdRate;
1647
+ r.invDollar += ( inv.dollarInvoices || 0 );
1648
+ r.invInr += ( inv.inrInvoices || 0 );
1469
1649
  if ( inv.companyName ) {
1470
1650
  r.registeredEntity = inv.companyName;
1471
1651
  }
1472
1652
  }
1473
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
+
1474
1678
  const curKey = months[4].key;
1475
1679
  const prevKey = months[3].key;
1476
1680
  const data = [ ...rows.values() ].map( ( r ) => {
@@ -1491,7 +1695,12 @@ export async function billingSummary( req, res ) {
1491
1695
  }
1492
1696
  let pricePerStore = null;
1493
1697
  const pdoc = pricingByClient.get( r.clientId );
1494
- const isLive = ( name ) => r.liveProductSet.has( String( name || '' ).toLowerCase() );
1698
+ // Price/Store counts only subscribed products and never the one-time
1699
+ // installationFee line, which isn't a per-store recurring price.
1700
+ const isLive = ( name ) => {
1701
+ const n = String( name || '' ).toLowerCase();
1702
+ return n !== 'installationfee' && r.liveProductSet.has( n );
1703
+ };
1495
1704
  if ( pdoc?.standard?.length ) {
1496
1705
  const liveRows = pdoc.standard.filter( ( p ) => isLive( p.productName ) );
1497
1706
  pricePerStore = liveRows.length ?
@@ -1573,7 +1782,44 @@ export async function billingSummary( req, res ) {
1573
1782
  };
1574
1783
  } ).sort( ( a, b ) => ( b.revCur || 0 ) - ( a.revCur || 0 ) );
1575
1784
 
1576
- return res.sendSuccess( { months, data, usdRate } );
1785
+ // Server-side filters (GET query or POST body). CSM / Product / Variance /
1786
+ // search narrow the per-client rows after they're computed, since those
1787
+ // fields are derived during the merge above.
1788
+ const f = { ...( req.query || {} ), ...( req.body || {} ) };
1789
+ const csm = f.csm && f.csm !== 'All' ? String( f.csm ) : '';
1790
+ const product = f.product && f.product !== 'All' ? String( f.product ) : '';
1791
+ const variance = f.variance && f.variance !== 'All' ? String( f.variance ) : '';
1792
+ const search = f.search ? String( f.search ).toLowerCase().trim() : '';
1793
+
1794
+ let filtered = data;
1795
+ if ( csm ) {
1796
+ filtered = filtered.filter( ( r ) => ( r.csm || '' ).split( ', ' ).includes( csm ) );
1797
+ }
1798
+ if ( product ) {
1799
+ filtered = filtered.filter( ( r ) => ( r.products || [] ).includes( product ) );
1800
+ }
1801
+ if ( variance === 'growth' ) {
1802
+ filtered = filtered.filter( ( r ) => r.variance > 0 );
1803
+ } else if ( variance === 'decline' ) {
1804
+ filtered = filtered.filter( ( r ) => r.variance < 0 );
1805
+ } else if ( variance === 'flat' ) {
1806
+ filtered = filtered.filter( ( r ) => r.variance === 0 );
1807
+ }
1808
+ if ( search ) {
1809
+ filtered = filtered.filter( ( r ) =>
1810
+ ( r.clientName || '' ).toLowerCase().includes( search ) ||
1811
+ ( r.registeredEntity || '' ).toLowerCase().includes( search ) ||
1812
+ String( r.clientId || '' ).includes( search ),
1813
+ );
1814
+ }
1815
+
1816
+ // Distinct option lists for the filter popover are derived from the FULL
1817
+ // result set (not the filtered slice) so the dropdowns don't shrink as
1818
+ // filters are applied.
1819
+ const csmOptions = [ ...new Set( data.flatMap( ( r ) => String( r.csm || '' ).split( ', ' ).filter( Boolean ) ) ) ].sort();
1820
+ const productOptions = [ ...new Set( data.flatMap( ( r ) => r.products || [] ) ) ].sort();
1821
+
1822
+ return res.sendSuccess( { months, data: filtered, total: data.length, csmOptions, productOptions, usdRate } );
1577
1823
  } catch ( error ) {
1578
1824
  logger.error( { error: error, function: 'billingSummary' } );
1579
1825
  return res.sendError( error, 500 );
@@ -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 } );