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 };