paypal.js
1 #!/usr/bin/env node 2 3 /** 4 * PayPal Payment Integration 5 * Handles payment link generation and payment verification 6 */ 7 8 import axios from 'axios'; 9 import Logger from '../utils/logger.js'; 10 import { getPrice } from '../utils/country-pricing.js'; 11 import '../utils/load-env.js'; 12 13 const logger = new Logger('PayPal'); 14 15 // PayPal API configuration 16 const PAYPAL_API_BASE = 17 process.env.PAYPAL_MODE === 'live' 18 ? 'https://api-m.paypal.com' 19 : 'https://api-m.sandbox.paypal.com'; 20 21 const { PAYPAL_CLIENT_ID } = process.env; 22 const { PAYPAL_CLIENT_SECRET } = process.env; 23 24 /** 25 * Get PayPal OAuth access token 26 * @returns {Promise<string>} Access token 27 */ 28 async function getAccessToken() { 29 if (!PAYPAL_CLIENT_ID || !PAYPAL_CLIENT_SECRET) { 30 throw new Error( 31 'PayPal credentials not configured. Set PAYPAL_CLIENT_ID and PAYPAL_CLIENT_SECRET in .env' 32 ); 33 } 34 35 try { 36 const auth = Buffer.from(`${PAYPAL_CLIENT_ID}:${PAYPAL_CLIENT_SECRET}`).toString('base64'); 37 38 const response = await axios.post( 39 `${PAYPAL_API_BASE}/v1/oauth2/token`, 40 'grant_type=client_credentials', 41 { 42 headers: { 43 Authorization: `Basic ${auth}`, 44 'Content-Type': 'application/x-www-form-urlencoded', 45 }, 46 } 47 ); 48 49 return response.data.access_token; 50 } catch (error) { 51 logger.error('Failed to get PayPal access token', error); 52 throw new Error(`PayPal authentication failed: ${error.message}`); 53 } 54 } 55 56 /** 57 * Create a PayPal payment order 58 * @param {Object} params - Payment parameters 59 * @param {string} params.domain - Customer domain 60 * @param {string} params.email - Customer email 61 * @param {number} params.siteId - Site ID for tracking 62 * @param {number} params.conversationId - Conversation ID for tracking 63 * @param {string} params.countryCode - ISO country code (e.g., 'AU', 'US') 64 * @returns {Promise<Object>} Payment order details 65 */ 66 export async function createPaymentOrder({ 67 domain, 68 email: _email, 69 siteId, 70 conversationId, 71 countryCode, 72 testPrice, 73 }) { 74 logger.info(`Creating PayPal order for ${domain} (${countryCode || 'US'})`); 75 76 // Get country-specific pricing 77 const pricing = await getPrice(countryCode || 'US'); 78 if (!pricing) { 79 throw new Error(`Unable to get pricing for country: ${countryCode}`); 80 } 81 82 // Allow override for live testing (e.g. testPrice=1.00) 83 if (testPrice !== null && testPrice !== undefined) { 84 pricing.priceLocal = parseFloat(testPrice); 85 pricing.formattedPrice = `${pricing.currencySymbol}${testPrice}`; 86 logger.warn(`TEST PRICE OVERRIDE: ${pricing.formattedPrice} (${pricing.currency})`); 87 } 88 89 logger.info(`Pricing: ${pricing.formattedPrice} (${pricing.currency})`); 90 91 try { 92 const accessToken = await getAccessToken(); 93 94 // Create payment order with local currency 95 const orderData = { 96 intent: 'CAPTURE', 97 purchase_units: [ 98 { 99 reference_id: `site_${siteId}_conv_${conversationId}`, 100 description: `CRO Analysis Report for ${domain}`, 101 amount: { 102 currency_code: pricing.currency, 103 value: pricing.priceLocal.toFixed(2), 104 breakdown: { 105 item_total: { 106 currency_code: pricing.currency, 107 value: pricing.priceLocal.toFixed(2), 108 }, 109 }, 110 }, 111 items: [ 112 { 113 name: 'Conversion Rate Optimization Analysis Report', 114 description: `Professional CRO analysis and recommendations for ${domain}`, 115 unit_amount: { 116 currency_code: pricing.currency, 117 value: pricing.priceLocal.toFixed(2), 118 }, 119 quantity: '1', 120 category: 'DIGITAL_GOODS', 121 }, 122 ], 123 }, 124 ], 125 payment_source: { 126 paypal: { 127 experience_context: { 128 payment_method_preference: 'IMMEDIATE_PAYMENT_REQUIRED', 129 brand_name: process.env.PAYPAL_BRAND_NAME || 'CRO Reports', 130 locale: 'en-US', 131 landing_page: 'LOGIN', 132 user_action: 'PAY_NOW', 133 return_url: `${process.env.BASE_URL || 'http://localhost:3000'}/payment/success`, 134 cancel_url: `${process.env.BASE_URL || 'http://localhost:3000'}/payment/cancel`, 135 }, 136 }, 137 }, 138 }; 139 140 const response = await axios.post(`${PAYPAL_API_BASE}/v2/checkout/orders`, orderData, { 141 headers: { 142 'Content-Type': 'application/json', 143 Authorization: `Bearer ${accessToken}`, 144 }, 145 }); 146 147 const order = response.data; 148 const approveLink = order.links.find(link => link.rel === 'payer-action')?.href; 149 150 if (!approveLink) { 151 throw new Error('No approval link returned from PayPal'); 152 } 153 154 logger.success(`Created PayPal order: ${order.id} (${pricing.formattedPrice})`); 155 156 return { 157 orderId: order.id, 158 paymentLink: approveLink, 159 amount: pricing.priceLocal, 160 amountUsd: pricing.priceUsd, 161 currency: pricing.currency, 162 formattedPrice: pricing.formattedPrice, 163 exchangeRate: pricing.exchangeRate, 164 status: order.status, 165 }; 166 } catch (error) { 167 logger.error('Failed to create PayPal order', error); 168 throw new Error( 169 `PayPal order creation failed: ${error.response?.data?.message || error.message}` 170 ); 171 } 172 } 173 174 /** 175 * Verify a payment has been completed 176 * @param {string} orderId - PayPal order ID 177 * @returns {Promise<Object>} Payment verification result 178 */ 179 export async function verifyPayment(orderId) { 180 logger.info(`Verifying PayPal payment: ${orderId}`); 181 182 try { 183 const accessToken = await getAccessToken(); 184 185 const response = await axios.get(`${PAYPAL_API_BASE}/v2/checkout/orders/${orderId}`, { 186 headers: { 187 'Content-Type': 'application/json', 188 Authorization: `Bearer ${accessToken}`, 189 }, 190 }); 191 192 const order = response.data; 193 const isPaid = order.status === 'COMPLETED'; 194 const payerEmail = order.payer?.email_address; 195 const payerName = order.payer?.name?.given_name 196 ? `${order.payer.name.given_name} ${order.payer.name.surname || ''}`.trim() 197 : null; 198 199 logger.info(`Payment ${orderId} status: ${order.status}`); 200 201 return { 202 isPaid, 203 status: order.status, 204 orderId: order.id, 205 payerEmail, 206 payerName, 207 amount: parseFloat(order.purchase_units[0].amount.value), 208 currency: order.purchase_units[0].amount.currency_code, 209 referenceId: order.purchase_units[0].reference_id, 210 createTime: order.create_time, 211 updateTime: order.update_time, 212 }; 213 } catch (error) { 214 logger.error(`Failed to verify payment ${orderId}`, error); 215 throw new Error( 216 `PayPal verification failed: ${error.response?.data?.message || error.message}` 217 ); 218 } 219 } 220 221 /** 222 * Capture a payment (complete the transaction) 223 * This is called after the customer approves the payment 224 * @param {string} orderId - PayPal order ID 225 * @returns {Promise<Object>} Capture result 226 */ 227 export async function capturePayment(orderId) { 228 logger.info(`Capturing PayPal payment: ${orderId}`); 229 230 try { 231 const accessToken = await getAccessToken(); 232 233 const response = await axios.post( 234 `${PAYPAL_API_BASE}/v2/checkout/orders/${orderId}/capture`, 235 {}, 236 { 237 headers: { 238 'Content-Type': 'application/json', 239 Authorization: `Bearer ${accessToken}`, 240 }, 241 } 242 ); 243 244 const capture = response.data; 245 const captureStatus = capture.purchase_units[0].payments.captures[0].status; 246 247 logger.success(`Captured payment ${orderId}: ${captureStatus}`); 248 249 return { 250 orderId: capture.id, 251 status: captureStatus, 252 captureId: capture.purchase_units[0].payments.captures[0].id, 253 amount: parseFloat(capture.purchase_units[0].payments.captures[0].amount.value), 254 currency: capture.purchase_units[0].payments.captures[0].amount.currency_code, 255 }; 256 } catch (error) { 257 logger.error(`Failed to capture payment ${orderId}`, error); 258 259 // Extract PayPal error details 260 const errorData = error.response?.data; 261 const errorName = errorData?.name; 262 const errorDetails = errorData?.details?.map(d => d.issue).join(', '); 263 const errorMessage = errorData?.message || error.message; 264 265 // Build comprehensive error message 266 let errorMsg = `PayPal capture failed: ${errorMessage}`; 267 if (errorName) errorMsg += ` (${errorName})`; 268 if (errorDetails) errorMsg += ` - ${errorDetails}`; 269 270 throw new Error(errorMsg); 271 } 272 } 273 274 /** 275 * Refund a captured PayPal payment 276 * @param {string} captureId - PayPal capture ID 277 * @param {string} [reason] - Reason for refund 278 * @returns {Promise<Object>} Refund result 279 */ 280 export async function refundPayment(captureId, reason) { 281 logger.info(`Refunding PayPal capture: ${captureId}`); 282 283 try { 284 const accessToken = await getAccessToken(); 285 286 const body = reason ? { note_to_payer: reason } : {}; 287 288 const response = await axios.post( 289 `${PAYPAL_API_BASE}/v2/payments/captures/${captureId}/refund`, 290 body, 291 { 292 headers: { 293 'Content-Type': 'application/json', 294 Authorization: `Bearer ${accessToken}`, 295 }, 296 } 297 ); 298 299 const refund = response.data; 300 logger.success(`Refunded capture ${captureId}: ${refund.status} (${refund.id})`); 301 302 return { 303 success: true, 304 refund_id: refund.id, 305 status: refund.status, 306 amount: parseFloat(refund.amount?.value || 0), 307 currency: refund.amount?.currency_code, 308 }; 309 } catch (error) { 310 logger.error(`Failed to refund capture ${captureId}`, error); 311 312 const errorData = error.response?.data; 313 const errorMessage = errorData?.message || error.message; 314 315 throw new Error(`PayPal refund failed: ${errorMessage}`); 316 } 317 } 318 319 /** 320 * Generate payment request message for SMS/Email 321 * @param {string} paymentLink - PayPal payment link 322 * @param {string} channel - Channel (sms, email) 323 * @param {string} domain - Customer domain 324 * @returns {string} Formatted payment message 325 */ 326 export async function generatePaymentMessage(paymentLink, channel, domain, countryCode) { 327 // Get country-specific pricing 328 const pricing = await getPrice(countryCode || 'US'); 329 if (!pricing) { 330 throw new Error(`Unable to get pricing for country: ${countryCode}`); 331 } 332 333 if (channel === 'sms') { 334 // SMS: Keep it concise. Marcus from Audit & Fix branding. 335 return `Hi, Marcus from Audit & Fix here. The full audit report for ${domain} is ${pricing.formattedPrice} — covers all the issues I found with exact fixes. Grab it here: ${paymentLink}`; 336 } 337 338 // Email: More detailed. Audit & Fix branding, no "CRO" jargon. 339 return `Hi, 340 341 Marcus from Audit & Fix here. You asked about the full report for ${domain} — here are the details. 342 343 The Homepage Conversion Audit covers every issue I found on your site, each with the exact fix and prioritised by impact. Most clients hand it straight to their web developer as a punch list. 344 345 Price: ${pricing.formattedPrice} (one-time, yours to keep) 346 347 You can grab it here: 348 ${paymentLink} 349 350 Questions? Just reply. 351 352 Marcus Webb 353 Audit & Fix — auditandfix.com`; 354 } 355 356 // CLI functionality 357 if (import.meta.url === `file://${process.argv[1]}`) { 358 const command = process.argv[2]; 359 360 if (command === 'create-order') { 361 const domain = process.argv[3] || 'example.com'; 362 const email = process.argv[4] || 'test@example.com'; 363 const siteId = parseInt(process.argv[5]) || 1; 364 const conversationId = parseInt(process.argv[6]) || 1; 365 const countryCode = process.argv[7] || 'US'; 366 const testPriceArg = process.argv.find(a => a.startsWith('--test-price=')); 367 const testPrice = testPriceArg ? parseFloat(testPriceArg.split('=')[1]) : null; 368 369 createPaymentOrder({ domain, email, siteId, conversationId, countryCode, testPrice }) 370 .then(order => { 371 console.log('\n✅ PayPal Order Created\n'); 372 console.log(`Order ID: ${order.orderId}`); 373 console.log(`Amount: ${order.formattedPrice} (${order.currency})`); 374 console.log(`USD Equivalent: $${order.amountUsd}`); 375 console.log(`Exchange Rate: ${order.exchangeRate}`); 376 console.log(`Payment Link: ${order.paymentLink}\n`); 377 process.exit(0); 378 }) 379 .catch(error => { 380 logger.error('Failed to create order', error); 381 process.exit(1); 382 }); 383 } else if (command === 'verify') { 384 const orderId = process.argv[3]; 385 386 if (!orderId) { 387 console.error('Usage: node src/payment/paypal.js verify <order_id>'); 388 process.exit(1); 389 } 390 391 verifyPayment(orderId) 392 .then(result => { 393 console.log('\n✅ Payment Verification\n'); 394 console.log(`Order ID: ${result.orderId}`); 395 console.log(`Status: ${result.status}`); 396 console.log(`Paid: ${result.isPaid ? 'YES' : 'NO'}`); 397 console.log(`Payer: ${result.payerName} (${result.payerEmail})`); 398 console.log(`Amount: ${result.currency} $${result.amount}\n`); 399 process.exit(0); 400 }) 401 .catch(error => { 402 logger.error('Failed to verify payment', error); 403 process.exit(1); 404 }); 405 } else { 406 console.log('PayPal Payment Integration'); 407 console.log(''); 408 console.log('Usage:'); 409 console.log(' create-order <domain> <email> <site_id> <conversation_id>'); 410 console.log(' verify <order_id>'); 411 console.log(''); 412 console.log('Examples:'); 413 console.log(' node src/payment/paypal.js create-order example.com test@example.com 123 456'); 414 console.log(' node src/payment/paypal.js verify 8AB12345CD678901E\n'); 415 process.exit(1); 416 } 417 } 418 419 export default { 420 createPaymentOrder, 421 verifyPayment, 422 capturePayment, 423 refundPayment, 424 generatePaymentMessage, 425 };