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
257 lines
8.1 KiB
TypeScript
257 lines
8.1 KiB
TypeScript
// ADR 0009: carrito real conectado a Medusa (Store API), en vez del
|
|
// estado 100% local que existia antes. Archivo separado de medusa.ts
|
|
// a proposito -- ese es de solo lectura (catalogo, cacheado con
|
|
// revalidate/tags), este es de escritura (carrito, siempre no-store,
|
|
// nunca debe cachearse).
|
|
//
|
|
// Formas de respuesta verificadas en vivo contra la API real
|
|
// (2026-08-29), no asumidas contra documentacion:
|
|
// - crear/agregar/actualizar linea: { cart: {...} }
|
|
// - eliminar linea: { id, object, deleted, parent: {...} } -- el
|
|
// carrito actualizado viene en "parent", no en "cart".
|
|
// - agregar el mismo variant_id dos veces hace merge de cantidad en la
|
|
// misma linea (confirmado con un carrito de prueba real), no crea
|
|
// una linea duplicada -- por eso addLineItem siempre manda
|
|
// quantity=1 sin revisar antes si ya existe.
|
|
|
|
export interface CartLineProduct {
|
|
id: string;
|
|
name: string;
|
|
thumbnail: string;
|
|
price: number | null;
|
|
currency: string;
|
|
}
|
|
|
|
export interface CartLine {
|
|
lineItemId: string;
|
|
product: CartLineProduct;
|
|
quantity: number;
|
|
variantId: string;
|
|
}
|
|
|
|
export interface CartSummary {
|
|
cartId: string;
|
|
currency: string;
|
|
subtotal: number;
|
|
shippingTotal: number;
|
|
total: number;
|
|
items: CartLine[];
|
|
}
|
|
|
|
export interface ShippingAddressInput {
|
|
firstName: string;
|
|
lastName: string;
|
|
address1: string;
|
|
city: string;
|
|
province?: string;
|
|
phone: string;
|
|
email: string;
|
|
// ids DIVIPOLA/DANE reales (lib/data/colombia.ts) -- se guardan en la
|
|
// metadata de la direccion porque Medusa no tiene un campo propio para
|
|
// esto. EnvioClick (fulfillment provider real, ver ADR 0009 §2) los
|
|
// necesita para cotizar/generar guia sin ambiguedad -- el nombre de
|
|
// ciudad solo no alcanza (hay nombres de municipio repetidos entre
|
|
// distintos departamentos en Colombia).
|
|
cityId?: number;
|
|
departmentId?: number;
|
|
}
|
|
|
|
export interface ShippingOption {
|
|
id: string;
|
|
name: string;
|
|
amount: number;
|
|
}
|
|
|
|
function backendUrl(): string {
|
|
const value = process.env.MEDUSA_BACKEND_URL;
|
|
if (!value) {
|
|
throw new Error("MEDUSA_BACKEND_URL no está configurado.");
|
|
}
|
|
return value.replace(/\/$/, "");
|
|
}
|
|
|
|
function regionId(): string {
|
|
const value = process.env.MEDUSA_REGION_ID;
|
|
if (!value) {
|
|
throw new Error("MEDUSA_REGION_ID no está configurado.");
|
|
}
|
|
return value;
|
|
}
|
|
|
|
type MedusaLineItem = {
|
|
id: string;
|
|
product_id: string;
|
|
variant_id: string;
|
|
title?: string | null;
|
|
product_title?: string | null;
|
|
thumbnail?: string | null;
|
|
quantity: number;
|
|
unit_price?: number | null;
|
|
product?: { images?: Array<{ url?: string | null }> | null } | null;
|
|
};
|
|
|
|
type MedusaCart = {
|
|
id: string;
|
|
currency_code: string;
|
|
subtotal?: number | null;
|
|
shipping_total?: number | null;
|
|
total?: number | null;
|
|
items?: MedusaLineItem[] | null;
|
|
};
|
|
|
|
function mapCart(cart: MedusaCart): CartSummary {
|
|
return {
|
|
cartId: cart.id,
|
|
currency: cart.currency_code,
|
|
subtotal: cart.subtotal ?? 0,
|
|
shippingTotal: cart.shipping_total ?? 0,
|
|
total: cart.total ?? cart.subtotal ?? 0,
|
|
items: (cart.items ?? []).map((item) => ({
|
|
lineItemId: item.id,
|
|
variantId: item.variant_id,
|
|
quantity: item.quantity,
|
|
product: {
|
|
id: item.product_id,
|
|
name: item.product_title || item.title || "Producto",
|
|
// item.thumbnail viene null en la practica para todo el catalogo
|
|
// real (confirmado contra /store/products: product.thumbnail
|
|
// tambien es null ahi, incluso con fotos reales cargadas) -- el
|
|
// catalogo ya resuelve esto cayendo a images[0] (mapProduct() en
|
|
// medusa.ts), asi que la linea del carrito pide esa misma relacion
|
|
// explicita (no viene por defecto) y aplica el mismo fallback.
|
|
thumbnail: item.thumbnail || item.product?.images?.[0]?.url || "/images/brand/logo-ari.png",
|
|
price: typeof item.unit_price === "number" ? item.unit_price : null,
|
|
currency: cart.currency_code,
|
|
},
|
|
})),
|
|
};
|
|
}
|
|
|
|
// Exportado para medusa-payment.ts (mismo backend, mismo criterio de
|
|
// no-store/x-publishable-api-key -- evita duplicar el fetch helper).
|
|
export async function medusaCartFetch(
|
|
path: string,
|
|
init?: { method?: string; body?: unknown },
|
|
): Promise<Record<string, unknown>> {
|
|
const publishableKey = process.env.MEDUSA_PUBLISHABLE_KEY;
|
|
|
|
const response = await fetch(`${backendUrl()}${path}`, {
|
|
method: init?.method ?? "GET",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
...(publishableKey ? { "x-publishable-api-key": publishableKey } : {}),
|
|
},
|
|
body: init?.body ? JSON.stringify(init.body) : undefined,
|
|
cache: "no-store",
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const text = await response.text().catch(() => "");
|
|
throw new Error(`Medusa cart API ${response.status}: ${text.slice(0, 300)}`);
|
|
}
|
|
|
|
return response.json();
|
|
}
|
|
|
|
// *items.product.images no viene por defecto (verificado en vivo) y es
|
|
// lo unico que necesitamos ademas del set por defecto -- *items ya trae
|
|
// unit_price/product_title/variant_id/quantity sin pedirlos aparte.
|
|
const ITEMS_FIELDS = "fields=*items,*items.product.images";
|
|
|
|
export async function createCart(): Promise<CartSummary> {
|
|
const data = await medusaCartFetch("/store/carts", {
|
|
method: "POST",
|
|
body: { region_id: regionId() },
|
|
});
|
|
return mapCart(data.cart as MedusaCart);
|
|
}
|
|
|
|
export async function getCart(cartId: string): Promise<CartSummary> {
|
|
const data = await medusaCartFetch(`/store/carts/${cartId}?${ITEMS_FIELDS}`);
|
|
return mapCart(data.cart as MedusaCart);
|
|
}
|
|
|
|
export async function addLineItem(
|
|
cartId: string,
|
|
variantId: string,
|
|
quantity: number,
|
|
): Promise<CartSummary> {
|
|
const data = await medusaCartFetch(`/store/carts/${cartId}/line-items?${ITEMS_FIELDS}`, {
|
|
method: "POST",
|
|
body: { variant_id: variantId, quantity },
|
|
});
|
|
return mapCart(data.cart as MedusaCart);
|
|
}
|
|
|
|
export async function updateLineItemQuantity(
|
|
cartId: string,
|
|
lineItemId: string,
|
|
quantity: number,
|
|
): Promise<CartSummary> {
|
|
const data = await medusaCartFetch(`/store/carts/${cartId}/line-items/${lineItemId}?${ITEMS_FIELDS}`, {
|
|
method: "POST",
|
|
body: { quantity },
|
|
});
|
|
return mapCart(data.cart as MedusaCart);
|
|
}
|
|
|
|
export async function removeLineItem(cartId: string, lineItemId: string): Promise<CartSummary> {
|
|
const data = await medusaCartFetch(`/store/carts/${cartId}/line-items/${lineItemId}?${ITEMS_FIELDS}`, {
|
|
method: "DELETE",
|
|
});
|
|
// Forma distinta a las demas: el carrito actualizado viene en
|
|
// "parent", no en "cart" -- verificado en vivo, no documentado.
|
|
return mapCart(data.parent as MedusaCart);
|
|
}
|
|
|
|
export async function setShippingAddress(
|
|
cartId: string,
|
|
address: ShippingAddressInput,
|
|
): Promise<CartSummary> {
|
|
const data = await medusaCartFetch(`/store/carts/${cartId}?${ITEMS_FIELDS}`, {
|
|
method: "POST",
|
|
body: {
|
|
email: address.email,
|
|
shipping_address: {
|
|
first_name: address.firstName,
|
|
last_name: address.lastName,
|
|
address_1: address.address1,
|
|
city: address.city,
|
|
province: address.province || undefined,
|
|
country_code: "co",
|
|
phone: address.phone,
|
|
metadata:
|
|
address.cityId || address.departmentId
|
|
? { cityId: address.cityId, departmentId: address.departmentId }
|
|
: undefined,
|
|
},
|
|
},
|
|
});
|
|
return mapCart(data.cart as MedusaCart);
|
|
}
|
|
|
|
type MedusaShippingOption = {
|
|
id: string;
|
|
name: string;
|
|
amount?: number | null;
|
|
calculated_price?: { calculated_amount?: number | null } | null;
|
|
};
|
|
|
|
export async function getShippingOptions(cartId: string): Promise<ShippingOption[]> {
|
|
const data = await medusaCartFetch(`/store/shipping-options?cart_id=${cartId}`);
|
|
const options = (data.shipping_options as MedusaShippingOption[] | undefined) ?? [];
|
|
return options.map((option) => ({
|
|
id: option.id,
|
|
name: option.name,
|
|
amount: option.calculated_price?.calculated_amount ?? option.amount ?? 0,
|
|
}));
|
|
}
|
|
|
|
export async function addShippingMethod(cartId: string, optionId: string): Promise<CartSummary> {
|
|
const data = await medusaCartFetch(`/store/carts/${cartId}/shipping-methods?${ITEMS_FIELDS}`, {
|
|
method: "POST",
|
|
body: { option_id: optionId },
|
|
});
|
|
return mapCart(data.cart as MedusaCart);
|
|
}
|