fix: update-stripe-presented-currency (#11431)
* Edit stripe currency * add simple test for stripe change currency * Undo import removed
This commit is contained in:
@@ -1,8 +1,8 @@
|
||||
import { expect } from "@playwright/test";
|
||||
import type Prisma from "@prisma/client";
|
||||
|
||||
import type { Fixtures } from "./lib/fixtures";
|
||||
import { test } from "./lib/fixtures";
|
||||
import type { Fixtures } from "./lib/fixtures";
|
||||
import { todo, selectFirstAvailableTimeSlotNextMonth } from "./lib/testUtils";
|
||||
|
||||
test.describe.configure({ mode: "parallel" });
|
||||
@@ -155,4 +155,54 @@ test.describe("Stripe integration", () => {
|
||||
|
||||
todo("Payment should trigger a BOOKING_PAID webhook");
|
||||
});
|
||||
|
||||
test.describe("Change stripe presented currency", () => {
|
||||
test("Should be able to change currency", async ({ page, users }) => {
|
||||
const user = await users.create();
|
||||
const eventType = user.eventTypes.find((e) => e.slug === "paid") as Prisma.EventType;
|
||||
await user.apiLogin();
|
||||
|
||||
await user.getPaymentCredential();
|
||||
|
||||
// Edit currency inside event type page
|
||||
await page.goto(`/event-types/${eventType?.id}?tabName=apps`);
|
||||
|
||||
// Enable Stripe
|
||||
await page.locator("#event-type-form").getByRole("switch").click();
|
||||
|
||||
// Set price
|
||||
await page.getByTestId("price-input-stripe").fill("200");
|
||||
|
||||
// Select currency in dropdown
|
||||
await page.locator("div").filter({ hasText: "United States dollar (USD)" }).nth(1).click();
|
||||
await page.locator("#react-select-2-input").fill("mexi");
|
||||
await page.locator("#react-select-2-option-81").click();
|
||||
|
||||
await page.getByTestId("update-eventtype").click();
|
||||
|
||||
// Book event
|
||||
await page.goto(`${user.username}/${eventType?.slug}`);
|
||||
|
||||
// Confirm MXN currency it's displayed use expect
|
||||
await expect(await page.getByText("MX$200.00")).toBeVisible();
|
||||
|
||||
await selectFirstAvailableTimeSlotNextMonth(page);
|
||||
|
||||
// Confirm again in book form page
|
||||
await expect(await page.getByText("MX$200.00")).toBeVisible();
|
||||
|
||||
// --- fill form
|
||||
await page.fill('[name="name"]', "Stripe Stripeson");
|
||||
await page.fill('[name="email"]', "stripe@example.com");
|
||||
|
||||
// Confirm booking
|
||||
await page.click('[data-testid="confirm-book-button"]');
|
||||
|
||||
// wait for url to be payment
|
||||
await page.waitForURL("/payment/*");
|
||||
|
||||
// Confirm again in book form page
|
||||
await expect(await page.getByText("MX$200.00")).toBeVisible();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
|
||||
import { useAppContextWithSchema } from "@calcom/app-store/EventTypeAppContext";
|
||||
import AppCard from "@calcom/app-store/_components/AppCard";
|
||||
@@ -9,6 +10,11 @@ import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Alert, Select, TextField } from "@calcom/ui";
|
||||
|
||||
import { paymentOptions } from "../lib/constants";
|
||||
import {
|
||||
convertFromSmallestToPresentableCurrencyUnit,
|
||||
convertToSmallestCurrencyUnit,
|
||||
} from "../lib/currencyConvertions";
|
||||
import { currencyOptions } from "../lib/currencyOptions";
|
||||
import type { appDataSchema } from "../zod";
|
||||
|
||||
type Option = { value: string; label: string };
|
||||
@@ -18,6 +24,12 @@ const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({
|
||||
const { getAppData, setAppData, disabled } = useAppContextWithSchema<typeof appDataSchema>();
|
||||
const price = getAppData("price");
|
||||
const currency = getAppData("currency");
|
||||
const [selectedCurrency, setSelectedCurrency] = useState(
|
||||
currencyOptions.find((c) => c.value === currency) || {
|
||||
label: currencyOptions[0].label,
|
||||
value: currencyOptions[0].value,
|
||||
}
|
||||
);
|
||||
const paymentOption = getAppData("paymentOption");
|
||||
const paymentOptionSelectValue = paymentOptions.find((option) => paymentOption === option.value);
|
||||
const { enabled: requirePayment, updateEnabled: setRequirePayment } = useIsAppEnabled(app);
|
||||
@@ -50,11 +62,33 @@ const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({
|
||||
)}
|
||||
{!recurringEventDefined && requirePayment && (
|
||||
<>
|
||||
<div className="mt-2 block items-center justify-start sm:flex sm:space-x-2">
|
||||
<div className="mt-5 w-60">
|
||||
<label className="text-default block text-sm font-medium" htmlFor="currency">
|
||||
{t("currency")}
|
||||
</label>
|
||||
<Select
|
||||
data-testid="currency-select-stripe"
|
||||
variant="default"
|
||||
options={currencyOptions}
|
||||
value={selectedCurrency}
|
||||
className="text-black"
|
||||
defaultValue={selectedCurrency}
|
||||
onChange={(e) => {
|
||||
if (e) {
|
||||
setSelectedCurrency(e);
|
||||
setAppData("currency", e.value);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-4 block items-center justify-start sm:flex sm:space-x-2">
|
||||
<TextField
|
||||
label=""
|
||||
data-testid="price-input-stripe"
|
||||
label={t("price")}
|
||||
className="h-[38px]"
|
||||
addOnLeading={<>{currency ? getCurrencySymbol("en", currency) : ""}</>}
|
||||
addOnLeading={
|
||||
<>{selectedCurrency.value ? getCurrencySymbol("en", selectedCurrency.value) : ""}</>
|
||||
}
|
||||
addOnClassname="h-[38px]"
|
||||
step="0.01"
|
||||
min="0.5"
|
||||
@@ -63,9 +97,9 @@ const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({
|
||||
placeholder="Price"
|
||||
disabled={disabled}
|
||||
onChange={(e) => {
|
||||
setAppData("price", Number(e.target.value) * 100);
|
||||
setAppData("price", convertToSmallestCurrencyUnit(Number(e.target.value), currency));
|
||||
}}
|
||||
value={price > 0 ? price / 100 : undefined}
|
||||
value={price > 0 ? convertFromSmallestToPresentableCurrencyUnit(price, currency) : undefined}
|
||||
/>
|
||||
<Select<Option>
|
||||
defaultValue={
|
||||
|
||||
@@ -80,7 +80,7 @@ export class PaymentService implements IAbstractPaymentService {
|
||||
|
||||
const params: Stripe.PaymentIntentCreateParams = {
|
||||
amount: payment.amount,
|
||||
currency: this.credentials.default_currency,
|
||||
currency: payment.currency,
|
||||
payment_method_types: ["card"],
|
||||
customer: customer.id,
|
||||
metadata: {
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* Docs: https://stripe.com/docs/currencies#zero-decimal
|
||||
* */
|
||||
const zeroDecimalCurrencies = [
|
||||
"BIF",
|
||||
"CLP",
|
||||
"DJF",
|
||||
"GNF",
|
||||
"JPY",
|
||||
"KMF",
|
||||
"KRW",
|
||||
"MGA",
|
||||
"PYG",
|
||||
"RWF",
|
||||
"UGX",
|
||||
"VND",
|
||||
"VUV",
|
||||
"XAF",
|
||||
"XOF",
|
||||
"XPF",
|
||||
];
|
||||
|
||||
export const convertToSmallestCurrencyUnit = (amount: number, currency: string) => {
|
||||
// Special cases
|
||||
if (zeroDecimalCurrencies.includes(currency)) {
|
||||
return amount;
|
||||
}
|
||||
return Math.round(amount * 100);
|
||||
};
|
||||
|
||||
export const convertFromSmallestToPresentableCurrencyUnit = (amount: number, currency: string) => {
|
||||
// Special cases
|
||||
if (zeroDecimalCurrencies.includes(currency)) {
|
||||
return amount;
|
||||
}
|
||||
return amount / 100;
|
||||
};
|
||||
@@ -0,0 +1,136 @@
|
||||
export const currencyOptions = [
|
||||
{ label: "United States dollar (USD)", value: "usd" },
|
||||
{ label: "United Arab Emirates dirham (AED)", value: "aed" },
|
||||
{ label: "Afghan afghani (AFN)", value: "afn" },
|
||||
{ label: "Albanian lek (ALL)", value: "all" },
|
||||
{ label: "Armenian dram (AMD)", value: "amd" },
|
||||
{ label: "Netherlands Antillean guilder (ANG)", value: "ang" },
|
||||
{ label: "Angolan kwanza (AOA)", value: "aoa" },
|
||||
{ label: "Argentine peso (ARS)", value: "ars" },
|
||||
{ label: "Australian dollar (AUD)", value: "aud" },
|
||||
{ label: "Aruban florin (AWG)", value: "awg" },
|
||||
{ label: "Azerbaijani manat (AZN)", value: "azn" },
|
||||
{ label: "Bosnia and Herzegovina convertible mark (BAM)", value: "bam" },
|
||||
{ label: "Barbadian dollar (BBD)", value: "bbd" },
|
||||
{ label: "Bangladeshi taka (BDT)", value: "bdt" },
|
||||
{ label: "Bulgarian lev (BGN)", value: "bgn" },
|
||||
{ label: "Burundian franc (BIF)", value: "bif" },
|
||||
{ label: "Bermudian dollar (BMD)", value: "bmd" },
|
||||
{ label: "Brunei dollar (BND)", value: "bnd" },
|
||||
{ label: "Bolivian boliviano (BOB)", value: "bob" },
|
||||
{ label: "Brazilian real (BRL)", value: "brl" },
|
||||
{ label: "Bahamian dollar (BSD)", value: "bsd" },
|
||||
{ label: "Botswana pula (BWP)", value: "bwp" },
|
||||
{ label: "Belarusian ruble (BYN)", value: "byn" },
|
||||
{ label: "Belize dollar (BZD)", value: "bzd" },
|
||||
{ label: "Canadian dollar (CAD)", value: "cad" },
|
||||
{ label: "Congolese franc (CDF)", value: "cdf" },
|
||||
{ label: "Swiss franc (CHF)", value: "chf" },
|
||||
{ label: "Chilean peso (CLP)", value: "clp" },
|
||||
{ label: "Chinese yuan (CNY)", value: "cny" },
|
||||
{ label: "Colombian peso (COP)", value: "cop" },
|
||||
{ label: "Costa Rican colón (CRC)", value: "crc" },
|
||||
{ label: "Cape Verdean escudo (CVE)", value: "cve" },
|
||||
{ label: "Czech koruna (CZK)", value: "czk" },
|
||||
{ label: "Djiboutian franc (DJF)", value: "djf" },
|
||||
{ label: "Danish krone (DKK)", value: "dkk" },
|
||||
{ label: "Dominican peso (DOP)", value: "dop" },
|
||||
{ label: "Algerian dinar (DZD)", value: "dzd" },
|
||||
{ label: "Egyptian pound (EGP)", value: "egp" },
|
||||
{ label: "Ethiopian birr (ETB)", value: "etb" },
|
||||
{ label: "Euro (EUR)", value: "eur" },
|
||||
{ label: "Fijian dollar (FJD)", value: "fjd" },
|
||||
{ label: "Falkland Islands pound (FKP)", value: "fkp" },
|
||||
{ label: "British pound sterling (GBP)", value: "gbp" },
|
||||
{ label: "Georgian lari (GEL)", value: "gel" },
|
||||
{ label: "Gibraltar pound (GIP)", value: "gip" },
|
||||
{ label: "Gambian dalasi (GMD)", value: "gmd" },
|
||||
{ label: "Guinean franc (GNF)", value: "gnf" },
|
||||
{ label: "Guatemalan quetzal (GTQ)", value: "gtq" },
|
||||
{ label: "Guyanese dollar (GYD)", value: "gyd" },
|
||||
{ label: "Hong Kong dollar (HKD)", value: "hkd" },
|
||||
{ label: "Honduran lempira (HNL)", value: "hnl" },
|
||||
{ label: "Haitian gourde (HTG)", value: "htg" },
|
||||
{ label: "Hungarian forint (HUF)", value: "huf" },
|
||||
{ label: "Indonesian rupiah (IDR)", value: "idr" },
|
||||
{ label: "Israeli new shekel (ILS)", value: "ils" },
|
||||
{ label: "Indian rupee (INR)", value: "inr" },
|
||||
{ label: "Icelandic króna (ISK)", value: "isk" },
|
||||
{ label: "Jamaican dollar (JMD)", value: "jmd" },
|
||||
{ label: "Japanese yen (JPY)", value: "jpy" },
|
||||
{ label: "Kenyan shilling (KES)", value: "kes" },
|
||||
{ label: "Kyrgyzstani som (KGS)", value: "kgs" },
|
||||
{ label: "Cambodian riel (KHR)", value: "khr" },
|
||||
{ label: "Comorian franc (KMF)", value: "kmf" },
|
||||
{ label: "South Korean won (KRW)", value: "krw" },
|
||||
{ label: "Cayman Islands dollar (KYD)", value: "kyd" },
|
||||
{ label: "Kazakhstani tenge (KZT)", value: "kzt" },
|
||||
{ label: "Laotian kip (LAK)", value: "lak" },
|
||||
{ label: "Lebanese pound (LBP)", value: "lbp" },
|
||||
{ label: "Sri Lankan rupee (LKR)", value: "lkr" },
|
||||
{ label: "Liberian dollar (LRD)", value: "lrd" },
|
||||
{ label: "Lesotho loti (LSL)", value: "lsl" },
|
||||
{ label: "Moroccan dirham (MAD)", value: "mad" },
|
||||
{ label: "Moldovan leu (MDL)", value: "mdl" },
|
||||
{ label: "Malagasy ariary (MGA)", value: "mga" },
|
||||
{ label: "Macedonian denar (MKD)", value: "mkd" },
|
||||
{ label: "Myanmar kyat (MMK)", value: "mmk" },
|
||||
{ label: "Mongolian tögrög (MNT)", value: "mnt" },
|
||||
{ label: "Macanese pataca (MOP)", value: "mop" },
|
||||
{ label: "Mauritian rupee (MUR)", value: "mur" },
|
||||
{ label: "Maldivian rufiyaa (MVR)", value: "mvr" },
|
||||
{ label: "Malawian kwacha (MWK)", value: "mwk" },
|
||||
{ label: "Mexican peso (MXN)", value: "mxn" },
|
||||
{ label: "Malaysian ringgit (MYR)", value: "myr" },
|
||||
{ label: "Mozambican metical (MZN)", value: "mzn" },
|
||||
{ label: "Namibian dollar (NAD)", value: "nad" },
|
||||
{ label: "Nigerian naira (NGN)", value: "ngn" },
|
||||
{ label: "Nicaraguan córdoba (NIO)", value: "nio" },
|
||||
{ label: "Norwegian krone (NOK)", value: "nok" },
|
||||
{ label: "Nepalese rupee (NPR)", value: "npr" },
|
||||
{ label: "New Zealand dollar (NZD)", value: "nzd" },
|
||||
{ label: "Panamanian balboa (PAB)", value: "pab" },
|
||||
{ label: "Peruvian sol (PEN)", value: "pen" },
|
||||
{ label: "Papua New Guinean kina (PGK)", value: "pgk" },
|
||||
{ label: "Philippine peso (PHP)", value: "php" },
|
||||
{ label: "Pakistani rupee (PKR)", value: "pkr" },
|
||||
{ label: "Polish złoty (PLN)", value: "pln" },
|
||||
{ label: "Paraguayan guaraní (PYG)", value: "pyg" },
|
||||
{ label: "Qatari riyal (QAR)", value: "qar" },
|
||||
{ label: "Romanian leu (RON)", value: "ron" },
|
||||
{ label: "Serbian dinar (RSD)", value: "rsd" },
|
||||
{ label: "Russian ruble (RUB)", value: "rub" },
|
||||
{ label: "Rwandan franc (RWF)", value: "rwf" },
|
||||
{ label: "Saudi riyal (SAR)", value: "sar" },
|
||||
{ label: "Solomon Islands dollar (SBD)", value: "sbd" },
|
||||
{ label: "Seychellois rupee (SCR)", value: "scr" },
|
||||
{ label: "Swedish krona (SEK)", value: "sek" },
|
||||
{ label: "Singapore dollar (SGD)", value: "sgd" },
|
||||
{ label: "Saint Helena pound (SHP)", value: "shp" },
|
||||
{ label: "Sierra Leonean leone (SLE)", value: "sle" },
|
||||
{ label: "Somali shilling (SOS)", value: "sos" },
|
||||
{ label: "Surinamese dollar (SRD)", value: "srd" },
|
||||
{ label: "São Tomé and Príncipe dobra (STD)", value: "std" },
|
||||
{ label: "Swazi lilangeni (SZL)", value: "szl" },
|
||||
{ label: "Thai baht (THB)", value: "thb" },
|
||||
{ label: "Tajikistani somoni (TJS)", value: "tjs" },
|
||||
{ label: "Tongan paʻanga (TOP)", value: "top" },
|
||||
{ label: "Turkish lira (TRY)", value: "try" },
|
||||
{ label: "Trinidad and Tobago dollar (TTD)", value: "ttd" },
|
||||
{ label: "New Taiwan dollar (TWD)", value: "twd" },
|
||||
{ label: "Tanzanian shilling (TZS)", value: "tzs" },
|
||||
{ label: "Ukrainian hryvnia (UAH)", value: "uah" },
|
||||
{ label: "Ugandan shilling (UGX)", value: "ugx" },
|
||||
{ label: "Uruguayan peso (UYU)", value: "uyu" },
|
||||
{ label: "Uzbekistan som (UZS)", value: "uzs" },
|
||||
{ label: "Vietnamese đồng (VND)", value: "vnd" },
|
||||
{ label: "Vanuatu vatu (VUV)", value: "vuv" },
|
||||
{ label: "Samoan tala (WST)", value: "wst" },
|
||||
{ label: "Central African CFA franc (XAF)", value: "xaf" },
|
||||
{ label: "East Caribbean dollar (XCD)", value: "xcd" },
|
||||
{ label: "West African CFA franc (XOF)", value: "xof" },
|
||||
{ label: "CFP franc (XPF)", value: "xpf" },
|
||||
{ label: "Yemeni rial (YER)", value: "yer" },
|
||||
{ label: "South African rand (ZAR)", value: "zar" },
|
||||
{ label: "Zambian kwacha (ZMW)", value: "zmw" },
|
||||
];
|
||||
@@ -11,6 +11,7 @@ import { CAL_URL } from "@calcom/lib/constants";
|
||||
import getPaymentAppData from "@calcom/lib/getPaymentAppData";
|
||||
import { getTranslation } from "@calcom/lib/server/i18n";
|
||||
import type { PrismaClient } from "@calcom/prisma";
|
||||
import type { Credential } from "@calcom/prisma/client";
|
||||
import { SchedulingType, MembershipRole, AppCategories } from "@calcom/prisma/enums";
|
||||
import { customInputSchema, EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
|
||||
|
||||
@@ -261,11 +262,7 @@ export default async function getEventTypeById({
|
||||
stripe: {
|
||||
...stripeMetaData,
|
||||
paymentOption: stripeMetaData.paymentOption as string,
|
||||
currency:
|
||||
(
|
||||
credentials.find((integration) => integration.type === "stripe_payment")
|
||||
?.key as unknown as StripeData
|
||||
)?.default_currency || "usd",
|
||||
currency: getStripeCurrency(stripeMetaData, credentials),
|
||||
},
|
||||
giphy: getEventTypeAppData(eventTypeWithParsedMetadata, "giphy", true),
|
||||
};
|
||||
@@ -415,3 +412,19 @@ export default async function getEventTypeById({
|
||||
};
|
||||
return finalObj;
|
||||
}
|
||||
|
||||
const getStripeCurrency = (stripeMetadata: { currency: string }, credentials: Credential[]) => {
|
||||
// Favor the currency from the metadata as EventType.currency was not always set and should be deprecated
|
||||
if (stripeMetadata.currency) {
|
||||
return stripeMetadata.currency;
|
||||
}
|
||||
|
||||
// Legacy support for EventType.currency
|
||||
const stripeCredential = credentials.find((integration) => integration.type === "stripe_payment");
|
||||
if (stripeCredential) {
|
||||
return (stripeCredential.key as unknown as StripeData)?.default_currency || "usd";
|
||||
}
|
||||
|
||||
// Fallback to USD but should not happen
|
||||
return "usd";
|
||||
};
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Prisma } from "@prisma/client";
|
||||
import type { NextApiResponse, GetServerSidePropsContext } from "next";
|
||||
|
||||
import { stripeDataSchema } from "@calcom/app-store/stripepayment/lib/server";
|
||||
import updateChildrenEventTypes from "@calcom/features/ee/managed-event-types/lib/handleChildrenEventTypes";
|
||||
import { validateIntervalLimitOrder } from "@calcom/lib";
|
||||
import logger from "@calcom/lib/logger";
|
||||
@@ -241,44 +240,17 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => {
|
||||
}
|
||||
}
|
||||
|
||||
if (input.metadata?.apps?.stripe?.price) {
|
||||
data.price = input.metadata?.apps?.stripe?.price;
|
||||
const paymentCredential = await ctx.prisma.credential.findFirst({
|
||||
where: {
|
||||
userId: ctx.user.id,
|
||||
appId: {
|
||||
equals: "stripe",
|
||||
},
|
||||
},
|
||||
select: {
|
||||
appId: true,
|
||||
key: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (paymentCredential?.appId === "stripe") {
|
||||
const { default_currency } = stripeDataSchema.parse(paymentCredential.key);
|
||||
data.currency = default_currency;
|
||||
}
|
||||
/**
|
||||
* Since you can have multiple payment apps we will honor the first one to save in eventType
|
||||
* but the real detail will be inside app metadata, so with this you can have different prices in different apps
|
||||
* So the price and currency inside eventType will be deprecated soon or just keep as reference.
|
||||
*/
|
||||
if (input?.metadata?.apps?.stripe?.price || input?.metadata?.apps?.paypal?.price) {
|
||||
data.price = input.metadata.apps.stripe?.price || input.metadata.apps.paypal?.price;
|
||||
}
|
||||
|
||||
if (input.metadata?.apps?.paypal?.price) {
|
||||
data.price = input.metadata?.apps?.paypal?.price;
|
||||
const paymentCredential = await ctx.prisma.credential.findFirst({
|
||||
where: {
|
||||
userId: ctx.user.id,
|
||||
appId: {
|
||||
equals: "paypal",
|
||||
},
|
||||
},
|
||||
select: {
|
||||
appId: true,
|
||||
key: true,
|
||||
},
|
||||
});
|
||||
if (paymentCredential?.appId === "paypal" && input.metadata?.apps?.paypal?.currency) {
|
||||
data.currency = input.metadata?.apps?.paypal?.currency.toLowerCase();
|
||||
}
|
||||
if (input?.metadata?.apps?.stripe?.currency || input?.metadata?.apps?.paypal?.currency) {
|
||||
data.currency = input.metadata.apps.stripe?.currency || input.metadata.apps.paypal?.currency;
|
||||
}
|
||||
|
||||
const connectedLink = await ctx.prisma.hashedLink.findFirst({
|
||||
|
||||
Reference in New Issue
Block a user