Merge feat/envioclick-fulfillment into main
Build and Push Frontend / Escaneo de Secretos (Gitleaks) (push) Successful in 31s
Build and Push Medusa / Escaneo de Secretos (Gitleaks) (push) Successful in 19s
Build and Push Frontend / Construir y Subir Imagen (push) Successful in 12m5s
Build and Push Medusa / Construir y publicar Medusa (push) Successful in 27m11s

This commit is contained in:
devops committed 2026-09-08 21:11:54 +00:00
commit 0c356d6fbe
10 files changed
+579 -1

No files matched your search

+10 -1
View File
@@ -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=[email protected] \
ENVIOCLICK_ORIGIN_PHONE=3000000000
COPY package.json package-lock.json .npmrc ./
@@ -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: {
@@ -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<string, unknown>,
): Promise<Record<string, unknown>> {
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<string, unknown>;
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<EnvioClickRate[]> {
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<string, unknown>;
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<CreateGuideResult> {
// /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<string, unknown>;
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<void> {
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`;
}
@@ -0,0 +1,6 @@
import { ModuleProvider, Modules } from "@medusajs/framework/utils";
import EnvioClickFulfillmentService from "./service";
export default ModuleProvider(Modules.FULFILLMENT, {
services: [EnvioClickFulfillmentService],
});
@@ -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<string, unknown> | 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<string, unknown>): 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<FulfillmentOption[]> {
// 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<string, unknown>,
data: Record<string, unknown>,
_context: ValidateFulfillmentDataContext,
): Promise<any> {
return data;
}
async validateOption(_data: Record<string, unknown>): Promise<boolean> {
return true;
}
async canCalculate(_data: CreateShippingOptionDTO): Promise<boolean> {
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<CalculatedShippingOptionPrice> {
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<string, unknown>,
_items: Partial<Omit<FulfillmentItemDTO, "fulfillment">>[],
order: Partial<FulfillmentOrderDTO> | undefined,
_fulfillment: Partial<Omit<FulfillmentDTO, "provider_id" | "data" | "items">>,
): Promise<CreateFulfillmentResult> {
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 || "[email protected]",
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<string, unknown>): Promise<any> {
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<string, unknown>): Promise<never[]> {
return [];
}
async createReturnFulfillment(_fulfillment: Record<string, unknown>): Promise<CreateFulfillmentResult> {
// Devoluciones no estan en alcance de esta primera version.
throw new Error("envioclick-fulfillment: createReturnFulfillment no está implementado todavía.");
}
async getReturnDocuments(_data: Record<string, unknown>): Promise<never[]> {
return [];
}
async getShipmentDocuments(_data: Record<string, unknown>): Promise<never[]> {
return [];
}
async retrieveDocuments(_fulfillmentData: Record<string, unknown>, _documentType: string): Promise<void> {
return undefined;
}
}
@@ -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,
};
@@ -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: "[email protected]"
ENVIOCLICK_ORIGIN_PHONE: "3000000000"
@@ -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
@@ -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,
});
@@ -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,
},
},
});