fix(payments): reemplazar PayU LatAm por ePayco como pasarela PSE

PayU LatAm (integrado en el commit anterior) resultó inviable: Rapyd
compró ese negocio completo en LatAm/África en 2023, y el registro real
del usuario quedó en la plataforma de Rapyd (board.rapyd.net,
credenciales access_key/secret_key incompatibles con el módulo ya
construido, API completamente distinta). Se evaluó reconstruir contra
Rapyd, pero el usuario comparó costos para su volumen real (~8
ventas/mes) y encontró que ePayco cobra menos en PSE. Se investigó
ePayco en vivo: es 100% de Davivienda desde 2024, acepta liquidación a
cualquier banco colombiano (no exige Bancolombia/Nequi como Wompi) y da
tarifa preferencial a cuentas Davivienda -- el usuario ya tiene una.
Mejor encaje de negocio y más barato que PayU/Rapyd. Confirmado como
elección definitiva.

- src/modules/epayco-pse/ reemplaza a src/modules/payu-pse/: mismo
  contrato AbstractPaymentProvider (initiatePayment/authorizePayment
  "pending_authorization"/getWebhookActionAndData con firma SHA256
  verificada), pero envuelve el SDK oficial `epayco-sdk-node` en vez de
  llamar la API cruda -- leyendo el código fuente del SDK (no la
  documentación pública, que resultó desactualizada en más de un campo)
  se confirmó que el protocolo real de ePayco para PSE es bastante más
  particular de lo que sugieren los ejemplos: cada campo del payload se
  cifra con AES usando la private key del comercio, más un login previo
  que da un bearer token que también viaja en la misma request.
  Reimplementar esto a mano tenía mucho más riesgo de error que delegar
  en el SDK mantenido por el propio proveedor.
- Corregido en el camino: el método real para listar bancos es
  `bank.getBanks()`, no `pseBank()` (así aparecía en un resumen de
  búsqueda, pero no existe en el SDK real -- confirmado leyendo
  node_modules/epayco-sdk-node/lib/resources/bank.js directamente). El
  campo real para la URL de confirmación es `method_confirmation`
  (con guión bajo en medio), no `metodoconfirmacion` como mostraba la
  documentación pública -- confirmado contra tests/bank.js del propio
  paquete.
- retrievePayment sí se implementó esta vez (endpoint de validación de
  ePayco documentado); refundPayment sigue sin implementar -- ese
  endpoint no quedó verificado en vivo, tira error explícito en vez de
  fingir éxito.
- Frontend: el step de pago recupera el selector de banco + tipo de
  persona + documento (a diferencia del diseño que se había planeado
  para Rapyd, donde esos datos se recolectaban en la página del propio
  Rapyd/SafetyPay) -- ePayco sí necesita esos campos de nuestro lado,
  como PayU.
- Dos puntos quedan sin verificar contra un sandbox real (anotados en
  el código con qué hacer si fallan): el nombre exacto del campo con la
  URL del banco en la respuesta de bank.create() (initiatePayment tira
  un error con el payload completo si no matchea ningún candidato
  probado), y si p_cust_id_cliente/p_key del webhook son iguales a las
  API keys o valores aparte del dashboard de ePayco.

Verificado: build real de Medusa con las 7 env vars EPAYCO_* dummy,
smoke test del wrapper del SDK cargando y la verificación de firma
dando el resultado esperado contra un caso conocido, typecheck+lint+
build completo del frontend.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01THZFwPzrwv6VAbuwmaf5vR
This commit is contained in:
devopsandClaude Sonnet 5 committed 2026-09-07 03:58:33 +00:00
1 parent 95cf791737
commit 12c4afa09c
17 files changed
+471 -509

No files matched your search

