tango-app-api-payment-subscription 3.5.13 → 3.5.14

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.13",
3
+ "version": "3.5.14",
4
4
  "description": "paymentSubscription",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -0,0 +1,201 @@
1
+ // One-shot script: creates billing groups by store country for specific
2
+ // clients. For each target client it reads every store, groups storeIds by
3
+ // `storeProfile.country`, and creates one billing group per distinct country
4
+ // holding all the storeIds in that country.
5
+ //
6
+ // Scope: ONLY clientIds 387 and 193 (override with --clients=a,b).
7
+ //
8
+ // Idempotency / existing groups: if a billing group with the same
9
+ // (clientId, groupName=<country>) already exists, its `stores` array is
10
+ // OVERWRITTEN to match the current country membership (not duplicated).
11
+ //
12
+ // One store, one group: a store placed in a country group is pulled out of the
13
+ // primary "Default Group" so it lives in exactly one billing group (mirrors
14
+ // createBillingGroup).
15
+ //
16
+ // Stores with a missing/empty country are NOT grouped — they remain in the
17
+ // primary "Default Group". Any legacy fallback group named UNKNOWN_GROUP_NAME
18
+ // (from an earlier version of this script) is deleted; its stores already live
19
+ // in the Default Group, so nothing is lost.
20
+ //
21
+ // Connection: reuses the app's own getConnection() (config/database) so it
22
+ // connects exactly like the server does — reading mongo_username /
23
+ // mongo_password from .env plus host/name/authSource from config/env/env.js.
24
+ // Override with MONGO_URI if you want to point it elsewhere.
25
+ //
26
+ // Run modes:
27
+ // DRY RUN (default) — prints what it WOULD create/update, writes nothing:
28
+ // node scripts/create-billing-groups-by-country.js
29
+ // APPLY — actually writes:
30
+ // node scripts/create-billing-groups-by-country.js --apply
31
+ //
32
+ // Other flags:
33
+ // --clients=387,193 override the target client list
34
+
35
+ import mongoose from 'mongoose';
36
+ import 'dotenv/config';
37
+ import { getConnection } from '../config/database/database.js';
38
+
39
+ const DEFAULT_CLIENTS = [ '387', '193' ];
40
+
41
+ // Fallback billing group for stores that have no storeProfile.country.
42
+ const UNKNOWN_GROUP_NAME = 'Unknown';
43
+
44
+ function parseArgs( argv ) {
45
+ const apply = argv.includes( '--apply' ) || process.env.APPLY === 'true';
46
+ let clients = DEFAULT_CLIENTS;
47
+ const clientsArg = argv.find( ( a ) => a.startsWith( '--clients=' ) );
48
+ if ( clientsArg ) {
49
+ clients = clientsArg.slice( '--clients='.length ).split( ',' ).map( ( c ) => c.trim() ).filter( Boolean );
50
+ }
51
+ return { apply, clients };
52
+ }
53
+
54
+ async function run() {
55
+ const { apply, clients } = parseArgs( process.argv.slice( 2 ) );
56
+
57
+ console.log( `Target clients: ${clients.join( ', ' )}` );
58
+ console.log( `Mode: ${apply ? 'APPLY (writing to DB)' : 'DRY RUN (no writes)'}` );
59
+ console.log( '' );
60
+
61
+ // Prefer an explicit MONGO_URI override; otherwise build the connection the
62
+ // same way the app does via getConnection().
63
+ if ( process.env.MONGO_URI ) {
64
+ await mongoose.connect( process.env.MONGO_URI );
65
+ } else {
66
+ const { uri, options } = getConnection();
67
+ await mongoose.connect( uri, options );
68
+ }
69
+
70
+ // strict:false so we don't pin to a specific tango-api-schema version.
71
+ const Store = mongoose.model(
72
+ '_cbgStore',
73
+ new mongoose.Schema( {}, { strict: false } ),
74
+ 'stores',
75
+ );
76
+ const Billing = mongoose.model(
77
+ '_cbgBilling',
78
+ new mongoose.Schema( {}, { strict: false } ),
79
+ 'billings',
80
+ );
81
+
82
+ let totalGroupsCreated = 0;
83
+ let totalGroupsUpdated = 0;
84
+ let totalPulledFromPrimary = 0;
85
+ let totalUnknownGroupsDeleted = 0;
86
+ const noCountryStores = [];
87
+
88
+ for ( const clientId of clients ) {
89
+ const stores = await Store.find(
90
+ { clientId: clientId },
91
+ { 'storeId': 1, 'storeName': 1, 'storeProfile.country': 1 },
92
+ ).lean();
93
+
94
+ console.log( `=== Client ${clientId}: ${stores.length} store(s) ===` );
95
+
96
+ if ( !stores.length ) {
97
+ console.log( ' No stores found. Skipping.\n' );
98
+ continue;
99
+ }
100
+
101
+ // Group storeIds by country. Stores with no country are NOT grouped — they
102
+ // remain in the primary "Default Group".
103
+ const byCountry = new Map();
104
+ for ( const s of stores ) {
105
+ const country = ( s.storeProfile?.country || '' ).trim();
106
+ if ( !country ) {
107
+ noCountryStores.push( { clientId, storeId: s.storeId, storeName: s.storeName } );
108
+ continue;
109
+ }
110
+ if ( !byCountry.has( country ) ) {
111
+ byCountry.set( country, [] );
112
+ }
113
+ byCountry.get( country ).push( s.storeId );
114
+ }
115
+
116
+ // Tidy up the legacy fallback group: country-less stores belong in the
117
+ // primary group, so any previously-created "Unknown" group is removed.
118
+ // (Its stores are also in the primary group, so nothing is lost.)
119
+ const unknownGroup = await Billing.findOne( { clientId: clientId, groupName: UNKNOWN_GROUP_NAME } );
120
+ if ( unknownGroup ) {
121
+ console.log( ` [DELETE] legacy "${UNKNOWN_GROUP_NAME}" group (_id ${unknownGroup._id}) — country-less stores stay in Default Group` );
122
+ if ( apply ) {
123
+ await Billing.deleteOne( { _id: unknownGroup._id } );
124
+ }
125
+ totalUnknownGroupsDeleted++;
126
+ }
127
+
128
+ if ( !byCountry.size ) {
129
+ console.log( ' No stores with a country value. Nothing to group.\n' );
130
+ continue;
131
+ }
132
+
133
+ for ( const [ country, storeIds ] of byCountry ) {
134
+ const existing = await Billing.findOne( { clientId: clientId, groupName: country } );
135
+
136
+ if ( existing ) {
137
+ console.log( ` [UPDATE] group "${country}" exists (_id ${existing._id}) → set ${storeIds.length} store(s)` );
138
+ if ( apply ) {
139
+ await Billing.updateOne(
140
+ { _id: existing._id },
141
+ { $set: { stores: storeIds } },
142
+ );
143
+ }
144
+ totalGroupsUpdated++;
145
+ } else {
146
+ console.log( ` [CREATE] group "${country}" with ${storeIds.length} store(s)` );
147
+ if ( apply ) {
148
+ await Billing.create( {
149
+ clientId: clientId,
150
+ groupName: country,
151
+ groupTag: 'store',
152
+ country: country,
153
+ stores: storeIds,
154
+ } );
155
+ }
156
+ totalGroupsCreated++;
157
+ }
158
+
159
+ // A store lives in exactly one group: once it's in a country group, pull
160
+ // it out of the primary "Default Group" (mirrors createBillingGroup).
161
+ if ( apply ) {
162
+ const pullRes = await Billing.updateOne(
163
+ { clientId: clientId, isPrimary: true },
164
+ { $pull: { stores: { $in: storeIds } } },
165
+ );
166
+ if ( pullRes.modifiedCount ) {
167
+ totalPulledFromPrimary += storeIds.length;
168
+ }
169
+ } else {
170
+ totalPulledFromPrimary += storeIds.length;
171
+ }
172
+ }
173
+ console.log( ` → pulled country-matched stores out of primary "Default Group"` );
174
+ console.log( '' );
175
+ }
176
+
177
+ console.log( '--- Summary ---' );
178
+ console.log( `Groups created: ${totalGroupsCreated}` );
179
+ console.log( `Groups updated: ${totalGroupsUpdated}` );
180
+ console.log( `Legacy "${UNKNOWN_GROUP_NAME}" groups deleted: ${totalUnknownGroupsDeleted}` );
181
+ console.log( `Store memberships pulled from primary group: ${totalPulledFromPrimary}` );
182
+ if ( noCountryStores.length ) {
183
+ console.log( `Stores with no country (left in "Default Group"): ${noCountryStores.length}` );
184
+ noCountryStores.slice( 0, 50 ).forEach( ( s ) =>
185
+ console.log( ` - client ${s.clientId} | ${s.storeId} | ${s.storeName || ''}` ),
186
+ );
187
+ if ( noCountryStores.length > 50 ) {
188
+ console.log( ` ...and ${noCountryStores.length - 50} more` );
189
+ }
190
+ }
191
+ if ( !apply ) {
192
+ console.log( '\nDRY RUN complete. No data was written. Re-run with --apply to write.' );
193
+ }
194
+
195
+ await mongoose.disconnect();
196
+ }
197
+
198
+ run().catch( ( err ) => {
199
+ console.error( err );
200
+ process.exit( 1 );
201
+ } );
@@ -586,16 +586,34 @@ export async function brandInvoiceList( req, res ) {
586
586
  };
