tango-app-api-payment-subscription 3.5.9 → 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.9",
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.28",
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(
@@ -203,7 +228,21 @@ export async function brandsBillingList( req, res ) {
203
228
  dueInr: 1,
204
229
  dueUsd: 1,
205
230
  status: 1,
206
- 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
+ },
207
246
  nextBillingDate: 1,
208
247
  currencyType: '$paymentInvoice.currencyType',
209
248
  },
@@ -265,9 +304,11 @@ export async function brandsBillingList( req, res ) {
265
304
  branches: [
266
305
  { case: { $eq: [ '$$ps', 'trial' ] }, then: 'trial' },
267
306
  { case: { $eq: [ '$$ps', 'free' ] }, then: 'free' },
268
- { case: { $and: [ { $eq: [ '$$ps', 'paid' ] }, '$$hasTrialProduct' ] }, then: 'trialPaid' },
269
- { case: { $eq: [ '$$ps', 'paid' ] }, then: 'paid' },
270
- { 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' },
271
312
  ],
272
313
  default: 'other',
273
314
  },
@@ -284,7 +325,7 @@ export async function brandsBillingList( req, res ) {
284
325
  // regardless of the selected tab, and show even when the Active tab is
285
326
  // empty.
286
327
  const lifecycle = { active: 0, hold: 0, suspended: 0, deactive: 0 };
287
- const payTotals = { trial: 0, paid: 0, free: 0, trialPaid: 0, unbilled: 0 };
328
+ const payTotals = { trial: 0, paid: 0, free: 0, trialPaid: 0 };
288
329
  const paymentByStatus = {};
289
330
  let totalBrands = 0;
290
331
  matrixAgg.forEach( ( row ) => {
@@ -299,7 +340,7 @@ export async function brandsBillingList( req, res ) {
299
340
  payTotals[pay] += n;
300
341
  }
301
342
  if ( !paymentByStatus[st] ) {
302
- paymentByStatus[st] = { trial: 0, paid: 0, free: 0, trialPaid: 0, unbilled: 0 };
343
+ paymentByStatus[st] = { trial: 0, paid: 0, free: 0, trialPaid: 0 };
303
344
  }
304
345
  if ( paymentByStatus[st][pay] != null ) {
305
346
  paymentByStatus[st][pay] += n;
@@ -316,7 +357,6 @@ export async function brandsBillingList( req, res ) {
316
357
  paid: payTotals.paid,
317
358
  free: payTotals.free,
318
359
  trialPaid: payTotals.trialPaid,
319
- unbilled: payTotals.unbilled,
320
360
  paymentByStatus,
321
361
  // Money/store totals stay tied to the filtered view so they match the
322
362
  // rows on screen.
@@ -610,8 +650,14 @@ export async function latestDailyPricing( req, res ) {
610
650
  let stores = record.stores || [];
611
651
  let count = stores.length;
612
652
 
613
- if ( req.body.statusFilter && req.body.statusFilter !== '' ) {
614
- 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 ) );
615
661
  }
616
662
 
617
663
  if ( req.body.searchValue && req.body.searchValue !== '' ) {
@@ -1573,6 +1619,18 @@ export async function billingSummary( req, res ) {
1573
1619
  } );
1574
1620
  const clientById = new Map( clients.map( ( c ) => [ String( c.clientId ), c ] ) );
1575
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
+
1576
1634
  // Per-client CSM (userAssignedStore, tangoUserType 'csm'); display the
1577
1635
  // email's local part since the collection carries no display name.
1578
1636
  const usdRate = await getUsdInrRate();
@@ -1786,10 +1844,17 @@ export async function billingSummary( req, res ) {
1786
1844
  revCur = revCur == null ? null : Math.round( revCur );
1787
1845
  const revPrevOut = revPrev == null ? null : Math.round( revPrev );
1788
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 || '';
1789
1853
  return {
1790
1854
  clientId: r.clientId,
1791
- clientName: r.clientName || r.registeredEntity || r.clientId,
1792
- registeredEntity: r.registeredEntity,
1855
+ clientName: r.clientName || registeredEntity || r.clientId,
1856
+ registeredEntity,
1857
+ registeredEntities: regNames.length ? regNames : ( r.registeredEntity ? [ r.registeredEntity ] : [] ),
1793
1858
  status: r.status,
1794
1859
  products: r.products,
1795
1860
  csm: r.csm,
@@ -17,6 +17,7 @@ import { symbolFor } from '../utils/currency.js';
17
17
  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
+ import * as bankTransactionService from '../services/bankTransaction.service.js';
20
21
  import { getUsdInrRate } from './brandsBilling.controller.js';
21
22
 
22
23
  // Pulls CSM + Finance head emails (stored under applicationDefault
@@ -136,6 +137,35 @@ export async function createInvoice( req, res ) {
136
137
  invoiceNo = invoiceNo.toString().padStart( 5, '0' );
137
138
  }
138
139
  const baseDate = req.body.billingDate ? dayjs( req.body.billingDate ) : dayjs();
140
+ let customProducts = Array.isArray( req.body.products ) ? req.body.products : [];
141
+
142
+ // Advance billing: monthly = 1, half-yearly = 6, yearly = 12. For a
143
+ // multi-month advance invoice, each month becomes its own line item —
144
+ // every posted product is repeated once per month, labelled with that
145
+ // month, so one invoice bills the whole period upfront (same logic as
146
+ // the auto-generated advance invoices).
147
+ const customAdvanceMonths = req.body.advanceInvoice ?
148
+ ( { quarterly: 3, halfyearly: 6, yearly: 12 }[req.body.advancePeriod] || 1 ) : 1;
149
+ if ( customAdvanceMonths > 1 ) {
150
+ const expanded = [];
151
+ for ( let m = 0; m < customAdvanceMonths; m++ ) {
152
+ const monthLabel = baseDate.add( m, 'month' ).format( 'MMM YYYY' );
153
+ for ( const p of customProducts ) {
154
+ expanded.push( { ...p, month: monthLabel } );
155
+ }
156
+ }
157
+ customProducts = expanded;
158
+ }
159
+ // Recompute totals from the (possibly expanded) line items so a
160
+ // multi-month advance invoice bills the full period.
161
+ const customAmount = customAdvanceMonths > 1 ?
162
+ Math.round( customProducts.reduce( ( s, p ) => s + ( Number( p.amount ) || 0 ), 0 ) ) :
163
+ Math.round( Number( req.body.amount ) || 0 );
164
+ const customTotal = customAdvanceMonths > 1 ?
165
+ Math.round( customProducts.reduce( ( s, p ) => s + ( Number( p.amount ) || 0 ), 0 ) +
166
+ ( Array.isArray( req.body.tax ) ? req.body.tax.reduce( ( s, t ) => s + ( Number( t.taxAmount ) || 0 ) * customAdvanceMonths, 0 ) : 0 ) ) :
167
+ Math.round( Number( req.body.totalAmount ) || 0 );
168
+
139
169
  const data = {
140
170
  invoice: `INV-${Finacialyear}-${invoiceNo}`,
141
171
  invoiceIndex: invoiceNo,
@@ -147,10 +177,10 @@ export async function createInvoice( req, res ) {
147
177
  companyAddress: req.body.companyAddress || '',
148
178
  GSTNumber: req.body.GSTNumber || '',
149
179
  PlaceOfSupply: req.body.PlaceOfSupply || '',
150
- products: Array.isArray( req.body.products ) ? req.body.products : [],
180
+ products: customProducts,
151
181
  tax: Array.isArray( req.body.tax ) ? req.body.tax : [],
152
- amount: Math.round( Number( req.body.amount ) || 0 ),
153
- totalAmount: Math.round( Number( req.body.totalAmount ) || 0 ),
182
+ amount: customAmount,
183
+ totalAmount: customTotal,
154
184
  stores: Number( req.body.stores ) || 0,
155
185
  currency: req.body.currency || 'inr',
156
186
  billingDate: baseDate.toDate(),
@@ -159,6 +189,9 @@ export async function createInvoice( req, res ) {
159
189
  paymentMethod: 'Online',
160
190
  status: 'pendingCsm',
161
191
  paymentStatus: 'unpaid',
192
+ advanceInvoice: req.body.advanceInvoice || false,
193
+ advancePeriod: req.body.advanceInvoice ? ( req.body.advancePeriod || 'monthly' ) : undefined,
194
+ advanceMonths: customAdvanceMonths,
162
195
  };
163
196
  const created = await invoiceService.create( data );
164
197
  const logObj = {
@@ -207,6 +240,32 @@ export async function createInvoice( req, res ) {
207
240
  } else {
208
241
  products = await stepPrice( group, getClient );
209
242
  }
243
+
244
+ // Billing horizon in months. Advance and normal cycle are independent —
245
+ // only one drives the span per generation:
246
+ // advanceInvoice ON -> advancePeriod (advance future billing)
247
+ // advanceInvoice OFF -> paymentCycle (normal billing cycle)
248
+ // For a multi-month span, each month becomes its own line item: every
249
+ // product is repeated once per month, labelled with that month, at the
250
+ // normal monthly amount. One invoice then bills the whole period.
251
+ // (advancePeriod uses 'halfyearly'; paymentCycle uses 'quarter'/'halfYearly'
252
+ // — map both spellings.)
253
+ const advancePeriodMonths = { quarterly: 3, halfyearly: 6, yearly: 12 };
254
+ const paymentCycleMonths = { quarter: 3, quarterly: 3, halfYearly: 6, halfyearly: 6, yearly: 12 };
255
+ const advanceMonths = group.advanceInvoice ?
256
+ ( advancePeriodMonths[group.advancePeriod] || 1 ) :
257
+ ( paymentCycleMonths[group.paymentCycle] || 1 );
258
+ if ( advanceMonths > 1 ) {
259
+ const expanded = [];
260
+ for ( let m = 0; m < advanceMonths; m++ ) {
261
+ const monthLabel = baseDate.add( m, 'month' ).format( 'MMM YYYY' );
262
+ for ( const p of products ) {
263
+ expanded.push( { ...p, month: monthLabel } );
264
+ }
265
+ }
266
+ products = expanded;
267
+ }
268
+
210
269
  let amount = products.reduce( ( sum, product ) => sum + product.amount, 0 );
211
270
  let taxList = [];
212
271
  let totalAmount = 0;
@@ -305,6 +364,8 @@ export async function createInvoice( req, res ) {
305
364
  monthOfbilling: baseDate.format( 'MM' ),
306
365
  dueDate: dueDate,
307
366
  advanceInvoice: group.advanceInvoice || false,
367
+ advancePeriod: group.advanceInvoice ? ( group.advancePeriod || 'monthly' ) : undefined,
368
+ advanceMonths: advanceMonths,
308
369
  };
309
370
 
310
371
  if ( req.body.invoiceId ) {
@@ -2211,6 +2272,18 @@ export async function migrateInvoice( req, res ) {
2211
2272
  export async function PaymentStatusChange( req, res ) {
2212
2273
  try {
2213
2274
  let invoice = await invoiceService.findOne( { invoice: req.body.invoiceId } );
2275
+ if ( !invoice ) {
2276
+ return res.sendError( 'Invoice not found', 404 );
2277
+ }
2278
+ // Payment can only be recorded on an APPROVED invoice — block while it's
2279
+ // still in the approval pipeline.
2280
+ if ( invoice.status !== 'approved' ) {
2281
+ return res.sendError( 'Payment status can be changed only after the invoice is approved.', 409 );
2282
+ }
2283
+ // A paid invoice is locked — its payment status cannot be changed.
2284
+ if ( invoice.paymentStatus === 'paid' ) {
2285
+ return res.sendError( 'This invoice is already paid; its payment status cannot be changed.', 409 );
2286
+ }
2214
2287
  let updateInvoice = await invoiceService.updateOne( { invoice: req.body.invoiceId }, { paymentStatus: req.body.status } );
2215
2288
  let logObj = {
2216
2289
  userName: req.user?.userName,
@@ -2255,6 +2328,14 @@ export async function recordPayment( req, res ) {
2255
2328
  if ( !invoice ) {
2256
2329
  return res.sendError( 'Invoice not found', 404 );
2257
2330
  }
2331
+ // Payment can only be recorded on an APPROVED invoice.
2332
+ if ( invoice.status !== 'approved' ) {
2333
+ return res.sendError( 'Payment can be recorded only after the invoice is approved.', 409 );
2334
+ }
2335
+ // A paid invoice is locked — no further payment / status change.
2336
+ if ( invoice.paymentStatus === 'paid' ) {
2337
+ return res.sendError( 'This invoice is already paid; its payment status cannot be changed.', 409 );
2338
+ }
2258
2339
 
2259
2340
  const previousPaid = Number( invoice.paidAmount ) || 0;
2260
2341
  const newPaid = Math.round( ( previousPaid + amountNum ) * 100 ) / 100;
@@ -2305,6 +2386,32 @@ export async function recordPayment( req, res ) {
2305
2386
  const result = await invoiceService.invoiceUpdateOne( { invoice: invoiceId }, update );
2306
2387
  logger.info?.( { function: 'recordPayment', invoiceId, matched: result?.matchedCount, modified: result?.modifiedCount } );
2307
2388
 
2389
+ // Mirror the manual payment into the Transactions section as a reconciled
2390
+ // row with source 'manual', so manually-recorded payments are visible
2391
+ // alongside bank/VA/gateway transactions.
2392
+ try {
2393
+ const clientForName = await clientService.findOne( { clientId: invoice.clientId }, { clientName: 1 } );
2394
+ await bankTransactionService.insertMany( [ {
2395
+ valueDate: historyEntry.date,
2396
+ narration: notes || `Manual payment recorded for ${invoiceId}`,
2397
+ refNo: reference || '',
2398
+ amount: amountNum,
2399
+ payer: clientForName?.clientName || invoice.companyName || '',
2400
+ source: 'manual',
2401
+ status: 'reconciled',
2402
+ identifiedClientId: String( invoice.clientId ?? '' ),
2403
+ identifiedClientName: clientForName?.clientName || invoice.companyName || '',
2404
+ invoice: invoice.invoice,
2405
+ invoiceId: String( invoice._id ),
2406
+ appliedInvoices: [ { invoiceId: String( invoice._id ), invoice: invoice.invoice, amount: amountNum } ],
2407
+ resultNote: `Manually recorded - ${derivedStatus === 'paid' ? 'invoice marked Paid' : 'partial payment'}`,
2408
+ fileName: 'manual-entry',
2409
+ } ] );
2410
+ } catch ( txnErr ) {
2411
+ // Non-fatal: the payment is already recorded; the mirror row is best-effort.
2412
+ logger.error( { error: txnErr, function: 'recordPayment.mirrorTransaction', invoiceId } );
2413
+ }
2414
+
2308
2415
  try {
2309
2416
  const logObj = {
2310
2417
  userName: req.user?.userName,
@@ -329,6 +329,7 @@ export const createBillingGroupBody = joi.object(
329
329
  attachAnnexure: joi.boolean().optional(),
330
330
  isPrimary: joi.boolean().optional(),
331
331
  advanceInvoice: joi.boolean().optional(),
332
+ advancePeriod: joi.string().valid( 'monthly', 'quarterly', 'halfyearly', 'yearly' ).optional().allow( '' ),
332
333
  products: joi.array().items( joi.object( {
333
334
  productName: joi.string().required(),
334
335
  billingMethod: joi.string().valid( 'eachStore', 'overallStore' ).required(),
@@ -368,6 +369,7 @@ export const updateBillingGroupBody = joi.object(
368
369
  attachAnnexure: joi.boolean().optional(),
369
370
  isPrimary: joi.boolean().optional(),
370
371
  advanceInvoice: joi.boolean().optional(),
372
+ advancePeriod: joi.string().valid( 'monthly', 'quarterly', 'halfyearly', 'yearly' ).optional().allow( '' ),
371
373
  products: joi.array().items( joi.object( {
372
374
  _id: joi.any().optional(),
373
375
  productName: joi.string().required(),
@@ -401,6 +403,7 @@ export const bulkUpdateBillingGroupRowSchema = joi.object( {
401
403
  isInstallationOneTime: joi.boolean().optional(),
402
404
  attachAnnexure: joi.boolean().optional(),
403
405
  advanceInvoice: joi.boolean().optional(),
406
+ advancePeriod: joi.string().valid( 'monthly', 'quarterly', 'halfyearly', 'yearly' ).optional().allow( '' ),
404
407
  storeId: joi.string().allow( '' ).optional(),
405
408
  } );
406
409