see comments

This commit is contained in:
Rajpreet0 2026-10-01 23:54:20 +02:00
parent 55d8838c0e
commit 85c2bd95eb
7 changed files with 153 additions and 47 deletions

View file

@ -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;
}
}

View file

@ -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=<id>) 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 }),

View file

@ -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<View>("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=<id>, 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<string | null>(null);
// Deep-Link (?course=<id>, 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 = ({
<main className="relative mx-auto -mt-16 max-w-6xl px-3 pb-12 sm:px-6">
<div className="rounded-3xl bg-white p-4 shadow-xl shadow-gray-900/5 ring-1 ring-gray-900/5 sm:p-6">
{showDeepLinkNotice && (
<div
role="status"
className="mb-5 flex items-start gap-3 rounded-2xl bg-amber-50 px-4 py-3 text-sm text-amber-900 ring-1 ring-amber-200"
>
<Info size={18} className="mt-0.5 shrink-0" aria-hidden />
<p className="flex-1">
{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.
</p>
<button
type="button"
onClick={() => setNoticeDismissed(true)}
aria-label="Hinweis schließen"
className="-m-1 rounded-full p-1 text-amber-700 transition-[background-color] duration-150 hover:bg-amber-100"
>
<X size={16} />
</button>
</div>
)}
{/* Monat, Kalenderwoche, Ansicht und Navigation. Am Handy rutscht der
Umschalter in eine eigene Zeile. */}
<div className="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-3 sm:grid-cols-[minmax(0,1fr)_auto_auto]">
@ -533,7 +567,7 @@ const BusinessPublicView = ({
<BookingDialog
course={deepLinkCourse}
business={businessInfo}
open={deepLinkOpen}
open={deepLinkOpen && deepLinkBookable}
onOpenChange={setDeepLinkOpen}
accentColor={business.accentColor}
serviceFeeCents={serviceFeeCents}

View file

@ -62,19 +62,23 @@ const TaxSettingsCard = ({
return;
}
startTransition(async () => {
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.");
}
});
};

View file

@ -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<string, unknown>).customer_details = {

View file

@ -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 ? <div role="dialog">{course.name}</div> : 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(

View file

@ -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,