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..52927a1 100644 --- a/.env.test +++ b/.env.test @@ -1,4 +1,10 @@ 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 + +# 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/.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 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/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/e2e/guest-create-order.spec.ts b/src/e2e/guest-create-order.spec.ts index b9d0516..236e675 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({ timeout: 10000 }); + + 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..9dc34a9 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(); @@ -76,9 +76,43 @@ test.describe("User", () => { await page.getByRole("button", { name: "Confirmar Orden" }).click(); + const checkoutFrame = page.locator('iframe[name="checkout_frame"]'); + await expect(checkoutFrame).toBeVisible({ timeout: 10000 }); + + 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/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/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/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)}

@@ -265,9 +359,16 @@ 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/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}

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 bd32067..46b29e1 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 undefined; } export default function Signup({ actionData }: Route.ComponentProps) { 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(); }); 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; }