/ src / payment / webhook-handler.js
webhook-handler.js
  1  #!/usr/bin/env node
  2  
  3  /**
  4   * PayPal Webhook Handler
  5   * Processes PayPal payment notifications and triggers report generation
  6   * Can be run as Express server or deployed to Cloudflare Workers
  7   */
  8  
  9  import express from 'express';
 10  import Logger from '../utils/logger.js';
 11  import { run, getOne } from '../utils/db.js';
 12  import { verifyPayment } from './paypal.js';
 13  import { getPrice } from '../utils/country-pricing.js';
 14  import { generateAuditReportForPurchase } from '../reports/report-orchestrator.js';
 15  import { deliverReport } from '../reports/report-delivery.js';
 16  import '../utils/load-env.js';
 17  
 18  const logger = new Logger('PayPalWebhook');
 19  
 20  /**
 21   * Atomic idempotency check using INSERT ... ON CONFLICT DO NOTHING.
 22   * Returns true if this is a NEW webhook (just inserted).
 23   * Returns false if the order_id was already processed (row existed).
 24   *
 25   * Table m333.processed_webhooks is defined in db/pg-schema.sql.
 26   *
 27   * @param {string} orderId
 28   * @param {number} amount
 29   * @param {string} currency
 30   * @returns {Promise<boolean>} true if newly inserted (first time), false if duplicate
 31   */
 32  async function claimWebhook(orderId, amount, currency) {
 33    const result = await run(
 34      `INSERT INTO processed_webhooks (order_id, amount, currency)
 35       VALUES ($1, $2, $3)
 36       ON CONFLICT (order_id) DO NOTHING`,
 37      [orderId, amount, currency]
 38    );
 39    return result.changes === 1;
 40  }
 41  
 42  /**
 43   * Amount tolerance for float comparison (0.01 = 1 cent).
 44   * PayPal may round differently, so we allow a small margin.
 45   */
 46  const AMOUNT_TOLERANCE = 0.02;
 47  
 48  /**
 49   * Verify that the payment amount and currency match expected pricing
 50   * for the given country. Protects against amount manipulation attacks
 51   * where an attacker creates an order with a lower price.
 52   *
 53   * @param {number} paidAmount - Amount actually paid (from PayPal verification)
 54   * @param {string} paidCurrency - Currency code from PayPal (e.g. 'AUD', 'USD')
 55   * @param {string} countryCode - Country code from the site record
 56   * @returns {{ valid: boolean, reason?: string }}
 57   */
 58  function verifyPaymentAmount(paidAmount, paidCurrency, countryCode) {
 59    // Skip verification in test mode only — never in production
 60    if (process.env.SKIP_AMOUNT_VERIFICATION === 'true' && process.env.NODE_ENV === 'test') {
 61      return { valid: true };
 62    }
 63  
 64    if (!countryCode) {
 65      // No country to verify against — allow but log
 66      logger.warn('Amount verification skipped: no country_code on site');
 67      return { valid: true };
 68    }
 69  
 70    // Check if this was a test-price order (< $5 in any currency)
 71    // Test orders use testPrice override which bypasses normal pricing
 72    if (paidAmount < 5) {
 73      logger.warn(`Low amount detected (${paidCurrency} ${paidAmount}) — likely test order, allowing`);
 74      return { valid: true };
 75    }
 76  
 77    const pricing = getPrice(countryCode);
 78    if (!pricing) {
 79      // Country not in pricing table — allow but log
 80      logger.warn(`Amount verification skipped: no pricing for country ${countryCode}`);
 81      return { valid: true };
 82    }
 83  
 84    // Verify currency matches expected
 85    if (paidCurrency !== pricing.currency) {
 86      return {
 87        valid: false,
 88        reason: `Currency mismatch: paid ${paidCurrency}, expected ${pricing.currency} for ${countryCode}`,
 89      };
 90    }
 91  
 92    // Verify amount matches expected (within tolerance)
 93    const expectedAmount = pricing.priceLocal;
 94    if (Math.abs(paidAmount - expectedAmount) > AMOUNT_TOLERANCE) {
 95      return {
 96        valid: false,
 97        reason: `Amount mismatch: paid ${paidCurrency} ${paidAmount}, expected ${paidCurrency} ${expectedAmount} for ${countryCode}`,
 98      };
 99    }
100  
101    return { valid: true };
102  }
103  
104  /**
105   * Process PayPal payment completion
106   * @param {string} orderId - PayPal order ID
107   * @returns {Promise<Object>} Processing result
108   */
109  export async function processPaymentComplete(orderId) {
110    logger.info(`Processing payment completion for order: ${orderId}`);
111  
112    try {
113      // Verify payment with PayPal API
114      const paymentDetails = await verifyPayment(orderId);
115  
116      if (!paymentDetails.isPaid) {
117        logger.warn(`Payment ${orderId} not completed yet: ${paymentDetails.status}`);
118        return {
119          success: false,
120          message: `Payment status: ${paymentDetails.status}`,
121        };
122      }
123  
124      // Extract site_id and message_id from reference_id
125      // Format: "site_123_conv_456"
126      const match = paymentDetails.referenceId?.match(/site_(\d+)_conv_(\d+)/);
127      if (!match) {
128        throw new Error(`Invalid reference ID format: ${paymentDetails.referenceId}`);
129      }
130  
131      const siteId = parseInt(match[1]);
132      const messageId = parseInt(match[2]);
133  
134      logger.info(
135        `Payment verified: Site ${siteId}, Message ${messageId}, Amount: ${paymentDetails.currency} $${paymentDetails.amount}`
136      );
137  
138      // ── Idempotency gate (atomic) ─────────────────────────────────────────
139      // INSERT ... ON CONFLICT DO NOTHING into processed_webhooks. If the row
140      // already exists, changes === 0 and we know this is a duplicate delivery.
141      const isNew = await claimWebhook(orderId, paymentDetails.amount, paymentDetails.currency);
142      if (!isNew) {
143        logger.info(`Payment ${orderId} already processed (idempotency gate)`);
144        return {
145          success: true,
146          message: 'Payment already processed',
147          orderId,
148        };
149      }
150  
151      // ── Amount & currency verification ────────────────────────────────────
152      // Look up the site's country to verify expected pricing
153      const siteForPricing = await getOne(
154        'SELECT country_code FROM sites WHERE id = $1',
155        [siteId]
156      );
157  
158      const amountCheck = verifyPaymentAmount(
159        paymentDetails.amount,
160        paymentDetails.currency,
161        siteForPricing?.country_code
162      );
163  
164      if (!amountCheck.valid) {
165        logger.error(
166          `PAYMENT AMOUNT REJECTED for order ${orderId}: ${amountCheck.reason}`
167        );
168        // Remove from processed_webhooks so it can be re-evaluated if needed
169        await run('DELETE FROM processed_webhooks WHERE order_id = $1', [orderId]);
170        return {
171          success: false,
172          message: `Amount verification failed: ${amountCheck.reason}`,
173          orderId,
174        };
175      }
176  
177      // Legacy idempotency check (belt-and-suspenders with processed_webhooks)
178      const existing = await getOne(
179        `SELECT id, payment_id FROM messages
180         WHERE id = $1 AND payment_id = $2`,
181        [messageId, orderId]
182      );
183  
184      if (existing?.payment_id) {
185        logger.info(`Payment ${orderId} already processed for message ${messageId}`);
186        return {
187          success: true,
188          message: 'Payment already processed',
189          messageId,
190        };
191      }
192  
193      // Update message with payment details
194      await run(
195        `UPDATE messages
196         SET payment_id = $1,
197             payment_amount = $2
198         WHERE id = $3`,
199        [orderId, paymentDetails.amount, messageId]
200      );
201  
202      // Update site resulted_in_sale
203      const message = await getOne(
204        'SELECT site_id FROM messages WHERE id = $1',
205        [messageId]
206      );
207  
208      if (message?.site_id) {
209        await run(
210          `UPDATE sites
211           SET resulted_in_sale = 1,
212               sale_amount = $1
213           WHERE id = $2`,
214          [paymentDetails.amount, message.site_id]
215        );
216      }
217  
218      logger.success(`Updated message ${messageId} to 'paid', marked site as sale`);
219  
220      // Find or create purchase record for report generation
221      let purchaseId = null;
222      const existingPurchase = await getOne(
223        'SELECT id FROM purchases WHERE paypal_order_id = $1',
224        [orderId]
225      );
226  
227      if (existingPurchase) {
228        purchaseId = existingPurchase.id;
229      } else {
230        // Create purchase record from conversation payment data
231        const site = await getOne(
232          'SELECT landing_page_url, country_code FROM sites WHERE id = $1',
233          [siteId]
234        );
235  
236        if (site) {
237          const result = await run(
238            `INSERT INTO purchases
239             (email, landing_page_url, paypal_order_id, amount, currency, amount_usd,
240              country_code, site_id, message_id, status)
241             VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 'paid')
242             RETURNING id`,
243            [
244              paymentDetails.payerEmail || '',
245              site.landing_page_url,
246              orderId,
247              Math.round(paymentDetails.amount * 100),
248              paymentDetails.currency || 'USD',
249              Math.round(paymentDetails.amount * 100),
250              site.country_code,
251              siteId,
252              messageId,
253            ]
254          );
255          purchaseId = result.lastInsertRowid;
256          logger.success(`Created purchase #${purchaseId} for report generation`);
257        }
258      }
259  
260      // Trigger fresh assessment + report generation asynchronously
261      // Always re-scrapes, re-screenshots, and re-scores with Opus + extended thinking + vision
262      if (purchaseId) {
263        triggerFreshAssessment(purchaseId, siteId).catch(err => {
264          logger.error(`Fresh assessment failed for purchase #${purchaseId}:`, err);
265        });
266      }
267  
268      return {
269        success: true,
270        message: 'Payment processed successfully',
271        messageId,
272        siteId,
273        orderId,
274        purchaseId,
275        amount: paymentDetails.amount,
276      };
277    } catch (error) {
278      logger.error(`Failed to process payment ${orderId}`, error);
279      throw error;
280    }
281  }
282  
283  /**
284   * Trigger a full fresh assessment for a purchase.
285   * Always re-scrapes assets (including screenshots), re-scores with Opus + extended thinking
286   * using the vision scoring prompt — regardless of ENABLE_VISION setting or score recency.
287   * Customers always get the most accurate report possible.
288   *
289   * @param {number} purchaseId - Purchase ID
290   * @param {number} siteId - Site ID
291   */
292  async function triggerFreshAssessment(purchaseId, siteId) {
293    logger.info(
294      `Triggering fresh assessment for purchase #${purchaseId}, site #${siteId} ` +
295        `(Opus + extended thinking + vision, ignoring ENABLE_VISION setting)`
296    );
297  
298    try {
299      // Generate full audit report (captures screenshots, scores with Opus, generates PDF)
300      const result = await generateAuditReportForPurchase(purchaseId);
301      logger.success(
302        `Report generated for purchase #${purchaseId}: ${result.score} (${result.grade})`
303      );
304  
305      // Deliver report via email
306      await deliverReport(purchaseId);
307      logger.success(`Report delivered for purchase #${purchaseId}`);
308  
309      // Update site conversation status
310      await run(
311        `UPDATE sites
312         SET conversation_status = 'report_delivered'
313         WHERE id = (SELECT site_id FROM messages WHERE id = (SELECT message_id FROM purchases WHERE id = $1))`,
314        [purchaseId]
315      );
316    } catch (error) {
317      logger.error(`Fresh assessment failed for purchase #${purchaseId}:`, error);
318  
319      // Mark purchase for retry by process-purchases cron
320      await run(
321        `UPDATE purchases SET error_message = $1, updated_at = NOW() WHERE id = $2`,
322        [`triggerFreshAssessment failed: ${error.message}`, purchaseId]
323      );
324  
325      throw error;
326    }
327  }
328  
329  /**
330   * Verify PayPal webhook signature using PayPal's verification API.
331   * This is the server-side equivalent of the Cloudflare Worker's verifyPayPalSignature().
332   *
333   * Required env vars:
334   *   PAYPAL_CLIENT_ID, PAYPAL_CLIENT_SECRET, PAYPAL_WEBHOOK_ID
335   *   PAYPAL_MODE ('live' or 'sandbox')
336   *
337   * @param {import('express').Request} req - Express request with headers and rawBody
338   * @returns {Promise<{ verified: boolean, error?: string }>}
339   */
340  export async function verifyWebhookSignature(req) {
341    const webhookId = process.env.PAYPAL_WEBHOOK_ID;
342  
343    if (!webhookId) {
344      logger.error('PAYPAL_WEBHOOK_ID not configured — rejecting webhook');
345      return { verified: false, error: 'Webhook verification not configured' };
346    }
347  
348    const transmissionId = req.headers['paypal-transmission-id'];
349    const transmissionTime = req.headers['paypal-transmission-time'];
350    const transmissionSig = req.headers['paypal-transmission-sig'];
351    const certUrl = req.headers['paypal-cert-url'];
352    const authAlgo = req.headers['paypal-auth-algo'];
353  
354    if (!transmissionId || !transmissionTime || !transmissionSig || !certUrl || !authAlgo) {
355      return { verified: false, error: 'Missing PayPal signature headers' };
356    }
357  
358    // Determine PayPal API base URL
359    const apiBase =
360      process.env.PAYPAL_MODE === 'live'
361        ? 'https://api-m.paypal.com'
362        : 'https://api-m.sandbox.paypal.com';
363  
364    try {
365      const clientId = process.env.PAYPAL_CLIENT_ID;
366      const clientSecret = process.env.PAYPAL_CLIENT_SECRET;
367  
368      if (!clientId || !clientSecret) {
369        return { verified: false, error: 'PayPal credentials not configured' };
370      }
371  
372      // Get OAuth token
373      const auth = Buffer.from(`${clientId}:${clientSecret}`).toString('base64');
374      const tokenRes = await fetch(`${apiBase}/v1/oauth2/token`, {
375        method: 'POST',
376        headers: {
377          'Content-Type': 'application/x-www-form-urlencoded',
378          Authorization: `Basic ${auth}`,
379        },
380        body: 'grant_type=client_credentials',
381      });
382  
383      if (!tokenRes.ok) {
384        return { verified: false, error: 'Failed to obtain PayPal access token' };
385      }
386  
387      const tokenData = await tokenRes.json();
388  
389      // Verify webhook signature via PayPal API
390      const rawBody = req.rawBody || JSON.stringify(req.body);
391      const verifyRes = await fetch(`${apiBase}/v1/notifications/verify-webhook-signature`, {
392        method: 'POST',
393        headers: {
394          'Content-Type': 'application/json',
395          Authorization: `Bearer ${tokenData.access_token}`,
396        },
397        body: JSON.stringify({
398          auth_algo: authAlgo,
399          cert_url: certUrl,
400          transmission_id: transmissionId,
401          transmission_sig: transmissionSig,
402          transmission_time: transmissionTime,
403          webhook_id: webhookId,
404          webhook_event: JSON.parse(rawBody),
405        }),
406      });
407  
408      if (!verifyRes.ok) {
409        const errText = await verifyRes.text();
410        logger.error(`PayPal signature verification request failed: ${verifyRes.status} ${errText}`);
411        return { verified: false, error: 'Signature verification request failed' };
412      }
413  
414      const verifyData = await verifyRes.json();
415      const verified = verifyData.verification_status === 'SUCCESS';
416  
417      if (!verified) {
418        logger.warn(`PayPal signature verification failed: ${verifyData.verification_status}`);
419      }
420  
421      return { verified };
422    } catch (err) {
423      logger.error('PayPal signature verification error:', err);
424      return { verified: false, error: err.message };
425    }
426  }
427  
428  /**
429   * Create Express webhook server
430   */
431  export function createWebhookServer(port = 3001) {
432    const app = express();
433  
434    // Capture raw body for signature verification, then parse JSON.
435    // PayPal's verification API needs the exact raw bytes of the request body.
436    app.use(
437      express.json({
438        verify: (req, _res, buf) => {
439          req.rawBody = buf.toString('utf8');
440        },
441      })
442    );
443  
444    // Health check endpoint
445    app.get('/health', (_req, res) => {
446      res.json({ status: 'ok', service: 'paypal-webhook' });
447    });
448  
449    // PayPal webhook endpoint
450    app.post('/webhook/paypal', async (req, res) => {
451      try {
452        // ── Signature verification ────────────────────────────────────────
453        // Verify the webhook actually came from PayPal before processing.
454        // This prevents forged payment events from triggering report generation.
455        const { verified, error: verifyError } = await verifyWebhookSignature(req);
456        if (!verified) {
457          logger.warn(`Webhook signature verification failed: ${verifyError}`);
458          return res.status(401).json({ error: 'Webhook signature verification failed' });
459        }
460  
461        const event = req.body;
462        logger.info(`Received PayPal webhook (verified): ${event.event_type}`);
463  
464        // Handle different event types
465        switch (event.event_type) {
466          case 'CHECKOUT.ORDER.APPROVED':
467          case 'PAYMENT.CAPTURE.COMPLETED': {
468            // Extract order ID from webhook event
469            const orderId =
470              event.resource?.id || event.resource?.supplementary_data?.related_ids?.order_id;
471  
472            if (!orderId) {
473              logger.warn('No order ID in webhook payload');
474              return res.status(400).json({ error: 'Missing order ID' });
475            }
476  
477            // Process payment asynchronously (don't block webhook response)
478            processPaymentComplete(orderId)
479              .then(result => {
480                logger.success(`Webhook processing complete: ${JSON.stringify(result)}`);
481              })
482              .catch(error => {
483                logger.error('Webhook processing error', error);
484              });
485  
486            // Respond immediately to PayPal
487            res.status(200).json({ received: true });
488            break;
489          }
490  
491          case 'PAYMENT.CAPTURE.DENIED':
492          case 'PAYMENT.CAPTURE.REFUNDED': {
493            logger.warn(`Payment issue: ${event.event_type}`);
494            res.status(200).json({ received: true });
495            break;
496          }
497  
498          default:
499            logger.info(`Unhandled event type: ${event.event_type}`);
500            res.status(200).json({ received: true });
501        }
502      } catch (error) {
503        logger.error('Webhook error', error);
504        res.status(500).json({ error: 'Internal server error' });
505      }
506    });
507  
508    return app;
509  }
510  
511  /**
512   * Start webhook server
513   */
514  export function startWebhookServer(port = 3001) {
515    const app = createWebhookServer(port);
516  
517    app.listen(port, () => {
518      logger.success(`PayPal webhook server listening on port ${port}`);
519      console.log(`\nPayPal Webhook Server`);
520      console.log(`  Health: http://localhost:${port}/health`);
521      console.log(`  Webhook: http://localhost:${port}/webhook/paypal`);
522      console.log(`\nConfigure this URL in your PayPal Developer Dashboard:`);
523      console.log(`  ${process.env.BASE_URL || 'https://yourdomain.com'}/webhook/paypal\n`);
524    });
525  
526    return app;
527  }
528  
529  // CLI functionality
530  if (import.meta.url === `file://${process.argv[1]}`) {
531    const command = process.argv[2];
532  
533    if (command === 'server') {
534      const port = parseInt(process.argv[3]) || 3001;
535      startWebhookServer(port);
536    } else if (command === 'process') {
537      const orderId = process.argv[3];
538  
539      if (!orderId) {
540        console.error('Usage: node src/payment/webhook-handler.js process <order_id>');
541        process.exit(1);
542      }
543  
544      processPaymentComplete(orderId)
545        .then(result => {
546          console.log('\n✅ Payment Processing Complete\n');
547          console.log(JSON.stringify(result, null, 2));
548          console.log('');
549          process.exit(0);
550        })
551        .catch(error => {
552          logger.error('Processing failed', error);
553          process.exit(1);
554        });
555    } else {
556      console.log('PayPal Webhook Handler');
557      console.log('');
558      console.log('Usage:');
559      console.log('  server [port]       - Start webhook server (default: 3001)');
560      console.log('  process <order_id>  - Manually process a payment');
561      console.log('');
562      console.log('Examples:');
563      console.log('  node src/payment/webhook-handler.js server 3001');
564      console.log('  node src/payment/webhook-handler.js process 8AB12345CD678901E');
565      console.log('');
566      console.log('Production Deployment:');
567      console.log('  1. Deploy as Cloudflare Worker (recommended) or Express server');
568      console.log('  2. Configure webhook URL in PayPal Developer Dashboard');
569      console.log('  3. Subscribe to: CHECKOUT.ORDER.APPROVED, PAYMENT.CAPTURE.COMPLETED\n');
570      process.exit(1);
571    }
572  }
573  
574  // Exported for testing only
575  export { claimWebhook, verifyPaymentAmount, AMOUNT_TOLERANCE };
576  
577  export default {
578    processPaymentComplete,
579    createWebhookServer,
580    startWebhookServer,
581  };