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
112 lines
3.7 KiB
TypeScript
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();
|
|
}
|