tango-app-api-payment-subscription 3.5.17 → 3.5.18
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/scripts/create-billing-groups-by-country.js +1 -1
- package/src/controllers/billing.controllers.js +31 -1
- package/src/controllers/brandsBilling.controller.js +5 -2
- package/src/controllers/invoice.controller.js +167 -33
- package/src/controllers/purchaseOrder.controller.js +171 -0
- package/src/routes/billing.routes.js +7 -0
- package/src/services/purchaseOrder.service.js +21 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tango-app-api-payment-subscription",
|
|
3
|
-
"version": "3.5.
|
|
3
|
+
"version": "3.5.18",
|
|
4
4
|
"description": "paymentSubscription",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"type": "module",
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
"nodemon": "^3.1.0",
|
|
30
30
|
"puppeteer": "^24.41.0",
|
|
31
31
|
"swagger-ui-express": "^5.0.0",
|
|
32
|
-
"tango-api-schema": "^2.6.
|
|
32
|
+
"tango-api-schema": "^2.6.36",
|
|
33
33
|
"tango-app-api-middleware": "^3.6.18",
|
|
34
34
|
"winston": "^3.12.0",
|
|
35
35
|
"winston-daily-rotate-file": "^5.0.0",
|
|
@@ -36,7 +36,7 @@ import mongoose from 'mongoose';
|
|
|
36
36
|
import 'dotenv/config';
|
|
37
37
|
import { getConnection } from '../config/database/database.js';
|
|
38
38
|
|
|
39
|
-
const DEFAULT_CLIENTS = [ '387', '193' ];
|
|
39
|
+
const DEFAULT_CLIENTS = [ '387', '193', '11' ];
|
|
40
40
|
|
|
41
41
|
// Fallback billing group for stores that have no storeProfile.country.
|
|
42
42
|
const UNKNOWN_GROUP_NAME = 'Unknown';
|
|
@@ -266,7 +266,37 @@ export const getAllBillingGroups = async ( req, res ) => {
|
|
|
266
266
|
try {
|
|
267
267
|
const billingGroups = await find( { clientId: req.query.clientId } );
|
|
268
268
|
|
|
269
|
-
|
|
269
|
+
// Per group: does an ADVANCE invoice already cover the CURRENT month? If so,
|
|
270
|
+
// attach { invoice, period } so the Generate Invoice popup can show it (and
|
|
271
|
+
// signal a normal monthly invoice will be made instead of another advance).
|
|
272
|
+
const currentMonthLabel = dayjs().format( 'MMM YYYY' );
|
|
273
|
+
const enriched = await Promise.all( ( billingGroups || [] ).map( async ( g ) => {
|
|
274
|
+
const plain = g.toObject?.() || g;
|
|
275
|
+
let advanceCoverage = null;
|
|
276
|
+
try {
|
|
277
|
+
const adv = await invoiceService.findOne( {
|
|
278
|
+
'groupId': g._id,
|
|
279
|
+
'advanceInvoice': true,
|
|
280
|
+
'products.month': currentMonthLabel,
|
|
281
|
+
} );
|
|
282
|
+
if ( adv ) {
|
|
283
|
+
// Ordered distinct month labels across the advance invoice's line
|
|
284
|
+
// items → period "first to last" (e.g. "Jun-2026 to Aug-2026").
|
|
285
|
+
const months = [ ...new Set( ( adv.products || [] ).map( ( p ) => p.month ).filter( Boolean ) ) ];
|
|
286
|
+
months.sort( ( a, b ) => dayjs( a, 'MMM YYYY' ).valueOf() - dayjs( b, 'MMM YYYY' ).valueOf() );
|
|
287
|
+
const fmt = ( m ) => dayjs( m, 'MMM YYYY' ).format( 'MMM-YYYY' );
|
|
288
|
+
const period = months.length ?
|
|
289
|
+
( months.length > 1 ? `${fmt( months[0] )} to ${fmt( months[months.length - 1] )}` : fmt( months[0] ) ) :
|
|
290
|
+
'';
|
|
291
|
+
advanceCoverage = { invoice: adv.invoice, period };
|
|
292
|
+
}
|
|
293
|
+
} catch ( e ) {
|
|
294
|
+
// Coverage is advisory only — never fail the group list over it.
|
|
295
|
+
}
|
|
296
|
+
return { ...plain, advanceCoverage };
|
|
297
|
+
} ) );
|
|
298
|
+
|
|
299
|
+
return res.sendSuccess( enriched );
|
|
270
300
|
} catch ( error ) {
|
|
271
301
|
logger.error( { error: error, function: 'getBillingGroups' } );
|
|
272
302
|
return res.sendError( error, 500 );
|
|
@@ -1057,8 +1057,11 @@ export async function updateDailyPricingWorkingDays( req, res ) {
|
|
|
1057
1057
|
{ arrayFilters: [ { 'store.storeId': storeId }, { 'product.productName': productName } ] },
|
|
1058
1058
|
);
|
|
1059
1059
|
|
|
1060
|
-
|
|
1061
|
-
|
|
1060
|
+
// Only a missing record is a real failure. matchedCount > 0 with
|
|
1061
|
+
// modifiedCount 0 just means the value was already that number (e.g. saving
|
|
1062
|
+
// 0 on an already-0 row) — treat that as success, not an error.
|
|
1063
|
+
if ( ( result.matchedCount || 0 ) === 0 ) {
|
|
1064
|
+
return res.sendError( 'Record not found', 404 );
|
|
1062
1065
|
}
|
|
1063
1066
|
|
|
1064
1067
|
const logObj = {
|
|
@@ -19,6 +19,7 @@ import { findOneApplicationDefault } from '../services/applicationDefault.servic
|
|
|
19
19
|
import * as assignedStoreService from '../services/assignedStore.service.js';
|
|
20
20
|
import * as bankTransactionService from '../services/bankTransaction.service.js';
|
|
21
21
|
import { getUsdInrRate, getAdditionalProducts } from './brandsBilling.controller.js';
|
|
22
|
+
import { applyInvoiceToPurchaseOrder } from './purchaseOrder.controller.js';
|
|
22
23
|
|
|
23
24
|
// Pulls CSM + Finance head emails (stored under applicationDefault
|
|
24
25
|
// type=invoice, subType=heads) AND the per-client CSMs assigned via
|
|
@@ -126,8 +127,11 @@ export async function createInvoice( req, res ) {
|
|
|
126
127
|
return res.sendError( 'clientId is required for customInvoice', 400 );
|
|
127
128
|
}
|
|
128
129
|
const Finacialyear = getCurrentFinancialYear();
|
|
130
|
+
// Quarterly/half-yearly/yearly advance invoices use the separate TINV-
|
|
131
|
+
// series with its own per-FY counter; monthly advance + normal stay INV-.
|
|
132
|
+
const invPrefix = invoicePrefixFor( req.body.advanceInvoice, req.body.advancePeriod );
|
|
129
133
|
const previousinvoice = await invoiceService.findandsort(
|
|
130
|
-
{ invoice: { $regex:
|
|
134
|
+
{ invoice: { $regex: `^${invPrefix}${Finacialyear}-` } },
|
|
131
135
|
{},
|
|
132
136
|
{ invoiceIndex: -1 },
|
|
133
137
|
);
|
|
@@ -167,7 +171,7 @@ export async function createInvoice( req, res ) {
|
|
|
167
171
|
Math.round( Number( req.body.totalAmount ) || 0 );
|
|
168
172
|
|
|
169
173
|
const data = {
|
|
170
|
-
invoice:
|
|
174
|
+
invoice: `${invPrefix}${Finacialyear}-${invoiceNo}`,
|
|
171
175
|
invoiceIndex: invoiceNo,
|
|
172
176
|
clientId: req.body.clientId,
|
|
173
177
|
groupId: req.body.groupId || undefined,
|
|
@@ -192,8 +196,24 @@ export async function createInvoice( req, res ) {
|
|
|
192
196
|
advanceInvoice: req.body.advanceInvoice || false,
|
|
193
197
|
advancePeriod: req.body.advanceInvoice ? ( req.body.advancePeriod || 'monthly' ) : undefined,
|
|
194
198
|
advanceMonths: customAdvanceMonths,
|
|
199
|
+
// Purchase order this invoice is mapped to (optional).
|
|
200
|
+
purchaseOrderNumber: req.body.purchaseOrderNumber || undefined,
|
|
195
201
|
};
|
|
196
202
|
const created = await invoiceService.create( data );
|
|
203
|
+
// Map to a PO (deduct its remaining balance + log) when one was picked.
|
|
204
|
+
if ( data.purchaseOrderNumber ) {
|
|
205
|
+
try {
|
|
206
|
+
await applyInvoiceToPurchaseOrder( {
|
|
207
|
+
clientId: data.clientId,
|
|
208
|
+
purchaseOrderNumber: data.purchaseOrderNumber,
|
|
209
|
+
invoice: data.invoice,
|
|
210
|
+
amount: data.totalAmount,
|
|
211
|
+
req,
|
|
212
|
+
} );
|
|
213
|
+
} catch ( poErr ) {
|
|
214
|
+
logger.error( { error: poErr, function: 'createInvoice.applyPO', invoice: data.invoice } );
|
|
215
|
+
}
|
|
216
|
+
}
|
|
197
217
|
const logObj = {
|
|
198
218
|
userName: req.user?.userName,
|
|
199
219
|
email: req.user?.email,
|
|
@@ -216,11 +236,41 @@ export async function createInvoice( req, res ) {
|
|
|
216
236
|
|
|
217
237
|
for ( let group of invoiceGroupList ) {
|
|
218
238
|
let Finacialyear = getCurrentFinancialYear();
|
|
219
|
-
|
|
220
|
-
//
|
|
221
|
-
//
|
|
239
|
+
|
|
240
|
+
// If an ADVANCE invoice already covers the CURRENT month for this group
|
|
241
|
+
// (i.e. it has a product line item labelled with the current MMM YYYY),
|
|
242
|
+
// we must NOT generate another advance invoice for it. Instead generate a
|
|
243
|
+
// normal monthly invoice using the store counts from that advance invoice.
|
|
244
|
+
const currentMonthLabel = dayjs().format( 'MMM YYYY' );
|
|
245
|
+
let advanceCoverProducts = null;
|
|
246
|
+
if ( group.advanceInvoice ) {
|
|
247
|
+
const existingAdvance = await invoiceService.findOne( {
|
|
248
|
+
'groupId': group._id,
|
|
249
|
+
'advanceInvoice': true,
|
|
250
|
+
'products.month': currentMonthLabel,
|
|
251
|
+
} );
|
|
252
|
+
if ( existingAdvance ) {
|
|
253
|
+
// Take this month's line items from the advance invoice (they carry the
|
|
254
|
+
// store counts + prices the advance covered) and bill them as a normal
|
|
255
|
+
// monthly invoice.
|
|
256
|
+
advanceCoverProducts = ( existingAdvance.products || [] )
|
|
257
|
+
.filter( ( p ) => p.month === currentMonthLabel )
|
|
258
|
+
.map( ( p ) => ( { ...( p._doc || p ), month: currentMonthLabel } ) );
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
// Effective advance flag: forced OFF when an advance invoice already covers
|
|
262
|
+
// this month, so the rest of the loop runs the normal monthly path.
|
|
263
|
+
const isAdvance = group.advanceInvoice && !advanceCoverProducts;
|
|
264
|
+
|
|
265
|
+
// Quarterly/half-yearly/yearly advance invoices use the separate TINV-
|
|
266
|
+
// series with its own per-FY counter; monthly advance + normal stay INV-.
|
|
267
|
+
const invPrefix = invoicePrefixFor( isAdvance, group.advancePeriod );
|
|
268
|
+
// Scope the highest-index lookup to invoices created in the current FY for
|
|
269
|
+
// THIS series. Without the FY scope the new FY would continue the previous
|
|
270
|
+
// year's sequence; without the prefix scope the two series would share one
|
|
271
|
+
// counter.
|
|
222
272
|
let previousinvoice = await invoiceService.findandsort(
|
|
223
|
-
{ invoice: { $regex:
|
|
273
|
+
{ invoice: { $regex: `^${invPrefix}${Finacialyear}-` } },
|
|
224
274
|
{},
|
|
225
275
|
{ invoiceIndex: -1 },
|
|
226
276
|
);
|
|
@@ -232,10 +282,16 @@ export async function createInvoice( req, res ) {
|
|
|
232
282
|
|
|
233
283
|
let address = group.addressLineOne + group.addressLineTwo + group.city + ',' + group.state + ',' + group.country + ' -' + group.pinCode;
|
|
234
284
|
let getClient = await clientService.findOne( { clientId: group.clientId, status: 'active' } );
|
|
235
|
-
|
|
285
|
+
// Advance bills next month; normal (incl. the advance-already-covered case)
|
|
286
|
+
// bills the current month.
|
|
287
|
+
let baseDate = isAdvance ? dayjs().add( 1, 'month' ).startOf( 'month' ) : dayjs();
|
|
236
288
|
let products;
|
|
237
289
|
|
|
238
|
-
if (
|
|
290
|
+
if ( advanceCoverProducts ) {
|
|
291
|
+
// Reuse the advance invoice's current-month line items (store counts +
|
|
292
|
+
// prices already set) and bill them as a normal monthly invoice.
|
|
293
|
+
products = advanceCoverProducts;
|
|
294
|
+
} else if ( getClient?.priceType === 'standard' ) {
|
|
239
295
|
products = await standardPrice( group, getClient, baseDate );
|
|
240
296
|
} else {
|
|
241
297
|
products = await stepPrice( group, getClient );
|
|
@@ -247,18 +303,22 @@ export async function createInvoice( req, res ) {
|
|
|
247
303
|
// store-based product lines. Added BEFORE the multi-month expansion below
|
|
248
304
|
// so they repeat per month and are included in the taxable subtotal, just
|
|
249
305
|
// like the standard products. Returns [] for any client without extras.
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
306
|
+
// Skip when reusing advance-invoice line items — those already include any
|
|
307
|
+
// additional products from when the advance invoice was generated.
|
|
308
|
+
if ( !advanceCoverProducts ) {
|
|
309
|
+
const extraProducts = await getAdditionalProducts( group.clientId );
|
|
310
|
+
for ( const ep of extraProducts ) {
|
|
311
|
+
products.push( {
|
|
312
|
+
productName: ep.productName,
|
|
313
|
+
period: 'fullmonth',
|
|
314
|
+
storeCount: ep.quantity,
|
|
315
|
+
amount: ep.total,
|
|
316
|
+
price: ep.price,
|
|
317
|
+
description: ep.productName,
|
|
318
|
+
HsnNumber: '998314',
|
|
319
|
+
month: baseDate.format( 'MMM YYYY' ),
|
|
320
|
+
} );
|
|
321
|
+
}
|
|
262
322
|
}
|
|
263
323
|
|
|
264
324
|
// Billing horizon in months. Advance and normal cycle are independent —
|
|
@@ -269,13 +329,15 @@ export async function createInvoice( req, res ) {
|
|
|
269
329
|
// product is repeated once per month, labelled with that month, at the
|
|
270
330
|
// normal monthly amount. One invoice then bills the whole period.
|
|
271
331
|
// (advancePeriod uses 'halfyearly'; paymentCycle uses 'quarter'/'halfYearly'
|
|
272
|
-
// — map both spellings.)
|
|
332
|
+
// — map both spellings.) Uses isAdvance (not group.advanceInvoice) so the
|
|
333
|
+
// advance-already-covered case bills a single normal month.
|
|
273
334
|
const advancePeriodMonths = { quarterly: 3, halfyearly: 6, yearly: 12 };
|
|
274
335
|
const paymentCycleMonths = { quarter: 3, quarterly: 3, halfYearly: 6, halfyearly: 6, yearly: 12 };
|
|
275
|
-
const advanceMonths =
|
|
336
|
+
const advanceMonths = isAdvance ?
|
|
276
337
|
( advancePeriodMonths[group.advancePeriod] || 1 ) :
|
|
277
338
|
( paymentCycleMonths[group.paymentCycle] || 1 );
|
|
278
|
-
|
|
339
|
+
// No re-expansion when reusing advance line items (already this month only).
|
|
340
|
+
if ( !advanceCoverProducts && advanceMonths > 1 ) {
|
|
279
341
|
const expanded = [];
|
|
280
342
|
for ( let m = 0; m < advanceMonths; m++ ) {
|
|
281
343
|
const monthLabel = baseDate.add( m, 'month' ).format( 'MMM YYYY' );
|
|
@@ -415,6 +477,36 @@ export async function createInvoice( req, res ) {
|
|
|
415
477
|
|
|
416
478
|
] );
|
|
417
479
|
|
|
480
|
+
// For TINV (advance quarterly/half-yearly/yearly) invoices, capture the
|
|
481
|
+
// actual store list — storeId, storeName and each store's products with
|
|
482
|
+
// working days — onto the invoice from the latest daily-pricing doc.
|
|
483
|
+
let storeDetails = [];
|
|
484
|
+
if ( invPrefix === 'TINV-' ) {
|
|
485
|
+
const dpDoc = await dailyPricingService.aggregate( [
|
|
486
|
+
{ $match: { clientId: group.clientId } },
|
|
487
|
+
{ $sort: { dateISO: -1 } },
|
|
488
|
+
{ $limit: 1 },
|
|
489
|
+
{ $project: { stores: {
|
|
490
|
+
$filter: { input: '$stores', as: 'item', cond: { $in: [ '$$item.storeId', group.stores ] } },
|
|
491
|
+
} } },
|
|
492
|
+
] );
|
|
493
|
+
const dpStores = dpDoc?.[0]?.stores || [];
|
|
494
|
+
storeDetails = dpStores
|
|
495
|
+
.map( ( s ) => ( {
|
|
496
|
+
storeId: s.storeId,
|
|
497
|
+
storeName: s.storeName,
|
|
498
|
+
// Only products that actually ran this month (workingdays > 0).
|
|
499
|
+
products: ( s.products || [] )
|
|
500
|
+
.filter( ( p ) => ( Number( p.workingdays ) || 0 ) > 0 )
|
|
501
|
+
.map( ( p ) => ( {
|
|
502
|
+
productName: p.productName,
|
|
503
|
+
workingdays: Number( p.workingdays ) || 0,
|
|
504
|
+
} ) ),
|
|
505
|
+
} ) )
|
|
506
|
+
// Drop stores left with no running products.
|
|
507
|
+
.filter( ( s ) => s.products.length > 0 );
|
|
508
|
+
}
|
|
509
|
+
|
|
418
510
|
// billingDate is the actual invoice (creation) date — even for advance
|
|
419
511
|
// invoices. The advance MONTH is reflected only in the product lines /
|
|
420
512
|
// monthOfbilling (driven by baseDate), not in the billing date itself.
|
|
@@ -428,7 +520,7 @@ export async function createInvoice( req, res ) {
|
|
|
428
520
|
let data = {
|
|
429
521
|
groupName: group.groupName,
|
|
430
522
|
groupId: group._id,
|
|
431
|
-
invoice: req.body.invoiceId ? req.body.invoiceId :
|
|
523
|
+
invoice: req.body.invoiceId ? req.body.invoiceId : `${invPrefix}${Finacialyear}-${invoiceNo}`,
|
|
432
524
|
products: products,
|
|
433
525
|
status: 'pendingCsm',
|
|
434
526
|
amount: Math.round( amount ),
|
|
@@ -442,12 +534,19 @@ export async function createInvoice( req, res ) {
|
|
|
442
534
|
clientId: group.clientId,
|
|
443
535
|
paymentMethod: 'Online',
|
|
444
536
|
billingDate: new Date( invoicedate ),
|
|
445
|
-
|
|
537
|
+
// For the advance-already-covered case, take the store count from the
|
|
538
|
+
// advance invoice's line items; otherwise the live running-store count.
|
|
539
|
+
stores: advanceCoverProducts ?
|
|
540
|
+
Math.max( 0, ...advanceCoverProducts.map( ( p ) => Number( p.storeCount ) || 0 ) ) :
|
|
541
|
+
( totalStoreCount.length ? totalStoreCount[0].stores.length : 0 ),
|
|
542
|
+
// TINV invoices carry the full store list (id, name, product workingdays).
|
|
543
|
+
storeDetails: storeDetails,
|
|
446
544
|
currency: group.currency ? group.currency : 'inr',
|
|
447
545
|
monthOfbilling: baseDate.format( 'MM' ),
|
|
448
546
|
dueDate: dueDate,
|
|
449
|
-
|
|
450
|
-
|
|
547
|
+
// Stored as a NORMAL invoice when an advance already covered this month.
|
|
548
|
+
advanceInvoice: isAdvance || false,
|
|
549
|
+
advancePeriod: isAdvance ? ( group.advancePeriod || 'monthly' ) : undefined,
|
|
451
550
|
advanceMonths: advanceMonths,
|
|
452
551
|
};
|
|
453
552
|
|
|
@@ -476,12 +575,12 @@ export async function createInvoice( req, res ) {
|
|
|
476
575
|
await invoiceService.create( data );
|
|
477
576
|
|
|
478
577
|
if ( !req.body.regenrate ) {
|
|
479
|
-
let invoiceType =
|
|
578
|
+
let invoiceType = isAdvance ? 'advance' : '';
|
|
480
579
|
let logObj = {
|
|
481
580
|
userName: req.user?.userName,
|
|
482
581
|
email: req.user?.email,
|
|
483
582
|
clientId: group.clientId,
|
|
484
|
-
logSubType:
|
|
583
|
+
logSubType: isAdvance ? 'advanceInvoiceCreated' : 'invoiceCreated',
|
|
485
584
|
logType: 'invoice',
|
|
486
585
|
date: new Date(),
|
|
487
586
|
changes: [ `${data.invoice} ${invoiceType} invoice has been generated for ${group.groupName} for ${baseDate.format( 'MMM YYYY' )}` ],
|
|
@@ -511,6 +610,17 @@ function getCurrentFinancialYear() {
|
|
|
511
610
|
}
|
|
512
611
|
}
|
|
513
612
|
|
|
613
|
+
// Invoice-number prefix. Advance invoices for quarterly / half-yearly / yearly
|
|
614
|
+
// use a separate "TINV-" series with its own counter; everything else
|
|
615
|
+
// (including MONTHLY advance) uses the normal "INV-" series.
|
|
616
|
+
function invoicePrefixFor( advanceInvoice, advancePeriod ) {
|
|
617
|
+
const multiMonth = [ 'quarterly', 'halfyearly', 'yearly' ];
|
|
618
|
+
if ( advanceInvoice && multiMonth.includes( String( advancePeriod || '' ).toLowerCase() ) ) {
|
|
619
|
+
return 'TINV-';
|
|
620
|
+
}
|
|
621
|
+
return 'INV-';
|
|
622
|
+
}
|
|
623
|
+
|
|
514
624
|
|
|
515
625
|
// ---------------------------------------------------------------------------
|
|
516
626
|
// Shared annexure builder. Anchored to the invoice's BILLING month and
|
|
@@ -707,7 +817,7 @@ export async function invoiceDownload( req, res ) {
|
|
|
707
817
|
companyAddress: invoiceInfo.companyAddress,
|
|
708
818
|
PlaceOfSupply: invoiceInfo.PlaceOfSupply,
|
|
709
819
|
GSTNumber: invoiceInfo.GSTNumber,
|
|
710
|
-
PoNum: getgroup?.po,
|
|
820
|
+
PoNum: invoiceInfo.purchaseOrderNumber || getgroup?.po || '',
|
|
711
821
|
amountwords: AmountinWords,
|
|
712
822
|
Terms: `Term ${getgroup?.paymentTerm ? getgroup?.paymentTerm : '30'}`,
|
|
713
823
|
currencyType: invoiceCurrency,
|
|
@@ -903,7 +1013,7 @@ async function buildInvoicePdfBuffer( invoiceId ) {
|
|
|
903
1013
|
companyAddress: invoiceInfo.companyAddress,
|
|
904
1014
|
PlaceOfSupply: invoiceInfo.PlaceOfSupply,
|
|
905
1015
|
GSTNumber: invoiceInfo.GSTNumber,
|
|
906
|
-
PoNum: getgroup?.po,
|
|
1016
|
+
PoNum: invoiceInfo.purchaseOrderNumber || getgroup?.po || '',
|
|
907
1017
|
amountwords: AmountinWords,
|
|
908
1018
|
Terms: `Term ${getgroup?.paymentTerm ? getgroup?.paymentTerm : '30'}`,
|
|
909
1019
|
currencyType: invoiceCurrency,
|
|
@@ -2521,7 +2631,14 @@ export async function checkPaymentStatus( req, res ) {
|
|
|
2521
2631
|
|
|
2522
2632
|
export async function getInvoice( req, res ) {
|
|
2523
2633
|
try {
|
|
2524
|
-
|
|
2634
|
+
// Accept either a Mongo _id or an invoice NUMBER. A 24-char hex string is
|
|
2635
|
+
// treated as an _id; anything else (e.g. "INV-2026-00012") matches the
|
|
2636
|
+
// invoice number — so the Purchase Order popup can preview by number.
|
|
2637
|
+
const idParam = String( req.params.invoiceId || '' );
|
|
2638
|
+
const isObjectId = /^[a-fA-F0-9]{24}$/.test( idParam );
|
|
2639
|
+
let invoice = await invoiceService.findOne(
|
|
2640
|
+
isObjectId ? { _id: idParam } : { invoice: idParam },
|
|
2641
|
+
);
|
|
2525
2642
|
if ( !invoice ) {
|
|
2526
2643
|
return res.sendError( 'Invoice not found', 404 );
|
|
2527
2644
|
}
|
|
@@ -2551,7 +2668,7 @@ export async function updateInvoice( req, res ) {
|
|
|
2551
2668
|
'companyName', 'companyAddress', 'GSTNumber', 'PlaceOfSupply',
|
|
2552
2669
|
'groupName', 'groupId', 'stores', 'billingDate', 'dueDate',
|
|
2553
2670
|
'currency', 'status', 'paymentStatus', 'products', 'tax',
|
|
2554
|
-
'amount', 'totalAmount', 'paymentMethod',
|
|
2671
|
+
'amount', 'totalAmount', 'paymentMethod', 'purchaseOrderNumber',
|
|
2555
2672
|
];
|
|
2556
2673
|
|
|
2557
2674
|
allowedFields.forEach( ( field ) => {
|
|
@@ -2577,6 +2694,23 @@ export async function updateInvoice( req, res ) {
|
|
|
2577
2694
|
|
|
2578
2695
|
let updatedInvoice = await invoiceService.updateOne( { _id: req.body._id }, updateData );
|
|
2579
2696
|
|
|
2697
|
+
// PO mapping on edit: when a (new) PO number is set, deduct the invoice
|
|
2698
|
+
// amount from that PO. applyInvoiceToPurchaseOrder is idempotent per
|
|
2699
|
+
// invoice+PO, so re-saving the same PO won't double-deduct.
|
|
2700
|
+
if ( updateData.purchaseOrderNumber && updateData.purchaseOrderNumber !== invoice.purchaseOrderNumber ) {
|
|
2701
|
+
try {
|
|
2702
|
+
await applyInvoiceToPurchaseOrder( {
|
|
2703
|
+
clientId: invoice.clientId,
|
|
2704
|
+
purchaseOrderNumber: updateData.purchaseOrderNumber,
|
|
2705
|
+
invoice: invoice.invoice,
|
|
2706
|
+
amount: updateData.totalAmount != null ? updateData.totalAmount : invoice.totalAmount,
|
|
2707
|
+
req,
|
|
2708
|
+
} );
|
|
2709
|
+
} catch ( poErr ) {
|
|
2710
|
+
logger.error( { error: poErr, function: 'updateInvoice.applyPO', invoice: invoice.invoice } );
|
|
2711
|
+
}
|
|
2712
|
+
}
|
|
2713
|
+
|
|
2580
2714
|
let logObj = {
|
|
2581
2715
|
userName: req.user?.userName,
|
|
2582
2716
|
email: req.user?.email,
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import * as purchaseOrderService from '../services/purchaseOrder.service.js';
|
|
2
|
+
import { logger, insertOpenSearchData, fileUpload, customSignedUrl } from 'tango-app-api-middleware';
|
|
3
|
+
|
|
4
|
+
// Derive PO status from how much of it has been consumed.
|
|
5
|
+
function statusFor( totalAmount, remainingAmount ) {
|
|
6
|
+
if ( remainingAmount <= 0 ) {
|
|
7
|
+
return 'fullyUsed';
|
|
8
|
+
}
|
|
9
|
+
if ( remainingAmount < totalAmount ) {
|
|
10
|
+
return 'partiallyUsed';
|
|
11
|
+
}
|
|
12
|
+
return 'open';
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function logPO( req, clientId, subType, changes ) {
|
|
16
|
+
try {
|
|
17
|
+
const logObj = {
|
|
18
|
+
userName: req.user?.userName,
|
|
19
|
+
email: req.user?.email,
|
|
20
|
+
clientId: clientId,
|
|
21
|
+
logSubType: subType,
|
|
22
|
+
logType: 'purchaseOrder',
|
|
23
|
+
date: new Date(),
|
|
24
|
+
changes: changes,
|
|
25
|
+
eventType: '',
|
|
26
|
+
timestamp: new Date(),
|
|
27
|
+
showTo: [ 'tango' ],
|
|
28
|
+
};
|
|
29
|
+
insertOpenSearchData( JSON.parse( process.env.OPENSEARCH ).activityLog, logObj );
|
|
30
|
+
} catch ( e ) {
|
|
31
|
+
logger.error( { error: e, function: 'logPO' } );
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Create a purchase order. remainingAmount starts equal to totalAmount.
|
|
36
|
+
export async function createPurchaseOrder( req, res ) {
|
|
37
|
+
try {
|
|
38
|
+
const clientId = String( req.body?.clientId || '' );
|
|
39
|
+
const companyName = String( req.body?.companyName || '' ).trim();
|
|
40
|
+
const purchaseOrderNumber = String( req.body?.purchaseOrderNumber || '' ).trim();
|
|
41
|
+
const totalAmount = Number( req.body?.totalAmount ) || 0;
|
|
42
|
+
const date = req.body?.date ? new Date( req.body.date ) : new Date();
|
|
43
|
+
|
|
44
|
+
if ( !clientId ) {
|
|
45
|
+
return res.sendError( 'clientId is required', 400 );
|
|
46
|
+
}
|
|
47
|
+
if ( !purchaseOrderNumber ) {
|
|
48
|
+
return res.sendError( 'purchaseOrderNumber is required', 400 );
|
|
49
|
+
}
|
|
50
|
+
if ( !( totalAmount > 0 ) ) {
|
|
51
|
+
return res.sendError( 'totalAmount must be greater than 0', 400 );
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Prevent duplicate PO numbers for the same client.
|
|
55
|
+
const existing = await purchaseOrderService.findOne( { clientId, purchaseOrderNumber } );
|
|
56
|
+
if ( existing ) {
|
|
57
|
+
return res.sendError( 'A purchase order with this number already exists', 409 );
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Optional PO PDF upload (multer single 'file') → assets bucket, same as
|
|
61
|
+
// brand document upload. PDF only.
|
|
62
|
+
let pdfPath = '';
|
|
63
|
+
const file = req.file;
|
|
64
|
+
if ( file ) {
|
|
65
|
+
if ( file.mimetype !== 'application/pdf' ) {
|
|
66
|
+
return res.sendError( 'Only PDF files are allowed', 400 );
|
|
67
|
+
}
|
|
68
|
+
const bucket = JSON.parse( process.env.BUCKET );
|
|
69
|
+
const safeName = purchaseOrderNumber.replace( /[^a-zA-Z0-9-_]/g, '_' );
|
|
70
|
+
const fileName = `${safeName}_${Date.now()}.pdf`;
|
|
71
|
+
const key = `${clientId}/purchase-orders/`;
|
|
72
|
+
let result = await fileUpload( {
|
|
73
|
+
Bucket: bucket.assets,
|
|
74
|
+
Key: key,
|
|
75
|
+
fileName: fileName,
|
|
76
|
+
ContentType: file.mimetype,
|
|
77
|
+
body: file.buffer,
|
|
78
|
+
} );
|
|
79
|
+
pdfPath = `${key}${fileName}`;
|
|
80
|
+
console.log( '🚀 ~ createPurchaseOrder ~ result:', result );
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const created = await purchaseOrderService.create( {
|
|
84
|
+
clientId,
|
|
85
|
+
companyName,
|
|
86
|
+
purchaseOrderNumber,
|
|
87
|
+
totalAmount,
|
|
88
|
+
remainingAmount: totalAmount,
|
|
89
|
+
date,
|
|
90
|
+
status: 'open',
|
|
91
|
+
usage: [],
|
|
92
|
+
pdfPath,
|
|
93
|
+
createdBy: req.user?.email || req.user?.userName || '',
|
|
94
|
+
} );
|
|
95
|
+
|
|
96
|
+
logPO( req, clientId, 'purchaseOrderCreated', [ `PO ${purchaseOrderNumber} created with amount ${totalAmount}` ] );
|
|
97
|
+
return res.sendSuccess( created );
|
|
98
|
+
} catch ( error ) {
|
|
99
|
+
logger.error( { error: error, function: 'createPurchaseOrder' } );
|
|
100
|
+
return res.sendError( error, 500 );
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// List a client's purchase orders (newest first).
|
|
105
|
+
export async function getPurchaseOrders( req, res ) {
|
|
106
|
+
try {
|
|
107
|
+
const clientId = String( req.query?.clientId || req.params?.clientId || '' );
|
|
108
|
+
if ( !clientId ) {
|
|
109
|
+
return res.sendError( 'clientId is required', 400 );
|
|
110
|
+
}
|
|
111
|
+
const orders = await purchaseOrderService.find( { clientId } );
|
|
112
|
+
const sorted = ( orders || [] ).sort( ( a, b ) =>
|
|
113
|
+
new Date( b.createdAt || b.date || 0 ) - new Date( a.createdAt || a.date || 0 ) );
|
|
114
|
+
// Attach a signed URL for any uploaded PO PDF (assets bucket).
|
|
115
|
+
const bucket = JSON.parse( process.env.BUCKET );
|
|
116
|
+
const withUrls = await Promise.all( sorted.map( async ( o ) => {
|
|
117
|
+
const plain = o.toObject?.() || o;
|
|
118
|
+
let pdfUrl = '';
|
|
119
|
+
if ( plain.pdfPath ) {
|
|
120
|
+
try {
|
|
121
|
+
pdfUrl = await customSignedUrl( { Bucket: bucket.assets, file_path: plain.pdfPath }, 8 );
|
|
122
|
+
} catch ( e ) {
|
|
123
|
+
pdfUrl = '';
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return { ...plain, pdfUrl };
|
|
127
|
+
} ) );
|
|
128
|
+
return res.sendSuccess( { purchaseOrders: withUrls } );
|
|
129
|
+
} catch ( error ) {
|
|
130
|
+
logger.error( { error: error, function: 'getPurchaseOrders' } );
|
|
131
|
+
return res.sendError( error, 500 );
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Map an invoice to a PO: deduct the invoice amount from the PO's remaining
|
|
136
|
+
// balance, record the usage, update status, and log it. Shared with invoice
|
|
137
|
+
// generation/edit so the deduction happens wherever an invoice gets a PO.
|
|
138
|
+
// Returns the updated PO doc, or throws on a missing PO.
|
|
139
|
+
export async function applyInvoiceToPurchaseOrder( { clientId, purchaseOrderNumber, invoice, amount, req } ) {
|
|
140
|
+
if ( !purchaseOrderNumber ) {
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
const po = await purchaseOrderService.findOne( { clientId, purchaseOrderNumber } );
|
|
144
|
+
if ( !po ) {
|
|
145
|
+
logger.error( { function: 'applyInvoiceToPurchaseOrder', message: 'PO not found', clientId, purchaseOrderNumber } );
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
// Idempotency: if this invoice is already mapped to this PO, don't double-deduct.
|
|
149
|
+
const alreadyMapped = ( po.usage || [] ).some( ( u ) => u.invoice === invoice );
|
|
150
|
+
if ( alreadyMapped ) {
|
|
151
|
+
return po;
|
|
152
|
+
}
|
|
153
|
+
const amt = Number( amount ) || 0;
|
|
154
|
+
const remainingAmount = Math.round( ( ( po.remainingAmount || 0 ) - amt ) * 100 ) / 100;
|
|
155
|
+
const usage = ( po.usage || [] ).concat( [ {
|
|
156
|
+
invoice,
|
|
157
|
+
amount: amt,
|
|
158
|
+
mappedAt: new Date(),
|
|
159
|
+
mappedBy: req?.user?.email || req?.user?.userName || '',
|
|
160
|
+
} ] );
|
|
161
|
+
const status = statusFor( po.totalAmount || 0, remainingAmount );
|
|
162
|
+
await purchaseOrderService.updateOne(
|
|
163
|
+
{ clientId, purchaseOrderNumber },
|
|
164
|
+
{ remainingAmount, usage, status },
|
|
165
|
+
);
|
|
166
|
+
if ( req ) {
|
|
167
|
+
logPO( req, clientId, 'purchaseOrderInvoiceMapped',
|
|
168
|
+
[ `Invoice ${invoice} mapped to PO ${purchaseOrderNumber}; ${amt} deducted, remaining ${remainingAmount}` ] );
|
|
169
|
+
}
|
|
170
|
+
return { ...po.toObject?.() || po, remainingAmount, usage, status };
|
|
171
|
+
}
|
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import express from 'express';
|
|
2
|
+
import multer from 'multer';
|
|
2
3
|
export const billingRouter = express.Router();
|
|
4
|
+
const poUpload = multer( { storage: multer.memoryStorage(), limits: { fileSize: 10 * 1024 * 1024 } } );
|
|
3
5
|
import { accessVerification, isAllowedSessionHandler, validate } from 'tango-app-api-middleware';
|
|
4
6
|
import { getPaymentReminder, savePaymentReminder } from '../controllers/paymentReminder.controller.js';
|
|
5
7
|
import { triggerPaymentReminders } from '../controllers/paymentReminderTrigger.controller.js';
|
|
8
|
+
import { createPurchaseOrder, getPurchaseOrders } from '../controllers/purchaseOrder.controller.js';
|
|
6
9
|
import { createBillingGroup, deleteBillingGroup, getAllBillingGroups, getBillingGroups, getClientProducts, getInvoices, getLeadProducts, getBaseProducts, onetimePayment, subscribedStoreList, updateBillingGroup, gstinLookup } from '../controllers/billing.controllers.js';
|
|
7
10
|
import { billingGroupSchema, clientProductsValid, createBillingGroupsSchema, deleteBillingGroupsSchema, getBillingGroupsSchema, getInvoiceSchema, leadProductsValid, onetimeFeeValid, subscribedStoreListSchema, updateBillingGroupsSchema } from '../dtos/validation.dtos.js';
|
|
8
11
|
|
|
@@ -37,6 +40,10 @@ billingRouter.get( '/gst-lookup/:gstin', isAllowedSessionHandler, accessVerifica
|
|
|
37
40
|
billingRouter.get( '/payment-reminder/:clientId', isAllowedSessionHandler, accessVerification( { userType: [ 'tango' ], access: [ { featureName: 'Global', name: 'Billing', permissions: [ 'isAdd' ] } ] } ), getPaymentReminder );
|
|
38
41
|
billingRouter.post( '/payment-reminder', isAllowedSessionHandler, accessVerification( { userType: [ 'tango' ], access: [ { featureName: 'Global', name: 'Billing', permissions: [ 'isAdd' ] } ] } ), savePaymentReminder );
|
|
39
42
|
|
|
43
|
+
// Purchase Orders (brand-view Purchase Order tab).
|
|
44
|
+
billingRouter.get( '/purchase-orders', isAllowedSessionHandler, accessVerification( { userType: [ 'tango' ], access: [ { featureName: 'Global', name: 'Billing', permissions: [ 'isAdd' ] } ] } ), getPurchaseOrders );
|
|
45
|
+
billingRouter.post( '/purchase-orders', isAllowedSessionHandler, accessVerification( { userType: [ 'tango' ], access: [ { featureName: 'Global', name: 'Billing', permissions: [ 'isAdd' ] } ] } ), poUpload.single( 'file' ), createPurchaseOrder );
|
|
46
|
+
|
|
40
47
|
// Cron-triggered: sends the configured payment reminder emails. Unauthenticated
|
|
41
48
|
// like the other cron endpoints; protect at the network / scheduler layer.
|
|
42
49
|
billingRouter.post( '/payment-reminder/trigger', triggerPaymentReminders );
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import model from 'tango-api-schema';
|
|
2
|
+
|
|
3
|
+
export const find = ( query = {}, record = {} ) => {
|
|
4
|
+
return model.purchaseOrderModel.find( query, record );
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
export const findOne = ( query = {}, record = {} ) => {
|
|
8
|
+
return model.purchaseOrderModel.findOne( query, record );
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export const create = ( record ) => {
|
|
12
|
+
return model.purchaseOrderModel.create( record );
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export const updateOne = ( query, record ) => {
|
|
16
|
+
return model.purchaseOrderModel.updateOne( query, { $set: record } );
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export const aggregate = ( query = [] ) => {
|
|
20
|
+
return model.purchaseOrderModel.aggregate( query );
|
|
21
|
+
};
|