From abffde336e744e15cf69626c10e436d6172bc406 Mon Sep 17 00:00:00 2001 From: shafi-VM Date: Fri, 4 Sep 2026 15:40:06 +0530 Subject: [PATCH] fix(paypal): don't scale zero-decimal currency amounts on order creation (#29984) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PayPal order creation divided every amount by 100 before sending it, regardless of currency. JPY has no minor unit, so `convertToSmallestCurrencyUnit` stores it unscaled and dividing corrupted it: a JPY 10000 event asked PayPal to collect 100. Use the same zero-decimal-aware conversion that the price input and `formatPrice` already use, so the amount charged matches the amount stored and displayed. Currencies with minor units are unaffected — among PayPal's supported currencies only JPY is both zero-decimal and supported, and the added tests pin USD, EUR, HUF and TWD to their existing behaviour. --- packages/app-store/paypal/lib/Paypal.test.ts | 69 ++++++++++++++++++++ packages/app-store/paypal/lib/Paypal.ts | 3 +- 2 files changed, 71 insertions(+), 1 deletion(-) create mode 100644 packages/app-store/paypal/lib/Paypal.test.ts diff --git a/packages/app-store/paypal/lib/Paypal.test.ts b/packages/app-store/paypal/lib/Paypal.test.ts new file mode 100644 index 0000000000..3df2b82d61 --- /dev/null +++ b/packages/app-store/paypal/lib/Paypal.test.ts @@ -0,0 +1,69 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import "vitest-fetch-mock"; + +import Paypal from "./Paypal"; + +vi.mock("@calcom/prisma", () => ({ + default: {}, + prisma: {}, +})); + +const ORDER_ENDPOINT = "/v2/checkout/orders"; + +const createOrderWith = async ({ + amount, + currency, +}: { + amount: number; + currency: string; +}): Promise<{ currency_code: string; value: string }> => { + fetchMock.resetMocks(); + // First fetch is the OAuth token request, second is the order creation. + fetchMock.mockResponseOnce(JSON.stringify({ access_token: "test-token", expires_in: 3600 })); + fetchMock.mockResponseOnce(JSON.stringify({ id: "TEST_ORDER_ID" })); + + const paypal = new Paypal({ clientId: "test-client-id", secretKey: "test-secret-key" }); + + await paypal.createOrder({ + referenceId: "test-reference-id", + amount, + currency, + returnUrl: "https://example.com/return", + cancelUrl: "https://example.com/cancel", + }); + + const orderCall = fetchMock.mock.calls.find(([url]) => String(url).includes(ORDER_ENDPOINT)); + if (!orderCall) throw new Error("Order creation request was never made"); + + return JSON.parse(String(orderCall[1]?.body)).purchase_units[0].amount; +}; + +describe("Paypal.createOrder", () => { + beforeEach(() => { + vi.resetAllMocks(); + }); + + // JPY is stored unscaled by `convertToSmallestCurrencyUnit`, so the stored amount is + // already the amount to charge. Scaling it down bills the attendee 1% of the price. + it("sends JPY amounts unscaled", async () => { + expect(await createOrderWith({ amount: 1000, currency: "JPY" })).toEqual({ + currency_code: "JPY", + value: "1000", + }); + }); + + // HUF and TWD also reject decimals at PayPal, but `convertToSmallestCurrencyUnit` stores + // them scaled, so they must keep going through the minor-unit conversion. + // https://developer.paypal.com/api/rest/reference/currency-codes/ + it.each([ + { currency: "USD", amount: 1000, expected: "10" }, + { currency: "EUR", amount: 1050, expected: "10.5" }, + { currency: "HUF", amount: 5000, expected: "50" }, + { currency: "TWD", amount: 300, expected: "3" }, + ])("converts $currency amounts from minor units", async ({ currency, amount, expected }) => { + expect(await createOrderWith({ amount, currency })).toEqual({ + currency_code: currency, + value: expected, + }); + }); +}); diff --git a/packages/app-store/paypal/lib/Paypal.ts b/packages/app-store/paypal/lib/Paypal.ts index e59b41d7dc..15d635db35 100644 --- a/packages/app-store/paypal/lib/Paypal.ts +++ b/packages/app-store/paypal/lib/Paypal.ts @@ -2,6 +2,7 @@ import { v4 as uuidv4 } from "uuid"; import z from "zod"; import { IS_PRODUCTION, WEBAPP_URL } from "@calcom/lib/constants"; +import { convertFromSmallestToPresentableCurrencyUnit } from "@calcom/lib/currencyConversions"; import logger from "@calcom/lib/logger"; import prisma from "@calcom/prisma"; import type { Prisma } from "@calcom/prisma/client"; @@ -85,7 +86,7 @@ class Paypal { reference_id: referenceId, amount: { currency_code: currency, - value: (amount / 100).toString(), + value: convertFromSmallestToPresentableCurrencyUnit(amount, currency).toString(), }, }, ],