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

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.7",
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.26",
33
33
  "tango-app-api-middleware": "^3.6.18",
34
34
  "winston": "^3.12.0",
35
35
  "winston-daily-rotate-file": "^5.0.0",
@@ -164,19 +164,93 @@ export async function brandsBillingList( req, res ) {
164
164
 
165
165
  let allData = await clientService.aggregate( query );
166
166
 
167
- if ( allData.length == 0 ) {
168
- return res.sendError( 'No data', 204 );
169
- }
167
+ // Lifecycle + payment counts over the FULL client population (no status /
168
+ // paymentStatus filter), so the overview cards stay stable regardless of
169
+ // which lifecycle tab is selected — and so Hold / Suspended / Deactive
170
+ // show even when the default Active tab has zero rows. trialPaid is the
171
+ // derived "paid plan with at least one product still on trial" bucket.
172
+ // Bucket every client into ONE mutually-exclusive payment bucket so the
173
+ // pills (Paid / Trial / Paid-Trial / Free) sum to their lifecycle tab
174
+ // total. trialPaid = paid plan that still has a product on trial; such a
175
+ // client is trialPaid, NOT also paid.
176
+ const payBucketExpr = {
177
+ $let: {
178
+ vars: {
179
+ ps: '$planDetails.paymentStatus',
180
+ hasTrialProduct: { $gt: [ { $size: { $filter: {
181
+ input: { $ifNull: [ '$planDetails.product', [] ] },
182
+ as: 'p',
183
+ cond: { $eq: [ '$$p.status', 'trial' ] },
184
+ } } }, 0 ] },
185
+ },
186
+ in: {
187
+ $switch: {
188
+ branches: [
189
+ { case: { $eq: [ '$$ps', 'trial' ] }, then: 'trial' },
190
+ { case: { $eq: [ '$$ps', 'free' ] }, then: 'free' },
191
+ { case: { $and: [ { $eq: [ '$$ps', 'paid' ] }, '$$hasTrialProduct' ] }, then: 'trialPaid' },
192
+ { case: { $eq: [ '$$ps', 'paid' ] }, then: 'paid' },
193
+ ],
194
+ default: 'other',
195
+ },
196
+ },
197
+ },
198
+ };
199
+ const matrixAgg = await clientService.aggregate( [
200
+ { $project: { status: 1, payBucket: payBucketExpr } },
201
+ { $group: { _id: { status: '$status', pay: '$payBucket' }, count: { $sum: 1 } } },
202
+ ] );
203
+
204
+ // Lifecycle totals + a status×payment matrix. Counts are over the FULL
205
+ // population (no filter) so the overview cards and pills stay stable
206
+ // regardless of the selected tab, and show even when the Active tab is
207
+ // empty.
208
+ const lifecycle = { active: 0, hold: 0, suspended: 0, deactive: 0 };
209
+ const payTotals = { trial: 0, paid: 0, free: 0, trialPaid: 0 };
210
+ const paymentByStatus = {};
211
+ let totalBrands = 0;
212
+ matrixAgg.forEach( ( row ) => {
213
+ const st = row._id.status || 'active';
214
+ const pay = row._id.pay;
215
+ const n = row.count || 0;
216
+ totalBrands += n;
217
+ if ( lifecycle[st] != null ) {
218
+ lifecycle[st] += n;
219
+ }
220
+ if ( payTotals[pay] != null ) {
221
+ payTotals[pay] += n;
222
+ }
223
+ if ( !paymentByStatus[st] ) {
224
+ paymentByStatus[st] = { trial: 0, paid: 0, free: 0, trialPaid: 0 };
225
+ }
226
+ if ( paymentByStatus[st][pay] != null ) {
227
+ paymentByStatus[st][pay] += n;
228
+ }
229
+ } );
170
230
 
171
231
  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,
232
+ totalBrands,
233
+ active: lifecycle.active,
234
+ hold: lifecycle.hold,
235
+ suspended: lifecycle.suspended,
236
+ deactive: lifecycle.deactive,
237
+ trial: payTotals.trial,
238
+ paid: payTotals.paid,
239
+ free: payTotals.free,
240
+ trialPaid: payTotals.trialPaid,
241
+ paymentByStatus,
242
+ // Money/store totals stay tied to the filtered view so they match the
243
+ // rows on screen.
176
244
  totalBillDue: allData.reduce( ( sum, c ) => sum + ( c.billAmountDue || 0 ), 0 ),
177
245
  storesUnderBilling: allData.reduce( ( sum, c ) => sum + ( c.billingStores || 0 ), 0 ),
178
246
  };
