tango-app-api-payment-subscription 3.5.22 → 3.5.24
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 +3 -3
- package/src/controllers/billing.controllers.js +88 -55
- package/src/controllers/brandsBilling.controller.js +430 -69
- package/src/controllers/estimate.controller.js +111 -17
- package/src/controllers/invoice.controller.js +53 -8
- package/src/controllers/paymentSubscription.controllers.js +82 -2
- package/src/controllers/purchaseOrder.controller.js +112 -4
- package/src/hbs/estimatePdf.hbs +12 -0
- package/src/hbs/invoicepaymentemail.hbs +4 -0
- package/src/routes/billing.routes.js +7 -8
- package/src/routes/brandsBilling.routes.js +12 -12
- package/src/routes/invoice.routes.js +4 -3
- package/src/routes/paymentSubscription.routes.js +5 -7
- package/src/services/clientPayment.services.js +21 -0
- package/src/services/purchaseOrder.service.js +4 -0
|
@@ -2,7 +2,7 @@ import * as estimateService from '../services/estimate.service.js';
|
|
|
2
2
|
import * as clientService from '../services/clientPayment.services.js';
|
|
3
3
|
import * as billingService from '../services/billing.service.js';
|
|
4
4
|
import dayjs from 'dayjs';
|
|
5
|
-
import { logger, download, sendEmailWithSES } from 'tango-app-api-middleware';
|
|
5
|
+
import { logger, download, sendEmailWithSES, insertOpenSearchData } from 'tango-app-api-middleware';
|
|
6
6
|
import Handlebars from '../utils/validations/helper/handlebar.helper.js';
|
|
7
7
|
import fs from 'fs';
|
|
8
8
|
import path from 'path';
|
|
@@ -17,6 +17,9 @@ async function buildEstimatePdf( estimate ) {
|
|
|
17
17
|
const currencyType = symbolFor( e.currency );
|
|
18
18
|
const fmt = ( n ) => Number( n || 0 ).toLocaleString( 'en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 } );
|
|
19
19
|
|
|
20
|
+
// Month per line item, mirroring the invoice PDF's Month column: use the
|
|
21
|
+
// product's stored month, else fall back to the estimate's created month.
|
|
22
|
+
const fallbackMonth = e.createdDate ? dayjs( e.createdDate ).format( 'MMM YYYY' ) : '';
|
|
20
23
|
const products = ( e.products || [] ).map( ( p, i ) => {
|
|
21
24
|
let name = String( p.productName || '' ).replace( /([a-z])([A-Z])/g, '$1 $2' );
|
|
22
25
|
name = name.charAt( 0 ).toUpperCase() + name.slice( 1 );
|
|
@@ -24,6 +27,7 @@ async function buildEstimatePdf( estimate ) {
|
|
|
24
27
|
index: i + 1,
|
|
25
28
|
productName: name,
|
|
26
29
|
description: p.description || '',
|
|
30
|
+
month: p.month || fallbackMonth,
|
|
27
31
|
hsn: p.hsn || p.hsnCode || '998314',
|
|
28
32
|
storeCount: p.storeCount || e.stores || '',
|
|
29
33
|
price: fmt( p.price ),
|
|
@@ -36,7 +40,7 @@ async function buildEstimatePdf( estimate ) {
|
|
|
36
40
|
taxAmount: fmt( t.taxAmount ),
|
|
37
41
|
} ) );
|
|
38
42
|
|
|
39
|
-
const statusLabelMap = {
|
|
43
|
+
const statusLabelMap = { pendingCsm: 'Pending CSM', pendingFinance: 'Pending Finance', pendingApproval: 'Pending Approval', approved: 'Approved', pending: 'Pending CSM' };
|
|
40
44
|
const data = {
|
|
41
45
|
estimate: e.estimate,
|
|
42
46
|
status: e.status,
|
|
@@ -106,26 +110,19 @@ async function nextEstimateNumber() {
|
|
|
106
110
|
};
|
|
107
111
|
}
|
|
108
112
|
|
|
109
|
-
// Expire stale estimates lazily on read: anything sent/draft past validTill
|
|
110
|
-
// flips to 'expired' so the list reflects reality without a cron.
|
|
111
|
-
async function expireOverdue( clientId ) {
|
|
112
|
-
await estimateService.updateOne(
|
|
113
|
-
{ clientId, status: { $in: [ 'pending', 'sent' ] }, validTill: { $lt: new Date() } },
|
|
114
|
-
{ $set: { status: 'expired' } },
|
|
115
|
-
);
|
|
116
|
-
}
|
|
117
|
-
|
|
118
113
|
export async function estimateList( req, res ) {
|
|
119
114
|
try {
|
|
120
115
|
const clientId = req.body?.clientId;
|
|
121
116
|
if ( !clientId ) {
|
|
122
117
|
return res.sendError( 'clientId is required', 400 );
|
|
123
118
|
}
|
|
124
|
-
await expireOverdue( clientId );
|
|
125
119
|
|
|
126
120
|
const match = { clientId };
|
|
127
121
|
if ( req.body?.status && req.body.status !== 'All' ) {
|
|
128
|
-
|
|
122
|
+
// Legacy 'pending' estimates are equivalent to the first CSM stage.
|
|
123
|
+
match.status = req.body.status === 'pendingCsm' ?
|
|
124
|
+
{ $in: [ 'pendingCsm', 'pending' ] } :
|
|
125
|
+
req.body.status;
|
|
129
126
|
}
|
|
130
127
|
|
|
131
128
|
// Optional month / year filter on the estimate period (stored as a
|
|
@@ -186,10 +183,12 @@ export async function estimateList( req, res ) {
|
|
|
186
183
|
{ $match: { clientId } },
|
|
187
184
|
{ $group: { _id: '$status', count: { $sum: 1 } } },
|
|
188
185
|
] );
|
|
189
|
-
const counts = {
|
|
186
|
+
const counts = { pendingCsm: 0, pendingFinance: 0, pendingApproval: 0, approved: 0, total: 0 };
|
|
190
187
|
statusAgg.forEach( ( s ) => {
|
|
191
|
-
|
|
192
|
-
|
|
188
|
+
// Fold legacy 'pending' into the CSM stage.
|
|
189
|
+
const key = s._id === 'pending' ? 'pendingCsm' : s._id;
|
|
190
|
+
if ( counts[key] != null ) {
|
|
191
|
+
counts[key] += s.count;
|
|
193
192
|
}
|
|
194
193
|
counts.total += s.count;
|
|
195
194
|
} );
|
|
@@ -244,7 +243,8 @@ export async function createEstimate( req, res ) {
|
|
|
244
243
|
amount,
|
|
245
244
|
totalAmount,
|
|
246
245
|
currency: estimateCurrency,
|
|
247
|
-
|
|
246
|
+
// Estimates enter the approval pipeline at the CSM stage.
|
|
247
|
+
status: 'pendingCsm',
|
|
248
248
|
createdDate,
|
|
249
249
|
validTill,
|
|
250
250
|
createdBy: req.user?.email || req.user?.userName || '',
|
|
@@ -374,6 +374,100 @@ export async function downloadEstimate( req, res ) {
|
|
|
374
374
|
}
|
|
375
375
|
}
|
|
376
376
|
|
|
377
|
+
// Advance an estimate one stage in the approval pipeline
|
|
378
|
+
// (pendingCsm → pendingFinance → pendingApproval → approved), mirroring
|
|
379
|
+
// transitionInvoiceStatus. On the FINAL transition to 'approved' the estimate
|
|
380
|
+
// PDF is emailed to the billing group recipients (same logic as the old
|
|
381
|
+
// approve-and-send). A wrong current status returns 409.
|
|
382
|
+
async function transitionEstimateStatus( req, res, fromStatus, toStatus ) {
|
|
383
|
+
try {
|
|
384
|
+
const { estimateId } = req.body || {};
|
|
385
|
+
if ( !estimateId ) {
|
|
386
|
+
return res.sendError( 'estimateId is required', 400 );
|
|
387
|
+
}
|
|
388
|
+
const estimate = await estimateService.findOne( { _id: estimateId } );
|
|
389
|
+
if ( !estimate ) {
|
|
390
|
+
return res.sendError( 'Estimate not found', 404 );
|
|
391
|
+
}
|
|
392
|
+
const e = estimate._doc || estimate;
|
|
393
|
+
|
|
394
|
+
// Legacy 'pending' estimates are equivalent to the first CSM stage.
|
|
395
|
+
const acceptedFrom = fromStatus === 'pendingCsm' ? [ 'pendingCsm', 'pending' ] : [ fromStatus ];
|
|
396
|
+
if ( !acceptedFrom.includes( e.status ) ) {
|
|
397
|
+
return res.sendError(
|
|
398
|
+
`Estimate is currently at status '${e.status}', not '${fromStatus}'. Another user may have advanced it.`,
|
|
399
|
+
409,
|
|
400
|
+
);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
await estimateService.updateOne( { _id: estimateId }, { $set: { status: toStatus } } );
|
|
404
|
+
|
|
405
|
+
insertOpenSearchData( JSON.parse( process.env.OPENSEARCH ).activityLog, {
|
|
406
|
+
userName: req.user?.userName,
|
|
407
|
+
email: req.user?.email,
|
|
408
|
+
clientId: e.clientId,
|
|
409
|
+
logSubType: 'estimateStatusTransition',
|
|
410
|
+
logType: 'estimate',
|
|
411
|
+
date: new Date(),
|
|
412
|
+
changes: [ `Estimate ${e.estimate} advanced from ${fromStatus} to ${toStatus} by ${req.user?.email}` ],
|
|
413
|
+
eventType: '',
|
|
414
|
+
timestamp: new Date(),
|
|
415
|
+
showTo: [ 'tango' ],
|
|
416
|
+
} );
|
|
417
|
+
|
|
418
|
+
// Final approval → email the estimate PDF to the billing group recipients.
|
|
419
|
+
// Non-fatal: a send failure is logged but the approval still stands.
|
|
420
|
+
if ( toStatus === 'approved' ) {
|
|
421
|
+
try {
|
|
422
|
+
let toEmails = [];
|
|
423
|
+
if ( e.groupId ) {
|
|
424
|
+
const group = await billingService.findOne( { _id: e.groupId } );
|
|
425
|
+
toEmails = ( group?.generateInvoiceTo || [] ).map( ( x ) => String( x || '' ).trim() ).filter( Boolean );
|
|
426
|
+
}
|
|
427
|
+
toEmails = [ ...new Set( toEmails ) ];
|
|
428
|
+
if ( toEmails.length ) {
|
|
429
|
+
const ccRaw = await getInvoiceCcEmails( e.clientId );
|
|
430
|
+
const toSet = new Set( toEmails.map( ( x ) => x.toLowerCase() ) );
|
|
431
|
+
const ccEmails = [ ...new Set( ( ccRaw || [] ).map( ( x ) => String( x || '' ).trim() ).filter( Boolean ) ) ]
|
|
432
|
+
.filter( ( x ) => !toSet.has( x.toLowerCase() ) );
|
|
433
|
+
const { pdfBuffer, filename, data } = await buildEstimatePdf( estimate );
|
|
434
|
+
const client = await clientService.findOne( { clientId: e.clientId }, { clientName: 1 } );
|
|
435
|
+
const emailData = { ...data, clientName: client?.clientName || e.companyName || 'Customer', companyName: 'Team Tango' };
|
|
436
|
+
const emailHtml = Handlebars.compile(
|
|
437
|
+
fs.readFileSync( path.resolve( path.dirname( '' ) ) + '/src/hbs/estimateEmail.hbs', 'utf8' ),
|
|
438
|
+
)( emailData );
|
|
439
|
+
const SES = JSON.parse( process.env.SES );
|
|
440
|
+
const fromEmail = SES.accountsEmail || SES.adminEmail;
|
|
441
|
+
const subject = `Estimate ${e.estimate} - Tango/${client?.clientName || e.companyName || ''}`;
|
|
442
|
+
const attachment = { filename, content: pdfBuffer, contentType: 'application/pdf' };
|
|
443
|
+
await sendEmailWithSES( toEmails, subject, emailHtml, attachment, fromEmail, ccEmails.length ? ccEmails : undefined );
|
|
444
|
+
} else {
|
|
445
|
+
logger.info?.( { function: 'transitionEstimateStatus', estimateId, message: 'No recipients configured; approval email skipped.' } );
|
|
446
|
+
}
|
|
447
|
+
} catch ( mailErr ) {
|
|
448
|
+
logger.error( { error: mailErr, function: 'transitionEstimateStatus.email', estimateId } );
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
return res.sendSuccess( { estimateId, fromStatus, status: toStatus } );
|
|
453
|
+
} catch ( error ) {
|
|
454
|
+
logger.error( { error: error, function: 'transitionEstimateStatus', fromStatus, toStatus, estimateId: req.body?.estimateId } );
|
|
455
|
+
return res.sendError( error, 500 );
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
export async function approveEstimateCsm( req, res ) {
|
|
460
|
+
return transitionEstimateStatus( req, res, 'pendingCsm', 'pendingFinance' );
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
export async function approveEstimateFinance( req, res ) {
|
|
464
|
+
return transitionEstimateStatus( req, res, 'pendingFinance', 'pendingApproval' );
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
export async function approveEstimateApproval( req, res ) {
|
|
468
|
+
return transitionEstimateStatus( req, res, 'pendingApproval', 'approved' );
|
|
469
|
+
}
|
|
470
|
+
|
|
377
471
|
// Approve & send an estimate: render the PDF, email it (with the PDF attached)
|
|
378
472
|
// to the billing group's recipients — same recipient logic as invoice send —
|
|
379
473
|
// then flip the status to 'sent'. Triggered by the Approve action's confirm.
|
|
@@ -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 {
|
|
21
|
+
import { getInrRate, getAdditionalProducts } from './brandsBilling.controller.js';
|
|
22
22
|
import { applyInvoiceToPurchaseOrder } from './purchaseOrder.controller.js';
|
|
23
23
|
|
|
24
24
|
// Pulls CSM + Finance head emails (stored under applicationDefault
|
|
@@ -312,8 +312,17 @@ export async function createInvoice( req, res ) {
|
|
|
312
312
|
// Skip when reusing advance-invoice line items — those already include any
|
|
313
313
|
// additional products from when the advance invoice was generated.
|
|
314
314
|
if ( !advanceCoverProducts ) {
|
|
315
|
-
|
|
315
|
+
// Scope additional-product quantities to this billing group's assigned
|
|
316
|
+
// stores (group.stores). Without this the counts span the whole brand
|
|
317
|
+
// and every group's invoice would be billed for all stores.
|
|
318
|
+
const extraProducts = await getAdditionalProducts( group.clientId, group.stores, baseDate );
|
|
316
319
|
for ( const ep of extraProducts ) {
|
|
320
|
+
// Skip additional products with no billable quantity — an Eyetest /
|
|
321
|
+
// Planogram / AI Manager / VMS line that resolved to 0 stores for this
|
|
322
|
+
// billing group should not appear as a ₹0 row on the invoice.
|
|
323
|
+
if ( ( Number( ep.quantity ) || 0 ) <= 0 ) {
|
|
324
|
+
continue;
|
|
325
|
+
}
|
|
317
326
|
products.push( {
|
|
318
327
|
productName: ep.productName,
|
|
319
328
|
period: 'fullmonth',
|
|
@@ -1271,7 +1280,8 @@ async function resolveBasePricingScope( group, getClient ) {
|
|
|
1271
1280
|
|
|
1272
1281
|
async function standardPrice( group, getClient, baseDate ) {
|
|
1273
1282
|
console.log( '🚀 ~ standardPrice ~ baseDate:', baseDate.format( 'MMM YYYY' ) );
|
|
1274
|
-
const currentMonthDays =
|
|
1283
|
+
const currentMonthDays = 30;
|
|
1284
|
+
console.log( '🚀 ~ standardPrice ~ currentMonthDays:', currentMonthDays );
|
|
1275
1285
|
// Pricing method: 'flat' => bill every store for the full month
|
|
1276
1286
|
// regardless of working days. 'prorate' => bill for actual working days.
|
|
1277
1287
|
// Computed once so the aggregation pipelines can inline a $literal.
|
|
@@ -2002,6 +2012,16 @@ export async function clientInvoiceList( req, res ) {
|
|
|
2002
2012
|
},
|
|
2003
2013
|
} ];
|
|
2004
2014
|
|
|
2015
|
+
// Invoice type sub-toggle: 'advance' shows only advance invoices (invoice #
|
|
2016
|
+
// starts with 'TINV-'); 'all' (default) shows only regular invoices (INV-*),
|
|
2017
|
+
// i.e. everything that is NOT a TINV-. Applied to the whole pipeline so the
|
|
2018
|
+
// list, count and cards all reflect the selected type.
|
|
2019
|
+
if ( req.body.invoiceType === 'advance' ) {
|
|
2020
|
+
query.push( { $match: { invoice: { $regex: '^TINV-' } } } );
|
|
2021
|
+
} else if ( req.body.invoiceType === 'all' ) {
|
|
2022
|
+
query.push( { $match: { invoice: { $not: { $regex: '^TINV-' } } } } );
|
|
2023
|
+
}
|
|
2024
|
+
|
|
2005
2025
|
// If the user picked an explicit Month or Year, ignore the Duration
|
|
2006
2026
|
// filter — otherwise the two ranges conflict (e.g. "current month" +
|
|
2007
2027
|
// April would always return zero results). Mirrors brand-invoices.
|
|
@@ -2239,30 +2259,49 @@ export async function clientInvoiceList( req, res ) {
|
|
|
2239
2259
|
// converted to INR at today's rate so all three totals are a single ₹
|
|
2240
2260
|
// figure. Outstanding = unpaid remaining (totalAmount - paidAmount);
|
|
2241
2261
|
// Overdue = past-due unpaid subset; Pending Payment = approved-but-unpaid.
|
|
2242
|
-
const usdRate = await getUsdInrRate();
|
|
2243
2262
|
const now = new Date();
|
|
2263
|
+
// Convert every non-INR invoice to INR so the ₹ card totals are comparable.
|
|
2264
|
+
// Resolve one rate per distinct currency present (dollar, euro, sgd, aed, …)
|
|
2265
|
+
// via getInrRate, so euro/others are converted too — not just dollar.
|
|
2266
|
+
const rateByCurrency = {};
|
|
2267
|
+
for ( const cur of new Set( count.map( ( c ) => String( c.currency || 'inr' ) ) ) ) {
|
|
2268
|
+
rateByCurrency[cur] = await getInrRate( cur );
|
|
2269
|
+
}
|
|
2270
|
+
// Each card carries the total remaining (incl. GST, net of payments — the
|
|
2271
|
+
// *Amount fields, kept for back-compat) PLUS a GST split of the GROSS invoice
|
|
2272
|
+
// figures: *ExclAmount = sum of `amount` (pre-tax subtotal), *InclAmount =
|
|
2273
|
+
// sum of `totalAmount` (with tax), matching the table's Excl/Incl columns.
|
|
2244
2274
|
const cards = {
|
|
2245
|
-
outstandingAmount: 0, outstandingCount: 0,
|
|
2246
|
-
overdueAmount: 0, overdueCount: 0,
|
|
2247
|
-
pendingPaymentAmount: 0, pendingPaymentCount: 0,
|
|
2275
|
+
outstandingAmount: 0, outstandingExclAmount: 0, outstandingInclAmount: 0, outstandingCount: 0,
|
|
2276
|
+
overdueAmount: 0, overdueExclAmount: 0, overdueInclAmount: 0, overdueCount: 0,
|
|
2277
|
+
pendingPaymentAmount: 0, pendingPaymentExclAmount: 0, pendingPaymentInclAmount: 0, pendingPaymentCount: 0,
|
|
2248
2278
|
};
|
|
2249
2279
|
for ( const inv of count ) {
|
|
2250
2280
|
if ( inv.paymentStatus === 'paid' ) {
|
|
2251
2281
|
continue;
|
|
2252
2282
|
}
|
|
2253
|
-
const fx = inv.currency
|
|
2283
|
+
const fx = rateByCurrency[String( inv.currency || 'inr' )] || 1;
|
|
2254
2284
|
const total = Number( inv.totalAmount ) || Number( inv.amount ) || 0;
|
|
2255
2285
|
const paid = Number( inv.paidAmount ) || 0;
|
|
2256
2286
|
const remaining = Math.max( 0, total - paid ) * fx;
|
|
2287
|
+
// Gross GST split (FX-normalised to INR): excl = subtotal, incl = with tax.
|
|
2288
|
+
const exclGross = ( Number( inv.amount ) || 0 ) * fx;
|
|
2289
|
+
const inclGross = ( Number( inv.totalAmount ) || Number( inv.amount ) || 0 ) * fx;
|
|
2257
2290
|
|
|
2258
2291
|
cards.outstandingAmount += remaining;
|
|
2292
|
+
cards.outstandingExclAmount += exclGross;
|
|
2293
|
+
cards.outstandingInclAmount += inclGross;
|
|
2259
2294
|
cards.outstandingCount += 1;
|
|
2260
2295
|
if ( inv.dueDate && new Date( inv.dueDate ) < now ) {
|
|
2261
2296
|
cards.overdueAmount += remaining;
|
|
2297
|
+
cards.overdueExclAmount += exclGross;
|
|
2298
|
+
cards.overdueInclAmount += inclGross;
|
|
2262
2299
|
cards.overdueCount += 1;
|
|
2263
2300
|
}
|
|
2264
2301
|
if ( inv.status === 'approved' ) {
|
|
2265
2302
|
cards.pendingPaymentAmount += remaining;
|
|
2303
|
+
cards.pendingPaymentExclAmount += exclGross;
|
|
2304
|
+
cards.pendingPaymentInclAmount += inclGross;
|
|
2266
2305
|
cards.pendingPaymentCount += 1;
|
|
2267
2306
|
}
|
|
2268
2307
|
}
|
|
@@ -2271,6 +2310,12 @@ export async function clientInvoiceList( req, res ) {
|
|
|
2271
2310
|
cards.outstandingAmount = roundAmount( cards.outstandingAmount, 'inr' );
|
|
2272
2311
|
cards.overdueAmount = roundAmount( cards.overdueAmount, 'inr' );
|
|
2273
2312
|
cards.pendingPaymentAmount = roundAmount( cards.pendingPaymentAmount, 'inr' );
|
|
2313
|
+
cards.outstandingExclAmount = roundAmount( cards.outstandingExclAmount, 'inr' );
|
|
2314
|
+
cards.outstandingInclAmount = roundAmount( cards.outstandingInclAmount, 'inr' );
|
|
2315
|
+
cards.overdueExclAmount = roundAmount( cards.overdueExclAmount, 'inr' );
|
|
2316
|
+
cards.overdueInclAmount = roundAmount( cards.overdueInclAmount, 'inr' );
|
|
2317
|
+
cards.pendingPaymentExclAmount = roundAmount( cards.pendingPaymentExclAmount, 'inr' );
|
|
2318
|
+
cards.pendingPaymentInclAmount = roundAmount( cards.pendingPaymentInclAmount, 'inr' );
|
|
2274
2319
|
|
|
2275
2320
|
res.sendSuccess( { count: count.length, data: invoiceList, cards } );
|
|
2276
2321
|
} catch ( error ) {
|
|
@@ -4250,7 +4250,7 @@ export async function uploadClientDocument( req, res ) {
|
|
|
4250
4250
|
const clientId = String( req.body?.clientId || '' );
|
|
4251
4251
|
const documentName = String( req.body?.documentName || '' ).trim();
|
|
4252
4252
|
const expiryDateRaw = req.body?.expiryDate;
|
|
4253
|
-
const file = req.file; //
|
|
4253
|
+
const file = req.files?.file; // express-fileupload → req.files.file
|
|
4254
4254
|
|
|
4255
4255
|
if ( !clientId ) {
|
|
4256
4256
|
return res.sendError( 'clientId is required', 400 );
|
|
@@ -4282,7 +4282,7 @@ export async function uploadClientDocument( req, res ) {
|
|
|
4282
4282
|
Key: key,
|
|
4283
4283
|
fileName: fileName,
|
|
4284
4284
|
ContentType: file.mimetype,
|
|
4285
|
-
body: file.
|
|
4285
|
+
body: file.data,
|
|
4286
4286
|
} );
|
|
4287
4287
|
|
|
4288
4288
|
const storedPath = `${key}${fileName}`;
|
|
@@ -4332,6 +4332,86 @@ export async function getClientDocuments( req, res ) {
|
|
|
4332
4332
|
}
|
|
4333
4333
|
}
|
|
4334
4334
|
|
|
4335
|
+
// Update a client document (name / expiry, and optionally replace the PDF).
|
|
4336
|
+
export async function updateClientDocument( req, res ) {
|
|
4337
|
+
try {
|
|
4338
|
+
const clientId = String( req.body?.clientId || '' );
|
|
4339
|
+
const documentId = String( req.body?.documentId || '' );
|
|
4340
|
+
const documentName = String( req.body?.documentName || '' ).trim();
|
|
4341
|
+
const expiryDateRaw = req.body?.expiryDate;
|
|
4342
|
+
const file = req.files?.file; // optional PDF replacement
|
|
4343
|
+
|
|
4344
|
+
if ( !clientId ) {
|
|
4345
|
+
return res.sendError( 'clientId is required', 400 );
|
|
4346
|
+
}
|
|
4347
|
+
if ( !documentId ) {
|
|
4348
|
+
return res.sendError( 'documentId is required', 400 );
|
|
4349
|
+
}
|
|
4350
|
+
if ( !documentName ) {
|
|
4351
|
+
return res.sendError( 'documentName is required', 400 );
|
|
4352
|
+
}
|
|
4353
|
+
|
|
4354
|
+
const client = await paymentService.findOneClient(
|
|
4355
|
+
{ clientId, 'additionalDocuments._id': documentId },
|
|
4356
|
+
{ clientId: 1 },
|
|
4357
|
+
);
|
|
4358
|
+
if ( !client ) {
|
|
4359
|
+
return res.sendError( 'Document not found', 404 );
|
|
4360
|
+
}
|
|
4361
|
+
|
|
4362
|
+
const expiryDate = expiryDateRaw ? new Date( expiryDateRaw ) : null;
|
|
4363
|
+
const fields = {
|
|
4364
|
+
documentName: documentName,
|
|
4365
|
+
expiryDate: ( expiryDate && !isNaN( expiryDate.getTime() ) ) ? expiryDate : null,
|
|
4366
|
+
};
|
|
4367
|
+
|
|
4368
|
+
// Optional new file → upload and point the subdoc at the new path.
|
|
4369
|
+
if ( file ) {
|
|
4370
|
+
if ( file.mimetype !== 'application/pdf' ) {
|
|
4371
|
+
return res.sendError( 'Only PDF files are allowed', 400 );
|
|
4372
|
+
}
|
|
4373
|
+
const bucket = JSON.parse( process.env.BUCKET );
|
|
4374
|
+
const safeName = documentName.replace( /[^a-zA-Z0-9-_]/g, '_' );
|
|
4375
|
+
const fileName = `${safeName}_${Date.now()}.pdf`;
|
|
4376
|
+
const key = `${clientId}/documents/`;
|
|
4377
|
+
await fileUpload( {
|
|
4378
|
+
Bucket: bucket.assets,
|
|
4379
|
+
Key: key,
|
|
4380
|
+
fileName: fileName,
|
|
4381
|
+
ContentType: file.mimetype,
|
|
4382
|
+
body: file.data,
|
|
4383
|
+
} );
|
|
4384
|
+
fields.path = `${key}${fileName}`;
|
|
4385
|
+
}
|
|
4386
|
+
|
|
4387
|
+
await paymentService.updateAdditionalDocument( clientId, documentId, fields );
|
|
4388
|
+
return res.sendSuccess( { message: 'Document updated', documentName, expiryDate: fields.expiryDate } );
|
|
4389
|
+
} catch ( error ) {
|
|
4390
|
+
logger.error( { error: error, function: 'updateClientDocument' } );
|
|
4391
|
+
return res.sendError( error, 500 );
|
|
4392
|
+
}
|
|
4393
|
+
}
|
|
4394
|
+
|
|
4395
|
+
// Delete a client document (removes the subdoc; the S3 object is left as-is,
|
|
4396
|
+
// matching the app's existing replace-without-delete behavior).
|
|
4397
|
+
export async function deleteClientDocument( req, res ) {
|
|
4398
|
+
try {
|
|
4399
|
+
const clientId = String( req.body?.clientId || req.query?.clientId || '' );
|
|
4400
|
+
const documentId = String( req.params?.documentId || req.body?.documentId || '' );
|
|
4401
|
+
if ( !clientId ) {
|
|
4402
|
+
return res.sendError( 'clientId is required', 400 );
|
|
4403
|
+
}
|
|
4404
|
+
if ( !documentId ) {
|
|
4405
|
+
return res.sendError( 'documentId is required', 400 );
|
|
4406
|
+
}
|
|
4407
|
+
await paymentService.pullAdditionalDocument( clientId, documentId );
|
|
4408
|
+
return res.sendSuccess( { message: 'Document deleted' } );
|
|
4409
|
+
} catch ( error ) {
|
|
4410
|
+
logger.error( { error: error, function: 'deleteClientDocument' } );
|
|
4411
|
+
return res.sendError( error, 500 );
|
|
4412
|
+
}
|
|
4413
|
+
}
|
|
4414
|
+
|
|
4335
4415
|
// Toggle billing-group-wise pricing on the client. When enabled, pricing is
|
|
4336
4416
|
// maintained per billing group (separate basepricing docs keyed by groupId).
|
|
4337
4417
|
export async function setBillingGroupWisePricing( req, res ) {
|
|
@@ -57,10 +57,10 @@ export async function createPurchaseOrder( req, res ) {
|
|
|
57
57
|
return res.sendError( 'A purchase order with this number already exists', 409 );
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
-
// Optional PO PDF upload (
|
|
61
|
-
// brand document upload. PDF only.
|
|
60
|
+
// Optional PO PDF upload (express-fileupload → req.files.file) → assets
|
|
61
|
+
// bucket, same as brand document upload. PDF only.
|
|
62
62
|
let pdfPath = '';
|
|
63
|
-
const file = req.file;
|
|
63
|
+
const file = req.files?.file;
|
|
64
64
|
if ( file ) {
|
|
65
65
|
if ( file.mimetype !== 'application/pdf' ) {
|
|
66
66
|
return res.sendError( 'Only PDF files are allowed', 400 );
|
|
@@ -74,7 +74,7 @@ export async function createPurchaseOrder( req, res ) {
|
|
|
74
74
|
Key: key,
|
|
75
75
|
fileName: fileName,
|
|
76
76
|
ContentType: file.mimetype,
|
|
77
|
-
body: file.
|
|
77
|
+
body: file.data,
|
|
78
78
|
} );
|
|
79
79
|
pdfPath = `${key}${fileName}`;
|
|
80
80
|
console.log( '🚀 ~ createPurchaseOrder ~ result:', result );
|
|
@@ -132,6 +132,114 @@ export async function getPurchaseOrders( req, res ) {
|
|
|
132
132
|
}
|
|
133
133
|
}
|
|
134
134
|
|
|
135
|
+
// Update a purchase order. Editable: companyName, purchaseOrderNumber,
|
|
136
|
+
// totalAmount, date and (optionally) the PO PDF. Changing totalAmount keeps the
|
|
137
|
+
// already-used amount intact and recomputes remainingAmount + status.
|
|
138
|
+
export async function updatePurchaseOrder( req, res ) {
|
|
139
|
+
try {
|
|
140
|
+
const id = String( req.params?.id || req.body?._id || '' );
|
|
141
|
+
if ( !id ) {
|
|
142
|
+
return res.sendError( 'purchase order id is required', 400 );
|
|
143
|
+
}
|
|
144
|
+
const existing = await purchaseOrderService.findOne( { _id: id } );
|
|
145
|
+
if ( !existing ) {
|
|
146
|
+
return res.sendError( 'Purchase order not found', 404 );
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const clientId = existing.clientId;
|
|
150
|
+
const companyName = String( req.body?.companyName || '' ).trim();
|
|
151
|
+
const purchaseOrderNumber = String( req.body?.purchaseOrderNumber || '' ).trim();
|
|
152
|
+
const totalAmount = Number( req.body?.totalAmount );
|
|
153
|
+
const date = req.body?.date ? new Date( req.body.date ) : existing.date;
|
|
154
|
+
|
|
155
|
+
if ( !purchaseOrderNumber ) {
|
|
156
|
+
return res.sendError( 'purchaseOrderNumber is required', 400 );
|
|
157
|
+
}
|
|
158
|
+
if ( !( totalAmount > 0 ) ) {
|
|
159
|
+
return res.sendError( 'totalAmount must be greater than 0', 400 );
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Reject a number that already belongs to a DIFFERENT PO for this client.
|
|
163
|
+
if ( purchaseOrderNumber !== existing.purchaseOrderNumber ) {
|
|
164
|
+
const clash = await purchaseOrderService.findOne( { clientId, purchaseOrderNumber } );
|
|
165
|
+
if ( clash && String( clash._id ) !== id ) {
|
|
166
|
+
return res.sendError( 'A purchase order with this number already exists', 409 );
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Preserve the amount already consumed by mapped invoices; the new total
|
|
171
|
+
// can't drop below it.
|
|
172
|
+
const usedAmount = Number( existing.totalAmount ) - Number( existing.remainingAmount );
|
|
173
|
+
if ( totalAmount < usedAmount ) {
|
|
174
|
+
return res.sendError(
|
|
175
|
+
`totalAmount cannot be less than the amount already used (${usedAmount})`,
|
|
176
|
+
400,
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
const remainingAmount = totalAmount - usedAmount;
|
|
180
|
+
|
|
181
|
+
const update = {
|
|
182
|
+
companyName,
|
|
183
|
+
purchaseOrderNumber,
|
|
184
|
+
totalAmount,
|
|
185
|
+
remainingAmount,
|
|
186
|
+
date,
|
|
187
|
+
status: statusFor( totalAmount, remainingAmount ),
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
// Optional PDF replacement (express-fileupload → req.files.file). PDF only.
|
|
191
|
+
const file = req.files?.file;
|
|
192
|
+
if ( file ) {
|
|
193
|
+
if ( file.mimetype !== 'application/pdf' ) {
|
|
194
|
+
return res.sendError( 'Only PDF files are allowed', 400 );
|
|
195
|
+
}
|
|
196
|
+
const bucket = JSON.parse( process.env.BUCKET );
|
|
197
|
+
const safeName = purchaseOrderNumber.replace( /[^a-zA-Z0-9-_]/g, '_' );
|
|
198
|
+
const fileName = `${safeName}_${Date.now()}.pdf`;
|
|
199
|
+
const key = `${clientId}/purchase-orders/`;
|
|
200
|
+
await fileUpload( {
|
|
201
|
+
Bucket: bucket.assets,
|
|
202
|
+
Key: key,
|
|
203
|
+
fileName: fileName,
|
|
204
|
+
ContentType: file.mimetype,
|
|
205
|
+
body: file.data,
|
|
206
|
+
} );
|
|
207
|
+
update.pdfPath = `${key}${fileName}`;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
await purchaseOrderService.updateOne( { _id: id }, update );
|
|
211
|
+
logPO( req, clientId, 'purchaseOrderUpdated', [ `PO ${purchaseOrderNumber} updated (total ${totalAmount})` ] );
|
|
212
|
+
return res.sendSuccess( { message: 'Purchase order updated' } );
|
|
213
|
+
} catch ( error ) {
|
|
214
|
+
logger.error( { error: error, function: 'updatePurchaseOrder' } );
|
|
215
|
+
return res.sendError( error, 500 );
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Delete a purchase order. Blocked when invoices are already mapped to it
|
|
220
|
+
// (deleting would orphan those invoice → PO links).
|
|
221
|
+
export async function deletePurchaseOrder( req, res ) {
|
|
222
|
+
try {
|
|
223
|
+
const id = String( req.params?.id || '' );
|
|
224
|
+
if ( !id ) {
|
|
225
|
+
return res.sendError( 'purchase order id is required', 400 );
|
|
226
|
+
}
|
|
227
|
+
const existing = await purchaseOrderService.findOne( { _id: id } );
|
|
228
|
+
if ( !existing ) {
|
|
229
|
+
return res.sendError( 'Purchase order not found', 404 );
|
|
230
|
+
}
|
|
231
|
+
if ( ( existing.usage || [] ).length > 0 ) {
|
|
232
|
+
return res.sendError( 'Cannot delete a purchase order with mapped invoices', 409 );
|
|
233
|
+
}
|
|
234
|
+
await purchaseOrderService.deleteRecord( { _id: id } );
|
|
235
|
+
logPO( req, existing.clientId, 'purchaseOrderDeleted', [ `PO ${existing.purchaseOrderNumber} deleted` ] );
|
|
236
|
+
return res.sendSuccess( { message: 'Purchase order deleted' } );
|
|
237
|
+
} catch ( error ) {
|
|
238
|
+
logger.error( { error: error, function: 'deletePurchaseOrder' } );
|
|
239
|
+
return res.sendError( error, 500 );
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
135
243
|
// Map an invoice to a PO: deduct the invoice amount from the PO's remaining
|
|
136
244
|
// balance, record the usage, update status, and log it. Shared with invoice
|
|
137
245
|
// generation/edit so the deduction happens wherever an invoice gets a PO.
|
package/src/hbs/estimatePdf.hbs
CHANGED
|
@@ -1540,6 +1540,18 @@
|
|
|
1540
1540
|
</div>
|
|
1541
1541
|
{{/each}}
|
|
1542
1542
|
</div>
|
|
1543
|
+
<div class="column2">
|
|
1544
|
+
<div class="table-header-cell2">
|
|
1545
|
+
<div class="table-header2">
|
|
1546
|
+
<div class="text6">Month</div>
|
|
1547
|
+
</div>
|
|
1548
|
+
</div>
|
|
1549
|
+
{{#each products}}
|
|
1550
|
+
<div class="table-cell4">
|
|
1551
|
+
<div class="text7">{{month}}</div>
|
|
1552
|
+
</div>
|
|
1553
|
+
{{/each}}
|
|
1554
|
+
</div>
|
|
1543
1555
|
<div class="column2">
|
|
1544
1556
|
<div class="table-header-cell2">
|
|
1545
1557
|
<div class="table-header2">
|
|
@@ -652,6 +652,7 @@ img {
|
|
|
652
652
|
</tbody></table>
|
|
653
653
|
</td>
|
|
654
654
|
</tr>
|
|
655
|
+
{{#gtZero tax.0.value}}
|
|
655
656
|
<tr>
|
|
656
657
|
<td height="12" style="height:12px; min-height:12px; line-height:12px;"></td>
|
|
657
658
|
</tr>
|
|
@@ -673,6 +674,7 @@ img {
|
|
|
673
674
|
<table width="100%" border="0" cellpadding="0" cellspacing="0">
|
|
674
675
|
<tbody>
|
|
675
676
|
{{#each tax }}
|
|
677
|
+
{{#gtZero value}}
|
|
676
678
|
<tr>
|
|
677
679
|
<td>
|
|
678
680
|
<div style="line-height:24px;text-align:right;">
|
|
@@ -680,6 +682,7 @@ img {
|
|
|
680
682
|
</div>
|
|
681
683
|
</td>
|
|
682
684
|
</tr>
|
|
685
|
+
{{/gtZero}}
|
|
683
686
|
{{/each}}
|
|
684
687
|
|
|
685
688
|
|
|
@@ -689,6 +692,7 @@ img {
|
|
|
689
692
|
</tbody></table>
|
|
690
693
|
</td>
|
|
691
694
|
</tr>
|
|
695
|
+
{{/gtZero}}
|
|
692
696
|
<tr>
|
|
693
697
|
<td height="12" style="height:12px; min-height:12px; line-height:12px;"></td>
|
|
694
698
|
</tr>
|
|
@@ -1,11 +1,9 @@
|
|
|
1
1
|
import express from 'express';
|
|
2
|
-
import multer from 'multer';
|
|
3
2
|
export const billingRouter = express.Router();
|
|
4
|
-
const poUpload = multer( { storage: multer.memoryStorage(), limits: { fileSize: 10 * 1024 * 1024 } } );
|
|
5
3
|
import { accessVerification, isAllowedSessionHandler, validate } from 'tango-app-api-middleware';
|
|
6
4
|
import { getPaymentReminder, savePaymentReminder } from '../controllers/paymentReminder.controller.js';
|
|
7
5
|
import { triggerPaymentReminders } from '../controllers/paymentReminderTrigger.controller.js';
|
|
8
|
-
import { createPurchaseOrder, getPurchaseOrders } from '../controllers/purchaseOrder.controller.js';
|
|
6
|
+
import { createPurchaseOrder, getPurchaseOrders, updatePurchaseOrder, deletePurchaseOrder } from '../controllers/purchaseOrder.controller.js';
|
|
9
7
|
import { createBillingGroup, deleteBillingGroup, getAllBillingGroups, getBillingGroups, getClientProducts, getInvoices, getLeadProducts, getBaseProducts, onetimePayment, subscribedStoreList, updateBillingGroup, gstinLookup } from '../controllers/billing.controllers.js';
|
|
10
8
|
import { billingGroupSchema, clientProductsValid, createBillingGroupsSchema, deleteBillingGroupsSchema, getBillingGroupsSchema, getInvoiceSchema, leadProductsValid, onetimeFeeValid, subscribedStoreListSchema, updateBillingGroupsSchema } from '../dtos/validation.dtos.js';
|
|
11
9
|
|
|
@@ -42,11 +40,12 @@ billingRouter.post( '/payment-reminder', isAllowedSessionHandler, accessVerifica
|
|
|
42
40
|
|
|
43
41
|
// Purchase Orders (brand-view Purchase Order tab).
|
|
44
42
|
billingRouter.get( '/purchase-orders', isAllowedSessionHandler, accessVerification( { userType: [ 'tango' ], access: [ { featureName: 'Global', name: 'Billing', permissions: [ 'isAdd' ] } ] } ), getPurchaseOrders );
|
|
45
|
-
//
|
|
46
|
-
//
|
|
47
|
-
//
|
|
48
|
-
|
|
49
|
-
billingRouter.
|
|
43
|
+
// The multipart body is parsed app-wide by express-fileupload (see app.js), so
|
|
44
|
+
// the uploaded PDF is on req.files.file and text fields on req.body regardless
|
|
45
|
+
// of middleware order — no per-route upload middleware needed.
|
|
46
|
+
billingRouter.post( '/purchase-orders', isAllowedSessionHandler, accessVerification( { userType: [ 'tango' ], access: [ { featureName: 'Global', name: 'Billing', permissions: [ 'isAdd' ] } ] } ), createPurchaseOrder );
|
|
47
|
+
billingRouter.put( '/purchase-orders/:id', isAllowedSessionHandler, accessVerification( { userType: [ 'tango' ], access: [ { featureName: 'Global', name: 'Billing', permissions: [ 'isAdd' ] } ] } ), updatePurchaseOrder );
|
|
48
|
+
billingRouter.delete( '/purchase-orders/:id', isAllowedSessionHandler, accessVerification( { userType: [ 'tango' ], access: [ { featureName: 'Global', name: 'Billing', permissions: [ 'isAdd' ] } ] } ), deletePurchaseOrder );
|
|
50
49
|
|
|
51
50
|
// Cron-triggered: sends the configured payment reminder emails. Unauthenticated
|
|
52
51
|
// like the other cron endpoints; protect at the network / scheduler layer.
|