ADR 0009 ya había decidido PayU LatAm como proveedor (única opción que no exige cuenta Bancolombia/Nequi) y confirmado que no existe ningún plugin real de Medusa para su API -- los paquetes que aparecen buscando "PayU" son para PayU Polonia/CEE, API completamente distinta (clientId/posId/secondKey vs. apiLogin/apiKey/merchantId/accountId). Este commit implementa esa decisión: módulo de pago custom (AbstractPaymentProvider) para PSE (transferencia bancaria), primer alcance del método de pago -- Nequi/Daviplata quedan fuera por ahora. Backend (commerce-backend): - src/modules/payu-pse/: initiatePayment arma y postea SUBMIT_TRANSACTION contra la API real de PayU LatAm; authorizePayment devuelve "pending_authorization" (PSE es async, la confirmación real llega por webhook, no en el momento de completar el carrito); getWebhookActionAndData valida la firma MD5 del webhook antes de confiar en cualquier confirmación de pago -- falla cerrado si no matchea. refundPayment/ retrievePayment tiran error explícito en vez de simular éxito: esos endpoints de PayU no quedaron verificados en vivo y hay dinero real de por medio. - No hace falta un subscriber propio para completar la orden -- el subscriber nativo de Medusa (payment-webhook + process-payment-workflow) ya hace ese trabajo con los métodos de arriba bien implementados (confirmado leyendo el código fuente instalado de @medusajs/medusa 2.18.0, no la documentación). - Correlación webhook↔sesión: Medusa inyecta su propio session_id en `data` antes de initiatePayment; se usa ese mismo valor como referenceCode de PayU, así el reference_sale que vuelve en el webhook ya es directamente el session_id de Medusa. - src/lib/payu.ts: cliente HTTP + firmas (orden y webhook, fórmulas distintas, confirmadas contra el SDK PHP oficial de PayU). - src/api/store/payments/pse-banks/route.ts: lista de bancos PSE, cacheada en memoria. - medusa-config.ts, Dockerfile, configmap/secrets.template: 7 env vars nuevas (PAYU_API_LOGIN/API_KEY/MERCHANT_ID/ACCOUNT_ID/ENV/RESPONSE_URL/ NOTIFY_URL), dummies de build agregados, secrets con placeholder "CAMBIAR". Frontend (ecommerce): - Nuevo step "payment" en components/checkout/checkout-client.tsx (reemplaza al step "review" que solo ofrecía WhatsApp): selector de banco, tipo de persona (N/J), tipo/número de documento, botón "Pagar con PSE" que crea la sesión de pago, completa el carrito y redirige al banco (navegación completa, no iframe -- así lo exige PayU). WhatsApp queda como respaldo manual si falla la iniciación del pago. - lib/headless/providers/medusa-payment.ts + 3 rutas proxy nuevas bajo app/api/cart/, mismo patrón que el resto del carrito (medusaCartFetch, ahora exportado desde medusa-cart.ts). - app/checkout/pse/return/: página de vuelta del banco -- nunca afirma éxito, la confirmación real es el webhook async. Arranca contra las credenciales de sandbox genéricas y públicas de PayU (developers.payulatam.com) mientras el usuario termina su propio registro de comercio -- swap de las 4 credenciales via secret cuando estén listas, sin cambios de código. Verificado: build real de Medusa con las env vars dummy (no solo tsc), tipos de AbstractPaymentProvider/WebhookActionResult/PaymentActions confirmados contra @medusajs/utils y @medusajs/types instalados (node_modules limpiado después), typecheck + lint + build completo del frontend, páginas nuevas renderizando sin error. Pendiente: prueba end-to-end contra el sandbox real de PayU (necesita el backend desplegado) y habilitar el provider en la región Colombia desde el Admin. Co-Authored-By: Claude Sonnet 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01THZFwPzrwv6VAbuwmaf5vR
245 lines
7.5 KiB
TypeScript
245 lines
7.5 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;
|
|
}
|
|
|
|
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,
|
|
},
|
|
},
|
|
});
|
|
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);
|
|
}
|