From 2f628a17df8773ff4cdbcb487d67500cb413f80b Mon Sep 17 00:00:00 2001 From: alannnc Date: Wed, 8 Feb 2023 13:36:22 -0700 Subject: [PATCH] feat/payment-service-6438-cal-767 (#6677) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 --------- Co-authored-by: zomars Co-authored-by: Hariom Balhara --- .../components/booking/BookingListItem.tsx | 2 +- .../booking/pages/AvailabilityPage.tsx | 10 +- .../components/booking/pages/BookingPage.tsx | 11 +- .../eventtype/EventRecurringTab.tsx | 6 +- apps/web/pages/event-types/index.tsx | 2 +- apps/web/playwright/fixtures/payments.ts | 6 +- apps/web/playwright/reschedule.e2e.ts | 17 +- .../components/FormInputFields.tsx | 2 +- packages/app-store/stripepayment/index.ts | 1 + .../stripepayment/lib/PaymentService.ts | 215 ++++++++++++++++++ packages/app-store/stripepayment/lib/index.ts | 1 + .../app-store/stripepayment/lib/server.ts | 180 +-------------- .../bookings/lib/handleCancelBooking.ts | 77 ++++++- .../features/bookings/lib/handleNewBooking.ts | 116 ++++++++-- .../ee/payments/components/Payment.tsx | 4 +- .../ee/payments/components/PaymentPage.tsx | 14 +- .../features/ee/payments/pages/payment.tsx | 5 +- .../components/EventTypeDescription.tsx | 6 +- packages/lib/PaymentService.ts | 25 ++ packages/lib/getEventTypeById.ts | 4 +- packages/lib/getPaymentAppData.ts | 42 ++++ packages/lib/getStripeAppData.ts | 11 - packages/lib/payment/deletePayment.ts | 28 +++ packages/lib/payment/handlePayment.ts | 53 +++++ packages/lib/payment/handleRefundError.ts | 16 ++ .../migration.sql | 19 ++ packages/prisma/schema.prisma | 17 +- packages/trpc/server/routers/viewer.tsx | 31 ++- packages/trpc/server/routers/viewer/apps.tsx | 41 ++++ .../trpc/server/routers/viewer/bookings.tsx | 72 +++++- 30 files changed, 761 insertions(+), 273 deletions(-) create mode 100644 packages/app-store/stripepayment/lib/PaymentService.ts create mode 100644 packages/lib/PaymentService.ts create mode 100644 packages/lib/getPaymentAppData.ts delete mode 100644 packages/lib/getStripeAppData.ts create mode 100644 packages/lib/payment/deletePayment.ts create mode 100644 packages/lib/payment/handlePayment.ts create mode 100644 packages/lib/payment/handleRefundError.ts create mode 100644 packages/prisma/migrations/20230125175109_remove_type_from_payment_and_add_app_relationship/migration.sql diff --git a/apps/web/components/booking/BookingListItem.tsx b/apps/web/components/booking/BookingListItem.tsx index 6dcdb3932d..686ff9d405 100644 --- a/apps/web/components/booking/BookingListItem.tsx +++ b/apps/web/components/booking/BookingListItem.tsx @@ -53,7 +53,7 @@ function BookingListItem(booking: BookingItemProps) { const [viewRecordingsDialogIsOpen, setViewRecordingsDialogIsOpen] = useState(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 { diff --git a/apps/web/components/booking/pages/AvailabilityPage.tsx b/apps/web/components/booking/pages/AvailabilityPage.tsx index 32144478a7..85c3633228 100644 --- a/apps/web/components/booking/pages/AvailabilityPage.tsx +++ b/apps/web/components/booking/pages/AvailabilityPage.tsx @@ -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) => { () => , [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) => { )} - {stripeAppData.price > 0 && ( + {paymentAppData.price > 0 && (

diff --git a/apps/web/components/booking/pages/BookingPage.tsx b/apps/web/components/booking/pages/BookingPage.tsx index fcd30b24e9..eaf35347d5 100644 --- a/apps/web/components/booking/pages/BookingPage.tsx +++ b/apps/web/components/booking/pages/BookingPage.tsx @@ -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 && (
- {stripeAppData.price > 0 && ( + {paymentAppData.price > 0 && (

diff --git a/apps/web/components/eventtype/EventRecurringTab.tsx b/apps/web/components/eventtype/EventRecurringTab.tsx index 4c2da8000b..1c55f6f6c3 100644 --- a/apps/web/components/eventtype/EventRecurringTab.tsx +++ b/apps/web/components/eventtype/EventRecurringTab.tsx @@ -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) => { - const stripeAppData = getStripeAppData(eventType); + const paymentAppData = getPaymentAppData(eventType); - const requirePayment = stripeAppData.price > 0; + const requirePayment = paymentAppData.price > 0; return (
diff --git a/apps/web/pages/event-types/index.tsx b/apps/web/pages/event-types/index.tsx index 2f1bf8e41c..c927c66ab5 100644 --- a/apps/web/pages/event-types/index.tsx +++ b/apps/web/pages/event-types/index.tsx @@ -136,7 +136,7 @@ const Item = ({ type, group, readOnly }: { type: EventType; group: EventTypeGrou )}
diff --git a/apps/web/playwright/fixtures/payments.ts b/apps/web/playwright/fixtures/payments.ts index 58bf57c387..19a634713f 100644 --- a/apps/web/playwright/fixtures/payments.ts +++ b/apps/web/playwright/fixtures/payments.ts @@ -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: { diff --git a/apps/web/playwright/reschedule.e2e.ts b/apps/web/playwright/reschedule.e2e.ts index 7ad35676df..036d1d08bb 100644 --- a/apps/web/playwright/reschedule.e2e.ts +++ b/apps/web/playwright/reschedule.e2e.ts @@ -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}`); diff --git a/packages/app-store/ee/routing-forms/components/FormInputFields.tsx b/packages/app-store/ee/routing-forms/components/FormInputFields.tsx index dec98020f9..f05547fac0 100644 --- a/packages/app-store/ee/routing-forms/components/FormInputFields.tsx +++ b/packages/app-store/ee/routing-forms/components/FormInputFields.tsx @@ -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; diff --git a/packages/app-store/stripepayment/index.ts b/packages/app-store/stripepayment/index.ts index 5d372ceda3..5373eb04ef 100644 --- a/packages/app-store/stripepayment/index.ts +++ b/packages/app-store/stripepayment/index.ts @@ -1,2 +1,3 @@ export * as api from "./api"; +export * as lib from "./lib"; export { metadata } from "./_metadata"; diff --git a/packages/app-store/stripepayment/lib/PaymentService.ts b/packages/app-store/stripepayment/lib/PaymentService.ts new file mode 100644 index 0000000000..7271b6ecd6 --- /dev/null +++ b/packages/app-store/stripepayment/lib/PaymentService.ts @@ -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; + + 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, + 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 { + throw new Error("Method not implemented."); + } + + async refund(paymentId: Payment["id"]): Promise { + 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 { + 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 { + 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 { + throw new Error("Method not implemented."); + } + + getPaymentDetails(): Promise { + throw new Error("Method not implemented."); + } +} diff --git a/packages/app-store/stripepayment/lib/index.ts b/packages/app-store/stripepayment/lib/index.ts index b04bfcf75e..c758477166 100644 --- a/packages/app-store/stripepayment/lib/index.ts +++ b/packages/app-store/stripepayment/lib/index.ts @@ -1 +1,2 @@ export * from "./constants"; +export * from "./PaymentService"; diff --git a/packages/app-store/stripepayment/lib/server.ts b/packages/app-store/stripepayment/lib/server.ts index e937c180f3..33b9e7fd88 100644 --- a/packages/app-store/stripepayment/lib/server.ts +++ b/packages/app-store/stripepayment/lib/server.ts @@ -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 & { +export type StripePaymentData = Stripe.Response & { 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, "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; diff --git a/packages/features/bookings/lib/handleCancelBooking.ts b/packages/features/bookings/lib/handleCancelBooking.ts index 0f87754973..2e53fd2b46 100644 --- a/packages/features/bookings/lib/handleCancelBooking.ts +++ b/packages/features/bookings/lib/handleCancelBooking.ts @@ -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, diff --git a/packages/features/bookings/lib/handleNewBooking.ts b/packages/features/bookings/lib/handleNewBooking.ts index 5377b0dbd5..9477e47f27 100644 --- a/packages/features/bookings/lib/handleNewBooking.ts +++ b/packages/features/bookings/lib/handleNewBooking.ts @@ -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; 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, }; diff --git a/packages/features/ee/payments/components/Payment.tsx b/packages/features/ee/payments/components/Payment.tsx index c83b09e9e8..4dcac52653 100644 --- a/packages/features/ee/payments/components/Payment.tsx +++ b/packages/features/ee/payments/components/Payment.tsx @@ -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 }; diff --git a/packages/features/ee/payments/components/PaymentPage.tsx b/packages/features/ee/payments/components/PaymentPage.tsx index 88c7c6bf17..4dacf67a91 100644 --- a/packages/features/ee/payments/components/PaymentPage.tsx +++ b/packages/features/ee/payments/components/PaymentPage.tsx @@ -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 = (props) => { const [timezone, setTimezone] = useState(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 = (props) => {
@@ -123,8 +124,9 @@ const PaymentPage: FC = (props) => { {props.payment.success && !props.payment.refunded && (
{t("paid")}
)} - {!props.payment.success && ( - + {props.payment.appId === "stripe" && !props.payment.success && ( + 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 }; diff --git a/packages/features/eventtypes/components/EventTypeDescription.tsx b/packages/features/eventtypes/components/EventTypeDescription.tsx index 42c603565c..af569353c1 100644 --- a/packages/features/eventtypes/components/EventTypeDescription.tsx +++ b/packages/features/eventtypes/components/EventTypeDescription.tsx @@ -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 diff --git a/packages/lib/PaymentService.ts b/packages/lib/PaymentService.ts new file mode 100644 index 0000000000..c81f9fce57 --- /dev/null +++ b/packages/lib/PaymentService.ts @@ -0,0 +1,25 @@ +import { Payment, Prisma, Booking } from "@prisma/client"; + +import { CalendarEvent } from "@calcom/types/Calendar"; + +export interface IAbstractPaymentService { + create( + payment: Pick, + bookingId: Booking["id"] + ): Promise; + update(paymentId: Payment["id"], data: Partial): Promise; + refund(paymentId: Payment["id"]): Promise; + getPaymentPaidStatus(): Promise; + getPaymentDetails(): Promise; + afterPayment( + event: CalendarEvent, + booking: { + user: { email: string | null; name: string | null; timeZone: string } | null; + id: number; + startTime: { toISOString: () => string }; + uid: string; + }, + paymentData: Payment + ): Promise; + deletePayment(paymentId: Payment["id"]): Promise; +} diff --git a/packages/lib/getEventTypeById.ts b/packages/lib/getEventTypeById.ts index 92ebf1e420..9eb70e47ca 100644 --- a/packages/lib/getEventTypeById.ts +++ b/packages/lib/getEventTypeById.ts @@ -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") diff --git a/packages/lib/getPaymentAppData.ts b/packages/lib/getPaymentAppData.ts new file mode 100644 index 0000000000..8cdadade88 --- /dev/null +++ b/packages/lib/getPaymentAppData.ts @@ -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[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).filter( + (app) => + (metadataApps[app as appId] as unknown as z.infer)?.price && + (metadataApps[app as appId] as unknown as z.infer)?.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 }; +} diff --git a/packages/lib/getStripeAppData.ts b/packages/lib/getStripeAppData.ts deleted file mode 100644 index 07585d2e1c..0000000000 --- a/packages/lib/getStripeAppData.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { getEventTypeAppData } from "@calcom/app-store/utils"; - -export default function getStripeAppData( - eventType: Parameters[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" }; -} diff --git a/packages/lib/payment/deletePayment.ts b/packages/lib/payment/deletePayment.ts new file mode 100644 index 0000000000..53033f4cca --- /dev/null +++ b/packages/lib/payment/deletePayment.ts @@ -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 => { + 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 }; diff --git a/packages/lib/payment/handlePayment.ts b/packages/lib/payment/handlePayment.ts new file mode 100644 index 0000000000..05565979fe --- /dev/null +++ b/packages/lib/payment/handlePayment.ts @@ -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, "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 }; diff --git a/packages/lib/payment/handleRefundError.ts b/packages/lib/payment/handleRefundError.ts new file mode 100644 index 0000000000..acb61826e5 --- /dev/null +++ b/packages/lib/payment/handleRefundError.ts @@ -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 }; diff --git a/packages/prisma/migrations/20230125175109_remove_type_from_payment_and_add_app_relationship/migration.sql b/packages/prisma/migrations/20230125175109_remove_type_from_payment_and_add_app_relationship/migration.sql new file mode 100644 index 0000000000..9abec5a30b --- /dev/null +++ b/packages/prisma/migrations/20230125175109_remove_type_from_payment_and_add_app_relationship/migration.sql @@ -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'; + diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index cd6b135753..043f4f16a2 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -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) diff --git a/packages/trpc/server/routers/viewer.tsx b/packages/trpc/server/routers/viewer.tsx index e3d8d299cf..6826c46a69 100644 --- a/packages/trpc/server/routers/viewer.tsx +++ b/packages/trpc/server/routers/viewer.tsx @@ -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, diff --git a/packages/trpc/server/routers/viewer/apps.tsx b/packages/trpc/server/routers/viewer/apps.tsx index 0f053e27e0..d16eabc103 100644 --- a/packages/trpc/server/routers/viewer/apps.tsx +++ b/packages/trpc/server/routers/viewer/apps.tsx @@ -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; + }), }); diff --git a/packages/trpc/server/routers/viewer/bookings.tsx b/packages/trpc/server/routers/viewer/bookings.tsx index b4ba2b4392..abeadd5297 100644 --- a/packages/trpc/server/routers/viewer/bookings.tsx +++ b/packages/trpc/server/routers/viewer/bookings.tsx @@ -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,