Several fixes

feat/alby
Alan 2023-09-26 16:27:55 -07:00
parent 1bdf1ce081
commit 7aafceb87d
15 changed files with 171 additions and 89 deletions

View File

@ -94,7 +94,7 @@ const querySchema = z.object({
});
export default function Success(props: SuccessProps) {
const { t, i18n } = useLocale();
const { t } = useLocale();
const router = useRouter();
const routerQuery = useRouterQuery();
const pathname = usePathname();

View File

@ -114,7 +114,7 @@ export default function AppCard({
{app?.isInstalled && switchChecked && <hr className="border-subtle" />}
{app?.isInstalled && switchChecked ? (
app.isSetup ? (
app.isSetupAlready ? (
<div className="relative p-4 pt-5 text-sm [&_input]:mb-0 [&_input]:leading-4">
<Link href={`/apps/${app.slug}/setup`} className="absolute right-4 top-4">
<Settings className="text-default h-4 w-4" aria-hidden="true" />

View File

@ -4,7 +4,6 @@ import prisma from "@calcom/prisma";
import config from "../config.json";
// FIXME: is a custom handler really needed?
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (!req.session?.user?.id) {
return res.status(401).json({ message: "You must be logged in to do this" });
@ -41,19 +40,3 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
return res.status(200).json({ url: "/apps/alby/setup" });
}
/*import { createDefaultInstallation } from "@calcom/app-store/_utils/installation";
import type { AppDeclarativeHandler } from "@calcom/types/AppHandler";
import appConfig from "../config.json";
const handler: AppDeclarativeHandler = {
appType: appConfig.type,
variant: appConfig.variant,
slug: appConfig.slug,
supportsMultipleInstalls: false,
handlerType: "add",
createCredential: ({ appType, user, slug, teamId }) =>
createDefaultInstallation({ appType, userId: user.id, slug, key: {}, teamId }),
};
export default handler;*/

View File

@ -123,9 +123,6 @@ export const AlbyPaymentComponent = (props: IAlbyPaymentComponentProps) => {
type PaymentCheckerProps = PaymentPageProps;
function PaymentChecker(props: PaymentCheckerProps) {
// This effect checks if the booking status has changed to "ACCEPTED"
// then reload the page to show the new payment status
// FIXME: subscribe to the exact payment instead of polling bookings
// FIXME: booking success is copied from packages/features/ee/payments/components/Payment.tsx
const searchParams = useSearchParams();
const bookingSuccessRedirect = useBookingSuccessRedirect();
@ -137,22 +134,17 @@ function PaymentChecker(props: PaymentCheckerProps) {
if (props.booking.status === "ACCEPTED") {
return;
}
const bookingsResult = await utils.viewer.bookings.get.fetch({
filters: {
status: "upcoming",
eventTypeIds: [props.eventType.id],
},
const bookingsResult = await utils.viewer.bookings.find.fetch({
bookingUid: props.booking.uid,
});
if (
bookingsResult.bookings.some(
(booking) => booking.id === props.booking.id && booking.status === "ACCEPTED"
)
) {
if (bookingsResult.paid) {
showToast("Payment successful", "success");
// TODO: add typings here
const params: {
[k: string]: any;
uid: string;
email: string | null;
location: string;
} = {
uid: props.booking.uid,
email: searchParams.get("email"),

View File

@ -10,10 +10,15 @@ import type { IAbstractPaymentService } from "@calcom/types/PaymentService";
import { albyCredentialKeysSchema } from "./albyCredentialKeysSchema";
export class PaymentService implements IAbstractPaymentService {
private credentials: z.infer<typeof albyCredentialKeysSchema>;
private credentials: z.infer<typeof albyCredentialKeysSchema> | null;
constructor(credentials: { key: Prisma.JsonValue }) {
this.credentials = albyCredentialKeysSchema.parse(credentials.key);
const keyParsing = albyCredentialKeysSchema.safeParse(credentials.key);
if (keyParsing.success) {
this.credentials = keyParsing.data;
} else {
this.credentials = null;
}
}
async create(
@ -30,7 +35,7 @@ export class PaymentService implements IAbstractPaymentService {
id: bookingId,
},
});
if (!booking) {
if (!booking || !this.credentials?.account_lightning_address) {
throw new Error();
}
@ -121,4 +126,8 @@ export class PaymentService implements IAbstractPaymentService {
deletePayment(paymentId: number): Promise<boolean> {
return Promise.resolve(false);
}
isSetupAlready(): boolean {
return !!this.credentials;
}
}

View File

@ -9,7 +9,13 @@ export default function parseInvoice(
"svix-signature": string;
},
webhookEndpointSecret: string
): Invoice {
const wh = new Webhook(webhookEndpointSecret);
return wh.verify(body, headers) as Invoice;
): Invoice | null {
try {
const wh = new Webhook(webhookEndpointSecret);
return wh.verify(body, headers) as Invoice;
} catch (err) {
// Looks like alby might sent multiple webhooks for the same invoice but it should only work once
console.error(err);
}
return null;
}

View File

@ -17,10 +17,15 @@ export const paypalCredentialKeysSchema = z.object({
});
export class PaymentService implements IAbstractPaymentService {
private credentials: z.infer<typeof paypalCredentialKeysSchema>;
private credentials: z.infer<typeof paypalCredentialKeysSchema> | null;
constructor(credentials: { key: Prisma.JsonValue }) {
this.credentials = paypalCredentialKeysSchema.parse(credentials.key);
const keyParsing = paypalCredentialKeysSchema.safeParse(credentials.key);
if (keyParsing.success) {
this.credentials = keyParsing.data;
} else {
this.credentials = null;
}
}
async create(
@ -37,7 +42,7 @@ export class PaymentService implements IAbstractPaymentService {
id: bookingId,
},
});
if (!booking) {
if (!booking || !this.credentials) {
throw new Error();
}
@ -113,7 +118,7 @@ export class PaymentService implements IAbstractPaymentService {
id: bookingId,
},
});
if (!booking) {
if (!booking || !this.credentials) {
throw new Error();
}
@ -192,4 +197,8 @@ export class PaymentService implements IAbstractPaymentService {
deletePayment(paymentId: number): Promise<boolean> {
return Promise.resolve(false);
}
isSetupAlready(): boolean {
return !!this.credentials;
}
}

View File

@ -14,7 +14,7 @@ import { createPaymentLink } from "./client";
import { retrieveOrCreateStripeCustomerByEmail } from "./customer";
import type { StripePaymentData, StripeSetupIntentData } from "./server";
const stripeCredentialKeysSchema = z.object({
export const stripeCredentialKeysSchema = z.object({
stripe_user_id: z.string(),
default_currency: z.string(),
stripe_publishable_key: z.string(),
@ -28,11 +28,15 @@ const stripeAppKeysSchema = z.object({
export class PaymentService implements IAbstractPaymentService {
private stripe: Stripe;
private credentials: z.infer<typeof stripeCredentialKeysSchema>;
private credentials: z.infer<typeof stripeCredentialKeysSchema> | null;
constructor(credentials: { key: Prisma.JsonValue }) {
// parse credentials key
this.credentials = stripeCredentialKeysSchema.parse(credentials.key);
const keyParsing = stripeCredentialKeysSchema.safeParse(credentials.key);
if (keyParsing.success) {
this.credentials = keyParsing.data;
} else {
this.credentials = null;
}
this.stripe = new Stripe(process.env.STRIPE_PRIVATE_KEY || "", {
apiVersion: "2020-08-27",
});
@ -63,15 +67,9 @@ export class PaymentService implements IAbstractPaymentService {
throw new Error("Payment option is not compatible with create method");
}
// Load stripe keys
const stripeAppKeys = await prisma.app.findFirst({
select: {
keys: true,
},
where: {
slug: "stripe",
},
});
if (!this.credentials) {
throw new Error("Stripe credentials not found");
}
const customer = await retrieveOrCreateStripeCustomerByEmail(
bookerEmail,
@ -142,21 +140,15 @@ export class PaymentService implements IAbstractPaymentService {
paymentOption: PaymentOption
): Promise<Payment> {
try {
if (!this.credentials) {
throw new Error("Stripe credentials not found");
}
// Ensure that the payment service can support the passed payment option
if (paymentOptionEnum.parse(paymentOption) !== "HOLD") {
throw new Error("Payment option is not compatible with create method");
}
// Load stripe keys
const stripeAppKeys = await prisma.app.findFirst({
select: {
keys: true,
},
where: {
slug: "stripe",
},
});
const customer = await retrieveOrCreateStripeCustomerByEmail(
bookerEmail,
this.credentials.stripe_user_id
@ -214,6 +206,10 @@ export class PaymentService implements IAbstractPaymentService {
async chargeCard(payment: Payment, _bookingId?: Booking["id"]): Promise<Payment> {
try {
if (!this.credentials) {
throw new Error("Stripe credentials not found");
}
const stripeAppKeys = await prisma.app.findFirst({
select: {
keys: true,
@ -385,4 +381,8 @@ export class PaymentService implements IAbstractPaymentService {
getPaymentDetails(): Promise<Payment> {
throw new Error("Method not implemented.");
}
isSetupAlready(): boolean {
return !!this.credentials;
}
}

View File

@ -83,7 +83,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
const credentials = parseCredentials.data;
const albyInvoice = await parseInvoice(bodyAsString, parsedHeaders, credentials.webhook_endpoint_secret);
if (!albyInvoice) throw new HttpCode({ statusCode: 204, message: "Invoice not found" });
if (albyInvoice.amount !== payment.amount) {
throw new HttpCode({ statusCode: 400, message: "invoice amount does not match payment amount" });
}

View File

@ -47,6 +47,7 @@ const getEnabledAppsFromCredentials = async (
..._where,
...(filterOnIds.credentials.some.OR.length && filterOnIds),
};
const enabledApps = await prisma.app.findMany({
where,
select: { slug: true, enabled: true },

View File

@ -1,5 +1,6 @@
import type { Prisma } from "@prisma/client";
import appStore from "@calcom/app-store";
import type { CredentialOwner } from "@calcom/app-store/types";
import getEnabledAppsFromCredentials from "@calcom/lib/apps/getEnabledAppsFromCredentials";
import getInstallCountPerApp from "@calcom/lib/apps/getInstallCountPerApp";
@ -9,6 +10,7 @@ import { MembershipRole } from "@calcom/prisma/enums";
import { credentialForCalendarServiceSelect } from "@calcom/prisma/selects/credential";
import type { TrpcSessionUser } from "@calcom/trpc/server/trpc";
import type { CredentialPayload } from "@calcom/types/Credential";
import type { PaymentApp } from "@calcom/types/PaymentService";
import type { TIntegrationsInputSchema } from "./integrations.schema";
@ -132,34 +134,48 @@ export const integrationsHandler = async ({ ctx, input }: IntegrationsOptions) =
...(appId ? { where: { slug: appId } } : {}),
});
//TODO: Refactor this to pick up only needed fields and prevent more leaking
let apps = enabledApps.map(
({ credentials: _, credential, key: _2 /* don't leak to frontend */, ...app }) => {
let apps = await Promise.all(
enabledApps.map(async ({ credentials: _, credential, key: _2 /* don't leak to frontend */, ...app }) => {
const userCredentialIds = credentials.filter((c) => c.type === app.type && !c.teamId).map((c) => c.id);
const invalidCredentialIds = credentials
.filter((c) => c.type === app.type && c.invalid)
.map((c) => c.id);
const teams = credentials
.filter((c) => c.type === app.type && c.teamId)
.map((c) => {
const team = userTeams.find((team) => team.id === c.teamId);
if (!team) {
return null;
}
return {
teamId: team.id,
name: team.name,
logo: team.logo,
credentialId: c.id,
isAdmin:
team.members[0].role === MembershipRole.ADMIN || team.members[0].role === MembershipRole.OWNER,
};
});
const teams = await Promise.all(
credentials
.filter((c) => c.type === app.type && c.teamId)
.map(async (c) => {
const team = userTeams.find((team) => team.id === c.teamId);
if (!team) {
return null;
}
return {
teamId: team.id,
name: team.name,
logo: team.logo,
credentialId: c.id,
isAdmin:
team.members[0].role === MembershipRole.ADMIN ||
team.members[0].role === MembershipRole.OWNER,
};
})
);
// type infer as CredentialOwner
const credentialOwner: CredentialOwner = {
name: user.name,
avatar: user.avatar,
};
// We need to know if app is payment type
let isSetupAlready = false;
if (credential && app.categories.includes("payment")) {
const paymentApp = (await appStore[app.dirName as keyof typeof appStore]()) as PaymentApp | null;
if (paymentApp && "lib" in paymentApp && paymentApp?.lib && "PaymentService" in paymentApp?.lib) {
const PaymentService = paymentApp.lib.PaymentService;
const paymentInstance = new PaymentService(credential);
isSetupAlready = paymentInstance.isSetupAlready();
}
}
return {
...app,
...(teams.length && {
@ -170,9 +186,9 @@ export const integrationsHandler = async ({ ctx, input }: IntegrationsOptions) =
teams,
isInstalled: !!userCredentialIds.length || !!teams.length || app.isGlobal,
// FIXME: remove hardcoding and add per-app validation
isSetup: !!(credential?.key as { account_id: string })?.account_id || app.slug !== "alby",
isSetupAlready,
};
}
})
);
if (variant) {

View File

@ -1,7 +1,9 @@
import authedProcedure from "../../../procedures/authedProcedure";
import publicProcedure from "../../../procedures/publicProcedure";
import { router } from "../../../trpc";
import { ZConfirmInputSchema } from "./confirm.schema";
import { ZEditLocationInputSchema } from "./editLocation.schema";
import { ZFindInputSchema } from "./find.schema";
import { ZGetInputSchema } from "./get.schema";
import { ZGetBookingAttendeesInputSchema } from "./getBookingAttendees.schema";
import { ZRequestRescheduleInputSchema } from "./requestReschedule.schema";
@ -105,4 +107,20 @@ export const bookingsRouter = router({
input,
});
}),
find: publicProcedure.input(ZFindInputSchema).query(async ({ input, ctx }) => {
if (!UNSTABLE_HANDLER_CACHE.find) {
UNSTABLE_HANDLER_CACHE.find = await import("./find.handler").then((mod) => mod.getHandler);
}
// Unreachable code but required for type safety
if (!UNSTABLE_HANDLER_CACHE.find) {
throw new Error("Failed to load handler");
}
return UNSTABLE_HANDLER_CACHE.find({
ctx,
input,
});
}),
});

View File

@ -0,0 +1,36 @@
import type { PrismaClient } from "@calcom/prisma";
import type { TFindInputSchema } from "./find.schema";
type GetOptions = {
ctx: {
prisma: PrismaClient;
};
input: TFindInputSchema;
};
export const getHandler = async ({ ctx, input }: GetOptions) => {
const { prisma } = ctx;
const { bookingUid } = input;
const booking = await prisma.booking.findUnique({
where: {
uid: bookingUid,
},
select: {
id: true,
uid: true,
startTime: true,
endTime: true,
description: true,
status: true,
paid: true,
eventTypeId: true,
},
});
// Don't leak anything private from the booking
return {
booking,
};
};

View File

@ -0,0 +1,9 @@
import { z } from "zod";
const ZFindInputSchema = z.object({
bookingUid: z.string().optional(),
});
type TFindInputSchema = z.infer<typeof ZFindInputSchema>;
export { ZFindInputSchema, TFindInputSchema };

View File

@ -1,10 +1,11 @@
import type { Payment, Prisma, Booking, PaymentOption } from "@prisma/client";
import type { PaymentService } from "@calcom/app-store/paypal/lib/PaymentService";
import type { CalendarEvent } from "@calcom/types/Calendar";
export interface PaymentApp {
lib?: {
PaymentService: IAbstractPaymentService;
PaymentService: typeof PaymentService;
};
}
@ -32,6 +33,7 @@ export interface IAbstractPaymentService {
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>;
@ -47,4 +49,5 @@ export interface IAbstractPaymentService {
paymentData: Payment
): Promise<void>;
deletePayment(paymentId: Payment["id"]): Promise<boolean>;
isSetupAlready(): boolean;
}