# 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 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_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ó 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: 1. **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. 2. **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). 3. **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. 4. **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. 5. **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. 6. **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 `200` a un `POST` sintético, no con un webhook real disparado por ePayco).