import { createHash } from "node:crypto"; // Contrato real de la API de PayU LatAm (developers.payulatam.com, // verificado en vivo 2026-09-06/07 -- NO es la misma API que los plugins // de Medusa que aparecen buscando "PayU": esos son para PayU Polonia/CEE // (clientId/posId/secondKey), API distinta por completo. Esta es la de // apiLogin/apiKey/merchantId/accountId, la que cubre Colombia. export type PayuEnv = "sandbox" | "production"; export interface PayuCredentials { apiLogin: string; apiKey: string; merchantId: string; accountId: string; env: PayuEnv; } const BASE_URL: Record = { sandbox: "https://sandbox.api.payulatam.com/payments-api/4.0/service.cgi", production: "https://api.payulatam.com/payments-api/4.0/service.cgi", }; export async function payuRequest( credentials: PayuCredentials, body: Record, ): Promise> { const response = await fetch(BASE_URL[credentials.env], { method: "POST", headers: { "Content-Type": "application/json", Accept: "application/json" }, body: JSON.stringify({ test: credentials.env === "sandbox", language: "es", ...body, }), }); const text = await response.text(); let json: Record; try { json = JSON.parse(text); } catch { throw new Error(`Respuesta no-JSON de PayU (HTTP ${response.status}): ${text.slice(0, 300)}`); } if (!response.ok) { throw new Error(`PayU respondió HTTP ${response.status}: ${JSON.stringify(json).slice(0, 300)}`); } return json; } export interface PayuBank { id: string; description: string; pseCode: string; } export async function fetchPseBanks(credentials: PayuCredentials): Promise { const json = await payuRequest(credentials, { command: "GET_BANKS_LIST", bankListInformation: { paymentMethod: "PSE", paymentCountry: "CO" }, }); const banks = (json.banks ?? []) as Array<{ id?: unknown; description?: unknown; pseCode?: unknown; }>; return banks .filter((bank) => typeof bank.pseCode === "string" && typeof bank.description === "string") .map((bank) => ({ id: String(bank.id ?? bank.pseCode), description: String(bank.description), pseCode: String(bank.pseCode), })); } // Regla documentada de PayU para el campo de monto de la firma: si el // segundo decimal es cero, un solo decimal (150.00 -> "150.0", // 150.20 -> "150.2"); si no, dos decimales (150.25 -> "150.25"). export function formatPayuAmount(amount: number): string { const twoDecimals = amount.toFixed(2); return twoDecimals.endsWith("0") ? amount.toFixed(1) : twoDecimals; } // Firma de la orden (SUBMIT_TRANSACTION) -- distinta de la firma del // webhook de abajo, confirmado contra el SDK PHP oficial de PayU. export function buildOrderSignature(params: { apiKey: string; merchantId: string; referenceCode: string; amount: string; currency: string; }): string { const raw = `${params.apiKey}~${params.merchantId}~${params.referenceCode}~${params.amount}~${params.currency}`; return createHash("md5").update(raw).digest("hex"); } // Firma del webhook de confirmación (notifyUrl) -- lo único que impide // que cualquiera falsifique una confirmación de pago. Falla cerrado: // cualquier mismatch devuelve false, nunca se traga la excepción. export function verifyWebhookSignature(params: { apiKey: string; merchantId: string; referenceSale: string; value: string; currency: string; statePol: string; sign: string; }): boolean { const raw = `${params.apiKey}~${params.merchantId}~${params.referenceSale}~${params.value}~${params.currency}~${params.statePol}`; const expected = createHash("md5").update(raw).digest("hex"); return expected.toLowerCase() === params.sign.toLowerCase(); }