From 40003e63d2e04b1beedffc6cf933d9634a844588 Mon Sep 17 00:00:00 2001 From: Rajpreet0 Date: Fri, 11 Sep 2026 12:06:58 +0200 Subject: [PATCH] bug fixes --- src/app/api/stripe/checkout/course/route.ts | 15 ++ src/app/api/stripe/webhook/route.ts | 44 ++++ src/lib/auth/account-role-by-email.ts | 28 +++ src/lib/image/square-logo.ts | 48 +++++ .../courses/actions/booking-actions.ts | 9 +- .../courses/hooks/useWeekdayTimings.ts | 82 ++----- .../ui/components/PublicCourseCard.tsx | 17 ++ .../courses/ui/components/UserBookingCard.tsx | 85 ++++++-- .../courses/ui/views/course-create-view.tsx | 161 ++++++-------- .../ui/components/BusinessBrandingEditor.tsx | 25 +-- .../settings/ui/components/LogoCropDialog.tsx | 201 ------------------ .../zahlungen/actions/zahlungen-actions.ts | 17 +- tests/api/stripe/checkout-course.test.ts | 129 +++++++++++ 13 files changed, 464 insertions(+), 397 deletions(-) create mode 100644 src/lib/auth/account-role-by-email.ts create mode 100644 src/lib/image/square-logo.ts delete mode 100644 src/modules/settings/ui/components/LogoCropDialog.tsx create mode 100644 tests/api/stripe/checkout-course.test.ts diff --git a/src/app/api/stripe/checkout/course/route.ts b/src/app/api/stripe/checkout/course/route.ts index d6815e5..d2164ea 100644 --- a/src/app/api/stripe/checkout/course/route.ts +++ b/src/app/api/stripe/checkout/course/route.ts @@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server"; import { prisma } from "@/src/lib/prisma"; import { stripe, calculatePlatformFeeCents, eurosToCents } from "@/src/lib/stripe"; import { getUserData, isUser } from "@/src/lib/auth/getUser"; +import { isManagerOrEmployeeEmail } from "@/src/lib/auth/account-role-by-email"; // Simple in-memory rate limiter: max 10 checkout attempts per IP per minute. // Best-effort only — in multi-instance/serverless deployments each instance has @@ -92,6 +93,20 @@ export async function POST(request: NextRequest) { { status: 400 } ); } + + // Manager/Mitarbeiter dürfen nicht als Gast mit ihrer geschäftlichen + // E-Mail buchen: sie haben keinen User-Eintrag, die Buchung liefe als + // GuestLead durch und würde den Account-Kontext umgehen. Vor der + // Stripe-Session abfangen, damit kein Geld fließt. + if (await isManagerOrEmployeeEmail(guestEmail.trim().toLowerCase())) { + return NextResponse.json( + { + error: + "Diese E-Mail-Adresse gehört zu einem Team-Account und kann nicht für eine Gast-Buchung verwendet werden. Bitte mit einem Kunden-Konto anmelden oder eine andere E-Mail-Adresse verwenden.", + }, + { status: 403 } + ); + } } const course = await prisma.course.findUnique({ diff --git a/src/app/api/stripe/webhook/route.ts b/src/app/api/stripe/webhook/route.ts index 04bb8cc..f78f1e3 100644 --- a/src/app/api/stripe/webhook/route.ts +++ b/src/app/api/stripe/webhook/route.ts @@ -12,6 +12,7 @@ import { } from "@/src/lib/mail/templates/booking-refund"; import { toEmailBranding } from "@/src/modules/settings/lib/to-email-branding"; import { notifyBusinessManager } from "@/src/modules/notifications/lib/create-notification"; +import { isManagerOrEmployeeEmail } from "@/src/lib/auth/account-role-by-email"; /** * Sucht einen registrierten User anhand der E-Mail, case-insensitive. @@ -126,6 +127,29 @@ async function handleCheckoutCompleted(session: Stripe.Checkout.Session) { return; } + // Sicherheitsnetz: Manager/Mitarbeiter dürfen nicht als Gast mit ihrer + // geschäftlichen E-Mail buchen. Die Checkout-Route fängt das bereits vor + // der Stripe-Session ab; falls eine Session diesen Pfad dennoch erreicht + // (direkter API-Aufruf, Race-Condition), wird hier keine Buchung + // angelegt und die Zahlung sofort erstattet. + if (await isManagerOrEmployeeEmail(guestEmail)) { + console.warn( + `checkout.session.completed: Gast-E-Mail ${guestEmail} gehört zu einem Team-Account. ` + + `Buchung wird abgelehnt, Zahlung wird erstattet (Session ${session.id}).` + ); + if (paymentIntentId) { + const refundResult = await refundStripePayment(paymentIntentId); + if (!refundResult.success) { + console.error( + `Refund fehlgeschlagen für abgelehnte Team-Account-Gastbuchung ` + + `(PaymentIntent ${paymentIntentId}): ${refundResult.error}. ` + + `Manuelle Erstattung im Stripe Dashboard nötig.` + ); + } + } + return; + } + // Zwischen Checkout-Start (als Gast) und Webhook-Zustellung kann sich der // Gast bereits mit derselben E-Mail registriert haben. In dem Fall die // Buchung direkt dem neuen Account zuordnen statt einen verwaisten @@ -161,6 +185,26 @@ async function handleCheckoutCompleted(session: Stripe.Checkout.Session) { session.customer_details?.email || session.customer_email; if (fallbackEmail) { + // Wie im Guest-Zweig: Team-Account-E-Mails abfangen und erstatten, + // statt eine GuestLead-Buchung anzulegen. + if (await isManagerOrEmployeeEmail(fallbackEmail)) { + console.warn( + `checkout.session.completed: Fallback-E-Mail ${fallbackEmail} gehört zu einem ` + + `Team-Account. Buchung wird abgelehnt, Zahlung wird erstattet (Session ${session.id}).` + ); + if (paymentIntentId) { + const refundResult = await refundStripePayment(paymentIntentId); + if (!refundResult.success) { + console.error( + `Refund fehlgeschlagen für abgelehnte Team-Account-Gastbuchung ` + + `(PaymentIntent ${paymentIntentId}): ${refundResult.error}. ` + + `Manuelle Erstattung im Stripe Dashboard nötig.` + ); + } + } + return; + } + const registeredUser = await findUserByEmailInsensitive(fallbackEmail); outcome = registeredUser ? await runCapacityTransactionUser({ diff --git a/src/lib/auth/account-role-by-email.ts b/src/lib/auth/account-role-by-email.ts new file mode 100644 index 0000000..8971407 --- /dev/null +++ b/src/lib/auth/account-role-by-email.ts @@ -0,0 +1,28 @@ +import { prisma } from "@/src/lib/prisma"; + +/** + * Prüft, ob eine E-Mail bereits zu einem Manager- oder Employee-Account gehört. + * + * Wird im Gast-Buchungsflow gebraucht: Manager/Mitarbeiter haben keinen Eintrag + * in der User-Tabelle und dürfen Kurse nicht als Gast mit ihrer geschäftlichen + * E-Mail buchen (siehe Checkout-Route und Stripe-Webhook). Der Vergleich ist + * case-insensitive, da Gast-E-Mails immer lowercase normalisiert werden, ältere + * Manager/Employee-Datensätze aber abweichende Schreibweisen haben können. + */ +export async function isManagerOrEmployeeEmail(email: string): Promise { + const normalized = email.trim(); + if (!normalized) return false; + + const [manager, employee] = await Promise.all([ + prisma.manager.findFirst({ + where: { email: { equals: normalized, mode: "insensitive" } }, + select: { id: true }, + }), + prisma.employee.findFirst({ + where: { email: { equals: normalized, mode: "insensitive" } }, + select: { id: true }, + }), + ]); + + return !!manager || !!employee; +} diff --git a/src/lib/image/square-logo.ts b/src/lib/image/square-logo.ts new file mode 100644 index 0000000..763e7b2 --- /dev/null +++ b/src/lib/image/square-logo.ts @@ -0,0 +1,48 @@ +"use client"; + +const OUTPUT_SIZE = 512; + +/** + * Passt ein Logo automatisch in ein quadratisches, transparentes PNG ein + * ("contain"): das Seitenverhältnis bleibt erhalten, nichts wird abgeschnitten, + * der Rand bleibt transparent. Ersetzt das manuelle Zoom/Pan-Zuschneiden – da + * Logos überall via `object-contain` in einem Quadrat dargestellt werden + * (Header, Rechnung, öffentliche Seite), reicht ein zentriertes Einpassen. + * + * SVGs werden unverändert zurückgegeben (Vektor bleibt scharf, das Rastern + * würde nur Qualität kosten). Nicht-Bild-Dateien ebenso. + */ +export async function squareLogo(file: File, size: number = OUTPUT_SIZE): Promise { + if (!file.type.startsWith("image/") || file.type === "image/svg+xml") { + return file; + } + + const bitmap = await createImageBitmap(file).catch(() => null); + if (!bitmap) return file; + + const canvas = document.createElement("canvas"); + canvas.width = size; + canvas.height = size; + const ctx = canvas.getContext("2d"); + if (!ctx) { + bitmap.close(); + return file; + } + + // Contain-Scale: längere Kante füllt das Quadrat, die kürzere wird zentriert. + const scale = Math.min(size / bitmap.width, size / bitmap.height); + const drawWidth = bitmap.width * scale; + const drawHeight = bitmap.height * scale; + const x = (size - drawWidth) / 2; + const y = (size - drawHeight) / 2; + + ctx.clearRect(0, 0, size, size); + ctx.drawImage(bitmap, x, y, drawWidth, drawHeight); + bitmap.close(); + + const blob = await new Promise((resolve) => canvas.toBlob(resolve, "image/png")); + if (!blob) return file; + + const name = file.name.replace(/\.[^.]+$/, "") + ".png"; + return new File([blob], name, { type: "image/png", lastModified: Date.now() }); +} diff --git a/src/modules/courses/actions/booking-actions.ts b/src/modules/courses/actions/booking-actions.ts index 5656290..e70f840 100644 --- a/src/modules/courses/actions/booking-actions.ts +++ b/src/modules/courses/actions/booking-actions.ts @@ -40,6 +40,9 @@ export type UserBooking = { paymentStatus: string; isPast: boolean; businessName: string; + /** Kontaktdaten des Standorts für den Detail-Dialog (Ticket: Email + Ort anzeigen). */ + businessAddress: string | null; + businessEmail: string | null; businessBranding: { logoUrl: string | null; accentColor: string; accentTextColor: string }; }; @@ -75,7 +78,9 @@ export async function getUserBookings(): Promise< coverImage: true, description: true, _count: { select: { bookings: { where: { paymentStatus: "paid" } } } }, - business: { select: { name: true, branding: true } }, + business: { + select: { name: true, address: true, email: true, branding: true }, + }, }, }, }, @@ -150,6 +155,8 @@ export async function getUserBookings(): Promise< paymentStatus: b.paymentStatus, isPast: courseStart < now, businessName: b.course.business?.name ?? branding.companyName, + businessAddress: b.course.business?.address ?? null, + businessEmail: b.course.business?.email ?? null, businessBranding: { logoUrl: branding.logoUrl, accentColor: branding.accentColor, diff --git a/src/modules/courses/hooks/useWeekdayTimings.ts b/src/modules/courses/hooks/useWeekdayTimings.ts index dc8bed7..233aacd 100644 --- a/src/modules/courses/hooks/useWeekdayTimings.ts +++ b/src/modules/courses/hooks/useWeekdayTimings.ts @@ -31,94 +31,60 @@ export const normalizeWeekdayTimings = (raw: RawWeekdayTimings): WeekdayTimings return result; }; -/** Ermittelt ein Standard-Zeitfenster, falls alle Tage genau ein identisches haben. */ -const deriveDefaultSlot = (timings: WeekdayTimings): WeekdaySlot => { - const daySlots = Object.values(timings); - if (daySlots.length === 0) return emptySlot(); - const first = daySlots[0][0]; - if (!first) return emptySlot(); - const allSame = daySlots.every( - (slots) => - slots.length === 1 && slots[0].timeFrom === first.timeFrom && slots[0].timeTo === first.timeTo - ); - return allSame ? { ...first } : emptySlot(); -}; - interface UseWeekdayTimingsOptions { /** Vorbelegung (Edit-Modus) — beliebiges gespeichertes Format. */ initialWeekdays?: string[]; initialTimings?: RawWeekdayTimings; + /** + * Uhrzeit, mit der neu ausgewählte Tage vorbefüllt werden — hier die + * allgemeinen Kurs-Zeitfelder ("Uhrzeit von/bis" oben im Formular). Wird bei + * jedem Render aktuell übergeben; leere Werte belegen einen leeren Slot. + */ + defaultSlot?: WeekdaySlot; } /** * Kapselt die Wochentag-Auswahl und deren Zeitfenster für die "custom"-Häufigkeit - * eines Dauerauftrags. Jeder Tag kann mehrere Zeitfenster haben; ein - * Standard-Zeitfenster kann auf alle Tage übertragen werden. + * eines Dauerauftrags. Jeder Tag kann mehrere Zeitfenster haben. Neu ausgewählte + * Tage (und neue Zeitfenster) werden mit der allgemeinen Kurs-Uhrzeit + * vorbelegt, falls diese gesetzt ist. */ export function useWeekdayTimings({ initialWeekdays = [], initialTimings, + defaultSlot, }: UseWeekdayTimingsOptions = {}) { const [selectedWeekdays, setSelectedWeekdays] = useState(initialWeekdays); const [weekdayTimings, setWeekdayTimings] = useState(() => normalizeWeekdayTimings(initialTimings) ); - const [defaultSlot, setDefaultSlot] = useState(() => - deriveDefaultSlot(normalizeWeekdayTimings(initialTimings)) - ); - const isDefaultSlotFilled = !!defaultSlot.timeFrom && !!defaultSlot.timeTo; + // Vorbelegung neuer Slots mit der aktuellen Kurs-Uhrzeit aus den oberen + // Zeitfeldern. Die Callbacks unten sind nicht memoisiert, lesen also bei + // jedem Render den aktuellen Wert. + const newSlot = (): WeekdaySlot => ({ + timeFrom: defaultSlot?.timeFrom ?? "", + timeTo: defaultSlot?.timeTo ?? "", + }); const reset = () => { setSelectedWeekdays([]); setWeekdayTimings({}); - setDefaultSlot(emptySlot()); }; - // Hält weekdayTimings mit der Tagesauswahl synchron: jeder ausgewählte Tag - // bekommt mindestens einen (ggf. leeren) Slot, abgewählte Tage fallen raus. + // Hält weekdayTimings mit der Tagesauswahl synchron: jeder neu ausgewählte Tag + // bekommt einen mit der Kurs-Uhrzeit vorbefüllten Slot, abgewählte Tage fallen raus. const syncWeekdays = (days: string[]) => { setSelectedWeekdays(days); setWeekdayTimings((prev) => { const next: WeekdayTimings = {}; days.forEach((d) => { - next[d] = prev[d]?.length ? prev[d] : [{ ...defaultSlot }]; + next[d] = prev[d]?.length ? prev[d] : [newSlot()]; }); return next; }); }; - // Standard-Zeitfenster auf alle ausgewählten Tage übertragen (ersetzt deren Slots). - const applyDefaultToAll = () => { - if (!isDefaultSlotFilled || selectedWeekdays.length === 0) return; - setWeekdayTimings(() => { - const next: WeekdayTimings = {}; - selectedWeekdays.forEach((d) => { - next[d] = [{ ...defaultSlot }]; - }); - return next; - }); - }; - - const updateDefaultSlot = (patch: Partial) => { - const updated = { ...defaultSlot, ...patch }; - setDefaultSlot(updated); - // Solange noch kein Tag eine eigene Uhrzeit gesetzt hat, die Änderung direkt - // auf alle ausgewählten Tage spiegeln (initiale Bequemlichkeit). - const noDayCustomized = selectedWeekdays.every((d) => - (weekdayTimings[d] ?? []).every((s) => !s.timeFrom && !s.timeTo) - ); - if (noDayCustomized && selectedWeekdays.length > 0) { - setWeekdayTimings(() => { - const next: WeekdayTimings = {}; - selectedWeekdays.forEach((d) => { - next[d] = [{ ...updated }]; - }); - return next; - }); - } - }; - const updateSlot = (day: string, index: number, patch: Partial) => { setWeekdayTimings((prev) => { const slots = (prev[day] ?? [emptySlot()]).map((s, i) => @@ -131,14 +97,14 @@ export function useWeekdayTimings({ const addSlot = (day: string) => { setWeekdayTimings((prev) => ({ ...prev, - [day]: [...(prev[day] ?? []), emptySlot()], + [day]: [...(prev[day] ?? []), newSlot()], })); }; const removeSlot = (day: string, index: number) => { setWeekdayTimings((prev) => { const slots = (prev[day] ?? []).filter((_, i) => i !== index); - return { ...prev, [day]: slots.length ? slots : [emptySlot()] }; + return { ...prev, [day]: slots.length ? slots : [newSlot()] }; }); }; @@ -166,14 +132,10 @@ export function useWeekdayTimings({ return { selectedWeekdays, weekdayTimings, - defaultSlot, - isDefaultSlotFilled, cleanedTimings, hasInvalidTiming, reset, syncWeekdays, - applyDefaultToAll, - updateDefaultSlot, updateSlot, addSlot, removeSlot, diff --git a/src/modules/courses/ui/components/PublicCourseCard.tsx b/src/modules/courses/ui/components/PublicCourseCard.tsx index 1ca863f..f0ecfad 100644 --- a/src/modules/courses/ui/components/PublicCourseCard.tsx +++ b/src/modules/courses/ui/components/PublicCourseCard.tsx @@ -141,6 +141,11 @@ export const BookingDialog = ({ // Wenn gesetzt → Admin-Ansicht: kein Buchen-Button, nur Status-Badge paymentStatus, accentColor, + // Wenn gesetzt → im Footer erscheint ein "Stornieren"-Button (z.B. "Meine + // Kurse": bezahlte, kommende Buchung). Das Öffnen des Bestätigungsdialogs + // liegt beim Aufrufer. + canCancel = false, + onRequestCancel, }: { course: PublicCourse; business?: BusinessInfo; @@ -148,6 +153,8 @@ export const BookingDialog = ({ onOpenChange: (v: boolean) => void; paymentStatus?: string; accentColor?: string; + canCancel?: boolean; + onRequestCancel?: () => void; }) => { const accentButtonStyle = accentColor ? { backgroundColor: accentColor, color: getContrastTextColor(accentColor) } @@ -434,6 +441,16 @@ export const BookingDialog = ({ > {adminStatus!.label} + {canCancel && onRequestCancel && ( + + )} + {/* Clickable card area */} - - {/* Stornierung Button – nur für zukünftige, nicht stornierte Buchungen */} - {canCancel && ( -
- -
- )} { + setDialogOpen(false); + setConfirmOpen(true); + }} /> ) : undefined, + // Neu ausgewählte Tage mit der allgemeinen Kurs-Uhrzeit oben vorbefüllen. + defaultSlot: { timeFrom, timeTo }, }); - const { selectedWeekdays, weekdayTimings, defaultSlot, isDefaultSlotFilled } = weekdays; + const { selectedWeekdays, weekdayTimings } = weekdays; const [endDate, setEndDate] = useState(() => { if (isEdit && initialData!.endDate) { const d = new Date(initialData!.endDate); @@ -745,102 +747,67 @@ const CourseCreateView = ({ /> {selectedWeekdays.length > 0 && ( -
- {/* Standard-Zeitfenster mit "für alle übernehmen" */} -
-

