refund-processor.js
1 #!/usr/bin/env node 2 3 /** 4 * Automatic Refund Processor 5 * Detects refund requests in inbound emails and processes them automatically. 6 * 7 * Rules: 8 * - Customer must request within 7 days of purchase 9 * - Purchase is looked up by sender email 10 * - Refund is issued automatically if eligible 11 * - Requests outside the window are silently ignored (no reply) 12 */ 13 14 import { Resend } from 'resend'; 15 import Logger from '../utils/logger.js'; 16 import { run, getOne } from '../utils/db.js'; 17 import { refundPayment } from './paypal.js'; 18 import '../utils/load-env.js'; 19 20 const logger = new Logger('RefundProcessor'); 21 22 const REFUND_WINDOW_DAYS = 7; 23 24 // Keywords that signal a refund request 25 const REFUND_KEYWORDS = [ 26 'refund', 27 'money back', 28 'give me my money', 29 'want my money', 30 'cancel my order', 31 'cancel my purchase', 32 'charge back', 33 'chargeback', 34 ]; 35 36 /** 37 * Check if email body contains a refund request 38 * @param {string} body - Email body text 39 * @returns {boolean} 40 */ 41 export function isRefundRequest(body) { 42 if (!body) return false; 43 const lower = body.toLowerCase(); 44 return REFUND_KEYWORDS.some(kw => lower.includes(kw)); 45 } 46 47 /** 48 * Find the most recent eligible purchase for a given email address. 49 * Returns null if not found, already refunded, or outside the 7-day window. 50 * 51 * @param {string} email - Customer email address 52 * @returns {Promise<{ purchase: Object, eligible: boolean, reason: string|null }>} 53 */ 54 export async function findEligiblePurchase(email) { 55 const normalized = email.toLowerCase().trim(); 56 57 const purchase = await getOne( 58 `SELECT id, email, paypal_capture_id, amount, currency, status, created_at, refunded_at 59 FROM purchases 60 WHERE LOWER(email) = $1 61 ORDER BY created_at DESC 62 LIMIT 1`, 63 [normalized] 64 ); 65 66 if (!purchase) { 67 return { purchase: null, eligible: false, reason: 'no_purchase' }; 68 } 69 70 if (purchase.status === 'refunded') { 71 return { purchase, eligible: false, reason: 'already_refunded' }; 72 } 73 74 if (!purchase.paypal_capture_id) { 75 return { purchase, eligible: false, reason: 'no_capture_id' }; 76 } 77 78 const purchasedAt = new Date(purchase.created_at); 79 const now = new Date(); 80 const daysSince = (now - purchasedAt) / (1000 * 60 * 60 * 24); 81 82 if (daysSince > REFUND_WINDOW_DAYS) { 83 return { purchase, eligible: false, reason: 'outside_window' }; 84 } 85 86 return { purchase, eligible: true, reason: null }; 87 } 88 89 /** 90 * Send a refund confirmation email to the customer 91 */ 92 async function sendRefundConfirmation(purchase, refundResult) { 93 const apiKey = process.env.RESEND_API_KEY; 94 if (!apiKey) { 95 logger.warn('RESEND_API_KEY not set — skipping refund confirmation email'); 96 return; 97 } 98 99 const senderEmail = process.env.SENDER_EMAIL || `reports@${process.env.BRAND_DOMAIN}`; 100 const resend = new Resend(apiKey); 101 102 const amountFormatted = `${purchase.currency} ${(purchase.amount / 100).toFixed(2)}`; 103 104 try { 105 await resend.emails.send({ 106 from: `${process.env.BRAND_NAME} <${senderEmail}>`, 107 to: purchase.email, 108 subject: 'Your refund has been processed', 109 html: ` 110 <html> 111 <body style="font-family: sans-serif; color: #333; max-width: 600px; margin: 0 auto; padding: 20px;"> 112 <h2>Refund Processed</h2> 113 <p>Hi,</p> 114 <p>We've processed your refund of <strong>${amountFormatted}</strong>. It should appear back in your account within 3–5 business days, depending on your bank.</p> 115 <p>Refund reference: <code>${refundResult.refund_id}</code></p> 116 <p>If you have any questions, just reply to this email.</p> 117 <p>Thanks for giving us a try.</p> 118 <p style="color: #666; font-size: 12px; margin-top: 30px;">Audit&Fix CRO Reports</p> 119 </body> 120 </html>`, 121 }); 122 123 logger.success(`Refund confirmation email sent to ${purchase.email}`); 124 } catch (error) { 125 // Non-fatal — the refund itself succeeded 126 logger.error(`Failed to send refund confirmation email to ${purchase.email}`, error); 127 } 128 } 129 130 /** 131 * Process a refund request from an inbound email. 132 * 133 * @param {string} fromEmail - Sender's email address 134 * @param {string} body - Email body text 135 * @returns {Promise<{ processed: boolean, reason: string }>} 136 */ 137 export async function processRefundRequest(fromEmail, body) { 138 if (!isRefundRequest(body)) { 139 return { processed: false, reason: 'not_a_refund_request' }; 140 } 141 142 logger.info(`Refund request detected from ${fromEmail}`); 143 144 try { 145 const { purchase, eligible, reason } = await findEligiblePurchase(fromEmail); 146 147 if (!eligible) { 148 logger.info(`Refund request from ${fromEmail} not eligible: ${reason}`); 149 return { processed: false, reason }; 150 } 151 152 logger.info( 153 `Processing refund for purchase #${purchase.id} (${fromEmail}, capture: ${purchase.paypal_capture_id})` 154 ); 155 156 const refundResult = await refundPayment( 157 purchase.paypal_capture_id, 158 'Customer requested refund' 159 ); 160 161 // Update purchase record 162 await run( 163 `UPDATE purchases 164 SET status = 'refunded', refunded_at = NOW(), 165 refund_reason = 'Customer requested via email', updated_at = NOW() 166 WHERE id = $1`, 167 [purchase.id] 168 ); 169 170 logger.success( 171 `Refund processed for purchase #${purchase.id}: PayPal refund ${refundResult.refund_id}` 172 ); 173 174 await sendRefundConfirmation(purchase, refundResult); 175 176 return { 177 processed: true, 178 reason: 'refund_issued', 179 purchaseId: purchase.id, 180 refundId: refundResult.refund_id, 181 }; 182 } catch (error) { 183 logger.error(`Failed to process refund for ${fromEmail}`, error); 184 return { processed: false, reason: `error: ${error.message}` }; 185 } 186 }