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 { createEpaycoClient, verifyWebhookSignature } from "../../lib/epayco"; import type { EpaycoPseInputData, EpaycoPseOptions, EpaycoPseSessionData } from "./types"; type InjectedDependencies = { logger: Logger; }; // PSE via ePayco es asincrono igual que PayU/Rapyd: no hay confirmacion // real hasta que llega el webhook (url_confirmation), nunca en la // respuesta sincrona de bank.create(). authorizePayment/capturePayment // por eso son casi no-ops -- el estado real lo trae siempre el webhook. export default class EpaycoPseProviderService extends AbstractPaymentProvider { static identifier = "epayco"; protected logger_: Logger; protected options_: EpaycoPseOptions; constructor(container: InjectedDependencies, options: EpaycoPseOptions) { super(container, options); this.logger_ = container.logger; this.options_ = options; } static validateOptions(options: Record): void { for (const key of ["publicKey", "privateKey", "pCustIdCliente", "pKey", "env", "responseUrl", "notifyUrl"]) { if (!options[key]) { throw new Error(`epayco-pse: falta la opción "${key}" en medusa-config.ts`); } } } async initiatePayment(input: InitiatePaymentInput): Promise { const data = input.data as unknown as EpaycoPseInputData & { session_id: string }; const sessionId = data.session_id; const amount = new BigNumber(input.amount).numeric; const currency = input.currency_code.toUpperCase(); const client = createEpaycoClient(this.options_); // Campos confirmados leyendo node_modules/epayco-sdk-node/lib/keylang.json // y tests/bank.js directamente (no la documentacion publica -- esa // mostraba "metodoconfirmacion", el codigo real que de verdad manda // el SDK usa "method_confirmation"). El SDK cifra estos valores y // agrega ip/test/auth por su cuenta, no hace falta hacerlo nosotros. const response = await client.bank.create({ bank: data.bank_code, invoice: sessionId, description: `Orden ARI Shopping ${sessionId}`, value: String(amount), tax: "0", tax_base: "0", currency, type_person: data.user_type, doc_type: data.doc_type, doc_number: data.doc_number, name: data.first_name, last_name: data.last_name, email: data.email, country: "CO", city: data.city, cell_phone: data.phone, url_response: this.options_.responseUrl, url_confirmation: this.options_.notifyUrl, method_confirmation: "POST", }); if (response.error) { throw new Error(`ePayco rechazó la transacción para la sesión ${sessionId}: ${JSON.stringify(response).slice(0, 300)}`); } // El nombre real del campo con la URL de redirección al banco NO // quedó verificado contra una respuesta real de sandbox en esta // sesión (ni la doc ni los tests del SDK lo muestran) -- se prueban // los candidatos más probables y, si ninguno aparece, se tira un // error con el payload completo para poder leerlo en los logs y // corregir esto en el primer intento real contra el sandbox. const flat = (response.data ?? response) as Record; const bankUrlCandidates = ["urlbanco", "url_banco", "urlBanco", "url", "redirect_url"]; const bankUrl = bankUrlCandidates.map((key) => flat[key]).find((value) => typeof value === "string" && value); if (typeof bankUrl !== "string" || !bankUrl) { throw new Error( `ePayco no devolvió una URL de banco reconocible para la sesión ${sessionId}. Respuesta completa (revisar para corregir el nombre del campo): ${JSON.stringify(response).slice(0, 500)}`, ); } const sessionData: EpaycoPseSessionData = { ...data, session_id: sessionId, ref_payco: String(flat.ref_payco ?? flat.refPayco ?? ""), transaction_id: String(flat.transactionID ?? flat.transaction_id ?? ""), bank_url: bankUrl, state: "PENDING", amount, currency, }; return { id: sessionId, data: sessionData as unknown as Record, status: "pending" }; } // PSE es diferido: en el momento de completar el carrito, ePayco // 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 { return { data: input.data, status: "pending_authorization" }; } // Casi no-op: cuando el webhook reporta "captured", ePayco ya liquido // el dinero de su lado -- solo persiste el data actualizado. async capturePayment(input: CapturePaymentInput): Promise { return { data: input.data }; } async cancelPayment(input: CancelPaymentInput): Promise { this.logger_.warn("epayco-pse: cancelPayment llamado, pero PSE no soporta cancelar una transacción ya enviada"); return { data: input.data }; } async deletePayment(input: DeletePaymentInput): Promise { return { data: input.data }; } // Endpoint de reembolso de ePayco no quedó verificado en vivo en esta // sesión -- con dinero real de por medio, mejor tirar un error // explícito que fingir un reembolso que nunca se procesó. async refundPayment(_input: RefundPaymentInput): Promise { throw new Error( "epayco-pse: refundPayment no está implementado todavía -- procesar el reembolso manualmente desde el panel de ePayco por ahora.", ); } // GET /validation/v1/reference/{ref_payco} -- endpoint de verificación // server-to-server documentado por ePayco para consultar el estado // real de una transacción. async retrievePayment(input: RetrievePaymentInput): Promise { const data = input.data as unknown as EpaycoPseSessionData | undefined; const refPayco = data?.ref_payco; if (!refPayco) { throw new Error("epayco-pse: retrievePayment necesita ref_payco, no está guardado en esta sesión."); } const response = await fetch(`https://secure.epayco.co/validation/v1/reference/${refPayco}`); const json = await response.json(); return { data: json as Record }; } async updatePayment(input: UpdatePaymentInput): Promise { // ePayco 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. return { data: input.data }; } async getPaymentStatus(input: GetPaymentStatusInput): Promise { const data = input.data as unknown as EpaycoPseSessionData | undefined; const state = data?.state; if (state === "1") return { status: "captured" }; if (state === "3") return { status: "pending" }; if (state === "PENDING") return { status: "pending" }; return { status: "canceled" }; } // 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. async getWebhookActionAndData(payload: ProviderWebhookPayload["payload"]): Promise { const body = payload.data as Record; const refPayco = String(body.x_ref_payco ?? ""); const transactionId = String(body.x_transaction_id ?? ""); const amount = String(body.x_amount ?? ""); const currencyCode = String(body.x_currency_code ?? ""); const codState = String(body.x_cod_transaction_state ?? ""); const signature = String(body.x_signature ?? ""); // session_id sale del invoice que nosotros mandamos -- ePayco lo // devuelve como x_id_invoice (o x_extra1 si no viniera) segun el // webhook real; se intentan ambos. const sessionId = String(body.x_id_invoice ?? body.x_extra1 ?? refPayco); const validSignature = verifyWebhookSignature({ pCustIdCliente: this.options_.pCustIdCliente, pKey: this.options_.pKey, refPayco, transactionId, amount, currencyCode, signature, }); if (!validSignature) { this.logger_.warn(`epayco-pse: webhook con firma inválida para x_ref_payco=${refPayco}, ignorado`); return { action: "not_supported" }; } // Solo "1" (Aceptada) es exito -- cualquier otro codigo (2 Rechazada, // 4 Fallida, 6 Reversada, 9 caducada, 10 abandonada, 11 Cancelada, // 3 Pendiente) se trata como fallido en vez de asumir un exito // distinto. Un "failed" de mas es reintentable; un "captured" de mas // (dinero que nunca se aprobo) no lo es. const action = codState === "1" ? "captured" : "failed"; return { action, data: { session_id: sessionId, amount: new BigNumber(amount || 0), }, }; } }