Files
apps-registry/workloads/docs-portal/docs/decisiones/0010-pago-envio-reales-y-storefront.md
T
devopsandClaude Sonnet 5 1abc69825c feat(ecommerce): banner dinámico desde catálogo real y buscador en dos filas en mobile
- 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
2026-09-09 21:45:25 +00:00

17 KiB
Raw Blame History

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: <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 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 [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:

  • 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).