tango-app-api-payment-subscription 3.5.8 → 3.5.10

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.8",
3
+ "version": "3.5.10",
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.27",
32
+ "tango-api-schema": "^2.6.29",
33
33
  "tango-app-api-middleware": "^3.6.18",
34
34
  "winston": "^3.12.0",
35
35
  "winston-daily-rotate-file": "^5.0.0",
@@ -467,69 +467,118 @@ export async function resolveBankTransaction( req, res ) {
467
467
  }
468
468
 
469
469
  // ---- reconcile ----
470
- const invoiceId = req.body?.invoiceId;
471
- if ( !invoiceId ) {
472
- return res.sendError( 'invoiceId is required to reconcile', 400 );
473
- }
474
- const invoice = await invoiceService.findOne( { _id: invoiceId } );
475
- if ( !invoice ) {
476
- return res.sendError( 'Invoice not found', 404 );
470
+ // Accept either a single invoiceId (legacy) or a list of invoiceIds. One
471
+ // payment can settle multiple invoices: the amount auto-fills them in the
472
+ // order given, each settled fully until the money runs out.
473
+ const invoiceIds = Array.isArray( req.body?.invoiceIds ) && req.body.invoiceIds.length ?
474
+ req.body.invoiceIds.map( String ) :
475
+ ( req.body?.invoiceId ? [ String( req.body.invoiceId ) ] : [] );
476
+ if ( !invoiceIds.length ) {
477
+ return res.sendError( 'invoiceIds is required to reconcile', 400 );
477
478
  }
478
479
 
479
- const prevPaid = Number( invoice.paidAmount ) || 0;
480
- const totalAmount = Number( invoice.totalAmount ) || 0;
481
- const outstanding = round2( totalAmount - prevPaid );
482
- const diff = round2( txn.amount - outstanding );
483
-
484
- let note;
485
- let newStatus;
486
- if ( Math.abs( diff ) <= AMOUNT_TOLERANCE ) {
487
- newStatus = 'paid';
488
- note = `Manually reconciled to ${invoice.invoice} invoice marked Paid`;
489
- } else if ( diff < 0 ) {
490
- newStatus = 'partial';
491
- note = `Partial — ${round2( -diff )} balance remains on ${invoice.invoice}`;
492
- } else {
493
- newStatus = 'paid';
494
- note = `Overpayment ${invoice.invoice} settled, ${round2( diff )} excess to follow up`;
480
+ // EDIT case: this transaction was ALREADY reconciled, so its amount was
481
+ // previously applied to one or more invoices. Reverse each prior allocation
482
+ // first so re-reconciling never double-counts. Prefer the recorded
483
+ // per-invoice allocations; fall back to the single invoiceId for older rows.
484
+ if ( txn.status === 'reconciled' ) {
485
+ const priorAllocations = ( Array.isArray( txn.appliedInvoices ) && txn.appliedInvoices.length ) ?
486
+ txn.appliedInvoices :
487
+ ( txn.invoiceId ? [ { invoiceId: txn.invoiceId, amount: txn.amount } ] : [] );
488
+ for ( const alloc of priorAllocations ) {
489
+ const prevInvoice = await invoiceService.findOne( { _id: alloc.invoiceId } );
490
+ if ( !prevInvoice ) {
491
+ continue;
492
+ }
493
+ const revertedPaid = round2( Math.max( 0, ( Number( prevInvoice.paidAmount ) || 0 ) - ( Number( alloc.amount ) || 0 ) ) );
494
+ const prevTotal = Number( prevInvoice.totalAmount ) || 0;
495
+ const revertedStatus = revertedPaid <= AMOUNT_TOLERANCE ? 'unpaid' :
496
+ ( round2( prevTotal - revertedPaid ) > AMOUNT_TOLERANCE ? 'partial' : 'paid' );
497
+ await invoiceService.invoiceUpdateOne(
498
+ { _id: prevInvoice._id },
499
+ {
500
+ $set: { paymentStatus: revertedStatus, paidAmount: revertedPaid },
501
+ $pull: { paymentHistory: { reference: txn.refNo || undefined, method: 'bank-statement' } },
502
+ },
503
+ );
504
+ }
495
505
  }
496
- const newPaid = round2( prevPaid + txn.amount );
497
-
498
- await invoiceService.invoiceUpdateOne(
499
- { _id: invoice._id },
500
- {
501
- $set: {
502
- paymentStatus: newStatus,
503
- paidAmount: newPaid,
504
- ...( newStatus === 'paid' ? { paidDate: new Date() } : {} ),
505
- },
506
- $push: {
507
- paymentHistory: {
508
- amount: txn.amount,
509
- date: txn.valueDate || new Date(),
510
- method: 'bank-statement',
511
- reference: txn.refNo || undefined,
512
- notes: `Manually reconciled from "${txn.fileName || 'statement'}"`,
513
- recordedBy: req.user?.email || req.user?.userName || 'bank-reconciliation',
514
- recordedAt: new Date(),
506
+
507
+ // Apply the payment across the selected invoices in order (auto-fill).
508
+ let remaining = round2( txn.amount );
509
+ const appliedInvoices = [];
510
+ const settledLabels = [];
511
+ let firstInvoice = null;
512
+ for ( const id of invoiceIds ) {
513
+ const invoice = await invoiceService.findOne( { _id: id } );
514
+ if ( !invoice ) {
515
+ continue;
516
+ }
517
+ if ( !firstInvoice ) {
518
+ firstInvoice = invoice;
519
+ }
520
+ const prevPaid = Number( invoice.paidAmount ) || 0;
521
+ const totalAmount = Number( invoice.totalAmount ) || 0;
522
+ const outstanding = round2( Math.max( 0, totalAmount - prevPaid ) );
523
+ const applied = round2( Math.max( 0, Math.min( remaining, outstanding ) ) );
524
+ if ( applied <= 0 && outstanding > 0 ) {
525
+ // No money left for this invoice — record it as selected but unfunded.
526
+ continue;
527
+ }
528
+ remaining = round2( remaining - applied );
529
+ const newPaid = round2( prevPaid + applied );
530
+ const fullySettled = round2( totalAmount - newPaid ) <= AMOUNT_TOLERANCE;
531
+ await invoiceService.invoiceUpdateOne(
532
+ { _id: invoice._id },
533
+ {
534
+ $set: {
535
+ paymentStatus: fullySettled ? 'paid' : 'partial',
536
+ paidAmount: newPaid,
537
+ ...( fullySettled ? { paidDate: new Date() } : {} ),
538
+ },
539
+ $push: {
540
+ paymentHistory: {
541
+ amount: applied,
542
+ date: txn.valueDate || new Date(),
543
+ method: 'bank-statement',
544
+ reference: txn.refNo || undefined,
545
+ notes: `Manually reconciled from "${txn.fileName || 'statement'}"`,
546
+ recordedBy: req.user?.email || req.user?.userName || 'bank-reconciliation',
547
+ recordedAt: new Date(),
548
+ },
515
549
  },
516
550
  },
517
- },
518
- );
551
+ );
552
+ appliedInvoices.push( { invoiceId: String( invoice._id ), invoice: invoice.invoice, amount: applied } );
553
+ settledLabels.push( `${invoice.invoice} (${fullySettled ? 'Paid' : 'Partial'})` );
554
+ }
555
+
556
+ if ( !appliedInvoices.length ) {
557
+ return res.sendError( 'None of the selected invoices could be reconciled.', 400 );
558
+ }
559
+
560
+ const pending = round2( remaining );
561
+ let note = appliedInvoices.length > 1 ?
562
+ `Reconciled across ${appliedInvoices.length} invoices: ${settledLabels.join( ', ' )}` :
563
+ `Manually reconciled to ${settledLabels[0]}`;
564
+ if ( pending > AMOUNT_TOLERANCE ) {
565
+ note += ` — ₹${pending} excess to follow up`;
566
+ }
519
567
 
520
- // Brand name for the table's Brand Name column.
521
- const client = await clientService.findOne( { clientId: invoice.clientId }, { clientName: 1 } );
568
+ // Brand name for the table's Brand Name column (from the first invoice).
569
+ const client = await clientService.findOne( { clientId: firstInvoice.clientId }, { clientName: 1 } );
522
570
  await bankTransactionService.updateOne( { _id: txnId }, { $set: {
523
571
  status: 'reconciled',
524
572
  contacted: false,
525
- invoice: invoice.invoice,
526
- invoiceId: String( invoice._id ),
527
- identifiedClientId: String( invoice.clientId ?? '' ),
528
- identifiedClientName: client?.clientName || invoice.companyName || '',
573
+ invoice: appliedInvoices.map( ( a ) => a.invoice ).join( ', ' ),
574
+ invoiceId: appliedInvoices[0].invoiceId,
575
+ appliedInvoices,
576
+ identifiedClientId: String( firstInvoice.clientId ?? '' ),
577
+ identifiedClientName: client?.clientName || firstInvoice.companyName || '',
529
578
  resultNote: note,
530
579
  } } );
531
580
 
532
- return res.sendSuccess( { status: 'reconciled', note, invoice: invoice.invoice } );
581
+ return res.sendSuccess( { status: 'reconciled', note, invoices: appliedInvoices.map( ( a ) => a.invoice ), pending } );
533
582
  } catch ( error ) {
534
583
  logger.error( { error: error, function: 'resolveBankTransaction' } );
535
584
  return res.sendError( error, 500 );
@@ -30,11 +30,36 @@ export async function brandsBillingList( req, res ) {
30
30
  }
31
31
 
32
32
  if ( req.body.paymentStatus && req.body.paymentStatus.length > 0 ) {
33
- query.push( {
34
- $match: {
35
- 'planDetails.paymentStatus': { $in: req.body.paymentStatus },
36
- },
37
- } );
33
+ // Filter by the SAME derived buckets the pills use (see payBucketExpr),
34
+ // so a selected pill returns exactly the rows it counts:
35
+ // trial -> paymentStatus == 'trial'
36
+ // free -> paymentStatus == 'free'
37
+ // paid -> paymentStatus in {paid,unbilled,due} AND no trial product
38
+ // trialPaid -> paymentStatus in {paid,unbilled,due} AND has a trial product
39
+ // 'trialPaid' is a derived bucket — there is no stored 'trialPaid'
40
+ // status — which is why a plain $in match returned no data.
41
+ const buckets = req.body.paymentStatus;
42
+ const paidLike = [ 'paid', 'unbilled', 'due' ];
43
+ const hasTrialProduct = { $gt: [ { $size: { $filter: {
44
+ input: { $ifNull: [ '$planDetails.product', [] ] },
45
+ as: 'p',
46
+ cond: { $eq: [ '$$p.status', 'trial' ] },
47
+ } } }, 0 ] };
48
+ const ors = [];
49
+ for ( const b of buckets ) {
50
+ if ( b === 'trial' || b === 'free' ) {
51
+ ors.push( { $eq: [ '$planDetails.paymentStatus', b ] } );
52
+ } else if ( b === 'paid' ) {
53
+ ors.push( { $and: [ { $in: [ '$planDetails.paymentStatus', paidLike ] }, { $not: hasTrialProduct } ] } );
54
+ } else if ( b === 'trialPaid' ) {
55
+ ors.push( { $and: [ { $in: [ '$planDetails.paymentStatus', paidLike ] }, hasTrialProduct ] } );
56
+ } else {
57
+ ors.push( { $eq: [ '$planDetails.paymentStatus', b ] } );
58
+ }
59
+ }
60
+ if ( ors.length ) {
61
+ query.push( { $match: { $expr: ors.length === 1 ? ors[0] : { $or: ors } } } );
62
+ }
38
63
  }
39
64
 
40
65
  query.push(
@@ -173,6 +198,21 @@ export async function brandsBillingList( req, res ) {
173
198
  dueInr: { $ifNull: [ '$invoiceData.dueInr', 0 ] },
174
199
  dueUsd: { $ifNull: [ '$invoiceData.dueUsd', 0 ] },
175
200
  nextBillingDate: { $ifNull: [ '$billingData.nextBillingDate', null ] },
201
+ // Whether billing is SET UP — true when a billing group exists (or
202
+ // live products are configured). Drives the Setup-Billing vs View
203
+ // button; must NOT depend on the volatile current-month run count.
204
+ billingConfigured: {
205
+ $or: [
206
+ { $ne: [ '$billingData', null ] },
207
+ { $gt: [ {
208
+ $size: { $filter: {
209
+ input: { $ifNull: [ '$planDetails.product', [] ] },
210
+ as: 'prod',
211
+ cond: { $eq: [ '$$prod.status', 'live' ] },
212
+ } },
213
+ }, 0 ] },
214
+ ],
215
+ },
176
216
  },
177
217
  },
178
218
  {
@@ -184,10 +224,25 @@ export async function brandsBillingList( req, res ) {
184
224
  totalStores: 1,
185
225
  billingStores: 1,
186
226
  productsAdded: 1,
227
+ billingConfigured: 1,
187
228
  dueInr: 1,
188
229
  dueUsd: 1,
189
230
  status: 1,
190
- paymentStatus: '$planDetails.paymentStatus',
231
+ // Display mapping for the Payment Status column: an 'unbilled' or
232
+ // 'due' plan is shown as 'paid'; any other status is shown as-is.
233
+ paymentStatus: {
234
+ $cond: {
235
+ if: { $eq: [ '$planDetails.paymentStatus', 'unbilled' ] },
236
+ then: 'paid',
237
+ else: {
238
+ $cond: {
239
+ if: { $eq: [ '$planDetails.paymentStatus', 'due' ] },
240
+ then: 'paid',
241
+ else: '$planDetails.paymentStatus',
242
+ },
243
+ },
244
+ },
245
+ },
191
246
  nextBillingDate: 1,
192
247
  currencyType: '$paymentInvoice.currencyType',
193
248
  },
@@ -249,9 +304,11 @@ export async function brandsBillingList( req, res ) {
249
304
  branches: [
250
305
  { case: { $eq: [ '$$ps', 'trial' ] }, then: 'trial' },
251
306
  { case: { $eq: [ '$$ps', 'free' ] }, then: 'free' },
252
- { case: { $and: [ { $eq: [ '$$ps', 'paid' ] }, '$$hasTrialProduct' ] }, then: 'trialPaid' },
253
- { case: { $eq: [ '$$ps', 'paid' ] }, then: 'paid' },
254
- { case: { $eq: [ '$$ps', 'unbilled' ] }, then: 'unbilled' },
307
+ // 'unbilled' and 'due' are treated as PAID (same mapping as the
308
+ // Payment Status column). A paid/unbilled/due plan that still has
309
+ // a product on trial is bucketed as trialPaid.
310
+ { case: { $and: [ { $in: [ '$$ps', [ 'paid', 'unbilled', 'due' ] ] }, '$$hasTrialProduct' ] }, then: 'trialPaid' },
311
+ { case: { $in: [ '$$ps', [ 'paid', 'unbilled', 'due' ] ] }, then: 'paid' },
255
312
  ],
256
313
  default: 'other',
257
314
  },
@@ -268,7 +325,7 @@ export async function brandsBillingList( req, res ) {
268
325
  // regardless of the selected tab, and show even when the Active tab is
269
326
  // empty.
270
327
  const lifecycle = { active: 0, hold: 0, suspended: 0, deactive: 0 };
271
- const payTotals = { trial: 0, paid: 0, free: 0, trialPaid: 0, unbilled: 0 };
328
+ const payTotals = { trial: 0, paid: 0, free: 0, trialPaid: 0 };
272
329
  const paymentByStatus = {};
273
330
  let totalBrands = 0;
274
331
  matrixAgg.forEach( ( row ) => {
@@ -283,7 +340,7 @@ export async function brandsBillingList( req, res ) {
283
340
  payTotals[pay] += n;
284
341
  }
285
342
  if ( !paymentByStatus[st] ) {
286
- paymentByStatus[st] = { trial: 0, paid: 0, free: 0, trialPaid: 0, unbilled: 0 };
343
+ paymentByStatus[st] = { trial: 0, paid: 0, free: 0, trialPaid: 0 };
287
344
  }
288
345
  if ( paymentByStatus[st][pay] != null ) {
289
346
  paymentByStatus[st][pay] += n;
@@ -300,7 +357,6 @@ export async function brandsBillingList( req, res ) {
300
357
  paid: payTotals.paid,
301
358
  free: payTotals.free,
302
359
  trialPaid: payTotals.trialPaid,
303
- unbilled: payTotals.unbilled,
304
360
  paymentByStatus,
305
361
  // Money/store totals stay tied to the filtered view so they match the
306
362
  // rows on screen.
@@ -594,8 +650,14 @@ export async function latestDailyPricing( req, res ) {
594
650
  let stores = record.stores || [];
595
651
  let count = stores.length;
596
652
 
597
- if ( req.body.statusFilter && req.body.statusFilter !== '' ) {
598
- stores = stores.filter( ( s ) => s.status === req.body.statusFilter );
653
+ // statusFilter may be an array (multi-select) or a legacy string. An empty
654
+ // value / empty array means "all statuses".
655
+ const statusFilterList = Array.isArray( req.body.statusFilter ) ?
656
+ req.body.statusFilter.filter( Boolean ) :
657
+ ( req.body.statusFilter ? [ req.body.statusFilter ] : [] );
658
+ if ( statusFilterList.length ) {
659
+ const allowed = new Set( statusFilterList );
660
+ stores = stores.filter( ( s ) => allowed.has( s.status ) );
599
661
  }
600
662
 
601
663
  if ( req.body.searchValue && req.body.searchValue !== '' ) {
@@ -1557,18 +1619,38 @@ export async function billingSummary( req, res ) {
1557
1619
  } );
1558
1620
  const clientById = new Map( clients.map( ( c ) => [ String( c.clientId ), c ] ) );
1559
1621
 
1622
+ // Registered Entity comes from the billings collection (each billing group's
1623
+ // registeredCompanyName). A client can have multiple groups/names, so
1624
+ // collect the distinct list per client — the UI shows the first and reveals
1625
+ // the rest on hover.
1626
+ const billingNameAgg = await billingService.aggregatebilling( [
1627
+ { $match: { registeredCompanyName: { $exists: true, $nin: [ '', null ] } } },
1628
+ { $group: { _id: '$clientId', names: { $addToSet: '$registeredCompanyName' } } },
1629
+ ] );
1630
+ const regNamesByClient = new Map(
1631
+ billingNameAgg.map( ( b ) => [ String( b._id ), ( b.names || [] ).filter( Boolean ) ] ),
1632
+ );
1633
+
1560
1634
  // Per-client CSM (userAssignedStore, tangoUserType 'csm'); display the
1561
1635
  // email's local part since the collection carries no display name.
1562
1636
  const usdRate = await getUsdInrRate();
1563
1637
 
1564
- // Current month's store count comes from dailyPricing (latest reading
1565
- // this month) invoices for the running month usually don't exist yet.
1638
+ // Current month's store count comes from dailyPricing counted the same
1639
+ // way as Brands & Billing's "Billing Stores": distinct ACTIVE stores that
1640
+ // RAN on more than one day in the month (a single-day appearance is
1641
+ // transient and isn't billed). Invoices for the running month usually don't
1642
+ // exist yet, so this is the current-month source.
1566
1643
  const curMonthStart = new Date( now.startOf( 'month' ).toISOString() );
1567
1644
  const latestDp = await dailyPriceService.aggregate( [
1568
1645
  { $match: { dateISO: { $gte: curMonthStart } } },
1569
- { $project: { clientId: 1, activeStores: 1, dateISO: 1 } },
1570
- { $sort: { dateISO: 1 } },
1571
- { $group: { _id: '$clientId', stores: { $last: '$activeStores' } } },
1646
+ { $unwind: { path: '$stores', preserveNullAndEmptyArrays: false } },
1647
+ { $match: { 'stores.status': 'active' } },
1648
+ { $group: {
1649
+ _id: { clientId: '$clientId', storeId: '$stores.storeId' },
1650
+ days: { $addToSet: { $dateToString: { format: '%Y-%m-%d', date: '$dateISO' } } },
1651
+ } },
1652
+ { $match: { $expr: { $gt: [ { $size: '$days' }, 1 ] } } },
1653
+ { $group: { _id: '$_id.clientId', stores: { $sum: 1 } } },
1572
1654
  ] );
1573
1655
  const curStoresByClient = new Map( latestDp.map( ( d ) => [ String( d._id ), d.stores || 0 ] ) );
1574
1656
 
@@ -1762,10 +1844,17 @@ export async function billingSummary( req, res ) {
1762
1844
  revCur = revCur == null ? null : Math.round( revCur );
1763
1845
  const revPrevOut = revPrev == null ? null : Math.round( revPrev );
1764
1846
  const installationOut = r.installationFee ? Math.round( r.installationFee ) : null;
1847
+ // Registered Entity from the billings collection (distinct group names).
1848
+ // First name shown in the column; the full list is sent so the UI can
1849
+ // reveal the others on hover. Fall back to the invoice company name when
1850
+ // a client has no billing-group registered name.
1851
+ const regNames = regNamesByClient.get( r.clientId ) || [];
1852
+ const registeredEntity = regNames[0] || r.registeredEntity || '';
1765
1853
  return {
1766
1854
  clientId: r.clientId,
1767
- clientName: r.clientName || r.registeredEntity || r.clientId,
1768
- registeredEntity: r.registeredEntity,
1855
+ clientName: r.clientName || registeredEntity || r.clientId,
1856
+ registeredEntity,
1857
+ registeredEntities: regNames.length ? regNames : ( r.registeredEntity ? [ r.registeredEntity ] : [] ),
1769
1858
  status: r.status,
1770
1859
  products: r.products,
1771
1860
  csm: r.csm,