tango-app-api-payment-subscription 3.5.5 → 3.5.7

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.5",
3
+ "version": "3.5.7",
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.9",
32
+ "tango-api-schema": "^2.6.26",
33
33
  "tango-app-api-middleware": "^3.6.18",
34
34
  "winston": "^3.12.0",
35
35
  "winston-daily-rotate-file": "^5.0.0",
@@ -0,0 +1,617 @@
1
+ import * as bankTransactionService from '../services/bankTransaction.service.js';
2
+ import * as invoiceService from '../services/invoice.service.js';
3
+ import * as clientService from '../services/clientPayment.services.js';
4
+ import dayjs from 'dayjs';
5
+ import { logger } from 'tango-app-api-middleware';
6
+
7
+ // ---------------------------------------------------------------------------
8
+ // Bank-statement upload + reconciliation list (billing "Transactions" tab).
9
+ //
10
+ // The frontend parses the statement file (CSV/XLSX) with SheetJS and POSTs
11
+ // the sheet as a JSON array-of-arrays — no file bytes reach the API. All
12
+ // validation, normalization and classification happen here.
13
+ //
14
+ // Classification (per business rules):
15
+ // reconciled — payer matches an invoice companyName AND the transaction
16
+ // amount equals (amount excl. GST − TDS) + GST for TDS of
17
+ // 0%, 2% or 10%. The matched invoice is closed as paid.
18
+ // needs_review — payer matches an unpaid invoice's companyName but no
19
+ // amount match at any TDS rate.
20
+ // unmatched — a payer name was extracted but matches no invoice
21
+ // companyName.
22
+ // excluded — everything else (no payer in the narration: inward
23
+ // remittances, interest credits, penny-drop validations).
24
+ // ---------------------------------------------------------------------------
25
+
26
+ const TDS_RATES = [ 0, 0.02, 0.10 ];
27
+ // Banks round TDS differently than we do; a rupee of slack avoids false
28
+ // "needs review" on paise-level rounding while never crossing TDS slabs.
29
+ const AMOUNT_TOLERANCE = 1.0;
30
+ const MAX_ROWS = 10000;
31
+
32
+ // Counterparty name from a statement narration. Bank narrations pack the
33
+ // payer between dashes in several formats (NEFT CR-IFSC-NAME-..., IMPS,
34
+ // FT-, TPT, plain "ref-NAME"), so rather than one regex per bank we take
35
+ // the longest dash-separated segment that looks like a company name and
36
+ // is not our own entity (TANGO...) or a transaction-type keyword.
37
+ export function extractPayer( narration ) {
38
+ const n = String( narration || '' ).trim();
39
+ if ( !n || /^INW\b/i.test( n ) || /^INT\./i.test( n ) || /BAV VALIDATION/i.test( n ) ) {
40
+ return '';
41
+ }
42
+ const segments = n.split( '-' ).map( ( s ) => s.trim() );
43
+ const candidates = segments.filter( ( s ) =>
44
+ /^[A-Za-z][A-Za-z .&()',]*$/.test( s ) &&
45
+ s.length >= 4 &&
46
+ !/TANGO/i.test( s ) &&
47
+ !/^(NEFT CR|RTGS CR|IMPS|TPT|OPERATIONS|EXPENSE ACCOUNT|CMS|FT)$/i.test( s ),
48
+ );
49
+ if ( !candidates.length ) {
50
+ return '';
51
+ }
52
+ return candidates.reduce( ( a, b ) => ( b.length > a.length ? b : a ), '' );
53
+ }
54
+
55
+ // Company-name normalization for matching bank narrations against invoice
56
+ // companyName. Banks truncate mid-word ("...PRIVATE LIMITE", "...PRIVATE
57
+ // LIMIT") and abbreviate (PVT/LTD), so fold every truncation of the legal
58
+ // suffixes and strip punctuation (which also folds "NAME - Karnataka" style
59
+ // state suffixes into plain trailing tokens).
60
+ export function normalizeCompany( name ) {
61
+ return String( name || '' )
62
+ .toUpperCase()
63
+ .replace( /[^A-Z0-9 ]/g, ' ' )
64
+ .replace( /\bPRIVATE?\b/g, 'PVT' ) // PRIVATE, PRIVAT
65
+ .replace( /\bLIMITE?D?\b/g, 'LTD' ) // LIMITED, LIMITE, LIMIT
66
+ .replace( /\bCOMPANY\b/g, 'CO' )
67
+ .replace( /\s+/g, ' ' )
68
+ .trim();
69
+ }
70
+
71
+ // Token-level prefix match: every token of the shorter name must equal the
72
+ // corresponding token of the longer one, except the LAST token which may be
73
+ // a truncated prefix (banks cut narrations mid-word). The longer name may
74
+ // carry extra trailing tokens — invoice names like "APOLLO PHARMAPRODUCTS
75
+ // PVT LTD KARNATAKA" still match the bank's "APOLLO PHARMAPRODUCTS PVT LTD".
76
+ function tokenPrefixMatch( shortTokens, longTokens ) {
77
+ if ( !shortTokens.length || shortTokens.length > longTokens.length ) {
78
+ return false;
79
+ }
80
+ for ( let i = 0; i < shortTokens.length; i++ ) {
81
+ if ( i === shortTokens.length - 1 ) {
82
+ if ( !longTokens[i].startsWith( shortTokens[i] ) ) {
83
+ return false;
84
+ }
85
+ } else if ( shortTokens[i] !== longTokens[i] ) {
86
+ return false;
87
+ }
88
+ }
89
+ return true;
90
+ }
91
+
92
+ // Truncation-tolerant company match (min 8 matched chars so "PVT LTD" alone
93
+ // can never link two unrelated companies). Tried in both directions because
94
+ // either side can be the longer one.
95
+ export function companyMatches( a, b ) {
96
+ if ( !a || !b ) {
97
+ return false;
98
+ }
99
+ if ( a === b ) {
100
+ return true;
101
+ }
102
+ const shorter = a.length < b.length ? a : b;
103
+ const longer = a.length < b.length ? b : a;
104
+ if ( shorter.length < 8 ) {
105
+ return false;
106
+ }
107
+ if ( tokenPrefixMatch( shorter.split( ' ' ), longer.split( ' ' ) ) ) {
108
+ return true;
109
+ }
110
+ // Spacing differences — banks write "THE WOODEN STREET FURNITURES" where
111
+ // the registered name is "THE WOODENSTREET FURNITURES". Compare with all
112
+ // spaces removed, prefix-tolerant for the usual narration truncation.
113
+ const aSquashed = a.replace( / /g, '' );
114
+ const bSquashed = b.replace( / /g, '' );
115
+ const shortSquashed = aSquashed.length < bSquashed.length ? aSquashed : bSquashed;
116
+ const longSquashed = aSquashed.length < bSquashed.length ? bSquashed : aSquashed;
117
+ return shortSquashed.length >= 8 && longSquashed.startsWith( shortSquashed );
118
+ }
119
+
120
+ // DD/MM/YY or DD/MM/YYYY -> Date (UTC midnight). Returns null when the cell
121
+ // is not a date — that's how header/separator/footer rows get skipped.
122
+ function parseStatementDate( value ) {
123
+ const s = String( value || '' ).trim();
124
+ const m = s.match( /^(\d{1,2})\/(\d{1,2})\/(\d{2,4})$/ );
125
+ if ( !m ) {
126
+ return null;
127
+ }
128
+ const year = m[3].length === 2 ? 2000 + Number( m[3] ) : Number( m[3] );
129
+ const d = new Date( Date.UTC( year, Number( m[2] ) - 1, Number( m[1] ) ) );
130
+ return isNaN( d.getTime() ) ? null : d;
131
+ }
132
+
133
+ function parseAmount( value ) {
134
+ const n = parseFloat( String( value ?? '' ).replace( /,/g, '' ) );
135
+ return Number.isFinite( n ) ? n : 0;
136
+ }
137
+
138
+ function round2( n ) {
139
+ return Math.round( n * 100 ) / 100;
140
+ }
141
+
142
+ // Classify one parsed transaction against the unpaid-invoice index.
143
+ // `consumed` tracks invoices already closed in this batch so two statement
144
+ // lines can never settle the same invoice.
145
+ export function classifyTransaction( txn, unpaidInvoices, consumed ) {
146
+ if ( !txn.payer ) {
147
+ // Inward dollar remittances carry no payer name (INW ... USD4140.0@92.5),
148
+ // but when the USD value matches a dollar invoice — or the INR credit
149
+ // matches any invoice — it is very likely a customer payment: surface it
150
+ // for review instead of excluding it.
151
+ const usdMatch = String( txn.narration || '' ).match( /USD\s*([\d,]+(?:\.\d+)?)/i );
152
+ if ( usdMatch ) {
153
+ const usd = parseFloat( usdMatch[1].replace( /,/g, '' ) );
154
+ const hit = unpaidInvoices.find( ( inv ) => {
155
+ if ( consumed.has( inv.invoice ) ) {
156
+ return false;
157
+ }
158
+ const outstanding = round2( ( inv.totalAmount || 0 ) - ( inv.paidAmount || 0 ) );
159
+ if ( inv.currency === 'dollar' && Math.abs( usd - outstanding ) <= AMOUNT_TOLERANCE ) {
160
+ return true;
161
+ }
162
+ return Math.abs( txn.amount - outstanding ) <= AMOUNT_TOLERANCE;
163
+ } );
164
+ if ( hit ) {
165
+ return {
166
+ status: 'needs_review',
167
+ identifiedClientId: String( hit.clientId ?? '' ),
168
+ identifiedClientName: hit.brandName || hit.companyName,
169
+ resultNote: `Inward remittance USD ${usd} matches ${hit.invoice} — confirm and resolve manually`,
170
+ };
171
+ }
172
+ }
173
+ return { status: 'excluded', resultNote: 'No payer name in narration — not a customer payment' };
174
+ }
175
+ const payerNorm = normalizeCompany( txn.payer );
176
+ const matches = unpaidInvoices.filter( ( inv ) => companyMatches( payerNorm, inv.companyNorm ) );
177
+ if ( !matches.length ) {
178
+ return { status: 'unmatched', resultNote: `Payer "${txn.payer}" does not match any invoice company` };
179
+ }
180
+
181
+ for ( const inv of matches ) {
182
+ if ( consumed.has( inv.invoice ) ) {
183
+ continue;
184
+ }
185
+ const gst = round2( ( inv.totalAmount || 0 ) - ( inv.amount || 0 ) );
186
+ for ( const tds of TDS_RATES ) {
187
+ const expected = round2( ( inv.amount || 0 ) * ( 1 - tds ) + gst );
188
+ if ( Math.abs( txn.amount - expected ) <= AMOUNT_TOLERANCE ) {
189
+ return {
190
+ status: 'reconciled',
191
+ invoice: inv.invoice,
192
+ invoiceId: String( inv._id ?? '' ),
193
+ identifiedClientId: String( inv.clientId ?? '' ),
194
+ identifiedClientName: inv.brandName || inv.companyName,
195
+ tdsRate: tds,
196
+ matchedInvoice: inv,
197
+ resultNote: `Fully matched to ${inv.invoice} · TDS ${tds * 100}%`,
198
+ };
199
+ }
200
+ }
201
+ }
202
+
203
+ const first = matches[0];
204
+ return {
205
+ status: 'needs_review',
206
+ identifiedClientId: String( first.clientId ?? '' ),
207
+ identifiedClientName: first.brandName || first.companyName,
208
+ resultNote: `Company matched (${matches.length} unpaid invoice${matches.length === 1 ? '' : 's'}) · amount does not equal any invoice after TDS 0% / 2% / 10%`,
209
+ };
210
+ }
211
+
212
+ export async function uploadBankStatement( req, res ) {
213
+ try {
214
+ // The sheet arrives as JSON rows (array of arrays) parsed client-side.
215
+ const rows = req.body?.rows;
216
+ const fileName = req.body?.fileName || 'statement';
217
+ if ( !Array.isArray( rows ) || rows.length === 0 ) {
218
+ return res.sendError( 'rows must be a non-empty array (parsed statement sheet)', 400 );
219
+ }
220
+ if ( rows.length > MAX_ROWS ) {
221
+ return res.sendError( `Too many rows (max ${MAX_ROWS})`, 413 );
222
+ }
223
+ if ( !rows.every( ( r ) => Array.isArray( r ) ) ) {
224
+ return res.sendError( 'Each row must be an array of cells', 400 );
225
+ }
226
+
227
+ // Locate the header row and map column indexes by header text, so column
228
+ // order changes between bank exports don't break the import.
229
+ let headerIdx = -1;
230
+ const col = { date: -1, narration: -1, ref: -1, valueDate: -1, withdrawal: -1, deposit: -1 };
231
+ for ( let i = 0; i < Math.min( rows.length, 30 ); i++ ) {
232
+ const cells = rows[i].map( ( c ) => String( c ).toLowerCase().trim() );
233
+ const dateIdx = cells.findIndex( ( c ) => c === 'date' );
234
+ const narrIdx = cells.findIndex( ( c ) => c.startsWith( 'narration' ) );
235
+ if ( dateIdx !== -1 && narrIdx !== -1 ) {
236
+ headerIdx = i;
237
+ col.date = dateIdx;
238
+ col.narration = narrIdx;
239
+ col.ref = cells.findIndex( ( c ) => c.includes( 'ref' ) || c.includes( 'chq' ) );
240
+ col.valueDate = cells.findIndex( ( c ) => c.startsWith( 'value' ) );
241
+ col.withdrawal = cells.findIndex( ( c ) => c.includes( 'withdrawal' ) );
242
+ col.deposit = cells.findIndex( ( c ) => c.includes( 'deposit' ) );
243
+ break;
244
+ }
245
+ }
246
+ if ( headerIdx === -1 ) {
247
+ return res.sendError( 'Could not find the statement header row (expected "Date" and "Narration" columns)', 400 );
248
+ }
249
+
250
+ const parsed = [];
251
+ for ( let i = headerIdx + 1; i < rows.length; i++ ) {
252
+ const r = rows[i];
253
+ const date = parseStatementDate( r[col.date] );
254
+ const narration = String( r[col.narration] || '' ).trim();
255
+ // Skips the asterisk separator line, blank lines and the footer block
256
+ // (GSTN notes, "--- End Of Statement ---") in one go.
257
+ if ( !date || !narration || /^\*+$/.test( narration ) ) {
258
+ continue;
259
+ }
260
+ const withdrawalAmt = col.withdrawal !== -1 ? parseAmount( r[col.withdrawal] ) : 0;
261
+ const depositAmt = col.deposit !== -1 ? parseAmount( r[col.deposit] ) : 0;
262
+ parsed.push( {
263
+ date,
264
+ valueDate: col.valueDate !== -1 ? ( parseStatementDate( r[col.valueDate] ) || date ) : date,
265
+ narration,
266
+ refNo: col.ref !== -1 ? String( r[col.ref] || '' ).trim() : '',
267
+ withdrawalAmt,
268
+ depositAmt,
269
+ amount: round2( depositAmt - withdrawalAmt ),
270
+ payer: extractPayer( narration ),
271
+ source: 'bank',
272
+ fileName,
273
+ uploadedBy: req.user?.email || req.user?.userName || '',
274
+ } );
275
+ }
276
+
277
+ if ( !parsed.length ) {
278
+ return res.sendError( 'No transaction rows found in the file', 400 );
279
+ }
280
+
281
+ // Re-uploading the same statement must not duplicate rows (or re-close
282
+ // invoices): a line is a duplicate when refNo AND amount already exist.
283
+ const refNos = [ ...new Set( parsed.map( ( p ) => p.refNo ).filter( Boolean ) ) ];
284
+ const existing = await bankTransactionService.find(
285
+ { refNo: { $in: refNos } },
286
+ { refNo: 1, amount: 1, _id: 0 },
287
+ );
288
+ const existingKeys = new Set( existing.map( ( e ) => `${e.refNo}|${e.amount}` ) );
289
+ const fresh = parsed.filter( ( p ) => !p.refNo || !existingKeys.has( `${p.refNo}|${p.amount}` ) );
290
+
291
+ // ---- Classification ----
292
+ // One query for the whole batch: every unpaid invoice with a registered
293
+ // company name, matched in memory against each transaction's payer.
294
+ const unpaidRaw = await invoiceService.find(
295
+ { paymentStatus: { $ne: 'paid' }, companyName: { $exists: true, $nin: [ '', null ] } },
296
+ { invoice: 1, companyName: 1, amount: 1, totalAmount: 1, paidAmount: 1, clientId: 1, currency: 1 },
297
+ );
298
+ // The UI shows the brand name (clientName), not the registered entity —
299
+ // resolve each invoice's clientId to its brand once for the whole batch.
300
+ const clientIds = [ ...new Set( unpaidRaw.map( ( i ) => i.clientId ).filter( ( x ) => x != null ) ) ];
301
+ const clients = await clientService.find( { clientId: { $in: clientIds } }, { clientId: 1, clientName: 1 } );
302
+ const brandByClient = new Map( clients.map( ( c ) => [ String( c.clientId ), c.clientName ] ) );
303
+ const unpaidInvoices = unpaidRaw.map( ( inv ) => ( {
304
+ ...( inv.toObject ? inv.toObject() : inv ),
305
+ companyNorm: normalizeCompany( inv.companyName ),
306
+ brandName: brandByClient.get( String( inv.clientId ) ) || inv.companyName,
307
+ } ) );
308
+
309
+ const consumed = new Set();
310
+ const summary = { reconciled: 0, needs_review: 0, unmatched: 0, excluded: 0 };
311
+ const closedInvoices = [];
312
+
313
+ for ( const txn of fresh ) {
314
+ const verdict = classifyTransaction( txn, unpaidInvoices, consumed );
315
+ txn.status = verdict.status;
316
+ txn.resultNote = verdict.resultNote;
317
+ txn.invoice = verdict.invoice || '';
318
+ txn.invoiceId = verdict.invoiceId || '';
319
+ txn.identifiedClientId = verdict.identifiedClientId || '';
320
+ txn.identifiedClientName = verdict.identifiedClientName || '';
321
+ summary[verdict.status]++;
322
+
323
+ if ( verdict.status === 'reconciled' ) {
324
+ consumed.add( verdict.invoice );
325
+ // Close the invoice — same field shape as recordPayment so the rest
326
+ // of the billing UI (paid badges, pending notes) just works. TDS is
327
+ // withheld by the customer, so paidAmount is what actually landed.
328
+ await invoiceService.invoiceUpdateOne(
329
+ { invoice: verdict.invoice },
330
+ {
331
+ $set: {
332
+ paymentStatus: 'paid',
333
+ paidDate: new Date(),
334
+ paidAmount: round2( ( Number( verdict.matchedInvoice.paidAmount ) || 0 ) + txn.amount ),
335
+ },
336
+ $push: {
337
+ paymentHistory: {
338
+ amount: txn.amount,
339
+ date: txn.valueDate,
340
+ method: 'bank-statement',
341
+ reference: txn.refNo || undefined,
342
+ notes: `Auto-reconciled from "${fileName}" · TDS ${verdict.tdsRate * 100}%`,
343
+ recordedBy: req.user?.email || req.user?.userName || 'bank-reconciliation',
344
+ recordedAt: new Date(),
345
+ },
346
+ },
347
+ },
348
+ );
349
+ closedInvoices.push( verdict.invoice );
350
+ }
351
+ }
352
+
353
+ if ( fresh.length ) {
354
+ await bankTransactionService.insertMany( fresh );
355
+ }
356
+
357
+ logger.info?.( { function: 'uploadBankStatement', fileName, totalRows: parsed.length, inserted: fresh.length, summary, closedInvoices } );
358
+ return res.sendSuccess( {
359
+ totalRows: parsed.length,
360
+ inserted: fresh.length,
361
+ duplicates: parsed.length - fresh.length,
362
+ summary,
363
+ closedInvoices,
364
+ } );
365
+ } catch ( error ) {
366
+ logger.error( { error: error, function: 'uploadBankStatement' } );
367
+ return res.sendError( error, 500 );
368
+ }
369
+ }
370
+
371
+ // Dropdown data for the "Resolve & reconcile" popup: unpaid invoices grouped
372
+ // by client, with the brand name the UI shows. One aggregation does the
373
+ // filter, grouping and client join inside MongoDB — the old two-query +
374
+ // in-JS grouping hydrated every unpaid invoice document and was slow on
375
+ // large invoice collections. Per-client invoice lists are capped: nobody
376
+ // reconciles against the 101st-oldest open invoice from a dropdown.
377
+ const RESOLVE_INVOICES_PER_CLIENT = 100;
378
+ export async function resolveOptions( req, res ) {
379
+ try {
380
+ const data = await invoiceService.aggregate( [
381
+ { $match: { paymentStatus: { $ne: 'paid' } } },
382
+ { $project: {
383
+ invoice: 1,
384
+ clientId: 1,
385
+ dueDate: 1,
386
+ companyName: 1,
387
+ outstanding: { $max: [ 0, { $subtract: [
388
+ { $ifNull: [ '$totalAmount', 0 ] },
389
+ { $ifNull: [ '$paidAmount', 0 ] },
390
+ ] } ] },
391
+ } },
392
+ // Newest due date first so the slice keeps the invoices people
393
+ // actually reconcile against.
394
+ { $sort: { dueDate: -1 } },
395
+ { $group: {
396
+ _id: '$clientId',
397
+ anyCompany: { $first: '$companyName' },
398
+ invoices: { $push: {
399
+ _id: '$_id',
400
+ invoice: '$invoice',
401
+ outstanding: '$outstanding',
402
+ dueDate: '$dueDate',
403
+ } },
404
+ } },
405
+ { $project: {
406
+ _id: 0,
407
+ clientId: { $toString: '$_id' },
408
+ anyCompany: 1,
409
+ invoices: { $slice: [ '$invoices', RESOLVE_INVOICES_PER_CLIENT ] },
410
+ } },
411
+ { $lookup: {
412
+ from: 'clients',
413
+ let: { cidRaw: '$clientId' },
414
+ pipeline: [
415
+ { $match: { $expr: { $eq: [ { $toString: '$clientId' }, '$$cidRaw' ] } } },
416
+ { $project: { clientName: 1, _id: 0 } },
417
+ ],
418
+ as: 'client',
419
+ } },
420
+ { $addFields: { brandName: { $ifNull: [ { $arrayElemAt: [ '$client.clientName', 0 ] }, '$anyCompany' ] } } },
421
+ { $project: { client: 0, anyCompany: 0 } },
422
+ { $sort: { brandName: 1 } },
423
+ ] );
424
+ return res.sendSuccess( data );
425
+ } catch ( error ) {
426
+ logger.error( { error: error, function: 'resolveOptions' } );
427
+ return res.sendError( error, 500 );
428
+ }
429
+ }
430
+
431
+ // Manual resolution from the popup. Three actions, mirroring the prototype:
432
+ // reconcile — apply the payment to the chosen invoice (exact -> paid,
433
+ // short -> partial, excess -> settled + follow-up note).
434
+ // contact — keep in needs_review, flag "reaching out to customer".
435
+ // exclude — mark as not to be considered.
436
+ export async function resolveBankTransaction( req, res ) {
437
+ try {
438
+ const { txnId, action } = req.body || {};
439
+ if ( !txnId || ![ 'reconcile', 'contact', 'exclude' ].includes( action ) ) {
440
+ return res.sendError( 'txnId and a valid action (reconcile | contact | exclude) are required', 400 );
441
+ }
442
+ const txns = await bankTransactionService.find( { _id: txnId } );
443
+ const txn = txns[0];
444
+ if ( !txn ) {
445
+ return res.sendError( 'Transaction not found', 404 );
446
+ }
447
+
448
+ if ( action === 'exclude' ) {
449
+ await bankTransactionService.updateOne( { _id: txnId }, { $set: {
450
+ status: 'excluded',
451
+ contacted: false,
452
+ resultNote: 'Marked as not to be considered',
453
+ } } );
454
+ return res.sendSuccess( { status: 'excluded', note: 'Marked as not to be considered' } );
455
+ }
456
+
457
+ if ( action === 'contact' ) {
458
+ const clientName = req.body?.clientName || txn.identifiedClientName || 'customer';
459
+ await bankTransactionService.updateOne( { _id: txnId }, { $set: {
460
+ status: 'needs_review',
461
+ contacted: true,
462
+ identifiedClientId: req.body?.clientId || txn.identifiedClientId || '',
463
+ identifiedClientName: clientName,
464
+ resultNote: `Reaching out to ${clientName} to confirm this payment — in progress`,
465
+ } } );
466
+ return res.sendSuccess( { status: 'needs_review', contacted: true } );
467
+ }
468
+
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 );
477
+ }
478
+
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`;
495
+ }
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(),
515
+ },
516
+ },
517
+ },
518
+ );
519
+
520
+ // Brand name for the table's Brand Name column.
521
+ const client = await clientService.findOne( { clientId: invoice.clientId }, { clientName: 1 } );
522
+ await bankTransactionService.updateOne( { _id: txnId }, { $set: {
523
+ status: 'reconciled',
524
+ contacted: false,
525
+ invoice: invoice.invoice,
526
+ invoiceId: String( invoice._id ),
527
+ identifiedClientId: String( invoice.clientId ?? '' ),
528
+ identifiedClientName: client?.clientName || invoice.companyName || '',
529
+ resultNote: note,
530
+ } } );
531
+
532
+ return res.sendSuccess( { status: 'reconciled', note, invoice: invoice.invoice } );
533
+ } catch ( error ) {
534
+ logger.error( { error: error, function: 'resolveBankTransaction' } );
535
+ return res.sendError( error, 500 );
536
+ }
537
+ }
538
+
539
+ export async function bankTransactionList( req, res ) {
540
+ try {
541
+ // Scope filters (date range + source) apply to the cards AND the rows;
542
+ // the status tab and search narrow only the rows, so the bucket counts
543
+ // stay stable while the user moves between tabs.
544
+ const scope = {};
545
+ if ( req.body?.from || req.body?.to ) {
546
+ scope.valueDate = {};
547
+ if ( req.body.from ) {
548
+ scope.valueDate.$gte = new Date( dayjs( req.body.from ).startOf( 'day' ).toISOString() );
549
+ }
550
+ if ( req.body.to ) {
551
+ scope.valueDate.$lte = new Date( dayjs( req.body.to ).endOf( 'day' ).toISOString() );
552
+ }
553
+ }
554
+ if ( req.body?.source && req.body.source !== 'all' ) {
555
+ scope.source = req.body.source;
556
+ }
557
+
558
+ const cardsAggregate = await bankTransactionService.aggregate( [
559
+ { $match: scope },
560
+ { $group: {
561
+ _id: '$status',
562
+ count: { $sum: 1 },
563
+ amount: { $sum: '$amount' },
564
+ } },
565
+ ] );
566
+ const cards = {
567
+ reconciled: { count: 0, amount: 0 },
568
+ needs_review: { count: 0, amount: 0 },
569
+ unmatched: { count: 0, amount: 0 },
570
+ excluded: { count: 0, amount: 0 },
571
+ total: 0,
572
+ };
573
+ cardsAggregate.forEach( ( c ) => {
574
+ if ( cards[c._id] ) {
575
+ cards[c._id] = { count: c.count, amount: Math.round( c.amount * 100 ) / 100 };
576
+ }
577
+ cards.total += c.count;
578
+ } );
579
+
580
+ const match = { ...scope };
581
+ if ( req.body?.status && req.body.status !== 'all' ) {
582
+ match.status = req.body.status;
583
+ }
584
+ if ( req.body?.searchValue ) {
585
+ match.$or = [
586
+ { narration: { $regex: req.body.searchValue, $options: 'i' } },
587
+ { refNo: { $regex: req.body.searchValue, $options: 'i' } },
588
+ { payer: { $regex: req.body.searchValue, $options: 'i' } },
589
+ { identifiedClientName: { $regex: req.body.searchValue, $options: 'i' } },
590
+ { invoice: { $regex: req.body.searchValue, $options: 'i' } },
591
+ ];
592
+ }
593
+
594
+ const sortCol = req.body?.sortColumName || 'valueDate';
595
+ const sortBy = req.body?.sortBy === 1 ? 1 : -1;
596
+ const query = [
597
+ { $match: match },
598
+ { $sort: { [sortCol]: sortBy, _id: 1 } },
599
+ ];
600
+
601
+ const countResult = await bankTransactionService.aggregate( [ ...query, { $count: 'n' } ] );
602
+ const count = countResult[0]?.n || 0;
603
+
604
+ if ( req.body?.limit && req.body?.offset ) {
605
+ query.push(
606
+ { $skip: ( req.body.offset - 1 ) * req.body.limit },
607
+ { $limit: Number( req.body.limit ) },
608
+ );
609
+ }
610
+ const data = await bankTransactionService.aggregate( query );
611
+
612
+ return res.sendSuccess( { count, data, cards } );
613
+ } catch ( error ) {
614
+ logger.error( { error: error, function: 'bankTransactionList' } );
615
+ return res.sendError( error, 500 );
616
+ }
617
+ }