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,265 @@
|
|
|
1
|
+
import { test, describe, beforeEach, afterEach } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import {
|
|
4
|
+
Database,
|
|
5
|
+
runMigrations,
|
|
6
|
+
MerchantRepository,
|
|
7
|
+
ReceivingAccountRepository,
|
|
8
|
+
CustomerRepository,
|
|
9
|
+
PaymentSessionRepository,
|
|
10
|
+
ObservationRepository,
|
|
11
|
+
SupportTicketRepository,
|
|
12
|
+
AuditLogRepository
|
|
13
|
+
} from '../index.js';
|
|
14
|
+
import { PaymentStatus, ObserverSource, SenderType, TicketStatus } from '../../common/index.js';
|
|
15
|
+
|
|
16
|
+
describe('../../db/index.js Unit & Integration Tests', () => {
|
|
17
|
+
let db;
|
|
18
|
+
let merchantRepo;
|
|
19
|
+
let accountRepo;
|
|
20
|
+
let customerRepo;
|
|
21
|
+
let sessionRepo;
|
|
22
|
+
let obsRepo;
|
|
23
|
+
let ticketRepo;
|
|
24
|
+
let auditRepo;
|
|
25
|
+
let testMerchant;
|
|
26
|
+
let testAccount;
|
|
27
|
+
|
|
28
|
+
beforeEach(() => {
|
|
29
|
+
db = new Database({ inMemory: true });
|
|
30
|
+
const { defaultMerchant } = runMigrations(db);
|
|
31
|
+
testMerchant = defaultMerchant;
|
|
32
|
+
|
|
33
|
+
merchantRepo = new MerchantRepository(db);
|
|
34
|
+
accountRepo = new ReceivingAccountRepository(db);
|
|
35
|
+
customerRepo = new CustomerRepository(db);
|
|
36
|
+
sessionRepo = new PaymentSessionRepository(db);
|
|
37
|
+
obsRepo = new ObservationRepository(db);
|
|
38
|
+
ticketRepo = new SupportTicketRepository(db);
|
|
39
|
+
auditRepo = new AuditLogRepository(db);
|
|
40
|
+
|
|
41
|
+
testAccount = accountRepo.findActiveForMerchant(testMerchant.id);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
afterEach(() => {
|
|
45
|
+
db.close();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test('migrations seed default merchant and active UPI receiving account', () => {
|
|
49
|
+
assert.ok(testMerchant);
|
|
50
|
+
assert.equal(testMerchant.slug, 'demo-store');
|
|
51
|
+
assert.ok(testAccount);
|
|
52
|
+
assert.equal(testAccount.upiId, 'merchant@upi');
|
|
53
|
+
assert.equal(testAccount.isActive, true);
|
|
54
|
+
assert.equal(testAccount.isSlotBusy, false);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test('customer registration and password verification', () => {
|
|
58
|
+
const customer = customerRepo.create({
|
|
59
|
+
merchantId: testMerchant.id,
|
|
60
|
+
phoneNumber: '9876543210',
|
|
61
|
+
fullName: 'Gaurav Sharma',
|
|
62
|
+
password: 'SecurePassword123!'
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
assert.ok(customer.id);
|
|
66
|
+
assert.equal(customer.phoneNumber, '9876543210');
|
|
67
|
+
assert.equal(customer.fullName, 'Gaurav Sharma');
|
|
68
|
+
|
|
69
|
+
const verified = customerRepo.verifyCredentials(
|
|
70
|
+
testMerchant.id,
|
|
71
|
+
'9876543210',
|
|
72
|
+
'SecurePassword123!'
|
|
73
|
+
);
|
|
74
|
+
assert.ok(verified);
|
|
75
|
+
assert.equal(verified.id, customer.id);
|
|
76
|
+
|
|
77
|
+
const failed = customerRepo.verifyCredentials(
|
|
78
|
+
testMerchant.id,
|
|
79
|
+
'9876543210',
|
|
80
|
+
'WrongPassword'
|
|
81
|
+
);
|
|
82
|
+
assert.equal(failed, null);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
describe('Queue Engine & Concurrency Slot Invariants', () => {
|
|
86
|
+
test('enforces ONE active slot at one time and queues subsequent sessions', () => {
|
|
87
|
+
// Create session A
|
|
88
|
+
const sessionA = sessionRepo.create({
|
|
89
|
+
merchantId: testMerchant.id,
|
|
90
|
+
receivingAccountId: testAccount.id,
|
|
91
|
+
orderId: 'ORDER-A',
|
|
92
|
+
amountPaise: 20000n
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
// Create session B (same amount, same account)
|
|
96
|
+
const sessionB = sessionRepo.create({
|
|
97
|
+
merchantId: testMerchant.id,
|
|
98
|
+
receivingAccountId: testAccount.id,
|
|
99
|
+
orderId: 'ORDER-B',
|
|
100
|
+
amountPaise: 20000n
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
// Create session C (same amount, same account)
|
|
104
|
+
const sessionC = sessionRepo.create({
|
|
105
|
+
merchantId: testMerchant.id,
|
|
106
|
+
receivingAccountId: testAccount.id,
|
|
107
|
+
orderId: 'ORDER-C',
|
|
108
|
+
amountPaise: 20000n
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
// 1. Session A attempts to acquire slot
|
|
112
|
+
const resA = sessionRepo.acquireSlotAtomic(sessionA.id, testAccount.id, 300000);
|
|
113
|
+
assert.equal(resA.status, PaymentStatus.ACTIVE);
|
|
114
|
+
assert.equal(resA.queuePosition, 0);
|
|
115
|
+
assert.ok(resA.expiresAt instanceof Date);
|
|
116
|
+
|
|
117
|
+
// Verify receiving account is marked busy
|
|
118
|
+
const acctAfterA = accountRepo.findById(testAccount.id);
|
|
119
|
+
assert.equal(acctAfterA.isSlotBusy, true);
|
|
120
|
+
assert.equal(acctAfterA.currentActiveSessionId, sessionA.id);
|
|
121
|
+
|
|
122
|
+
// 2. Session B attempts to acquire slot -> MUST BE QUEUED at position 1
|
|
123
|
+
const resB = sessionRepo.acquireSlotAtomic(sessionB.id, testAccount.id, 300000);
|
|
124
|
+
assert.equal(resB.status, PaymentStatus.QUEUED);
|
|
125
|
+
assert.equal(resB.queuePosition, 1);
|
|
126
|
+
assert.equal(resB.expiresAt, null);
|
|
127
|
+
|
|
128
|
+
// 3. Session C attempts to acquire slot -> MUST BE QUEUED at position 2
|
|
129
|
+
const resC = sessionRepo.acquireSlotAtomic(sessionC.id, testAccount.id, 300000);
|
|
130
|
+
assert.equal(resC.status, PaymentStatus.QUEUED);
|
|
131
|
+
assert.equal(resC.queuePosition, 2);
|
|
132
|
+
|
|
133
|
+
// 4. Session A transitions to PROCESSING when customer clicks "Payment Done"
|
|
134
|
+
const procA = sessionRepo.transitionToProcessing(sessionA.id, 30000);
|
|
135
|
+
assert.equal(procA.status, PaymentStatus.PROCESSING);
|
|
136
|
+
assert.ok(procA.processingStartedAt instanceof Date);
|
|
137
|
+
assert.ok(procA.processingExpiresAt instanceof Date);
|
|
138
|
+
|
|
139
|
+
// Slot MUST STILL BE BUSY during PROCESSING! Session B cannot steal it
|
|
140
|
+
const acctDuringProc = accountRepo.findById(testAccount.id);
|
|
141
|
+
assert.equal(acctDuringProc.isSlotBusy, true);
|
|
142
|
+
assert.equal(acctDuringProc.currentActiveSessionId, sessionA.id);
|
|
143
|
+
|
|
144
|
+
// 5. Session A finishes with SUCCESS
|
|
145
|
+
const successA = sessionRepo.transitionToTerminal(sessionA.id, PaymentStatus.SUCCESS);
|
|
146
|
+
assert.equal(successA.status, PaymentStatus.SUCCESS);
|
|
147
|
+
assert.ok(successA.completedAt instanceof Date);
|
|
148
|
+
|
|
149
|
+
// Slot is now released!
|
|
150
|
+
const acctAfterSuccess = accountRepo.findById(testAccount.id);
|
|
151
|
+
assert.equal(acctAfterSuccess.isSlotBusy, false);
|
|
152
|
+
assert.equal(acctAfterSuccess.currentActiveSessionId, null);
|
|
153
|
+
|
|
154
|
+
// 6. Promote next queued session (B)
|
|
155
|
+
const nextQueued = sessionRepo.getNextQueuedSession(testAccount.id);
|
|
156
|
+
assert.equal(nextQueued.id, sessionB.id);
|
|
157
|
+
|
|
158
|
+
const resBAfterPromo = sessionRepo.acquireSlotAtomic(sessionB.id, testAccount.id, 300000);
|
|
159
|
+
assert.equal(resBAfterPromo.status, PaymentStatus.ACTIVE);
|
|
160
|
+
assert.equal(resBAfterPromo.queuePosition, 0);
|
|
161
|
+
|
|
162
|
+
// Re-index remaining queue
|
|
163
|
+
sessionRepo.reindexQueue(testAccount.id);
|
|
164
|
+
const updatedC = sessionRepo.findById(sessionC.id);
|
|
165
|
+
assert.equal(updatedC.queuePosition, 1); // C promoted from #2 to #1 in waiting line
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test('transitions to CANCELLED and immediately releases slot', () => {
|
|
169
|
+
const session = sessionRepo.create({
|
|
170
|
+
merchantId: testMerchant.id,
|
|
171
|
+
receivingAccountId: testAccount.id,
|
|
172
|
+
orderId: 'ORDER-CANCEL',
|
|
173
|
+
amountPaise: 50000n
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
sessionRepo.acquireSlotAtomic(session.id, testAccount.id, 300000);
|
|
177
|
+
let acct = accountRepo.findById(testAccount.id);
|
|
178
|
+
assert.equal(acct.isSlotBusy, true);
|
|
179
|
+
|
|
180
|
+
sessionRepo.transitionToTerminal(session.id, PaymentStatus.CANCELLED);
|
|
181
|
+
acct = accountRepo.findById(testAccount.id);
|
|
182
|
+
assert.equal(acct.isSlotBusy, false);
|
|
183
|
+
assert.equal(acct.currentActiveSessionId, null);
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
describe('Observation Deduplication & Storage', () => {
|
|
188
|
+
test('records observations and retrieves by fingerprint for deduplication', () => {
|
|
189
|
+
const now = new Date();
|
|
190
|
+
const obs = obsRepo.create({
|
|
191
|
+
receivingAccountId: testAccount.id,
|
|
192
|
+
source: ObserverSource.GOOGLE_MESSAGES,
|
|
193
|
+
amountPaise: 20000n,
|
|
194
|
+
observedAt: now,
|
|
195
|
+
bankReferenceNumber: 'UTR998877',
|
|
196
|
+
fingerprintHash: 'hash_abc_123',
|
|
197
|
+
rawPayload: { text: 'Received Rs 200 via UPI' }
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
assert.ok(obs.id);
|
|
201
|
+
assert.equal(obs.bankReferenceNumber, 'UTR998877');
|
|
202
|
+
|
|
203
|
+
const found = obsRepo.findByFingerprint('hash_abc_123');
|
|
204
|
+
assert.ok(found);
|
|
205
|
+
assert.equal(found.id, obs.id);
|
|
206
|
+
});
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
describe('Support Tickets & Live Messages', () => {
|
|
210
|
+
test('creates tickets, links payments, and records bidirectional chat', () => {
|
|
211
|
+
const customer = customerRepo.create({
|
|
212
|
+
merchantId: testMerchant.id,
|
|
213
|
+
phoneNumber: '9123456780',
|
|
214
|
+
fullName: 'Anita Roy',
|
|
215
|
+
password: 'PassWord123!'
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
const ticket = ticketRepo.createTicket({
|
|
219
|
+
merchantId: testMerchant.id,
|
|
220
|
+
customerId: customer.id,
|
|
221
|
+
subject: 'Payment failed but money deducted',
|
|
222
|
+
initialMessage: {
|
|
223
|
+
senderType: SenderType.CUSTOMER,
|
|
224
|
+
senderId: customer.id,
|
|
225
|
+
message: 'I paid 200 rupees but the screen said failed.'
|
|
226
|
+
}
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
assert.ok(ticket.id);
|
|
230
|
+
assert.equal(ticket.status, TicketStatus.OPEN);
|
|
231
|
+
|
|
232
|
+
// Admin replies
|
|
233
|
+
const adminMsg = ticketRepo.addMessage({
|
|
234
|
+
ticketId: ticket.id,
|
|
235
|
+
senderType: SenderType.ADMIN,
|
|
236
|
+
senderId: testMerchant.id,
|
|
237
|
+
message: 'Hello Anita, we found your transaction and have credited your order.'
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
assert.ok(adminMsg.id);
|
|
241
|
+
|
|
242
|
+
const messages = ticketRepo.findMessagesByTicket(ticket.id);
|
|
243
|
+
assert.equal(messages.length, 2);
|
|
244
|
+
assert.equal(messages[0].senderType, SenderType.CUSTOMER);
|
|
245
|
+
assert.equal(messages[1].senderType, SenderType.ADMIN);
|
|
246
|
+
});
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
describe('Audit Logging', () => {
|
|
250
|
+
test('records and queries audit trail', () => {
|
|
251
|
+
auditRepo.log({
|
|
252
|
+
merchantId: testMerchant.id,
|
|
253
|
+
entityType: 'PAYMENT_SESSION',
|
|
254
|
+
entityId: 'pay_123',
|
|
255
|
+
action: 'SLOT_ACQUIRED',
|
|
256
|
+
details: { queuePosition: 0 }
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
const logs = auditRepo.findByEntity('PAYMENT_SESSION', 'pay_123');
|
|
260
|
+
assert.equal(logs.length, 1);
|
|
261
|
+
assert.equal(logs[0].action, 'SLOT_ACQUIRED');
|
|
262
|
+
assert.equal(logs[0].details.queuePosition, 0);
|
|
263
|
+
});
|
|
264
|
+
});
|
|
265
|
+
});
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { test, describe } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import { Database } from '../database.js';
|
|
7
|
+
|
|
8
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
9
|
+
const __dirname = path.dirname(__filename);
|
|
10
|
+
|
|
11
|
+
describe('Database Schema Parity & Integrity (Postgres vs SQLite) (#13)', () => {
|
|
12
|
+
const pgSchemaPath = path.join(__dirname, '..', 'schema.sql');
|
|
13
|
+
const sqliteSchemaPath = path.join(__dirname, '..', 'sqlite-schema.sql');
|
|
14
|
+
|
|
15
|
+
const pgSchema = fs.readFileSync(pgSchemaPath, 'utf8');
|
|
16
|
+
const sqliteSchema = fs.readFileSync(sqliteSchemaPath, 'utf8');
|
|
17
|
+
|
|
18
|
+
function extractTableNames(sql) {
|
|
19
|
+
const regex = /CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?([a-zA-Z0-9_]+)/gi;
|
|
20
|
+
const tables = [];
|
|
21
|
+
let match;
|
|
22
|
+
while ((match = regex.exec(sql)) !== null) {
|
|
23
|
+
tables.push(match[1].toLowerCase());
|
|
24
|
+
}
|
|
25
|
+
return tables;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function extractIndexNames(sql) {
|
|
29
|
+
const regex = /CREATE\s+(?:UNIQUE\s+)?INDEX\s+(?:IF\s+NOT\s+EXISTS\s+)?([a-zA-Z0-9_]+)/gi;
|
|
30
|
+
const indexes = [];
|
|
31
|
+
let match;
|
|
32
|
+
while ((match = regex.exec(sql)) !== null) {
|
|
33
|
+
indexes.push(match[1].toLowerCase());
|
|
34
|
+
}
|
|
35
|
+
return indexes;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
test('PostgreSQL and SQLite schemas define identical set of tables', () => {
|
|
39
|
+
const pgTables = extractTableNames(pgSchema);
|
|
40
|
+
const sqliteTables = extractTableNames(sqliteSchema);
|
|
41
|
+
|
|
42
|
+
assert.deepEqual(pgTables.sort(), sqliteTables.sort(), 'Tables in schema.sql and sqlite-schema.sql must match 1:1');
|
|
43
|
+
assert.ok(pgTables.length >= 10, 'Must have at least 10 core tables defined');
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test('devices table is defined before payment_sessions in both schemas to prevent foreign key errors', () => {
|
|
47
|
+
const pgTables = extractTableNames(pgSchema);
|
|
48
|
+
const sqliteTables = extractTableNames(sqliteSchema);
|
|
49
|
+
|
|
50
|
+
const pgDevicesIdx = pgTables.indexOf('devices');
|
|
51
|
+
const pgSessionsIdx = pgTables.indexOf('payment_sessions');
|
|
52
|
+
assert.ok(pgDevicesIdx !== -1, 'devices table must exist in schema.sql');
|
|
53
|
+
assert.ok(pgSessionsIdx !== -1, 'payment_sessions table must exist in schema.sql');
|
|
54
|
+
assert.ok(pgDevicesIdx < pgSessionsIdx, 'devices must be declared before payment_sessions in schema.sql');
|
|
55
|
+
|
|
56
|
+
const sqliteDevicesIdx = sqliteTables.indexOf('devices');
|
|
57
|
+
const sqliteSessionsIdx = sqliteTables.indexOf('payment_sessions');
|
|
58
|
+
assert.ok(sqliteDevicesIdx < sqliteSessionsIdx, 'devices must be declared before payment_sessions in sqlite-schema.sql');
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test('PostgreSQL and SQLite schemas define identical core indexes', () => {
|
|
62
|
+
const pgIndexes = extractIndexNames(pgSchema);
|
|
63
|
+
const sqliteIndexes = extractIndexNames(sqliteSchema);
|
|
64
|
+
|
|
65
|
+
const requiredIndexes = [
|
|
66
|
+
'idx_single_active_vpa_per_merchant',
|
|
67
|
+
'idx_customers_email',
|
|
68
|
+
'idx_customers_mobile',
|
|
69
|
+
'idx_customer_sessions_token',
|
|
70
|
+
'idx_customer_sessions_customer',
|
|
71
|
+
'idx_devices_customer',
|
|
72
|
+
'idx_devices_fingerprint',
|
|
73
|
+
'idx_devices_token',
|
|
74
|
+
'idx_payment_sessions_active',
|
|
75
|
+
'idx_payment_sessions_order',
|
|
76
|
+
'idx_payment_sessions_device',
|
|
77
|
+
'idx_single_active_session_per_order',
|
|
78
|
+
'idx_one_slot_per_receiving_account',
|
|
79
|
+
'idx_payment_sessions_attributed_obs',
|
|
80
|
+
'idx_observations_match',
|
|
81
|
+
'idx_observations_bank_ref',
|
|
82
|
+
'idx_observations_unique_attributed_bank_ref',
|
|
83
|
+
'idx_observations_fingerprint',
|
|
84
|
+
'idx_observations_attributed_payment',
|
|
85
|
+
'idx_webhook_deliveries_retry',
|
|
86
|
+
'idx_support_tickets_merchant',
|
|
87
|
+
'idx_support_tickets_customer',
|
|
88
|
+
'idx_ticket_messages_ticket',
|
|
89
|
+
'idx_audit_logs_merchant',
|
|
90
|
+
'idx_audit_logs_entity',
|
|
91
|
+
'idx_security_events_customer',
|
|
92
|
+
'idx_security_events_session',
|
|
93
|
+
'idx_security_events_type'
|
|
94
|
+
];
|
|
95
|
+
|
|
96
|
+
for (const idx of requiredIndexes) {
|
|
97
|
+
assert.ok(pgIndexes.includes(idx), `schema.sql missing index: ${idx}`);
|
|
98
|
+
assert.ok(sqliteIndexes.includes(idx), `sqlite-schema.sql missing index: ${idx}`);
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test('customers table has required security and fraud tracking columns in both schemas', () => {
|
|
103
|
+
const requiredCustomerCols = [
|
|
104
|
+
'otp_attempts',
|
|
105
|
+
'ban_reason',
|
|
106
|
+
'banned_at',
|
|
107
|
+
'unbanned_at',
|
|
108
|
+
'last_device'
|
|
109
|
+
];
|
|
110
|
+
|
|
111
|
+
for (const col of requiredCustomerCols) {
|
|
112
|
+
assert.ok(pgSchema.includes(col), `schema.sql customers table missing column: ${col}`);
|
|
113
|
+
assert.ok(sqliteSchema.includes(col), `sqlite-schema.sql customers table missing column: ${col}`);
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test('sqlite-schema.sql executes cleanly and creates complete schema on fresh database', () => {
|
|
118
|
+
const db = new Database({ inMemory: true, autoMigrate: false });
|
|
119
|
+
try {
|
|
120
|
+
db.exec(sqliteSchema);
|
|
121
|
+
const tables = db.query("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'");
|
|
122
|
+
const tableNames = tables.map(t => t.name);
|
|
123
|
+
assert.ok(tableNames.includes('merchants'));
|
|
124
|
+
assert.ok(tableNames.includes('payment_receiving_accounts'));
|
|
125
|
+
assert.ok(tableNames.includes('customers'));
|
|
126
|
+
assert.ok(tableNames.includes('devices'));
|
|
127
|
+
assert.ok(tableNames.includes('payment_sessions'));
|
|
128
|
+
assert.ok(tableNames.includes('payment_observations'));
|
|
129
|
+
assert.ok(tableNames.includes('webhook_deliveries'));
|
|
130
|
+
assert.ok(tableNames.includes('support_tickets'));
|
|
131
|
+
assert.ok(tableNames.includes('audit_logs'));
|
|
132
|
+
assert.ok(tableNames.includes('security_events'));
|
|
133
|
+
} finally {
|
|
134
|
+
db.close();
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
});
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
4
|
+
|
|
5
|
+
test('native node:sqlite operates in memory', () => {
|
|
6
|
+
const db = new DatabaseSync(':memory:');
|
|
7
|
+
db.exec('CREATE TABLE test (id TEXT PRIMARY KEY, val INT);');
|
|
8
|
+
const insert = db.prepare('INSERT INTO test (id, val) VALUES (?, ?)');
|
|
9
|
+
insert.run('1', 42);
|
|
10
|
+
|
|
11
|
+
const select = db.prepare('SELECT * FROM test WHERE id = ?');
|
|
12
|
+
const row = select.get('1');
|
|
13
|
+
assert.equal(row.val, 42);
|
|
14
|
+
db.close();
|
|
15
|
+
});
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
5
|
+
import { runMigrations } from './migrate.js';
|
|
6
|
+
|
|
7
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
8
|
+
const __dirname = path.dirname(__filename);
|
|
9
|
+
const repoRoot = path.resolve(__dirname, '../../..');
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Checks if core tables exist and executes migrations if not yet initialized.
|
|
13
|
+
* @param {Database} db
|
|
14
|
+
*/
|
|
15
|
+
export function ensureDatabaseInitialized(db) {
|
|
16
|
+
try {
|
|
17
|
+
const row = db.queryOne("SELECT count(*) as count FROM sqlite_master WHERE type='table' AND name='merchants'");
|
|
18
|
+
if (!row || Number(row.count) === 0) {
|
|
19
|
+
return runMigrations(db);
|
|
20
|
+
}
|
|
21
|
+
} catch {
|
|
22
|
+
return runMigrations(db);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Universal Database Client for ELPAY PG.
|
|
28
|
+
* Uses native node:sqlite for high-performance, embedded zero-dependency local operation
|
|
29
|
+
* with full transactional ACID guarantees.
|
|
30
|
+
*/
|
|
31
|
+
export class Database {
|
|
32
|
+
/**
|
|
33
|
+
* @param {{ dbPath?: string, inMemory?: boolean, autoMigrate?: boolean }} [options={}]
|
|
34
|
+
*/
|
|
35
|
+
constructor(options = {}) {
|
|
36
|
+
if (options.inMemory) {
|
|
37
|
+
this.db = new DatabaseSync(':memory:');
|
|
38
|
+
} else {
|
|
39
|
+
let dbPath;
|
|
40
|
+
if (options.dbPath) {
|
|
41
|
+
dbPath = path.isAbsolute(options.dbPath)
|
|
42
|
+
? options.dbPath
|
|
43
|
+
: path.resolve(process.cwd(), options.dbPath);
|
|
44
|
+
} else if (process.env.SQLITE_DB_PATH) {
|
|
45
|
+
dbPath = path.isAbsolute(process.env.SQLITE_DB_PATH)
|
|
46
|
+
? process.env.SQLITE_DB_PATH
|
|
47
|
+
: path.resolve(repoRoot, process.env.SQLITE_DB_PATH);
|
|
48
|
+
} else {
|
|
49
|
+
dbPath = path.resolve(repoRoot, 'data/elpay_pg.sqlite');
|
|
50
|
+
}
|
|
51
|
+
const dir = path.dirname(dbPath);
|
|
52
|
+
if (!fs.existsSync(dir)) {
|
|
53
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
54
|
+
}
|
|
55
|
+
this.db = new DatabaseSync(dbPath);
|
|
56
|
+
// The file holds webhook secrets, session-token indexes, OTPs and customer PII.
|
|
57
|
+
// Default file creation honours umask, which on many hosts leaves it group/world
|
|
58
|
+
// readable, so restrict it explicitly.
|
|
59
|
+
try { fs.chmodSync(dbPath, 0o600); } catch {}
|
|
60
|
+
try { fs.chmodSync(dir, 0o700); } catch {}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Enable foreign keys, busy timeout, and WAL mode for maximum concurrency and integrity
|
|
64
|
+
this.db.exec('PRAGMA foreign_keys = ON;');
|
|
65
|
+
this.db.exec('PRAGMA busy_timeout = 5000;');
|
|
66
|
+
try {
|
|
67
|
+
this.db.exec('PRAGMA journal_mode = WAL;');
|
|
68
|
+
} catch {}
|
|
69
|
+
|
|
70
|
+
// Auto-initialize SQLite on first boot if core tables do not exist
|
|
71
|
+
if (!options.inMemory && options.autoMigrate !== false) {
|
|
72
|
+
ensureDatabaseInitialized(this);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Normalizes parameter markers from PostgreSQL style ($1, $2) to SQLite style (?, ?).
|
|
78
|
+
* @param {string} sql
|
|
79
|
+
* @returns {string}
|
|
80
|
+
*/
|
|
81
|
+
normalizeSql(sql) {
|
|
82
|
+
return sql.replace(/\$\d+/g, '?');
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Executes a statement (INSERT, UPDATE, DELETE, DDL).
|
|
87
|
+
* @param {string} sql
|
|
88
|
+
* @param {any[]} [params=[]]
|
|
89
|
+
* @returns {{ changes: number, lastInsertRowid: number | bigint }}
|
|
90
|
+
*/
|
|
91
|
+
execute(sql, params = []) {
|
|
92
|
+
const normalized = this.normalizeSql(sql);
|
|
93
|
+
const stmt = this.db.prepare(normalized);
|
|
94
|
+
return stmt.run(...params);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
run(sql, params = []) {
|
|
98
|
+
return this.execute(sql, params);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Queries multiple rows.
|
|
103
|
+
* @param {string} sql
|
|
104
|
+
* @param {any[]} [params=[]]
|
|
105
|
+
* @returns {any[]}
|
|
106
|
+
*/
|
|
107
|
+
query(sql, params = []) {
|
|
108
|
+
const normalized = this.normalizeSql(sql);
|
|
109
|
+
const stmt = this.db.prepare(normalized);
|
|
110
|
+
return stmt.all(...params);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Queries a single row.
|
|
115
|
+
* @param {string} sql
|
|
116
|
+
* @param {any[]} [params=[]]
|
|
117
|
+
* @returns {any | null}
|
|
118
|
+
*/
|
|
119
|
+
queryOne(sql, params = []) {
|
|
120
|
+
const normalized = this.normalizeSql(sql);
|
|
121
|
+
const stmt = this.db.prepare(normalized);
|
|
122
|
+
const res = stmt.get(...params);
|
|
123
|
+
return res !== undefined ? res : null;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Executes a batch of DDL or SQL statements.
|
|
128
|
+
* @param {string} sql
|
|
129
|
+
*/
|
|
130
|
+
exec(sql) {
|
|
131
|
+
this.db.exec(sql);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Executes a callback within an ACID database transaction.
|
|
136
|
+
* Automatically rolls back on exception.
|
|
137
|
+
* @template T
|
|
138
|
+
* @param {() => T} callback
|
|
139
|
+
* @returns {T}
|
|
140
|
+
*/
|
|
141
|
+
transaction(callback) {
|
|
142
|
+
// IMMEDIATE (not DEFERRED) so the write lock is taken up front. A deferred transaction
|
|
143
|
+
// starts as a read transaction and only upgrades on first write, which lets two
|
|
144
|
+
// concurrent slot-acquisition callers both observe a free slot before either writes.
|
|
145
|
+
this.db.exec('BEGIN IMMEDIATE;');
|
|
146
|
+
try {
|
|
147
|
+
const result = callback();
|
|
148
|
+
this.db.exec('COMMIT;');
|
|
149
|
+
return result;
|
|
150
|
+
} catch (err) {
|
|
151
|
+
try {
|
|
152
|
+
this.db.exec('ROLLBACK;');
|
|
153
|
+
} catch {
|
|
154
|
+
// Ignore rollback error if already rolled back
|
|
155
|
+
}
|
|
156
|
+
throw err;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Closes the database connection.
|
|
162
|
+
*/
|
|
163
|
+
close() {
|
|
164
|
+
this.db.close();
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
let defaultDbInstance = null;
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Returns the singleton database instance.
|
|
172
|
+
* @param {{ dbPath?: string, inMemory?: boolean }} [options={}]
|
|
173
|
+
* @returns {Database}
|
|
174
|
+
*/
|
|
175
|
+
export function getDatabase(options = {}) {
|
|
176
|
+
if (!defaultDbInstance) {
|
|
177
|
+
defaultDbInstance = new Database(options);
|
|
178
|
+
ensureDatabaseInitialized(defaultDbInstance);
|
|
179
|
+
}
|
|
180
|
+
return defaultDbInstance;
|
|
181
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export * from './database.js';
|
|
2
|
+
export * from './migrate.js';
|
|
3
|
+
export * from './repositories/merchant.repository.js';
|
|
4
|
+
export * from './repositories/receiving-account.repository.js';
|
|
5
|
+
export * from './repositories/customer.repository.js';
|
|
6
|
+
export * from './repositories/customer-session.repository.js';
|
|
7
|
+
export * from './repositories/payment-session.repository.js';
|
|
8
|
+
export * from './repositories/observation.repository.js';
|
|
9
|
+
export * from './repositories/support-ticket.repository.js';
|
|
10
|
+
export * from './repositories/audit-log.repository.js';
|
|
11
|
+
export * from './repositories/device.repository.js';
|
|
12
|
+
|