import { AbstractFulfillmentProviderService } from "@medusajs/framework/utils"; import type { CalculatedShippingOptionPrice, CalculateShippingOptionPriceContext, CalculateShippingOptionPriceDTO, CreateFulfillmentResult, CreateShippingOptionDTO, FulfillmentDTO, FulfillmentItemDTO, FulfillmentOption, FulfillmentOrderDTO, Logger, ValidateFulfillmentDataContext, } from "@medusajs/framework/types"; import { cancelGuides, createGuide, quoteShipment, toEnvioClickDaneCode } from "../../lib/envioclick"; import { DEFAULT_PACKAGE, FALLBACK_PRICE_COP, type EnvioClickOptions } from "./types"; type InjectedDependencies = { logger: Logger; }; // Contexto minimo compartido entre calculatePrice/createFulfillment -- // ambos necesitan lo mismo (id DIVIPOLA de la ciudad destino, guardado // como metadata en la direccion por el checkout -- ver ADR 0009 y // checkout-client.tsx del storefront) y el paquete agregado del pedido. interface AddressLike { metadata?: Record | null; address_1?: string | null; first_name?: string | null; last_name?: string | null; phone?: string | null; email?: string | null; } function destinationDaneCode(address: AddressLike | null | undefined): string | null { const cityId = address?.metadata?.cityId; return typeof cityId === "number" ? toEnvioClickDaneCode(cityId) : null; } // Agrega el carrito/orden a UN solo paquete (EnvioClick solo acepta 1 por // cotizacion/guia, confirmado en la doc real: "min 1, max 1"). Si algun // item no tiene peso real cargado (hoy NINGUN producto lo tiene, ver // plan de implementacion), se usa el paquete de fallback conservador // completo en vez de mezclar datos reales con inventados a medias. function aggregatePackage(items: Array<{ quantity: number; variant?: { weight?: number | null } | null }>) { if (items.length === 0) return { ...DEFAULT_PACKAGE }; let totalWeight = 0; for (const item of items) { const weight = item.variant?.weight; if (!weight) return { ...DEFAULT_PACKAGE }; totalWeight += weight * item.quantity; } return { ...DEFAULT_PACKAGE, weight: totalWeight }; } export default class EnvioClickFulfillmentService extends AbstractFulfillmentProviderService { static identifier = "envioclick"; protected logger_: Logger; protected options_: EnvioClickOptions; constructor(container: InjectedDependencies, options: EnvioClickOptions) { super(); this.logger_ = container.logger; this.options_ = options; } static validateOptions(options: Record): void { for (const key of ["apiKey", "originDaneCode", "originAddress", "originCompany", "originFirstName", "originLastName", "originEmail", "originPhone"]) { if (!options[key]) { throw new Error(`envioclick-fulfillment: falta la opción "${key}" en medusa-config.ts`); } } } async getFulfillmentOptions(): Promise { // Una sola opcion: siempre toma la tarifa mas barata entre las // transportadoras que cotizan, mismo criterio que el plugin oficial // de WooCommerce de referencia ("toma el precio mas economico"). return [{ id: "standard", name: "Envío nacional (calculado)" }]; } async validateFulfillmentData( optionData: Record, data: Record, _context: ValidateFulfillmentDataContext, ): Promise { return data; } async validateOption(_data: Record): Promise { return true; } async canCalculate(_data: CreateShippingOptionDTO): Promise { return true; } // Se invoca en vivo durante el checkout cada vez que se piden/refrescan // las shipping options del carrito. Medusa advierte explicitamente que // un error acá bloquea el checkout entero -- por eso NUNCA se deja // propagar una excepcion: cualquier falla (red, ciudad sin id DIVIPOLA, // EnvioClick caido) cae al precio fijo que ya se usaba antes de esta // integracion, para no romper la compra. async calculatePrice( optionData: CalculateShippingOptionPriceDTO["optionData"], _data: CalculateShippingOptionPriceDTO["data"], context: CalculateShippingOptionPriceContext, ): Promise { const fallback: CalculatedShippingOptionPrice = { calculated_amount: FALLBACK_PRICE_COP, is_calculated_price_tax_inclusive: false, }; try { const address = context.shipping_address as AddressLike | undefined; const daneCode = destinationDaneCode(address); if (!daneCode) { this.logger_.warn("envioclick-fulfillment: dirección sin id DIVIPOLA en metadata, usando precio fijo de respaldo"); return fallback; } const items = (context.items ?? []) as Array<{ quantity: number; variant?: { weight?: number | null } | null }>; const pkg = aggregatePackage(items); const rates = await quoteShipment( { apiKey: this.options_.apiKey }, { pkg, description: "Pedido ARI Shopping", contentValue: 50000, origin: { daneCode: this.options_.originDaneCode, address: this.options_.originAddress }, destination: { daneCode, address: address?.address_1 || "Dirección de entrega" }, }, ); if (rates.length === 0) return fallback; const cheapest = rates.reduce((min, rate) => rate.flete + rate.minimumInsurance < min.flete + min.minimumInsurance ? rate : min, ); return { calculated_amount: cheapest.flete + cheapest.minimumInsurance, is_calculated_price_tax_inclusive: false, }; } catch (error) { this.logger_.warn(`envioclick-fulfillment: falló calculatePrice (${(error as Error).message}), usando precio fijo de respaldo`); return fallback; } } // A diferencia de calculatePrice, acá SÍ se deja propagar el error -- // el idRate de la cotización mostrada en checkout no se persiste en // ningún lado (CalculatedShippingOptionPrice no tiene un campo `data` // para eso, confirmado leyendo el tipo real), así que se vuelve a // cotizar en el momento de generar la guía (más correcto igual: el // fulfillment puede pasar horas/días después del checkout, un idRate // viejo podría estar vencido) y se reserva con la tarifa más barata de // esa cotización fresca. async createFulfillment( _data: Record, _items: Partial>[], order: Partial | undefined, _fulfillment: Partial>, ): Promise { const address = order?.shipping_address as AddressLike | undefined; const daneCode = destinationDaneCode(address); if (!daneCode) { throw new Error("envioclick-fulfillment: no se puede generar la guía sin un id DIVIPOLA en la dirección de envío."); } const items = (order?.items ?? []) as unknown as Array<{ quantity: number; variant?: { weight?: number | null } | null }>; const pkg = aggregatePackage(items); const rates = await quoteShipment( { apiKey: this.options_.apiKey }, { pkg, description: "Pedido ARI Shopping", contentValue: 50000, origin: { daneCode: this.options_.originDaneCode, address: this.options_.originAddress }, destination: { daneCode, address: address?.address_1 || "Dirección de entrega" }, }, ); if (rates.length === 0) { throw new Error("envioclick-fulfillment: no hay tarifas disponibles para generar la guía."); } const cheapest = rates.reduce((min, rate) => rate.flete + rate.minimumInsurance < min.flete + min.minimumInsurance ? rate : min, ); const guide = await createGuide( { apiKey: this.options_.apiKey }, this.options_.sandbox, { idRate: cheapest.idRate, myShipmentReference: `Orden ${order?.display_id ?? order?.id ?? ""}`, externalOrderId: String(order?.id ?? ""), pkg, description: "Pedido ARI Shopping", contentValue: 50000, origin: { company: this.options_.originCompany, firstName: this.options_.originFirstName, lastName: this.options_.originLastName, email: this.options_.originEmail, phone: this.options_.originPhone, address: this.options_.originAddress, suburb: "N/A", crossStreet: "N/A", reference: "N/A", daneCode: this.options_.originDaneCode, }, destination: { company: "N/A", firstName: address?.first_name || "Cliente", lastName: address?.last_name || "ARI", email: address?.email || order?.email || "sin-correo@ari-shopping.com", phone: address?.phone || "0000000000", address: address?.address_1 || "Dirección de entrega", suburb: "N/A", crossStreet: "N/A", reference: "N/A", daneCode, }, }, ); return { data: { idOrder: guide.idOrder, tracker: guide.tracker, labelUrl: guide.url, }, labels: [ { tracking_number: guide.tracker, tracking_url: guide.url, label_url: guide.url, }, ], }; } async cancelFulfillment(data: Record): Promise { const idOrder = data.idOrder; if (typeof idOrder !== "number") { this.logger_.warn("envioclick-fulfillment: cancelFulfillment sin idOrder guardado, no hay nada que cancelar en EnvíoClick"); return { data }; } await cancelGuides({ apiKey: this.options_.apiKey }, [idOrder]); return { data }; } async getFulfillmentDocuments(_data: Record): Promise { return []; } async createReturnFulfillment(_fulfillment: Record): Promise { // Devoluciones no estan en alcance de esta primera version. throw new Error("envioclick-fulfillment: createReturnFulfillment no está implementado todavía."); } async getReturnDocuments(_data: Record): Promise { return []; } async getShipmentDocuments(_data: Record): Promise { return []; } async retrieveDocuments(_fulfillmentData: Record, _documentType: string): Promise { return undefined; } }