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
93 lines
3.4 KiB
TypeScript
93 lines
3.4 KiB
TypeScript
// ADR 0009: pago real via PayU LatAm (PSE), modulo custom del backend
|
|
// (workloads/commerce-backend/src/modules/payu-pse). Archivo separado de
|
|
// medusa-cart.ts por el mismo motivo que ese archivo esta separado de
|
|
// medusa.ts -- reutiliza su mismo fetch helper (medusaCartFetch), no
|
|
// duplica la logica de headers/no-store.
|
|
import { medusaCartFetch } from "./medusa-cart";
|
|
|
|
export interface PseBank {
|
|
pseCode: string;
|
|
description: string;
|
|
}
|
|
|
|
export type PseUserType = "N" | "J";
|
|
export type PseDocType = "CC" | "CE" | "NIT" | "TI" | "PP";
|
|
|
|
export interface PsePaymentInput {
|
|
bankCode: string;
|
|
userType: PseUserType;
|
|
docType: PseDocType;
|
|
docNumber: string;
|
|
fullName: string;
|
|
email: string;
|
|
phone: string;
|
|
address: string;
|
|
city: string;
|
|
state: string;
|
|
}
|
|
|
|
// "pp_{identifier}_{id}" -- convencion de Medusa, confirmada contra el
|
|
// codigo fuente de @medusajs/payment (identifier "payu" + id "pse" en
|
|
// medusa-config.ts).
|
|
const PSE_PROVIDER_ID = "pp_payu_pse";
|
|
|
|
export async function getPseBanks(): Promise<PseBank[]> {
|
|
const data = await medusaCartFetch("/store/payments/pse-banks");
|
|
const banks = (data.banks as Array<{ pseCode?: string; description?: string }> | undefined) ?? [];
|
|
return banks
|
|
.filter((bank): bank is { pseCode: string; description: string } => Boolean(bank.pseCode && bank.description))
|
|
.map((bank) => ({ pseCode: bank.pseCode, description: bank.description }));
|
|
}
|
|
|
|
export async function createPaymentCollection(cartId: string): Promise<string> {
|
|
const data = await medusaCartFetch("/store/payment-collections", {
|
|
method: "POST",
|
|
body: { cart_id: cartId },
|
|
});
|
|
const collection = data.payment_collection as { id: string };
|
|
return collection.id;
|
|
}
|
|
|
|
export async function createPsePaymentSession(paymentCollectionId: string, input: PsePaymentInput): Promise<string> {
|
|
const data = await medusaCartFetch(`/store/payment-collections/${paymentCollectionId}/payment-sessions`, {
|
|
method: "POST",
|
|
body: {
|
|
provider_id: PSE_PROVIDER_ID,
|
|
data: {
|
|
bank_code: input.bankCode,
|
|
user_type: input.userType,
|
|
doc_type: input.docType,
|
|
doc_number: input.docNumber,
|
|
full_name: input.fullName,
|
|
email: input.email,
|
|
phone: input.phone,
|
|
address: input.address,
|
|
city: input.city,
|
|
state: input.state,
|
|
},
|
|
},
|
|
});
|
|
const collection = data.payment_collection as {
|
|
payment_sessions?: Array<{ provider_id: string; data?: { bank_url?: string } }>;
|
|
};
|
|
const session = collection.payment_sessions?.find((paymentSession) => paymentSession.provider_id === PSE_PROVIDER_ID);
|
|
const bankUrl = session?.data?.bank_url;
|
|
if (!bankUrl) {
|
|
throw new Error("PayU no devolvió la URL del banco para completar el pago.");
|
|
}
|
|
return bankUrl;
|
|
}
|
|
|
|
// El carrito se completa (orden creada en estado "awaiting") ANTES de
|
|
// redirigir al banco -- authorizePayment del modulo PSE devuelve
|
|
// "pending_authorization" a proposito, que es justo el mecanismo de
|
|
// Medusa para esto (ver AbstractPaymentProvider). La confirmacion real
|
|
// llega despues via el webhook nativo de Medusa, no por esta llamada.
|
|
export async function completeCart(cartId: string): Promise<void> {
|
|
const data = await medusaCartFetch(`/store/carts/${cartId}/complete`, { method: "POST" });
|
|
if (data.type === "cart") {
|
|
const error = (data as { error?: { message?: string } }).error;
|
|
throw new Error(error?.message || "No se pudo completar el pedido.");
|
|
}
|
|
}
|