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.
@@ -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
+ }