diff --git a/packages/app-store/hitpay/lib/PaymentService.test.ts b/packages/app-store/hitpay/lib/PaymentService.test.ts new file mode 100644 index 0000000000..49911f8c94 --- /dev/null +++ b/packages/app-store/hitpay/lib/PaymentService.test.ts @@ -0,0 +1,98 @@ +import axios from "axios"; +import qs from "qs"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { BuildPaymentService } from "./PaymentService"; + +vi.mock("axios"); + +const paymentCreate = vi.fn<(args: { data: { amount: number } }) => void>(); + +vi.mock("@calcom/prisma", () => ({ + default: { + booking: { + findUnique: vi.fn(async () => ({ + uid: "booking-uid", + title: "Paid event", + startTime: new Date("2026-04-01T10:00:00.000Z"), + endTime: new Date("2026-04-01T10:30:00.000Z"), + eventTypeId: 1, + eventType: { slug: "paid-event", seatsPerTimeSlot: null }, + attendees: [], + })), + findMany: vi.fn(async () => []), + }, + payment: { + create: (args: { data: { amount: number } }) => { + paymentCreate(args); + return { id: 1, ...args.data }; + }, + }, + }, +})); + +const credentials = { + key: { isSandbox: true, sandbox: { apiKey: "test-api-key", saltKey: "test-salt-key" } }, +}; + +/** + * HitPay's payment-requests API takes `amount` as a decimal string in major units and echoes it + * back the same way, so both directions have to respect zero-decimal currencies. Those are stored + * unscaled by `convertToSmallestCurrencyUnit`, which is what the price input writes. + * https://docs.hitpayapp.com/apis/payment-request/create-request + */ +const createPaymentWith = async ({ + amount, + currency, +}: { + amount: number; + currency: string; +}): Promise<{ sent: string; stored: number }> => { + // Echo back whatever amount was actually requested, the way HitPay would. + vi.mocked(axios.post).mockImplementation(async (_url, body) => ({ + data: { + id: "hitpay-request-id", + amount: (qs.parse(body as string) as { amount: string }).amount, + currency, + }, + })); + vi.mocked(axios.get).mockResolvedValue({ + data: { data: [{ url: "https://securecheckout.sandbox.hit-pay.com/payment-request/test" }] }, + }); + + await BuildPaymentService(credentials).create( + { amount, currency }, + 1, + 101, + "organizer", + "Booker", + "ON_BOOKING", + "booker@example.com" + ); + + const requestBody = qs.parse(vi.mocked(axios.post).mock.calls[0][1] as string) as { amount: string }; + + return { sent: requestBody.amount, stored: paymentCreate.mock.calls[0][0].data.amount }; +}; + +describe("HitPayPaymentService.create", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + // JPY, KRW and VND have no minor unit, so the stored amount is already the amount to charge. + // Scaling it down bills the attendee 1% of the price. + it.each([ + { currency: "jpy", amount: 10000 }, + { currency: "krw", amount: 50000 }, + { currency: "vnd", amount: 200000 }, + ])("charges $currency unscaled and round-trips the stored amount", async ({ currency, amount }) => { + expect(await createPaymentWith({ amount, currency })).toEqual({ sent: `${amount}`, stored: amount }); + }); + + it.each([ + { currency: "usd", amount: 5000, sent: "50" }, + { currency: "sgd", amount: 5000, sent: "50" }, + ])("converts $currency from minor units", async ({ currency, amount, sent }) => { + expect(await createPaymentWith({ amount, currency })).toEqual({ sent, stored: amount }); + }); +}); diff --git a/packages/app-store/hitpay/lib/PaymentService.ts b/packages/app-store/hitpay/lib/PaymentService.ts index 75787348a9..8ccfd97d2e 100644 --- a/packages/app-store/hitpay/lib/PaymentService.ts +++ b/packages/app-store/hitpay/lib/PaymentService.ts @@ -4,6 +4,10 @@ import { v4 as uuidv4 } from "uuid"; import type z from "zod"; import { WEBAPP_URL } from "@calcom/lib/constants"; +import { + convertFromSmallestToPresentableCurrencyUnit, + convertToSmallestCurrencyUnit, +} from "@calcom/lib/currencyConversions"; import { ErrorCode } from "@calcom/lib/errorCodes"; import logger from "@calcom/lib/logger"; import { safeStringify } from "@calcom/lib/safeStringify"; @@ -106,7 +110,7 @@ class HitPayPaymentService implements IAbstractPaymentService { const webhookUri = `${WEBAPP_URL}/api/integrations/${appConfig.slug}/webhook`; const formData = { - amount: payment.amount / 100, + amount: convertFromSmallestToPresentableCurrencyUnit(payment.amount, payment.currency), currency: payment.currency, email: bookerEmail, name: bookerName, @@ -153,7 +157,7 @@ class HitPayPaymentService implements IAbstractPaymentService { id: bookingId, }, }, - amount: parseFloat(data.amount.replace(/,/g, "")) * 100, + amount: convertToSmallestCurrencyUnit(parseFloat(data.amount.replace(/,/g, "")), data.currency), externalId: data.id, currency: data.currency, data: Object.assign(