diff --git a/workloads/commerce-backend/Dockerfile b/workloads/commerce-backend/Dockerfile index d53734b..ff1fd60 100644 --- a/workloads/commerce-backend/Dockerfile +++ b/workloads/commerce-backend/Dockerfile @@ -33,7 +33,16 @@ ENV CI=true \ EPAYCO_P_KEY=build-only-epayco-pkey \ EPAYCO_ENV=sandbox \ EPAYCO_RESPONSE_URL=http://localhost:8000/checkout/pse/return \ - EPAYCO_NOTIFY_URL=http://localhost:9000/hooks/payment/epayco_pse + EPAYCO_NOTIFY_URL=http://localhost:9000/hooks/payment/epayco_pse \ + ENVIOCLICK_API_KEY=build-only-envioclick-key \ + ENVIOCLICK_SANDBOX=true \ + ENVIOCLICK_ORIGIN_DANE_CODE=11001000 \ + ENVIOCLICK_ORIGIN_ADDRESS="Calle 1 No 1-1" \ + ENVIOCLICK_ORIGIN_COMPANY="ARI Shopping" \ + ENVIOCLICK_ORIGIN_FIRST_NAME=ARI \ + ENVIOCLICK_ORIGIN_LAST_NAME=Shopping \ + ENVIOCLICK_ORIGIN_EMAIL=build-only@example.com \ + ENVIOCLICK_ORIGIN_PHONE=3000000000 COPY package.json package-lock.json .npmrc ./ diff --git a/workloads/commerce-backend/medusa-config.ts b/workloads/commerce-backend/medusa-config.ts index aea458b..b7d47f2 100644 --- a/workloads/commerce-backend/medusa-config.ts +++ b/workloads/commerce-backend/medusa-config.ts @@ -133,6 +133,45 @@ module.exports = defineConfig({ }, 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: { diff --git a/workloads/commerce-backend/src/lib/envioclick.ts b/workloads/commerce-backend/src/lib/envioclick.ts new file mode 100644 index 0000000..d1ceddf --- /dev/null +++ b/workloads/commerce-backend/src/lib/envioclick.ts @@ -0,0 +1,179 @@ +// EnvíoClick (ADR 0009 §2, ya anticipado desde el modelo de direcciones +// del checkout): cotización y generación de guía real por peso/volumen, +// reemplazando la tarifa fija de $15.000 COP. Contrato REST confirmado en +// vivo contra la cuenta real del usuario (apiDoc real, +// apidoc.envioclickpro.com.co/api_data.json -- la SPA de la doc no +// renderiza sin JS, pero ese archivo generado por apidoc trae la spec +// completa) y una llamada de prueba real que devolvió 200 con tarifas +// reales de PIBOX/COORDINADORA/ENVIA/RAPPI/TCC. + +const BASE_URL = "https://api.envioclickpro.com.co/api/v2"; + +export interface EnvioClickCredentials { + apiKey: string; +} + +export interface EnvioClickPackage { + weight: number; + height: number; + length: number; + // "widht" es un typo real de la API (confirmado en el body de + // /shipment de la doc oficial), no un error nuestro -- solo se usa acá, + // en el request de /quotation el campo si se llama "width" (confirmado + // tambien contra la doc real, son dos endpoints con esa inconsistencia). + width: number; +} + +export interface EnvioClickRate { + idRate: number; + idProduct: number; + product: string; + idCarrier: number; + carrier: string; + flete: number; + minimumInsurance: number; + extraInsurance: number; + deliveryDays: number; + cod: boolean; +} + +export interface EnvioClickAddress { + daneCode: string; + address: string; +} + +async function envioclickRequest( + credentials: EnvioClickCredentials, + path: string, + body: Record, +): Promise> { + const response = await fetch(`${BASE_URL}${path}`, { + method: "POST", + headers: { + "Content-Type": "application/json", + // Header real confirmado contra la doc oficial y una llamada en + // vivo -- "Authorization" a secas (sin "Bearer"), NO + // "AuthorizationKey" como usaba el plugin de WordPress de + // referencia (ese era para la API v1, desactualizada). + Authorization: credentials.apiKey, + }, + body: JSON.stringify(body), + }); + const text = await response.text(); + let json: Record; + try { + json = JSON.parse(text); + } catch { + throw new Error(`Respuesta no-JSON de EnvíoClick (HTTP ${response.status}): ${text.slice(0, 300)}`); + } + if (json.status !== "OK") { + throw new Error(`EnvíoClick respondió con error: ${JSON.stringify(json).slice(0, 400)}`); + } + return json; +} + +// Solo 1 paquete por cotizacion (limite real documentado y confirmado: +// "min 1, max 1" -- cada vez menos transportadoras ofrecen multi-paquete). +export async function quoteShipment( + credentials: EnvioClickCredentials, + params: { + pkg: EnvioClickPackage; + description: string; + contentValue: number; + origin: EnvioClickAddress; + destination: EnvioClickAddress; + }, +): Promise { + const json = await envioclickRequest(credentials, "/quotation", { + packages: [params.pkg], + description: params.description, + contentValue: params.contentValue, + origin: params.origin, + destination: params.destination, + }); + const data = json.data as Record; + return (data.rates as EnvioClickRate[]) ?? []; +} + +export interface EnvioClickPerson { + company: string; + firstName: string; + lastName: string; + email: string; + phone: string; + address: string; + suburb: string; + crossStreet: string; + reference: string; + daneCode: string; +} + +export interface CreateGuideResult { + guide: string; + url: string; + tracker: string; + idOrder: number; +} + +export async function createGuide( + credentials: EnvioClickCredentials, + sandbox: boolean, + params: { + idRate: number; + myShipmentReference: string; + externalOrderId: string; + pkg: EnvioClickPackage; + description: string; + contentValue: number; + origin: EnvioClickPerson; + destination: EnvioClickPerson; + }, +): Promise { + // /shipment_sandbox es el UNICO endpoint con modo de pruebas real (la + // cotizacion siempre usa tarifas reales de las transportadoras, no + // tiene sandbox) -- confirmado en la doc oficial ("Deberás cambiar a + // la URL de producción para que puedas generar guías reales"). + const path = sandbox ? "/shipment_sandbox" : "/shipment"; + const json = await envioclickRequest(credentials, path, { + idRate: params.idRate, + myShipmentReference: params.myShipmentReference, + external_order_id: params.externalOrderId, + requestPickup: false, + pickupDate: new Date().toISOString().slice(0, 10), + insurance: false, + description: params.description, + contentValue: params.contentValue, + packages: [ + { + weight: params.pkg.weight, + height: params.pkg.height, + length: params.pkg.length, + widht: params.pkg.width, + }, + ], + origin: params.origin, + destination: params.destination, + }); + const data = json.data as Record; + return { + guide: String(data.guide ?? ""), + url: String(data.url ?? ""), + tracker: String(data.tracker ?? ""), + idOrder: Number(data.idOrder ?? 0), + }; +} + +// Hasta 50 ordenes por llamada (limite real documentado). +export async function cancelGuides(credentials: EnvioClickCredentials, idOrders: number[]): Promise { + await envioclickRequest(credentials, "cancellation/batch/order", { idOrders }); +} + +// código DANE estandar (5 digitos: 2 de departamento + 3 de municipio, +// ej. 11001 = Bogota, 5001 = Medellin -- mismo id que ya usa +// lib/data/colombia.ts del storefront, confirmado contra su propio +// comentario) con el padding real que espera EnvioClick (8 caracteres, +// confirmado contra su propio ejemplo real: "11001000" para Bogota = +// "11001" + "000"). +export function toEnvioClickDaneCode(divipolaCityId: number): string { + return `${String(divipolaCityId).padStart(5, "0")}000`; +} diff --git a/workloads/commerce-backend/src/modules/envioclick-fulfillment/index.ts b/workloads/commerce-backend/src/modules/envioclick-fulfillment/index.ts new file mode 100644 index 0000000..dc62a41 --- /dev/null +++ b/workloads/commerce-backend/src/modules/envioclick-fulfillment/index.ts @@ -0,0 +1,6 @@ +import { ModuleProvider, Modules } from "@medusajs/framework/utils"; +import EnvioClickFulfillmentService from "./service"; + +export default ModuleProvider(Modules.FULFILLMENT, { + services: [EnvioClickFulfillmentService], +}); diff --git a/workloads/commerce-backend/src/modules/envioclick-fulfillment/service.ts b/workloads/commerce-backend/src/modules/envioclick-fulfillment/service.ts new file mode 100644 index 0000000..f507931 --- /dev/null +++ b/workloads/commerce-backend/src/modules/envioclick-fulfillment/service.ts @@ -0,0 +1,275 @@ +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; + } +} diff --git a/workloads/commerce-backend/src/modules/envioclick-fulfillment/types.ts b/workloads/commerce-backend/src/modules/envioclick-fulfillment/types.ts new file mode 100644 index 0000000..d88e272 --- /dev/null +++ b/workloads/commerce-backend/src/modules/envioclick-fulfillment/types.ts @@ -0,0 +1,33 @@ +export interface EnvioClickOptions { + apiKey: string; + // El unico endpoint con modo de pruebas real es la generacion de guia + // (ver src/lib/envioclick.ts) -- la cotizacion siempre usa tarifas + // reales, no hay nada que alternar ahi. + sandbox: boolean; + // Datos de la bodega de origen -- la stock location real todavia no + // tiene direccion fisica cargada ("Sin dirección física registrada"), + // asi que estos vienen de config hasta que se cargue una real. + originDaneCode: string; + originAddress: string; + originCompany: string; + originFirstName: string; + originLastName: string; + originEmail: string; + originPhone: string; +} + +// Precio de fallback cuando EnvíoClick falla o no hay peso/dimensiones +// reales del pedido (hoy ningun producto los tiene cargados) -- mismo +// valor que ya usaba la shipping option fija de ADR 0009, para no +// empeorar la experiencia mientras no haya datos reales. +export const FALLBACK_PRICE_COP = 15000; + +// Paquete por defecto cuando algun item del carrito no tiene +// peso/dimensiones reales -- conservador, no preciso. Ver nota en el +// plan de implementacion: esto es una tarea de datos, no de codigo. +export const DEFAULT_PACKAGE = { + weight: 1, + length: 20, + height: 20, + width: 20, +}; diff --git a/workloads/ecommerce/commerce/configmap.yaml b/workloads/ecommerce/commerce/configmap.yaml index 168311f..95e1a15 100644 --- a/workloads/ecommerce/commerce/configmap.yaml +++ b/workloads/ecommerce/commerce/configmap.yaml @@ -33,3 +33,18 @@ data: EPAYCO_ENV: sandbox EPAYCO_RESPONSE_URL: https://shop.cruzcloud.net/checkout/pse/return EPAYCO_NOTIFY_URL: https://commerce.cruzcloud.net/hooks/payment/epayco_pse + + # EnvíoClick (ADR 0009 §2) -- fulfillment provider real por peso/volumen, + # reemplaza la tarifa fija. Solo config no sensible; ENVIOCLICK_API_KEY + # va en secrets.template.yaml. ENVIOCLICK_SANDBOX=true usa el endpoint + # de guía de pruebas (/shipment_sandbox) -- la cotización siempre usa + # tarifas reales, no tiene modo sandbox. Origen = bodega Bogotá + # ("Bodega principal ARI Shopping", sin dirección física real cargada + # todavía -- ENVIOCLICK_ORIGIN_ADDRESS es un placeholder hasta que la + # haya). + ENVIOCLICK_SANDBOX: "true" + ENVIOCLICK_ORIGIN_DANE_CODE: "11001000" + ENVIOCLICK_ORIGIN_ADDRESS: "Calle 1 No 1-1" + ENVIOCLICK_ORIGIN_COMPANY: "ARI Shopping" + ENVIOCLICK_ORIGIN_EMAIL: "contacto@ari-shopping.com" + ENVIOCLICK_ORIGIN_PHONE: "3000000000" diff --git a/workloads/ecommerce/commerce/secrets.template.yaml b/workloads/ecommerce/commerce/secrets.template.yaml index 334c877..ac6936c 100644 --- a/workloads/ecommerce/commerce/secrets.template.yaml +++ b/workloads/ecommerce/commerce/secrets.template.yaml @@ -47,6 +47,10 @@ stringData: EPAYCO_PRIVATE_KEY: CAMBIAR EPAYCO_P_CUST_ID_CLIENTE: CAMBIAR EPAYCO_P_KEY: CAMBIAR + + # EnvíoClick (ADR 0009 §2) -- API key real de la cuenta + # (envioclickpro.com.co > API Key / Desarrolladores). + ENVIOCLICK_API_KEY: CAMBIAR --- apiVersion: v1 kind: Secret diff --git a/workloads/ecommerce/components/checkout/checkout-client.tsx b/workloads/ecommerce/components/checkout/checkout-client.tsx index 755ddc8..fee2431 100644 --- a/workloads/ecommerce/components/checkout/checkout-client.tsx +++ b/workloads/ecommerce/components/checkout/checkout-client.tsx @@ -88,6 +88,12 @@ export function CheckoutClient() { address1: form.address1, city: selectedCiudad.name, province: selectedDepartamento.name, + // ids DIVIPOLA reales -- EnvioClick (fulfillment provider) los + // necesita para cotizar sin ambiguedad, el nombre solo no + // alcanza (hay municipios con el mismo nombre en distintos + // departamentos). + cityId: selectedCiudad.id, + departmentId: selectedDepartamento.id, phone: form.phone, email: form.email, }); diff --git a/workloads/ecommerce/lib/headless/providers/medusa-cart.ts b/workloads/ecommerce/lib/headless/providers/medusa-cart.ts index 1523b7b..a1ffa66 100644 --- a/workloads/ecommerce/lib/headless/providers/medusa-cart.ts +++ b/workloads/ecommerce/lib/headless/providers/medusa-cart.ts @@ -46,6 +46,14 @@ export interface ShippingAddressInput { 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 { @@ -212,6 +220,10 @@ export async function setShippingAddress( province: address.province || undefined, country_code: "co", phone: address.phone, + metadata: + address.cityId || address.departmentId + ? { cityId: address.cityId, departmentId: address.departmentId } + : undefined, }, }, });