Merge feat/payu-pse-pago into main
Build and Push Frontend / Escaneo de Secretos (Gitleaks) (push) Successful in 1m8s
Build and Push Frontend / Construir y Subir Imagen (push) Successful in 14m42s
Build and Push Medusa / Escaneo de Secretos (Gitleaks) (push) Successful in 22s
Build and Push Medusa / Construir y publicar Medusa (push) Successful in 19m56s
Build and Push Frontend / Escaneo de Secretos (Gitleaks) (push) Successful in 1m8s
Build and Push Frontend / Construir y Subir Imagen (push) Successful in 14m42s
Build and Push Medusa / Escaneo de Secretos (Gitleaks) (push) Successful in 22s
Build and Push Medusa / Construir y publicar Medusa (push) Successful in 19m56s
This commit is contained in:
commit
95cf791737
17 files changed
+802
-17
No files matched your search
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Suspense } from "react";
|
||||
import { PseReturnClient } from "@/components/checkout/pse-return-client";
|
||||
|
||||
export default function PseReturnPage() {
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<PseReturnClient />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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<string | null>(null);
|
||||
const [banks, setBanks] = useState<PseBank[]>([]);
|
||||
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() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === "review" && (
|
||||
<div className="mt-8 space-y-4">
|
||||
{step === "payment" && (
|
||||
<form onSubmit={submitPayment} className="mt-8 space-y-4">
|
||||
<div className="rounded-2xl border border-slate-200 p-5">
|
||||
<p className="text-xs font-black uppercase tracking-wide text-slate-400">Entrega</p>
|
||||
<p className="mt-1 font-bold">{form.firstName} {form.lastName}</p>
|
||||
<p className="text-sm text-slate-500">{form.address1}, {selectedCiudad?.name}{selectedDepartamento ? `, ${selectedDepartamento.name}` : ""}</p>
|
||||
<p className="text-sm text-slate-500">{form.phone}</p>
|
||||
</div>
|
||||
<p className="text-sm text-slate-500">El pago en línea todavía no está disponible — confirmamos tu pedido por WhatsApp con estos datos.</p>
|
||||
<Button variant="primary" size="lg" className="w-full" onClick={continueOnWhatsApp}><MessageCircle className="size-5" />Confirmar por WhatsApp</Button>
|
||||
</div>
|
||||
|
||||
<p className="flex items-center gap-2 text-sm font-bold text-slate-700"><Landmark className="size-4" />Paga con PSE (transferencia desde tu banco)</p>
|
||||
|
||||
{banks.length > 0 ? (
|
||||
<select required value={bankCode} onChange={(event) => setBankCode(event.target.value)} className={SELECT_CLASS}>
|
||||
{banks.map((bank) => <option key={bank.pseCode} value={bank.pseCode}>{bank.description}</option>)}
|
||||
</select>
|
||||
) : (
|
||||
<p className="rounded-2xl bg-yellow-50 p-3 text-xs font-bold leading-5 text-yellow-800">No pudimos cargar la lista de bancos — probá de nuevo en un momento, o confirmá tu pedido por WhatsApp abajo.</p>
|
||||
)}
|
||||
|
||||
<div className="flex gap-4 text-sm font-bold">
|
||||
<label className="flex items-center gap-2"><input type="radio" name="userType" checked={userType === "N"} onChange={() => setUserType("N")} />Persona natural</label>
|
||||
<label className="flex items-center gap-2"><input type="radio" name="userType" checked={userType === "J"} onChange={() => setUserType("J")} />Persona jurídica</label>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<select
|
||||
required
|
||||
value={docType}
|
||||
onChange={(event) => setDocType(event.target.value as typeof docType)}
|
||||
className={SELECT_CLASS}
|
||||
>
|
||||
<option value="CC">Cédula de ciudadanía</option>
|
||||
<option value="CE">Cédula de extranjería</option>
|
||||
<option value="TI">Tarjeta de identidad</option>
|
||||
<option value="PP">Pasaporte</option>
|
||||
<option value="NIT">NIT</option>
|
||||
</select>
|
||||
<Input placeholder="Número de documento" required value={docNumber} onChange={(event) => setDocNumber(event.target.value)} />
|
||||
</div>
|
||||
|
||||
{error && <p className="text-sm font-bold text-rose-600">{error}</p>}
|
||||
|
||||
<Button type="submit" variant="primary" size="lg" className="w-full" disabled={loading || banks.length === 0}>{loading ? "Redirigiendo a tu banco…" : "Pagar con PSE"}</Button>
|
||||
|
||||
<div className="flex items-center gap-3 text-xs font-bold uppercase tracking-wide text-slate-400">
|
||||
<span className="h-px flex-1 bg-slate-200" />¿Prefieres otra vía?<span className="h-px flex-1 bg-slate-200" />
|
||||
</div>
|
||||
<Button type="button" variant="outline" size="lg" className="w-full" onClick={continueOnWhatsApp}><MessageCircle className="size-5" />Confirmar por WhatsApp</Button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -219,8 +305,8 @@ export function CheckoutClient() {
|
||||
</div>
|
||||
<div className="mt-5 space-y-2 border-t border-slate-100 pt-4 text-sm">
|
||||
<div className="flex justify-between"><span className="text-slate-500">Subtotal</span><span className="font-bold">{formatMoney(itemsSubtotal, currency)}</span></div>
|
||||
{step === "review" && <div className="flex justify-between"><span className="text-slate-500">Envío</span><span className="font-bold">{formatMoney(shippingTotal, currency)}</span></div>}
|
||||
<div className="flex justify-between text-base"><span className="font-black">Total</span><span className="font-black">{formatMoney(step === "review" ? total : itemsSubtotal, currency)}</span></div>
|
||||
{step === "payment" && <div className="flex justify-between"><span className="text-slate-500">Envío</span><span className="font-bold">{formatMoney(shippingTotal, currency)}</span></div>}
|
||||
<div className="flex justify-between text-base"><span className="font-black">Total</span><span className="font-black">{formatMoney(step === "payment" ? total : itemsSubtotal, currency)}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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 (
|
||||
<div className="mx-auto max-w-lg px-4 py-24 text-center">
|
||||
<Clock className="mx-auto size-12 text-ari-marino" />
|
||||
<h1 className="mt-6 text-2xl font-black">Estamos confirmando tu pago</h1>
|
||||
<p className="mt-3 text-slate-500">
|
||||
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 <strong>{referenceCode}</strong>.
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
<p className="mt-3 text-sm text-slate-400">
|
||||
Si después de un rato no ves la confirmación, escribinos por WhatsApp con este número de referencia.
|
||||
</p>
|
||||
<Button asChild variant="primary" className="mt-8">
|
||||
<Link href="/catalog">Volver al catálogo</Link>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<Record<string, unknown>> {
|
||||
|
||||
@@ -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<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.");
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user