587
587
 
588
588
  if ( req.body.export ) {
589
+ // Mirror the on-screen table exactly: same columns (excl. GST and incl.
590
+ // GST as separate amounts), same currency symbol, and the same status
591
+ // text the table renders via getInvoiceStatus().
592
+ const currencySymbols = {
593
+ inr: '₹', dollar: '$', singaporedollar: 'S$', euro: '€', aed: 'AED',
594
+ };
595
+ const statusFor = ( inv ) => {
596
+ if ( inv.status === 'pendingCsm' ) return 'Pending CSM';
597
+ if ( inv.status === 'pendingFinance' ) return 'Pending Finance';
598
+ if ( inv.status === 'pendingApproval' ) return 'Pending Approval';
599
+ if ( inv.status === 'pending' ) return 'Pending Approval';
600
+ if ( inv.paymentStatus === 'partial' ) return 'Partial';
601
+ if ( inv.paymentStatus === 'unpaid' ) return 'Pending Payment';
602
+ if ( inv.paymentStatus === 'paid' ) return 'Paid';
603
+ return inv.status;
604
+ };
589
605
  const exportdata = [];
590
606
  allInvoices.forEach( ( element ) => {
607
+ const symbol = currencySymbols[element.currency] || '$';
591
608
  exportdata.push( {
592
609
  'Invoice #': element.invoice,
593
610
  'Billing Group': element.groupName,
594
611
  'Period': dayjs( element.billingDate ).format( 'MMM YYYY' ),
595
612
  'Generated': dayjs( element.billingDate ).format( 'DD MMM YYYY' ),
596
613
  'No of Stores': element.stores,
597
- 'Amount': element.totalAmount,
598
- 'Status': [ 'pendingCsm', 'pendingFinance', 'pendingApproval' ].includes( element.status ) ? 'Pending Approval' : element.paymentStatus === 'unpaid' ? 'Pending Payment' : 'Paid',
614
+ 'Amount (excl. GST)': `${symbol}${Number( element.amount || 0 ).toFixed( 2 )}`,
615
+ 'Amount (incl. GST)': `${symbol}${Number( element.totalAmount || 0 ).toFixed( 2 )}`,
616
+ 'Status': statusFor( element ),
599
617
  } );
600
618
  } );
601
619
  await download( exportdata, res );
@@ -705,18 +723,27 @@ export async function latestDailyPricing( req, res ) {
705
723
  logger.error( { error: bpErr, function: 'latestDailyPricing.basePrice', clientId: req.body.clientId } );
706
724
  }
707
725
  // Billing type per product (perStore / perZone / perCamera) from the client
708
- // plan — drives whether the amount multiplies by camera/zone count.
726
+ // plan — drives whether the amount multiplies by camera/zone count. The
727
+ // same client doc supplies the invoice-amount currency below.
709
728
  const billingTypeByProduct = {};
729
+ // Invoice Amount currency comes from the client's paymentInvoice.currencyType
730
+ // (normalised to 'dollar' / 'inr' like the rest of this file). Falls back to
731
+ // the base-pricing currency only when the client has no currencyType set.
732
+ let invoiceCurrency = bpCurrency;
710
733
  try {
711
734
  const planClient = await clientService.findOne(
712
735
  { clientId: req.body.clientId },
713
- { 'planDetails.product.productName': 1, 'planDetails.product.billingType': 1 },
736
+ { 'planDetails.product.productName': 1, 'planDetails.product.billingType': 1, 'paymentInvoice.currencyType': 1 },
714
737
  );
715
738
  for ( const p of ( planClient?.planDetails?.product || [] ) ) {
716
739
  if ( p.productName ) {
717
740
  billingTypeByProduct[p.productName] = p.billingType || 'perStore';
718
741
  }
719
742
  }
743
+ const clientCurrency = planClient?.paymentInvoice?.currencyType;
744
+ if ( clientCurrency ) {
745
+ invoiceCurrency = clientCurrency === 'dollar' ? 'dollar' : 'inr';
746
+ }
720
747
  } catch ( btErr ) {
721
748
  logger.error( { error: btErr, function: 'latestDailyPricing.billingType', clientId: req.body.clientId } );
722
749
  }
@@ -828,7 +855,7 @@ export async function latestDailyPricing( req, res ) {
828
855
  ...( store._doc || store ),
829
856
  invoiceAmount: Math.round( invoiceAmount * 100 ) / 100,
830
857
  invoiceBreakdown,
831
- invoiceCurrency: bpCurrency,
858
+ invoiceCurrency: invoiceCurrency,
832
859
  };
833
860
  } );
