separated in modules
This commit is contained in:
42
src/modules/6-mercadopago/controllers/mercadoPago.js
Normal file
42
src/modules/6-mercadopago/controllers/mercadoPago.js
Normal file
@@ -0,0 +1,42 @@
|
||||
import { fetchPayment, reconcilePayment, verifyWebhookSignature } from "../mercadoPago.js";
|
||||
|
||||
export function makeMercadoPagoWebhook() {
|
||||
return async function handleMercadoPagoWebhook(req, res) {
|
||||
try {
|
||||
const signature = verifyWebhookSignature({ headers: req.headers, query: req.query || {} });
|
||||
if (!signature.ok) {
|
||||
return res.status(401).json({ ok: false, error: "invalid_signature", reason: signature.reason });
|
||||
}
|
||||
|
||||
const paymentId =
|
||||
req?.query?.["data.id"] ||
|
||||
req?.query?.data?.id ||
|
||||
req?.body?.data?.id ||
|
||||
null;
|
||||
|
||||
if (!paymentId) {
|
||||
return res.status(400).json({ ok: false, error: "missing_payment_id" });
|
||||
}
|
||||
|
||||
const payment = await fetchPayment({ paymentId });
|
||||
const reconciled = await reconcilePayment({ payment });
|
||||
|
||||
return res.status(200).json({
|
||||
ok: true,
|
||||
payment_id: payment?.id || null,
|
||||
status: payment?.status || null,
|
||||
woo_order_id: reconciled?.woo_order_id || null,
|
||||
});
|
||||
} catch (e) {
|
||||
return res.status(500).json({ ok: false, error: String(e?.message || e) });
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function makeMercadoPagoReturn() {
|
||||
return function handleMercadoPagoReturn(req, res) {
|
||||
const status = req.query?.status || "unknown";
|
||||
res.status(200).send(`OK - ${status}`);
|
||||
};
|
||||
}
|
||||
|
||||
178
src/modules/6-mercadopago/mercadoPago.js
Normal file
178
src/modules/6-mercadopago/mercadoPago.js
Normal file
@@ -0,0 +1,178 @@
|
||||
import crypto from "crypto";
|
||||
import { upsertMpPayment } from "../2-identity/db/repo.js";
|
||||
import { updateOrderStatus } from "../4-woo-orders/wooOrders.js";
|
||||
|
||||
function getAccessToken() {
|
||||
return process.env.MP_ACCESS_TOKEN || null;
|
||||
}
|
||||
|
||||
function getWebhookSecret() {
|
||||
return process.env.MP_WEBHOOK_SECRET || null;
|
||||
}
|
||||
|
||||
function normalizeBaseUrl(base) {
|
||||
if (!base) return null;
|
||||
return base.endsWith("/") ? base : `${base}/`;
|
||||
}
|
||||
|
||||
function getBaseUrl() {
|
||||
return normalizeBaseUrl(process.env.MP_BASE_URL || process.env.MP_WEBHOOK_BASE_URL || null);
|
||||
}
|
||||
|
||||
async function fetchMp({ url, method = "GET", body = null }) {
|
||||
const token = getAccessToken();
|
||||
if (!token) throw new Error("MP_ACCESS_TOKEN is not set");
|
||||
const res = await fetch(url, {
|
||||
method,
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
const text = await res.text();
|
||||
let parsed;
|
||||
try {
|
||||
parsed = text ? JSON.parse(text) : null;
|
||||
} catch {
|
||||
parsed = text;
|
||||
}
|
||||
if (!res.ok) {
|
||||
const err = new Error(`MP HTTP ${res.status}`);
|
||||
err.status = res.status;
|
||||
err.body = parsed;
|
||||
throw err;
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
export async function createPreference({
|
||||
tenantId,
|
||||
wooOrderId,
|
||||
amount,
|
||||
payer = null,
|
||||
items = null,
|
||||
baseUrl = null,
|
||||
}) {
|
||||
const root = normalizeBaseUrl(baseUrl || getBaseUrl());
|
||||
if (!root) throw new Error("MP_BASE_URL is not set");
|
||||
const notificationUrl = `${root}webhook/mercadopago`;
|
||||
const backUrls = {
|
||||
success: `${root}return?status=success`,
|
||||
failure: `${root}return?status=failure`,
|
||||
pending: `${root}return?status=pending`,
|
||||
};
|
||||
const statementDescriptor = process.env.MP_STATEMENT_DESCRIPTOR || "Whatsapp Store";
|
||||
const externalReference = `${tenantId}|${wooOrderId}`;
|
||||
const unitPrice = Number(amount);
|
||||
if (!Number.isFinite(unitPrice)) throw new Error("invalid_amount");
|
||||
|
||||
const payload = {
|
||||
auto_return: "approved",
|
||||
back_urls: backUrls,
|
||||
statement_descriptor: statementDescriptor,
|
||||
binary_mode: false,
|
||||
external_reference: externalReference,
|
||||
items: Array.isArray(items) && items.length
|
||||
? items
|
||||
: [
|
||||
{
|
||||
id: String(wooOrderId || "order"),
|
||||
title: "Productos x whatsapp",
|
||||
quantity: 1,
|
||||
currency_id: "ARS",
|
||||
unit_price: unitPrice,
|
||||
},
|
||||
],
|
||||
notification_url: notificationUrl,
|
||||
...(payer ? { payer } : {}),
|
||||
};
|
||||
|
||||
const data = await fetchMp({
|
||||
url: "https://api.mercadopago.com/checkout/preferences",
|
||||
method: "POST",
|
||||
body: payload,
|
||||
});
|
||||
|
||||
return {
|
||||
preference_id: data?.id || null,
|
||||
init_point: data?.init_point || null,
|
||||
sandbox_init_point: data?.sandbox_init_point || null,
|
||||
raw: data,
|
||||
};
|
||||
}
|
||||
|
||||
function parseSignatureHeader(header) {
|
||||
const h = String(header || "");
|
||||
const parts = h.split(",");
|
||||
let ts = null;
|
||||
let v1 = null;
|
||||
for (const p of parts) {
|
||||
const [k, v] = p.split("=");
|
||||
if (!k || !v) continue;
|
||||
const key = k.trim();
|
||||
const val = v.trim();
|
||||
if (key === "ts") ts = val;
|
||||
if (key === "v1") v1 = val;
|
||||
}
|
||||
return { ts, v1 };
|
||||
}
|
||||
|
||||
export function verifyWebhookSignature({ headers = {}, query = {} }) {
|
||||
const secret = getWebhookSecret();
|
||||
if (!secret) return { ok: false, reason: "MP_WEBHOOK_SECRET is not set" };
|
||||
const xSignature = headers["x-signature"] || headers["X-Signature"] || headers["x-signature"];
|
||||
const xRequestId = headers["x-request-id"] || headers["X-Request-Id"] || headers["x-request-id"];
|
||||
const { ts, v1 } = parseSignatureHeader(xSignature);
|
||||
const dataId = query["data.id"] || query?.data?.id || null;
|
||||
if (!xRequestId || !ts || !v1 || !dataId) {
|
||||
return { ok: false, reason: "missing_signature_fields" };
|
||||
}
|
||||
const manifest = `id:${String(dataId).toLowerCase()};request-id:${xRequestId};ts:${ts};`;
|
||||
const hmac = crypto.createHmac("sha256", secret);
|
||||
hmac.update(manifest);
|
||||
const hash = hmac.digest("hex");
|
||||
const ok = crypto.timingSafeEqual(Buffer.from(hash), Buffer.from(v1));
|
||||
return ok ? { ok: true } : { ok: false, reason: "invalid_signature" };
|
||||
}
|
||||
|
||||
export async function fetchPayment({ paymentId }) {
|
||||
if (!paymentId) throw new Error("missing_payment_id");
|
||||
return await fetchMp({
|
||||
url: `https://api.mercadopago.com/v1/payments/${encodeURIComponent(paymentId)}`,
|
||||
method: "GET",
|
||||
});
|
||||
}
|
||||
|
||||
export function parseExternalReference(externalReference) {
|
||||
if (!externalReference) return { tenantId: null, wooOrderId: null };
|
||||
const parts = String(externalReference).split("|").filter(Boolean);
|
||||
if (parts.length >= 2) {
|
||||
return { tenantId: parts[0], wooOrderId: Number(parts[1]) || null };
|
||||
}
|
||||
return { tenantId: null, wooOrderId: Number(externalReference) || null };
|
||||
}
|
||||
|
||||
export async function reconcilePayment({ tenantId, payment }) {
|
||||
const status = payment?.status || null;
|
||||
const paidAt = payment?.date_approved || payment?.date_created || null;
|
||||
const { tenantId: refTenantId, wooOrderId } = parseExternalReference(payment?.external_reference);
|
||||
const resolvedTenantId = tenantId || refTenantId;
|
||||
if (!resolvedTenantId) throw new Error("tenant_id_missing_from_payment");
|
||||
const saved = await upsertMpPayment({
|
||||
tenant_id: resolvedTenantId,
|
||||
woo_order_id: wooOrderId,
|
||||
preference_id: payment?.order?.id || payment?.preference_id || null,
|
||||
payment_id: String(payment?.id || ""),
|
||||
status,
|
||||
paid_at: paidAt,
|
||||
raw: payment,
|
||||
});
|
||||
|
||||
if (status === "approved" && wooOrderId) {
|
||||
await updateOrderStatus({ tenantId: resolvedTenantId, wooOrderId, status: "processing" });
|
||||
}
|
||||
|
||||
return { payment: saved, woo_order_id: wooOrderId, tenant_id: resolvedTenantId };
|
||||
}
|
||||
|
||||
10
src/modules/6-mercadopago/routes/mercadoPago.js
Normal file
10
src/modules/6-mercadopago/routes/mercadoPago.js
Normal file
@@ -0,0 +1,10 @@
|
||||
import express from "express";
|
||||
import { makeMercadoPagoReturn, makeMercadoPagoWebhook } from "../controllers/mercadoPago.js";
|
||||
|
||||
export function createMercadoPagoRouter() {
|
||||
const router = express.Router();
|
||||
router.post("/webhook/mercadopago", makeMercadoPagoWebhook());
|
||||
router.get("/return", makeMercadoPagoReturn());
|
||||
return router;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user