Files
apps-registry/workloads/commerce-backend/medusa-config.ts
T
devopsandClaude Sonnet 5 c507da8abc feat(fulfillment): integrar EnvíoClick para cálculo real de envío
ADR 0009 §2 ya había anticipado esto: la shipping option actual es un
placeholder de negocio ($15.000 COP fijo), no un cálculo real por
peso/volumen -- y el modelo de direcciones del checkout (IDs DIVIPOLA
en vez de texto libre en lib/data/colombia.ts) ya se había construido
pensando en esta integración. Este commit la implementa.

Investigación en vivo (no solo documentación -- misma disciplina que
ya costó varios rehechos con las pasarelas de pago): la SPA de docs
públicas (apidoc.envioclickpro.com.co) no renderiza sin sesión, pero
está generada con apiDoc, así que se bajó y leyó directo su
api_data.json/api_project.json (spec completa real), y se probó
/quotation en vivo con la API key real del usuario -- 200 OK con
tarifas reales de PIBOX/COORDINADORA/ENVIA/RAPPI/TCC. Esto corrigió
varios datos mal asumidos de un plugin de WordPress desactualizado
usado como referencia inicial (API v1): la URL base real es
api.envioclickpro.com.co/api/v2 (no api.envioclickpro.com a secas), y
el header de auth real es "Authorization" a secas, no
"AuthorizationKey".

Hallazgo crítico evitado leyendo el código fuente de
@medusajs/utils/dist/common/define-config.js: defineConfig() ya
registra por defecto un módulo de fulfillment con el provider
manual_manual, aunque medusa-config.ts no lo mencione explícitamente
-- pero transformModules() indexa por serviceName y el último que
llega pisa al anterior (no mezcla providers). Agregar un bloque propio
de fulfillment sin incluir también manual_manual ahí habría roto en
silencio el envío manual que ya usa la shipping option existente.
Verificado cargando la config compilada real: ambos providers quedan
registrados juntos.

- src/modules/envioclick-fulfillment/: AbstractFulfillmentProviderService.
  calculatePrice() cotiza en vivo durante el checkout -- Medusa advierte
  explícitamente que un error acá bloquea el checkout completo, así que
  NUNCA se deja propagar una excepción: cualquier falla (red, dirección
  sin id DIVIPOLA, EnvíoClick caído) cae al precio fijo de $15.000 que
  ya se usaba, para no romper la compra. createFulfillment() vuelve a
  cotizar en el momento de generar la guía real en vez de reusar el
  idRate mostrado en checkout (más correcto: CalculatedShippingOptionPrice
  no tiene forma de persistir ese id entre llamadas, confirmado leyendo
  el tipo real, y un idRate viejo podría estar vencido si el fulfillment
  pasa horas/días después). cancelFulfillment() sí cancela de verdad
  (cancellation/batch/order existe y está confirmado, a diferencia del
  refund de ePayco que quedó sin implementar).
- Solo 1 paquete por cotización/guía (límite real de la API, confirmado:
  "min 1, max 1") -- el carrito se agrega a un solo paquete. Como hoy
  NINGÚN producto tiene peso/dimensiones cargados (confirmado en vivo:
  18 productos activos, 0 con weight), se usa un paquete de fallback
  conservador completo en vez de mezclar datos reales con inventados a
  medias -- la cotización no va a ser precisa hasta que se carguen esos
  datos (tarea de datos, no de código, fuera de alcance de este commit).
- Frontend: cambio chico pero necesario en checkout-client.tsx/
  medusa-cart.ts -- el checkout mandaba el nombre de la ciudad a Medusa,
  nunca el id DIVIPOLA (aunque el form sí lo tenía disponible). Hay
  municipios con el mismo nombre en distintos departamentos de Colombia,
  así que el nombre solo no alcanza para identificar la ciudad sin
  ambigüedad -- se guarda el id real en la metadata de la dirección
  (Medusa sí tiene ese campo), que el fulfillment provider lee para
  derivar el código DANE real que pide EnvíoClick (5 dígitos + "000" de
  relleno, confirmado contra su propio ejemplo: "11001000" = Bogotá).

Pendiente de verificar con una llamada real (no se quiso generar una
guía de prueba en la cuenta real del usuario sin avisar): /shipment_sandbox
y cancellation/batch/order, verificados contra la spec oficial real
pero no probados en vivo todavía.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01THZFwPzrwv6VAbuwmaf5vR
2026-09-08 21:11:43 +00:00

326 lines
12 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: [
// ADR 0009 §2 / EnvíoClick: defineConfig() ya registra por defecto
// un modulo de fulfillment con el provider manual_manual
// (confirmado leyendo @medusajs/utils/dist/common/define-config.js
// directo) -- pero como este bloque propio comparte el mismo tipo de
// modulo, transformModules() lo pisa por completo (indexa por
// serviceName, el ultimo que llega gana), no lo mezcla. Por eso
// manual_manual va explicito ACA tambien: si se omitiera, la
// shipping option existente que ya usa ese provider se rompe en
// silencio. envioclick-fulfillment implementa el mismo contrato
// (AbstractFulfillmentProviderService) contra
// developers.envioclickpro.com.co, verificado en vivo (no solo doc)
// con una llamada de prueba real que devolvio tarifas reales de
// varias transportadoras.
{
resolve: "@medusajs/medusa/fulfillment",
options: {
providers: [
{
resolve: "@medusajs/medusa/fulfillment-manual",
id: "manual",
},
{
resolve: "./src/modules/envioclick-fulfillment",
id: "standard",
options: {
apiKey: required("ENVIOCLICK_API_KEY"),
sandbox: process.env.ENVIOCLICK_SANDBOX !== "false",
originDaneCode: process.env.ENVIOCLICK_ORIGIN_DANE_CODE || "11001000",
originAddress: required("ENVIOCLICK_ORIGIN_ADDRESS"),
originCompany: process.env.ENVIOCLICK_ORIGIN_COMPANY || "ARI Shopping",
originFirstName: process.env.ENVIOCLICK_ORIGIN_FIRST_NAME || "ARI",
originLastName: process.env.ENVIOCLICK_ORIGIN_LAST_NAME || "Shopping",
originEmail: required("ENVIOCLICK_ORIGIN_EMAIL"),
originPhone: required("ENVIOCLICK_ORIGIN_PHONE"),
},
},
],
},
},
{
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,
},
},
},
},
],
})