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,618 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import { PaymentStatus, PaymentMethod, isValidTransition, TERMINAL_STATES } from '../../common/index.js';
|
|
3
|
+
|
|
4
|
+
export class PaymentSessionRepository {
|
|
5
|
+
/**
|
|
6
|
+
* @param {import('../database.js').Database} db
|
|
7
|
+
*/
|
|
8
|
+
constructor(db) {
|
|
9
|
+
this.db = db;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
create(data) {
|
|
13
|
+
const id = data.id || crypto.randomUUID();
|
|
14
|
+
const now = new Date().toISOString();
|
|
15
|
+
const metadata = typeof data.metadata === 'object' ? JSON.stringify(data.metadata) : (data.metadata || '{}');
|
|
16
|
+
const attemptId = data.attemptId || id;
|
|
17
|
+
|
|
18
|
+
const sql = `
|
|
19
|
+
INSERT INTO payment_sessions (
|
|
20
|
+
id, merchant_id, customer_id, receiving_account_id, order_id, attempt_id,
|
|
21
|
+
amount_paise, currency, method, status, queue_position,
|
|
22
|
+
return_url, idempotency_key, device_id, verification_level, verification_sources,
|
|
23
|
+
verification_tag, metadata, created_at, updated_at
|
|
24
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, '[]', 'UNVERIFIED', ?, ?, ?)
|
|
25
|
+
`;
|
|
26
|
+
|
|
27
|
+
this.db.execute(sql, [
|
|
28
|
+
id,
|
|
29
|
+
data.merchantId,
|
|
30
|
+
data.customerId || null,
|
|
31
|
+
data.receivingAccountId || null,
|
|
32
|
+
data.orderId,
|
|
33
|
+
attemptId,
|
|
34
|
+
Number(data.amountPaise),
|
|
35
|
+
data.currency || 'INR',
|
|
36
|
+
data.method || PaymentMethod.UPI,
|
|
37
|
+
data.status || PaymentStatus.INITIATED,
|
|
38
|
+
data.queuePosition || 0,
|
|
39
|
+
data.returnUrl || null,
|
|
40
|
+
data.idempotencyKey || null,
|
|
41
|
+
data.deviceId || null,
|
|
42
|
+
metadata,
|
|
43
|
+
now,
|
|
44
|
+
now
|
|
45
|
+
]);
|
|
46
|
+
|
|
47
|
+
return this.findById(id);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
findById(id) {
|
|
51
|
+
const row = this.db.queryOne('SELECT * FROM payment_sessions WHERE id = ?', [id]);
|
|
52
|
+
if (!row) return null;
|
|
53
|
+
return this.mapRow(row);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
findByOrderId(merchantId, orderId) {
|
|
57
|
+
// The schema allows a second row for the same (merchant, order) once the previous one
|
|
58
|
+
// is terminal, so the newest row must win. Without an explicit ordering a stale
|
|
59
|
+
// terminal row could be returned and the caller would miss a SUCCESS session.
|
|
60
|
+
const row = this.db.queryOne(
|
|
61
|
+
'SELECT * FROM payment_sessions WHERE merchant_id = ? AND order_id = ? ORDER BY created_at DESC, rowid DESC LIMIT 1',
|
|
62
|
+
[merchantId, orderId]
|
|
63
|
+
);
|
|
64
|
+
if (!row) return null;
|
|
65
|
+
return this.mapRow(row);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
findByCustomerId(customerId) {
|
|
69
|
+
const rows = this.db.query(
|
|
70
|
+
'SELECT * FROM payment_sessions WHERE customer_id = ? ORDER BY created_at DESC',
|
|
71
|
+
[customerId]
|
|
72
|
+
);
|
|
73
|
+
return rows.map(r => this.mapRow(r));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
findByMerchantId(merchantId, limit = 50) {
|
|
77
|
+
const rows = this.db.query(
|
|
78
|
+
'SELECT * FROM payment_sessions WHERE merchant_id = ? ORDER BY created_at DESC LIMIT ?',
|
|
79
|
+
[merchantId, limit]
|
|
80
|
+
);
|
|
81
|
+
return rows.map(r => this.mapRow(r));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Atomic slot acquisition transaction for UPI payments.
|
|
86
|
+
* Invariant: Only ONE active or processing session per receiving account.
|
|
87
|
+
* If slot is free, session transitions to ACTIVE with 5:00 window.
|
|
88
|
+
* If slot is occupied, session transitions to QUEUED with sequence position.
|
|
89
|
+
*/
|
|
90
|
+
acquireSlotAtomic(sessionId, receivingAccountId, durationMs = 300000) {
|
|
91
|
+
return this.db.transaction(() => {
|
|
92
|
+
const now = new Date();
|
|
93
|
+
const nowIso = now.toISOString();
|
|
94
|
+
|
|
95
|
+
// Check for current active or processing session on this receiving account
|
|
96
|
+
let activeRow = this.db.queryOne(`
|
|
97
|
+
SELECT id, status, expires_at, processing_expires_at
|
|
98
|
+
FROM payment_sessions
|
|
99
|
+
WHERE receiving_account_id = ? AND status IN (?, ?)
|
|
100
|
+
LIMIT 1
|
|
101
|
+
`, [receivingAccountId, PaymentStatus.ACTIVE, PaymentStatus.PROCESSING]);
|
|
102
|
+
|
|
103
|
+
if (activeRow) {
|
|
104
|
+
const isStale = (activeRow.status === PaymentStatus.ACTIVE && activeRow.expires_at && new Date(activeRow.expires_at) <= now) ||
|
|
105
|
+
(activeRow.status === PaymentStatus.PROCESSING && activeRow.processing_expires_at && new Date(activeRow.processing_expires_at) <= now);
|
|
106
|
+
if (isStale) {
|
|
107
|
+
const terminalStatus = activeRow.status === PaymentStatus.PROCESSING ? PaymentStatus.FAILED : PaymentStatus.EXPIRED;
|
|
108
|
+
if (!isValidTransition(activeRow.status, terminalStatus)) {
|
|
109
|
+
throw new Error(`Illegal payment state transition: cannot move from '${activeRow.status}' to '${terminalStatus}'.`);
|
|
110
|
+
}
|
|
111
|
+
this.db.execute(`
|
|
112
|
+
UPDATE payment_sessions
|
|
113
|
+
SET status = ?, completed_at = ?, updated_at = ?
|
|
114
|
+
WHERE id = ?
|
|
115
|
+
`, [terminalStatus, nowIso, nowIso, activeRow.id]);
|
|
116
|
+
activeRow = null;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// CRITICAL GUARD: If activeRow is THIS session itself, it's ALREADY ACTIVE! Do not demote to QUEUED!
|
|
121
|
+
if (activeRow && activeRow.id === sessionId) {
|
|
122
|
+
return {
|
|
123
|
+
status: PaymentStatus.ACTIVE,
|
|
124
|
+
queuePosition: 0,
|
|
125
|
+
expiresAt: activeRow.expires_at ? new Date(activeRow.expires_at) : new Date(now.getTime() + durationMs)
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (!activeRow) {
|
|
130
|
+
// Slot is vacant! Grant ACTIVE slot.
|
|
131
|
+
// Enforce the transition graph: a terminal or PROCESSING session must never be
|
|
132
|
+
// revived to ACTIVE by a concurrent queue tick.
|
|
133
|
+
const currentStatus = this.findById(sessionId)?.status;
|
|
134
|
+
if (currentStatus && !isValidTransition(currentStatus, PaymentStatus.ACTIVE)) {
|
|
135
|
+
throw new Error(`Illegal payment state transition: cannot move from '${currentStatus}' to 'ACTIVE'.`);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const expiresAt = new Date(now.getTime() + durationMs).toISOString();
|
|
139
|
+
|
|
140
|
+
this.db.execute(`
|
|
141
|
+
UPDATE payment_sessions
|
|
142
|
+
SET status = ?, slot_acquired_at = ?, expires_at = ?, queue_position = 0, updated_at = ?
|
|
143
|
+
WHERE id = ?
|
|
144
|
+
`, [PaymentStatus.ACTIVE, nowIso, expiresAt, nowIso, sessionId]);
|
|
145
|
+
|
|
146
|
+
// Mark receiving account as busy
|
|
147
|
+
this.db.execute(`
|
|
148
|
+
UPDATE payment_receiving_accounts
|
|
149
|
+
SET is_slot_busy = 1, current_active_session_id = ?, updated_at = ?
|
|
150
|
+
WHERE id = ?
|
|
151
|
+
`, [sessionId, nowIso, receivingAccountId]);
|
|
152
|
+
|
|
153
|
+
return {
|
|
154
|
+
status: PaymentStatus.ACTIVE,
|
|
155
|
+
queuePosition: 0,
|
|
156
|
+
expiresAt: new Date(expiresAt)
|
|
157
|
+
};
|
|
158
|
+
} else {
|
|
159
|
+
// Slot is busy! Place in QUEUED
|
|
160
|
+
const existingSession = this.findById(sessionId);
|
|
161
|
+
let queuePosition = existingSession?.queuePosition;
|
|
162
|
+
if (!queuePosition || queuePosition <= 0) {
|
|
163
|
+
const posRow = this.db.queryOne(`
|
|
164
|
+
SELECT COALESCE(MAX(queue_position), 0) + 1 AS next_pos
|
|
165
|
+
FROM payment_sessions
|
|
166
|
+
WHERE receiving_account_id = ? AND status = ?
|
|
167
|
+
`, [receivingAccountId, PaymentStatus.QUEUED]);
|
|
168
|
+
|
|
169
|
+
queuePosition = posRow ? posRow.next_pos : 1;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (existingSession && !isValidTransition(existingSession.status, PaymentStatus.QUEUED)) {
|
|
173
|
+
throw new Error(`Illegal payment state transition: cannot move from '${existingSession.status}' to 'QUEUED'.`);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
this.db.execute(`
|
|
177
|
+
UPDATE payment_sessions
|
|
178
|
+
SET status = ?, queue_position = ?, updated_at = ?
|
|
179
|
+
WHERE id = ?
|
|
180
|
+
`, [PaymentStatus.QUEUED, queuePosition, nowIso, sessionId]);
|
|
181
|
+
|
|
182
|
+
return {
|
|
183
|
+
status: PaymentStatus.QUEUED,
|
|
184
|
+
queuePosition,
|
|
185
|
+
expiresAt: null
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Transitions an ACTIVE session to PROCESSING upon customer clicking Payment Done.
|
|
193
|
+
* Starts independent 30-second verification timer.
|
|
194
|
+
*/
|
|
195
|
+
transitionToProcessing(sessionId, durationMs = 30000) {
|
|
196
|
+
return this.db.transaction(() => {
|
|
197
|
+
const session = this.findById(sessionId);
|
|
198
|
+
if (!session) throw new Error(`Session not found: ${sessionId}`);
|
|
199
|
+
|
|
200
|
+
// Idempotency: if already processing, return existing
|
|
201
|
+
if (session.status === PaymentStatus.PROCESSING) {
|
|
202
|
+
return session;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if (session.status !== PaymentStatus.ACTIVE) {
|
|
206
|
+
throw new Error(`Cannot transition to PROCESSING from status: ${session.status}`);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const now = new Date();
|
|
210
|
+
const nowIso = now.toISOString();
|
|
211
|
+
const processingExpiresAt = new Date(now.getTime() + durationMs).toISOString();
|
|
212
|
+
|
|
213
|
+
this.db.execute(`
|
|
214
|
+
UPDATE payment_sessions
|
|
215
|
+
SET status = ?, processing_started_at = ?, processing_expires_at = ?, updated_at = ?
|
|
216
|
+
WHERE id = ? AND status = ?
|
|
217
|
+
`, [PaymentStatus.PROCESSING, nowIso, processingExpiresAt, nowIso, sessionId, PaymentStatus.ACTIVE]);
|
|
218
|
+
|
|
219
|
+
return this.findById(sessionId);
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Transitions session to terminal state (SUCCESS, FAILED, EXPIRED, CANCELLED).
|
|
225
|
+
* Atomically releases receiving account slot.
|
|
226
|
+
*/
|
|
227
|
+
transitionToTerminal(sessionId, targetStatus, options = {}) {
|
|
228
|
+
return this.db.transaction(() => {
|
|
229
|
+
const session = this.findById(sessionId);
|
|
230
|
+
if (!session) throw new Error(`Session not found: ${sessionId}`);
|
|
231
|
+
|
|
232
|
+
// Terminal states are final
|
|
233
|
+
const terminalStates = [
|
|
234
|
+
PaymentStatus.SUCCESS,
|
|
235
|
+
PaymentStatus.FAILED,
|
|
236
|
+
PaymentStatus.EXPIRED,
|
|
237
|
+
PaymentStatus.CANCELLED
|
|
238
|
+
];
|
|
239
|
+
|
|
240
|
+
if (terminalStates.includes(session.status)) {
|
|
241
|
+
return session; // already terminal, idempotent
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// The state machine is enforced here rather than merely documented: without this,
|
|
245
|
+
// any caller could move a live session straight to SUCCESS and bypass verification.
|
|
246
|
+
if (!TERMINAL_STATES.includes(targetStatus)) {
|
|
247
|
+
throw new Error(`Not a terminal target status: ${targetStatus}`);
|
|
248
|
+
}
|
|
249
|
+
if (!isValidTransition(session.status, targetStatus)) {
|
|
250
|
+
throw new Error(`Illegal payment state transition: cannot move from '${session.status}' to '${targetStatus}'.`);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const now = new Date().toISOString();
|
|
254
|
+
const attributedObsId = options.attributedObservationId || session.attributedObservationId || null;
|
|
255
|
+
|
|
256
|
+
let metadata = session.metadata || {};
|
|
257
|
+
if (typeof metadata === 'string') {
|
|
258
|
+
try { metadata = JSON.parse(metadata); } catch { metadata = {}; }
|
|
259
|
+
}
|
|
260
|
+
if (options.reason) {
|
|
261
|
+
metadata = { ...metadata, cancelledReason: options.reason };
|
|
262
|
+
}
|
|
263
|
+
if (options.replacedByDeviceId) {
|
|
264
|
+
metadata = { ...metadata, replacedByDeviceId: options.replacedByDeviceId };
|
|
265
|
+
}
|
|
266
|
+
if (options.replacedBySessionId) {
|
|
267
|
+
metadata = { ...metadata, replacedBySessionId: options.replacedBySessionId };
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
this.db.execute(`
|
|
271
|
+
UPDATE payment_sessions
|
|
272
|
+
SET status = ?, completed_at = ?, attributed_observation_id = ?, metadata = ?, updated_at = ?
|
|
273
|
+
WHERE id = ?
|
|
274
|
+
`, [targetStatus, now, attributedObsId, JSON.stringify(metadata), now, sessionId]);
|
|
275
|
+
|
|
276
|
+
// Release slot on receiving account if this session owned it
|
|
277
|
+
if (session.receivingAccountId) {
|
|
278
|
+
this.db.execute(`
|
|
279
|
+
UPDATE payment_receiving_accounts
|
|
280
|
+
SET is_slot_busy = 0, current_active_session_id = NULL, updated_at = ?
|
|
281
|
+
WHERE id = ? AND (current_active_session_id = ? OR current_active_session_id IS NULL)
|
|
282
|
+
`, [now, session.receivingAccountId, sessionId]);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
return this.findById(sessionId);
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
findCurrentActiveOrProcessing(receivingAccountId) {
|
|
290
|
+
const row = this.db.queryOne(`
|
|
291
|
+
SELECT * FROM payment_sessions
|
|
292
|
+
WHERE receiving_account_id = ? AND status IN (?, ?)
|
|
293
|
+
LIMIT 1
|
|
294
|
+
`, [receivingAccountId, PaymentStatus.ACTIVE, PaymentStatus.PROCESSING]);
|
|
295
|
+
|
|
296
|
+
if (!row) return null;
|
|
297
|
+
const now = new Date();
|
|
298
|
+
const isStale = (row.status === PaymentStatus.ACTIVE && row.expires_at && new Date(row.expires_at) <= now) ||
|
|
299
|
+
(row.status === PaymentStatus.PROCESSING && row.processing_expires_at && new Date(row.processing_expires_at) <= now);
|
|
300
|
+
if (isStale) {
|
|
301
|
+
const terminalStatus = row.status === PaymentStatus.PROCESSING ? PaymentStatus.FAILED : PaymentStatus.EXPIRED;
|
|
302
|
+
this.transitionToTerminal(row.id, terminalStatus);
|
|
303
|
+
return null;
|
|
304
|
+
}
|
|
305
|
+
return this.mapRow(row);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
findCurrentActiveOrProcessingForMerchant(merchantId) {
|
|
309
|
+
if (!merchantId) return null;
|
|
310
|
+
const row = this.db.queryOne(`
|
|
311
|
+
SELECT * FROM payment_sessions
|
|
312
|
+
WHERE merchant_id = ? AND status IN (?, ?)
|
|
313
|
+
ORDER BY created_at DESC
|
|
314
|
+
LIMIT 1
|
|
315
|
+
`, [merchantId, PaymentStatus.ACTIVE, PaymentStatus.PROCESSING]);
|
|
316
|
+
|
|
317
|
+
if (!row) return null;
|
|
318
|
+
const now = new Date();
|
|
319
|
+
const isStale = (row.status === PaymentStatus.ACTIVE && row.expires_at && new Date(row.expires_at) <= now) ||
|
|
320
|
+
(row.status === PaymentStatus.PROCESSING && row.processing_expires_at && new Date(row.processing_expires_at) <= now);
|
|
321
|
+
if (isStale) {
|
|
322
|
+
const terminalStatus = row.status === PaymentStatus.PROCESSING ? PaymentStatus.FAILED : PaymentStatus.EXPIRED;
|
|
323
|
+
this.transitionToTerminal(row.id, terminalStatus);
|
|
324
|
+
return null;
|
|
325
|
+
}
|
|
326
|
+
return this.mapRow(row);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
findActiveForCustomer(customerId, excludeSessionId = null) {
|
|
330
|
+
if (!customerId) return null;
|
|
331
|
+
let query = `
|
|
332
|
+
SELECT * FROM payment_sessions
|
|
333
|
+
WHERE customer_id = ? AND status IN (?, ?, ?)
|
|
334
|
+
`;
|
|
335
|
+
const params = [customerId, PaymentStatus.ACTIVE, PaymentStatus.PROCESSING, PaymentStatus.QUEUED];
|
|
336
|
+
if (excludeSessionId) {
|
|
337
|
+
query += ` AND id != ? `;
|
|
338
|
+
params.push(excludeSessionId);
|
|
339
|
+
}
|
|
340
|
+
query += ` ORDER BY created_at DESC LIMIT 1 `;
|
|
341
|
+
const row = this.db.queryOne(query, params);
|
|
342
|
+
|
|
343
|
+
if (!row) return null;
|
|
344
|
+
const now = new Date();
|
|
345
|
+
const isStale = (row.status === PaymentStatus.ACTIVE && row.expires_at && new Date(row.expires_at) <= now) ||
|
|
346
|
+
(row.status === PaymentStatus.PROCESSING && row.processing_expires_at && new Date(row.processing_expires_at) <= now);
|
|
347
|
+
if (isStale) {
|
|
348
|
+
const terminalStatus = row.status === PaymentStatus.PROCESSING ? PaymentStatus.FAILED : PaymentStatus.EXPIRED;
|
|
349
|
+
this.transitionToTerminal(row.id, terminalStatus);
|
|
350
|
+
return null;
|
|
351
|
+
}
|
|
352
|
+
return this.mapRow(row);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
findActiveForDevice(deviceId, excludeSessionId = null) {
|
|
356
|
+
if (!deviceId) return null;
|
|
357
|
+
let query = `
|
|
358
|
+
SELECT * FROM payment_sessions
|
|
359
|
+
WHERE device_id = ? AND status IN (?, ?, ?)
|
|
360
|
+
`;
|
|
361
|
+
const params = [deviceId, PaymentStatus.ACTIVE, PaymentStatus.PROCESSING, PaymentStatus.QUEUED];
|
|
362
|
+
if (excludeSessionId) {
|
|
363
|
+
query += ` AND id != ? `;
|
|
364
|
+
params.push(excludeSessionId);
|
|
365
|
+
}
|
|
366
|
+
query += ` ORDER BY created_at DESC LIMIT 1 `;
|
|
367
|
+
const row = this.db.queryOne(query, params);
|
|
368
|
+
|
|
369
|
+
if (!row) return null;
|
|
370
|
+
const now = new Date();
|
|
371
|
+
const isStale = (row.status === PaymentStatus.ACTIVE && row.expires_at && new Date(row.expires_at) <= now) ||
|
|
372
|
+
(row.status === PaymentStatus.PROCESSING && row.processing_expires_at && new Date(row.processing_expires_at) <= now);
|
|
373
|
+
if (isStale) {
|
|
374
|
+
const terminalStatus = row.status === PaymentStatus.PROCESSING ? PaymentStatus.FAILED : PaymentStatus.EXPIRED;
|
|
375
|
+
this.transitionToTerminal(row.id, terminalStatus);
|
|
376
|
+
return null;
|
|
377
|
+
}
|
|
378
|
+
return this.mapRow(row);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
findConflictingActiveSession({ customerId = null, deviceId = null, excludeSessionId = null }) {
|
|
382
|
+
if (!customerId && !deviceId) return null;
|
|
383
|
+
let query = `
|
|
384
|
+
SELECT * FROM payment_sessions
|
|
385
|
+
WHERE status IN (?, ?, ?)
|
|
386
|
+
`;
|
|
387
|
+
const params = [PaymentStatus.ACTIVE, PaymentStatus.PROCESSING, PaymentStatus.QUEUED];
|
|
388
|
+
if (excludeSessionId) {
|
|
389
|
+
query += ` AND id != ? `;
|
|
390
|
+
params.push(excludeSessionId);
|
|
391
|
+
}
|
|
392
|
+
if (customerId && deviceId) {
|
|
393
|
+
query += ` AND (customer_id = ? OR device_id = ?) `;
|
|
394
|
+
params.push(customerId, deviceId);
|
|
395
|
+
} else if (customerId) {
|
|
396
|
+
query += ` AND customer_id = ? `;
|
|
397
|
+
params.push(customerId);
|
|
398
|
+
} else {
|
|
399
|
+
query += ` AND device_id = ? `;
|
|
400
|
+
params.push(deviceId);
|
|
401
|
+
}
|
|
402
|
+
query += ` ORDER BY created_at DESC LIMIT 1 `;
|
|
403
|
+
const row = this.db.queryOne(query, params);
|
|
404
|
+
|
|
405
|
+
if (!row) return null;
|
|
406
|
+
const now = new Date();
|
|
407
|
+
const isStale = (row.status === PaymentStatus.ACTIVE && row.expires_at && new Date(row.expires_at) <= now) ||
|
|
408
|
+
(row.status === PaymentStatus.PROCESSING && row.processing_expires_at && new Date(row.processing_expires_at) <= now);
|
|
409
|
+
if (isStale) {
|
|
410
|
+
const terminalStatus = row.status === PaymentStatus.PROCESSING ? PaymentStatus.FAILED : PaymentStatus.EXPIRED;
|
|
411
|
+
this.transitionToTerminal(row.id, terminalStatus);
|
|
412
|
+
return null;
|
|
413
|
+
}
|
|
414
|
+
return this.mapRow(row);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
findAllActiveOrPendingForCustomer(customerId) {
|
|
418
|
+
if (!customerId) return [];
|
|
419
|
+
const rows = this.db.query(`
|
|
420
|
+
SELECT * FROM payment_sessions
|
|
421
|
+
WHERE customer_id = ? AND status IN (?, ?, ?, ?)
|
|
422
|
+
ORDER BY created_at DESC
|
|
423
|
+
`, [customerId, PaymentStatus.ACTIVE, PaymentStatus.PROCESSING, PaymentStatus.QUEUED, PaymentStatus.INITIATED]);
|
|
424
|
+
return rows.map(r => this.mapRow(r));
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
findQueuedSessions(receivingAccountId) {
|
|
428
|
+
const rows = this.db.query(`
|
|
429
|
+
SELECT * FROM payment_sessions
|
|
430
|
+
WHERE receiving_account_id = ? AND status = ?
|
|
431
|
+
ORDER BY queue_position ASC, created_at ASC
|
|
432
|
+
`, [receivingAccountId, PaymentStatus.QUEUED]);
|
|
433
|
+
|
|
434
|
+
return rows.map(r => this.mapRow(r));
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
getNextQueuedSession(receivingAccountId) {
|
|
438
|
+
const row = this.db.queryOne(`
|
|
439
|
+
SELECT * FROM payment_sessions
|
|
440
|
+
WHERE receiving_account_id = ? AND status = ?
|
|
441
|
+
ORDER BY queue_position ASC, created_at ASC
|
|
442
|
+
LIMIT 1
|
|
443
|
+
`, [receivingAccountId, PaymentStatus.QUEUED]);
|
|
444
|
+
|
|
445
|
+
if (!row) return null;
|
|
446
|
+
return this.mapRow(row);
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/**
|
|
450
|
+
* Re-indexes remaining queued sessions after a promotion.
|
|
451
|
+
*/
|
|
452
|
+
reindexQueue(receivingAccountId) {
|
|
453
|
+
const queued = this.findQueuedSessions(receivingAccountId);
|
|
454
|
+
let pos = 1;
|
|
455
|
+
for (const session of queued) {
|
|
456
|
+
this.db.execute(`
|
|
457
|
+
UPDATE payment_sessions SET queue_position = ?, updated_at = ?
|
|
458
|
+
WHERE id = ?
|
|
459
|
+
`, [pos++, new Date().toISOString(), session.id]);
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
findRecentlyExpiredOrFailed(receivingAccountId, sinceDate) {
|
|
464
|
+
const rows = this.db.query(`
|
|
465
|
+
SELECT * FROM payment_sessions
|
|
466
|
+
WHERE receiving_account_id = ?
|
|
467
|
+
AND status IN (?, ?, ?)
|
|
468
|
+
AND updated_at >= ?
|
|
469
|
+
ORDER BY updated_at DESC
|
|
470
|
+
LIMIT 20
|
|
471
|
+
`, [receivingAccountId, PaymentStatus.EXPIRED, PaymentStatus.FAILED, PaymentStatus.CANCELLED, sinceDate.toISOString()]);
|
|
472
|
+
|
|
473
|
+
return rows.map(r => this.mapRow(r));
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/**
|
|
477
|
+
* Records observation confirmation, calculates 2-level verification tier (SMS + Gmail),
|
|
478
|
+
* updates the verification tag, and transitions to SUCCESS if level >= 1 and shouldFinalize is true.
|
|
479
|
+
* @param {string} sessionId
|
|
480
|
+
* @param {string} source E.g. 'GMAIL', 'GOOGLE_MESSAGES'
|
|
481
|
+
* @param {string} observationId
|
|
482
|
+
* @param {{ finalizeSuccess?: boolean }} [options={}]
|
|
483
|
+
* @returns {{ session: any, level: number, sources: string[], tag: string, isFirstSuccess: boolean }}
|
|
484
|
+
*/
|
|
485
|
+
recordVerificationSource(sessionId, source, observationId, options = {}) {
|
|
486
|
+
return this.db.transaction(() => {
|
|
487
|
+
const session = this.findById(sessionId);
|
|
488
|
+
if (!session) throw new Error(`Payment session not found: ${sessionId}`);
|
|
489
|
+
|
|
490
|
+
// A dead outcome is final. recordVerificationSource is called from the attribution engine
|
|
491
|
+
// on previously attributed sessions with no status check, so without this guard an
|
|
492
|
+
// out-of-window or duplicated observation flips an EXPIRED/FAILED/CANCELLED session back
|
|
493
|
+
// to SUCCESS and emits a webhook for a payment that was never settled.
|
|
494
|
+
// An already-SUCCESS session is NOT blocked: recording a second channel there is the
|
|
495
|
+
// legitimate SMS+Gmail verification upgrade, which must not re-emit SUCCESS.
|
|
496
|
+
const DEAD_STATES = [
|
|
497
|
+
PaymentStatus.FAILED,
|
|
498
|
+
PaymentStatus.EXPIRED,
|
|
499
|
+
PaymentStatus.CANCELLED
|
|
500
|
+
];
|
|
501
|
+
if (DEAD_STATES.includes(session.status)) {
|
|
502
|
+
return {
|
|
503
|
+
session,
|
|
504
|
+
level: session.verificationLevel || 0,
|
|
505
|
+
sources: session.verificationSources || [],
|
|
506
|
+
tag: session.verificationTag || 'UNVERIFIED',
|
|
507
|
+
isFirstSuccess: false
|
|
508
|
+
};
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
// Read current verification sources
|
|
512
|
+
const sourcesSet = new Set(session.verificationSources || []);
|
|
513
|
+
sourcesSet.add(source);
|
|
514
|
+
const sources = Array.from(sourcesSet);
|
|
515
|
+
|
|
516
|
+
const level = Math.min(2, sources.length); // 1 or 2 (2-Layer Engine: SMS + Gmail)
|
|
517
|
+
const sourceLabels = sources.map(s => {
|
|
518
|
+
if (s === 'GOOGLE_MESSAGES') return 'SMS';
|
|
519
|
+
if (s === 'GMAIL') return 'Gmail';
|
|
520
|
+
return s;
|
|
521
|
+
});
|
|
522
|
+
|
|
523
|
+
let tag = `Single Verification (${sourceLabels.join(', ')})`;
|
|
524
|
+
if (level >= 2) {
|
|
525
|
+
tag = `Dual Verification (${sourceLabels.join(' + ')})`;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
const shouldFinalize = options.finalizeSuccess !== false;
|
|
529
|
+
const wantsSuccess = shouldFinalize && level >= 1;
|
|
530
|
+
if (wantsSuccess && session.status !== PaymentStatus.SUCCESS && !isValidTransition(session.status, PaymentStatus.SUCCESS)) {
|
|
531
|
+
throw new Error(`Illegal payment state transition: cannot move from '${session.status}' to 'SUCCESS'.`);
|
|
532
|
+
}
|
|
533
|
+
const isFirstSuccess = wantsSuccess && session.status !== PaymentStatus.SUCCESS;
|
|
534
|
+
const targetStatus = wantsSuccess ? PaymentStatus.SUCCESS : session.status;
|
|
535
|
+
const now = new Date().toISOString();
|
|
536
|
+
|
|
537
|
+
this.db.execute(`
|
|
538
|
+
UPDATE payment_sessions
|
|
539
|
+
SET status = ?,
|
|
540
|
+
verification_level = ?,
|
|
541
|
+
verification_sources = ?,
|
|
542
|
+
verification_tag = ?,
|
|
543
|
+
attributed_observation_id = COALESCE(attributed_observation_id, ?),
|
|
544
|
+
completed_at = COALESCE(completed_at, ?),
|
|
545
|
+
updated_at = ?
|
|
546
|
+
WHERE id = ?
|
|
547
|
+
`, [
|
|
548
|
+
targetStatus,
|
|
549
|
+
level,
|
|
550
|
+
JSON.stringify(sources),
|
|
551
|
+
tag,
|
|
552
|
+
observationId,
|
|
553
|
+
targetStatus === PaymentStatus.SUCCESS ? now : null,
|
|
554
|
+
now,
|
|
555
|
+
sessionId
|
|
556
|
+
]);
|
|
557
|
+
|
|
558
|
+
// If this transition made it terminal SUCCESS, release slot on receiving account
|
|
559
|
+
if (targetStatus === PaymentStatus.SUCCESS && session.receivingAccountId) {
|
|
560
|
+
this.db.execute(`
|
|
561
|
+
UPDATE payment_receiving_accounts
|
|
562
|
+
SET is_slot_busy = 0, current_active_session_id = NULL, updated_at = ?
|
|
563
|
+
WHERE id = ? AND current_active_session_id = ?
|
|
564
|
+
`, [now, session.receivingAccountId, sessionId]);
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
const updated = this.findById(sessionId);
|
|
568
|
+
return {
|
|
569
|
+
session: updated,
|
|
570
|
+
level,
|
|
571
|
+
sources,
|
|
572
|
+
tag,
|
|
573
|
+
isFirstSuccess
|
|
574
|
+
};
|
|
575
|
+
});
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
bindDevice(sessionId, deviceId) {
|
|
579
|
+
const now = new Date().toISOString();
|
|
580
|
+
this.db.execute(
|
|
581
|
+
'UPDATE payment_sessions SET device_id = ?, updated_at = ? WHERE id = ?',
|
|
582
|
+
[deviceId, now, sessionId]
|
|
583
|
+
);
|
|
584
|
+
return this.findById(sessionId);
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
mapRow(row) {
|
|
588
|
+
return {
|
|
589
|
+
id: row.id,
|
|
590
|
+
merchantId: row.merchant_id,
|
|
591
|
+
customerId: row.customer_id,
|
|
592
|
+
receivingAccountId: row.receiving_account_id,
|
|
593
|
+
orderId: row.order_id,
|
|
594
|
+
attemptId: row.attempt_id || row.id,
|
|
595
|
+
amountPaise: BigInt(row.amount_paise),
|
|
596
|
+
currency: row.currency,
|
|
597
|
+
method: row.method,
|
|
598
|
+
status: row.status,
|
|
599
|
+
queuePosition: row.queue_position,
|
|
600
|
+
slotAcquiredAt: row.slot_acquired_at ? new Date(row.slot_acquired_at) : null,
|
|
601
|
+
expiresAt: row.expires_at ? new Date(row.expires_at) : null,
|
|
602
|
+
processingStartedAt: row.processing_started_at ? new Date(row.processing_started_at) : null,
|
|
603
|
+
processingExpiresAt: row.processing_expires_at ? new Date(row.processing_expires_at) : null,
|
|
604
|
+
completedAt: row.completed_at ? new Date(row.completed_at) : null,
|
|
605
|
+
returnUrl: row.return_url,
|
|
606
|
+
attributedObservationId: row.attributed_observation_id,
|
|
607
|
+
idempotencyKey: row.idempotency_key,
|
|
608
|
+
deviceId: row.device_id || null,
|
|
609
|
+
verificationLevel: row.verification_level !== undefined && row.verification_level !== null ? Number(row.verification_level) : 0,
|
|
610
|
+
verificationSources: JSON.parse(row.verification_sources || '[]'),
|
|
611
|
+
verificationTag: row.verification_tag || 'UNVERIFIED',
|
|
612
|
+
metadata: JSON.parse(row.metadata || '{}'),
|
|
613
|
+
createdAt: new Date(row.created_at),
|
|
614
|
+
updatedAt: new Date(row.updated_at)
|
|
615
|
+
};
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
|