From 85c2bd95ebb90703dbafb43f1fe3d899772a9655 Mon Sep 17 00:00:00 2001 From: Rajpreet0 Date: Thu, 1 Oct 2026 23:54:20 +0200 Subject: [PATCH] see comments --- src/app/api/stripe/webhook/route.ts | 10 ++- src/app/business/[slug]/page.tsx | 34 ++++++--- .../courses/ui/views/business-public-view.tsx | 76 ++++++++++++++----- .../ui/components/TaxSettingsCard.tsx | 30 ++++---- tests/api/stripe/webhook.test.ts | 19 +++++ tests/courses/business-public-view.test.tsx | 28 ++++++- .../onboarding-wizard-view.test.tsx | 3 +- 7 files changed, 153 insertions(+), 47 deletions(-) diff --git a/src/app/api/stripe/webhook/route.ts b/src/app/api/stripe/webhook/route.ts index 5f251ee..f80628f 100644 --- a/src/app/api/stripe/webhook/route.ts +++ b/src/app/api/stripe/webhook/route.ts @@ -932,6 +932,14 @@ async function sendBookingRefund( } } +/** + * Liest die tatsächlich einbehaltene Servicegebühr vom PaymentIntent. Ein + * Fehler wird bewusst weitergeworfen: Mit 0 als Ersatzwert landete die Buchung + * mit platformFee 0 in der DB - der Studio-Umsatz enthielte dann die Gebühr, + * die Auszahlung wäre zu hoch, und eine erneute Zustellung ändert eine + * bezahlte Buchung nicht mehr. So antwortet der Webhook mit 500, es ist noch + * nichts geschrieben, und Stripe stellt das Event später erneut zu. + */ async function getApplicationFeeFromPaymentIntent( paymentIntentId: string, stripeAccountId: string | null @@ -945,7 +953,7 @@ async function getApplicationFeeFromPaymentIntent( return intent.application_fee_amount ?? 0; } catch (err) { console.error("Fehler beim Abrufen des PaymentIntent:", err); - return 0; + throw err; } } diff --git a/src/app/business/[slug]/page.tsx b/src/app/business/[slug]/page.tsx index 5aed9fc..fde882d 100644 --- a/src/app/business/[slug]/page.tsx +++ b/src/app/business/[slug]/page.tsx @@ -11,13 +11,21 @@ import { prisma } from "@/src/lib/prisma"; import { cancellationPolicySchema } from "@/src/modules/settings/schemas/cancellation-policy-schema"; import { DEFAULT_POLICY } from "@/src/modules/courses/actions/cancellation-policy"; import { describeCancellationPolicy } from "@/src/modules/courses/lib/cancellation-summary"; +import { + addDaysToKey, + berlinDayEnd, + berlinDayStart, + dayKey, +} from "@/src/modules/courses/lib/course-days"; interface BusinessPageProps { params: Promise<{ slug: string }>; + searchParams: Promise<{ course?: string }>; } -export default async function BusinessPage({ params }: BusinessPageProps) { +export default async function BusinessPage({ params, searchParams }: BusinessPageProps) { const { slug } = await params; + const { course: deepLinkCourseId } = await searchParams; const businessResult = await getBusinessBySlug(slug); @@ -45,14 +53,22 @@ export default async function BusinessPage({ params }: BusinessPageProps) { accentColor: branding.accentColor, }; - // Einen Tag Puffer nach hinten: Kurse sind auf Mitternacht Berliner Zeit - // datiert, und "heute 0:00" in der Zeitzone des Servers (z.B. UTC) würde die - // heutigen Kurse abschneiden. Welche Tage tatsächlich "kommend" sind, - // entscheidet die Ansicht in Berliner Zeit. - const from = new Date(); - from.setDate(from.getDate() - 1); - const to = new Date(from); - to.setDate(to.getDate() + 31); + // Ganze Berliner Tage, unabhängig von der Zeitzone des Servers: heute bis + // einschließlich heute +30. Welche Kurse heute noch buchbar sind, entscheidet + // die Ansicht anhand der Uhrzeit. + const todayKey = dayKey(new Date()); + const from = berlinDayStart(todayKey); + let to = berlinDayEnd(addDaysToKey(todayKey, 30)); + + // Ein geteilter Link (?course=) kann auf einen Kurs weiter in der Zukunft + // zeigen - dann bis zu dessen Tag laden, sonst fände die Ansicht ihn nicht. + if (deepLinkCourseId) { + const linked = await prisma.course.findFirst({ + where: { id: deepLinkCourseId, businessId: businessResult.business.id, status: "published" }, + select: { date: true }, + }); + if (linked && linked.date > to) to = berlinDayEnd(dayKey(linked.date)); + } const [coursesResult, policyRow] = await Promise.all([ getPublishedCoursesForBusiness(businessResult.business.id, { from, to }), diff --git a/src/modules/courses/ui/views/business-public-view.tsx b/src/modules/courses/ui/views/business-public-view.tsx index c5ab2da..b033643 100644 --- a/src/modules/courses/ui/views/business-public-view.tsx +++ b/src/modules/courses/ui/views/business-public-view.tsx @@ -3,7 +3,7 @@ import { useMemo, useState, useSyncExternalStore, useTransition, type CSSProperties } from "react"; import { useSearchParams } from "next/navigation"; import Image from "next/image"; -import { CalendarDays, ChevronLeft, ChevronRight, LayoutGrid, MapPin } from "lucide-react"; +import { CalendarDays, ChevronLeft, ChevronRight, Info, LayoutGrid, MapPin, X } from "lucide-react"; import { getContrastTextColor } from "@/src/modules/settings/lib/contrast"; import { BookingDialog, type PublicCourse } from "../components/BookingDialog"; import WeekTimeGrid from "../components/WeekTimeGrid"; @@ -67,6 +67,13 @@ function subscribeToMinute(onChange: () => void) { return () => clearInterval(id); } +/** Buchbar: ab heute, ohne bereits begonnene Kurse. Ohne Uhrzeit zählt nur der Tag. */ +function isBookable(course: PublicCourse, todayKey: string, nowTime: string | null) { + const key = dayKey(course.date); + if (key < todayKey) return false; + return !(key === todayKey && nowTime && course.timeFrom <= nowTime); +} + function byDateAndTime(a: PublicCourse, b: PublicCourse) { return dayKey(a.date).localeCompare(dayKey(b.date)) || a.timeFrom.localeCompare(b.timeFrom); } @@ -119,10 +126,30 @@ const BusinessPublicView = ({ const [direction, setDirection] = useState(0); const [view, setView] = useState("calendar"); - // Startwoche: diese Woche - außer sie hat keinen Kurs mehr, dann die Woche - // des nächsten Kurses. Sonst landet man bei einem Studio, das erst in zwei - // Wochen wieder Kurse hat, vor einem leeren Kalender. + // Deep-Link (?course=, z.B. ein geteilter Buchungslink) öffnet den + // Buchungsdialog direkt - aber nur, solange der Kurs buchbar ist. Die Seite + // lädt dafür bis zum Tag des Kurses, auch wenn der weit in der Zukunft liegt. + const deepLinkCourse = deepLinkCourseId + ? initialCourses.find((course) => course.id === deepLinkCourseId) + : undefined; + const [deepLinkOpen, setDeepLinkOpen] = useState(true); + // Erst mit bekannter Uhrzeit entscheiden - sonst ginge der Dialog für einen + // heute schon begonnenen Kurs bei der Hydration kurz auf. + const deepLinkBookable = + deepLinkCourse !== undefined && + nowTime !== null && + isBookable(deepLinkCourse, todayKey, nowTime); + const [noticeDismissed, setNoticeDismissed] = useState(false); + const showDeepLinkNotice = + deepLinkCourseId !== null && nowTime !== null && !deepLinkBookable && !noticeDismissed; + + // Startwoche: die Woche des verlinkten Kurses, sonst diese Woche - außer sie + // hat keinen Kurs mehr, dann die Woche des nächsten Kurses. Sonst landet man + // bei einem Studio, das erst in zwei Wochen wieder Kurse hat, vor einem + // leeren Kalender. const [weekKey, setWeekKey] = useState(() => { + const linkedKey = deepLinkCourse ? dayKey(deepLinkCourse.date) : null; + if (linkedKey && linkedKey >= todayKey) return startOfWeekKey(linkedKey); const nextKey = initialCourses .map((course) => dayKey(course.date)) .filter((key) => key >= todayKey) @@ -132,13 +159,6 @@ const BusinessPublicView = ({ // Am Handy der gewählte Tag; null = der erste sinnvolle Tag der Woche. const [selectedDayKey, setSelectedDayKey] = useState(null); - // Deep-Link (?course=, z.B. ein geteilter Buchungslink) öffnet den - // Buchungsdialog direkt. - const deepLinkCourse = deepLinkCourseId - ? initialCourses.find((course) => course.id === deepLinkCourseId) - : undefined; - const [deepLinkOpen, setDeepLinkOpen] = useState(Boolean(deepLinkCourse)); - const accentTextColor = useMemo( () => getContrastTextColor(business.accentColor), [business.accentColor] @@ -151,17 +171,9 @@ const BusinessPublicView = ({ const primaryMutedTextColor = onDarkPrimary ? "rgba(255,255,255,0.78)" : "#4B5563"; const businessInfo = { address: business.address, email: business.email }; - // Buchbar: ab heute, ohne bereits begonnene Kurse. const bookable = useMemo( () => - loadedCourses - .filter((course) => { - const key = dayKey(course.date); - if (key < todayKey) return false; - if (key === todayKey && nowTime && course.timeFrom <= nowTime) return false; - return true; - }) - .sort(byDateAndTime), + loadedCourses.filter((course) => isBookable(course, todayKey, nowTime)).sort(byDateAndTime), [loadedCourses, todayKey, nowTime] ); @@ -293,6 +305,28 @@ const BusinessPublicView = ({
+ {showDeepLinkNotice && ( +
+ +

+ {deepLinkCourse + ? `„${deepLinkCourse.name}“ hat bereits begonnen und kann nicht mehr gebucht werden.` + : "Dieser Kurs kann nicht mehr gebucht werden."}{" "} + Hier siehst du alle aktuellen Kurse. +

+ +
+ )} {/* Monat, Kalenderwoche, Ansicht und Navigation. Am Handy rutscht der Umschalter in eine eigene Zeile. */}
@@ -533,7 +567,7 @@ const BusinessPublicView = ({ { - const result = await updateBusinessTaxSettings({ - businessId, - taxStatus: status, - vatRate: status === "regular" ? vatRate : null, - taxNumber, - ustId, - taxExemptionNote: status === "exempt" ? exemptionNote : null, - }); - if (result.success) { - setIsConfigured(true); - toast.success("Steuerangaben gespeichert. Rechnungen enthalten jetzt diese Angaben."); - } else { - toast.error(result.error ?? "Fehler beim Speichern."); + try { + const result = await updateBusinessTaxSettings({ + businessId, + taxStatus: status, + vatRate: status === "regular" ? vatRate : null, + taxNumber, + ustId, + taxExemptionNote: status === "exempt" ? exemptionNote : null, + }); + if (result.success) { + setIsConfigured(true); + toast.success("Steuerangaben gespeichert. Rechnungen enthalten jetzt diese Angaben."); + } else { + toast.error(result.error ?? "Fehler beim Speichern."); + } + } catch { + toast.error("Fehler beim Speichern."); } }); }; diff --git a/tests/api/stripe/webhook.test.ts b/tests/api/stripe/webhook.test.ts index e8ad458..e3aa3d8 100644 --- a/tests/api/stripe/webhook.test.ts +++ b/tests/api/stripe/webhook.test.ts @@ -177,6 +177,25 @@ describe("Stripe-Webhook – Buchung", () => { expect(recordStripeFee).toHaveBeenCalledWith("pi_1", "acct_studio"); }); + it("bucht nicht mit Gebühr 0, wenn der PaymentIntent nicht abrufbar ist", async () => { + // Mit 0 als Ersatz wäre die Gebühr dauerhaft falsch verbucht - eine erneute + // Zustellung ändert eine bezahlte Buchung nicht mehr. Stattdessen 500, + // damit Stripe das Event erneut zustellt. + vi.mocked(stripe.paymentIntents.retrieve).mockRejectedValue( + new Error("Stripe nicht erreichbar") + ); + const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}); + + const res = await deliver( + sessionEvent("checkout.session.completed", { account: "acct_studio" }), + "whsec_connect" + ); + + expect(res.status).toBe(500); + expect(tx.courseBooking.upsert).not.toHaveBeenCalled(); + consoleError.mockRestore(); + }); + it("speichert die Rechnungsadresse aus dem Checkout an der Buchung", async () => { const event = sessionEvent("checkout.session.completed", { account: "acct_studio" }); (event.data.object as Record).customer_details = { diff --git a/tests/courses/business-public-view.test.tsx b/tests/courses/business-public-view.test.tsx index 4e84e72..28a51fc 100644 --- a/tests/courses/business-public-view.test.tsx +++ b/tests/courses/business-public-view.test.tsx @@ -1,7 +1,8 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { render, screen, fireEvent } from "@testing-library/react"; -vi.mock("next/navigation", () => ({ useSearchParams: () => new URLSearchParams() })); +const nav = vi.hoisted(() => ({ params: new URLSearchParams() })); +vi.mock("next/navigation", () => ({ useSearchParams: () => nav.params })); vi.mock("next/image", () => ({ default: () => null })); // Block (Desktop) und Zeitleiste (Handy) sind eigene Bausteine - hier zählt, // welche Kurse in welcher Woche bzw. an welchem Tag erscheinen. @@ -20,7 +21,8 @@ vi.mock("@/src/modules/courses/ui/components/DayTimeline", () => ({ ), })); vi.mock("@/src/modules/courses/ui/components/BookingDialog", () => ({ - BookingDialog: () => null, + BookingDialog: ({ open, course }: { open: boolean; course: { name: string } }) => + open ?
{course.name}
: null, })); vi.mock("@/src/modules/courses/actions/booking-actions", () => ({ getPublishedCoursesForBusiness: vi.fn(), @@ -96,6 +98,7 @@ beforeEach(() => { vi.useFakeTimers({ toFake: ["Date"] }); vi.setSystemTime(new Date("2026-10-01T08:00:00Z")); vi.mocked(getPublishedCoursesForBusiness).mockReset(); + nav.params = new URLSearchParams(); }); afterEach(() => { @@ -173,6 +176,27 @@ describe("Öffentliche Studio-Seite – Wochenkalender", () => { }); }); + it("öffnet einen geteilten Link auf einen Kurs in einer späteren Woche", () => { + nav.params = new URLSearchParams({ course: "pilates" }); + renderView(); + + expect(screen.getByRole("dialog").textContent).toBe("Reformer Pilates"); + // Der Kalender steht dahinter in der Woche des Kurses. + expect(cards()).toEqual(["18:00 Reformer Pilates"]); + }); + + it("öffnet keinen Buchungsdialog für einen bereits begonnenen Kurs", () => { + // "Frueh" lief heute um 07:30 - jetzt ist es 10:00. + nav.params = new URLSearchParams({ course: "started" }); + renderView(); + + expect(screen.queryByRole("dialog")).toBeNull(); + expect(screen.getByRole("status").textContent).toMatch( + /„Frueh“ hat bereits begonnen und kann nicht mehr gebucht werden/ + ); + expect(cards()).toEqual(["18:00 Yoga Flow", "07:30 Spinning"]); + }); + it("nennt Stornoregel und Servicegebühr in einer Zeile", () => { renderView(); expect( diff --git a/tests/onboarding/onboarding-wizard-view.test.tsx b/tests/onboarding/onboarding-wizard-view.test.tsx index 62a3197..974f167 100644 --- a/tests/onboarding/onboarding-wizard-view.test.tsx +++ b/tests/onboarding/onboarding-wizard-view.test.tsx @@ -1,3 +1,4 @@ +import "@testing-library/jest-dom/vitest"; import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { render, screen, cleanup } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; @@ -44,7 +45,7 @@ const defaultProps = { primaryColor: "#1d3557", accentColor: "#c9a84c", }, - policy: { tiers: [] }, + policy: { enabled: true, mode: "tiered" as const, fixedRefundPercent: 100, tiers: [] }, isPublic: false, slug: null, stripeAccountStatus: null,