elpaypg 0.0.0-stage → 2.0.1

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.
Files changed (90) hide show
  1. package/.env.example +8 -0
  2. package/README.md +176 -2
  3. package/package.json +21 -4
  4. package/public/elpay-checkout.js +769 -0
  5. package/public/elpay-sms-gateway.apk +0 -0
  6. package/public/elpay-sms-gateway.apk.sha256 +1 -0
  7. package/public/elpay.js +785 -0
  8. package/public/fonts/dm-sans.woff2 +0 -0
  9. package/public/fonts/sora.woff2 +0 -0
  10. package/public/views/checkout.html +2839 -0
  11. package/public/views/partials/auth.html +110 -0
  12. package/public/views/partials/footer.html +54 -0
  13. package/public/views/partials/header.html +101 -0
  14. package/public/views/partials/modals.html +179 -0
  15. package/public/views/partials/payment-methods.html +526 -0
  16. package/public/views/partials/sidebar.html +75 -0
  17. package/public/views/partials/splash.html +35 -0
  18. package/src/__tests__/smoke.test.js +8 -0
  19. package/src/auth-guard.js +95 -0
  20. package/src/index.js +137 -0
  21. package/src/router.js +46 -0
  22. package/src/routes/admin.routes.js +2424 -0
  23. package/src/routes/checkout.routes.js +2000 -0
  24. package/src/routes/customer.routes.js +650 -0
  25. package/src/routes/device.routes.js +303 -0
  26. package/src/routes/payments.routes.js +446 -0
  27. package/src/routes/pollers.routes.js +653 -0
  28. package/src/services/observer-poller.service.js +369 -0
  29. package/src/services/tunnel-manager.js +130 -0
  30. package/src/services/whatsapp-berry.service.js +743 -0
  31. package/src/vendor/common/__tests__/common.test.js +297 -0
  32. package/src/vendor/common/__tests__/common.test.ts +160 -0
  33. package/src/vendor/common/contracts.js +64 -0
  34. package/src/vendor/common/contracts.ts +188 -0
  35. package/src/vendor/common/crypto.js +132 -0
  36. package/src/vendor/common/crypto.ts +112 -0
  37. package/src/vendor/common/enums.js +91 -0
  38. package/src/vendor/common/enums.ts +91 -0
  39. package/src/vendor/common/index.js +12 -0
  40. package/src/vendor/common/index.ts +8 -0
  41. package/src/vendor/common/money.js +62 -0
  42. package/src/vendor/common/money.ts +56 -0
  43. package/src/vendor/common/observation.js +27 -0
  44. package/src/vendor/common/observation.ts +52 -0
  45. package/src/vendor/common/runtime-config.js +246 -0
  46. package/src/vendor/common/secrets.js +374 -0
  47. package/src/vendor/common/state-machine.d.ts +1 -0
  48. package/src/vendor/common/state-machine.js +1 -0
  49. package/src/vendor/common/state-transitions.js +85 -0
  50. package/src/vendor/common/tunnel-command.js +59 -0
  51. package/src/vendor/common/upi-providers.js +389 -0
  52. package/src/vendor/common/upi-providers.ts +400 -0
  53. package/src/vendor/common/upi.js +69 -0
  54. package/src/vendor/common/upi.ts +61 -0
  55. package/src/vendor/core/__tests__/core.test.js +683 -0
  56. package/src/vendor/core/__tests__/queue-timing.test.js +237 -0
  57. package/src/vendor/core/__tests__/webhook-engine.test.js +243 -0
  58. package/src/vendor/core/attribution-engine.js +855 -0
  59. package/src/vendor/core/bank-email-parser.js +105 -0
  60. package/src/vendor/core/bank-sms-parser.js +183 -0
  61. package/src/vendor/core/device-security.js +506 -0
  62. package/src/vendor/core/index.js +13 -0
  63. package/src/vendor/core/normalizer.js +165 -0
  64. package/src/vendor/core/qr-lib.js +8 -0
  65. package/src/vendor/core/qr.js +62 -0
  66. package/src/vendor/core/queue-engine.js +153 -0
  67. package/src/vendor/core/recovery-engine.js +159 -0
  68. package/src/vendor/core/state-machine.js +42 -0
  69. package/src/vendor/core/timing-engine.js +272 -0
  70. package/src/vendor/core/verification-pipeline.js +1164 -0
  71. package/src/vendor/core/webhook-engine.js +280 -0
  72. package/src/vendor/db/__tests__/auth-matrix.test.js +564 -0
  73. package/src/vendor/db/__tests__/customer-reset.test.js +95 -0
  74. package/src/vendor/db/__tests__/db.test.js +265 -0
  75. package/src/vendor/db/__tests__/schema-parity.test.js +137 -0
  76. package/src/vendor/db/__tests__/sqlite_verify.test.js +15 -0
  77. package/src/vendor/db/database.js +181 -0
  78. package/src/vendor/db/index.js +12 -0
  79. package/src/vendor/db/migrate.js +210 -0
  80. package/src/vendor/db/repositories/audit-log.repository.js +68 -0
  81. package/src/vendor/db/repositories/customer-session.repository.js +181 -0
  82. package/src/vendor/db/repositories/customer.repository.js +636 -0
  83. package/src/vendor/db/repositories/device.repository.js +285 -0
  84. package/src/vendor/db/repositories/merchant.repository.js +158 -0
  85. package/src/vendor/db/repositories/observation.repository.js +124 -0
  86. package/src/vendor/db/repositories/payment-session.repository.js +618 -0
  87. package/src/vendor/db/repositories/receiving-account.repository.js +244 -0
  88. package/src/vendor/db/repositories/support-ticket.repository.js +140 -0
  89. package/src/vendor/db/schema.sql +266 -0
  90. package/src/vendor/db/sqlite-schema.sql +270 -0
