mirror of
https://github.com/Since-3/Kuma.git
synced 2026-10-12 20:51:18 +00:00
see comments
This commit is contained in:
parent
55d8838c0e
commit
85c2bd95eb
7 changed files with 153 additions and 47 deletions
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 }),
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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.");
|
||||
}
|
||||
});
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue