Cierra la mayor parte del checklist de pruebas reales que había quedado pendiente: pago PSE hasta la redirección al banco, envío calculado visible, banner y buscador mobile -- todo verificado con Chromium real (Docker + Playwright en la NAS, ver .browser-scratch/README.md). Deja documentados los 2 bugs reales que esa prueba encontró (ya corregidos en otro commit) y un checklist ampliado para la próxima sesión. Co-Authored-By: Claude Sonnet 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01THZFwPzrwv6VAbuwmaf5vR
20 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 con navegador real hasta la redirección al banco de PSE (ver nota de implementación 2026-09-10 abajo), pero ambos proveedores siguen en modo sandbox — no se ha completado ni un pago real ni un envío real todavía, y el login bancario de PSE tampoco se completó (falta credencial de prueba). Ver checklist ampliado 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ó primero a nivel API/backend con llamadas directas o carritos de prueba armados por API, sin manejar el storefront real como lo haría un comprador. Esa parte del checklist se cerró el 2026-09-10 (ver nota de implementación abajo) usando un navegador real de verdad, no curl.
RESUELTO (2026-09-10) — navegador real vía Docker en la NAS
El shell donde corren los comandos de esta sesión tiene su propia red
restringida para npm (un proxy interno que rompe algunas descargas de
paquetes — no relacionado con la red real del lab). Docker en la NAS
sí sale a internet real, sin ese filtro — confirmado en vivo
(docker pull normal, un contenedor de Node golpeando
registry.npmjs.org sin problema). Se armó un harness reutilizable con
la imagen oficial mcr.microsoft.com/playwright:v1.51.1-jammy
(Chromium real) corrida puntualmente contra shop.cruzcloud.net,
guardado en .browser-scratch/ en la raíz del lab (checks.mjs +
README.md con el comando exacto para reactivarlo — no hace falta
ningún contenedor corriendo de forma persistente, la imagen y las
dependencias ya quedan cacheadas localmente en la NAS).
Hallazgo no obvio: el daemon de Docker de la NAS ve un filesystem
distinto al que ve el shell de esta sesión (que corre en su propio
sandbox, aunque monte los mismos repos git) — un volumen -v apuntando
a un path de /tmp del shell no tira error pero monta un directorio
vacío del lado del contenedor. Hay que usar un path bajo
/media/HDD-Storage/AppData/k3d-lab-v3/lab-cruzcloud/ (montaje real
compartido) para que el contenedor vea los archivos de verdad.
Resultados, con capturas reales:
- Pago PSE completo — FUNCIONA. Carrito → dirección Bogotá →
envío calculado → banco PSE (sandbox) → la redirección real
funcionó: el nombre de campo manejado a la defensiva en
bank.create()/initiatePaymentmatcheó al primer intento, y el navegador llegó a la página real de autorización de PSE (registro.desarrollo.pse.com.co, ambiente de desarrollo/sandbox de PSE Colombia). No se completó el login bancario (requiere credenciales de prueba específicas de PSE que no se tenían a mano) — sigue pendiente confirmar el webhook con un pago sandbox completado de principio a fin (ver checklist nuevo abajo). - Envío calculado real — FUNCIONA. El paso de pago mostró
Envío $8.941(mismo valor ya visto en la verificación por API de la sección anterior) en vez del fijo de $15.000, para una dirección real de Bogotá. - Banner dinámico — funciona, pero destapó un bug real: un
producto marcado
featured: truepero sin stock (available: false, "Toy Story 5 - Rex") salía primero en el carrusel con botón "Comprar ahora" hacia una página donde en realidad no se puede comprar. Corregido en el mismo commit que este hallazgo (filtro adicional&& product.availableenapp/page.tsx). - Buscador mobile — funciona. El buscador ya no se ve recortado, y el autocompletado despliega bien debajo de la fila nueva a todo el ancho (probado con el viewport de un iPhone 13 real, no devtools).
- Bug adicional encontrado, no buscado: el sandbox real de ePayco
(
bank.getBanks()) devuelve un primer elemento placeholder (bankCode: "0","A continuación seleccione su banco..."con el texto mal decodificado — unicode escapado literal) mezclado con los bancos reales, mostrado como una opción más, seleccionable, en el<select>del checkout. Ningún banco PSE real tiene código "0" — se filtra explícitamente enpse-banks/route.ts.
Pendiente — checklist ampliado para la próxima sesión con navegador
- Resiliencia del fallback de envío: forzar una dirección o
producto que haga fallar la cotización real de EnvíoClick (ej. un
DANE code inválido) y confirmar que el checkout sigue avanzando con
el precio de respaldo ($15.000) en vez de romperse — comportamiento
documentado en
calculatePrice(), nunca ejercitado con un fallo real. - Webhook de ePayco con un pago real de sandbox completado:
conseguir credenciales de prueba reales de PSE (persona/clave de
algún banco del ambiente
desarrollo), completar el login bancario de punta a punta, y confirmar que el webhook mueve la orden a completada sin intervención manual — hoy solo se confirmó que el endpoint responde200a unPOSTsintético y que la redirección al banco ocurre, no el ciclo completo con notificación real. - Login/registro de cliente real (Google OAuth y con contraseña) — nunca ejercitado con un navegador real en ninguna sesión.
- Bug conocido de Firefox Android (ver memoria de proyecto,
known-bug-firefox-android-layout): el layout se corta/desplaza solo en Firefox Android — Playwright puede emular Android/Chrome pero no Firefox; si se necesita reproducir esto específicamente hace faltaplaywright-firefoxo un dispositivo real. - Otros viewports (tablet, desktop grande) para el banner y el buscador — solo se probaron mobile (iPhone 13) y desktop 1280px.
- Flujo de wishlist/favoritos y el diálogo de cuenta — no ejercitados todavía con navegador real.