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

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.15",
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
+ } );
@@ -5,8 +5,10 @@ import * as dailyPriceService from '../services/dailyPrice.service.js';
5
5
  import * as storeService from '../services/store.service.js';
6
6
  import * as assignedStoreService from '../services/assignedStore.service.js';
7
7
  import * as basePriceService from '../services/basePrice.service.js';
8
+ import * as cameraService from '../services/camera.service.js';
9
+ import * as planogramService from '../services/planogram.service.js';
8
10
  import dayjs from 'dayjs';
9
- import { logger, checkFileExist, signedUrl, download, insertOpenSearchData } from 'tango-app-api-middleware';
11
+ import { logger, checkFileExist, signedUrl, download, insertOpenSearchData, getOpenSearchData } from 'tango-app-api-middleware';
10
12
  import * as XLSX from 'xlsx';
11
13
  import ExcelJS from 'exceljs';
12
14
  import { bulkUpdateBillingGroupRowSchema } from '../dtos/validation.dtos.js';
@@ -586,16 +588,34 @@ export async function brandInvoiceList( req, res ) {
586
588
  };
587
589
 
588
590
  if ( req.body.export ) {
591
+ // Mirror the on-screen table exactly: same columns (excl. GST and incl.
592
+ // GST as separate amounts), same currency symbol, and the same status
593
+ // text the table renders via getInvoiceStatus().
594
+ const currencySymbols = {
595
+ inr: '₹', dollar: '$', singaporedollar: 'S$', euro: '€', aed: 'AED',
596
+ };
597
+ const statusFor = ( inv ) => {
598
+ if ( inv.status === 'pendingCsm' ) return 'Pending CSM';
599
+ if ( inv.status === 'pendingFinance' ) return 'Pending Finance';
600
+ if ( inv.status === 'pendingApproval' ) return 'Pending Approval';
601
+ if ( inv.status === 'pending' ) return 'Pending Approval';
602
+ if ( inv.paymentStatus === 'partial' ) return 'Partial';
603
+ if ( inv.paymentStatus === 'unpaid' ) return 'Pending Payment';
604
+ if ( inv.paymentStatus === 'paid' ) return 'Paid';
605
+ return inv.status;
606
+ };
589
607
  const exportdata = [];
590
608
  allInvoices.forEach( ( element ) => {
609
+ const symbol = currencySymbols[element.currency] || '$';
591
610
  exportdata.push( {
592
611
  'Invoice #': element.invoice,
593
612
  'Billing Group': element.groupName,
594
613
  'Period': dayjs( element.billingDate ).format( 'MMM YYYY' ),
595
614
  'Generated': dayjs( element.billingDate ).format( 'DD MMM YYYY' ),
596
615
  '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',
616
+ 'Amount (excl. GST)': `${symbol}${Number( element.amount || 0 ).toFixed( 2 )}`,
617
+ 'Amount (incl. GST)': `${symbol}${Number( element.totalAmount || 0 ).toFixed( 2 )}`,
618
+ 'Status': statusFor( element ),
599
619
  } );
600
620
  } );
601
621
  await download( exportdata, res );
@@ -705,18 +725,27 @@ export async function latestDailyPricing( req, res ) {
705
725
  logger.error( { error: bpErr, function: 'latestDailyPricing.basePrice', clientId: req.body.clientId } );
706
726
  }
707
727
  // Billing type per product (perStore / perZone / perCamera) from the client
708
- // plan — drives whether the amount multiplies by camera/zone count.
728
+ // plan — drives whether the amount multiplies by camera/zone count. The
729
+ // same client doc supplies the invoice-amount currency below.
709
730
  const billingTypeByProduct = {};
731
+ // Invoice Amount currency comes from the client's paymentInvoice.currencyType
732
+ // (normalised to 'dollar' / 'inr' like the rest of this file). Falls back to
733
+ // the base-pricing currency only when the client has no currencyType set.
734
+ let invoiceCurrency = bpCurrency;
710
735
  try {
711
736
  const planClient = await clientService.findOne(
712
737
  { clientId: req.body.clientId },
713
- { 'planDetails.product.productName': 1, 'planDetails.product.billingType': 1 },
738
+ { 'planDetails.product.productName': 1, 'planDetails.product.billingType': 1, 'paymentInvoice.currencyType': 1 },
714
739
  );
715
740
  for ( const p of ( planClient?.planDetails?.product || [] ) ) {
716
741
  if ( p.productName ) {
717
742
  billingTypeByProduct[p.productName] = p.billingType || 'perStore';
718
743
  }
719
744
  }
745
+ const clientCurrency = planClient?.paymentInvoice?.currencyType;
746
+ if ( clientCurrency ) {
747
+ invoiceCurrency = clientCurrency === 'dollar' ? 'dollar' : 'inr';
748
+ }
720
749
  } catch ( btErr ) {
721
750
  logger.error( { error: btErr, function: 'latestDailyPricing.billingType', clientId: req.body.clientId } );
722
751
  }
@@ -828,7 +857,7 @@ export async function latestDailyPricing( req, res ) {
828
857
  ...( store._doc || store ),
829
858
  invoiceAmount: Math.round( invoiceAmount * 100 ) / 100,
830
859
  invoiceBreakdown,
831
- invoiceCurrency: bpCurrency,
860
+ invoiceCurrency: invoiceCurrency,
832
861
  };
833
862
  } );