Standard-Zeitfenster

-
- weekdays.updateDefaultSlot({ timeFrom: e.target.value })} - /> - – - weekdays.updateDefaultSlot({ timeTo: e.target.value })} - /> - -
-

- Wird automatisch für alle Tage gesetzt, solange du keine Uhrzeit einzeln - anpasst. -

-
- -
-

Uhrzeiten pro Tag

- {selectedWeekdays.map((day) => { - const dayLabel = WEEKDAYS.find((w) => w.value === day)?.label ?? day; - const slots: WeekdaySlot[] = weekdayTimings[day]?.length - ? weekdayTimings[day] - : [{ timeFrom: "", timeTo: "" }]; - return ( -
- {dayLabel} -
- {slots.map((slot, index) => ( -
- - weekdays.updateSlot(day, index, { - timeFrom: e.target.value, - }) - } - /> - – - - weekdays.updateSlot(day, index, { - timeTo: e.target.value, - }) - } - /> - {slots.length > 1 && ( - - )} -
- ))} - -
+
+

Uhrzeiten pro Tag

+ {selectedWeekdays.map((day) => { + const dayLabel = WEEKDAYS.find((w) => w.value === day)?.label ?? day; + const slots: WeekdaySlot[] = weekdayTimings[day]?.length + ? weekdayTimings[day] + : [{ timeFrom: "", timeTo: "" }]; + return ( +
+ {dayLabel} +
+ {slots.map((slot, index) => ( +
+ + weekdays.updateSlot(day, index, { + timeFrom: e.target.value, + }) + } + /> + – + + weekdays.updateSlot(day, index, { + timeTo: e.target.value, + }) + } + /> + {slots.length > 1 && ( + + )} +
+ ))} +
- ); - })} - {errors.weekdayTimings && ( -

{errors.weekdayTimings}

- )} -
+
+ ); + })} + {errors.weekdayTimings && ( +

{errors.weekdayTimings}

+ )}
)} diff --git a/src/modules/settings/ui/components/BusinessBrandingEditor.tsx b/src/modules/settings/ui/components/BusinessBrandingEditor.tsx index 0438949..40e9108 100644 --- a/src/modules/settings/ui/components/BusinessBrandingEditor.tsx +++ b/src/modules/settings/ui/components/BusinessBrandingEditor.tsx @@ -9,10 +9,10 @@ import { Input } from "@/src/components/ui/input"; import { Progress } from "@/src/components/ui/progress"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/src/components/ui/tabs"; import { downsizeImage } from "@/src/lib/image/downsize"; +import { squareLogo } from "@/src/lib/image/square-logo"; import { getContrastTextColor } from "../../lib/contrast"; import type { BusinessBranding } from "../../schemas/business-branding-schema"; import ColorPickerField from "./ColorPickerField"; -import LogoCropDialog from "./LogoCropDialog"; import EmailBrandingPreview from "./branding-previews/EmailBrandingPreview"; import PublicPageBrandingPreview from "./branding-previews/PublicPageBrandingPreview"; import InvoiceBrandingPreview from "./branding-previews/InvoiceBrandingPreview"; @@ -37,7 +37,6 @@ const BusinessBrandingEditor = ({ const [accentColor, setAccentColor] = useState(initialBranding.accentColor); const [isUploadingLogo, setIsUploadingLogo] = useState(false); const [uploadPhase, setUploadPhase] = useState<"optimizing" | "uploading" | null>(null); - const [pendingCropFile, setPendingCropFile] = useState(null); const accentTextColor = getContrastTextColor(accentColor); @@ -45,7 +44,10 @@ const BusinessBrandingEditor = ({ setIsUploadingLogo(true); setUploadPhase("optimizing"); try { - const resizedFile = await downsizeImage(file); + // Automatisch quadratisch einpassen (contain), dann komprimieren – + // kein manuelles Zoom/Pan mehr. + const squared = await squareLogo(file); + const resizedFile = await downsizeImage(squared); setUploadPhase("uploading"); const formData = new FormData(); formData.append("file", resizedFile); @@ -68,12 +70,7 @@ const BusinessBrandingEditor = ({ // gewählt werden kann (sonst feuert onChange beim zweiten Mal nicht). e.target.value = ""; if (!file) return; - setPendingCropFile(file); - }; - - const handleCropConfirm = (croppedFile: File) => { - setPendingCropFile(null); - uploadLogo(croppedFile); + void uploadLogo(file); }; const handleSave = async () => { @@ -160,7 +157,9 @@ const BusinessBrandingEditor = ({ )}
-

