feat/payment-service-6438-cal-767 (#6677)
* WIP paymentService * Changes for payment Service * Fix for stripe payment flow * Remove logs/comments * Refactored refund for stripe app * Move stripe handlePayment to own lib * Move stripe delete payments to paymentService * lint fix * Change handleRefundError as generic function * remove log * remove logs * remove logs * Return stripe default export to lib/server * Fixing types * Fix types * Upgrades typescript * Update yarn lock * Typings * Hotfix: ping,riverside,whereby and around not showing up in list (#6712) * Hotfix: ping,riverside,whereby and around not showing up in list (#6712) (#6713) * Adds deployment settings to DB (#6706) * WIP * Adds DeploymentTheme * Add missing migrations * Adds client extensions for deployment * Cleanup * Revert "lint fix" This reverts commit e1a2e4a357e58e6673c47399888ae2e00d1351a6. * Add validation * Revert changes removed in force push * Removing abstract class and just leaving interface implementation * Fix types for handlePayments * Fix payment test appStore import * Fix stripe metadata in event type * Move migration to separate PR * Revert "Move migration to separate PR" This reverts commit 48aa64e0724a522d3cc2fefaaaee5792ee9cd9e6. * Update packages/prisma/migrations/20230125175109_remove_type_from_payment_and_add_app_relationship/migration.sql Co-authored-by: Omar López <zomars@me.com> --------- Co-authored-by: zomars <zomars@me.com> Co-authored-by: Hariom Balhara <hariombalhara@gmail.com>
This commit is contained in:
co-authored by
zomars
Hariom Balhara
parent
1ee3783db3
commit
2f628a17df
@@ -53,7 +53,7 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
const [viewRecordingsDialogIsOpen, setViewRecordingsDialogIsOpen] = useState<boolean>(false);
|
||||
const mutation = trpc.viewer.bookings.confirm.useMutation({
|
||||
onSuccess: (data) => {
|
||||
if (data.status === BookingStatus.REJECTED) {
|
||||
if (data?.status === BookingStatus.REJECTED) {
|
||||
setRejectionDialogIsOpen(false);
|
||||
showToast(t("booking_rejection_success"), "success");
|
||||
} else {
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
import DatePicker from "@calcom/features/calendars/DatePicker";
|
||||
import CustomBranding from "@calcom/lib/CustomBranding";
|
||||
import classNames from "@calcom/lib/classNames";
|
||||
import getStripeAppData from "@calcom/lib/getStripeAppData";
|
||||
import getPaymentAppData from "@calcom/lib/getPaymentAppData";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import useTheme from "@calcom/lib/hooks/useTheme";
|
||||
import notEmpty from "@calcom/lib/notEmpty";
|
||||
@@ -291,7 +291,7 @@ const AvailabilityPage = ({ profile, eventType, ...restProps }: Props) => {
|
||||
() => <TimezoneDropdown timeZone={timeZone} onChangeTimeZone={setTimeZone} />,
|
||||
[timeZone]
|
||||
);
|
||||
const stripeAppData = getStripeAppData(eventType);
|
||||
const paymentAppData = getPaymentAppData(eventType);
|
||||
const rainbowAppData = getEventTypeAppData(eventType, "rainbow") || {};
|
||||
const rawSlug = profile.slug ? profile.slug.split("/") : [];
|
||||
if (rawSlug.length > 1) rawSlug.pop(); //team events have team name as slug, but user events have [user]/[type] as slug.
|
||||
@@ -379,14 +379,14 @@ const AvailabilityPage = ({ profile, eventType, ...restProps }: Props) => {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{stripeAppData.price > 0 && (
|
||||
{paymentAppData.price > 0 && (
|
||||
<p className="-ml-2 px-2 text-sm font-medium">
|
||||
<FiCreditCard className="ml-[2px] -mt-1 inline-block h-4 w-4 ltr:mr-[10px] rtl:ml-[10px]" />
|
||||
<IntlProvider locale="en">
|
||||
<FormattedNumber
|
||||
value={stripeAppData.price / 100.0}
|
||||
value={paymentAppData.price / 100.0}
|
||||
style="currency"
|
||||
currency={stripeAppData.currency.toUpperCase()}
|
||||
currency={paymentAppData.currency?.toUpperCase()}
|
||||
/>
|
||||
</IntlProvider>
|
||||
</p>
|
||||
|
||||
@@ -32,7 +32,7 @@ import {
|
||||
import CustomBranding from "@calcom/lib/CustomBranding";
|
||||
import classNames from "@calcom/lib/classNames";
|
||||
import { APP_NAME } from "@calcom/lib/constants";
|
||||
import getStripeAppData from "@calcom/lib/getStripeAppData";
|
||||
import getPaymentAppData from "@calcom/lib/getPaymentAppData";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import useTheme from "@calcom/lib/hooks/useTheme";
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
@@ -122,7 +122,7 @@ const BookingPage = ({
|
||||
}),
|
||||
{}
|
||||
);
|
||||
const stripeAppData = getStripeAppData(eventType);
|
||||
const paymentAppData = getPaymentAppData(eventType);
|
||||
// Define duration now that we support multiple duration eventTypes
|
||||
let duration = eventType.length;
|
||||
if (
|
||||
@@ -148,6 +148,7 @@ const BookingPage = ({
|
||||
const mutation = useMutation(createBooking, {
|
||||
onSuccess: async (responseData) => {
|
||||
const { uid, paymentUid } = responseData;
|
||||
|
||||
if (paymentUid) {
|
||||
return await router.push(
|
||||
createPaymentLink({
|
||||
@@ -557,14 +558,14 @@ const BookingPage = ({
|
||||
{showEventTypeDetails && (
|
||||
<div className="sm:dark:border-darkgray-300 dark:text-darkgray-600 flex flex-col px-6 pt-6 pb-0 text-gray-600 sm:w-1/2 sm:border-r sm:pb-6">
|
||||
<BookingDescription isBookingPage profile={profile} eventType={eventType}>
|
||||
{stripeAppData.price > 0 && (
|
||||
{paymentAppData.price > 0 && (
|
||||
<p className="text-bookinglight -ml-2 px-2 text-sm ">
|
||||
<FiCreditCard className="ml-[2px] -mt-1 inline-block h-4 w-4 ltr:mr-[10px] rtl:ml-[10px]" />
|
||||
<IntlProvider locale="en">
|
||||
<FormattedNumber
|
||||
value={stripeAppData.price / 100.0}
|
||||
value={paymentAppData.price / 100.0}
|
||||
style="currency"
|
||||
currency={stripeAppData.currency.toUpperCase()}
|
||||
currency={paymentAppData?.currency?.toUpperCase()}
|
||||
/>
|
||||
</IntlProvider>
|
||||
</p>
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { EventTypeSetupProps } from "pages/event-types/[type]";
|
||||
|
||||
import getStripeAppData from "@calcom/lib/getStripeAppData";
|
||||
import getPaymentAppData from "@calcom/lib/getPaymentAppData";
|
||||
|
||||
import RecurringEventController from "./RecurringEventController";
|
||||
|
||||
export const EventRecurringTab = ({ eventType }: Pick<EventTypeSetupProps, "eventType">) => {
|
||||
const stripeAppData = getStripeAppData(eventType);
|
||||
const paymentAppData = getPaymentAppData(eventType);
|
||||
|
||||
const requirePayment = stripeAppData.price > 0;
|
||||
const requirePayment = paymentAppData.price > 0;
|
||||
|
||||
return (
|
||||
<div className="">
|
||||
|
||||
@@ -136,7 +136,7 @@ const Item = ({ type, group, readOnly }: { type: EventType; group: EventTypeGrou
|
||||
)}
|
||||
</div>
|
||||
<EventTypeDescription
|
||||
// @ts-expect-error FIXME We have a type mismtach here @hariombalhara @sean-brydon
|
||||
// @ts-expect-error FIXME: We have a type mismatch here @hariombalhara @sean-brydon
|
||||
eventType={type}
|
||||
/>
|
||||
</Link>
|
||||
|
||||
@@ -22,7 +22,11 @@ export const createPaymentsFixture = (page: Page) => {
|
||||
currency: "usd",
|
||||
success,
|
||||
refunded,
|
||||
type: "STRIPE",
|
||||
app: {
|
||||
connect: {
|
||||
slug: "stripe",
|
||||
},
|
||||
},
|
||||
data: {},
|
||||
externalId: "DEMO_PAYMENT_FROM_DB_" + Date.now(),
|
||||
booking: {
|
||||
|
||||
@@ -113,7 +113,22 @@ test.describe("Reschedule Tests", async () => {
|
||||
status: BookingStatus.CANCELLED,
|
||||
paid: false,
|
||||
});
|
||||
|
||||
await prisma.eventType.update({
|
||||
where: {
|
||||
id: eventType.id,
|
||||
},
|
||||
data: {
|
||||
metadata: {
|
||||
apps: {
|
||||
stripe: {
|
||||
price: 20000,
|
||||
enabled: true,
|
||||
currency: "usd",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const payment = await payments.create(booking.id);
|
||||
await page.goto(`/${user.username}/${eventType.slug}?rescheduleUid=${booking.uid}`);
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Dispatch, SetStateAction } from "react";
|
||||
|
||||
import { getQueryBuilderConfig } from "../lib/getQueryBuilderConfig";
|
||||
import isRouterLinkedField from "../lib/isRouterLinkedField";
|
||||
import { Response, SerializableForm } from "../types/types";
|
||||
import { SerializableForm, Response } from "../types/types";
|
||||
|
||||
type Props = {
|
||||
form: SerializableForm<App_RoutingForms_Form>;
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export * as api from "./api";
|
||||
export * as lib from "./lib";
|
||||
export { metadata } from "./_metadata";
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
import { Booking, Payment, Prisma } from "@prisma/client";
|
||||
import Stripe from "stripe";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import z from "zod";
|
||||
|
||||
import { sendAwaitingPaymentEmail } from "@calcom/emails";
|
||||
import { IAbstractPaymentService } from "@calcom/lib/PaymentService";
|
||||
import { getErrorFromUnknown } from "@calcom/lib/errors";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { CalendarEvent } from "@calcom/types/Calendar";
|
||||
|
||||
import { createPaymentLink } from "./client";
|
||||
import { StripePaymentData } from "./server";
|
||||
|
||||
const stripeCredentialKeysSchema = z.object({
|
||||
stripe_user_id: z.string(),
|
||||
default_currency: z.string(),
|
||||
stripe_publishable_key: z.string(),
|
||||
});
|
||||
|
||||
const stripeAppKeysSchema = z.object({
|
||||
client_id: z.string(),
|
||||
payment_fee_fixed: z.number(),
|
||||
payment_fee_percentage: z.number(),
|
||||
});
|
||||
|
||||
export class PaymentService implements IAbstractPaymentService {
|
||||
private stripe: Stripe;
|
||||
private credentials: z.infer<typeof stripeCredentialKeysSchema>;
|
||||
|
||||
constructor(credentials: { key: Prisma.JsonValue }) {
|
||||
// parse credentials key
|
||||
this.credentials = stripeCredentialKeysSchema.parse(credentials.key);
|
||||
this.stripe = new Stripe(process.env.STRIPE_PRIVATE_KEY || "", {
|
||||
apiVersion: "2020-08-27",
|
||||
});
|
||||
}
|
||||
|
||||
async create(
|
||||
payment: Pick<Prisma.PaymentUncheckedCreateInput, "amount" | "currency">,
|
||||
bookingId: Booking["id"]
|
||||
) {
|
||||
try {
|
||||
// Load stripe keys
|
||||
const stripeAppKeys = await prisma?.app.findFirst({
|
||||
select: {
|
||||
keys: true,
|
||||
},
|
||||
where: {
|
||||
slug: "stripe",
|
||||
},
|
||||
});
|
||||
|
||||
// Parse keys with zod
|
||||
const { client_id, payment_fee_fixed, payment_fee_percentage } = stripeAppKeysSchema.parse(
|
||||
stripeAppKeys?.keys
|
||||
);
|
||||
const paymentFee = Math.round(payment.amount * payment_fee_percentage + payment_fee_fixed);
|
||||
|
||||
const params: Stripe.PaymentIntentCreateParams = {
|
||||
amount: payment.amount,
|
||||
currency: this.credentials.default_currency,
|
||||
payment_method_types: ["card"],
|
||||
application_fee_amount: paymentFee,
|
||||
};
|
||||
|
||||
const paymentIntent = await this.stripe.paymentIntents.create(params, {
|
||||
stripeAccount: this.credentials.stripe_user_id,
|
||||
});
|
||||
|
||||
const paymentData = await prisma?.payment.create({
|
||||
data: {
|
||||
uid: uuidv4(),
|
||||
app: {
|
||||
connect: {
|
||||
slug: "stripe",
|
||||
},
|
||||
},
|
||||
booking: {
|
||||
connect: {
|
||||
id: bookingId,
|
||||
},
|
||||
},
|
||||
amount: payment.amount,
|
||||
currency: payment.currency,
|
||||
externalId: paymentIntent.id,
|
||||
|
||||
data: Object.assign({}, paymentIntent, {
|
||||
stripe_publishable_key: this.credentials.stripe_publishable_key,
|
||||
stripeAccount: this.credentials.stripe_user_id,
|
||||
}) as unknown as Prisma.InputJsonValue,
|
||||
fee: paymentFee,
|
||||
refunded: false,
|
||||
success: false,
|
||||
},
|
||||
});
|
||||
if (!paymentData) {
|
||||
throw new Error();
|
||||
}
|
||||
return paymentData;
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
throw new Error("Payment could not be created");
|
||||
}
|
||||
}
|
||||
|
||||
async update(): Promise<Payment> {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
|
||||
async refund(paymentId: Payment["id"]): Promise<Payment> {
|
||||
try {
|
||||
const payment = await prisma.payment.findFirst({
|
||||
where: {
|
||||
id: paymentId,
|
||||
success: true,
|
||||
refunded: false,
|
||||
},
|
||||
});
|
||||
if (!payment) {
|
||||
throw new Error("Payment not found");
|
||||
}
|
||||
|
||||
const refund = await this.stripe.refunds.create(
|
||||
{
|
||||
payment_intent: payment.externalId,
|
||||
},
|
||||
{ stripeAccount: (payment.data as unknown as StripePaymentData)["stripeAccount"] }
|
||||
);
|
||||
|
||||
if (!refund || refund.status === "failed") {
|
||||
throw new Error("Refund failed");
|
||||
}
|
||||
|
||||
const updatedPayment = await prisma.payment.update({
|
||||
where: {
|
||||
id: payment.id,
|
||||
},
|
||||
data: {
|
||||
refunded: true,
|
||||
},
|
||||
});
|
||||
return updatedPayment;
|
||||
} catch (e) {
|
||||
const err = getErrorFromUnknown(e);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async afterPayment(
|
||||
event: CalendarEvent,
|
||||
booking: {
|
||||
user: { email: string | null; name: string | null; timeZone: string } | null;
|
||||
id: number;
|
||||
startTime: { toISOString: () => string };
|
||||
uid: string;
|
||||
},
|
||||
paymentData: Payment
|
||||
): Promise<void> {
|
||||
await sendAwaitingPaymentEmail({
|
||||
...event,
|
||||
paymentInfo: {
|
||||
link: createPaymentLink({
|
||||
paymentUid: paymentData.uid,
|
||||
name: booking.user?.name,
|
||||
email: booking.user?.email,
|
||||
date: booking.startTime.toISOString(),
|
||||
}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async deletePayment(paymentId: Payment["id"]): Promise<boolean> {
|
||||
try {
|
||||
const payment = await prisma.payment.findFirst({
|
||||
where: {
|
||||
id: paymentId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!payment) {
|
||||
throw new Error("Payment not found");
|
||||
}
|
||||
const stripeAccount = (payment.data as unknown as StripePaymentData).stripeAccount;
|
||||
|
||||
if (!stripeAccount) {
|
||||
throw new Error("Stripe account not found");
|
||||
}
|
||||
// Expire all current sessions
|
||||
const sessions = await this.stripe.checkout.sessions.list(
|
||||
{
|
||||
payment_intent: payment.externalId,
|
||||
},
|
||||
{ stripeAccount }
|
||||
);
|
||||
for (const session of sessions.data) {
|
||||
await this.stripe.checkout.sessions.expire(session.id, { stripeAccount });
|
||||
}
|
||||
// Then cancel the payment intent
|
||||
await this.stripe.paymentIntents.cancel(payment.externalId, { stripeAccount });
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
getPaymentPaidStatus(): Promise<string> {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
|
||||
getPaymentDetails(): Promise<Payment> {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
}
|
||||
@@ -1 +1,2 @@
|
||||
export * from "./constants";
|
||||
export * from "./PaymentService";
|
||||
|
||||
@@ -1,19 +1,7 @@
|
||||
import { PaymentType, Prisma } from "@prisma/client";
|
||||
import Stripe from "stripe";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { z } from "zod";
|
||||
|
||||
import { sendAwaitingPaymentEmail, sendOrganizerPaymentRefundFailedEmail } from "@calcom/emails";
|
||||
import { getErrorFromUnknown } from "@calcom/lib/errors";
|
||||
import getStripeAppData from "@calcom/lib/getStripeAppData";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { EventTypeModel } from "@calcom/prisma/zod";
|
||||
import type { CalendarEvent } from "@calcom/types/Calendar";
|
||||
|
||||
import getAppKeysFromSlug from "../../_utils/getAppKeysFromSlug";
|
||||
import { createPaymentLink } from "./client";
|
||||
|
||||
export type PaymentData = Stripe.Response<Stripe.PaymentIntent> & {
|
||||
export type StripePaymentData = Stripe.Response<Stripe.PaymentIntent> & {
|
||||
stripe_publishable_key: string;
|
||||
stripeAccount: string;
|
||||
};
|
||||
@@ -40,170 +28,4 @@ const stripe = new Stripe(stripePrivateKey, {
|
||||
apiVersion: "2020-08-27",
|
||||
});
|
||||
|
||||
const stripeKeysSchema = z.object({
|
||||
payment_fee_fixed: z.number(),
|
||||
payment_fee_percentage: z.number(),
|
||||
});
|
||||
|
||||
const stripeCredentialSchema = z.object({
|
||||
stripe_user_id: z.string(),
|
||||
stripe_publishable_key: z.string(),
|
||||
});
|
||||
|
||||
export async function handlePayment(
|
||||
evt: CalendarEvent,
|
||||
selectedEventType: Pick<z.infer<typeof EventTypeModel>, "price" | "currency" | "metadata">,
|
||||
stripeCredential: { key: Prisma.JsonValue },
|
||||
booking: {
|
||||
user: { email: string | null; name: string | null; timeZone: string } | null;
|
||||
id: number;
|
||||
startTime: { toISOString: () => string };
|
||||
uid: string;
|
||||
}
|
||||
) {
|
||||
const appKeys = await getAppKeysFromSlug("stripe");
|
||||
const { payment_fee_fixed, payment_fee_percentage } = stripeKeysSchema.parse(appKeys);
|
||||
const stripeAppData = getStripeAppData(selectedEventType);
|
||||
const paymentFee = Math.round(stripeAppData.price * payment_fee_percentage + payment_fee_fixed);
|
||||
const { stripe_user_id, stripe_publishable_key } = stripeCredentialSchema.parse(stripeCredential.key);
|
||||
|
||||
const params: Stripe.PaymentIntentCreateParams = {
|
||||
amount: stripeAppData.price,
|
||||
currency: stripeAppData.currency,
|
||||
payment_method_types: ["card"],
|
||||
application_fee_amount: paymentFee,
|
||||
};
|
||||
|
||||
const paymentIntent = await stripe.paymentIntents.create(params, { stripeAccount: stripe_user_id });
|
||||
|
||||
const payment = await prisma.payment.create({
|
||||
data: {
|
||||
type: PaymentType.STRIPE,
|
||||
uid: uuidv4(),
|
||||
booking: {
|
||||
connect: {
|
||||
id: booking.id,
|
||||
},
|
||||
},
|
||||
amount: stripeAppData.price,
|
||||
fee: paymentFee,
|
||||
currency: stripeAppData.currency,
|
||||
success: false,
|
||||
refunded: false,
|
||||
data: Object.assign({}, paymentIntent, {
|
||||
stripe_publishable_key,
|
||||
stripeAccount: stripe_user_id,
|
||||
}) /* We should treat this */ as PaymentData /* but Prisma doesn't know how to handle it, so it we treat it */ as unknown /* and then */ as Prisma.InputJsonValue,
|
||||
externalId: paymentIntent.id,
|
||||
},
|
||||
});
|
||||
|
||||
await sendAwaitingPaymentEmail({
|
||||
...evt,
|
||||
paymentInfo: {
|
||||
link: createPaymentLink({
|
||||
paymentUid: payment.uid,
|
||||
name: booking.user?.name,
|
||||
email: booking.user?.email,
|
||||
date: booking.startTime.toISOString(),
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
return payment;
|
||||
}
|
||||
|
||||
export async function refund(
|
||||
booking: {
|
||||
id: number;
|
||||
uid: string;
|
||||
startTime: Date;
|
||||
payment: {
|
||||
id: number;
|
||||
success: boolean;
|
||||
refunded: boolean;
|
||||
externalId: string;
|
||||
data: Prisma.JsonValue;
|
||||
type: PaymentType;
|
||||
}[];
|
||||
},
|
||||
calEvent: CalendarEvent
|
||||
) {
|
||||
try {
|
||||
const payment = booking.payment.find((e) => e.success && !e.refunded);
|
||||
if (!payment) return;
|
||||
|
||||
if (payment.type !== PaymentType.STRIPE) {
|
||||
await handleRefundError({
|
||||
event: calEvent,
|
||||
reason: "cannot refund non Stripe payment",
|
||||
paymentId: "unknown",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const refund = await stripe.refunds.create(
|
||||
{
|
||||
payment_intent: payment.externalId,
|
||||
},
|
||||
{ stripeAccount: (payment.data as unknown as PaymentData)["stripeAccount"] }
|
||||
);
|
||||
|
||||
if (!refund || refund.status === "failed") {
|
||||
await handleRefundError({
|
||||
event: calEvent,
|
||||
reason: refund?.failure_reason || "unknown",
|
||||
paymentId: payment.externalId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await prisma.payment.update({
|
||||
where: {
|
||||
id: payment.id,
|
||||
},
|
||||
data: {
|
||||
refunded: true,
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
const err = getErrorFromUnknown(e);
|
||||
console.error(err, "Refund failed");
|
||||
await handleRefundError({
|
||||
event: calEvent,
|
||||
reason: err.message || "unknown",
|
||||
paymentId: "unknown",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const closePayments = async (paymentIntentId: string, stripeAccount: string) => {
|
||||
try {
|
||||
// Expire all current sessions
|
||||
const sessions = await stripe.checkout.sessions.list(
|
||||
{
|
||||
payment_intent: paymentIntentId,
|
||||
},
|
||||
{ stripeAccount }
|
||||
);
|
||||
for (const session of sessions.data) {
|
||||
await stripe.checkout.sessions.expire(session.id, { stripeAccount });
|
||||
}
|
||||
// Then cancel the payment intent
|
||||
await stripe.paymentIntents.cancel(paymentIntentId, { stripeAccount });
|
||||
return;
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
async function handleRefundError(opts: { event: CalendarEvent; reason: string; paymentId: string }) {
|
||||
console.error(`refund failed: ${opts.reason} for booking '${opts.event.uid}'`);
|
||||
await sendOrganizerPaymentRefundFailedEmail({
|
||||
...opts.event,
|
||||
paymentInfo: { reason: opts.reason, id: opts.paymentId },
|
||||
});
|
||||
}
|
||||
|
||||
export default stripe;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
BookingStatus,
|
||||
MembershipRole,
|
||||
Prisma,
|
||||
PrismaPromise,
|
||||
WebhookTriggerEvents,
|
||||
@@ -8,10 +9,10 @@ import {
|
||||
} from "@prisma/client";
|
||||
import { NextApiRequest } from "next";
|
||||
|
||||
import appStore from "@calcom/app-store";
|
||||
import { getCalendar } from "@calcom/app-store/_utils/getCalendar";
|
||||
import { FAKE_DAILY_CREDENTIAL } from "@calcom/app-store/dailyvideo/lib/VideoApiAdapter";
|
||||
import { DailyLocationType } from "@calcom/app-store/locations";
|
||||
import { refund } from "@calcom/app-store/stripepayment/lib/server";
|
||||
import { cancelScheduledJobs } from "@calcom/app-store/zapier/lib/nodeScheduler";
|
||||
import { deleteMeeting } from "@calcom/core/videoClient";
|
||||
import dayjs from "@calcom/dayjs";
|
||||
@@ -23,6 +24,7 @@ import getWebhooks from "@calcom/features/webhooks/lib/getWebhooks";
|
||||
import sendPayload, { EventTypeInfo } from "@calcom/features/webhooks/lib/sendPayload";
|
||||
import { isPrismaObjOrUndefined, parseRecurringEvent } from "@calcom/lib";
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
import { handleRefundError } from "@calcom/lib/payment/handleRefundError";
|
||||
import { getTranslation } from "@calcom/lib/server/i18n";
|
||||
import prisma, { bookingMinimalSelect } from "@calcom/prisma";
|
||||
import { schemaBookingCancelParams } from "@calcom/prisma/zod-utils";
|
||||
@@ -65,6 +67,8 @@ async function handler(req: NextApiRequest & { userId?: number }) {
|
||||
paid: true,
|
||||
eventType: {
|
||||
select: {
|
||||
owner: true,
|
||||
teamId: true,
|
||||
recurringEvent: true,
|
||||
title: true,
|
||||
description: true,
|
||||
@@ -377,7 +381,76 @@ async function handler(req: NextApiRequest & { userId?: number }) {
|
||||
uid: bookingToDelete.uid ?? "",
|
||||
destinationCalendar: bookingToDelete?.destinationCalendar || bookingToDelete?.user.destinationCalendar,
|
||||
};
|
||||
await refund(bookingToDelete, evt);
|
||||
|
||||
const successPayment = bookingToDelete.payment.find((payment) => payment.success);
|
||||
if (!successPayment) {
|
||||
throw new Error("Cannot reject a booking without a successful payment");
|
||||
}
|
||||
|
||||
let eventTypeOwnerId;
|
||||
if (bookingToDelete.eventType?.owner) {
|
||||
eventTypeOwnerId = bookingToDelete.eventType.owner.id;
|
||||
} else if (bookingToDelete.eventType?.teamId) {
|
||||
const teamOwner = await prisma.membership.findFirst({
|
||||
where: {
|
||||
teamId: bookingToDelete.eventType.teamId,
|
||||
role: MembershipRole.OWNER,
|
||||
},
|
||||
select: {
|
||||
userId: true,
|
||||
},
|
||||
});
|
||||
eventTypeOwnerId = teamOwner?.userId;
|
||||
}
|
||||
|
||||
if (!eventTypeOwnerId) {
|
||||
throw new Error("Event Type owner not found for obtaining payment app credentials");
|
||||
}
|
||||
|
||||
const paymentAppCredentials = await prisma.credential.findMany({
|
||||
where: {
|
||||
userId: eventTypeOwnerId,
|
||||
appId: successPayment.appId,
|
||||
},
|
||||
select: {
|
||||
key: true,
|
||||
appId: true,
|
||||
app: {
|
||||
select: {
|
||||
categories: true,
|
||||
dirName: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const paymentAppCredential = paymentAppCredentials.find((credential) => {
|
||||
return credential.appId === successPayment.appId;
|
||||
});
|
||||
|
||||
if (!paymentAppCredential) {
|
||||
throw new Error("Payment app credentials not found");
|
||||
}
|
||||
|
||||
// Posible to refactor TODO:
|
||||
const paymentApp = appStore[paymentAppCredential?.app?.dirName as keyof typeof appStore];
|
||||
if (!(paymentApp && "lib" in paymentApp && "PaymentService" in paymentApp.lib)) {
|
||||
console.warn(`payment App service of type ${paymentApp} is not implemented`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const PaymentService = paymentApp.lib.PaymentService;
|
||||
const paymentInstance = new PaymentService(paymentAppCredential);
|
||||
try {
|
||||
await paymentInstance.refund(successPayment.id);
|
||||
} catch (error) {
|
||||
await handleRefundError({
|
||||
event: evt,
|
||||
reason: error?.toString() || "unknown",
|
||||
paymentId: successPayment.externalId,
|
||||
});
|
||||
}
|
||||
|
||||
await prisma.booking.update({
|
||||
where: {
|
||||
id: bookingToDelete.id,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
App,
|
||||
BookingStatus,
|
||||
Credential,
|
||||
EventTypeCustomInput,
|
||||
@@ -18,8 +19,7 @@ import { metadata as GoogleMeetMetadata } from "@calcom/app-store/googlevideo/_m
|
||||
import { getLocationValueForDB, LocationObject } from "@calcom/app-store/locations";
|
||||
import { MeetLocationType } from "@calcom/app-store/locations";
|
||||
import { handleEthSignature } from "@calcom/app-store/rainbow/utils/ethereum";
|
||||
import { handlePayment } from "@calcom/app-store/stripepayment/lib/server";
|
||||
import { getEventTypeAppData } from "@calcom/app-store/utils";
|
||||
import { EventTypeAppsList, getEventTypeAppData } from "@calcom/app-store/utils";
|
||||
import { cancelScheduledJobs, scheduleTrigger } from "@calcom/app-store/zapier/lib/nodeScheduler";
|
||||
import EventManager from "@calcom/core/EventManager";
|
||||
import { getEventName } from "@calcom/core/event";
|
||||
@@ -37,10 +37,11 @@ import getWebhooks from "@calcom/features/webhooks/lib/getWebhooks";
|
||||
import { isPrismaObjOrUndefined, parseRecurringEvent } from "@calcom/lib";
|
||||
import { getDefaultEvent, getGroupName, getUsernameList } from "@calcom/lib/defaultEvents";
|
||||
import { getErrorFromUnknown } from "@calcom/lib/errors";
|
||||
import getStripeAppData from "@calcom/lib/getStripeAppData";
|
||||
import getPaymentAppData from "@calcom/lib/getPaymentAppData";
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
import isOutOfBounds, { BookingDateInPastError } from "@calcom/lib/isOutOfBounds";
|
||||
import logger from "@calcom/lib/logger";
|
||||
import { handlePayment } from "@calcom/lib/payment/handlePayment";
|
||||
import { checkBookingLimits, getLuckyUser } from "@calcom/lib/server";
|
||||
import { getTranslation } from "@calcom/lib/server/i18n";
|
||||
import { updateWebUser as syncServicesUpdateWebUser } from "@calcom/lib/sync/SyncServiceManager";
|
||||
@@ -63,6 +64,15 @@ const log = logger.getChildLogger({ prefix: ["[api] book:user"] });
|
||||
type User = Prisma.UserGetPayload<typeof userSelect>;
|
||||
type BufferedBusyTimes = BufferedBusyTime[];
|
||||
|
||||
interface IEventTypePaymentCredentialType {
|
||||
appId: EventTypeAppsList;
|
||||
app: {
|
||||
categories: App["categories"];
|
||||
dirName: string;
|
||||
};
|
||||
key: Prisma.JsonValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* Refreshes a Credential with fresh data from the database.
|
||||
*
|
||||
@@ -324,7 +334,7 @@ async function handler(req: NextApiRequest & { userId?: number | undefined }) {
|
||||
|
||||
if (!eventType) throw new HttpError({ statusCode: 404, message: "eventType.notFound" });
|
||||
|
||||
const stripeAppData = getStripeAppData(eventType);
|
||||
const paymentAppData = getPaymentAppData(eventType);
|
||||
|
||||
// Check if required custom inputs exist
|
||||
handleCustomInputs(eventType.customInputs as EventTypeCustomInput[], reqBody.customInputs);
|
||||
@@ -634,18 +644,48 @@ async function handler(req: NextApiRequest & { userId?: number | undefined }) {
|
||||
const eventManager = new EventManager({ ...organizerUser, credentials });
|
||||
await eventManager.updateCalendarAttendees(evt, booking);
|
||||
|
||||
if (!Number.isNaN(stripeAppData.price) && stripeAppData.price > 0 && !!booking) {
|
||||
const [firstStripeCredential] = organizerUser.credentials.filter(
|
||||
(cred) => cred.type == "stripe_payment"
|
||||
if (!Number.isNaN(paymentAppData.price) && paymentAppData.price > 0 && !!booking) {
|
||||
const credentialPaymentAppCategories = await prisma.credential.findMany({
|
||||
where: {
|
||||
userId: organizerUser.id,
|
||||
app: {
|
||||
categories: {
|
||||
hasSome: ["payment"],
|
||||
},
|
||||
},
|
||||
},
|
||||
select: {
|
||||
key: true,
|
||||
appId: true,
|
||||
app: {
|
||||
select: {
|
||||
categories: true,
|
||||
dirName: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const eventTypePaymentAppCredential = credentialPaymentAppCategories.find((credential) => {
|
||||
return credential.appId === paymentAppData.appId;
|
||||
});
|
||||
|
||||
if (!eventTypePaymentAppCredential) {
|
||||
throw new HttpError({ statusCode: 400, message: "Missing payment credentials" });
|
||||
}
|
||||
if (!eventTypePaymentAppCredential?.appId) {
|
||||
throw new HttpError({ statusCode: 400, message: "Missing payment app id" });
|
||||
}
|
||||
|
||||
const payment = await handlePayment(
|
||||
evt,
|
||||
eventType,
|
||||
eventTypePaymentAppCredential as IEventTypePaymentCredentialType,
|
||||
booking
|
||||
);
|
||||
|
||||
if (!firstStripeCredential)
|
||||
throw new HttpError({ statusCode: 400, message: "Missing payment credentials" });
|
||||
|
||||
const payment = await handlePayment(evt, eventType, firstStripeCredential, booking);
|
||||
|
||||
req.statusCode = 201;
|
||||
return { ...booking, message: "Payment required", paymentUid: payment.uid };
|
||||
return { ...booking, message: "Payment required", paymentUid: payment?.uid };
|
||||
}
|
||||
|
||||
req.statusCode = 201;
|
||||
@@ -713,7 +753,7 @@ async function handler(req: NextApiRequest & { userId?: number | undefined }) {
|
||||
// Otherwise, an owner rescheduling should be always accepted.
|
||||
// Before comparing make sure that userId is set, otherwise undefined === undefined
|
||||
const userReschedulingIsOwner = userId && originalRescheduledBooking?.user?.id === userId;
|
||||
const isConfirmedByDefault = (!requiresConfirmation && !stripeAppData.price) || userReschedulingIsOwner;
|
||||
const isConfirmedByDefault = (!requiresConfirmation && !paymentAppData.price) || userReschedulingIsOwner;
|
||||
|
||||
async function createBooking() {
|
||||
if (originalRescheduledBooking) {
|
||||
@@ -807,7 +847,7 @@ async function handler(req: NextApiRequest & { userId?: number | undefined }) {
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof stripeAppData.price === "number" && stripeAppData.price > 0) {
|
||||
if (typeof paymentAppData.price === "number" && paymentAppData.price > 0) {
|
||||
/* Validate if there is any stripe_payment credential for this user */
|
||||
/* note: removes custom error message about stripe */
|
||||
await prisma.credential.findFirstOrThrow({
|
||||
@@ -939,7 +979,7 @@ async function handler(req: NextApiRequest & { userId?: number | undefined }) {
|
||||
}
|
||||
// If it's not a reschedule, doesn't require confirmation and there's no price,
|
||||
// Create a booking
|
||||
} else if (!requiresConfirmation && !stripeAppData.price) {
|
||||
} else if (!requiresConfirmation && !paymentAppData.price) {
|
||||
// Use EventManager to conditionally use all needed integrations.
|
||||
const createManager = await eventManager.create(evt);
|
||||
|
||||
@@ -1019,21 +1059,51 @@ async function handler(req: NextApiRequest & { userId?: number | undefined }) {
|
||||
}
|
||||
|
||||
if (
|
||||
!Number.isNaN(stripeAppData.price) &&
|
||||
stripeAppData.price > 0 &&
|
||||
!Number.isNaN(paymentAppData.price) &&
|
||||
paymentAppData.price > 0 &&
|
||||
!originalRescheduledBooking?.paid &&
|
||||
!!booking
|
||||
) {
|
||||
const [firstStripeCredential] = organizerUser.credentials.filter((cred) => cred.type == "stripe_payment");
|
||||
// Load credentials.app.categories
|
||||
const credentialPaymentAppCategories = await prisma.credential.findMany({
|
||||
where: {
|
||||
userId: organizerUser.id,
|
||||
app: {
|
||||
categories: {
|
||||
hasSome: ["payment"],
|
||||
},
|
||||
},
|
||||
},
|
||||
select: {
|
||||
key: true,
|
||||
appId: true,
|
||||
app: {
|
||||
select: {
|
||||
categories: true,
|
||||
dirName: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const eventTypePaymentAppCredential = credentialPaymentAppCategories.find((credential) => {
|
||||
return credential.appId === paymentAppData.appId;
|
||||
});
|
||||
|
||||
if (!firstStripeCredential)
|
||||
if (!eventTypePaymentAppCredential) {
|
||||
throw new HttpError({ statusCode: 400, message: "Missing payment credentials" });
|
||||
}
|
||||
|
||||
// Convert type of eventTypePaymentAppCredential to appId: EventTypeAppList
|
||||
if (!booking.user) booking.user = organizerUser;
|
||||
const payment = await handlePayment(evt, eventType, firstStripeCredential, booking);
|
||||
const payment = await handlePayment(
|
||||
evt,
|
||||
eventType,
|
||||
eventTypePaymentAppCredential as IEventTypePaymentCredentialType,
|
||||
booking
|
||||
);
|
||||
|
||||
req.statusCode = 201;
|
||||
return { ...booking, message: "Payment required", paymentUid: payment.uid };
|
||||
return { ...booking, message: "Payment required", paymentUid: payment?.uid };
|
||||
}
|
||||
|
||||
log.debug(`Booking ${organizerUser.username} completed`);
|
||||
@@ -1082,7 +1152,7 @@ async function handler(req: NextApiRequest & { userId?: number | undefined }) {
|
||||
eventTitle: eventType.title,
|
||||
eventDescription: eventType.description,
|
||||
requiresConfirmation: requiresConfirmation || null,
|
||||
price: stripeAppData.price,
|
||||
price: paymentAppData.price,
|
||||
currency: eventType.currency,
|
||||
length: eventType.length,
|
||||
};
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useRouter } from "next/router";
|
||||
import { stringify } from "querystring";
|
||||
import { SyntheticEvent, useEffect, useState } from "react";
|
||||
|
||||
import { PaymentData } from "@calcom/app-store/stripepayment/lib/server";
|
||||
import { StripePaymentData } from "@calcom/app-store/stripepayment/lib/server";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Button } from "@calcom/ui";
|
||||
|
||||
@@ -29,7 +29,7 @@ const CARD_OPTIONS: stripejs.StripeCardElementOptions = {
|
||||
|
||||
type Props = {
|
||||
payment: {
|
||||
data: PaymentData;
|
||||
data: StripePaymentData;
|
||||
};
|
||||
eventType: { id: number };
|
||||
user: { username: string | null };
|
||||
|
||||
@@ -6,10 +6,11 @@ import { FormattedNumber, IntlProvider } from "react-intl";
|
||||
|
||||
import { getSuccessPageLocationMessage } from "@calcom/app-store/locations";
|
||||
import getStripe from "@calcom/app-store/stripepayment/lib/client";
|
||||
import { StripePaymentData } from "@calcom/app-store/stripepayment/lib/server";
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { sdkActionManager, useIsEmbed } from "@calcom/embed-core/embed-iframe";
|
||||
import { APP_NAME, WEBSITE_URL } from "@calcom/lib/constants";
|
||||
import getStripeAppData from "@calcom/lib/getStripeAppData";
|
||||
import getPaymentAppData from "@calcom/lib/getPaymentAppData";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import useTheme from "@calcom/lib/hooks/useTheme";
|
||||
import { getIs24hClockFromLocalStorage, isBrowserLocale24h } from "@calcom/lib/timeFormat";
|
||||
@@ -26,7 +27,7 @@ const PaymentPage: FC<PaymentPageProps> = (props) => {
|
||||
const [timezone, setTimezone] = useState<string | null>(null);
|
||||
useTheme(props.profile.theme);
|
||||
const isEmbed = useIsEmbed();
|
||||
const stripeAppData = getStripeAppData(props.eventType);
|
||||
const paymentAppData = getPaymentAppData(props.eventType);
|
||||
useEffect(() => {
|
||||
let embedIframeWidth = 0;
|
||||
const _timezone = localStorage.getItem("timeOption.preferredTimeZone") || dayjs.tz.guess();
|
||||
@@ -110,9 +111,9 @@ const PaymentPage: FC<PaymentPageProps> = (props) => {
|
||||
<div className="col-span-2 mb-6">
|
||||
<IntlProvider locale="en">
|
||||
<FormattedNumber
|
||||
value={stripeAppData.price / 100.0}
|
||||
value={paymentAppData.price / 100.0}
|
||||
style="currency"
|
||||
currency={stripeAppData.currency.toUpperCase()}
|
||||
currency={paymentAppData?.currency?.toUpperCase()}
|
||||
/>
|
||||
</IntlProvider>
|
||||
</div>
|
||||
@@ -123,8 +124,9 @@ const PaymentPage: FC<PaymentPageProps> = (props) => {
|
||||
{props.payment.success && !props.payment.refunded && (
|
||||
<div className="mt-4 text-center text-gray-700 dark:text-gray-300">{t("paid")}</div>
|
||||
)}
|
||||
{!props.payment.success && (
|
||||
<Elements stripe={getStripe(props.payment.data.stripe_publishable_key)}>
|
||||
{props.payment.appId === "stripe" && !props.payment.success && (
|
||||
<Elements
|
||||
stripe={getStripe((props.payment.data as StripePaymentData).stripe_publishable_key)}>
|
||||
<PaymentComponent
|
||||
payment={props.payment}
|
||||
eventType={props.eventType}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { GetServerSidePropsContext } from "next";
|
||||
import { z } from "zod";
|
||||
|
||||
import { PaymentData } from "@calcom/app-store/stripepayment/lib/server";
|
||||
import { StripePaymentData } from "@calcom/app-store/stripepayment/lib/server";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
|
||||
import type { inferSSRProps } from "@calcom/types/inferSSRProps";
|
||||
@@ -28,6 +28,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||
uid: true,
|
||||
refunded: true,
|
||||
bookingId: true,
|
||||
appId: true,
|
||||
booking: {
|
||||
select: {
|
||||
id: true,
|
||||
@@ -81,7 +82,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||
const { data, booking: _booking, ...restPayment } = rawPayment;
|
||||
const payment = {
|
||||
...restPayment,
|
||||
data: data as unknown as PaymentData,
|
||||
data: data as unknown as StripePaymentData,
|
||||
};
|
||||
|
||||
if (!_booking) return { notFound: true };
|
||||
|
||||
@@ -4,7 +4,7 @@ import { FormattedNumber, IntlProvider } from "react-intl";
|
||||
import { z } from "zod";
|
||||
|
||||
import { classNames, parseRecurringEvent } from "@calcom/lib";
|
||||
import getStripeAppData from "@calcom/lib/getStripeAppData";
|
||||
import getPaymentAppData from "@calcom/lib/getPaymentAppData";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { baseEventTypeSelect } from "@calcom/prisma";
|
||||
import { EventTypeModel } from "@calcom/prisma/zod";
|
||||
@@ -29,7 +29,7 @@ export const EventTypeDescription = ({ eventType, className }: EventTypeDescript
|
||||
[eventType.recurringEvent]
|
||||
);
|
||||
|
||||
const stripeAppData = getStripeAppData(eventType);
|
||||
const stripeAppData = getPaymentAppData(eventType);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -80,7 +80,7 @@ export const EventTypeDescription = ({ eventType, className }: EventTypeDescript
|
||||
<FormattedNumber
|
||||
value={stripeAppData.price / 100.0}
|
||||
style="currency"
|
||||
currency={stripeAppData.currency.toUpperCase()}
|
||||
currency={stripeAppData?.currency?.toUpperCase()}
|
||||
/>
|
||||
</IntlProvider>
|
||||
</Badge>
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { Payment, Prisma, Booking } from "@prisma/client";
|
||||
|
||||
import { CalendarEvent } from "@calcom/types/Calendar";
|
||||
|
||||
export interface IAbstractPaymentService {
|
||||
create(
|
||||
payment: Pick<Prisma.PaymentUncheckedCreateInput, "amount" | "currency">,
|
||||
bookingId: Booking["id"]
|
||||
): Promise<Payment>;
|
||||
update(paymentId: Payment["id"], data: Partial<Prisma.PaymentUncheckedCreateInput>): Promise<Payment>;
|
||||
refund(paymentId: Payment["id"]): Promise<Payment>;
|
||||
getPaymentPaidStatus(): Promise<string>;
|
||||
getPaymentDetails(): Promise<Payment>;
|
||||
afterPayment(
|
||||
event: CalendarEvent,
|
||||
booking: {
|
||||
user: { email: string | null; name: string | null; timeZone: string } | null;
|
||||
id: number;
|
||||
startTime: { toISOString: () => string };
|
||||
uid: string;
|
||||
},
|
||||
paymentData: Payment
|
||||
): Promise<void>;
|
||||
deletePayment(paymentId: Payment["id"]): Promise<boolean>;
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import { LocationObject } from "@calcom/core/location";
|
||||
import { parseBookingLimit, parseRecurringEvent } from "@calcom/lib";
|
||||
import getEnabledApps from "@calcom/lib/apps/getEnabledApps";
|
||||
import { CAL_URL } from "@calcom/lib/constants";
|
||||
import getStripeAppData from "@calcom/lib/getStripeAppData";
|
||||
import getPaymentAppData from "@calcom/lib/getPaymentAppData";
|
||||
import { getTranslation } from "@calcom/lib/server/i18n";
|
||||
import { customInputSchema, EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
|
||||
|
||||
@@ -201,7 +201,7 @@ export default async function getEventTypeById({
|
||||
newMetadata.apps = {
|
||||
...apps,
|
||||
stripe: {
|
||||
...getStripeAppData(eventTypeWithParsedMetadata, true),
|
||||
...getPaymentAppData(eventTypeWithParsedMetadata, true),
|
||||
currency:
|
||||
(
|
||||
credentials.find((integration) => integration.type === "stripe_payment")
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { appDataSchemas } from "@calcom/app-store/apps.schemas.generated";
|
||||
import { appDataSchema } from "@calcom/app-store/stripepayment/zod";
|
||||
import { EventTypeAppsList, getEventTypeAppData } from "@calcom/app-store/utils";
|
||||
|
||||
export default function getPaymentAppData(
|
||||
eventType: Parameters<typeof getEventTypeAppData>[0],
|
||||
forcedGet?: boolean
|
||||
) {
|
||||
const metadataApps = eventType?.metadata?.apps as unknown as EventTypeAppsList;
|
||||
if (!metadataApps) {
|
||||
return { enabled: false, price: 0, currency: "usd", appId: null };
|
||||
}
|
||||
type appId = keyof typeof metadataApps;
|
||||
// @TODO: a lot of unknowns types here can be improved later
|
||||
const paymentAppIds = (Object.keys(metadataApps) as Array<keyof typeof appDataSchemas>).filter(
|
||||
(app) =>
|
||||
(metadataApps[app as appId] as unknown as z.infer<typeof appDataSchema>)?.price &&
|
||||
(metadataApps[app as appId] as unknown as z.infer<typeof appDataSchema>)?.enabled
|
||||
);
|
||||
|
||||
// Event type should only have one payment app data
|
||||
let paymentAppData: {
|
||||
enabled: boolean;
|
||||
price: number;
|
||||
currency: string;
|
||||
appId: EventTypeAppsList | null;
|
||||
} | null = null;
|
||||
for (const appId of paymentAppIds) {
|
||||
const appData = getEventTypeAppData(eventType, appId, forcedGet);
|
||||
if (appData && paymentAppData === null) {
|
||||
paymentAppData = {
|
||||
...appData,
|
||||
appId,
|
||||
};
|
||||
}
|
||||
}
|
||||
// This is the current expectation of system to have price and currency set always(using DB Level defaults).
|
||||
// Newly added apps code should assume that their app data might not be set.
|
||||
return paymentAppData || { enabled: false, price: 0, currency: "usd", appId: null };
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
import { getEventTypeAppData } from "@calcom/app-store/utils";
|
||||
|
||||
export default function getStripeAppData(
|
||||
eventType: Parameters<typeof getEventTypeAppData>[0],
|
||||
forcedGet?: boolean
|
||||
) {
|
||||
const stripeAppData = getEventTypeAppData(eventType, "stripe", forcedGet);
|
||||
// This is the current expectation of system to have price and currency set always(using DB Level defaults).
|
||||
// Newly added apps code should assume that their app data might not be set.
|
||||
return stripeAppData || { enabled: false, price: 0, currency: "usd" };
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import appStore from "@calcom/app-store";
|
||||
import { EventTypeAppsList } from "@calcom/app-store/utils";
|
||||
|
||||
import { AppCategories, Payment, Prisma } from ".prisma/client";
|
||||
|
||||
const deletePayment = async (
|
||||
paymentId: Payment["id"],
|
||||
paymentAppCredentials: {
|
||||
key: Prisma.JsonValue;
|
||||
appId: string | null;
|
||||
app: {
|
||||
dirName: string;
|
||||
categories: AppCategories[];
|
||||
} | null;
|
||||
}
|
||||
): Promise<boolean> => {
|
||||
const paymentApp = appStore[paymentAppCredentials?.app?.dirName as keyof typeof appStore];
|
||||
if (!(paymentApp && "lib" in paymentApp && "PaymentService" in paymentApp.lib)) {
|
||||
console.warn(`payment App service of type ${paymentApp} is not implemented`);
|
||||
return false;
|
||||
}
|
||||
const PaymentService = paymentApp.lib.PaymentService;
|
||||
const paymentInstance = new PaymentService(paymentAppCredentials);
|
||||
const deleted = await paymentInstance.deletePayment(paymentId);
|
||||
return deleted;
|
||||
};
|
||||
|
||||
export { deletePayment };
|
||||
@@ -0,0 +1,53 @@
|
||||
import appStore from "@calcom/app-store";
|
||||
import { EventTypeAppsList } from "@calcom/app-store/utils";
|
||||
import { EventTypeModel } from "@calcom/prisma/zod";
|
||||
import { CalendarEvent } from "@calcom/types/Calendar";
|
||||
|
||||
import { AppCategories, Prisma } from ".prisma/client";
|
||||
|
||||
const handlePayment = async (
|
||||
evt: CalendarEvent,
|
||||
selectedEventType: Pick<Zod.infer<typeof EventTypeModel>, "metadata">,
|
||||
paymentAppCredentials: {
|
||||
key: Prisma.JsonValue;
|
||||
appId: EventTypeAppsList;
|
||||
app: {
|
||||
dirName: string;
|
||||
categories: AppCategories[];
|
||||
} | null;
|
||||
},
|
||||
booking: {
|
||||
user: { email: string | null; name: string | null; timeZone: string } | null;
|
||||
id: number;
|
||||
startTime: { toISOString: () => string };
|
||||
uid: string;
|
||||
}
|
||||
) => {
|
||||
const paymentApp = appStore[paymentAppCredentials?.app?.dirName as keyof typeof appStore];
|
||||
if (!(paymentApp && "lib" in paymentApp && "PaymentService" in paymentApp.lib)) {
|
||||
console.warn(`payment App service of type ${paymentApp} is not implemented`);
|
||||
return null;
|
||||
}
|
||||
const PaymentService = paymentApp.lib.PaymentService;
|
||||
const paymentInstance = new PaymentService(paymentAppCredentials);
|
||||
const paymentData = await paymentInstance.create(
|
||||
{
|
||||
amount: selectedEventType?.metadata?.apps?.[paymentAppCredentials.appId].price,
|
||||
currency: selectedEventType?.metadata?.apps?.[paymentAppCredentials.appId].currency,
|
||||
},
|
||||
booking.id
|
||||
);
|
||||
|
||||
if (!paymentData) {
|
||||
console.error("Payment data is null");
|
||||
throw new Error("Payment data is null");
|
||||
}
|
||||
try {
|
||||
await paymentInstance.afterPayment(evt, booking, paymentData);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
return paymentData;
|
||||
};
|
||||
|
||||
export { handlePayment };
|
||||
@@ -0,0 +1,16 @@
|
||||
import { sendOrganizerPaymentRefundFailedEmail } from "@calcom/emails";
|
||||
import { CalendarEvent } from "@calcom/types/Calendar";
|
||||
|
||||
const handleRefundError = async (opts: { event: CalendarEvent; reason: string; paymentId: string }) => {
|
||||
console.error(`refund failed: ${opts.reason} for booking '${opts.event.uid}'`);
|
||||
try {
|
||||
await sendOrganizerPaymentRefundFailedEmail({
|
||||
...opts.event,
|
||||
paymentInfo: { reason: opts.reason, id: opts.paymentId },
|
||||
});
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
};
|
||||
|
||||
export { handleRefundError };
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- You are about to drop the column `type` on the `Payment` table. All the data in the column will be lost.
|
||||
|
||||
*/
|
||||
-- AlterTable
|
||||
ALTER TABLE "Payment" DROP COLUMN "type",
|
||||
ADD COLUMN "appId" TEXT;
|
||||
|
||||
-- DropEnum
|
||||
DROP TYPE "PaymentType";
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Payment" ADD CONSTRAINT "Payment_appId_fkey" FOREIGN KEY ("appId") REFERENCES "App"("slug") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- At this point all payments are from stripe. We update existing values to reflect this.
|
||||
UPDATE "Payment" SET "appId" = 'stripe';
|
||||
|
||||
@@ -399,24 +399,20 @@ model ReminderMail {
|
||||
createdAt DateTime @default(now())
|
||||
}
|
||||
|
||||
enum PaymentType {
|
||||
STRIPE
|
||||
}
|
||||
|
||||
model Payment {
|
||||
id Int @id @default(autoincrement())
|
||||
uid String @unique
|
||||
// TODO: Use an App relationship instead of PaymentType enum?
|
||||
type PaymentType
|
||||
id Int @id @default(autoincrement())
|
||||
uid String @unique
|
||||
app App? @relation(fields: [appId], references: [slug], onDelete: Cascade)
|
||||
appId String?
|
||||
bookingId Int
|
||||
booking Booking? @relation(fields: [bookingId], references: [id], onDelete: Cascade)
|
||||
booking Booking? @relation(fields: [bookingId], references: [id], onDelete: Cascade)
|
||||
amount Int
|
||||
fee Int
|
||||
currency String
|
||||
success Boolean
|
||||
refunded Boolean
|
||||
data Json
|
||||
externalId String @unique
|
||||
externalId String @unique
|
||||
}
|
||||
|
||||
enum WebhookTriggerEvents {
|
||||
@@ -525,6 +521,7 @@ model App {
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
credentials Credential[]
|
||||
payments Payment[]
|
||||
Webhook Webhook[]
|
||||
ApiKey ApiKey[]
|
||||
enabled Boolean @default(false)
|
||||
|
||||
@@ -6,7 +6,7 @@ import z from "zod";
|
||||
import app_RoutingForms from "@calcom/app-store/ee/routing-forms/trpc-router";
|
||||
import ethRouter from "@calcom/app-store/rainbow/trpc/router";
|
||||
import { deleteStripeCustomer } from "@calcom/app-store/stripepayment/lib/customer";
|
||||
import stripe, { closePayments } from "@calcom/app-store/stripepayment/lib/server";
|
||||
import stripe from "@calcom/app-store/stripepayment/lib/server";
|
||||
import { getPremiumPlanProductId } from "@calcom/app-store/stripepayment/lib/utils";
|
||||
import getApps, { getLocationGroupedOptions } from "@calcom/app-store/utils";
|
||||
import { cancelScheduledJobs } from "@calcom/app-store/zapier/lib/nodeScheduler";
|
||||
@@ -20,8 +20,9 @@ import { isPrismaObjOrUndefined, parseRecurringEvent } from "@calcom/lib";
|
||||
import getEnabledApps from "@calcom/lib/apps/getEnabledApps";
|
||||
import { ErrorCode, verifyPassword } from "@calcom/lib/auth";
|
||||
import { symmetricDecrypt } from "@calcom/lib/crypto";
|
||||
import getStripeAppData from "@calcom/lib/getStripeAppData";
|
||||
import getPaymentAppData from "@calcom/lib/getPaymentAppData";
|
||||
import hasKeyInMetadata from "@calcom/lib/hasKeyInMetadata";
|
||||
import { deletePayment } from "@calcom/lib/payment/deletePayment";
|
||||
import { checkUsername } from "@calcom/lib/server/checkUsername";
|
||||
import { getTranslation } from "@calcom/lib/server/i18n";
|
||||
import { resizeBase64Image } from "@calcom/lib/server/resizeBase64Image";
|
||||
@@ -817,11 +818,14 @@ const loggedInViewerRouter = router({
|
||||
id: id,
|
||||
userId: ctx.user.id,
|
||||
},
|
||||
include: {
|
||||
select: {
|
||||
key: true,
|
||||
appId: true,
|
||||
app: {
|
||||
select: {
|
||||
slug: true,
|
||||
categories: true,
|
||||
dirName: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -838,7 +842,11 @@ const loggedInViewerRouter = router({
|
||||
select: {
|
||||
id: true,
|
||||
locations: true,
|
||||
destinationCalendar: true,
|
||||
destinationCalendar: {
|
||||
include: {
|
||||
credential: true,
|
||||
},
|
||||
},
|
||||
price: true,
|
||||
currency: true,
|
||||
metadata: true,
|
||||
@@ -882,7 +890,7 @@ const loggedInViewerRouter = router({
|
||||
|
||||
// If it's a calendar, remove the destination calendar from the event type
|
||||
if (credential.app?.categories.includes(AppCategories.calendar)) {
|
||||
if (eventType.destinationCalendar?.integration === credential.type) {
|
||||
if (eventType.destinationCalendar?.credential?.appId === credential.appId) {
|
||||
const destinationCalendar = await prisma.destinationCalendar.findFirst({
|
||||
where: {
|
||||
id: eventType.destinationCalendar?.id,
|
||||
@@ -920,7 +928,7 @@ const loggedInViewerRouter = router({
|
||||
|
||||
const metadata = EventTypeMetaDataSchema.parse(eventType.metadata);
|
||||
|
||||
const stripeAppData = getStripeAppData({ ...eventType, metadata });
|
||||
const stripeAppData = getPaymentAppData({ ...eventType, metadata });
|
||||
|
||||
// If it's a payment, hide the event type and set the price to 0. Also cancel all pending bookings
|
||||
if (credential.app?.categories.includes(AppCategories.payment)) {
|
||||
@@ -1007,12 +1015,11 @@ const loggedInViewerRouter = router({
|
||||
});
|
||||
|
||||
for (const payment of booking.payment) {
|
||||
// Right now we only close payments on Stripe
|
||||
const stripeKeysSchema = z.object({
|
||||
stripe_user_id: z.string(),
|
||||
});
|
||||
const { stripe_user_id } = stripeKeysSchema.parse(credential.key);
|
||||
await closePayments(payment.externalId, stripe_user_id);
|
||||
try {
|
||||
await deletePayment(payment.id, credential);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
await prisma.payment.delete({
|
||||
where: {
|
||||
id: payment.id,
|
||||
|
||||
@@ -279,4 +279,45 @@ export const appsRouter = router({
|
||||
});
|
||||
return !!gCalPresent;
|
||||
}),
|
||||
updateAppCredentials: authedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
credentialId: z.number(),
|
||||
key: z.object({}).passthrough(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { user } = ctx;
|
||||
|
||||
const { key } = input;
|
||||
|
||||
// Find user credential
|
||||
const credential = await ctx.prisma.credential.findFirst({
|
||||
where: {
|
||||
id: input.credentialId,
|
||||
userId: user.id,
|
||||
},
|
||||
});
|
||||
// Check if credential exists
|
||||
if (!credential) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: `Could not find credential ${input.credentialId}`,
|
||||
});
|
||||
}
|
||||
|
||||
const updated = await ctx.prisma.credential.update({
|
||||
where: {
|
||||
id: credential.id,
|
||||
},
|
||||
data: {
|
||||
key: {
|
||||
...(credential.key as Prisma.JsonObject),
|
||||
...(key as Prisma.JsonObject),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return !!updated;
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
BookingReference,
|
||||
BookingStatus,
|
||||
EventType,
|
||||
MembershipRole,
|
||||
Prisma,
|
||||
SchedulingType,
|
||||
User,
|
||||
@@ -13,9 +14,9 @@ import {
|
||||
import type { TFunction } from "next-i18next";
|
||||
import { z } from "zod";
|
||||
|
||||
import appStore from "@calcom/app-store";
|
||||
import { getCalendar } from "@calcom/app-store/_utils/getCalendar";
|
||||
import { DailyLocationType } from "@calcom/app-store/locations";
|
||||
import { refund } from "@calcom/app-store/stripepayment/lib/server";
|
||||
import { scheduleTrigger } from "@calcom/app-store/zapier/lib/nodeScheduler";
|
||||
import EventManager from "@calcom/core/EventManager";
|
||||
import { CalendarEventBuilder } from "@calcom/core/builders/CalendarEvent/builder";
|
||||
@@ -319,7 +320,7 @@ export const bookingsRouter = router({
|
||||
|
||||
const recurringInfo = recurringInfoBasic.map(
|
||||
(
|
||||
info: typeof recurringInfoBasic[number]
|
||||
info: (typeof recurringInfoBasic)[number]
|
||||
): {
|
||||
recurringEventId: string | null;
|
||||
count: number;
|
||||
@@ -709,6 +710,8 @@ export const bookingsRouter = router({
|
||||
eventType: {
|
||||
select: {
|
||||
id: true,
|
||||
owner: true,
|
||||
teamId: true,
|
||||
recurringEvent: true,
|
||||
title: true,
|
||||
requiresConfirmation: true,
|
||||
@@ -1074,7 +1077,70 @@ export const bookingsRouter = router({
|
||||
},
|
||||
});
|
||||
} else {
|
||||
await refund(booking, evt); // No payment integration for recurring events for v1
|
||||
const successPayment = booking.payment.find((payment) => payment.success);
|
||||
if (!successPayment) {
|
||||
throw new Error("Cannot reject a booking without a successful payment");
|
||||
}
|
||||
|
||||
let eventTypeOwnerId;
|
||||
if (booking.eventType?.owner) {
|
||||
eventTypeOwnerId = booking.eventType.owner.id;
|
||||
} else if (booking.eventType?.teamId) {
|
||||
const teamOwner = await prisma.membership.findFirst({
|
||||
where: {
|
||||
teamId: booking.eventType.teamId,
|
||||
role: MembershipRole.OWNER,
|
||||
},
|
||||
select: {
|
||||
userId: true,
|
||||
},
|
||||
});
|
||||
eventTypeOwnerId = teamOwner?.userId;
|
||||
}
|
||||
|
||||
if (!eventTypeOwnerId) {
|
||||
throw new Error("Event Type owner not found for obtaining payment app credentials");
|
||||
}
|
||||
|
||||
const paymentAppCredentials = await prisma.credential.findMany({
|
||||
where: {
|
||||
userId: eventTypeOwnerId,
|
||||
appId: successPayment.appId,
|
||||
},
|
||||
select: {
|
||||
key: true,
|
||||
appId: true,
|
||||
app: {
|
||||
select: {
|
||||
categories: true,
|
||||
dirName: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const paymentAppCredential = paymentAppCredentials.find((credential) => {
|
||||
return credential.appId === successPayment.appId;
|
||||
});
|
||||
|
||||
if (!paymentAppCredential) {
|
||||
throw new Error("Payment app credentials not found");
|
||||
}
|
||||
|
||||
// Posible to refactor TODO:
|
||||
const paymentApp = appStore[paymentAppCredential?.app?.dirName as keyof typeof appStore];
|
||||
if (!(paymentApp && "lib" in paymentApp && "PaymentService" in paymentApp.lib)) {
|
||||
console.warn(`payment App service of type ${paymentApp} is not implemented`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const PaymentService = paymentApp.lib.PaymentService;
|
||||
const paymentInstance = new PaymentService(paymentAppCredential);
|
||||
const paymentData = await paymentInstance.refund(successPayment.id);
|
||||
if (!paymentData.refunded) {
|
||||
throw new Error("Payment could not be refunded");
|
||||
}
|
||||
|
||||
await prisma.booking.update({
|
||||
where: {
|
||||
id: bookingId,
|
||||
|
||||
Reference in New Issue
Block a user