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,237 @@
|
|
|
1
|
+
import { test, describe, beforeEach, afterEach } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import {
|
|
4
|
+
Database,
|
|
5
|
+
runMigrations,
|
|
6
|
+
PaymentSessionRepository,
|
|
7
|
+
ReceivingAccountRepository,
|
|
8
|
+
AuditLogRepository
|
|
9
|
+
} from '../../db/index.js';
|
|
10
|
+
import {
|
|
11
|
+
QueueEngine,
|
|
12
|
+
TimingEngine,
|
|
13
|
+
RecoveryEngine
|
|
14
|
+
} from '../index.js';
|
|
15
|
+
import { PaymentStatus } from '../../common/index.js';
|
|
16
|
+
|
|
17
|
+
describe('../../core/index.js Queue & Timing Engine Tests', () => {
|
|
18
|
+
let db;
|
|
19
|
+
let sessionRepo;
|
|
20
|
+
let accountRepo;
|
|
21
|
+
let auditRepo;
|
|
22
|
+
let queueEngine;
|
|
23
|
+
let timingEngine;
|
|
24
|
+
let recoveryEngine;
|
|
25
|
+
let testAccount;
|
|
26
|
+
let defaultMerchant;
|
|
27
|
+
let events;
|
|
28
|
+
|
|
29
|
+
beforeEach(() => {
|
|
30
|
+
db = new Database({ inMemory: true });
|
|
31
|
+
const seed = runMigrations(db);
|
|
32
|
+
defaultMerchant = seed.defaultMerchant;
|
|
33
|
+
testAccount = { id: '00000000-0000-0000-0000-000000000002' };
|
|
34
|
+
|
|
35
|
+
sessionRepo = new PaymentSessionRepository(db);
|
|
36
|
+
accountRepo = new ReceivingAccountRepository(db);
|
|
37
|
+
auditRepo = new AuditLogRepository(db);
|
|
38
|
+
|
|
39
|
+
events = [];
|
|
40
|
+
const eventEmitter = {
|
|
41
|
+
emit: (event, payload) => events.push({ event, payload })
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
queueEngine = new QueueEngine({
|
|
45
|
+
sessionRepo,
|
|
46
|
+
accountRepo,
|
|
47
|
+
eventEmitter,
|
|
48
|
+
auditRepo
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
timingEngine = new TimingEngine({
|
|
52
|
+
sessionRepo,
|
|
53
|
+
queueEngine,
|
|
54
|
+
eventEmitter,
|
|
55
|
+
auditRepo,
|
|
56
|
+
activeDurationMs: 100, // fast 100ms timer for testing
|
|
57
|
+
processingDurationMs: 50 // fast 50ms timer for testing
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
recoveryEngine = new RecoveryEngine({
|
|
61
|
+
db,
|
|
62
|
+
sessionRepo,
|
|
63
|
+
accountRepo,
|
|
64
|
+
queueEngine,
|
|
65
|
+
timingEngine,
|
|
66
|
+
auditRepo
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
afterEach(() => {
|
|
71
|
+
timingEngine.destroy();
|
|
72
|
+
db.close();
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test('Queue Engine correctly assigns active slot and queued positions with wait estimates', () => {
|
|
76
|
+
const s1 = sessionRepo.create({
|
|
77
|
+
merchantId: defaultMerchant.id,
|
|
78
|
+
receivingAccountId: testAccount.id,
|
|
79
|
+
orderId: 'Q-ORD-1',
|
|
80
|
+
amountPaise: 20000n
|
|
81
|
+
});
|
|
82
|
+
const s2 = sessionRepo.create({
|
|
83
|
+
merchantId: defaultMerchant.id,
|
|
84
|
+
receivingAccountId: testAccount.id,
|
|
85
|
+
orderId: 'Q-ORD-2',
|
|
86
|
+
amountPaise: 20000n
|
|
87
|
+
});
|
|
88
|
+
const s3 = sessionRepo.create({
|
|
89
|
+
merchantId: defaultMerchant.id,
|
|
90
|
+
receivingAccountId: testAccount.id,
|
|
91
|
+
orderId: 'Q-ORD-3',
|
|
92
|
+
amountPaise: 20000n
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
// 1. Session 1 enters
|
|
96
|
+
const res1 = queueEngine.enqueueOrActivate(s1.id, testAccount.id, 300000);
|
|
97
|
+
assert.equal(res1.status, PaymentStatus.ACTIVE);
|
|
98
|
+
assert.equal(res1.queuePosition, 0);
|
|
99
|
+
|
|
100
|
+
// 2. Session 2 enters -> QUEUED at position 1
|
|
101
|
+
const res2 = queueEngine.enqueueOrActivate(s2.id, testAccount.id, 300000);
|
|
102
|
+
assert.equal(res2.status, PaymentStatus.QUEUED);
|
|
103
|
+
assert.equal(res2.queuePosition, 1);
|
|
104
|
+
|
|
105
|
+
// 3. Session 3 enters -> QUEUED at position 2
|
|
106
|
+
const res3 = queueEngine.enqueueOrActivate(s3.id, testAccount.id, 300000);
|
|
107
|
+
assert.equal(res3.status, PaymentStatus.QUEUED);
|
|
108
|
+
assert.equal(res3.queuePosition, 2);
|
|
109
|
+
|
|
110
|
+
// Wait estimations
|
|
111
|
+
const wait2 = queueEngine.calculateEstimatedWaitSeconds(testAccount.id, 1);
|
|
112
|
+
const wait3 = queueEngine.calculateEstimatedWaitSeconds(testAccount.id, 2);
|
|
113
|
+
assert.ok(wait2 > 0);
|
|
114
|
+
assert.ok(wait3 > wait2);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test('Timing Engine: 5-minute active window expires and auto-promotes next in queue', async () => {
|
|
118
|
+
const s1 = sessionRepo.create({
|
|
119
|
+
merchantId: defaultMerchant.id,
|
|
120
|
+
receivingAccountId: testAccount.id,
|
|
121
|
+
orderId: 'TIM-ORD-1',
|
|
122
|
+
amountPaise: 20000n
|
|
123
|
+
});
|
|
124
|
+
const s2 = sessionRepo.create({
|
|
125
|
+
merchantId: defaultMerchant.id,
|
|
126
|
+
receivingAccountId: testAccount.id,
|
|
127
|
+
orderId: 'TIM-ORD-2',
|
|
128
|
+
amountPaise: 20000n
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
const res1 = queueEngine.enqueueOrActivate(s1.id, testAccount.id, 50); // expires in 50ms
|
|
132
|
+
queueEngine.enqueueOrActivate(s2.id, testAccount.id, 50);
|
|
133
|
+
|
|
134
|
+
timingEngine.startActiveTimer(s1.id, res1.expiresAt);
|
|
135
|
+
|
|
136
|
+
// Wait 70ms for s1 to expire
|
|
137
|
+
await new Promise(resolve => setTimeout(resolve, 70));
|
|
138
|
+
|
|
139
|
+
// s1 must now be EXPIRED
|
|
140
|
+
const updatedS1 = sessionRepo.findById(s1.id);
|
|
141
|
+
assert.equal(updatedS1.status, PaymentStatus.EXPIRED);
|
|
142
|
+
|
|
143
|
+
// s2 must now be promoted to ACTIVE!
|
|
144
|
+
const updatedS2 = sessionRepo.findById(s2.id);
|
|
145
|
+
assert.equal(updatedS2.status, PaymentStatus.ACTIVE);
|
|
146
|
+
assert.equal(updatedS2.queuePosition, 0);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test('Timing Engine: Payment Done starts 30-sec processing window, fails if no observation', async () => {
|
|
150
|
+
const s1 = sessionRepo.create({
|
|
151
|
+
merchantId: defaultMerchant.id,
|
|
152
|
+
receivingAccountId: testAccount.id,
|
|
153
|
+
orderId: 'PROC-ORD-1',
|
|
154
|
+
amountPaise: 20000n
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
queueEngine.enqueueOrActivate(s1.id, testAccount.id, 300000);
|
|
158
|
+
|
|
159
|
+
// Customer clicks Payment Done
|
|
160
|
+
const procSession = timingEngine.handlePaymentDone(s1.id);
|
|
161
|
+
assert.equal(procSession.status, PaymentStatus.PROCESSING);
|
|
162
|
+
assert.ok(procSession.processingExpiresAt);
|
|
163
|
+
|
|
164
|
+
// Wait 70ms for processing timer (configured as 50ms) to elapse
|
|
165
|
+
await new Promise(resolve => setTimeout(resolve, 70));
|
|
166
|
+
|
|
167
|
+
// s1 must now be FAILED because no observer verified it
|
|
168
|
+
const updatedS1 = sessionRepo.findById(s1.id);
|
|
169
|
+
assert.equal(updatedS1.status, PaymentStatus.FAILED);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test('Recovery Engine: crash recovery cleans up overdue sessions and promotes queued', () => {
|
|
173
|
+
// Simulate a crash scenario:
|
|
174
|
+
// Session 1 was ACTIVE but expires_at was 10 minutes ago
|
|
175
|
+
const pastDate = new Date(Date.now() - 600000).toISOString();
|
|
176
|
+
const s1 = sessionRepo.create({
|
|
177
|
+
merchantId: defaultMerchant.id,
|
|
178
|
+
receivingAccountId: testAccount.id,
|
|
179
|
+
orderId: 'CRASH-ORD-1',
|
|
180
|
+
amountPaise: 20000n
|
|
181
|
+
});
|
|
182
|
+
db.execute(`
|
|
183
|
+
UPDATE payment_sessions
|
|
184
|
+
SET status = ?, expires_at = ?, slot_acquired_at = ?
|
|
185
|
+
WHERE id = ?
|
|
186
|
+
`, [PaymentStatus.ACTIVE, pastDate, pastDate, s1.id]);
|
|
187
|
+
db.execute(`
|
|
188
|
+
UPDATE payment_receiving_accounts
|
|
189
|
+
SET is_slot_busy = 1, current_active_session_id = ?
|
|
190
|
+
WHERE id = ?
|
|
191
|
+
`, [s1.id, testAccount.id]);
|
|
192
|
+
|
|
193
|
+
// Session 2 was QUEUED waiting for slot
|
|
194
|
+
const s2 = sessionRepo.create({
|
|
195
|
+
merchantId: defaultMerchant.id,
|
|
196
|
+
receivingAccountId: testAccount.id,
|
|
197
|
+
orderId: 'CRASH-ORD-2',
|
|
198
|
+
amountPaise: 20000n,
|
|
199
|
+
status: PaymentStatus.QUEUED,
|
|
200
|
+
queuePosition: 1
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
// Run crash recovery
|
|
204
|
+
const stats = recoveryEngine.recover();
|
|
205
|
+
assert.equal(stats.expiredCount, 1);
|
|
206
|
+
assert.equal(stats.promotedCount, 1);
|
|
207
|
+
|
|
208
|
+
// S1 must be EXPIRED
|
|
209
|
+
const checkS1 = sessionRepo.findById(s1.id);
|
|
210
|
+
assert.equal(checkS1.status, PaymentStatus.EXPIRED);
|
|
211
|
+
|
|
212
|
+
// S2 must be promoted to ACTIVE
|
|
213
|
+
const checkS2 = sessionRepo.findById(s2.id);
|
|
214
|
+
assert.equal(checkS2.status, PaymentStatus.ACTIVE);
|
|
215
|
+
assert.equal(checkS2.queuePosition, 0);
|
|
216
|
+
});
|
|
217
|
+
test('uses the configured payment window (PAYMENT_ACTIVE_WINDOW_MS) for activation and promotion', () => {
|
|
218
|
+
const engine = new QueueEngine({ sessionRepo, accountRepo, activeDurationMs: 60000 });
|
|
219
|
+
const mk = (orderId) => sessionRepo.create({ merchantId: defaultMerchant.id, receivingAccountId: testAccount.id, orderId, amountPaise: 20000n });
|
|
220
|
+
const a = mk('WIN-ORD-1');
|
|
221
|
+
const b = mk('WIN-ORD-2');
|
|
222
|
+
|
|
223
|
+
const t0 = Date.now();
|
|
224
|
+
const first = engine.enqueueOrActivate(a.id, testAccount.id);
|
|
225
|
+
assert.equal(first.status, PaymentStatus.ACTIVE);
|
|
226
|
+
const firstWindow = sessionRepo.findById(a.id).expiresAt.getTime() - t0;
|
|
227
|
+
assert.ok(firstWindow > 55000 && firstWindow < 65000, `first slot should get 60s, got ${firstWindow}ms`);
|
|
228
|
+
|
|
229
|
+
assert.equal(engine.enqueueOrActivate(b.id, testAccount.id).status, PaymentStatus.QUEUED);
|
|
230
|
+
sessionRepo.transitionToTerminal(a.id, PaymentStatus.EXPIRED);
|
|
231
|
+
const t1 = Date.now();
|
|
232
|
+
const promoted = engine.promoteNext(testAccount.id);
|
|
233
|
+
assert.ok(promoted, 'queued session was not promoted');
|
|
234
|
+
const promotedWindow = promoted.expiresAt.getTime() - t1;
|
|
235
|
+
assert.ok(promotedWindow > 55000 && promotedWindow < 65000, `promoted slot should get 60s, got ${promotedWindow}ms`);
|
|
236
|
+
});
|
|
237
|
+
});
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
import { test, describe, before, after } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import http from 'node:http';
|
|
4
|
+
import '../../../../scripts/test-support.js';
|
|
5
|
+
import { Database, runMigrations, MerchantRepository, PaymentSessionRepository, ObservationRepository } from '../../db/index.js';
|
|
6
|
+
import { WebhookEngine } from '../webhook-engine.js';
|
|
7
|
+
import { verifyHmac } from '../../common/index.js';
|
|
8
|
+
|
|
9
|
+
describe('Webhook Engine Integration Tests', () => {
|
|
10
|
+
let db;
|
|
11
|
+
let merchantRepo;
|
|
12
|
+
let sessionRepo;
|
|
13
|
+
let webhookEngine;
|
|
14
|
+
let receiverServer;
|
|
15
|
+
let receiverPort;
|
|
16
|
+
let receivedWebhooks = [];
|
|
17
|
+
let shouldReceiverFail = false;
|
|
18
|
+
|
|
19
|
+
before(async () => {
|
|
20
|
+
db = new Database({ inMemory: true });
|
|
21
|
+
runMigrations(db);
|
|
22
|
+
merchantRepo = new MerchantRepository(db);
|
|
23
|
+
sessionRepo = new PaymentSessionRepository(db);
|
|
24
|
+
|
|
25
|
+
webhookEngine = new WebhookEngine({
|
|
26
|
+
db,
|
|
27
|
+
merchantRepo,
|
|
28
|
+
maxRetries: 3
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
// Start a mock merchant webhook receiver HTTP server
|
|
32
|
+
receiverServer = http.createServer((req, res) => {
|
|
33
|
+
let body = '';
|
|
34
|
+
req.on('data', chunk => { body += chunk; });
|
|
35
|
+
req.on('end', () => {
|
|
36
|
+
receivedWebhooks.push({
|
|
37
|
+
headers: req.headers,
|
|
38
|
+
body: JSON.parse(body || '{}')
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
if (shouldReceiverFail) {
|
|
42
|
+
res.writeHead(500, { 'Content-Type': 'text/plain' });
|
|
43
|
+
res.end('Internal Merchant Error');
|
|
44
|
+
} else {
|
|
45
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
46
|
+
res.end(JSON.stringify({ received: true }));
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
await new Promise(resolve => {
|
|
52
|
+
receiverServer.listen(0, () => {
|
|
53
|
+
receiverPort = receiverServer.address().port;
|
|
54
|
+
resolve();
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
after(async () => {
|
|
60
|
+
await new Promise(cb => receiverServer.close(cb));
|
|
61
|
+
db.close();
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test('successfully dispatches HMAC-SHA256 signed webhook on payment event', async () => {
|
|
65
|
+
const merchant = merchantRepo.create({
|
|
66
|
+
businessName: 'Webhook Test Store',
|
|
67
|
+
slug: 'webhook-store',
|
|
68
|
+
contactEmail: 'store@example.com',
|
|
69
|
+
contactPhone: '9876543210',
|
|
70
|
+
webhookUrl: `http://localhost:${receiverPort}/webhook`,
|
|
71
|
+
webhookSecret: 'test_secret_for_merchant_signatures_123'
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
const session = sessionRepo.create({
|
|
75
|
+
merchantId: merchant.id,
|
|
76
|
+
orderId: 'HOOK-ORD-001',
|
|
77
|
+
amountPaise: 20000n
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
receivedWebhooks = [];
|
|
81
|
+
shouldReceiverFail = false;
|
|
82
|
+
|
|
83
|
+
const result = await webhookEngine.dispatch('SUCCESS', session);
|
|
84
|
+
|
|
85
|
+
assert.equal(result.delivered, true);
|
|
86
|
+
assert.equal(result.status, 200);
|
|
87
|
+
assert.ok(result.deliveryId);
|
|
88
|
+
|
|
89
|
+
// Verify received payload & signature
|
|
90
|
+
assert.equal(receivedWebhooks.length, 1);
|
|
91
|
+
const hook = receivedWebhooks[0];
|
|
92
|
+
|
|
93
|
+
assert.equal(hook.headers['x-gateway-event'], 'SUCCESS');
|
|
94
|
+
assert.ok(hook.headers['x-gateway-signature'].startsWith('sha256='));
|
|
95
|
+
assert.equal(hook.headers['x-gateway-delivery-id'], result.deliveryId);
|
|
96
|
+
|
|
97
|
+
assert.equal(hook.body.event, 'SUCCESS');
|
|
98
|
+
assert.equal(hook.body.orderId, 'HOOK-ORD-001');
|
|
99
|
+
assert.equal(hook.body.amount, '200.00');
|
|
100
|
+
assert.equal(hook.body.amountPaise, 20000);
|
|
101
|
+
|
|
102
|
+
// Cryptographically verify signature using merchant webhookSecret
|
|
103
|
+
const rawSig = hook.headers['x-gateway-signature'].replace('sha256=', '');
|
|
104
|
+
const isValid = verifyHmac(JSON.stringify(hook.body), merchant.webhookSecret, rawSig);
|
|
105
|
+
assert.equal(isValid, true);
|
|
106
|
+
assert.ok('utr' in hook.body);
|
|
107
|
+
|
|
108
|
+
// Verify recorded in DB as successful
|
|
109
|
+
const delivery = db.queryOne('SELECT * FROM webhook_deliveries WHERE id = ?', [result.deliveryId]);
|
|
110
|
+
assert.ok(delivery);
|
|
111
|
+
assert.equal(delivery.is_successful, 1);
|
|
112
|
+
assert.equal(delivery.response_status, 200);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test('carries the bank reference (UTR) from the attributed observation', async () => {
|
|
116
|
+
const merchant = merchantRepo.create({
|
|
117
|
+
businessName: 'UTR Store',
|
|
118
|
+
slug: 'utr-store',
|
|
119
|
+
contactEmail: 'utr@example.com',
|
|
120
|
+
contactPhone: '9876543210',
|
|
121
|
+
webhookUrl: `http://localhost:${receiverPort}/webhook-utr`,
|
|
122
|
+
webhookSecret: 'test_secret_for_utr_signatures_123'
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
const session = sessionRepo.create({
|
|
126
|
+
merchantId: merchant.id,
|
|
127
|
+
orderId: 'HOOK-ORD-UTR',
|
|
128
|
+
amountPaise: 20000n,
|
|
129
|
+
receivingAccountId: '00000000-0000-0000-0000-000000000002'
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
// The UTR lives on the observation, not on the session row.
|
|
133
|
+
const observation = new ObservationRepository(db).create({
|
|
134
|
+
receivingAccountId: session.receivingAccountId,
|
|
135
|
+
source: 'GOOGLE_MESSAGES',
|
|
136
|
+
rawText: 'Rs 200.00 credited UPI ref 412345678901',
|
|
137
|
+
amountPaise: 20000n,
|
|
138
|
+
bankReferenceNumber: '412345678901',
|
|
139
|
+
observedAt: new Date(),
|
|
140
|
+
fingerprintHash: 'fp_utr_test'
|
|
141
|
+
});
|
|
142
|
+
db.execute('UPDATE payment_sessions SET attributed_observation_id = ? WHERE id = ?', [observation.id, session.id]);
|
|
143
|
+
|
|
144
|
+
receivedWebhooks = [];
|
|
145
|
+
shouldReceiverFail = false;
|
|
146
|
+
|
|
147
|
+
const result = await webhookEngine.dispatch('SUCCESS', sessionRepo.findById(session.id));
|
|
148
|
+
assert.equal(result.delivered, true);
|
|
149
|
+
|
|
150
|
+
const hook = receivedWebhooks.find(h => h.body.orderId === 'HOOK-ORD-UTR');
|
|
151
|
+
assert.ok(hook, 'webhook not received');
|
|
152
|
+
assert.equal(hook.body.utr, '412345678901');
|
|
153
|
+
|
|
154
|
+
// The signature must still cover the body that now includes the UTR.
|
|
155
|
+
const rawSig = hook.headers['x-gateway-signature'].replace('sha256=', '');
|
|
156
|
+
assert.equal(verifyHmac(JSON.stringify(hook.body), merchant.webhookSecret, rawSig), true);
|
|
157
|
+
|
|
158
|
+
// The documented webhook contract must expose every field.
|
|
159
|
+
for (const field of ['event', 'paymentId', 'orderId', 'amount', 'amountPaise', 'currency', 'method', 'utr', 'completedAt', 'metadata']) {
|
|
160
|
+
assert.ok(field in hook.body, `missing documented webhook field: ${field}`);
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test('records failure and supports retry on merchant HTTP 500 error', async () => {
|
|
165
|
+
const merchant = merchantRepo.create({
|
|
166
|
+
businessName: 'Failing Store',
|
|
167
|
+
slug: 'failing-store',
|
|
168
|
+
contactEmail: 'fail@example.com',
|
|
169
|
+
contactPhone: '9876543210',
|
|
170
|
+
webhookUrl: `http://localhost:${receiverPort}/webhook-fail`,
|
|
171
|
+
webhookSecret: 'secret_abc_123'
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
const session = sessionRepo.create({
|
|
175
|
+
merchantId: merchant.id,
|
|
176
|
+
orderId: 'HOOK-ORD-FAIL',
|
|
177
|
+
amountPaise: 50000n
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
shouldReceiverFail = true;
|
|
181
|
+
const result = await webhookEngine.dispatch('FAILED', session);
|
|
182
|
+
|
|
183
|
+
assert.equal(result.delivered, false);
|
|
184
|
+
assert.equal(result.status, 500);
|
|
185
|
+
|
|
186
|
+
// Verify recorded as failed with next_retry_at set
|
|
187
|
+
const delivery = db.queryOne('SELECT * FROM webhook_deliveries WHERE id = ?', [result.deliveryId]);
|
|
188
|
+
assert.equal(delivery.is_successful, 0);
|
|
189
|
+
assert.equal(delivery.attempt_count, 1);
|
|
190
|
+
assert.ok(delivery.next_retry_at);
|
|
191
|
+
|
|
192
|
+
// Now make receiver healthy and simulate retry worker
|
|
193
|
+
shouldReceiverFail = false;
|
|
194
|
+
const pastIso = new Date(Date.now() - 60000).toISOString();
|
|
195
|
+
db.execute('UPDATE webhook_deliveries SET next_retry_at = ? WHERE id = ?', [pastIso, result.deliveryId]);
|
|
196
|
+
|
|
197
|
+
const retryResults = await webhookEngine.retryPending();
|
|
198
|
+
assert.equal(retryResults.length, 1);
|
|
199
|
+
assert.equal(retryResults[0].success, true);
|
|
200
|
+
assert.equal(retryResults[0].attempt, 2);
|
|
201
|
+
|
|
202
|
+
const updatedDelivery = db.queryOne('SELECT * FROM webhook_deliveries WHERE id = ?', [result.deliveryId]);
|
|
203
|
+
assert.equal(updatedDelivery.is_successful, 1);
|
|
204
|
+
|
|
205
|
+
// A retried delivery must carry the SAME MAC as a first delivery: the signature covers
|
|
206
|
+
// the exact transmitted body, so the merchant's verification accepts it too. Signing the
|
|
207
|
+
// retry as `${timestamp}.${payload}` made every retry fail verification.
|
|
208
|
+
const retried = receivedWebhooks.filter(h => h.body && h.body.orderId === 'HOOK-ORD-FAIL').pop();
|
|
209
|
+
assert.ok(retried, 'retried webhook not received');
|
|
210
|
+
const retrySig = retried.headers['x-gateway-signature'].replace('sha256=', '');
|
|
211
|
+
assert.equal(verifyHmac(JSON.stringify(retried.body), merchant.webhookSecret, retrySig), true);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
test('persists delivery failure record when merchant webhookSecret is missing', async () => {
|
|
215
|
+
const merchantNoSecret = merchantRepo.create({
|
|
216
|
+
businessName: 'No Secret Store',
|
|
217
|
+
slug: 'no-secret-store',
|
|
218
|
+
contactEmail: 'nosecret@example.com',
|
|
219
|
+
contactPhone: '9876543210',
|
|
220
|
+
webhookUrl: `http://localhost:${receiverPort}/webhook-no-secret`,
|
|
221
|
+
webhookSecret: 'temp_secret'
|
|
222
|
+
});
|
|
223
|
+
// Explicitly set secret to empty string in DB to test missing secret guard
|
|
224
|
+
db.execute("UPDATE merchants SET webhook_secret = '' WHERE id = ?", [merchantNoSecret.id]);
|
|
225
|
+
|
|
226
|
+
const session = sessionRepo.create({
|
|
227
|
+
merchantId: merchantNoSecret.id,
|
|
228
|
+
orderId: 'HOOK-ORD-NO-SEC',
|
|
229
|
+
amountPaise: 10000n
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
const res = await webhookEngine.dispatch('SUCCESS', session);
|
|
233
|
+
assert.equal(res.delivered, false);
|
|
234
|
+
assert.equal(res.status, 500);
|
|
235
|
+
assert.ok(res.deliveryId);
|
|
236
|
+
|
|
237
|
+
const recorded = db.queryOne('SELECT * FROM webhook_deliveries WHERE id = ?', [res.deliveryId]);
|
|
238
|
+
assert.ok(recorded);
|
|
239
|
+
assert.equal(recorded.is_successful, 0);
|
|
240
|
+
assert.equal(recorded.response_status, 500);
|
|
241
|
+
assert.match(recorded.response_body, /webhookSecret is not configured/);
|
|
242
|
+
});
|
|
243
|
+
});
|