Files
apps-registry/workloads/commerce-backend/medusa-config.ts
T
devopsandClaude Sonnet 5 12c4afa09c fix(payments): reemplazar PayU LatAm por ePayco como pasarela PSE
PayU LatAm (integrado en el commit anterior) resultó inviable: Rapyd
compró ese negocio completo en LatAm/África en 2023, y el registro real
del usuario quedó en la plataforma de Rapyd (board.rapyd.net,
credenciales access_key/secret_key incompatibles con el módulo ya
construido, API completamente distinta). Se evaluó reconstruir contra
Rapyd, pero el usuario comparó costos para su volumen real (~8
ventas/mes) y encontró que ePayco cobra menos en PSE. Se investigó
ePayco en vivo: es 100% de Davivienda desde 2024, acepta liquidación a
cualquier banco colombiano (no exige Bancolombia/Nequi como Wompi) y da
tarifa preferencial a cuentas Davivienda -- el usuario ya tiene una.
Mejor encaje de negocio y más barato que PayU/Rapyd. Confirmado como
elección definitiva.

- src/modules/epayco-pse/ reemplaza a src/modules/payu-pse/: mismo
  contrato AbstractPaymentProvider (initiatePayment/authorizePayment
  "pending_authorization"/getWebhookActionAndData con firma SHA256
  verificada), pero envuelve el SDK oficial `epayco-sdk-node` en vez de
  llamar la API cruda -- leyendo el código fuente del SDK (no la
  documentación pública, que resultó desactualizada en más de un campo)
  se confirmó que el protocolo real de ePayco para PSE es bastante más
  particular de lo que sugieren los ejemplos: cada campo del payload se
  cifra con AES usando la private key del comercio, más un login previo
  que da un bearer token que también viaja en la misma request.
  Reimplementar esto a mano tenía mucho más riesgo de error que delegar
  en el SDK mantenido por el propio proveedor.
- Corregido en el camino: el método real para listar bancos es
  `bank.getBanks()`, no `pseBank()` (así aparecía en un resumen de
  búsqueda, pero no existe en el SDK real -- confirmado leyendo
  node_modules/epayco-sdk-node/lib/resources/bank.js directamente). El
  campo real para la URL de confirmación es `method_confirmation`
  (con guión bajo en medio), no `metodoconfirmacion` como mostraba la
  documentación pública -- confirmado contra tests/bank.js del propio
  paquete.
- retrievePayment sí se implementó esta vez (endpoint de validación de
  ePayco documentado); refundPayment sigue sin implementar -- ese
  endpoint no quedó verificado en vivo, tira error explícito en vez de
  fingir éxito.
- Frontend: el step de pago recupera el selector de banco + tipo de
  persona + documento (a diferencia del diseño que se había planeado
  para Rapyd, donde esos datos se recolectaban en la página del propio
  Rapyd/SafetyPay) -- ePayco sí necesita esos campos de nuestro lado,
  como PayU.
- Dos puntos quedan sin verificar contra un sandbox real (anotados en
  el código con qué hacer si fallan): el nombre exacto del campo con la
  URL del banco en la respuesta de bank.create() (initiatePayment tira
  un error con el payload completo si no matchea ningún candidato
  probado), y si p_cust_id_cliente/p_key del webhook son iguales a las
  API keys o valores aparte del dashboard de ePayco.

Verificado: build real de Medusa con las 7 env vars EPAYCO_* dummy,
smoke test del wrapper del SDK cargando y la verificación de firma
dando el resultado esperado contra un caso conocido, typecheck+lint+
build completo del frontend.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01THZFwPzrwv6VAbuwmaf5vR
2026-09-07 03:58:33 +00:00

287 lines
9.7 KiB
TypeScript