834
861
 
@@ -324,7 +324,15 @@ export async function createInvoice( req, res ) {
324
324
  $filter: {
325
325
  input: '$stores',
326
326
  as: 'item',
327
- cond: { $and: [ { $gt: [ '$$item.daysDifference', 0 ] }, { $in: [ '$$item.storeId', group.stores ] } ] },
327
+ // A store is "running" if it has working days for ANY product.
328
+ // Edge products (tangoZone/tangoTraffic) use daysDifference;
329
+ // tangoTrax uses daysDifferenceTrax. A trax-only store has
330
+ // daysDifference = 0 but daysDifferenceTrax > 0 — counting only
331
+ // daysDifference would wrongly drop it and report stores: 0.
332
+ cond: { $and: [
333
+ { $or: [ { $gt: [ '$$item.daysDifference', 0 ] }, { $gt: [ '$$item.daysDifferenceTrax', 0 ] } ] },
334
+ { $in: [ '$$item.storeId', group.stores ] },
335
+ ] },
328
336
  },
329
337
  },
330
338
  },
@@ -440,10 +448,11 @@ async function buildAnnexureRows( invoiceInfo, getgroup ) {
440
448
  const billingMonthEnd = new Date( billingMonth.endOf( 'month' ).toISOString() );
441
449
  const monthDays = billingMonth.daysInMonth();
442
450
  const invoiceCurrency = symbolFor( invoiceInfo.currency );
443
- // basepricing negotiatePrice is stored in INR. For non-INR invoices the
444
- // annexure must convert it to the invoice currency, otherwise the INR number
445
- // is shown verbatim under a $ symbol (e.g. ₹1650 rendered as "$1,650").
446
- const annexFx = invoiceInfo.currency === 'dollar' ? ( await getUsdInrRate() ) : 1;
451
+ // basepricing negotiatePrice is stored in the client's own billing currency
452
+ // (INR for INR clients, the foreign currency for dollar/AED clients). Invoice
453
+ // generation uses negotiatePrice verbatim (no FX), so the annexure must too
454
+ // converting here would make the annexure unit price disagree with the actual
455
+ // billed amount (e.g. a $45 negotiated price wrongly shown as $0.48).
447
456
 
448
457
  const annexClient = await clientService.findOne( { clientId: invoiceInfo.clientId }, { 'planDetails.product': 1 } );
449
458
  const billingTypeMap = {};
@@ -504,9 +513,9 @@ async function buildAnnexureRows( invoiceInfo, getgroup ) {
504
513
  units = s.trafficCameraCount;
505
514
  }
506
515
  }
