/ src / payment / paypal.js
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  };