Z User commited on
Commit
6b807b3
·
1 Parent(s): 2b4bf9b

feat: Payment gateway — PalmPay manual transfer + Gmail IMAP verification

Browse files

DATABASE:
- payment_orders table: order_id, buyer info, expected_amount (with unique
decimal suffix), status (pending → awaiting_verification → matched/expired),
matched_email_id (prevents reuse)
- payment_rate_limits table: 1 confirm per order, 3 per IP per 10 min

EDGE FUNCTION (payment):
- create_order: generates unique order_id + unique expected_amount (base + 0.01-0.99 suffix)
- confirm_sent: validates + rate-limits, then triggers background Gmail IMAP polling
that runs every 30s for up to 10 minutes, searching for PalmPay alert emails
that match the exact amount
- check_status: returns only status + message (no email/transaction details)

GMAIL IMAP:
- Connects to imap.gmail.com:993 via TLS
- Searches INBOX for unread emails FROM 'palmpay' in last 24h
- Parses amount from email body (₦5,000.23 format)
- Matches against expected_amount (exact match including decimal suffix)
- Marks matched email as read (SEEN flag)
- Checks matched_email_id to prevent reusing the same email for two orders

SECURITY:
- GMAIL_EMAIL, GMAIL_APP_PASSWORD read from env vars only — never returned or logged
- check_status returns only status, no transaction details
- Rate limiting on confirm_sent (1 per order, 3 per IP per 10 min)
- Input validation on all endpoints

FRONTEND (payment.html):
- 3-step flow: Create order → Transfer to PalmPay → Auto-verify
- Step 1: buyer enters name/email/phone, sees order summary
- Step 2: shows exact amount (with unique decimal), PalmPay account details, copy button
- Step 3: polls check_status every 5s, shows spinner + status messages
- Success: green checkmark + link to orders
- Timeout: shows WhatsApp support fallback link
- 30-minute countdown timer

ENV VARS NEEDED (set as placeholders — user must update in Supabase dashboard):
- GMAIL_EMAIL: the Gmail address that receives PalmPay alerts
- GMAIL_APP_PASSWORD: Gmail app password (Settings → Security → 2FA → App passwords)
- PALMPAY_ACCOUNT_NAME: the name on the PalmPay account
- PALMPAY_ACCOUNT_NUMBER: the PalmPay account number

js/config/edge-functions.js CHANGED
@@ -331,6 +331,13 @@
331
  unlinkPhone: (phone) => call('cross-platform', { op: 'unlink_phone', phone }),
332
  },
333
 
 
 
 
 
 
 
 
334
  telegram: {
335
  channelInfo: () => call('telegram', { op: 'channel_info' }),
336
  recent: () => call('telegram', { op: 'recent' }),
 
331
  unlinkPhone: (phone) => call('cross-platform', { op: 'unlink_phone', phone }),
332
  },
333
 
