fix: update-stripe-presented-currency (#11431)

* Edit stripe currency

* add simple test for stripe change currency

* Undo import removed
This commit is contained in:
alannnc
2023-09-27 21:30:22 +00:00
committed by GitHub
parent d7c3132fa5
commit 403d08de41
7 changed files with 291 additions and 49 deletions
+51 -1
View File
@@ -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" },
];
+18 -5
View File
@@ -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({