fix(hitpay): respect zero-decimal currencies when charging and storing (#30039)
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.
This commit is contained in:
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user