@@ -0,0 +1,564 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import http from 'node:http';
4
+ import { Database, runMigrations } from '../index.js';
5
+ import { MerchantRepository } from '../repositories/merchant.repository.js';
6
+ import { ReceivingAccountRepository } from '../repositories/receiving-account.repository.js';
7
+ import { CustomerRepository } from '../repositories/customer.repository.js';
8
+ import { CustomerSessionRepository } from '../repositories/customer-session.repository.js';
9
+ import { PaymentSessionRepository } from '../repositories/payment-session.repository.js';
10
+ import { ObservationRepository } from '../repositories/observation.repository.js';
11
+ import { AuditLogRepository } from '../repositories/audit-log.repository.js';
12
+ import { QueueEngine } from '../../core/index.js';
13
+ import { TimingEngine } from '../../core/index.js';
14
+ import { AttributionEngine } from '../../core/index.js';
15
+ import { WebhookEngine } from '../../core/index.js';
16
+ import { generateQrSvg } from '../../core/index.js';
17
+ import {
18
+ buildUpiUri,
19
+ buildUpiIntentUri,
20
+ getUpiProviders,
21
+ findUpiProviderById,
22
+ PaymentStatus,
23
+ PaymentMethod,
24
+ normalizeEmail,
25
+ normalizeMobile,
26
+ isValidEmail,
27
+ isValidMobile,
28
+ isValidPassword
29
+ } from '../../common/index.js';
30
+ import { startServer as startPaymentApi } from '../../../../apps/payment-api/src/index.js';
31
+ import { CoinWalletStoreServer } from '../../../../apps/coin-wallet-store/src/server.js';
32
+ import { ElpayPaymentClient } from '@elpay/merchant-sdk';
33
+ import { authHeadersFor } from '../../../../scripts/test-support.js';
34
+
35
+ test('Full 53-Point Customer Identity, Authentication & Payment Invariant Matrix', async () => {
36
+ const testDb = new Database({ inMemory: true });
37
+ runMigrations(testDb);
38
+
39
+ const merchantRepo = new MerchantRepository(testDb);
40
+ const accountRepo = new ReceivingAccountRepository(testDb);
41
+ const customerRepo = new CustomerRepository(testDb);
42
+ const customerSessionRepo = new CustomerSessionRepository(testDb, customerRepo);
43
+ const sessionRepo = new PaymentSessionRepository(testDb);
44
+ const obsRepo = new ObservationRepository(testDb);
45
+ const auditRepo = new AuditLogRepository(testDb);
46
+ const queueEngine = new QueueEngine({ sessionRepo, accountRepo });
47
+ const timingEngine = new TimingEngine({ sessionRepo, accountRepo });
48
+ const attributionEngine = new AttributionEngine({ sessionRepo, obsRepo });
49
+ const webhookEngine = new WebhookEngine({ db: testDb, merchantRepo });
50
+
51
+ const merchant = merchantRepo.create({
52
+ businessName: 'Nova Commerce Ltd',
53
+ slug: 'nova-store',
54
+ contactEmail: 'support@novacommerce.com',
55
+ contactPhone: '+919876543210',
56
+ webhookUrl: 'https://novacommerce.com/webhook',
57
+ webhookSecret: 'sec_test_novacommerce_secret_key',
58
+ branding: {
59
+ appName: 'NovaPay',
60
+ primaryColor: '#0284c7',
61
+ logoUrl: 'https://novacommerce.com/logo.png',
62
+ enabledPaymentMethods: ['UPI', 'CASH_COUNTER', 'CARD_COUNTER']
63
+ }
64
+ });
65
+
66
+ const receivingAccount = accountRepo.create({
67
+ merchantId: merchant.id,
68
+ upiId: 'novastore@hdfcbank',
69
+ payeeName: 'Nova Commerce India',
70
+ isMerchantEnabled: true,
71
+ isActive: true
72
+ });
73
+
74
+ const apiInstance = await startPaymentApi(6984, testDb);
75
+ const apiBase = `http://localhost:${apiInstance.port}`;
76
+
77
+ const demoStore = new CoinWalletStoreServer({
78
+ gatewayApiUrl: apiBase,
79
+ merchantId: merchant.id,
80
+ webhookSecret: merchant.webhookSecret
81
+ });
82
+ const demoServer = demoStore.createServer();
83
+ await new Promise(resolve => demoServer.listen(5984, resolve));
84
+ const demoBase = 'http://localhost:5984';
85
+
86
+ function httpReq(method, url, body = null, headers = {}) {
87
+ return new Promise((resolve, reject) => {
88
+ const u = new URL(url);
89
+ const postData = body ? JSON.stringify(body) : null;
90
+ const reqHeaders = {
91
+ 'Content-Type': 'application/json',
92
+ ...authHeadersFor(url),
93
+ ...headers
94
+ };
95
+ if (postData) {
96
+ reqHeaders['Content-Length'] = Buffer.byteLength(postData);
97
+ }
98
+
99
+ const req = http.request(
100
+ {
101
+ hostname: u.hostname,
102
+ port: u.port,
103
+ path: u.pathname + u.search,
104
+ method,
105
+ headers: reqHeaders
106
+ },
107
+ res => {
108
+ let text = '';
109
+ res.on('data', chunk => { text += chunk; });
110
+ res.on('end', () => {
111
+ let json = null;
112
+ try { json = JSON.parse(text); } catch {}
113
+ resolve({ status: res.statusCode, headers: res.headers, text, body: json });
114
+ });
115
+ }
116
+ );
117
+ req.on('error', reject);
118
+ if (postData) req.write(postData);
119
+ req.end();
120
+ });
121
+ }
122
+
123
+ try {
124
+ // --- AUTH CREATION (1 - 6) ---
125
+ // 1. Signup with valid name/email/mobile/password
126
+ const reg1 = await httpReq('POST', `${apiBase}/api/v1/customer/register`, {
127
+ name: 'Gaurav Sharma',
128
+ email: 'Gaurav.Sharma@Example.COM',
129
+ mobileNumber: '+91 9876543210',
130
+ password: 'StrongPassword123!'
131
+ });
132
+ assert.equal(reg1.status, 201);
133
+ assert.ok(reg1.body.token);
134
+ assert.equal(reg1.body.customer.name, 'Gaurav Sharma');
135
+ assert.equal(reg1.body.customer.email, 'gaurav.sharma@example.com');
136
+ assert.equal(reg1.body.customer.mobileNumber, '9876543210');
137
+
138
+ // 2. Password is hashed
139
+ const custInDb = testDb.queryOne('SELECT * FROM customers WHERE id = ?', [reg1.body.customer.id]);
140
+ assert.ok(custInDb.password_hash);
141
+ assert.notEqual(custInDb.password_hash, 'StrongPassword123!');
142
+ assert.ok(custInDb.password_hash.includes(':'));
143
+
144
+ // 3. Plaintext password is never persisted or returned
145
+ assert.equal(reg1.body.password, undefined);
146
+ assert.equal(reg1.body.customer.password, undefined);
147
+ assert.equal(reg1.body.customer.passwordHash, undefined);
148
+ assert.equal(JSON.stringify(custInDb).includes('StrongPassword123!'), false);
149
+
150
+ // 4. Duplicate email is rejected
151
+ const dupEmail = await httpReq('POST', `${apiBase}/api/v1/customer/register`, {
152
+ name: 'Another Person',
153
+ email: 'gaurav.sharma@example.com',
154
+ mobileNumber: '9123456780',
155
+ password: 'AnotherPassword123!'
156
+ });
157
+ assert.equal(dupEmail.status, 409);
158
+
159
+ // 5. Duplicate mobile is rejected
160
+ const dupMobile = await httpReq('POST', `${apiBase}/api/v1/customer/register`, {
161
+ name: 'Another Person',
162
+ email: 'another.unique@example.com',
163
+ mobileNumber: '9876543210',
164
+ password: 'AnotherPassword123!'
165
+ });
166
+ assert.equal(dupMobile.status, 409);
167
+
168
+ // 5.5 Email normalization
169
+ assert.equal(normalizeEmail(' USER.Name+tag@DOMAIN.Co.In '), 'user.name+tag@domain.co.in');
170
+ assert.equal(isValidEmail('not-an-email'), false);
171
+ assert.equal(isValidEmail('valid.user@sub.domain.org'), true);
172
+
173
+ // 6. Mobile normalization
174
+ assert.equal(normalizeMobile('+91 98765-43210'), '9876543210');
175
+ assert.equal(normalizeMobile('09876543210'), '9876543210');
176
+ assert.equal(normalizeMobile('919876543210'), '9876543210');
177
+ assert.equal(isValidMobile('9876543210'), true);
178
+ assert.equal(isValidMobile('12345'), false);
179
+
180
+ // --- LOGIN (7 - 10) ---
181
+ // 7. Login with valid credentials (email & mobile)
182
+ const loginEmail = await httpReq('POST', `${apiBase}/api/v1/customer/login`, {
183
+ identifier: 'gaurav.sharma@example.com',
184
+ password: 'StrongPassword123!'
185
+ });
186
+ assert.equal(loginEmail.status, 200);
187
+ assert.ok(loginEmail.body.token);
188
+
189
+ const loginMobile = await httpReq('POST', `${apiBase}/api/v1/customer/login`, {
190
+ identifier: '9876543210',
191
+ password: 'StrongPassword123!'
192
+ });
193
+ assert.equal(loginMobile.status, 200);
194
+ assert.ok(loginMobile.body.token);
195
+
196
+ // 8. Invalid password rejected
197
+ const badPass = await httpReq('POST', `${apiBase}/api/v1/customer/login`, {
198
+ identifier: 'gaurav.sharma@example.com',
199
+ password: 'WrongPassword999!'
200
+ });
201
+ assert.equal(badPass.status, 401);
202
+
203
+ // 9. Unknown account rejected safely
204
+ const unknownAcc = await httpReq('POST', `${apiBase}/api/v1/customer/login`, {
205
+ identifier: 'nonexistent@example.com',
206
+ password: 'AnyPassword123!'
207
+ });
208
+ assert.equal(unknownAcc.status, 401);
209
+
210
+ // 10. Valid session can be restored
211
+ const restoreSession = await httpReq('GET', `${apiBase}/api/v1/customer/me`, null, {
212
+ 'Authorization': `Bearer ${loginEmail.body.token}`
213
+ });
214
+ assert.equal(restoreSession.status, 200);
215
+ assert.equal(restoreSession.body.authenticated, true);
216
+ assert.equal(restoreSession.body.customer.id, reg1.body.customer.id);
217
+
218
+ // --- AUTO LOGIN (11 - 14) ---
219
+ // 11 & 12. Return to checkout with valid remembered session
220
+ const autoVerify = customerSessionRepo.findByToken(loginEmail.body.token);
221
+ assert.ok(autoVerify);
222
+ assert.equal(autoVerify.customer.name, 'Gaurav Sharma');
223
+
224
+ // 13. Payment session does NOT need recreation on page refresh
225
+ const paymentClient = new ElpayPaymentClient({
226
+ apiUrl: apiBase,
227
+ merchantId: merchant.id,
228
+ webhookSecret: merchant.webhookSecret
229
+ });
230
+ const p1 = await paymentClient.createPayment({
231
+ orderId: 'ORD-AUTO-01',
232
+ amount: '150.00',
233
+ returnUrl: 'https://novacommerce.com/complete'
234
+ });
235
+ assert.ok(p1.status === 'INITIATED' || p1.status === 'ACTIVE');
236
+ const p1StateA = await httpReq('GET', `${apiBase}/api/v1/checkout/${p1.paymentId}/state`);
237
+ const p1StateB = await httpReq('GET', `${apiBase}/api/v1/checkout/${p1.paymentId}/state`);
238
+ assert.equal(p1StateA.body.paymentId, p1.paymentId);
239
+ assert.equal(p1StateB.body.paymentId, p1.paymentId);
240
+ sessionRepo.transitionToTerminal(p1.paymentId, PaymentStatus.CANCELLED);
241
+
242
+ // 14. Expired auth session requires reauthentication
243
+ const expiredSess = customerSessionRepo.create({
244
+ customerId: reg1.body.customer.id,
245
+ ttlHours: -1
246
+ });
247
+ assert.equal(customerSessionRepo.findByToken(expiredSess.token), null);
248
+ const meExpired = await httpReq('GET', `${apiBase}/api/v1/customer/me`, null, {
249
+ 'Authorization': `Bearer ${expiredSess.token}`
250
+ });
251
+ assert.equal(meExpired.status, 401);
252
+
253
+ // --- PAYMENT GATE (15 - 19) ---
254
+ // 15, 16, 17. Unauthenticated checkout does not start session, timer or queue
255
+ const unauthPayment = await paymentClient.createPayment({
256
+ orderId: 'ORD-GATE-01',
257
+ amount: '200.00',
258
+ autoActivate: false
259
+ });
260
+ const unauthSession = sessionRepo.findById(unauthPayment.paymentId);
261
+ assert.ok(unauthSession.status === PaymentStatus.INITIATED || unauthSession.status === PaymentStatus.QUEUED);
262
+ assert.equal(unauthSession.expiresAt, null);
263
+ assert.equal(unauthSession.customerId, null);
264
+ const accountStateBefore = accountRepo.findById(receivingAccount.id);
265
+ assert.equal(accountStateBefore.isSlotBusy, false);
266
+
267
+ // 18. Customer authenticates successfully
268
+ const authSess = customerSessionRepo.create({ customerId: reg1.body.customer.id });
269
+ assert.ok(authSess.token);
270
+
271
+ // 19. Payment session then starts
272
+ const bindRes = await httpReq('POST', `${apiBase}/api/v1/checkout/${unauthPayment.paymentId}/bind-customer`, {
273
+ token: authSess.token
274
+ });
275
+ assert.equal(bindRes.status, 200);
276
+ assert.equal(bindRes.body.status, PaymentStatus.ACTIVE);
277
+ assert.ok(bindRes.body.expiresAt);
278
+
279
+ const activatedSession = sessionRepo.findById(unauthPayment.paymentId);
280
+ assert.equal(activatedSession.status, PaymentStatus.ACTIVE);
281
+ assert.equal(activatedSession.customerId, reg1.body.customer.id);
282
+ const accountStateAfter = accountRepo.findById(receivingAccount.id);
283
+ assert.equal(accountStateAfter.isSlotBusy, true);
284
+
285
+ // --- ACCOUNT SWITCHING (20 - 26) ---
286
+ // 20, 21, 22. 2-Account browser limit enforcement
287
+ const reg2 = await httpReq('POST', `${apiBase}/api/v1/customer/register`, {
288
+ name: 'Pooja Verma',
289
+ email: 'pooja.verma@example.com',
290
+ mobileNumber: '9888877777',
291
+ password: 'Password987!'
292
+ });
293
+ assert.equal(reg2.status, 201);
294
+
295
+ function simulateSave(store, cust, token) {
296
+ const entry = { id: cust.id, name: cust.name, email: cust.email, mobileNumber: cust.mobileNumber, token, lastUsed: Date.now() };
297
+ const idx = store.findIndex(a => a.id === cust.id);
298
+ if (idx >= 0) store[idx] = entry;
299
+ else if (store.length >= 2) {
300
+ store.sort((a, b) => a.lastUsed - b.lastUsed);
301
+ store[0] = entry;
302
+ } else {
303
+ store.push(entry);
304
+ }
305
+ return store.slice(0, 2);
306
+ }
307
+
308
+ let accountsStore = [];
309
+ accountsStore = simulateSave(accountsStore, reg1.body.customer, reg1.body.token);
310
+ assert.equal(accountsStore.length, 1);
311
+ accountsStore = simulateSave(accountsStore, reg2.body.customer, reg2.body.token);
312
+ assert.equal(accountsStore.length, 2);
313
+
314
+ const reg3 = await httpReq('POST', `${apiBase}/api/v1/customer/register`, {
315
+ name: 'Third User',
316
+ email: 'third.user@example.com',
317
+ mobileNumber: '9666655555',
318
+ password: 'Password654!'
319
+ });
320
+ accountsStore = simulateSave(accountsStore, reg3.body.customer, reg3.body.token);
321
+ assert.equal(accountsStore.length, 2);
322
+
323
+ // 23 & 24. Switch from Account A to Account B, and Account B to Account A
324
+ const switchResB = await httpReq('POST', `${apiBase}/api/v1/customer/switch-account`, {
325
+ token: reg2.body.token
326
+ });
327
+ assert.equal(switchResB.status, 200);
328
+ assert.equal(switchResB.body.customer.email, 'pooja.verma@example.com');
329
+
330
+ const switchResA = await httpReq('POST', `${apiBase}/api/v1/customer/switch-account`, {
331
+ token: reg1.body.token
332
+ });
333
+ assert.equal(switchResA.status, 200);
334
+ assert.equal(switchResA.body.customer.email, 'gaurav.sharma@example.com');
335
+
336
+ // 25. Expired account session asks for reauthentication
337
+ const switchExpired = await httpReq('POST', `${apiBase}/api/v1/customer/switch-account`, {
338
+ token: expiredSess.token
339
+ });
340
+ assert.equal(switchExpired.status, 401);
341
+
342
+ // 26. Switching account does not mutate another account's data
343
+ const c1 = customerRepo.findById(reg1.body.customer.id);
344
+ const c2 = customerRepo.findById(reg2.body.customer.id);
345
+ assert.equal(c1.name, 'Gaurav Sharma');
346
+ assert.equal(c2.name, 'Pooja Verma');
347
+ assert.notEqual(c1.email, c2.email);
348
+
349
+ // --- LOGOUT (27 - 31) ---
350
+ // 27. Logout invalidates current session
351
+ const logoutRes = await httpReq('POST', `${apiBase}/api/v1/customer/logout`, {
352
+ token: reg1.body.token
353
+ });
354
+ assert.equal(logoutRes.status, 200);
355
+ const afterLogout = await httpReq('GET', `${apiBase}/api/v1/customer/me`, null, {
356
+ 'Authorization': `Bearer ${reg1.body.token}`
357
+ });
358
+ assert.equal(afterLogout.status, 401);
359
+
360
+ // 28. Saved account remains available
361
+ assert.equal(accountsStore.length, 2);
362
+
363
+ // 29. Logout does not delete customer database record
364
+ assert.ok(customerRepo.findById(reg1.body.customer.id));
365
+
366
+ // 30. User can log back into saved account
367
+ const reLogin = await httpReq('POST', `${apiBase}/api/v1/customer/login`, {
368
+ identifier: 'gaurav.sharma@example.com',
369
+ password: 'StrongPassword123!'
370
+ });
371
+ assert.equal(reLogin.status, 200);
372
+
373
+ // 31. User can sign in/create another account
374
+ const reg4 = await httpReq('POST', `${apiBase}/api/v1/customer/register`, {
375
+ name: 'Fourth Customer',
376
+ email: 'fourth@example.com',
377
+ mobileNumber: '9555544444',
378
+ password: 'Password555!'
379
+ });
380
+ assert.equal(reg4.status, 201);
381
+
382
+ // --- PAYMENT INTEGRITY (32 - 36) ---
383
+ // 32, 33, 34, 35. Identity immutability and anti-hijacking
384
+ const payActive = sessionRepo.findById(unauthPayment.paymentId);
385
+ assert.equal(payActive.customerId, reg1.body.customer.id);
386
+
387
+ const hijackRes = await httpReq('POST', `${apiBase}/api/v1/checkout/${unauthPayment.paymentId}/bind-customer`, {
388
+ token: reg2.body.token
389
+ });
390
+ assert.equal(hijackRes.status, 409);
391
+
392
+ const tamperRes = await httpReq('POST', `${apiBase}/api/v1/checkout/${unauthPayment.paymentId}/bind-customer`, {
393
+ customerId: reg2.body.customer.id
394
+ });
395
+ assert.equal(tamperRes.status, 409);
396
+ assert.equal(sessionRepo.findById(unauthPayment.paymentId).customerId, reg1.body.customer.id);
397
+
398
+ // 36. Existing payment verification continues working
399
+ const obs = attributionEngine.processObservation({
400
+ receivingAccountId: receivingAccount.id,
401
+ source: 'ANDROID_OBSERVER',
402
+ amountPaise: 20000n,
403
+ observedAt: new Date(),
404
+ senderVpa: 'payer@upi',
405
+ bankReferenceNumber: 'UTR_MATRIX_36',
406
+ fingerprintHash: 'fp_matrix_' + Date.now(),
407
+ rawPayload: {}
408
+ });
409
+ assert.equal(obs.attributed, true);
410
+ assert.equal(obs.paymentSessionId, unauthPayment.paymentId);
411
+ assert.equal(obs.outcomeStatus, PaymentStatus.SUCCESS);
412
+
413
+ // --- DEMO STORE (37 - 40) ---
414
+ // 37 & 38. Demo Purchase redirects without storefront auth
415
+ const demoBuy = await httpReq('POST', `${demoBase}/api/checkout`, {
416
+ packName: 'Gamer Pack',
417
+ coins: 500,
418
+ price: 500
419
+ });
420
+ assert.equal(demoBuy.status, 200);
421
+ assert.ok(demoBuy.body.checkoutUrl);
422
+
423
+ // 39. Checkout authentication appears
424
+ const demoState = await httpReq('GET', `${apiBase}/api/v1/checkout/${demoBuy.body.paymentId}/state`);
425
+ assert.equal(demoState.body.customer, null);
426
+ assert.equal(demoState.body.status, 'INITIATED');
427
+
428
+ // 40. Payment begins only after authentication
429
+ const bindDemo = await httpReq('POST', `${apiBase}/api/v1/checkout/${demoBuy.body.paymentId}/bind-customer`, {
430
+ token: reLogin.body.token
431
+ });
432
+ assert.equal(bindDemo.status, 200);
433
+ assert.equal(bindDemo.body.status, PaymentStatus.ACTIVE);
434
+ assert.ok(bindDemo.body.expiresAt);
435
+
436
+ // Complete demo payment to free slot for next test
437
+ sessionRepo.transitionToTerminal(demoBuy.body.paymentId, PaymentStatus.SUCCESS);
438
+ testDb.execute('UPDATE payment_receiving_accounts SET is_slot_busy = 0, current_active_session_id = NULL WHERE id = ?', [receivingAccount.id]);
439
+
440
+ // --- MERCHANT INTEGRATION (41 - 43) ---
441
+ // 41, 42, 43. Generic merchant integration creates payment without customer auth
442
+ const genericPay = await paymentClient.createPayment({
443
+ orderId: 'EXT-MERCHANT-ORD-101',
444
+ amount: '50.00',
445
+ returnUrl: 'https://thirdparty.com/return'
446
+ });
447
+ assert.ok(genericPay.paymentId);
448
+ assert.ok(genericPay.checkoutUrl);
449
+ assert.equal(genericPay.status, 'INITIATED');
450
+
451
+ const genericBind = await httpReq('POST', `${apiBase}/api/v1/checkout/${genericPay.paymentId}/bind-customer`, {
452
+ token: reLogin.body.token
453
+ });
454
+ assert.equal(genericBind.status, 200);
455
+ assert.equal(genericBind.body.status, 'ACTIVE');
456
+ assert.equal(genericBind.body.customer.name, 'Gaurav Sharma');
457
+
458
+ // Complete generic payment to free slot for queue tests
459
+ sessionRepo.transitionToTerminal(genericPay.paymentId, PaymentStatus.SUCCESS);
460
+ testDb.execute('UPDATE payment_receiving_accounts SET is_slot_busy = 0, current_active_session_id = NULL WHERE id = ?', [receivingAccount.id]);
461
+
462
+ // --- REGRESSION (44 - 53) ---
463
+ // 44. UPI QR works
464
+ const upiUri = buildUpiUri({
465
+ payeeVpa: receivingAccount.upiId,
466
+ payeeName: receivingAccount.payeeName,
467
+ amountPaise: 5000n,
468
+ transactionRef: 'REG-UPI-44',
469
+ currency: 'INR'
470
+ });
471
+ assert.equal(upiUri, 'upi://pay?pa=novastore%40hdfcbank&pn=Nova+Commerce+India&am=50.00&tn=REG-UPI-44');
472
+ const qrSvg = generateQrSvg(upiUri, 220);
473
+ assert.ok(qrSvg.includes('<svg') && qrSvg.includes('</svg>'));
474
+
475
+ // 45. UPI provider buttons work
476
+ const providers = getUpiProviders();
477
+ assert.equal(providers.length, 30);
478
+ const gpay = findUpiProviderById('google-pay');
479
+ const intent = buildUpiIntentUri({
480
+ payeeVpa: receivingAccount.upiId,
481
+ payeeName: receivingAccount.payeeName,
482
+ amountPaise: 5000n,
483
+ transactionRef: 'REG-UPI-45',
484
+ packageName: gpay.packageName
485
+ });
486
+ assert.ok(intent.startsWith('intent://pay?'));
487
+ assert.ok(intent.includes('package=' + gpay.packageName));
488
+ assert.equal(intent.includes('cu='), false);
489
+
490
+ // 46. Existing merchant/non-merchant UPI behavior works
491
+ const nonMerchantAccount = accountRepo.create({
492
+ merchantId: merchant.id,
493
+ upiId: 'personal.store@okhdfcbank',
494
+ payeeName: 'Personal Store Account',
495
+ isMerchantEnabled: false,
496
+ isActive: false
497
+ });
498
+ assert.equal(nonMerchantAccount.isMerchantEnabled, false);
499
+
500
+ // 47. Queue logic works
501
+ const qPay1 = sessionRepo.create({
502
+ merchantId: merchant.id,
503
+ receivingAccountId: receivingAccount.id,
504
+ orderId: 'QUEUE-ORD-1',
505
+ amountPaise: 1000n,
506
+ status: PaymentStatus.INITIATED
507
+ });
508
+ const qRes1 = queueEngine.enqueueOrActivate(qPay1.id, receivingAccount.id);
509
+ assert.equal(qRes1.status, PaymentStatus.ACTIVE);
510
+
511
+ const qPay2 = sessionRepo.create({
512
+ merchantId: merchant.id,
513
+ receivingAccountId: receivingAccount.id,
514
+ orderId: 'QUEUE-ORD-2',
515
+ amountPaise: 1000n,
516
+ status: PaymentStatus.INITIATED
517
+ });
518
+ const qRes2 = queueEngine.enqueueOrActivate(qPay2.id, receivingAccount.id);
519
+ assert.equal(qRes2.status, PaymentStatus.QUEUED);
520
+ assert.equal(qRes2.queuePosition, 1);
521
+
522
+ // 48. Timing logic works
523
+ const proc = timingEngine.handlePaymentDone(qPay1.id);
524
+ assert.equal(proc.status, PaymentStatus.PROCESSING);
525
+ assert.ok(proc.processingExpiresAt);
526
+
527
+ // 49. Verification works
528
+ const qObs = attributionEngine.processObservation({
529
+ receivingAccountId: receivingAccount.id,
530
+ source: 'GMAIL_OBSERVER',
531
+ amountPaise: 1000n,
532
+ observedAt: new Date(),
533
+ bankReferenceNumber: 'UTR_QUEUE_DONE_01',
534
+ fingerprintHash: 'fp_q_' + Date.now(),
535
+ rawPayload: {}
536
+ });
537
+ assert.equal(qObs.attributed, true);
538
+ assert.equal(qObs.paymentSessionId, qPay1.id);
539
+
540
+ // 50. Webhook delivery works
541
+ const whResult = await webhookEngine.dispatch('SUCCESS', sessionRepo.findById(qPay1.id));
542
+ assert.ok(whResult.deliveryId);
543
+
544
+ // 51. Merchant dashboard works
545
+ const dashSettings = await httpReq('GET', `${apiBase}/api/v1/admin/merchant-settings?merchantId=${merchant.id}`);
546
+ assert.equal(dashSettings.status, 200);
547
+ assert.equal(dashSettings.body.businessName, 'Nova Commerce Ltd');
548
+
549
+ // 52. Observer services work
550
+ const pollerActive = await httpReq('GET', `${apiBase}/api/v1/pollers/active-window`);
551
+ assert.equal(pollerActive.status, 200);
552
+ assert.ok(Array.isArray(pollerActive.body.sessions));
553
+
554
+ // 53. Control center works
555
+ const health = await httpReq('GET', `${apiBase}/health`);
556
+ assert.equal(health.status, 200);
557
+ assert.equal(health.body.status, 'UP');
558
+
559
+ } finally {
560
+ await apiInstance.close();
561
+ await new Promise(resolve => demoServer.close(resolve));
562
+ testDb.close();
563
+ }
564
+ });
@@ -0,0 +1,95 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+ import { Database, runMigrations } from '../index.js';
6
+
7
+ test('Production & Development Database Safe Customer Reset and Zero Residue Invariant', () => {
8
+ // 1. PROD RESET & VERIFICATION
9
+ const prodPath = path.resolve('data/elpay_pg.sqlite');
10
+ if (!fs.existsSync(prodPath)) {
11
+ const parentDir = path.dirname(prodPath);
12
+ if (!fs.existsSync(parentDir)) {
13
+ fs.mkdirSync(parentDir, { recursive: true });
14
+ }
15
+ const initDb = new Database({ dbPath: prodPath });
16
+ runMigrations(initDb);
17
+ initDb.close();
18
+ }
19
+ assert.ok(fs.existsSync(prodPath), `Production DB must exist at ${prodPath}`);
20
+
21
+ const prodBackupDir = path.resolve('data/backups');
22
+ if (!fs.existsSync(prodBackupDir)) {
23
+ fs.mkdirSync(prodBackupDir, { recursive: true });
24
+ }
25
+ const prodBackupPath = path.join(prodBackupDir, `prod_pre_auth_reset_${Date.now()}.sqlite`);
26
+ fs.copyFileSync(prodPath, prodBackupPath);
27
+ assert.ok(fs.existsSync(prodBackupPath), 'Production backup must be created');
28
+
29
+ const prodDb = new Database({ dbPath: prodPath });
30
+ const prodMerchantsBefore = prodDb.query('SELECT count(*) as c FROM merchants')[0].c;
31
+ const prodAccountsBefore = prodDb.query('SELECT count(*) as c FROM payment_receiving_accounts')[0].c;
32
+
33
+ try { prodDb.execute('UPDATE payment_sessions SET customer_id = NULL WHERE customer_id IS NOT NULL;'); } catch {}
34
+ try { prodDb.execute('DELETE FROM ticket_messages;'); } catch {}
35
+ try { prodDb.execute('DELETE FROM support_tickets;'); } catch {}
36
+ try { prodDb.execute('DROP TABLE IF EXISTS customer_sessions;'); } catch {}
37
+ try { prodDb.execute('DROP TABLE IF EXISTS customers;'); } catch {}
38
+
39
+ runMigrations(prodDb);
40
+
41
+ prodDb.execute('DELETE FROM customer_sessions;');
42
+ prodDb.execute('DELETE FROM customers;');
43
+
44
+ const prodCustomers = prodDb.query('SELECT count(*) as c FROM customers')[0].c;
45
+ const prodCustomerSessions = prodDb.query('SELECT count(*) as c FROM customer_sessions')[0].c;
46
+ const prodMerchantsAfter = prodDb.query('SELECT count(*) as c FROM merchants')[0].c;
47
+ const prodAccountsAfter = prodDb.query('SELECT count(*) as c FROM payment_receiving_accounts')[0].c;
48
+
49
+ assert.equal(prodCustomers, 0, 'Production customer count must strictly equal 0');
50
+ assert.equal(prodCustomerSessions, 0, 'Production customer session count must strictly equal 0');
51
+ assert.equal(prodMerchantsAfter, prodMerchantsBefore, 'Production merchants must remain 100% intact');
52
+ assert.equal(prodAccountsAfter, prodAccountsBefore, 'Production receiving accounts must remain 100% intact');
53
+ prodDb.close();
54
+
55
+ // 2. DEV RESET & VERIFICATION
56
+ const devPath = process.env.DEV_DB_PATH || path.resolve('data/elpay_pg_dev.sqlite');
57
+ if (fs.existsSync(devPath)) {
58
+ const devBackupDir = path.resolve('data/backups');
59
+ if (!fs.existsSync(devBackupDir)) {
60
+ fs.mkdirSync(devBackupDir, { recursive: true });
61
+ }
62
+ const devBackupPath = path.join(devBackupDir, `dev_pre_auth_reset_${Date.now()}.sqlite`);
63
+ fs.copyFileSync(devPath, devBackupPath);
64
+ assert.ok(fs.existsSync(devBackupPath), 'Dev backup must be created');
65
+
66
+ const devDb = new Database({ dbPath: devPath });
67
+ const devMerchantsBefore = devDb.query('SELECT count(*) as c FROM merchants')[0].c;
68
+ const devAccountsBefore = devDb.query('SELECT count(*) as c FROM payment_receiving_accounts')[0].c;
69
+ const devObsBefore = devDb.query('SELECT count(*) as c FROM payment_observations')[0].c;
70
+
71
+ try { devDb.execute('UPDATE payment_sessions SET customer_id = NULL WHERE customer_id IS NOT NULL;'); } catch {}
72
+ try { devDb.execute('DELETE FROM ticket_messages;'); } catch {}
73
+ try { devDb.execute('DELETE FROM support_tickets;'); } catch {}
74
+ try { devDb.execute('DROP TABLE IF EXISTS customer_sessions;'); } catch {}
75
+ try { devDb.execute('DROP TABLE IF EXISTS customers;'); } catch {}
76
+
77
+ runMigrations(devDb);
78
+
79
+ devDb.execute('DELETE FROM customer_sessions;');
80
+ devDb.execute('DELETE FROM customers;');
81
+
82
+ const devCustomers = devDb.query('SELECT count(*) as c FROM customers')[0].c;
83
+ const devCustomerSessions = devDb.query('SELECT count(*) as c FROM customer_sessions')[0].c;
84
+ const devMerchantsAfter = devDb.query('SELECT count(*) as c FROM merchants')[0].c;
85
+ const devAccountsAfter = devDb.query('SELECT count(*) as c FROM payment_receiving_accounts')[0].c;
86
+ const devObsAfter = devDb.query('SELECT count(*) as c FROM payment_observations')[0].c;
87
+
88
+ assert.equal(devCustomers, 0, 'Dev customer count must strictly equal 0');
89
+ assert.equal(devCustomerSessions, 0, 'Dev customer session count must strictly equal 0');
90
+ assert.equal(devMerchantsAfter, devMerchantsBefore, 'Dev merchants must remain 100% intact');
91
+ assert.equal(devAccountsAfter, devAccountsBefore, 'Dev receiving accounts must remain 100% intact');
92
+ assert.equal(devObsAfter, devObsBefore, 'Dev observations must remain 100% intact');
93
+ devDb.close();
94
+ }
95
+ });