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,285 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import { blindIndex, blindIndexMatches, safeEqual, issueDeviceCredential, verifyDeviceCredential } from '../../common/index.js';
|
|
3
|
+
|
|
4
|
+
/** Namespace so device-token digests never collide with other blind indexes. */
|
|
5
|
+
const INDEX_NS = 'devtok';
|
|
6
|
+
|
|
7
|
+
export class DeviceRepository {
|
|
8
|
+
/**
|
|
9
|
+
* @param {import('../database.js').Database} db
|
|
10
|
+
*/
|
|
11
|
+
constructor(db) {
|
|
12
|
+
this.db = db;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Creates or registers a new device record.
|
|
17
|
+
* @param {{
|
|
18
|
+
* id?: string,
|
|
19
|
+
* customerId?: string|null,
|
|
20
|
+
* publicKey: string,
|
|
21
|
+
* fingerprintHash: string,
|
|
22
|
+
* userAgent?: string,
|
|
23
|
+
* platform?: string,
|
|
24
|
+
* browser?: string,
|
|
25
|
+
* timezone?: string,
|
|
26
|
+
* language?: string,
|
|
27
|
+
* screenSignature?: string,
|
|
28
|
+
* deviceToken?: string,
|
|
29
|
+
* status?: string
|
|
30
|
+
* }} data
|
|
31
|
+
*/
|
|
32
|
+
create(data) {
|
|
33
|
+
const id = data.id || `dev_${crypto.randomUUID()}`;
|
|
34
|
+
const deviceToken = data.deviceToken || issueDeviceCredential(id);
|
|
35
|
+
const now = new Date().toISOString();
|
|
36
|
+
const status = data.status || 'ACTIVE';
|
|
37
|
+
|
|
38
|
+
const sql = `
|
|
39
|
+
INSERT INTO devices (
|
|
40
|
+
id, customer_id, public_key, fingerprint_hash, user_agent,
|
|
41
|
+
platform, browser, timezone, language, screen_signature,
|
|
42
|
+
device_token, status, created_at, last_seen_at
|
|
43
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
44
|
+
`;
|
|
45
|
+
|
|
46
|
+
this.db.execute(sql, [
|
|
47
|
+
id,
|
|
48
|
+
data.customerId || null,
|
|
49
|
+
data.publicKey,
|
|
50
|
+
data.fingerprintHash,
|
|
51
|
+
data.userAgent || null,
|
|
52
|
+
data.platform || null,
|
|
53
|
+
data.browser || null,
|
|
54
|
+
data.timezone || null,
|
|
55
|
+
data.language || null,
|
|
56
|
+
data.screenSignature || null,
|
|
57
|
+
// Only a keyed blind index is persisted: the raw token is never needed again and a
|
|
58
|
+
// database dump must not yield usable device credentials.
|
|
59
|
+
blindIndex(deviceToken, INDEX_NS),
|
|
60
|
+
status,
|
|
61
|
+
now,
|
|
62
|
+
now
|
|
63
|
+
]);
|
|
64
|
+
|
|
65
|
+
// The plaintext token is returned here and only here: it is issued to the registering
|
|
66
|
+
// client once. Persisted storage keeps only the keyed blind index.
|
|
67
|
+
const created = this.findById(id);
|
|
68
|
+
return created ? { ...created, deviceToken } : null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Finds a device by its unique ID.
|
|
73
|
+
* @param {string} id
|
|
74
|
+
*/
|
|
75
|
+
findById(id) {
|
|
76
|
+
if (!id) return null;
|
|
77
|
+
const row = this.db.queryOne('SELECT * FROM devices WHERE id = ?', [id]);
|
|
78
|
+
return row ? this.mapRow(row) : null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Verifies a presented device token for a specific device ID in constant time.
|
|
83
|
+
* @param {string} deviceId
|
|
84
|
+
* @param {string} presentedToken
|
|
85
|
+
* @returns {boolean}
|
|
86
|
+
*/
|
|
87
|
+
verifyToken(deviceId, presentedToken) {
|
|
88
|
+
if (!deviceId || typeof deviceId !== 'string' || !presentedToken || typeof presentedToken !== 'string') {
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
const row = this.db.queryOne(
|
|
92
|
+
'SELECT id, device_token, status FROM devices WHERE id = ?',
|
|
93
|
+
[deviceId]
|
|
94
|
+
);
|
|
95
|
+
if (!row || row.status !== 'ACTIVE' || !row.device_token) {
|
|
96
|
+
return false;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// If token uses the server-signed envelope (`devtok_<deviceId>...`), verify its HMAC signature too.
|
|
100
|
+
if (presentedToken.startsWith(`devtok_${deviceId}.`) && !verifyDeviceCredential(deviceId, presentedToken)) {
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (blindIndexMatches(presentedToken, row.device_token, INDEX_NS)) {
|
|
105
|
+
return true;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Support legacy rows that stored the raw token prior to blind indexing, and upgrade them.
|
|
109
|
+
if (safeEqual(presentedToken, row.device_token)) {
|
|
110
|
+
try {
|
|
111
|
+
this.db.execute('UPDATE devices SET device_token = ? WHERE id = ?', [blindIndex(presentedToken, INDEX_NS), row.id]);
|
|
112
|
+
} catch {}
|
|
113
|
+
return true;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Rotates and issues a fresh server-signed device credential for an existing device.
|
|
121
|
+
* @param {string} deviceId
|
|
122
|
+
* @returns {string|null}
|
|
123
|
+
*/
|
|
124
|
+
rotateToken(deviceId) {
|
|
125
|
+
if (!deviceId) return null;
|
|
126
|
+
const row = this.db.queryOne('SELECT id, status FROM devices WHERE id = ?', [deviceId]);
|
|
127
|
+
if (!row || row.status !== 'ACTIVE') return null;
|
|
128
|
+
const freshToken = issueDeviceCredential(deviceId);
|
|
129
|
+
const now = new Date().toISOString();
|
|
130
|
+
this.db.execute(
|
|
131
|
+
'UPDATE devices SET device_token = ?, last_seen_at = ? WHERE id = ?',
|
|
132
|
+
[blindIndex(freshToken, INDEX_NS), now, deviceId]
|
|
133
|
+
);
|
|
134
|
+
return freshToken;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Finds a device by its active device token.
|
|
139
|
+
* @param {string} token
|
|
140
|
+
*/
|
|
141
|
+
findByToken(token) {
|
|
142
|
+
if (!token) return null;
|
|
143
|
+
let row = this.db.queryOne(
|
|
144
|
+
'SELECT * FROM devices WHERE device_token = ? AND status = ?',
|
|
145
|
+
[blindIndex(token, INDEX_NS), 'ACTIVE']
|
|
146
|
+
);
|
|
147
|
+
// Rows written before index-only storage held the raw token; re-index on first use.
|
|
148
|
+
if (!row && typeof token === 'string' && token.startsWith('devtok_')) {
|
|
149
|
+
const legacy = this.db.queryOne(
|
|
150
|
+
'SELECT * FROM devices WHERE device_token = ? AND status = ?',
|
|
151
|
+
[token, 'ACTIVE']
|
|
152
|
+
);
|
|
153
|
+
if (legacy) {
|
|
154
|
+
try {
|
|
155
|
+
this.db.execute('UPDATE devices SET device_token = ? WHERE id = ?', [blindIndex(token, INDEX_NS), legacy.id]);
|
|
156
|
+
} catch {}
|
|
157
|
+
row = legacy;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return row ? this.mapRow(row) : null;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Finds existing device by matching public key.
|
|
165
|
+
* @param {string} publicKey
|
|
166
|
+
*/
|
|
167
|
+
findByPublicKey(publicKey) {
|
|
168
|
+
if (!publicKey) return null;
|
|
169
|
+
const row = this.db.queryOne('SELECT * FROM devices WHERE public_key = ? LIMIT 1', [publicKey]);
|
|
170
|
+
return row ? this.mapRow(row) : null;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Finds existing device by customerId and fingerprint.
|
|
175
|
+
* @param {string} customerId
|
|
176
|
+
* @param {string} fingerprintHash
|
|
177
|
+
*/
|
|
178
|
+
findByCustomerAndFingerprint(customerId, fingerprintHash) {
|
|
179
|
+
if (!customerId || !fingerprintHash) return null;
|
|
180
|
+
const row = this.db.queryOne(
|
|
181
|
+
'SELECT * FROM devices WHERE customer_id = ? AND fingerprint_hash = ? AND status = "ACTIVE" LIMIT 1',
|
|
182
|
+
[customerId, fingerprintHash]
|
|
183
|
+
);
|
|
184
|
+
return row ? this.mapRow(row) : null;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Finds all devices registered for a customer.
|
|
189
|
+
* @param {string} customerId
|
|
190
|
+
*/
|
|
191
|
+
findByCustomerId(customerId) {
|
|
192
|
+
if (!customerId) return [];
|
|
193
|
+
const rows = this.db.query(
|
|
194
|
+
'SELECT * FROM devices WHERE customer_id = ? ORDER BY last_seen_at DESC',
|
|
195
|
+
[customerId]
|
|
196
|
+
);
|
|
197
|
+
return rows.map(r => this.mapRow(r));
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Associates an existing device with a customer.
|
|
202
|
+
* @param {string} deviceId
|
|
203
|
+
* @param {string} customerId
|
|
204
|
+
*/
|
|
205
|
+
associateCustomer(deviceId, customerId) {
|
|
206
|
+
const now = new Date().toISOString();
|
|
207
|
+
this.db.execute(
|
|
208
|
+
'UPDATE devices SET customer_id = ?, last_seen_at = ? WHERE id = ?',
|
|
209
|
+
[customerId, now, deviceId]
|
|
210
|
+
);
|
|
211
|
+
return this.findById(deviceId);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Updates last seen timestamp for a device.
|
|
216
|
+
* @param {string} deviceId
|
|
217
|
+
*/
|
|
218
|
+
touch(deviceId) {
|
|
219
|
+
if (!deviceId) return;
|
|
220
|
+
try {
|
|
221
|
+
this.db.execute(
|
|
222
|
+
'UPDATE devices SET last_seen_at = ? WHERE id = ?',
|
|
223
|
+
[new Date().toISOString(), deviceId]
|
|
224
|
+
);
|
|
225
|
+
} catch {}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Updates device record.
|
|
230
|
+
* @param {string} id
|
|
231
|
+
* @param {Partial<any>} data
|
|
232
|
+
*/
|
|
233
|
+
update(id, data) {
|
|
234
|
+
const fields = [];
|
|
235
|
+
const values = [];
|
|
236
|
+
|
|
237
|
+
if (data.customerId !== undefined) {
|
|
238
|
+
fields.push('customer_id = ?');
|
|
239
|
+
values.push(data.customerId);
|
|
240
|
+
}
|
|
241
|
+
if (data.fingerprintHash !== undefined) {
|
|
242
|
+
fields.push('fingerprint_hash = ?');
|
|
243
|
+
values.push(data.fingerprintHash);
|
|
244
|
+
}
|
|
245
|
+
if (data.status !== undefined) {
|
|
246
|
+
fields.push('status = ?');
|
|
247
|
+
values.push(data.status);
|
|
248
|
+
}
|
|
249
|
+
if (data.deviceToken !== undefined) {
|
|
250
|
+
fields.push('device_token = ?');
|
|
251
|
+
values.push(blindIndex(data.deviceToken, INDEX_NS));
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
fields.push('last_seen_at = ?');
|
|
255
|
+
values.push(new Date().toISOString());
|
|
256
|
+
|
|
257
|
+
values.push(id);
|
|
258
|
+
this.db.execute(`UPDATE devices SET ${fields.join(', ')} WHERE id = ?`, values);
|
|
259
|
+
return this.findById(id);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Maps a database row to a clean camelCase object.
|
|
264
|
+
* @param {any} row
|
|
265
|
+
*/
|
|
266
|
+
mapRow(row) {
|
|
267
|
+
return {
|
|
268
|
+
id: row.id,
|
|
269
|
+
customerId: row.customer_id,
|
|
270
|
+
publicKey: row.public_key,
|
|
271
|
+
fingerprintHash: row.fingerprint_hash,
|
|
272
|
+
userAgent: row.user_agent,
|
|
273
|
+
platform: row.platform,
|
|
274
|
+
browser: row.browser,
|
|
275
|
+
timezone: row.timezone,
|
|
276
|
+
language: row.language,
|
|
277
|
+
screenSignature: row.screen_signature,
|
|
278
|
+
// Omitted: this column now holds a keyed blind index, not a usable credential.
|
|
279
|
+
deviceToken: null,
|
|
280
|
+
status: row.status,
|
|
281
|
+
createdAt: new Date(row.created_at),
|
|
282
|
+
lastSeenAt: new Date(row.last_seen_at)
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import { encryptAtRest, decryptAtRest } from '../../common/index.js';
|
|
3
|
+
|
|
4
|
+
/** Gmail OAuth credentials are nested inside `branding.gmailConfig`; encrypt just those. */
|
|
5
|
+
const GMAIL_SECRET_FIELDS = ['clientSecret', 'refreshToken', 'apiKey'];
|
|
6
|
+
|
|
7
|
+
/** Serializes branding, encrypting any nested Gmail OAuth secrets. */
|
|
8
|
+
function serializeBranding(branding) {
|
|
9
|
+
if (!branding || typeof branding !== 'object') return branding || '{"primaryColor":"#2563eb","appName":"ELPAY PG","logoUrl":null}';
|
|
10
|
+
const copy = { ...branding };
|
|
11
|
+
const gmail = copy.gmailConfig;
|
|
12
|
+
if (gmail && typeof gmail === 'object') {
|
|
13
|
+
const enc = { ...gmail };
|
|
14
|
+
for (const field of GMAIL_SECRET_FIELDS) {
|
|
15
|
+
if (enc[field]) enc[field] = encryptAtRest(enc[field]);
|
|
16
|
+
}
|
|
17
|
+
copy.gmailConfig = enc;
|
|
18
|
+
}
|
|
19
|
+
return JSON.stringify(copy);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Reverses serializeBranding. Plaintext values written earlier pass through unchanged. */
|
|
23
|
+
function deserializeBranding(raw) {
|
|
24
|
+
const branding = JSON.parse(raw || '{}');
|
|
25
|
+
const gmail = branding.gmailConfig;
|
|
26
|
+
if (gmail && typeof gmail === 'object') {
|
|
27
|
+
const dec = { ...gmail };
|
|
28
|
+
for (const field of GMAIL_SECRET_FIELDS) {
|
|
29
|
+
if (dec[field]) dec[field] = decryptAtRest(dec[field]);
|
|
30
|
+
}
|
|
31
|
+
branding.gmailConfig = dec;
|
|
32
|
+
}
|
|
33
|
+
return branding;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export class MerchantRepository {
|
|
37
|
+
/**
|
|
38
|
+
* @param {import('../database.js').Database} db
|
|
39
|
+
*/
|
|
40
|
+
constructor(db) {
|
|
41
|
+
this.db = db;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Creates a new merchant record.
|
|
46
|
+
*/
|
|
47
|
+
create(data) {
|
|
48
|
+
const id = data.id || crypto.randomUUID();
|
|
49
|
+
const branding = typeof data.branding === 'object'
|
|
50
|
+
? serializeBranding(data.branding)
|
|
51
|
+
: (data.branding || '{"primaryColor":"#2563eb","appName":"ELPAY PG","logoUrl":null}');
|
|
52
|
+
const now = new Date().toISOString();
|
|
53
|
+
|
|
54
|
+
const sql = `
|
|
55
|
+
INSERT INTO merchants (id, business_name, slug, contact_email, contact_phone, webhook_url, webhook_secret, branding, created_at, updated_at)
|
|
56
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
57
|
+
`;
|
|
58
|
+
|
|
59
|
+
this.db.execute(sql, [
|
|
60
|
+
id,
|
|
61
|
+
data.businessName,
|
|
62
|
+
data.slug,
|
|
63
|
+
data.contactEmail,
|
|
64
|
+
data.contactPhone,
|
|
65
|
+
data.webhookUrl || null,
|
|
66
|
+
// Webhook HMAC key must be recoverable at signing time, so it is encrypted at rest.
|
|
67
|
+
encryptAtRest(data.webhookSecret || crypto.randomBytes(32).toString('hex')),
|
|
68
|
+
branding,
|
|
69
|
+
now,
|
|
70
|
+
now
|
|
71
|
+
]);
|
|
72
|
+
|
|
73
|
+
return this.findById(id);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
findById(id) {
|
|
77
|
+
const row = this.db.queryOne('SELECT * FROM merchants WHERE id = ?', [id]);
|
|
78
|
+
if (!row) return null;
|
|
79
|
+
return this.mapRow(row);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
findBySlug(slug) {
|
|
83
|
+
const row = this.db.queryOne('SELECT * FROM merchants WHERE slug = ?', [slug]);
|
|
84
|
+
if (!row) return null;
|
|
85
|
+
return this.mapRow(row);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
getDefault() {
|
|
89
|
+
const canonical = this.findById('00000000-0000-0000-0000-000000000001');
|
|
90
|
+
if (canonical) return canonical;
|
|
91
|
+
const all = this.findAll();
|
|
92
|
+
return all[0] || null;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
findAll() {
|
|
96
|
+
const rows = this.db.query(`
|
|
97
|
+
SELECT * FROM merchants
|
|
98
|
+
ORDER BY CASE WHEN id = '00000000-0000-0000-0000-000000000001' THEN 0 ELSE 1 END, created_at ASC
|
|
99
|
+
`);
|
|
100
|
+
return rows.map(r => this.mapRow(r));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
update(id, updates) {
|
|
104
|
+
const fields = [];
|
|
105
|
+
const params = [];
|
|
106
|
+
|
|
107
|
+
if (updates.businessName !== undefined) {
|
|
108
|
+
fields.push('business_name = ?');
|
|
109
|
+
params.push(updates.businessName);
|
|
110
|
+
}
|
|
111
|
+
if (updates.contactEmail !== undefined) {
|
|
112
|
+
fields.push('contact_email = ?');
|
|
113
|
+
params.push(updates.contactEmail);
|
|
114
|
+
}
|
|
115
|
+
if (updates.contactPhone !== undefined) {
|
|
116
|
+
fields.push('contact_phone = ?');
|
|
117
|
+
params.push(updates.contactPhone);
|
|
118
|
+
}
|
|
119
|
+
if (updates.webhookUrl !== undefined) {
|
|
120
|
+
fields.push('webhook_url = ?');
|
|
121
|
+
params.push(updates.webhookUrl);
|
|
122
|
+
}
|
|
123
|
+
if (updates.webhookSecret !== undefined) {
|
|
124
|
+
fields.push('webhook_secret = ?');
|
|
125
|
+
params.push(encryptAtRest(updates.webhookSecret));
|
|
126
|
+
}
|
|
127
|
+
if (updates.branding !== undefined) {
|
|
128
|
+
fields.push('branding = ?');
|
|
129
|
+
params.push(typeof updates.branding === 'object' ? serializeBranding(updates.branding) : updates.branding);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (fields.length === 0) return this.findById(id);
|
|
133
|
+
|
|
134
|
+
fields.push('updated_at = ?');
|
|
135
|
+
params.push(new Date().toISOString());
|
|
136
|
+
|
|
137
|
+
params.push(id);
|
|
138
|
+
this.db.execute(`UPDATE merchants SET ${fields.join(', ')} WHERE id = ?`, params);
|
|
139
|
+
|
|
140
|
+
return this.findById(id);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
mapRow(row) {
|
|
144
|
+
return {
|
|
145
|
+
id: row.id,
|
|
146
|
+
businessName: row.business_name,
|
|
147
|
+
slug: row.slug,
|
|
148
|
+
contactEmail: row.contact_email,
|
|
149
|
+
contactPhone: row.contact_phone,
|
|
150
|
+
webhookUrl: row.webhook_url,
|
|
151
|
+
// Plaintext rows written before encryption existed decrypt to themselves.
|
|
152
|
+
webhookSecret: decryptAtRest(row.webhook_secret),
|
|
153
|
+
branding: deserializeBranding(row.branding),
|
|
154
|
+
createdAt: new Date(row.created_at),
|
|
155
|
+
updatedAt: new Date(row.updated_at)
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import { ObservationAttributionStatus } from '../../common/index.js';
|
|
3
|
+
|
|
4
|
+
export class ObservationRepository {
|
|
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 rawPayload = typeof data.rawPayload === 'object' ? JSON.stringify(data.rawPayload) : (data.rawPayload || '{}');
|
|
16
|
+
|
|
17
|
+
const sql = `
|
|
18
|
+
INSERT INTO payment_observations (
|
|
19
|
+
id, receiving_account_id, source, amount_paise, observed_at,
|
|
20
|
+
ingested_at, sender_vpa, bank_reference_number, fingerprint_hash,
|
|
21
|
+
raw_payload, attribution_status, attributed_payment_id, created_at
|
|
22
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
23
|
+
`;
|
|
24
|
+
|
|
25
|
+
this.db.execute(sql, [
|
|
26
|
+
id,
|
|
27
|
+
data.receivingAccountId,
|
|
28
|
+
data.source,
|
|
29
|
+
Number(data.amountPaise),
|
|
30
|
+
data.observedAt.toISOString(),
|
|
31
|
+
now,
|
|
32
|
+
data.senderVpa || null,
|
|
33
|
+
data.bankReferenceNumber || null,
|
|
34
|
+
data.fingerprintHash,
|
|
35
|
+
rawPayload,
|
|
36
|
+
data.attributionStatus || ObservationAttributionStatus.UNATTRIBUTED,
|
|
37
|
+
data.attributedPaymentId || null,
|
|
38
|
+
now
|
|
39
|
+
]);
|
|
40
|
+
|
|
41
|
+
return this.findById(id);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
findById(id) {
|
|
45
|
+
const row = this.db.queryOne('SELECT * FROM payment_observations WHERE id = ?', [id]);
|
|
46
|
+
if (!row) return null;
|
|
47
|
+
return this.mapRow(row);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
findByFingerprint(fingerprintHash) {
|
|
51
|
+
const row = this.db.queryOne('SELECT * FROM payment_observations WHERE fingerprint_hash = ?', [fingerprintHash]);
|
|
52
|
+
if (!row) return null;
|
|
53
|
+
return this.mapRow(row);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
findUnattributed(receivingAccountId) {
|
|
57
|
+
const rows = this.db.query(`
|
|
58
|
+
SELECT * FROM payment_observations
|
|
59
|
+
WHERE receiving_account_id = ? AND attribution_status = ?
|
|
60
|
+
ORDER BY observed_at ASC
|
|
61
|
+
`, [receivingAccountId, ObservationAttributionStatus.UNATTRIBUTED]);
|
|
62
|
+
|
|
63
|
+
return rows.map(r => this.mapRow(r));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
findByReceivingAccount(receivingAccountId, limit = 50) {
|
|
67
|
+
const rows = this.db.query(`
|
|
68
|
+
SELECT * FROM payment_observations
|
|
69
|
+
WHERE receiving_account_id = ?
|
|
70
|
+
ORDER BY observed_at DESC
|
|
71
|
+
LIMIT ?
|
|
72
|
+
`, [receivingAccountId, limit]);
|
|
73
|
+
|
|
74
|
+
return rows.map(r => this.mapRow(r));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
markAttributed(id, paymentId) {
|
|
78
|
+
this.db.execute(`
|
|
79
|
+
UPDATE payment_observations
|
|
80
|
+
SET attribution_status = ?, attributed_payment_id = ?
|
|
81
|
+
WHERE id = ?
|
|
82
|
+
`, [ObservationAttributionStatus.ATTRIBUTED, paymentId, id]);
|
|
83
|
+
|
|
84
|
+
return this.findById(id);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
markDuplicate(id) {
|
|
88
|
+
this.db.execute(`
|
|
89
|
+
UPDATE payment_observations
|
|
90
|
+
SET attribution_status = ?
|
|
91
|
+
WHERE id = ?
|
|
92
|
+
`, [ObservationAttributionStatus.DUPLICATE_IGNORED, id]);
|
|
93
|
+
|
|
94
|
+
return this.findById(id);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
markLateUnmatched(id) {
|
|
98
|
+
this.db.execute(`
|
|
99
|
+
UPDATE payment_observations
|
|
100
|
+
SET attribution_status = ?
|
|
101
|
+
WHERE id = ?
|
|
102
|
+
`, [ObservationAttributionStatus.LATE_UNMATCHED, id]);
|
|
103
|
+
|
|
104
|
+
return this.findById(id);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
mapRow(row) {
|
|
108
|
+
return {
|
|
109
|
+
id: row.id,
|
|
110
|
+
receivingAccountId: row.receiving_account_id,
|
|
111
|
+
source: row.source,
|
|
112
|
+
amountPaise: BigInt(row.amount_paise),
|
|
113
|
+
observedAt: new Date(row.observed_at),
|
|
114
|
+
ingestedAt: new Date(row.ingested_at),
|
|
115
|
+
senderVpa: row.sender_vpa,
|
|
116
|
+
bankReferenceNumber: row.bank_reference_number,
|
|
117
|
+
fingerprintHash: row.fingerprint_hash,
|
|
118
|
+
rawPayload: JSON.parse(row.raw_payload || '{}'),
|
|
119
|
+
attributionStatus: row.attribution_status,
|
|
120
|
+
attributedPaymentId: row.attributed_payment_id,
|
|
121
|
+
createdAt: new Date(row.created_at)
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
}
|