From a96ebce8f35bb3db8fb741f59bd08570fb716085 Mon Sep 17 00:00:00 2001 From: Diego Torres Date: Thu, 31 Jul 2025 21:19:44 -0500 Subject: [PATCH 1/8] feat: wip -> integrate Culqi payment gateway in Checkout component --- src/routes/checkout/index.tsx | 97 ++++++++++++++++++++++++++++++++--- 1 file changed, 90 insertions(+), 7 deletions(-) diff --git a/src/routes/checkout/index.tsx b/src/routes/checkout/index.tsx index 2e67d52..49c3bbb 100644 --- a/src/routes/checkout/index.tsx +++ b/src/routes/checkout/index.tsx @@ -1,5 +1,6 @@ import { zodResolver } from "@hookform/resolvers/zod"; import { X } from "lucide-react"; +import { useEffect, useState } from "react"; import { useForm } from "react-hook-form"; import { redirect, useNavigation, useSubmit } from "react-router"; import { z } from "zod"; @@ -116,6 +117,7 @@ export default function Checkout({ loaderData }: Route.ComponentProps) { const navigation = useNavigation(); const submit = useSubmit(); const loading = navigation.state === "submitting"; + const [Culqui, setCulqui] = useState(null); const { register, @@ -138,14 +140,95 @@ export default function Checkout({ loaderData }: Route.ComponentProps) { mode: "onTouched", }); + useEffect(() => { + const script = document.createElement("script"); + script.src = "https://js.culqi.com/checkout-js"; + script.async = true; + script.onload = () => { + // Aqui ya existe window.CulqiCheckout + const settings = { + title: "FullStock", + currency: "USD", + amount: total * 100, // Este parámetro es requerido para realizar pagos yape(80.00) + // order: "ord_live_d1P0Tu1n7Od4nZdp", // Este parámetro es requerido para realizar pagos con pagoEfectivo, billeteras y Cuotéalo + // xculqirsaid: "Inserta aquí el id de tu llave pública RSA", + // rsapublickey: "Inserta aquí tu llave pública RSA", + }; + + const paymentMethods = { + // las opciones se ordenan según se configuren + tarjeta: true, + yape: false, + billetera: false, + bancaMovil: false, + agente: false, + cuotealo: false, + }; + + const options = { + lang: "auto", + installments: true, // Habilitar o deshabilitar el campo de cuotas + modal: true, + // container: "#culqi-container", // Opcional - Div donde quieres cargar el checkout + paymentMethods: paymentMethods, + paymentMethodsSort: Object.keys(paymentMethods), // las opciones se ordenan según se configuren en paymentMethods + }; + + const appearance = { + theme: "default", + hiddenCulqiLogo: false, + hiddenBannerContent: false, + hiddenBanner: false, + hiddenToolBarAmount: false, + hiddenEmail: false, + menuType: "select", // sidebar / sliderTop / select + buttonCardPayText: "Pagar", // + logo: null, // 'http://www.childrensociety.ms/wp-content/uploads/2019/11/MCS-Logo-2019-no-text.jpg', + defaultStyle: { + bannerColor: "blue", // hexadecimal + buttonBackground: "yellow", // hexadecimal + menuColor: "pink", // hexadecimal + linksColor: "green", // hexadecimal + buttonTextColor: "blue", // hexadecimal + priceColor: "red", + }, + }; + + const config = { + settings, + // client, + options, + appearance, + }; + + const publicKey = "pk_test_Ws4NXfH95QXlZgaz"; + // @ts-ignore + const Culqi = new window.CulqiCheckout(publicKey, config); + + setCulqui(Culqi); + + console.log("Script loaded"); + }; + + document.body.appendChild(script); + + // Cleanup function + return () => { + document.body.removeChild(script); + }; + }, []); + async function onSubmit(formData: CheckoutForm) { - submit( - { - shippingDetailsJson: JSON.stringify(formData), - cartItemsJson: JSON.stringify(cart.items), - }, - { method: "POST" } - ); + // submit( + // { + // shippingDetailsJson: JSON.stringify(formData), + // cartItemsJson: JSON.stringify(cart.items), + // }, + // { method: "POST" } + // ); + if (Culqui) { + Culqui.open(); + } } return ( From bcf8fef3a4e5ecd6b0c0b962806af22ea492916e Mon Sep 17 00:00:00 2001 From: Diego Torres Date: Tue, 5 Aug 2025 16:44:51 -0500 Subject: [PATCH 2/8] feat: wip -> implement Culqi payment processing in Checkout component --- src/routes/checkout/index.tsx | 211 ++++++++++++++++++++-------------- 1 file changed, 127 insertions(+), 84 deletions(-) diff --git a/src/routes/checkout/index.tsx b/src/routes/checkout/index.tsx index 49c3bbb..d0c3786 100644 --- a/src/routes/checkout/index.tsx +++ b/src/routes/checkout/index.tsx @@ -1,6 +1,6 @@ import { zodResolver } from "@hookform/resolvers/zod"; import { X } from "lucide-react"; -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { useForm } from "react-hook-form"; import { redirect, useNavigation, useSubmit } from "react-router"; import { z } from "zod"; @@ -22,6 +22,20 @@ import { commitSession, getSession } from "@/session.server"; import type { Route } from "./+types"; +interface CulqiInstance { + open: () => void; + close: () => void; + token?: { id: string }; + error?: Error; + culqi?: () => void; +} + +declare global { + interface Window { + CulqiCheckout: new (publicKey: string, config: object) => CulqiInstance; + } +} + const countryOptions = [ { value: "AR", label: "Argentina" }, { value: "BO", label: "Bolivia" }, @@ -69,6 +83,30 @@ export async function action({ request }: Route.ActionArgs) { const cartItems = JSON.parse( formData.get("cartItemsJson") as string ) as CartItem[]; + const token = formData.get("token") as string; + + const body = { + amount: 2000, + currency_code: "PEN", + email: shippingDetails.email, + source_id: token, + capture: true, + }; + + const response = await fetch("https://api.culqi.com/v2/charges", { + method: "POST", + headers: { + "content-type": "application/json", + Authorization: `Bearer sk_test_EC8oOLd3ZiCTKqjN`, + }, + body: JSON.stringify(body), + }); + + if (!response.ok) { + const errorData = await response.json(); + console.error("Error creating charge:", errorData); + throw new Error("Error processing payment"); + } const items = cartItems.map((item) => ({ productId: item.product.id, @@ -117,12 +155,14 @@ export default function Checkout({ loaderData }: Route.ComponentProps) { const navigation = useNavigation(); const submit = useSubmit(); const loading = navigation.state === "submitting"; - const [Culqui, setCulqui] = useState(null); + const [culqui, setCulqui] = useState(null); + const scriptRef = useRef(null); const { register, handleSubmit, formState: { errors, isValid }, + getValues, } = useForm({ resolver: zodResolver(CheckoutFormSchema), defaultValues: { @@ -141,93 +181,95 @@ export default function Checkout({ loaderData }: Route.ComponentProps) { }); useEffect(() => { - const script = document.createElement("script"); - script.src = "https://js.culqi.com/checkout-js"; - script.async = true; - script.onload = () => { - // Aqui ya existe window.CulqiCheckout - const settings = { - title: "FullStock", - currency: "USD", - amount: total * 100, // Este parámetro es requerido para realizar pagos yape(80.00) - // order: "ord_live_d1P0Tu1n7Od4nZdp", // Este parámetro es requerido para realizar pagos con pagoEfectivo, billeteras y Cuotéalo - // xculqirsaid: "Inserta aquí el id de tu llave pública RSA", - // rsapublickey: "Inserta aquí tu llave pública RSA", - }; - - const paymentMethods = { - // las opciones se ordenan según se configuren - tarjeta: true, - yape: false, - billetera: false, - bancaMovil: false, - agente: false, - cuotealo: false, - }; - - const options = { - lang: "auto", - installments: true, // Habilitar o deshabilitar el campo de cuotas - modal: true, - // container: "#culqi-container", // Opcional - Div donde quieres cargar el checkout - paymentMethods: paymentMethods, - paymentMethodsSort: Object.keys(paymentMethods), // las opciones se ordenan según se configuren en paymentMethods - }; - - const appearance = { - theme: "default", - hiddenCulqiLogo: false, - hiddenBannerContent: false, - hiddenBanner: false, - hiddenToolBarAmount: false, - hiddenEmail: false, - menuType: "select", // sidebar / sliderTop / select - buttonCardPayText: "Pagar", // - logo: null, // 'http://www.childrensociety.ms/wp-content/uploads/2019/11/MCS-Logo-2019-no-text.jpg', - defaultStyle: { - bannerColor: "blue", // hexadecimal - buttonBackground: "yellow", // hexadecimal - menuColor: "pink", // hexadecimal - linksColor: "green", // hexadecimal - buttonTextColor: "blue", // hexadecimal - priceColor: "red", - }, - }; - - const config = { - settings, - // client, - options, - appearance, - }; - - const publicKey = "pk_test_Ws4NXfH95QXlZgaz"; - // @ts-ignore - const Culqi = new window.CulqiCheckout(publicKey, config); - - setCulqui(Culqi); - - console.log("Script loaded"); + // Function to load the Culqi script + const loadCulqiScript = (): Promise => { + return new Promise((resolve, reject) => { + if (window.CulqiCheckout) { + resolve(window.CulqiCheckout); + return; + } + + // Create script element + const script = document.createElement("script"); + script.src = "https://js.culqi.com/checkout-js"; + script.async = true; + + // Store reference for cleanup + scriptRef.current = script; + + script.onload = () => { + if (window.CulqiCheckout) { + resolve(window.CulqiCheckout); + } else { + reject( + new Error( + "Culqi script loaded but CulqiCheckout object not found" + ) + ); + } + }; + + script.onerror = () => { + reject(new Error("Failed to load CulqiCheckout script")); + }; + + document.head.appendChild(script); + }); }; - document.body.appendChild(script); + loadCulqiScript() + .then((CulqiCheckout) => { + const config = { + settings: { + currency: "USD", + amount: total * 100, + }, + client: { + email: user?.email, + }, + options: {}, + appearance: {}, + }; + + const publicKey = "pk_test_Ws4NXfH95QXlZgaz"; + const culqiInstance = new CulqiCheckout(publicKey, config); + + const handleCulqiAction = () => { + if (culqiInstance.token) { + const token = culqiInstance.token.id; + culqiInstance.close(); + const formData = getValues(); + submit( + { + shippingDetailsJson: JSON.stringify(formData), + cartItemsJson: JSON.stringify(cart.items), + token, + }, + { method: "POST" } + ); + } else { + console.log("Error : ", culqiInstance.error); + } + }; + + culqiInstance.culqi = handleCulqiAction; + + setCulqui(culqiInstance); + }) + .catch((error) => { + console.error("Error loading Culqi script:", error); + }); - // Cleanup function return () => { - document.body.removeChild(script); + if (scriptRef.current) { + scriptRef.current.remove(); + } }; - }, []); - - async function onSubmit(formData: CheckoutForm) { - // submit( - // { - // shippingDetailsJson: JSON.stringify(formData), - // cartItemsJson: JSON.stringify(cart.items), - // }, - // { method: "POST" } - // ); - if (Culqui) { - Culqui.open(); + }, [total, user, submit, getValues, cart.items]); + + async function onSubmit() { + if (culqui) { + culqui.open(); } } @@ -353,6 +395,7 @@ export default function Checkout({ loaderData }: Route.ComponentProps) { +
); From 23a953657100b64f04d548d677a9860bc52b1a54 Mon Sep 17 00:00:00 2001 From: Diego Torres Date: Tue, 5 Aug 2025 19:36:49 -0500 Subject: [PATCH 3/8] feat: update currency display to PEN in Orders, ProductCard, and Checkout components --- src/routes/account/orders/index.tsx | 10 ++++----- src/routes/cart/index.tsx | 2 +- .../components/product-card/index.tsx | 2 +- src/routes/checkout/index.tsx | 21 ++++++++++++------- src/routes/product/index.tsx | 2 +- 5 files changed, 22 insertions(+), 15 deletions(-) diff --git a/src/routes/account/orders/index.tsx b/src/routes/account/orders/index.tsx index 98144c2..dccd7c0 100644 --- a/src/routes/account/orders/index.tsx +++ b/src/routes/account/orders/index.tsx @@ -53,9 +53,9 @@ export default function Orders({ loaderData }: Route.ComponentProps) { Total
- {order.totalAmount.toLocaleString("en-US", { + {order.totalAmount.toLocaleString("es-PE", { style: "currency", - currency: "USD", + currency: "PEN", })}
@@ -102,19 +102,19 @@ export default function Orders({ loaderData }: Route.ComponentProps) { {item.title}
- {item.quantity} × ${item.price.toFixed(2)} + {item.quantity} × S/{item.price.toFixed(2)}
- ${item.price.toFixed(2)} + S/{item.price.toFixed(2)} {item.quantity} - ${(item.price * item.quantity).toFixed(2)} + S/{(item.price * item.quantity).toFixed(2)} ))} diff --git a/src/routes/cart/index.tsx b/src/routes/cart/index.tsx index 3d58a2a..d330cef 100644 --- a/src/routes/cart/index.tsx +++ b/src/routes/cart/index.tsx @@ -90,7 +90,7 @@ export default function Cart({ loaderData }: Route.ComponentProps) { ))}

Total

-

${total.toFixed(2)}

+

S/{total.toFixed(2)}

))}

