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

feat: AI-powered payment verification (Qwen2.5-72B) + bank name collection

Browse files

UPGRADES:
1. Added buyer_bank_name field to payment_orders table + create_order endpoint
2. Frontend now collects buyer's bank name (required field)
3. Gmail IMAP polling now fetches full email content (headers + body)
4. AI matching uses Qwen2.5-72B to verify ALL THREE fields:
- Buyer name (fuzzy match — handles variations like 'John Doe' vs 'JOHN D.')
- Bank name (exact or close match)
- Amount (must match exactly, including decimal suffix)
5. AI returns structured JSON: {match, email_index, sender_name, amount}
6. Fallback: if AI fails, falls back to simple amount matching
7. After match: marks Gmail email as read (prevents reuse)

FLOW (matches the original plan):
1. User enters: full name, email, bank name
2. System generates unique amount (base + decimal suffix)
3. User transfers to PalmPay → clicks 'I've sent it'
4. AI checks Gmail for PalmPay emails matching name + bank + amount
5. On match → order confirmed, redirect to order confirmation page
6. On timeout → WhatsApp support fallback

ENV VARS SET:
- GMAIL_EMAIL=fuhandinho@gmail.com
- GMAIL_APP_PASSWORD=wttdauhasjdpqrem
- PALMPAY_ACCOUNT_NAME=Cellex Store
- PALMPAY_ACCOUNT_NUMBER=0000000000 (update with real number)
- PALMPAY_BANK=PalmPay

Files changed (2) hide show
  1. payment.html +9 -2
  2. supabase/functions/payment/index.ts +214 -24
payment.html CHANGED
@@ -63,6 +63,11 @@
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 -->
@@ -187,9 +192,10 @@
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) {
@@ -206,6 +212,7 @@
206
  buyerName: name,
207
  buyerEmail: email,
208
  buyerPhone: phone || null,
 
209
  itemsSummary: cartItems,
210
  itemCount: cartCount,
211
  total: cartTotal,
 
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
+ <label class="block text-sm font-medium mb-1">Your bank name <span class="text-red-500">*</span></label>
68
+ <input type="text" id="bank-name" class="w-full px-3 py-2 border border-gray-300 rounded-xl focus:border-cyan-500 focus:outline-none" placeholder="e.g. GTBank, Access Bank, Zenith Bank" required>
69
+ <p class="text-xs text-gray-400 mt-1">Enter the name of the bank you're transferring from (as it appears on your receipt)</p>
70
+ </div>
71
  </div>
72
 
73
  <!-- Order summary -->
 
192
  const name = document.getElementById('name').value.trim();
193
  const email = document.getElementById('email').value.trim();
194
  const phone = document.getElementById('phone').value.trim();
195
+ const bankName = document.getElementById('bank-name').value.trim();
196
 
197
+ if (!name || !email || !bankName) {
198
+ alert('Please enter your name, email, and bank name');
199
  return;
200
  }
201
  if (cartTotal <= 0) {
 
212
  buyerName: name,
213
  buyerEmail: email,
214
  buyerPhone: phone || null,
215
+ buyerBankName: bankName,
216
  itemsSummary: cartItems,
217
  itemCount: cartCount,
218
  total: cartTotal,
supabase/functions/payment/index.ts CHANGED
@@ -29,6 +29,9 @@ 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,
@@ -81,6 +84,7 @@ async function handleCreateOrder(req: Request, body: Record<string, unknown>): P
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);
@@ -106,6 +110,7 @@ async function handleCreateOrder(req: Request, body: Record<string, unknown>): P
106
  buyer_email: buyerEmail,
107
  buyer_name: buyerName,
108
  buyer_phone: buyerPhone,
 
109
  expected_amount: expectedAmount,
110
  items_summary: itemsSummary,
111
  item_count: itemCount,
@@ -207,7 +212,7 @@ async function handleConfirmSent(req: Request, body: Record<string, unknown>): P
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
 
@@ -220,10 +225,9 @@ async function handleConfirmSent(req: Request, body: Record<string, unknown>): P
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
  }
