Spaces:
Sleeping
feat: AI-powered payment verification (Qwen2.5-72B) + bank name collection
Browse filesUPGRADES:
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
- payment.html +9 -2
- supabase/functions/payment/index.ts +214 -24
|
@@ -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
|
| 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,
|
|
@@ -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;
|
| 243 |
}
|
| 244 |
|
| 245 |
try {
|
| 246 |
-
// Connect to Gmail via IMAP and
|
| 247 |
-
const
|
| 248 |
|
| 249 |
-
if (
|
| 250 |
-
//
|
| 251 |
-
await
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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`);
|