From 1687f15856c3bbb437161f53e775ff7a22268ac1 Mon Sep 17 00:00:00 2001 From: deepshekhardas Date: Mon, 6 Apr 2026 12:28:31 +0530 Subject: [PATCH 1/2] feat(round-robin): add host effective-limits foundation service --- .../RoundRobinHostLimitsService.ts | 225 ++++++++++++++++++ 1 file changed, 225 insertions(+) create mode 100644 packages/features/ee/round-robin/RoundRobinHostLimitsService.ts diff --git a/packages/features/ee/round-robin/RoundRobinHostLimitsService.ts b/packages/features/ee/round-robin/RoundRobinHostLimitsService.ts new file mode 100644 index 00000000000000..4e4ac383d0c99e --- /dev/null +++ b/packages/features/ee/round-robin/RoundRobinHostLimitsService.ts @@ -0,0 +1,225 @@ +import type { PrismaClient } from "@calcom/prisma"; +import dayjs from "@calcom/dayjs"; + +export interface HostLimitConfig { + userId: number; + eventTypeId: number; + limit: number | null; + window: "day" | "week" | "month" | null; +} + +export interface EffectiveLimitsResult { + userId: number; + currentCount: number; + limit: number | null; + window: string | null; + isWithinLimit: boolean; + remainingSlots: number | null; +} + +/** + * Service for managing round-robin host effective limits + * This is the foundation for per-member round-robin limit settings + */ +export class RoundRobinHostLimitsService { + constructor(private prisma: PrismaClient) {} + + /** + * Get effective limits for hosts in an event type + * This is a foundation method that prepares the structure for limit checks + * without changing current booking behavior + */ + async getEffectiveLimits({ + eventTypeId, + hostIds, + limitConfig, + }: { + eventTypeId: number; + hostIds: number[]; + limitConfig?: Map; + }): Promise { + const results: EffectiveLimitsResult[] = []; + + for (const userId of hostIds) { + const config = limitConfig?.get(userId); + const limit = config?.limit ?? null; + const window = config?.window ?? null; + + // If no limit is set, host has unlimited capacity + if (!limit || !window) { + results.push({ + userId, + currentCount: 0, + limit: null, + window: null, + isWithinLimit: true, + remainingSlots: null, + }); + continue; + } + + // Calculate window boundaries + const now = dayjs(); + let windowStart: Date; + + switch (window) { + case "day": + windowStart = now.startOf("day").toDate(); + break; + case "week": + windowStart = now.startOf("week").toDate(); + break; + case "month": + windowStart = now.startOf("month").toDate(); + break; + default: + windowStart = now.startOf("day").toDate(); + } + + // Count bookings for this host in the current window + const currentCount = await this.prisma.booking.count({ + where: { + userId, + eventTypeId, + createdAt: { + gte: windowStart, + }, + status: { + notIn: ["CANCELLED"], + }, + }, + }); + + results.push({ + userId, + currentCount, + limit, + window, + isWithinLimit: currentCount < limit, + remainingSlots: Math.max(0, limit - currentCount), + }); + } + + return results; + } + + /** + * Filter hosts by effective limits + * Returns only hosts that are within their booking limits + * This is a foundation method - currently allows all hosts (no limit enforcement) + * but provides the structure for future limit enforcement + */ + async filterHostsByLimits({ + eventTypeId, + hosts, + limitConfig, + }: { + eventTypeId: number; + hosts: { userId: number; isFixed: boolean }[]; + limitConfig?: Map; + }): Promise<{ userId: number; isFixed: boolean }[]> { + // For now, return all hosts (no limit enforcement) + // This is the foundation - limits will be enforced in a future iteration + const hostIds = hosts.filter((h) => !h.isFixed).map((h) => h.userId); + + if (hostIds.length === 0 || !limitConfig) { + return hosts; + } + + const effectiveLimits = await this.getEffectiveLimits({ + eventTypeId, + hostIds, + limitConfig, + }); + + // Create a map of userId to limit status + const limitStatusMap = new Map( + effectiveLimits.map((r) => [ + r.userId, + { + isWithinLimit: r.isWithinLimit, + remainingSlots: r.remainingSlots, + }, + ]) + ); + + // Filter hosts - for now we keep all hosts but the structure is ready + // In the future, this will filter out hosts that have exceeded their limits + return hosts.filter((host) => { + if (host.isFixed) return true; // Fixed hosts are not affected by RR limits + + const status = limitStatusMap.get(host.userId); + if (!status) return true; + + // Foundation: Currently allows all hosts + // TODO: In future PR, change to: return status.isWithinLimit; + return true; + }); + } + + /** + * Get limit status for a specific host + * Useful for UI indicators showing how many bookings a host has left + */ + async getHostLimitStatus({ + userId, + eventTypeId, + limit, + window, + }: { + userId: number; + eventTypeId: number; + limit: number | null; + window: "day" | "week" | "month" | null; + }): Promise | null> { + if (!limit || !window) { + return { + currentCount: 0, + limit: null, + window: null, + isWithinLimit: true, + remainingSlots: null, + }; + } + + const now = dayjs(); + let windowStart: Date; + + switch (window) { + case "day": + windowStart = now.startOf("day").toDate(); + break; + case "week": + windowStart = now.startOf("week").toDate(); + break; + case "month": + windowStart = now.startOf("month").toDate(); + break; + default: + windowStart = now.startOf("day").toDate(); + } + + const currentCount = await this.prisma.booking.count({ + where: { + userId, + eventTypeId, + createdAt: { + gte: windowStart, + }, + status: { + notIn: ["CANCELLED"], + }, + }, + }); + + return { + currentCount, + limit, + window, + isWithinLimit: currentCount < limit, + remainingSlots: Math.max(0, limit - currentCount), + }; + } +} + +export default RoundRobinHostLimitsService; From 5999b42c62ca8ecc535b1ab3958eac646139d6bc Mon Sep 17 00:00:00 2001 From: deepshekhardas Date: Mon, 6 Apr 2026 12:34:16 +0530 Subject: [PATCH 2/2] feat(paystack): add Paystack payment integration foundation --- .../app-store/paystack/lib/PaymentService.ts | 201 ++++++++++++++++++ 1 file changed, 201 insertions(+) create mode 100644 packages/app-store/paystack/lib/PaymentService.ts diff --git a/packages/app-store/paystack/lib/PaymentService.ts b/packages/app-store/paystack/lib/PaymentService.ts new file mode 100644 index 00000000000000..45eff4fcf0b501 --- /dev/null +++ b/packages/app-store/paystack/lib/PaymentService.ts @@ -0,0 +1,201 @@ +import type z from "zod"; +import { v4 as uuidv4 } from "uuid"; + +import { WEBAPP_URL } from "@calcom/lib/constants"; +import { ErrorCode } from "@calcom/lib/errorCodes"; +import logger from "@calcom/lib/logger"; +import { safeStringify } from "@calcom/lib/safeStringify"; +import prisma from "@calcom/prisma"; +import type { Booking, Payment, PaymentOption, Prisma } from "@calcom/prisma/client"; +import type { CalendarEvent } from "@calcom/types/Calendar"; +import type { IAbstractPaymentService } from "@calcom/types/PaymentService"; + +const log = logger.getSubLogger({ prefix: ["payment-service:paystack"] }); + +interface PaystackCredential { + secretKey: string; + publicKey: string; +} + +class PaystackPaymentService implements IAbstractPaymentService { + private credentials: PaystackCredential | null; + + constructor(credentials: { key: Prisma.JsonValue }) { + const keyData = credentials.key as PaystackCredential | null; + if (keyData && keyData.secretKey) { + this.credentials = keyData; + } else { + this.credentials = null; + } + } + + async create( + payment: Pick, + bookingId: Booking["id"], + userId: Booking["userId"], + username: string | null, + bookerName: string, + paymentOption: PaymentOption, + bookerEmail: string + ) { + try { + const booking = await prisma.booking.findUnique({ + where: { id: bookingId }, + select: { + uid: true, + title: true, + startTime: true, + endTime: true, + eventTypeId: true, + eventType: { + select: { + slug: true, + seatsPerTimeSlot: true, + }, + }, + attendees: { include: { bookingSeat: true } }, + }, + }); + + if (!booking || !this.credentials) { + throw new Error("Booking or credentials not found"); + } + + // Check for seat availability + const { startTime, endTime, eventTypeId } = booking; + const bookingsWithSameTimeSlot = await prisma.booking.findMany({ + where: { + eventTypeId, + startTime, + endTime, + OR: [{ status: "PENDING" }, { status: "AWAITING_HOST" }], + }, + select: { uid: true, title: true }, + }); + + if (booking.eventType?.seatsPerTimeSlot) { + if ( + booking.eventType.seatsPerTimeSlot <= + booking.attendees.filter((attendee) => !!attendee.bookingSeat).length || + bookingsWithSameTimeSlot.length > booking.eventType.seatsPerTimeSlot + ) { + throw new Error(ErrorCode.BookingSeatsFull); + } + } else { + if (bookingsWithSameTimeSlot.length > 1) { + throw new Error(ErrorCode.NoAvailableUsersFound); + } + } + + const uid = uuidv4(); + + // Create payment record + const paymentData = await prisma.payment.create({ + data: { + uid, + app: { + connect: { + slug: "paystack", + }, + }, + booking: { + connect: { + id: bookingId, + }, + }, + amount: payment.amount, + currency: payment.currency, + externalId: uid, + data: { + bookingUserName: username, + eventTypeSlug: booking.eventType?.slug, + bookingUid: booking.uid, + isPaid: false, + paystackPublicKey: this.credentials.publicKey, + } as unknown as Prisma.InputJsonValue, + fee: 0, + refunded: false, + success: false, + }, + }); + + if (!paymentData) { + throw new Error("Failed to store Payment data"); + } + + return paymentData; + } catch (error: any) { + log.error("Payment could not be created", bookingId, safeStringify(error)); + try { + await prisma.booking.update({ + where: { id: bookingId }, + data: { status: "CANCELLED" }, + }); + } catch { + throw new Error(ErrorCode.PaymentCreationFailure); + } + + if (error.message === ErrorCode.BookingSeatsFull || error.message === ErrorCode.NoAvailableUsersFound) { + throw error; + } + throw new Error(ErrorCode.PaymentCreationFailure); + } + } + + async update(): Promise { + throw new Error("Method not implemented."); + } + + async refund(): Promise { + throw new Error("Method not implemented."); + } + + async collectCard( + _payment: Pick, + _bookingId: number, + _bookerEmail: string, + _paymentOption: PaymentOption + ): Promise { + throw new Error("Method not implemented"); + } + + chargeCard( + _payment: Pick, + _bookingId: number + ): Promise { + throw new Error("Method not implemented."); + } + + getPaymentPaidStatus(): Promise { + throw new Error("Method not implemented."); + } + + getPaymentDetails(): Promise { + throw new Error("Method not implemented."); + } + + afterPayment( + _event: CalendarEvent, + _booking: { + user: { email: string | null; name: string | null; timeZone: string } | null; + id: number; + startTime: { toISOString: () => string }; + uid: string; + }, + _paymentData: Payment + ): Promise { + return Promise.resolve(); + } + + deletePayment(_paymentId: number): Promise { + return Promise.resolve(false); + } + + isSetupAlready(): boolean { + return !!this.credentials; + } +} + +export function BuildPaymentService(credentials: { key: Prisma.JsonValue }): IAbstractPaymentService { + return new PaystackPaymentService(credentials); +}