Total

-

${total.toFixed(2)}

+

S/{total.toFixed(2)}

diff --git a/src/routes/product/index.tsx b/src/routes/product/index.tsx index bd88861..f444f0b 100644 --- a/src/routes/product/index.tsx +++ b/src/routes/product/index.tsx @@ -41,7 +41,7 @@ export default function Product({ loaderData }: Route.ComponentProps) {

{product.title}

-

${product.price}

+

S/{product.price}

{product.description}

From 82f325e6323fc512476346edb2c37c14c08bd739 Mon Sep 17 00:00:00 2001 From: Diego Torres Date: Wed, 6 Aug 2025 12:06:26 -0500 Subject: [PATCH 4/8] feat: integrate Culqi payment processing and update order model to include payment_id --- .env.example | 4 + .env.test | 6 +- .../migration.sql | 2 + prisma/schema.prisma | 1 + .../appspecific/com.chrome.devtools.json | 0 src/hooks/use-culqui.ts | 78 ++++++++ src/routes/checkout/index.tsx | 186 ++++++++---------- src/routes/login/index.tsx | 2 +- src/routes/signup/index.tsx | 9 +- src/services/order.service.ts | 4 +- src/services/user.service.ts | 2 +- 11 files changed, 178 insertions(+), 116 deletions(-) create mode 100644 prisma/migrations/20250806155625_add_payment_id_to_order/migration.sql create mode 100644 public/.well-known/appspecific/com.chrome.devtools.json create mode 100644 src/hooks/use-culqui.ts diff --git a/.env.example b/.env.example index 576f44f..83edb6e 100644 --- a/.env.example +++ b/.env.example @@ -10,3 +10,7 @@ DB_PASSWORD=your_db_password # Admin Database (for database creation/deletion) ADMIN_DB_NAME=postgres + +# Culqui Keys +CULQI_PRIVATE_KEY="sk_test_xxx" +VITE_CULQI_PUBLIC_KEY="pk_test_xxx" \ No newline at end of file diff --git a/.env.test b/.env.test index 7b1edd0..3b81323 100644 --- a/.env.test +++ b/.env.test @@ -1,4 +1,8 @@ DATABASE_URL="postgresql://diego@localhost:5432/fullstock_test?schema=public" # Admin Database (for database creation/deletion) -ADMIN_DB_NAME=postgres \ No newline at end of file +ADMIN_DB_NAME=postgres + +# Culqui Keys +CULQI_PRIVATE_KEY="sk_test_xxx" +VITE_CULQI_PUBLIC_KEY="pk_test_xxx" \ No newline at end of file diff --git a/prisma/migrations/20250806155625_add_payment_id_to_order/migration.sql b/prisma/migrations/20250806155625_add_payment_id_to_order/migration.sql new file mode 100644 index 0000000..1b545cf --- /dev/null +++ b/prisma/migrations/20250806155625_add_payment_id_to_order/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "orders" ADD COLUMN "payment_id" TEXT; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 4314c02..e0f992b 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -103,6 +103,7 @@ model Order { userId Int @map("user_id") totalAmount Decimal @map("total_amount") @db.Decimal(10, 2) email String + paymentId String? @map("payment_id") firstName String @map("first_name") lastName String @map("last_name") company String? diff --git a/public/.well-known/appspecific/com.chrome.devtools.json b/public/.well-known/appspecific/com.chrome.devtools.json new file mode 100644 index 0000000..e69de29 diff --git a/src/hooks/use-culqui.ts b/src/hooks/use-culqui.ts new file mode 100644 index 0000000..d4445d0 --- /dev/null +++ b/src/hooks/use-culqui.ts @@ -0,0 +1,78 @@ +import { useEffect, useRef, useState } from "react"; + +export type CulqiChargeError = { + object: "error"; + type: string; + charge_id: string; + code: string; + decline_code: string | null; + merchant_message: string; + user_message: string; +}; + +export interface CulqiInstance { + open: () => void; + close: () => void; + token?: { id: string }; + error?: Error; + culqi?: () => void; +} + +export type CulqiConstructorType = new ( + publicKey: string, + config: object +) => CulqiInstance; + +declare global { + interface Window { + CulqiCheckout?: CulqiConstructorType; + } +} + +// Return type explicitly includes the constructor function +export function useCulqi() { + const [CulqiCheckout, setCulqiCheckout] = + useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const scriptRef = useRef(null); + + useEffect(() => { + if (window.CulqiCheckout) { + setCulqiCheckout(() => window.CulqiCheckout!); + return; + } + + setLoading(true); + const script = document.createElement("script"); + script.src = "https://js.culqi.com/checkout-js"; + script.async = true; + scriptRef.current = script; + + script.onload = () => { + if (window.CulqiCheckout) { + setCulqiCheckout(() => window.CulqiCheckout!); + } else { + setError( + new Error("Culqi script loaded but CulqiCheckout object not found") + ); + } + setLoading(false); + }; + + script.onerror = () => { + setError(new Error("Failed to load CulqiCheckout script")); + setLoading(false); + }; + + document.head.appendChild(script); + + return () => { + if (scriptRef.current) { + scriptRef.current.remove(); + } + }; + }, []); + + return { CulqiCheckout, loading, error }; +} diff --git a/src/routes/checkout/index.tsx b/src/routes/checkout/index.tsx index 063dd5f..1ceb7ae 100644 --- a/src/routes/checkout/index.tsx +++ b/src/routes/checkout/index.tsx @@ -1,6 +1,6 @@ import { zodResolver } from "@hookform/resolvers/zod"; import { X } from "lucide-react"; -import { useEffect, useRef, useState } from "react"; +import { useEffect, useState } from "react"; import { useForm } from "react-hook-form"; import { redirect, useNavigation, useSubmit } from "react-router"; import { z } from "zod"; @@ -13,6 +13,11 @@ import { Separator, SelectField, } from "@/components/ui"; +import { + useCulqi, + type CulqiChargeError, + type CulqiInstance, +} from "@/hooks/use-culqui"; import { calculateTotal, getCart } from "@/lib/cart"; import { type CartItem } from "@/models/cart.model"; import { getCurrentUser } from "@/services/auth.service"; @@ -22,20 +27,6 @@ import { commitSession, getSession } from "@/session.server"; import type { Route } from "./+types"; -interface CulqiInstance { - open: () => void; - close: () => void; - token?: { id: string }; - error?: Error; - culqi?: () => void; -} - -declare global { - interface Window { - CulqiCheckout: new (publicKey: string, config: object) => CulqiInstance; - } -} - const countryOptions = [ { value: "AR", label: "Argentina" }, { value: "BO", label: "Bolivia" }, @@ -85,8 +76,10 @@ export async function action({ request }: Route.ActionArgs) { ) as CartItem[]; const token = formData.get("token") as string; + const total = Math.round(calculateTotal(cartItems) * 100); + const body = { - amount: 2000, // TODO: Calculate total dynamically + amount: total, currency_code: "PEN", email: shippingDetails.email, source_id: token, @@ -97,18 +90,19 @@ export async function action({ request }: Route.ActionArgs) { method: "POST", headers: { "content-type": "application/json", - Authorization: `Bearer sk_test_EC8oOLd3ZiCTKqjN`, // TODO: Use environment variable + Authorization: `Bearer ${process.env.CULQI_PRIVATE_KEY}`, }, body: JSON.stringify(body), }); if (!response.ok) { - const errorData = await response.json(); + const errorData = (await response.json()) as CulqiChargeError; console.error("Error creating charge:", errorData); - // TODO: Handle error appropriately - throw new Error("Error processing payment"); + return { error: errorData.user_message || "Error processing payment" }; } + const chargeData = await response.json(); + const items = cartItems.map((item) => ({ productId: item.product.id, quantity: item.quantity, @@ -117,9 +111,13 @@ export async function action({ request }: Route.ActionArgs) { imgSrc: item.product.imgSrc, })); - // TODO - // @ts-expect-error Arreglar el tipo de shippingDetails - const { id: orderId } = await createOrder(items, shippingDetails); // TODO: Add payment information to the order + const { id: orderId } = await createOrder( + items, + // TODO + // @ts-expect-error Arreglar el tipo de shippingDetails + shippingDetails, + chargeData.id + ); await deleteRemoteCart(request); const session = await getSession(request.headers.get("Cookie")); @@ -151,14 +149,18 @@ export async function loader({ request }: Route.LoaderArgs) { return user ? { user, cart, total } : { cart, total }; } -export default function Checkout({ loaderData }: Route.ComponentProps) { +export default function Checkout({ + loaderData, + actionData, +}: Route.ComponentProps) { const { user, cart, total } = loaderData; const navigation = useNavigation(); const submit = useSubmit(); const loading = navigation.state === "submitting"; + const paymentError = actionData?.error; const [culqui, setCulqui] = useState(null); - const scriptRef = useRef(null); + const { CulqiCheckout } = useCulqi(); const { register, @@ -183,96 +185,56 @@ export default function Checkout({ loaderData }: Route.ComponentProps) { }); useEffect(() => { - // Function to load the Culqi script - const loadCulqiScript = (): Promise => { - return new Promise((resolve, reject) => { - if (window.CulqiCheckout) { - resolve(window.CulqiCheckout); - return; - } - - // Create script element - const script = document.createElement("script"); - script.src = "https://js.culqi.com/checkout-js"; - script.async = true; - - // Store reference for cleanup - scriptRef.current = script; - - script.onload = () => { - if (window.CulqiCheckout) { - resolve(window.CulqiCheckout); - } else { - reject( - new Error( - "Culqi script loaded but CulqiCheckout object not found" - ) - ); - } - }; - - script.onerror = () => { - reject(new Error("Failed to load CulqiCheckout script")); - }; - - document.head.appendChild(script); - }); + if (!CulqiCheckout) return; + + const config = { + settings: { + currency: "PEN", + amount: Math.round(total * 100), + }, + client: { + email: user?.email, + }, + options: { + paymentMethods: { + tarjeta: true, + yape: false, + }, + }, + appearance: {}, }; - loadCulqiScript() - .then((CulqiCheckout) => { - const config = { - settings: { - currency: "PEN", - amount: total * 100, - }, - client: { - email: user?.email, - }, - options: { - paymentMethods: { - tarjeta: true, - yape: false, - }, + const culqiInstance = new CulqiCheckout( + import.meta.env.VITE_CULQI_PUBLIC_KEY as string, + config + ); + + culqiInstance.culqi = () => { + if (culqiInstance.token) { + const token = culqiInstance.token.id; + culqiInstance.close(); + const formData = getValues(); + submit( + { + shippingDetailsJson: JSON.stringify(formData), + cartItemsJson: JSON.stringify(cart.items), + token, }, - appearance: {}, - }; - - const publicKey = "pk_test_Ws4NXfH95QXlZgaz"; - const culqiInstance = new CulqiCheckout(publicKey, config); - - const handleCulqiAction = () => { - if (culqiInstance.token) { - const token = culqiInstance.token.id; - culqiInstance.close(); - const formData = getValues(); - submit( - { - shippingDetailsJson: JSON.stringify(formData), - cartItemsJson: JSON.stringify(cart.items), - token, - }, - { method: "POST" } - ); - } else { - console.log("Error : ", culqiInstance.error); - } - }; - - culqiInstance.culqi = handleCulqiAction; + { method: "POST" } + ); + } else { + console.log("Error : ", culqiInstance.error); + } + }; - setCulqui(culqiInstance); - }) - .catch((error) => { - console.error("Error loading Culqi script:", error); - }); + setCulqui(culqiInstance); return () => { - if (scriptRef.current) { - scriptRef.current.remove(); + if (culqiInstance) { + culqiInstance.close(); } }; - }, [total, user, submit, getValues, cart.items]); + }, [total, user, submit, getValues, cart.items, CulqiCheckout]); async function onSubmit() { if (culqui) { @@ -397,12 +359,18 @@ export default function Checkout({ loaderData }: Route.ComponentProps) { /> - + {paymentError && ( +

{paymentError}

+ )} -
); diff --git a/src/routes/login/index.tsx b/src/routes/login/index.tsx index ebab02b..49cf1a5 100644 --- a/src/routes/login/index.tsx +++ b/src/routes/login/index.tsx @@ -32,7 +32,7 @@ export async function action({ request }: Route.ActionArgs) { try { // Proceso de login nuevo const user = await prisma.user.findUnique({ where: { email } }); - if (!user) { + if (!user || user.isGuest) { return { error: "Correo electrónico o contraseña inválidos" }; } diff --git a/src/routes/signup/index.tsx b/src/routes/signup/index.tsx index bd32067..716d1c4 100644 --- a/src/routes/signup/index.tsx +++ b/src/routes/signup/index.tsx @@ -45,7 +45,7 @@ export async function action({ request }: Route.ActionArgs) { const existingUser = await prisma.user.findUnique({ where: { email: email }, }); - if (existingUser) { + if (existingUser && !existingUser.isGuest) { return { error: "El correo electrónico ya existe" }; } @@ -58,8 +58,10 @@ export async function action({ request }: Route.ActionArgs) { name: null, }; - const user = await prisma.user.create({ - data: newUser, + const user = await prisma.user.upsert({ + where: { email: email }, + update: newUser, + create: newUser, }); session.set("userId", user.id); @@ -92,6 +94,7 @@ export async function action({ request }: Route.ActionArgs) { export async function loader({ request }: Route.LoaderArgs) { await redirectIfAuthenticated(request); + return null; } export default function Signup({ actionData }: Route.ComponentProps) { diff --git a/src/services/order.service.ts b/src/services/order.service.ts index f29f93d..6f5948a 100644 --- a/src/services/order.service.ts +++ b/src/services/order.service.ts @@ -8,7 +8,8 @@ import { getOrCreateUser } from "./user.service"; export async function createOrder( items: CartItemInput[], - formData: OrderDetails + formData: OrderDetails, + paymentId: string ): Promise { const shippingDetails = formData; const user = await getOrCreateUser(shippingDetails.email); @@ -31,6 +32,7 @@ export async function createOrder( imgSrc: item.imgSrc, })), }, + paymentId: paymentId, }, include: { items: true, diff --git a/src/services/user.service.ts b/src/services/user.service.ts index d389630..f125866 100644 --- a/src/services/user.service.ts +++ b/src/services/user.service.ts @@ -54,5 +54,5 @@ export async function verifyUniqueEmail(email: string): Promise { where: { email }, }); - return user ? false : true; + return user && !user.isGuest ? false : true; } From 9a1b22f923dd5f89be66da3953584774f8d22e6e Mon Sep 17 00:00:00 2001 From: Sebas Vallejo Date: Thu, 7 Aug 2025 18:19:06 -0500 Subject: [PATCH 5/8] Fix tests after Culqi integration --- src/routes/product/product.test.tsx | 4 ++-- src/routes/signup/index.tsx | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/routes/product/product.test.tsx b/src/routes/product/product.test.tsx index fe59644..f70059d 100644 --- a/src/routes/product/product.test.tsx +++ b/src/routes/product/product.test.tsx @@ -51,14 +51,14 @@ describe("Product Component", () => { expect(titleElement).toHaveTextContent("Awesome Product"); }); - it("should render product price with dollar sign", () => { + it("should render product price with correct currency", () => { // Step 1: Setup - Create test props const props = createTestProps({ price: 150.99 }); // Step 2: Mock - Component mocks already set up above // Step 3: Call - Render component render(); // Step 4: Verify - Check price is rendered correctly - expect(screen.queryByText("$150.99")).toBeInTheDocument(); + expect(screen.queryByText("S/150.99")).toBeInTheDocument(); }); it("should render product description", () => { diff --git a/src/routes/signup/index.tsx b/src/routes/signup/index.tsx index 716d1c4..46b29e1 100644 --- a/src/routes/signup/index.tsx +++ b/src/routes/signup/index.tsx @@ -94,7 +94,7 @@ export async function action({ request }: Route.ActionArgs) { export async function loader({ request }: Route.LoaderArgs) { await redirectIfAuthenticated(request); - return null; + return undefined; } export default function Signup({ actionData }: Route.ComponentProps) { From c99683e228dcad9645d506607bbadea73c858e56 Mon Sep 17 00:00:00 2001 From: Diego Torres Date: Thu, 7 Aug 2025 20:00:28 -0500 Subject: [PATCH 6/8] feat: update payment processing tests and utilities, add credit card mock data --- .env.test | 8 +++++- package.json | 1 + src/e2e/guest-create-order.spec.ts | 43 +++++++++++++++++++++++++++-- src/e2e/user-create-order.spec.ts | 44 ++++++++++++++++++++++++++++-- src/e2e/utils-tests-e2e.ts | 13 +++++++++ src/lib/utils.tests.ts | 2 ++ src/services/order.service.test.ts | 10 ++++--- 7 files changed, 111 insertions(+), 10 deletions(-) diff --git a/.env.test b/.env.test index 3b81323..e9aea72 100644 --- a/.env.test +++ b/.env.test @@ -5,4 +5,10 @@ ADMIN_DB_NAME=postgres # Culqui Keys CULQI_PRIVATE_KEY="sk_test_xxx" -VITE_CULQI_PUBLIC_KEY="pk_test_xxx" \ No newline at end of file +VITE_CULQI_PUBLIC_KEY="pk_test_xxx" + +# Cloud Storage base url +CS_BASE_URL="https://fullstock-images.s3.us-east-2.amazonaws.com" + +CULQI_PRIVATE_KEY="sk_test_EC8oOLd3ZiCTKqjN" +VITE_CULQI_PUBLIC_KEY="pk_test_Ws4NXfH95QXlZgaz" \ No newline at end of file diff --git a/package.json b/package.json index f56e61e..bff0d85 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "prisma:studio": "prisma studio", "prisma:seed": "prisma db seed", "test:prisma:migrate:deploy": "dotenv -e .env.test -- prisma migrate deploy", + "test:prisma:migrate:reset": "dotenv -e .env.test -- prisma migrate reset", "test:e2e": "playwright test", "test:prisma:seed": "dotenv -e .env.test prisma db seed" }, diff --git a/src/e2e/guest-create-order.spec.ts b/src/e2e/guest-create-order.spec.ts index b9d0516..ba00232 100644 --- a/src/e2e/guest-create-order.spec.ts +++ b/src/e2e/guest-create-order.spec.ts @@ -1,7 +1,12 @@ // import { createOrderFormData } from "@/lib/utils.tests"; import { expect, test } from "@playwright/test"; -import { baseUrl, cleanDatabase, createOrderFormData } from "./utils-tests-e2e"; +import { + baseUrl, + cleanDatabase, + createOrderFormData, + creditCards, +} from "./utils-tests-e2e"; export type OrderFormData = Record; @@ -33,9 +38,43 @@ test.describe("Guest", () => { await page.getByRole("button", { name: "Confirmar Orden" }).click(); + const checkoutFrame = page.locator('iframe[name="checkout_frame"]'); + await expect(checkoutFrame).toBeVisible(); + + const validCard = creditCards.valid; + + await checkoutFrame + .contentFrame() + .getByRole("textbox", { name: "#### #### #### ####" }) + .fill(validCard.number); + await expect( - page.getByText("¡Muchas gracias por tu compra!") + checkoutFrame.contentFrame().getByRole("img", { name: "Culqi icon" }) ).toBeVisible(); + + await checkoutFrame + .contentFrame() + .getByRole("textbox", { name: "MM/AA" }) + .fill(validCard.exp); + + await checkoutFrame + .contentFrame() + .getByRole("textbox", { name: "CVV" }) + .fill(validCard.cvv); + + await checkoutFrame + .contentFrame() + .getByRole("textbox", { name: "correo@electronico.com" }) + .fill(orderForm["Correo electrónico"]); + + await checkoutFrame + .contentFrame() + .getByRole("button", { name: "Pagar S/" }) + .click(); + + await expect(page.getByText("¡Muchas gracias por tu compra!")).toBeVisible({ + timeout: 10000, + }); await expect(page.getByTestId("orderId")).toBeVisible(); }); }); diff --git a/src/e2e/user-create-order.spec.ts b/src/e2e/user-create-order.spec.ts index 5af2716..63d47d1 100644 --- a/src/e2e/user-create-order.spec.ts +++ b/src/e2e/user-create-order.spec.ts @@ -4,7 +4,7 @@ import { prisma } from "@/db/prisma"; import { hashPassword } from "@/lib/security"; import type { CreateUserDTO } from "@/models/user.model"; -import { baseUrl, cleanDatabase } from "./utils-tests-e2e"; +import { baseUrl, cleanDatabase, creditCards } from "./utils-tests-e2e"; test.beforeEach(async () => { await cleanDatabase(); @@ -19,9 +19,13 @@ test.describe("User", () => { isGuest: false, }; - await prisma.user.create({ + console.log("Creating test user:", testUser); + + const user = await prisma.user.create({ data: testUser, }); + + console.log("Test user created:", user); }); test("User can create an order", async ({ page }) => { @@ -76,9 +80,43 @@ test.describe("User", () => { await page.getByRole("button", { name: "Confirmar Orden" }).click(); + const checkoutFrame = page.locator('iframe[name="checkout_frame"]'); + await expect(checkoutFrame).toBeVisible(); + + const validCard = creditCards.valid; + + await checkoutFrame + .contentFrame() + .getByRole("textbox", { name: "#### #### #### ####" }) + .fill(validCard.number); + await expect( - page.getByText("¡Muchas gracias por tu compra!") + checkoutFrame.contentFrame().getByRole("img", { name: "Culqi icon" }) ).toBeVisible(); + + await checkoutFrame + .contentFrame() + .getByRole("textbox", { name: "MM/AA" }) + .fill(validCard.exp); + + await checkoutFrame + .contentFrame() + .getByRole("textbox", { name: "CVV" }) + .fill(validCard.cvv); + + await checkoutFrame + .contentFrame() + .getByRole("textbox", { name: "correo@electronico.com" }) + .fill(loginForm["Correo electrónico"]); + + await checkoutFrame + .contentFrame() + .getByRole("button", { name: "Pagar S/" }) + .click(); + + await expect(page.getByText("¡Muchas gracias por tu compra!")).toBeVisible({ + timeout: 10000, + }); await expect(page.getByTestId("orderId")).toBeVisible(); }); }); diff --git a/src/e2e/utils-tests-e2e.ts b/src/e2e/utils-tests-e2e.ts index 1d3a40f..809fdf2 100644 --- a/src/e2e/utils-tests-e2e.ts +++ b/src/e2e/utils-tests-e2e.ts @@ -19,6 +19,19 @@ export const createOrderFormData = ( ...overrides, }); +export const creditCards = { + valid: { + number: "4111 1111 1111 1111", + exp: "12/30", + cvv: "123", + }, + declined: { + number: "4000 0200 0000 0000", + exp: "12/30", + cvv: "354", + }, +}; + export async function cleanDatabase() { await prisma.order.deleteMany(); await prisma.cart.deleteMany(); diff --git a/src/lib/utils.tests.ts b/src/lib/utils.tests.ts index cb6ce33..1526f23 100644 --- a/src/lib/utils.tests.ts +++ b/src/lib/utils.tests.ts @@ -158,6 +158,7 @@ export const createTestOrder = (overrides: Partial = {}): Order => { createdAt: new Date(), updatedAt: new Date(), ...details, // Expande todos los campos de contacto sin undefined + paymentId: `payment-id-${Math.random()}`, ...overrides, } satisfies Order; }; @@ -181,6 +182,7 @@ export const createTestDBOrder = ( phone: "123456789", createdAt: new Date(), updatedAt: new Date(), + paymentId: `payment-id-${Math.random()}`, ...overrides, } satisfies PrismaOrder; }; diff --git a/src/services/order.service.test.ts b/src/services/order.service.test.ts index 22f80e6..f5ffa51 100644 --- a/src/services/order.service.test.ts +++ b/src/services/order.service.test.ts @@ -64,7 +64,7 @@ describe("Order Service", () => { vi.mocked(mockPrisma.order.create).mockResolvedValue(prismaOrder); - const order = await createOrder(mockedItems, mockedFormData); + const order = await createOrder(mockedItems, mockedFormData, "payment-id"); expect(mockPrisma.order.create).toHaveBeenCalledWith({ data: { userId: mockedUser.id, @@ -88,6 +88,7 @@ describe("Order Service", () => { imgSrc: item.imgSrc, })), }, + paymentId: "payment-id", }, include: { items: true, @@ -118,6 +119,7 @@ describe("Order Service", () => { zip: prismaOrder.zip, phone: prismaOrder.phone, }, + paymentId: prismaOrder.paymentId, }); }); @@ -187,9 +189,9 @@ describe("Order Service", () => { new Error("Database error") ); - await expect(createOrder(mockedItems, mockedFormData)).rejects.toThrow( - "Failed to create order" - ); + await expect( + createOrder(mockedItems, mockedFormData, "payment-id") + ).rejects.toThrow("Failed to create order"); expect(mockPrisma.order.create).toHaveBeenCalled(); }); From d36de7692c42446c15f76c5c73b20136d3106eec Mon Sep 17 00:00:00 2001 From: Diego Torres Date: Thu, 7 Aug 2025 20:10:33 -0500 Subject: [PATCH 7/8] fix: add timeout to checkout frame visibility assertion in order tests --- src/e2e/guest-create-order.spec.ts | 2 +- src/e2e/user-create-order.spec.ts | 8 ++------ 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/src/e2e/guest-create-order.spec.ts b/src/e2e/guest-create-order.spec.ts index ba00232..236e675 100644 --- a/src/e2e/guest-create-order.spec.ts +++ b/src/e2e/guest-create-order.spec.ts @@ -39,7 +39,7 @@ test.describe("Guest", () => { await page.getByRole("button", { name: "Confirmar Orden" }).click(); const checkoutFrame = page.locator('iframe[name="checkout_frame"]'); - await expect(checkoutFrame).toBeVisible(); + await expect(checkoutFrame).toBeVisible({ timeout: 10000 }); const validCard = creditCards.valid; diff --git a/src/e2e/user-create-order.spec.ts b/src/e2e/user-create-order.spec.ts index 63d47d1..9dc34a9 100644 --- a/src/e2e/user-create-order.spec.ts +++ b/src/e2e/user-create-order.spec.ts @@ -19,13 +19,9 @@ test.describe("User", () => { isGuest: false, }; - console.log("Creating test user:", testUser); - - const user = await prisma.user.create({ + await prisma.user.create({ data: testUser, }); - - console.log("Test user created:", user); }); test("User can create an order", async ({ page }) => { @@ -81,7 +77,7 @@ test.describe("User", () => { await page.getByRole("button", { name: "Confirmar Orden" }).click(); const checkoutFrame = page.locator('iframe[name="checkout_frame"]'); - await expect(checkoutFrame).toBeVisible(); + await expect(checkoutFrame).toBeVisible({ timeout: 10000 }); const validCard = creditCards.valid; From da5c18ccef4b744f99359ad4e971b6479fc96f98 Mon Sep 17 00:00:00 2001 From: Diego Torres Date: Thu, 7 Aug 2025 20:54:21 -0500 Subject: [PATCH 8/8] chore: add environment variables to e2e-test workflow --- .env.test | 4 ---- .github/workflows/tests.yaml | 5 +++++ 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/.env.test b/.env.test index e9aea72..52927a1 100644 --- a/.env.test +++ b/.env.test @@ -3,10 +3,6 @@ DATABASE_URL="postgresql://diego@localhost:5432/fullstock_test?schema=public" # Admin Database (for database creation/deletion) ADMIN_DB_NAME=postgres -# Culqui Keys -CULQI_PRIVATE_KEY="sk_test_xxx" -VITE_CULQI_PUBLIC_KEY="pk_test_xxx" - # Cloud Storage base url CS_BASE_URL="https://fullstock-images.s3.us-east-2.amazonaws.com" diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index b02b8e3..99febd4 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -61,6 +61,11 @@ jobs: e2e-test: runs-on: ubuntu-latest needs: [test] + env: + CS_BASE_URL: "https://fullstock-images.s3.us-east-2.amazonaws.com" + CULQI_PRIVATE_KEY: "sk_test_EC8oOLd3ZiCTKqjN" + VITE_CULQI_PUBLIC_KEY: "pk_test_Ws4NXfH95QXlZgaz" + DATABASE_URL: "postgresql://diego@localhost:5432/fullstock_test?schema=public" services: postgres: image: postgres:15