@@ -232,45 +236,231 @@ async function pollGmailForPayment(orderId: string, expectedAmount: number): Pro
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`);
 
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
+ const HF_ROUTER_URL = Deno.env.get('HF_ROUTER_URL') || 'https://router.huggingface.co/v1/chat/completions';
33
+ const HF_INFERENCE_MODEL = Deno.env.get('HF_INFERENCE_MODEL') || 'Qwen/Qwen2.5-72B-Instruct';
34
+ const HF_TOKEN = Deno.env.get('HF_TOKEN') || '';
35
 
36
  const adminHeaders = {
37
  'apikey': SERVICE_KEY,
 
84
  const buyerName = (body.buyerName as string)?.trim();
85
  const buyerEmail = (body.buyerEmail as string)?.trim();
86
  const buyerPhone = (body.buyerPhone as string)?.trim() || null;
87
+ const buyerBankName = (body.buyerBankName as string)?.trim() || null;
88
  const itemsSummary = (body.itemsSummary as string)?.trim();
89
  const itemCount = Number(body.itemCount) || 1;
90
  const total = Number(body.total);
 
110
  buyer_email: buyerEmail,
111
  buyer_name: buyerName,
112
  buyer_phone: buyerPhone,
113
+ buyer_bank_name: buyerBankName,
114
  expected_amount: expectedAmount,
115
  items_summary: itemsSummary,
116
  item_count: itemCount,
 
212
  });
213
 
214
  // Start Gmail IMAP polling in the background (don't await — return immediately)
215
+ pollGmailForPayment(orderId, Number(order.expected_amount), order.buyer_name, order.buyer_bank_name || '').catch((e) => {
216
  console.error(`Gmail polling failed for order ${orderId}:`, e);
217
  });
218
 
 
225
  // ---------------------------------------------------------------------------
226
  // 3. Gmail IMAP polling — runs in background for up to 10 minutes
227
  // ---------------------------------------------------------------------------
228
+ async function pollGmailForPayment(orderId: string, expectedAmount: number, buyerName: string, buyerBankName: string): Promise<void> {
229
  if (!GMAIL_EMAIL || !GMAIL_APP_PASSWORD) {
230
  console.warn('GMAIL_EMAIL or GMAIL_APP_PASSWORD not set — cannot verify payment');
 
231
  await updateOrderStatus(orderId, 'expired', null);
232
  return;
233
  }
 
236
  const intervalMs = 30000; // 30 seconds
237
 
238
  for (let attempt = 0; attempt < maxAttempts; attempt++) {
 
239
  const checkResp = await fetch(
240
  `${SUPABASE_URL}/rest/v1/payment_orders?order_id=eq.${encodeURIComponent(orderId)}&select=status`,
241
  { headers: adminHeaders }
242
  );
243
  const checkData = await checkResp.json();
244
  if (!checkData?.length || checkData[0].status !== 'awaiting_verification') {
245
+ return;
246
  }
247
 
248
  try {
249
+ // Connect to Gmail via IMAP and fetch recent PalmPay emails
250
+ const emails = await fetchRecentPalmPayEmails();
251
 
252
+ if (emails.length > 0) {
253
+ // Use AI to match the email against the order (name + bank + amount)
254
+ const matched = await aiMatchPayment(emails, buyerName, buyerBankName, expectedAmount);
255
+
256
+ if (matched) {
257
+ // Check that this email hasn't been used for another order
258
+ const usedCheck = await fetch(
259
+ `${SUPABASE_URL}/rest/v1/payment_orders?matched_email_id=eq.${encodeURIComponent(matched.messageId)}&select=id`,
260
+ { headers: adminHeaders }
261
+ );
262
+ const used = await usedCheck.json();
263
+ if (used?.length > 0) {
264
+ // Email already used — skip
265
+ } else {
266
+ // Mark email as read in Gmail
267
+ await markEmailAsRead(matched.gmailMsgId);
268
+
269
+ // Mark the order as matched
270
+ await fetch(`${SUPABASE_URL}/rest/v1/payment_orders?order_id=eq.${encodeURIComponent(orderId)}`, {
271
+ method: 'PATCH',
272
+ headers: { ...adminHeaders, 'Content-Type': 'application/json' },
273
+ body: JSON.stringify({
274
+ status: 'matched',
275
+ matched_at: new Date().toISOString(),
276
+ matched_email_id: matched.messageId,
277
+ matched_sender_name: matched.senderName,
278
+ matched_amount: matched.amount,
279
+ updated_at: new Date().toISOString(),
280
+ }),
281
+ });
282
+ console.log(`✅ Payment matched for order ${orderId}: ${matched.senderName} sent ₦${matched.amount}`);
283
+ return;
284
+ }
285
+ }
286
  }
287
  } catch (e) {
288
  console.error(`Gmail check attempt ${attempt + 1} failed:`, e);
289
  }
290
 
 
291
  await new Promise(resolve => setTimeout(resolve, intervalMs));
292
  }
293
 
294
+ await updateOrderStatus(orderId, 'expired', null);
295
+ console.log(`⏰ Order ${orderId} expired — no matching payment found`);
296
+ }
297
+
298
+ // ---------------------------------------------------------------------------
299
+ // AI-powered payment matching using Qwen2.5-72B
300
+ // ---------------------------------------------------------------------------
301
+ async function aiMatchPayment(emails: {msgId: string; rawContent: string}[], buyerName: string, buyerBankName: string, expectedAmount: number): Promise<{messageId: string; gmailMsgId: string; senderName: string; amount: number} | null> {
302
+ if (!HF_TOKEN) {
303
+ // Fallback: simple amount matching without AI
304
+ for (const email of emails) {
305
+ const amount = parseAmountFromEmail(email.rawContent);
306
+ const sender = parseSenderFromEmail(email.rawContent);
307
+ if (amount !== null && Math.abs(amount - expectedAmount) < 0.01) {
308
+ return { messageId: `INBOX:${email.msgId}`, gmailMsgId: email.msgId, senderName: sender, amount };
309
+ }
310
+ }
311
+ return null;
312
+ }
313
+
314
+ // Use AI to extract structured data from each email and match
315
+ const prompt = `You are a payment verification assistant. I will give you PalmPay transaction alert emails and the expected payment details. Determine which email (if any) matches the payment.
316
+
317
+ Expected payment:
318
+ - Buyer name: "${buyerName}"
319
+ - Buyer bank: "${buyerBankName || 'any'}"
320
+ - Expected amount: ₦${expectedAmount.toFixed(2)} (must match exactly, including decimals)
321
+
322
+ Emails to check:
323
+ ${emails.map((e, i) => `---EMAIL ${i + 1} (ID: ${e.msgId})---\n${e.rawContent.substring(0, 1000)}`).join('\n\n')}
324
+
325
+ Rules:
326
+ 1. The amount must match ₦${expectedAmount.toFixed(2)} exactly
327
+ 2. The sender name should be similar to "${buyerName}" (allow for minor variations)
328
+ 3. The sender bank should match "${buyerBankName}" if provided
329
+ 4. Only one email can match
330
+
331
+ Respond with JSON: {"match": true/false, "email_index": 1-based-index-or-null, "sender_name": "extracted-name", "amount": extracted-amount}`;
332
+
333
+ try {
334
+ const resp = await fetch(HF_ROUTER_URL, {
335
+ method: 'POST',
336
+ headers: {
337
+ 'Authorization': `Bearer ${HF_TOKEN}`,
338
+ 'Content-Type': 'application/json',
339
+ },
340
+ body: JSON.stringify({
341
+ model: HF_INFERENCE_MODEL,
342
+ messages: [
343
+ { role: 'system', content: 'You are a payment verification assistant. Respond ONLY with valid JSON.' },
344
+ { role: 'user', content: prompt },
345
+ ],
346
+ temperature: 0.1,
347
+ max_tokens: 300,
348
+ }),
349
+ });
350
+
351
+ if (!resp.ok) {
352
+ console.error('AI match failed:', resp.status);
353
+ // Fallback to simple amount matching
354
+ for (const email of emails) {
355
+ const amount = parseAmountFromEmail(email.rawContent);
356
+ const sender = parseSenderFromEmail(email.rawContent);
357
+ if (amount !== null && Math.abs(amount - expectedAmount) < 0.01) {
358
+ return { messageId: `INBOX:${email.msgId}`, gmailMsgId: email.msgId, senderName: sender, amount };
359
+ }
360
+ }
361
+ return null;
362
+ }
363
+
364
+ const data = await resp.json();
365
+ const text = data.choices?.[0]?.message?.content || '';
366
+ const match = text.match(/\{[\s\S]*\}/);
367
+ if (match) {
368
+ const result = JSON.parse(match[0]);
369
+ if (result.match && result.email_index) {
370
+ const emailIdx = result.email_index - 1;
371
+ if (emailIdx >= 0 && emailIdx < emails.length) {
372
+ const email = emails[emailIdx];
373
+ return {
374
+ messageId: `INBOX:${email.msgId}`,
375
+ gmailMsgId: email.msgId,
376
+ senderName: result.sender_name || 'Unknown',
377
+ amount: result.amount || expectedAmount,
378
+ };
379
+ }
380
+ }
381
+ }
382
+ return null;
383
+ } catch (e) {
384
+ console.error('AI match error:', e);
385
+ return null;
386
+ }
387
+ }
388
+
389
+ // ---------------------------------------------------------------------------
390
+ // Fetch recent PalmPay emails from Gmail (returns raw content for AI matching)
391
+ // ---------------------------------------------------------------------------
392
+ async function fetchRecentPalmPayEmails(): Promise<{msgId: string; rawContent: string}[]> {
393
+ const IMAP_HOST = 'imap.gmail.com';
394
+ const IMAP_PORT = 993;
395
+ const conn = await Deno.connectTls({ hostname: IMAP_HOST, port: IMAP_PORT });
396
+ const decoder = new TextDecoder();
397
+ const encoder = new TextEncoder();
398
+
399
+ await readUntil(conn, decoder, '\r\n');
400
+
401
+ const tag = `A${Date.now()}`;
402
+ await writeLine(conn, encoder, `${tag} LOGIN ${GMAIL_EMAIL} ${GMAIL_APP_PASSWORD}\r\n`);
403
+ const loginResp = await readUntil(conn, decoder, `${tag} OK`);
404
+ if (loginResp.includes('BAD') || loginResp.includes('NO')) {
405
+ conn.close();
406
+ throw new Error('Gmail login failed');
407
+ }
408
+
409
+ const folderTag = `${tag}1`;
410
+ await writeLine(conn, encoder, `${folderTag} SELECT INBOX\r\n`);
411
+ await readUntil(conn, decoder, `${folderTag} OK`);
412
+
413
+ // Search for unread emails from PalmPay in last 24 hours
414
+ const searchTag = `${tag}2`;
415
+ const sinceDate = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString().split('T')[0];
416
+ await writeLine(conn, encoder, `${searchTag} SEARCH UNSEEN FROM "palmpay" SINCE ${sinceDate}\r\n`);
417
+ const searchResp = await readUntil(conn, decoder, `${searchTag} OK`);
418
+
419
+ const searchMatch = searchResp.match(/\* SEARCH (.+)/);
420
+ if (!searchMatch) {
421
+ await logoutImap(conn, encoder, decoder, tag);
422
+ return [];
423
+ }
424
+
425
+ const messageIds = searchMatch[1].trim().split(/\s+/);
426
+ const emails: {msgId: string; rawContent: string}[] = [];
427
+
428
+ // Fetch each email's content (headers + body)
429
+ for (const msgId of messageIds.slice(0, 10)) { // Limit to 10 emails per check
430
+ const fetchTag = `${tag}3${msgId}`;
431
+ await writeLine(conn, encoder, `${fetchTag} FETCH ${msgId} (BODY.PEEK[HEADER.FIELDS (FROM SUBJECT DATE)] BODY.PEEK[TEXT])\r\n`);
432
+ const fetchResp = await readUntil(conn, decoder, `${fetchTag} OK`);
433
+ emails.push({ msgId, rawContent: fetchResp });
434
+ }
435
+
436
+ await logoutImap(conn, encoder, decoder, tag);
437
+ return emails;
438
+ }
439
+
440
+ // ---------------------------------------------------------------------------
441
+ // Mark an email as read in Gmail
442
+ // ---------------------------------------------------------------------------
443
+ async function markEmailAsRead(msgId: string): Promise<void> {
444
+ const conn = await Deno.connectTls({ hostname: 'imap.gmail.com', port: 993 });
445
+ const decoder = new TextDecoder();
446
+ const encoder = new TextEncoder();
447
+
448
+ await readUntil(conn, decoder, '\r\n');
449
+ const tag = `A${Date.now()}`;
450
+ await writeLine(conn, encoder, `${tag} LOGIN ${GMAIL_EMAIL} ${GMAIL_APP_PASSWORD}\r\n`);
451
+ await readUntil(conn, decoder, `${tag} OK`);
452
+
453
+ const folderTag = `${tag}1`;
454
+ await writeLine(conn, encoder, `${folderTag} SELECT INBOX\r\n`);
455
+ await readUntil(conn, decoder, `${folderTag} OK`);
456
+
457
+ const seenTag = `${tag}2`;
458
+ await writeLine(conn, encoder, `${seenTag} STORE ${msgId} +FLAGS (\\Seen)\r\n`);
459
+ await readUntil(conn, decoder, `${seenTag} OK`);
460
+
461
+ await logoutImap(conn, encoder, decoder, tag);
462
+ }
463
+
464
  // No match found within 10 minutes — mark as expired
465
  await updateOrderStatus(orderId, 'expired', null);
466
  console.log(`⏰ Order ${orderId} expired — no matching payment found`);