334
+ // ---- Payment Gateway (PalmPay verification) ----
335
+ payment: {
336
+ createOrder: (data) => call('payment', { op: 'create_order', ...data }),
337
+ confirmSent: (orderId) => call('payment', { op: 'confirm_sent', orderId }),
338
+ checkStatus: (orderId) => call('payment', { op: 'check_status', orderId }),
339
+ },
340
+
341
  telegram: {
342
  channelInfo: () => call('telegram', { op: 'channel_info' }),
343
  recent: () => call('telegram', { op: 'recent' }),
js/load-navbar.js CHANGED
File without changes
payment.html ADDED
@@ -0,0 +1,338 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>Payment · Cellex</title>
7
+ <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
8
+ <script src="https://cdn.tailwindcss.com"></script>
9
+ <link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700;800&family=Sora:wght@600;700;800&display=swap" rel="stylesheet">
10
+ <script src="js/config/edge-functions.js?v=2"></script>
11
+ <style>
12
+ * { margin:0; padding:0; box-sizing:border-box; font-family:'Plus Jakarta Sans',sans-serif; }
13
+ body { background:#f8fafc; color:#0f172a; }
14
+ .pay-card { background:#fff; border-radius:20px; box-shadow:0 4px 20px rgba(0,212,255,0.08); padding:2rem; border:1px solid rgba(0,212,255,0.1); }
15
+ .btn-cyan { background:linear-gradient(135deg,#00d4ff,#0891b2); color:#0f172a; font-weight:700; transition:all 0.3s; }
16
+ .btn-cyan:hover { box-shadow:0 0 30px rgba(0,212,255,0.4); transform:translateY(-1px); }
17
+ .btn-cyan:disabled { opacity:0.5; cursor:not-allowed; transform:none; box-shadow:none; }
18
+ .amount-box { background:linear-gradient(135deg,#0f172a,#1e293b); border-radius:16px; padding:1.5rem; text-align:center; }
19
+ .amount-big { font-size:2.5rem; font-weight:800; color:#00d4ff; font-family:'Sora',sans-serif; }
20
+ .step-circle { width:32px; height:32px; border-radius:50%; background:#e2e8f0; color:#64748b; display:flex; align-items:center; justify-content:center; font-weight:700; font-size:0.85rem; flex-shrink:0; }
21
+ .step-circle.active { background:linear-gradient(135deg,#00d4ff,#0891b2); color:#0f172a; }
22
+ .step-circle.done { background:#22c55e; color:#fff; }
23
+ .spinner { width:20px; height:20px; border:3px solid rgba(0,212,255,0.2); border-top-color:#00d4ff; border-radius:50%; animation:spin 0.8s linear infinite; }
24
+ @keyframes spin { to { transform:rotate(360deg); } }
25
+ .logo-text { background:linear-gradient(135deg,#00d4ff,#0891b2); -webkit-background-clip:text; -webkit-text-fill-color:transparent; background-clip:text; }
26
+ .copy-btn { background:#f1f5f9; border:none; padding:0.4rem 0.8rem; border-radius:8px; cursor:pointer; font-size:0.8rem; color:#0891b2; transition:all 0.2s; }
27
+ .copy-btn:hover { background:#e0f2fe; }
28
+ .status-pending { color:#f59e0b; }
29
+ .status-checking { color:#00d4ff; }
30
+ .status-matched { color:#22c55e; }
31
+ .status-expired { color:#ef4444; }
32
+ </style>
33
+ </head>
34
+ <body>
35
+ <div id="nav-mount"></div>
36
+ <script src="js/load-navbar.js"></script>
37
+
38
+ <main class="max-w-lg mx-auto px-4 py-6 pb-24 md:pb-6">
39
+ <!-- Step indicator -->
40
+ <div class="flex items-center justify-center gap-2 mb-6">
41
+ <div id="step1" class="step-circle active">1</div>
42
+ <div class="w-8 h-0.5 bg-gray-200"></div>
43
+ <div id="step2" class="step-circle">2</div>
44
+ <div class="w-8 h-0.5 bg-gray-200"></div>
45
+ <div id="step3" class="step-circle">3</div>
46
+ </div>
47
+
48
+ <!-- Step 1: Create order -->
49
+ <div id="step-create" class="pay-card">
50
+ <h2 class="text-xl font-bold mb-1">Complete your payment</h2>
51
+ <p class="text-gray-500 text-sm mb-4">Enter your details to generate a payment order</p>
52
+
53
+ <div class="space-y-3">
54
+ <div>
55
+ <label class="block text-sm font-medium mb-1">Full name</label>
56
+ <input type="text" id="name" class="w-full px-3 py-2 border border-gray-300 rounded-xl focus:border-cyan-500 focus:outline-none" placeholder="John Doe">
57
+ </div>
58
+ <div>
59
+ <label class="block text-sm font-medium mb-1">Email</label>
60
+ <input type="email" id="email" class="w-full px-3 py-2 border border-gray-300 rounded-xl focus:border-cyan-500 focus:outline-none" placeholder="john@example.com">
61
+ </div>
62
+ <div>
63
+ <label class="block text-sm font-medium mb-1">Phone (optional)</label>
64
+ <input type="tel" id="phone" class="w-full px-3 py-2 border border-gray-300 rounded-xl focus:border-cyan-500 focus:outline-none" placeholder="+234 801 234 5678">
65
+ </div>
66
+ </div>
67
+
68
+ <!-- Order summary -->
69
+ <div class="mt-4 bg-gray-50 rounded-xl p-3">
70
+ <div class="text-xs font-bold uppercase tracking-wide text-gray-500 mb-1">Order Summary</div>
71
+ <div id="items-summary" class="text-sm text-gray-700">—</div>
72
+ <div class="flex justify-between mt-2 pt-2 border-t border-gray-200">
73
+ <span class="font-bold">Total</span>
74
+ <span class="font-bold text-cyan-600" id="total-amount">₦—</span>
75
+ </div>
76
+ </div>
77
+
78
+ <button id="create-btn" class="btn-cyan w-full py-3 rounded-xl mt-4 flex items-center justify-center gap-2">
79
+ <span>Generate Payment Order</span>
80
+ </button>
81
+ </div>
82
+
83
+ <!-- Step 2: Transfer + confirm -->
84
+ <div id="step-transfer" class="pay-card hidden">
85
+ <h2 class="text-xl font-bold mb-1">Transfer to PalmPay</h2>
86
+ <p class="text-gray-500 text-sm mb-4">Transfer the EXACT amount below (including decimals)</p>
87
+
88
+ <!-- Amount box -->
89
+ <div class="amount-box mb-4">
90
+ <div class="text-xs text-gray-400 uppercase tracking-wide mb-1">Amount to transfer</div>
91
+ <div class="amount-big" id="display-amount">₦—</div>
92
+ <div class="text-xs text-gray-400 mt-1">The decimal (.XX) is unique to your order</div>
93
+ </div>
94
+
95
+ <!-- Bank details -->
96
+ <div class="bg-gray-50 rounded-xl p-3 space-y-2">
97
+ <div class="flex justify-between items-center">
98
+ <span class="text-sm text-gray-500">Account Name</span>
99
+ <span class="font-medium text-sm" id="acct-name">—</span>
100
+ </div>
101
+ <div class="flex justify-between items-center">
102
+ <span class="text-sm text-gray-500">Account Number</span>
103
+ <div class="flex items-center gap-2">
104
+ <span class="font-bold text-sm font-mono" id="acct-number">—</span>
105
+ <button class="copy-btn" onclick="copyText(document.getElementById('acct-number').textContent, this)">Copy</button>
106
+ </div>
107
+ </div>
108
+ <div class="flex justify-between items-center">
109
+ <span class="text-sm text-gray-500">Bank</span>
110
+ <span class="font-medium text-sm" id="acct-bank">—</span>
111
+ </div>
112
+ </div>
113
+
114
+ <div class="mt-4 bg-cyan-50 border border-cyan-200 rounded-xl p-3 text-sm text-cyan-800">
115
+ <i class="fas fa-info-circle mr-1"></i>
116
+ Transfer <strong>exactly</strong> the amount shown (including the decimal). This is how we auto-verify your payment.
117
+ </div>
118
+
119
+ <button id="confirm-btn" class="btn-cyan w-full py-3 rounded-xl mt-4 flex items-center justify-center gap-2">
120
+ <i class="fas fa-check"></i>
121
+ <span>I've sent it</span>
122
+ </button>
123
+
124
+ <p class="text-xs text-gray-400 text-center mt-2">Order expires in <span id="countdown">30:00</span></p>
125
+ </div>
126
+
127
+ <!-- Step 3: Verifying -->
128
+ <div id="step-verifying" class="pay-card hidden text-center">
129
+ <div class="flex justify-center mb-4">
130
+ <div class="spinner" style="width:48px;height:48px;border-width:4px;"></div>
131
+ </div>
132
+ <h2 class="text-xl font-bold mb-1">Checking for your payment…</h2>
133
+ <p class="text-gray-500 text-sm mb-4">We're monitoring our PalmPay account for your transfer. This usually takes 1-3 minutes.</p>
134
+
135
+ <div class="bg-gray-50 rounded-xl p-3">
136
+ <div class="text-xs text-gray-500 uppercase tracking-wide">Status</div>
137
+ <div id="verify-status" class="font-bold text-cyan-600 text-lg mt-1">Checking…</div>
138
+ <div id="verify-message" class="text-sm text-gray-500 mt-1"></div>
139
+ </div>
140
+
141
+ <div id="support-fallback" class="hidden mt-4 bg-red-50 border border-red-200 rounded-xl p-3 text-sm text-red-700">
142
+ <p class="font-bold mb-1">Verification timed out</p>
143
+ <p>If you've already sent the payment, please contact support:</p>
144
+ <a href="https://wa.me/2348012345678?text=Payment%20issue%20-%20Order%20" id="support-link" class="text-cyan-600 underline mt-1 inline-block">WhatsApp Support</a>
145
+ </div>
146
+ </div>
147
+
148
+ <!-- Success -->
149
+ <div id="step-success" class="pay-card hidden text-center">
150
+ <div class="w-16 h-16 mx-auto rounded-full bg-green-100 flex items-center justify-center mb-3">
151
+ <i class="fas fa-check text-3xl text-green-500"></i>
152
+ </div>
153
+ <h2 class="text-xl font-bold mb-1 text-green-600">Payment Verified!</h2>
154
+ <p class="text-gray-500 text-sm mb-4">Your order has been confirmed. You'll receive an email shortly.</p>
155
+ <a href="orders.html" class="btn-cyan inline-block px-6 py-3 rounded-xl">View Orders</a>
156
+ </div>
157
+ </main>
158
+
159
+ <script>
160
+ let currentOrderId = null;
161
+ let statusPollTimer = null;
162
+ let countdownTimer = null;
163
+
164
+ // Parse cart/order info from URL params or use defaults
165
+ const params = new URLSearchParams(location.search);
166
+ const cartTotal = parseFloat(params.get('total') || '0');
167
+ const cartItems = params.get('items') || 'Items from cart';
168
+ const cartCount = parseInt(params.get('count') || '1');
169
+
170
+ // Pre-fill
171
+ if (cartTotal > 0) {
172
+ document.getElementById('total-amount').textContent = `₦${cartTotal.toLocaleString('en-US', {minimumFractionDigits: 2})}`;
173
+ document.getElementById('items-summary').textContent = cartItems;
174
+ }
175
+
176
+ // Pre-fill user info if logged in
177
+ (async () => {
178
+ const sess = await window.EdgeFunctions.auth.checkSession();
179
+ if (sess.success && sess.user) {
180
+ document.getElementById('email').value = sess.user.email || '';
181
+ document.getElementById('name').value = sess.user.email?.split('@')[0] || '';
182
+ }
183
+ })();
184
+
185
+ // Step 1: Create order
186
+ document.getElementById('create-btn').addEventListener('click', async () => {
187
+ const name = document.getElementById('name').value.trim();
188
+ const email = document.getElementById('email').value.trim();
189
+ const phone = document.getElementById('phone').value.trim();
190
+
191
+ if (!name || !email) {
192
+ alert('Please enter your name and email');
193
+ return;
194
+ }
195
+ if (cartTotal <= 0) {
196
+ alert('No items to pay for. Go to your cart first.');
197
+ return;
198
+ }
199
+
200
+ const btn = document.getElementById('create-btn');
201
+ btn.disabled = true;
202
+ btn.innerHTML = '<div class="spinner"></div> Creating order…';
203
+
204
+ try {
205
+ const r = await window.EdgeFunctions.payment.createOrder({
206
+ buyerName: name,
207
+ buyerEmail: email,
208
+ buyerPhone: phone || null,
209
+ itemsSummary: cartItems,
210
+ itemCount: cartCount,
211
+ total: cartTotal,
212
+ });
213
+
214
+ if (r.success) {
215
+ currentOrderId = r.orderId;
216
+ // Show transfer step
217
+ document.getElementById('step-create').classList.add('hidden');
218
+ document.getElementById('step-transfer').classList.remove('hidden');
219
+ document.getElementById('step1').classList.add('done');
220
+ document.getElementById('step2').classList.add('active');
221
+
222
+ // Display payment info
223
+ document.getElementById('display-amount').textContent = `₦${r.expectedAmount.toLocaleString('en-US', {minimumFractionDigits: 2})}`;
224
+ document.getElementById('acct-name').textContent = r.palmpayAccount.name;
225
+ document.getElementById('acct-number').textContent = r.palmpayAccount.number;
226
+ document.getElementById('acct-bank').textContent = r.palmpayAccount.bank;
227
+
228
+ // Start countdown
229
+ startCountdown(r.expiresAt);
230
+ } else {
231
+ alert('Failed: ' + (r.error || 'Unknown error'));
232
+ btn.disabled = false;
233
+ btn.innerHTML = '<span>Generate Payment Order</span>';
234
+ }
235
+ } catch (e) {
236
+ alert('Error: ' + e.message);
237
+ btn.disabled = false;
238
+ btn.innerHTML = '<span>Generate Payment Order</span>';
239
+ }
240
+ });
241
+
242
+ // Step 2: Confirm sent
243
+ document.getElementById('confirm-btn').addEventListener('click', async () => {
244
+ if (!currentOrderId) return;
245
+
246
+ const btn = document.getElementById('confirm-btn');
247
+ btn.disabled = true;
248
+ btn.innerHTML = '<div class="spinner"></div> Starting verification…';
249
+
250
+ try {
251
+ const r = await window.EdgeFunctions.payment.confirmSent(currentOrderId);
252
+ if (r.success) {
253
+ // Show verifying step
254
+ document.getElementById('step-transfer').classList.add('hidden');
255
+ document.getElementById('step-verifying').classList.remove('hidden');
256
+ document.getElementById('step2').classList.add('done');
257
+ document.getElementById('step3').classList.add('active');
258
+
259
+ // Start polling for status
260
+ startStatusPolling();
261
+ } else {
262
+ alert('Failed: ' + (r.error || 'Unknown error'));
263
+ btn.disabled = false;
264
+ btn.innerHTML = '<i class="fas fa-check"></i> <span>I\'ve sent it</span>';
265
+ }
266
+ } catch (e) {
267
+ alert('Error: ' + e.message);
268
+ btn.disabled = false;
269
+ btn.innerHTML = '<i class="fas fa-check"></i> <span>I\'ve sent it</span>';
270
+ }
271
+ });
272
+
273
+ // Poll for status
274
+ function startStatusPolling() {
275
+ statusPollTimer = setInterval(async () => {
276
+ if (!currentOrderId) return;
277
+ try {
278
+ const r = await window.EdgeFunctions.payment.checkStatus(currentOrderId);
279
+ if (!r.success) return;
280
+
281
+ const statusEl = document.getElementById('verify-status');
282
+ const msgEl = document.getElementById('verify-message');
283
+
284
+ if (r.status === 'matched') {
285
+ clearInterval(statusPollTimer);
286
+ clearInterval(countdownTimer);
287
+ statusEl.textContent = '✓ Verified';
288
+ statusEl.className = 'font-bold text-green-600 text-lg mt-1';
289
+ msgEl.textContent = r.message;
290
+ // Show success after 2s
291
+ setTimeout(() => {
292
+ document.getElementById('step-verifying').classList.add('hidden');
293
+ document.getElementById('step-success').classList.remove('hidden');
294
+ document.getElementById('step3').classList.add('done');
295
+ }, 2000);
296
+ } else if (r.status === 'expired') {
297
+ clearInterval(statusPollTimer);
298
+ clearInterval(countdownTimer);
299
+ statusEl.textContent = 'Timed out';
300
+ statusEl.className = 'font-bold text-red-500 text-lg mt-1';
301
+ msgEl.textContent = r.message;
302
+ document.getElementById('support-fallback').classList.remove('hidden');
303
+ document.getElementById('support-link').href += currentOrderId;
304
+ } else {
305
+ statusEl.textContent = 'Checking…';
306
+ msgEl.textContent = r.message || 'Looking for your payment…';
307
+ }
308
+ } catch (e) {}
309
+ }, 5000); // Poll every 5 seconds
310
+ }
311
+
312
+ // Countdown timer
313
+ function startCountdown(expiresAt) {
314
+ const expiry = new Date(expiresAt).getTime();
315
+ countdownTimer = setInterval(() => {
316
+ const remaining = expiry - Date.now();
317
+ if (remaining <= 0) {
318
+ clearInterval(countdownTimer);
319
+ document.getElementById('countdown').textContent = '00:00';
320
+ return;
321
+ }
322
+ const mins = Math.floor(remaining / 60000);
323
+ const secs = Math.floor((remaining % 60000) / 1000);
324
+ document.getElementById('countdown').textContent = `${mins}:${secs.toString().padStart(2, '0')}`;
325
+ }, 1000);
326
+ }
327
+
328
+ // Copy to clipboard
329
+ function copyText(text, btn) {
330
+ navigator.clipboard.writeText(text).then(() => {
331
+ const orig = btn.textContent;
332
+ btn.textContent = '✓ Copied';
333
+ setTimeout(() => btn.textContent = orig, 1500);
334
+ });
335
+ }
336
+ </script>
337
+ </body>
338
+ </html>
supabase/functions/payment/index.ts ADDED
@@ -0,0 +1,459 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /// <reference lib="deno.ns" />
2
+ // Cellex Payment Gateway — PalmPay manual transfer verification
3
+ // ====================================================================
4
+ // Customers transfer money to PalmPay, we verify by checking Gmail for
5
+ // the PalmPay transaction alert email that matches the order amount.
6
+ //
7
+ // API:
8
+ // op=create_order body: { buyerName, buyerEmail, buyerPhone?, itemsSummary, itemCount, total }
9
+ // → { orderId, expectedAmount, palmpayAccount, expiresAt }
10
+ // op=confirm_sent body: { orderId }
11
+ // → { success, message } (starts Gmail IMAP polling in background)
12
+ // op=check_status body: { orderId }
13
+ // → { status, expectedAmount, expiresAt }
14
+ //
15
+ // SECURITY:
16
+ // - Gmail credentials are read from env vars, never returned or logged
17
+ // - Rate limiting: 1 confirm_sent per order, 3 per IP per 10 min
18
+ // - check_status only returns status, no email/transaction details
19
+ // - IMAP connection uses TLS
20
+
21
+ import {
22
+ corsHeaders, jsonResponse, errorResponse, getUser,
23
+ } from '../_shared/cors.ts';
24
+
25
+ const SUPABASE_URL = Deno.env.get('SUPABASE_URL')!;
26
+ const SERVICE_KEY = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!;
27
+ const GMAIL_EMAIL = Deno.env.get('GMAIL_EMAIL') || '';
28
+ const GMAIL_APP_PASSWORD = Deno.env.get('GMAIL_APP_PASSWORD') || '';
29
+ const PALMPAY_ACCOUNT_NAME = Deno.env.get('PALMPAY_ACCOUNT_NAME') || 'Cellex Store';
30
+ const PALMPAY_ACCOUNT_NUMBER = Deno.env.get('PALMPAY_ACCOUNT_NUMBER') || '0000000000';
31
+ const PALMPAY_BANK = Deno.env.get('PALMPAY_BANK') || 'PalmPay';
32
+
33
+ const adminHeaders = {
34
+ 'apikey': SERVICE_KEY,
35
+ 'Authorization': `Bearer ${SERVICE_KEY}`,
36
+ 'Content-Type': 'application/json',
37
+ };
38
+
39
+ // Amount suffix counter — generates unique decimal suffixes (0.01 - 0.99)
40
+ let suffixCounter = Math.floor(Math.random() * 99) + 1;
41
+
42
+ function generateOrderId(): string {
43
+ const ts = Date.now().toString(36).toUpperCase();
44
+ const rand = Math.random().toString(36).substring(2, 6).toUpperCase();
45
+ return `CELLEX-${ts}${rand}`;
46
+ }
47
+
48
+ function generateUniqueAmount(baseAmount: number): number {
49
+ // Add a unique decimal suffix (1-99 kobo) to disambiguate
50
+ suffixCounter = (suffixCounter % 99) + 1;
51
+ const base = Math.floor(baseAmount);
52
+ const suffix = suffixCounter / 100; // 0.01 to 0.99
53
+ return Math.round((base + suffix) * 100) / 100;
54
+ }
55
+
56
+ Deno.serve(async (req: Request) => {
57
+ if (req.method === 'OPTIONS') return new Response('ok', { headers: corsHeaders });
58
+ if (req.method !== 'POST') return errorResponse('Method not allowed', 405);
59
+
60
+ try {
61
+ const body = await req.json().catch(() => ({}));
62
+ const op = body.op || '';
63
+
64
+ switch (op) {
65
+ case 'create_order': return await handleCreateOrder(req, body);
66
+ case 'confirm_sent': return await handleConfirmSent(req, body);
67
+ case 'check_status': return await handleCheckStatus(body);
68
+ default: return errorResponse(`Unknown op: ${op}`, 400);
69
+ }
70
+ } catch (error) {
71
+ console.error('Payment error:', error);
72
+ return errorResponse(`Internal error: ${error instanceof Error ? error.message : String(error)}`, 500);
73
+ }
74
+ });
75
+
76
+ // ---------------------------------------------------------------------------
77
+ // 1. CREATE ORDER
78
+ // ---------------------------------------------------------------------------
79
+ async function handleCreateOrder(req: Request, body: Record<string, unknown>): Promise<Response> {
80
+ // Validate inputs
81
+ const buyerName = (body.buyerName as string)?.trim();
82
+ const buyerEmail = (body.buyerEmail as string)?.trim();
83
+ const buyerPhone = (body.buyerPhone as string)?.trim() || null;
84
+ const itemsSummary = (body.itemsSummary as string)?.trim();
85
+ const itemCount = Number(body.itemCount) || 1;
86
+ const total = Number(body.total);
87
+
88
+ if (!buyerName || !buyerEmail || !itemsSummary || !total || total <= 0) {
89
+ return errorResponse('Missing required fields: buyerName, buyerEmail, itemsSummary, total', 400);
90
+ }
91
+
92
+ // Get user if logged in (optional)
93
+ const user = await getUser(req);
94
+
95
+ // Generate unique order ID + amount
96
+ const orderId = generateOrderId();
97
+ const expectedAmount = generateUniqueAmount(total);
98
+
99
+ // Insert into database
100
+ const resp = await fetch(`${SUPABASE_URL}/rest/v1/payment_orders`, {
101
+ method: 'POST',
102
+ headers: { ...adminHeaders, 'Prefer': 'return=representation' },
103
+ body: JSON.stringify({
104
+ order_id: orderId,
105
+ buyer_id: user?.id || null,
106
+ buyer_email: buyerEmail,
107
+ buyer_name: buyerName,
108
+ buyer_phone: buyerPhone,
109
+ expected_amount: expectedAmount,
110
+ items_summary: itemsSummary,
111
+ item_count: itemCount,
112
+ status: 'pending',
113
+ }),
114
+ });
115
+
116
+ if (!resp.ok) {
117
+ const err = await resp.text();
118
+ return errorResponse(`Failed to create order: ${err}`, 500);
119
+ }
120
+
121
+ const order = (await resp.json())[0];
122
+
123
+ return jsonResponse({
124
+ success: true,
125
+ orderId: order.order_id,
126
+ expectedAmount: Number(order.expected_amount),
127
+ palmpayAccount: {
128
+ name: PALMPAY_ACCOUNT_NAME,
129
+ number: PALMPAY_ACCOUNT_NUMBER,
130
+ bank: PALMPAY_BANK,
131
+ },
132
+ expiresAt: order.expires_at,
133
+ instructions: `Transfer exactly ₦${Number(order.expected_amount).toFixed(2)} to ${PALMPAY_ACCOUNT_NAME}, ${PALMPAY_ACCOUNT_NUMBER} (${PALMPAY_BANK}). The decimal amount (.${String(order.expected_amount).split('.')[1] || '00'}) is unique to your order — it helps us verify your payment automatically.`,
134
+ });
135
+ }
136
+
137
+ // ---------------------------------------------------------------------------
138
+ // 2. CONFIRM SENT — triggers Gmail IMAP polling
139
+ // ---------------------------------------------------------------------------
140
+ async function handleConfirmSent(req: Request, body: Record<string, unknown>): Promise<Response> {
141
+ const orderId = (body.orderId as string)?.trim();
142
+ if (!orderId) return errorResponse('orderId is required', 400);
143
+
144
+ // Get client IP for rate limiting
145
+ const clientIP = req.headers.get('x-forwarded-for')?.split(',')[0] || 'unknown';
146
+
147
+ // Rate limit check: max 1 confirm per order
148
+ const existingConfirm = await fetch(
149
+ `${SUPABASE_URL}/rest/v1/payment_rate_limits?order_id=eq.${encodeURIComponent(orderId)}&select=id&limit=1`,
150
+ { headers: adminHeaders }
151
+ );
152
+ const existing = await existingConfirm.json();
153
+ if (existing?.length > 0) {
154
+ return errorResponse('Verification already started for this order. Please wait.', 429);
155
+ }
156
+
157
+ // Rate limit check: max 3 per IP per 10 minutes
158
+ const ipCheck = await fetch(
159
+ `${SUPABASE_URL}/rest/v1/payment_rate_limits?ip_address=eq.${encodeURIComponent(clientIP)}&created_at=gte.${encodeURIComponent(new Date(Date.now() - 10 * 60 * 1000).toISOString())}&select=id`,
160
+ { headers: adminHeaders }
161
+ );
162
+ const ipEntries = await ipCheck.json();
163
+ if (ipEntries?.length >= 3) {
164
+ return errorResponse('Too many verification attempts. Please wait 10 minutes.', 429);
165
+ }
166
+
167
+ // Fetch the order
168
+ const orderResp = await fetch(
169
+ `${SUPABASE_URL}/rest/v1/payment_orders?order_id=eq.${encodeURIComponent(orderId)}&select=*`,
170
+ { headers: adminHeaders }
171
+ );
172
+ const orders = await orderResp.json();
173
+ if (!orders?.length) return errorResponse('Order not found', 404);
174
+ const order = orders[0];
175
+
176
+ if (order.status !== 'pending') {
177
+ return errorResponse(`Order is already ${order.status}`, 400);
178
+ }
179
+
180
+ // Check expiry
181
+ if (new Date(order.expires_at) < new Date()) {
182
+ // Mark as expired
183
+ await fetch(`${SUPABASE_URL}/rest/v1/payment_orders?order_id=eq.${encodeURIComponent(orderId)}`, {
184
+ method: 'PATCH',
185
+ headers: { ...adminHeaders, 'Content-Type': 'application/json' },
186
+ body: JSON.stringify({ status: 'expired', updated_at: new Date().toISOString() }),
187
+ });
188
+ return errorResponse('Order has expired. Please create a new order.', 410);
189
+ }
190
+
191
+ // Record the rate limit entry
192
+ await fetch(`${SUPABASE_URL}/rest/v1/payment_rate_limits`, {
193
+ method: 'POST',
194
+ headers: { ...adminHeaders, 'Content-Type': 'application/json' },
195
+ body: JSON.stringify({ order_id: orderId, ip_address: clientIP }),
196
+ });
197
+
198
+ // Update order status to awaiting_verification
199
+ await fetch(`${SUPABASE_URL}/rest/v1/payment_orders?order_id=eq.${encodeURIComponent(orderId)}`, {
200
+ method: 'PATCH',
201
+ headers: { ...adminHeaders, 'Content-Type': 'application/json' },
202
+ body: JSON.stringify({
203
+ status: 'awaiting_verification',
204
+ verification_started_at: new Date().toISOString(),
205
+ updated_at: new Date().toISOString(),
206
+ }),
207
+ });
208
+
209
+ // Start Gmail IMAP polling in the background (don't await — return immediately)
210
+ pollGmailForPayment(orderId, Number(order.expected_amount)).catch((e) => {
211
+ console.error(`Gmail polling failed for order ${orderId}:`, e);
212
+ });
213
+
214
+ return jsonResponse({
215
+ success: true,
216
+ message: 'Verification started. We\'re checking for your payment. This usually takes 1-3 minutes.',
217
+ });
218
+ }
219
+
220
+ // ---------------------------------------------------------------------------
221
+ // 3. Gmail IMAP polling — runs in background for up to 10 minutes
222
+ // ---------------------------------------------------------------------------
223
+ async function pollGmailForPayment(orderId: string, expectedAmount: number): Promise<void> {
224
+ if (!GMAIL_EMAIL || !GMAIL_APP_PASSWORD) {
225
+ console.warn('GMAIL_EMAIL or GMAIL_APP_PASSWORD not set — cannot verify payment');
226
+ // Mark as expired after 1 minute
227
+ await updateOrderStatus(orderId, 'expired', null);
228
+ return;
229
+ }
230
+
231
+ const maxAttempts = 20; // 20 attempts × 30s = 10 minutes
232
+ const intervalMs = 30000; // 30 seconds
233
+
234
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
235
+ // Check if order is still awaiting verification (might have been cancelled)
236
+ const checkResp = await fetch(
237
+ `${SUPABASE_URL}/rest/v1/payment_orders?order_id=eq.${encodeURIComponent(orderId)}&select=status`,
238
+ { headers: adminHeaders }
239
+ );
240
+ const checkData = await checkResp.json();
241
+ if (!checkData?.length || checkData[0].status !== 'awaiting_verification') {
242
+ return; // Order was cancelled or already matched
243
+ }
244
+
245
+ try {
246
+ // Connect to Gmail via IMAP and search for PalmPay emails
247
+ const matched = await searchGmailForPayment(expectedAmount);
248
+
249
+ if (matched) {
250
+ // Found a matching email! Mark the order as matched.
251
+ await fetch(`${SUPABASE_URL}/rest/v1/payment_orders?order_id=eq.${encodeURIComponent(orderId)}`, {
252
+ method: 'PATCH',
253
+ headers: { ...adminHeaders, 'Content-Type': 'application/json' },
254
+ body: JSON.stringify({
255
+ status: 'matched',
256
+ matched_at: new Date().toISOString(),
257
+ matched_email_id: matched.messageId,
258
+ matched_sender_name: matched.senderName,
259
+ matched_amount: matched.amount,
260
+ updated_at: new Date().toISOString(),
261
+ }),
262
+ });
263
+ console.log(`✅ Payment matched for order ${orderId}: ${matched.senderName} sent ₦${matched.amount}`);
264
+ return;
265
+ }
266
+ } catch (e) {
267
+ console.error(`Gmail check attempt ${attempt + 1} failed:`, e);
268
+ }
269
+
270
+ // Wait before next attempt
271
+ await new Promise(resolve => setTimeout(resolve, intervalMs));
272
+ }
273
+
274
+ // No match found within 10 minutes — mark as expired
275
+ await updateOrderStatus(orderId, 'expired', null);
276
+ console.log(`⏰ Order ${orderId} expired — no matching payment found`);
277
+ }
278
+
279
+ // ---------------------------------------------------------------------------
280
+ // Gmail IMAP search — connects to Gmail, searches for PalmPay emails
281
+ // ---------------------------------------------------------------------------
282
+ async function searchGmailForPayment(expectedAmount: number): Promise<{ messageId: string; senderName: string; amount: number } | null> {
283
+ // Connect to Gmail IMAP using Deno's TCP socket + TLS
284
+ // We use a raw IMAP implementation since Deno doesn't have a built-in IMAP library
285
+
286
+ const IMAP_HOST = 'imap.gmail.com';
287
+ const IMAP_PORT = 993;
288
+
289
+ // Connect via TLS
290
+ const conn = await Deno.connectTls({ hostname: IMAP_HOST, port: IMAP_PORT });
291
+ const decoder = new TextDecoder();
292
+ const encoder = new TextEncoder();
293
+
294
+ // Read until we see the server greeting
295
+ await readUntil(conn, decoder, '\r\n');
296
+
297
+ // Login
298
+ const tag = `A${Date.now()}`;
299
+ await writeLine(conn, encoder, `${tag} LOGIN ${GMAIL_EMAIL} ${GMAIL_APP_PASSWORD}\r\n`);
300
+ const loginResp = await readUntil(conn, decoder, `${tag} OK`);
301
+ if (loginResp.includes('BAD') || loginResp.includes('NO')) {
302
+ conn.close();
303
+ throw new Error('Gmail login failed');
304
+ }
305
+
306
+ // Select the "PalmPay" label/folder (or INBOX if no label)
307
+ const folderTag = `${tag}1`;
308
+ await writeLine(conn, encoder, `${folderTag} SELECT INBOX\r\n`);
309
+ await readUntil(conn, decoder, `${folderTag} OK`);
310
+
311
+ // Search for unread emails from PalmPay (last 24 hours)
312
+ const searchTag = `${tag}2`;
313
+ const sinceDate = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString().split('T')[0].replace(/-/g, '-');
314
+ await writeLine(conn, encoder, `${searchTag} SEARCH UNSEEN FROM "palmpay" SINCE ${sinceDate}\r\n`);
315
+ const searchResp = await readUntil(conn, decoder, `${searchTag} OK`);
316
+
317
+ // Parse search results — format: * SEARCH 1 2 3 4
318
+ const searchMatch = searchResp.match(/\* SEARCH (.+)/);
319
+ if (!searchMatch) {
320
+ // No unread PalmPay emails
321
+ await logoutImap(conn, encoder, decoder, tag);
322
+ return null;
323
+ }
324
+
325
+ const messageIds = searchMatch[1].trim().split(/\s+/);
326
+
327
+ // Check each email for matching amount
328
+ for (const msgId of messageIds) {
329
+ const fetchTag = `${tag}3`;
330
+ await writeLine(conn, encoder, `${fetchTag} FETCH ${msgId} (BODY.PEEK[HEADER.FIELDS (FROM SUBJECT)] BODY.PEEK[TEXT])\r\n`);
331
+ const fetchResp = await readUntil(conn, decoder, `${fetchTag} OK`);
332
+
333
+ // Parse the email for amount
334
+ const amount = parseAmountFromEmail(fetchResp);
335
+ const senderName = parseSenderFromEmail(fetchResp);
336
+
337
+ if (amount !== null && Math.abs(amount - expectedAmount) < 0.01) {
338
+ // Amount matches! Check that this email hasn't been used for another order
339
+ const usedCheck = await fetch(
340
+ `${SUPABASE_URL}/rest/v1/payment_orders?matched_email_id=not.is.null&select=matched_email_id`,
341
+ { headers: adminHeaders }
342
+ );
343
+ const usedEmails = await usedCheck.json();
344
+ const usedIds = new Set((usedEmails || []).map((o: Record<string, unknown>) => o.matched_email_id));
345
+
346
+ // Generate a unique email ID (folder + message number)
347
+ const emailUid = `INBOX:${msgId}`;
348
+
349
+ if (!usedIds.has(emailUid)) {
350
+ // Mark the email as read (SEEN)
351
+ const seenTag = `${tag}4`;
352
+ await writeLine(conn, encoder, `${seenTag} STORE ${msgId} +FLAGS (\Seen)\r\n`);
353
+ await readUntil(conn, decoder, `${seenTag} OK`);
354
+
355
+ await logoutImap(conn, encoder, decoder, tag);
356
+ return { messageId: emailUid, senderName, amount };
357
+ }
358
+ }
359
+ }
360
+
361
+ await logoutImap(conn, encoder, decoder, tag);
362
+ return null;
363
+ }
364
+
365
+ // Parse amount from PalmPay email body
366
+ function parseAmountFromEmail(emailText: string): number | null {
367
+ // PalmPay emails typically contain: "Amount: ₦5,000.23" or "You received ₦5,000.23"
368
+ const patterns = [
369
+ /Amount:\s*₦?\s*([\d,]+\.?\d*)/i,
370
+ /received\s*₦?\s*([\d,]+\.?\d*)/i,
371
+ /₦\s*([\d,]+\.?\d*)/i,
372
+ /NGN\s*([\d,]+\.?\d*)/i,
373
+ ];
374
+
375
+ for (const pattern of patterns) {
376
+ const match = emailText.match(pattern);
377
+ if (match) {
378
+ const amount = parseFloat(match[1].replace(/,/g, ''));
379
+ if (!isNaN(amount) && amount > 0) return Math.round(amount * 100) / 100;
380
+ }
381
+ }
382
+ return null;
383
+ }
384
+
385
+ // Parse sender name from email
386
+ function parseSenderFromEmail(emailText: string): string {
387
+ const fromMatch = emailText.match(/From:\s*(.+)/i);
388
+ if (fromMatch) return fromMatch[1].trim().split('<')[0].trim().replace(/"/g, '');
389
+ return 'Unknown';
390
+ }
391
+
392
+ // IMAP helper: read until we find a specific string
393
+ async function readUntil(conn: Deno.TlsConn, decoder: TextDecoder, until: string): Promise<string> {
394
+ let buf = '';
395
+ const chunk = new Uint8Array(4096);
396
+ while (!buf.includes(until)) {
397
+ const n = await conn.read(chunk);
398
+ if (n === null) break;
399
+ buf += decoder.decode(chunk.subarray(0, n));
400
+ }
401
+ return buf;
402
+ }
403
+
404
+ // IMAP helper: write a line
405
+ async function writeLine(conn: Deno.TlsConn, encoder: TextEncoder, text: string): Promise<void> {
406
+ await conn.write(encoder.encode(text));
407
+ }
408
+
409
+ // IMAP helper: logout
410
+ async function logoutImap(conn: Deno.TlsConn, encoder: TextEncoder, decoder: TextDecoder, tag: string): Promise<void> {
411
+ try {
412
+ const logoutTag = `${tag}99`;
413
+ await writeLine(conn, encoder, `${logoutTag} LOGOUT\r\n`);
414
+ await readUntil(conn, decoder, logoutTag);
415
+ } catch {}
416
+ conn.close();
417
+ }
418
+
419
+ // ---------------------------------------------------------------------------
420
+ // 4. CHECK STATUS — returns only status, no sensitive details
421
+ // ---------------------------------------------------------------------------
422
+ async function handleCheckStatus(body: Record<string, unknown>): Promise<Response> {
423
+ const orderId = (body.orderId as string)?.trim();
424
+ if (!orderId) return errorResponse('orderId is required', 400);
425
+
426
+ const resp = await fetch(
427
+ `${SUPABASE_URL}/rest/v1/payment_orders?order_id=eq.${encodeURIComponent(orderId)}&select=status,expected_amount,expires_at,created_at&limit=1`,
428
+ { headers: adminHeaders }
429
+ );
430
+ const orders = await resp.json();
431
+ if (!orders?.length) return errorResponse('Order not found', 404);
432
+ const order = orders[0];
433
+
434
+ return jsonResponse({
435
+ success: true,
436
+ status: order.status,
437
+ expectedAmount: Number(order.expected_amount),
438
+ expiresAt: order.expires_at,
439
+ createdAt: order.created_at,
440
+ // Only return matched info if matched (no email details)
441
+ ...(order.status === 'matched' ? { message: 'Payment verified! Your order is confirmed.' } : {}),
442
+ ...(order.status === 'expired' ? { message: 'Payment verification timed out. Please contact support.' } : {}),
443
+ ...(order.status === 'awaiting_verification' ? { message: 'Checking for your payment...' } : {}),
444
+ ...(order.status === 'pending' ? { message: 'Click "I\'ve sent it" after making the transfer.' } : {}),
445
+ });
446
+ }
447
+
448
+ // ---------------------------------------------------------------------------
449
+ // Helper: update order status
450
+ // ---------------------------------------------------------------------------
451
+ async function updateOrderStatus(orderId: string, status: string, matchedEmailId: string | null): Promise<void> {
452
+ const updates: Record<string, unknown> = { status, updated_at: new Date().toISOString() };
453
+ if (matchedEmailId) updates.matched_email_id = matchedEmailId;
454
+ await fetch(`${SUPABASE_URL}/rest/v1/payment_orders?order_id=eq.${encodeURIComponent(orderId)}`, {
455
+ method: 'PATCH',
456
+ headers: { ...adminHeaders, 'Content-Type': 'application/json' },
457
+ body: JSON.stringify(updates),
458
+ });
459
+ }
web-server/server.py CHANGED
@@ -324,6 +324,11 @@ async def proxy_cross_platform(request: Request):
324
  async def proxy_telegram(request: Request):
325
  return await _proxy_to_edge_function("telegram", request)
326
 
 
 
 
 
 
327
  # ---- Phase 4: OpenWA gateway proxy (avoids CORS issues from browser) ----
328
  OPENWA_BASE_URL = os.environ.get("OPENWA_BASE_URL", "https://eesha-search.onrender.com")
329
  OPENWA_API_KEY = os.environ.get("OPENWA_API_KEY", "CellexWA2024")
 
324
  async def proxy_telegram(request: Request):
325
  return await _proxy_to_edge_function("telegram", request)
326
 
327
+ # ---- Payment Gateway (PalmPay verification) ----
328
+ @app.post("/api/payment")
329
+ async def proxy_payment(request: Request):
330
+ return await _proxy_to_edge_function("payment", request)
331
+
332
  # ---- Phase 4: OpenWA gateway proxy (avoids CORS issues from browser) ----
333
  OPENWA_BASE_URL = os.environ.get("OPENWA_BASE_URL", "https://eesha-search.onrender.com")
334
  OPENWA_API_KEY = os.environ.get("OPENWA_API_KEY", "CellexWA2024")