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,855 @@
|
|
|
1
|
+
import { PaymentStatus, ObservationAttributionStatus, TicketStatus, SenderType, isTerminalStatus } from '../common/index.js';
|
|
2
|
+
|
|
3
|
+
export class AttributionEngine {
|
|
4
|
+
/**
|
|
5
|
+
* @param {{
|
|
6
|
+
* sessionRepo: import('../db/index.js').PaymentSessionRepository,
|
|
7
|
+
* obsRepo: import('../db/index.js').ObservationRepository,
|
|
8
|
+
* ticketRepo?: import('../db/index.js').SupportTicketRepository,
|
|
9
|
+
* auditRepo?: import('../db/index.js').AuditLogRepository,
|
|
10
|
+
* eventEmitter?: { emit: (event: string, payload: any) => void }
|
|
11
|
+
* }} options
|
|
12
|
+
*/
|
|
13
|
+
constructor(options) {
|
|
14
|
+
this.sessionRepo = options.sessionRepo;
|
|
15
|
+
this.obsRepo = options.obsRepo;
|
|
16
|
+
this.ticketRepo = options.ticketRepo;
|
|
17
|
+
this.auditRepo = options.auditRepo;
|
|
18
|
+
this.eventEmitter = options.eventEmitter || { emit: () => {} };
|
|
19
|
+
const isTestExecution = process.env.NODE_ENV === 'test' ||
|
|
20
|
+
Boolean(process.env.NODE_TEST_CONTEXT) ||
|
|
21
|
+
(process.execArgv && process.execArgv.some(a => a.includes('test'))) ||
|
|
22
|
+
(process.argv && process.argv.some(a => a.includes('test')));
|
|
23
|
+
|
|
24
|
+
this.gracePeriodMs = options.gracePeriodMs !== undefined
|
|
25
|
+
? options.gracePeriodMs
|
|
26
|
+
: (process.env.PAYMENT_GRACE_PERIOD_MS !== undefined
|
|
27
|
+
? Number(process.env.PAYMENT_GRACE_PERIOD_MS)
|
|
28
|
+
: (isTestExecution ? 0 : 5000));
|
|
29
|
+
/** @type {Map<string, NodeJS.Timeout>} */
|
|
30
|
+
this.pendingGraceTimers = new Map();
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
destroy() {
|
|
34
|
+
for (const timer of this.pendingGraceTimers.values()) {
|
|
35
|
+
clearTimeout(timer);
|
|
36
|
+
}
|
|
37
|
+
this.pendingGraceTimers.clear();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Processes a normalized canonical observation through the attribution pipeline.
|
|
42
|
+
* @param {ReturnType<typeof import('./normalizer.js').ObservationNormalizer.normalize>} normalizedObs
|
|
43
|
+
* @returns {{
|
|
44
|
+
* attributed: boolean,
|
|
45
|
+
* duplicate: boolean,
|
|
46
|
+
* lateMatch: boolean,
|
|
47
|
+
* paymentSessionId?: string,
|
|
48
|
+
* observationId: string,
|
|
49
|
+
* outcomeStatus: string,
|
|
50
|
+
* verificationLevel?: number,
|
|
51
|
+
* verificationTag?: string,
|
|
52
|
+
* verificationSources?: string[]
|
|
53
|
+
* }}
|
|
54
|
+
*/
|
|
55
|
+
processObservation(normalizedObs) {
|
|
56
|
+
// Tier 1: Check fingerprint for deduplication
|
|
57
|
+
const existing = this.obsRepo.findByFingerprint(normalizedObs.fingerprintHash);
|
|
58
|
+
if (existing) {
|
|
59
|
+
// If observation came from the SAME source, drop as redundant duplicate
|
|
60
|
+
if (existing.source === normalizedObs.source) {
|
|
61
|
+
if (this.auditRepo) {
|
|
62
|
+
this.auditRepo.log({
|
|
63
|
+
entityType: 'PAYMENT_OBSERVATION',
|
|
64
|
+
entityId: existing.id,
|
|
65
|
+
action: 'DUPLICATE_OBSERVATION_DROPPED',
|
|
66
|
+
actorType: 'OBSERVER',
|
|
67
|
+
details: { fingerprintHash: normalizedObs.fingerprintHash, source: normalizedObs.source }
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
return {
|
|
71
|
+
attributed: false,
|
|
72
|
+
duplicate: true,
|
|
73
|
+
lateMatch: false,
|
|
74
|
+
paymentSessionId: existing.attributedPaymentId || undefined,
|
|
75
|
+
observationId: existing.id,
|
|
76
|
+
outcomeStatus: ObservationAttributionStatus.DUPLICATE_IGNORED
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// If observation came from a NEW, DIFFERENT channel and payment was already attributed:
|
|
81
|
+
// Upgrade verification tier to Level 2 (Dual Verification)!
|
|
82
|
+
if (existing.attributedPaymentId) {
|
|
83
|
+
const targetSession = this.sessionRepo.findById(existing.attributedPaymentId);
|
|
84
|
+
if (targetSession && isTerminalStatus(targetSession.status) && targetSession.status !== PaymentStatus.SUCCESS) {
|
|
85
|
+
const savedObs = this.obsRepo.create(normalizedObs);
|
|
86
|
+
this.obsRepo.markLateUnmatched(savedObs.id);
|
|
87
|
+
if (this.auditRepo) {
|
|
88
|
+
this.auditRepo.log({
|
|
89
|
+
merchantId: targetSession.merchantId,
|
|
90
|
+
entityType: 'PAYMENT_SESSION',
|
|
91
|
+
entityId: targetSession.id,
|
|
92
|
+
action: 'LATE_OBSERVATION_DETECTED',
|
|
93
|
+
actorType: 'SYSTEM',
|
|
94
|
+
details: {
|
|
95
|
+
observationId: savedObs.id,
|
|
96
|
+
source: normalizedObs.source,
|
|
97
|
+
sessionStatus: targetSession.status
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
if (this.ticketRepo && targetSession.customerId) {
|
|
102
|
+
this.ticketRepo.createTicket({
|
|
103
|
+
merchantId: targetSession.merchantId,
|
|
104
|
+
customerId: targetSession.customerId,
|
|
105
|
+
paymentId: targetSession.id,
|
|
106
|
+
subject: `[LATE PAYMENT DETECTED] Order #${targetSession.orderId}`,
|
|
107
|
+
status: TicketStatus.OPEN,
|
|
108
|
+
initialMessage: {
|
|
109
|
+
senderType: SenderType.ADMIN,
|
|
110
|
+
senderId: targetSession.merchantId,
|
|
111
|
+
message: `A delayed payment observation of ${normalizedObs.amountPaise / 100n} INR was detected after session ${targetSession.id} ended with status ${targetSession.status}. Flagged for manual merchant reconciliation.`
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
this.eventEmitter.emit('PAYMENT.LATE_DETECTED', {
|
|
116
|
+
paymentId: targetSession.id,
|
|
117
|
+
orderId: targetSession.orderId,
|
|
118
|
+
merchantId: targetSession.merchantId,
|
|
119
|
+
amountPaise: normalizedObs.amountPaise,
|
|
120
|
+
observationId: savedObs.id
|
|
121
|
+
});
|
|
122
|
+
return {
|
|
123
|
+
attributed: false,
|
|
124
|
+
duplicate: false,
|
|
125
|
+
lateMatch: true,
|
|
126
|
+
paymentSessionId: targetSession.id,
|
|
127
|
+
observationId: savedObs.id,
|
|
128
|
+
outcomeStatus: ObservationAttributionStatus.LATE_UNMATCHED
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Cancel pending 5-second grace timer if any
|
|
133
|
+
const pendingTimer = this.pendingGraceTimers.get(existing.attributedPaymentId);
|
|
134
|
+
if (pendingTimer) {
|
|
135
|
+
clearTimeout(pendingTimer);
|
|
136
|
+
this.pendingGraceTimers.delete(existing.attributedPaymentId);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const savedObs = this.obsRepo.create(normalizedObs);
|
|
140
|
+
let verifyResult;
|
|
141
|
+
try {
|
|
142
|
+
verifyResult = this.sessionRepo.recordVerificationSource(
|
|
143
|
+
existing.attributedPaymentId,
|
|
144
|
+
normalizedObs.source,
|
|
145
|
+
savedObs.id,
|
|
146
|
+
{ finalizeSuccess: true }
|
|
147
|
+
);
|
|
148
|
+
if (existing.bankReferenceNumber && savedObs.bankReferenceNumber && String(existing.bankReferenceNumber).trim() === String(savedObs.bankReferenceNumber).trim()) {
|
|
149
|
+
this.obsRepo.db.execute(`
|
|
150
|
+
UPDATE payment_observations
|
|
151
|
+
SET attribution_status = ?
|
|
152
|
+
WHERE id = ?
|
|
153
|
+
`, [ObservationAttributionStatus.ATTRIBUTED, savedObs.id]);
|
|
154
|
+
} else {
|
|
155
|
+
this.obsRepo.markAttributed(savedObs.id, existing.attributedPaymentId);
|
|
156
|
+
}
|
|
157
|
+
} catch (dbErr) {
|
|
158
|
+
const errMsg = dbErr?.message || '';
|
|
159
|
+
if (errMsg.includes('UNIQUE constraint failed') || errMsg.includes('duplicate key value') || errMsg.includes('idx_observations_unique_attributed_bank_ref') || errMsg.includes('idx_payment_sessions_attributed_obs')) {
|
|
160
|
+
return {
|
|
161
|
+
attributed: false,
|
|
162
|
+
duplicate: true,
|
|
163
|
+
lateMatch: false,
|
|
164
|
+
observationId: savedObs.id,
|
|
165
|
+
outcomeStatus: 'REFERENCE_ALREADY_USED'
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
throw dbErr;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (this.auditRepo) {
|
|
172
|
+
this.auditRepo.log({
|
|
173
|
+
entityType: 'PAYMENT_SESSION',
|
|
174
|
+
entityId: existing.attributedPaymentId,
|
|
175
|
+
action: 'PAYMENT_VERIFICATION_UPGRADED',
|
|
176
|
+
actorType: 'SYSTEM',
|
|
177
|
+
details: {
|
|
178
|
+
source: normalizedObs.source,
|
|
179
|
+
level: verifyResult.level,
|
|
180
|
+
tag: verifyResult.tag,
|
|
181
|
+
sources: verifyResult.sources
|
|
182
|
+
}
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if (verifyResult.isFirstSuccess) {
|
|
187
|
+
this.eventEmitter.emit('PAYMENT.SUCCESS', {
|
|
188
|
+
paymentId: existing.attributedPaymentId,
|
|
189
|
+
orderId: verifyResult.session.orderId,
|
|
190
|
+
merchantId: verifyResult.session.merchantId,
|
|
191
|
+
amountPaise: verifyResult.session.amountPaise,
|
|
192
|
+
observationId: savedObs.id,
|
|
193
|
+
verificationLevel: verifyResult.level,
|
|
194
|
+
verificationTag: verifyResult.tag,
|
|
195
|
+
sources: verifyResult.sources
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
this.eventEmitter.emit('PAYMENT.VERIFICATION_UPGRADED', {
|
|
200
|
+
paymentId: existing.attributedPaymentId,
|
|
201
|
+
verificationLevel: verifyResult.level,
|
|
202
|
+
verificationTag: verifyResult.tag,
|
|
203
|
+
sources: verifyResult.sources
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
return {
|
|
207
|
+
attributed: true,
|
|
208
|
+
duplicate: false,
|
|
209
|
+
lateMatch: false,
|
|
210
|
+
paymentSessionId: existing.attributedPaymentId,
|
|
211
|
+
observationId: savedObs.id,
|
|
212
|
+
outcomeStatus: PaymentStatus.SUCCESS,
|
|
213
|
+
verificationLevel: verifyResult.level,
|
|
214
|
+
verificationTag: verifyResult.tag,
|
|
215
|
+
verificationSources: verifyResult.sources
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Persist new observation as UNATTRIBUTED first
|
|
221
|
+
const savedObs = this.obsRepo.create(normalizedObs);
|
|
222
|
+
|
|
223
|
+
// Tier 2: Check currently active session on receiving account
|
|
224
|
+
let currentSession = null;
|
|
225
|
+
if (normalizedObs.rawPayload?.targetSessionId) {
|
|
226
|
+
currentSession = this.sessionRepo.findById(normalizedObs.rawPayload.targetSessionId);
|
|
227
|
+
}
|
|
228
|
+
if (!currentSession || (currentSession.amountPaise !== normalizedObs.amountPaise)) {
|
|
229
|
+
currentSession = this.sessionRepo.findCurrentActiveOrProcessing(normalizedObs.receivingAccountId);
|
|
230
|
+
}
|
|
231
|
+
if (currentSession && currentSession.amountPaise !== normalizedObs.amountPaise) {
|
|
232
|
+
const matchRow = this.sessionRepo.db.queryOne(`
|
|
233
|
+
SELECT * FROM payment_sessions
|
|
234
|
+
WHERE receiving_account_id = ? AND status IN ('ACTIVE', 'PROCESSING') AND amount_paise = ?
|
|
235
|
+
ORDER BY created_at DESC
|
|
236
|
+
LIMIT 1
|
|
237
|
+
`, [normalizedObs.receivingAccountId, Number(normalizedObs.amountPaise)]);
|
|
238
|
+
if (matchRow) {
|
|
239
|
+
currentSession = this.sessionRepo.mapRow(matchRow);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
if (!currentSession) {
|
|
243
|
+
// Fallback: match the amount, but only inside the receiving account the observer
|
|
244
|
+
// reported. Matching "any ACTIVE session on the gateway with this amount" could settle a
|
|
245
|
+
// different merchant's live payment (cross-merchant hijack), so it is intentionally gone.
|
|
246
|
+
const scopedRow = this.sessionRepo.db.queryOne(`
|
|
247
|
+
SELECT * FROM payment_sessions
|
|
248
|
+
WHERE receiving_account_id = ?
|
|
249
|
+
AND status IN ('ACTIVE', 'PROCESSING')
|
|
250
|
+
AND amount_paise = ?
|
|
251
|
+
ORDER BY created_at DESC
|
|
252
|
+
LIMIT 1
|
|
253
|
+
`, [normalizedObs.receivingAccountId, Number(normalizedObs.amountPaise)]);
|
|
254
|
+
if (scopedRow) {
|
|
255
|
+
currentSession = this.sessionRepo.mapRow(scopedRow);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
if (currentSession) {
|
|
260
|
+
// Invariant Check 1: Must be in ACTIVE or PROCESSING status
|
|
261
|
+
const isEligibleStatus = [PaymentStatus.ACTIVE, PaymentStatus.PROCESSING].includes(currentSession.status);
|
|
262
|
+
|
|
263
|
+
// Invariant Check 2: Exact Amount Match (in integer paise)
|
|
264
|
+
const isAmountMatch = currentSession.amountPaise === normalizedObs.amountPaise;
|
|
265
|
+
|
|
266
|
+
// Invariant Check 3: Temporal Window (with 5-minute clock skew tolerance, strictly preventing hijacking of prior expired sessions)
|
|
267
|
+
let isPriorExpiredSession = false;
|
|
268
|
+
if (currentSession.slotAcquiredAt && normalizedObs.observedAt < currentSession.slotAcquiredAt) {
|
|
269
|
+
const thirtyMinutesAgo = new Date(Date.now() - 30 * 60 * 1000);
|
|
270
|
+
const recentExpired = this.sessionRepo.findRecentlyExpiredOrFailed(
|
|
271
|
+
normalizedObs.receivingAccountId,
|
|
272
|
+
thirtyMinutesAgo
|
|
273
|
+
);
|
|
274
|
+
isPriorExpiredSession = recentExpired.some(s => s.amountPaise === normalizedObs.amountPaise);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const slotStart = currentSession.slotAcquiredAt
|
|
278
|
+
? new Date(currentSession.slotAcquiredAt.getTime() - 300000)
|
|
279
|
+
: new Date(currentSession.createdAt.getTime() - 300000);
|
|
280
|
+
|
|
281
|
+
const isTimeValid = !isPriorExpiredSession && (normalizedObs.observedAt >= slotStart);
|
|
282
|
+
|
|
283
|
+
// Invariant Check 0a: Transaction Reference Already Used Check
|
|
284
|
+
let isUtrAlreadyUsed = false;
|
|
285
|
+
const trimmedUtr = normalizedObs.bankReferenceNumber ? String(normalizedObs.bankReferenceNumber).trim() : '';
|
|
286
|
+
if (trimmedUtr.length > 0) {
|
|
287
|
+
const priorUsedSession = this.sessionRepo.db.queryOne(`
|
|
288
|
+
SELECT ps.id, ps.order_id
|
|
289
|
+
FROM payment_sessions ps
|
|
290
|
+
JOIN payment_observations po ON (ps.attributed_observation_id = po.id OR po.attributed_payment_id = ps.id)
|
|
291
|
+
WHERE ps.status NOT IN ('FAILED', 'EXPIRED', 'CANCELLED')
|
|
292
|
+
AND ps.id != ?
|
|
293
|
+
AND po.bank_reference_number = ?
|
|
294
|
+
LIMIT 1
|
|
295
|
+
`, [currentSession.id, trimmedUtr]);
|
|
296
|
+
|
|
297
|
+
if (priorUsedSession) {
|
|
298
|
+
isUtrAlreadyUsed = true;
|
|
299
|
+
if (this.auditRepo) {
|
|
300
|
+
this.auditRepo.log({
|
|
301
|
+
merchantId: currentSession.merchantId,
|
|
302
|
+
entityType: 'PAYMENT_SESSION',
|
|
303
|
+
entityId: currentSession.id,
|
|
304
|
+
action: 'REUSED_UTR_REJECTED',
|
|
305
|
+
actorType: 'SYSTEM',
|
|
306
|
+
details: {
|
|
307
|
+
bankReferenceNumber: trimmedUtr,
|
|
308
|
+
consumedByOrderId: priorUsedSession.order_id,
|
|
309
|
+
consumedBySessionId: priorUsedSession.id
|
|
310
|
+
}
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// Invariant Check 0b: Check if SMS is from personal numeric phone number (10-15 digits E.164, no letters)
|
|
317
|
+
let isPersonalSms = false;
|
|
318
|
+
const senderStr = (normalizedObs.rawPayload?.sender || normalizedObs.rawPayload?.from || '').toString().trim();
|
|
319
|
+
if ((normalizedObs.source === 'SMS' || normalizedObs.source === 'ANDROID_OBSERVER') && senderStr) {
|
|
320
|
+
const digitsOnly = senderStr.replace(/[\s+-]/g, '');
|
|
321
|
+
if (digitsOnly.length >= 10 && /^\d+$/.test(digitsOnly) && !/[a-zA-Z]/.test(senderStr)) {
|
|
322
|
+
isPersonalSms = true;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
if (isUtrAlreadyUsed) {
|
|
327
|
+
return {
|
|
328
|
+
attributed: false,
|
|
329
|
+
duplicate: true,
|
|
330
|
+
lateMatch: false,
|
|
331
|
+
observationId: savedObs.id,
|
|
332
|
+
outcomeStatus: 'REFERENCE_ALREADY_USED'
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
if (isPersonalSms) {
|
|
337
|
+
return {
|
|
338
|
+
attributed: false,
|
|
339
|
+
duplicate: false,
|
|
340
|
+
lateMatch: false,
|
|
341
|
+
observationId: savedObs.id,
|
|
342
|
+
outcomeStatus: 'PERSONAL_SMS_REJECTED'
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
if (isEligibleStatus && isAmountMatch && isTimeValid) {
|
|
347
|
+
const wasActive = currentSession.status === PaymentStatus.ACTIVE;
|
|
348
|
+
|
|
349
|
+
// Check if there is already an active grace period awaiting second source for this session
|
|
350
|
+
const pendingGrace = this.pendingGraceTimers.get(currentSession.id);
|
|
351
|
+
if (pendingGrace) {
|
|
352
|
+
clearTimeout(pendingGrace);
|
|
353
|
+
this.pendingGraceTimers.delete(currentSession.id);
|
|
354
|
+
|
|
355
|
+
// Second source confirmed within 5-second grace window -> LEVEL 2 DUAL VERIFICATION!
|
|
356
|
+
let verifyResult;
|
|
357
|
+
try {
|
|
358
|
+
verifyResult = this.sessionRepo.recordVerificationSource(
|
|
359
|
+
currentSession.id,
|
|
360
|
+
normalizedObs.source,
|
|
361
|
+
savedObs.id,
|
|
362
|
+
{ finalizeSuccess: true }
|
|
363
|
+
);
|
|
364
|
+
|
|
365
|
+
// Check if primary observation already holds the attributed bank_reference_number
|
|
366
|
+
const primaryObs = currentSession.attributedObservationId
|
|
367
|
+
? this.obsRepo.findById(currentSession.attributedObservationId)
|
|
368
|
+
: null;
|
|
369
|
+
const sameUtrAsPrimary = Boolean(
|
|
370
|
+
primaryObs &&
|
|
371
|
+
primaryObs.bankReferenceNumber &&
|
|
372
|
+
savedObs.bankReferenceNumber &&
|
|
373
|
+
String(primaryObs.bankReferenceNumber).trim() === String(savedObs.bankReferenceNumber).trim()
|
|
374
|
+
);
|
|
375
|
+
|
|
376
|
+
if (!sameUtrAsPrimary) {
|
|
377
|
+
this.obsRepo.markAttributed(savedObs.id, currentSession.id);
|
|
378
|
+
} else {
|
|
379
|
+
// Secondary dual-verification observation corroborates existing attributed UTR
|
|
380
|
+
this.obsRepo.db.execute(`
|
|
381
|
+
UPDATE payment_observations
|
|
382
|
+
SET attribution_status = ?
|
|
383
|
+
WHERE id = ?
|
|
384
|
+
`, [ObservationAttributionStatus.ATTRIBUTED, savedObs.id]);
|
|
385
|
+
}
|
|
386
|
+
} catch (dbErr) {
|
|
387
|
+
const errMsg = dbErr?.message || '';
|
|
388
|
+
if (errMsg.includes('UNIQUE constraint failed') || errMsg.includes('duplicate key value') || errMsg.includes('idx_observations_unique_attributed_bank_ref') || errMsg.includes('idx_payment_sessions_attributed_obs')) {
|
|
389
|
+
return {
|
|
390
|
+
attributed: false,
|
|
391
|
+
duplicate: true,
|
|
392
|
+
lateMatch: false,
|
|
393
|
+
observationId: savedObs.id,
|
|
394
|
+
outcomeStatus: 'REFERENCE_ALREADY_USED'
|
|
395
|
+
};
|
|
396
|
+
}
|
|
397
|
+
throw dbErr;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
if (this.auditRepo) {
|
|
401
|
+
this.auditRepo.log({
|
|
402
|
+
merchantId: currentSession.merchantId,
|
|
403
|
+
entityType: 'PAYMENT_SESSION',
|
|
404
|
+
entityId: currentSession.id,
|
|
405
|
+
action: 'PAYMENT_VERIFICATION_UPGRADED_L2',
|
|
406
|
+
actorType: 'SYSTEM',
|
|
407
|
+
details: {
|
|
408
|
+
observationId: savedObs.id,
|
|
409
|
+
source: normalizedObs.source,
|
|
410
|
+
verificationLevel: 2,
|
|
411
|
+
verificationTag: verifyResult.tag,
|
|
412
|
+
sources: verifyResult.sources
|
|
413
|
+
}
|
|
414
|
+
});
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
this.eventEmitter.emit('PAYMENT.SUCCESS', {
|
|
418
|
+
paymentId: currentSession.id,
|
|
419
|
+
orderId: currentSession.orderId,
|
|
420
|
+
merchantId: currentSession.merchantId,
|
|
421
|
+
amountPaise: currentSession.amountPaise,
|
|
422
|
+
observationId: savedObs.id,
|
|
423
|
+
verificationLevel: 2,
|
|
424
|
+
verificationTag: verifyResult.tag,
|
|
425
|
+
sources: verifyResult.sources
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
this.eventEmitter.emit('PAYMENT.VERIFICATION_UPGRADED', {
|
|
429
|
+
paymentId: currentSession.id,
|
|
430
|
+
orderId: currentSession.orderId,
|
|
431
|
+
verificationLevel: 2,
|
|
432
|
+
verificationTag: verifyResult.tag,
|
|
433
|
+
sources: verifyResult.sources
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
return {
|
|
437
|
+
attributed: true,
|
|
438
|
+
duplicate: false,
|
|
439
|
+
lateMatch: false,
|
|
440
|
+
wasActive,
|
|
441
|
+
paymentSessionId: currentSession.id,
|
|
442
|
+
observationId: savedObs.id,
|
|
443
|
+
outcomeStatus: PaymentStatus.SUCCESS,
|
|
444
|
+
verificationLevel: 2,
|
|
445
|
+
verificationTag: verifyResult.tag,
|
|
446
|
+
verificationSources: verifyResult.sources
|
|
447
|
+
};
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// FIRST SOURCE CONFIRMATION:
|
|
451
|
+
// Record Level 1 verification source without immediate finalization (start 5-second grace window)
|
|
452
|
+
let verifyResult;
|
|
453
|
+
try {
|
|
454
|
+
verifyResult = this.sessionRepo.recordVerificationSource(
|
|
455
|
+
currentSession.id,
|
|
456
|
+
normalizedObs.source,
|
|
457
|
+
savedObs.id,
|
|
458
|
+
{ finalizeSuccess: false }
|
|
459
|
+
);
|
|
460
|
+
|
|
461
|
+
this.obsRepo.markAttributed(savedObs.id, currentSession.id);
|
|
462
|
+
} catch (dbErr) {
|
|
463
|
+
const errMsg = dbErr.message || '';
|
|
464
|
+
if (errMsg.includes('UNIQUE constraint failed') || errMsg.includes('duplicate key value') || errMsg.includes('idx_observations_unique_attributed_bank_ref') || errMsg.includes('idx_payment_sessions_attributed_obs')) {
|
|
465
|
+
if (this.auditRepo) {
|
|
466
|
+
this.auditRepo.log({
|
|
467
|
+
merchantId: currentSession.merchantId,
|
|
468
|
+
entityType: 'PAYMENT_SESSION',
|
|
469
|
+
entityId: currentSession.id,
|
|
470
|
+
action: 'REUSED_UTR_REJECTED',
|
|
471
|
+
actorType: 'SYSTEM',
|
|
472
|
+
details: {
|
|
473
|
+
bankReferenceNumber: trimmedUtr,
|
|
474
|
+
error: 'Concurrent UTR or observation attribution race prevented by database unique constraint.'
|
|
475
|
+
}
|
|
476
|
+
});
|
|
477
|
+
}
|
|
478
|
+
return {
|
|
479
|
+
attributed: false,
|
|
480
|
+
duplicate: true,
|
|
481
|
+
lateMatch: false,
|
|
482
|
+
observationId: savedObs.id,
|
|
483
|
+
outcomeStatus: 'REFERENCE_ALREADY_USED'
|
|
484
|
+
};
|
|
485
|
+
}
|
|
486
|
+
throw dbErr;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
if (this.auditRepo) {
|
|
490
|
+
this.auditRepo.log({
|
|
491
|
+
merchantId: currentSession.merchantId,
|
|
492
|
+
entityType: 'PAYMENT_SESSION',
|
|
493
|
+
entityId: currentSession.id,
|
|
494
|
+
action: 'PAYMENT_LEVEL1_VERIFIED',
|
|
495
|
+
actorType: 'SYSTEM',
|
|
496
|
+
details: {
|
|
497
|
+
observationId: savedObs.id,
|
|
498
|
+
source: normalizedObs.source,
|
|
499
|
+
amountPaise: normalizedObs.amountPaise.toString(),
|
|
500
|
+
verificationLevel: 1,
|
|
501
|
+
verificationTag: verifyResult.tag,
|
|
502
|
+
sources: verifyResult.sources
|
|
503
|
+
}
|
|
504
|
+
});
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
this.eventEmitter.emit('PAYMENT.LEVEL1_VERIFIED', {
|
|
508
|
+
paymentId: currentSession.id,
|
|
509
|
+
orderId: currentSession.orderId,
|
|
510
|
+
merchantId: currentSession.merchantId,
|
|
511
|
+
amountPaise: currentSession.amountPaise,
|
|
512
|
+
observationId: savedObs.id,
|
|
513
|
+
verificationLevel: 1,
|
|
514
|
+
verificationTag: verifyResult.tag,
|
|
515
|
+
sources: verifyResult.sources
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
// If grace period is 0 (e.g. in instant tests), finalize immediately
|
|
519
|
+
if (this.gracePeriodMs <= 0) {
|
|
520
|
+
const finalResult = this.sessionRepo.recordVerificationSource(
|
|
521
|
+
currentSession.id,
|
|
522
|
+
normalizedObs.source,
|
|
523
|
+
savedObs.id,
|
|
524
|
+
{ finalizeSuccess: true }
|
|
525
|
+
);
|
|
526
|
+
this.eventEmitter.emit('PAYMENT.SUCCESS', {
|
|
527
|
+
paymentId: currentSession.id,
|
|
528
|
+
orderId: currentSession.orderId,
|
|
529
|
+
merchantId: currentSession.merchantId,
|
|
530
|
+
amountPaise: currentSession.amountPaise,
|
|
531
|
+
observationId: savedObs.id,
|
|
532
|
+
verificationLevel: finalResult.level,
|
|
533
|
+
verificationTag: finalResult.tag,
|
|
534
|
+
sources: finalResult.sources
|
|
535
|
+
});
|
|
536
|
+
|
|
537
|
+
return {
|
|
538
|
+
attributed: true,
|
|
539
|
+
duplicate: false,
|
|
540
|
+
lateMatch: false,
|
|
541
|
+
wasActive,
|
|
542
|
+
paymentSessionId: currentSession.id,
|
|
543
|
+
observationId: savedObs.id,
|
|
544
|
+
outcomeStatus: PaymentStatus.SUCCESS,
|
|
545
|
+
verificationLevel: finalResult.level,
|
|
546
|
+
verificationTag: finalResult.tag,
|
|
547
|
+
verificationSources: finalResult.sources
|
|
548
|
+
};
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
// Start 5-second grace window awaiting second poller
|
|
552
|
+
const targetSessionId = currentSession.id;
|
|
553
|
+
const targetObsId = savedObs.id;
|
|
554
|
+
const targetSource = normalizedObs.source;
|
|
555
|
+
const targetOrderId = currentSession.orderId;
|
|
556
|
+
const targetMerchantId = currentSession.merchantId;
|
|
557
|
+
const targetAmountPaise = currentSession.amountPaise;
|
|
558
|
+
|
|
559
|
+
const graceTimer = setTimeout(() => {
|
|
560
|
+
this.pendingGraceTimers.delete(targetSessionId);
|
|
561
|
+
// Check if session became terminal during the grace window
|
|
562
|
+
const latestSession = this.sessionRepo.findById(targetSessionId);
|
|
563
|
+
if (!latestSession || (isTerminalStatus(latestSession.status) && latestSession.status !== PaymentStatus.SUCCESS)) {
|
|
564
|
+
const sessionStatus = latestSession ? latestSession.status : 'EXPIRED';
|
|
565
|
+
this.obsRepo.markLateUnmatched(targetObsId);
|
|
566
|
+
if (this.auditRepo) {
|
|
567
|
+
this.auditRepo.log({
|
|
568
|
+
merchantId: targetMerchantId,
|
|
569
|
+
entityType: 'PAYMENT_SESSION',
|
|
570
|
+
entityId: targetSessionId,
|
|
571
|
+
action: 'LATE_OBSERVATION_DETECTED',
|
|
572
|
+
actorType: 'SYSTEM',
|
|
573
|
+
details: {
|
|
574
|
+
observationId: targetObsId,
|
|
575
|
+
source: targetSource,
|
|
576
|
+
sessionStatus
|
|
577
|
+
}
|
|
578
|
+
});
|
|
579
|
+
}
|
|
580
|
+
if (this.ticketRepo && latestSession?.customerId) {
|
|
581
|
+
this.ticketRepo.createTicket({
|
|
582
|
+
merchantId: targetMerchantId,
|
|
583
|
+
customerId: latestSession.customerId,
|
|
584
|
+
paymentId: targetSessionId,
|
|
585
|
+
subject: `[LATE PAYMENT DETECTED] Order #${targetOrderId}`,
|
|
586
|
+
status: TicketStatus.OPEN,
|
|
587
|
+
initialMessage: {
|
|
588
|
+
senderType: SenderType.ADMIN,
|
|
589
|
+
senderId: targetMerchantId,
|
|
590
|
+
message: `A delayed payment observation of ${targetAmountPaise / 100n} INR was detected after session ${targetSessionId} ended with status ${sessionStatus}. Flagged for manual merchant reconciliation.`
|
|
591
|
+
}
|
|
592
|
+
});
|
|
593
|
+
}
|
|
594
|
+
this.eventEmitter.emit('PAYMENT.LATE_DETECTED', {
|
|
595
|
+
paymentId: targetSessionId,
|
|
596
|
+
orderId: targetOrderId,
|
|
597
|
+
merchantId: targetMerchantId,
|
|
598
|
+
amountPaise: targetAmountPaise,
|
|
599
|
+
observationId: targetObsId
|
|
600
|
+
});
|
|
601
|
+
return;
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
if (latestSession.status === PaymentStatus.SUCCESS) {
|
|
605
|
+
return;
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
// 5 seconds elapsed without second source: finalize as Level 1
|
|
609
|
+
const finalResult = this.sessionRepo.recordVerificationSource(
|
|
610
|
+
targetSessionId,
|
|
611
|
+
targetSource,
|
|
612
|
+
targetObsId,
|
|
613
|
+
{ finalizeSuccess: true }
|
|
614
|
+
);
|
|
615
|
+
|
|
616
|
+
if (this.auditRepo) {
|
|
617
|
+
this.auditRepo.log({
|
|
618
|
+
merchantId: targetMerchantId,
|
|
619
|
+
entityType: 'PAYMENT_SESSION',
|
|
620
|
+
entityId: targetSessionId,
|
|
621
|
+
action: 'PAYMENT_ATTRIBUTED_SUCCESS_L1',
|
|
622
|
+
actorType: 'SYSTEM',
|
|
623
|
+
details: {
|
|
624
|
+
observationId: targetObsId,
|
|
625
|
+
source: targetSource,
|
|
626
|
+
verificationLevel: finalResult.level,
|
|
627
|
+
verificationTag: finalResult.tag
|
|
628
|
+
}
|
|
629
|
+
});
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
this.eventEmitter.emit('PAYMENT.SUCCESS', {
|
|
633
|
+
paymentId: targetSessionId,
|
|
634
|
+
orderId: targetOrderId,
|
|
635
|
+
merchantId: targetMerchantId,
|
|
636
|
+
amountPaise: targetAmountPaise,
|
|
637
|
+
observationId: targetObsId,
|
|
638
|
+
verificationLevel: finalResult.level,
|
|
639
|
+
verificationTag: finalResult.tag,
|
|
640
|
+
sources: finalResult.sources
|
|
641
|
+
});
|
|
642
|
+
}, this.gracePeriodMs);
|
|
643
|
+
|
|
644
|
+
if (graceTimer.unref) graceTimer.unref();
|
|
645
|
+
this.pendingGraceTimers.set(targetSessionId, graceTimer);
|
|
646
|
+
|
|
647
|
+
return {
|
|
648
|
+
attributed: true,
|
|
649
|
+
duplicate: false,
|
|
650
|
+
lateMatch: false,
|
|
651
|
+
wasActive,
|
|
652
|
+
paymentSessionId: currentSession.id,
|
|
653
|
+
observationId: savedObs.id,
|
|
654
|
+
outcomeStatus: 'VERIFYING',
|
|
655
|
+
verificationLevel: 1,
|
|
656
|
+
verificationTag: verifyResult.tag,
|
|
657
|
+
verificationSources: verifyResult.sources
|
|
658
|
+
};
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
// Check if observation confirms a recently successful session on this receiving account (within 60s)
|
|
663
|
+
// Upgrades verification level (e.g. from Level 1 -> Level 2 -> Level 3)
|
|
664
|
+
const sixtySecsAgo = new Date(Date.now() - 60000).toISOString();
|
|
665
|
+
const recentSuccessSession = this.sessionRepo.db.queryOne(`
|
|
666
|
+
SELECT * FROM payment_sessions
|
|
667
|
+
WHERE receiving_account_id = ? AND status = 'SUCCESS'
|
|
668
|
+
AND amount_paise = ? AND completed_at >= ?
|
|
669
|
+
ORDER BY completed_at DESC LIMIT 1
|
|
670
|
+
`, [normalizedObs.receivingAccountId, Number(normalizedObs.amountPaise), sixtySecsAgo]);
|
|
671
|
+
|
|
672
|
+
if (recentSuccessSession) {
|
|
673
|
+
// Check if this session's attributed observation had a UTR
|
|
674
|
+
const existingObs = recentSuccessSession.attributed_observation_id
|
|
675
|
+
? this.obsRepo.findById(recentSuccessSession.attributed_observation_id)
|
|
676
|
+
: null;
|
|
677
|
+
|
|
678
|
+
// If both have UTRs and they do NOT match, this is NOT the same transaction!
|
|
679
|
+
const isUtrConflict = normalizedObs.bankReferenceNumber &&
|
|
680
|
+
existingObs?.bankReferenceNumber &&
|
|
681
|
+
normalizedObs.bankReferenceNumber !== existingObs.bankReferenceNumber;
|
|
682
|
+
|
|
683
|
+
if (isUtrConflict) {
|
|
684
|
+
if (this.auditRepo) {
|
|
685
|
+
this.auditRepo.log({
|
|
686
|
+
merchantId: recentSuccessSession.merchant_id,
|
|
687
|
+
entityType: 'PAYMENT_SESSION',
|
|
688
|
+
entityId: recentSuccessSession.id,
|
|
689
|
+
action: 'DUPLICATE_PAYMENT_UTR_CONFLICT',
|
|
690
|
+
actorType: 'SYSTEM',
|
|
691
|
+
details: {
|
|
692
|
+
observationId: savedObs.id,
|
|
693
|
+
existingUtr: existingObs?.bankReferenceNumber,
|
|
694
|
+
newUtr: normalizedObs.bankReferenceNumber,
|
|
695
|
+
amountPaise: normalizedObs.amountPaise.toString()
|
|
696
|
+
}
|
|
697
|
+
});
|
|
698
|
+
}
|
|
699
|
+
if (this.ticketRepo && recentSuccessSession.customer_id) {
|
|
700
|
+
this.ticketRepo.createTicket({
|
|
701
|
+
merchantId: recentSuccessSession.merchant_id,
|
|
702
|
+
customerId: recentSuccessSession.customer_id,
|
|
703
|
+
paymentId: recentSuccessSession.id,
|
|
704
|
+
subject: `[DUPLICATE PAYMENT DETECTED] Order #${recentSuccessSession.order_id}`,
|
|
705
|
+
status: TicketStatus.OPEN,
|
|
706
|
+
initialMessage: {
|
|
707
|
+
senderType: SenderType.ADMIN,
|
|
708
|
+
senderId: recentSuccessSession.merchant_id,
|
|
709
|
+
message: `A duplicate payment of ${normalizedObs.amountPaise / 100n} INR with reference ${normalizedObs.bankReferenceNumber} was received within 60s of completed order #${recentSuccessSession.order_id} (reference ${existingObs?.bankReferenceNumber || 'N/A'}). Flagged for merchant refund reconciliation.`
|
|
710
|
+
}
|
|
711
|
+
});
|
|
712
|
+
}
|
|
713
|
+
this.eventEmitter.emit('PAYMENT.DUPLICATE_DETECTED', {
|
|
714
|
+
paymentId: recentSuccessSession.id,
|
|
715
|
+
orderId: recentSuccessSession.order_id,
|
|
716
|
+
merchantId: recentSuccessSession.merchant_id,
|
|
717
|
+
observationId: savedObs.id,
|
|
718
|
+
existingUtr: existingObs?.bankReferenceNumber,
|
|
719
|
+
newUtr: normalizedObs.bankReferenceNumber,
|
|
720
|
+
amountPaise: normalizedObs.amountPaise
|
|
721
|
+
});
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
const sessionSlotStart = recentSuccessSession.slot_acquired_at
|
|
725
|
+
? new Date(new Date(recentSuccessSession.slot_acquired_at).getTime() - 30000)
|
|
726
|
+
: new Date(new Date(recentSuccessSession.created_at).getTime() - 30000);
|
|
727
|
+
const sessionCompleted = recentSuccessSession.completed_at
|
|
728
|
+
? new Date(new Date(recentSuccessSession.completed_at).getTime() + 30000)
|
|
729
|
+
: new Date();
|
|
730
|
+
|
|
731
|
+
// Temporal check: observation must have been observed during this session's window
|
|
732
|
+
if (!isUtrConflict && normalizedObs.observedAt >= sessionSlotStart && normalizedObs.observedAt <= sessionCompleted) {
|
|
733
|
+
const verifyResult = this.sessionRepo.recordVerificationSource(
|
|
734
|
+
recentSuccessSession.id,
|
|
735
|
+
normalizedObs.source,
|
|
736
|
+
savedObs.id
|
|
737
|
+
);
|
|
738
|
+
|
|
739
|
+
this.obsRepo.markAttributed(savedObs.id, recentSuccessSession.id);
|
|
740
|
+
|
|
741
|
+
if (this.auditRepo) {
|
|
742
|
+
this.auditRepo.log({
|
|
743
|
+
merchantId: recentSuccessSession.merchant_id,
|
|
744
|
+
entityType: 'PAYMENT_SESSION',
|
|
745
|
+
entityId: recentSuccessSession.id,
|
|
746
|
+
action: 'PAYMENT_VERIFICATION_UPGRADED',
|
|
747
|
+
actorType: 'SYSTEM',
|
|
748
|
+
details: {
|
|
749
|
+
observationId: savedObs.id,
|
|
750
|
+
source: normalizedObs.source,
|
|
751
|
+
amountPaise: normalizedObs.amountPaise.toString(),
|
|
752
|
+
verificationLevel: verifyResult.level,
|
|
753
|
+
verificationTag: verifyResult.tag,
|
|
754
|
+
sources: verifyResult.sources
|
|
755
|
+
}
|
|
756
|
+
});
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
this.eventEmitter.emit('PAYMENT.VERIFICATION_UPGRADED', {
|
|
760
|
+
paymentId: recentSuccessSession.id,
|
|
761
|
+
orderId: recentSuccessSession.order_id,
|
|
762
|
+
verificationLevel: verifyResult.level,
|
|
763
|
+
verificationTag: verifyResult.tag,
|
|
764
|
+
sources: verifyResult.sources
|
|
765
|
+
});
|
|
766
|
+
|
|
767
|
+
return {
|
|
768
|
+
attributed: true,
|
|
769
|
+
duplicate: false,
|
|
770
|
+
lateMatch: false,
|
|
771
|
+
paymentSessionId: recentSuccessSession.id,
|
|
772
|
+
observationId: savedObs.id,
|
|
773
|
+
outcomeStatus: PaymentStatus.SUCCESS,
|
|
774
|
+
verificationLevel: verifyResult.level,
|
|
775
|
+
verificationTag: verifyResult.tag,
|
|
776
|
+
verificationSources: verifyResult.sources
|
|
777
|
+
};
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
// Tier 3: Late Observation Handling
|
|
782
|
+
// Check if observation matches a recently expired or failed session on this receiving account (< 30 mins)
|
|
783
|
+
const thirtyMinutesAgo = new Date(Date.now() - 30 * 60 * 1000);
|
|
784
|
+
const recentExpired = this.sessionRepo.findRecentlyExpiredOrFailed(
|
|
785
|
+
normalizedObs.receivingAccountId,
|
|
786
|
+
thirtyMinutesAgo
|
|
787
|
+
);
|
|
788
|
+
|
|
789
|
+
const lateMatchedSession = recentExpired.find(s => s.amountPaise === normalizedObs.amountPaise);
|
|
790
|
+
|
|
791
|
+
if (lateMatchedSession) {
|
|
792
|
+
// Mark observation as LATE_UNMATCHED
|
|
793
|
+
this.obsRepo.markLateUnmatched(savedObs.id);
|
|
794
|
+
|
|
795
|
+
if (this.auditRepo) {
|
|
796
|
+
this.auditRepo.log({
|
|
797
|
+
merchantId: lateMatchedSession.merchantId,
|
|
798
|
+
entityType: 'PAYMENT_SESSION',
|
|
799
|
+
entityId: lateMatchedSession.id,
|
|
800
|
+
action: 'LATE_OBSERVATION_DETECTED',
|
|
801
|
+
actorType: 'SYSTEM',
|
|
802
|
+
details: {
|
|
803
|
+
observationId: savedObs.id,
|
|
804
|
+
source: normalizedObs.source,
|
|
805
|
+
amountPaise: normalizedObs.amountPaise.toString(),
|
|
806
|
+
sessionStatus: lateMatchedSession.status
|
|
807
|
+
}
|
|
808
|
+
});
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
// Automatically create a high-priority reconciliation support ticket if customer exists
|
|
812
|
+
if (this.ticketRepo && lateMatchedSession.customerId) {
|
|
813
|
+
this.ticketRepo.createTicket({
|
|
814
|
+
merchantId: lateMatchedSession.merchantId,
|
|
815
|
+
customerId: lateMatchedSession.customerId,
|
|
816
|
+
paymentId: lateMatchedSession.id,
|
|
817
|
+
subject: `[LATE PAYMENT DETECTED] Order #${lateMatchedSession.orderId}`,
|
|
818
|
+
status: TicketStatus.OPEN,
|
|
819
|
+
initialMessage: {
|
|
820
|
+
senderType: SenderType.ADMIN,
|
|
821
|
+
senderId: lateMatchedSession.merchantId,
|
|
822
|
+
message: `A delayed payment observation of ${normalizedObs.amountPaise / 100n} INR was detected after session ${lateMatchedSession.id} ended with status ${lateMatchedSession.status}. Flagged for manual merchant reconciliation.`
|
|
823
|
+
}
|
|
824
|
+
});
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
// Emit LATE_DETECTED event
|
|
828
|
+
this.eventEmitter.emit('PAYMENT.LATE_DETECTED', {
|
|
829
|
+
paymentId: lateMatchedSession.id,
|
|
830
|
+
orderId: lateMatchedSession.orderId,
|
|
831
|
+
merchantId: lateMatchedSession.merchantId,
|
|
832
|
+
amountPaise: normalizedObs.amountPaise,
|
|
833
|
+
observationId: savedObs.id
|
|
834
|
+
});
|
|
835
|
+
|
|
836
|
+
return {
|
|
837
|
+
attributed: false,
|
|
838
|
+
duplicate: false,
|
|
839
|
+
lateMatch: true,
|
|
840
|
+
paymentSessionId: lateMatchedSession.id,
|
|
841
|
+
observationId: savedObs.id,
|
|
842
|
+
outcomeStatus: ObservationAttributionStatus.LATE_UNMATCHED
|
|
843
|
+
};
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
// Unattributed observation
|
|
847
|
+
return {
|
|
848
|
+
attributed: false,
|
|
849
|
+
duplicate: false,
|
|
850
|
+
lateMatch: false,
|
|
851
|
+
observationId: savedObs.id,
|
|
852
|
+
outcomeStatus: ObservationAttributionStatus.UNATTRIBUTED
|
|
853
|
+
};
|
|
854
|
+
}
|
|
855
|
+
}
|