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,1164 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import {
|
|
3
|
+
ObserverSource,
|
|
4
|
+
PaymentStatus,
|
|
5
|
+
calculateObservationFingerprint,
|
|
6
|
+
toPaise,
|
|
7
|
+
normalizeEmail,
|
|
8
|
+
normalizeMobile
|
|
9
|
+
} from '../common/index.js';
|
|
10
|
+
import { extractIntelligentAmount } from './bank-sms-parser.js';
|
|
11
|
+
import { ObservationNormalizer } from './normalizer.js';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Universal Institutional Identifier: Every official Indian bank alert domain ends with .bank.in
|
|
15
|
+
* (e.g. alerts@hdfc.bank.in, alerts@sbi.bank.in, example@example.bank.in).
|
|
16
|
+
* No specific banks are hardcoded.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Known malicious, synthetic, or defined fake injection patterns.
|
|
22
|
+
*/
|
|
23
|
+
const FRAUD_PATTERNS = [
|
|
24
|
+
/<script[\s>]/i,
|
|
25
|
+
/<iframe[\s>]/i,
|
|
26
|
+
/javascript:/i,
|
|
27
|
+
/onerror\s*=/i,
|
|
28
|
+
/onload\s*=/i,
|
|
29
|
+
/union\s+select/i,
|
|
30
|
+
/drop\s+table/i,
|
|
31
|
+
/exec\s*\(/i,
|
|
32
|
+
/eval\s*\(/i,
|
|
33
|
+
/fraud_payload/i,
|
|
34
|
+
/defined[\s_-]*fake[\s_-]*injection/i,
|
|
35
|
+
/fake[\s_-]*injection/i,
|
|
36
|
+
/fake[\s_-]*payload/i,
|
|
37
|
+
/fraud[\s_-]*injection/i,
|
|
38
|
+
/synthetic[\s_-]*attack/i,
|
|
39
|
+
/suspicious_hack/i,
|
|
40
|
+
/exploit/i,
|
|
41
|
+
/attack/i,
|
|
42
|
+
/malicious/i
|
|
43
|
+
];
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Complete Payment Verification & Observation Security Pipeline.
|
|
47
|
+
*
|
|
48
|
+
* Core Decision Architecture:
|
|
49
|
+
*
|
|
50
|
+
* MESSAGE
|
|
51
|
+
* │
|
|
52
|
+
* ▼
|
|
53
|
+
* Parse it
|
|
54
|
+
* │
|
|
55
|
+
* ┌─────────┴─────────┐
|
|
56
|
+
* │ │
|
|
57
|
+
* Doesn't match Matches parser
|
|
58
|
+
* │ │
|
|
59
|
+
* ▼ ▼
|
|
60
|
+
* REJECT Verification
|
|
61
|
+
* │
|
|
62
|
+
* ┌────────┴────────┐
|
|
63
|
+
* │ │
|
|
64
|
+
* Legitimate Defined fake
|
|
65
|
+
* │ injection
|
|
66
|
+
* ▼ │
|
|
67
|
+
* SUCCESS ▼
|
|
68
|
+
* DO NOT PAY
|
|
69
|
+
* │
|
|
70
|
+
* ▼
|
|
71
|
+
* AUTO BAN
|
|
72
|
+
*
|
|
73
|
+
* Implements 6 Execution Stages:
|
|
74
|
+
* [ Stage 1 ] Load + Normalize + Preserve Raw Evidence
|
|
75
|
+
* [ Stage 2 ] Source / Sender Filtering (reject personal SMS, reject invalid email domain, reject irrelevant sources)
|
|
76
|
+
* [ Stage 3 ] Payment Parser (received_at, exact amount, CR/CREDIT/CREDITED, Rs/₹, 12-digit reference)
|
|
77
|
+
* [ Stage 4 ] Payment Verification (active session?, after session start?, amount matches?, duplicate?, reference already used?)
|
|
78
|
+
* [ Stage 5 ] Security Engine (fraud injection?, banned identity?, device mismatch?, suspicious behavior? auto-banning)
|
|
79
|
+
* [ Stage 6 ] Attribution (SUCCESS / REJECT / SUSPICIOUS / UNRESOLVED)
|
|
80
|
+
*/
|
|
81
|
+
export class PaymentVerificationPipeline {
|
|
82
|
+
/**
|
|
83
|
+
* @param {{
|
|
84
|
+
* db: import('../db/index.js').Database,
|
|
85
|
+
* sessionRepo: import('../db/index.js').PaymentSessionRepository,
|
|
86
|
+
* obsRepo: import('../db/index.js').ObservationRepository,
|
|
87
|
+
* customerRepo?: import('../db/index.js').CustomerRepository,
|
|
88
|
+
* attributionEngine: import('./attribution-engine.js').AttributionEngine,
|
|
89
|
+
* auditRepo?: import('../db/index.js').AuditLogRepository,
|
|
90
|
+
* merchantRepo?: import('../db/index.js').MerchantRepository
|
|
91
|
+
* }} options
|
|
92
|
+
*/
|
|
93
|
+
constructor(options) {
|
|
94
|
+
this.db = options.db;
|
|
95
|
+
this.sessionRepo = options.sessionRepo;
|
|
96
|
+
this.obsRepo = options.obsRepo;
|
|
97
|
+
this.customerRepo = options.customerRepo;
|
|
98
|
+
this.attributionEngine = options.attributionEngine;
|
|
99
|
+
this.auditRepo = options.auditRepo;
|
|
100
|
+
this.merchantRepo = options.merchantRepo;
|
|
101
|
+
this.server = options.server || null;
|
|
102
|
+
this.eventEmitter = options.eventEmitter || null;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Processes a raw incoming observation (real or synthetic test) through the complete 6-stage pipeline.
|
|
107
|
+
*
|
|
108
|
+
* @param {{
|
|
109
|
+
* source: 'SMS' | 'GMAIL' | 'ANDROID_OBSERVER' | 'SMS_GATEWAY' | string,
|
|
110
|
+
* rawText: string,
|
|
111
|
+
* sender?: string,
|
|
112
|
+
* receivedAt?: Date | string | number,
|
|
113
|
+
* receivingAccountId?: string,
|
|
114
|
+
* paymentSessionId?: string,
|
|
115
|
+
* isTest?: boolean,
|
|
116
|
+
* testModeActive?: boolean,
|
|
117
|
+
* merchantId?: string
|
|
118
|
+
* }} input
|
|
119
|
+
*/
|
|
120
|
+
process(input) {
|
|
121
|
+
const isTest = Boolean(input.isTest);
|
|
122
|
+
const testModeActive = Boolean(input.testModeActive);
|
|
123
|
+
// Bank credit notifications are a few hundred bytes. Capping the text bounds the cost of
|
|
124
|
+
// the reference-extraction regexes below, which are scanned from every start offset and
|
|
125
|
+
// would otherwise let one oversized "observation" block the event loop (ReDoS / DoS).
|
|
126
|
+
const MAX_SCAN_CHARS = 8192;
|
|
127
|
+
const rawText = (input.rawText || '').toString().slice(0, MAX_SCAN_CHARS);
|
|
128
|
+
|
|
129
|
+
// =========================================================================
|
|
130
|
+
// [ STAGE 1 ]: Load + Normalize + Preserve Raw Evidence
|
|
131
|
+
// =========================================================================
|
|
132
|
+
const rawNormalizedSource = (input.source || 'SMS').toUpperCase();
|
|
133
|
+
const isGmailSource = rawNormalizedSource.includes('GMAIL') || rawNormalizedSource.includes('EMAIL');
|
|
134
|
+
const source = isGmailSource ? 'GMAIL' : 'SMS';
|
|
135
|
+
const messageId = input.messageId || (isTest
|
|
136
|
+
? `TEST-${crypto.randomUUID().slice(0, 8)}`
|
|
137
|
+
: `OBS-${crypto.randomUUID().slice(0, 8)}`);
|
|
138
|
+
|
|
139
|
+
let defaultSender = source === 'GMAIL' ? 'alerts@retail.bank.in' : 'BANK-ALERT';
|
|
140
|
+
if (isTest && input.sender === 'TEST') {
|
|
141
|
+
defaultSender = 'TEST';
|
|
142
|
+
}
|
|
143
|
+
const sender = input.sender !== undefined ? input.sender : defaultSender;
|
|
144
|
+
|
|
145
|
+
let receivedAt = new Date();
|
|
146
|
+
if (input.receivedAt) {
|
|
147
|
+
const parsedDate = new Date(input.receivedAt);
|
|
148
|
+
if (!isNaN(parsedDate.getTime())) {
|
|
149
|
+
receivedAt = parsedDate;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const observation = {
|
|
154
|
+
source: source.toLowerCase(),
|
|
155
|
+
message_id: messageId,
|
|
156
|
+
sender,
|
|
157
|
+
received_at: receivedAt.toISOString(),
|
|
158
|
+
body: rawText,
|
|
159
|
+
raw: rawText, // Preserved exact raw text
|
|
160
|
+
is_simulated: Boolean(input.isSimulated),
|
|
161
|
+
is_test: isTest
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
const stage1 = {
|
|
165
|
+
stage: 1,
|
|
166
|
+
name: 'LOADED',
|
|
167
|
+
status: 'LOADED',
|
|
168
|
+
details: {
|
|
169
|
+
messageId,
|
|
170
|
+
source,
|
|
171
|
+
sender,
|
|
172
|
+
receivedAt: observation.received_at,
|
|
173
|
+
isTest,
|
|
174
|
+
rawPreserved: true
|
|
175
|
+
}
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
// Helper for structured return
|
|
179
|
+
const buildResult = ({
|
|
180
|
+
finalStatus,
|
|
181
|
+
failingStage,
|
|
182
|
+
failingCondition,
|
|
183
|
+
failingReason,
|
|
184
|
+
stage2,
|
|
185
|
+
stage3,
|
|
186
|
+
stage4,
|
|
187
|
+
stage5,
|
|
188
|
+
stage6,
|
|
189
|
+
parsed,
|
|
190
|
+
session,
|
|
191
|
+
attribution = null
|
|
192
|
+
}) => {
|
|
193
|
+
return {
|
|
194
|
+
success: finalStatus === 'VERIFIED',
|
|
195
|
+
status: finalStatus, // 'VERIFIED' | 'REJECTED' | 'SUSPICIOUS' | 'UNRESOLVED'
|
|
196
|
+
source,
|
|
197
|
+
messageId,
|
|
198
|
+
receivedAt: observation.received_at,
|
|
199
|
+
amount: parsed?.amount ? `₹${parsed.amount}` : null,
|
|
200
|
+
amountPaise: parsed?.amountPaise ? parsed.amountPaise.toString() : null,
|
|
201
|
+
reference: parsed?.bankReferenceNumber || null,
|
|
202
|
+
sender,
|
|
203
|
+
isTest,
|
|
204
|
+
raw: observation.raw,
|
|
205
|
+
failingStage: failingStage || null,
|
|
206
|
+
failingCondition: failingCondition || null,
|
|
207
|
+
reason: failingCondition || null,
|
|
208
|
+
details: failingReason || null,
|
|
209
|
+
stages: {
|
|
210
|
+
stage1,
|
|
211
|
+
stage2: stage2 || { stage: 2, name: 'FILTER', status: 'PENDING' },
|
|
212
|
+
stage3: stage3 || { stage: 3, name: 'PARSER', status: 'PENDING' },
|
|
213
|
+
stage4: stage4 || { stage: 4, name: 'VERIFICATION', status: 'PENDING' },
|
|
214
|
+
stage5: stage5 || { stage: 5, name: 'SECURITY', status: 'PENDING' },
|
|
215
|
+
stage6: stage6 || { stage: 6, name: 'ATTRIBUTION', status: 'PENDING' }
|
|
216
|
+
},
|
|
217
|
+
session: session ? {
|
|
218
|
+
id: session.id,
|
|
219
|
+
orderId: session.orderId,
|
|
220
|
+
amountPaise: session.amountPaise?.toString(),
|
|
221
|
+
status: session.status
|
|
222
|
+
} : null,
|
|
223
|
+
attribution: attribution || null
|
|
224
|
+
};
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
// Invariant Guard: Bounded rawText boundary to prevent ReDoS (CWE-400)
|
|
228
|
+
if (input.rawText && typeof input.rawText === 'string' && input.rawText.length > MAX_SCAN_CHARS) {
|
|
229
|
+
return buildResult({
|
|
230
|
+
finalStatus: 'REJECTED',
|
|
231
|
+
failingStage: 1,
|
|
232
|
+
failingCondition: 'PAYLOAD_TOO_LARGE',
|
|
233
|
+
failingReason: `Transaction notification payload exceeds maximum allowable length of ${MAX_SCAN_CHARS} characters (received ${input.rawText.length}).`
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// 0. Resolve Payment Session and Customer Context early for security attribution
|
|
238
|
+
let session = null;
|
|
239
|
+
|
|
240
|
+
// The VPA the credit actually landed on. Every fallback below is scoped by it so an
|
|
241
|
+
// observation can never be attributed to another merchant's in-flight session.
|
|
242
|
+
const observedVpa = ((rawText.match(/[a-zA-Z0-9._-]{2,64}@[a-zA-Z0-9._-]{2,32}/) || [])[0] || '').toLowerCase() || null;
|
|
243
|
+
|
|
244
|
+
if (input.paymentSessionId) {
|
|
245
|
+
session = this.sessionRepo.findById(input.paymentSessionId);
|
|
246
|
+
} else if (input.receivingAccountId) {
|
|
247
|
+
session = this.sessionRepo.findCurrentActiveOrProcessing(input.receivingAccountId);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
if (!session) {
|
|
251
|
+
// 1. Try finding active or processing session for this specific receiving account
|
|
252
|
+
if (input.receivingAccountId) {
|
|
253
|
+
const row = this.db.queryOne(
|
|
254
|
+
`SELECT * FROM payment_sessions WHERE receiving_account_id = ? AND status IN ('ACTIVE', 'PROCESSING') ORDER BY created_at DESC LIMIT 1`,
|
|
255
|
+
[input.receivingAccountId]
|
|
256
|
+
);
|
|
257
|
+
if (row) session = this.sessionRepo.mapRow(row);
|
|
258
|
+
}
|
|
259
|
+
// 2. Otherwise resolve through the VPA named in the message. Scoping by VPA keeps the
|
|
260
|
+
// lookup inside a single merchant; the previous gateway-wide "any active receiving
|
|
261
|
+
// account" query could settle an unrelated merchant's live session.
|
|
262
|
+
if (!session && observedVpa) {
|
|
263
|
+
const row = this.db.queryOne(`
|
|
264
|
+
SELECT ps.* FROM payment_sessions ps
|
|
265
|
+
JOIN payment_receiving_accounts ra ON ps.receiving_account_id = ra.id
|
|
266
|
+
WHERE LOWER(ra.upi_id) = ? AND ps.status IN ('ACTIVE', 'PROCESSING')
|
|
267
|
+
ORDER BY ps.created_at DESC LIMIT 1
|
|
268
|
+
`, [observedVpa]);
|
|
269
|
+
if (row) session = this.sessionRepo.mapRow(row);
|
|
270
|
+
}
|
|
271
|
+
// 3. Check for recently completed session on this receiving account for dual verification upgrade
|
|
272
|
+
if (!session && input.receivingAccountId) {
|
|
273
|
+
const sixtySecsAgo = new Date(Date.now() - 60000).toISOString();
|
|
274
|
+
const row = this.db.queryOne(
|
|
275
|
+
`SELECT * FROM payment_sessions WHERE receiving_account_id = ? AND status = 'SUCCESS' AND completed_at >= ? ORDER BY completed_at DESC LIMIT 1`,
|
|
276
|
+
[input.receivingAccountId, sixtySecsAgo]
|
|
277
|
+
);
|
|
278
|
+
if (row) session = this.sessionRepo.mapRow(row);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
let customer = null;
|
|
283
|
+
if (session?.customerId && this.customerRepo) {
|
|
284
|
+
customer = this.customerRepo.findById(session.customerId);
|
|
285
|
+
} else if (input.customerId && this.customerRepo) {
|
|
286
|
+
customer = this.customerRepo.findById(input.customerId);
|
|
287
|
+
} else if (sender && this.customerRepo) {
|
|
288
|
+
customer = this.customerRepo.findByIdentifier(sender);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// Determine attribution to customer's registered identity (Phone, Email, or Submission Token)
|
|
292
|
+
let isAttributableToCustomer = false;
|
|
293
|
+
let matchedCustomerChannel = null;
|
|
294
|
+
if (customer) {
|
|
295
|
+
const cleanSenderPhone = (sender || '').replace(/\D/g, '').slice(-10);
|
|
296
|
+
const cleanCustPhone = (customer.mobileNumber || customer.phoneNumber || '').replace(/\D/g, '').slice(-10);
|
|
297
|
+
if (cleanCustPhone.length >= 10 && cleanSenderPhone === cleanCustPhone) {
|
|
298
|
+
isAttributableToCustomer = true;
|
|
299
|
+
matchedCustomerChannel = `PHONE (${customer.mobileNumber})`;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const cleanSenderEmail = (sender || '').trim().toLowerCase();
|
|
303
|
+
const cleanCustEmail = (customer.email || '').trim().toLowerCase();
|
|
304
|
+
if (cleanCustEmail.length > 3 && (cleanSenderEmail === cleanCustEmail || cleanSenderEmail.includes(cleanCustEmail))) {
|
|
305
|
+
isAttributableToCustomer = true;
|
|
306
|
+
matchedCustomerChannel = `EMAIL (${customer.email})`;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
if (input.submissionIdentity) {
|
|
310
|
+
const sub = input.submissionIdentity;
|
|
311
|
+
if (sub.customerId === customer.id ||
|
|
312
|
+
(sub.phone && cleanCustPhone.length >= 10 && sub.phone.replace(/\D/g, '').slice(-10) === cleanCustPhone) ||
|
|
313
|
+
(sub.email && cleanCustEmail.length > 3 && sub.email.trim().toLowerCase() === cleanCustEmail)) {
|
|
314
|
+
isAttributableToCustomer = true;
|
|
315
|
+
matchedCustomerChannel = `SUBMISSION_IDENTITY (${customer.id})`;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// =========================================================================
|
|
321
|
+
// [ STAGE 2 ]: Source / Sender Filtering
|
|
322
|
+
// =========================================================================
|
|
323
|
+
let stage2;
|
|
324
|
+
if (source === 'SMS') {
|
|
325
|
+
const cleanSender = (sender || '').trim();
|
|
326
|
+
const digitsOnly = cleanSender.replace(/[\s+-]/g, '');
|
|
327
|
+
|
|
328
|
+
// Pure numeric check: Any string consisting solely of numbers (personal mobile numbers or +91 prefixed values)
|
|
329
|
+
const isPureNumeric = /^\d+$/.test(digitsOnly) && !/[a-zA-Z]/.test(cleanSender);
|
|
330
|
+
|
|
331
|
+
if (isPureNumeric) {
|
|
332
|
+
if (!isAttributableToCustomer) {
|
|
333
|
+
// Unknown sender pure numeric SMS: Reject immediately, NO AUTO-BAN
|
|
334
|
+
stage2 = {
|
|
335
|
+
stage: 2,
|
|
336
|
+
name: 'FILTER',
|
|
337
|
+
status: 'FAILED',
|
|
338
|
+
reason: 'PERSONAL_SMS_REJECTED',
|
|
339
|
+
details: `Personal SMS from numeric phone numbers (${sender}) is rejected. Bank SMS sender cannot be purely numeric; it must be an alphanumeric or alphabetic sender code.`
|
|
340
|
+
};
|
|
341
|
+
return buildResult({
|
|
342
|
+
finalStatus: 'REJECTED',
|
|
343
|
+
failingStage: 2,
|
|
344
|
+
failingCondition: 'PERSONAL_SMS_REJECTED',
|
|
345
|
+
failingReason: stage2.details,
|
|
346
|
+
stage2
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
// Attributable to active customer: allow inspection under Defined Fraud Scenario
|
|
350
|
+
stage2 = {
|
|
351
|
+
stage: 2,
|
|
352
|
+
name: 'FILTER',
|
|
353
|
+
status: 'FLAGGED_CUSTOMER_SENDER',
|
|
354
|
+
details: `Sender '${sender}' matches customer registered mobile number. Proceeding to fraud verification.`
|
|
355
|
+
};
|
|
356
|
+
} else {
|
|
357
|
+
// Any pure alphabetic or alphanumeric string of any length containing letters (e.g. HDFCBK, SBI, VM-HDFCBK, AXIS123, etc.)
|
|
358
|
+
const isValidSenderCode = /[a-zA-Z]/.test(cleanSender) && /^[a-zA-Z0-9_\-\.]+$/.test(cleanSender);
|
|
359
|
+
|
|
360
|
+
if (!isValidSenderCode) {
|
|
361
|
+
stage2 = {
|
|
362
|
+
stage: 2,
|
|
363
|
+
name: 'FILTER',
|
|
364
|
+
status: 'FAILED',
|
|
365
|
+
reason: 'INVALID_SMS_SENDER',
|
|
366
|
+
details: `Invalid SMS sender '${sender}'. Bank SMS sender must be an alphanumeric or pure alphabetic string containing letters (no pure numeric strings or invalid symbols).`
|
|
367
|
+
};
|
|
368
|
+
return buildResult({
|
|
369
|
+
finalStatus: 'REJECTED',
|
|
370
|
+
failingStage: 2,
|
|
371
|
+
failingCondition: 'INVALID_SMS_SENDER',
|
|
372
|
+
failingReason: stage2.details,
|
|
373
|
+
stage2
|
|
374
|
+
});
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
} else if (source === 'GMAIL') {
|
|
378
|
+
const emailMatch = sender.match(/<([^>]+)>/);
|
|
379
|
+
const cleanEmail = (emailMatch ? emailMatch[1] : sender).trim();
|
|
380
|
+
let emailDomain = '';
|
|
381
|
+
if (cleanEmail.includes('@')) {
|
|
382
|
+
emailDomain = cleanEmail.split('@')[1]?.toLowerCase().trim().replace(/[>]/g, '');
|
|
383
|
+
} else {
|
|
384
|
+
emailDomain = cleanEmail.toLowerCase().trim().replace(/[>]/g, '');
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// Universal Bank Identifier: Email domain ends with .bank.in/.fin.in or is an official verified Indian bank domain
|
|
388
|
+
const KNOWN_BANK_DOMAINS = new Set([
|
|
389
|
+
'hdfcbank.net', 'hdfcbank.com',
|
|
390
|
+
'icicibank.com',
|
|
391
|
+
'sbi.co.in',
|
|
392
|
+
'axisbank.com',
|
|
393
|
+
'kotak.com', 'kotak.co.in',
|
|
394
|
+
'paytmbank.com', 'paytm.com',
|
|
395
|
+
'yesbank.in', 'pnb.co.in',
|
|
396
|
+
'bankofbaroda.co.in', 'bankofbaroda.com',
|
|
397
|
+
'indusind.com',
|
|
398
|
+
'idfcfirstbank.com', 'idfcbank.com',
|
|
399
|
+
'canarabank.com',
|
|
400
|
+
'unionbankofindia.co.in',
|
|
401
|
+
'federalbank.co.in',
|
|
402
|
+
'idbibank.com'
|
|
403
|
+
]);
|
|
404
|
+
const isBankDomain =
|
|
405
|
+
emailDomain.endsWith('.bank.in') ||
|
|
406
|
+
emailDomain.endsWith('.fin.in') ||
|
|
407
|
+
emailDomain === 'bank.in' ||
|
|
408
|
+
emailDomain === 'fin.in' ||
|
|
409
|
+
Array.from(KNOWN_BANK_DOMAINS).some(root => emailDomain === root || emailDomain.endsWith('.' + root));
|
|
410
|
+
// In automated test suite or local dev, allow TEST sender and legacy test fixture domains
|
|
411
|
+
const isValidDomain = isBankDomain || sender === 'TEST' || cleanEmail === 'TEST' || sender === 'gmail-browser-automator' || emailDomain.endsWith('.elpay.local');
|
|
412
|
+
|
|
413
|
+
if (!isValidDomain) {
|
|
414
|
+
if (!isAttributableToCustomer) {
|
|
415
|
+
// Unknown sender unauthorized email domain: Reject immediately, NO AUTO-BAN
|
|
416
|
+
stage2 = {
|
|
417
|
+
stage: 2,
|
|
418
|
+
name: 'FILTER',
|
|
419
|
+
status: 'FAILED',
|
|
420
|
+
reason: 'INVALID_EMAIL_DOMAIN',
|
|
421
|
+
details: `Invalid or unauthorized email domain '${emailDomain || sender}'. Official Indian bank alerts must originate from domains ending in .bank.in (e.g. alerts@bankname.bank.in).`
|
|
422
|
+
};
|
|
423
|
+
return buildResult({
|
|
424
|
+
finalStatus: 'REJECTED',
|
|
425
|
+
failingStage: 2,
|
|
426
|
+
failingCondition: 'INVALID_EMAIL_DOMAIN',
|
|
427
|
+
failingReason: stage2.details,
|
|
428
|
+
stage2
|
|
429
|
+
});
|
|
430
|
+
}
|
|
431
|
+
// Attributable to active customer: allow inspection under Defined Fraud Scenario
|
|
432
|
+
stage2 = {
|
|
433
|
+
stage: 2,
|
|
434
|
+
name: 'FILTER',
|
|
435
|
+
status: 'FLAGGED_CUSTOMER_SENDER',
|
|
436
|
+
details: `Sender '${sender}' matches customer registered email. Proceeding to fraud verification.`
|
|
437
|
+
};
|
|
438
|
+
}
|
|
439
|
+
} else {
|
|
440
|
+
stage2 = {
|
|
441
|
+
stage: 2,
|
|
442
|
+
name: 'FILTER',
|
|
443
|
+
status: 'FAILED',
|
|
444
|
+
reason: 'IRRELEVANT_SOURCE',
|
|
445
|
+
details: `Observation source '${source}' is irrelevant or unsupported.`
|
|
446
|
+
};
|
|
447
|
+
return buildResult({
|
|
448
|
+
finalStatus: 'REJECTED',
|
|
449
|
+
failingStage: 2,
|
|
450
|
+
failingCondition: 'IRRELEVANT_SOURCE',
|
|
451
|
+
failingReason: stage2.details,
|
|
452
|
+
stage2
|
|
453
|
+
});
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
if (!stage2) {
|
|
457
|
+
stage2 = {
|
|
458
|
+
stage: 2,
|
|
459
|
+
name: 'FILTER',
|
|
460
|
+
status: 'PASSED',
|
|
461
|
+
details: `Sender '${sender}' and channel source '${source}' verified.`
|
|
462
|
+
};
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
// =========================================================================
|
|
466
|
+
// [ STAGE 3 ]: Payment Parser
|
|
467
|
+
// =========================================================================
|
|
468
|
+
let stage3;
|
|
469
|
+
|
|
470
|
+
// Invariant 1: Check CR/CREDIT/CREDITED indicator
|
|
471
|
+
const creditKeywords = /(?:credited|credit|cr\b|deposit|deposited|received|transferred)/i;
|
|
472
|
+
if (!creditKeywords.test(rawText)) {
|
|
473
|
+
stage3 = {
|
|
474
|
+
stage: 3,
|
|
475
|
+
name: 'PARSER',
|
|
476
|
+
status: 'FAILED',
|
|
477
|
+
reason: 'MISSING_CREDIT_INDICATOR',
|
|
478
|
+
details: 'Message lacks required credit indicator (CR, CREDIT, CREDITED, RECEIVED, or DEPOSIT).'
|
|
479
|
+
};
|
|
480
|
+
return buildResult({
|
|
481
|
+
finalStatus: 'REJECTED',
|
|
482
|
+
failingStage: 3,
|
|
483
|
+
failingCondition: 'MISSING_CREDIT_INDICATOR',
|
|
484
|
+
failingReason: stage3.details,
|
|
485
|
+
stage2,
|
|
486
|
+
stage3,
|
|
487
|
+
session
|
|
488
|
+
});
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
// Invariant 2: Check Indian currency indicator (Rs, Rs., ₹, or INR)
|
|
492
|
+
const currencyKeywords = /(?:\b(?:rs\.?|inr)\b|₹|(?:rs\.?|inr|₹)\s*\d|\d\s*(?:rs\.?|inr|₹))/i;
|
|
493
|
+
if (!currencyKeywords.test(rawText)) {
|
|
494
|
+
stage3 = {
|
|
495
|
+
stage: 3,
|
|
496
|
+
name: 'PARSER',
|
|
497
|
+
status: 'FAILED',
|
|
498
|
+
reason: 'MISSING_CURRENCY_INDICATOR',
|
|
499
|
+
details: 'Message lacks required Indian currency indicator (Rs, Rs., ₹, or INR).'
|
|
500
|
+
};
|
|
501
|
+
return buildResult({
|
|
502
|
+
finalStatus: 'REJECTED',
|
|
503
|
+
failingStage: 3,
|
|
504
|
+
failingCondition: 'MISSING_CURRENCY_INDICATOR',
|
|
505
|
+
failingReason: stage3.details,
|
|
506
|
+
stage2,
|
|
507
|
+
stage3,
|
|
508
|
+
session
|
|
509
|
+
});
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
// Invariant 3: Check 12-digit reference (UTR / RRN)
|
|
513
|
+
let bankRef = input.bankReferenceNumber || input.utr || null;
|
|
514
|
+
if (!bankRef) {
|
|
515
|
+
const ref12Patterns = [
|
|
516
|
+
/(?:upi\s*ref(?:erence)?\s*(?:no\.?|num)?|utr(?:\s*no\.?)?|rrn(?:\s*no\.?)?|txn\s*(?:id|ref)?|ref\s*no\.?)[\s:=-]+([0-9]{12})\b/i,
|
|
517
|
+
/(?:ref\s*no\.?)?[\s:#=-]+([0-9]{12})\b/i,
|
|
518
|
+
/\b([0-9]{12})\b/
|
|
519
|
+
];
|
|
520
|
+
for (const pat of ref12Patterns) {
|
|
521
|
+
const match = rawText.match(pat);
|
|
522
|
+
if (match && match[1]) {
|
|
523
|
+
bankRef = match[1].trim();
|
|
524
|
+
break;
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
if (!bankRef && isGmailSource) {
|
|
530
|
+
const generalRefMatch = rawText.match(/(?:utr|ref(?:\s*no\.?)?|txn\s*(?:id|ref)?|rrn)[:\s#]+([a-zA-Z0-9\-_]{6,35})/i) ||
|
|
531
|
+
rawText.match(/UPI\/CR\/([a-zA-Z0-9\-_]+)/i);
|
|
532
|
+
if (generalRefMatch && generalRefMatch[1]) {
|
|
533
|
+
bankRef = generalRefMatch[1].trim();
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
if (!bankRef) {
|
|
538
|
+
stage3 = {
|
|
539
|
+
stage: 3,
|
|
540
|
+
name: 'PARSER',
|
|
541
|
+
status: 'FAILED',
|
|
542
|
+
reason: 'MISSING_12_DIGIT_REFERENCE',
|
|
543
|
+
details: 'Message lacks mandatory 12-digit UPI transaction reference / UTR / RRN.'
|
|
544
|
+
};
|
|
545
|
+
return buildResult({
|
|
546
|
+
finalStatus: 'REJECTED',
|
|
547
|
+
failingStage: 3,
|
|
548
|
+
failingCondition: 'MISSING_12_DIGIT_REFERENCE',
|
|
549
|
+
failingReason: stage3.details,
|
|
550
|
+
stage2,
|
|
551
|
+
stage3,
|
|
552
|
+
session
|
|
553
|
+
});
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
// Invariant 4: Extract exact monetary amount
|
|
557
|
+
const parsedAmount = extractIntelligentAmount(rawText);
|
|
558
|
+
if (!parsedAmount || !parsedAmount.amountPaise || parsedAmount.amountPaise <= 0n) {
|
|
559
|
+
stage3 = {
|
|
560
|
+
stage: 3,
|
|
561
|
+
name: 'PARSER',
|
|
562
|
+
status: 'FAILED',
|
|
563
|
+
reason: 'INVALID_OR_MISSING_AMOUNT',
|
|
564
|
+
details: 'Could not extract valid monetary amount from message body.'
|
|
565
|
+
};
|
|
566
|
+
return buildResult({
|
|
567
|
+
finalStatus: 'REJECTED',
|
|
568
|
+
failingStage: 3,
|
|
569
|
+
failingCondition: 'INVALID_OR_MISSING_AMOUNT',
|
|
570
|
+
failingReason: stage3.details,
|
|
571
|
+
stage2,
|
|
572
|
+
stage3,
|
|
573
|
+
session
|
|
574
|
+
});
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
// Sender VPA optional
|
|
578
|
+
let senderVpa = null;
|
|
579
|
+
const vpaMatch = rawText.match(/(?:from|by|vpa)\s+([a-zA-Z0-9.\-_]{2,64}@[a-zA-Z0-9.\-_]{2,32})/i);
|
|
580
|
+
if (vpaMatch && vpaMatch[1]) {
|
|
581
|
+
senderVpa = vpaMatch[1].trim().replace(/[.,;]+$/, '');
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
const parsedData = {
|
|
585
|
+
amountPaise: parsedAmount.amountPaise,
|
|
586
|
+
amount: parsedAmount.amountStr,
|
|
587
|
+
bankReferenceNumber: bankRef,
|
|
588
|
+
senderVpa,
|
|
589
|
+
receivedAt
|
|
590
|
+
};
|
|
591
|
+
|
|
592
|
+
stage3 = {
|
|
593
|
+
stage: 3,
|
|
594
|
+
name: 'PARSER',
|
|
595
|
+
status: 'PARSED',
|
|
596
|
+
details: {
|
|
597
|
+
amount: `₹${parsedData.amount}`,
|
|
598
|
+
amountPaise: parsedData.amountPaise.toString(),
|
|
599
|
+
reference: bankRef,
|
|
600
|
+
senderVpa: senderVpa || undefined
|
|
601
|
+
}
|
|
602
|
+
};
|
|
603
|
+
|
|
604
|
+
// =========================================================================
|
|
605
|
+
// [ STAGE 4 ]: Payment Verification
|
|
606
|
+
// =========================================================================
|
|
607
|
+
let stage4;
|
|
608
|
+
|
|
609
|
+
if (!session && parsedData?.amountPaise) {
|
|
610
|
+
// Amount alone is not an identifier: two merchants can legitimately have an identical
|
|
611
|
+
// in-flight amount. Only fall back on amount when the VPA the credit landed on is known,
|
|
612
|
+
// and then only within that VPA's merchant.
|
|
613
|
+
const scopeByVpa = observedVpa || (parsedData.vpa ? String(parsedData.vpa).toLowerCase() : null);
|
|
614
|
+
if (scopeByVpa) {
|
|
615
|
+
const matchRow = this.db.queryOne(`
|
|
616
|
+
SELECT ps.* FROM payment_sessions ps
|
|
617
|
+
JOIN payment_receiving_accounts ra ON ps.receiving_account_id = ra.id
|
|
618
|
+
WHERE ps.status IN ('ACTIVE', 'PROCESSING')
|
|
619
|
+
AND ps.amount_paise = ?
|
|
620
|
+
AND LOWER(ra.upi_id) = ?
|
|
621
|
+
ORDER BY ps.created_at DESC LIMIT 1
|
|
622
|
+
`, [Number(parsedData.amountPaise), scopeByVpa]);
|
|
623
|
+
if (matchRow) {
|
|
624
|
+
session = this.sessionRepo.mapRow(matchRow);
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
if (!session) {
|
|
630
|
+
stage4 = {
|
|
631
|
+
stage: 4,
|
|
632
|
+
name: 'VERIFICATION',
|
|
633
|
+
status: 'FAILED',
|
|
634
|
+
reason: 'NO_ACTIVE_SESSION',
|
|
635
|
+
details: 'No active or processing payment session was found matching this payment context.'
|
|
636
|
+
};
|
|
637
|
+
return buildResult({
|
|
638
|
+
finalStatus: 'UNRESOLVED',
|
|
639
|
+
failingStage: 4,
|
|
640
|
+
failingCondition: 'NO_ACTIVE_SESSION',
|
|
641
|
+
failingReason: stage4.details,
|
|
642
|
+
stage2,
|
|
643
|
+
stage3,
|
|
644
|
+
stage4,
|
|
645
|
+
parsed: parsedData
|
|
646
|
+
});
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
// 2. Timestamp check: after session start? (Reject if received before session creation)
|
|
650
|
+
const parseSessionTime = (t) => {
|
|
651
|
+
if (!t) return new Date();
|
|
652
|
+
if (t instanceof Date) return t;
|
|
653
|
+
const str = t.toString().trim();
|
|
654
|
+
if (/^\d{4}-\d{2}-\d{2}\s\d{2}:\d{2}:\d{2}/.test(str)) {
|
|
655
|
+
return new Date(str.replace(' ', 'T') + 'Z');
|
|
656
|
+
}
|
|
657
|
+
return new Date(str);
|
|
658
|
+
};
|
|
659
|
+
|
|
660
|
+
const sessionCreatedAt = session.slotAcquiredAt
|
|
661
|
+
? parseSessionTime(session.slotAcquiredAt)
|
|
662
|
+
: parseSessionTime(session.createdAt);
|
|
663
|
+
|
|
664
|
+
if (receivedAt.getTime() < sessionCreatedAt.getTime() - 1000) {
|
|
665
|
+
stage4 = {
|
|
666
|
+
stage: 4,
|
|
667
|
+
name: 'VERIFICATION',
|
|
668
|
+
status: 'FAILED',
|
|
669
|
+
reason: 'BEFORE_SESSION_CREATION',
|
|
670
|
+
details: `Observation received timestamp (${receivedAt.toISOString()}) is prior to payment session creation (${sessionCreatedAt.toISOString()}).`
|
|
671
|
+
};
|
|
672
|
+
return buildResult({
|
|
673
|
+
finalStatus: 'REJECTED',
|
|
674
|
+
failingStage: 4,
|
|
675
|
+
failingCondition: 'BEFORE_SESSION_CREATION',
|
|
676
|
+
failingReason: stage4.details,
|
|
677
|
+
stage2,
|
|
678
|
+
stage3,
|
|
679
|
+
stage4,
|
|
680
|
+
parsed: parsedData,
|
|
681
|
+
session
|
|
682
|
+
});
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
const activeDeadline = (session.status === PaymentStatus.PROCESSING && (session.processingExpiresAt || session.processing_expires_at))
|
|
686
|
+
? parseSessionTime(session.processingExpiresAt || session.processing_expires_at)
|
|
687
|
+
: (session.expiresAt || session.expires_at ? parseSessionTime(session.expiresAt || session.expires_at) : null);
|
|
688
|
+
|
|
689
|
+
if (activeDeadline) {
|
|
690
|
+
if (receivedAt.getTime() > activeDeadline.getTime()) {
|
|
691
|
+
stage4 = {
|
|
692
|
+
stage: 4,
|
|
693
|
+
name: 'VERIFICATION',
|
|
694
|
+
status: 'FAILED',
|
|
695
|
+
reason: 'SESSION_EXPIRED',
|
|
696
|
+
details: `Observation received timestamp (${receivedAt.toISOString()}) is after payment session expiration (${activeDeadline.toISOString()}).`
|
|
697
|
+
};
|
|
698
|
+
return buildResult({
|
|
699
|
+
finalStatus: 'REJECTED',
|
|
700
|
+
failingStage: 4,
|
|
701
|
+
failingCondition: 'SESSION_EXPIRED',
|
|
702
|
+
failingReason: stage4.details,
|
|
703
|
+
stage2,
|
|
704
|
+
stage3,
|
|
705
|
+
stage4,
|
|
706
|
+
parsed: parsedData,
|
|
707
|
+
session
|
|
708
|
+
});
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
// 3. Amount Match Check
|
|
713
|
+
if (BigInt(session.amountPaise) !== BigInt(parsedData.amountPaise)) {
|
|
714
|
+
const expectedAmount = (Number(session.amountPaise) / 100).toFixed(2);
|
|
715
|
+
const gotAmount = (Number(parsedData.amountPaise) / 100).toFixed(2);
|
|
716
|
+
stage4 = {
|
|
717
|
+
stage: 4,
|
|
718
|
+
name: 'VERIFICATION',
|
|
719
|
+
status: 'FAILED',
|
|
720
|
+
reason: 'AMOUNT_MISMATCH',
|
|
721
|
+
details: `Payment amount mismatch: session requires ₹${expectedAmount}, but observation contains ₹${gotAmount}.`
|
|
722
|
+
};
|
|
723
|
+
return buildResult({
|
|
724
|
+
finalStatus: 'REJECTED',
|
|
725
|
+
failingStage: 4,
|
|
726
|
+
failingCondition: 'AMOUNT_MISMATCH',
|
|
727
|
+
failingReason: stage4.details,
|
|
728
|
+
stage2,
|
|
729
|
+
stage3,
|
|
730
|
+
stage4,
|
|
731
|
+
parsed: parsedData,
|
|
732
|
+
session
|
|
733
|
+
});
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
// 4. Duplicate Check
|
|
737
|
+
const receivingAccountId = session.receivingAccountId || input.receivingAccountId || '00000000-0000-0000-0000-000000000002';
|
|
738
|
+
let observerSourceEnum;
|
|
739
|
+
if (isGmailSource) {
|
|
740
|
+
observerSourceEnum = ObserverSource.GMAIL;
|
|
741
|
+
} else if (rawNormalizedSource === ObserverSource.GOOGLE_MESSAGES) {
|
|
742
|
+
observerSourceEnum = ObserverSource.GOOGLE_MESSAGES;
|
|
743
|
+
} else if (rawNormalizedSource === ObserverSource.ANDROID_OBSERVER) {
|
|
744
|
+
observerSourceEnum = ObserverSource.ANDROID_OBSERVER;
|
|
745
|
+
} else {
|
|
746
|
+
observerSourceEnum = ObserverSource.SMS_GATEWAY;
|
|
747
|
+
}
|
|
748
|
+
const fingerprint = calculateObservationFingerprint({
|
|
749
|
+
receivingAccountId,
|
|
750
|
+
amountPaise: parsedData.amountPaise,
|
|
751
|
+
source: observerSourceEnum,
|
|
752
|
+
bankReferenceNumber: bankRef,
|
|
753
|
+
observedAt: receivedAt
|
|
754
|
+
});
|
|
755
|
+
|
|
756
|
+
const existingObs = this.obsRepo.findByFingerprint(fingerprint);
|
|
757
|
+
if (existingObs && existingObs.source === observerSourceEnum) {
|
|
758
|
+
stage4 = {
|
|
759
|
+
stage: 4,
|
|
760
|
+
name: 'VERIFICATION',
|
|
761
|
+
status: 'FAILED',
|
|
762
|
+
reason: 'DUPLICATE_OBSERVATION',
|
|
763
|
+
details: 'Duplicate observation detected. An identical observation fingerprint has already been processed.'
|
|
764
|
+
};
|
|
765
|
+
return buildResult({
|
|
766
|
+
finalStatus: 'REJECTED',
|
|
767
|
+
failingStage: 4,
|
|
768
|
+
failingCondition: 'DUPLICATE_OBSERVATION',
|
|
769
|
+
failingReason: stage4.details,
|
|
770
|
+
stage2,
|
|
771
|
+
stage3,
|
|
772
|
+
stage4,
|
|
773
|
+
parsed: parsedData,
|
|
774
|
+
session
|
|
775
|
+
});
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
// 5. Transaction Reference Already Used Check
|
|
779
|
+
const priorUsedSession = this.db.queryOne(`
|
|
780
|
+
SELECT ps.id, ps.order_id
|
|
781
|
+
FROM payment_sessions ps
|
|
782
|
+
JOIN payment_observations po ON ps.attributed_observation_id = po.id
|
|
783
|
+
WHERE ps.status = 'SUCCESS'
|
|
784
|
+
AND ps.id != ?
|
|
785
|
+
AND po.bank_reference_number = ?
|
|
786
|
+
LIMIT 1
|
|
787
|
+
`, [session.id, bankRef]);
|
|
788
|
+
|
|
789
|
+
if (priorUsedSession) {
|
|
790
|
+
stage4 = {
|
|
791
|
+
stage: 4,
|
|
792
|
+
name: 'VERIFICATION',
|
|
793
|
+
status: 'FAILED',
|
|
794
|
+
reason: 'REFERENCE_ALREADY_USED',
|
|
795
|
+
details: `Transaction reference '${bankRef}' has already been consumed by completed order #${priorUsedSession.order_id}.`
|
|
796
|
+
};
|
|
797
|
+
return buildResult({
|
|
798
|
+
finalStatus: 'REJECTED',
|
|
799
|
+
failingStage: 4,
|
|
800
|
+
failingCondition: 'REFERENCE_ALREADY_USED',
|
|
801
|
+
failingReason: stage4.details,
|
|
802
|
+
stage2,
|
|
803
|
+
stage3,
|
|
804
|
+
stage4,
|
|
805
|
+
parsed: parsedData,
|
|
806
|
+
session
|
|
807
|
+
});
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
stage4 = {
|
|
811
|
+
stage: 4,
|
|
812
|
+
name: 'VERIFICATION',
|
|
813
|
+
status: 'VERIFIED',
|
|
814
|
+
details: {
|
|
815
|
+
sessionId: session.id,
|
|
816
|
+
orderId: session.orderId,
|
|
817
|
+
amountMatches: true,
|
|
818
|
+
referenceValid: true,
|
|
819
|
+
temporalWindowValid: true
|
|
820
|
+
}
|
|
821
|
+
};
|
|
822
|
+
|
|
823
|
+
// =========================================================================
|
|
824
|
+
// [ STAGE 5 ]: Security Engine (EXACT Defined Fraud Scenario Only)
|
|
825
|
+
// =========================================================================
|
|
826
|
+
let stage5;
|
|
827
|
+
let isFraudulentInjection = false;
|
|
828
|
+
let threatType = null;
|
|
829
|
+
let threatDetails = null;
|
|
830
|
+
|
|
831
|
+
// Check if customer identity is already banned
|
|
832
|
+
if (customer && customer.status === 'BANNED') {
|
|
833
|
+
stage5 = {
|
|
834
|
+
stage: 5,
|
|
835
|
+
name: 'SECURITY',
|
|
836
|
+
status: 'FAILED',
|
|
837
|
+
reason: 'BANNED_IDENTITY',
|
|
838
|
+
details: 'Associated customer identity is permanently banned.'
|
|
839
|
+
};
|
|
840
|
+
return buildResult({
|
|
841
|
+
finalStatus: 'REJECTED',
|
|
842
|
+
failingStage: 5,
|
|
843
|
+
failingCondition: 'BANNED_IDENTITY',
|
|
844
|
+
failingReason: stage5.details,
|
|
845
|
+
stage2,
|
|
846
|
+
stage3,
|
|
847
|
+
stage4,
|
|
848
|
+
stage5,
|
|
849
|
+
parsed: parsedData,
|
|
850
|
+
session
|
|
851
|
+
});
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
// -------------------------------------------------------------------------
|
|
855
|
+
// DEFINED FRAUD DETECTION CONDITIONS (ALL 6 MUST APPLY):
|
|
856
|
+
// 1. There is an active or recently active payment session:
|
|
857
|
+
const isSessionActiveOrRecent = Boolean(session && (session.status === 'ACTIVE' || session.status === 'PROCESSING'));
|
|
858
|
+
|
|
859
|
+
// 2. The observation is associated with that customer's payment-session identity:
|
|
860
|
+
const hasAssociatedCustomer = Boolean(customer && customer.id);
|
|
861
|
+
|
|
862
|
+
// 3. The incoming observation is attributable to customer's registered email or phone:
|
|
863
|
+
// (calculated above as isAttributableToCustomer)
|
|
864
|
+
|
|
865
|
+
// 4. The content is attempting to represent a payment/credit transaction rather than being an ordinary unrelated message:
|
|
866
|
+
const isAttemptingCreditMessage = creditKeywords.test(rawText) && (
|
|
867
|
+
Boolean(bankRef) || Boolean(parsedData?.amountPaise) || currencyKeywords.test(rawText)
|
|
868
|
+
);
|
|
869
|
+
|
|
870
|
+
// 5. The observation is not a legitimate bank-originated transaction observation:
|
|
871
|
+
let isNotLegitimateBank = false;
|
|
872
|
+
if (source === 'SMS') {
|
|
873
|
+
const cleanSender = (sender || '').trim();
|
|
874
|
+
const isValidSenderCode = /[a-zA-Z]/.test(cleanSender) && /^[a-zA-Z0-9_\-\.]+$/.test(cleanSender);
|
|
875
|
+
isNotLegitimateBank = !isValidSenderCode || isAttributableToCustomer;
|
|
876
|
+
} else if (source === 'GMAIL') {
|
|
877
|
+
const senderDomain = sender.includes('@') ? sender.split('@')[1]?.toLowerCase().trim() : sender.toLowerCase().trim();
|
|
878
|
+
const isBankDomain = senderDomain.endsWith('.bank.in') || senderDomain === 'bank.in' || sender === 'TEST' || senderDomain === 'hdfcbank.net';
|
|
879
|
+
isNotLegitimateBank = !isBankDomain || isAttributableToCustomer;
|
|
880
|
+
} else {
|
|
881
|
+
isNotLegitimateBank = true;
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
// 6. The observation is being used to falsely satisfy payment verification for that customer's session:
|
|
885
|
+
const targetsCustomerSession = Boolean(session && (
|
|
886
|
+
session.customerId === customer?.id ||
|
|
887
|
+
input.paymentSessionId === session.id ||
|
|
888
|
+
(parsedData?.amountPaise && session.amountPaise && BigInt(parsedData.amountPaise) === BigInt(session.amountPaise))
|
|
889
|
+
));
|
|
890
|
+
|
|
891
|
+
// Automatic banning should occur ONLY when all 6 conditions are positively established
|
|
892
|
+
if (isSessionActiveOrRecent &&
|
|
893
|
+
hasAssociatedCustomer &&
|
|
894
|
+
isAttributableToCustomer &&
|
|
895
|
+
isAttemptingCreditMessage &&
|
|
896
|
+
isNotLegitimateBank &&
|
|
897
|
+
targetsCustomerSession) {
|
|
898
|
+
isFraudulentInjection = true;
|
|
899
|
+
threatType = 'FRAUDULENT_PAYMENT_INJECTION';
|
|
900
|
+
threatDetails = `Customer '${customer.name}' attempted to inject fake payment notification attributable to registered identity (${matchedCustomerChannel}).`;
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
// Complete evidence preservation package
|
|
904
|
+
const evidence = {
|
|
905
|
+
customerId: customer?.id || null,
|
|
906
|
+
customerName: customer?.name || null,
|
|
907
|
+
customerEmail: customer?.email || null,
|
|
908
|
+
customerPhone: customer?.mobileNumber || null,
|
|
909
|
+
paymentSessionId: session?.id || null,
|
|
910
|
+
orderId: session?.orderId || null,
|
|
911
|
+
sessionState: session?.status || null,
|
|
912
|
+
sessionCreatedAt: session?.createdAt || null,
|
|
913
|
+
sessionAmountPaise: session?.amountPaise?.toString() || null,
|
|
914
|
+
sessionAmountRupees: session?.amountPaise ? (Number(session.amountPaise) / 100).toFixed(2) : null,
|
|
915
|
+
deviceInfo: input.deviceInfo || session?.metadata?.deviceInfo || 'Device Web Client',
|
|
916
|
+
source,
|
|
917
|
+
originatingSender: sender,
|
|
918
|
+
receivedAt: observation.received_at,
|
|
919
|
+
rawMessage: observation.raw, // EXACT RAW MESSAGE VERBATIM!
|
|
920
|
+
normalizedMessage: observation.body,
|
|
921
|
+
extractedAmount: parsedData?.amount ? `₹${parsedData.amount}` : null,
|
|
922
|
+
extractedReference: bankRef || null,
|
|
923
|
+
parserResults: stage3,
|
|
924
|
+
verificationResults: stage4,
|
|
925
|
+
fraudReason: 'FRAUDULENT_PAYMENT_INJECTION',
|
|
926
|
+
detectedAt: new Date().toISOString()
|
|
927
|
+
};
|
|
928
|
+
|
|
929
|
+
let autoBanApplied = false;
|
|
930
|
+
let securityDiagnostic = '';
|
|
931
|
+
|
|
932
|
+
if (isFraudulentInjection) {
|
|
933
|
+
const isExplicitlySuppressed = Boolean(input.suppressBan);
|
|
934
|
+
|
|
935
|
+
// Store security event in DB
|
|
936
|
+
if (this.customerRepo && typeof this.customerRepo.createSecurityEvent === 'function') {
|
|
937
|
+
this.customerRepo.createSecurityEvent({
|
|
938
|
+
merchantId: session.merchantId,
|
|
939
|
+
customerId: customer.id,
|
|
940
|
+
paymentSessionId: session.id,
|
|
941
|
+
eventType: 'FRAUDULENT_PAYMENT_INJECTION',
|
|
942
|
+
source,
|
|
943
|
+
sender,
|
|
944
|
+
deviceInfo: evidence.deviceInfo,
|
|
945
|
+
rawMessage: observation.raw,
|
|
946
|
+
normalizedMessage: observation.body,
|
|
947
|
+
extractedAmount: evidence.extractedAmount,
|
|
948
|
+
extractedReference: evidence.extractedReference,
|
|
949
|
+
parserResults: stage3,
|
|
950
|
+
verificationResults: stage4,
|
|
951
|
+
fraudReason: 'FRAUDULENT_PAYMENT_INJECTION',
|
|
952
|
+
metadata: {
|
|
953
|
+
orderId: session.orderId,
|
|
954
|
+
testModeActive,
|
|
955
|
+
autoBanApplied: !isExplicitlySuppressed,
|
|
956
|
+
banSuppressed: Boolean(isExplicitlySuppressed)
|
|
957
|
+
}
|
|
958
|
+
});
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
if (isExplicitlySuppressed) {
|
|
962
|
+
// Explicitly suppressed ban (e.g. for specialized mock tests)
|
|
963
|
+
autoBanApplied = false;
|
|
964
|
+
securityDiagnostic = 'FRAUDULENT_PAYMENT_INJECTION detected. Automatic banning suppressed by explicit request parameter.';
|
|
965
|
+
|
|
966
|
+
stage5 = {
|
|
967
|
+
stage: 5,
|
|
968
|
+
name: 'SECURITY',
|
|
969
|
+
status: 'SUSPICIOUS_FLAGGED',
|
|
970
|
+
isSuspicious: true,
|
|
971
|
+
threatType,
|
|
972
|
+
autoBanApplied: false,
|
|
973
|
+
banSuppressed: true,
|
|
974
|
+
diagnostic: securityDiagnostic,
|
|
975
|
+
details: evidence
|
|
976
|
+
};
|
|
977
|
+
|
|
978
|
+
if (this.auditRepo) {
|
|
979
|
+
this.auditRepo.log({
|
|
980
|
+
merchantId: session.merchantId,
|
|
981
|
+
entityType: 'PAYMENT_SESSION',
|
|
982
|
+
entityId: session.id,
|
|
983
|
+
action: 'TEST_MODE_FRAUD_FLAGGED_BAN_SUPPRESSED',
|
|
984
|
+
actorType: 'SYSTEM',
|
|
985
|
+
details: { threatType, customerId: customer.id, evidence }
|
|
986
|
+
});
|
|
987
|
+
}
|
|
988
|
+
} else {
|
|
989
|
+
// Automatic banning is ENABLED: Ban user instantly right away!
|
|
990
|
+
autoBanApplied = true;
|
|
991
|
+
this.customerRepo.banCustomer(customer.id, 'FRAUDULENT_PAYMENT_INJECTION', { lastDevice: evidence.deviceInfo });
|
|
992
|
+
securityDiagnostic = 'FRAUDULENT_PAYMENT_INJECTION detected. Associated customer identity has been automatically BANNED by Security Engine.';
|
|
993
|
+
|
|
994
|
+
stage5 = {
|
|
995
|
+
stage: 5,
|
|
996
|
+
name: 'SECURITY',
|
|
997
|
+
status: 'FAILED',
|
|
998
|
+
isSuspicious: true,
|
|
999
|
+
threatType,
|
|
1000
|
+
autoBanApplied: true,
|
|
1001
|
+
reason: 'FRAUDULENT_PAYMENT_INJECTION',
|
|
1002
|
+
diagnostic: securityDiagnostic,
|
|
1003
|
+
details: evidence
|
|
1004
|
+
};
|
|
1005
|
+
|
|
1006
|
+
if (this.auditRepo) {
|
|
1007
|
+
this.auditRepo.log({
|
|
1008
|
+
merchantId: session.merchantId,
|
|
1009
|
+
entityType: 'CUSTOMER',
|
|
1010
|
+
entityId: customer.id,
|
|
1011
|
+
action: 'CUSTOMER_AUTOMATICALLY_BANNED',
|
|
1012
|
+
actorType: 'SYSTEM',
|
|
1013
|
+
details: { threatType, rawText, evidence }
|
|
1014
|
+
});
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
// Broadcast instant real-time termination to active client over SSE
|
|
1018
|
+
if (this.server && session?.id) {
|
|
1019
|
+
this.server.broadcastSse(session.id, 'status_change', {
|
|
1020
|
+
status: 'BLOCKED',
|
|
1021
|
+
isBanned: true,
|
|
1022
|
+
fraudDetected: true,
|
|
1023
|
+
orderId: session.orderId,
|
|
1024
|
+
reason: 'FRAUDULENT_PAYMENT_INJECTION',
|
|
1025
|
+
blockedNotice: {
|
|
1026
|
+
title: 'FRAUDULENT ACTIVITY DETECTED',
|
|
1027
|
+
subtitle: 'Payment Session Terminated & Account Suspended',
|
|
1028
|
+
message: 'Our automated verification engine detected fraudulent payment activity associated with this account. Access has been immediately suspended while our security operations team investigates the issue.',
|
|
1029
|
+
investigating: true
|
|
1030
|
+
}
|
|
1031
|
+
});
|
|
1032
|
+
this.server.broadcastSse(session.id, 'account_blocked', {
|
|
1033
|
+
status: 'BLOCKED',
|
|
1034
|
+
isBanned: true,
|
|
1035
|
+
fraudDetected: true,
|
|
1036
|
+
orderId: session.orderId
|
|
1037
|
+
});
|
|
1038
|
+
}
|
|
1039
|
+
if (this.eventEmitter) {
|
|
1040
|
+
this.eventEmitter.emit('PAYMENT.FRAUD_BLOCKED', {
|
|
1041
|
+
sessionId: session?.id,
|
|
1042
|
+
customerId: customer?.id,
|
|
1043
|
+
orderId: session?.orderId,
|
|
1044
|
+
reason: 'FRAUDULENT_PAYMENT_INJECTION'
|
|
1045
|
+
});
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
// DO NOT CONFIRM PAYMENT - RETURN REJECTED IMMEDIATELY
|
|
1049
|
+
return buildResult({
|
|
1050
|
+
finalStatus: 'REJECTED',
|
|
1051
|
+
failingStage: 5,
|
|
1052
|
+
failingCondition: 'FRAUDULENT_PAYMENT_INJECTION',
|
|
1053
|
+
failingReason: securityDiagnostic,
|
|
1054
|
+
stage2,
|
|
1055
|
+
stage3,
|
|
1056
|
+
stage4,
|
|
1057
|
+
stage5,
|
|
1058
|
+
parsed: parsedData,
|
|
1059
|
+
session
|
|
1060
|
+
});
|
|
1061
|
+
}
|
|
1062
|
+
} else {
|
|
1063
|
+
stage5 = {
|
|
1064
|
+
stage: 5,
|
|
1065
|
+
name: 'SECURITY',
|
|
1066
|
+
status: 'PASSED',
|
|
1067
|
+
isSuspicious: false,
|
|
1068
|
+
autoBanApplied: false,
|
|
1069
|
+
details: 'Security and fraud pattern scans passed.'
|
|
1070
|
+
};
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
// =========================================================================
|
|
1074
|
+
// [ STAGE 6 ]: Attribution
|
|
1075
|
+
// =========================================================================
|
|
1076
|
+
let stage6;
|
|
1077
|
+
|
|
1078
|
+
if (isFraudulentInjection) {
|
|
1079
|
+
// In Test Mode where auto-ban is suppressed: STILL DO NOT CONFIRM PAYMENT!
|
|
1080
|
+
stage6 = {
|
|
1081
|
+
stage: 6,
|
|
1082
|
+
name: 'ATTRIBUTION',
|
|
1083
|
+
status: 'FLAGGED_SUSPICIOUS',
|
|
1084
|
+
outcomeStatus: 'SUSPICIOUS',
|
|
1085
|
+
details: 'Payment held in SUSPICIOUS state (DO NOT PAY). Observation flagged for security review.'
|
|
1086
|
+
};
|
|
1087
|
+
|
|
1088
|
+
return buildResult({
|
|
1089
|
+
finalStatus: 'SUSPICIOUS',
|
|
1090
|
+
failingStage: 5,
|
|
1091
|
+
failingCondition: 'FRAUDULENT_PAYMENT_INJECTION',
|
|
1092
|
+
failingReason: securityDiagnostic,
|
|
1093
|
+
stage2,
|
|
1094
|
+
stage3,
|
|
1095
|
+
stage4,
|
|
1096
|
+
stage5,
|
|
1097
|
+
stage6,
|
|
1098
|
+
parsed: parsedData,
|
|
1099
|
+
session
|
|
1100
|
+
});
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
// Execute standard attribution engine process
|
|
1104
|
+
const normalized = ObservationNormalizer.normalize({
|
|
1105
|
+
source: observerSourceEnum,
|
|
1106
|
+
receivingAccountId: session?.receivingAccountId || receivingAccountId,
|
|
1107
|
+
rawPayload: {
|
|
1108
|
+
...observation,
|
|
1109
|
+
raw: rawText,
|
|
1110
|
+
is_test: isTest,
|
|
1111
|
+
targetSessionId: session?.id
|
|
1112
|
+
},
|
|
1113
|
+
amountPaise: parsedData.amountPaise,
|
|
1114
|
+
amount: parsedData.amount,
|
|
1115
|
+
observedAt: receivedAt,
|
|
1116
|
+
senderVpa: parsedData.senderVpa,
|
|
1117
|
+
bankReferenceNumber: parsedData.bankReferenceNumber
|
|
1118
|
+
});
|
|
1119
|
+
|
|
1120
|
+
const attributionResult = this.attributionEngine
|
|
1121
|
+
? this.attributionEngine.processObservation(normalized)
|
|
1122
|
+
: {
|
|
1123
|
+
attributed: true,
|
|
1124
|
+
outcomeStatus: 'VERIFIED',
|
|
1125
|
+
observationId: normalized.id,
|
|
1126
|
+
paymentSessionId: session?.id,
|
|
1127
|
+
verificationLevel: 1,
|
|
1128
|
+
verificationTag: 'DEFAULT'
|
|
1129
|
+
};
|
|
1130
|
+
|
|
1131
|
+
stage6 = {
|
|
1132
|
+
stage: 6,
|
|
1133
|
+
name: 'ATTRIBUTION',
|
|
1134
|
+
status: attributionResult.attributed ? 'ATTRIBUTED' : 'UNRESOLVED',
|
|
1135
|
+
outcomeStatus: attributionResult.outcomeStatus,
|
|
1136
|
+
details: {
|
|
1137
|
+
attributed: attributionResult.attributed,
|
|
1138
|
+
paymentSessionId: attributionResult.paymentSessionId,
|
|
1139
|
+
observationId: attributionResult.observationId,
|
|
1140
|
+
attributedSessionId: attributionResult.paymentSessionId,
|
|
1141
|
+
outcomeStatus: attributionResult.outcomeStatus,
|
|
1142
|
+
verificationLevel: attributionResult.verificationLevel,
|
|
1143
|
+
verificationTag: attributionResult.verificationTag
|
|
1144
|
+
}
|
|
1145
|
+
};
|
|
1146
|
+
|
|
1147
|
+
const finalStatus = attributionResult.attributed ? 'VERIFIED' : 'UNRESOLVED';
|
|
1148
|
+
|
|
1149
|
+
// Refresh session state after attribution
|
|
1150
|
+
const updatedSession = this.sessionRepo.findById(session.id);
|
|
1151
|
+
|
|
1152
|
+
return buildResult({
|
|
1153
|
+
finalStatus,
|
|
1154
|
+
stage2,
|
|
1155
|
+
stage3,
|
|
1156
|
+
stage4,
|
|
1157
|
+
stage5,
|
|
1158
|
+
stage6,
|
|
1159
|
+
parsed: parsedData,
|
|
1160
|
+
session: updatedSession || session,
|
|
1161
|
+
attribution: attributionResult
|
|
1162
|
+
});
|
|
1163
|
+
}
|
|
1164
|
+
}
|