separated in modules

This commit is contained in:
Lucas Tettamanti
2026-01-15 22:45:33 -03:00
parent eedd16afdb
commit ea62385e3d
41 changed files with 1116 additions and 2918 deletions

View 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}`);
};
}

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

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