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:
@@ -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,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(),
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user