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
326 lines
12 KiB
TypeScript
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,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
],
|
|
})
|