Skip to content

Commit 8be5442

Browse files
committed
feat: support different Stripe prices per event duration
1 parent f0a7293 commit 8be5442

File tree

6 files changed

+124
-40
lines changed

6 files changed

+124
-40
lines changed

packages/app-store/stripepayment/components/EventTypeAppCardInterface.tsx

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,10 @@
1-
import { usePathname } from "next/navigation";
2-
import { useState } from "react";
3-
4-
import { useAppContextWithSchema } from "@calcom/app-store/EventTypeAppContext";
51
import AppCard from "@calcom/app-store/_components/AppCard";
2+
import { useAppContextWithSchema } from "@calcom/app-store/EventTypeAppContext";
63
import type { EventTypeAppCardComponent } from "@calcom/app-store/types";
74
import { WEBAPP_URL } from "@calcom/lib/constants";
85
import { useLocale } from "@calcom/lib/hooks/useLocale";
9-
6+
import { usePathname } from "next/navigation";
7+
import { useState } from "react";
108
import checkForMultiplePaymentApps from "../../_utils/payments/checkForMultiplePaymentApps";
119
import useIsAppEnabled from "../../_utils/useIsAppEnabled";
1210
import type { appDataSchema } from "../zod";
@@ -44,6 +42,7 @@ const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({
4442
disabled={disabled}
4543
getAppData={getAppData}
4644
setAppData={setAppData}
45+
eventTypeFormMetadata={eventTypeFormMetadata}
4746
/>
4847
</AppCard>
4948
);

packages/app-store/stripepayment/components/EventTypeAppSettingsInterface.tsx

Lines changed: 70 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,16 @@
1-
import * as RadioGroup from "@radix-ui/react-radio-group";
2-
import { useState, useEffect } from "react";
3-
41
import type { EventTypeAppSettingsComponent } from "@calcom/app-store/types";
52
import {
6-
convertToSmallestCurrencyUnit,
73
convertFromSmallestToPresentableCurrencyUnit,
4+
convertToSmallestCurrencyUnit,
85
} from "@calcom/lib/currencyConversions";
96
import { useLocale } from "@calcom/lib/hooks/useLocale";
107
import { RefundPolicy } from "@calcom/lib/payment/types";
118
import classNames from "@calcom/ui/classNames";
129
import { Alert } from "@calcom/ui/components/alert";
13-
import { Select } from "@calcom/ui/components/form";
14-
import { CheckboxField } from "@calcom/ui/components/form";
15-
import { TextField } from "@calcom/ui/components/form";
10+
import { CheckboxField, Select, TextField } from "@calcom/ui/components/form";
1611
import { RadioField } from "@calcom/ui/components/radio";
17-
12+
import * as RadioGroup from "@radix-ui/react-radio-group";
13+
import { useEffect, useState } from "react";
1814
import { paymentOptions } from "../lib/constants";
1915
import { currencyOptions } from "../lib/currencyOptions";
2016
import { autoChargeNoShowFeeTimeUnitEnum } from "../zod";
@@ -26,8 +22,10 @@ const EventTypeAppSettingsInterface: EventTypeAppSettingsComponent = ({
2622
setAppData,
2723
disabled,
2824
eventType,
25+
eventTypeFormMetadata,
2926
}) => {
3027
const price = getAppData("price");
28+
const pricePerDuration = getAppData("pricePerDuration") || {};
3129
const currency = getAppData("currency") || currencyOptions[0].value;
3230
const [selectedCurrency, setSelectedCurrency] = useState(
3331
currencyOptions.find((c) => c.value === currency) || {
@@ -82,6 +80,10 @@ const EventTypeAppSettingsInterface: EventTypeAppSettingsComponent = ({
8280
{ value: autoChargeNoShowFeeTimeUnitEnum.enum.hours, label: t("hours") },
8381
{ value: autoChargeNoShowFeeTimeUnitEnum.enum.days, label: t("days") },
8482
];
83+
84+
const multipleDuration = eventTypeFormMetadata?.multipleDuration;
85+
const hasMultipleDurations = Array.isArray(multipleDuration) && multipleDuration.length > 0;
86+
8587
return (
8688
<>
8789
{recurringEventDefined && (
@@ -90,26 +92,66 @@ const EventTypeAppSettingsInterface: EventTypeAppSettingsComponent = ({
9092
{!recurringEventDefined && requirePayment && (
9193
<>
9294
<div className="mt-4 block items-center justify-start sm:flex sm:space-x-2">
93-
<TextField
94-
data-testid="stripe-price-input"
95-
label={t("price")}
96-
className="h-[38px]"
97-
addOnLeading={
98-
<>{selectedCurrency.value ? getCurrencySymbol("en", selectedCurrency.value) : ""}</>
99-
}
100-
addOnSuffix={currency.toUpperCase()}
101-
addOnClassname="h-[38px]"
102-
step="0.01"
103-
min="0.5"
104-
type="number"
105-
required
106-
placeholder="Price"
107-
disabled={disabled}
108-
onChange={(e) => {
109-
setAppData("price", convertToSmallestCurrencyUnit(Number(e.target.value), currency));
110-
}}
111-
value={price > 0 ? convertFromSmallestToPresentableCurrencyUnit(price, currency) : undefined}
112-
/>
95+
{hasMultipleDurations ? (
96+
<div className="flex flex-col space-y-4">
97+
{multipleDuration.map((duration) => (
98+
<TextField
99+
key={duration}
100+
name={`price-${duration}`}
101+
data-testid={`stripe-price-input-${duration}`}
102+
label={`${duration} ${t("minute_timeUnit")}`}
103+
className="h-[38px]"
104+
addOnLeading={
105+
<>{selectedCurrency.value ? getCurrencySymbol("en", selectedCurrency.value) : ""}</>
106+
}
107+
addOnSuffix={currency.toUpperCase()}
108+
addOnClassname="h-[38px]"
109+
step="0.01"
110+
min="0.5"
111+
type="number"
112+
required
113+
placeholder="Price"
114+
disabled={disabled}
115+
onChange={(e) => {
116+
const newPrice = convertToSmallestCurrencyUnit(Number(e.target.value), currency);
117+
const newPricePerDuration = { ...pricePerDuration, [duration]: newPrice };
118+
setAppData("pricePerDuration", newPricePerDuration);
119+
120+
// Keep base price synced with the first duration for backward compatibility
121+
if (duration === multipleDuration[0]) {
122+
setAppData("price", newPrice);
123+
}
124+
}}
125+
value={
126+
pricePerDuration[duration] > 0
127+
? convertFromSmallestToPresentableCurrencyUnit(pricePerDuration[duration], currency)
128+
: undefined
129+
}
130+
/>
131+
))}
132+
</div>
133+
) : (
134+
<TextField
135+
data-testid="stripe-price-input"
136+
label={t("price")}
137+
className="h-[38px]"
138+
addOnLeading={
139+
<>{selectedCurrency.value ? getCurrencySymbol("en", selectedCurrency.value) : ""}</>
140+
}
141+
addOnSuffix={currency.toUpperCase()}
142+
addOnClassname="h-[38px]"
143+
step="0.01"
144+
min="0.5"
145+
type="number"
146+
required
147+
placeholder="Price"
148+
disabled={disabled}
149+
onChange={(e) => {
150+
setAppData("price", convertToSmallestCurrencyUnit(Number(e.target.value), currency));
151+
}}
152+
value={price > 0 ? convertFromSmallestToPresentableCurrencyUnit(price, currency) : undefined}
153+
/>
154+
)}
113155
</div>
114156
<div className="mt-5 w-60">
115157
<label className="text-default mb-1 block text-sm font-medium" htmlFor="currency">

packages/app-store/stripepayment/zod.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,5 @@
1-
import { z } from "zod";
2-
31
import { RefundPolicy } from "@calcom/lib/payment/types";
4-
2+
import { z } from "zod";
53
import { eventTypeAppCardZod } from "../eventTypeAppCardZod";
64
import { paymentOptions } from "./lib/constants";
75

@@ -19,6 +17,7 @@ export const autoChargeNoShowFeeTimeUnitEnum = z.enum(["minutes", "hours", "days
1917
export const appDataSchema = eventTypeAppCardZod.merge(
2018
z.object({
2119
price: z.number(),
20+
pricePerDuration: z.record(z.string(), z.number()).optional(),
2221
currency: z.string(),
2322
paymentOption: paymentOptionEnum.optional(),
2423
enabled: z.boolean().optional(),

packages/app-store/types.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@ export type EventTypeAppSettingsComponentProps = {
8585
setAppData: SetAppData;
8686
disabled?: boolean;
8787
slug: string;
88+
eventTypeFormMetadata?: z.infer<typeof EventTypeFormMetadataSchema>;
8889
};
8990

9091
export type EventTypeAppCardComponent = React.FC<EventTypeAppCardComponentProps>;

packages/features/bookings/lib/handlePayment.test.ts

Lines changed: 43 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
1-
import { describe, expect, it, vi, beforeEach } from "vitest";
2-
1+
import { beforeEach, describe, expect, it, vi } from "vitest";
32
import { handlePayment } from "./handlePayment";
43

54
vi.mock("@calcom/app-store/zod-utils", () => ({
@@ -51,6 +50,10 @@ describe("handlePayment", () => {
5150
stripe: {
5251
enabled: true,
5352
price: 1000,
53+
pricePerDuration: {
54+
"30": 1000,
55+
"60": 2000,
56+
},
5457
currency: "USD",
5558
paymentOption: "ON_BOOKING",
5659
},
@@ -289,4 +292,42 @@ describe("handlePayment", () => {
289292

290293
expect(result?.amount).toBe(3000); // Base 1000 cents + ($20 * 100 cents)
291294
});
295+
296+
it("should calculate total amount based on duration when pricePerDuration is provided", async () => {
297+
const mockEvent60Min = {
298+
...mockEvent,
299+
length: 60,
300+
};
301+
302+
const result = await handlePayment({
303+
evt: mockEvent60Min,
304+
selectedEventType: mockEventType,
305+
paymentAppCredentials: mockPaymentCredentials,
306+
booking: mockBooking,
307+
bookerName: "John Doe",
308+
bookerEmail: "john@example.com",
309+
bookingFields: [],
310+
});
311+
312+
expect(result?.amount).toBe(2000); // the price for 60 min
313+
});
314+
315+
it("should calculate total amount based on base price when pricePerDuration doesn't have the specific duration", async () => {
316+
const mockEvent45Min = {
317+
...mockEvent,
318+
length: 45,
319+
};
320+
321+
const result = await handlePayment({
322+
evt: mockEvent45Min,
323+
selectedEventType: mockEventType,
324+
paymentAppCredentials: mockPaymentCredentials,
325+
booking: mockBooking,
326+
bookerName: "John Doe",
327+
bookerEmail: "john@example.com",
328+
bookingFields: [],
329+
});
330+
331+
expect(result?.amount).toBe(1000); // fallbacks to base price
332+
});
292333
});

packages/features/bookings/lib/handlePayment.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { eventTypeMetaDataSchemaWithTypedApps } from "@calcom/app-store/zod-util
44
import type { Fields } from "@calcom/features/bookings/lib/getBookingFields";
55
import { fieldTypesConfigMap } from "@calcom/features/form-builder/fieldTypes";
66
import { convertToSmallestCurrencyUnit } from "@calcom/lib/currencyConversions";
7-
import type { AppCategories, Prisma, EventType } from "@calcom/prisma/client";
7+
import type { AppCategories, EventType, Prisma } from "@calcom/prisma/client";
88
import type { CalendarEvent } from "@calcom/types/Calendar";
99
import type { IAbstractPaymentService } from "@calcom/types/PaymentService";
1010

@@ -73,7 +73,9 @@ const handlePayment = async ({
7373
// Ensure we have a valid currency - fallback to USD if undefined
7474
const currency = paymentCurrency || "USD";
7575

76-
let totalAmount = apps?.[paymentAppCredentials.appId].price || 0;
76+
const appData = apps?.[paymentAppCredentials.appId];
77+
const pricePerDuration = evt.length != null ? appData?.pricePerDuration?.[evt.length] : undefined;
78+
let totalAmount = pricePerDuration ?? appData?.price ?? 0;
7779

7880
if ((bookingFields || [])?.length > 0) {
7981
let addonsPrice = 0;

0 commit comments

Comments
 (0)