File size: 3,095 Bytes
4c6b1e8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
const Order = require("../models/Order");
const payoneerCheckout = require("../utils/payoneerCheckout");
const { confirmOrderAndGrantAccess } = require("../utils/orderConfirmation");
const logger = require("../utils/logger");

// PAYONEER-DOCS-NEEDED: confirm the actual header name Payoneer sends
// the webhook signature in — this is a placeholder guess following
// common convention (e.g. Stripe uses "Stripe-Signature").
const SIGNATURE_HEADER = "x-payoneer-signature";

/**
 * Receives Payoneer Checkout's payment-status webhook and, on a
 * successful payment, automatically confirms the matching Order and
 * grants the buyer access — no admin involved, unlike the manual
 * bank_wire/payoneer-reference flow.
 *
 * Registered in app.js with a raw body parser (see
 * routes/webhook.routes.js) so `req.body` here is the exact raw
 * bytes Payoneer signed, not the parsed JSON the rest of the app uses.
 *
 * Always responds 200 once the webhook is understood (even for a
 * "not our order" or "already confirmed" case) so Payoneer doesn't
 * keep retrying a webhook we've already handled. Only a genuinely bad
 * signature or unreadable payload gets a 4xx.
 */
async function handlePayoneerWebhook(req, res) {
  let parsed;
  try {
    parsed = payoneerCheckout.verifyAndParseWebhook({
      rawBody: req.body,
      signatureHeader: req.headers[SIGNATURE_HEADER],
    });
  } catch (err) {
    logger.error("Payoneer webhook rejected", { error: err.message });
    return res.status(err.status || 400).json({ error: "Invalid webhook." });
  }

  try {
    if (parsed.status !== "paid") {
      // Payoneer also sends non-success events (failed, cancelled,
      // pending, etc.) — nothing to do for those except acknowledge.
      logger.info("Payoneer webhook received (non-paid status, no action)", { status: parsed.status });
      return res.status(200).json({ received: true });
    }

    const order = await Order.findOne({ providerSessionId: parsed.sessionId, paymentMethod: "payoneer_checkout" });
    if (!order) {
      logger.error("Payoneer webhook: no matching order for session", { sessionId: parsed.sessionId });
      // Still 200 — this is Payoneer's event, not a request we want retried forever.
      return res.status(200).json({ received: true, matched: false });
    }

    const { alreadyConfirmed } = await confirmOrderAndGrantAccess({ orderId: order._id, confirmedBy: null });

    logger.info("Payoneer webhook: order auto-confirmed", {
      orderId: order._id.toString(),
      alreadyConfirmed,
    });

    res.status(200).json({ received: true, matched: true });
  } catch (err) {
    logger.error("Payoneer webhook processing failed", { error: err.message });
    // 200 anyway: a bug on our side confirming the order shouldn't
    // cause Payoneer to hammer us with retries for an event we did
    // successfully authenticate and understand. This case should
    // instead be caught by monitoring your server logs.
    res.status(200).json({ received: true, error: "processing_failed" });
  }
}

module.exports = { handlePayoneerWebhook };