From b879a542509d908d40141a82bf0caa8cd2fc8f9a Mon Sep 17 00:00:00 2001 From: shafi-VM Date: Fri, 4 Sep 2026 15:39:40 +0530 Subject: [PATCH] fix(hitpay): respect zero-decimal currencies when charging and storing (#30039) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit HitPay's payment-requests API takes `amount` as a decimal string in major units, so the integration divided by 100 on the way out and multiplied by 100 on the way back in. Both conversions were unconditional, but the price input writes the amount through `convertToSmallestCurrencyUnit`, which stores zero-decimal currencies unscaled. For the three zero-decimal currencies HitPay offers — JPY, KRW and VND — a ¥10,000 event therefore asked HitPay to collect ¥100. The two unconditional conversions cancelled out in the database, so the Payment row and the booker's page both still showed ¥10,000 while only 1% was charged, and `refund()` is not implemented for this app. Route both directions through the shared helpers so the amount charged matches the amount stored and displayed. Currencies with a minor unit are unaffected; the added tests pin USD and SGD to their existing behaviour. --- .../hitpay/lib/PaymentService.test.ts | 98 +++++++++++++++++++ .../app-store/hitpay/lib/PaymentService.ts | 8 +- 2 files changed, 104 insertions(+), 2 deletions(-) create mode 100644 packages/app-store/hitpay/lib/PaymentService.test.ts 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(