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,244 @@
1
+ import crypto from 'node:crypto';
2
+
3
+ export class ReceivingAccountRepository {
4
+ /**
5
+ * @param {import('../database.js').Database} db
6
+ */
7
+ constructor(db) {
8
+ this.db = db;
9
+ }
10
+
11
+ /**
12
+ * Creates a new receiving UPI account.
13
+ * If isActive is true, atomically deactivates any other active accounts for this merchant.
14
+ * Enforces that maximum ONE VPA may be active per merchant.
15
+ */
16
+ create(data) {
17
+ const id = data.id || crypto.randomUUID();
18
+ const now = new Date().toISOString();
19
+ const isActive = data.isActive !== undefined ? Boolean(data.isActive) : true;
20
+ const isMerchantEnabled = data.isMerchantEnabled !== undefined ? Boolean(data.isMerchantEnabled) : true;
21
+
22
+ return this.db.transaction(() => {
23
+ // If this new account is marked active, atomically deactivate all existing active accounts for this merchant
24
+ if (isActive) {
25
+ this.db.execute(`
26
+ UPDATE payment_receiving_accounts
27
+ SET is_active = 0, updated_at = ?
28
+ WHERE merchant_id = ?
29
+ `, [now, data.merchantId]);
30
+ }
31
+
32
+ const sql = `
33
+ INSERT INTO payment_receiving_accounts (
34
+ id, merchant_id, upi_id, payee_name, is_merchant_enabled,
35
+ is_active, is_slot_busy, current_active_session_id, created_at, updated_at
36
+ )
37
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
38
+ `;
39
+
40
+ this.db.execute(sql, [
41
+ id,
42
+ data.merchantId,
43
+ data.upiId.trim(),
44
+ data.payeeName.trim(),
45
+ isMerchantEnabled ? 1 : 0,
46
+ isActive ? 1 : 0,
47
+ 0,
48
+ null,
49
+ now,
50
+ now
51
+ ]);
52
+
53
+ return this.findById(id);
54
+ });
55
+ }
56
+
57
+ findById(id) {
58
+ const row = this.db.queryOne('SELECT * FROM payment_receiving_accounts WHERE id = ?', [id]);
59
+ if (!row) return null;
60
+ return this.mapRow(row);
61
+ }
62
+
63
+ findByMerchantId(merchantId) {
64
+ const rows = this.db.query(
65
+ 'SELECT * FROM payment_receiving_accounts WHERE merchant_id = ? ORDER BY is_active DESC, created_at ASC',
66
+ [merchantId]
67
+ );
68
+ return rows.map(r => this.mapRow(r));
69
+ }
70
+
71
+ findActiveForMerchant(merchantId) {
72
+ const row = this.db.queryOne(
73
+ 'SELECT * FROM payment_receiving_accounts WHERE merchant_id = ? AND is_active = 1 LIMIT 1',
74
+ [merchantId]
75
+ );
76
+ if (!row) return null;
77
+ return this.mapRow(row);
78
+ }
79
+
80
+ /**
81
+ * Atomically activates an account and deactivates all other accounts for this merchant.
82
+ * Backend invariant: At most ONE VPA active per merchant.
83
+ */
84
+ activateAccount(merchantId, accountId) {
85
+ return this.db.transaction(() => {
86
+ const now = new Date().toISOString();
87
+
88
+ // Deactivate all existing accounts for this merchant
89
+ this.db.execute(`
90
+ UPDATE payment_receiving_accounts
91
+ SET is_active = 0, updated_at = ?
92
+ WHERE merchant_id = ?
93
+ `, [now, merchantId]);
94
+
95
+ // Activate the targeted account
96
+ this.db.execute(`
97
+ UPDATE payment_receiving_accounts
98
+ SET is_active = 1, updated_at = ?
99
+ WHERE id = ? AND merchant_id = ?
100
+ `, [now, accountId, merchantId]);
101
+
102
+ return this.findById(accountId);
103
+ });
104
+ }
105
+
106
+ /**
107
+ * Deactivates a specific account.
108
+ */
109
+ deactivateAccount(merchantId, accountId) {
110
+ const now = new Date().toISOString();
111
+ this.db.execute(`
112
+ UPDATE payment_receiving_accounts
113
+ SET is_active = 0, updated_at = ?
114
+ WHERE id = ? AND merchant_id = ?
115
+ `, [now, accountId, merchantId]);
116
+
117
+ return this.findById(accountId);
118
+ }
119
+
120
+ /**
121
+ * Updates an existing account's details.
122
+ */
123
+ updateAccount(id, data) {
124
+ return this.db.transaction(() => {
125
+ const existing = this.findById(id);
126
+ if (!existing) return null;
127
+
128
+ const now = new Date().toISOString();
129
+ const upiId = data.upiId !== undefined ? data.upiId.trim() : existing.upiId;
130
+
131
+ if (existing.isSlotBusy && data.upiId !== undefined && upiId !== existing.upiId) {
132
+ throw new Error('Cannot change UPI ID while receiving account slot is currently busy with an active payment session.');
133
+ }
134
+
135
+ const payeeName = data.payeeName !== undefined ? data.payeeName.trim() : existing.payeeName;
136
+ const isMerchantEnabled = data.isMerchantEnabled !== undefined ? Boolean(data.isMerchantEnabled) : existing.isMerchantEnabled;
137
+
138
+ if (data.isActive === true) {
139
+ this.db.execute(`
140
+ UPDATE payment_receiving_accounts
141
+ SET is_active = 0, updated_at = ?
142
+ WHERE merchant_id = ?
143
+ `, [now, existing.merchantId]);
144
+
145
+ this.db.execute(`
146
+ UPDATE payment_receiving_accounts
147
+ SET upi_id = ?, payee_name = ?, is_merchant_enabled = ?, is_active = 1, updated_at = ?
148
+ WHERE id = ?
149
+ `, [upiId, payeeName, isMerchantEnabled ? 1 : 0, now, id]);
150
+ } else if (data.isActive === false) {
151
+ this.db.execute(`
152
+ UPDATE payment_receiving_accounts
153
+ SET upi_id = ?, payee_name = ?, is_merchant_enabled = ?, is_active = 0, updated_at = ?
154
+ WHERE id = ?
155
+ `, [upiId, payeeName, isMerchantEnabled ? 1 : 0, now, id]);
156
+ } else {
157
+ this.db.execute(`
158
+ UPDATE payment_receiving_accounts
159
+ SET upi_id = ?, payee_name = ?, is_merchant_enabled = ?, updated_at = ?
160
+ WHERE id = ?
161
+ `, [upiId, payeeName, isMerchantEnabled ? 1 : 0, now, id]);
162
+ }
163
+
164
+ return this.findById(id);
165
+ });
166
+ }
167
+
168
+ /**
169
+ * Deletes a receiving account.
170
+ * Throws if the slot is currently busy.
171
+ */
172
+ deleteAccount(merchantId, accountId) {
173
+ const account = this.findById(accountId);
174
+ if (!account || account.merchantId !== merchantId) {
175
+ throw new Error(`Account not found or does not belong to merchant: ${accountId}`);
176
+ }
177
+
178
+ if (account.isSlotBusy || account.currentActiveSessionId) {
179
+ throw new Error(`Cannot delete account ${account.upiId} because it has an active checkout session.`);
180
+ }
181
+
182
+ this.db.execute('DELETE FROM payment_receiving_accounts WHERE id = ? AND merchant_id = ?', [accountId, merchantId]);
183
+ return true;
184
+ }
185
+
186
+ /**
187
+ * Sets slot busy status and currently active session ID.
188
+ */
189
+ setSlotStatus(id, isBusy, activeSessionId = null) {
190
+ const now = new Date().toISOString();
191
+ this.db.execute(`
192
+ UPDATE payment_receiving_accounts
193
+ SET is_slot_busy = ?, current_active_session_id = ?, updated_at = ?
194
+ WHERE id = ?
195
+ `, [isBusy ? 1 : 0, activeSessionId, now, id]);
196
+
197
+ return this.findById(id);
198
+ }
199
+
200
+ /**
201
+ * Updates or creates the active receiving UPI account for a merchant.
202
+ * @param {string} merchantId
203
+ * @param {string} upiId
204
+ * @param {string} [payeeName]
205
+ * @param {boolean} [isMerchantEnabled=true]
206
+ */
207
+ upsertActiveForMerchant(merchantId, upiId, payeeName = null, isMerchantEnabled = true) {
208
+ const cleanUpi = upiId.trim();
209
+ const existing = this.findActiveForMerchant(merchantId);
210
+ const now = new Date().toISOString();
211
+
212
+ if (existing) {
213
+ this.db.execute(`
214
+ UPDATE payment_receiving_accounts
215
+ SET upi_id = ?, payee_name = COALESCE(?, payee_name), is_merchant_enabled = ?, updated_at = ?
216
+ WHERE id = ?
217
+ `, [cleanUpi, payeeName?.trim() || null, isMerchantEnabled ? 1 : 0, now, existing.id]);
218
+ return this.findById(existing.id);
219
+ } else {
220
+ return this.create({
221
+ merchantId,
222
+ upiId: cleanUpi,
223
+ payeeName: payeeName?.trim() || 'Merchant Store',
224
+ isMerchantEnabled,
225
+ isActive: true
226
+ });
227
+ }
228
+ }
229
+
230
+ mapRow(row) {
231
+ return {
232
+ id: row.id,
233
+ merchantId: row.merchant_id,
234
+ upiId: row.upi_id,
235
+ payeeName: row.payee_name,
236
+ isMerchantEnabled: Boolean(row.is_merchant_enabled !== undefined ? row.is_merchant_enabled : 1),
237
+ isActive: Boolean(row.is_active),
238
+ isSlotBusy: Boolean(row.is_slot_busy),
239
+ currentActiveSessionId: row.current_active_session_id,
240
+ createdAt: new Date(row.created_at),
241
+ updatedAt: new Date(row.updated_at)
242
+ };
243
+ }
244
+ }
@@ -0,0 +1,140 @@
1
+ import crypto from 'node:crypto';
2
+ import { TicketStatus, SenderType } from '../../common/index.js';
3
+
4
+ export class SupportTicketRepository {
5
+ /**
6
+ * @param {import('../database.js').Database} db
7
+ */
8
+ constructor(db) {
9
+ this.db = db;
10
+ }
11
+
12
+ createTicket(data) {
13
+ const id = data.id || crypto.randomUUID();
14
+ const now = new Date().toISOString();
15
+
16
+ const sql = `
17
+ INSERT INTO support_tickets (id, merchant_id, customer_id, payment_id, subject, status, created_at, updated_at)
18
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
19
+ `;
20
+
21
+ this.db.execute(sql, [
22
+ id,
23
+ data.merchantId,
24
+ data.customerId,
25
+ data.paymentId || null,
26
+ data.subject.trim(),
27
+ data.status || TicketStatus.OPEN,
28
+ now,
29
+ now
30
+ ]);
31
+
32
+ if (data.initialMessage) {
33
+ this.addMessage({
34
+ ticketId: id,
35
+ senderType: data.initialMessage.senderType || SenderType.CUSTOMER,
36
+ senderId: data.initialMessage.senderId || data.customerId,
37
+ message: data.initialMessage.message
38
+ });
39
+ }
40
+
41
+ return this.findTicketById(id);
42
+ }
43
+
44
+ findTicketById(id) {
45
+ const row = this.db.queryOne('SELECT * FROM support_tickets WHERE id = ?', [id]);
46
+ if (!row) return null;
47
+ return this.mapTicketRow(row);
48
+ }
49
+
50
+ findTicketsByCustomer(customerId) {
51
+ const rows = this.db.query(
52
+ 'SELECT * FROM support_tickets WHERE customer_id = ? ORDER BY updated_at DESC',
53
+ [customerId]
54
+ );
55
+ return rows.map(r => this.mapTicketRow(r));
56
+ }
57
+
58
+ findTicketsByMerchant(merchantId) {
59
+ const rows = this.db.query(
60
+ 'SELECT * FROM support_tickets WHERE merchant_id = ? ORDER BY updated_at DESC',
61
+ [merchantId]
62
+ );
63
+ return rows.map(r => this.mapTicketRow(r));
64
+ }
65
+
66
+ updateTicketStatus(ticketId, status) {
67
+ const now = new Date().toISOString();
68
+ this.db.execute(
69
+ 'UPDATE support_tickets SET status = ?, updated_at = ? WHERE id = ?',
70
+ [status, now, ticketId]
71
+ );
72
+ return this.findTicketById(ticketId);
73
+ }
74
+
75
+ addMessage(data) {
76
+ const id = data.id || crypto.randomUUID();
77
+ const now = new Date().toISOString();
78
+ const attachments = typeof data.attachments === 'object' ? JSON.stringify(data.attachments) : (data.attachments || '[]');
79
+
80
+ this.db.execute(`
81
+ INSERT INTO ticket_messages (id, ticket_id, sender_type, sender_id, message, attachments, created_at)
82
+ VALUES (?, ?, ?, ?, ?, ?, ?)
83
+ `, [
84
+ id,
85
+ data.ticketId,
86
+ data.senderType,
87
+ data.senderId,
88
+ data.message.trim(),
89
+ attachments,
90
+ now
91
+ ]);
92
+
93
+ // Update ticket updated_at
94
+ this.db.execute(
95
+ 'UPDATE support_tickets SET updated_at = ? WHERE id = ?',
96
+ [now, data.ticketId]
97
+ );
98
+
99
+ return this.findMessageById(id);
100
+ }
101
+
102
+ findMessageById(id) {
103
+ const row = this.db.queryOne('SELECT * FROM ticket_messages WHERE id = ?', [id]);
104
+ if (!row) return null;
105
+ return this.mapMessageRow(row);
106
+ }
107
+
108
+ findMessagesByTicket(ticketId) {
109
+ const rows = this.db.query(
110
+ 'SELECT * FROM ticket_messages WHERE ticket_id = ? ORDER BY created_at ASC',
111
+ [ticketId]
112
+ );
113
+ return rows.map(r => this.mapMessageRow(r));
114
+ }
115
+
116
+ mapTicketRow(row) {
117
+ return {
118
+ id: row.id,
119
+ merchantId: row.merchant_id,
120
+ customerId: row.customer_id,
121
+ paymentId: row.payment_id,
122
+ subject: row.subject,
123
+ status: row.status,
124
+ createdAt: new Date(row.created_at),
125
+ updatedAt: new Date(row.updated_at)
126
+ };
127
+ }
128
+
129
+ mapMessageRow(row) {
130
+ return {
131
+ id: row.id,
132
+ ticketId: row.ticket_id,
133
+ senderType: row.sender_type,
134
+ senderId: row.sender_id,
135
+ message: row.message,
136
+ attachments: JSON.parse(row.attachments || '[]'),
137
+ createdAt: new Date(row.created_at)
138
+ };
139
+ }
140
+ }
@@ -0,0 +1,266 @@
1
+ -- PostgreSQL Schema for ELPAY PG
2
+ -- Canonical Production Schema
3
+
4
+ CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
5
+
6
+ -- 1. Merchants Table
7
+ CREATE TABLE IF NOT EXISTS merchants (
8
+ id TEXT PRIMARY KEY,
9
+ business_name VARCHAR(255) NOT NULL,
10
+ slug VARCHAR(100) UNIQUE NOT NULL,
11
+ contact_email VARCHAR(255) NOT NULL,
12
+ contact_phone VARCHAR(20) NOT NULL,
13
+ webhook_url VARCHAR(1024),
14
+ webhook_secret VARCHAR(255) NOT NULL,
15
+ branding TEXT NOT NULL DEFAULT '{"primaryColor":"#2563eb","appName":"ELPAY PG","logoUrl":null}',
16
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
17
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
18
+ );
19
+
20
+ -- 2. Payment Receiving Accounts
21
+ CREATE TABLE IF NOT EXISTS payment_receiving_accounts (
22
+ id TEXT PRIMARY KEY,
23
+ merchant_id TEXT NOT NULL REFERENCES merchants(id) ON DELETE CASCADE,
24
+ upi_id VARCHAR(255) NOT NULL,
25
+ payee_name VARCHAR(255) NOT NULL,
26
+ is_merchant_enabled BOOLEAN NOT NULL DEFAULT true,
27
+ is_active BOOLEAN NOT NULL DEFAULT true,
28
+ is_slot_busy BOOLEAN NOT NULL DEFAULT false,
29
+ current_active_session_id TEXT,
30
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
31
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
32
+ );
33
+
34
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_single_active_vpa_per_merchant ON payment_receiving_accounts (merchant_id) WHERE is_active = true;
35
+
36
+ -- 3. Customers Table (Gateway-owned Customer Identity with WhatsApp Berry OTP)
37
+ CREATE TABLE IF NOT EXISTS customers (
38
+ id TEXT PRIMARY KEY,
39
+ name VARCHAR(255) NOT NULL,
40
+ email VARCHAR(255) UNIQUE NOT NULL,
41
+ mobile_number VARCHAR(20) UNIQUE NOT NULL,
42
+ password_hash VARCHAR(255),
43
+ otp_code VARCHAR(10),
44
+ otp_expires_at TIMESTAMPTZ,
45
+ otp_attempts INT NOT NULL DEFAULT 0,
46
+ is_phone_verified BOOLEAN DEFAULT false,
47
+ status VARCHAR(50) NOT NULL DEFAULT 'ACTIVE',
48
+ ban_reason TEXT,
49
+ banned_at TIMESTAMPTZ,
50
+ unbanned_at TIMESTAMPTZ,
51
+ last_device TEXT,
52
+ last_login_at TIMESTAMPTZ,
53
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
54
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
55
+ );
56
+
57
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_customers_email ON customers (email);
58
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_customers_mobile ON customers (mobile_number);
59
+
60
+ -- 3b. Customer Sessions Table (Server-authoritative session engine)
61
+ CREATE TABLE IF NOT EXISTS customer_sessions (
62
+ id TEXT PRIMARY KEY,
63
+ customer_id TEXT NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
64
+ token VARCHAR(255) UNIQUE NOT NULL,
65
+ expires_at TIMESTAMPTZ NOT NULL,
66
+ is_active BOOLEAN NOT NULL DEFAULT true,
67
+ ip_address VARCHAR(45),
68
+ user_agent TEXT,
69
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
70
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
71
+ );
72
+
73
+ CREATE INDEX IF NOT EXISTS idx_customer_sessions_token ON customer_sessions (token, is_active);
74
+ CREATE INDEX IF NOT EXISTS idx_customer_sessions_customer ON customer_sessions (customer_id);
75
+
76
+ -- 3c. Devices Table (Cryptographic Device Binding & Hardware Context Fingerprinting)
77
+ CREATE TABLE IF NOT EXISTS devices (
78
+ id TEXT PRIMARY KEY,
79
+ customer_id TEXT REFERENCES customers(id) ON DELETE SET NULL,
80
+ public_key TEXT NOT NULL,
81
+ fingerprint_hash VARCHAR(64) NOT NULL,
82
+ user_agent TEXT,
83
+ platform VARCHAR(100),
84
+ browser VARCHAR(100),
85
+ timezone VARCHAR(100),
86
+ language VARCHAR(50),
87
+ screen_signature VARCHAR(100),
88
+ device_token VARCHAR(255) UNIQUE,
89
+ status VARCHAR(50) NOT NULL DEFAULT 'ACTIVE',
90
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
91
+ last_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
92
+ );
93
+
94
+ CREATE INDEX IF NOT EXISTS idx_devices_customer ON devices (customer_id);
95
+ CREATE INDEX IF NOT EXISTS idx_devices_fingerprint ON devices (fingerprint_hash);
96
+ CREATE INDEX IF NOT EXISTS idx_devices_token ON devices (device_token);
97
+
98
+ -- 4. Payment Sessions Table
99
+ CREATE TABLE IF NOT EXISTS payment_sessions (
100
+ id TEXT PRIMARY KEY,
101
+ merchant_id TEXT NOT NULL REFERENCES merchants(id) ON DELETE CASCADE,
102
+ customer_id TEXT REFERENCES customers(id) ON DELETE SET NULL,
103
+ receiving_account_id TEXT REFERENCES payment_receiving_accounts(id),
104
+ order_id VARCHAR(255) NOT NULL,
105
+ attempt_id TEXT,
106
+ amount_paise BIGINT NOT NULL CHECK (amount_paise > 0),
107
+ currency VARCHAR(3) NOT NULL DEFAULT 'INR',
108
+ method VARCHAR(50) NOT NULL DEFAULT 'UPI',
109
+ status VARCHAR(50) NOT NULL DEFAULT 'INITIATED',
110
+ queue_position INT DEFAULT 0,
111
+ slot_acquired_at TIMESTAMPTZ,
112
+ expires_at TIMESTAMPTZ,
113
+ processing_started_at TIMESTAMPTZ,
114
+ processing_expires_at TIMESTAMPTZ,
115
+ completed_at TIMESTAMPTZ,
116
+ return_url VARCHAR(1024),
117
+ attributed_observation_id TEXT,
118
+ idempotency_key VARCHAR(255),
119
+ device_id TEXT REFERENCES devices(id),
120
+ verification_level INT DEFAULT 0,
121
+ verification_sources TEXT DEFAULT '[]',
122
+ verification_tag VARCHAR(100) DEFAULT 'UNVERIFIED',
123
+ metadata TEXT NOT NULL DEFAULT '{}',
124
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
125
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
126
+ );
127
+
128
+ CREATE INDEX IF NOT EXISTS idx_payment_sessions_active ON payment_sessions (receiving_account_id, status);
129
+ CREATE INDEX IF NOT EXISTS idx_payment_sessions_order ON payment_sessions (merchant_id, order_id, created_at);
130
+ CREATE INDEX IF NOT EXISTS idx_payment_sessions_device ON payment_sessions (device_id);
131
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_single_active_session_per_order
132
+ ON payment_sessions (merchant_id, order_id)
133
+ WHERE status NOT IN ('FAILED', 'EXPIRED', 'CANCELLED');
134
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_one_slot_per_receiving_account
135
+ ON payment_sessions (receiving_account_id)
136
+ WHERE status IN ('ACTIVE', 'PROCESSING') AND receiving_account_id IS NOT NULL;
137
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_payment_sessions_attributed_obs
138
+ ON payment_sessions (attributed_observation_id)
139
+ WHERE attributed_observation_id IS NOT NULL;
140
+
141
+ -- 5. Payment Observations Table
142
+ CREATE TABLE IF NOT EXISTS payment_observations (
143
+ id TEXT PRIMARY KEY,
144
+ receiving_account_id TEXT NOT NULL REFERENCES payment_receiving_accounts(id) ON DELETE CASCADE,
145
+ source VARCHAR(50) NOT NULL,
146
+ amount_paise BIGINT NOT NULL,
147
+ observed_at TIMESTAMPTZ NOT NULL,
148
+ ingested_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
149
+ sender_vpa VARCHAR(255),
150
+ bank_reference_number VARCHAR(255),
151
+ fingerprint_hash VARCHAR(64) NOT NULL,
152
+ raw_payload TEXT NOT NULL DEFAULT '{}',
153
+ attribution_status VARCHAR(50) NOT NULL DEFAULT 'UNATTRIBUTED',
154
+ attributed_payment_id TEXT REFERENCES payment_sessions(id),
155
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
156
+ );
157
+
158
+ CREATE INDEX IF NOT EXISTS idx_observations_match ON payment_observations (receiving_account_id, amount_paise, attribution_status);
159
+ CREATE INDEX IF NOT EXISTS idx_observations_bank_ref ON payment_observations (bank_reference_number);
160
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_observations_unique_attributed_bank_ref
161
+ ON payment_observations (bank_reference_number)
162
+ WHERE bank_reference_number IS NOT NULL AND attributed_payment_id IS NOT NULL;
163
+ CREATE INDEX IF NOT EXISTS idx_observations_fingerprint ON payment_observations (fingerprint_hash);
164
+ CREATE INDEX IF NOT EXISTS idx_observations_attributed_payment ON payment_observations (attributed_payment_id);
165
+
166
+ -- 6. Android Devices Table
167
+ CREATE TABLE IF NOT EXISTS android_devices (
168
+ id TEXT PRIMARY KEY,
169
+ merchant_id TEXT NOT NULL REFERENCES merchants(id) ON DELETE CASCADE,
170
+ device_name VARCHAR(255) NOT NULL,
171
+ device_model VARCHAR(255),
172
+ signing_key_hash VARCHAR(255) NOT NULL,
173
+ is_active BOOLEAN NOT NULL DEFAULT true,
174
+ last_heartbeat_at TIMESTAMPTZ,
175
+ last_observation_at TIMESTAMPTZ,
176
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
177
+ );
178
+
179
+ -- 7. Webhook Deliveries Table
180
+ CREATE TABLE IF NOT EXISTS webhook_deliveries (
181
+ id TEXT PRIMARY KEY,
182
+ merchant_id TEXT NOT NULL REFERENCES merchants(id) ON DELETE CASCADE,
183
+ payment_id TEXT NOT NULL REFERENCES payment_sessions(id) ON DELETE CASCADE,
184
+ event_type VARCHAR(50) NOT NULL,
185
+ payload TEXT NOT NULL,
186
+ endpoint_url VARCHAR(1024) NOT NULL,
187
+ response_status INT,
188
+ response_body TEXT,
189
+ attempt_count INT NOT NULL DEFAULT 1,
190
+ is_successful BOOLEAN NOT NULL DEFAULT false,
191
+ next_retry_at TIMESTAMPTZ,
192
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
193
+ );
194
+
195
+ CREATE INDEX IF NOT EXISTS idx_webhook_deliveries_retry ON webhook_deliveries (is_successful, next_retry_at);
196
+
197
+ -- 8. Support Tickets & Ticket Messages
198
+ CREATE TABLE IF NOT EXISTS support_tickets (
199
+ id TEXT PRIMARY KEY,
200
+ merchant_id TEXT NOT NULL REFERENCES merchants(id) ON DELETE CASCADE,
201
+ customer_id TEXT NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
202
+ payment_id TEXT REFERENCES payment_sessions(id) ON DELETE SET NULL,
203
+ subject VARCHAR(255) NOT NULL,
204
+ status VARCHAR(50) NOT NULL DEFAULT 'OPEN',
205
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
206
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
207
+ );
208
+
209
+ CREATE INDEX IF NOT EXISTS idx_support_tickets_merchant ON support_tickets (merchant_id);
210
+ CREATE INDEX IF NOT EXISTS idx_support_tickets_customer ON support_tickets (customer_id);
211
+
212
+ CREATE TABLE IF NOT EXISTS ticket_messages (
213
+ id TEXT PRIMARY KEY,
214
+ ticket_id TEXT NOT NULL REFERENCES support_tickets(id) ON DELETE CASCADE,
215
+ sender_type VARCHAR(20) NOT NULL,
216
+ sender_id TEXT NOT NULL,
217
+ message TEXT NOT NULL,
218
+ attachments TEXT DEFAULT '[]',
219
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
220
+ );
221
+
222
+ CREATE INDEX IF NOT EXISTS idx_ticket_messages_ticket ON ticket_messages (ticket_id);
223
+
224
+ -- 9. Audit Logs Table
225
+ CREATE TABLE IF NOT EXISTS audit_logs (
226
+ id TEXT PRIMARY KEY,
227
+ merchant_id TEXT REFERENCES merchants(id) ON DELETE CASCADE,
228
+ entity_type VARCHAR(50) NOT NULL,
229
+ entity_id TEXT NOT NULL,
230
+ action VARCHAR(100) NOT NULL,
231
+ actor_type VARCHAR(50) NOT NULL,
232
+ actor_id VARCHAR(255),
233
+ details TEXT NOT NULL DEFAULT '{}',
234
+ ip_address VARCHAR(45),
235
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
236
+ );
237
+
238
+ CREATE INDEX IF NOT EXISTS idx_audit_logs_merchant ON audit_logs (merchant_id);
239
+ CREATE INDEX IF NOT EXISTS idx_audit_logs_entity ON audit_logs (entity_type, entity_id);
240
+
241
+ -- 10. Security Events Table
242
+ CREATE TABLE IF NOT EXISTS security_events (
243
+ id TEXT PRIMARY KEY,
244
+ merchant_id TEXT,
245
+ customer_id TEXT,
246
+ payment_session_id TEXT,
247
+ event_type VARCHAR(100) NOT NULL,
248
+ source VARCHAR(50),
249
+ sender VARCHAR(255),
250
+ device_info TEXT,
251
+ raw_message TEXT NOT NULL,
252
+ normalized_message TEXT,
253
+ extracted_amount VARCHAR(50),
254
+ extracted_reference VARCHAR(100),
255
+ parser_results TEXT DEFAULT '{}',
256
+ verification_results TEXT DEFAULT '{}',
257
+ fraud_reason VARCHAR(255) NOT NULL,
258
+ metadata TEXT DEFAULT '{}',
259
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
260
+ );
261
+
262
+ CREATE INDEX IF NOT EXISTS idx_security_events_customer ON security_events (customer_id);
263
+ CREATE INDEX IF NOT EXISTS idx_security_events_session ON security_events (payment_session_id);
264
+ CREATE INDEX IF NOT EXISTS idx_security_events_type ON security_events (event_type);
265
+
266
+