179
247
 
248
+ if ( allData.length == 0 ) {
249
+ // Still return the population summary so the overview cards populate even
250
+ // when the current lifecycle tab is empty.
251
+ return res.sendSuccess( { summary, count: 0, data: [] } );
252
+ }
253
+
180
254
  if ( req.body.export ) {
181
255
  const exportdata = [];
182
256
  allData.forEach( ( element ) => {
@@ -526,6 +600,36 @@ export async function latestDailyPricing( req, res ) {
526
600
  storeList = stores.slice( skip, skip + Number( req.body.limit ) );
527
601
  }
528
602
 
603
+ // Monthly Billing Summary — one row per month of the brand's invoice
604
+ // history (stores billed + invoice amount), newest first. The UI tags the
605
+ // current/last-generated rows and computes month-over-month deltas.
606
+ // billingDate is a Date on most rows but a string on some legacy ones, so
607
+ // coerce before extracting year/month.
608
+ const monthlyBillingSummary = await invoiceService.aggregate( [
609
+ { $match: { clientId: req.body.clientId } },
610
+ { $addFields: { billingDateD: { $cond: [
611
+ { $eq: [ { $type: '$billingDate' }, 'date' ] },
612
+ '$billingDate',
613
+ { $toDate: '$billingDate' },
614
+ ] } } },
615
+ { $match: { billingDateD: { $ne: null } } },
616
+ { $group: {
617
+ _id: { year: { $year: '$billingDateD' }, month: { $month: '$billingDateD' } },
618
+ storesBilled: { $sum: { $ifNull: [ '$stores', 0 ] } },
619
+ invoiceAmount: { $sum: { $ifNull: [ '$totalAmount', 0 ] } },
620
+ currency: { $last: { $ifNull: [ '$currency', 'inr' ] } },
621
+ } },
622
+ { $sort: { '_id.year': -1, '_id.month': -1 } },
623
+ { $project: {
624
+ _id: 0,
625
+ year: '$_id.year',
626
+ month: '$_id.month',
627
+ storesBilled: 1,
628
+ invoiceAmount: { $round: [ '$invoiceAmount', 2 ] },
629
+ currency: 1,
630
+ } },
631
+ ] );
632
+
529
633
  let data = {
530
634
  clientId: record.clientId,
531
635
  brandName: record.brandName,
@@ -537,6 +641,7 @@ export async function latestDailyPricing( req, res ) {
537
641
  proRate: record.proRate,
538
642
  count,
539
643
  data: storeList,
644
+ monthlyBillingSummary,
540
645
  };
541
646
 
542
647
  res.sendSuccess( data );
@@ -1305,7 +1410,7 @@ export async function bulkUpdateBillingGroups( req, res ) {
1305
1410
  // with an env override (USD_INR_RATE) and a last-known/static fallback so
1306
1411
  // the summary never fails because a rate API is down.
1307
1412
  let usdRateCache = { rate: null, at: 0 };
1308
- async function getUsdInrRate() {
1413
+ export async function getUsdInrRate() {
1309
1414
  const override = Number( process.env.USD_INR_RATE );
1310
1415
  if ( override > 0 ) {
1311
1416
  return override;
@@ -1491,7 +1596,12 @@ export async function billingSummary( req, res ) {
1491
1596
  }
1492
1597
  let pricePerStore = null;
1493
1598
  const pdoc = pricingByClient.get( r.clientId );
1494
- const isLive = ( name ) => r.liveProductSet.has( String( name || '' ).toLowerCase() );
1599
+ // Price/Store counts only subscribed products and never the one-time
1600
+ // installationFee line, which isn't a per-store recurring price.
1601
+ const isLive = ( name ) => {
1602
+ const n = String( name || '' ).toLowerCase();
1603
+ return n !== 'installationfee' && r.liveProductSet.has( n );
1604
+ };
1495
1605
  if ( pdoc?.standard?.length ) {
1496
1606
  const liveRows = pdoc.standard.filter( ( p ) => isLive( p.productName ) );
1497
1607
  pricePerStore = liveRows.length ?
@@ -1573,7 +1683,44 @@ export async function billingSummary( req, res ) {
1573
1683
  };
1574
1684
  } ).sort( ( a, b ) => ( b.revCur || 0 ) - ( a.revCur || 0 ) );
1575
1685
 
1576
- return res.sendSuccess( { months, data, usdRate } );
1686
+ // Server-side filters (GET query or POST body). CSM / Product / Variance /
1687
+ // search narrow the per-client rows after they're computed, since those
1688
+ // fields are derived during the merge above.
1689
+ const f = { ...( req.query || {} ), ...( req.body || {} ) };
1690
+ const csm = f.csm && f.csm !== 'All' ? String( f.csm ) : '';
1691
+ const product = f.product && f.product !== 'All' ? String( f.product ) : '';
1692
+ const variance = f.variance && f.variance !== 'All' ? String( f.variance ) : '';
1693
+ const search = f.search ? String( f.search ).toLowerCase().trim() : '';
1694
+
1695
+ let filtered = data;
1696
+ if ( csm ) {
1697
+ filtered = filtered.filter( ( r ) => ( r.csm || '' ).split( ', ' ).includes( csm ) );
1698
+ }
1699
+ if ( product ) {
1700
+ filtered = filtered.filter( ( r ) => ( r.products || [] ).includes( product ) );
1701
+ }
1702
+ if ( variance === 'growth' ) {
1703
+ filtered = filtered.filter( ( r ) => r.variance > 0 );
1704
+ } else if ( variance === 'decline' ) {
1705
+ filtered = filtered.filter( ( r ) => r.variance < 0 );
1706
+ } else if ( variance === 'flat' ) {
1707
+ filtered = filtered.filter( ( r ) => r.variance === 0 );
1708
+ }
1709
+ if ( search ) {
1710
+ filtered = filtered.filter( ( r ) =>
1711
+ ( r.clientName || '' ).toLowerCase().includes( search ) ||
1712
+ ( r.registeredEntity || '' ).toLowerCase().includes( search ) ||
1713
+ String( r.clientId || '' ).includes( search ),
1714
+ );
1715
+ }
1716
+
1717
+ // Distinct option lists for the filter popover are derived from the FULL
1718
+ // result set (not the filtered slice) so the dropdowns don't shrink as
1719
+ // filters are applied.
1720
+ const csmOptions = [ ...new Set( data.flatMap( ( r ) => String( r.csm || '' ).split( ', ' ).filter( Boolean ) ) ) ].sort();
1721
+ const productOptions = [ ...new Set( data.flatMap( ( r ) => r.products || [] ) ) ].sort();
1722
+
1723
+ return res.sendSuccess( { months, data: filtered, total: data.length, csmOptions, productOptions, usdRate } );
1577
1724
  } catch ( error ) {
1578
1725
  logger.error( { error: error, function: 'billingSummary' } );
1579
1726
  return res.sendError( error, 500 );
@@ -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
@@ -1895,35 +1896,42 @@ export async function clientInvoiceList( req, res ) {
1895
1896
  }
1896
1897
  }
1897
1898
 
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.
1899
+ // Card totals — computed over the CURRENTLY-FILTERED result set (`count`
1900
+ // holds every invoice matching the active filters, pre-pagination), so the
1901
+ // cards reflect exactly what the filters select. Dollar invoices are
1902
+ // converted to INR at today's rate so all three totals are a single ₹
1903
+ // figure. Outstanding = unpaid remaining (totalAmount - paidAmount);
1904
+ // Overdue = past-due unpaid subset; Pending Payment = approved-but-unpaid.
1905
+ const usdRate = await getUsdInrRate();
1904
1906
  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] || {
1907
+ const cards = {
1922
1908
  outstandingAmount: 0, outstandingCount: 0,
1923
1909
  overdueAmount: 0, overdueCount: 0,
1924
1910
  pendingPaymentAmount: 0, pendingPaymentCount: 0,
1925
1911
  };
1926
- delete cards._id;
1912
+ for ( const inv of count ) {
1913
+ if ( inv.paymentStatus === 'paid' ) {
1914
+ continue;
1915
+ }
1916
+ const fx = inv.currency === 'dollar' ? usdRate : 1;
1917
+ const total = Number( inv.totalAmount ) || Number( inv.amount ) || 0;
1918
+ const paid = Number( inv.paidAmount ) || 0;
1919
+ const remaining = Math.max( 0, total - paid ) * fx;
1920
+
1921
+ cards.outstandingAmount += remaining;
1922
+ cards.outstandingCount += 1;
1923
+ if ( inv.dueDate && new Date( inv.dueDate ) < now ) {
1924
+ cards.overdueAmount += remaining;
1925
+ cards.overdueCount += 1;
1926
+ }
1927
+ if ( inv.status === 'approved' ) {
1928
+ cards.pendingPaymentAmount += remaining;
1929
+ cards.pendingPaymentCount += 1;
1930
+ }
1931
+ }
1932
+ cards.outstandingAmount = Math.round( cards.outstandingAmount * 100 ) / 100;
1933
+ cards.overdueAmount = Math.round( cards.overdueAmount * 100 ) / 100;
1934
+ cards.pendingPaymentAmount = Math.round( cards.pendingPaymentAmount * 100 ) / 100;
1927
1935
 
1928
1936
  res.sendSuccess( { count: count.length, data: invoiceList, cards } );
1929
1937
  } catch ( error ) {
@@ -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
+ }
@@ -1,6 +1,7 @@
1
1
  import express from 'express';
2
2
  export const billingRouter = express.Router();
3
3
  import { accessVerification, isAllowedSessionHandler, validate } from 'tango-app-api-middleware';
4
+ import { getPaymentReminder, savePaymentReminder } from '../controllers/paymentReminder.controller.js';
4
5
  import { createBillingGroup, deleteBillingGroup, getAllBillingGroups, getBillingGroups, getClientProducts, getInvoices, getLeadProducts, onetimePayment, subscribedStoreList, updateBillingGroup, gstinLookup } from '../controllers/billing.controllers.js';
5
6
  import { billingGroupSchema, clientProductsValid, createBillingGroupsSchema, deleteBillingGroupsSchema, getBillingGroupsSchema, getInvoiceSchema, leadProductsValid, onetimeFeeValid, subscribedStoreListSchema, updateBillingGroupsSchema } from '../dtos/validation.dtos.js';
6
7
 
@@ -28,3 +29,7 @@ billingRouter.get( '/getClientProducts/:id', isAllowedSessionHandler, validate(
28
29
 
29
30
 
30
31
  billingRouter.get( '/gst-lookup/:gstin', isAllowedSessionHandler, accessVerification( { userType: [ 'tango' ], access: [ { featureName: 'Global', name: 'Billing', permissions: [ 'isAdd' ] } ] } ), gstinLookup );
32
+
33
+ // Payment reminder config (Billing Settings page), one document per brand.
34
+ billingRouter.get( '/payment-reminder/:clientId', isAllowedSessionHandler, accessVerification( { userType: [ 'tango' ], access: [ { featureName: 'Global', name: 'Billing', permissions: [ 'isAdd' ] } ] } ), getPaymentReminder );
35
+ billingRouter.post( '/payment-reminder', isAllowedSessionHandler, accessVerification( { userType: [ 'tango' ], access: [ { featureName: 'Global', name: 'Billing', permissions: [ 'isAdd' ] } ] } ), savePaymentReminder );
@@ -14,4 +14,5 @@ brandsBillingRouter.put( '/updateDailyPricingStoreField', isAllowedSessionHandle
14
14
  brandsBillingRouter.post( '/getClientBillingInfo', isAllowedSessionHandler, accessVerification( { userType: [ 'tango' ], access: [ { featureName: 'TangoAdmin', name: 'invoiceApproval', permissions: [] } ] } ), getClientBillingInfo );
15
15
  brandsBillingRouter.get( '/bulk-download-billing-groups', isAllowedSessionHandler, accessVerification( { userType: [ 'tango' ], access: [ { featureName: 'TangoAdmin', name: 'invoiceApproval', permissions: [ 'isEdit' ] } ] } ), bulkDownloadBillingGroups );
16
16
  brandsBillingRouter.post( '/bulk-update-billing-groups', isAllowedSessionHandler, accessVerification( { userType: [ 'tango' ], access: [ { featureName: 'TangoAdmin', name: 'invoiceApproval', permissions: [ 'isEdit' ] } ] } ), bulkUpdateBillingGroups );
17
+ brandsBillingRouter.post( '/billingSummary', isAllowedSessionHandler, accessVerification( { userType: [ 'tango' ], access: [ { featureName: 'TangoAdmin', name: 'invoiceApproval', permissions: [] } ] } ), billingSummary );
17
18
  brandsBillingRouter.get( '/billingSummary', isAllowedSessionHandler, accessVerification( { userType: [ 'tango' ], access: [ { featureName: 'TangoAdmin', name: 'invoiceApproval', permissions: [] } ] } ), billingSummary );
@@ -0,0 +1,9 @@
1
+ import model from 'tango-api-schema';
2
+
3
+ export const findOne = async ( query = {}, projection = {} ) => {
4
+ return await model.paymentReminderModel.findOne( query, projection );
5
+ };
6
+
7
+ export const upsert = async ( filter, update ) => {
8
+ return await model.paymentReminderModel.updateOne( filter, { $set: update }, { upsert: true } );
9
+ };