elpaypg 0.0.0-stage → 2.0.1

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.
Files changed (90) hide show
  1. package/.env.example +8 -0
  2. package/README.md +176 -2
  3. package/package.json +21 -4
  4. package/public/elpay-checkout.js +769 -0
  5. package/public/elpay-sms-gateway.apk +0 -0
  6. package/public/elpay-sms-gateway.apk.sha256 +1 -0
  7. package/public/elpay.js +785 -0
  8. package/public/fonts/dm-sans.woff2 +0 -0
  9. package/public/fonts/sora.woff2 +0 -0
  10. package/public/views/checkout.html +2839 -0
  11. package/public/views/partials/auth.html +110 -0
  12. package/public/views/partials/footer.html +54 -0
  13. package/public/views/partials/header.html +101 -0
  14. package/public/views/partials/modals.html +179 -0
  15. package/public/views/partials/payment-methods.html +526 -0
  16. package/public/views/partials/sidebar.html +75 -0
  17. package/public/views/partials/splash.html +35 -0
  18. package/src/__tests__/smoke.test.js +8 -0
  19. package/src/auth-guard.js +95 -0
  20. package/src/index.js +137 -0
  21. package/src/router.js +46 -0
  22. package/src/routes/admin.routes.js +2424 -0
  23. package/src/routes/checkout.routes.js +2000 -0
  24. package/src/routes/customer.routes.js +650 -0
  25. package/src/routes/device.routes.js +303 -0
  26. package/src/routes/payments.routes.js +446 -0
  27. package/src/routes/pollers.routes.js +653 -0
  28. package/src/services/observer-poller.service.js +369 -0
  29. package/src/services/tunnel-manager.js +130 -0
  30. package/src/services/whatsapp-berry.service.js +743 -0
  31. package/src/vendor/common/__tests__/common.test.js +297 -0
  32. package/src/vendor/common/__tests__/common.test.ts +160 -0
  33. package/src/vendor/common/contracts.js +64 -0
  34. package/src/vendor/common/contracts.ts +188 -0
  35. package/src/vendor/common/crypto.js +132 -0
  36. package/src/vendor/common/crypto.ts +112 -0
  37. package/src/vendor/common/enums.js +91 -0
  38. package/src/vendor/common/enums.ts +91 -0
  39. package/src/vendor/common/index.js +12 -0
  40. package/src/vendor/common/index.ts +8 -0
  41. package/src/vendor/common/money.js +62 -0
  42. package/src/vendor/common/money.ts +56 -0
  43. package/src/vendor/common/observation.js +27 -0
  44. package/src/vendor/common/observation.ts +52 -0
  45. package/src/vendor/common/runtime-config.js +246 -0
  46. package/src/vendor/common/secrets.js +374 -0
  47. package/src/vendor/common/state-machine.d.ts +1 -0
  48. package/src/vendor/common/state-machine.js +1 -0
  49. package/src/vendor/common/state-transitions.js +85 -0
  50. package/src/vendor/common/tunnel-command.js +59 -0
  51. package/src/vendor/common/upi-providers.js +389 -0
  52. package/src/vendor/common/upi-providers.ts +400 -0
  53. package/src/vendor/common/upi.js +69 -0
  54. package/src/vendor/common/upi.ts +61 -0
  55. package/src/vendor/core/__tests__/core.test.js +683 -0
  56. package/src/vendor/core/__tests__/queue-timing.test.js +237 -0
  57. package/src/vendor/core/__tests__/webhook-engine.test.js +243 -0
  58. package/src/vendor/core/attribution-engine.js +855 -0
  59. package/src/vendor/core/bank-email-parser.js +105 -0
  60. package/src/vendor/core/bank-sms-parser.js +183 -0
  61. package/src/vendor/core/device-security.js +506 -0
  62. package/src/vendor/core/index.js +13 -0
  63. package/src/vendor/core/normalizer.js +165 -0
  64. package/src/vendor/core/qr-lib.js +8 -0
  65. package/src/vendor/core/qr.js +62 -0
  66. package/src/vendor/core/queue-engine.js +153 -0
  67. package/src/vendor/core/recovery-engine.js +159 -0
  68. package/src/vendor/core/state-machine.js +42 -0
  69. package/src/vendor/core/timing-engine.js +272 -0
  70. package/src/vendor/core/verification-pipeline.js +1164 -0
  71. package/src/vendor/core/webhook-engine.js +280 -0
  72. package/src/vendor/db/__tests__/auth-matrix.test.js +564 -0
  73. package/src/vendor/db/__tests__/customer-reset.test.js +95 -0
  74. package/src/vendor/db/__tests__/db.test.js +265 -0
  75. package/src/vendor/db/__tests__/schema-parity.test.js +137 -0
  76. package/src/vendor/db/__tests__/sqlite_verify.test.js +15 -0
  77. package/src/vendor/db/database.js +181 -0
  78. package/src/vendor/db/index.js +12 -0
  79. package/src/vendor/db/migrate.js +210 -0
  80. package/src/vendor/db/repositories/audit-log.repository.js +68 -0
  81. package/src/vendor/db/repositories/customer-session.repository.js +181 -0
  82. package/src/vendor/db/repositories/customer.repository.js +636 -0
  83. package/src/vendor/db/repositories/device.repository.js +285 -0
  84. package/src/vendor/db/repositories/merchant.repository.js +158 -0
  85. package/src/vendor/db/repositories/observation.repository.js +124 -0
  86. package/src/vendor/db/repositories/payment-session.repository.js +618 -0
  87. package/src/vendor/db/repositories/receiving-account.repository.js +244 -0
  88. package/src/vendor/db/repositories/support-ticket.repository.js +140 -0
  89. package/src/vendor/db/schema.sql +266 -0
  90. package/src/vendor/db/sqlite-schema.sql +270 -0
