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
This commit is contained in:
1 parent
313bb2eab4
commit
2ceea23f40
17 files changed
+802
-17
No files matched your search
@@ -0,0 +1,111 @@
|
||||
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();
|
||||
}
|
||||
Reference in new issue
Block a user