507
- // Convert the INR negotiatePrice into the invoice currency (annexFx = 1 for
508
- // INR invoices, = USD→INR rate for dollar invoices, so divide).
509
- const price = ( Number( s.standard?.negotiatePrice ) || 0 ) / annexFx;
516
+ // negotiatePrice is already in the invoice currency use it verbatim,
517
+ // matching invoice generation.
518
+ const price = Number( s.standard?.negotiatePrice ) || 0;
510
519
  const runningCost = s.workingdays >= monthDays ?
511
520
  Math.round( price * units * 100 ) / 100 :
512
521
  Math.round( ( price / monthDays ) * s.workingdays * units * 100 ) / 100;
@@ -1713,7 +1722,6 @@ async function stepPrice( group, getClient ) {
1713
1722
  function processArray( array1, array2 ) {
1714
1723
  let updatedArray = [];
1715
1724
 
1716
- let firstPriceAssigned = false;
1717
1725
  for ( let item of array1 ) {
1718
1726
  let remainingStores = item.storeCount;
1719
1727
 
@@ -1721,16 +1729,17 @@ async function stepPrice( group, getClient ) {
1721
1729
  let [ min, max ] = range.storeRange.split( '-' ).map( Number );
1722
1730
 
1723
1731
  if ( remainingStores > 0 ) {
1724
- console.log( firstPriceAssigned );
1725
1732
  let applicableStores = Math.min( remainingStores, max - min + 1 );
1726
1733
  updatedArray.push( {
1727
1734
  productName: item.productName,
1728
1735
  workingdays: item.workingdays,
1729
1736
  storeCount: applicableStores,
1730
- price: firstPriceAssigned ? 1100 : range.negotiatePrice,
1737
+ // Each tier is charged at its own range's negotiated price. (Was a
1738
+ // hardcoded 1100 for every tier after the first — a placeholder
1739
+ // that ignored the actual step price of the next range.)
1740
+ price: range.negotiatePrice,
1731
1741
  } );
1732
1742
  remainingStores -= applicableStores;
1733
- firstPriceAssigned = true;
1734
1743
  }
1735
1744
  }
1736
1745
  }