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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tango-app-api-payment-subscription",
3
- "version": "3.5.17",
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.35",
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
- return res.sendSuccess( billingGroups );
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
- if ( result.modifiedCount === 0 ) {
1061
- return res.sendError( 'Record not found or no changes made', 204 );
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: `^INV-${Finacialyear}-` } },
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: `INV-${Finacialyear}-${invoiceNo}`,
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
- // Scope the highest-index lookup to invoices created in the current FY
220
- // (invoice IDs are `INV-${FY}-${index}`). Without this scope the new FY
221
- // would continue the previous year's sequence instead of resetting.
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: `^INV-${Finacialyear}-` } },
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
- let baseDate = group.advanceInvoice ? dayjs().add( 1, 'month' ).startOf( 'month' ) : dayjs();
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 ( getClient?.priceType === 'standard' ) {
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
- const extraProducts = await getAdditionalProducts( group.clientId );
251
- for ( const ep of extraProducts ) {
252
- products.push( {
253
- productName: ep.productName,
254
- period: 'fullmonth',
255
- storeCount: ep.quantity,
256
- amount: ep.total,
257
- price: ep.price,
258
- description: ep.productName,
259
- HsnNumber: '998314',
260
- month: baseDate.format( 'MMM YYYY' ),
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 = group.advanceInvoice ?
336
+ const advanceMonths = isAdvance ?
276
337
  ( advancePeriodMonths[group.advancePeriod] || 1 ) :
277
338
  ( paymentCycleMonths[group.paymentCycle] || 1 );
278
- if ( advanceMonths > 1 ) {
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 : `INV-${Finacialyear}-${invoiceNo}`,
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
- stores: totalStoreCount.length ? totalStoreCount[0].stores.length : 0,
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
- advanceInvoice: group.advanceInvoice || false,
450
- advancePeriod: group.advanceInvoice ? ( group.advancePeriod || 'monthly' ) : undefined,
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 = group.advanceInvoice ? 'advance' : '';
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: group.advanceInvoice ? 'advanceInvoiceCreated' : 'invoiceCreated',
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
- let invoice = await invoiceService.findOne( { _id: req.params.invoiceId } );
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
+ };