tango-app-api-payment-subscription 3.5.4 → 3.5.6
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/scripts/migrate-billing-prorata-pricing.js +66 -0
- package/src/controllers/bankTransaction.controller.js +617 -0
- package/src/controllers/billing.controllers.js +142 -1
- package/src/controllers/brandsBilling.controller.js +290 -0
- package/src/controllers/estimate.controller.js +317 -0
- package/src/controllers/invoice.controller.js +392 -290
- package/src/controllers/paymentSubscription.controllers.js +75 -10
- package/src/dtos/validation.dtos.js +8 -0
- package/src/hbs/estimatePdf.hbs +125 -0
- package/src/hbs/invoicePdf.hbs +27 -0
- package/src/routes/billing.routes.js +2 -1
- package/src/routes/brandsBilling.routes.js +2 -1
- package/src/routes/invoice.routes.js +20 -1
- package/src/services/bankTransaction.service.js +21 -0
- package/src/services/estimate.service.js +25 -0
- package/src/services/paymentAccount.service.js +5 -0
|
@@ -4,6 +4,7 @@ import * as invoice from '../services/invoice.service.js';
|
|
|
4
4
|
import { aggregatebilling, countDocuments, create, deleteOne, find, findOne, updateMany, updateOne } from '../services/billing.service.js';
|
|
5
5
|
import * as invoiceService from '../services/invoice.service.js';
|
|
6
6
|
import mongoose from 'mongoose';
|
|
7
|
+
import axios from 'axios';
|
|
7
8
|
import dayjs from 'dayjs';
|
|
8
9
|
import { leadGet } from '../services/lead.service.js';
|
|
9
10
|
import { findOneClient } from '../services/clientPayment.services.js';
|
|
@@ -577,7 +578,6 @@ export const getInvoices = async ( req, res ) => {
|
|
|
577
578
|
filterStartDate = new Date( dayjs().subtract( 3, 'month' ).startOf( 'month' ).format( 'YYYY-MM-DD' ) );
|
|
578
579
|
filterEndDate = new Date( dayjs().endOf( 'month' ).format( 'YYYY-MM-DD' ) );
|
|
579
580
|
}
|
|
580
|
-
|
|
581
581
|
if ( req.body?.filter && !req.body?.searchValue ) {
|
|
582
582
|
matchStage.$match['$and'] = [
|
|
583
583
|
{ billingDate: { $gte: filterStartDate } },
|
|
@@ -868,4 +868,145 @@ export async function getClientProducts( req, res ) {
|
|
|
868
868
|
}
|
|
869
869
|
}
|
|
870
870
|
|
|
871
|
+
// GSTIN address lookup via Sheet GST Check
|
|
872
|
+
// (https://sheet.gstincheck.co.in). Returns the registered company name
|
|
873
|
+
// + principal place of business address so the UI can auto-fill the
|
|
874
|
+
// billing-group form.
|
|
875
|
+
//
|
|
876
|
+
// Requires GST_LOOKUP_API_KEY in the API environment. The key is embedded
|
|
877
|
+
// in the provider's URL path (their auth scheme); we never log it.
|
|
878
|
+
//
|
|
879
|
+
// Caller validates GSTIN format (15 chars, alphanumeric). Server-side we
|
|
880
|
+
// re-check before forwarding to the provider.
|
|
881
|
+
export async function gstinLookup( req, res ) {
|
|
882
|
+
try {
|
|
883
|
+
const gstin = String( req.params.gstin || '' ).trim().toUpperCase();
|
|
884
|
+
if ( !/^[0-9A-Z]{15}$/.test( gstin ) ) {
|
|
885
|
+
return res.sendError( 'Invalid GSTIN format', 400 );
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
// GSTIN state-code prefix → State + Place of Supply (matches the
|
|
889
|
+
// table the frontend already maintains).
|
|
890
|
+
const stateMap = {
|
|
891
|
+
'01': 'Jammu and Kashmir', '02': 'Himachal Pradesh', '03': 'Punjab',
|
|
892
|
+
'04': 'Chandigarh', '05': 'Uttarakhand', '06': 'Haryana', '07': 'Delhi',
|
|
893
|
+
'08': 'Rajasthan', '09': 'Uttar Pradesh', '10': 'Bihar', '11': 'Sikkim',
|
|
894
|
+
'12': 'Arunachal Pradesh', '13': 'Nagaland', '14': 'Manipur',
|
|
895
|
+
'15': 'Mizoram', '16': 'Tripura', '17': 'Meghalaya', '18': 'Assam',
|
|
896
|
+
'19': 'West Bengal', '20': 'Jharkhand', '21': 'Odisha',
|
|
897
|
+
'22': 'Chhattisgarh', '23': 'Madhya Pradesh', '24': 'Gujarat',
|
|
898
|
+
'25': 'Daman and Diu', '27': 'Maharashtra', '29': 'Karnataka',
|
|
899
|
+
'30': 'Goa', '32': 'Kerala', '33': 'Tamil Nadu', '34': 'Puducherry',
|
|
900
|
+
'36': 'Telangana', '37': 'Andhra Pradesh',
|
|
901
|
+
};
|
|
902
|
+
const code = gstin.substring( 0, 2 );
|
|
903
|
+
const stateName = stateMap[code];
|
|
904
|
+
if ( !stateName ) {
|
|
905
|
+
return res.sendError( 'Unknown state code in GSTIN', 404 );
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
// Real lookup via Sheet GST Check (https://gstincheck.co.in/).
|
|
909
|
+
// Endpoint: GET https://sheet.gstincheck.co.in/check/{API_KEY}/{GSTIN}
|
|
910
|
+
// Auth: API key embedded in the URL path (no header). Set
|
|
911
|
+
// GST_LOOKUP_API_KEY in the API's environment.
|
|
912
|
+
const apiKey = process.env.GST_LOOKUP_API_KEY;
|
|
913
|
+
console.log( '🚀 ~ gstinLookup ~ apiKey:', apiKey );
|
|
914
|
+
if ( !apiKey ) {
|
|
915
|
+
// Fail explicitly rather than silently fall back — that way a missing
|
|
916
|
+
// key shows up immediately instead of mock data leaking into prod.
|
|
917
|
+
logger.error( { function: 'gstinLookup', message: 'GST_LOOKUP_API_KEY not set' } );
|
|
918
|
+
return res.sendError( 'GST lookup is not configured on the server', 500 );
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
let providerResponse;
|
|
922
|
+
try {
|
|
923
|
+
providerResponse = await axios.get(
|
|
924
|
+
`https://sheet.gstincheck.co.in/check/${encodeURIComponent( apiKey )}/${encodeURIComponent( gstin )}`,
|
|
925
|
+
{ timeout: 8000 },
|
|
926
|
+
);
|
|
927
|
+
} catch ( axiosErr ) {
|
|
928
|
+
// Distinguish provider-down vs other errors so the client can show a
|
|
929
|
+
// useful message. 4xx from the provider is treated as a real error;
|
|
930
|
+
// network errors fall through to a generic 502.
|
|
931
|
+
const status = axiosErr?.response?.status;
|
|
932
|
+
logger.error( { error: axiosErr?.message, status, function: 'gstinLookup.provider', gstin } );
|
|
933
|
+
if ( status === 404 ) {
|
|
934
|
+
return res.sendError( 'GSTIN not found', 404 );
|
|
935
|
+
}
|
|
936
|
+
return res.sendError( 'GST lookup service unavailable', 502 );
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
const body = providerResponse?.data || {};
|
|
940
|
+
if ( !body.flag || !body.data ) {
|
|
941
|
+
// Provider reached but couldn't resolve this GSTIN.
|
|
942
|
+
return res.sendError( 'GSTIN not found', 404 );
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
// Map provider's response shape into the contract the frontend expects.
|
|
946
|
+
// Provider uses Indian-government short names (lgnm, tradeNam, pradr,
|
|
947
|
+
// sts, etc.); we normalize to our flat structure.
|
|
948
|
+
const src = body.data;
|
|
949
|
+
// TEMP: full raw response logged so we can confirm field names per
|
|
950
|
+
// GSTIN — remove once the mapping is verified across a few real cases.
|
|
951
|
+
logger.error( { function: 'gstinLookup.raw', gstin, providerData: src } );
|
|
952
|
+
|
|
953
|
+
const principal = src.pradr || src.pradr1 || {};
|
|
954
|
+
// The provider has used both `addr` (newer) and a flatter form
|
|
955
|
+
// (older). Coalesce — `addr` if present, otherwise the principal
|
|
956
|
+
// itself contains the address fields.
|
|
957
|
+
const addr = principal.addr || principal || {};
|
|
958
|
+
|
|
959
|
+
// If the provider returned a fully-formed display address string, use
|
|
960
|
+
// it directly for addressLineOne — that's the canonical comma-joined
|
|
961
|
+
// address GSTN itself displays. Otherwise build it from the
|
|
962
|
+
// individual fields. This avoids re-joining in a different order than
|
|
963
|
+
// the registry intended.
|
|
964
|
+
const fullAddr = ( principal.adr || principal.address || src.adr || '' ).trim();
|
|
965
|
+
|
|
966
|
+
let addressLineOne;
|
|
967
|
+
let addressLineTwo;
|
|
968
|
+
if ( fullAddr ) {
|
|
969
|
+
// The registry format is `bno, bnm, flno, st, loc, lndmrk, city,
|
|
970
|
+
// dst, stcd, pncd`. Split on commas, take the first half for line 1
|
|
971
|
+
// and the rest (minus city/state/pincode tail which goes to the
|
|
972
|
+
// separate fields) for line 2. Heuristic: split into two halves.
|
|
973
|
+
const parts = fullAddr.split( ',' ).map( ( s ) => s.trim() ).filter( Boolean );
|
|
974
|
+
const half = Math.ceil( parts.length / 2 );
|
|
975
|
+
addressLineOne = parts.slice( 0, half ).join( ', ' );
|
|
976
|
+
addressLineTwo = parts.slice( half ).join( ', ' );
|
|
977
|
+
} else {
|
|
978
|
+
const line1Parts = [ addr.bno, addr.bnm, addr.flno, addr.st ].filter( ( p ) => p && String( p ).trim() );
|
|
979
|
+
const line2Parts = [ addr.loc, addr.lndmrk, addr.dst ].filter( ( p ) => p && String( p ).trim() );
|
|
980
|
+
addressLineOne = line1Parts.join( ', ' );
|
|
981
|
+
addressLineTwo = line2Parts.join( ', ' );
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
// City: prefer explicit `city` / `dst`. Some records put the city
|
|
985
|
+
// name in `loc` and the colony / sub-area in `dst` — prefer city, but
|
|
986
|
+
// fall back to dst then loc when missing.
|
|
987
|
+
const cityValue = addr.city || addr.dst || addr.loc || '';
|
|
988
|
+
|
|
989
|
+
const data = {
|
|
990
|
+
gstin: src.gstin || gstin,
|
|
991
|
+
legalName: src.lgnm || src.legalName || '',
|
|
992
|
+
tradeName: src.tradeNam || src.tradeName || '',
|
|
993
|
+
addressLineOne,
|
|
994
|
+
addressLineTwo,
|
|
995
|
+
city: cityValue,
|
|
996
|
+
// Prefer provider's state name; fall back to our state-code map so a
|
|
997
|
+
// missing field doesn't blank out the Place of Supply.
|
|
998
|
+
state: addr.stcd || addr.state || stateName,
|
|
999
|
+
country: 'India',
|
|
1000
|
+
pinCode: addr.pncd ? String( addr.pncd ) : ( addr.pincode ? String( addr.pincode ) : '' ),
|
|
1001
|
+
placeOfSupply: `${addr.stcd || addr.state || stateName} (${code})`,
|
|
1002
|
+
status: src.sts || '',
|
|
1003
|
+
};
|
|
1004
|
+
|
|
1005
|
+
return res.sendSuccess( data );
|
|
1006
|
+
} catch ( error ) {
|
|
1007
|
+
logger.error( { error: error, function: 'gstinLookup' } );
|
|
1008
|
+
return res.sendError( error, 500 );
|
|
1009
|
+
}
|
|
1010
|
+
}
|
|
1011
|
+
|
|
871
1012
|
|
|
@@ -3,6 +3,8 @@ import * as invoiceService from '../services/invoice.service.js';
|
|
|
3
3
|
import * as billingService from '../services/billing.service.js';
|
|
4
4
|
import * as dailyPriceService from '../services/dailyPrice.service.js';
|
|
5
5
|
import * as storeService from '../services/store.service.js';
|
|
6
|
+
import * as assignedStoreService from '../services/assignedStore.service.js';
|
|
7
|
+
import * as basePriceService from '../services/basePrice.service.js';
|
|
6
8
|
import dayjs from 'dayjs';
|
|
7
9
|
import { logger, checkFileExist, signedUrl, download, insertOpenSearchData } from 'tango-app-api-middleware';
|
|
8
10
|
import * as XLSX from 'xlsx';
|
|
@@ -339,6 +341,7 @@ export async function brandInvoiceList( req, res ) {
|
|
|
339
341
|
totalAmount: 1,
|
|
340
342
|
status: 1,
|
|
341
343
|
paymentStatus: 1,
|
|
344
|
+
paidAmount: { $ifNull: [ '$paidAmount', 0 ] },
|
|
342
345
|
clientId: 1,
|
|
343
346
|
currency: 1,
|
|
344
347
|
dueDate: 1,
|
|
@@ -1289,3 +1292,290 @@ export async function bulkUpdateBillingGroups( req, res ) {
|
|
|
1289
1292
|
return res.sendError( error, 500 );
|
|
1290
1293
|
}
|
|
1291
1294
|
}
|
|
1295
|
+
|
|
1296
|
+
// ---------------------------------------------------------------------------
|
|
1297
|
+
// Billing Summary (landing tab): one row per client — monthly store counts,
|
|
1298
|
+
// revenue, price-per-store and installation fee all from the invoice
|
|
1299
|
+
// collection (the billed `stores` count per invoice), products + status from
|
|
1300
|
+
// clients, CSM from userAssignedStore. Window: last 5 calendar months
|
|
1301
|
+
// including the current. Filtering/sorting happen client-side (the dataset
|
|
1302
|
+
// is one row per client).
|
|
1303
|
+
// ---------------------------------------------------------------------------
|
|
1304
|
+
// Today's USD->INR rate for dollar-priced clients. Live rate (cached 6h)
|
|
1305
|
+
// with an env override (USD_INR_RATE) and a last-known/static fallback so
|
|
1306
|
+
// the summary never fails because a rate API is down.
|
|
1307
|
+
let usdRateCache = { rate: null, at: 0 };
|
|
1308
|
+
async function getUsdInrRate() {
|
|
1309
|
+
const override = Number( process.env.USD_INR_RATE );
|
|
1310
|
+
if ( override > 0 ) {
|
|
1311
|
+
return override;
|
|
1312
|
+
}
|
|
1313
|
+
if ( usdRateCache.rate && ( Date.now() - usdRateCache.at ) < 6 * 60 * 60 * 1000 ) {
|
|
1314
|
+
return usdRateCache.rate;
|
|
1315
|
+
}
|
|
1316
|
+
try {
|
|
1317
|
+
const resp = await fetch( 'https://open.er-api.com/v6/latest/USD', { signal: AbortSignal.timeout( 4000 ) } );
|
|
1318
|
+
const body = await resp.json();
|
|
1319
|
+
const rate = Number( body?.rates?.INR );
|
|
1320
|
+
if ( rate > 0 ) {
|
|
1321
|
+
usdRateCache = { rate, at: Date.now() };
|
|
1322
|
+
return rate;
|
|
1323
|
+
}
|
|
1324
|
+
} catch ( err ) {
|
|
1325
|
+
logger.error( { error: err, function: 'getUsdInrRate' } );
|
|
1326
|
+
}
|
|
1327
|
+
return usdRateCache.rate || 83.33;
|
|
1328
|
+
}
|
|
1329
|
+
|
|
1330
|
+
export async function billingSummary( req, res ) {
|
|
1331
|
+
try {
|
|
1332
|
+
const now = dayjs();
|
|
1333
|
+
const months = [];
|
|
1334
|
+
for ( let i = 4; i >= 0; i-- ) {
|
|
1335
|
+
const m = now.subtract( i, 'month' );
|
|
1336
|
+
months.push( { key: m.format( 'YYYY-MM' ), label: m.format( 'MMM-YY' ) } );
|
|
1337
|
+
}
|
|
1338
|
+
const windowStart = new Date( now.subtract( 4, 'month' ).startOf( 'month' ).toISOString() );
|
|
1339
|
+
|
|
1340
|
+
// Revenue (excl. GST), billed stores and installation fees per client per
|
|
1341
|
+
// month. billingDate is a string on some legacy rows — coerce first.
|
|
1342
|
+
const invoices = await invoiceService.aggregate( [
|
|
1343
|
+
{ $addFields: { billingDateD: { $cond: [
|
|
1344
|
+
{ $eq: [ { $type: '$billingDate' }, 'date' ] },
|
|
1345
|
+
'$billingDate',
|
|
1346
|
+
{ $toDate: '$billingDate' },
|
|
1347
|
+
] } } },
|
|
1348
|
+
{ $match: { billingDateD: { $gte: windowStart } } },
|
|
1349
|
+
{ $project: {
|
|
1350
|
+
clientId: 1,
|
|
1351
|
+
companyName: 1,
|
|
1352
|
+
amount: { $ifNull: [ '$amount', 0 ] },
|
|
1353
|
+
stores: { $ifNull: [ '$stores', 0 ] },
|
|
1354
|
+
isDollar: { $eq: [ '$currency', 'dollar' ] },
|
|
1355
|
+
ym: { $dateToString: { format: '%Y-%m', date: '$billingDateD' } },
|
|
1356
|
+
installation: { $sum: { $map: {
|
|
1357
|
+
input: { $filter: {
|
|
1358
|
+
input: { $ifNull: [ '$products', [] ] },
|
|
1359
|
+
cond: { $eq: [ '$$this.productName', 'installationFee' ] },
|
|
1360
|
+
} },
|
|
1361
|
+
in: { $ifNull: [ '$$this.amount', 0 ] },
|
|
1362
|
+
} } },
|
|
1363
|
+
} },
|
|
1364
|
+
// Dollar invoices are summed separately so they can be converted to
|
|
1365
|
+
// INR at today's rate when the months are merged below.
|
|
1366
|
+
{ $group: {
|
|
1367
|
+
_id: { c: '$clientId', ym: '$ym' },
|
|
1368
|
+
revenueInr: { $sum: { $cond: [ '$isDollar', 0, '$amount' ] } },
|
|
1369
|
+
revenueUsd: { $sum: { $cond: [ '$isDollar', '$amount', 0 ] } },
|
|
1370
|
+
stores: { $sum: '$stores' },
|
|
1371
|
+
installationInr: { $sum: { $cond: [ '$isDollar', 0, '$installation' ] } },
|
|
1372
|
+
installationUsd: { $sum: { $cond: [ '$isDollar', '$installation', 0 ] } },
|
|
1373
|
+
companyName: { $last: '$companyName' },
|
|
1374
|
+
} },
|
|
1375
|
+
] );
|
|
1376
|
+
|
|
1377
|
+
const clients = await clientService.find( {}, {
|
|
1378
|
+
'clientId': 1,
|
|
1379
|
+
'clientName': 1,
|
|
1380
|
+
'planDetails.product.productName': 1,
|
|
1381
|
+
'planDetails.product.status': 1,
|
|
1382
|
+
'paymentInvoice.currencyType': 1,
|
|
1383
|
+
} );
|
|
1384
|
+
const clientById = new Map( clients.map( ( c ) => [ String( c.clientId ), c ] ) );
|
|
1385
|
+
|
|
1386
|
+
// Per-client CSM (userAssignedStore, tangoUserType 'csm'); display the
|
|
1387
|
+
// email's local part since the collection carries no display name.
|
|
1388
|
+
const usdRate = await getUsdInrRate();
|
|
1389
|
+
|
|
1390
|
+
// Current month's store count comes from dailyPricing (latest reading
|
|
1391
|
+
// this month) — invoices for the running month usually don't exist yet.
|
|
1392
|
+
const curMonthStart = new Date( now.startOf( 'month' ).toISOString() );
|
|
1393
|
+
const latestDp = await dailyPriceService.aggregate( [
|
|
1394
|
+
{ $match: { dateISO: { $gte: curMonthStart } } },
|
|
1395
|
+
{ $project: { clientId: 1, activeStores: 1, dateISO: 1 } },
|
|
1396
|
+
{ $sort: { dateISO: 1 } },
|
|
1397
|
+
{ $group: { _id: '$clientId', stores: { $last: '$activeStores' } } },
|
|
1398
|
+
] );
|
|
1399
|
+
const curStoresByClient = new Map( latestDp.map( ( d ) => [ String( d._id ), d.stores || 0 ] ) );
|
|
1400
|
+
|
|
1401
|
+
// Negotiated price per store from the basepricing collection — standard
|
|
1402
|
+
// rows sum across products; step rows are resolved by store-count range.
|
|
1403
|
+
const pricingDocs = await basePriceService.find(
|
|
1404
|
+
{ clientId: { $exists: true, $nin: [ '', null ] } },
|
|
1405
|
+
{ clientId: 1, standard: 1, step: 1 },
|
|
1406
|
+
);
|
|
1407
|
+
const pricingByClient = new Map( pricingDocs.map( ( d ) => [ String( d.clientId ), d ] ) );
|
|
1408
|
+
const rangeContains = ( rangeStr, count ) => {
|
|
1409
|
+
const s = String( rangeStr || '' );
|
|
1410
|
+
const between = s.match( /(\d+)\s*-\s*(\d+)/ );
|
|
1411
|
+
if ( between ) {
|
|
1412
|
+
return count >= Number( between[1] ) && count <= Number( between[2] );
|
|
1413
|
+
}
|
|
1414
|
+
const plus = s.match( /(\d+)\s*\+/ );
|
|
1415
|
+
if ( plus ) {
|
|
1416
|
+
return count >= Number( plus[1] );
|
|
1417
|
+
}
|
|
1418
|
+
return false;
|
|
1419
|
+
};
|
|
1420
|
+
|
|
1421
|
+
const csms = await assignedStoreService.find( { tangoUserType: 'csm' }, { clientId: 1, userEmail: 1 } );
|
|
1422
|
+
const csmByClient = new Map();
|
|
1423
|
+
for ( const a of csms ) {
|
|
1424
|
+
const key = String( a.clientId ?? '' );
|
|
1425
|
+
const name = String( a.userEmail || '' ).split( '@' )[0];
|
|
1426
|
+
if ( !name ) {
|
|
1427
|
+
continue;
|
|
1428
|
+
}
|
|
1429
|
+
const pretty = name.charAt( 0 ).toUpperCase() + name.slice( 1 );
|
|
1430
|
+
if ( !csmByClient.has( key ) ) {
|
|
1431
|
+
csmByClient.set( key, new Set() );
|
|
1432
|
+
}
|
|
1433
|
+
csmByClient.get( key ).add( pretty );
|
|
1434
|
+
}
|
|
1435
|
+
|
|
1436
|
+
// Merge — a client appears when it has billed invoices in the window.
|
|
1437
|
+
const rows = new Map();
|
|
1438
|
+
const rowOf = ( clientId ) => {
|
|
1439
|
+
const key = String( clientId ?? '' );
|
|
1440
|
+
if ( !rows.has( key ) ) {
|
|
1441
|
+
const c = clientById.get( key );
|
|
1442
|
+
const products = ( c?.planDetails?.product || [] );
|
|
1443
|
+
rows.set( key, {
|
|
1444
|
+
clientId: key,
|
|
1445
|
+
clientName: c?.clientName || '',
|
|
1446
|
+
registeredEntity: '',
|
|
1447
|
+
status: products.length && products.every( ( p ) => p.status === 'trial' ) ? 'Trial' : 'Paid',
|
|
1448
|
+
products: [ ...new Set( products.map( ( p ) => String( p.productName || '' )
|
|
1449
|
+
.replace( /^tango/i, '' ).replace( /^./, ( ch ) => ch.toUpperCase() ) ).filter( Boolean ) ) ],
|
|
1450
|
+
// Price/Store only counts products the client actually subscribes
|
|
1451
|
+
// to (status 'live') — trials are excluded.
|
|
1452
|
+
liveProductSet: new Set( products.filter( ( p ) => p.status === 'live' )
|
|
1453
|
+
.map( ( p ) => String( p.productName || '' ).toLowerCase() ) ),
|
|
1454
|
+
currency: c?.paymentInvoice?.currencyType === 'dollar' ? 'dollar' : 'inr',
|
|
1455
|
+
csm: [ ...( csmByClient.get( key ) || [] ) ].join( ', ' ),
|
|
1456
|
+
revenueMonths: {},
|
|
1457
|
+
billedStoresMonths: {},
|
|
1458
|
+
installationFee: 0,
|
|
1459
|
+
} );
|
|
1460
|
+
}
|
|
1461
|
+
return rows.get( key );
|
|
1462
|
+
};
|
|
1463
|
+
|
|
1464
|
+
for ( const inv of invoices ) {
|
|
1465
|
+
const r = rowOf( inv._id.c );
|
|
1466
|
+
r.revenueMonths[inv._id.ym] = Math.round( ( ( inv.revenueInr || 0 ) + ( inv.revenueUsd || 0 ) * usdRate ) * 100 ) / 100;
|
|
1467
|
+
r.billedStoresMonths[inv._id.ym] = inv.stores || 0;
|
|
1468
|
+
r.installationFee += ( inv.installationInr || 0 ) + ( inv.installationUsd || 0 ) * usdRate;
|
|
1469
|
+
if ( inv.companyName ) {
|
|
1470
|
+
r.registeredEntity = inv.companyName;
|
|
1471
|
+
}
|
|
1472
|
+
}
|
|
1473
|
+
|
|
1474
|
+
const curKey = months[4].key;
|
|
1475
|
+
const prevKey = months[3].key;
|
|
1476
|
+
const data = [ ...rows.values() ].map( ( r ) => {
|
|
1477
|
+
const revPrev = r.revenueMonths[prevKey] ?? null;
|
|
1478
|
+
// Price per store from the basepricing collection (negotiated price,
|
|
1479
|
+
// base price fallback). Step pricing picks the row whose store-count
|
|
1480
|
+
// range covers the latest billed store count. Clients without a
|
|
1481
|
+
// basepricing doc fall back to revenue / stores so the column never
|
|
1482
|
+
// lies silently.
|
|
1483
|
+
let latestStores = curStoresByClient.get( r.clientId ) ?? null;
|
|
1484
|
+
if ( latestStores == null ) {
|
|
1485
|
+
for ( let i = months.length - 1; i >= 0; i-- ) {
|
|
1486
|
+
if ( r.billedStoresMonths[months[i].key] ) {
|
|
1487
|
+
latestStores = r.billedStoresMonths[months[i].key];
|
|
1488
|
+
break;
|
|
1489
|
+
}
|
|
1490
|
+
}
|
|
1491
|
+
}
|
|
1492
|
+
let pricePerStore = null;
|
|
1493
|
+
const pdoc = pricingByClient.get( r.clientId );
|
|
1494
|
+
const isLive = ( name ) => r.liveProductSet.has( String( name || '' ).toLowerCase() );
|
|
1495
|
+
if ( pdoc?.standard?.length ) {
|
|
1496
|
+
const liveRows = pdoc.standard.filter( ( p ) => isLive( p.productName ) );
|
|
1497
|
+
pricePerStore = liveRows.length ?
|
|
1498
|
+
liveRows.reduce( ( a, p ) => a + ( Number( p.negotiatePrice ) || Number( p.basePrice ) || 0 ), 0 ) :
|
|
1499
|
+
null;
|
|
1500
|
+
} else if ( pdoc?.step?.length ) {
|
|
1501
|
+
const byProduct = new Map();
|
|
1502
|
+
for ( const p of pdoc.step ) {
|
|
1503
|
+
if ( !isLive( p.productName ) ) {
|
|
1504
|
+
continue;
|
|
1505
|
+
}
|
|
1506
|
+
const key = p.productName || '';
|
|
1507
|
+
if ( !byProduct.has( key ) ) {
|
|
1508
|
+
byProduct.set( key, [] );
|
|
1509
|
+
}
|
|
1510
|
+
byProduct.get( key ).push( p );
|
|
1511
|
+
}
|
|
1512
|
+
let sum = 0;
|
|
1513
|
+
for ( const list of byProduct.values() ) {
|
|
1514
|
+
const hit = ( latestStores != null && list.find( ( x ) => rangeContains( x.storeRange, latestStores ) ) ) || list[0];
|
|
1515
|
+
sum += Number( hit?.negotiatePrice ) || Number( hit?.basePrice ) || 0;
|
|
1516
|
+
}
|
|
1517
|
+
pricePerStore = sum || null;
|
|
1518
|
+
}
|
|
1519
|
+
if ( pricePerStore == null ) {
|
|
1520
|
+
for ( let i = months.length - 1; i >= 0; i-- ) {
|
|
1521
|
+
const k = months[i].key;
|
|
1522
|
+
if ( r.revenueMonths[k] && r.billedStoresMonths[k] ) {
|
|
1523
|
+
// revenueMonths is INR — divide back for dollar clients so the
|
|
1524
|
+
// fallback price stays in their native currency.
|
|
1525
|
+
pricePerStore = r.revenueMonths[k] / r.billedStoresMonths[k] / ( r.currency === 'dollar' ? usdRate : 1 );
|
|
1526
|
+
break;
|
|
1527
|
+
}
|
|
1528
|
+
}
|
|
1529
|
+
}
|
|
1530
|
+
pricePerStore = pricePerStore == null ? null : Math.round( pricePerStore );
|
|
1531
|
+
// Current month's revenue: the invoice amount once generated; until
|
|
1532
|
+
// then an estimate of live store count x price per store.
|
|
1533
|
+
let revCur = r.revenueMonths[curKey] ?? null;
|
|
1534
|
+
let revCurEstimated = false;
|
|
1535
|
+
if ( revCur == null ) {
|
|
1536
|
+
const curStores = curStoresByClient.get( r.clientId );
|
|
1537
|
+
if ( curStores && pricePerStore != null ) {
|
|
1538
|
+
// Dollar clients carry a USD per-store price — convert the
|
|
1539
|
+
// projection to INR at today's rate.
|
|
1540
|
+
revCur = curStores * pricePerStore * ( r.currency === 'dollar' ? usdRate : 1 );
|
|
1541
|
+
revCurEstimated = true;
|
|
1542
|
+
}
|
|
1543
|
+
}
|
|
1544
|
+
let variance = null;
|
|
1545
|
+
if ( revCur != null && revPrev != null && revPrev > 0 ) {
|
|
1546
|
+
variance = Math.round( ( revCur - revPrev ) / revPrev * 100 );
|
|
1547
|
+
} else if ( revCur == null && revPrev != null ) {
|
|
1548
|
+
variance = -100;
|
|
1549
|
+
}
|
|
1550
|
+
// All revenue figures are already INR (dollar invoices converted at
|
|
1551
|
+
// aggregation time; the estimate converted above). Price/Store stays
|
|
1552
|
+
// in native USD for dollar clients.
|
|
1553
|
+
revCur = revCur == null ? null : Math.round( revCur );
|
|
1554
|
+
const revPrevOut = revPrev == null ? null : Math.round( revPrev );
|
|
1555
|
+
const installationOut = r.installationFee ? Math.round( r.installationFee ) : null;
|
|
1556
|
+
return {
|
|
1557
|
+
clientId: r.clientId,
|
|
1558
|
+
clientName: r.clientName || r.registeredEntity || r.clientId,
|
|
1559
|
+
registeredEntity: r.registeredEntity,
|
|
1560
|
+
status: r.status,
|
|
1561
|
+
products: r.products,
|
|
1562
|
+
csm: r.csm,
|
|
1563
|
+
currency: r.currency,
|
|
1564
|
+
stores: months.map( ( m, i ) => i === months.length - 1 ?
|
|
1565
|
+
( curStoresByClient.get( r.clientId ) ?? r.billedStoresMonths[m.key] ?? null ) :
|
|
1566
|
+
( r.billedStoresMonths[m.key] ?? null ) ),
|
|
1567
|
+
pricePerStore,
|
|
1568
|
+
revPrev: revPrevOut,
|
|
1569
|
+
revCur,
|
|
1570
|
+
revCurEstimated,
|
|
1571
|
+
variance,
|
|
1572
|
+
installationFee: installationOut,
|
|
1573
|
+
};
|
|
1574
|
+
} ).sort( ( a, b ) => ( b.revCur || 0 ) - ( a.revCur || 0 ) );
|
|
1575
|
+
|
|
1576
|
+
return res.sendSuccess( { months, data, usdRate } );
|
|
1577
|
+
} catch ( error ) {
|
|
1578
|
+
logger.error( { error: error, function: 'billingSummary' } );
|
|
1579
|
+
return res.sendError( error, 500 );
|
|
1580
|
+
}
|
|
1581
|
+
}
|