diff --git a/apps/api/v1/lib/validations/payment.ts b/apps/api/v1/lib/validations/payment.ts index b8d544730a565b..4a942cd86ca93f 100644 --- a/apps/api/v1/lib/validations/payment.ts +++ b/apps/api/v1/lib/validations/payment.ts @@ -2,6 +2,7 @@ import { PaymentSchema } from "@calcom/prisma/zod/modelSchema/PaymentSchema"; export const schemaPaymentPublic = PaymentSchema.pick({ id: true, + uid: true, amount: true, success: true, refunded: true, diff --git a/apps/api/v1/pages/api/docs.ts b/apps/api/v1/pages/api/docs.ts index e0f33493d6cc1b..8aa16a745e63ca 100644 --- a/apps/api/v1/pages/api/docs.ts +++ b/apps/api/v1/pages/api/docs.ts @@ -33,6 +33,73 @@ const swaggerHandler = withSwagger({ $ref: "#/components/schemas/Recording", }, }, + Payment: { + type: "object", + properties: { + id: { + type: "number", + example: 1, + description: "Payment ID", + }, + uid: { + type: "string", + example: "payment_abc123", + description: "Payment UID used for abandoned-cart recovery", + }, + amount: { + type: "number", + example: 5000, + description: "Payment amount in cents", + }, + success: { + type: "boolean", + example: true, + description: "Whether the payment was successful", + }, + refunded: { + type: "boolean", + example: false, + description: "Whether the payment was refunded", + }, + fee: { + type: "number", + example: 150, + description: "Payment processing fee in cents", + }, + paymentOption: { + type: "string", + example: "ON_BOOKING", + description: "Payment option type", + }, + currency: { + type: "string", + example: "USD", + description: "Payment currency", + }, + bookingId: { + type: "number", + example: 123, + description: "Associated booking ID", + }, + }, + required: [ + "id", + "uid", + "amount", + "success", + "refunded", + "fee", + "paymentOption", + "currency", + "bookingId", + ], + }, + ArrayOfPayments: { + type: "array", + items: { + $ref: "#/components/schemas/Payment", + }, + }, Recording: { properties: { id: { @@ -135,23 +202,7 @@ const swaggerHandler = withSwagger({ }, }, payment: { - type: Array, - items: { - properties: { - id: { - type: "number", - example: 1, - }, - success: { - type: "boolean", - example: true, - }, - paymentOption: { - type: "string", - example: "ON_BOOKING", - }, - }, - }, + $ref: "#/components/schemas/ArrayOfPayments", }, }, }, diff --git a/apps/api/v1/pages/api/payments/[id].ts b/apps/api/v1/pages/api/payments/[id].ts index 35ab674c07d8a7..08dbb7f82caeb3 100644 --- a/apps/api/v1/pages/api/payments/[id].ts +++ b/apps/api/v1/pages/api/payments/[id].ts @@ -33,8 +33,15 @@ import { * responses: * 200: * description: OK + * content: + * application/json: + * schema: + * type: object + * properties: + * payment: + * $ref: "#/components/schemas/Payment" * 401: - * description: Authorization information is missing or invalid. + * description: Authorization information is missing or invalid. * 404: * description: Payment was not found */ @@ -46,25 +53,23 @@ export async function paymentById( if (safeQuery.success && method === "GET") { const userWithBookings = await prisma.user.findUnique({ where: { id: userId }, - // eslint-disable-next-line @calcom/eslint/no-prisma-include-true - include: { bookings: true }, + select: { bookings: true }, }); - await prisma.payment - .findUnique({ where: { id: safeQuery.data.id } }) - .then((data) => schemaPaymentPublic.parse(data)) - .then((payment) => { - if (!userWithBookings?.bookings.map((b) => b.id).includes(payment.bookingId)) { - res.status(401).json({ message: "Unauthorized" }); - } else { - res.status(200).json({ payment }); - } - }) - .catch((error: Error) => - res.status(404).json({ - message: `Payment with id: ${safeQuery.data.id} not found`, - error, - }) - ); + const data = await prisma.payment.findUnique({ where: { id: safeQuery.data.id } }); + + if (!data) { + return res.status(404).json({ + message: `Payment with id: ${safeQuery.data.id} not found`, + }); + } + + const payment = schemaPaymentPublic.parse(data); + + if (!userWithBookings || !userWithBookings?.bookings.map((b) => b.id).includes(payment.bookingId)) { + return res.status(401).json({ message: "Unauthorized" }); + } + + res.status(200).json({ payment }); } } export default withMiddleware("HTTP_GET")(withValidQueryIdTransformParseInt(paymentById)); diff --git a/apps/api/v1/pages/api/payments/index.ts b/apps/api/v1/pages/api/payments/index.ts index d556245753e058..34f980ffa41c0c 100644 --- a/apps/api/v1/pages/api/payments/index.ts +++ b/apps/api/v1/pages/api/payments/index.ts @@ -13,32 +13,43 @@ import { schemaPaymentPublic } from "~/lib/validations/payment"; * summary: Find all payments * tags: * - payments + * parameters: + * - in: query + * name: apiKey + * required: true + * schema: + * type: string + * description: Your API key * responses: * 200: * description: OK + * content: + * application/json: + * schema: + * type: object + * properties: + * payments: + * $ref: "#/components/schemas/ArrayOfPayments" * 401: - * description: Authorization information is missing or invalid. + * description: Authorization information is missing or invalid. * 404: - * description: No payments were found + * description: User not found + * */ async function allPayments({ userId }: NextApiRequest, res: NextApiResponse) { const userWithBookings = await prisma.user.findUnique({ where: { id: userId }, - include: { bookings: true }, + select: { bookings: true }, }); - if (!userWithBookings) throw new Error("No user found"); + if (!userWithBookings) { + return res.status(404).json({ message: "User not found" }); + } const bookings = userWithBookings.bookings; const bookingIds = bookings.map((booking) => booking.id); const data = await prisma.payment.findMany({ where: { bookingId: { in: bookingIds } } }); const payments = data.map((payment) => schemaPaymentPublic.parse(payment)); - if (payments) res.status(200).json({ payments }); - else - (error: Error) => - res.status(404).json({ - message: "No Payments were found", - error, - }); + res.status(200).json({ payments }); } // NO POST FOR PAYMENTS FOR NOW export default withMiddleware("HTTP_GET")(allPayments); diff --git a/apps/api/v1/test/lib/payments/[id]/_get.test.ts b/apps/api/v1/test/lib/payments/[id]/_get.test.ts new file mode 100644 index 00000000000000..c2d8b87732e95a --- /dev/null +++ b/apps/api/v1/test/lib/payments/[id]/_get.test.ts @@ -0,0 +1,248 @@ +import type { NextApiRequest, NextApiResponse } from "next"; +import { createMocks } from "node-mocks-http"; +import { describe, expect, it, beforeEach, afterEach, vi } from "vitest"; + +import prisma from "@calcom/prisma"; + +import { schemaQueryIdParseInt } from "~/lib/validations/shared/queryIdTransformParseInt"; + +import paymentById from "../../../../pages/api/payments/[id]"; + +// Mock the withMiddleware function +vi.mock("~/lib/helpers/withMiddleware", () => ({ + withMiddleware: (_method: string) => (handler: unknown) => handler, +})); + +// Mock the query validation +vi.mock("~/lib/validations/shared/queryIdTransformParseInt", () => ({ + schemaQueryIdParseInt: { + safeParse: vi.fn(), + }, + withValidQueryIdTransformParseInt: (handler: unknown) => handler, +})); + +// Mock prisma +vi.mock("@calcom/prisma", () => ({ + default: { + user: { + findUnique: vi.fn(), + }, + payment: { + findUnique: vi.fn(), + }, + }, +})); + +const mockPrisma = prisma as unknown as { + user: { findUnique: ReturnType }; + payment: { findUnique: ReturnType }; +}; +const mockSchemaQueryIdParseInt = schemaQueryIdParseInt as unknown as { + safeParse: ReturnType; +}; + +describe("GET /api/payments/[id]", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.resetAllMocks(); + }); + + it("should return 200 with payment object when payment exists", async () => { + const mockUser = { + id: 1, + bookings: [{ id: 1 }], + }; + + const mockPayment = { + id: 1, + uid: "payment_123", + amount: 5000, + success: true, + refunded: false, + fee: 150, + paymentOption: "ON_BOOKING", + currency: "USD", + bookingId: 1, + }; + + mockSchemaQueryIdParseInt.safeParse.mockReturnValue({ + success: true, + data: { id: 1 }, + }); + + mockPrisma.user.findUnique.mockResolvedValue(mockUser); + mockPrisma.payment.findUnique.mockResolvedValue(mockPayment); + + const { req, res } = createMocks({ + method: "GET", + query: { apiKey: "test_key", id: "1" }, + userId: 1, + }); + + await paymentById(req as unknown as NextApiRequest, res as unknown as NextApiResponse); + + expect(res._getStatusCode()).toBe(200); + const responseData = JSON.parse(res._getData()); + expect(responseData).toEqual({ + payment: { + id: 1, + uid: "payment_123", + amount: 5000, + success: true, + refunded: false, + fee: 150, + paymentOption: "ON_BOOKING", + currency: "USD", + bookingId: 1, + }, + }); + }); + + it("should return 404 when payment not found", async () => { + const mockUser = { + id: 1, + bookings: [{ id: 1 }], + }; + + mockSchemaQueryIdParseInt.safeParse.mockReturnValue({ + success: true, + data: { id: 999 }, + }); + + mockPrisma.user.findUnique.mockResolvedValue(mockUser); + mockPrisma.payment.findUnique.mockResolvedValue(null); + + const { req, res } = createMocks({ + method: "GET", + query: { apiKey: "test_key", id: "999" }, + userId: 1, + }); + + await paymentById(req as unknown as NextApiRequest, res as unknown as NextApiResponse); + + expect(res._getStatusCode()).toBe(404); + const responseData = JSON.parse(res._getData()); + expect(responseData).toEqual({ + message: "Payment with id: 999 not found", + }); + }); + + it("should return 401 when user not authorized for payment", async () => { + const mockUser = { + id: 1, + bookings: [{ id: 2 }], // Different booking ID + }; + + const mockPayment = { + id: 1, + uid: "payment_123", + amount: 5000, + success: true, + refunded: false, + fee: 150, + paymentOption: "ON_BOOKING", + currency: "USD", + bookingId: 1, // Different from user's bookings + }; + + mockSchemaQueryIdParseInt.safeParse.mockReturnValue({ + success: true, + data: { id: 1 }, + }); + + mockPrisma.user.findUnique.mockResolvedValue(mockUser); + mockPrisma.payment.findUnique.mockResolvedValue(mockPayment); + + const { req, res } = createMocks({ + method: "GET", + query: { apiKey: "test_key", id: "1" }, + userId: 1, + }); + + await paymentById(req as unknown as NextApiRequest, res as unknown as NextApiResponse); + + expect(res._getStatusCode()).toBe(401); + const responseData = JSON.parse(res._getData()); + expect(responseData).toEqual({ + message: "Unauthorized", + }); + }); + + it("should not process request when invalid query parameters", async () => { + mockSchemaQueryIdParseInt.safeParse.mockReturnValue({ + success: false, + }); + + const { req, res } = createMocks({ + method: "GET", + query: { apiKey: "test_key", id: "invalid" }, + userId: 1, + }); + + await paymentById(req as unknown as NextApiRequest, res as unknown as NextApiResponse); + + // When query parsing fails, the function returns early without setting status + expect(res._getStatusCode()).toBe(200); + }); + + it("should include paymentUid (uid field) in response", async () => { + const mockUser = { + id: 1, + bookings: [{ id: 1 }], + }; + + const mockPayment = { + id: 1, + uid: "payment_abc123", + amount: 5000, + success: true, + refunded: false, + fee: 150, + paymentOption: "ON_BOOKING", + currency: "USD", + bookingId: 1, + }; + + mockSchemaQueryIdParseInt.safeParse.mockReturnValue({ + success: true, + data: { id: 1 }, + }); + + mockPrisma.user.findUnique.mockResolvedValue(mockUser); + mockPrisma.payment.findUnique.mockResolvedValue(mockPayment); + + const { req, res } = createMocks({ + method: "GET", + query: { apiKey: "test_key", id: "1" }, + userId: 1, + }); + + await paymentById(req as unknown as NextApiRequest, res as unknown as NextApiResponse); + + expect(res._getStatusCode()).toBe(200); + const responseData = JSON.parse(res._getData()); + expect(responseData.payment).toHaveProperty("uid"); + expect(responseData.payment.uid).toBe("payment_abc123"); + }); + + it("should not process request for non-GET methods", async () => { + mockSchemaQueryIdParseInt.safeParse.mockReturnValue({ + success: true, + data: { id: 1 }, + }); + + const { req, res } = createMocks({ + method: "POST", + query: { apiKey: "test_key", id: "1" }, + userId: 1, + }); + + await paymentById(req as unknown as NextApiRequest, res as unknown as NextApiResponse); + + // When method is not GET, the function returns early without setting status + expect(res._getStatusCode()).toBe(200); + }); +}); diff --git a/apps/api/v1/test/lib/payments/_get.test.ts b/apps/api/v1/test/lib/payments/_get.test.ts new file mode 100644 index 00000000000000..d31170796fa7aa --- /dev/null +++ b/apps/api/v1/test/lib/payments/_get.test.ts @@ -0,0 +1,190 @@ +import type { NextApiRequest, NextApiResponse } from "next"; +import { createMocks } from "node-mocks-http"; +import { describe, expect, it, beforeEach, afterEach, vi } from "vitest"; + +import prisma from "@calcom/prisma"; + +import allPayments from "../../../pages/api/payments/index"; + +// Mock the withMiddleware function +vi.mock("~/lib/helpers/withMiddleware", () => ({ + withMiddleware: (_method: string) => (handler: unknown) => handler, +})); + +// Mock prisma +vi.mock("@calcom/prisma", () => ({ + default: { + user: { + findUnique: vi.fn(), + }, + payment: { + findMany: vi.fn(), + }, + }, +})); + +const mockPrisma = prisma as unknown as { + user: { findUnique: ReturnType }; + payment: { findMany: ReturnType }; +}; + +describe("GET /api/payments", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.resetAllMocks(); + }); + + it("should return 200 with payments array when payments exist", async () => { + const mockUser = { + id: 1, + bookings: [{ id: 1 }, { id: 2 }], + }; + + const mockPayments = [ + { + id: 1, + uid: "payment_123", + amount: 5000, + success: true, + refunded: false, + fee: 150, + paymentOption: "ON_BOOKING", + currency: "USD", + bookingId: 1, + }, + { + id: 2, + uid: "payment_456", + amount: 3000, + success: true, + refunded: false, + fee: 90, + paymentOption: "ON_BOOKING", + currency: "USD", + bookingId: 2, + }, + ]; + + mockPrisma.user.findUnique.mockResolvedValue(mockUser); + mockPrisma.payment.findMany.mockResolvedValue(mockPayments); + + const { req, res } = createMocks({ + method: "GET", + query: { apiKey: "test_key" }, + userId: 1, + }); + + await allPayments(req as unknown as NextApiRequest, res as unknown as NextApiResponse); + + expect(res._getStatusCode()).toBe(200); + const responseData = JSON.parse(res._getData()); + expect(responseData).toEqual({ + payments: [ + { + id: 1, + uid: "payment_123", + amount: 5000, + success: true, + refunded: false, + fee: 150, + paymentOption: "ON_BOOKING", + currency: "USD", + bookingId: 1, + }, + { + id: 2, + uid: "payment_456", + amount: 3000, + success: true, + refunded: false, + fee: 90, + paymentOption: "ON_BOOKING", + currency: "USD", + bookingId: 2, + }, + ], + }); + }); + + it("should return 200 with empty array when no payments found", async () => { + const mockUser = { + id: 1, + bookings: [{ id: 1 }], + }; + + mockPrisma.user.findUnique.mockResolvedValue(mockUser); + mockPrisma.payment.findMany.mockResolvedValue([]); + + const { req, res } = createMocks({ + method: "GET", + query: { apiKey: "test_key" }, + userId: 1, + }); + + await allPayments(req as unknown as NextApiRequest, res as unknown as NextApiResponse); + + expect(res._getStatusCode()).toBe(200); + const responseData = JSON.parse(res._getData()); + expect(responseData).toEqual({ + payments: [], + }); + }); + + it("should return 404 when user not found", async () => { + mockPrisma.user.findUnique.mockResolvedValue(null); + + const { req, res } = createMocks({ + method: "GET", + query: { apiKey: "test_key" }, + userId: 1, + }); + + await allPayments(req as unknown as NextApiRequest, res as unknown as NextApiResponse); + + expect(res._getStatusCode()).toBe(404); + const responseData = JSON.parse(res._getData()); + expect(responseData).toEqual({ + message: "User not found", + }); + }); + + it("should include paymentUid (uid field) in response", async () => { + const mockUser = { + id: 1, + bookings: [{ id: 1 }], + }; + + const mockPayments = [ + { + id: 1, + uid: "payment_abc123", + amount: 5000, + success: true, + refunded: false, + fee: 150, + paymentOption: "ON_BOOKING", + currency: "USD", + bookingId: 1, + }, + ]; + + mockPrisma.user.findUnique.mockResolvedValue(mockUser); + mockPrisma.payment.findMany.mockResolvedValue(mockPayments); + + const { req, res } = createMocks({ + method: "GET", + query: { apiKey: "test_key" }, + userId: 1, + }); + + await allPayments(req as unknown as NextApiRequest, res as unknown as NextApiResponse); + + expect(res._getStatusCode()).toBe(200); + const responseData = JSON.parse(res._getData()); + expect(responseData.payments[0]).toHaveProperty("uid"); + expect(responseData.payments[0].uid).toBe("payment_abc123"); + }); +}); diff --git a/packages/embeds/embed-core/src/embed-iframe.ts b/packages/embeds/embed-core/src/embed-iframe.ts index 9f0a2ff72950dc..f7be73daa08a59 100644 --- a/packages/embeds/embed-core/src/embed-iframe.ts +++ b/packages/embeds/embed-core/src/embed-iframe.ts @@ -279,6 +279,11 @@ export const useEmbedType = () => { }; function makeBodyVisible() { + // Check if we're in a browser environment before accessing document + if (typeof document === "undefined") { + return; + } + if (document.body.style.visibility !== "visible") { document.body.style.visibility = "visible"; } diff --git a/packages/embeds/embed-core/src/embed-iframe/lib/utils.ts b/packages/embeds/embed-core/src/embed-iframe/lib/utils.ts index c1dfa155909831..6aeec9ca95ba9a 100644 --- a/packages/embeds/embed-core/src/embed-iframe/lib/utils.ts +++ b/packages/embeds/embed-core/src/embed-iframe/lib/utils.ts @@ -12,6 +12,11 @@ export function isBookerReady() { } function isSkeletonSupportedPageType() { + // Check if we're in a browser environment before accessing document + if (typeof document === "undefined") { + return false; + } + const url = new URL(document.URL); const pageType = url.searchParams.get("cal.embed.pageType"); // Any non-empty pageType is skeleton supported because generateSkeleton() @@ -44,6 +49,11 @@ export function isLinkReady({ embedStore }: { embedStore: typeof import("./embed * Moves the queuedFormResponse to the routingFormResponse record to mark it as an actual response now. */ export const recordResponseIfQueued = async (params: Record) => { + // Check if we're in a browser environment before accessing document + if (typeof document === "undefined") { + return null; + } + const url = new URL(document.URL); let routingFormResponseId: number | null = null; const queuedFormResponseIdParam = url.searchParams.get("cal.queuedFormResponseId");