feat(payments): integrar PayU LatAm (PSE) como pasarela de pagos
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
This commit is contained in:
1 parent
313bb2eab4
commit
2ceea23f40
17 files changed
+802
-17
No files matched your search
@@ -0,0 +1,6 @@
|
||||
import { ModuleProvider, Modules } from "@medusajs/framework/utils";
|
||||
import PayuPseProviderService from "./service";
|
||||
|
||||
export default ModuleProvider(Modules.PAYMENT, {
|
||||
services: [PayuPseProviderService],
|
||||
});
|
||||
@@ -0,0 +1,260 @@
|
||||
import { AbstractPaymentProvider, BigNumber } from "@medusajs/framework/utils";
|
||||
import type {
|
||||
AuthorizePaymentInput,
|
||||
AuthorizePaymentOutput,
|
||||
CancelPaymentInput,
|
||||
CancelPaymentOutput,
|
||||
CapturePaymentInput,
|
||||
CapturePaymentOutput,
|
||||
DeletePaymentInput,
|
||||
DeletePaymentOutput,
|
||||
GetPaymentStatusInput,
|
||||
GetPaymentStatusOutput,
|
||||
InitiatePaymentInput,
|
||||
InitiatePaymentOutput,
|
||||
Logger,
|
||||
ProviderWebhookPayload,
|
||||
RefundPaymentInput,
|
||||
RefundPaymentOutput,
|
||||
RetrievePaymentInput,
|
||||
RetrievePaymentOutput,
|
||||
UpdatePaymentInput,
|
||||
UpdatePaymentOutput,
|
||||
WebhookActionResult,
|
||||
} from "@medusajs/framework/types";
|
||||
import { buildOrderSignature, formatPayuAmount, payuRequest, verifyWebhookSignature } from "../../lib/payu";
|
||||
import type { PayuPseInputData, PayuPseOptions, PayuPseSessionData } from "./types";
|
||||
|
||||
type InjectedDependencies = {
|
||||
logger: Logger;
|
||||
};
|
||||
|
||||
// PSE es AUTHORIZATION_AND_CAPTURE en un solo paso del lado de PayU, y
|
||||
// asincrono: no hay confirmacion real hasta que llega el webhook (ver
|
||||
// getWebhookActionAndData). authorizePayment/capturePayment por eso son
|
||||
// casi no-ops -- el estado real lo trae siempre el webhook, nunca una
|
||||
// llamada sincrona nuestra a PayU.
|
||||
export default class PayuPseProviderService extends AbstractPaymentProvider<PayuPseOptions> {
|
||||
static identifier = "payu";
|
||||
|
||||
protected logger_: Logger;
|
||||
protected options_: PayuPseOptions;
|
||||
|
||||
constructor(container: InjectedDependencies, options: PayuPseOptions) {
|
||||
super(container, options);
|
||||
this.logger_ = container.logger;
|
||||
this.options_ = options;
|
||||
}
|
||||
|
||||
static validateOptions(options: Record<string, unknown>): void {
|
||||
for (const key of ["apiLogin", "apiKey", "merchantId", "accountId", "env", "responseUrl", "notifyUrl"]) {
|
||||
if (!options[key]) {
|
||||
throw new Error(`payu-pse: falta la opción "${key}" en medusa-config.ts`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async initiatePayment(input: InitiatePaymentInput): Promise<InitiatePaymentOutput> {
|
||||
const data = input.data as unknown as PayuPseInputData & { session_id: string };
|
||||
const sessionId = data.session_id;
|
||||
const amount = new BigNumber(input.amount).numeric;
|
||||
const currency = input.currency_code.toUpperCase();
|
||||
const formattedAmount = formatPayuAmount(amount);
|
||||
|
||||
const signature = buildOrderSignature({
|
||||
apiKey: this.options_.apiKey,
|
||||
merchantId: this.options_.merchantId,
|
||||
referenceCode: sessionId,
|
||||
amount: formattedAmount,
|
||||
currency,
|
||||
});
|
||||
|
||||
// Shape del request confirmado contra la documentacion real de PayU
|
||||
// LatAm para command/paymentMethod/extraParameters -- la anidacion
|
||||
// exacta de buyer/payer (nombres de campo dentro de esos objetos) no
|
||||
// quedo verificada campo por campo contra una respuesta real de
|
||||
// sandbox en esta sesion. Primera prueba end-to-end contra el
|
||||
// sandbox real de PayU debe confirmar esto (ver plan de
|
||||
// implementacion, seccion Verificacion).
|
||||
const json = await payuRequest(
|
||||
{ apiLogin: this.options_.apiLogin, apiKey: this.options_.apiKey, merchantId: this.options_.merchantId, accountId: this.options_.accountId, env: this.options_.env },
|
||||
{
|
||||
command: "SUBMIT_TRANSACTION",
|
||||
merchant: {
|
||||
apiLogin: this.options_.apiLogin,
|
||||
apiKey: this.options_.apiKey,
|
||||
},
|
||||
transaction: {
|
||||
order: {
|
||||
accountId: this.options_.accountId,
|
||||
referenceCode: sessionId,
|
||||
description: `Orden ARI Shopping ${sessionId}`,
|
||||
language: "es",
|
||||
signature,
|
||||
notifyUrl: this.options_.notifyUrl,
|
||||
additionalValues: {
|
||||
TX_VALUE: { value: amount, currency },
|
||||
},
|
||||
buyer: {
|
||||
fullName: data.full_name,
|
||||
emailAddress: data.email,
|
||||
contactPhone: data.phone,
|
||||
dniNumber: data.doc_number,
|
||||
shippingAddress: {
|
||||
street1: data.address,
|
||||
city: data.city,
|
||||
state: data.state,
|
||||
country: "CO",
|
||||
},
|
||||
},
|
||||
},
|
||||
payer: {
|
||||
fullName: data.full_name,
|
||||
emailAddress: data.email,
|
||||
contactPhone: data.phone,
|
||||
dniNumber: data.doc_number,
|
||||
billingAddress: {
|
||||
street1: data.address,
|
||||
city: data.city,
|
||||
state: data.state,
|
||||
country: "CO",
|
||||
},
|
||||
},
|
||||
type: "AUTHORIZATION_AND_CAPTURE",
|
||||
paymentMethod: "PSE",
|
||||
paymentCountry: "CO",
|
||||
extraParameters: {
|
||||
RESPONSE_URL: this.options_.responseUrl,
|
||||
FINANCIAL_INSTITUTION_CODE: data.bank_code,
|
||||
USER_TYPE: data.user_type,
|
||||
PSE_REFERENCE2: data.doc_type,
|
||||
PSE_REFERENCE3: data.doc_number,
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const transactionResponse = (json.transactionResponse ?? {}) as Record<string, unknown>;
|
||||
const extraParameters = (transactionResponse.extraParameters ?? {}) as Record<string, unknown>;
|
||||
const bankUrl = extraParameters.BANK_URL;
|
||||
if (typeof bankUrl !== "string" || !bankUrl) {
|
||||
throw new Error(
|
||||
`PayU no devolvió BANK_URL para la sesión ${sessionId}: ${JSON.stringify(transactionResponse).slice(0, 300)}`,
|
||||
);
|
||||
}
|
||||
|
||||
const sessionData: PayuPseSessionData = {
|
||||
...data,
|
||||
session_id: sessionId,
|
||||
payu_order_id: String(transactionResponse.orderId ?? ""),
|
||||
payu_transaction_id: String(transactionResponse.transactionId ?? ""),
|
||||
reference_code: sessionId,
|
||||
bank_url: bankUrl,
|
||||
state: String(transactionResponse.state ?? "PENDING"),
|
||||
amount,
|
||||
currency,
|
||||
};
|
||||
|
||||
return { id: sessionId, data: sessionData as unknown as Record<string, unknown>, status: "pending" };
|
||||
}
|
||||
|
||||
// PSE es diferido: en el momento de completar el carrito, PayU todavia
|
||||
// no confirmo nada -- "pending_authorization" es el mecanismo
|
||||
// documentado de Medusa para esto (la orden queda "awaiting" y el
|
||||
// navegador ya puede redirigirse al banco).
|
||||
async authorizePayment(input: AuthorizePaymentInput): Promise<AuthorizePaymentOutput> {
|
||||
return { data: input.data, status: "pending_authorization" };
|
||||
}
|
||||
|
||||
// Casi no-op: cuando el webhook reporta "captured", PayU ya liquido el
|
||||
// dinero de su lado (un solo paso AUTHORIZATION_AND_CAPTURE) -- solo
|
||||
// persiste el data actualizado.
|
||||
async capturePayment(input: CapturePaymentInput): Promise<CapturePaymentOutput> {
|
||||
return { data: input.data };
|
||||
}
|
||||
|
||||
// PSE no tiene concepto de cancelar una transaccion ya enviada al banco.
|
||||
async cancelPayment(input: CancelPaymentInput): Promise<CancelPaymentOutput> {
|
||||
this.logger_.warn("payu-pse: cancelPayment llamado, pero PSE no soporta cancelar una transacción ya enviada");
|
||||
return { data: input.data };
|
||||
}
|
||||
|
||||
async deletePayment(input: DeletePaymentInput): Promise<DeletePaymentOutput> {
|
||||
return { data: input.data };
|
||||
}
|
||||
|
||||
// No verificado contra el endpoint real de PayU en esta sesión -- con
|
||||
// dinero real de por medio, mejor tirar un error explícito que fingir
|
||||
// un reembolso que nunca llamó a PayU.
|
||||
async refundPayment(_input: RefundPaymentInput): Promise<RefundPaymentOutput> {
|
||||
throw new Error(
|
||||
"payu-pse: refundPayment no está implementado todavía -- el endpoint de reembolso de PayU LatAm no quedó verificado en vivo. Procesar el reembolso manualmente desde el panel de PayU por ahora.",
|
||||
);
|
||||
}
|
||||
|
||||
// Idem refundPayment: no verificado en vivo, mejor fallar explícito.
|
||||
async retrievePayment(_input: RetrievePaymentInput): Promise<RetrievePaymentOutput> {
|
||||
throw new Error("payu-pse: retrievePayment no está implementado todavía -- usar el panel de PayU para consultar una transacción.");
|
||||
}
|
||||
|
||||
async updatePayment(input: UpdatePaymentInput): Promise<UpdatePaymentOutput> {
|
||||
// PayU no tiene concepto de "modificar" una transaccion ya enviada.
|
||||
// El checkout solo llega al step de pago despues de fijar direccion
|
||||
// y envio, asi que el total no deberia cambiar una vez que existe
|
||||
// una sesion PSE -- si esto se dispara, es una señal de que algo
|
||||
// cambió el carrito después de crear la sesión.
|
||||
return { data: input.data };
|
||||
}
|
||||
|
||||
async getPaymentStatus(input: GetPaymentStatusInput): Promise<GetPaymentStatusOutput> {
|
||||
const data = input.data as unknown as PayuPseSessionData | undefined;
|
||||
const state = data?.state;
|
||||
if (state === "APPROVED") return { status: "captured" };
|
||||
if (state === "DECLINED" || state === "EXPIRED" || state === "ERROR") return { status: "canceled" };
|
||||
return { status: "pending" };
|
||||
}
|
||||
|
||||
// Unico punto real de confirmacion asincrona. Falla cerrado: si la
|
||||
// firma no matchea, no se hace nada -- nunca se confia en un webhook
|
||||
// sin firma valida, es lo unico que impide que cualquiera falsifique
|
||||
// un pago aprobado.
|
||||
async getWebhookActionAndData(payload: ProviderWebhookPayload["payload"]): Promise<WebhookActionResult> {
|
||||
const body = payload.data as Record<string, unknown>;
|
||||
const merchantId = String(body.merchant_id ?? "");
|
||||
const referenceSale = String(body.reference_sale ?? "");
|
||||
const value = String(body.value ?? "");
|
||||
const currency = String(body.currency ?? "");
|
||||
const statePol = String(body.state_pol ?? "");
|
||||
const sign = String(body.sign ?? "");
|
||||
|
||||
const validSignature = verifyWebhookSignature({
|
||||
apiKey: this.options_.apiKey,
|
||||
merchantId,
|
||||
referenceSale,
|
||||
value,
|
||||
currency,
|
||||
statePol,
|
||||
sign,
|
||||
});
|
||||
|
||||
if (!validSignature) {
|
||||
this.logger_.warn(`payu-pse: webhook con firma inválida para reference_sale=${referenceSale}, ignorado`);
|
||||
return { action: "not_supported" };
|
||||
}
|
||||
|
||||
// Solo state_pol "4" (aprobado) es exito -- la tabla completa de
|
||||
// codigos no quedo 100% verificada, asi que cualquier otro valor se
|
||||
// trata como fallido en vez de asumir que es un exito distinto. Un
|
||||
// "failed" de mas es reintentable; un "captured" de mas (dinero que
|
||||
// nunca se aprobo) no lo es.
|
||||
const action = statePol === "4" ? "captured" : "failed";
|
||||
|
||||
return {
|
||||
action,
|
||||
data: {
|
||||
session_id: referenceSale,
|
||||
amount: new BigNumber(value || 0),
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import type { PayuEnv } from "../../lib/payu";
|
||||
|
||||
export interface PayuPseOptions {
|
||||
apiLogin: string;
|
||||
apiKey: string;
|
||||
merchantId: string;
|
||||
accountId: string;
|
||||
env: PayuEnv;
|
||||
// A donde PayU redirige al comprador de vuelta tras pagar en el banco
|
||||
// (pagina propia del storefront, no confirma nada por si sola -- ver
|
||||
// src/modules/payu-pse/service.ts).
|
||||
responseUrl: string;
|
||||
// URL publica de notifyUrl -- la rellena Medusa (ruta nativa
|
||||
// /hooks/payment/:provider, no la escribimos nosotros), ver
|
||||
// medusa-config.ts.
|
||||
notifyUrl: string;
|
||||
}
|
||||
|
||||
export type PayuDocType = "CC" | "CE" | "NIT" | "TI" | "PP";
|
||||
export type PayuUserType = "N" | "J";
|
||||
|
||||
// Lo que el storefront manda como `data` al crear la sesion de pago PSE
|
||||
// (recolectado en el step "address" del checkout, mas el banco elegido en
|
||||
// el nuevo step "payment"). No viene de `context.customer` porque el
|
||||
// checkout es de invitado -- ver ADR 0009.
|
||||
export interface PayuPseInputData {
|
||||
bank_code: string;
|
||||
user_type: PayuUserType;
|
||||
doc_type: PayuDocType;
|
||||
doc_number: string;
|
||||
full_name: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
address: string;
|
||||
city: string;
|
||||
// Nombre del departamento (PayU pide "state" como texto libre, no un
|
||||
// codigo DIVIPOLA como el resto del checkout).
|
||||
state: string;
|
||||
}
|
||||
|
||||
// Lo que initiatePayment devuelve y queda guardado en `data` de la
|
||||
// PaymentSession -- publico para el storefront (ver nota en
|
||||
// AbstractPaymentProvider.initiatePayment), nunca poner credenciales acá.
|
||||
export interface PayuPseSessionData extends PayuPseInputData {
|
||||
session_id: string;
|
||||
payu_order_id: string;
|
||||
payu_transaction_id: string;
|
||||
reference_code: string;
|
||||
bank_url: string;
|
||||
state: string;
|
||||
amount: number;
|
||||
currency: string;
|
||||
}
|
||||
Reference in new issue
Block a user