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,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
+ }