tango-app-api-payment-subscription 3.5.5 → 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/src/controllers/bankTransaction.controller.js +617 -0
- package/src/controllers/brandsBilling.controller.js +289 -0
- package/src/controllers/estimate.controller.js +317 -0
- package/src/controllers/invoice.controller.js +164 -260
- package/src/controllers/paymentSubscription.controllers.js +55 -3
- package/src/dtos/validation.dtos.js +6 -0
- package/src/hbs/estimatePdf.hbs +125 -0
- package/src/hbs/invoicePdf.hbs +27 -0
- package/src/routes/brandsBilling.routes.js +2 -1
- package/src/routes/invoice.routes.js +18 -0
- package/src/services/bankTransaction.service.js +21 -0
- package/src/services/estimate.service.js +25 -0
|
@@ -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';
|
|
@@ -1290,3 +1292,290 @@ export async function bulkUpdateBillingGroups( req, res ) {
|
|
|
1290
1292
|
return res.sendError( error, 500 );
|
|
1291
1293
|
}
|
|
1292
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
|
+
}
|
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
import * as estimateService from '../services/estimate.service.js';
|
|
2
|
+
import * as clientService from '../services/clientPayment.services.js';
|
|
3
|
+
import dayjs from 'dayjs';
|
|
4
|
+
import { logger, download } from 'tango-app-api-middleware';
|
|
5
|
+
import Handlebars from '../utils/validations/helper/handlebar.helper.js';
|
|
6
|
+
import fs from 'fs';
|
|
7
|
+
import path from 'path';
|
|
8
|
+
import htmlpdf from 'html-pdf-node';
|
|
9
|
+
import { symbolFor } from '../utils/currency.js';
|
|
10
|
+
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
// Estimates (quotations). A lightweight pre-invoice document with its own
|
|
13
|
+
// lifecycle (draft → sent → accepted/declined/expired). Stored in the
|
|
14
|
+
// `estimates` collection. Per-brand (clientId) listing for the Estimate tab.
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
|
|
17
|
+
function getCurrentFinancialYear() {
|
|
18
|
+
const today = new Date();
|
|
19
|
+
const month = today.getMonth();
|
|
20
|
+
const year = today.getFullYear();
|
|
21
|
+
if ( month >= 3 ) {
|
|
22
|
+
return year.toString().slice( -2 ) + '-' + ( year + 1 ).toString().slice( -2 );
|
|
23
|
+
}
|
|
24
|
+
return ( ( year - 1 ).toString().slice( -2 ) ) + '-' + year.toString().slice( -2 );
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Next EST-<FY>-<index> for the current financial year.
|
|
28
|
+
async function nextEstimateNumber() {
|
|
29
|
+
const fy = getCurrentFinancialYear();
|
|
30
|
+
const previous = await estimateService.aggregate( [
|
|
31
|
+
{ $match: { estimate: { $regex: `^EST-${fy}-` } } },
|
|
32
|
+
{ $sort: { estimateIndex: -1 } },
|
|
33
|
+
{ $limit: 1 },
|
|
34
|
+
] );
|
|
35
|
+
const index = previous.length ? Number( previous[0].estimateIndex ) + 1 : 1;
|
|
36
|
+
return {
|
|
37
|
+
estimate: `EST-${fy}-${String( index ).padStart( 5, '0' )}`,
|
|
38
|
+
estimateIndex: index,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Expire stale estimates lazily on read: anything sent/draft past validTill
|
|
43
|
+
// flips to 'expired' so the list reflects reality without a cron.
|
|
44
|
+
async function expireOverdue( clientId ) {
|
|
45
|
+
await estimateService.updateOne(
|
|
46
|
+
{ clientId, status: { $in: [ 'draft', 'sent' ] }, validTill: { $lt: new Date() } },
|
|
47
|
+
{ $set: { status: 'expired' } },
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export async function estimateList( req, res ) {
|
|
52
|
+
try {
|
|
53
|
+
const clientId = req.body?.clientId;
|
|
54
|
+
if ( !clientId ) {
|
|
55
|
+
return res.sendError( 'clientId is required', 400 );
|
|
56
|
+
}
|
|
57
|
+
await expireOverdue( clientId );
|
|
58
|
+
|
|
59
|
+
const match = { clientId };
|
|
60
|
+
if ( req.body?.status && req.body.status !== 'All' ) {
|
|
61
|
+
match.status = req.body.status;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Optional month / year filter on the estimate period (stored as a
|
|
65
|
+
// "MMM YYYY" string; createdDate is the reliable date to range on).
|
|
66
|
+
if ( req.body?.fromDate || req.body?.toDate ) {
|
|
67
|
+
match.createdDate = {};
|
|
68
|
+
if ( req.body.fromDate ) {
|
|
69
|
+
match.createdDate.$gte = new Date( dayjs( req.body.fromDate ).startOf( 'day' ).toISOString() );
|
|
70
|
+
}
|
|
71
|
+
if ( req.body.toDate ) {
|
|
72
|
+
match.createdDate.$lte = new Date( dayjs( req.body.toDate ).endOf( 'day' ).toISOString() );
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if ( req.body?.searchValue ) {
|
|
77
|
+
match.$or = [
|
|
78
|
+
{ estimate: { $regex: req.body.searchValue, $options: 'i' } },
|
|
79
|
+
{ groupName: { $regex: req.body.searchValue, $options: 'i' } },
|
|
80
|
+
{ period: { $regex: req.body.searchValue, $options: 'i' } },
|
|
81
|
+
];
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const query = [
|
|
85
|
+
{ $match: match },
|
|
86
|
+
{ $sort: { [req.body?.sortColumName || 'createdDate']: req.body?.sortBy === 1 ? 1 : -1, _id: -1 } },
|
|
87
|
+
];
|
|
88
|
+
|
|
89
|
+
const countResult = await estimateService.aggregate( [ ...query, { $count: 'n' } ] );
|
|
90
|
+
const count = countResult[0]?.n || 0;
|
|
91
|
+
|
|
92
|
+
if ( req.body?.export ) {
|
|
93
|
+
const all = await estimateService.aggregate( query );
|
|
94
|
+
const rows = all.map( ( e ) => ( {
|
|
95
|
+
'Estimate #': e.estimate,
|
|
96
|
+
'Billing Group': e.groupName || '',
|
|
97
|
+
'Period': e.period || '',
|
|
98
|
+
'Generated': e.createdDate ? dayjs( e.createdDate ).format( 'DD MMM YYYY' ) : '',
|
|
99
|
+
'Valid Till': e.validTill ? dayjs( e.validTill ).format( 'DD MMM YYYY' ) : '',
|
|
100
|
+
'No of Stores': e.stores || 0,
|
|
101
|
+
'Amount (excl. GST)': e.amount || 0,
|
|
102
|
+
'Amount (incl. GST)': e.totalAmount || 0,
|
|
103
|
+
'Status': e.status,
|
|
104
|
+
} ) );
|
|
105
|
+
await download( rows, res );
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if ( req.body?.limit && req.body?.offset ) {
|
|
110
|
+
query.push(
|
|
111
|
+
{ $skip: ( req.body.offset - 1 ) * req.body.limit },
|
|
112
|
+
{ $limit: Number( req.body.limit ) },
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
const data = await estimateService.aggregate( query );
|
|
116
|
+
|
|
117
|
+
// Status counts over the brand's whole estimate set (filter-stable).
|
|
118
|
+
const statusAgg = await estimateService.aggregate( [
|
|
119
|
+
{ $match: { clientId } },
|
|
120
|
+
{ $group: { _id: '$status', count: { $sum: 1 } } },
|
|
121
|
+
] );
|
|
122
|
+
const counts = { draft: 0, sent: 0, accepted: 0, declined: 0, expired: 0, total: 0 };
|
|
123
|
+
statusAgg.forEach( ( s ) => {
|
|
124
|
+
if ( counts[s._id] != null ) {
|
|
125
|
+
counts[s._id] = s.count;
|
|
126
|
+
}
|
|
127
|
+
counts.total += s.count;
|
|
128
|
+
} );
|
|
129
|
+
|
|
130
|
+
return res.sendSuccess( { count, data, counts } );
|
|
131
|
+
} catch ( error ) {
|
|
132
|
+
logger.error( { error: error, function: 'estimateList' } );
|
|
133
|
+
return res.sendError( error, 500 );
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export async function createEstimate( req, res ) {
|
|
138
|
+
try {
|
|
139
|
+
const b = req.body || {};
|
|
140
|
+
if ( !b.clientId ) {
|
|
141
|
+
return res.sendError( 'clientId is required', 400 );
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const client = await clientService.findOne(
|
|
145
|
+
{ clientId: b.clientId },
|
|
146
|
+
{ 'clientName': 1, 'paymentInvoice.currencyType': 1 },
|
|
147
|
+
);
|
|
148
|
+
|
|
149
|
+
const amount = Math.round( Number( b.amount ) || 0 );
|
|
150
|
+
let totalAmount = Math.round( Number( b.totalAmount ) || 0 );
|
|
151
|
+
if ( !totalAmount && amount ) {
|
|
152
|
+
// Default to 18% GST when caller sends only the pre-tax amount.
|
|
153
|
+
totalAmount = Math.round( amount * 1.18 );
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const { estimate, estimateIndex } = await nextEstimateNumber();
|
|
157
|
+
const createdDate = b.createdDate ? new Date( b.createdDate ) : new Date();
|
|
158
|
+
// Estimates are valid for 14 days unless an explicit date is supplied.
|
|
159
|
+
const validTill = b.validTill ? new Date( b.validTill ) : dayjs( createdDate ).add( 14, 'days' ).toDate();
|
|
160
|
+
|
|
161
|
+
const doc = {
|
|
162
|
+
clientId: b.clientId,
|
|
163
|
+
estimate,
|
|
164
|
+
estimateIndex,
|
|
165
|
+
companyName: b.companyName || client?.clientName || '',
|
|
166
|
+
companyAddress: b.companyAddress || '',
|
|
167
|
+
PlaceOfSupply: b.PlaceOfSupply || '',
|
|
168
|
+
GSTNumber: b.GSTNumber || '',
|
|
169
|
+
groupId: b.groupId || undefined,
|
|
170
|
+
groupName: b.groupName || 'Default Group',
|
|
171
|
+
period: b.period || dayjs( createdDate ).format( 'MMM YYYY' ),
|
|
172
|
+
stores: Number( b.stores ) || 0,
|
|
173
|
+
products: Array.isArray( b.products ) ? b.products : [],
|
|
174
|
+
tax: Array.isArray( b.tax ) ? b.tax : [],
|
|
175
|
+
amount,
|
|
176
|
+
totalAmount,
|
|
177
|
+
currency: b.currency || ( client?.paymentInvoice?.currencyType === 'dollar' ? 'dollar' : 'inr' ),
|
|
178
|
+
status: b.status === 'sent' ? 'sent' : 'draft',
|
|
179
|
+
createdDate,
|
|
180
|
+
validTill,
|
|
181
|
+
createdBy: req.user?.email || req.user?.userName || '',
|
|
182
|
+
notes: b.notes || '',
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
const created = await estimateService.create( doc );
|
|
186
|
+
logger.info?.( { function: 'createEstimate', estimate, clientId: b.clientId } );
|
|
187
|
+
return res.sendSuccess( created );
|
|
188
|
+
} catch ( error ) {
|
|
189
|
+
logger.error( { error: error, function: 'createEstimate' } );
|
|
190
|
+
return res.sendError( error, 500 );
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export async function getEstimate( req, res ) {
|
|
195
|
+
try {
|
|
196
|
+
const estimate = await estimateService.findOne( { _id: req.params.estimateId } );
|
|
197
|
+
if ( !estimate ) {
|
|
198
|
+
return res.sendError( 'Estimate not found', 404 );
|
|
199
|
+
}
|
|
200
|
+
return res.sendSuccess( estimate );
|
|
201
|
+
} catch ( error ) {
|
|
202
|
+
logger.error( { error: error, function: 'getEstimate' } );
|
|
203
|
+
return res.sendError( error, 500 );
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export async function estimateStatusUpdate( req, res ) {
|
|
208
|
+
try {
|
|
209
|
+
const { estimateId, status } = req.body || {};
|
|
210
|
+
const allowed = [ 'draft', 'sent', 'accepted', 'declined', 'expired' ];
|
|
211
|
+
if ( !estimateId || !allowed.includes( status ) ) {
|
|
212
|
+
return res.sendError( 'estimateId and a valid status are required', 400 );
|
|
213
|
+
}
|
|
214
|
+
const result = await estimateService.updateOne( { _id: estimateId }, { $set: { status } } );
|
|
215
|
+
if ( !result?.matchedCount ) {
|
|
216
|
+
return res.sendError( 'Estimate not found', 404 );
|
|
217
|
+
}
|
|
218
|
+
return res.sendSuccess( { status } );
|
|
219
|
+
} catch ( error ) {
|
|
220
|
+
logger.error( { error: error, function: 'estimateStatusUpdate' } );
|
|
221
|
+
return res.sendError( error, 500 );
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Renders the estimate to a PDF and streams it back. Mirrors the invoice PDF
|
|
226
|
+
// flow (Handlebars template + html-pdf-node) but uses the estimate template.
|
|
227
|
+
export async function downloadEstimate( req, res ) {
|
|
228
|
+
try {
|
|
229
|
+
const estimate = await estimateService.findOne( { _id: req.params.estimateId } );
|
|
230
|
+
if ( !estimate ) {
|
|
231
|
+
return res.sendError( 'Estimate not found', 404 );
|
|
232
|
+
}
|
|
233
|
+
const e = estimate._doc || estimate;
|
|
234
|
+
const currencyType = symbolFor( e.currency );
|
|
235
|
+
const fmt = ( n ) => Number( n || 0 ).toLocaleString( 'en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 } );
|
|
236
|
+
|
|
237
|
+
const products = ( e.products || [] ).map( ( p, i ) => {
|
|
238
|
+
let name = String( p.productName || '' ).replace( /([a-z])([A-Z])/g, '$1 $2' );
|
|
239
|
+
name = name.charAt( 0 ).toUpperCase() + name.slice( 1 );
|
|
240
|
+
return {
|
|
241
|
+
index: i + 1,
|
|
242
|
+
productName: name,
|
|
243
|
+
description: p.description || '',
|
|
244
|
+
hsn: p.hsn || p.hsnCode || '998314',
|
|
245
|
+
storeCount: p.storeCount || e.stores || '',
|
|
246
|
+
price: fmt( p.price ),
|
|
247
|
+
amount: fmt( p.amount ),
|
|
248
|
+
};
|
|
249
|
+
} );
|
|
250
|
+
const tax = ( e.tax || [] ).map( ( t ) => ( {
|
|
251
|
+
type: t.type || t.taxName || t.name || 'GST',
|
|
252
|
+
value: t.value ?? t.taxPercentage ?? t.percentage ?? '',
|
|
253
|
+
taxAmount: fmt( t.taxAmount ),
|
|
254
|
+
} ) );
|
|
255
|
+
|
|
256
|
+
const statusLabelMap = { draft: 'Draft', sent: 'Sent', accepted: 'Accepted', declined: 'Declined', expired: 'Expired' };
|
|
257
|
+
const data = {
|
|
258
|
+
estimate: e.estimate,
|
|
259
|
+
status: e.status,
|
|
260
|
+
statusLabel: statusLabelMap[e.status] || e.status,
|
|
261
|
+
companyName: e.companyName || '',
|
|
262
|
+
companyAddress: e.companyAddress || '',
|
|
263
|
+
GSTNumber: e.GSTNumber || '',
|
|
264
|
+
PlaceOfSupply: e.PlaceOfSupply || '',
|
|
265
|
+
groupName: e.groupName || '',
|
|
266
|
+
period: e.period || '',
|
|
267
|
+
createdDate: e.createdDate ? dayjs( e.createdDate ).format( 'DD/MM/YYYY' ) : '',
|
|
268
|
+
validTill: e.validTill ? dayjs( e.validTill ).format( 'DD/MM/YYYY' ) : '',
|
|
269
|
+
currencyType,
|
|
270
|
+
amount: fmt( e.amount ),
|
|
271
|
+
totalAmount: fmt( e.totalAmount ),
|
|
272
|
+
products,
|
|
273
|
+
tax,
|
|
274
|
+
notes: e.notes || '',
|
|
275
|
+
logo: `${JSON.parse( process.env.URL ).apiDomain}/logo.png`,
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
const templateHtml = fs.readFileSync( path.resolve( path.dirname( '' ) ) + '/src/hbs/estimatePdf.hbs', 'utf8' );
|
|
279
|
+
const template = Handlebars.compile( templateHtml );
|
|
280
|
+
const html = template( data );
|
|
281
|
+
const file = { content: html };
|
|
282
|
+
const options = {
|
|
283
|
+
executablePath: '/usr/bin/chromium',
|
|
284
|
+
args: [ '--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage', '--no-zygote', '--single-process' ],
|
|
285
|
+
format: 'A4',
|
|
286
|
+
margin: { top: '0.5in', right: '0.5in', bottom: '0.5in', left: '0.5in' },
|
|
287
|
+
printBackground: true,
|
|
288
|
+
preferCSSPageSize: true,
|
|
289
|
+
};
|
|
290
|
+
const pdfBuffer = await htmlpdf.generatePdf( file, options );
|
|
291
|
+
|
|
292
|
+
const filename = ( e.estimate + '-' + ( e.companyName || 'estimate' ) + '.pdf' ).split( '/' ).join( '_' ).split( '"' ).join( '' ).trim();
|
|
293
|
+
res.set( 'Content-Type', 'application/pdf' );
|
|
294
|
+
res.set( 'Content-Disposition', `attachment; filename="${filename}"` );
|
|
295
|
+
return res.send( pdfBuffer );
|
|
296
|
+
} catch ( error ) {
|
|
297
|
+
logger.error( { error: error, function: 'downloadEstimate' } );
|
|
298
|
+
return res.sendError( error, 500 );
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
export async function deleteEstimate( req, res ) {
|
|
303
|
+
try {
|
|
304
|
+
const estimate = await estimateService.findOne( { _id: req.params.estimateId } );
|
|
305
|
+
if ( !estimate ) {
|
|
306
|
+
return res.sendError( 'Estimate not found', 404 );
|
|
307
|
+
}
|
|
308
|
+
if ( estimate.status === 'accepted' ) {
|
|
309
|
+
return res.sendError( 'An accepted estimate cannot be deleted.', 409 );
|
|
310
|
+
}
|
|
311
|
+
await estimateService.updateOne( { _id: req.params.estimateId }, { $set: { status: 'declined' } } );
|
|
312
|
+
return res.sendSuccess( 'Estimate removed' );
|
|
313
|
+
} catch ( error ) {
|
|
314
|
+
logger.error( { error: error, function: 'deleteEstimate' } );
|
|
315
|
+
return res.sendError( error, 500 );
|
|
316
|
+
}
|
|
317
|
+
}
|