elpaypg 0.0.0-stage → 2.0.0
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/.env.example +8 -0
- package/README.md +176 -2
- package/package.json +21 -4
- package/public/elpay-checkout.js +769 -0
- package/public/elpay-sms-gateway.apk +0 -0
- package/public/elpay-sms-gateway.apk.sha256 +1 -0
- package/public/elpay.js +785 -0
- package/public/fonts/dm-sans.woff2 +0 -0
- package/public/fonts/sora.woff2 +0 -0
- package/public/views/checkout.html +2839 -0
- package/public/views/partials/auth.html +110 -0
- package/public/views/partials/footer.html +54 -0
- package/public/views/partials/header.html +101 -0
- package/public/views/partials/modals.html +179 -0
- package/public/views/partials/payment-methods.html +526 -0
- package/public/views/partials/sidebar.html +75 -0
- package/public/views/partials/splash.html +35 -0
- package/src/__tests__/smoke.test.js +8 -0
- package/src/auth-guard.js +95 -0
- package/src/index.js +137 -0
- package/src/router.js +46 -0
- package/src/routes/admin.routes.js +2421 -0
- package/src/routes/checkout.routes.js +2000 -0
- package/src/routes/customer.routes.js +650 -0
- package/src/routes/device.routes.js +303 -0
- package/src/routes/payments.routes.js +446 -0
- package/src/routes/pollers.routes.js +653 -0
- package/src/services/observer-poller.service.js +369 -0
- package/src/services/tunnel-manager.js +130 -0
- package/src/services/whatsapp-berry.service.js +743 -0
- package/src/vendor/common/__tests__/common.test.js +297 -0
- package/src/vendor/common/__tests__/common.test.ts +160 -0
- package/src/vendor/common/contracts.js +64 -0
- package/src/vendor/common/contracts.ts +188 -0
- package/src/vendor/common/crypto.js +132 -0
- package/src/vendor/common/crypto.ts +112 -0
- package/src/vendor/common/enums.js +91 -0
- package/src/vendor/common/enums.ts +91 -0
- package/src/vendor/common/index.js +12 -0
- package/src/vendor/common/index.ts +8 -0
- package/src/vendor/common/money.js +62 -0
- package/src/vendor/common/money.ts +56 -0
- package/src/vendor/common/observation.js +27 -0
- package/src/vendor/common/observation.ts +52 -0
- package/src/vendor/common/runtime-config.js +246 -0
- package/src/vendor/common/secrets.js +374 -0
- package/src/vendor/common/state-machine.d.ts +1 -0
- package/src/vendor/common/state-machine.js +1 -0
- package/src/vendor/common/state-transitions.js +85 -0
- package/src/vendor/common/tunnel-command.js +59 -0
- package/src/vendor/common/upi-providers.js +389 -0
- package/src/vendor/common/upi-providers.ts +400 -0
- package/src/vendor/common/upi.js +69 -0
- package/src/vendor/common/upi.ts +61 -0
- package/src/vendor/core/__tests__/core.test.js +683 -0
- package/src/vendor/core/__tests__/queue-timing.test.js +237 -0
- package/src/vendor/core/__tests__/webhook-engine.test.js +243 -0
- package/src/vendor/core/attribution-engine.js +855 -0
- package/src/vendor/core/bank-email-parser.js +105 -0
- package/src/vendor/core/bank-sms-parser.js +183 -0
- package/src/vendor/core/device-security.js +506 -0
- package/src/vendor/core/index.js +13 -0
- package/src/vendor/core/normalizer.js +165 -0
- package/src/vendor/core/qr-lib.js +8 -0
- package/src/vendor/core/qr.js +62 -0
- package/src/vendor/core/queue-engine.js +153 -0
- package/src/vendor/core/recovery-engine.js +159 -0
- package/src/vendor/core/state-machine.js +42 -0
- package/src/vendor/core/timing-engine.js +272 -0
- package/src/vendor/core/verification-pipeline.js +1164 -0
- package/src/vendor/core/webhook-engine.js +280 -0
- package/src/vendor/db/__tests__/auth-matrix.test.js +564 -0
- package/src/vendor/db/__tests__/customer-reset.test.js +95 -0
- package/src/vendor/db/__tests__/db.test.js +265 -0
- package/src/vendor/db/__tests__/schema-parity.test.js +137 -0
- package/src/vendor/db/__tests__/sqlite_verify.test.js +15 -0
- package/src/vendor/db/database.js +181 -0
- package/src/vendor/db/index.js +12 -0
- package/src/vendor/db/migrate.js +210 -0
- package/src/vendor/db/repositories/audit-log.repository.js +68 -0
- package/src/vendor/db/repositories/customer-session.repository.js +181 -0
- package/src/vendor/db/repositories/customer.repository.js +636 -0
- package/src/vendor/db/repositories/device.repository.js +285 -0
- package/src/vendor/db/repositories/merchant.repository.js +158 -0
- package/src/vendor/db/repositories/observation.repository.js +124 -0
- package/src/vendor/db/repositories/payment-session.repository.js +618 -0
- package/src/vendor/db/repositories/receiving-account.repository.js +244 -0
- package/src/vendor/db/repositories/support-ticket.repository.js +140 -0
- package/src/vendor/db/schema.sql +266 -0
- 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
|
+
|