@@ -0,0 +1,683 @@
1
+ import { test, describe, beforeEach, afterEach } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import {
4
+ isValidTransition,
5
+ assertValidTransition,
6
+ toPublicOutcome,
7
+ ObservationNormalizer,
8
+ AttributionEngine,
9
+ BankSmsParser
10
+ } from '../index.js';
11
+ import { extractIntelligentAmount } from '../bank-sms-parser.js';
12
+ import {
13
+ PaymentStatus,
14
+ PublicPaymentOutcome,
15
+ ObserverSource,
16
+ ObservationAttributionStatus
17
+ } from '../../common/index.js';
18
+ import {
19
+ Database,
20
+ runMigrations,
21
+ PaymentSessionRepository,
22
+ ObservationRepository,
23
+ SupportTicketRepository,
24
+ AuditLogRepository,
25
+ CustomerRepository
26
+ } from '../../db/index.js';
27
+
28
+ describe('../../core/index.js Unit & Integration Tests', () => {
29
+ describe('Payment State Machine', () => {
30
+ test('permits valid transitions and denies illegal transitions', () => {
31
+ assert.equal(isValidTransition(PaymentStatus.INITIATED, PaymentStatus.ACTIVE), true);
32
+ assert.equal(isValidTransition(PaymentStatus.ACTIVE, PaymentStatus.PROCESSING), true);
33
+ assert.equal(isValidTransition(PaymentStatus.PROCESSING, PaymentStatus.SUCCESS), true);
34
+ assert.equal(isValidTransition(PaymentStatus.PROCESSING, PaymentStatus.FAILED), true);
35
+ assert.equal(isValidTransition(PaymentStatus.ACTIVE, PaymentStatus.EXPIRED), true);
36
+ assert.equal(isValidTransition(PaymentStatus.ACTIVE, PaymentStatus.CANCELLED), true);
37
+
38
+ // Cash-on-Delivery: placing the order terminates the session as SUCCESS even before
39
+ // a UPI slot is acquired (INITIATED/QUEUED) or while it is held (ACTIVE).
40
+ assert.equal(isValidTransition(PaymentStatus.INITIATED, PaymentStatus.SUCCESS), true);
41
+ assert.equal(isValidTransition(PaymentStatus.QUEUED, PaymentStatus.SUCCESS), true);
42
+
43
+ // Illegal transitions
44
+ assert.equal(isValidTransition(PaymentStatus.QUEUED, PaymentStatus.PROCESSING), false);
45
+ assert.equal(isValidTransition(PaymentStatus.EXPIRED, PaymentStatus.SUCCESS), false);
46
+ assert.equal(isValidTransition(PaymentStatus.SUCCESS, PaymentStatus.FAILED), false);
47
+ assert.equal(isValidTransition(PaymentStatus.SUCCESS, PaymentStatus.ACTIVE), false);
48
+ assert.equal(isValidTransition(PaymentStatus.PROCESSING, PaymentStatus.ACTIVE), false);
49
+
50
+ assert.throws(
51
+ () => assertValidTransition(PaymentStatus.SUCCESS, PaymentStatus.ACTIVE),
52
+ /Illegal payment state transition/
53
+ );
54
+ });
55
+
56
+ test('maps internal terminal states to public contract outcomes', () => {
57
+ assert.equal(toPublicOutcome(PaymentStatus.SUCCESS), PublicPaymentOutcome.SUCCESS);
58
+ assert.equal(toPublicOutcome(PaymentStatus.FAILED), PublicPaymentOutcome.FAILED);
59
+ assert.equal(toPublicOutcome(PaymentStatus.EXPIRED), PublicPaymentOutcome.EXPIRED);
60
+ assert.equal(toPublicOutcome(PaymentStatus.CANCELLED), PublicPaymentOutcome.CANCELLED);
61
+
62
+ assert.throws(
63
+ () => toPublicOutcome(PaymentStatus.ACTIVE),
64
+ /internal non-terminal state/
65
+ );
66
+ });
67
+ });
68
+
69
+ describe('Observation Normalizer', () => {
70
+ test('extracts monetary amounts from various notification formats', () => {
71
+ const obs1 = ObservationNormalizer.normalize({
72
+ source: ObserverSource.GMAIL,
73
+ receivingAccountId: 'acc_1',
74
+ rawPayload: {
75
+ snippet: 'Your account has been credited with Rs. 200.00 on 08-Sep-2026. Ref: UTR12345678'
76
+ }
77
+ });
78
+ assert.equal(obs1.amountPaise, 20000n);
79
+ assert.equal(obs1.bankReferenceNumber, 'UTR12345678');
80
+ assert.ok(obs1.fingerprintHash);
81
+
82
+ const obs2 = ObservationNormalizer.normalize({
83
+ source: ObserverSource.GOOGLE_MESSAGES,
84
+ receivingAccountId: 'acc_1',
85
+ rawPayload: {
86
+ notificationText: 'Payment of ₹1,500.50 received from user@upi via PhonePe.'
87
+ }
88
+ });
89
+ assert.equal(obs2.amountPaise, 150050n);
90
+ assert.equal(obs2.senderVpa, 'user@upi');
91
+ });
92
+
93
+ test('normalizes explicit amount in paise directly', () => {
94
+ const obs = ObservationNormalizer.normalize({
95
+ source: ObserverSource.GOOGLE_MESSAGES,
96
+ receivingAccountId: 'acc_1',
97
+ amountPaise: 49900n,
98
+ rawPayload: { text: 'Bank SMS alert' }
99
+ });
100
+ assert.equal(obs.amountPaise, 49900n);
101
+ });
102
+
103
+ test('intelligently extracts amounts without standard currency symbols e.g. "20.00 received"', () => {
104
+ const obs = ObservationNormalizer.normalize({
105
+ source: ObserverSource.GMAIL,
106
+ receivingAccountId: 'acc_1',
107
+ rawPayload: { text: '20.00 received from alt account' }
108
+ });
109
+ assert.equal(obs.amountPaise, 2000n);
110
+ });
111
+ });
112
+
113
+ describe('Attribution Engine & Invariants', () => {
114
+ let db;
115
+ let sessionRepo;
116
+ let obsRepo;
117
+ let ticketRepo;
118
+ let auditRepo;
119
+ let customerRepo;
120
+ let defaultMerchant;
121
+ let receivingAccount;
122
+ let attributionEngine;
123
+ let emittedEvents;
124
+
125
+ beforeEach(() => {
126
+ db = new Database({ inMemory: true });
127
+ const seed = runMigrations(db);
128
+ defaultMerchant = seed.defaultMerchant;
129
+ receivingAccount = { id: '00000000-0000-0000-0000-000000000002' };
130
+
131
+ sessionRepo = new PaymentSessionRepository(db);
132
+ obsRepo = new ObservationRepository(db);
133
+ ticketRepo = new SupportTicketRepository(db);
134
+ auditRepo = new AuditLogRepository(db);
135
+ customerRepo = new CustomerRepository(db);
136
+
137
+ emittedEvents = [];
138
+ attributionEngine = new AttributionEngine({
139
+ sessionRepo,
140
+ obsRepo,
141
+ ticketRepo,
142
+ auditRepo,
143
+ eventEmitter: {
144
+ emit: (event, payload) => emittedEvents.push({ event, payload })
145
+ }
146
+ });
147
+ });
148
+
149
+ afterEach(() => {
150
+ db.close();
151
+ });
152
+
153
+ test('attributes matching observation to active processing session', () => {
154
+ // 1. Create and activate session for ₹200
155
+ const session = sessionRepo.create({
156
+ merchantId: defaultMerchant.id,
157
+ receivingAccountId: receivingAccount.id,
158
+ orderId: 'ORD-101',
159
+ amountPaise: 20000n
160
+ });
161
+
162
+ sessionRepo.acquireSlotAtomic(session.id, receivingAccount.id, 300000);
163
+ sessionRepo.transitionToProcessing(session.id, 30000);
164
+
165
+ // 2. Normalize and ingest observation for ₹200
166
+ const normObs = ObservationNormalizer.normalize({
167
+ source: ObserverSource.GOOGLE_MESSAGES,
168
+ receivingAccountId: receivingAccount.id,
169
+ amountPaise: 20000n,
170
+ bankReferenceNumber: 'UTR998877',
171
+ rawPayload: { text: 'Received Rs 200' }
172
+ });
173
+
174
+ const result = attributionEngine.processObservation(normObs);
175
+ assert.equal(result.attributed, true);
176
+ assert.equal(result.outcomeStatus, PaymentStatus.SUCCESS);
177
+ assert.equal(result.paymentSessionId, session.id);
178
+
179
+ // Verify session updated to SUCCESS in DB
180
+ const updatedSession = sessionRepo.findById(session.id);
181
+ assert.equal(updatedSession.status, PaymentStatus.SUCCESS);
182
+ assert.ok(updatedSession.completedAt);
183
+
184
+ // Verify event was emitted
185
+ assert.ok(emittedEvents.some(e => e.event === 'PAYMENT.SUCCESS'));
186
+ });
187
+
188
+ test('deduplicates multi-channel observations (SMS + Gmail)', () => {
189
+ const session = sessionRepo.create({
190
+ merchantId: defaultMerchant.id,
191
+ receivingAccountId: receivingAccount.id,
192
+ orderId: 'ORD-DEDUP',
193
+ amountPaise: 20000n
194
+ });
195
+ sessionRepo.acquireSlotAtomic(session.id, receivingAccount.id, 300000);
196
+ sessionRepo.transitionToProcessing(session.id, 30000);
197
+
198
+ // Method 1: SMS observes ₹200 with UTR111 -> Level 1 (L1)
199
+ const obsSms = ObservationNormalizer.normalize({
200
+ source: ObserverSource.GOOGLE_MESSAGES,
201
+ receivingAccountId: receivingAccount.id,
202
+ amountPaise: 20000n,
203
+ bankReferenceNumber: 'UTR111',
204
+ rawPayload: { text: 'Bank SMS notification Rs 200' }
205
+ });
206
+ const res1 = attributionEngine.processObservation(obsSms);
207
+ assert.equal(res1.attributed, true);
208
+ assert.equal(res1.outcomeStatus, PaymentStatus.SUCCESS);
209
+ assert.equal(res1.verificationLevel, 1);
210
+
211
+ // Method 2: Gmail observes same payment from independent channel -> Upgrades to Level 2 (L2 Dual Verification)
212
+ const obsGmail = ObservationNormalizer.normalize({
213
+ source: ObserverSource.GMAIL,
214
+ receivingAccountId: receivingAccount.id,
215
+ amountPaise: 20000n,
216
+ bankReferenceNumber: 'UTR111',
217
+ rawPayload: { text: 'Gmail bank email Rs 200' }
218
+ });
219
+ const res2 = attributionEngine.processObservation(obsGmail);
220
+ assert.equal(res2.attributed, true);
221
+ assert.equal(res2.verificationLevel, 2);
222
+
223
+ // Duplicate test: SMS submits same UTR111 again -> MUST be rejected as DUPLICATE_IGNORED!
224
+ const resSmsDup = attributionEngine.processObservation(obsSms);
225
+ assert.equal(resSmsDup.attributed, false);
226
+ assert.equal(resSmsDup.duplicate, true);
227
+ assert.equal(resSmsDup.outcomeStatus, ObservationAttributionStatus.DUPLICATE_IGNORED);
228
+
229
+ // Exactly ONE success event was emitted (subsequent multi-channel confirmations were verification upgrades)!
230
+ const successEvents = emittedEvents.filter(e => e.event === 'PAYMENT.SUCCESS');
231
+ assert.equal(successEvents.length, 1);
232
+ const upgradeEvents = emittedEvents.filter(e => e.event === 'PAYMENT.VERIFICATION_UPGRADED');
233
+ assert.equal(upgradeEvents.length, 1);
234
+ });
235
+
236
+ test('CRITICAL INVARIANT: Late observation does not attach to a new queued customer', () => {
237
+ // Customer A creates ₹200 session, but it expires
238
+ const customerA = customerRepo.create({
239
+ merchantId: defaultMerchant.id,
240
+ phoneNumber: '9876500001',
241
+ fullName: 'Customer A',
242
+ password: 'PassWord1!'
243
+ });
244
+
245
+ const sessionA = sessionRepo.create({
246
+ merchantId: defaultMerchant.id,
247
+ customerId: customerA.id,
248
+ receivingAccountId: receivingAccount.id,
249
+ orderId: 'ORD-EXPIRED-A',
250
+ amountPaise: 20000n
251
+ });
252
+ sessionRepo.acquireSlotAtomic(sessionA.id, receivingAccount.id, 300000);
253
+ sessionRepo.transitionToTerminal(sessionA.id, PaymentStatus.EXPIRED);
254
+
255
+ // Customer B arrives for another ₹200 and becomes ACTIVE
256
+ const customerB = customerRepo.create({
257
+ merchantId: defaultMerchant.id,
258
+ phoneNumber: '9876500002',
259
+ fullName: 'Customer B',
260
+ password: 'PassWord2!'
261
+ });
262
+
263
+ const sessionB = sessionRepo.create({
264
+ merchantId: defaultMerchant.id,
265
+ customerId: customerB.id,
266
+ receivingAccountId: receivingAccount.id,
267
+ orderId: 'ORD-ACTIVE-B',
268
+ amountPaise: 20000n
269
+ });
270
+ sessionRepo.acquireSlotAtomic(sessionB.id, receivingAccount.id, 300000);
271
+
272
+ // Delayed observation arrives for ₹200 with timestamp corresponding to Customer A's window
273
+ const lateObs = ObservationNormalizer.normalize({
274
+ source: ObserverSource.GMAIL,
275
+ receivingAccountId: receivingAccount.id,
276
+ amountPaise: 20000n,
277
+ bankReferenceNumber: 'DELAYED-UTR-A',
278
+ observedAt: new Date(Date.now() - 60000), // observed during A's time
279
+ rawPayload: { text: 'Delayed bank email' }
280
+ });
281
+
282
+ // Customer B is in ACTIVE, but has NOT clicked Payment Done (not in PROCESSING).
283
+ // Even if Customer B were in PROCESSING, late observation check detects expired A!
284
+ const lateRes = attributionEngine.processObservation(lateObs);
285
+
286
+ // Observation must NOT be given to Customer B!
287
+ assert.equal(lateRes.attributed, false);
288
+ assert.equal(lateRes.lateMatch, true);
289
+ assert.equal(lateRes.paymentSessionId, sessionA.id);
290
+
291
+ // Customer B remains ACTIVE, not falsely completed!
292
+ const checkB = sessionRepo.findById(sessionB.id);
293
+ assert.equal(checkB.status, PaymentStatus.ACTIVE);
294
+
295
+ // A support ticket must be raised for Customer A's late payment
296
+ const tickets = ticketRepo.findTicketsByCustomer(customerA.id);
297
+ assert.equal(tickets.length, 1);
298
+ assert.ok(tickets[0].subject.includes('LATE PAYMENT DETECTED'));
299
+ });
300
+ });
301
+
302
+
303
+
304
+ describe('Bank SMS Parser UTR & Amount Extraction', () => {
305
+ test('does not extract English words as UTR when ref prefix is absent', () => {
306
+ const sms = {
307
+ text: 'Dear Customer, your ac no. XXXXX12345 has been credited with Rs 500.00 on 18-Oct-26.'
308
+ };
309
+ const parsed = BankSmsParser.parse(sms);
310
+ assert.ok(parsed);
311
+ assert.equal(parsed.amount, '500.00');
312
+ assert.equal(parsed.amountPaise, 50000n);
313
+ assert.notEqual(parsed.bankReferenceNumber, 'Customer');
314
+ assert.equal(parsed.bankReferenceNumber, undefined);
315
+ });
316
+
317
+ test('extracts valid UTR from explicit ref no prefix', () => {
318
+ const sms = {
319
+ text: 'A/C 1234 credited by Rs. 499.00 on 18-10-26. UPI Ref no 429182736451 from rahul@upi.'
320
+ };
321
+ const parsed = BankSmsParser.parse(sms);
322
+ assert.ok(parsed);
323
+ assert.equal(parsed.amount, '499.00');
324
+ assert.equal(parsed.amountPaise, 49900n);
325
+ assert.equal(parsed.bankReferenceNumber, '429182736451');
326
+ assert.equal(parsed.senderVpa, 'rahul@upi');
327
+ });
328
+
329
+ test('extracts standalone 12-digit UTR', () => {
330
+ const sms = {
331
+ text: 'Rs 1200.00 received in your account via UPI 987654321012 on 18/10.'
332
+ };
333
+ const parsed = BankSmsParser.parse(sms);
334
+ assert.ok(parsed);
335
+ assert.equal(parsed.amount, '1200.00');
336
+ assert.equal(parsed.bankReferenceNumber, '987654321012');
337
+ });
338
+
339
+ test('ignores available balance amount appearing before credit amount', () => {
340
+ const sms = {
341
+ text: 'Avl Bal Rs 15,000.00. Rs 500.00 credited to A/c XX1234 for UPI txn Ref 123456789012.'
342
+ };
343
+ const parsed = BankSmsParser.parse(sms);
344
+ assert.ok(parsed);
345
+ assert.equal(parsed.amount, '500.00');
346
+ assert.equal(parsed.amountPaise, 50000n);
347
+ assert.equal(parsed.bankReferenceNumber, '123456789012');
348
+ });
349
+
350
+ test('rejects debit notification even if credited disclaimer appears later', () => {
351
+ const sms = {
352
+ text: 'Dear Customer, A/c *1234 is debited for Rs 750 on 19-Oct-26. If not done by you, contact bank. Amount will be credited back in 48 hours.'
353
+ };
354
+ const parsed = BankSmsParser.parse(sms);
355
+ assert.equal(parsed, null);
356
+ });
357
+
358
+ test('amount extraction is not vulnerable to quadratic backtracking (ReDoS)', () => {
359
+ // The credit patterns chain several whitespace-matching quantifiers. A body such as
360
+ // "credited" followed by a long whitespace run used to take seconds to scan and blocked
361
+ // the payment event loop, so the text is whitespace-collapsed before matching.
362
+ const hostile = 'credited' + ' '.repeat(20000);
363
+ const started = Date.now();
364
+ extractIntelligentAmount(hostile);
365
+ const elapsed = Date.now() - started;
366
+ assert.ok(elapsed < 500, `amount extraction took ${elapsed}ms on a whitespace flood`);
367
+ });
368
+
369
+ test('rejects bare payment or transfer debit message without credit indicator', () => {
370
+ const sms = {
371
+ text: 'Payment of Rs 350.00 sent to merchant@upi on 19-Oct. UPI Ref 998877665544.'
372
+ };
373
+ const parsed = BankSmsParser.parse(sms);
374
+ assert.equal(parsed, null);
375
+ });
376
+
377
+ test('Issue #20: correctly parses FamPay / FamApp credit notification with wallet, transaction ID, and UPI ID', () => {
378
+ const sms = {
379
+ text: 'Money received! Rs. 350.00 credited to FamPay Wallet via UPI from Gaurav (UPI ID: gaurav@fam). Transaction ID: 418293847562'
380
+ };
381
+ const parsed = BankSmsParser.parse(sms);
382
+ assert.ok(parsed);
383
+ assert.equal(parsed.amount, '350.00');
384
+ assert.equal(parsed.amountPaise, 35000n);
385
+ assert.equal(parsed.bankReferenceNumber, '418293847562');
386
+ assert.equal(parsed.senderVpa, 'gaurav@fam');
387
+ });
388
+
389
+ test('Issue #20: normalizes FamPay observation with added to account format', () => {
390
+ const obs = ObservationNormalizer.normalize({
391
+ source: ObserverSource.GOOGLE_MESSAGES,
392
+ receivingAccountId: 'acc_1',
393
+ rawPayload: {
394
+ text: 'Payment received! Rs. 500.00 added to your account via UPI from user@idfcbank. UPI Ref: 328910293841'
395
+ }
396
+ });
397
+ assert.equal(obs.amountPaise, 50000n);
398
+ assert.equal(obs.bankReferenceNumber, '328910293841');
399
+ assert.equal(obs.senderVpa, 'user@idfcbank');
400
+ });
401
+ });
402
+
403
+ describe('Issue #4: Terminal State Invariants and Re-activation Guards', () => {
404
+ let db;
405
+ let sessionRepo;
406
+ let obsRepo;
407
+ let ticketRepo;
408
+ let auditRepo;
409
+ let customerRepo;
410
+ let defaultMerchant;
411
+ let receivingAccount;
412
+ let attributionEngine;
413
+ let emittedEvents;
414
+
415
+ beforeEach(() => {
416
+ db = new Database({ inMemory: true });
417
+ const seed = runMigrations(db);
418
+ defaultMerchant = seed.defaultMerchant;
419
+ receivingAccount = { id: '00000000-0000-0000-0000-000000000002' };
420
+
421
+ sessionRepo = new PaymentSessionRepository(db);
422
+ obsRepo = new ObservationRepository(db);
423
+ ticketRepo = new SupportTicketRepository(db);
424
+ auditRepo = new AuditLogRepository(db);
425
+ customerRepo = new CustomerRepository(db);
426
+
427
+ emittedEvents = [];
428
+ attributionEngine = new AttributionEngine({
429
+ sessionRepo,
430
+ obsRepo,
431
+ ticketRepo,
432
+ auditRepo,
433
+ eventEmitter: {
434
+ emit: (event, payload) => emittedEvents.push({ event, payload })
435
+ }
436
+ });
437
+ });
438
+
439
+ afterEach(() => {
440
+ db.close();
441
+ });
442
+ test('strictly forbids all transitions out of terminal states in the state machine', () => {
443
+ const terminalStates = [
444
+ PaymentStatus.SUCCESS,
445
+ PaymentStatus.FAILED,
446
+ PaymentStatus.EXPIRED,
447
+ PaymentStatus.CANCELLED
448
+ ];
449
+
450
+ const allStates = [
451
+ PaymentStatus.INITIATED,
452
+ PaymentStatus.QUEUED,
453
+ PaymentStatus.ACTIVE,
454
+ PaymentStatus.PROCESSING,
455
+ PaymentStatus.SUCCESS,
456
+ PaymentStatus.FAILED,
457
+ PaymentStatus.EXPIRED,
458
+ PaymentStatus.CANCELLED
459
+ ];
460
+
461
+ for (const terminal of terminalStates) {
462
+ for (const target of allStates) {
463
+ assert.equal(
464
+ isValidTransition(terminal, target),
465
+ false,
466
+ `Expected isValidTransition('${terminal}', '${target}') to be false`
467
+ );
468
+ assert.throws(
469
+ () => assertValidTransition(terminal, target),
470
+ /Illegal payment state transition/
471
+ );
472
+ }
473
+ }
474
+ });
475
+
476
+ test('acquireSlotAtomic rejects sessions in terminal status', () => {
477
+ const testCases = [
478
+ PaymentStatus.SUCCESS,
479
+ PaymentStatus.FAILED,
480
+ PaymentStatus.EXPIRED,
481
+ PaymentStatus.CANCELLED
482
+ ];
483
+
484
+ for (const status of testCases) {
485
+ const session = sessionRepo.create({
486
+ merchantId: defaultMerchant.id,
487
+ receivingAccountId: receivingAccount.id,
488
+ orderId: `ORD-TERMINAL-${status}`,
489
+ amountPaise: 50000n
490
+ });
491
+
492
+ if (status === PaymentStatus.CANCELLED) {
493
+ sessionRepo.transitionToTerminal(session.id, PaymentStatus.CANCELLED);
494
+ } else {
495
+ sessionRepo.acquireSlotAtomic(session.id, receivingAccount.id);
496
+ sessionRepo.transitionToTerminal(session.id, status);
497
+ }
498
+
499
+ const terminalSession = sessionRepo.findById(session.id);
500
+ assert.equal(terminalSession.status, status);
501
+
502
+ assert.throws(
503
+ () => sessionRepo.acquireSlotAtomic(session.id, receivingAccount.id),
504
+ /Cannot acquire slot for session in terminal status|Illegal payment state transition/
505
+ );
506
+
507
+ // Status must remain terminal in database
508
+ const afterAttempt = sessionRepo.findById(session.id);
509
+ assert.equal(afterAttempt.status, status);
510
+ }
511
+ });
512
+
513
+ test('recordVerificationSource ignores terminal non-success sessions and preserves terminal status', () => {
514
+ const terminalStatuses = [
515
+ PaymentStatus.FAILED,
516
+ PaymentStatus.EXPIRED,
517
+ PaymentStatus.CANCELLED
518
+ ];
519
+
520
+ for (const status of terminalStatuses) {
521
+ const session = sessionRepo.create({
522
+ merchantId: defaultMerchant.id,
523
+ receivingAccountId: receivingAccount.id,
524
+ orderId: `ORD-VERIFY-${status}`,
525
+ amountPaise: 40000n
526
+ });
527
+ if (status === PaymentStatus.CANCELLED) {
528
+ sessionRepo.transitionToTerminal(session.id, PaymentStatus.CANCELLED);
529
+ } else {
530
+ sessionRepo.acquireSlotAtomic(session.id, receivingAccount.id);
531
+ sessionRepo.transitionToTerminal(session.id, status);
532
+ }
533
+
534
+ const res = sessionRepo.recordVerificationSource(session.id, ObserverSource.GMAIL, 'obs_fake_123', { finalizeSuccess: true });
535
+ assert.equal(res.isFirstSuccess, false);
536
+
537
+ // Status in database must still be the terminal status
538
+ const unchanged = sessionRepo.findById(session.id);
539
+ assert.equal(unchanged.status, status);
540
+ }
541
+ });
542
+
543
+ test('transitionToProcessing rejects terminal sessions and non-active sessions', () => {
544
+ // 1. INITIATED session rejects transition to PROCESSING
545
+ const initiated = sessionRepo.create({
546
+ merchantId: defaultMerchant.id,
547
+ receivingAccountId: receivingAccount.id,
548
+ orderId: 'ORD-PROC-INITIATED',
549
+ amountPaise: 15000n
550
+ });
551
+ assert.throws(
552
+ () => sessionRepo.transitionToProcessing(initiated.id),
553
+ /Cannot transition to PROCESSING from status|Illegal payment state transition/
554
+ );
555
+
556
+ // 2. QUEUED session rejects transition to PROCESSING
557
+ const activeSess = sessionRepo.create({
558
+ merchantId: defaultMerchant.id,
559
+ receivingAccountId: receivingAccount.id,
560
+ orderId: 'ORD-PROC-ACTIVE',
561
+ amountPaise: 15000n
562
+ });
563
+ sessionRepo.acquireSlotAtomic(activeSess.id, receivingAccount.id);
564
+
565
+ const queuedSess = sessionRepo.create({
566
+ merchantId: defaultMerchant.id,
567
+ receivingAccountId: receivingAccount.id,
568
+ orderId: 'ORD-PROC-QUEUED',
569
+ amountPaise: 15000n
570
+ });
571
+ sessionRepo.acquireSlotAtomic(queuedSess.id, receivingAccount.id);
572
+ assert.equal(sessionRepo.findById(queuedSess.id).status, PaymentStatus.QUEUED);
573
+
574
+ assert.throws(
575
+ () => sessionRepo.transitionToProcessing(queuedSess.id),
576
+ /Cannot transition to PROCESSING from status|Illegal payment state transition/
577
+ );
578
+
579
+ // 3. SUCCESS session rejects transition to PROCESSING
580
+ sessionRepo.transitionToTerminal(activeSess.id, PaymentStatus.SUCCESS);
581
+ assert.throws(
582
+ () => sessionRepo.transitionToProcessing(activeSess.id),
583
+ /Cannot transition to PROCESSING from status|Illegal payment state transition/
584
+ );
585
+
586
+ // 4. CANCELLED session rejects transition to PROCESSING
587
+ const cancelled = sessionRepo.create({
588
+ merchantId: defaultMerchant.id,
589
+ receivingAccountId: receivingAccount.id,
590
+ orderId: 'ORD-PROC-CANCELLED',
591
+ amountPaise: 15000n
592
+ });
593
+ sessionRepo.transitionToTerminal(cancelled.id, PaymentStatus.CANCELLED);
594
+ assert.throws(
595
+ () => sessionRepo.transitionToProcessing(cancelled.id),
596
+ /Cannot transition to PROCESSING from status|Illegal payment state transition/
597
+ );
598
+
599
+ // 5. EXPIRED session rejects transition to PROCESSING
600
+ const expired = sessionRepo.create({
601
+ merchantId: defaultMerchant.id,
602
+ receivingAccountId: receivingAccount.id,
603
+ orderId: 'ORD-PROC-EXPIRED',
604
+ amountPaise: 15000n
605
+ });
606
+ sessionRepo.acquireSlotAtomic(expired.id, receivingAccount.id);
607
+ sessionRepo.transitionToTerminal(expired.id, PaymentStatus.EXPIRED);
608
+ assert.throws(
609
+ () => sessionRepo.transitionToProcessing(expired.id),
610
+ /Cannot transition to PROCESSING from status|Illegal payment state transition/
611
+ );
612
+
613
+ // 6. FAILED session rejects transition to PROCESSING
614
+ const failed = sessionRepo.create({
615
+ merchantId: defaultMerchant.id,
616
+ receivingAccountId: receivingAccount.id,
617
+ orderId: 'ORD-PROC-FAILED',
618
+ amountPaise: 15000n
619
+ });
620
+ sessionRepo.acquireSlotAtomic(failed.id, receivingAccount.id);
621
+ sessionRepo.transitionToTerminal(failed.id, PaymentStatus.FAILED);
622
+ assert.throws(
623
+ () => sessionRepo.transitionToProcessing(failed.id),
624
+ /Cannot transition to PROCESSING from status|Illegal payment state transition/
625
+ );
626
+ });
627
+
628
+
629
+ test('AttributionEngine does not revive expired session to SUCCESS and creates support ticket', () => {
630
+ const customer = customerRepo.create({
631
+ merchantId: defaultMerchant.id,
632
+ phoneNumber: '9123456780',
633
+ fullName: 'Late Paying User',
634
+ password: 'Password9!'
635
+ });
636
+
637
+ const expiredSession = sessionRepo.create({
638
+ merchantId: defaultMerchant.id,
639
+ customerId: customer.id,
640
+ receivingAccountId: receivingAccount.id,
641
+ orderId: 'ORD-LATE-RECONCILE',
642
+ amountPaise: 75000n
643
+ });
644
+
645
+ sessionRepo.acquireSlotAtomic(expiredSession.id, receivingAccount.id);
646
+ sessionRepo.transitionToTerminal(expiredSession.id, PaymentStatus.EXPIRED);
647
+
648
+ const lateObs = ObservationNormalizer.normalize({
649
+ source: ObserverSource.GMAIL,
650
+ receivingAccountId: receivingAccount.id,
651
+ amountPaise: 75000n,
652
+ bankReferenceNumber: 'UTR-LATE-999',
653
+ observedAt: new Date(),
654
+ rawPayload: { text: 'Bank alert Rs 750' }
655
+ });
656
+
657
+ const result = attributionEngine.processObservation(lateObs);
658
+
659
+ assert.equal(result.attributed, false);
660
+ assert.equal(result.lateMatch, true);
661
+ assert.equal(result.paymentSessionId, expiredSession.id);
662
+ assert.equal(result.outcomeStatus, ObservationAttributionStatus.LATE_UNMATCHED);
663
+
664
+ // Verify the session status in DB is STILL EXPIRED (not SUCCESS)
665
+ const freshSession = sessionRepo.findById(expiredSession.id);
666
+ assert.equal(freshSession.status, PaymentStatus.EXPIRED);
667
+
668
+ // Verify support ticket was created
669
+ const tickets = ticketRepo.findTicketsByCustomer(customer.id);
670
+ assert.ok(tickets.length >= 1);
671
+ const ticket = tickets.find(t => t.paymentId === expiredSession.id);
672
+ assert.ok(ticket);
673
+ assert.ok(ticket.subject.includes('[LATE PAYMENT DETECTED]'));
674
+
675
+ // Verify PAYMENT.LATE_DETECTED was emitted and not PAYMENT.SUCCESS
676
+ const lateEvents = emittedEvents.filter(e => e.event === 'PAYMENT.LATE_DETECTED' && e.payload.paymentId === expiredSession.id);
677
+ assert.equal(lateEvents.length, 1);
678
+ const successEvents = emittedEvents.filter(e => e.event === 'PAYMENT.SUCCESS' && e.payload.paymentId === expiredSession.id);
679
+ assert.equal(successEvents.length, 0);
680
+ });
681
+ });
682
+ });
683
+