tango-app-api-payment-subscription 3.5.14 → 3.5.16
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 +2 -2
- package/src/controllers/brandsBilling.controller.js +224 -2
- package/src/controllers/invoice.controller.js +229 -251
- package/src/controllers/paymentSubscription.controllers.js +116 -11
- package/src/dtos/validation.dtos.js +1 -0
- package/src/routes/brandsBilling.routes.js +2 -1
- package/src/routes/paymentSubscription.routes.js +9 -0
- package/src/services/clientPayment.services.js +5 -0
- package/src/services/planogram.service.js +13 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tango-app-api-payment-subscription",
|
|
3
|
-
"version": "3.5.
|
|
3
|
+
"version": "3.5.16",
|
|
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.
|
|
32
|
+
"tango-api-schema": "^2.6.34",
|
|
33
33
|
"tango-app-api-middleware": "^3.6.18",
|
|
34
34
|
"winston": "^3.12.0",
|
|
35
35
|
"winston-daily-rotate-file": "^5.0.0",
|
|
@@ -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';
|
|
@@ -580,6 +582,11 @@ export async function brandInvoiceList( req, res ) {
|
|
|
580
582
|
let summary = {
|
|
581
583
|
totalInvoices: allInvoices.length,
|
|
582
584
|
totalInvoiced: allInvoices.reduce( ( sum, inv ) => sum + ( inv.totalAmount || 0 ), 0 ),
|
|
585
|
+
// Footer totals over the FULL filtered set (not just the current page):
|
|
586
|
+
// stores, amount excl. GST and amount incl. GST.
|
|
587
|
+
totalStores: allInvoices.reduce( ( sum, inv ) => sum + ( inv.stores || 0 ), 0 ),
|
|
588
|
+
totalAmountExclGst: allInvoices.reduce( ( sum, inv ) => sum + ( inv.amount || 0 ), 0 ),
|
|
589
|
+
totalAmountInclGst: allInvoices.reduce( ( sum, inv ) => sum + ( inv.totalAmount || 0 ), 0 ),
|
|
583
590
|
pendingApproval: allInvoices.filter( ( inv ) => [ 'pendingCsm', 'pendingFinance', 'pendingApproval' ].includes( inv.status ) ).length,
|
|
584
591
|
pendingPayment: allInvoices.filter( ( inv ) => inv.paymentStatus === 'unpaid' && inv.status === 'approved' ).length,
|
|
585
592
|
paid: allInvoices.filter( ( inv ) => inv.paymentStatus === 'paid' ).length,
|
|
@@ -889,6 +896,32 @@ export async function latestDailyPricing( req, res ) {
|
|
|
889
896
|
} },
|
|
890
897
|
] );
|
|
891
898
|
|
|
899
|
+
// Newly onboarded stores = stores whose FIRST FILE date falls in the current
|
|
900
|
+
// calendar month. Counted over the FULL store list (record.stores), not the
|
|
901
|
+
// status/search-filtered view, so the card reflects all new onboardings.
|
|
902
|
+
// First-file date is edgefirstFileDate, with processfirstFileDate as the
|
|
903
|
+
// fallback (same rule the annexure uses); some legacy rows store it as a
|
|
904
|
+
// string, so coerce defensively.
|
|
905
|
+
const monthStart = dayjs().startOf( 'month' );
|
|
906
|
+
const monthEnd = dayjs().endOf( 'month' );
|
|
907
|
+
const newlyOnboardedStoreList = ( record.stores || [] )
|
|
908
|
+
.map( ( s ) => {
|
|
909
|
+
const raw = s.edgefirstFileDate || s.processfirstFileDate;
|
|
910
|
+
const ff = raw ? dayjs( raw ) : null;
|
|
911
|
+
return { store: s, ff };
|
|
912
|
+
} )
|
|
913
|
+
.filter( ( x ) => x.ff && x.ff.isValid() &&
|
|
914
|
+
x.ff.isAfter( monthStart.subtract( 1, 'millisecond' ) ) &&
|
|
915
|
+
x.ff.isBefore( monthEnd.add( 1, 'millisecond' ) ) )
|
|
916
|
+
.map( ( x ) => ( {
|
|
917
|
+
storeId: x.store.storeId,
|
|
918
|
+
storeName: x.store.storeName,
|
|
919
|
+
status: x.store.status,
|
|
920
|
+
firstFileDate: x.ff.format( 'YYYY-MM-DD' ),
|
|
921
|
+
} ) )
|
|
922
|
+
.sort( ( a, b ) => a.firstFileDate.localeCompare( b.firstFileDate ) );
|
|
923
|
+
const newlyOnboardedStores = newlyOnboardedStoreList.length;
|
|
924
|
+
|
|
892
925
|
let data = {
|
|
893
926
|
clientId: record.clientId,
|
|
894
927
|
brandName: record.brandName,
|
|
@@ -899,6 +932,8 @@ export async function latestDailyPricing( req, res ) {
|
|
|
899
932
|
status: record.status,
|
|
900
933
|
proRate: record.proRate,
|
|
901
934
|
count,
|
|
935
|
+
newlyOnboardedStores,
|
|
936
|
+
newlyOnboardedStoreList,
|
|
902
937
|
data: storeList,
|
|
903
938
|
monthlyBillingSummary,
|
|
904
939
|
};
|
|
@@ -1694,6 +1729,11 @@ export async function getUsdInrRate() {
|
|
|
1694
1729
|
export async function billingSummary( req, res ) {
|
|
1695
1730
|
try {
|
|
1696
1731
|
const now = dayjs();
|
|
1732
|
+
const nowDate = now.toDate();
|
|
1733
|
+
// Today's USD->INR rate. Hoisted here (cached) so both the aging buckets and
|
|
1734
|
+
// the revenue columns convert dollar invoices consistently.
|
|
1735
|
+
const usdRate = await getUsdInrRate();
|
|
1736
|
+
const usdRateForAging = usdRate;
|
|
1697
1737
|
const months = [];
|
|
1698
1738
|
for ( let i = 4; i >= 0; i-- ) {
|
|
1699
1739
|
const m = now.subtract( i, 'month' );
|
|
@@ -1753,6 +1793,54 @@ export async function billingSummary( req, res ) {
|
|
|
1753
1793
|
} );
|
|
1754
1794
|
const clientById = new Map( clients.map( ( c ) => [ String( c.clientId ), c ] ) );
|
|
1755
1795
|
|
|
1796
|
+
// AR aging — outstanding balance per client, bucketed by days past the
|
|
1797
|
+
// invoice DUE DATE. Scope: APPROVED invoices that are not fully paid
|
|
1798
|
+
// (paymentStatus unpaid/partial). Amount per invoice = balance due
|
|
1799
|
+
// (totalAmount - paidAmount). Dollar invoices are converted to INR at
|
|
1800
|
+
// today's rate so the buckets are comparable across currencies (matching the
|
|
1801
|
+
// revenue columns). This intentionally spans ALL time, not the 5-month
|
|
1802
|
+
// window, because overdue balances can be older than the revenue window.
|
|
1803
|
+
const agingRows = await invoiceService.aggregate( [
|
|
1804
|
+
{ $match: {
|
|
1805
|
+
status: 'approved',
|
|
1806
|
+
paymentStatus: { $in: [ 'unpaid', 'partial', 'due' ] },
|
|
1807
|
+
} },
|
|
1808
|
+
{ $addFields: {
|
|
1809
|
+
dueDateD: { $cond: [
|
|
1810
|
+
{ $eq: [ { $type: '$dueDate' }, 'date' ] },
|
|
1811
|
+
'$dueDate',
|
|
1812
|
+
{ $convert: { input: '$dueDate', to: 'date', onError: null, onNull: null } },
|
|
1813
|
+
] },
|
|
1814
|
+
balance: { $subtract: [
|
|
1815
|
+
{ $ifNull: [ '$totalAmount', 0 ] },
|
|
1816
|
+
{ $ifNull: [ '$paidAmount', 0 ] },
|
|
1817
|
+
] },
|
|
1818
|
+
isDollar: { $eq: [ '$currency', 'dollar' ] },
|
|
1819
|
+
} },
|
|
1820
|
+
// Only invoices with a positive balance still owed.
|
|
1821
|
+
{ $match: { balance: { $gt: 0 } } },
|
|
1822
|
+
{ $addFields: {
|
|
1823
|
+
// Days past due. Missing/invalid dueDate -> treat as 0 days overdue so
|
|
1824
|
+
// the amount still shows in the youngest bucket rather than vanishing.
|
|
1825
|
+
daysPastDue: { $cond: [
|
|
1826
|
+
{ $eq: [ '$dueDateD', null ] },
|
|
1827
|
+
0,
|
|
1828
|
+
{ $dateDiff: { startDate: '$dueDateD', endDate: nowDate, unit: 'day' } },
|
|
1829
|
+
] },
|
|
1830
|
+
} },
|
|
1831
|
+
{ $group: {
|
|
1832
|
+
_id: '$clientId',
|
|
1833
|
+
// Bucket the BALANCE (INR-normalized) by age. < 30, 30-60, > 60.
|
|
1834
|
+
b0to30: { $sum: { $cond: [ { $lt: [ '$daysPastDue', 30 ] },
|
|
1835
|
+
{ $cond: [ '$isDollar', { $multiply: [ '$balance', usdRateForAging ] }, '$balance' ] }, 0 ] } },
|
|
1836
|
+
b30to60: { $sum: { $cond: [ { $and: [ { $gte: [ '$daysPastDue', 30 ] }, { $lte: [ '$daysPastDue', 60 ] } ] },
|
|
1837
|
+
{ $cond: [ '$isDollar', { $multiply: [ '$balance', usdRateForAging ] }, '$balance' ] }, 0 ] } },
|
|
1838
|
+
bOver60: { $sum: { $cond: [ { $gt: [ '$daysPastDue', 60 ] },
|
|
1839
|
+
{ $cond: [ '$isDollar', { $multiply: [ '$balance', usdRateForAging ] }, '$balance' ] }, 0 ] } },
|
|
1840
|
+
} },
|
|
1841
|
+
] );
|
|
1842
|
+
const agingByClient = new Map( agingRows.map( ( a ) => [ String( a._id ), a ] ) );
|
|
1843
|
+
|
|
1756
1844
|
// Registered Entity comes from the billings collection (each billing group's
|
|
1757
1845
|
// registeredCompanyName). A client can have multiple groups/names, so
|
|
1758
1846
|
// collect the distinct list per client — the UI shows the first and reveals
|
|
@@ -1767,7 +1855,7 @@ export async function billingSummary( req, res ) {
|
|
|
1767
1855
|
|
|
1768
1856
|
// Per-client CSM (userAssignedStore, tangoUserType 'csm'); display the
|
|
1769
1857
|
// email's local part since the collection carries no display name.
|
|
1770
|
-
|
|
1858
|
+
// usdRate already fetched at the top of the function.
|
|
1771
1859
|
|
|
1772
1860
|
// Current month's store count comes from dailyPricing — counted the same
|
|
1773
1861
|
// way as Brands & Billing's "Billing Stores": distinct ACTIVE stores that
|
|
@@ -1984,11 +2072,21 @@ export async function billingSummary( req, res ) {
|
|
|
1984
2072
|
// a client has no billing-group registered name.
|
|
1985
2073
|
const regNames = regNamesByClient.get( r.clientId ) || [];
|
|
1986
2074
|
const registeredEntity = regNames[0] || r.registeredEntity || '';
|
|
2075
|
+
// AR aging buckets (INR-normalized). Rounded; outstanding = sum of buckets.
|
|
2076
|
+
const ag = agingByClient.get( r.clientId );
|
|
2077
|
+
const aging0to30 = ag ? Math.round( ag.b0to30 || 0 ) : 0;
|
|
2078
|
+
const aging30to60 = ag ? Math.round( ag.b30to60 || 0 ) : 0;
|
|
2079
|
+
const agingOver60 = ag ? Math.round( ag.bOver60 || 0 ) : 0;
|
|
2080
|
+
const outstanding = aging0to30 + aging30to60 + agingOver60;
|
|
1987
2081
|
return {
|
|
1988
2082
|
clientId: r.clientId,
|
|
1989
2083
|
clientName: r.clientName || registeredEntity || r.clientId,
|
|
1990
2084
|
registeredEntity,
|
|
1991
2085
|
registeredEntities: regNames.length ? regNames : ( r.registeredEntity ? [ r.registeredEntity ] : [] ),
|
|
2086
|
+
outstanding,
|
|
2087
|
+
aging0to30,
|
|
2088
|
+
aging30to60,
|
|
2089
|
+
agingOver60,
|
|
1992
2090
|
status: r.status,
|
|
1993
2091
|
products: r.products,
|
|
1994
2092
|
csm: r.csm,
|
|
@@ -2048,3 +2146,127 @@ export async function billingSummary( req, res ) {
|
|
|
2048
2146
|
return res.sendError( error, 500 );
|
|
2049
2147
|
}
|
|
2050
2148
|
}
|
|
2149
|
+
|
|
2150
|
+
// ---------------------------------------------------------------------------
|
|
2151
|
+
// Additional Products table (Billing Breakdown). These are extra, non-standard
|
|
2152
|
+
// products billed per store for a SPECIFIC client only. Currently scoped to
|
|
2153
|
+
// clientId '11'; for any other client this returns an empty list so the table
|
|
2154
|
+
// simply doesn't render.
|
|
2155
|
+
//
|
|
2156
|
+
// Both products and their unit prices are hardcoded here by design (they're not
|
|
2157
|
+
// in the basepricing catalog). Quantity = distinct store count from each
|
|
2158
|
+
// product's own source collection:
|
|
2159
|
+
// - Eyetest -> cameras collection (eye-test streams with a QR code)
|
|
2160
|
+
// - Planogram -> planograms collection (distinct storeName)
|
|
2161
|
+
// Total = Quantity * unit price (INR).
|
|
2162
|
+
// ---------------------------------------------------------------------------
|
|
2163
|
+
const ADDITIONAL_PRODUCTS_CLIENT_ID = '11';
|
|
2164
|
+
// Agreed INR unit prices for client 11.
|
|
2165
|
+
const ADDITIONAL_PRODUCT_PRICES = {
|
|
2166
|
+
eyetest: 450,
|
|
2167
|
+
planogram: 770,
|
|
2168
|
+
aiManager: 500,
|
|
2169
|
+
};
|
|
2170
|
+
// OpenSearch index holding extra billed products (VMS, Run AI, etc.). Each
|
|
2171
|
+
// record is a daily snapshot carrying its own quantity + price per product.
|
|
2172
|
+
const BILLING_DETAILS_INDEX = ( () => {
|
|
2173
|
+
try {
|
|
2174
|
+
return JSON.parse( process.env.OPENSEARCH || '{}' ).billingDetails || 'billing_details';
|
|
2175
|
+
} catch ( e ) {
|
|
2176
|
+
return 'billing_details';
|
|
2177
|
+
}
|
|
2178
|
+
} )();
|
|
2179
|
+
|
|
2180
|
+
// Compute the additional-products list for a client. Returns [] for any client
|
|
2181
|
+
// that has none (currently only clientId 11). Shared by the Billing Breakdown
|
|
2182
|
+
// table (additionalProducts controller) AND invoice generation, so both stay in
|
|
2183
|
+
// sync. Each entry: { productName, quantity, price, total }.
|
|
2184
|
+
export async function getAdditionalProducts( clientId ) {
|
|
2185
|
+
clientId = String( clientId ?? '' );
|
|
2186
|
+
if ( clientId !== ADDITIONAL_PRODUCTS_CLIENT_ID ) {
|
|
2187
|
+
return [];
|
|
2188
|
+
}
|
|
2189
|
+
|
|
2190
|
+
// Eyetest: distinct stores in the cameras collection that have an eye-test
|
|
2191
|
+
// stream with a QR code.
|
|
2192
|
+
const eyetestRows = await cameraService.aggregate( [
|
|
2193
|
+
{ $match: { $and: [
|
|
2194
|
+
{ clientId: clientId },
|
|
2195
|
+
{ isEyeTestStream: true },
|
|
2196
|
+
{ qrCode: { $exists: true } },
|
|
2197
|
+
] } },
|
|
2198
|
+
{ $group: { _id: '$storeId' } },
|
|
2199
|
+
{ $count: 'stores' },
|
|
2200
|
+
] );
|
|
2201
|
+
const eyetestQty = eyetestRows?.[0]?.stores || 0;
|
|
2202
|
+
|
|
2203
|
+
// Planogram: distinct storeName in the planograms collection.
|
|
2204
|
+
const planogramRows = await planogramService.aggregate( [
|
|
2205
|
+
{ $match: { clientId: clientId } },
|
|
2206
|
+
{ $group: { _id: null, storecount: { $addToSet: '$storeName' } } },
|
|
2207
|
+
{ $project: { count: { $size: '$storecount' } } },
|
|
2208
|
+
] );
|
|
2209
|
+
const planogramQty = planogramRows?.[0]?.count || 0;
|
|
2210
|
+
|
|
2211
|
+
// AI Manager: same count as tangoTraffic running stores — distinct stores in
|
|
2212
|
+
// the latest daily-pricing doc whose tangoTraffic product ran more than one
|
|
2213
|
+
// working day (a single-day appearance is transient and not billed).
|
|
2214
|
+
const aiManagerRows = await dailyPriceService.aggregate( [
|
|
2215
|
+
{ $match: { clientId: clientId } },
|
|
2216
|
+
{ $sort: { dateISO: -1 } },
|
|
2217
|
+
{ $limit: 1 },
|
|
2218
|
+
{ $unwind: '$stores' },
|
|
2219
|
+
{ $unwind: '$stores.products' },
|
|
2220
|
+
{ $match: { 'stores.products.productName': 'tangoTraffic', 'stores.products.workingdays': { $gt: 1 } } },
|
|
2221
|
+
{ $group: { _id: '$stores.storeId' } },
|
|
2222
|
+
{ $count: 'stores' },
|
|
2223
|
+
] );
|
|
2224
|
+
const aiManagerQty = aiManagerRows?.[0]?.stores || 0;
|
|
2225
|
+
|
|
2226
|
+
const build = ( productName, quantity, price ) => ( {
|
|
2227
|
+
productName,
|
|
2228
|
+
quantity,
|
|
2229
|
+
price,
|
|
2230
|
+
total: Math.round( quantity * price * 100 ) / 100,
|
|
2231
|
+
} );
|
|
2232
|
+
|
|
2233
|
+
const products = [
|
|
2234
|
+
build( 'Eyetest', eyetestQty, ADDITIONAL_PRODUCT_PRICES.eyetest ),
|
|
2235
|
+
build( 'Planogram', planogramQty, ADDITIONAL_PRODUCT_PRICES.planogram ),
|
|
2236
|
+
build( 'AI Manager', aiManagerQty, ADDITIONAL_PRODUCT_PRICES.aiManager ),
|
|
2237
|
+
];
|
|
2238
|
+
|
|
2239
|
+
// Extra products from the billing_details OpenSearch index — the LATEST daily
|
|
2240
|
+
// snapshot for this client. quantity/price are stored as strings, so coerce
|
|
2241
|
+
// them. If OpenSearch is unreachable we just skip these rows rather than
|
|
2242
|
+
// failing the whole list.
|
|
2243
|
+
try {
|
|
2244
|
+
const osRes = await getOpenSearchData( BILLING_DETAILS_INDEX, {
|
|
2245
|
+
size: 1,
|
|
2246
|
+
query: { term: { 'client_id': clientId } },
|
|
2247
|
+
sort: [ { date_string: { order: 'desc' } } ],
|
|
2248
|
+
} );
|
|
2249
|
+
const hits = osRes?.body?.hits?.hits || osRes?.hits?.hits || [];
|
|
2250
|
+
const osProducts = hits[0]?._source?.products || [];
|
|
2251
|
+
for ( const p of osProducts ) {
|
|
2252
|
+
const quantity = Number( p.quantity ) || 0;
|
|
2253
|
+
const price = Number( p.price ) || 0;
|
|
2254
|
+
products.push( build( p.productName, quantity, price ) );
|
|
2255
|
+
}
|
|
2256
|
+
} catch ( osError ) {
|
|
2257
|
+
logger.error( { error: osError, function: 'getAdditionalProducts:openSearch' } );
|
|
2258
|
+
}
|
|
2259
|
+
|
|
2260
|
+
return products;
|
|
2261
|
+
}
|
|
2262
|
+
|
|
2263
|
+
export async function additionalProducts( req, res ) {
|
|
2264
|
+
try {
|
|
2265
|
+
const clientId = String( req.query?.clientId ?? req.body?.clientId ?? '' );
|
|
2266
|
+
const products = await getAdditionalProducts( clientId );
|
|
2267
|
+
return res.sendSuccess( { currency: 'inr', products } );
|
|
2268
|
+
} catch ( error ) {
|
|
2269
|
+
logger.error( { error: error, function: 'additionalProducts' } );
|
|
2270
|
+
return res.sendError( error, 500 );
|
|
2271
|
+
}
|
|
2272
|
+
}
|
|
@@ -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)
|
|
@@ -266,6 +286,58 @@ export async function createInvoice( req, res ) {
|
|
|
266
286
|
products = expanded;
|
|
267
287
|
}
|
|
268
288
|
|
|
289
|
+
// One-time onboarding fee: stores in THIS billing group whose first file
|
|
290
|
+
// date falls in the invoice's billing month, charged a flat
|
|
291
|
+
// oneTimeFeePerStore (from the client's basepricing). Added AFTER the
|
|
292
|
+
// multi-month expansion so it's billed ONCE per invoice (not per month).
|
|
293
|
+
// It's a normal line item, so it lands in the taxable subtotal below.
|
|
294
|
+
try {
|
|
295
|
+
const oneTimeBp = await basepricingService.findOne(
|
|
296
|
+
{ clientId: group.clientId },
|
|
297
|
+
{ oneTimeFeePerStore: 1 },
|
|
298
|
+
);
|
|
299
|
+
const oneTimeFeePerStore = Number( oneTimeBp?.oneTimeFeePerStore ) || 0;
|
|
300
|
+
if ( oneTimeFeePerStore > 0 && Array.isArray( group.stores ) && group.stores.length ) {
|
|
301
|
+
const monthStart = new Date( baseDate.startOf( 'month' ).toISOString() );
|
|
302
|
+
const monthEnd = new Date( baseDate.endOf( 'month' ).toISOString() );
|
|
303
|
+
// Distinct group stores whose first file date is within the billing
|
|
304
|
+
// month. First file = edgefirstFileDate, fallback processfirstFileDate.
|
|
305
|
+
const newStoreAgg = await dailyPricingService.aggregate( [
|
|
306
|
+
{ $match: { clientId: group.clientId } },
|
|
307
|
+
{ $sort: { dateISO: -1 } },
|
|
308
|
+
{ $limit: 1 },
|
|
309
|
+
{ $project: { stores: { $filter: {
|
|
310
|
+
input: '$stores', as: 'item', cond: { $in: [ '$$item.storeId', group.stores ] },
|
|
311
|
+
} } } },
|
|
312
|
+
{ $unwind: '$stores' },
|
|
313
|
+
{ $addFields: { ff: {
|
|
314
|
+
$convert: {
|
|
315
|
+
input: { $ifNull: [ '$stores.edgefirstFileDate', '$stores.processfirstFileDate' ] },
|
|
316
|
+
to: 'date', onError: null, onNull: null,
|
|
317
|
+
},
|
|
318
|
+
} } },
|
|
319
|
+
{ $match: { ff: { $ne: null, $gte: monthStart, $lte: monthEnd } } },
|
|
320
|
+
{ $group: { _id: '$stores.storeId' } },
|
|
321
|
+
{ $count: 'newStores' },
|
|
322
|
+
] );
|
|
323
|
+
const newStoreCount = newStoreAgg?.[0]?.newStores || 0;
|
|
324
|
+
if ( newStoreCount > 0 ) {
|
|
325
|
+
products.push( {
|
|
326
|
+
productName: 'oneTimeFee',
|
|
327
|
+
period: 'fullmonth',
|
|
328
|
+
storeCount: newStoreCount,
|
|
329
|
+
amount: Math.round( newStoreCount * oneTimeFeePerStore * 100 ) / 100,
|
|
330
|
+
price: oneTimeFeePerStore,
|
|
331
|
+
description: `One-Time Fee - ${newStoreCount} stores`,
|
|
332
|
+
HsnNumber: '998314',
|
|
333
|
+
month: baseDate.format( 'MMM YYYY' ),
|
|
334
|
+
} );
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
} catch ( oneTimeErr ) {
|
|
338
|
+
logger.error( { error: oneTimeErr, function: 'createInvoice.oneTimeFee', clientId: group.clientId } );
|
|
339
|
+
}
|
|
340
|
+
|
|
269
341
|
let amount = products.reduce( ( sum, product ) => sum + product.amount, 0 );
|
|
270
342
|
let taxList = [];
|
|
271
343
|
let totalAmount = 0;
|
|
@@ -454,12 +526,56 @@ async function buildAnnexureRows( invoiceInfo, getgroup ) {
|
|
|
454
526
|
// converting here would make the annexure unit price disagree with the actual
|
|
455
527
|
// billed amount (e.g. a $45 negotiated price wrongly shown as $0.48).
|
|
456
528
|
|
|
457
|
-
const annexClient = await clientService.findOne( { clientId: invoiceInfo.clientId }, { 'planDetails.product': 1 } );
|
|
529
|
+
const annexClient = await clientService.findOne( { clientId: invoiceInfo.clientId }, { 'planDetails.product': 1, 'priceType': 1 } );
|
|
458
530
|
const billingTypeMap = {};
|
|
459
531
|
( annexClient?.planDetails?.product || [] ).forEach( ( p ) => {
|
|
460
532
|
billingTypeMap[p.productName] = p.billingType || 'perStore';
|
|
461
533
|
} );
|
|
462
534
|
|
|
535
|
+
// Pricing for this client. For STEP clients the price depends on the store's
|
|
536
|
+
// position within the product (tier ranges), so we can't join a single price
|
|
537
|
+
// per store in the aggregation — that join is what caused every store to
|
|
538
|
+
// appear once per tier (e.g. the same store listed at $45 and again at $40).
|
|
539
|
+
// Instead we pull the tiers here and assign each store a single tier price
|
|
540
|
+
// below, in JS, by its 1-based index within the product.
|
|
541
|
+
const pricingDoc = await basepricingService.findOne( { clientId: invoiceInfo.clientId }, { standard: 1, step: 1 } );
|
|
542
|
+
const isStep = annexClient?.priceType === 'step';
|
|
543
|
+
const tiersByProduct = {};
|
|
544
|
+
( ( isStep ? pricingDoc?.step : pricingDoc?.standard ) || [] ).forEach( ( p ) => {
|
|
545
|
+
if ( !tiersByProduct[p.productName] ) {
|
|
546
|
+
tiersByProduct[p.productName] = [];
|
|
547
|
+
}
|
|
548
|
+
tiersByProduct[p.productName].push( p );
|
|
549
|
+
} );
|
|
550
|
+
// Step tiers must be ordered by their range start so index lookups are correct.
|
|
551
|
+
Object.keys( tiersByProduct ).forEach( ( name ) => {
|
|
552
|
+
tiersByProduct[name].sort( ( a, b ) => {
|
|
553
|
+
const aStart = parseInt( String( a.storeRange || '0' ).split( '-' )[0], 10 ) || 0;
|
|
554
|
+
const bStart = parseInt( String( b.storeRange || '0' ).split( '-' )[0], 10 ) || 0;
|
|
555
|
+
return aStart - bStart;
|
|
556
|
+
} );
|
|
557
|
+
} );
|
|
558
|
+
|
|
559
|
+
// Resolve the per-store price. Standard: first (only) tier's price. Step: the
|
|
560
|
+
// tier whose range contains this store's 1-based position within the product.
|
|
561
|
+
const priceForStore = ( productName, positionInProduct ) => {
|
|
562
|
+
const tiers = tiersByProduct[productName] || [];
|
|
563
|
+
if ( !tiers.length ) {
|
|
564
|
+
return 0;
|
|
565
|
+
}
|
|
566
|
+
if ( !isStep ) {
|
|
567
|
+
return Number( tiers[0].negotiatePrice ) || 0;
|
|
568
|
+
}
|
|
569
|
+
for ( const tier of tiers ) {
|
|
570
|
+
const [ min, max ] = String( tier.storeRange || '' ).split( '-' ).map( Number );
|
|
571
|
+
if ( positionInProduct >= min && positionInProduct <= max ) {
|
|
572
|
+
return Number( tier.negotiatePrice ) || 0;
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
// Beyond the last defined tier: fall back to the last tier's price.
|
|
576
|
+
return Number( tiers[tiers.length - 1].negotiatePrice ) || 0;
|
|
577
|
+
};
|
|
578
|
+
|
|
463
579
|
const rows = await dailyPricingService.aggregate( [
|
|
464
580
|
{ $match: { clientId: invoiceInfo.clientId, dateISO: { $lte: billingMonthEnd } } },
|
|
465
581
|
{ $sort: { dateISO: -1 } },
|
|
@@ -478,25 +594,12 @@ async function buildAnnexureRows( invoiceInfo, getgroup ) {
|
|
|
478
594
|
zoneCameraCount: { $ifNull: [ '$stores.zoneCameraCount', 0 ] },
|
|
479
595
|
} },
|
|
480
596
|
{ $match: { workingdays: { $gt: 0 } } },
|
|
481
|
-
|
|
482
|
-
{ $
|
|
483
|
-
from: 'basepricings',
|
|
484
|
-
let: { clientId: invoiceInfo.clientId },
|
|
485
|
-
pipeline: [
|
|
486
|
-
{ $match: { $expr: { $eq: [ '$clientId', '$$clientId' ] } } },
|
|
487
|
-
{ $project: { standard: 1 } },
|
|
488
|
-
],
|
|
489
|
-
as: 'basepricing',
|
|
490
|
-
} },
|
|
491
|
-
{ $unwind: { path: '$basepricing', preserveNullAndEmptyArrays: true } },
|
|
492
|
-
{ $project: {
|
|
493
|
-
productName: 1, workingdays: 1, storeName: 1, storeId: 1, edgefirstFileDate: 1,
|
|
494
|
-
zoneCount: 1, trafficCameraCount: 1, zoneCameraCount: 1,
|
|
495
|
-
standard: { $filter: { input: '$basepricing.standard', as: 'standard', cond: { $eq: [ '$$standard.productName', '$productName' ] } } },
|
|
496
|
-
} },
|
|
497
|
-
{ $unwind: { path: '$standard', preserveNullAndEmptyArrays: true } },
|
|
597
|
+
// Deterministic ordering so step-tier assignment is stable run to run.
|
|
598
|
+
{ $sort: { productName: 1, storeId: 1 } },
|
|
498
599
|
] );
|
|
499
600
|
|
|
601
|
+
// Track each store's 1-based position within its product to pick the step tier.
|
|
602
|
+
const productPosition = {};
|
|
500
603
|
const data = rows.map( ( s ) => {
|
|
501
604
|
const billingType = billingTypeMap[s.productName] || 'perStore';
|
|
502
605
|
// Same units rule as invoice generation: perZone / perCamera multiply by
|
|
@@ -513,9 +616,11 @@ async function buildAnnexureRows( invoiceInfo, getgroup ) {
|
|
|
513
616
|
units = s.trafficCameraCount;
|
|
514
617
|
}
|
|
515
618
|
}
|
|
619
|
+
productPosition[s.productName] = ( productPosition[s.productName] || 0 ) + 1;
|
|
516
620
|
// negotiatePrice is already in the invoice currency — use it verbatim,
|
|
517
|
-
// matching invoice generation.
|
|
518
|
-
|
|
621
|
+
// matching invoice generation. For step clients the tier is chosen by the
|
|
622
|
+
// store's position within the product.
|
|
623
|
+
const price = priceForStore( s.productName, productPosition[s.productName] );
|
|
519
624
|
const runningCost = s.workingdays >= monthDays ?
|
|
520
625
|
Math.round( price * units * 100 ) / 100 :
|
|
521
626
|
Math.round( ( price / monthDays ) * s.workingdays * units * 100 ) / 100;
|
|
@@ -1428,134 +1533,40 @@ async function stepPrice( group, getClient ) {
|
|
|
1428
1533
|
billingTypeMap[p.productName] = p.billingType || 'perStore';
|
|
1429
1534
|
} );
|
|
1430
1535
|
}
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
},
|
|
1437
|
-
{
|
|
1438
|
-
$sort: { dateISO: -1 },
|
|
1439
|
-
},
|
|
1536
|
+
// PER-STORE rows (not bucketed) so step tiers can be assigned to individual
|
|
1537
|
+
// stores and each store charged tierPrice x its EXACT camera/zone count.
|
|
1538
|
+
let perStoreRows = await dailyPricingService.aggregate( [
|
|
1539
|
+
{ $match: { clientId: group.clientId } },
|
|
1540
|
+
{ $sort: { dateISO: -1 } },
|
|
1440
1541
|
{ $limit: 1 },
|
|
1441
|
-
{
|
|
1442
|
-
$
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
$
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
},
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
storeStatus: '$stores.status',
|
|
1470
|
-
zoneCount: { $ifNull: [ '$stores.zoneCount', 0 ] },
|
|
1471
|
-
cameraCount: { $ifNull: [ '$stores.cameraCount', 0 ] },
|
|
1472
|
-
// Pull per-store camera splits; same fix as standardPrice — without
|
|
1473
|
-
// these the second $group sums missing fields and the perCamera
|
|
1474
|
-
// branch in the downstream map silently falls back to perStore.
|
|
1475
|
-
trafficCameraCount: { $ifNull: [ '$stores.trafficCameraCount', 0 ] },
|
|
1476
|
-
zoneCameraCount: { $ifNull: [ '$stores.zoneCameraCount', 0 ] },
|
|
1477
|
-
},
|
|
1478
|
-
},
|
|
1479
|
-
{
|
|
1480
|
-
$match: {
|
|
1481
|
-
workingDays: { $gt: 0 },
|
|
1482
|
-
},
|
|
1483
|
-
},
|
|
1484
|
-
{
|
|
1485
|
-
$project: {
|
|
1486
|
-
productName: 1,
|
|
1487
|
-
storeId: 1,
|
|
1488
|
-
// Group-level flag baked into every doc; consumed by the next
|
|
1489
|
-
// $project's $cond.
|
|
1490
|
-
isFlatPricing: { $literal: isFlatPricing },
|
|
1491
|
-
workingDays: 1,
|
|
1492
|
-
storeStatus: 1,
|
|
1493
|
-
zoneCount: 1,
|
|
1494
|
-
cameraCount: 1,
|
|
1495
|
-
trafficCameraCount: 1,
|
|
1496
|
-
zoneCameraCount: 1,
|
|
1497
|
-
},
|
|
1498
|
-
},
|
|
1499
|
-
{
|
|
1500
|
-
$project: {
|
|
1501
|
-
productName: 1,
|
|
1502
|
-
storeId: 1,
|
|
1503
|
-
// Flat => full month per store. Prorate => actual working days.
|
|
1504
|
-
workingDays: {
|
|
1505
|
-
$cond: { if: '$isFlatPricing', then: currentMonthDays, else: '$workingDays' },
|
|
1506
|
-
},
|
|
1507
|
-
storeStatus: 1,
|
|
1508
|
-
zoneCount: 1,
|
|
1509
|
-
cameraCount: 1,
|
|
1510
|
-
trafficCameraCount: 1,
|
|
1511
|
-
zoneCameraCount: 1,
|
|
1512
|
-
},
|
|
1513
|
-
}, {
|
|
1514
|
-
$group: {
|
|
1515
|
-
_id: {
|
|
1516
|
-
productName: '$productName',
|
|
1517
|
-
storeId: '$storeId',
|
|
1518
|
-
},
|
|
1519
|
-
workingdays: { $first: '$workingDays' },
|
|
1520
|
-
zoneCount: { $first: '$zoneCount' },
|
|
1521
|
-
cameraCount: { $first: '$cameraCount' },
|
|
1522
|
-
trafficCameraCount: { $first: '$trafficCameraCount' },
|
|
1523
|
-
zoneCameraCount: { $first: '$zoneCameraCount' },
|
|
1524
|
-
},
|
|
1525
|
-
},
|
|
1526
|
-
{
|
|
1527
|
-
$group: {
|
|
1528
|
-
_id: {
|
|
1529
|
-
productName: '$_id.productName',
|
|
1530
|
-
workingdays: '$workingdays',
|
|
1531
|
-
},
|
|
1532
|
-
storeCount: { $sum: 1 },
|
|
1533
|
-
totalZoneCount: { $sum: '$zoneCount' },
|
|
1534
|
-
totalTrafficCameraCount: { $sum: '$trafficCameraCount' },
|
|
1535
|
-
totalZoneCameraCount: { $sum: '$zoneCameraCount' },
|
|
1536
|
-
},
|
|
1537
|
-
},
|
|
1538
|
-
{
|
|
1539
|
-
$project: {
|
|
1540
|
-
_id: 0,
|
|
1541
|
-
productName: '$_id.productName',
|
|
1542
|
-
workingdays: '$_id.workingdays',
|
|
1543
|
-
storeCount: '$storeCount',
|
|
1544
|
-
totalZoneCount: '$totalZoneCount',
|
|
1545
|
-
totalTrafficCameraCount: '$totalTrafficCameraCount',
|
|
1546
|
-
totalZoneCameraCount: '$totalZoneCameraCount',
|
|
1547
|
-
},
|
|
1548
|
-
},
|
|
1549
|
-
{
|
|
1550
|
-
// productName first so order is deterministic across views/PDF, then
|
|
1551
|
-
// workingdays so step rows stay grouped consistently.
|
|
1552
|
-
$sort: {
|
|
1553
|
-
productName: 1,
|
|
1554
|
-
workingdays: -1,
|
|
1555
|
-
},
|
|
1556
|
-
},
|
|
1557
|
-
|
|
1558
|
-
|
|
1542
|
+
{ $project: { stores: { $filter: {
|
|
1543
|
+
input: '$stores', as: 'item', cond: { $in: [ '$$item.storeId', group.stores ] },
|
|
1544
|
+
} } } },
|
|
1545
|
+
{ $unwind: { path: '$stores', preserveNullAndEmptyArrays: false } },
|
|
1546
|
+
{ $unwind: { path: '$stores.products', preserveNullAndEmptyArrays: false } },
|
|
1547
|
+
{ $project: {
|
|
1548
|
+
productName: '$stores.products.productName',
|
|
1549
|
+
storeId: '$stores.storeId',
|
|
1550
|
+
storeName: '$stores.storeName',
|
|
1551
|
+
workingDays: '$stores.products.workingdays',
|
|
1552
|
+
zoneCount: { $ifNull: [ '$stores.zoneCount', 0 ] },
|
|
1553
|
+
trafficCameraCount: { $ifNull: [ '$stores.trafficCameraCount', 0 ] },
|
|
1554
|
+
zoneCameraCount: { $ifNull: [ '$stores.zoneCameraCount', 0 ] },
|
|
1555
|
+
} },
|
|
1556
|
+
{ $match: { workingDays: { $gt: 0 } } },
|
|
1557
|
+
// Flat pricing => bill every store for the full month.
|
|
1558
|
+
{ $project: {
|
|
1559
|
+
_id: 0,
|
|
1560
|
+
productName: 1,
|
|
1561
|
+
storeId: 1,
|
|
1562
|
+
storeName: 1,
|
|
1563
|
+
workingDays: { $cond: { if: { $literal: isFlatPricing }, then: currentMonthDays, else: '$workingDays' } },
|
|
1564
|
+
zoneCount: 1,
|
|
1565
|
+
trafficCameraCount: 1,
|
|
1566
|
+
zoneCameraCount: 1,
|
|
1567
|
+
} },
|
|
1568
|
+
// Deterministic order so per-store tier assignment is stable run to run.
|
|
1569
|
+
{ $sort: { productName: 1, storeId: 1 } },
|
|
1559
1570
|
] );
|
|
1560
1571
|
// Build billingMethod map from group.products
|
|
1561
1572
|
let billingMethodMap = {};
|
|
@@ -1668,133 +1679,100 @@ async function stepPrice( group, getClient ) {
|
|
|
1668
1679
|
}
|
|
1669
1680
|
}
|
|
1670
1681
|
|
|
1671
|
-
//
|
|
1672
|
-
|
|
1682
|
+
// Drop eachStore products — those are handled by the per-store branch above.
|
|
1683
|
+
perStoreRows = perStoreRows.filter( ( p ) => !eachStoreProductNames.includes( p.productName ) );
|
|
1673
1684
|
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1685
|
+
const stepPriceRecord = await basepricingService.findOne( { clientId: group.clientId } );
|
|
1686
|
+
// Tiers ordered by range start so per-store tier assignment walks low-to-high.
|
|
1687
|
+
const pricing = ( stepPriceRecord?.step || [] ).slice().sort( ( a, b ) => {
|
|
1688
|
+
const aStart = parseInt( String( a.storeRange || '0' ).split( '-' )[0], 10 ) || 0;
|
|
1689
|
+
const bStart = parseInt( String( b.storeRange || '0' ).split( '-' )[0], 10 ) || 0;
|
|
1690
|
+
return aStart - bStart;
|
|
1691
|
+
} );
|
|
1692
|
+
// Tiers grouped per product (only one product set here, but keep it general).
|
|
1693
|
+
const tiersByProduct = {};
|
|
1694
|
+
for ( const t of pricing ) {
|
|
1695
|
+
if ( !tiersByProduct[t.productName] ) {
|
|
1696
|
+
tiersByProduct[t.productName] = [];
|
|
1697
|
+
}
|
|
1698
|
+
tiersByProduct[t.productName].push( t );
|
|
1699
|
+
}
|
|
1700
|
+
// Resolve the tier price for a store's 1-based position within its product.
|
|
1701
|
+
const tierPriceForPosition = ( productName, position ) => {
|
|
1702
|
+
const tiers = tiersByProduct[productName] || [];
|
|
1703
|
+
if ( !tiers.length ) {
|
|
1704
|
+
return 0;
|
|
1705
|
+
}
|
|
1706
|
+
for ( const t of tiers ) {
|
|
1707
|
+
const [ min, max ] = String( t.storeRange || '' ).split( '-' ).map( Number );
|
|
1708
|
+
if ( position >= min && position <= max ) {
|
|
1709
|
+
return Number( t.negotiatePrice ) || 0;
|
|
1686
1710
|
}
|
|
1687
1711
|
}
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
return product;
|
|
1692
|
-
} );
|
|
1693
|
-
|
|
1694
|
-
let stepPriceRecord = await basepricingService.findOne( { clientId: group.clientId } );
|
|
1695
|
-
let data = products;
|
|
1696
|
-
let pricing = stepPriceRecord.step;
|
|
1697
|
-
|
|
1698
|
-
const applyPricing = ( data, pricing ) => {
|
|
1699
|
-
let totalcount = 0;
|
|
1700
|
-
return data.map( ( item ) => {
|
|
1701
|
-
totalcount = totalcount + item.storeCount;
|
|
1712
|
+
// Beyond the last defined tier -> last tier's price.
|
|
1713
|
+
return Number( tiers[tiers.length - 1].negotiatePrice ) || 0;
|
|
1714
|
+
};
|
|
1702
1715
|
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
1716
|
+
// EXACT per-store pricing: assign each store a 1-based position within its
|
|
1717
|
+
// product (tier boundary counted in STORES), then charge tierPrice x that
|
|
1718
|
+
// store's EXACT billable units — cameras (perCamera) / zones (perZone) / 1
|
|
1719
|
+
// (perStore) — prorated by working days. Group lines by product + period +
|
|
1720
|
+
// tier price so each tier collapses into a single line item.
|
|
1721
|
+
const productPosition = {};
|
|
1722
|
+
const grouped = {};
|
|
1723
|
+
for ( const s of perStoreRows ) {
|
|
1724
|
+
const billingType = billingTypeMap[s.productName] || 'perStore';
|
|
1725
|
+
let units = 1;
|
|
1726
|
+
if ( s.productName === 'tangoZone' ) {
|
|
1727
|
+
if ( billingType === 'perZone' && s.zoneCount > 0 ) {
|
|
1728
|
+
units = s.zoneCount;
|
|
1729
|
+
} else if ( billingType === 'perCamera' && s.zoneCameraCount > 0 ) {
|
|
1730
|
+
units = s.zoneCameraCount;
|
|
1710
1731
|
}
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
negotiatePrice: item.price,
|
|
1715
|
-
perstorecost: ( ( item.price / currentMonthDays ) * item.workingdays ).toFixed( 2 ),
|
|
1716
|
-
};
|
|
1717
|
-
} );
|
|
1718
|
-
};
|
|
1719
|
-
console.log( '---->', data, pricing );
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
function processArray( array1, array2 ) {
|
|
1723
|
-
let updatedArray = [];
|
|
1724
|
-
|
|
1725
|
-
for ( let item of array1 ) {
|
|
1726
|
-
let remainingStores = item.storeCount;
|
|
1727
|
-
|
|
1728
|
-
for ( let range of array2 ) {
|
|
1729
|
-
let [ min, max ] = range.storeRange.split( '-' ).map( Number );
|
|
1730
|
-
|
|
1731
|
-
if ( remainingStores > 0 ) {
|
|
1732
|
-
let applicableStores = Math.min( remainingStores, max - min + 1 );
|
|
1733
|
-
updatedArray.push( {
|
|
1734
|
-
productName: item.productName,
|
|
1735
|
-
workingdays: item.workingdays,
|
|
1736
|
-
storeCount: applicableStores,
|
|
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,
|
|
1741
|
-
} );
|
|
1742
|
-
remainingStores -= applicableStores;
|
|
1743
|
-
}
|
|
1732
|
+
} else if ( s.productName === 'tangoTraffic' ) {
|
|
1733
|
+
if ( billingType === 'perCamera' && s.trafficCameraCount > 0 ) {
|
|
1734
|
+
units = s.trafficCameraCount;
|
|
1744
1735
|
}
|
|
1745
1736
|
}
|
|
1746
|
-
|
|
1737
|
+
productPosition[s.productName] = ( productPosition[s.productName] || 0 ) + 1;
|
|
1738
|
+
const price = tierPriceForPosition( s.productName, productPosition[s.productName] );
|
|
1739
|
+
const fullMonth = s.workingDays >= currentMonthDays;
|
|
1740
|
+
const amount = fullMonth ?
|
|
1741
|
+
Math.round( units * price * 100 ) / 100 :
|
|
1742
|
+
Math.round( ( units * ( price / currentMonthDays ) * s.workingDays ) * 100 ) / 100;
|
|
1743
|
+
const period = fullMonth ? 'fullMonth' : 'proRate';
|
|
1744
|
+
const key = `${s.productName}_${period}_${price}`;
|
|
1745
|
+
if ( !grouped[key] ) {
|
|
1746
|
+
grouped[key] = { productName: s.productName, period, price, totalAmount: 0, unitCount: 0 };
|
|
1747
|
+
}
|
|
1748
|
+
grouped[key].totalAmount += amount;
|
|
1749
|
+
grouped[key].unitCount += units;
|
|
1747
1750
|
}
|
|
1748
1751
|
|
|
1749
|
-
|
|
1750
|
-
console.log( '++++++++++++', resultarray );
|
|
1751
|
-
const result = applyPricing( resultarray, pricing );
|
|
1752
|
-
console.log( '***********', result );
|
|
1753
|
-
const groupedData = result.reduce( ( acc, item ) => {
|
|
1754
|
-
const { productName, period, runningCost } = item;
|
|
1755
|
-
const key = `${productName}_${period}_${item.storeCount}`;
|
|
1756
|
-
if ( !acc[key] ) {
|
|
1757
|
-
acc[key] = {
|
|
1758
|
-
productName,
|
|
1759
|
-
period,
|
|
1760
|
-
totalRunningCost: 0,
|
|
1761
|
-
count: 0,
|
|
1762
|
-
};
|
|
1763
|
-
}
|
|
1764
|
-
if ( period === 'proRate' ) {
|
|
1765
|
-
acc[key].totalRunningCost += parseFloat( runningCost );
|
|
1766
|
-
acc[key].count += item.storeCount;
|
|
1767
|
-
} else {
|
|
1768
|
-
acc[key].totalRunningCost = parseFloat( runningCost );
|
|
1769
|
-
acc[key].count = item.storeCount;
|
|
1770
|
-
}
|
|
1771
|
-
|
|
1772
|
-
return acc;
|
|
1773
|
-
}, {} );
|
|
1774
|
-
console.log( groupedData );
|
|
1775
|
-
// Calculating average running cost
|
|
1776
|
-
const finalresult = Object.values( groupedData ).map( ( grp ) => {
|
|
1752
|
+
const finalresult = Object.values( grouped ).map( ( grp ) => {
|
|
1777
1753
|
let description = '';
|
|
1778
1754
|
if ( grp.productName === 'tangoTraffic' ) {
|
|
1779
1755
|
description = 'Customer Footfall Analytics';
|
|
1780
1756
|
} else if ( grp.productName === 'tangoZone' ) {
|
|
1781
1757
|
description = 'Product category/section analytics';
|
|
1782
|
-
} else {
|
|
1783
|
-
description = '';
|
|
1784
1758
|
}
|
|
1759
|
+
const amount = Math.round( grp.totalAmount * 100 ) / 100;
|
|
1785
1760
|
return {
|
|
1786
1761
|
productName: grp.productName,
|
|
1787
1762
|
period: grp.period,
|
|
1788
|
-
|
|
1763
|
+
// Quantity = billable units (cameras for perCamera, zones for perZone,
|
|
1764
|
+
// stores otherwise). For full-month lines price = tier price; for prorated
|
|
1765
|
+
// lines it's the effective per-unit cost (amount / units).
|
|
1766
|
+
storeCount: Math.round( grp.unitCount * 100 ) / 100,
|
|
1789
1767
|
description: description,
|
|
1790
1768
|
HsnNumber: '998314',
|
|
1791
|
-
amount:
|
|
1769
|
+
amount: amount,
|
|
1792
1770
|
month: dayjs().format( 'MMM YYYY' ),
|
|
1793
|
-
price:
|
|
1771
|
+
price: grp.unitCount ? ( amount / grp.unitCount ).toFixed( 2 ) : '0.00',
|
|
1794
1772
|
};
|
|
1795
1773
|
} );
|
|
1796
1774
|
|
|
1797
|
-
// Combine overallStore and eachStore products
|
|
1775
|
+
// Combine overallStore (per-store tiered) and eachStore products.
|
|
1798
1776
|
return [ ...finalresult, ...eachStoreProducts ];
|
|
1799
1777
|
}
|
|
1800
1778
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
|
|
2
2
|
/* eslint-disable new-cap */
|
|
3
|
-
import { logger, download, sendEmailWithSES, insertOpenSearchData, getOpenSearchData, updateOpenSearchData, getOpenSearchById } from 'tango-app-api-middleware';
|
|
3
|
+
import { logger, download, sendEmailWithSES, insertOpenSearchData, getOpenSearchData, updateOpenSearchData, getOpenSearchById, fileUpload, customSignedUrl } from 'tango-app-api-middleware';
|
|
4
4
|
import * as paymentService from '../services/clientPayment.services.js';
|
|
5
5
|
import * as basePriceService from '../services/basePrice.service.js';
|
|
6
6
|
import * as storeService from '../services/store.service.js';
|
|
@@ -2246,7 +2246,7 @@ export const invoiceList = async ( req, res ) => {
|
|
|
2246
2246
|
|
|
2247
2247
|
export const priceList = async ( req, res ) => {
|
|
2248
2248
|
try {
|
|
2249
|
-
let pricingDetails = await basePricingService.findOne( { clientId: { $exists: true }, clientId: req.body.clientId }, { standard: 1, step: 1 } );
|
|
2249
|
+
let pricingDetails = await basePricingService.findOne( { clientId: { $exists: true }, clientId: req.body.clientId }, { standard: 1, step: 1, oneTimeFeePerStore: 1 } );
|
|
2250
2250
|
if ( !pricingDetails ) {
|
|
2251
2251
|
return res.sendError( 'no data found', 204 );
|
|
2252
2252
|
}
|
|
@@ -2301,19 +2301,25 @@ export const priceList = async ( req, res ) => {
|
|
|
2301
2301
|
product.showImg = true;
|
|
2302
2302
|
product.showEditDelete = true;
|
|
2303
2303
|
}
|
|
2304
|
-
|
|
2304
|
+
// Last tier absorbs whatever stores remain after the earlier tiers.
|
|
2305
|
+
// Never let it go negative when the actual store count is smaller
|
|
2306
|
+
// than the defined tier ranges (e.g. 83 stores across 1-50 / 51-100
|
|
2307
|
+
// left the last tier at 83 - 100 = -17).
|
|
2308
|
+
product.storeCount = Math.max( item.storeCount, 0 );
|
|
2305
2309
|
product.lastIndex = true;
|
|
2306
|
-
} else if ( index == 0 ) {
|
|
2307
|
-
product.storeCount = 100;
|
|
2308
|
-
item.storeCount = item.storeCount - 100;
|
|
2309
2310
|
} else {
|
|
2310
|
-
|
|
2311
|
-
|
|
2311
|
+
// Non-last tier: use its own range size (end - start + 1), but cap at
|
|
2312
|
+
// the stores still remaining so a tier can't claim more stores than
|
|
2313
|
+
// exist. The first tier previously hardcoded 100, which overcounted
|
|
2314
|
+
// (and pushed the last tier negative) whenever the range wasn't 1-100.
|
|
2315
|
+
product.showImg = index != 0;
|
|
2316
|
+
let rangeArray = String( product.storeRange || '' ).split( '-' );
|
|
2312
2317
|
let startNumber = parseInt( rangeArray[0] );
|
|
2313
2318
|
let endNumber = parseInt( rangeArray[1] );
|
|
2314
|
-
let diff = endNumber - startNumber + 1;
|
|
2315
|
-
|
|
2316
|
-
|
|
2319
|
+
let diff = ( endNumber - startNumber + 1 ) || 0;
|
|
2320
|
+
let assigned = Math.max( Math.min( diff, item.storeCount ), 0 );
|
|
2321
|
+
product.storeCount = assigned;
|
|
2322
|
+
item.storeCount = item.storeCount - assigned;
|
|
2317
2323
|
}
|
|
2318
2324
|
}
|
|
2319
2325
|
// let discountPrice = product.basePrice * ( product.discountPercentage / 100 );
|
|
@@ -2335,6 +2341,7 @@ export const priceList = async ( req, res ) => {
|
|
|
2335
2341
|
let finalValue = parseFloat( discountTotalPrice ) + gstAmount;
|
|
2336
2342
|
let result = {
|
|
2337
2343
|
product: data,
|
|
2344
|
+
oneTimeFeePerStore: pricingDetails.oneTimeFeePerStore != null ? pricingDetails.oneTimeFeePerStore : null,
|
|
2338
2345
|
totalActualPrice: originalTotalPrice,
|
|
2339
2346
|
totalNegotiatePrice: discountTotalPrice.toFixed( 2 ),
|
|
2340
2347
|
actualPrice: totalProductPrice,
|
|
@@ -2484,6 +2491,11 @@ export const pricingListUpdate = async ( req, res ) => {
|
|
|
2484
2491
|
} else {
|
|
2485
2492
|
getPriceInfo.step = req.body.products;
|
|
2486
2493
|
}
|
|
2494
|
+
// Brand-level one-time fee per store. Only overwrite when the request
|
|
2495
|
+
// actually carries a value so other save paths don't wipe it.
|
|
2496
|
+
if ( req.body.oneTimeFeePerStore != null && req.body.oneTimeFeePerStore !== '' ) {
|
|
2497
|
+
getPriceInfo.oneTimeFeePerStore = Number( req.body.oneTimeFeePerStore ) || 0;
|
|
2498
|
+
}
|
|
2487
2499
|
getPriceInfo.save().then( async () => {
|
|
2488
2500
|
let clientDetails = await paymentService.findOne( { clientId: req.body.clientId }, { priceType: 1, paymentInvoice: 1, planDetails: 1 } );
|
|
2489
2501
|
clientDetails.priceType = req.body.type;
|
|
@@ -4130,3 +4142,96 @@ export async function createDefaultbillings( req, res ) {
|
|
|
4130
4142
|
}
|
|
4131
4143
|
}
|
|
4132
4144
|
|
|
4145
|
+
// ---------------------------------------------------------------------------
|
|
4146
|
+
// Brand documents (Plans & Subscription > Documents accordion).
|
|
4147
|
+
// Upload a single PDF for a client, store it in the assets bucket under
|
|
4148
|
+
// <clientId>/documents/, and record { documentName, path } on the client's
|
|
4149
|
+
// additionalDocuments array. List returns the docs with signed URLs.
|
|
4150
|
+
// ---------------------------------------------------------------------------
|
|
4151
|
+
export async function uploadClientDocument( req, res ) {
|
|
4152
|
+
try {
|
|
4153
|
+
const clientId = String( req.body?.clientId || '' );
|
|
4154
|
+
const documentName = String( req.body?.documentName || '' ).trim();
|
|
4155
|
+
const expiryDateRaw = req.body?.expiryDate;
|
|
4156
|
+
const file = req.file; // multer single('file')
|
|
4157
|
+
|
|
4158
|
+
if ( !clientId ) {
|
|
4159
|
+
return res.sendError( 'clientId is required', 400 );
|
|
4160
|
+
}
|
|
4161
|
+
if ( !documentName ) {
|
|
4162
|
+
return res.sendError( 'documentName is required', 400 );
|
|
4163
|
+
}
|
|
4164
|
+
if ( !file ) {
|
|
4165
|
+
return res.sendError( 'file is required', 400 );
|
|
4166
|
+
}
|
|
4167
|
+
// PDF only.
|
|
4168
|
+
if ( file.mimetype !== 'application/pdf' ) {
|
|
4169
|
+
return res.sendError( 'Only PDF files are allowed', 400 );
|
|
4170
|
+
}
|
|
4171
|
+
|
|
4172
|
+
const client = await paymentService.findOneClient( { clientId }, { clientId: 1 } );
|
|
4173
|
+
if ( !client ) {
|
|
4174
|
+
return res.sendError( 'Client not found', 404 );
|
|
4175
|
+
}
|
|
4176
|
+
|
|
4177
|
+
const bucket = JSON.parse( process.env.BUCKET );
|
|
4178
|
+
// Unique-ish file name so re-uploads with the same display name don't clash.
|
|
4179
|
+
const safeName = documentName.replace( /[^a-zA-Z0-9-_]/g, '_' );
|
|
4180
|
+
const fileName = `${safeName}_${Date.now()}.pdf`;
|
|
4181
|
+
const key = `${clientId}/documents/`;
|
|
4182
|
+
|
|
4183
|
+
await fileUpload( {
|
|
4184
|
+
Bucket: bucket.assets,
|
|
4185
|
+
Key: key,
|
|
4186
|
+
fileName: fileName,
|
|
4187
|
+
ContentType: file.mimetype,
|
|
4188
|
+
body: file.buffer,
|
|
4189
|
+
} );
|
|
4190
|
+
|
|
4191
|
+
const storedPath = `${key}${fileName}`;
|
|
4192
|
+
const expiryDate = expiryDateRaw ? new Date( expiryDateRaw ) : null;
|
|
4193
|
+
await paymentService.pushAdditionalDocument( { clientId }, {
|
|
4194
|
+
documentName: documentName,
|
|
4195
|
+
path: storedPath,
|
|
4196
|
+
expiryDate: ( expiryDate && !isNaN( expiryDate.getTime() ) ) ? expiryDate : null,
|
|
4197
|
+
uploadedAt: new Date(),
|
|
4198
|
+
} );
|
|
4199
|
+
|
|
4200
|
+
return res.sendSuccess( { documentName, path: storedPath, expiryDate } );
|
|
4201
|
+
} catch ( error ) {
|
|
4202
|
+
logger.error( { error: error, function: 'uploadClientDocument' } );
|
|
4203
|
+
return res.sendError( error, 500 );
|
|
4204
|
+
}
|
|
4205
|
+
}
|
|
4206
|
+
|
|
4207
|
+
export async function getClientDocuments( req, res ) {
|
|
4208
|
+
try {
|
|
4209
|
+
const clientId = String( req.query?.clientId || req.params?.clientId || '' );
|
|
4210
|
+
if ( !clientId ) {
|
|
4211
|
+
return res.sendError( 'clientId is required', 400 );
|
|
4212
|
+
}
|
|
4213
|
+
const client = await paymentService.findOneClient( { clientId }, { additionalDocuments: 1 } );
|
|
4214
|
+
const bucket = JSON.parse( process.env.BUCKET );
|
|
4215
|
+
const docs = await Promise.all( ( client?.additionalDocuments || [] ).map( async ( d ) => {
|
|
4216
|
+
let url = '';
|
|
4217
|
+
try {
|
|
4218
|
+
url = await customSignedUrl( { Bucket: bucket.assets, file_path: d.path }, 8 );
|
|
4219
|
+
} catch ( e ) {
|
|
4220
|
+
url = '';
|
|
4221
|
+
}
|
|
4222
|
+
return {
|
|
4223
|
+
_id: d._id,
|
|
4224
|
+
documentName: d.documentName,
|
|
4225
|
+
path: d.path,
|
|
4226
|
+
expiryDate: d.expiryDate,
|
|
4227
|
+
uploadedAt: d.uploadedAt,
|
|
4228
|
+
url,
|
|
4229
|
+
};
|
|
4230
|
+
} ) );
|
|
4231
|
+
return res.sendSuccess( { documents: docs } );
|
|
4232
|
+
} catch ( error ) {
|
|
4233
|
+
logger.error( { error: error, function: 'getClientDocuments' } );
|
|
4234
|
+
return res.sendError( error, 500 );
|
|
4235
|
+
}
|
|
4236
|
+
}
|
|
4237
|
+
|
|
@@ -153,6 +153,7 @@ export const validatePriceSchema = joi.object( {
|
|
|
153
153
|
type: joi.string().optional(),
|
|
154
154
|
clientId: joi.string().required(),
|
|
155
155
|
products: joi.array().optional(),
|
|
156
|
+
oneTimeFeePerStore: joi.number().optional().allow( null, '' ),
|
|
156
157
|
pricing: joi.array().items( joi.object( {
|
|
157
158
|
productName: joi.string().required(),
|
|
158
159
|
negotiatePrice: joi.number().required(),
|
|
@@ -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 );
|
|
@@ -1,11 +1,16 @@
|
|
|
1
1
|
|
|
2
2
|
import express from 'express';
|
|
3
|
+
import multer from 'multer';
|
|
3
4
|
import * as paymentController from '../controllers/paymentSubscription.controllers.js';
|
|
4
5
|
import { validate, isAllowedSessionHandler, accessVerification } from 'tango-app-api-middleware';
|
|
5
6
|
import * as validationDtos from '../dtos/validation.dtos.js';
|
|
6
7
|
import { validateClient } from '../utils/validations/client.validation.js';
|
|
7
8
|
export const paymentSubscriptionRouter = express.Router();
|
|
8
9
|
|
|
10
|
+
// PDF document upload (Plans & Subscription > Documents). In-memory storage,
|
|
11
|
+
// 10MB cap; the controller streams the buffer to the assets S3 bucket.
|
|
12
|
+
const documentUpload = multer( { storage: multer.memoryStorage(), limits: { fileSize: 10 * 1024 * 1024 } } );
|
|
13
|
+
|
|
9
14
|
paymentSubscriptionRouter.post( '/addBilling', isAllowedSessionHandler, accessVerification( {
|
|
10
15
|
userType: [ 'tango', 'client' ], access: [
|
|
11
16
|
{ featureName: 'Global', name: 'Subscription', permissions: [ 'isAdd' ] },
|
|
@@ -146,4 +151,8 @@ paymentSubscriptionRouter.put( '/pushNotification/update/:notificationId', isAll
|
|
|
146
151
|
paymentSubscriptionRouter.post( '/updateRemind/:notificationId', isAllowedSessionHandler, validate( validationDtos.validateId ), paymentController.updateRemind );
|
|
147
152
|
paymentSubscriptionRouter.post( '/createDefaultbillings', paymentController.createDefaultbillings );
|
|
148
153
|
|
|
154
|
+
// Brand documents (Plans & Subscription > Documents accordion).
|
|
155
|
+
paymentSubscriptionRouter.post( '/client-document/upload', isAllowedSessionHandler, accessVerification( { userType: [ 'tango' ], access: [ { featureName: 'Global', name: 'Subscription', permissions: [ 'isEdit' ] } ] } ), documentUpload.single( 'file' ), paymentController.uploadClientDocument );
|
|
156
|
+
paymentSubscriptionRouter.get( '/client-document/list', isAllowedSessionHandler, accessVerification( { userType: [ 'tango' ], access: [ { featureName: 'Global', name: 'Subscription', permissions: [] } ] } ), paymentController.getClientDocuments );
|
|
157
|
+
|
|
149
158
|
|
|
@@ -16,6 +16,11 @@ export const updateOne = ( query = {}, record = {} ) => {
|
|
|
16
16
|
return model.clientModel.updateOne( query, { $set: record } );
|
|
17
17
|
};
|
|
18
18
|
|
|
19
|
+
// Push a document into the client's additionalDocuments array.
|
|
20
|
+
export const pushAdditionalDocument = ( query = {}, document = {} ) => {
|
|
21
|
+
return model.clientModel.updateOne( query, { $push: { additionalDocuments: document } } );
|
|
22
|
+
};
|
|
23
|
+
|
|
19
24
|
export const aggregate = ( query = [] ) => {
|
|
20
25
|
return model.clientModel.aggregate( query );
|
|
21
26
|
};
|
|
@@ -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
|
+
};
|