834
863
 
@@ -1667,6 +1696,11 @@ export async function getUsdInrRate() {
1667
1696
  export async function billingSummary( req, res ) {
1668
1697
  try {
1669
1698
  const now = dayjs();
1699
+ const nowDate = now.toDate();
1700
+ // Today's USD->INR rate. Hoisted here (cached) so both the aging buckets and
1701
+ // the revenue columns convert dollar invoices consistently.
1702
+ const usdRate = await getUsdInrRate();
1703
+ const usdRateForAging = usdRate;
1670
1704
  const months = [];
1671
1705
  for ( let i = 4; i >= 0; i-- ) {
1672
1706
  const m = now.subtract( i, 'month' );
@@ -1726,6 +1760,54 @@ export async function billingSummary( req, res ) {
1726
1760
  } );
1727
1761
  const clientById = new Map( clients.map( ( c ) => [ String( c.clientId ), c ] ) );
1728
1762
 
1763
+ // AR aging — outstanding balance per client, bucketed by days past the
1764
+ // invoice DUE DATE. Scope: APPROVED invoices that are not fully paid
1765
+ // (paymentStatus unpaid/partial). Amount per invoice = balance due
1766
+ // (totalAmount - paidAmount). Dollar invoices are converted to INR at
1767
+ // today's rate so the buckets are comparable across currencies (matching the
1768
+ // revenue columns). This intentionally spans ALL time, not the 5-month
1769
+ // window, because overdue balances can be older than the revenue window.
1770
+ const agingRows = await invoiceService.aggregate( [
1771
+ { $match: {
1772
+ status: 'approved',
1773
+ paymentStatus: { $in: [ 'unpaid', 'partial', 'due' ] },
1774
+ } },
1775
+ { $addFields: {
1776
+ dueDateD: { $cond: [
1777
+ { $eq: [ { $type: '$dueDate' }, 'date' ] },
1778
+ '$dueDate',
1779
+ { $convert: { input: '$dueDate', to: 'date', onError: null, onNull: null } },
1780
+ ] },
1781
+ balance: { $subtract: [
1782
+ { $ifNull: [ '$totalAmount', 0 ] },
1783
+ { $ifNull: [ '$paidAmount', 0 ] },
1784
+ ] },
1785
+ isDollar: { $eq: [ '$currency', 'dollar' ] },
1786
+ } },
1787
+ // Only invoices with a positive balance still owed.
1788
+ { $match: { balance: { $gt: 0 } } },
1789
+ { $addFields: {
1790
+ // Days past due. Missing/invalid dueDate -> treat as 0 days overdue so
1791
+ // the amount still shows in the youngest bucket rather than vanishing.
1792
+ daysPastDue: { $cond: [
1793
+ { $eq: [ '$dueDateD', null ] },
1794
+ 0,
1795
+ { $dateDiff: { startDate: '$dueDateD', endDate: nowDate, unit: 'day' } },
1796
+ ] },
1797
+ } },
1798
+ { $group: {
1799
+ _id: '$clientId',
1800
+ // Bucket the BALANCE (INR-normalized) by age. < 30, 30-60, > 60.
1801
+ b0to30: { $sum: { $cond: [ { $lt: [ '$daysPastDue', 30 ] },
1802
+ { $cond: [ '$isDollar', { $multiply: [ '$balance', usdRateForAging ] }, '$balance' ] }, 0 ] } },
1803
+ b30to60: { $sum: { $cond: [ { $and: [ { $gte: [ '$daysPastDue', 30 ] }, { $lte: [ '$daysPastDue', 60 ] } ] },
1804
+ { $cond: [ '$isDollar', { $multiply: [ '$balance', usdRateForAging ] }, '$balance' ] }, 0 ] } },
1805
+ bOver60: { $sum: { $cond: [ { $gt: [ '$daysPastDue', 60 ] },
1806
+ { $cond: [ '$isDollar', { $multiply: [ '$balance', usdRateForAging ] }, '$balance' ] }, 0 ] } },
1807
+ } },
1808
+ ] );
1809
+ const agingByClient = new Map( agingRows.map( ( a ) => [ String( a._id ), a ] ) );
1810
+
1729
1811
  // Registered Entity comes from the billings collection (each billing group's
1730
1812
  // registeredCompanyName). A client can have multiple groups/names, so
1731
1813
  // collect the distinct list per client — the UI shows the first and reveals
@@ -1740,7 +1822,7 @@ export async function billingSummary( req, res ) {
1740
1822
 
1741
1823
  // Per-client CSM (userAssignedStore, tangoUserType 'csm'); display the
1742
1824
  // email's local part since the collection carries no display name.
1743
- const usdRate = await getUsdInrRate();
1825
+ // usdRate already fetched at the top of the function.
1744
1826
 
1745
1827
  // Current month's store count comes from dailyPricing — counted the same
1746
1828
  // way as Brands & Billing's "Billing Stores": distinct ACTIVE stores that
@@ -1957,11 +2039,21 @@ export async function billingSummary( req, res ) {
1957
2039
  // a client has no billing-group registered name.
1958
2040
  const regNames = regNamesByClient.get( r.clientId ) || [];
1959
2041
  const registeredEntity = regNames[0] || r.registeredEntity || '';
2042
+ // AR aging buckets (INR-normalized). Rounded; outstanding = sum of buckets.
2043
+ const ag = agingByClient.get( r.clientId );
2044
+ const aging0to30 = ag ? Math.round( ag.b0to30 || 0 ) : 0;
2045
+ const aging30to60 = ag ? Math.round( ag.b30to60 || 0 ) : 0;
2046
+ const agingOver60 = ag ? Math.round( ag.bOver60 || 0 ) : 0;
2047
+ const outstanding = aging0to30 + aging30to60 + agingOver60;
1960
2048
  return {
1961
2049
  clientId: r.clientId,
1962
2050
  clientName: r.clientName || registeredEntity || r.clientId,
1963
2051
  registeredEntity,
1964
2052
  registeredEntities: regNames.length ? regNames : ( r.registeredEntity ? [ r.registeredEntity ] : [] ),
2053
+ outstanding,
2054
+ aging0to30,
2055
+ aging30to60,
2056
+ agingOver60,
1965
2057
  status: r.status,
1966
2058
  products: r.products,
1967
2059
  csm: r.csm,
@@ -2021,3 +2113,127 @@ export async function billingSummary( req, res ) {
2021
2113
  return res.sendError( error, 500 );
2022
2114
  }
2023
2115
  }
2116
+
2117
+ // ---------------------------------------------------------------------------
2118
+ // Additional Products table (Billing Breakdown). These are extra, non-standard
2119
+ // products billed per store for a SPECIFIC client only. Currently scoped to
2120
+ // clientId '11'; for any other client this returns an empty list so the table
2121
+ // simply doesn't render.
2122
+ //
2123
+ // Both products and their unit prices are hardcoded here by design (they're not
2124
+ // in the basepricing catalog). Quantity = distinct store count from each
2125
+ // product's own source collection:
2126
+ // - Eyetest -> cameras collection (eye-test streams with a QR code)
2127
+ // - Planogram -> planograms collection (distinct storeName)
2128
+ // Total = Quantity * unit price (INR).
2129
+ // ---------------------------------------------------------------------------
2130
+ const ADDITIONAL_PRODUCTS_CLIENT_ID = '11';
2131
+ // Agreed INR unit prices for client 11.
2132
+ const ADDITIONAL_PRODUCT_PRICES = {
2133
+ eyetest: 450,
2134
+ planogram: 770,
2135
+ aiManager: 500,
2136
+ };
2137
+ // OpenSearch index holding extra billed products (VMS, Run AI, etc.). Each
2138
+ // record is a daily snapshot carrying its own quantity + price per product.
2139
+ const BILLING_DETAILS_INDEX = ( () => {
2140
+ try {
2141
+ return JSON.parse( process.env.OPENSEARCH || '{}' ).billingDetails || 'billing_details';
2142
+ } catch ( e ) {
2143
+ return 'billing_details';
2144
+ }
2145
+ } )();
2146
+
2147
+ // Compute the additional-products list for a client. Returns [] for any client
2148
+ // that has none (currently only clientId 11). Shared by the Billing Breakdown
2149
+ // table (additionalProducts controller) AND invoice generation, so both stay in
2150
+ // sync. Each entry: { productName, quantity, price, total }.
2151
+ export async function getAdditionalProducts( clientId ) {
2152
+ clientId = String( clientId ?? '' );
2153
+ if ( clientId !== ADDITIONAL_PRODUCTS_CLIENT_ID ) {
2154
+ return [];
2155
+ }
2156
+
2157
+ // Eyetest: distinct stores in the cameras collection that have an eye-test
2158
+ // stream with a QR code.
2159
+ const eyetestRows = await cameraService.aggregate( [
2160
+ { $match: { $and: [
2161
+ { clientId: clientId },
2162
+ { isEyeTestStream: true },
2163
+ { qrCode: { $exists: true } },
2164
+ ] } },
2165
+ { $group: { _id: '$storeId' } },
2166
+ { $count: 'stores' },
2167
+ ] );
2168
+ const eyetestQty = eyetestRows?.[0]?.stores || 0;
2169
+
2170
+ // Planogram: distinct storeName in the planograms collection.
2171
+ const planogramRows = await planogramService.aggregate( [
2172
+ { $match: { clientId: clientId } },
2173
+ { $group: { _id: null, storecount: { $addToSet: '$storeName' } } },
2174
+ { $project: { count: { $size: '$storecount' } } },
2175
+ ] );
2176
+ const planogramQty = planogramRows?.[0]?.count || 0;
2177
+
2178
+ // AI Manager: same count as tangoTraffic running stores — distinct stores in
2179
+ // the latest daily-pricing doc whose tangoTraffic product ran more than one
2180
+ // working day (a single-day appearance is transient and not billed).
2181
+ const aiManagerRows = await dailyPriceService.aggregate( [
2182
+ { $match: { clientId: clientId } },
2183
+ { $sort: { dateISO: -1 } },
2184
+ { $limit: 1 },
2185
+ { $unwind: '$stores' },
2186
+ { $unwind: '$stores.products' },
2187
+ { $match: { 'stores.products.productName': 'tangoTraffic', 'stores.products.workingdays': { $gt: 1 } } },
2188
+ { $group: { _id: '$stores.storeId' } },
2189
+ { $count: 'stores' },
2190
+ ] );
2191
+ const aiManagerQty = aiManagerRows?.[0]?.stores || 0;
2192
+
2193
+ const build = ( productName, quantity, price ) => ( {
2194
+ productName,
2195
+ quantity,
2196
+ price,
2197
+ total: Math.round( quantity * price * 100 ) / 100,
2198
+ } );
2199
+
2200
+ const products = [
2201
+ build( 'Eyetest', eyetestQty, ADDITIONAL_PRODUCT_PRICES.eyetest ),
2202
+ build( 'Planogram', planogramQty, ADDITIONAL_PRODUCT_PRICES.planogram ),
2203
+ build( 'AI Manager', aiManagerQty, ADDITIONAL_PRODUCT_PRICES.aiManager ),
2204
+ ];
2205
+
2206
+ // Extra products from the billing_details OpenSearch index — the LATEST daily
2207
+ // snapshot for this client. quantity/price are stored as strings, so coerce
2208
+ // them. If OpenSearch is unreachable we just skip these rows rather than
2209
+ // failing the whole list.
2210
+ try {
2211
+ const osRes = await getOpenSearchData( BILLING_DETAILS_INDEX, {
2212
+ size: 1,
2213
+ query: { term: { 'client_id': clientId } },
2214
+ sort: [ { date_string: { order: 'desc' } } ],
2215
+ } );
2216
+ const hits = osRes?.body?.hits?.hits || osRes?.hits?.hits || [];
2217
+ const osProducts = hits[0]?._source?.products || [];
2218
+ for ( const p of osProducts ) {
2219
+ const quantity = Number( p.quantity ) || 0;
2220
+ const price = Number( p.price ) || 0;
2221
+ products.push( build( p.productName, quantity, price ) );
2222
+ }
2223
+ } catch ( osError ) {
2224
+ logger.error( { error: osError, function: 'getAdditionalProducts:openSearch' } );
2225
+ }
2226
+
2227
+ return products;
2228
+ }
2229
+
2230
+ export async function additionalProducts( req, res ) {
2231
+ try {
2232
+ const clientId = String( req.query?.clientId ?? req.body?.clientId ?? '' );
2233
+ const products = await getAdditionalProducts( clientId );
2234
+ return res.sendSuccess( { currency: 'inr', products } );
2235
+ } catch ( error ) {
2236
+ logger.error( { error: error, function: 'additionalProducts' } );
2237
+ return res.sendError( error, 500 );
2238
+ }
2239
+ }
@@ -18,7 +18,7 @@ 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
20
  import * as bankTransactionService from '../services/bankTransaction.service.js';
21
- import { getUsdInrRate } from './brandsBilling.controller.js';
21
+ import { getUsdInrRate, getAdditionalProducts } from './brandsBilling.controller.js';
22
22
 
23
23
  // Pulls CSM + Finance head emails (stored under applicationDefault
24
24
  // type=invoice, subType=heads) AND the per-client CSMs assigned via
@@ -241,6 +241,26 @@ export async function createInvoice( req, res ) {
241
241
  products = await stepPrice( group, getClient );
242
242
  }
243
243
 
244
+ // Additional products (e.g. Lenskart / clientId 11: Eyetest, Planogram,
245
+ // AI Manager + the OpenSearch billing_details rows). Each is a single
246
+ // full-month line item (quantity x price), appended on top of the normal
247
+ // store-based product lines. Added BEFORE the multi-month expansion below
248
+ // so they repeat per month and are included in the taxable subtotal, just
249
+ // like the standard products. Returns [] for any client without extras.
250
+ const extraProducts = await getAdditionalProducts( group.clientId );
251
+ for ( const ep of extraProducts ) {
252
+ products.push( {
253
+ productName: ep.productName,
254
+ period: 'fullmonth',
255
+ storeCount: ep.quantity,
256
+ amount: ep.total,
257
+ price: ep.price,
258
+ description: ep.productName,
259
+ HsnNumber: '998314',
260
+ month: baseDate.format( 'MMM YYYY' ),
261
+ } );
262
+ }
263
+
244
264
  // Billing horizon in months. Advance and normal cycle are independent —
245
265
  // only one drives the span per generation:
246
266
  // advanceInvoice ON -> advancePeriod (advance future billing)
@@ -324,7 +344,15 @@ export async function createInvoice( req, res ) {
324
344
  $filter: {
325
345
  input: '$stores',
326
346
  as: 'item',
327
- cond: { $and: [ { $gt: [ '$$item.daysDifference', 0 ] }, { $in: [ '$$item.storeId', group.stores ] } ] },
347
+ // A store is "running" if it has working days for ANY product.
348
+ // Edge products (tangoZone/tangoTraffic) use daysDifference;
349
+ // tangoTrax uses daysDifferenceTrax. A trax-only store has
350
+ // daysDifference = 0 but daysDifferenceTrax > 0 — counting only
351
+ // daysDifference would wrongly drop it and report stores: 0.
352
+ cond: { $and: [
353
+ { $or: [ { $gt: [ '$$item.daysDifference', 0 ] }, { $gt: [ '$$item.daysDifferenceTrax', 0 ] } ] },
354
+ { $in: [ '$$item.storeId', group.stores ] },
355
+ ] },
328
356
  },
329
357
  },
330
358
  },
@@ -440,17 +468,62 @@ async function buildAnnexureRows( invoiceInfo, getgroup ) {
440
468
  const billingMonthEnd = new Date( billingMonth.endOf( 'month' ).toISOString() );
441
469
  const monthDays = billingMonth.daysInMonth();
442
470
  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;
471
+ // basepricing negotiatePrice is stored in the client's own billing currency
472
+ // (INR for INR clients, the foreign currency for dollar/AED clients). Invoice
473
+ // generation uses negotiatePrice verbatim (no FX), so the annexure must too
474
+ // converting here would make the annexure unit price disagree with the actual
475
+ // billed amount (e.g. a $45 negotiated price wrongly shown as $0.48).
447
476
 
448
- const annexClient = await clientService.findOne( { clientId: invoiceInfo.clientId }, { 'planDetails.product': 1 } );
477
+ const annexClient = await clientService.findOne( { clientId: invoiceInfo.clientId }, { 'planDetails.product': 1, 'priceType': 1 } );
449
478
  const billingTypeMap = {};
450
479
  ( annexClient?.planDetails?.product || [] ).forEach( ( p ) => {
451
480
  billingTypeMap[p.productName] = p.billingType || 'perStore';
452
481
  } );
453
482
 
483
+ // Pricing for this client. For STEP clients the price depends on the store's
484
+ // position within the product (tier ranges), so we can't join a single price
485
+ // per store in the aggregation — that join is what caused every store to
486
+ // appear once per tier (e.g. the same store listed at $45 and again at $40).
487
+ // Instead we pull the tiers here and assign each store a single tier price
488
+ // below, in JS, by its 1-based index within the product.
489
+ const pricingDoc = await basepricingService.findOne( { clientId: invoiceInfo.clientId }, { standard: 1, step: 1 } );
490
+ const isStep = annexClient?.priceType === 'step';
491
+ const tiersByProduct = {};
492
+ ( ( isStep ? pricingDoc?.step : pricingDoc?.standard ) || [] ).forEach( ( p ) => {
493
+ if ( !tiersByProduct[p.productName] ) {
494
+ tiersByProduct[p.productName] = [];
495
+ }
496
+ tiersByProduct[p.productName].push( p );
497
+ } );
498
+ // Step tiers must be ordered by their range start so index lookups are correct.
499
+ Object.keys( tiersByProduct ).forEach( ( name ) => {
500
+ tiersByProduct[name].sort( ( a, b ) => {
501
+ const aStart = parseInt( String( a.storeRange || '0' ).split( '-' )[0], 10 ) || 0;
502
+ const bStart = parseInt( String( b.storeRange || '0' ).split( '-' )[0], 10 ) || 0;
503
+ return aStart - bStart;
504
+ } );
505
+ } );
506
+
507
+ // Resolve the per-store price. Standard: first (only) tier's price. Step: the
508
+ // tier whose range contains this store's 1-based position within the product.
509
+ const priceForStore = ( productName, positionInProduct ) => {
510
+ const tiers = tiersByProduct[productName] || [];
511
+ if ( !tiers.length ) {
512
+ return 0;
513
+ }
514
+ if ( !isStep ) {
515
+ return Number( tiers[0].negotiatePrice ) || 0;
516
+ }
517
+ for ( const tier of tiers ) {
518
+ const [ min, max ] = String( tier.storeRange || '' ).split( '-' ).map( Number );
519
+ if ( positionInProduct >= min && positionInProduct <= max ) {
520
+ return Number( tier.negotiatePrice ) || 0;
521
+ }
522
+ }
523
+ // Beyond the last defined tier: fall back to the last tier's price.
524
+ return Number( tiers[tiers.length - 1].negotiatePrice ) || 0;
525
+ };
526
+
454
527
  const rows = await dailyPricingService.aggregate( [
455
528
  { $match: { clientId: invoiceInfo.clientId, dateISO: { $lte: billingMonthEnd } } },
456
529
  { $sort: { dateISO: -1 } },
@@ -469,25 +542,12 @@ async function buildAnnexureRows( invoiceInfo, getgroup ) {
469
542
  zoneCameraCount: { $ifNull: [ '$stores.zoneCameraCount', 0 ] },
470
543
  } },
471
544
  { $match: { workingdays: { $gt: 0 } } },
472
- { $sort: { productName: 1, workingdays: -1 } },
473
- { $lookup: {
474
- from: 'basepricings',
475
- let: { clientId: invoiceInfo.clientId },
476
- pipeline: [
477
- { $match: { $expr: { $eq: [ '$clientId', '$$clientId' ] } } },
478
- { $project: { standard: 1 } },
479
- ],
480
- as: 'basepricing',
481
- } },
482
- { $unwind: { path: '$basepricing', preserveNullAndEmptyArrays: true } },
483
- { $project: {
484
- productName: 1, workingdays: 1, storeName: 1, storeId: 1, edgefirstFileDate: 1,
485
- zoneCount: 1, trafficCameraCount: 1, zoneCameraCount: 1,
486
- standard: { $filter: { input: '$basepricing.standard', as: 'standard', cond: { $eq: [ '$$standard.productName', '$productName' ] } } },
487
- } },
488
- { $unwind: { path: '$standard', preserveNullAndEmptyArrays: true } },
545
+ // Deterministic ordering so step-tier assignment is stable run to run.
546
+ { $sort: { productName: 1, storeId: 1 } },
489
547
  ] );
490
548
 
549
+ // Track each store's 1-based position within its product to pick the step tier.
550
+ const productPosition = {};
491
551
  const data = rows.map( ( s ) => {
492
552
  const billingType = billingTypeMap[s.productName] || 'perStore';
493
553
  // Same units rule as invoice generation: perZone / perCamera multiply by
@@ -504,9 +564,11 @@ async function buildAnnexureRows( invoiceInfo, getgroup ) {
504
564
  units = s.trafficCameraCount;
505
565
  }
506
566
  }
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;
567
+ productPosition[s.productName] = ( productPosition[s.productName] || 0 ) + 1;
568
+ // negotiatePrice is already in the invoice currency use it verbatim,
569
+ // matching invoice generation. For step clients the tier is chosen by the
570
+ // store's position within the product.
571
+ const price = priceForStore( s.productName, productPosition[s.productName] );
510
572
  const runningCost = s.workingdays >= monthDays ?
511
573
  Math.round( price * units * 100 ) / 100 :
512
574
  Math.round( ( price / monthDays ) * s.workingdays * units * 100 ) / 100;
@@ -1684,7 +1746,13 @@ async function stepPrice( group, getClient ) {
1684
1746
 
1685
1747
  let stepPriceRecord = await basepricingService.findOne( { clientId: group.clientId } );
1686
1748
  let data = products;
1687
- let pricing = stepPriceRecord.step;
1749
+ // Tiers must be ordered by range start for the cumulative step assignment in
1750
+ // processArray to walk them low-to-high.
1751
+ let pricing = ( stepPriceRecord.step || [] ).slice().sort( ( a, b ) => {
1752
+ const aStart = parseInt( String( a.storeRange || '0' ).split( '-' )[0], 10 ) || 0;
1753
+ const bStart = parseInt( String( b.storeRange || '0' ).split( '-' )[0], 10 ) || 0;
1754
+ return aStart - bStart;
1755
+ } );
1688
1756
 
1689
1757
  const applyPricing = ( data, pricing ) => {
1690
1758
  let totalcount = 0;
@@ -1713,26 +1781,54 @@ async function stepPrice( group, getClient ) {
1713
1781
  function processArray( array1, array2 ) {
1714
1782
  let updatedArray = [];
1715
1783
 
1716
- let firstPriceAssigned = false;
1784
+ // Step tiers apply CUMULATIVELY across every working-days bucket of a
1785
+ // product, not per bucket. Track how many stores of each product have
1786
+ // already been priced so each bucket continues where the previous one left
1787
+ // off. (Previously the tier counter reset for every bucket, so e.g. the 9
1788
+ // prorated stores — really stores 75-83, in the $40 tier — were each priced
1789
+ // from tier 1 at $45, producing extra/wrong-priced line items.)
1790
+ const assignedByProduct = {};
1791
+
1717
1792
  for ( let item of array1 ) {
1718
1793
  let remainingStores = item.storeCount;
1794
+ let assigned = assignedByProduct[item.productName] || 0;
1719
1795
 
1720
1796
  for ( let range of array2 ) {
1721
- let [ min, max ] = range.storeRange.split( '-' ).map( Number );
1722
-
1723
- if ( remainingStores > 0 ) {
1724
- console.log( firstPriceAssigned );
1725
- let applicableStores = Math.min( remainingStores, max - min + 1 );
1726
- updatedArray.push( {
1727
- productName: item.productName,
1728
- workingdays: item.workingdays,
1729
- storeCount: applicableStores,
1730
- price: firstPriceAssigned ? 1100 : range.negotiatePrice,
1731
- } );
1732
- remainingStores -= applicableStores;
1733
- firstPriceAssigned = true;
1797
+ const [ min, max ] = range.storeRange.split( '-' ).map( Number );
1798
+ if ( remainingStores <= 0 ) {
1799
+ break;
1734
1800
  }
1801
+ // How many of this tier's slots are still free given everything already
1802
+ // assigned to this product across previous buckets.
1803
+ const tierCapacityLeft = max - Math.max( min - 1, assigned );
1804
+ if ( tierCapacityLeft <= 0 ) {
1805
+ continue;
1806
+ }
1807
+ const applicableStores = Math.min( remainingStores, tierCapacityLeft );
1808
+ updatedArray.push( {
1809
+ productName: item.productName,
1810
+ workingdays: item.workingdays,
1811
+ storeCount: applicableStores,
1812
+ // Each tier is charged at its own range's negotiated price.
1813
+ price: range.negotiatePrice,
1814
+ } );
1815
+ remainingStores -= applicableStores;
1816
+ assigned += applicableStores;
1735
1817
  }
1818
+
1819
+ // Any stores beyond the last defined tier are charged at the last tier.
1820
+ if ( remainingStores > 0 && array2.length ) {
1821
+ updatedArray.push( {
1822
+ productName: item.productName,
1823
+ workingdays: item.workingdays,
1824
+ storeCount: remainingStores,
1825
+ price: array2[array2.length - 1].negotiatePrice,
1826
+ } );
1827
+ assigned += remainingStores;
1828
+ remainingStores = 0;
1829
+ }
1830
+
1831
+ assignedByProduct[item.productName] = assigned;
1736
1832
  }
1737
1833
  return updatedArray;
1738
1834
  }
@@ -1741,9 +1837,16 @@ async function stepPrice( group, getClient ) {
1741
1837
  console.log( '++++++++++++', resultarray );
1742
1838
  const result = applyPricing( resultarray, pricing );
1743
1839
  console.log( '***********', result );
1840
+ // Group by product + period + PRICE TIER so each step tier is its own line
1841
+ // item, and all stores within a tier+period collapse into a single line:
1842
+ // - full-month stores of a tier -> one line (sum cost, sum count)
1843
+ // - prorated stores of a tier -> one averaged line (sum cost, sum count)
1844
+ // Keying on price (not storeCount) is what merges the leftover prorated stores
1845
+ // — which span several working-days buckets — into one $40 line instead of one
1846
+ // line per working-days value.
1744
1847
  const groupedData = result.reduce( ( acc, item ) => {
1745
1848
  const { productName, period, runningCost } = item;
1746
- const key = `${productName}_${period}_${item.storeCount}`;
1849
+ const key = `${productName}_${period}_${item.price}`;
1747
1850
  if ( !acc[key] ) {
1748
1851
  acc[key] = {
1749
1852
  productName,
@@ -1752,14 +1855,8 @@ async function stepPrice( group, getClient ) {
1752
1855
  count: 0,
1753
1856
  };
1754
1857
  }
1755
- if ( period === 'proRate' ) {
1756
- acc[key].totalRunningCost += parseFloat( runningCost );
1757
- acc[key].count += item.storeCount;
1758
- } else {
1759
- acc[key].totalRunningCost = parseFloat( runningCost );
1760
- acc[key].count = item.storeCount;
1761
- }
1762
-
1858
+ acc[key].totalRunningCost += parseFloat( runningCost );
1859
+ acc[key].count += item.storeCount;
1763
1860
  return acc;
1764
1861
  }, {} );
1765
1862
  console.log( groupedData );
@@ -1,6 +1,6 @@
1
1
 
2
2
  import express from 'express';
3
- import { brandsBillingList, brandInvoiceList, latestDailyPricing, brandBillingGroups, updateDailyPricingWorkingDays, updateDailyPricingStoreField, getClientBillingInfo, bulkDownloadBillingGroups, bulkUpdateBillingGroups, billingSummary } from '../controllers/brandsBilling.controller.js';
3
+ import { brandsBillingList, brandInvoiceList, latestDailyPricing, brandBillingGroups, updateDailyPricingWorkingDays, updateDailyPricingStoreField, getClientBillingInfo, bulkDownloadBillingGroups, bulkUpdateBillingGroups, billingSummary, additionalProducts } from '../controllers/brandsBilling.controller.js';
4
4
  import { isAllowedSessionHandler, accessVerification } from 'tango-app-api-middleware';
5
5
 
6
6
  export const brandsBillingRouter = express.Router();
@@ -16,3 +16,4 @@ brandsBillingRouter.get( '/bulk-download-billing-groups', isAllowedSessionHandle
16
16
  brandsBillingRouter.post( '/bulk-update-billing-groups', isAllowedSessionHandler, accessVerification( { userType: [ 'tango' ], access: [ { featureName: 'TangoAdmin', name: 'invoiceApproval', permissions: [ 'isEdit' ] } ] } ), bulkUpdateBillingGroups );
17
17
  brandsBillingRouter.post( '/billingSummary', isAllowedSessionHandler, accessVerification( { userType: [ 'tango' ], access: [ { featureName: 'TangoAdmin', name: 'invoiceApproval', permissions: [] } ] } ), billingSummary );
18
18
  brandsBillingRouter.get( '/billingSummary', isAllowedSessionHandler, accessVerification( { userType: [ 'tango' ], access: [ { featureName: 'TangoAdmin', name: 'invoiceApproval', permissions: [] } ] } ), billingSummary );
19
+ brandsBillingRouter.get( '/additionalProducts', isAllowedSessionHandler, accessVerification( { userType: [ 'tango' ], access: [ { featureName: 'TangoAdmin', name: 'invoiceApproval', permissions: [] } ] } ), additionalProducts );
@@ -0,0 +1,13 @@
1
+ import model from 'tango-api-schema';
2
+
3
+ export const aggregate = ( query = {} ) => {
4
+ return model.planogramModel.aggregate( query );
5
+ };
6
+
7
+ export const find = ( query = {}, record = {} ) => {
8
+ return model.planogramModel.find( query, record );
9
+ };
10
+
11
+ export const findOne = ( query = {}, record = {} ) => {
12
+ return model.planogramModel.findOne( query, record );
13
+ };