fix: Update Event Type Pricing For Multiple Installed Payment Apps (#12272)
* Prevent two payment apps from being enabled * Find the enabled payment app to update the event type * Add string * Add tests * Type fix * Abstract check for multiple payment app logic * Type check * Address feedback * chore: Enable One Payment App Per Event Type (#12414) Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com> Co-authored-by: Morgan Vernay <morgan@cal.com> * Fix bug * Fix test * Clean up * Fix test * Fix test --------- Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com> Co-authored-by: Peer Richelsen <peeroke@gmail.com> Co-authored-by: Alex van Andel <me@alexvanandel.com> Co-authored-by: Omar López <zomars@me.com> Co-authored-by: Morgan Vernay <morgan@cal.com> Co-authored-by: Keith Williams <keithwillcode@gmail.com>
This commit is contained in:
co-authored by
Morgan
Peer Richelsen
Alex van Andel
Omar López
Morgan Vernay
Keith Williams
parent
68d40cabbe
commit
d07e86e4f3
@@ -47,10 +47,17 @@ export const EventAppsTab = ({ eventType }: { eventType: EventType }) => {
|
||||
};
|
||||
};
|
||||
|
||||
const getAppDataSetter = (appId: EventTypeAppsList, credentialId?: number): SetAppData => {
|
||||
const eventTypeFormMetadata = methods.getValues("metadata");
|
||||
|
||||
const getAppDataSetter = (
|
||||
appId: EventTypeAppsList,
|
||||
appCategories: string[],
|
||||
credentialId?: number
|
||||
): SetAppData => {
|
||||
return function (key, value) {
|
||||
// Always get latest data available in Form because consequent calls to setData would update the Form but not allAppsData(it would update during next render)
|
||||
const allAppsDataFromForm = methods.getValues("metadata")?.apps || {};
|
||||
|
||||
const appData = allAppsDataFromForm[appId];
|
||||
setAllAppsData({
|
||||
...allAppsDataFromForm,
|
||||
@@ -58,6 +65,7 @@ export const EventAppsTab = ({ eventType }: { eventType: EventType }) => {
|
||||
...appData,
|
||||
[key]: value,
|
||||
credentialId,
|
||||
appCategories,
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -77,10 +85,15 @@ export const EventAppsTab = ({ eventType }: { eventType: EventType }) => {
|
||||
appCards.push(
|
||||
<EventTypeAppCard
|
||||
getAppData={getAppDataGetter(app.slug as EventTypeAppsList)}
|
||||
setAppData={getAppDataSetter(app.slug as EventTypeAppsList, app.userCredentialIds[0])}
|
||||
setAppData={getAppDataSetter(
|
||||
app.slug as EventTypeAppsList,
|
||||
app.categories,
|
||||
app.userCredentialIds[0]
|
||||
)}
|
||||
key={app.slug}
|
||||
app={app}
|
||||
eventType={eventType}
|
||||
eventTypeFormMetadata={eventTypeFormMetadata}
|
||||
{...shouldLockDisableProps("apps")}
|
||||
/>
|
||||
);
|
||||
@@ -91,7 +104,7 @@ export const EventAppsTab = ({ eventType }: { eventType: EventType }) => {
|
||||
appCards.push(
|
||||
<EventTypeAppCard
|
||||
getAppData={getAppDataGetter(app.slug as EventTypeAppsList)}
|
||||
setAppData={getAppDataSetter(app.slug as EventTypeAppsList, team.credentialId)}
|
||||
setAppData={getAppDataSetter(app.slug as EventTypeAppsList, app.categories, team.credentialId)}
|
||||
key={app.slug + team?.credentialId}
|
||||
app={{
|
||||
...app,
|
||||
@@ -104,6 +117,7 @@ export const EventAppsTab = ({ eventType }: { eventType: EventType }) => {
|
||||
},
|
||||
}}
|
||||
eventType={eventType}
|
||||
eventTypeFormMetadata={eventTypeFormMetadata}
|
||||
{...shouldLockDisableProps("apps")}
|
||||
/>
|
||||
);
|
||||
@@ -148,10 +162,15 @@ export const EventAppsTab = ({ eventType }: { eventType: EventType }) => {
|
||||
return (
|
||||
<EventTypeAppCard
|
||||
getAppData={getAppDataGetter(app.slug as EventTypeAppsList)}
|
||||
setAppData={getAppDataSetter(app.slug as EventTypeAppsList, app.userCredentialIds[0])}
|
||||
setAppData={getAppDataSetter(
|
||||
app.slug as EventTypeAppsList,
|
||||
app.categories,
|
||||
app.userCredentialIds[0]
|
||||
)}
|
||||
key={app.slug}
|
||||
app={app}
|
||||
eventType={eventType}
|
||||
eventTypeFormMetadata={eventTypeFormMetadata}
|
||||
{...shouldLockDisableProps("apps")}
|
||||
/>
|
||||
);
|
||||
@@ -179,10 +198,11 @@ export const EventAppsTab = ({ eventType }: { eventType: EventType }) => {
|
||||
{notInstalledApps?.map((app) => (
|
||||
<EventTypeAppCard
|
||||
getAppData={getAppDataGetter(app.slug as EventTypeAppsList)}
|
||||
setAppData={getAppDataSetter(app.slug as EventTypeAppsList)}
|
||||
setAppData={getAppDataSetter(app.slug as EventTypeAppsList, app.categories)}
|
||||
key={app.slug}
|
||||
app={app}
|
||||
eventType={eventType}
|
||||
eventTypeFormMetadata={eventTypeFormMetadata}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useEffect, useMemo, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
|
||||
import checkForMultiplePaymentApps from "@calcom/app-store/_utils/payments/checkForMultiplePaymentApps";
|
||||
import { getEventLocationType } from "@calcom/app-store/locations";
|
||||
import { validateCustomEventName } from "@calcom/core/event";
|
||||
import type { EventLocationType } from "@calcom/core/location";
|
||||
@@ -484,6 +485,11 @@ const EventTypePage = (props: EventTypeSetupProps) => {
|
||||
}
|
||||
}
|
||||
|
||||
// Prevent two payment apps to be enabled
|
||||
// Ok to cast type here because this metadata will be updated as the event type metadata
|
||||
if (checkForMultiplePaymentApps(metadata as z.infer<typeof EventTypeMetaDataSchema>))
|
||||
throw new Error(t("event_setup_multiple_payment_apps_error"));
|
||||
|
||||
if (metadata?.apps?.stripe?.paymentOption === "HOLD" && seatsPerTimeSlot) {
|
||||
throw new Error(t("seats_and_no_show_fee_error"));
|
||||
}
|
||||
@@ -584,6 +590,12 @@ const EventTypePage = (props: EventTypeSetupProps) => {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Prevent two payment apps to be enabled
|
||||
// Ok to cast type here because this metadata will be updated as the event type metadata
|
||||
if (checkForMultiplePaymentApps(metadata as z.infer<typeof EventTypeMetaDataSchema>))
|
||||
throw new Error(t("event_setup_multiple_payment_apps_error"));
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { availability, ...rest } = input;
|
||||
updateMutation.mutate({
|
||||
|
||||
@@ -508,8 +508,8 @@ const createUserFixture = (user: UserWithIncludes, page: Page) => {
|
||||
});
|
||||
},
|
||||
getPaymentCredential: async () => getPaymentCredential(store.page),
|
||||
setupEventWithPrice: async (eventType: Pick<Prisma.EventType, "id">) =>
|
||||
setupEventWithPrice(eventType, store.page),
|
||||
setupEventWithPrice: async (eventType: Pick<Prisma.EventType, "id">, slug: string) =>
|
||||
setupEventWithPrice(eventType, slug, store.page),
|
||||
bookAndPayEvent: async (eventType: Pick<Prisma.EventType, "slug">) =>
|
||||
bookAndPayEvent(user, eventType, store.page),
|
||||
makePaymentUsingStripe: async () => makePaymentUsingStripe(store.page),
|
||||
@@ -693,9 +693,9 @@ export async function apiLogin(
|
||||
});
|
||||
}
|
||||
|
||||
export async function setupEventWithPrice(eventType: Pick<Prisma.EventType, "id">, page: Page) {
|
||||
export async function setupEventWithPrice(eventType: Pick<Prisma.EventType, "id">, slug: string, page: Page) {
|
||||
await page.goto(`/event-types/${eventType?.id}?tabName=apps`);
|
||||
await page.locator("[data-testid='app-switch']").first().click();
|
||||
await page.locator(`[data-testid='${slug}-app-switch']`).first().click();
|
||||
await page.getByPlaceholder("Price").fill("100");
|
||||
await page.getByTestId("update-eventtype").click();
|
||||
}
|
||||
|
||||
@@ -46,7 +46,7 @@ test.describe("Stripe integration", () => {
|
||||
await user.getPaymentCredential();
|
||||
|
||||
const eventType = user.eventTypes.find((e) => e.slug === "paid") as Prisma.EventType;
|
||||
await user.setupEventWithPrice(eventType);
|
||||
await user.setupEventWithPrice(eventType, "stripe");
|
||||
|
||||
// Need to wait for the DB to be updated with the metadata
|
||||
await page.waitForResponse((res) => res.url().includes("update") && res.status() === 200);
|
||||
@@ -104,7 +104,7 @@ test.describe("Stripe integration", () => {
|
||||
page.click('[id="skip-account-app"]'),
|
||||
]);
|
||||
|
||||
await owner.setupEventWithPrice(teamEvent);
|
||||
await owner.setupEventWithPrice(teamEvent, "stripe");
|
||||
|
||||
// Need to wait for the DB to be updated with the metadata
|
||||
await page.waitForResponse((res) => res.url().includes("update") && res.status() === 200);
|
||||
@@ -134,7 +134,7 @@ test.describe("Stripe integration", () => {
|
||||
await page.goto("/apps/installed");
|
||||
|
||||
await user.getPaymentCredential();
|
||||
await user.setupEventWithPrice(eventType);
|
||||
await user.setupEventWithPrice(eventType, "stripe");
|
||||
await user.bookAndPayEvent(eventType);
|
||||
// success
|
||||
await expect(page.locator("[data-testid=success-page]")).toBeVisible();
|
||||
@@ -147,7 +147,7 @@ test.describe("Stripe integration", () => {
|
||||
await page.goto("/apps/installed");
|
||||
|
||||
await user.getPaymentCredential();
|
||||
await user.setupEventWithPrice(eventType);
|
||||
await user.setupEventWithPrice(eventType, "stripe");
|
||||
|
||||
// booking process without payment
|
||||
await page.goto(`${user.username}/${eventType?.slug}`);
|
||||
@@ -171,7 +171,7 @@ test.describe("Stripe integration", () => {
|
||||
await page.goto("/apps/installed");
|
||||
|
||||
await user.getPaymentCredential();
|
||||
await user.setupEventWithPrice(eventType);
|
||||
await user.setupEventWithPrice(eventType, "stripe");
|
||||
await user.bookAndPayEvent(eventType);
|
||||
|
||||
// Rescheduling the event
|
||||
@@ -194,7 +194,7 @@ test.describe("Stripe integration", () => {
|
||||
await page.goto("/apps/installed");
|
||||
|
||||
await user.getPaymentCredential();
|
||||
await user.setupEventWithPrice(eventType);
|
||||
await user.setupEventWithPrice(eventType, "stripe");
|
||||
await user.bookAndPayEvent(eventType);
|
||||
|
||||
await page.click('[data-testid="cancel"]');
|
||||
@@ -214,7 +214,7 @@ test.describe("Stripe integration", () => {
|
||||
await page.goto("/apps/installed");
|
||||
|
||||
await user.getPaymentCredential();
|
||||
await user.setupEventWithPrice(eventType);
|
||||
await user.setupEventWithPrice(eventType, "stripe");
|
||||
await user.bookAndPayEvent(eventType);
|
||||
await user.confirmPendingPayment();
|
||||
});
|
||||
@@ -264,7 +264,7 @@ test.describe("Stripe integration", () => {
|
||||
await page.locator("#event-type-form").getByRole("switch").click();
|
||||
|
||||
// Set price
|
||||
await page.getByTestId("price-input-stripe").fill("200");
|
||||
await page.getByTestId("stripe-price-input").fill("200");
|
||||
|
||||
// Select currency in dropdown
|
||||
await page.getByTestId("stripe-currency-select").click();
|
||||
|
||||
@@ -80,8 +80,8 @@ test.describe("Payment app", () => {
|
||||
await page.getByTestId("stripe-currency-select").click();
|
||||
await page.getByTestId("select-option-usd").click();
|
||||
|
||||
await page.getByTestId("price-input-stripe").click();
|
||||
await page.getByTestId("price-input-stripe").fill("350");
|
||||
await page.getByTestId("stripe-price-input").click();
|
||||
await page.getByTestId("stripe-price-input").fill("350");
|
||||
await page.getByTestId("update-eventtype").click();
|
||||
|
||||
await page.goto(`${user.username}/${paymentEvent.slug}`);
|
||||
@@ -234,4 +234,128 @@ test.describe("Payment app", () => {
|
||||
await page.getByLabel("Tracking ID").fill("demo");
|
||||
await page.getByTestId("update-eventtype").click();
|
||||
});
|
||||
|
||||
test("Should only be allowed to enable one payment app", async ({ page, users }) => {
|
||||
const user = await users.create();
|
||||
await user.apiLogin();
|
||||
const paymentEvent = user.eventTypes.find((item) => item.slug === "paid");
|
||||
if (!paymentEvent) {
|
||||
throw new Error("No payment event found");
|
||||
}
|
||||
await prisma.credential.createMany({
|
||||
data: [
|
||||
{
|
||||
type: "paypal_payment",
|
||||
userId: user.id,
|
||||
key: {
|
||||
client_id: "randomString",
|
||||
secret_key: "randomString",
|
||||
webhook_id: "randomString",
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "stripe_payment",
|
||||
userId: user.id,
|
||||
key: {
|
||||
scope: "read_write",
|
||||
livemode: false,
|
||||
token_type: "bearer",
|
||||
access_token: "sk_test_randomString",
|
||||
refresh_token: "rt_randomString",
|
||||
stripe_user_id: "acct_randomString",
|
||||
default_currency: "usd",
|
||||
stripe_publishable_key: "pk_test_randomString",
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await page.goto(`event-types/${paymentEvent.id}?tabName=apps`);
|
||||
|
||||
await page.locator("[data-testid='paypal-app-switch']").click();
|
||||
await page.locator("[data-testid='stripe-app-switch']").isDisabled();
|
||||
});
|
||||
|
||||
test("when more than one payment app is installed the price should be updated when changing settings", async ({
|
||||
page,
|
||||
users,
|
||||
}) => {
|
||||
const user = await users.create();
|
||||
await user.apiLogin();
|
||||
const paymentEvent = user.eventTypes.find((item) => item.slug === "paid");
|
||||
if (!paymentEvent) {
|
||||
throw new Error("No payment event found");
|
||||
}
|
||||
|
||||
await prisma.credential.createMany({
|
||||
data: [
|
||||
{
|
||||
type: "paypal_payment",
|
||||
userId: user.id,
|
||||
key: {
|
||||
client_id: "randomString",
|
||||
secret_key: "randomString",
|
||||
webhook_id: "randomString",
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "stripe_payment",
|
||||
userId: user.id,
|
||||
key: {
|
||||
scope: "read_write",
|
||||
livemode: false,
|
||||
token_type: "bearer",
|
||||
access_token: "sk_test_randomString",
|
||||
refresh_token: "rt_randomString",
|
||||
stripe_user_id: "acct_randomString",
|
||||
default_currency: "usd",
|
||||
stripe_publishable_key: "pk_test_randomString",
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await page.goto(`event-types/${paymentEvent.id}?tabName=apps`);
|
||||
|
||||
await page.locator("[data-testid='paypal-app-switch']").click();
|
||||
await page.locator("[data-testid='paypal-price-input']").fill("100");
|
||||
await page.locator("[data-testid='paypal-currency-select']").first().click();
|
||||
await page.locator("#react-select-2-option-13").click();
|
||||
// await page.locator(".mb-1 > .bg-default > div > div:nth-child(2)").first().click();
|
||||
// await page.getByText("$MXNCurrencyMexican pesoPayment option").click();
|
||||
await page.locator("[data-testid='update-eventtype']").click();
|
||||
|
||||
// Need to wait for the DB to be updated
|
||||
await page.waitForResponse((res) => res.url().includes("update") && res.status() === 200);
|
||||
|
||||
const paypalPrice = await prisma.eventType.findFirst({
|
||||
where: {
|
||||
id: paymentEvent.id,
|
||||
},
|
||||
select: {
|
||||
price: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(paypalPrice?.price).toEqual(10000);
|
||||
|
||||
await page.locator("[data-testid='paypal-app-switch']").click();
|
||||
await page.locator("[data-testid='stripe-app-switch']").click();
|
||||
await page.locator("[data-testid='stripe-price-input']").fill("200");
|
||||
await page.locator("[data-testid='update-eventtype']").click();
|
||||
|
||||
// Need to wait for the DB to be updated
|
||||
await page.waitForResponse((res) => res.url().includes("update") && res.status() === 200);
|
||||
|
||||
const stripePrice = await prisma.eventType.findFirst({
|
||||
where: {
|
||||
id: paymentEvent.id,
|
||||
},
|
||||
select: {
|
||||
price: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(stripePrice?.price).toEqual(20000);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2158,6 +2158,7 @@
|
||||
"manage_availability_schedules":"Manage availability schedules",
|
||||
"lock_timezone_toggle_on_booking_page": "Lock timezone on booking page",
|
||||
"description_lock_timezone_toggle_on_booking_page" : "To lock the timezone on booking page, useful for in-person events.",
|
||||
"event_setup_multiple_payment_apps_error": "You can only have one payment app enabled per event type.",
|
||||
"number_in_international_format": "Please enter number in international format.",
|
||||
"install_calendar":"Install Calendar",
|
||||
"branded_subdomain": "Branded Subdomain",
|
||||
@@ -2175,6 +2176,7 @@
|
||||
"enterprise_description": "Upgrade to Enterprise to create your Organization",
|
||||
"create_your_org": "Create your Organization",
|
||||
"create_your_org_description": "Upgrade to Enterprise and receive a subdomain, unified billing, Insights, extensive whitelabeling and more",
|
||||
"other_payment_app_enabled": "You can only enable one payment app per event type",
|
||||
"admin_delete_organization_description": "<ul><li>Teams that are member of this organization will also be deleted along with their event-types</li><li>Users that were part of the organization will not be deleted and their event-types will also remain intact.</li><li>Usernames would be changed to allow them to exist outside the organization</li></ul>",
|
||||
"admin_delete_organization_title": "Delete organization?",
|
||||
"published": "Published",
|
||||
|
||||
Reference in New Issue
Block a user