- hero-carousel.tsx: slides derivados de productos featured/nuevos reales (imagen, nombre, precio, link, badge de % OFF) en vez de un array hardcodeado; fallback estático solo si la tienda no tiene productos activos. - navbar.tsx: en mobile/tablet el buscador pasa a una segunda fila a todo el ancho debajo de logo+iconos (antes competía por espacio en una sola fila y quedaba recortado); desktop sin cambios. - docs: ADR 0010 documentando el pivote de pago PayU→Rapyd(descartado)→ ePayco, la integración de EnvíoClick y checklist de pruebas reales pendientes desde el storefront; puntero agregado desde ADR 0009. Co-Authored-By: Claude Sonnet 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01THZFwPzrwv6VAbuwmaf5vR
17 KiB
ADR 0010 — Pago (ePayco PSE) y envío (EnvíoClick) reales; banner y buscador del storefront
Estado: aceptada — ambas integraciones desplegadas y verificadas en vivo (sandbox de cada proveedor); banner dinámico y buscador rediseñado desplegados Fecha: 2026-09-09
Contexto
El ADR 0009 dejó PayU decidido como proveedor de pago de negocio y
documentó la necesidad de un módulo custom (AbstractPaymentProvider)
porque ningún plugin comunitario de Medusa sirve para la API real de
PayU LatAm. Entre esa decisión y la implementación real pasaron dos
correcciones más, ninguna prevista en el ADR original — este documento
cierra esa cadena y agrega la segunda mitad de "checkout real" que el
0009 dejó pendiente: el envío (EnvíoClick), más dos mejoras de UI del
storefront relacionadas (banner dinámico, buscador) pedidas una vez que
ambas integraciones ya estaban en producción.
1. Pago — de PayU a Rapyd a ePayco
1.1 PayU LatAm quedó inviable: Rapyd compró ese negocio
Al intentar registrar la cuenta de comercio en developers.payulatam.com
(2026-08-29), el registro real redirige a Rapyd — confirmado en vivo
contra board.rapyd.net con capturas de pantalla del propio usuario, no
solo documentación. Investigación confirmó la causa: Rapyd adquirió el
negocio de PayU LatAm/África en 2023. PayU LatAm como entidad separada
con la API documentada en el ADR 0009 (apiLogin/apiKey/accountId)
ya no es la vía de registro real para un comercio nuevo.
Se investigó y planificó por completo la alternativa Rapyd (API REST
moderna, firma HMAC-SHA256, endpoint /v1/payments — arquitectura
totalmente distinta a la de PayU LatAm clásico, no una migración menor
de credenciales) pero nunca se escribió código: antes de implementar,
el usuario planteó ePayco como alternativa y, al evaluarla, resultó
preferible por costo/encaje — Rapyd quedó descartado en la etapa de plan,
no de código.
1.2 Decisión final: ePayco
Motivo de negocio: ePayco es 100% propiedad de Davivienda desde 2024 — mejor tarifa PSE dado que Davivienda ya es uno de los bancos disponibles del comercio (mismo dato de negocio del ADR 0009 §3: Davivienda, Lulo Bank, Banco de Bogotá).
Hallazgo técnico no obvio, verificado en vivo, no en documentación: ePayco tiene dos productos de integración distintos bajo la misma marca:
- Un producto nuevo tipo "Apify"/Web-Checkout (widget alojado).
- La integración real de PSE que efectivamente se usó: la API
"restpagos" /
secure.payco.co, más antigua, que combina login con bearer token más cifrado AES-CBC campo por campo usando la llave privada del comercio como clave AES con IV en cero — un protocolo poco común que no vale la pena reimplementar a mano. Se optó por depender del paquete oficialepayco-sdk-nodeen vez de escribir el cliente HTTP propio (a diferencia de EnvíoClick, ver §2).
Nombres de método/campo reales del SDK, corregidos contra su código fuente instalado (no contra su propia documentación pública, que estaba desactualizada respecto al paquete):
- Lista de bancos PSE:
getBanks()(la doc pública sugierepseBank(), que no existe en el SDK real). - Confirmación de método: campo
method_confirmation(la doc pública sugieremetodoconfirmacion).
Implementación (commerce-backend/src/modules/epayco-pse/,
src/lib/epayco.ts): módulo de pago custom
(AbstractPaymentProvider), estado pending_authorization tras iniciar
el pago (mecanismo documentado de Medusa para métodos asíncronos —
permite que el carrito complete en una orden "esperando confirmación" sin
bloquear sobre la respuesta del banco), webhook nativo de Medusa en
/hooks/payment/epayco_pse (sin subscriber custom — el
payment-webhook + process-payment-workflow incluidos en el core ya
completan la orden automáticamente al recibir la notificación).
Verificado en vivo (2026-09):
- Credenciales reales del comercio (
P_CUST_ID_CLIENTE,P_KEY,PUBLIC_KEY,PRIVATE_KEY) cargadas en el secret real del cluster. - Llamada real a
getBanks()contra la API de ePayco devolvió el listado real de bancos PSE en modo sandbox. - El webhook
/hooks/payment/epayco_psees alcanzable desde afuera:POSTexterno contrahttps://commerce.cruzcloud.net/hooks/payment/epayco_pseresponde200 OK(la firma inválida se maneja puertas adentro sin romper la respuesta HTTP) — cierra el riesgo de red que el ADR 0009 §2 había señalado como pendiente de validar (mismo patrón de incidente ya documentado para otro servicio,incidente-cloudflared-504-quic-2026-08.md).
Limitaciones conocidas y aceptadas, no bloqueantes:
refundPaymenttira un error explícito de "no implementado" — el endpoint real de reembolso nunca se verificó en vivo, se prefirió fallar ruidosamente a fingir éxito.- El nombre exacto del campo de la URL de redirección del banco en la
respuesta de
bank.create()se maneja de forma defensiva, probando varios nombres candidatos con un error explícito y el payload completo si ninguno matchea — todavía no se probó con un pago PSE real de principio a fin (sologetBanks()está confirmado en vivo). Ver §3 para cómo cerrar esto desde el storefront real. - El comercio está en modo sandbox de ePayco (
EPAYCO_ENV: sandboxen el ConfigMap) — no se ha movido dinero real todavía.
2. Envío — EnvíoClick reemplaza la tarifa fija
El ADR 0009 (nota de implementación) dejó una shipping option con tarifa fija de $15.000 COP como placeholder de negocio, anotando explícitamente que un cálculo real por peso/volumen era trabajo futuro — "eso es justamente lo que EnvíoClick resolvería más adelante". Este ADR cierra ese punto.
Hallazgo de negocio, no técnico, que condiciona la precisión real
Ningún producto del catálogo tiene peso ni dimensiones cargados
(confirmado contra la base real: 18 productos activos, 0 con weight,
0 con length). Sin este dato, cualquier cotización real de EnvíoClick
no puede ser precisa. Se aceptó como tarea de datos pendiente, no de
código — la integración usa un paquete de fallback conservador (1 kg,
20×20×20 cm) mientras no haya datos reales por producto, documentado en
src/modules/envioclick-fulfillment/types.ts (DEFAULT_PACKAGE).
API real, corregida contra la fuente primaria, no contra un plugin de referencia desactualizado
Un plugin de WordPress usado como primera referencia resultó ser para la
API v1 de EnvíoClick, ya obsoleta. La doc real
(apidoc.envioclickpro.com.co, generada con apiDoc — se bajó y leyó
directo api_data.json/api_project.json, ya que la SPA no renderizaba
sin sesión) reveló diferencias reales, confirmadas con una llamada real
exitosa:
| Plugin de referencia (v1, desactualizado) | API real (v2, confirmada en vivo) | |
|---|---|---|
| Base URL | api.envioclickpro.com |
https://api.envioclickpro.com.co/api/v2 |
| Header de auth | AuthorizationKey: <key> |
Authorization: <key> (sin prefijo) |
POST /quotation devolvió en vivo tarifas reales de PIBOX, COORDINADORA,
ENVIA, RAPPI y TCC para una cotización de prueba real — se toma
flete + minimumInsurance (seguro mínimo obligatorio, hay que sumarlo al
flete para el precio real a cobrar) más barato del array data.rates[],
mismo criterio de "más económico" que ya usaba el plugin de referencia.
Hallazgo crítico de Medusa, evitado antes de romper nada (no un incidente real)
Leyendo directo @medusajs/utils/dist/common/define-config.js (no
documentación): defineConfig() ya registra por defecto
Modules.FULFILLMENT con el provider manual_manual, aunque
medusa-config.ts no lo mencione explícitamente. transformModules()
indexa por serviceName y el último bloque que se declara pisa, no
suma, al anterior. Agregar un bloque de fulfillment propio sin incluir
también @medusajs/medusa/fulfillment-manual adentro habría roto el
fulfillment manual existente en silencio — mismo tipo de riesgo que casi
pasó con payment en el ADR 0009, evitado leyendo el código fuente real
antes de escribir el bloque nuevo (ambos providers conviven hoy en el
mismo bloque de medusa-config.ts).
Implementación (commerce-backend/src/modules/envioclick-fulfillment/,
src/lib/envioclick.ts): AbstractFulfillmentProviderService,
calculatePrice() invocado en vivo cada vez que el carrito refresca
shipping options (Medusa advierte que si este método falla, bloquea el
checkout — el fallback conservador de arriba existe también por esto,
no solo por falta de datos). createFulfillment() genera la guía real vía
/shipment (o /shipment_sandbox en modo prueba —
el único endpoint con sandbox real, la cotización siempre usa
tarifas reales). Sin cambios en el storefront: GET /store/shipping-options
nativo de Medusa ya invocaba calculatePrice() automáticamente para
cualquier shipping option price_type: "calculated".
Verificado en vivo (2026-09):
- Shipping option "Envío nacional estándar" migrada de
manual_manual/ tarifa fija aenvioclick_standard/calculatedvía Admin API (con usuario admin temporal, eliminado después de usarlo). - Un
400inicial ("Providers (envioclick_standard) are not enabled for the service location") se resolvió vinculando el provider a la stock location (POST /admin/stock-locations/:id/fulfillment-providers) — Medusa exige esto además del bloque de módulo. - Carrito de prueba real con dirección →
shipping_total: 8941COP, tarifa real de EnvíoClick, no el fijo de $15.000. - Datos reales de la bodega de origen cargados (dirección
Cra 100 #42-65, contacto[email protected]/3014750823) tanto en el ConfigMap (ENVIOCLICK_ORIGIN_*) como en el registro real de la stock location en Medusa (antes: "Sin dirección física registrada"). - El comercio está en modo sandbox de EnvíoClick
(
ENVIOCLICK_SANDBOX: "true") — las guías que se generan son de prueba, sin costo; ninguna cotización es de prueba (no existe sandbox de cotización).
Limitaciones conocidas y aceptadas, no bloqueantes:
createReturnFulfillmenttira "no implementado" — devoluciones fuera de alcance.- Cancelación de guía real (
POST cancellation/batch/order, hasta 50 por lote, usando losidOrderque devuelve/shipment) confirmada como existente en la API pero no implementada en el módulo — se descubrió durante la investigación, no se integró por no ser parte del flujo de checkout mínimo. - Solo EnvíoClick como provider — no hay selección de múltiples couriers por el comprador (v1 siempre toma el más barato).
- Ningún producto tiene peso/dimensiones reales — ver arriba.
3. Storefront — banner dinámico y buscador
Con ambas integraciones ya en producción, se atendieron dos pedidos de UI del storefront relacionados con la experiencia de compra real:
3.1 Banner dinámico (components/home/hero-carousel.tsx)
Antes: tres slides con copy e imágenes fijas, hardcodeadas en el
componente, sin relación con el catálogo real. Ahora: HomePage
(app/page.tsx, que ya hace getProducts() del backend real) le pasa al
carrusel los productos marcados como featured en el admin (o, si no hay
ninguno, los mismos "tesoros" —nuevos o destacados— que ya usa la sección
de abajo, para no duplicar el criterio de selección); el carrusel arma
cada slide con la imagen, nombre, precio (priceLabel) y link reales del
producto, calculando el badge de descuento (% OFF) cuando
compareAtPrice existe. Si la tienda no tuviera ningún producto activo
(caso borde), cae a un slide estático de respaldo para no romper la
página. Autoplay, dots de paginación y animación quedan igual — solo
cambia el origen de los datos.
3.2 Buscador (components/layout/navbar.tsx)
El header en mobile competía el buscador con el logo y los tres iconos
de acción en una sola fila de grid (grid-cols-[auto_1fr_auto]),
quedando visualmente recortado — reportado por el usuario junto a una
captura de referencia del header mobile de Best Buy (fila de iconos
arriba, buscador a todo el ancho debajo, con micrófono y escaneo QR
inline).
Decisión: adoptar el mismo patrón de dos filas en mobile/tablet
(logo + iconos arriba, buscador a todo el ancho debajo) pero sin los
íconos de micrófono/QR — no hay búsqueda por voz ni por código de barras
implementada, y un ícono sin función real confunde más de lo que ayuda.
En desktop (lg: y arriba) se mantuvo la fila única original del grid
[auto_1fr_auto]: ahí ya sobraba espacio horizontal y partirla en dos
solo alargaría el header sin necesidad. Sin cambios en la lógica de
PredictiveSearch (autocompletado, debounce, navegación) — solo se
reubicó su contenedor según el breakpoint.
Opciones consideradas (pago)
| Opción | A favor | En contra |
|---|---|---|
| PayU LatAm (ADR 0009) | Ya decidido, credenciales bien documentadas | Registro real redirige a Rapyd — la entidad "PayU LatAm" independiente ya no existe para un comercio nuevo |
| Rapyd | Dueño real del negocio ex-PayU LatAm, API moderna y bien documentada | API completamente distinta (HMAC, REST moderno) — no es una migración de credenciales, es una integración nueva desde cero; descartada en fase de plan por costo/encaje frente a ePayco |
| ePayco — elegido | 100% Davivienda desde 2024, mejor tarifa PSE con el banco ya disponible del comercio; SDK oficial cubre el protocolo inusual (login + cifrado AES por campo) | Dos productos bajo la misma marca (Apify vs. la API real usada) fáciles de confundir; doc pública desactualizada respecto al SDK real; reembolsos no implementados |
Consecuencias
- Checkout real de punta a punta (pago + envío calculado) queda desplegado y verificado a nivel API/backend, pero ambos proveedores siguen en modo sandbox — no se ha completado ni un pago real ni un envío real todavía. Ver checklist de pruebas abajo.
- La precisión de la tarifa de envío depende de cargar peso/dimensiones reales por producto — deuda de datos explícita, no de código; candidato natural a extender el importador CSV existente con columnas nuevas.
- Reembolsos (ePayco) y devoluciones/cancelación de guía (EnvíoClick) quedan fuera de alcance — fallan explícitamente en vez de fingir éxito.
- Revertir el pago implica deshabilitar el provider
epayco_pseen la región y volver al placeholder anterior; revertir el envío implica volver la shipping option amanual_manual/tarifa fija — ninguna de las dos requiere cambios de esquema de datos para revertirse. - El banner y el buscador no introducen dependencias nuevas ni cambian ninguna ruta de API — son cambios de storefront puros, reversibles cambiando los dos componentes de vuelta a su versión anterior.
Checklist de pruebas reales desde el storefront (pendiente, no ejecutado en este ADR)
Todo lo de arriba se verificó a nivel API/backend con llamadas directas o carritos de prueba armados por API — no todavía manejando el storefront real como lo haría un comprador. Pendiente, con la próxima sesión que tenga acceso a un navegador real:
- Pago PSE completo: agregar un producto real al carrito → checkout
→ elegir PSE → seleccionar un banco del listado real (sandbox) →
confirmar que la redirección al banco ocurre (esto es justo lo que
nunca se probó — el nombre del campo de URL de
bank.create()está manejado a la defensiva y podría no matchear ningún candidato) → volver a/checkout/pse/return→ confirmar que la orden queda en estado correcto. - Envío calculado real: en el paso de dirección del checkout, confirmar que el precio de envío mostrado ya no es el fijo de $15.000 sino el que devuelve EnvíoClick en vivo para esa dirección (visible también sin completar el pago, solo llegando al paso de shipping).
- Resiliencia del fallback de envío: si es posible, forzar una
dirección o producto que haga fallar la cotización real (por ejemplo,
un DANE code inválido) y confirmar que el checkout sigue avanzando
con el precio de respaldo en vez de romperse — el comportamiento
documentado en
calculatePrice(), nunca ejercitado con un fallo real. - Banner dinámico: confirmar en
/que el carrusel muestra productos reales del catálogo (no las tres imágenes viejas hardcodeadas) y que cada slide lleva al producto correcto al hacer clic. - Buscador: en un viewport mobile real (no solo devtools), confirmar que el buscador ya no se ve recortado y que el autocompletado despliega bien debajo de la fila nueva a todo el ancho.
- Webhook de ePayco con un pago real de sandbox: confirmar que,
tras completar un pago PSE de prueba desde el banco sandbox, el
webhook mueve la orden a completada sin intervención manual (hoy solo
se confirmó que el endpoint responde
200a unPOSTsintético, no con un webhook real disparado por ePayco).