diff --git a/workloads/commerce-backend/Dockerfile b/workloads/commerce-backend/Dockerfile index dd05ed0..5cea39b 100644 --- a/workloads/commerce-backend/Dockerfile +++ b/workloads/commerce-backend/Dockerfile @@ -26,7 +26,14 @@ ENV CI=true \ MEILISEARCH_API_KEY=build-only-meili-key \ GOOGLE_CLIENT_ID=build-only-google-client-id \ GOOGLE_CLIENT_SECRET=build-only-google-client-secret \ - GOOGLE_CALLBACK_URL=http://localhost:9000/auth/customer/google/callback + GOOGLE_CALLBACK_URL=http://localhost:9000/auth/customer/google/callback \ + PAYU_API_LOGIN=build-only-payu-login \ + PAYU_API_KEY=build-only-payu-key \ + PAYU_MERCHANT_ID=999999 \ + PAYU_ACCOUNT_ID=999999 \ + PAYU_ENV=sandbox \ + PAYU_RESPONSE_URL=http://localhost:8000/checkout/pse/return \ + PAYU_NOTIFY_URL=http://localhost:9000/hooks/payment/payu_pse COPY package.json package-lock.json .npmrc ./ diff --git a/workloads/commerce-backend/medusa-config.ts b/workloads/commerce-backend/medusa-config.ts index 3454364..63d5d8e 100644 --- a/workloads/commerce-backend/medusa-config.ts +++ b/workloads/commerce-backend/medusa-config.ts @@ -181,6 +181,33 @@ module.exports = defineConfig({ ], }, }, + // ADR 0009: PayU LatAm elegido como proveedor de pago -- no existe + // ningun plugin real de Medusa para su API (los paquetes que + // aparecen buscando "PayU" son para PayU Polonia/CEE, API distinta + // por completo), asi que es un modulo custom propio. Alcance inicial: + // solo PSE (transferencia bancaria), no Nequi/Daviplata/tarjetas. + // provider_id resultante: "pp_payu_pse" (identifier "payu" + id + // "pse", convencion de Medusa). + { + resolve: "@medusajs/medusa/payment", + options: { + providers: [ + { + resolve: "./src/modules/payu-pse", + id: "pse", + options: { + apiLogin: required("PAYU_API_LOGIN"), + apiKey: required("PAYU_API_KEY"), + merchantId: required("PAYU_MERCHANT_ID"), + accountId: required("PAYU_ACCOUNT_ID"), + env: process.env.PAYU_ENV || "sandbox", + responseUrl: required("PAYU_RESPONSE_URL"), + notifyUrl: required("PAYU_NOTIFY_URL"), + }, + }, + ], + }, + }, ], // ADR 0006 Etapa 2. Sin required(): a diferencia de S3/DB, Meilisearch diff --git a/workloads/commerce-backend/src/api/store/payments/pse-banks/route.ts b/workloads/commerce-backend/src/api/store/payments/pse-banks/route.ts new file mode 100644 index 0000000..228fa0a --- /dev/null +++ b/workloads/commerce-backend/src/api/store/payments/pse-banks/route.ts @@ -0,0 +1,37 @@ +import type { MedusaRequest, MedusaResponse } from "@medusajs/framework"; +import { fetchPseBanks, type PayuBank, type PayuEnv } from "../../../../lib/payu"; + +// Lista de bancos PSE -- no hay forma de pedirle esto al modulo de pago +// custom directamente (AbstractPaymentProvider no expone sus metodos +// internos fuera del contrato de pago), asi que esta ruta llama a PayU +// con las mismas credenciales de medusa-config.ts. Cache en memoria (sin +// Redis/cron) porque PayU solo pide refrescar "una vez al dia" -- mismo +// criterio de simplicidad que ya usa la seccion de Meilisearch en +// medusa-config.ts. +const CACHE_TTL_MS = 6 * 60 * 60 * 1000; +let cache: { banks: PayuBank[]; fetchedAt: number } | null = null; + +export async function GET(req: MedusaRequest, res: MedusaResponse) { + if (cache && Date.now() - cache.fetchedAt < CACHE_TTL_MS) { + res.json({ banks: cache.banks }); + return; + } + + try { + const banks = await fetchPseBanks({ + apiLogin: process.env.PAYU_API_LOGIN ?? "", + apiKey: process.env.PAYU_API_KEY ?? "", + merchantId: process.env.PAYU_MERCHANT_ID ?? "", + accountId: process.env.PAYU_ACCOUNT_ID ?? "", + env: (process.env.PAYU_ENV as PayuEnv) || "sandbox", + }); + cache = { banks, fetchedAt: Date.now() }; + res.json({ banks }); + } catch (error) { + // Degrada a lista vacia en vez de tirar 500 -- el checkout debe poder + // mostrar un mensaje de "no disponible" en vez de romper la pagina + // entera, mismo criterio que /store/search cuando Meilisearch no + // responde. + res.json({ banks: cache?.banks ?? [], error: (error as Error).message }); + } +} diff --git a/workloads/commerce-backend/src/lib/payu.ts b/workloads/commerce-backend/src/lib/payu.ts new file mode 100644 index 0000000..5b0d076 --- /dev/null +++ b/workloads/commerce-backend/src/lib/payu.ts @@ -0,0 +1,111 @@ +import { createHash } from "node:crypto"; + +// Contrato real de la API de PayU LatAm (developers.payulatam.com, +// verificado en vivo 2026-09-06/07 -- NO es la misma API que los plugins +// de Medusa que aparecen buscando "PayU": esos son para PayU Polonia/CEE +// (clientId/posId/secondKey), API distinta por completo. Esta es la de +// apiLogin/apiKey/merchantId/accountId, la que cubre Colombia. + +export type PayuEnv = "sandbox" | "production"; + +export interface PayuCredentials { + apiLogin: string; + apiKey: string; + merchantId: string; + accountId: string; + env: PayuEnv; +} + +const BASE_URL: Record = { + sandbox: "https://sandbox.api.payulatam.com/payments-api/4.0/service.cgi", + production: "https://api.payulatam.com/payments-api/4.0/service.cgi", +}; + +export async function payuRequest( + credentials: PayuCredentials, + body: Record, +): Promise> { + const response = await fetch(BASE_URL[credentials.env], { + method: "POST", + headers: { "Content-Type": "application/json", Accept: "application/json" }, + body: JSON.stringify({ + test: credentials.env === "sandbox", + language: "es", + ...body, + }), + }); + const text = await response.text(); + let json: Record; + try { + json = JSON.parse(text); + } catch { + throw new Error(`Respuesta no-JSON de PayU (HTTP ${response.status}): ${text.slice(0, 300)}`); + } + if (!response.ok) { + throw new Error(`PayU respondió HTTP ${response.status}: ${JSON.stringify(json).slice(0, 300)}`); + } + return json; +} + +export interface PayuBank { + id: string; + description: string; + pseCode: string; +} + +export async function fetchPseBanks(credentials: PayuCredentials): Promise { + const json = await payuRequest(credentials, { + command: "GET_BANKS_LIST", + bankListInformation: { paymentMethod: "PSE", paymentCountry: "CO" }, + }); + const banks = (json.banks ?? []) as Array<{ + id?: unknown; + description?: unknown; + pseCode?: unknown; + }>; + return banks + .filter((bank) => typeof bank.pseCode === "string" && typeof bank.description === "string") + .map((bank) => ({ + id: String(bank.id ?? bank.pseCode), + description: String(bank.description), + pseCode: String(bank.pseCode), + })); +} + +// Regla documentada de PayU para el campo de monto de la firma: si el +// segundo decimal es cero, un solo decimal (150.00 -> "150.0", +// 150.20 -> "150.2"); si no, dos decimales (150.25 -> "150.25"). +export function formatPayuAmount(amount: number): string { + const twoDecimals = amount.toFixed(2); + return twoDecimals.endsWith("0") ? amount.toFixed(1) : twoDecimals; +} + +// Firma de la orden (SUBMIT_TRANSACTION) -- distinta de la firma del +// webhook de abajo, confirmado contra el SDK PHP oficial de PayU. +export function buildOrderSignature(params: { + apiKey: string; + merchantId: string; + referenceCode: string; + amount: string; + currency: string; +}): string { + const raw = `${params.apiKey}~${params.merchantId}~${params.referenceCode}~${params.amount}~${params.currency}`; + return createHash("md5").update(raw).digest("hex"); +} + +// Firma del webhook de confirmación (notifyUrl) -- lo único que impide +// que cualquiera falsifique una confirmación de pago. Falla cerrado: +// cualquier mismatch devuelve false, nunca se traga la excepción. +export function verifyWebhookSignature(params: { + apiKey: string; + merchantId: string; + referenceSale: string; + value: string; + currency: string; + statePol: string; + sign: string; +}): boolean { + const raw = `${params.apiKey}~${params.merchantId}~${params.referenceSale}~${params.value}~${params.currency}~${params.statePol}`; + const expected = createHash("md5").update(raw).digest("hex"); + return expected.toLowerCase() === params.sign.toLowerCase(); +} diff --git a/workloads/commerce-backend/src/modules/payu-pse/index.ts b/workloads/commerce-backend/src/modules/payu-pse/index.ts new file mode 100644 index 0000000..bd2949e --- /dev/null +++ b/workloads/commerce-backend/src/modules/payu-pse/index.ts @@ -0,0 +1,6 @@ +import { ModuleProvider, Modules } from "@medusajs/framework/utils"; +import PayuPseProviderService from "./service"; + +export default ModuleProvider(Modules.PAYMENT, { + services: [PayuPseProviderService], +}); diff --git a/workloads/commerce-backend/src/modules/payu-pse/service.ts b/workloads/commerce-backend/src/modules/payu-pse/service.ts new file mode 100644 index 0000000..f491468 --- /dev/null +++ b/workloads/commerce-backend/src/modules/payu-pse/service.ts @@ -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 { + 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): 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 { + 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; + const extraParameters = (transactionResponse.extraParameters ?? {}) as Record; + 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, 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 { + 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 { + return { data: input.data }; + } + + // PSE no tiene concepto de cancelar una transaccion ya enviada al banco. + async cancelPayment(input: CancelPaymentInput): Promise { + 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 { + 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 { + 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 { + 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 { + // 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 { + 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 { + const body = payload.data as Record; + 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), + }, + }; + } +} diff --git a/workloads/commerce-backend/src/modules/payu-pse/types.ts b/workloads/commerce-backend/src/modules/payu-pse/types.ts new file mode 100644 index 0000000..1bcd2e7 --- /dev/null +++ b/workloads/commerce-backend/src/modules/payu-pse/types.ts @@ -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; +} diff --git a/workloads/ecommerce/app/api/cart/complete/route.ts b/workloads/ecommerce/app/api/cart/complete/route.ts new file mode 100644 index 0000000..adcbf2d --- /dev/null +++ b/workloads/ecommerce/app/api/cart/complete/route.ts @@ -0,0 +1,14 @@ +import { completeCart } from "@/lib/headless/providers/medusa-payment"; + +export async function POST(request: Request) { + const { cartId } = await request.json(); + if (!cartId) { + return Response.json({ error: "Falta cartId" }, { status: 400 }); + } + try { + await completeCart(cartId); + return Response.json({ ok: true }); + } catch (error) { + return Response.json({ error: (error as Error).message }, { status: 502 }); + } +} diff --git a/workloads/ecommerce/app/api/cart/payment/banks/route.ts b/workloads/ecommerce/app/api/cart/payment/banks/route.ts new file mode 100644 index 0000000..97b08fc --- /dev/null +++ b/workloads/ecommerce/app/api/cart/payment/banks/route.ts @@ -0,0 +1,10 @@ +import { getPseBanks } from "@/lib/headless/providers/medusa-payment"; + +export async function GET() { + try { + const banks = await getPseBanks(); + return Response.json({ banks }); + } catch (error) { + return Response.json({ error: (error as Error).message }, { status: 502 }); + } +} diff --git a/workloads/ecommerce/app/api/cart/payment/session/route.ts b/workloads/ecommerce/app/api/cart/payment/session/route.ts new file mode 100644 index 0000000..267c5f3 --- /dev/null +++ b/workloads/ecommerce/app/api/cart/payment/session/route.ts @@ -0,0 +1,15 @@ +import { createPaymentCollection, createPsePaymentSession } from "@/lib/headless/providers/medusa-payment"; + +export async function POST(request: Request) { + const { cartId, ...pse } = await request.json(); + if (!cartId || !pse.bankCode || !pse.docNumber || !pse.fullName || !pse.email || !pse.phone) { + return Response.json({ error: "Faltan campos obligatorios para el pago con PSE" }, { status: 400 }); + } + try { + const paymentCollectionId = await createPaymentCollection(cartId); + const bankUrl = await createPsePaymentSession(paymentCollectionId, pse); + return Response.json({ bankUrl }); + } catch (error) { + return Response.json({ error: (error as Error).message }, { status: 502 }); + } +} diff --git a/workloads/ecommerce/app/checkout/pse/return/page.tsx b/workloads/ecommerce/app/checkout/pse/return/page.tsx new file mode 100644 index 0000000..6ca481d --- /dev/null +++ b/workloads/ecommerce/app/checkout/pse/return/page.tsx @@ -0,0 +1,10 @@ +import { Suspense } from "react"; +import { PseReturnClient } from "@/components/checkout/pse-return-client"; + +export default function PseReturnPage() { + return ( + + + + ); +} diff --git a/workloads/ecommerce/commerce/configmap.yaml b/workloads/ecommerce/commerce/configmap.yaml index c390dc7..c6fbd79 100644 --- a/workloads/ecommerce/commerce/configmap.yaml +++ b/workloads/ecommerce/commerce/configmap.yaml @@ -24,3 +24,11 @@ data: # env var del plugin, no del servidor Meilisearch en si -- ver # MEILI_MASTER_KEY en secrets.template.yaml). MEILISEARCH_HOST: http://meilisearch-svc:7700 + + # PayU LatAm (ADR 0009) -- solo URLs/config no sensible, credenciales + # reales (apiLogin/apiKey/merchantId/accountId) van en + # secrets.template.yaml. PAYU_ENV=sandbox hasta que haya credenciales + # de produccion reales. + PAYU_ENV: sandbox + PAYU_RESPONSE_URL: https://shop.cruzcloud.net/checkout/pse/return + PAYU_NOTIFY_URL: https://commerce.cruzcloud.net/hooks/payment/payu_pse diff --git a/workloads/ecommerce/commerce/secrets.template.yaml b/workloads/ecommerce/commerce/secrets.template.yaml index 77cda00..27b5666 100644 --- a/workloads/ecommerce/commerce/secrets.template.yaml +++ b/workloads/ecommerce/commerce/secrets.template.yaml @@ -35,6 +35,15 @@ stringData: # commerce-storefront: el subscriber de Medusa lo envía como header # x-revalidate-secret al invocar POST /api/revalidate en el storefront. REVALIDATE_SECRET: CAMBIAR + + # PayU LatAm (ADR 0009) -- credenciales del comercio. Mientras no exista + # cuenta real, usar las de sandbox genéricas y públicas de PayU + # (developers.payulatam.com/latam/en/docs/getting-started/test-your-solution.html), + # nunca hardcodeadas acá. + PAYU_API_LOGIN: CAMBIAR + PAYU_API_KEY: CAMBIAR + PAYU_MERCHANT_ID: CAMBIAR + PAYU_ACCOUNT_ID: CAMBIAR --- apiVersion: v1 kind: Secret diff --git a/workloads/ecommerce/components/checkout/checkout-client.tsx b/workloads/ecommerce/components/checkout/checkout-client.tsx index 2caef9f..4187386 100644 --- a/workloads/ecommerce/components/checkout/checkout-client.tsx +++ b/workloads/ecommerce/components/checkout/checkout-client.tsx @@ -2,7 +2,7 @@ import Image from "next/image"; import Link from "next/link"; -import { ArrowLeft, MapPin, MessageCircle, Truck } from "lucide-react"; +import { ArrowLeft, Landmark, MapPin, MessageCircle, Truck } from "lucide-react"; import { type FormEvent, useState } from "react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -13,11 +13,12 @@ import { useCartStore } from "@/store/cart-store"; const SELECT_CLASS = "h-12 w-full rounded-full border border-slate-200 bg-white px-5 text-sm text-slate-950 outline-none transition focus:border-cyan-500 focus:ring-4 focus:ring-cyan-300/30 disabled:opacity-50"; -// ADR 0009, siguiente corte tras el carrito real: direccion + seleccion de -// envio contra el Store API real (mismos endpoints ya verificados en vivo -// para las shipping options). El pago (PayU custom) todavia no existe -- -// el ultimo paso sigue siendo WhatsApp, ahora con el pedido completo -// (direccion, envio, total real) en vez de solo la lista de productos. +// ADR 0009: direccion + envio + pago real, todo contra el Store API real. +// El pago es PayU LatAm/PSE (modulo custom del backend, ver +// workloads/commerce-backend/src/modules/payu-pse) -- redirige al banco +// del comprador, la confirmacion real llega despues via webhook async +// (nunca por esta pantalla). WhatsApp queda como respaldo manual si la +// iniciacion del pago falla, ya no como camino principal. interface ShippingOption { id: string; @@ -25,7 +26,12 @@ interface ShippingOption { amount: number; } -type Step = "address" | "shipping" | "review"; +interface PseBank { + pseCode: string; + description: string; +} + +type Step = "address" | "shipping" | "payment"; async function postJson(url: string, body: unknown) { const response = await fetch(url, { @@ -60,6 +66,11 @@ export function CheckoutClient() { const [total, setTotal] = useState(0); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); + const [banks, setBanks] = useState([]); + const [bankCode, setBankCode] = useState(""); + const [userType, setUserType] = useState<"N" | "J">("N"); + const [docType, setDocType] = useState<"CC" | "CE" | "NIT" | "TI" | "PP">("CC"); + const [docNumber, setDocNumber] = useState(""); const itemsSubtotal = items.reduce((sum, item) => sum + (item.product.price ?? 0) * item.quantity, 0); const currency = items[0]?.product.currency; @@ -103,7 +114,12 @@ export function CheckoutClient() { if (cart.error) throw new Error(cart.error); setShippingTotal(cart.shippingTotal ?? 0); setTotal(cart.total ?? itemsSubtotal); - setStep("review"); + const banksResponse = await fetch("/api/cart/payment/banks"); + const banksData = await banksResponse.json(); + const bankList: PseBank[] = banksData.banks ?? []; + setBanks(bankList); + setBankCode(bankList[0]?.pseCode ?? ""); + setStep("payment"); } catch (submitError) { setError((submitError as Error).message); } finally { @@ -111,6 +127,38 @@ export function CheckoutClient() { } } + async function submitPayment(event: FormEvent) { + event.preventDefault(); + if (!cartId || !bankCode || !docNumber || !selectedCiudad || !selectedDepartamento) return; + setLoading(true); + setError(null); + try { + const session = await postJson("/api/cart/payment/session", { + cartId, + bankCode, + userType, + docType, + docNumber, + fullName: `${form.firstName} ${form.lastName}`.trim(), + email: form.email, + phone: form.phone, + address: form.address1, + city: selectedCiudad.name, + state: selectedDepartamento.name, + }); + if (session.error) throw new Error(session.error); + const completion = await postJson("/api/cart/complete", { cartId }); + if (completion.error) throw new Error(completion.error); + // Navegacion completa, no iframe -- asi lo exige PayU para el + // flujo de PSE. La confirmacion real llega despues via webhook, + // esta pagina nunca vuelve a cargar sola. + window.location.href = session.bankUrl; + } catch (submitError) { + setError((submitError as Error).message); + setLoading(false); + } + } + function continueOnWhatsApp() { const lines = items.map((item) => `• ${item.quantity} x ${item.product.name}`).join("\n"); const message = `Hola Ari Shopping 👋\nQuiero confirmar mi pedido:\n\n${lines}\n\nSubtotal: ${formatMoney(itemsSubtotal, currency)}\nEnvío: ${formatMoney(shippingTotal, currency)}\nTotal: ${formatMoney(total, currency)}\n\nNombre: ${form.firstName} ${form.lastName}\nDirección: ${form.address1}, ${selectedCiudad?.name ?? ""}${selectedDepartamento ? `, ${selectedDepartamento.name}` : ""}\nTeléfono: ${form.phone}`; @@ -192,17 +240,55 @@ export function CheckoutClient() { )} - {step === "review" && ( -
+ {step === "payment" && ( +

Entrega

{form.firstName} {form.lastName}

{form.address1}, {selectedCiudad?.name}{selectedDepartamento ? `, ${selectedDepartamento.name}` : ""}

{form.phone}

-

El pago en línea todavía no está disponible — confirmamos tu pedido por WhatsApp con estos datos.

- -
+ +

Paga con PSE (transferencia desde tu banco)

+ + {banks.length > 0 ? ( + + ) : ( +

No pudimos cargar la lista de bancos — probá de nuevo en un momento, o confirmá tu pedido por WhatsApp abajo.

+ )} + +
+ + +
+ +
+ + setDocNumber(event.target.value)} /> +
+ + {error &&

{error}

} + + + +
+ ¿Prefieres otra vía? +
+ + )} @@ -219,8 +305,8 @@ export function CheckoutClient() {
Subtotal{formatMoney(itemsSubtotal, currency)}
- {step === "review" &&
Envío{formatMoney(shippingTotal, currency)}
} -
Total{formatMoney(step === "review" ? total : itemsSubtotal, currency)}
+ {step === "payment" &&
Envío{formatMoney(shippingTotal, currency)}
} +
Total{formatMoney(step === "payment" ? total : itemsSubtotal, currency)}
diff --git a/workloads/ecommerce/components/checkout/pse-return-client.tsx b/workloads/ecommerce/components/checkout/pse-return-client.tsx new file mode 100644 index 0000000..913c755 --- /dev/null +++ b/workloads/ecommerce/components/checkout/pse-return-client.tsx @@ -0,0 +1,38 @@ +"use client"; + +import Link from "next/link"; +import { Clock } from "lucide-react"; +import { useSearchParams } from "next/navigation"; +import { Button } from "@/components/ui/button"; + +// PayU redirige aca despues de que el comprador paga en su banco -- esto +// NO confirma el pago. Los parametros de esta URL son solo informativos; +// el estado real y definitivo llega por el webhook async de PayU al +// backend (getWebhookActionAndData en el modulo payu-pse), nunca por +// esta pantalla. Por eso el mensaje nunca afirma exito, solo que +// estamos esperando la confirmacion. +export function PseReturnClient() { + const searchParams = useSearchParams(); + const referenceCode = searchParams.get("referenceCode") ?? searchParams.get("reference_sale"); + + return ( +
+ +

Estamos confirmando tu pago

+

+ Tu banco ya procesó la transferencia. PayU nos avisa en unos minutos si quedó aprobada — te llegará la confirmación por correo. + {referenceCode && ( + <> + {" "}Tu número de referencia es {referenceCode}. + + )} +

+

+ Si después de un rato no ves la confirmación, escribinos por WhatsApp con este número de referencia. +

+ +
+ ); +} diff --git a/workloads/ecommerce/lib/headless/providers/medusa-cart.ts b/workloads/ecommerce/lib/headless/providers/medusa-cart.ts index 46bbc42..1523b7b 100644 --- a/workloads/ecommerce/lib/headless/providers/medusa-cart.ts +++ b/workloads/ecommerce/lib/headless/providers/medusa-cart.ts @@ -119,7 +119,9 @@ function mapCart(cart: MedusaCart): CartSummary { }; } -async function medusaCartFetch( +// 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> { diff --git a/workloads/ecommerce/lib/headless/providers/medusa-payment.ts b/workloads/ecommerce/lib/headless/providers/medusa-payment.ts new file mode 100644 index 0000000..32e7feb --- /dev/null +++ b/workloads/ecommerce/lib/headless/providers/medusa-payment.ts @@ -0,0 +1,92 @@ +// 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 { + 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 { + 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 { + 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 { + 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."); + } +}