@@ -0,0 +1,234 @@
import { AbstractPaymentProvider, BigNumber } from "@medusajs/framework/utils";
import type {
AuthorizePaymentInput,
AuthorizePaymentOutput,
CancelPaymentInput,
CancelPaymentOutput,
CapturePaymentInput,
CapturePaymentOutput,
DeletePaymentInput,
DeletePaymentOutput,
GetPaymentStatusInput,
GetPaymentStatusOutput,
InitiatePaymentInput,
InitiatePaymentOutput,
Logger,
ProviderWebhookPayload,
RefundPaymentInput,
RefundPaymentOutput,
RetrievePaymentInput,
RetrievePaymentOutput,
UpdatePaymentInput,
UpdatePaymentOutput,
WebhookActionResult,
} from "@medusajs/framework/types";
import { createEpaycoClient, verifyWebhookSignature } from "../../lib/epayco";
import type { EpaycoPseInputData, EpaycoPseOptions, EpaycoPseSessionData } from "./types";
type InjectedDependencies = {
logger: Logger;
};
// PSE via ePayco es asincrono igual que PayU/Rapyd: no hay confirmacion
// real hasta que llega el webhook (url_confirmation), nunca en la
// respuesta sincrona de bank.create(). authorizePayment/capturePayment
// por eso son casi no-ops -- el estado real lo trae siempre el webhook.
export default class EpaycoPseProviderService extends AbstractPaymentProvider<EpaycoPseOptions> {
static identifier = "epayco";
protected logger_: Logger;
protected options_: EpaycoPseOptions;
constructor(container: InjectedDependencies, options: EpaycoPseOptions) {
super(container, options);
this.logger_ = container.logger;
this.options_ = options;
}
static validateOptions(options: Record<string, unknown>): void {
for (const key of ["publicKey", "privateKey", "pCustIdCliente", "pKey", "env", "responseUrl", "notifyUrl"]) {
if (!options[key]) {
throw new Error(`epayco-pse: falta la opción "${key}" en medusa-config.ts`);
}
}
}
async initiatePayment(input: InitiatePaymentInput): Promise<InitiatePaymentOutput> {
const data = input.data as unknown as EpaycoPseInputData & { session_id: string };
const sessionId = data.session_id;
const amount = new BigNumber(input.amount).numeric;
const currency = input.currency_code.toUpperCase();
const client = createEpaycoClient(this.options_);
// Campos confirmados leyendo node_modules/epayco-sdk-node/lib/keylang.json
// y tests/bank.js directamente (no la documentacion publica -- esa
// mostraba "metodoconfirmacion", el codigo real que de verdad manda
// el SDK usa "method_confirmation"). El SDK cifra estos valores y
// agrega ip/test/auth por su cuenta, no hace falta hacerlo nosotros.
const response = await client.bank.create({
bank: data.bank_code,
invoice: sessionId,
description: `Orden ARI Shopping ${sessionId}`,
value: String(amount),
tax: "0",
tax_base: "0",
currency,
type_person: data.user_type,
doc_type: data.doc_type,
doc_number: data.doc_number,
name: data.first_name,
last_name: data.last_name,
email: data.email,
country: "CO",
city: data.city,
cell_phone: data.phone,
url_response: this.options_.responseUrl,
url_confirmation: this.options_.notifyUrl,
method_confirmation: "POST",
});
if (response.error) {
throw new Error(`ePayco rechazó la transacción para la sesión ${sessionId}: ${JSON.stringify(response).slice(0, 300)}`);
}
// El nombre real del campo con la URL de redirección al banco NO
// quedó verificado contra una respuesta real de sandbox en esta
// sesión (ni la doc ni los tests del SDK lo muestran) -- se prueban
// los candidatos más probables y, si ninguno aparece, se tira un
// error con el payload completo para poder leerlo en los logs y
// corregir esto en el primer intento real contra el sandbox.
const flat = (response.data ?? response) as Record<string, unknown>;
const bankUrlCandidates = ["urlbanco", "url_banco", "urlBanco", "url", "redirect_url"];
const bankUrl = bankUrlCandidates.map((key) => flat[key]).find((value) => typeof value === "string" && value);
if (typeof bankUrl !== "string" || !bankUrl) {
throw new Error(
`ePayco no devolvió una URL de banco reconocible para la sesión ${sessionId}. Respuesta completa (revisar para corregir el nombre del campo): ${JSON.stringify(response).slice(0, 500)}`,
);
}
const sessionData: EpaycoPseSessionData = {
...data,
session_id: sessionId,
ref_payco: String(flat.ref_payco ?? flat.refPayco ?? ""),
transaction_id: String(flat.transactionID ?? flat.transaction_id ?? ""),
bank_url: bankUrl,
state: "PENDING",
amount,
currency,
};
return { id: sessionId, data: sessionData as unknown as Record<string, unknown>, status: "pending" };
}
// PSE es diferido: en el momento de completar el carrito, ePayco
// todavia no confirmo nada -- "pending_authorization" es el mecanismo
// documentado de Medusa para esto (la orden queda "awaiting" y el
// navegador ya puede redirigirse al banco).
async authorizePayment(input: AuthorizePaymentInput): Promise<AuthorizePaymentOutput> {
return { data: input.data, status: "pending_authorization" };
}
// Casi no-op: cuando el webhook reporta "captured", ePayco ya liquido
// el dinero de su lado -- solo persiste el data actualizado.
async capturePayment(input: CapturePaymentInput): Promise<CapturePaymentOutput> {
return { data: input.data };
}
async cancelPayment(input: CancelPaymentInput): Promise<CancelPaymentOutput> {
this.logger_.warn("epayco-pse: cancelPayment llamado, pero PSE no soporta cancelar una transacción ya enviada");
return { data: input.data };
}
async deletePayment(input: DeletePaymentInput): Promise<DeletePaymentOutput> {
return { data: input.data };
}
// Endpoint de reembolso de ePayco no quedó verificado en vivo en esta
// sesión -- con dinero real de por medio, mejor tirar un error
// explícito que fingir un reembolso que nunca se procesó.
async refundPayment(_input: RefundPaymentInput): Promise<RefundPaymentOutput> {
throw new Error(
"epayco-pse: refundPayment no está implementado todavía -- procesar el reembolso manualmente desde el panel de ePayco por ahora.",
);
}
// GET /validation/v1/reference/{ref_payco} -- endpoint de verificación
// server-to-server documentado por ePayco para consultar el estado
// real de una transacción.
async retrievePayment(input: RetrievePaymentInput): Promise<RetrievePaymentOutput> {
const data = input.data as unknown as EpaycoPseSessionData | undefined;
const refPayco = data?.ref_payco;
if (!refPayco) {
throw new Error("epayco-pse: retrievePayment necesita ref_payco, no está guardado en esta sesión.");
}
const response = await fetch(`https://secure.epayco.co/validation/v1/reference/${refPayco}`);
const json = await response.json();
return { data: json as Record<string, unknown> };
}
async updatePayment(input: UpdatePaymentInput): Promise<UpdatePaymentOutput> {
// ePayco no tiene concepto de "modificar" una transaccion ya
// enviada. El checkout solo llega al step de pago despues de fijar
// direccion y envio, asi que el total no deberia cambiar una vez que
// existe una sesion PSE.
return { data: input.data };
}
async getPaymentStatus(input: GetPaymentStatusInput): Promise<GetPaymentStatusOutput> {
const data = input.data as unknown as EpaycoPseSessionData | undefined;
const state = data?.state;
if (state === "1") return { status: "captured" };
if (state === "3") return { status: "pending" };
if (state === "PENDING") return { status: "pending" };
return { status: "canceled" };
}
// Unico punto real de confirmacion asincrona. Falla cerrado: si la
// firma no matchea, no se hace nada -- nunca se confia en un webhook
// sin firma valida.
async getWebhookActionAndData(payload: ProviderWebhookPayload["payload"]): Promise<WebhookActionResult> {
const body = payload.data as Record<string, unknown>;
const refPayco = String(body.x_ref_payco ?? "");
const transactionId = String(body.x_transaction_id ?? "");
const amount = String(body.x_amount ?? "");
const currencyCode = String(body.x_currency_code ?? "");
const codState = String(body.x_cod_transaction_state ?? "");
const signature = String(body.x_signature ?? "");
// session_id sale del invoice que nosotros mandamos -- ePayco lo
// devuelve como x_id_invoice (o x_extra1 si no viniera) segun el
// webhook real; se intentan ambos.
const sessionId = String(body.x_id_invoice ?? body.x_extra1 ?? refPayco);
const validSignature = verifyWebhookSignature({
pCustIdCliente: this.options_.pCustIdCliente,
pKey: this.options_.pKey,
refPayco,
transactionId,
amount,
currencyCode,
signature,
});
if (!validSignature) {
this.logger_.warn(`epayco-pse: webhook con firma inválida para x_ref_payco=${refPayco}, ignorado`);
return { action: "not_supported" };
}
// Solo "1" (Aceptada) es exito -- cualquier otro codigo (2 Rechazada,
// 4 Fallida, 6 Reversada, 9 caducada, 10 abandonada, 11 Cancelada,
// 3 Pendiente) se trata como fallido en vez de asumir un exito
// distinto. Un "failed" de mas es reintentable; un "captured" de mas
// (dinero que nunca se aprobo) no lo es.
const action = codState === "1" ? "captured" : "failed";
return {
action,
data: {
session_id: sessionId,
amount: new BigNumber(amount || 0),
},
};
}
}