Max. 5 MB, danach zuschneidbar.

+

+ Max. 5 MB. Wird automatisch quadratisch eingepasst. +

{/* Farben */} @@ -230,12 +229,6 @@ const BusinessBrandingEditor = ({ - - setPendingCropFile(null)} - onConfirm={handleCropConfirm} - /> ); }; diff --git a/src/modules/settings/ui/components/LogoCropDialog.tsx b/src/modules/settings/ui/components/LogoCropDialog.tsx deleted file mode 100644 index 550f16d..0000000 --- a/src/modules/settings/ui/components/LogoCropDialog.tsx +++ /dev/null @@ -1,201 +0,0 @@ -"use client"; - -import { useCallback, useEffect, useRef, useState } from "react"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from "@/src/components/ui/dialog"; -import { Button } from "@/src/components/ui/button"; -import { Slider } from "@/src/components/ui/slider"; - -const CANVAS_SIZE = 320; -const OUTPUT_SIZE = 512; - -interface LogoCropDialogProps { - file: File | null; - onCancel: () => void; - onConfirm: (croppedFile: File) => void; -} - -/** Größe, in der das Bild bei gegebenem Zoom gezeichnet wird (füllt CANVAS_SIZE mindestens aus). */ -function getScaledSize(image: HTMLImageElement, zoom: number): { width: number; height: number } { - const baseScale = Math.max(CANVAS_SIZE / image.width, CANVAS_SIZE / image.height); - const scale = baseScale * zoom; - return { width: image.width * scale, height: image.height * scale }; -} - -/** - * Begrenzt den Offset auf den Bereich, in dem das skalierte Bild den - * quadratischen Canvas noch vollständig ausfüllt - ohne diese Klemmung könnte - * ein Drag oder ein Zoom-Wechsel das Bild aus dem sichtbaren Bereich schieben - * (grauer Freiraum in der Vorschau, transparente Fläche statt Logo im Export). - */ -function clampOffset( - offset: { x: number; y: number }, - drawWidth: number, - drawHeight: number -): { x: number; y: number } { - const maxOffsetX = Math.max(0, (drawWidth - CANVAS_SIZE) / 2); - const maxOffsetY = Math.max(0, (drawHeight - CANVAS_SIZE) / 2); - return { - x: Math.min(maxOffsetX, Math.max(-maxOffsetX, offset.x)), - y: Math.min(maxOffsetY, Math.max(-maxOffsetY, offset.y)), - }; -} - -/** - * Einfacher Zoom/Pan-Crop direkt per Canvas, ohne zusätzliche Dependency - - * analog zum bestehenden Canvas-Ansatz in downsizeImage. Schneidet immer - * quadratisch zu (Logos werden überall als Quadrat/Kreis dargestellt: Header, - * Rechnung, öffentliche Seite), damit kein gestrecktes oder schief - * zugeschnittenes Logo entstehen kann. - */ -const LogoCropDialog = ({ file, onCancel, onConfirm }: LogoCropDialogProps) => { - const canvasRef = useRef(null); - const imageRef = useRef(null); - const [zoom, setZoom] = useState(1); - const [offset, setOffset] = useState({ x: 0, y: 0 }); - const dragState = useRef<{ - startX: number; - startY: number; - origin: { x: number; y: number }; - } | null>(null); - - const draw = useCallback( - (currentZoom: number, currentOffset: { x: number; y: number }, img?: HTMLImageElement) => { - const canvas = canvasRef.current; - const image = img ?? imageRef.current; - if (!canvas || !image) return; - const ctx = canvas.getContext("2d"); - if (!ctx) return; - - canvas.width = CANVAS_SIZE; - canvas.height = CANVAS_SIZE; - ctx.clearRect(0, 0, CANVAS_SIZE, CANVAS_SIZE); - ctx.fillStyle = "#f5f5f5"; - ctx.fillRect(0, 0, CANVAS_SIZE, CANVAS_SIZE); - - const { width: drawWidth, height: drawHeight } = getScaledSize(image, currentZoom); - const clampedOffset = clampOffset(currentOffset, drawWidth, drawHeight); - const x = (CANVAS_SIZE - drawWidth) / 2 + clampedOffset.x; - const y = (CANVAS_SIZE - drawHeight) / 2 + clampedOffset.y; - - ctx.drawImage(image, x, y, drawWidth, drawHeight); - }, - [] - ); - - useEffect(() => { - if (!file) return; - const url = URL.createObjectURL(file); - const img = new window.Image(); - img.onload = () => { - imageRef.current = img; - setZoom(1); - setOffset({ x: 0, y: 0 }); - draw(1, { x: 0, y: 0 }, img); - }; - img.src = url; - return () => URL.revokeObjectURL(url); - }, [file, draw]); - - const handleZoomChange = ([value]: number[]) => { - const image = imageRef.current; - if (!image) return; - const { width, height } = getScaledSize(image, value); - const clamped = clampOffset(offset, width, height); - setZoom(value); - setOffset(clamped); - draw(value, clamped); - }; - - const handlePointerDown = (e: React.PointerEvent) => { - dragState.current = { startX: e.clientX, startY: e.clientY, origin: offset }; - e.currentTarget.setPointerCapture(e.pointerId); - }; - - const handlePointerMove = (e: React.PointerEvent) => { - if (!dragState.current) return; - const image = imageRef.current; - if (!image) return; - const dx = e.clientX - dragState.current.startX; - const dy = e.clientY - dragState.current.startY; - const raw = { x: dragState.current.origin.x + dx, y: dragState.current.origin.y + dy }; - const { width, height } = getScaledSize(image, zoom); - const next = clampOffset(raw, width, height); - setOffset(next); - draw(zoom, next); - }; - - const handlePointerUp = () => { - dragState.current = null; - }; - - const handleConfirm = () => { - const image = imageRef.current; - if (!image) return; - - const outputCanvas = document.createElement("canvas"); - outputCanvas.width = OUTPUT_SIZE; - outputCanvas.height = OUTPUT_SIZE; - const ctx = outputCanvas.getContext("2d"); - if (!ctx) return; - - const outputScale = OUTPUT_SIZE / CANVAS_SIZE; - const { width: drawWidth, height: drawHeight } = getScaledSize(image, zoom); - const clampedOffset = clampOffset(offset, drawWidth, drawHeight); - const x = (OUTPUT_SIZE - drawWidth * outputScale) / 2 + clampedOffset.x * outputScale; - const y = (OUTPUT_SIZE - drawHeight * outputScale) / 2 + clampedOffset.y * outputScale; - - ctx.drawImage(image, x, y, drawWidth * outputScale, drawHeight * outputScale); - - outputCanvas.toBlob((blob) => { - if (!blob) return; - onConfirm(new File([blob], "logo.png", { type: "image/png" })); - }, "image/png"); - }; - - return ( - !open && onCancel()}> - - - Logo zuschneiden - - Ziehe das Bild, um den Ausschnitt zu verschieben, und nutze den Regler zum Zoomen. - - - -
- -
- -
- Zoom - -
- - - - - -
-
- ); -}; - -export default LogoCropDialog; diff --git a/src/modules/zahlungen/actions/zahlungen-actions.ts b/src/modules/zahlungen/actions/zahlungen-actions.ts index c48db76..ab3d132 100644 --- a/src/modules/zahlungen/actions/zahlungen-actions.ts +++ b/src/modules/zahlungen/actions/zahlungen-actions.ts @@ -31,6 +31,12 @@ export type ZahlungRow = { courseName: string; businessId: string; businessName: string; + /** + * Betrag, den das Business tatsächlich behält, in Cents. Bei + * `partially_refunded` ist das der Nettobetrag (gezahlt − erstattet), + * sonst der voll gezahlte Betrag. Die Rechnung selbst bleibt davon + * unberührt (siehe invoice-actions.ts). + */ amountPaid: number | null; paymentStatus: string; invoiceNumber: string | null; @@ -68,6 +74,7 @@ export async function getZahlungen( createdAt: true, paymentStatus: true, amountPaid: true, + refundedAmount: true, invoiceNumber: true, course: { select: { @@ -89,6 +96,14 @@ export async function getZahlungen( b.user?.name ?? b.user?.email ?? b.guestLead?.name ?? b.guestLead?.email ?? "–"; const customerEmail = b.user?.email ?? b.guestLead?.email ?? "–"; + // Bei Teilerstattung zeigt die Übersicht den Betrag, den das Business + // behält (netto). Voll erstattete / stornierte Zeilen behalten ihren + // Rohbetrag – dort trägt das Status-Badge die Bedeutung. + const netAmount = + b.paymentStatus === "partially_refunded" && b.amountPaid !== null + ? b.amountPaid - (b.refundedAmount ?? 0) + : b.amountPaid; + return { id: b.id, createdAt: b.createdAt.toISOString(), @@ -98,7 +113,7 @@ export async function getZahlungen( courseName: b.course.name, businessId: b.course.business?.id ?? "", businessName: b.course.business?.name ?? "–", - amountPaid: b.amountPaid, + amountPaid: netAmount, paymentStatus: b.paymentStatus, invoiceNumber: b.invoiceNumber, }; diff --git a/tests/api/stripe/checkout-course.test.ts b/tests/api/stripe/checkout-course.test.ts new file mode 100644 index 0000000..2c0adee --- /dev/null +++ b/tests/api/stripe/checkout-course.test.ts @@ -0,0 +1,129 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { POST } from "@/src/app/api/stripe/checkout/course/route"; + +vi.mock("@/src/lib/auth/getUser", () => ({ + getUserData: vi.fn(), + isUser: vi.fn(), +})); + +vi.mock("@/src/lib/auth/account-role-by-email", () => ({ + isManagerOrEmployeeEmail: vi.fn(), +})); + +vi.mock("@/src/lib/prisma", () => ({ + prisma: { + course: { findUnique: vi.fn() }, + courseBooking: { findUnique: vi.fn() }, + }, +})); + +vi.mock("@/src/lib/stripe", () => ({ + stripe: { checkout: { sessions: { create: vi.fn() } } }, + calculatePlatformFeeCents: vi.fn().mockReturnValue(100), + eurosToCents: vi.fn().mockReturnValue(2900), +})); + +import { getUserData, isUser } from "@/src/lib/auth/getUser"; +import { isManagerOrEmployeeEmail } from "@/src/lib/auth/account-role-by-email"; +import { prisma } from "@/src/lib/prisma"; +import { stripe } from "@/src/lib/stripe"; + +let ipCounter = 0; + +function makeRequest(body: Record) { + // Jede Anfrage aus einer eigenen IP, damit der In-Memory-Rate-Limiter + // (10/Minute/IP) die Tests nicht gegenseitig beeinflusst. + ipCounter += 1; + return new Request("http://localhost/api/stripe/checkout/course", { + method: "POST", + headers: { + "Content-Type": "application/json", + "x-forwarded-for": `10.0.0.${ipCounter}`, + }, + body: JSON.stringify(body), + }) as never; +} + +const publishedCourse = { + id: "course-1", + name: "Yoga", + status: "published", + price: 29, + date: new Date("2099-01-01"), + timeFrom: "10:00", + maxParticipants: 20, + _count: { bookings: 0 }, + business: { + id: "biz-1", + name: "Studio", + stripeAccountId: "acct_123", + stripeAccountStatus: "active", + }, +}; + +beforeEach(() => { + vi.mocked(getUserData).mockReset(); + vi.mocked(isUser).mockReset(); + vi.mocked(isManagerOrEmployeeEmail).mockReset(); + vi.mocked(prisma.course.findUnique).mockReset(); + vi.mocked(prisma.courseBooking.findUnique).mockReset(); + vi.mocked(stripe.checkout.sessions.create).mockReset(); + + vi.mocked(stripe.checkout.sessions.create).mockResolvedValue({ + url: "https://checkout.stripe.com/session", + } as never); + vi.mocked(prisma.course.findUnique).mockResolvedValue(publishedCourse as never); +}); + +describe("POST /api/stripe/checkout/course – Gast mit Team-Account-E-Mail (Ticket 6)", () => { + it("lehnt eine Gast-Buchung mit Manager/Employee-E-Mail mit 403 ab, ohne Stripe-Session", async () => { + vi.mocked(getUserData).mockResolvedValue(null as never); + vi.mocked(isManagerOrEmployeeEmail).mockResolvedValue(true); + + const res = await POST(makeRequest({ courseId: "course-1", guestEmail: "Manager@Studio.de" })); + + expect(res.status).toBe(403); + const data = await res.json(); + expect(data.error).toMatch(/Team-Account/i); + expect(isManagerOrEmployeeEmail).toHaveBeenCalledWith("manager@studio.de"); + expect(stripe.checkout.sessions.create).not.toHaveBeenCalled(); + }); + + it("lässt eine normale Gast-Buchung durch, wenn die E-Mail zu keinem Team-Account gehört", async () => { + vi.mocked(getUserData).mockResolvedValue(null as never); + vi.mocked(isManagerOrEmployeeEmail).mockResolvedValue(false); + vi.mocked(prisma.courseBooking.findUnique).mockResolvedValue(null as never); + + const res = await POST(makeRequest({ courseId: "course-1", guestEmail: "gast@example.com" })); + + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.url).toBe("https://checkout.stripe.com/session"); + expect(stripe.checkout.sessions.create).toHaveBeenCalledOnce(); + }); + + it("prüft die Team-Account-E-Mail nicht für eingeloggte Kunden-Accounts", async () => { + vi.mocked(getUserData).mockResolvedValue({ + id: "user-1", + email: "kunde@example.com", + } as never); + vi.mocked(isUser).mockReturnValue(true); + vi.mocked(prisma.courseBooking.findUnique).mockResolvedValue(null as never); + + const res = await POST(makeRequest({ courseId: "course-1" })); + + expect(res.status).toBe(200); + expect(isManagerOrEmployeeEmail).not.toHaveBeenCalled(); + }); + + it("weist eine ungültige Gast-E-Mail weiterhin vor der Team-Account-Prüfung mit 400 ab", async () => { + vi.mocked(getUserData).mockResolvedValue(null as never); + + const res = await POST(makeRequest({ courseId: "course-1", guestEmail: "keine-email" })); + + expect(res.status).toBe(400); + const data = await res.json(); + expect(data.isGuestRequired).toBe(true); + expect(isManagerOrEmployeeEmail).not.toHaveBeenCalled(); + }); +});