fix(paypal): don't scale zero-decimal currency amounts on order creation (#29984)

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.
This commit is contained in:
shafi-VM
2026-09-04 15:40:06 +05:30
committed by GitHub
parent b879a54250
commit abffde336e
2 changed files with 71 additions and 1 deletions
@@ -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,
});
});
});
+2 -1
View File
@@ -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(),
},
},
],