# 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 oficial `epayco-sdk-node` en 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 sugiere `pseBank()`, que no existe en el SDK real). - Confirmación de método: campo `method_confirmation` (la doc pública sugiere `metodoconfirmacion`). **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_pse` es alcanzable desde afuera: `POST` externo contra `https://commerce.cruzcloud.net/hooks/payment/epayco_pse` responde `200 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:** - `refundPayment` tira 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** (solo `getBanks()` 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: sandbox` en 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: ` | `Authorization: ` (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 a `envioclick_standard`/`calculated` vía Admin API (con usuario admin temporal, eliminado después de usarlo). - Un `400` inicial (`"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: 8941` COP, 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 `cristiancruz29@outlook.com` / `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:** - `createReturnFulfillment` tira "no implementado" — devoluciones fuera de alcance. - Cancelación de guía real (`POST cancellation/batch/order`, hasta 50 por lote, usando los `idOrder` que 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_pse` en la región y volver al placeholder anterior; revertir el envío implica volver la shipping option a `manual_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:** 1. **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()`/`initiatePayment` matcheó 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). 2. **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á. 3. **Banner dinámico — funciona, pero destapó un bug real:** un producto marcado `featured: true` pero **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.available` en `app/page.tsx`). 4. **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). 5. **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 `