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:
1 parent
95cf791737
commit
12c4afa09c
17 files changed
+471
-509
No files matched your search
@@ -2,7 +2,7 @@ import { createPaymentCollection, createPsePaymentSession } from "@/lib/headless
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const { cartId, ...pse } = await request.json();
|
||||
if (!cartId || !pse.bankCode || !pse.docNumber || !pse.fullName || !pse.email || !pse.phone) {
|
||||
if (!cartId || !pse.bankCode || !pse.docNumber || !pse.firstName || !pse.lastName || !pse.email || !pse.phone) {
|
||||
return Response.json({ error: "Faltan campos obligatorios para el pago con PSE" }, { status: 400 });
|
||||
}
|
||||
try {
|
||||
|
||||
@@ -25,10 +25,11 @@ data:
|
||||
# MEILI_MASTER_KEY en secrets.template.yaml).
|
||||
MEILISEARCH_HOST: http://meilisearch-svc:7700
|
||||
|
||||
# PayU LatAm (ADR 0009) -- solo URLs/config no sensible, credenciales
|
||||
# reales (apiLogin/apiKey/merchantId/accountId) van en
|
||||
# secrets.template.yaml. PAYU_ENV=sandbox hasta que haya credenciales
|
||||
# ePayco (ADR 0009 -- tercer proveedor evaluado, ver medusa-config.ts
|
||||
# para el historial completo) -- solo URLs/config no sensible,
|
||||
# credenciales reales (publicKey/privateKey/pCustIdCliente/pKey) van en
|
||||
# secrets.template.yaml. EPAYCO_ENV=sandbox hasta que haya credenciales
|
||||
# de produccion reales.
|
||||
PAYU_ENV: sandbox
|
||||
PAYU_RESPONSE_URL: https://shop.cruzcloud.net/checkout/pse/return
|
||||
PAYU_NOTIFY_URL: https://commerce.cruzcloud.net/hooks/payment/payu_pse
|
||||
EPAYCO_ENV: sandbox
|
||||
EPAYCO_RESPONSE_URL: https://shop.cruzcloud.net/checkout/pse/return
|
||||
EPAYCO_NOTIFY_URL: https://commerce.cruzcloud.net/hooks/payment/epayco_pse
|
||||
@@ -36,14 +36,17 @@ stringData:
|
||||
# x-revalidate-secret al invocar POST /api/revalidate en el storefront.
|
||||
REVALIDATE_SECRET: CAMBIAR
|
||||
|
||||
# PayU LatAm (ADR 0009) -- credenciales del comercio. Mientras no exista
|
||||
# cuenta real, usar las de sandbox genéricas y públicas de PayU
|
||||
# (developers.payulatam.com/latam/en/docs/getting-started/test-your-solution.html),
|
||||
# nunca hardcodeadas acá.
|
||||
PAYU_API_LOGIN: CAMBIAR
|
||||
PAYU_API_KEY: CAMBIAR
|
||||
PAYU_MERCHANT_ID: CAMBIAR
|
||||
PAYU_ACCOUNT_ID: CAMBIAR
|
||||
# ePayco (ADR 0009 -- tercer proveedor evaluado: PayU LatAm quedó
|
||||
# inviable porque Rapyd compró ese negocio en 2023, Rapyd descartado
|
||||
# por costo; ePayco es de Davivienda desde 2024, mejor tarifa PSE para
|
||||
# el volumen real del usuario). Credenciales del comercio real, nunca
|
||||
# hardcodeadas acá. p_cust_id_cliente/p_key son valores propios del
|
||||
# dashboard de ePayco (Configuración > Llaves secretas), distintos de
|
||||
# publicKey/privateKey.
|
||||
EPAYCO_PUBLIC_KEY: CAMBIAR
|
||||
EPAYCO_PRIVATE_KEY: CAMBIAR
|
||||
EPAYCO_P_CUST_ID_CLIENTE: CAMBIAR
|
||||
EPAYCO_P_KEY: CAMBIAR
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
|
||||
@@ -14,8 +14,8 @@ const SELECT_CLASS =
|
||||
"h-12 w-full rounded-full border border-slate-200 bg-white px-5 text-sm text-slate-950 outline-none transition focus:border-cyan-500 focus:ring-4 focus:ring-cyan-300/30 disabled:opacity-50";
|
||||
|
||||
// ADR 0009: direccion + envio + pago real, todo contra el Store API real.
|
||||
// El pago es PayU LatAm/PSE (modulo custom del backend, ver
|
||||
// workloads/commerce-backend/src/modules/payu-pse) -- redirige al banco
|
||||
// El pago es ePayco/PSE (modulo custom del backend, ver
|
||||
// workloads/commerce-backend/src/modules/epayco-pse) -- redirige al banco
|
||||
// del comprador, la confirmacion real llega despues via webhook async
|
||||
// (nunca por esta pantalla). WhatsApp queda como respaldo manual si la
|
||||
// iniciacion del pago falla, ya no como camino principal.
|
||||
@@ -68,7 +68,7 @@ export function CheckoutClient() {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [banks, setBanks] = useState<PseBank[]>([]);
|
||||
const [bankCode, setBankCode] = useState("");
|
||||
const [userType, setUserType] = useState<"N" | "J">("N");
|
||||
const [userType, setUserType] = useState<"0" | "1">("0");
|
||||
const [docType, setDocType] = useState<"CC" | "CE" | "NIT" | "TI" | "PP">("CC");
|
||||
const [docNumber, setDocNumber] = useState("");
|
||||
|
||||
@@ -139,17 +139,16 @@ export function CheckoutClient() {
|
||||
userType,
|
||||
docType,
|
||||
docNumber,
|
||||
fullName: `${form.firstName} ${form.lastName}`.trim(),
|
||||
firstName: form.firstName,
|
||||
lastName: form.lastName,
|
||||
email: form.email,
|
||||
phone: form.phone,
|
||||
address: form.address1,
|
||||
city: selectedCiudad.name,
|
||||
state: selectedDepartamento.name,
|
||||
});
|
||||
if (session.error) throw new Error(session.error);
|
||||
const completion = await postJson("/api/cart/complete", { cartId });
|
||||
if (completion.error) throw new Error(completion.error);
|
||||
// Navegacion completa, no iframe -- asi lo exige PayU para el
|
||||
// Navegacion completa, no iframe -- asi lo exige ePayco para el
|
||||
// flujo de PSE. La confirmacion real llega despues via webhook,
|
||||
// esta pagina nunca vuelve a cargar sola.
|
||||
window.location.href = session.bankUrl;
|
||||
@@ -260,8 +259,8 @@ export function CheckoutClient() {
|
||||
)}
|
||||
|
||||
<div className="flex gap-4 text-sm font-bold">
|
||||
<label className="flex items-center gap-2"><input type="radio" name="userType" checked={userType === "N"} onChange={() => setUserType("N")} />Persona natural</label>
|
||||
<label className="flex items-center gap-2"><input type="radio" name="userType" checked={userType === "J"} onChange={() => setUserType("J")} />Persona jurídica</label>
|
||||
<label className="flex items-center gap-2"><input type="radio" name="userType" checked={userType === "0"} onChange={() => setUserType("0")} />Persona natural</label>
|
||||
<label className="flex items-center gap-2"><input type="radio" name="userType" checked={userType === "1"} onChange={() => setUserType("1")} />Persona jurídica</label>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
|
||||
@@ -5,12 +5,12 @@ import { Clock } from "lucide-react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
// PayU redirige aca despues de que el comprador paga en su banco -- esto
|
||||
// NO confirma el pago. Los parametros de esta URL son solo informativos;
|
||||
// el estado real y definitivo llega por el webhook async de PayU al
|
||||
// backend (getWebhookActionAndData en el modulo payu-pse), nunca por
|
||||
// esta pantalla. Por eso el mensaje nunca afirma exito, solo que
|
||||
// estamos esperando la confirmacion.
|
||||
// ePayco redirige aca despues de que el comprador paga en su banco --
|
||||
// esto NO confirma el pago. Los parametros de esta URL son solo
|
||||
// informativos; el estado real y definitivo llega por el webhook async
|
||||
// de ePayco al backend (getWebhookActionAndData en el modulo
|
||||
// epayco-pse), nunca por esta pantalla. Por eso el mensaje nunca afirma
|
||||
// exito, solo que estamos esperando la confirmacion.
|
||||
export function PseReturnClient() {
|
||||
const searchParams = useSearchParams();
|
||||
const referenceCode = searchParams.get("referenceCode") ?? searchParams.get("reference_sale");
|
||||
@@ -20,7 +20,7 @@ export function PseReturnClient() {
|
||||
<Clock className="mx-auto size-12 text-ari-marino" />
|
||||
<h1 className="mt-6 text-2xl font-black">Estamos confirmando tu pago</h1>
|
||||
<p className="mt-3 text-slate-500">
|
||||
Tu banco ya procesó la transferencia. PayU nos avisa en unos minutos si quedó aprobada — te llegará la confirmación por correo.
|
||||
Tu banco ya procesó la transferencia. ePayco nos avisa en unos minutos si quedó aprobada — te llegará la confirmación por correo.
|
||||
{referenceCode && (
|
||||
<>
|
||||
{" "}Tu número de referencia es <strong>{referenceCode}</strong>.
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
// ADR 0009: pago real via PayU LatAm (PSE), modulo custom del backend
|
||||
// (workloads/commerce-backend/src/modules/payu-pse). Archivo separado de
|
||||
// medusa-cart.ts por el mismo motivo que ese archivo esta separado de
|
||||
// ADR 0009: pago real via ePayco (PSE) -- tercer proveedor evaluado
|
||||
// (PayU LatAm descartado porque Rapyd compró ese negocio en 2023 y el
|
||||
// registro real quedó con credenciales incompatibles; Rapyd descartado
|
||||
// por costo). ePayco: 100% de Davivienda desde 2024, comisión PSE más
|
||||
// baja para el volumen real del usuario, tarifa preferencial con cuenta
|
||||
// Davivienda. Módulo custom del backend
|
||||
// (workloads/commerce-backend/src/modules/epayco-pse). Archivo separado
|
||||
// de medusa-cart.ts por el mismo motivo que ese archivo esta separado de
|
||||
// medusa.ts -- reutiliza su mismo fetch helper (medusaCartFetch), no
|
||||
// duplica la logica de headers/no-store.
|
||||
import { medusaCartFetch } from "./medusa-cart";
|
||||
@@ -10,7 +15,9 @@ export interface PseBank {
|
||||
description: string;
|
||||
}
|
||||
|
||||
export type PseUserType = "N" | "J";
|
||||
// "0" persona natural, "1" persona juridica -- valores reales que espera
|
||||
// ePayco (campo type_person), no un codigo propio nuestro.
|
||||
export type PseUserType = "0" | "1";
|
||||
export type PseDocType = "CC" | "CE" | "NIT" | "TI" | "PP";
|
||||
|
||||
export interface PsePaymentInput {
|
||||
@@ -18,18 +25,17 @@ export interface PsePaymentInput {
|
||||
userType: PseUserType;
|
||||
docType: PseDocType;
|
||||
docNumber: string;
|
||||
fullName: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
address: string;
|
||||
city: string;
|
||||
state: string;
|
||||
}
|
||||
|
||||
// "pp_{identifier}_{id}" -- convencion de Medusa, confirmada contra el
|
||||
// codigo fuente de @medusajs/payment (identifier "payu" + id "pse" en
|
||||
// codigo fuente de @medusajs/payment (identifier "epayco" + id "pse" en
|
||||
// medusa-config.ts).
|
||||
const PSE_PROVIDER_ID = "pp_payu_pse";
|
||||
const PSE_PROVIDER_ID = "pp_epayco_pse";
|
||||
|
||||
export async function getPseBanks(): Promise<PseBank[]> {
|
||||
const data = await medusaCartFetch("/store/payments/pse-banks");
|
||||
@@ -58,12 +64,11 @@ export async function createPsePaymentSession(paymentCollectionId: string, input
|
||||
user_type: input.userType,
|
||||
doc_type: input.docType,
|
||||
doc_number: input.docNumber,
|
||||
full_name: input.fullName,
|
||||
first_name: input.firstName,
|
||||
last_name: input.lastName,
|
||||
email: input.email,
|
||||
phone: input.phone,
|
||||
address: input.address,
|
||||
city: input.city,
|
||||
state: input.state,
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -73,7 +78,7 @@ export async function createPsePaymentSession(paymentCollectionId: string, input
|
||||
const session = collection.payment_sessions?.find((paymentSession) => paymentSession.provider_id === PSE_PROVIDER_ID);
|
||||
const bankUrl = session?.data?.bank_url;
|
||||
if (!bankUrl) {
|
||||
throw new Error("PayU no devolvió la URL del banco para completar el pago.");
|
||||
throw new Error("ePayco no devolvió la URL del banco para completar el pago.");
|
||||
}
|
||||
return bankUrl;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user