import { defineConfig, loadEnv } from "@medusajs/framework/utils"
loadEnv(process.env.NODE_ENV || "development", process.cwd())
const required = (name: string): string => {
const value = process.env[name]
if (!value) {
throw new Error(`La variable ${name} es obligatoria.`)
}
return value
}
// Mismos campos derivados de metadata/categories/collection/variants que
// mapProduct() en workloads/ecommerce/lib/headless/providers/medusa.ts --
// el documento indexado debe reflejar exactamente lo que el storefront
// ya muestra, no una vista distinta del catálogo.
function stringMetadata(
metadata: Record<string, unknown> | null | undefined,
key: string,
): string {
const value = metadata?.[key]
return typeof value === "string" ? value : ""
}
function booleanMetadata(
metadata: Record<string, unknown> | null | undefined,
key: string,
): boolean {
const value = metadata?.[key]
return value === true || value === "true"
}
async function meilisearchProductTransformer(
document: Record<string, unknown>,
) {
const metadata = (document.metadata ?? null) as Record<
string,
unknown
> | null
const categories = document.categories as
| Array<{ name?: string | null }>
| null
| undefined
const collection = document.collection as { title?: string | null } | null
const variants = document.variants as
| Array<{
inventory_quantity?: number | null
manage_inventory?: boolean | null
allow_backorder?: boolean | null
prices?: Array<{
amount?: number | null
currency_code?: string | null
}> | null
}>
| null
| undefined
const variant = variants?.[0]
const tags = document.tags as Array<{ value?: string | null }> | null
const category =
categories?.[0]?.name || stringMetadata(metadata, "category") || "Variedades"
const available =
!variant ||
variant.allow_backorder === true ||
variant.manage_inventory === false ||
(variant.inventory_quantity ?? 0) > 0
// variants.calculated_price requiere currency_code/region_id en el
// contexto de pricing, que el workflow de sync interno del plugin no
// provee -- hace fallar TODO el job de indexado (confirmado en vivo:
// "Method calculatePrices requires currency_code in the pricing
// context"). variants.prices es la lista cruda de precios (sin
// resolver promociones/reglas), no necesita contexto -- alcanza para
// el filtro/orden por precio en la busqueda, que no necesita el
// precio final promocional.
const copPrice = variant?.prices?.find(
(price) => price.currency_code?.toLowerCase() === "cop",
)
return {
id: document.id,
name: document.title || "Producto ARI Shopping",
shortDescription: document.subtitle || document.title || "",
description: document.description || "",
category,
collection: collection?.title || stringMetadata(metadata, "collection") || "",
brand: stringMetadata(metadata, "brand") || "ARI Shopping",
ageRange: stringMetadata(metadata, "ageRange") || "Todas",
tags: (tags || []).map((tag) => tag.value).filter(Boolean),
price: typeof copPrice?.amount === "number" ? copPrice.amount : null,
available,
isNew: booleanMetadata(metadata, "isNew"),
featured: booleanMetadata(metadata, "featured"),
}
}
module.exports = defineConfig({
projectConfig: {
databaseUrl: required("DATABASE_URL"),
redisUrl: required("REDIS_URL"),
workerMode: (process.env.MEDUSA_WORKER_MODE || "shared") as
| "shared"
| "worker"
| "server",
http: {
storeCors: required("STORE_CORS"),
adminCors: required("ADMIN_CORS"),
authCors: required("AUTH_CORS"),
jwtSecret: required("JWT_SECRET"),
cookieSecret: required("COOKIE_SECRET"),
},
// Todos los Ingress del lab terminan en Traefik por HTTP plano
// (entrypoint "web", sin TLS); Cloudflare es quien atiende HTTPS de
// cara al navegador. Con `secure: true` (el default en producción),
// express-session nunca emite Set-Cookie porque no detecta la
// conexión como HTTPS, y el dashboard admin queda con 401 en
// /admin/users/me pese a loguear bien. La API con Bearer token no se
// ve afectada por esto.
cookieOptions: {
secure: false,
},
},
admin: {
disable: process.env.DISABLE_MEDUSA_ADMIN === "true",
backendUrl:
process.env.MEDUSA_BACKEND_URL ||
"https://commerce.cruzcloud.net",
storefrontUrl:
process.env.STOREFRONT_URL ||
"https://shop.cruzcloud.net",
},
modules: [
{
resolve: "@medusajs/medusa/file",
options: {
providers: [
{
resolve: "@medusajs/medusa/file-s3",
id: "s3",
options: {
file_url: required("S3_FILE_URL"),
access_key_id: required("S3_ACCESS_KEY_ID"),
secret_access_key: required("S3_SECRET_ACCESS_KEY"),
region: process.env.S3_REGION || "us-east-1",
bucket: required("S3_BUCKET"),
endpoint: required("S3_ENDPOINT"),
cache_control:
"public, max-age=31536000, immutable",
additional_client_config: {
forcePathStyle: true,
},
},
},
],
},
},
{
resolve: "@medusajs/medusa/auth",
options: {
providers: [
// emailpass se declara explicito junto con google -- una vez
// que el modulo auth se customiza, la doc no deja claro si el
// provider default (emailpass) se sigue registrando solo.
{
resolve: "@medusajs/medusa/auth-emailpass",
id: "emailpass",
options: {},
},
{
resolve: "@medusajs/medusa/auth-google",
id: "google",
options: {
clientId: required("GOOGLE_CLIENT_ID"),
clientSecret: required("GOOGLE_CLIENT_SECRET"),
callbackUrl: required("GOOGLE_CALLBACK_URL"),
},
},
],
},
},
// ADR 0009: proveedor de pago -- tercera vuelta. PayU LatAm fue la
// decision original, pero Rapyd compro ese negocio en 2023 y el
// registro real del usuario quedo con credenciales de Rapyd
// (incompatibles, API completamente distinta). Se evaluo Rapyd en
// vivo y se termino descartando: comision mas alta que otras
// opciones para el volumen real del usuario. ePayco elegido en su
// lugar -- 100% de Davivienda desde 2024, comision PSE mas baja,
// tarifa preferencial con cuenta Davivienda (que el usuario ya
// tiene), sin exigir Bancolombia/Nequi como si exige Wompi. Tampoco
// existe plugin real de Medusa para ePayco, modulo custom propio.
// Alcance inicial: solo PSE, no tarjetas/otros metodos.
// provider_id resultante: "pp_epayco_pse" (identifier "epayco" + id
// "pse", convencion de Medusa).
{
resolve: "@medusajs/medusa/payment",
options: {
providers: [
{
resolve: "./src/modules/epayco-pse",
id: "pse",
options: {
publicKey: required("EPAYCO_PUBLIC_KEY"),
privateKey: required("EPAYCO_PRIVATE_KEY"),
pCustIdCliente: required("EPAYCO_P_CUST_ID_CLIENTE"),
pKey: required("EPAYCO_P_KEY"),
env: process.env.EPAYCO_ENV || "sandbox",
responseUrl: required("EPAYCO_RESPONSE_URL"),
notifyUrl: required("EPAYCO_NOTIFY_URL"),
},
},
],
},
},
],
// ADR 0006 Etapa 2. Sin required(): a diferencia de S3/DB, Meilisearch
// es un servicio nuevo y todavia no probado en produccion en un host
// con historial de swap saturado -- si esta caido o mal configurado,
// el plugin se degrada solo (soporta enabled:false, "silently skipped"
// segun su propio README) en vez de tumbar el arranque de Medusa
// entero, que es lo que si haria required().
plugins: [
{
resolve: "@rokmohar/medusa-plugin-meilisearch",
options: {
config: {
host: process.env.MEILISEARCH_HOST ?? "",
apiKey: process.env.MEILISEARCH_API_KEY ?? "",
},
settings: {
products: {
type: "products",
enabled: Boolean(process.env.MEILISEARCH_HOST),
primaryKey: "id",
// Campos que el plugin pide via query.graph antes de pasarlos
// al transformer de arriba -- si falta alguno acá, llega
// undefined al transformer sin importar lo que este haga.
fields: [
"id",
"title",
"subtitle",
"description",
"handle",
"status",
"thumbnail",
"metadata",
"tags.value",
"categories.name",
"collection.title",
"variants.inventory_quantity",
"variants.manage_inventory",
"variants.allow_backorder",
"variants.prices.amount",
"variants.prices.currency_code",
],
indexSettings: {
// Tabla de ADR 0006 §3: Buscable / Filtrable-faceta / Ordenable.
searchableAttributes: [
"name",
"shortDescription",
"description",
"category",
"collection",
"brand",
"tags",
],
filterableAttributes: [
"category",
"collection",
"brand",
"ageRange",
"price",
"available",
],
sortableAttributes: ["price", "isNew", "featured"],
},
transformer: meilisearchProductTransformer,
},
},
},
},
],
})