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
|
@@ -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.
|
|
598
|
-
'
|
|
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:
|
|
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
|
-
|
|
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
|
|
444
|
-
//
|
|
445
|
-
//
|
|
446
|
-
|
|
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
|
-
//
|
|
508
|
-
//
|
|
509
|
-
const price =
|
|
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
|
-
|
|
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
|
}
|