added card creation and booking functionality

This commit is contained in:
Rajpreet0 2026-10-02 14:30:54 +02:00
parent 86330af16e
commit 7aa887c5aa
85 changed files with 7434 additions and 314 deletions

View file

@ -9,6 +9,10 @@ vi.mock("@/src/modules/courses/actions/booking-actions", () => ({
vi.mock("@/src/modules/courses/actions/waitlist-actions", () => ({
joinWaitlistAsUser: vi.fn(),
}));
vi.mock("@/src/modules/products/actions/pass-booking-actions", () => ({
bookCourseWithPass: vi.fn(),
}));
vi.mock("next/navigation", () => ({ useRouter: () => ({ refresh: vi.fn() }) }));
vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
import { BookingDialog } from "@/src/modules/courses/ui/components/BookingDialog";

View file

@ -86,6 +86,7 @@ function renderView(initialCourses = [...thisWeek, ...later], initialWindow = wi
initialWindow={initialWindow}
serviceFeeCents={50}
cancellationSummary="Kostenlos stornierbar bis Kursbeginn."
products={[]}
/>
);
}

View file

@ -0,0 +1,158 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
const tx = {
courseBooking: { updateMany: vi.fn() },
customerPass: { update: vi.fn() },
};
vi.mock("@/src/lib/prisma", () => ({
prisma: {
courseBooking: { findUnique: vi.fn(), updateMany: vi.fn(), update: vi.fn() },
courseWaitlistEntry: { count: vi.fn() },
user: { findUnique: vi.fn() },
guestLead: { findUnique: vi.fn() },
$transaction: vi.fn((fn: (t: typeof tx) => unknown) => fn(tx)),
},
}));
vi.mock("@/src/lib/stripe-refund", () => ({ refundStripePayment: vi.fn() }));
vi.mock("@/src/lib/mail/resend", () => ({ sendMail: vi.fn() }));
vi.mock("@/src/lib/mail/templates/pass-booking-cancellation", () => ({
generatePassBookingCancellationEmail: vi
.fn()
.mockReturnValue({ subject: "s", html: "h", text: "t" }),
}));
vi.mock("@/src/modules/courses/actions/notify-waitlist", () => ({ notifyCourseWaitlist: vi.fn() }));
vi.mock("@/src/modules/notifications/lib/create-notification", () => ({
notifyBusinessManager: vi.fn(),
}));
vi.mock("@/src/modules/settings/lib/to-email-branding", () => ({
toEmailBranding: vi.fn().mockReturnValue({}),
}));
vi.mock("@/src/modules/courses/actions/cancellation-policy", async (importOriginal) => {
const actual = await importOriginal<object>();
return { ...actual, calculateRefundAmountCents: vi.fn() };
});
import { executeBookingCancellation } from "@/src/modules/courses/actions/cancel-booking-shared";
import { calculateRefundAmountCents } from "@/src/modules/courses/actions/cancellation-policy";
import { refundStripePayment } from "@/src/lib/stripe-refund";
import { prisma } from "@/src/lib/prisma";
import { generatePassBookingCancellationEmail } from "@/src/lib/mail/templates/pass-booking-cancellation";
import { notifyCourseWaitlist } from "@/src/modules/courses/actions/notify-waitlist";
import { notifyBusinessManager } from "@/src/modules/notifications/lib/create-notification";
import { sendMail } from "@/src/lib/mail/resend";
// Mit einer 10er-Karte gebucht: kein Geld, keine Stripe-Felder.
const passBooking = {
paymentStatus: "paid",
amountPaid: null,
platformFee: null,
platformFeeOnTop: false,
stripePaymentIntentId: null,
stripeAccountId: null,
userId: "user-1",
guestLeadId: null,
seats: 2,
seatsPurchased: 2,
spotIds: [] as string[],
spotLabels: [] as string[],
refundedAmount: null,
refundedFeeAmount: null,
refundedAt: null,
passId: "pass-1",
pass: { name: "10er-Karte" },
course: {
id: "course-1",
name: "Yoga",
date: new Date("2099-01-01"),
timeFrom: "10:00",
businessId: "biz-1",
business: { name: "Studio", branding: null },
},
};
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(prisma.courseBooking.findUnique).mockResolvedValue(passBooking as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({ email: "kunde@example.com" } as never);
vi.mocked(prisma.courseWaitlistEntry.count).mockResolvedValue(0 as never);
vi.mocked(notifyCourseWaitlist).mockResolvedValue(undefined as never);
vi.mocked(notifyBusinessManager).mockResolvedValue(undefined as never);
vi.mocked(sendMail).mockResolvedValue(undefined as never);
vi.mocked(generatePassBookingCancellationEmail).mockReturnValue({
subject: "s",
html: "h",
text: "t",
});
tx.courseBooking.updateMany.mockResolvedValue({ count: 1 });
tx.customerPass.update.mockResolvedValue({});
vi.mocked(calculateRefundAmountCents).mockResolvedValue({
cancellationAllowed: true,
refundPercent: 100,
refundAmountCents: 0,
});
});
describe("executeBookingCancellation – Kartenbuchung", () => {
it("gibt die Einheiten zurück, wenn die Stornoregel 100 % erstatten würde", async () => {
const result = await executeBookingCancellation("booking-1", "user");
expect(result).toMatchObject({ success: true, creditsReturned: 2, refundedAmountCents: 0 });
expect(refundStripePayment).not.toHaveBeenCalled();
expect(tx.courseBooking.updateMany.mock.calls[0][0].data.paymentStatus).toBe("refunded");
expect(tx.customerPass.update).toHaveBeenCalledWith({
where: { id: "pass-1" },
data: { creditsRemaining: { increment: 2 } },
});
});
it("lässt die Einheiten verfallen, wenn die Frist verstrichen ist", async () => {
vi.mocked(calculateRefundAmountCents).mockResolvedValue({
cancellationAllowed: true,
refundPercent: 50,
refundAmountCents: 0,
});
const result = await executeBookingCancellation("booking-1", "user");
expect(result).toMatchObject({ success: true, creditsReturned: 0 });
expect(tx.courseBooking.updateMany.mock.calls[0][0].data.paymentStatus).toBe("cancelled");
expect(tx.customerPass.update).not.toHaveBeenCalled();
expect(vi.mocked(generatePassBookingCancellationEmail).mock.calls[0][0].creditsReturned).toBe(
false
);
});
it("gibt bei einer Kursabsage immer zurück, ohne die Stornoregel zu fragen", async () => {
const result = await executeBookingCancellation("booking-1", "manager", {
forceFullRefund: true,
});
expect(result).toMatchObject({ success: true, creditsReturned: 2 });
expect(calculateRefundAmountCents).not.toHaveBeenCalled();
expect(vi.mocked(generatePassBookingCancellationEmail).mock.calls[0][0].reason).toBe(
"course_cancelled"
);
});
it("storniert einzelne Plätze und gibt nur deren Einheiten zurück", async () => {
const result = await executeBookingCancellation("booking-1", "user", {
seatSelection: { seats: 1 },
});
expect(result).toMatchObject({ success: true, remainingSeats: 1, creditsReturned: 1 });
expect(tx.courseBooking.updateMany.mock.calls[0][0].data).toMatchObject({ seats: 1 });
expect(tx.customerPass.update.mock.calls[0][0].data).toEqual({
creditsRemaining: { increment: 1 },
});
});
it("schreibt bei einem Doppelklick nicht zweimal gut", async () => {
tx.courseBooking.updateMany.mockResolvedValue({ count: 0 });
const result = await executeBookingCancellation("booking-1", "user");
expect(result.success).toBe(false);
expect(tx.customerPass.update).not.toHaveBeenCalled();
});
});

View file

@ -0,0 +1,86 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("@/src/lib/prisma", () => ({
prisma: {
course: { findUnique: vi.fn(), delete: vi.fn(), deleteMany: vi.fn() },
courseBooking: { count: vi.fn() },
},
}));
vi.mock("@/src/lib/auth/getUser", () => ({
getUserData: vi.fn(),
isManager: () => true,
isEmployee: () => false,
getEffectiveManagerId: (user: { id: string }) => user.id,
}));
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
vi.mock("@/src/modules/courses/lib/public-courses-cache", () => ({
refreshPublicCourses: vi.fn(),
}));
import { deleteCourse } from "@/src/modules/courses/actions/course-actions";
import { prisma } from "@/src/lib/prisma";
import { getUserData } from "@/src/lib/auth/getUser";
const instance = {
id: "course-2",
createdBy: "mgr-1",
parentCourseId: "template-1",
date: new Date("2099-01-08"),
businessId: "biz-1",
};
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(getUserData).mockResolvedValue({ id: "mgr-1", role: "manager" } as never);
vi.mocked(prisma.course.findUnique).mockResolvedValue(instance as never);
vi.mocked(prisma.courseBooking.count).mockResolvedValue(0 as never);
});
describe("deleteCourse – Buchungen mit Zahlungen", () => {
it("löscht keinen Kurs mit bezahlten Buchungen oder Rechnungen", async () => {
vi.mocked(prisma.courseBooking.count).mockResolvedValue(2 as never);
const result = await deleteCourse("course-2");
expect(result.success).toBe(false);
expect(prisma.course.delete).not.toHaveBeenCalled();
const { where } = vi.mocked(prisma.courseBooking.count).mock.calls[0][0]!;
expect(where).toMatchObject({
course: { id: "course-2" },
OR: [
{ paymentStatus: { in: ["paid", "refunding", "partially_refunded"] } },
{ invoiceNumber: { not: null } },
],
});
});
it("prüft bei 'diesen und folgende' alle betroffenen Termine", async () => {
await deleteCourse("course-2", "this_and_following");
expect(vi.mocked(prisma.courseBooking.count).mock.calls[0][0]?.where?.course).toEqual({
parentCourseId: "template-1",
date: { gte: instance.date },
});
expect(prisma.course.deleteMany).toHaveBeenCalled();
});
it("prüft beim Löschen einer Vorlage auch alle Termine, die mitgelöscht werden", async () => {
vi.mocked(prisma.course.findUnique).mockResolvedValue({
...instance,
id: "template-1",
parentCourseId: null,
} as never);
await deleteCourse("template-1");
expect(vi.mocked(prisma.courseBooking.count).mock.calls[0][0]?.where?.course).toEqual({
OR: [{ id: "template-1" }, { parentCourseId: "template-1" }],
});
});
it("löscht Kurse ohne solche Buchungen wie bisher", async () => {
const result = await deleteCourse("course-2");
expect(result.success).toBe(true);
expect(prisma.course.delete).toHaveBeenCalledWith({ where: { id: "course-2" } });
});
});