Files
apps-registry/workloads/commerce-backend/src/lib/payu.ts
T
devopsandClaude Sonnet 5 2ceea23f40 feat(payments): integrar PayU LatAm (PSE) como pasarela de pagos
ADR 0009 ya había decidido PayU LatAm como proveedor (única opción que
no exige cuenta Bancolombia/Nequi) y confirmado que no existe ningún
plugin real de Medusa para su API -- los paquetes que aparecen
buscando "PayU" son para PayU Polonia/CEE, API completamente distinta
(clientId/posId/secondKey vs. apiLogin/apiKey/merchantId/accountId).
Este commit implementa esa decisión: módulo de pago custom
(AbstractPaymentProvider) para PSE (transferencia bancaria), primer
alcance del método de pago -- Nequi/Daviplata quedan fuera por ahora.

Backend (commerce-backend):
- src/modules/payu-pse/: initiatePayment arma y postea SUBMIT_TRANSACTION
  contra la API real de PayU LatAm; authorizePayment devuelve
  "pending_authorization" (PSE es async, la confirmación real llega por
  webhook, no en el momento de completar el carrito); getWebhookActionAndData
  valida la firma MD5 del webhook antes de confiar en cualquier
  confirmación de pago -- falla cerrado si no matchea. refundPayment/
  retrievePayment tiran error explícito en vez de simular éxito: esos
  endpoints de PayU no quedaron verificados en vivo y hay dinero real de
  por medio.
- No hace falta un subscriber propio para completar la orden -- el
  subscriber nativo de Medusa (payment-webhook + process-payment-workflow)
  ya hace ese trabajo con los métodos de arriba bien implementados
  (confirmado leyendo el código fuente instalado de @medusajs/medusa
  2.18.0, no la documentación).
- Correlación webhook↔sesión: Medusa inyecta su propio session_id en
  `data` antes de initiatePayment; se usa ese mismo valor como
  referenceCode de PayU, así el reference_sale que vuelve en el webhook
  ya es directamente el session_id de Medusa.
- src/lib/payu.ts: cliente HTTP + firmas (orden y webhook, fórmulas
  distintas, confirmadas contra el SDK PHP oficial de PayU).
- src/api/store/payments/pse-banks/route.ts: lista de bancos PSE,
  cacheada en memoria.
- medusa-config.ts, Dockerfile, configmap/secrets.template: 7 env vars
  nuevas (PAYU_API_LOGIN/API_KEY/MERCHANT_ID/ACCOUNT_ID/ENV/RESPONSE_URL/
  NOTIFY_URL), dummies de build agregados, secrets con placeholder
  "CAMBIAR".

Frontend (ecommerce):
- Nuevo step "payment" en components/checkout/checkout-client.tsx
  (reemplaza al step "review" que solo ofrecía WhatsApp): selector de
  banco, tipo de persona (N/J), tipo/número de documento, botón "Pagar
  con PSE" que crea la sesión de pago, completa el carrito y redirige
  al banco (navegación completa, no iframe -- así lo exige PayU).
  WhatsApp queda como respaldo manual si falla la iniciación del pago.
- lib/headless/providers/medusa-payment.ts + 3 rutas proxy nuevas bajo
  app/api/cart/, mismo patrón que el resto del carrito
  (medusaCartFetch, ahora exportado desde medusa-cart.ts).
- app/checkout/pse/return/: página de vuelta del banco -- nunca afirma
  éxito, la confirmación real es el webhook async.

Arranca contra las credenciales de sandbox genéricas y públicas de
PayU (developers.payulatam.com) mientras el usuario termina su propio
registro de comercio -- swap de las 4 credenciales via secret cuando
estén listas, sin cambios de código.

Verificado: build real de Medusa con las env vars dummy (no solo tsc),
tipos de AbstractPaymentProvider/WebhookActionResult/PaymentActions
confirmados contra @medusajs/utils y @medusajs/types instalados
(node_modules limpiado después), typecheck + lint + build completo del
frontend, páginas nuevas renderizando sin error. Pendiente: prueba
end-to-end contra el sandbox real de PayU (necesita el backend
desplegado) y habilitar el provider en la región Colombia desde el
Admin.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01THZFwPzrwv6VAbuwmaf5vR
2026-09-07 01:17:22 +00:00

112 lines
3.7 KiB
TypeScript

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<PayuEnv, string> = {
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<string, unknown>,
): Promise<Record<string, unknown>> {
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<string, unknown>;
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<PayuBank[]> {
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();
}