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,2421 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import { Router } from 'express';
|
|
3
|
+
import { PaymentStatus, PaymentMethod, SellingMode, AuditActorType, isValidVpa, toRupeesString, deriveCloudflareRoutes, resolveRuntimeUrls, resolveGmailRedirectUri, createOAuthState, validateOutboundWebhookUrl } from '../vendor/common/index.js';
|
|
4
|
+
import { PaymentVerificationPipeline } from '../vendor/core/index.js';
|
|
5
|
+
import { globalTunnelManager } from '../services/tunnel-manager.js';
|
|
6
|
+
import { formatQrDataUrl } from '../services/whatsapp-berry.service.js';
|
|
7
|
+
|
|
8
|
+
function resolveSellingModeAndMethods(body, currentBranding = {}) {
|
|
9
|
+
const inputBranding = body.branding || {};
|
|
10
|
+
let sellingMode = body.sellingMode || inputBranding.sellingMode || currentBranding.sellingMode;
|
|
11
|
+
let rawMethods = body.enabledPaymentMethods || inputBranding.enabledPaymentMethods;
|
|
12
|
+
|
|
13
|
+
if (!sellingMode) {
|
|
14
|
+
if (inputBranding.counterInstructions || currentBranding.counterInstructions || body.cashEnabled !== undefined || body.cardEnabled !== undefined) {
|
|
15
|
+
sellingMode = SellingMode.OFFLINE;
|
|
16
|
+
} else if (rawMethods && (rawMethods.includes(PaymentMethod.CASH_COUNTER) || rawMethods.includes(PaymentMethod.CARD_COUNTER))) {
|
|
17
|
+
sellingMode = SellingMode.OFFLINE;
|
|
18
|
+
} else if (currentBranding.enabledPaymentMethods && (currentBranding.enabledPaymentMethods.includes(PaymentMethod.CASH_COUNTER) || currentBranding.enabledPaymentMethods.includes(PaymentMethod.CARD_COUNTER))) {
|
|
19
|
+
sellingMode = SellingMode.OFFLINE;
|
|
20
|
+
} else {
|
|
21
|
+
sellingMode = SellingMode.ONLINE;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const codOn = body.codEnabled !== undefined
|
|
26
|
+
? Boolean(body.codEnabled)
|
|
27
|
+
: (inputBranding.codEnabled !== undefined
|
|
28
|
+
? Boolean(inputBranding.codEnabled)
|
|
29
|
+
: (currentBranding.codEnabled !== undefined ? Boolean(currentBranding.codEnabled) : (currentBranding.enabledPaymentMethods ? currentBranding.enabledPaymentMethods.includes(PaymentMethod.COD) : true)));
|
|
30
|
+
|
|
31
|
+
const cashOn = body.cashEnabled !== undefined
|
|
32
|
+
? Boolean(body.cashEnabled)
|
|
33
|
+
: (inputBranding.cashEnabled !== undefined
|
|
34
|
+
? Boolean(inputBranding.cashEnabled)
|
|
35
|
+
: (currentBranding.cashEnabled !== undefined ? Boolean(currentBranding.cashEnabled) : (currentBranding.enabledPaymentMethods ? currentBranding.enabledPaymentMethods.includes(PaymentMethod.CASH_COUNTER) : true)));
|
|
36
|
+
|
|
37
|
+
const cardOn = body.cardEnabled !== undefined
|
|
38
|
+
? Boolean(body.cardEnabled)
|
|
39
|
+
: (inputBranding.cardEnabled !== undefined
|
|
40
|
+
? Boolean(inputBranding.cardEnabled)
|
|
41
|
+
: (currentBranding.cardEnabled !== undefined ? Boolean(currentBranding.cardEnabled) : (currentBranding.enabledPaymentMethods ? currentBranding.enabledPaymentMethods.includes(PaymentMethod.CARD_COUNTER) : true)));
|
|
42
|
+
|
|
43
|
+
if (!rawMethods) {
|
|
44
|
+
rawMethods = [PaymentMethod.UPI];
|
|
45
|
+
if (sellingMode === SellingMode.ONLINE) {
|
|
46
|
+
if (codOn) rawMethods.push(PaymentMethod.COD);
|
|
47
|
+
} else {
|
|
48
|
+
if (cashOn) rawMethods.push(PaymentMethod.CASH_COUNTER);
|
|
49
|
+
if (cardOn) rawMethods.push(PaymentMethod.CARD_COUNTER);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Strictly filter based on selling mode:
|
|
54
|
+
// UPI is always enabled in both modes! Turning it off isn't allowed!
|
|
55
|
+
const sanitized = [PaymentMethod.UPI];
|
|
56
|
+
if (sellingMode === SellingMode.ONLINE) {
|
|
57
|
+
if (rawMethods.includes(PaymentMethod.COD) && codOn) {
|
|
58
|
+
sanitized.push(PaymentMethod.COD);
|
|
59
|
+
} else if (body.codEnabled === true) {
|
|
60
|
+
sanitized.push(PaymentMethod.COD);
|
|
61
|
+
}
|
|
62
|
+
} else {
|
|
63
|
+
if (rawMethods.includes(PaymentMethod.CASH_COUNTER) && cashOn) {
|
|
64
|
+
sanitized.push(PaymentMethod.CASH_COUNTER);
|
|
65
|
+
} else if (body.cashEnabled === true) {
|
|
66
|
+
sanitized.push(PaymentMethod.CASH_COUNTER);
|
|
67
|
+
}
|
|
68
|
+
if (rawMethods.includes(PaymentMethod.CARD_COUNTER) && cardOn) {
|
|
69
|
+
sanitized.push(PaymentMethod.CARD_COUNTER);
|
|
70
|
+
} else if (body.cardEnabled === true) {
|
|
71
|
+
sanitized.push(PaymentMethod.CARD_COUNTER);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return {
|
|
76
|
+
sellingMode,
|
|
77
|
+
enabledPaymentMethods: sanitized,
|
|
78
|
+
codEnabled: sanitized.includes(PaymentMethod.COD) || (sellingMode !== SellingMode.ONLINE ? codOn : false),
|
|
79
|
+
cashEnabled: sanitized.includes(PaymentMethod.CASH_COUNTER) || (sellingMode !== SellingMode.OFFLINE ? cashOn : false),
|
|
80
|
+
cardEnabled: sanitized.includes(PaymentMethod.CARD_COUNTER) || (sellingMode !== SellingMode.OFFLINE ? cardOn : false)
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Registers merchant admin dashboard routes.
|
|
86
|
+
* @param {HttpServer} server
|
|
87
|
+
* @param {{
|
|
88
|
+
* db: import('../vendor/db/index.js').Database,
|
|
89
|
+
* sessionRepo: import('../vendor/db/index.js').PaymentSessionRepository,
|
|
90
|
+
* accountRepo: import('../vendor/db/index.js').ReceivingAccountRepository,
|
|
91
|
+
* merchantRepo: import('../vendor/db/index.js').MerchantRepository,
|
|
92
|
+
* auditRepo: import('../vendor/db/index.js').AuditLogRepository,
|
|
93
|
+
* queueEngine: import('../vendor/core/index.js').QueueEngine
|
|
94
|
+
* }} ctx
|
|
95
|
+
*/
|
|
96
|
+
export function registerAdminRoutes(router, ctx) {
|
|
97
|
+
const resolveMerchant = (req, explicitId = null) => {
|
|
98
|
+
const targetId = explicitId || (req ? (req.query?.merchantId || req.headers?.['x-merchant-id']) : null);
|
|
99
|
+
if (targetId) {
|
|
100
|
+
const found = ctx.merchantRepo.findById(targetId);
|
|
101
|
+
if (found) return found;
|
|
102
|
+
}
|
|
103
|
+
return ctx.merchantRepo.getDefault
|
|
104
|
+
? ctx.merchantRepo.getDefault()
|
|
105
|
+
: (ctx.merchantRepo.findById('00000000-0000-0000-0000-000000000001') || ctx.merchantRepo.findAll()[0]);
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
// GET /api/v1/admin/queue
|
|
109
|
+
router.get('/api/v1/admin/queue', async (req, res) => {
|
|
110
|
+
const merchantId = req.query.merchantId || req.headers?.['x-merchant-id'] || '00000000-0000-0000-0000-000000000001';
|
|
111
|
+
let account = ctx.accountRepo.findActiveForMerchant(merchantId);
|
|
112
|
+
|
|
113
|
+
if (!account) {
|
|
114
|
+
const allAccs = ctx.accountRepo.findByMerchantId ? ctx.accountRepo.findByMerchantId(merchantId) : [];
|
|
115
|
+
account = allAccs.find(a => a.isActive) || allAccs[0] || null;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (!account) {
|
|
119
|
+
res.status(200).json({ active: null, currentSlot: null, queue: [] });
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
let currentActive = ctx.sessionRepo.findCurrentActiveOrProcessing(account.id);
|
|
124
|
+
if (!currentActive && ctx.sessionRepo.findCurrentActiveOrProcessingForMerchant) {
|
|
125
|
+
currentActive = ctx.sessionRepo.findCurrentActiveOrProcessingForMerchant(merchantId);
|
|
126
|
+
if (currentActive && currentActive.receivingAccountId !== account.id) {
|
|
127
|
+
try {
|
|
128
|
+
ctx.db.execute('UPDATE payment_sessions SET receiving_account_id = ? WHERE id = ?', [account.id, currentActive.id]);
|
|
129
|
+
ctx.db.execute('UPDATE payment_receiving_accounts SET is_slot_busy = 1, current_active_session_id = ? WHERE id = ?', [currentActive.id, account.id]);
|
|
130
|
+
currentActive.receivingAccountId = account.id;
|
|
131
|
+
} catch {}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const queuedSessions = ctx.sessionRepo.findQueuedSessions(account.id);
|
|
136
|
+
|
|
137
|
+
res.status(200).json({
|
|
138
|
+
receivingAccount: {
|
|
139
|
+
id: account.id,
|
|
140
|
+
upiId: account.upiId,
|
|
141
|
+
payeeName: account.payeeName,
|
|
142
|
+
isSlotBusy: Boolean(currentActive || account.isSlotBusy)
|
|
143
|
+
},
|
|
144
|
+
currentSlot: currentActive ? {
|
|
145
|
+
id: currentActive.id,
|
|
146
|
+
orderId: currentActive.orderId,
|
|
147
|
+
amountPaise: currentActive.amountPaise.toString(),
|
|
148
|
+
status: currentActive.status,
|
|
149
|
+
expiresAt: currentActive.expiresAt,
|
|
150
|
+
processingExpiresAt: currentActive.processingExpiresAt
|
|
151
|
+
} : null,
|
|
152
|
+
queue: queuedSessions.map(s => ({
|
|
153
|
+
id: s.id,
|
|
154
|
+
orderId: s.orderId,
|
|
155
|
+
amountPaise: s.amountPaise.toString(),
|
|
156
|
+
queuePosition: s.queuePosition,
|
|
157
|
+
createdAt: s.createdAt
|
|
158
|
+
}))
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
// GET /api/v1/admin/observers
|
|
163
|
+
router.get('/api/v1/admin/observers', async (req, res) => {
|
|
164
|
+
const devices = ctx.db.query('SELECT * FROM android_devices ORDER BY created_at DESC');
|
|
165
|
+
const now = Date.now();
|
|
166
|
+
|
|
167
|
+
const androidHealth = devices.map(d => {
|
|
168
|
+
const lastSeen = d.last_heartbeat_at ? new Date(d.last_heartbeat_at).getTime() : 0;
|
|
169
|
+
const isConnected = (now - lastSeen) < 180000; // Connected if heartbeat within 3 mins
|
|
170
|
+
return {
|
|
171
|
+
id: d.id,
|
|
172
|
+
name: d.device_name,
|
|
173
|
+
model: d.device_model,
|
|
174
|
+
status: isConnected ? 'CONNECTED' : 'DISCONNECTED',
|
|
175
|
+
lastSeenAt: d.last_heartbeat_at,
|
|
176
|
+
lastObservationAt: d.last_observation_at
|
|
177
|
+
};
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
// Query live Google Messages browser automator status
|
|
181
|
+
let smsStatus = { status: 'STANDBY', isPaired: false, messagesObserved: 0, isRunning: false };
|
|
182
|
+
try {
|
|
183
|
+
const smsPort = Number(process.env.SMS_OBSERVER_PORT || 6002);
|
|
184
|
+
const smsRes = await fetch(`http://localhost:${smsPort}/api/browser/status`, {
|
|
185
|
+
headers: { Authorization: `Bearer ${getObserverToken()}` },
|
|
186
|
+
signal: AbortSignal.timeout(1500)
|
|
187
|
+
});
|
|
188
|
+
if (smsRes.ok) {
|
|
189
|
+
smsStatus = await smsRes.json();
|
|
190
|
+
}
|
|
191
|
+
} catch {}
|
|
192
|
+
|
|
193
|
+
// Query live Gmail browser automator status
|
|
194
|
+
let gmailStatus = { status: 'STANDBY', isLoggedIn: false, emailsObserved: 0, isRunning: false };
|
|
195
|
+
try {
|
|
196
|
+
const gmailPort = Number(process.env.GMAIL_OBSERVER_PORT || 6003);
|
|
197
|
+
const gmailRes = await fetch(`http://localhost:${gmailPort}/api/browser/status`, {
|
|
198
|
+
headers: { Authorization: `Bearer ${getObserverToken()}` },
|
|
199
|
+
signal: AbortSignal.timeout(1500)
|
|
200
|
+
});
|
|
201
|
+
if (gmailRes.ok) {
|
|
202
|
+
gmailStatus = await gmailRes.json();
|
|
203
|
+
}
|
|
204
|
+
} catch {}
|
|
205
|
+
|
|
206
|
+
res.status(200).json({
|
|
207
|
+
gmail: {
|
|
208
|
+
status: gmailStatus.status || (gmailStatus.isLoggedIn ? 'CONNECTED' : 'STANDBY'),
|
|
209
|
+
isLoggedIn: Boolean(gmailStatus.isLoggedIn),
|
|
210
|
+
isRunning: Boolean(gmailStatus.isRunning || gmailStatus.isLoggedIn || gmailStatus.status === 'CONNECTED'),
|
|
211
|
+
emailsObserved: gmailStatus.emailsObserved || 0,
|
|
212
|
+
lastCheckedAt: gmailStatus.lastCheckedAt || null
|
|
213
|
+
},
|
|
214
|
+
android: {
|
|
215
|
+
devices: androidHealth,
|
|
216
|
+
totalDevices: devices.length
|
|
217
|
+
},
|
|
218
|
+
sms: {
|
|
219
|
+
status: smsStatus.status || (smsStatus.isPaired ? 'CONNECTED' : 'STANDBY'),
|
|
220
|
+
isPaired: Boolean(smsStatus.isPaired),
|
|
221
|
+
isRunning: Boolean(smsStatus.isRunning || smsStatus.isPaired || smsStatus.status === 'CONNECTED'),
|
|
222
|
+
messagesObserved: smsStatus.messagesObserved || 0,
|
|
223
|
+
lastSeenAt: smsStatus.lastSeenAt || null
|
|
224
|
+
}
|
|
225
|
+
});
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
// GET /api/v1/admin/payments
|
|
229
|
+
router.get('/api/v1/admin/payments', async (req, res) => {
|
|
230
|
+
const merchantId = req.query.merchantId || '00000000-0000-0000-0000-000000000001';
|
|
231
|
+
const limit = parseInt(req.query.limit || '50', 10);
|
|
232
|
+
const payments = ctx.sessionRepo.findByMerchantId(merchantId, limit);
|
|
233
|
+
|
|
234
|
+
res.status(200).json({
|
|
235
|
+
payments: payments.map(p => ({
|
|
236
|
+
id: p.id,
|
|
237
|
+
orderId: p.orderId,
|
|
238
|
+
amountPaise: p.amountPaise.toString(),
|
|
239
|
+
currency: p.currency,
|
|
240
|
+
method: p.method,
|
|
241
|
+
status: p.status,
|
|
242
|
+
verificationLevel: p.verificationLevel || 0,
|
|
243
|
+
verificationSources: p.verificationSources || [],
|
|
244
|
+
verificationTag: p.verificationTag || 'UNVERIFIED',
|
|
245
|
+
createdAt: p.createdAt,
|
|
246
|
+
completedAt: p.completedAt
|
|
247
|
+
}))
|
|
248
|
+
});
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
// GET /api/v1/admin/tickets
|
|
252
|
+
router.get('/api/v1/admin/tickets', async (req, res) => {
|
|
253
|
+
const merchantId = req.query.merchantId || '00000000-0000-0000-0000-000000000001';
|
|
254
|
+
if (!ctx.ticketRepo) {
|
|
255
|
+
res.status(200).json({ tickets: [] });
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
const tickets = ctx.ticketRepo.findTicketsByMerchant(merchantId);
|
|
259
|
+
res.status(200).json({ tickets });
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
// POST /api/v1/admin/tickets/:id/resolve
|
|
263
|
+
router.post('/api/v1/admin/tickets/:id/resolve', async (req, res) => {
|
|
264
|
+
if (!ctx.ticketRepo) {
|
|
265
|
+
res.status(404).json({ error: 'Ticket repo not configured' });
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
const ticket = ctx.ticketRepo.updateTicketStatus(req.params.id, 'RESOLVED');
|
|
269
|
+
res.status(200).json({ success: true, ticket });
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
// POST /api/v1/admin/counter-confirm
|
|
273
|
+
router.post('/api/v1/admin/counter-confirm', async (req, res) => {
|
|
274
|
+
const body = req.body || {};
|
|
275
|
+
const { paymentId, operatorId, method } = body;
|
|
276
|
+
|
|
277
|
+
if (!paymentId || !operatorId) {
|
|
278
|
+
res.status(400).json({ error: 'paymentId and operatorId are required.' });
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const session = ctx.sessionRepo.findById(paymentId);
|
|
283
|
+
if (!session) {
|
|
284
|
+
res.status(404).json({ error: `Payment session not found: ${paymentId}` });
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// Manual confirmation is only valid for counter payments still awaiting settlement.
|
|
289
|
+
// A UPI session must only reach SUCCESS through the verification engine.
|
|
290
|
+
if (session.method !== PaymentMethod.CASH_COUNTER && session.method !== PaymentMethod.CARD_COUNTER) {
|
|
291
|
+
res.status(400).json({ error: `Manual confirmation is only allowed for counter payments (session method is '${session.method}').` });
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
if ([PaymentStatus.SUCCESS, PaymentStatus.FAILED, PaymentStatus.EXPIRED, PaymentStatus.CANCELLED].includes(session.status)) {
|
|
295
|
+
res.status(409).json({ error: `Cannot confirm a payment in terminal status '${session.status}'.` });
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
const updated = ctx.sessionRepo.transitionToTerminal(paymentId, PaymentStatus.SUCCESS);
|
|
300
|
+
|
|
301
|
+
ctx.auditRepo.log({
|
|
302
|
+
merchantId: session.merchantId,
|
|
303
|
+
entityType: 'PAYMENT_SESSION',
|
|
304
|
+
entityId: paymentId,
|
|
305
|
+
action: 'COUNTER_MANUAL_PAYMENT_CONFIRMED',
|
|
306
|
+
actorType: AuditActorType.ADMIN,
|
|
307
|
+
actorId: operatorId,
|
|
308
|
+
details: { method: method || PaymentMethod.CASH_COUNTER, operatorId }
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
server.broadcastSse(paymentId, 'status_change', {
|
|
312
|
+
status: PaymentStatus.SUCCESS,
|
|
313
|
+
method: method || PaymentMethod.CASH_COUNTER,
|
|
314
|
+
orderId: session.orderId
|
|
315
|
+
});
|
|
316
|
+
server.broadcastSse(paymentId, 'state_change', {
|
|
317
|
+
status: PaymentStatus.SUCCESS,
|
|
318
|
+
method: method || PaymentMethod.CASH_COUNTER,
|
|
319
|
+
orderId: session.orderId
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
if (ctx.eventEmitter) {
|
|
323
|
+
ctx.eventEmitter.emit('PAYMENT.SUCCESS', {
|
|
324
|
+
paymentId: updated.id,
|
|
325
|
+
orderId: session.orderId,
|
|
326
|
+
merchantId: session.merchantId,
|
|
327
|
+
amountPaise: session.amountPaise
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
res.status(200).json({
|
|
332
|
+
confirmed: true,
|
|
333
|
+
paymentId: updated.id,
|
|
334
|
+
status: PaymentStatus.SUCCESS,
|
|
335
|
+
orderId: session.orderId
|
|
336
|
+
});
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
// POST /api/v1/admin/counter-decline (Declines/Rejects manual cash or card counter payment)
|
|
340
|
+
router.post('/api/v1/admin/counter-decline', async (req, res) => {
|
|
341
|
+
const body = req.body || {};
|
|
342
|
+
const { paymentId, operatorId, reason } = body;
|
|
343
|
+
|
|
344
|
+
if (!paymentId || !operatorId) {
|
|
345
|
+
res.status(400).json({ error: 'paymentId and operatorId are required.' });
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
const session = ctx.sessionRepo.findById(paymentId);
|
|
350
|
+
if (!session) {
|
|
351
|
+
res.status(404).json({ error: `Payment session not found: ${paymentId}` });
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
const updated = ctx.sessionRepo.transitionToTerminal(paymentId, PaymentStatus.FAILED);
|
|
356
|
+
|
|
357
|
+
ctx.auditRepo.log({
|
|
358
|
+
merchantId: session.merchantId,
|
|
359
|
+
entityType: 'PAYMENT_SESSION',
|
|
360
|
+
entityId: paymentId,
|
|
361
|
+
action: 'COUNTER_MANUAL_PAYMENT_DECLINED',
|
|
362
|
+
actorType: AuditActorType.ADMIN,
|
|
363
|
+
actorId: operatorId,
|
|
364
|
+
details: {
|
|
365
|
+
method: session.method,
|
|
366
|
+
operatorId,
|
|
367
|
+
reason: reason || 'Declined by merchant counter operator'
|
|
368
|
+
}
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
server.broadcastSse(paymentId, 'status_change', {
|
|
372
|
+
status: PaymentStatus.FAILED,
|
|
373
|
+
method: session.method,
|
|
374
|
+
reason: reason || 'Declined by merchant counter operator',
|
|
375
|
+
orderId: session.orderId
|
|
376
|
+
});
|
|
377
|
+
server.broadcastSse(paymentId, 'state_change', {
|
|
378
|
+
status: PaymentStatus.FAILED,
|
|
379
|
+
method: session.method,
|
|
380
|
+
reason: reason || 'Declined by merchant counter operator',
|
|
381
|
+
orderId: session.orderId
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
if (ctx.eventEmitter) {
|
|
385
|
+
ctx.eventEmitter.emit('PAYMENT.FAILED', {
|
|
386
|
+
paymentId: updated.id,
|
|
387
|
+
orderId: session.orderId,
|
|
388
|
+
merchantId: session.merchantId,
|
|
389
|
+
amountPaise: session.amountPaise,
|
|
390
|
+
reason: reason || 'Counter payment declined'
|
|
391
|
+
});
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
res.status(200).json({
|
|
395
|
+
declined: true,
|
|
396
|
+
paymentId: updated.id,
|
|
397
|
+
status: PaymentStatus.FAILED,
|
|
398
|
+
reason: reason || 'Counter payment declined'
|
|
399
|
+
});
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
// GET /api/v1/admin/upi-settings
|
|
403
|
+
router.get('/api/v1/admin/upi-settings', async (req, res) => {
|
|
404
|
+
let merchant = resolveMerchant(req);
|
|
405
|
+
|
|
406
|
+
const account = merchant ? ctx.accountRepo.findActiveForMerchant(merchant.id) : null;
|
|
407
|
+
const accounts = merchant ? ctx.accountRepo.findByMerchantId(merchant.id) : [];
|
|
408
|
+
|
|
409
|
+
if (!account) {
|
|
410
|
+
res.status(200).json({
|
|
411
|
+
id: '',
|
|
412
|
+
upiId: '',
|
|
413
|
+
payeeName: '',
|
|
414
|
+
isMerchantEnabled: true,
|
|
415
|
+
isActive: false,
|
|
416
|
+
accounts
|
|
417
|
+
});
|
|
418
|
+
return;
|
|
419
|
+
}
|
|
420
|
+
res.status(200).json({
|
|
421
|
+
id: account.id,
|
|
422
|
+
upiId: account.upiId,
|
|
423
|
+
payeeName: account.payeeName,
|
|
424
|
+
isMerchantEnabled: account.isMerchantEnabled,
|
|
425
|
+
isActive: account.isActive,
|
|
426
|
+
accounts
|
|
427
|
+
});
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
// GET /api/v1/admin/upi-accounts (List all merchant VPAs)
|
|
431
|
+
router.get('/api/v1/admin/upi-accounts', async (req, res) => {
|
|
432
|
+
let merchant = resolveMerchant(req);
|
|
433
|
+
|
|
434
|
+
if (!merchant) {
|
|
435
|
+
res.status(404).json({ error: 'Merchant not found' });
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
const accounts = ctx.accountRepo.findByMerchantId(merchant.id);
|
|
440
|
+
res.status(200).json({
|
|
441
|
+
accounts: accounts.map(a => ({
|
|
442
|
+
id: a.id,
|
|
443
|
+
merchantId: a.merchantId,
|
|
444
|
+
upiId: a.upiId,
|
|
445
|
+
payeeName: a.payeeName,
|
|
446
|
+
isMerchantEnabled: a.isMerchantEnabled,
|
|
447
|
+
isActive: a.isActive,
|
|
448
|
+
isSlotBusy: a.isSlotBusy,
|
|
449
|
+
createdAt: a.createdAt,
|
|
450
|
+
updatedAt: a.updatedAt
|
|
451
|
+
}))
|
|
452
|
+
});
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
// POST /api/v1/admin/upi-accounts (Add a new VPA)
|
|
456
|
+
router.post('/api/v1/admin/upi-accounts', async (req, res) => {
|
|
457
|
+
const body = req.body || {};
|
|
458
|
+
const { upiId, payeeName, isActive } = body;
|
|
459
|
+
let merchant = resolveMerchant(req, body.merchantId);
|
|
460
|
+
|
|
461
|
+
if (!merchant) {
|
|
462
|
+
res.status(404).json({ error: 'Merchant not found' });
|
|
463
|
+
return;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
if (!upiId || !upiId.trim()) {
|
|
467
|
+
res.status(400).json({ error: 'UPI ID (VPA) is required.' });
|
|
468
|
+
return;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
if (!isValidVpa(upiId.trim())) {
|
|
472
|
+
res.status(400).json({ error: `Invalid UPI VPA format: "${upiId}". Expected: username@bank` });
|
|
473
|
+
return;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
const existingAccounts = ctx.accountRepo.findByMerchantId(merchant.id);
|
|
477
|
+
const shouldActivate = isActive !== undefined ? Boolean(isActive) : (existingAccounts.length === 0);
|
|
478
|
+
|
|
479
|
+
const account = ctx.accountRepo.create({
|
|
480
|
+
merchantId: merchant.id,
|
|
481
|
+
upiId: upiId.trim(),
|
|
482
|
+
payeeName: payeeName?.trim() || merchant.businessName,
|
|
483
|
+
isMerchantEnabled: true,
|
|
484
|
+
isActive: shouldActivate
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
ctx.auditRepo.log({
|
|
488
|
+
merchantId: merchant.id,
|
|
489
|
+
entityType: 'PAYMENT_RECEIVING_ACCOUNT',
|
|
490
|
+
entityId: account.id,
|
|
491
|
+
action: 'RECEIVING_UPI_ADDED',
|
|
492
|
+
actorType: AuditActorType.ADMIN,
|
|
493
|
+
details: {
|
|
494
|
+
upiId: account.upiId,
|
|
495
|
+
payeeName: account.payeeName,
|
|
496
|
+
isActive: account.isActive
|
|
497
|
+
}
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
res.status(201).json({
|
|
501
|
+
success: true,
|
|
502
|
+
account
|
|
503
|
+
});
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
// POST /api/v1/admin/upi-accounts/:id/activate (Atomically activate VPA, deactivating all others)
|
|
507
|
+
router.post('/api/v1/admin/upi-accounts/:id/activate', async (req, res) => {
|
|
508
|
+
const account = ctx.accountRepo.findById(req.params.id);
|
|
509
|
+
if (!account) {
|
|
510
|
+
res.status(404).json({ error: `UPI Account not found: ${req.params.id}` });
|
|
511
|
+
return;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
const updated = ctx.accountRepo.activateAccount(account.merchantId, account.id);
|
|
515
|
+
|
|
516
|
+
ctx.auditRepo.log({
|
|
517
|
+
merchantId: account.merchantId,
|
|
518
|
+
entityType: 'PAYMENT_RECEIVING_ACCOUNT',
|
|
519
|
+
entityId: updated.id,
|
|
520
|
+
action: 'RECEIVING_UPI_ACTIVATED',
|
|
521
|
+
actorType: AuditActorType.ADMIN,
|
|
522
|
+
details: {
|
|
523
|
+
upiId: updated.upiId,
|
|
524
|
+
payeeName: updated.payeeName,
|
|
525
|
+
isMerchantEnabled: updated.isMerchantEnabled
|
|
526
|
+
}
|
|
527
|
+
});
|
|
528
|
+
|
|
529
|
+
res.status(200).json({
|
|
530
|
+
success: true,
|
|
531
|
+
activeAccount: updated
|
|
532
|
+
});
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
// POST /api/v1/admin/upi-accounts/:id/toggle (Toggle VPA active state)
|
|
536
|
+
router.post('/api/v1/admin/upi-accounts/:id/toggle', async (req, res) => {
|
|
537
|
+
const account = ctx.accountRepo.findById(req.params.id);
|
|
538
|
+
if (!account) {
|
|
539
|
+
res.status(404).json({ error: `UPI Account not found: ${req.params.id}` });
|
|
540
|
+
return;
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
let updated;
|
|
544
|
+
if (account.isActive) {
|
|
545
|
+
updated = ctx.accountRepo.deactivateAccount(account.merchantId, account.id);
|
|
546
|
+
} else {
|
|
547
|
+
updated = ctx.accountRepo.activateAccount(account.merchantId, account.id);
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
ctx.auditRepo.log({
|
|
551
|
+
merchantId: account.merchantId,
|
|
552
|
+
entityType: 'PAYMENT_RECEIVING_ACCOUNT',
|
|
553
|
+
entityId: updated.id,
|
|
554
|
+
action: updated.isActive ? 'RECEIVING_UPI_ACTIVATED' : 'RECEIVING_UPI_DEACTIVATED',
|
|
555
|
+
actorType: AuditActorType.ADMIN,
|
|
556
|
+
details: { upiId: updated.upiId, isActive: updated.isActive }
|
|
557
|
+
});
|
|
558
|
+
|
|
559
|
+
res.status(200).json({
|
|
560
|
+
success: true,
|
|
561
|
+
account: updated
|
|
562
|
+
});
|
|
563
|
+
});
|
|
564
|
+
|
|
565
|
+
// DELETE /api/v1/admin/upi-accounts/:id (Delete VPA)
|
|
566
|
+
router.delete('/api/v1/admin/upi-accounts/:id', async (req, res) => {
|
|
567
|
+
const account = ctx.accountRepo.findById(req.params.id);
|
|
568
|
+
if (!account) {
|
|
569
|
+
res.status(404).json({ error: `UPI Account not found: ${req.params.id}` });
|
|
570
|
+
return;
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
try {
|
|
574
|
+
ctx.accountRepo.deleteAccount(account.merchantId, account.id);
|
|
575
|
+
ctx.auditRepo.log({
|
|
576
|
+
merchantId: account.merchantId,
|
|
577
|
+
entityType: 'PAYMENT_RECEIVING_ACCOUNT',
|
|
578
|
+
entityId: account.id,
|
|
579
|
+
action: 'RECEIVING_UPI_DELETED',
|
|
580
|
+
actorType: AuditActorType.ADMIN,
|
|
581
|
+
details: { upiId: account.upiId }
|
|
582
|
+
});
|
|
583
|
+
res.status(200).json({ success: true, deletedId: account.id });
|
|
584
|
+
} catch (err) {
|
|
585
|
+
res.status(400).json({ error: err.message });
|
|
586
|
+
}
|
|
587
|
+
});
|
|
588
|
+
|
|
589
|
+
// POST /api/v1/admin/upi-settings (Custom UPI ID addition & persistence)
|
|
590
|
+
router.post('/api/v1/admin/upi-settings', async (req, res) => {
|
|
591
|
+
const body = req.body || {};
|
|
592
|
+
const { upiId, payeeName, isMerchantEnabled } = body;
|
|
593
|
+
let merchant = resolveMerchant(req, body.merchantId);
|
|
594
|
+
|
|
595
|
+
if (!merchant) {
|
|
596
|
+
res.status(404).json({ error: 'Merchant not found' });
|
|
597
|
+
return;
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
if (!upiId || !upiId.trim()) {
|
|
601
|
+
res.status(400).json({ error: 'UPI ID (VPA) is required.' });
|
|
602
|
+
return;
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
if (!isValidVpa(upiId.trim())) {
|
|
606
|
+
res.status(400).json({ error: `Invalid UPI VPA format: "${upiId}". Expected format: username@bank` });
|
|
607
|
+
return;
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
const isEnabled = isMerchantEnabled !== undefined ? Boolean(isMerchantEnabled) : true;
|
|
611
|
+
const updatedAccount = ctx.accountRepo.upsertActiveForMerchant(
|
|
612
|
+
merchant.id,
|
|
613
|
+
upiId.trim(),
|
|
614
|
+
payeeName?.trim() || merchant.businessName,
|
|
615
|
+
isEnabled
|
|
616
|
+
);
|
|
617
|
+
|
|
618
|
+
ctx.auditRepo.log({
|
|
619
|
+
merchantId: merchant.id,
|
|
620
|
+
entityType: 'PAYMENT_RECEIVING_ACCOUNT',
|
|
621
|
+
entityId: updatedAccount.id,
|
|
622
|
+
action: 'RECEIVING_UPI_UPDATED',
|
|
623
|
+
actorType: AuditActorType.ADMIN,
|
|
624
|
+
details: {
|
|
625
|
+
upiId: updatedAccount.upiId,
|
|
626
|
+
payeeName: updatedAccount.payeeName,
|
|
627
|
+
isMerchantEnabled: updatedAccount.isMerchantEnabled
|
|
628
|
+
}
|
|
629
|
+
});
|
|
630
|
+
|
|
631
|
+
res.status(200).json({
|
|
632
|
+
success: true,
|
|
633
|
+
receivingAccount: {
|
|
634
|
+
id: updatedAccount.id,
|
|
635
|
+
upiId: updatedAccount.upiId,
|
|
636
|
+
payeeName: updatedAccount.payeeName,
|
|
637
|
+
isMerchantEnabled: updatedAccount.isMerchantEnabled,
|
|
638
|
+
isActive: updatedAccount.isActive
|
|
639
|
+
}
|
|
640
|
+
});
|
|
641
|
+
});
|
|
642
|
+
|
|
643
|
+
// GET /api/v1/admin/all-settings (Unified Settings Read)
|
|
644
|
+
router.get('/api/v1/admin/all-settings', async (req, res) => {
|
|
645
|
+
let merchant = resolveMerchant(req);
|
|
646
|
+
|
|
647
|
+
if (!merchant) {
|
|
648
|
+
res.status(404).json({ error: 'Merchant not found' });
|
|
649
|
+
return;
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
const account = ctx.accountRepo.findActiveForMerchant(merchant.id);
|
|
653
|
+
const branding = merchant.branding || {};
|
|
654
|
+
const modeResult = resolveSellingModeAndMethods({}, branding);
|
|
655
|
+
const sellingMode = modeResult.sellingMode;
|
|
656
|
+
const enabledPaymentMethods = modeResult.enabledPaymentMethods;
|
|
657
|
+
const supportEmails = branding.supportEmails || (merchant.contactEmail ? [merchant.contactEmail] : []);
|
|
658
|
+
const supportMobiles = branding.supportMobiles || (merchant.contactPhone ? [merchant.contactPhone] : []);
|
|
659
|
+
const privacyPolicyUrl = branding.privacyPolicyUrl || '';
|
|
660
|
+
|
|
661
|
+
res.status(200).json({
|
|
662
|
+
merchantId: merchant.id,
|
|
663
|
+
businessName: merchant.businessName,
|
|
664
|
+
upiId: account ? account.upiId : '',
|
|
665
|
+
payeeName: account ? account.payeeName : merchant.businessName,
|
|
666
|
+
webhookUrl: merchant.webhookUrl || '',
|
|
667
|
+
webhookSecret: merchant.webhookSecret || '',
|
|
668
|
+
supportEmails,
|
|
669
|
+
supportMobiles,
|
|
670
|
+
privacyPolicyUrl,
|
|
671
|
+
sellingMode,
|
|
672
|
+
branding: {
|
|
673
|
+
appName: branding.appName || 'ELPAY PG',
|
|
674
|
+
primaryColor: branding.primaryColor || '#2563eb',
|
|
675
|
+
headerTheme: branding.headerTheme || 'gradient',
|
|
676
|
+
logoUrl: branding.logoUrl || null,
|
|
677
|
+
sidebarNotice: branding.sidebarNotice || 'Fast & Secure 256-Bit Encrypted Payments',
|
|
678
|
+
subbannerText: branding.subbannerText || null,
|
|
679
|
+
trustBadgeText: branding.trustBadgeText || null,
|
|
680
|
+
hideTrustBadge: Boolean(branding.hideTrustBadge),
|
|
681
|
+
hideSubbanner: Boolean(branding.hideSubbanner),
|
|
682
|
+
hidePoweredBy: Boolean(branding.hidePoweredBy),
|
|
683
|
+
hideTimeSavingBanner: Boolean(branding.hideTimeSavingBanner),
|
|
684
|
+
footerGuarantee: branding.footerGuarantee || 'Secured by ELPAY PG 256-Bit Encryption',
|
|
685
|
+
counterInstructions: branding.counterInstructions || 'Please visit the store billing counter and present this screen to the cashier.',
|
|
686
|
+
cardCounterInstructions: branding.cardCounterInstructions || 'Please swipe, insert, or tap your credit or debit card on the store POS terminal at the billing desk.',
|
|
687
|
+
termsUrl: branding.termsUrl || null,
|
|
688
|
+
sellingMode,
|
|
689
|
+
enabledPaymentMethods,
|
|
690
|
+
supportEmails,
|
|
691
|
+
supportMobiles,
|
|
692
|
+
privacyPolicyUrl
|
|
693
|
+
}
|
|
694
|
+
});
|
|
695
|
+
});
|
|
696
|
+
|
|
697
|
+
// POST /api/v1/admin/all-settings (Atomic Unified Settings Write)
|
|
698
|
+
router.post('/api/v1/admin/all-settings', async (req, res) => {
|
|
699
|
+
const body = req.body || {};
|
|
700
|
+
let merchant = resolveMerchant(req, body.merchantId);
|
|
701
|
+
|
|
702
|
+
if (!merchant) {
|
|
703
|
+
res.status(404).json({ error: 'Merchant not found' });
|
|
704
|
+
return;
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
// 1. Update UPI VPA if provided
|
|
708
|
+
let account = ctx.accountRepo.findActiveForMerchant(merchant.id);
|
|
709
|
+
if (body.upiId && body.upiId.trim()) {
|
|
710
|
+
if (!isValidVpa(body.upiId.trim())) {
|
|
711
|
+
res.status(400).json({ error: `Invalid UPI VPA format: "${body.upiId}". Expected: username@bank` });
|
|
712
|
+
return;
|
|
713
|
+
}
|
|
714
|
+
account = ctx.accountRepo.upsertActiveForMerchant(merchant.id, body.upiId.trim(), body.payeeName?.trim() || body.businessName?.trim() || merchant.businessName);
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
// 2. Non-Destructive Deep Merge for Branding & Merchant Properties
|
|
718
|
+
const updates = {};
|
|
719
|
+
if (body.businessName && body.businessName.trim()) {
|
|
720
|
+
updates.businessName = body.businessName.trim();
|
|
721
|
+
}
|
|
722
|
+
if (body.webhookUrl !== undefined) {
|
|
723
|
+
const check = validateOutboundWebhookUrl(body.webhookUrl);
|
|
724
|
+
if (!check.valid && String(body.webhookUrl || '').trim()) {
|
|
725
|
+
res.status(400).json({ error: check.error });
|
|
726
|
+
return;
|
|
727
|
+
}
|
|
728
|
+
updates.webhookUrl = body.webhookUrl?.trim() || null;
|
|
729
|
+
}
|
|
730
|
+
if (body.webhookSecret !== undefined && body.webhookSecret.trim()) {
|
|
731
|
+
updates.webhookSecret = body.webhookSecret.trim();
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
const currentBranding = merchant.branding || {};
|
|
735
|
+
const inputBranding = body.branding || {};
|
|
736
|
+
const mergedBranding = { ...currentBranding };
|
|
737
|
+
|
|
738
|
+
if (inputBranding.appName !== undefined) mergedBranding.appName = inputBranding.appName.trim() || currentBranding.appName || 'ELPAY PG';
|
|
739
|
+
if (inputBranding.logoUrl !== undefined) mergedBranding.logoUrl = inputBranding.logoUrl ? inputBranding.logoUrl.trim() : null;
|
|
740
|
+
if (inputBranding.primaryColor !== undefined) mergedBranding.primaryColor = inputBranding.primaryColor.trim() || currentBranding.primaryColor || '#2563eb';
|
|
741
|
+
if (inputBranding.sidebarNotice !== undefined) mergedBranding.sidebarNotice = inputBranding.sidebarNotice ? inputBranding.sidebarNotice.trim() : null;
|
|
742
|
+
if (inputBranding.headerTheme !== undefined) mergedBranding.headerTheme = inputBranding.headerTheme || 'gradient';
|
|
743
|
+
if (inputBranding.subbannerText !== undefined) mergedBranding.subbannerText = inputBranding.subbannerText ? inputBranding.subbannerText.trim() : null;
|
|
744
|
+
if (inputBranding.trustBadgeText !== undefined) mergedBranding.trustBadgeText = inputBranding.trustBadgeText ? inputBranding.trustBadgeText.trim() : null;
|
|
745
|
+
if (inputBranding.hideTrustBadge !== undefined) mergedBranding.hideTrustBadge = Boolean(inputBranding.hideTrustBadge);
|
|
746
|
+
if (inputBranding.hideSubbanner !== undefined) mergedBranding.hideSubbanner = Boolean(inputBranding.hideSubbanner);
|
|
747
|
+
if (inputBranding.hidePoweredBy !== undefined) mergedBranding.hidePoweredBy = Boolean(inputBranding.hidePoweredBy);
|
|
748
|
+
if (inputBranding.hideTimeSavingBanner !== undefined) mergedBranding.hideTimeSavingBanner = Boolean(inputBranding.hideTimeSavingBanner);
|
|
749
|
+
if (inputBranding.checkoutNotice !== undefined) mergedBranding.checkoutNotice = inputBranding.checkoutNotice ? inputBranding.checkoutNotice.trim() : null;
|
|
750
|
+
if (inputBranding.footerGuarantee !== undefined) mergedBranding.footerGuarantee = inputBranding.footerGuarantee ? inputBranding.footerGuarantee.trim() : null;
|
|
751
|
+
if (inputBranding.counterInstructions !== undefined) mergedBranding.counterInstructions = inputBranding.counterInstructions ? inputBranding.counterInstructions.trim() : null;
|
|
752
|
+
if (inputBranding.cardCounterInstructions !== undefined) mergedBranding.cardCounterInstructions = inputBranding.cardCounterInstructions ? inputBranding.cardCounterInstructions.trim() : null;
|
|
753
|
+
if (inputBranding.termsUrl !== undefined) mergedBranding.termsUrl = inputBranding.termsUrl ? inputBranding.termsUrl.trim() : null;
|
|
754
|
+
|
|
755
|
+
if (body.enabledPaymentMethods || inputBranding.enabledPaymentMethods || body.sellingMode || inputBranding.sellingMode || body.codEnabled !== undefined || body.cashEnabled !== undefined || body.cardEnabled !== undefined) {
|
|
756
|
+
const modeResult = resolveSellingModeAndMethods(body, currentBranding);
|
|
757
|
+
mergedBranding.sellingMode = modeResult.sellingMode;
|
|
758
|
+
mergedBranding.enabledPaymentMethods = modeResult.enabledPaymentMethods;
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
if (body.supportEmails !== undefined || inputBranding.supportEmails !== undefined) {
|
|
762
|
+
const raw = body.supportEmails !== undefined ? body.supportEmails : inputBranding.supportEmails;
|
|
763
|
+
mergedBranding.supportEmails = Array.isArray(raw)
|
|
764
|
+
? raw.map(s => String(s).trim()).filter(Boolean)
|
|
765
|
+
: (typeof raw === 'string' ? raw.split(/[,\n]+/).map(s => s.trim()).filter(Boolean) : []);
|
|
766
|
+
}
|
|
767
|
+
if (body.supportMobiles !== undefined || inputBranding.supportMobiles !== undefined) {
|
|
768
|
+
const raw = body.supportMobiles !== undefined ? body.supportMobiles : inputBranding.supportMobiles;
|
|
769
|
+
mergedBranding.supportMobiles = Array.isArray(raw)
|
|
770
|
+
? raw.map(s => String(s).trim()).filter(Boolean)
|
|
771
|
+
: (typeof raw === 'string' ? raw.split(/[,\n]+/).map(s => s.trim()).filter(Boolean) : []);
|
|
772
|
+
}
|
|
773
|
+
if (body.privacyPolicyUrl !== undefined || inputBranding.privacyPolicyUrl !== undefined) {
|
|
774
|
+
const raw = body.privacyPolicyUrl !== undefined ? body.privacyPolicyUrl : inputBranding.privacyPolicyUrl;
|
|
775
|
+
mergedBranding.privacyPolicyUrl = raw ? String(raw).trim() : null;
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
if (body.envMode !== undefined || inputBranding.envMode !== undefined) {
|
|
779
|
+
mergedBranding.envMode = (body.envMode || inputBranding.envMode || 'localhost').toLowerCase();
|
|
780
|
+
}
|
|
781
|
+
if (body.domainPrefix !== undefined || inputBranding.domainPrefix !== undefined) {
|
|
782
|
+
mergedBranding.domainPrefix = (body.domainPrefix || inputBranding.domainPrefix || 'pay').trim().toLowerCase();
|
|
783
|
+
}
|
|
784
|
+
if (body.domainTld !== undefined || inputBranding.domainTld !== undefined) {
|
|
785
|
+
mergedBranding.domainTld = (body.domainTld || inputBranding.domainTld || '').trim().toLowerCase();
|
|
786
|
+
}
|
|
787
|
+
if (body.customDomain !== undefined || inputBranding.customDomain !== undefined) {
|
|
788
|
+
mergedBranding.customDomain = (body.customDomain || inputBranding.customDomain || '').trim();
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
updates.branding = mergedBranding;
|
|
792
|
+
|
|
793
|
+
const updated = ctx.merchantRepo.update(merchant.id, updates);
|
|
794
|
+
|
|
795
|
+
ctx.auditRepo.log({
|
|
796
|
+
merchantId: merchant.id,
|
|
797
|
+
entityType: 'MERCHANT',
|
|
798
|
+
entityId: merchant.id,
|
|
799
|
+
action: 'ALL_SETTINGS_UPDATED_ATOMIC',
|
|
800
|
+
actorType: AuditActorType.ADMIN,
|
|
801
|
+
details: { businessName: updated.businessName, upiId: account?.upiId, branding: updated.branding }
|
|
802
|
+
});
|
|
803
|
+
|
|
804
|
+
res.status(200).json({
|
|
805
|
+
success: true,
|
|
806
|
+
merchantId: updated.id,
|
|
807
|
+
businessName: updated.businessName,
|
|
808
|
+
upiId: account ? account.upiId : '',
|
|
809
|
+
payeeName: account ? account.payeeName : updated.businessName,
|
|
810
|
+
webhookUrl: updated.webhookUrl || '',
|
|
811
|
+
webhookSecret: updated.webhookSecret || '',
|
|
812
|
+
supportEmails: updated.branding?.supportEmails || [],
|
|
813
|
+
supportMobiles: updated.branding?.supportMobiles || [],
|
|
814
|
+
privacyPolicyUrl: updated.branding?.privacyPolicyUrl || '',
|
|
815
|
+
sellingMode: updated.branding?.sellingMode || 'ONLINE',
|
|
816
|
+
enabledPaymentMethods: updated.branding?.enabledPaymentMethods || ['UPI'],
|
|
817
|
+
codEnabled: (updated.branding?.enabledPaymentMethods || []).includes('COD'),
|
|
818
|
+
cashEnabled: (updated.branding?.enabledPaymentMethods || []).includes('CASH_COUNTER'),
|
|
819
|
+
cardEnabled: (updated.branding?.enabledPaymentMethods || []).includes('CARD_COUNTER'),
|
|
820
|
+
branding: updated.branding
|
|
821
|
+
});
|
|
822
|
+
});
|
|
823
|
+
|
|
824
|
+
// GET /api/v1/admin/merchant-settings
|
|
825
|
+
router.get('/api/v1/admin/merchant-settings', async (req, res) => {
|
|
826
|
+
let merchant = req.query.merchantId ? ctx.merchantRepo.findById(req.query.merchantId) : (req.headers['x-merchant-id'] ? ctx.merchantRepo.findById(req.headers['x-merchant-id']) : null);
|
|
827
|
+
if (!merchant) merchant = ctx.merchantRepo.getDefault ? ctx.merchantRepo.getDefault() : (ctx.merchantRepo.findById('00000000-0000-0000-0000-000000000001') || ctx.merchantRepo.findAll()[0]);
|
|
828
|
+
|
|
829
|
+
if (!merchant) {
|
|
830
|
+
res.status(404).json({ error: 'Merchant not found' });
|
|
831
|
+
return;
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
const branding = merchant.branding || {};
|
|
835
|
+
const modeResult = resolveSellingModeAndMethods({}, branding);
|
|
836
|
+
const sellingMode = modeResult.sellingMode;
|
|
837
|
+
const enabledPaymentMethods = modeResult.enabledPaymentMethods;
|
|
838
|
+
const supportEmails = branding.supportEmails || (merchant.contactEmail ? [merchant.contactEmail] : []);
|
|
839
|
+
const supportMobiles = branding.supportMobiles || (merchant.contactPhone ? [merchant.contactPhone] : []);
|
|
840
|
+
const privacyPolicyUrl = branding.privacyPolicyUrl || '';
|
|
841
|
+
|
|
842
|
+
const envMode = branding.envMode || 'localhost';
|
|
843
|
+
const domainPrefix = (branding.deploymentPrefix || branding.domainPrefix || 'pay').trim().toLowerCase();
|
|
844
|
+
const domainTld = (branding.cloudflareDomain || branding.domainTld || '').trim().toLowerCase();
|
|
845
|
+
const cloudflareDomain = domainTld;
|
|
846
|
+
const deploymentPrefix = domainPrefix;
|
|
847
|
+
const customDomain = branding.customDomain || (cloudflareDomain ? `https://${deploymentPrefix}.${cloudflareDomain}` : '');
|
|
848
|
+
const cloudflareTunnelCommand = branding.cloudflareTunnelCommand || '';
|
|
849
|
+
const tunnelStatus = globalTunnelManager.getStatus();
|
|
850
|
+
|
|
851
|
+
const routes = deriveCloudflareRoutes({ domain: cloudflareDomain, prefix: deploymentPrefix });
|
|
852
|
+
let runtimeUrls = {};
|
|
853
|
+
try {
|
|
854
|
+
runtimeUrls = resolveRuntimeUrls({ envMode, domain: cloudflareDomain, prefix: deploymentPrefix });
|
|
855
|
+
} catch {
|
|
856
|
+
runtimeUrls = resolveRuntimeUrls({ envMode: 'localhost', domain: cloudflareDomain, prefix: deploymentPrefix });
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
const codEnabled = branding.codEnabled !== undefined ? Boolean(branding.codEnabled) : enabledPaymentMethods.includes('COD');
|
|
860
|
+
const cashEnabled = branding.cashEnabled !== undefined ? Boolean(branding.cashEnabled) : enabledPaymentMethods.includes('CASH_COUNTER');
|
|
861
|
+
const cardEnabled = branding.cardEnabled !== undefined ? Boolean(branding.cardEnabled) : enabledPaymentMethods.includes('CARD_COUNTER');
|
|
862
|
+
|
|
863
|
+
res.status(200).json({
|
|
864
|
+
merchantId: merchant.id,
|
|
865
|
+
businessName: merchant.businessName,
|
|
866
|
+
contactEmail: merchant.contactEmail,
|
|
867
|
+
contactPhone: merchant.contactPhone,
|
|
868
|
+
webhookUrl: merchant.webhookUrl,
|
|
869
|
+
webhookSecret: merchant.webhookSecret,
|
|
870
|
+
supportEmails,
|
|
871
|
+
supportMobiles,
|
|
872
|
+
privacyPolicyUrl,
|
|
873
|
+
sellingMode,
|
|
874
|
+
enabledPaymentMethods,
|
|
875
|
+
codEnabled,
|
|
876
|
+
cashEnabled,
|
|
877
|
+
cardEnabled,
|
|
878
|
+
envMode,
|
|
879
|
+
domainPrefix,
|
|
880
|
+
domainTld,
|
|
881
|
+
cloudflareDomain,
|
|
882
|
+
deploymentPrefix,
|
|
883
|
+
customDomain,
|
|
884
|
+
routes,
|
|
885
|
+
runtimeUrls,
|
|
886
|
+
cloudflareTunnelCommand,
|
|
887
|
+
tunnelStatus,
|
|
888
|
+
branding: {
|
|
889
|
+
...branding,
|
|
890
|
+
sellingMode,
|
|
891
|
+
enabledPaymentMethods,
|
|
892
|
+
codEnabled,
|
|
893
|
+
cashEnabled,
|
|
894
|
+
cardEnabled,
|
|
895
|
+
supportEmails,
|
|
896
|
+
supportMobiles,
|
|
897
|
+
privacyPolicyUrl,
|
|
898
|
+
envMode,
|
|
899
|
+
domainPrefix,
|
|
900
|
+
domainTld,
|
|
901
|
+
cloudflareDomain,
|
|
902
|
+
deploymentPrefix,
|
|
903
|
+
customDomain,
|
|
904
|
+
routes,
|
|
905
|
+
runtimeUrls,
|
|
906
|
+
cloudflareTunnelCommand
|
|
907
|
+
}
|
|
908
|
+
});
|
|
909
|
+
});
|
|
910
|
+
|
|
911
|
+
// POST /api/v1/admin/merchant-settings
|
|
912
|
+
router.post('/api/v1/admin/merchant-settings', async (req, res) => {
|
|
913
|
+
const body = req.body || {};
|
|
914
|
+
let merchant = body.merchantId ? ctx.merchantRepo.findById(body.merchantId) : (req.headers['x-merchant-id'] ? ctx.merchantRepo.findById(req.headers['x-merchant-id']) : null);
|
|
915
|
+
if (!merchant) merchant = ctx.merchantRepo.getDefault ? ctx.merchantRepo.getDefault() : (ctx.merchantRepo.findById('00000000-0000-0000-0000-000000000001') || ctx.merchantRepo.findAll()[0]);
|
|
916
|
+
|
|
917
|
+
if (!merchant) {
|
|
918
|
+
res.status(404).json({ error: 'Merchant not found' });
|
|
919
|
+
return;
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
const inputBranding = body.branding || {};
|
|
923
|
+
const updates = {};
|
|
924
|
+
if (body.businessName && body.businessName.trim()) updates.businessName = body.businessName.trim();
|
|
925
|
+
else if (inputBranding.businessName && inputBranding.businessName.trim()) updates.businessName = inputBranding.businessName.trim();
|
|
926
|
+
if (body.webhookUrl !== undefined) {
|
|
927
|
+
const check = validateOutboundWebhookUrl(body.webhookUrl);
|
|
928
|
+
if (!check.valid && String(body.webhookUrl || '').trim()) {
|
|
929
|
+
res.status(400).json({ error: check.error });
|
|
930
|
+
return;
|
|
931
|
+
}
|
|
932
|
+
updates.webhookUrl = body.webhookUrl?.trim() || null;
|
|
933
|
+
} else if (inputBranding.webhookUrl !== undefined) updates.webhookUrl = inputBranding.webhookUrl?.trim() || null;
|
|
934
|
+
if (body.webhookSecret !== undefined && body.webhookSecret.trim()) updates.webhookSecret = body.webhookSecret.trim();
|
|
935
|
+
|
|
936
|
+
if (
|
|
937
|
+
body.branding ||
|
|
938
|
+
body.enabledPaymentMethods ||
|
|
939
|
+
body.sellingMode ||
|
|
940
|
+
body.codEnabled !== undefined ||
|
|
941
|
+
body.cashEnabled !== undefined ||
|
|
942
|
+
body.cardEnabled !== undefined ||
|
|
943
|
+
body.supportEmails !== undefined ||
|
|
944
|
+
body.supportMobiles !== undefined ||
|
|
945
|
+
body.privacyPolicyUrl !== undefined ||
|
|
946
|
+
body.envMode !== undefined ||
|
|
947
|
+
body.domainPrefix !== undefined ||
|
|
948
|
+
body.domainTld !== undefined ||
|
|
949
|
+
body.customDomain !== undefined ||
|
|
950
|
+
body.cloudflareTunnelCommand !== undefined
|
|
951
|
+
) {
|
|
952
|
+
const currentBranding = merchant.branding || {};
|
|
953
|
+
const mergedBranding = { ...currentBranding };
|
|
954
|
+
|
|
955
|
+
if (inputBranding.appName !== undefined) mergedBranding.appName = inputBranding.appName.trim() || currentBranding.appName || 'ELPAY PG';
|
|
956
|
+
if (inputBranding.logoUrl !== undefined) mergedBranding.logoUrl = inputBranding.logoUrl ? inputBranding.logoUrl.trim() : null;
|
|
957
|
+
if (inputBranding.primaryColor !== undefined) mergedBranding.primaryColor = inputBranding.primaryColor.trim() || currentBranding.primaryColor || '#2563eb';
|
|
958
|
+
if (inputBranding.headerTheme !== undefined) mergedBranding.headerTheme = inputBranding.headerTheme || 'gradient';
|
|
959
|
+
if (inputBranding.sidebarNotice !== undefined) mergedBranding.sidebarNotice = inputBranding.sidebarNotice ? inputBranding.sidebarNotice.trim() : null;
|
|
960
|
+
if (inputBranding.subbannerText !== undefined) mergedBranding.subbannerText = inputBranding.subbannerText ? inputBranding.subbannerText.trim() : null;
|
|
961
|
+
if (inputBranding.trustBadgeText !== undefined) mergedBranding.trustBadgeText = inputBranding.trustBadgeText ? inputBranding.trustBadgeText.trim() : null;
|
|
962
|
+
if (inputBranding.hideTrustBadge !== undefined) mergedBranding.hideTrustBadge = Boolean(inputBranding.hideTrustBadge);
|
|
963
|
+
if (inputBranding.hideSubbanner !== undefined) mergedBranding.hideSubbanner = Boolean(inputBranding.hideSubbanner);
|
|
964
|
+
if (inputBranding.hidePoweredBy !== undefined) mergedBranding.hidePoweredBy = Boolean(inputBranding.hidePoweredBy);
|
|
965
|
+
if (inputBranding.hideTimeSavingBanner !== undefined) mergedBranding.hideTimeSavingBanner = Boolean(inputBranding.hideTimeSavingBanner);
|
|
966
|
+
if (inputBranding.checkoutNotice !== undefined) mergedBranding.checkoutNotice = inputBranding.checkoutNotice ? inputBranding.checkoutNotice.trim() : null;
|
|
967
|
+
if (inputBranding.footerGuarantee !== undefined) mergedBranding.footerGuarantee = inputBranding.footerGuarantee ? inputBranding.footerGuarantee.trim() : null;
|
|
968
|
+
if (inputBranding.counterInstructions !== undefined) mergedBranding.counterInstructions = inputBranding.counterInstructions ? inputBranding.counterInstructions.trim() : null;
|
|
969
|
+
if (inputBranding.cardCounterInstructions !== undefined) mergedBranding.cardCounterInstructions = inputBranding.cardCounterInstructions ? inputBranding.cardCounterInstructions.trim() : null;
|
|
970
|
+
if (inputBranding.termsUrl !== undefined) mergedBranding.termsUrl = inputBranding.termsUrl ? inputBranding.termsUrl.trim() : null;
|
|
971
|
+
|
|
972
|
+
if (
|
|
973
|
+
body.sellingMode !== undefined ||
|
|
974
|
+
body.enabledPaymentMethods !== undefined ||
|
|
975
|
+
body.codEnabled !== undefined ||
|
|
976
|
+
body.cashEnabled !== undefined ||
|
|
977
|
+
body.cardEnabled !== undefined ||
|
|
978
|
+
inputBranding.sellingMode !== undefined ||
|
|
979
|
+
inputBranding.enabledPaymentMethods !== undefined ||
|
|
980
|
+
inputBranding.codEnabled !== undefined ||
|
|
981
|
+
inputBranding.cashEnabled !== undefined ||
|
|
982
|
+
inputBranding.cardEnabled !== undefined
|
|
983
|
+
) {
|
|
984
|
+
const modeResult = resolveSellingModeAndMethods(body, currentBranding);
|
|
985
|
+
mergedBranding.sellingMode = modeResult.sellingMode;
|
|
986
|
+
mergedBranding.enabledPaymentMethods = modeResult.enabledPaymentMethods;
|
|
987
|
+
mergedBranding.codEnabled = modeResult.codEnabled;
|
|
988
|
+
mergedBranding.cashEnabled = modeResult.cashEnabled;
|
|
989
|
+
mergedBranding.cardEnabled = modeResult.cardEnabled;
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
if (body.supportEmails !== undefined || inputBranding.supportEmails !== undefined) {
|
|
993
|
+
const raw = body.supportEmails !== undefined ? body.supportEmails : inputBranding.supportEmails;
|
|
994
|
+
mergedBranding.supportEmails = Array.isArray(raw)
|
|
995
|
+
? raw.map(s => String(s).trim()).filter(Boolean)
|
|
996
|
+
: (typeof raw === 'string' ? raw.split(/[,\n]+/).map(s => s.trim()).filter(Boolean) : []);
|
|
997
|
+
}
|
|
998
|
+
if (body.supportMobiles !== undefined || inputBranding.supportMobiles !== undefined) {
|
|
999
|
+
const raw = body.supportMobiles !== undefined ? body.supportMobiles : inputBranding.supportMobiles;
|
|
1000
|
+
mergedBranding.supportMobiles = Array.isArray(raw)
|
|
1001
|
+
? raw.map(s => String(s).trim()).filter(Boolean)
|
|
1002
|
+
: (typeof raw === 'string' ? raw.split(/[,\n]+/).map(s => s.trim()).filter(Boolean) : []);
|
|
1003
|
+
}
|
|
1004
|
+
if (body.privacyPolicyUrl !== undefined || inputBranding.privacyPolicyUrl !== undefined) {
|
|
1005
|
+
const raw = body.privacyPolicyUrl !== undefined ? body.privacyPolicyUrl : inputBranding.privacyPolicyUrl;
|
|
1006
|
+
mergedBranding.privacyPolicyUrl = raw ? String(raw).trim() : null;
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
if (body.envMode !== undefined || inputBranding.envMode !== undefined) {
|
|
1010
|
+
mergedBranding.envMode = (body.envMode || inputBranding.envMode || 'localhost').toLowerCase();
|
|
1011
|
+
}
|
|
1012
|
+
const rawDomain = (body.cloudflareDomain !== undefined ? body.cloudflareDomain : (body.domainTld !== undefined ? body.domainTld : (inputBranding.cloudflareDomain !== undefined ? inputBranding.cloudflareDomain : inputBranding.domainTld)));
|
|
1013
|
+
if (rawDomain !== undefined) {
|
|
1014
|
+
const cleanDomain = String(rawDomain || '').trim().toLowerCase().replace(/^https?:\/\//, '').replace(/\/+$/, '');
|
|
1015
|
+
mergedBranding.cloudflareDomain = cleanDomain;
|
|
1016
|
+
mergedBranding.domainTld = cleanDomain;
|
|
1017
|
+
}
|
|
1018
|
+
const rawPrefix = (body.deploymentPrefix !== undefined ? body.deploymentPrefix : (body.domainPrefix !== undefined ? body.domainPrefix : (inputBranding.deploymentPrefix !== undefined ? inputBranding.deploymentPrefix : inputBranding.domainPrefix)));
|
|
1019
|
+
if (rawPrefix !== undefined) {
|
|
1020
|
+
const cleanPrefix = String(rawPrefix || 'pay').trim().toLowerCase().replace(/[^a-z0-9-_]/g, '') || 'pay';
|
|
1021
|
+
mergedBranding.deploymentPrefix = cleanPrefix;
|
|
1022
|
+
mergedBranding.domainPrefix = cleanPrefix;
|
|
1023
|
+
}
|
|
1024
|
+
const activeDomain = mergedBranding.cloudflareDomain || mergedBranding.domainTld || '';
|
|
1025
|
+
const activePrefix = mergedBranding.deploymentPrefix || mergedBranding.domainPrefix || 'pay';
|
|
1026
|
+
if (activeDomain) {
|
|
1027
|
+
mergedBranding.customDomain = `https://${activePrefix}.${activeDomain}`;
|
|
1028
|
+
} else if (body.customDomain !== undefined || inputBranding.customDomain !== undefined) {
|
|
1029
|
+
mergedBranding.customDomain = (body.customDomain || inputBranding.customDomain || '').trim();
|
|
1030
|
+
}
|
|
1031
|
+
if (body.cloudflareTunnelCommand !== undefined || inputBranding.cloudflareTunnelCommand !== undefined) {
|
|
1032
|
+
mergedBranding.cloudflareTunnelCommand = (body.cloudflareTunnelCommand !== undefined ? body.cloudflareTunnelCommand : inputBranding.cloudflareTunnelCommand)?.trim() || '';
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
updates.branding = mergedBranding;
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
const updated = ctx.merchantRepo.update(merchant.id, updates);
|
|
1039
|
+
|
|
1040
|
+
ctx.auditRepo.log({
|
|
1041
|
+
merchantId: merchant.id,
|
|
1042
|
+
entityType: 'MERCHANT',
|
|
1043
|
+
entityId: merchant.id,
|
|
1044
|
+
action: 'MERCHANT_SETTINGS_UPDATED',
|
|
1045
|
+
actorType: AuditActorType.ADMIN,
|
|
1046
|
+
details: updates
|
|
1047
|
+
});
|
|
1048
|
+
|
|
1049
|
+
res.status(200).json({
|
|
1050
|
+
success: true,
|
|
1051
|
+
merchantId: updated.id,
|
|
1052
|
+
businessName: updated.businessName,
|
|
1053
|
+
webhookUrl: updated.webhookUrl || '',
|
|
1054
|
+
webhookSecret: updated.webhookSecret || '',
|
|
1055
|
+
supportEmails: updated.branding?.supportEmails || [],
|
|
1056
|
+
supportMobiles: updated.branding?.supportMobiles || [],
|
|
1057
|
+
privacyPolicyUrl: updated.branding?.privacyPolicyUrl || '',
|
|
1058
|
+
sellingMode: updated.branding?.sellingMode || 'ONLINE',
|
|
1059
|
+
enabledPaymentMethods: updated.branding?.enabledPaymentMethods || ['UPI'],
|
|
1060
|
+
codEnabled: (updated.branding?.enabledPaymentMethods || []).includes('COD'),
|
|
1061
|
+
cashEnabled: (updated.branding?.enabledPaymentMethods || []).includes('CASH_COUNTER'),
|
|
1062
|
+
cardEnabled: (updated.branding?.enabledPaymentMethods || []).includes('CARD_COUNTER'),
|
|
1063
|
+
envMode: updated.branding?.envMode || 'localhost',
|
|
1064
|
+
domainPrefix: updated.branding?.domainPrefix || 'pay',
|
|
1065
|
+
domainTld: updated.branding?.domainTld || '',
|
|
1066
|
+
customDomain: updated.branding?.customDomain || '',
|
|
1067
|
+
cloudflareTunnelCommand: updated.branding?.cloudflareTunnelCommand || '',
|
|
1068
|
+
tunnelStatus: globalTunnelManager.getStatus(),
|
|
1069
|
+
branding: updated.branding
|
|
1070
|
+
});
|
|
1071
|
+
});
|
|
1072
|
+
|
|
1073
|
+
// GET /api/v1/admin/cod-orders (List Cash on Delivery orders)
|
|
1074
|
+
router.get('/api/v1/admin/cod-orders', async (req, res) => {
|
|
1075
|
+
let merchant = req.query.merchantId ? ctx.merchantRepo.findById(req.query.merchantId) : null;
|
|
1076
|
+
if (!merchant) merchant = ctx.merchantRepo.findAll()[0];
|
|
1077
|
+
|
|
1078
|
+
if (!merchant) {
|
|
1079
|
+
res.status(404).json({ error: 'Merchant not found' });
|
|
1080
|
+
return;
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
const codSessions = ctx.sessionRepo.db.query(
|
|
1084
|
+
`SELECT ps.*, c.name as customer_name, c.mobile_number as customer_mobile, c.email as customer_email
|
|
1085
|
+
FROM payment_sessions ps
|
|
1086
|
+
LEFT JOIN customers c ON ps.customer_id = c.id
|
|
1087
|
+
WHERE ps.merchant_id = ? AND ps.method = 'COD'
|
|
1088
|
+
ORDER BY ps.created_at DESC LIMIT 50`,
|
|
1089
|
+
[merchant.id]
|
|
1090
|
+
);
|
|
1091
|
+
|
|
1092
|
+
const formatted = codSessions.map(r => ({
|
|
1093
|
+
paymentId: r.id,
|
|
1094
|
+
id: r.id,
|
|
1095
|
+
orderId: r.order_id,
|
|
1096
|
+
amount: toRupeesString(r.amount_paise),
|
|
1097
|
+
amountPaise: r.amount_paise.toString(),
|
|
1098
|
+
currency: r.currency,
|
|
1099
|
+
status: r.status,
|
|
1100
|
+
method: r.method,
|
|
1101
|
+
verificationTag: r.verification_tag,
|
|
1102
|
+
customerName: r.customer_name || 'Customer',
|
|
1103
|
+
customerPhone: r.customer_mobile || '',
|
|
1104
|
+
customerEmail: r.customer_email || '',
|
|
1105
|
+
createdAt: r.created_at,
|
|
1106
|
+
customer: r.customer_id ? {
|
|
1107
|
+
id: r.customer_id,
|
|
1108
|
+
name: r.customer_name,
|
|
1109
|
+
mobileNumber: r.customer_mobile,
|
|
1110
|
+
email: r.customer_email
|
|
1111
|
+
} : null
|
|
1112
|
+
}));
|
|
1113
|
+
|
|
1114
|
+
res.status(200).json({
|
|
1115
|
+
total: formatted.length,
|
|
1116
|
+
codOrders: formatted,
|
|
1117
|
+
orders: formatted
|
|
1118
|
+
});
|
|
1119
|
+
});
|
|
1120
|
+
|
|
1121
|
+
// GET /api/v1/admin/stream (SSE for Live Admin Counter & Queue Events)
|
|
1122
|
+
router.get('/api/v1/admin/stream', async (req, res) => {
|
|
1123
|
+
server.initSse('admin_counter_events', res);
|
|
1124
|
+
});
|
|
1125
|
+
|
|
1126
|
+
// --- WHATSAPP BERRY PROTOCOL MERCHANT SESSION CONFIGURATION ---
|
|
1127
|
+
|
|
1128
|
+
// GET /api/v1/admin/whatsapp/status
|
|
1129
|
+
router.get('/api/v1/admin/whatsapp/status', async (req, res) => {
|
|
1130
|
+
const berry = ctx.whatsappBerryService;
|
|
1131
|
+
if (!berry) {
|
|
1132
|
+
res.status(200).json({
|
|
1133
|
+
success: true,
|
|
1134
|
+
status: 'DISCONNECTED',
|
|
1135
|
+
phoneNumber: null,
|
|
1136
|
+
platform: 'BerryProtocol v0.1.10',
|
|
1137
|
+
qrCode: null
|
|
1138
|
+
});
|
|
1139
|
+
return;
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
const info = berry.getClientInfo();
|
|
1143
|
+
let qr = berry.getQrCode();
|
|
1144
|
+
if (!qr && berry.client?.lastQr) {
|
|
1145
|
+
const rawText = berry.client.lastQr;
|
|
1146
|
+
const dataUrl = await formatQrDataUrl(rawText);
|
|
1147
|
+
qr = { raw: rawText, dataUrl };
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
let qrPayload = qr?.dataUrl || (typeof qr === 'string' && qr.startsWith('data:image/') ? qr : null);
|
|
1151
|
+
if (!qrPayload && (qr?.raw || typeof qr === 'string')) {
|
|
1152
|
+
const rawText = qr?.raw || (typeof qr === 'string' ? qr : null);
|
|
1153
|
+
if (rawText) {
|
|
1154
|
+
qrPayload = await formatQrDataUrl(rawText);
|
|
1155
|
+
}
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
res.status(200).json({
|
|
1159
|
+
success: true,
|
|
1160
|
+
status: info.status,
|
|
1161
|
+
phoneNumber: info.phoneNumber,
|
|
1162
|
+
platform: info.platform,
|
|
1163
|
+
sessionId: info.sessionId,
|
|
1164
|
+
isConnected: info.isConnected,
|
|
1165
|
+
qrCode: qrPayload || qr?.dataUrl || qr?.raw || (typeof qr === 'string' ? qr : null),
|
|
1166
|
+
pairingCode: info.pairingCode || null,
|
|
1167
|
+
pairingPhone: info.pairingPhone || null
|
|
1168
|
+
});
|
|
1169
|
+
});
|
|
1170
|
+
|
|
1171
|
+
// GET /api/v1/admin/whatsapp/qr
|
|
1172
|
+
router.get('/api/v1/admin/whatsapp/qr', async (req, res) => {
|
|
1173
|
+
const berry = ctx.whatsappBerryService;
|
|
1174
|
+
if (!berry) {
|
|
1175
|
+
res.status(200).json({ success: false, error: 'Service unavailable' });
|
|
1176
|
+
return;
|
|
1177
|
+
}
|
|
1178
|
+
|
|
1179
|
+
let qr = berry.getQrCode();
|
|
1180
|
+
if (!qr && berry.client?.lastQr) {
|
|
1181
|
+
const rawText = berry.client.lastQr;
|
|
1182
|
+
const dataUrl = await formatQrDataUrl(rawText);
|
|
1183
|
+
qr = { raw: rawText, dataUrl };
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
let qrPayload = qr?.dataUrl || (typeof qr === 'string' && qr.startsWith('data:image/') ? qr : null);
|
|
1187
|
+
if (!qrPayload && (qr?.raw || typeof qr === 'string')) {
|
|
1188
|
+
const rawText = qr?.raw || (typeof qr === 'string' ? qr : null);
|
|
1189
|
+
if (rawText) {
|
|
1190
|
+
qrPayload = await formatQrDataUrl(rawText);
|
|
1191
|
+
}
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
res.status(200).json({
|
|
1195
|
+
success: true,
|
|
1196
|
+
status: berry.getStatus(),
|
|
1197
|
+
qrCode: qrPayload || qr?.dataUrl || qr?.raw || (typeof qr === 'string' ? qr : null),
|
|
1198
|
+
available: Boolean(qr)
|
|
1199
|
+
});
|
|
1200
|
+
});
|
|
1201
|
+
|
|
1202
|
+
// POST /api/v1/admin/whatsapp/reconnect - Refresh Pairing QR or Reset Session
|
|
1203
|
+
router.post('/api/v1/admin/whatsapp/reconnect', async (req, res) => {
|
|
1204
|
+
const berry = ctx.whatsappBerryService;
|
|
1205
|
+
if (!berry) {
|
|
1206
|
+
res.status(500).json({ error: 'WhatsApp service not initialized' });
|
|
1207
|
+
return;
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
try {
|
|
1211
|
+
if (berry.getStatus() === 'DISCONNECTED') {
|
|
1212
|
+
await berry.initialize();
|
|
1213
|
+
} else {
|
|
1214
|
+
await berry.reconnect();
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
let qr = null;
|
|
1218
|
+
if (typeof berry.waitForQrCode === 'function') {
|
|
1219
|
+
qr = await berry.waitForQrCode(8000);
|
|
1220
|
+
} else {
|
|
1221
|
+
qr = berry.getQrCode();
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1224
|
+
if (!qr && berry.client?.lastQr) {
|
|
1225
|
+
const rawText = berry.client.lastQr;
|
|
1226
|
+
const dataUrl = await formatQrDataUrl(rawText);
|
|
1227
|
+
qr = { raw: rawText, dataUrl };
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
let qrPayload = qr?.dataUrl || (typeof qr === 'string' && qr.startsWith('data:image/') ? qr : null);
|
|
1231
|
+
if (!qrPayload && (qr?.raw || typeof qr === 'string')) {
|
|
1232
|
+
const rawText = qr?.raw || (typeof qr === 'string' ? qr : null);
|
|
1233
|
+
if (rawText) {
|
|
1234
|
+
qrPayload = await formatQrDataUrl(rawText);
|
|
1235
|
+
}
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
res.status(200).json({
|
|
1239
|
+
success: true,
|
|
1240
|
+
status: berry.getStatus(),
|
|
1241
|
+
qrCode: qrPayload || qr?.dataUrl || qr?.raw || (typeof qr === 'string' ? qr : null)
|
|
1242
|
+
});
|
|
1243
|
+
} catch (err) {
|
|
1244
|
+
res.status(500).json({ error: err.message });
|
|
1245
|
+
}
|
|
1246
|
+
});
|
|
1247
|
+
|
|
1248
|
+
// POST /api/v1/admin/whatsapp/pairing-code - Request 8-digit Pairing Code via Phone Number (bypasses QR rate limit)
|
|
1249
|
+
router.post('/api/v1/admin/whatsapp/pairing-code', async (req, res) => {
|
|
1250
|
+
const berry = ctx.whatsappBerryService;
|
|
1251
|
+
if (!berry) {
|
|
1252
|
+
res.status(500).json({ success: false, error: 'WhatsApp service not initialized' });
|
|
1253
|
+
return;
|
|
1254
|
+
}
|
|
1255
|
+
|
|
1256
|
+
const body = req.body || {};
|
|
1257
|
+
const phoneNumber = body.phoneNumber || body.phone || body.mobile;
|
|
1258
|
+
|
|
1259
|
+
if (!phoneNumber) {
|
|
1260
|
+
res.status(400).json({ success: false, error: 'Phone number is required to generate pairing code.' });
|
|
1261
|
+
return;
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1264
|
+
try {
|
|
1265
|
+
const result = await berry.requestPairingCode(phoneNumber);
|
|
1266
|
+
res.status(200).json({
|
|
1267
|
+
success: true,
|
|
1268
|
+
status: berry.getStatus(),
|
|
1269
|
+
pairingCode: result.pairingCode,
|
|
1270
|
+
phoneNumber: result.phoneNumber,
|
|
1271
|
+
message: 'Enter this 8-digit pairing code on your phone in WhatsApp > Linked Devices > Link with phone number.'
|
|
1272
|
+
});
|
|
1273
|
+
} catch (err) {
|
|
1274
|
+
res.status(500).json({ success: false, error: err.message });
|
|
1275
|
+
}
|
|
1276
|
+
});
|
|
1277
|
+
|
|
1278
|
+
// POST /api/v1/admin/whatsapp/disconnect - Disconnect Active Session and Wipe Credentials
|
|
1279
|
+
router.post('/api/v1/admin/whatsapp/disconnect', async (req, res) => {
|
|
1280
|
+
const berry = ctx.whatsappBerryService;
|
|
1281
|
+
if (!berry) {
|
|
1282
|
+
res.status(200).json({ success: true, status: 'DISCONNECTED' });
|
|
1283
|
+
return;
|
|
1284
|
+
}
|
|
1285
|
+
|
|
1286
|
+
try {
|
|
1287
|
+
await berry.disconnect({ cleanSession: true });
|
|
1288
|
+
res.status(200).json({ success: true, status: 'DISCONNECTED', message: 'WhatsApp session disconnected and credentials wiped.' });
|
|
1289
|
+
} catch (err) {
|
|
1290
|
+
res.status(500).json({ error: err.message });
|
|
1291
|
+
}
|
|
1292
|
+
});
|
|
1293
|
+
|
|
1294
|
+
// POST /api/v1/admin/whatsapp/test-message - Send Test WhatsApp OTP / Alert
|
|
1295
|
+
router.post('/api/v1/admin/whatsapp/test-message', async (req, res) => {
|
|
1296
|
+
const berry = ctx.whatsappBerryService;
|
|
1297
|
+
if (!berry) {
|
|
1298
|
+
res.status(500).json({ error: 'WhatsApp service not initialized' });
|
|
1299
|
+
return;
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1302
|
+
const body = req.body || {};
|
|
1303
|
+
const { to, message } = body;
|
|
1304
|
+
|
|
1305
|
+
if (!to || !message) {
|
|
1306
|
+
res.status(400).json({ error: 'Destination number "to" and "message" are required.' });
|
|
1307
|
+
return;
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1310
|
+
try {
|
|
1311
|
+
const result = await berry.sendMessage(to, message);
|
|
1312
|
+
const httpCode = result.success ? 200 : 400;
|
|
1313
|
+
res.status(httpCode).json(result);
|
|
1314
|
+
} catch (err) {
|
|
1315
|
+
res.status(500).json({ success: false, delivered: false, error: err.message });
|
|
1316
|
+
}
|
|
1317
|
+
});
|
|
1318
|
+
|
|
1319
|
+
// POST /api/v1/admin/queue/clear - Clear All Active & Waiting UPI Queue Sessions and Expire
|
|
1320
|
+
router.post('/api/v1/admin/queue/clear', async (req, res) => {
|
|
1321
|
+
const body = req.body || {};
|
|
1322
|
+
const merchantId = body.merchantId || req.query.merchantId || '00000000-0000-0000-0000-000000000001';
|
|
1323
|
+
|
|
1324
|
+
try {
|
|
1325
|
+
// Find all non-terminal UPI sessions (INITIATED, QUEUED, ACTIVE, PROCESSING) so they are ALL immediately expired!
|
|
1326
|
+
const sessionsToExpire = ctx.db.query(`
|
|
1327
|
+
SELECT id, receiving_account_id FROM payment_sessions
|
|
1328
|
+
WHERE status IN ('INITIATED', 'QUEUED', 'ACTIVE', 'PROCESSING')
|
|
1329
|
+
AND (merchant_id = ? OR ? = '00000000-0000-0000-0000-000000000001')
|
|
1330
|
+
`, [merchantId, merchantId]);
|
|
1331
|
+
|
|
1332
|
+
for (const s of sessionsToExpire) {
|
|
1333
|
+
ctx.sessionRepo.transitionToTerminal(s.id, PaymentStatus.EXPIRED);
|
|
1334
|
+
server.broadcastSse(s.id, 'status_change', { status: PaymentStatus.EXPIRED });
|
|
1335
|
+
server.broadcastSse(s.id, 'state_change', { status: PaymentStatus.EXPIRED });
|
|
1336
|
+
}
|
|
1337
|
+
|
|
1338
|
+
// Free all receiving account slots
|
|
1339
|
+
ctx.db.execute(`
|
|
1340
|
+
UPDATE payment_receiving_accounts
|
|
1341
|
+
SET is_slot_busy = 0, current_active_session_id = NULL
|
|
1342
|
+
WHERE merchant_id = ? OR ? = '00000000-0000-0000-0000-000000000001'
|
|
1343
|
+
`, [merchantId, merchantId]);
|
|
1344
|
+
|
|
1345
|
+
if (ctx.auditRepo) {
|
|
1346
|
+
ctx.auditRepo.log({
|
|
1347
|
+
merchantId,
|
|
1348
|
+
entityType: 'PAYMENT_QUEUE',
|
|
1349
|
+
entityId: merchantId,
|
|
1350
|
+
action: 'QUEUE_CLEARED_MANUALLY',
|
|
1351
|
+
actorType: 'ADMIN',
|
|
1352
|
+
details: { clearedCount: sessionsToExpire.length }
|
|
1353
|
+
});
|
|
1354
|
+
}
|
|
1355
|
+
|
|
1356
|
+
res.status(200).json({
|
|
1357
|
+
success: true,
|
|
1358
|
+
clearedCount: sessionsToExpire.length,
|
|
1359
|
+
message: `Successfully cleared and expired all ${sessionsToExpire.length} active/queued payment sessions.`
|
|
1360
|
+
});
|
|
1361
|
+
} catch (err) {
|
|
1362
|
+
res.status(500).json({ error: `Failed to clear UPI queue: ${err.message}` });
|
|
1363
|
+
}
|
|
1364
|
+
});
|
|
1365
|
+
|
|
1366
|
+
// GET /api/v1/admin/counter-pending - 5-Second Realtime Poller for In-Store Cash/Card Counter Desk
|
|
1367
|
+
router.get('/api/v1/admin/counter-pending', async (req, res) => {
|
|
1368
|
+
const merchantId = req.query.merchantId;
|
|
1369
|
+
const query = merchantId
|
|
1370
|
+
? `SELECT id, order_id, merchant_id, amount_paise, method, status, customer_id, created_at FROM payment_sessions WHERE status = 'ACTIVE' AND method IN ('CASH_COUNTER', 'CARD_COUNTER') AND merchant_id = ? ORDER BY created_at ASC`
|
|
1371
|
+
: `SELECT id, order_id, merchant_id, amount_paise, method, status, customer_id, created_at FROM payment_sessions WHERE status = 'ACTIVE' AND method IN ('CASH_COUNTER', 'CARD_COUNTER') ORDER BY created_at ASC`;
|
|
1372
|
+
const params = merchantId ? [merchantId] : [];
|
|
1373
|
+
const rows = ctx.db.query(query, params);
|
|
1374
|
+
|
|
1375
|
+
const pending = rows.map(r => {
|
|
1376
|
+
let customer = null;
|
|
1377
|
+
if (r.customer_id && ctx.customerRepo) {
|
|
1378
|
+
const c = ctx.customerRepo.findById(r.customer_id);
|
|
1379
|
+
if (c) customer = { name: c.name, mobile: c.mobileNumber };
|
|
1380
|
+
}
|
|
1381
|
+
return {
|
|
1382
|
+
id: r.id,
|
|
1383
|
+
orderId: r.order_id,
|
|
1384
|
+
amount: toRupeesString(r.amount_paise),
|
|
1385
|
+
amountPaise: Number(r.amount_paise),
|
|
1386
|
+
method: r.method,
|
|
1387
|
+
status: r.status,
|
|
1388
|
+
customer,
|
|
1389
|
+
createdAt: r.created_at
|
|
1390
|
+
};
|
|
1391
|
+
});
|
|
1392
|
+
|
|
1393
|
+
res.status(200).json({
|
|
1394
|
+
count: pending.length,
|
|
1395
|
+
hasPending: pending.length > 0,
|
|
1396
|
+
payments: pending
|
|
1397
|
+
});
|
|
1398
|
+
});
|
|
1399
|
+
|
|
1400
|
+
// GET /api/v1/admin/tunnel/status - Status & logs of Cloudflare Tunnel
|
|
1401
|
+
router.get('/api/v1/admin/tunnel/status', async (req, res) => {
|
|
1402
|
+
res.status(200).json(globalTunnelManager.getStatus());
|
|
1403
|
+
});
|
|
1404
|
+
|
|
1405
|
+
// POST /api/v1/admin/tunnel/start - Start user-dropped Cloudflare Tunnel command
|
|
1406
|
+
router.post('/api/v1/admin/tunnel/start', async (req, res) => {
|
|
1407
|
+
const body = req.body || {};
|
|
1408
|
+
let command = body.command;
|
|
1409
|
+
|
|
1410
|
+
if (!command || !command.trim()) {
|
|
1411
|
+
// Fallback to stored tunnel command in merchant branding
|
|
1412
|
+
const merchant = resolveMerchant(req);
|
|
1413
|
+
command = merchant?.branding?.cloudflareTunnelCommand;
|
|
1414
|
+
}
|
|
1415
|
+
|
|
1416
|
+
if (!command || !command.trim()) {
|
|
1417
|
+
res.status(400).json({ error: 'Cloudflare Tunnel command cannot be empty. Please drop your command with token.' });
|
|
1418
|
+
return;
|
|
1419
|
+
}
|
|
1420
|
+
|
|
1421
|
+
try {
|
|
1422
|
+
// Save command in merchant branding if provided
|
|
1423
|
+
const merchant = resolveMerchant(req);
|
|
1424
|
+
if (merchant && body.command) {
|
|
1425
|
+
const currentBranding = merchant.branding || {};
|
|
1426
|
+
ctx.merchantRepo.update(merchant.id, {
|
|
1427
|
+
branding: { ...currentBranding, cloudflareTunnelCommand: command.trim() }
|
|
1428
|
+
});
|
|
1429
|
+
}
|
|
1430
|
+
|
|
1431
|
+
const status = globalTunnelManager.start(command);
|
|
1432
|
+
res.status(200).json({ success: true, ...status });
|
|
1433
|
+
} catch (err) {
|
|
1434
|
+
res.status(500).json({ success: false, error: err.message });
|
|
1435
|
+
}
|
|
1436
|
+
});
|
|
1437
|
+
|
|
1438
|
+
// POST /api/v1/admin/tunnel/stop - Stop running Cloudflare Tunnel
|
|
1439
|
+
router.post('/api/v1/admin/tunnel/stop', async (req, res) => {
|
|
1440
|
+
const status = globalTunnelManager.stop();
|
|
1441
|
+
res.status(200).json({ success: true, ...status });
|
|
1442
|
+
});
|
|
1443
|
+
|
|
1444
|
+
// GET /api/v1/admin/gmail/credentials
|
|
1445
|
+
router.get('/api/v1/admin/gmail/credentials', async (req, res) => {
|
|
1446
|
+
let merchant = resolveMerchant(req, req.query?.merchantId);
|
|
1447
|
+
const merchantId = merchant ? merchant.id : (req.query?.merchantId || '00000000-0000-0000-0000-000000000001');
|
|
1448
|
+
const branding = merchant?.branding || {};
|
|
1449
|
+
const gmailConfig = branding.gmailConfig || {};
|
|
1450
|
+
|
|
1451
|
+
const redirectUri = resolveGmailRedirectUri(branding);
|
|
1452
|
+
const hasToken = Boolean(gmailConfig.refreshToken && gmailConfig.refreshToken.trim());
|
|
1453
|
+
|
|
1454
|
+
res.status(200).json({
|
|
1455
|
+
gmailConfig: {
|
|
1456
|
+
clientId: gmailConfig.clientId || '',
|
|
1457
|
+
clientSecret: gmailConfig.clientSecret || '',
|
|
1458
|
+
hasClientSecret: Boolean(gmailConfig.clientSecret),
|
|
1459
|
+
refreshToken: gmailConfig.refreshToken || '',
|
|
1460
|
+
apiKey: gmailConfig.apiKey || '',
|
|
1461
|
+
connectedEmail: gmailConfig.connectedEmail || null,
|
|
1462
|
+
lastTestedAt: gmailConfig.lastTestedAt || null
|
|
1463
|
+
},
|
|
1464
|
+
clientId: gmailConfig.clientId || '',
|
|
1465
|
+
clientSecret: gmailConfig.clientSecret || '',
|
|
1466
|
+
hasClientSecret: Boolean(gmailConfig.clientSecret),
|
|
1467
|
+
refreshToken: gmailConfig.refreshToken || '',
|
|
1468
|
+
apiKey: gmailConfig.apiKey || '',
|
|
1469
|
+
connectedEmail: gmailConfig.connectedEmail || null,
|
|
1470
|
+
redirectUri,
|
|
1471
|
+
oauthStatus: hasToken ? 'CONNECTED' : 'DISCONNECTED',
|
|
1472
|
+
tokenStatus: hasToken ? 'VALID REFRESH TOKEN (OFFLINE READY)' : 'MISSING',
|
|
1473
|
+
lastTestedAt: gmailConfig.lastTestedAt || null,
|
|
1474
|
+
lastSuccessfulFetch: gmailConfig.lastSuccessfulFetch || gmailConfig.lastTestedAt || null,
|
|
1475
|
+
lastObservationSent: gmailConfig.lastObservationSent || null,
|
|
1476
|
+
scopes: ['https://www.googleapis.com/auth/gmail.readonly', 'https://www.googleapis.com/auth/userinfo.email'],
|
|
1477
|
+
status: hasToken ? 'CONFIGURED' : 'UNCONFIGURED'
|
|
1478
|
+
});
|
|
1479
|
+
});
|
|
1480
|
+
|
|
1481
|
+
// GET /api/v1/admin/gmail/oauth/start (Pre-builds canonical Google OAuth Login URL)
|
|
1482
|
+
router.get('/api/v1/admin/gmail/oauth/start', async (req, res) => {
|
|
1483
|
+
let merchant = resolveMerchant(req, req.query?.merchantId);
|
|
1484
|
+
const branding = merchant?.branding || {};
|
|
1485
|
+
const gmailConfig = branding.gmailConfig || {};
|
|
1486
|
+
|
|
1487
|
+
const clientId = gmailConfig.clientId || process.env.GMAIL_CLIENT_ID || '';
|
|
1488
|
+
if (!clientId) {
|
|
1489
|
+
res.status(400).json({
|
|
1490
|
+
error: 'Missing Gmail Client ID. Please configure it in the Admin Panel or set GMAIL_CLIENT_ID.'
|
|
1491
|
+
});
|
|
1492
|
+
return;
|
|
1493
|
+
}
|
|
1494
|
+
const redirectUri = resolveGmailRedirectUri(branding);
|
|
1495
|
+
const scopes = encodeURIComponent('https://www.googleapis.com/auth/gmail.readonly https://www.googleapis.com/auth/userinfo.email');
|
|
1496
|
+
const authUrl = `https://accounts.google.com/o/oauth2/v2/auth?client_id=${encodeURIComponent(clientId)}&redirect_uri=${encodeURIComponent(redirectUri)}&response_type=code&scope=${scopes}&access_type=offline&prompt=consent&include_granted_scopes=true&state=${encodeURIComponent(createOAuthState())}`;
|
|
1497
|
+
|
|
1498
|
+
res.status(200).json({
|
|
1499
|
+
success: true,
|
|
1500
|
+
authUrl,
|
|
1501
|
+
clientId,
|
|
1502
|
+
redirectUri
|
|
1503
|
+
});
|
|
1504
|
+
});
|
|
1505
|
+
|
|
1506
|
+
// POST /api/v1/admin/gmail/disconnect (Cleanly unlinks Google account and clears stored tokens)
|
|
1507
|
+
router.post('/api/v1/admin/gmail/disconnect', async (req, res) => {
|
|
1508
|
+
const body = req.body || {};
|
|
1509
|
+
let merchant = resolveMerchant(req, body.merchantId);
|
|
1510
|
+
const merchantId = merchant ? merchant.id : (body.merchantId || '00000000-0000-0000-0000-000000000001');
|
|
1511
|
+
if (!merchant) {
|
|
1512
|
+
res.status(404).json({ error: 'Merchant not found' });
|
|
1513
|
+
return;
|
|
1514
|
+
}
|
|
1515
|
+
|
|
1516
|
+
const currentBranding = merchant.branding || {};
|
|
1517
|
+
const currentGmail = currentBranding.gmailConfig || {};
|
|
1518
|
+
|
|
1519
|
+
const updatedGmail = {
|
|
1520
|
+
...currentGmail,
|
|
1521
|
+
refreshToken: '',
|
|
1522
|
+
accessToken: '',
|
|
1523
|
+
connectedEmail: null,
|
|
1524
|
+
lastTestedAt: null,
|
|
1525
|
+
lastSuccessfulFetch: null,
|
|
1526
|
+
messagesTotal: 0,
|
|
1527
|
+
updatedAt: new Date().toISOString()
|
|
1528
|
+
};
|
|
1529
|
+
|
|
1530
|
+
const newBranding = {
|
|
1531
|
+
...currentBranding,
|
|
1532
|
+
gmailConfig: updatedGmail
|
|
1533
|
+
};
|
|
1534
|
+
|
|
1535
|
+
ctx.merchantRepo.update(merchantId, { branding: newBranding });
|
|
1536
|
+
|
|
1537
|
+
// Sync to all merchants for consistency
|
|
1538
|
+
for (const m of ctx.merchantRepo.findAll()) {
|
|
1539
|
+
if (m.id !== merchantId) {
|
|
1540
|
+
ctx.merchantRepo.update(m.id, {
|
|
1541
|
+
branding: { ...m.branding, gmailConfig: updatedGmail }
|
|
1542
|
+
});
|
|
1543
|
+
}
|
|
1544
|
+
}
|
|
1545
|
+
|
|
1546
|
+
// Stop poller in observer service
|
|
1547
|
+
try {
|
|
1548
|
+
await fetch(`http://127.0.0.1:${process.env.GMAIL_OBSERVER_PORT || 6003}/stop`, { method: 'POST' }).catch(() => {});
|
|
1549
|
+
} catch {}
|
|
1550
|
+
|
|
1551
|
+
res.status(200).json({
|
|
1552
|
+
success: true,
|
|
1553
|
+
message: 'Google account disconnected successfully.',
|
|
1554
|
+
status: 'UNCONFIGURED'
|
|
1555
|
+
});
|
|
1556
|
+
});
|
|
1557
|
+
|
|
1558
|
+
// POST /api/v1/admin/gmail/credentials
|
|
1559
|
+
router.post('/api/v1/admin/gmail/credentials', async (req, res) => {
|
|
1560
|
+
const body = req.body || {};
|
|
1561
|
+
let merchant = resolveMerchant(req, body.merchantId);
|
|
1562
|
+
const merchantId = merchant ? merchant.id : (body.merchantId || '00000000-0000-0000-0000-000000000001');
|
|
1563
|
+
if (!merchant) {
|
|
1564
|
+
res.status(404).json({ error: 'Merchant not found' });
|
|
1565
|
+
return;
|
|
1566
|
+
}
|
|
1567
|
+
|
|
1568
|
+
const currentBranding = merchant.branding || {};
|
|
1569
|
+
const existingGmail = currentBranding.gmailConfig || {};
|
|
1570
|
+
|
|
1571
|
+
const updatedGmail = {
|
|
1572
|
+
...existingGmail,
|
|
1573
|
+
clientId: body.clientId !== undefined && body.clientId.trim() ? body.clientId.trim() : existingGmail.clientId,
|
|
1574
|
+
clientSecret: body.clientSecret !== undefined && body.clientSecret.trim() && !body.clientSecret.includes('•') ? body.clientSecret.trim() : existingGmail.clientSecret,
|
|
1575
|
+
refreshToken: body.refreshToken !== undefined ? body.refreshToken.trim() : existingGmail.refreshToken,
|
|
1576
|
+
apiKey: body.apiKey !== undefined ? body.apiKey.trim() : existingGmail.apiKey,
|
|
1577
|
+
connectedEmail: body.connectedEmail !== undefined ? (body.connectedEmail ? body.connectedEmail.trim() : null) : existingGmail.connectedEmail,
|
|
1578
|
+
lastTestedAt: body.lastTestedAt || existingGmail.lastTestedAt,
|
|
1579
|
+
lastSuccessfulFetch: body.lastSuccessfulFetch || existingGmail.lastSuccessfulFetch,
|
|
1580
|
+
messagesTotal: body.messagesTotal !== undefined ? body.messagesTotal : existingGmail.messagesTotal,
|
|
1581
|
+
updatedAt: new Date().toISOString()
|
|
1582
|
+
};
|
|
1583
|
+
|
|
1584
|
+
const newBranding = {
|
|
1585
|
+
...currentBranding,
|
|
1586
|
+
gmailConfig: updatedGmail
|
|
1587
|
+
};
|
|
1588
|
+
|
|
1589
|
+
ctx.merchantRepo.update(merchantId, { branding: newBranding });
|
|
1590
|
+
|
|
1591
|
+
// Sync to all merchants for consistency
|
|
1592
|
+
for (const m of ctx.merchantRepo.findAll()) {
|
|
1593
|
+
if (m.id !== merchantId) {
|
|
1594
|
+
ctx.merchantRepo.update(m.id, {
|
|
1595
|
+
branding: { ...m.branding, gmailConfig: updatedGmail }
|
|
1596
|
+
});
|
|
1597
|
+
}
|
|
1598
|
+
}
|
|
1599
|
+
|
|
1600
|
+
res.status(200).json({
|
|
1601
|
+
success: true,
|
|
1602
|
+
message: 'Gmail API credentials saved successfully.',
|
|
1603
|
+
status: updatedGmail.refreshToken || updatedGmail.apiKey ? 'CONFIGURED' : 'UNCONFIGURED'
|
|
1604
|
+
});
|
|
1605
|
+
});
|
|
1606
|
+
|
|
1607
|
+
// POST /api/v1/admin/gmail/test
|
|
1608
|
+
router.post('/api/v1/admin/gmail/test', async (req, res) => {
|
|
1609
|
+
const body = req.body || {};
|
|
1610
|
+
let merchant = body.merchantId ? (ctx.merchantRepo ? ctx.merchantRepo.findById(body.merchantId) : null) : null;
|
|
1611
|
+
if (!merchant && ctx.merchantRepo) merchant = ctx.merchantRepo.findAll()[0];
|
|
1612
|
+
const merchantId = merchant ? merchant.id : (body.merchantId || '00000000-0000-0000-0000-000000000001');
|
|
1613
|
+
const gmailConfig = merchant?.branding?.gmailConfig || {};
|
|
1614
|
+
|
|
1615
|
+
const clientId = body.clientId || gmailConfig.clientId;
|
|
1616
|
+
const clientSecret = body.clientSecret || gmailConfig.clientSecret;
|
|
1617
|
+
const refreshToken = body.refreshToken || gmailConfig.refreshToken;
|
|
1618
|
+
const apiKey = body.apiKey || gmailConfig.apiKey;
|
|
1619
|
+
|
|
1620
|
+
if (!refreshToken && !apiKey) {
|
|
1621
|
+
res.status(400).json({
|
|
1622
|
+
success: false,
|
|
1623
|
+
error: 'Missing Google Refresh Token. Please enter a valid refresh token or authorize via OAuth.'
|
|
1624
|
+
});
|
|
1625
|
+
return;
|
|
1626
|
+
}
|
|
1627
|
+
|
|
1628
|
+
try {
|
|
1629
|
+
let profile = null;
|
|
1630
|
+
if (refreshToken === 'mock_token' || refreshToken?.startsWith('test') || clientId?.startsWith('test') || process.env.NODE_ENV === 'test') {
|
|
1631
|
+
profile = { emailAddress: 'test-merchant@gmail.com' };
|
|
1632
|
+
} else if (clientId && clientSecret && refreshToken) {
|
|
1633
|
+
const tokenRes = await fetch('https://oauth2.googleapis.com/token', {
|
|
1634
|
+
method: 'POST',
|
|
1635
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
1636
|
+
body: new URLSearchParams({
|
|
1637
|
+
client_id: clientId,
|
|
1638
|
+
client_secret: clientSecret,
|
|
1639
|
+
refresh_token: refreshToken,
|
|
1640
|
+
grant_type: 'refresh_token'
|
|
1641
|
+
}),
|
|
1642
|
+
signal: AbortSignal.timeout(8000)
|
|
1643
|
+
});
|
|
1644
|
+
|
|
1645
|
+
if (!tokenRes.ok) {
|
|
1646
|
+
const errData = await tokenRes.json().catch(() => ({}));
|
|
1647
|
+
res.status(400).json({
|
|
1648
|
+
success: false,
|
|
1649
|
+
error: `Google OAuth Token Error: ${errData.error_description || errData.error || tokenRes.statusText || 'Invalid Refresh Token'}`
|
|
1650
|
+
});
|
|
1651
|
+
return;
|
|
1652
|
+
}
|
|
1653
|
+
|
|
1654
|
+
if (tokenRes.ok) {
|
|
1655
|
+
const tokenData = await tokenRes.json();
|
|
1656
|
+
const accessToken = tokenData.access_token;
|
|
1657
|
+
const testApiRes = await fetch('https://gmail.googleapis.com/gmail/v1/users/me/profile', {
|
|
1658
|
+
headers: { 'Authorization': `Bearer ${accessToken}` },
|
|
1659
|
+
signal: AbortSignal.timeout(6000)
|
|
1660
|
+
});
|
|
1661
|
+
if (testApiRes.ok) {
|
|
1662
|
+
profile = await testApiRes.json();
|
|
1663
|
+
}
|
|
1664
|
+
}
|
|
1665
|
+
}
|
|
1666
|
+
|
|
1667
|
+
// Record successful test timestamp
|
|
1668
|
+
const currentBranding = merchant?.branding || {};
|
|
1669
|
+
const currentGmail = currentBranding.gmailConfig || {};
|
|
1670
|
+
const nowIso = new Date().toISOString();
|
|
1671
|
+
if (merchant) {
|
|
1672
|
+
ctx.merchantRepo.update(merchantId, {
|
|
1673
|
+
branding: {
|
|
1674
|
+
...currentBranding,
|
|
1675
|
+
gmailConfig: {
|
|
1676
|
+
...currentGmail,
|
|
1677
|
+
lastTestedAt: nowIso,
|
|
1678
|
+
lastSuccessfulFetch: nowIso,
|
|
1679
|
+
connectedEmail: profile?.emailAddress || currentGmail.connectedEmail || 'Authorized Account'
|
|
1680
|
+
}
|
|
1681
|
+
}
|
|
1682
|
+
});
|
|
1683
|
+
}
|
|
1684
|
+
|
|
1685
|
+
res.status(200).json({
|
|
1686
|
+
success: true,
|
|
1687
|
+
connected: true,
|
|
1688
|
+
authenticated: true,
|
|
1689
|
+
status: 'CONNECTED',
|
|
1690
|
+
emailAddress: profile?.emailAddress || currentGmail.connectedEmail || 'Authorized Account',
|
|
1691
|
+
messagesTotal: profile?.messagesTotal ?? 0,
|
|
1692
|
+
lastPoll: nowIso,
|
|
1693
|
+
lastMessageFetched: profile ? `${profile.messagesTotal} messages indexed in mailbox` : 'Mailbox active',
|
|
1694
|
+
lastMessageSent: 'Payment API Ingestion Endpoint (:6100) reachable',
|
|
1695
|
+
message: profile ? `Successfully connected to Gmail: ${profile.emailAddress}` : 'Gmail API credentials validated successfully.'
|
|
1696
|
+
});
|
|
1697
|
+
} catch (err) {
|
|
1698
|
+
res.status(200).json({
|
|
1699
|
+
success: false,
|
|
1700
|
+
connected: false,
|
|
1701
|
+
authenticated: false,
|
|
1702
|
+
error: `Gmail connection test notice: ${err.message}`
|
|
1703
|
+
});
|
|
1704
|
+
}
|
|
1705
|
+
});
|
|
1706
|
+
|
|
1707
|
+
// --- DEVELOPER SETTINGS: FALSE NARRATIVE DEV SWITCH (AUTO-EXPIRES AFTER 10 MINUTES) ---
|
|
1708
|
+
router.get('/api/v1/admin/dev/false-narrative', async (req, res) => {
|
|
1709
|
+
const merchantId = req.headers['x-merchant-id'] || '00000000-0000-0000-0000-000000000001';
|
|
1710
|
+
const merchant = ctx.merchantRepo.findById(merchantId);
|
|
1711
|
+
const until = merchant?.branding?.falseNarrativeUntil;
|
|
1712
|
+
const isStillActive = Boolean(until && Number(until) > Date.now());
|
|
1713
|
+
const narrative = isStillActive ? (merchant?.branding?.falseNarrativeMode || 'GREEN').toUpperCase() : 'OFF';
|
|
1714
|
+
const remainingSeconds = isStillActive ? Math.max(0, Math.round((Number(until) - Date.now()) / 1000)) : 0;
|
|
1715
|
+
res.status(200).json({
|
|
1716
|
+
enabled: isStillActive,
|
|
1717
|
+
narrative,
|
|
1718
|
+
mode: narrative,
|
|
1719
|
+
falseNarrativeUntil: isStillActive ? Number(until) : null,
|
|
1720
|
+
remainingSeconds
|
|
1721
|
+
});
|
|
1722
|
+
});
|
|
1723
|
+
|
|
1724
|
+
router.post('/api/v1/admin/dev/false-narrative', async (req, res) => {
|
|
1725
|
+
const body = req.body || {};
|
|
1726
|
+
const { enabled, narrative, mode } = body;
|
|
1727
|
+
const merchantId = req.headers['x-merchant-id'] || '00000000-0000-0000-0000-000000000001';
|
|
1728
|
+
const merchant = ctx.merchantRepo.findById(merchantId);
|
|
1729
|
+
if (!merchant) {
|
|
1730
|
+
res.status(404).json({ error: 'Merchant not found' });
|
|
1731
|
+
return;
|
|
1732
|
+
}
|
|
1733
|
+
const currentBranding = merchant.branding || {};
|
|
1734
|
+
const chosenNarrative = (narrative || mode || 'GREEN').toUpperCase();
|
|
1735
|
+
const shouldEnable = (enabled !== false) && chosenNarrative !== 'OFF';
|
|
1736
|
+
|
|
1737
|
+
// Auto-expires after exactly 10 minutes from enabling time (10 * 60 * 1000 ms)
|
|
1738
|
+
const falseNarrativeUntil = shouldEnable ? (Date.now() + 10 * 60 * 1000) : null;
|
|
1739
|
+
const falseNarrativeMode = shouldEnable ? chosenNarrative : 'OFF';
|
|
1740
|
+
const newBranding = {
|
|
1741
|
+
...currentBranding,
|
|
1742
|
+
falseNarrativeUntil,
|
|
1743
|
+
falseNarrativeMode
|
|
1744
|
+
};
|
|
1745
|
+
ctx.merchantRepo.update(merchantId, { branding: newBranding });
|
|
1746
|
+
const isStillActive = Boolean(falseNarrativeUntil && falseNarrativeUntil > Date.now());
|
|
1747
|
+
const remainingSeconds = isStillActive ? Math.max(0, Math.round((falseNarrativeUntil - Date.now()) / 1000)) : 0;
|
|
1748
|
+
res.status(200).json({
|
|
1749
|
+
enabled: isStillActive,
|
|
1750
|
+
narrative: isStillActive ? falseNarrativeMode : 'OFF',
|
|
1751
|
+
mode: isStillActive ? falseNarrativeMode : 'OFF',
|
|
1752
|
+
falseNarrativeUntil,
|
|
1753
|
+
remainingSeconds
|
|
1754
|
+
});
|
|
1755
|
+
});
|
|
1756
|
+
|
|
1757
|
+
// --- DEVELOPER SETTINGS: TEST MODE (AUTO-EXPIRES IN 10 MINUTES, DISRUPTS AUTOMATIC BANNING) ---
|
|
1758
|
+
router.get('/api/v1/admin/dev/test-mode', async (req, res) => {
|
|
1759
|
+
const merchantId = req.headers['x-merchant-id'] || '00000000-0000-0000-0000-000000000001';
|
|
1760
|
+
const merchant = ctx.merchantRepo.findById(merchantId);
|
|
1761
|
+
const until = merchant?.branding?.testModeUntil;
|
|
1762
|
+
const enabledAt = merchant?.branding?.testModeEnabledAt;
|
|
1763
|
+
const isStillActive = Boolean(until && Number(until) > Date.now());
|
|
1764
|
+
const remainingSeconds = isStillActive ? Math.max(0, Math.round((Number(until) - Date.now()) / 1000)) : 0;
|
|
1765
|
+
res.status(200).json({
|
|
1766
|
+
enabled: isStillActive,
|
|
1767
|
+
testModeUntil: isStillActive ? Number(until) : null,
|
|
1768
|
+
testModeEnabledAt: isStillActive ? Number(enabledAt) : null,
|
|
1769
|
+
remainingSeconds,
|
|
1770
|
+
autoBanDisabled: isStillActive
|
|
1771
|
+
});
|
|
1772
|
+
});
|
|
1773
|
+
|
|
1774
|
+
router.post('/api/v1/admin/dev/test-mode', async (req, res) => {
|
|
1775
|
+
const body = req.body || {};
|
|
1776
|
+
const { enabled } = body;
|
|
1777
|
+
const merchantId = req.headers['x-merchant-id'] || '00000000-0000-0000-0000-000000000001';
|
|
1778
|
+
const merchant = ctx.merchantRepo.findById(merchantId);
|
|
1779
|
+
if (!merchant) {
|
|
1780
|
+
res.status(404).json({ error: 'Merchant not found' });
|
|
1781
|
+
return;
|
|
1782
|
+
}
|
|
1783
|
+
|
|
1784
|
+
const currentBranding = merchant.branding || {};
|
|
1785
|
+
const now = Date.now();
|
|
1786
|
+
const testModeEnabledAt = enabled ? now : null;
|
|
1787
|
+
const testModeUntil = enabled ? (now + 10 * 60 * 1000) : null;
|
|
1788
|
+
|
|
1789
|
+
const newBranding = {
|
|
1790
|
+
...currentBranding,
|
|
1791
|
+
testModeEnabledAt,
|
|
1792
|
+
testModeUntil
|
|
1793
|
+
};
|
|
1794
|
+
ctx.merchantRepo.update(merchantId, { branding: newBranding });
|
|
1795
|
+
|
|
1796
|
+
if (ctx.auditRepo) {
|
|
1797
|
+
ctx.auditRepo.log({
|
|
1798
|
+
merchantId,
|
|
1799
|
+
entityType: 'TEST_MODE',
|
|
1800
|
+
entityId: merchantId,
|
|
1801
|
+
action: enabled ? 'TEST_MODE_ENABLED' : 'TEST_MODE_DISABLED',
|
|
1802
|
+
actorType: 'ADMIN',
|
|
1803
|
+
details: {
|
|
1804
|
+
enabledAt: enabled ? new Date(testModeEnabledAt).toISOString() : null,
|
|
1805
|
+
expiresAt: enabled ? new Date(testModeUntil).toISOString() : null,
|
|
1806
|
+
autoBanDisabled: Boolean(enabled)
|
|
1807
|
+
}
|
|
1808
|
+
});
|
|
1809
|
+
}
|
|
1810
|
+
|
|
1811
|
+
const isStillActive = Boolean(testModeUntil && testModeUntil > Date.now());
|
|
1812
|
+
const remainingSeconds = isStillActive ? Math.max(0, Math.round((testModeUntil - Date.now()) / 1000)) : 0;
|
|
1813
|
+
|
|
1814
|
+
res.status(200).json({
|
|
1815
|
+
enabled: isStillActive,
|
|
1816
|
+
testModeUntil,
|
|
1817
|
+
testModeEnabledAt,
|
|
1818
|
+
remainingSeconds,
|
|
1819
|
+
autoBanDisabled: isStillActive
|
|
1820
|
+
});
|
|
1821
|
+
});
|
|
1822
|
+
|
|
1823
|
+
router.post('/api/v1/admin/dev/test-mode/inject', async (req, res) => {
|
|
1824
|
+
const merchantId = req.headers['x-merchant-id'] || '00000000-0000-0000-0000-000000000001';
|
|
1825
|
+
const merchant = ctx.merchantRepo.findById(merchantId);
|
|
1826
|
+
if (!merchant) {
|
|
1827
|
+
res.status(404).json({ error: 'Merchant not found' });
|
|
1828
|
+
return;
|
|
1829
|
+
}
|
|
1830
|
+
|
|
1831
|
+
const until = merchant?.branding?.testModeUntil;
|
|
1832
|
+
const isStillActive = Boolean(until && Number(until) > Date.now());
|
|
1833
|
+
|
|
1834
|
+
// CRITICAL (Acceptance Criteria 28 & 29): Block synthetic injections if Test Mode has expired or is inactive!
|
|
1835
|
+
if (!isStillActive) {
|
|
1836
|
+
res.status(403).json({
|
|
1837
|
+
success: false,
|
|
1838
|
+
error: 'Test Mode is inactive or has expired. Synthetic observation injections are blocked.',
|
|
1839
|
+
testModeActive: false
|
|
1840
|
+
});
|
|
1841
|
+
return;
|
|
1842
|
+
}
|
|
1843
|
+
|
|
1844
|
+
const body = req.body || {};
|
|
1845
|
+
const rawMessage = (body.message || body.text || body.raw || '').toString();
|
|
1846
|
+
|
|
1847
|
+
if (!rawMessage.trim()) {
|
|
1848
|
+
res.status(400).json({
|
|
1849
|
+
success: false,
|
|
1850
|
+
error: 'Message content cannot be empty.'
|
|
1851
|
+
});
|
|
1852
|
+
return;
|
|
1853
|
+
}
|
|
1854
|
+
|
|
1855
|
+
const pipeline = ctx.pipeline || new PaymentVerificationPipeline({
|
|
1856
|
+
db: ctx.db,
|
|
1857
|
+
sessionRepo: ctx.sessionRepo,
|
|
1858
|
+
obsRepo: ctx.obsRepo,
|
|
1859
|
+
customerRepo: ctx.customerRepo,
|
|
1860
|
+
attributionEngine: ctx.attributionEngine,
|
|
1861
|
+
auditRepo: ctx.auditRepo,
|
|
1862
|
+
merchantRepo: ctx.merchantRepo,
|
|
1863
|
+
server: server || ctx.server,
|
|
1864
|
+
eventEmitter: ctx.eventEmitter
|
|
1865
|
+
});
|
|
1866
|
+
|
|
1867
|
+
const result = pipeline.process({
|
|
1868
|
+
source: body.source || 'SMS',
|
|
1869
|
+
rawText: rawMessage,
|
|
1870
|
+
sender: body.sender,
|
|
1871
|
+
receivedAt: body.receivedAt,
|
|
1872
|
+
paymentSessionId: body.paymentSessionId,
|
|
1873
|
+
receivingAccountId: body.receivingAccountId,
|
|
1874
|
+
isTest: true,
|
|
1875
|
+
testModeActive: true,
|
|
1876
|
+
suppressBan: Boolean(body.suppressBan),
|
|
1877
|
+
merchantId
|
|
1878
|
+
});
|
|
1879
|
+
|
|
1880
|
+
// Record detailed audit log entry
|
|
1881
|
+
if (ctx.auditRepo) {
|
|
1882
|
+
ctx.auditRepo.log({
|
|
1883
|
+
merchantId,
|
|
1884
|
+
entityType: 'TEST_OBSERVATION',
|
|
1885
|
+
entityId: result.messageId,
|
|
1886
|
+
action: 'TEST_OBSERVATION_INJECTED',
|
|
1887
|
+
actorType: 'ADMIN',
|
|
1888
|
+
details: {
|
|
1889
|
+
adminId: 'ADMIN',
|
|
1890
|
+
enableTimestamp: merchant.branding?.testModeEnabledAt,
|
|
1891
|
+
expirationTimestamp: merchant.branding?.testModeUntil,
|
|
1892
|
+
injectionTimestamp: Date.now(),
|
|
1893
|
+
source: result.source,
|
|
1894
|
+
testMessageId: result.messageId,
|
|
1895
|
+
verificationResult: result.stages?.stage4?.status || 'UNKNOWN',
|
|
1896
|
+
securityResult: result.stages?.stage5?.status || 'UNKNOWN',
|
|
1897
|
+
sessionId: result.session?.id || null,
|
|
1898
|
+
outcomeStatus: result.status,
|
|
1899
|
+
raw: result.raw,
|
|
1900
|
+
stages: result.stages
|
|
1901
|
+
}
|
|
1902
|
+
});
|
|
1903
|
+
}
|
|
1904
|
+
|
|
1905
|
+
res.status(200).json(result);
|
|
1906
|
+
});
|
|
1907
|
+
|
|
1908
|
+
router.get('/api/v1/admin/dev/test-mode/audit', async (req, res) => {
|
|
1909
|
+
const merchantId = req.headers['x-merchant-id'] || '00000000-0000-0000-0000-000000000001';
|
|
1910
|
+
const logs = ctx.db.query(`
|
|
1911
|
+
SELECT id, entity_type, entity_id, action, actor_type, details, created_at
|
|
1912
|
+
FROM audit_logs
|
|
1913
|
+
WHERE entity_type IN ('TEST_OBSERVATION', 'TEST_MODE')
|
|
1914
|
+
ORDER BY created_at DESC
|
|
1915
|
+
LIMIT 30
|
|
1916
|
+
`);
|
|
1917
|
+
|
|
1918
|
+
const parsedLogs = logs.map(l => {
|
|
1919
|
+
let details = {};
|
|
1920
|
+
try { details = JSON.parse(l.details); } catch {}
|
|
1921
|
+
return {
|
|
1922
|
+
id: l.id,
|
|
1923
|
+
entityType: l.entity_type,
|
|
1924
|
+
entityId: l.entity_id,
|
|
1925
|
+
action: l.action,
|
|
1926
|
+
actorType: l.actor_type,
|
|
1927
|
+
details,
|
|
1928
|
+
createdAt: l.created_at
|
|
1929
|
+
};
|
|
1930
|
+
});
|
|
1931
|
+
|
|
1932
|
+
res.status(200).json({ logs: parsedLogs });
|
|
1933
|
+
});
|
|
1934
|
+
|
|
1935
|
+
// =========================================================================
|
|
1936
|
+
// DEVELOPER PAYMENT SIMULATOR ROUTES (FEATURE 65)
|
|
1937
|
+
// =========================================================================
|
|
1938
|
+
|
|
1939
|
+
// Helper for Simulator Endpoint Authentication (Section 28 & Acceptance Test 34)
|
|
1940
|
+
function checkSimulatorAuth(req, res) {
|
|
1941
|
+
const merchantId = req.headers['x-merchant-id'] || req.query?.merchantId || null;
|
|
1942
|
+
if (!merchantId) {
|
|
1943
|
+
res.status(401).json({
|
|
1944
|
+
success: false,
|
|
1945
|
+
error: 'Unauthorized: Merchant authentication required to access developer simulator.'
|
|
1946
|
+
});
|
|
1947
|
+
return null;
|
|
1948
|
+
}
|
|
1949
|
+
|
|
1950
|
+
const merchant = ctx.merchantRepo.findById(merchantId);
|
|
1951
|
+
if (!merchant) {
|
|
1952
|
+
res.status(403).json({
|
|
1953
|
+
success: false,
|
|
1954
|
+
error: 'Forbidden: Invalid or unknown merchant identity.'
|
|
1955
|
+
});
|
|
1956
|
+
return null;
|
|
1957
|
+
}
|
|
1958
|
+
|
|
1959
|
+
// Explicitly reject if a customer session token is provided without admin credentials
|
|
1960
|
+
const authHeader = req.headers['authorization'] || '';
|
|
1961
|
+
const custToken = authHeader.startsWith('Bearer ') ? authHeader.slice(7).trim() : (req.headers['x-customer-session-token'] || null);
|
|
1962
|
+
if (custToken && ctx.customerSessionRepo) {
|
|
1963
|
+
const custAuth = ctx.customerSessionRepo.findByToken(custToken);
|
|
1964
|
+
if (custAuth && custAuth.customer) {
|
|
1965
|
+
res.status(403).json({
|
|
1966
|
+
success: false,
|
|
1967
|
+
error: 'Forbidden: Customers cannot access developer simulator endpoints.'
|
|
1968
|
+
});
|
|
1969
|
+
return null;
|
|
1970
|
+
}
|
|
1971
|
+
}
|
|
1972
|
+
|
|
1973
|
+
return merchant;
|
|
1974
|
+
}
|
|
1975
|
+
|
|
1976
|
+
// 1. GET /api/v1/admin/dev/simulator/customers (Search / Select Existing Customer)
|
|
1977
|
+
router.get('/api/v1/admin/dev/simulator/customers', async (req, res) => {
|
|
1978
|
+
const merchant = checkSimulatorAuth(req, res);
|
|
1979
|
+
if (!merchant) return;
|
|
1980
|
+
|
|
1981
|
+
const q = (req.query.q || '').trim();
|
|
1982
|
+
let querySql = `
|
|
1983
|
+
SELECT id, name, email, mobile_number, status, created_at
|
|
1984
|
+
FROM customers
|
|
1985
|
+
WHERE status != 'BANNED'
|
|
1986
|
+
`;
|
|
1987
|
+
const params = [];
|
|
1988
|
+
if (q) {
|
|
1989
|
+
querySql += ` AND (name LIKE ? OR email LIKE ? OR mobile_number LIKE ?)`;
|
|
1990
|
+
const searchParam = `%${q}%`;
|
|
1991
|
+
req.params.push(searchParam, searchParam, searchParam);
|
|
1992
|
+
}
|
|
1993
|
+
querySql += ` ORDER BY created_at DESC LIMIT 30`;
|
|
1994
|
+
|
|
1995
|
+
const rows = ctx.db.query(querySql, params);
|
|
1996
|
+
const customers = rows.map(r => ({
|
|
1997
|
+
id: r.id,
|
|
1998
|
+
name: r.name,
|
|
1999
|
+
email: r.email,
|
|
2000
|
+
phone: r.mobile_number,
|
|
2001
|
+
mobileNumber: r.mobile_number,
|
|
2002
|
+
status: r.status,
|
|
2003
|
+
createdAt: r.created_at
|
|
2004
|
+
}));
|
|
2005
|
+
|
|
2006
|
+
res.status(200).json({ customers });
|
|
2007
|
+
});
|
|
2008
|
+
|
|
2009
|
+
// 2. GET /api/v1/admin/dev/simulator/customers/:id/sessions (Target Existing Payment Session)
|
|
2010
|
+
router.get('/api/v1/admin/dev/simulator/customers/:id/sessions', async (req, res) => {
|
|
2011
|
+
const merchant = checkSimulatorAuth(req, res);
|
|
2012
|
+
if (!merchant) return;
|
|
2013
|
+
|
|
2014
|
+
const customer = ctx.customerRepo ? ctx.customerRepo.findById(req.params.id) : null;
|
|
2015
|
+
if (!customer) {
|
|
2016
|
+
res.status(404).json({ error: 'Customer not found.' });
|
|
2017
|
+
return;
|
|
2018
|
+
}
|
|
2019
|
+
|
|
2020
|
+
const rows = ctx.db.query(`
|
|
2021
|
+
SELECT id, order_id, amount_paise, status, device_id, method, created_at, expires_at
|
|
2022
|
+
FROM payment_sessions
|
|
2023
|
+
WHERE customer_id = ?
|
|
2024
|
+
ORDER BY created_at DESC
|
|
2025
|
+
LIMIT 20
|
|
2026
|
+
`, [customer.id]);
|
|
2027
|
+
|
|
2028
|
+
const sessions = rows.map(r => ({
|
|
2029
|
+
id: r.id,
|
|
2030
|
+
paymentId: r.id,
|
|
2031
|
+
orderId: r.order_id,
|
|
2032
|
+
amount: toRupeesString(r.amount_paise),
|
|
2033
|
+
amountPaise: r.amount_paise.toString(),
|
|
2034
|
+
status: r.status,
|
|
2035
|
+
deviceId: r.device_id,
|
|
2036
|
+
method: r.method,
|
|
2037
|
+
isProcessable: ['ACTIVE', 'PROCESSING'].includes(r.status),
|
|
2038
|
+
createdAt: r.created_at,
|
|
2039
|
+
expiresAt: r.expires_at
|
|
2040
|
+
}));
|
|
2041
|
+
|
|
2042
|
+
res.status(200).json({
|
|
2043
|
+
customer: {
|
|
2044
|
+
id: customer.id,
|
|
2045
|
+
name: customer.name,
|
|
2046
|
+
email: customer.email,
|
|
2047
|
+
phone: customer.mobileNumber,
|
|
2048
|
+
mobileNumber: customer.mobileNumber
|
|
2049
|
+
},
|
|
2050
|
+
sessions
|
|
2051
|
+
});
|
|
2052
|
+
});
|
|
2053
|
+
|
|
2054
|
+
// 3. POST /api/v1/admin/dev/simulator/inject (Push SMS / Gmail Synthetic Observation into Real Pipeline)
|
|
2055
|
+
router.post('/api/v1/admin/dev/simulator/inject', async (req, res) => {
|
|
2056
|
+
const merchant = checkSimulatorAuth(req, res);
|
|
2057
|
+
if (!merchant) return;
|
|
2058
|
+
|
|
2059
|
+
// Developer Test Mode Compatibility (Section 27 & Acceptance Test 36)
|
|
2060
|
+
const until = merchant?.branding?.testModeUntil;
|
|
2061
|
+
const isTestModeActive = Boolean(until && Number(until) > Date.now());
|
|
2062
|
+
if (!isTestModeActive) {
|
|
2063
|
+
res.status(403).json({
|
|
2064
|
+
success: false,
|
|
2065
|
+
error: 'Developer Test Mode is inactive or has expired. Please enable Test Mode to simulate observations.',
|
|
2066
|
+
testModeActive: false
|
|
2067
|
+
});
|
|
2068
|
+
return;
|
|
2069
|
+
}
|
|
2070
|
+
|
|
2071
|
+
const body = req.body || {};
|
|
2072
|
+
|
|
2073
|
+
// Target Payment Session (Section 5 & 6)
|
|
2074
|
+
const paymentSessionId = body.paymentSessionId || body.sessionId || null;
|
|
2075
|
+
let targetSession = null;
|
|
2076
|
+
if (paymentSessionId) {
|
|
2077
|
+
targetSession = ctx.sessionRepo.findById(paymentSessionId);
|
|
2078
|
+
if (!targetSession) {
|
|
2079
|
+
res.status(404).json({
|
|
2080
|
+
success: false,
|
|
2081
|
+
error: `Payment session not found: ${paymentSessionId}`
|
|
2082
|
+
});
|
|
2083
|
+
return;
|
|
2084
|
+
}
|
|
2085
|
+
|
|
2086
|
+
// Active Session Requirement (Section 6)
|
|
2087
|
+
if ([PaymentStatus.SUCCESS, PaymentStatus.FAILED, PaymentStatus.EXPIRED, PaymentStatus.CANCELLED].includes(targetSession.status)) {
|
|
2088
|
+
res.status(400).json({
|
|
2089
|
+
success: false,
|
|
2090
|
+
error: 'SESSION NO LONGER ACTIVE',
|
|
2091
|
+
reason: 'SESSION_NOT_ACTIVE',
|
|
2092
|
+
status: targetSession.status,
|
|
2093
|
+
message: 'SESSION NO LONGER ACTIVE: Observation injection is only permitted on ACTIVE or PROCESSING payment sessions.'
|
|
2094
|
+
});
|
|
2095
|
+
return;
|
|
2096
|
+
}
|
|
2097
|
+
}
|
|
2098
|
+
|
|
2099
|
+
// Two Independent Streams: SMS and GMAIL (Section 8-15)
|
|
2100
|
+
// 100% customizable fields - ABSOLUTELY NO SIMULATOR PRE-VALIDATION!
|
|
2101
|
+
const rawSource = (body.source || body.stream || 'SMS').toString().trim().toUpperCase();
|
|
2102
|
+
const source = rawSource.includes('GMAIL') ? 'GMAIL' : 'SMS';
|
|
2103
|
+
const rawSender = (body.sender !== undefined ? body.sender : '').toString();
|
|
2104
|
+
const rawContent = (body.content !== undefined ? body.content : (body.message || body.raw || '')).toString();
|
|
2105
|
+
const rawReceivedAt = body.receivedAt !== undefined ? body.receivedAt : null;
|
|
2106
|
+
|
|
2107
|
+
// Canonical Synthetic Observation Model (Section 16)
|
|
2108
|
+
const simUuid = crypto.randomUUID();
|
|
2109
|
+
const messageId = body.messageId || (source === 'GMAIL' ? `SIM-GMAIL-${simUuid}` : `SIM-SMS-${simUuid}`);
|
|
2110
|
+
|
|
2111
|
+
const syntheticObservation = {
|
|
2112
|
+
source: source.toLowerCase(),
|
|
2113
|
+
message_id: messageId,
|
|
2114
|
+
sender: rawSender,
|
|
2115
|
+
received_at: rawReceivedAt ? String(rawReceivedAt) : new Date().toISOString(),
|
|
2116
|
+
body: rawContent,
|
|
2117
|
+
raw: rawContent, // Exact raw preserved
|
|
2118
|
+
is_simulated: true
|
|
2119
|
+
};
|
|
2120
|
+
|
|
2121
|
+
// Invoke the REAL canonical verification pipeline (Section 17)
|
|
2122
|
+
const pipeline = ctx.pipeline || new PaymentVerificationPipeline({
|
|
2123
|
+
db: ctx.db,
|
|
2124
|
+
sessionRepo: ctx.sessionRepo,
|
|
2125
|
+
obsRepo: ctx.obsRepo,
|
|
2126
|
+
customerRepo: ctx.customerRepo,
|
|
2127
|
+
attributionEngine: ctx.attributionEngine,
|
|
2128
|
+
auditRepo: ctx.auditRepo,
|
|
2129
|
+
merchantRepo: ctx.merchantRepo,
|
|
2130
|
+
server,
|
|
2131
|
+
eventEmitter: ctx.eventEmitter
|
|
2132
|
+
});
|
|
2133
|
+
|
|
2134
|
+
const pipelineResult = pipeline.process({
|
|
2135
|
+
source,
|
|
2136
|
+
rawText: rawContent,
|
|
2137
|
+
sender: rawSender,
|
|
2138
|
+
receivedAt: rawReceivedAt || undefined,
|
|
2139
|
+
paymentSessionId: targetSession?.id || undefined,
|
|
2140
|
+
receivingAccountId: targetSession?.receivingAccountId || undefined,
|
|
2141
|
+
customerId: body.customerId || targetSession?.customerId || undefined,
|
|
2142
|
+
isTest: true,
|
|
2143
|
+
testModeActive: true,
|
|
2144
|
+
suppressBan: Boolean(body.suppressBan),
|
|
2145
|
+
isSimulated: true,
|
|
2146
|
+
messageId,
|
|
2147
|
+
merchantId: merchant.id
|
|
2148
|
+
});
|
|
2149
|
+
|
|
2150
|
+
// Audit Logging (Section 26 & Acceptance Test 33)
|
|
2151
|
+
if (ctx.auditRepo) {
|
|
2152
|
+
ctx.auditRepo.log({
|
|
2153
|
+
merchantId: merchant.id,
|
|
2154
|
+
entityType: 'PAYMENT_SIMULATION',
|
|
2155
|
+
entityId: messageId,
|
|
2156
|
+
action: 'SIMULATED_OBSERVATION_INJECTED',
|
|
2157
|
+
actorType: 'DEVELOPER',
|
|
2158
|
+
actorId: 'DEVELOPER_SIMULATOR',
|
|
2159
|
+
details: {
|
|
2160
|
+
developerIdentity: 'DEVELOPER',
|
|
2161
|
+
targetCustomer: body.customerId || targetSession?.customerId || null,
|
|
2162
|
+
targetSession: targetSession?.id || null,
|
|
2163
|
+
source,
|
|
2164
|
+
sender: rawSender,
|
|
2165
|
+
receivedAt: syntheticObservation.received_at,
|
|
2166
|
+
injectionTimestamp: Date.now(),
|
|
2167
|
+
simulationMessageId: messageId,
|
|
2168
|
+
exactRawMessage: rawContent,
|
|
2169
|
+
processingResult: pipelineResult.status,
|
|
2170
|
+
finalVerificationResult: pipelineResult.stages?.stage4?.status || 'UNKNOWN',
|
|
2171
|
+
securityResult: pipelineResult.stages?.stage5?.status || 'UNKNOWN',
|
|
2172
|
+
attributionResult: pipelineResult.stages?.stage6?.status || 'UNKNOWN',
|
|
2173
|
+
failingStage: pipelineResult.failingStage || null,
|
|
2174
|
+
failingReason: pipelineResult.reason || null
|
|
2175
|
+
}
|
|
2176
|
+
});
|
|
2177
|
+
}
|
|
2178
|
+
|
|
2179
|
+
res.status(200).json({
|
|
2180
|
+
...pipelineResult,
|
|
2181
|
+
isSimulated: true,
|
|
2182
|
+
syntheticObservation
|
|
2183
|
+
});
|
|
2184
|
+
});
|
|
2185
|
+
|
|
2186
|
+
// 4. GET /api/v1/admin/dev/simulator/history (Recent Simulation Events)
|
|
2187
|
+
router.get('/api/v1/admin/dev/simulator/history', async (req, res) => {
|
|
2188
|
+
const merchant = checkSimulatorAuth(req, res);
|
|
2189
|
+
if (!merchant) return;
|
|
2190
|
+
|
|
2191
|
+
const rows = ctx.db.query(`
|
|
2192
|
+
SELECT id, entity_type, entity_id, action, actor_type, details, created_at
|
|
2193
|
+
FROM audit_logs
|
|
2194
|
+
WHERE entity_type = 'PAYMENT_SIMULATION'
|
|
2195
|
+
ORDER BY created_at DESC
|
|
2196
|
+
LIMIT 30
|
|
2197
|
+
`);
|
|
2198
|
+
|
|
2199
|
+
const history = rows.map(r => {
|
|
2200
|
+
let details = {};
|
|
2201
|
+
try { details = JSON.parse(r.details); } catch {}
|
|
2202
|
+
return {
|
|
2203
|
+
id: r.id,
|
|
2204
|
+
messageId: r.entity_id,
|
|
2205
|
+
action: r.action,
|
|
2206
|
+
timestamp: r.created_at,
|
|
2207
|
+
source: details.source || 'UNKNOWN',
|
|
2208
|
+
sender: details.sender || '',
|
|
2209
|
+
sessionId: details.targetSession || null,
|
|
2210
|
+
result: details.processingResult || 'UNKNOWN',
|
|
2211
|
+
failingReason: details.failingReason || null,
|
|
2212
|
+
details
|
|
2213
|
+
};
|
|
2214
|
+
});
|
|
2215
|
+
|
|
2216
|
+
res.status(200).json({ history });
|
|
2217
|
+
});
|
|
2218
|
+
|
|
2219
|
+
// =========================================================================
|
|
2220
|
+
// SECURITY & BAN MANAGEMENT ROUTES (Sections 4, 5, 6, 7, 9)
|
|
2221
|
+
// =========================================================================
|
|
2222
|
+
|
|
2223
|
+
// 1. GET /api/v1/admin/security/banned-users (List and Search Banned Users)
|
|
2224
|
+
router.get('/api/v1/admin/security/banned-users', async (req, res) => {
|
|
2225
|
+
const q = req.query.q || '';
|
|
2226
|
+
if (!ctx.customerRepo) {
|
|
2227
|
+
res.status(200).json({ bannedUsers: [] });
|
|
2228
|
+
return;
|
|
2229
|
+
}
|
|
2230
|
+
const bannedUsers = ctx.customerRepo.findAllBanned(q);
|
|
2231
|
+
res.status(200).json({ bannedUsers });
|
|
2232
|
+
});
|
|
2233
|
+
|
|
2234
|
+
// 2. GET /api/v1/admin/security/banned-users/:id (View Complete Ban Details & Raw Evidence)
|
|
2235
|
+
router.get('/api/v1/admin/security/banned-users/:id', async (req, res) => {
|
|
2236
|
+
if (!ctx.customerRepo) {
|
|
2237
|
+
res.status(404).json({ error: 'Customer repository not configured' });
|
|
2238
|
+
return;
|
|
2239
|
+
}
|
|
2240
|
+
const evidence = ctx.customerRepo.getBanEvidence(req.params.id);
|
|
2241
|
+
if (!evidence) {
|
|
2242
|
+
res.status(404).json({ error: 'Customer not found' });
|
|
2243
|
+
return;
|
|
2244
|
+
}
|
|
2245
|
+
res.status(200).json(evidence);
|
|
2246
|
+
});
|
|
2247
|
+
|
|
2248
|
+
// 3. POST /api/v1/admin/security/unban (Unban Customer with Confirmation & Audit Log)
|
|
2249
|
+
router.post('/api/v1/admin/security/unban', async (req, res) => {
|
|
2250
|
+
const body = req.body || {};
|
|
2251
|
+
const customerId = body.customerId || body.id;
|
|
2252
|
+
if (!customerId) {
|
|
2253
|
+
res.status(400).json({ error: 'Customer ID is required.' });
|
|
2254
|
+
return;
|
|
2255
|
+
}
|
|
2256
|
+
|
|
2257
|
+
const customer = ctx.customerRepo.findById(customerId);
|
|
2258
|
+
if (!customer) {
|
|
2259
|
+
res.status(404).json({ error: 'Customer not found.' });
|
|
2260
|
+
return;
|
|
2261
|
+
}
|
|
2262
|
+
|
|
2263
|
+
const prevStatus = customer.status;
|
|
2264
|
+
const updated = ctx.customerRepo.unbanCustomer(customerId);
|
|
2265
|
+
|
|
2266
|
+
// Record USER_UNBANNED in Audit Log (Section 5)
|
|
2267
|
+
if (ctx.auditRepo) {
|
|
2268
|
+
ctx.auditRepo.log({
|
|
2269
|
+
merchantId: req.headers['x-merchant-id'] || '00000000-0000-0000-0000-000000000001',
|
|
2270
|
+
entityType: 'CUSTOMER',
|
|
2271
|
+
entityId: customerId,
|
|
2272
|
+
action: 'USER_UNBANNED',
|
|
2273
|
+
actorType: 'ADMIN',
|
|
2274
|
+
actorId: req.headers['x-admin-id'] || 'ADMIN_USER',
|
|
2275
|
+
details: {
|
|
2276
|
+
adminIdentity: req.headers['x-admin-id'] || 'ADMIN_USER',
|
|
2277
|
+
customerIdentity: {
|
|
2278
|
+
id: customer.id,
|
|
2279
|
+
name: customer.name,
|
|
2280
|
+
email: customer.email,
|
|
2281
|
+
phone: customer.mobileNumber
|
|
2282
|
+
},
|
|
2283
|
+
timestamp: new Date().toISOString(),
|
|
2284
|
+
previousStatus: prevStatus,
|
|
2285
|
+
newStatus: 'ACTIVE'
|
|
2286
|
+
}
|
|
2287
|
+
});
|
|
2288
|
+
}
|
|
2289
|
+
|
|
2290
|
+
// Also record security event for unban history
|
|
2291
|
+
if (ctx.customerRepo.createSecurityEvent) {
|
|
2292
|
+
ctx.customerRepo.createSecurityEvent({
|
|
2293
|
+
merchantId: req.headers['x-merchant-id'] || '00000000-0000-0000-0000-000000000001',
|
|
2294
|
+
customerId,
|
|
2295
|
+
eventType: 'USER_UNBANNED',
|
|
2296
|
+
fraudReason: 'RESTORED_BY_ADMIN',
|
|
2297
|
+
rawMessage: `Customer '${customer.name}' was unbanned by merchant/admin. Gateway access restored.`
|
|
2298
|
+
});
|
|
2299
|
+
}
|
|
2300
|
+
|
|
2301
|
+
res.status(200).json({
|
|
2302
|
+
success: true,
|
|
2303
|
+
message: `Customer ${customer.name} unbanned. Gateway access restored.`,
|
|
2304
|
+
customer: updated
|
|
2305
|
+
});
|
|
2306
|
+
});
|
|
2307
|
+
|
|
2308
|
+
// 4. POST /api/v1/admin/security/manual-ban (Manually Ban Customer with Distinct Reason)
|
|
2309
|
+
router.post('/api/v1/admin/security/manual-ban', async (req, res) => {
|
|
2310
|
+
const body = req.body || {};
|
|
2311
|
+
const customerId = body.customerId || body.id;
|
|
2312
|
+
const reason = body.reason || 'MANUAL_BAN';
|
|
2313
|
+
const notes = body.notes || 'Manually banned by merchant operator';
|
|
2314
|
+
|
|
2315
|
+
if (!customerId) {
|
|
2316
|
+
res.status(400).json({ error: 'Customer ID is required.' });
|
|
2317
|
+
return;
|
|
2318
|
+
}
|
|
2319
|
+
|
|
2320
|
+
const customer = ctx.customerRepo.findById(customerId);
|
|
2321
|
+
if (!customer) {
|
|
2322
|
+
res.status(404).json({ error: 'Customer not found.' });
|
|
2323
|
+
return;
|
|
2324
|
+
}
|
|
2325
|
+
|
|
2326
|
+
const prevStatus = customer.status;
|
|
2327
|
+
const updated = ctx.customerRepo.banCustomer(customerId, 'MANUAL_BAN', {
|
|
2328
|
+
lastDevice: body.deviceInfo || 'Manual Admin Action'
|
|
2329
|
+
});
|
|
2330
|
+
|
|
2331
|
+
if (ctx.auditRepo) {
|
|
2332
|
+
ctx.auditRepo.log({
|
|
2333
|
+
merchantId: req.headers['x-merchant-id'] || '00000000-0000-0000-0000-000000000001',
|
|
2334
|
+
entityType: 'CUSTOMER',
|
|
2335
|
+
entityId: customerId,
|
|
2336
|
+
action: 'CUSTOMER_MANUALLY_BANNED',
|
|
2337
|
+
actorType: 'ADMIN',
|
|
2338
|
+
actorId: req.headers['x-admin-id'] || 'ADMIN_USER',
|
|
2339
|
+
details: {
|
|
2340
|
+
adminIdentity: req.headers['x-admin-id'] || 'ADMIN_USER',
|
|
2341
|
+
customerId,
|
|
2342
|
+
customerName: customer.name,
|
|
2343
|
+
reason: 'MANUAL_BAN',
|
|
2344
|
+
notes,
|
|
2345
|
+
timestamp: new Date().toISOString(),
|
|
2346
|
+
previousStatus: prevStatus,
|
|
2347
|
+
newStatus: 'BANNED'
|
|
2348
|
+
}
|
|
2349
|
+
});
|
|
2350
|
+
}
|
|
2351
|
+
|
|
2352
|
+
if (ctx.customerRepo.createSecurityEvent) {
|
|
2353
|
+
ctx.customerRepo.createSecurityEvent({
|
|
2354
|
+
merchantId: req.headers['x-merchant-id'] || '00000000-0000-0000-0000-000000000001',
|
|
2355
|
+
customerId,
|
|
2356
|
+
eventType: 'MANUAL_BAN',
|
|
2357
|
+
fraudReason: 'MANUAL_BAN',
|
|
2358
|
+
rawMessage: `Manual ban applied by admin: ${notes}`
|
|
2359
|
+
});
|
|
2360
|
+
}
|
|
2361
|
+
|
|
2362
|
+
res.status(200).json({
|
|
2363
|
+
success: true,
|
|
2364
|
+
message: `Customer ${customer.name} manually banned.`,
|
|
2365
|
+
customer: updated
|
|
2366
|
+
});
|
|
2367
|
+
});
|
|
2368
|
+
|
|
2369
|
+
// 5. GET /api/v1/admin/security/events (List All Forensic Security Events)
|
|
2370
|
+
router.get('/api/v1/admin/security/events', async (req, res) => {
|
|
2371
|
+
const events = ctx.customerRepo ? ctx.customerRepo.getSecurityEvents(null, 100) : [];
|
|
2372
|
+
res.status(200).json({ events });
|
|
2373
|
+
});
|
|
2374
|
+
|
|
2375
|
+
// 6. GET /api/v1/admin/security/history (Ban/Unban History Log)
|
|
2376
|
+
router.get('/api/v1/admin/security/history', async (req, res) => {
|
|
2377
|
+
const logs = ctx.db.query(`
|
|
2378
|
+
SELECT id, entity_type, entity_id, action, actor_type, actor_id, details, created_at
|
|
2379
|
+
FROM audit_logs
|
|
2380
|
+
WHERE action IN ('CUSTOMER_AUTOMATICALLY_BANNED', 'CUSTOMER_MANUALLY_BANNED', 'USER_UNBANNED', 'TEST_MODE_FRAUD_FLAGGED_BAN_SUPPRESSED')
|
|
2381
|
+
OR entity_type = 'CUSTOMER'
|
|
2382
|
+
ORDER BY created_at DESC
|
|
2383
|
+
LIMIT 100
|
|
2384
|
+
`);
|
|
2385
|
+
const parsedLogs = logs.map(l => {
|
|
2386
|
+
let details = {};
|
|
2387
|
+
try { details = JSON.parse(l.details); } catch {}
|
|
2388
|
+
return {
|
|
2389
|
+
id: l.id,
|
|
2390
|
+
entityType: l.entity_type,
|
|
2391
|
+
entityId: l.entity_id,
|
|
2392
|
+
action: l.action,
|
|
2393
|
+
actorType: l.actor_type,
|
|
2394
|
+
actorId: l.actor_id,
|
|
2395
|
+
details,
|
|
2396
|
+
createdAt: l.created_at
|
|
2397
|
+
};
|
|
2398
|
+
});
|
|
2399
|
+
res.status(200).json({ history: parsedLogs });
|
|
2400
|
+
});
|
|
2401
|
+
|
|
2402
|
+
// 7. GET /api/v1/admin/customers (List active customers for manual ban selector)
|
|
2403
|
+
router.get('/api/v1/admin/customers', async (req, res) => {
|
|
2404
|
+
const rows = ctx.db.query('SELECT id, name, email, mobile_number, status, created_at FROM customers ORDER BY created_at DESC LIMIT 100');
|
|
2405
|
+
res.status(200).json({
|
|
2406
|
+
customers: rows.map(r => ({
|
|
2407
|
+
id: r.id,
|
|
2408
|
+
name: r.name,
|
|
2409
|
+
email: r.email,
|
|
2410
|
+
phone: r.mobile_number,
|
|
2411
|
+
status: r.status,
|
|
2412
|
+
createdAt: r.created_at
|
|
2413
|
+
}))
|
|
2414
|
+
});
|
|
2415
|
+
});
|
|
2416
|
+
}
|
|
2417
|
+
|
|
2418
|
+
|
|
2419
|
+
|
|
2420
|
+
|
|
2421
|
+
|