From 7aa887c5aa377a2c3fc46358b98269fec7a22d82 Mon Sep 17 00:00:00 2001
From: Rajpreet0
Date: Fri, 2 Oct 2026 14:30:54 +0200
Subject: [PATCH] added card creation and booking functionality
---
.../20261004100000_products/migration.sql | 23 +
.../migration.sql | 92 ++++
prisma/schema.prisma | 100 ++++
src/app/(main)/courses/myCards/page.tsx | 31 ++
src/app/(main)/kunden/[id]/page.tsx | 5 +-
src/app/(main)/kunden/page.tsx | 16 +-
src/app/(main)/settings/products/page.tsx | 38 ++
src/app/api/stripe/checkout/course/route.ts | 48 +-
src/app/api/stripe/checkout/pass/route.ts | 153 ++++++
src/app/api/stripe/webhook/route.ts | 78 ++-
src/app/api/upload/business-logo/route.ts | 13 +-
src/app/api/zahlungen/invoices/zip/route.tsx | 20 +-
.../passes/[passId]/invoice/route.tsx | 58 +++
src/app/business/[slug]/page.tsx | 26 +-
.../mail/templates/booking-confirmation.ts | 24 +-
.../templates/pass-booking-cancellation.ts | 113 +++++
src/lib/mail/templates/pass-confirmation.ts | 150 ++++++
src/lib/stripe-checkout.ts | 53 ++
src/lib/stripe-fee.ts | 20 +-
src/modules/auth/ui/views/login-view.tsx | 7 +-
.../courses/actions/booking-actions.ts | 19 +-
.../courses/actions/cancel-booking-shared.ts | 196 ++++++-
.../actions/cancellation-preview-actions.ts | 37 +-
src/modules/courses/actions/course-actions.ts | 32 +-
src/modules/courses/lib/notify-booking.ts | 51 ++
.../courses/ui/components/BookingDialog.tsx | 215 ++++++--
.../components/CancelBookingConfirmDialog.tsx | 40 +-
.../courses/ui/components/UserBookingCard.tsx | 7 +-
.../courses/ui/views/business-public-view.tsx | 18 +
.../dashboard/actions/dashboard-actions.ts | 37 +-
.../ui/components/DashboardNavbar.tsx | 1 +
.../ui/components/DashboardSearch.tsx | 19 +
.../ui/components/DashboardSidebar.tsx | 16 +-
.../kunden/actions/kunde-detail-actions.ts | 119 ++++-
src/modules/kunden/actions/kunden-actions.ts | 57 ++-
.../ui/components/kunde-booking-card.tsx | 9 +-
.../kunden/ui/components/kunden-table.tsx | 1 +
.../kunden/ui/views/kunde-detail-view.tsx | 43 +-
src/modules/kunden/ui/views/kunden-view.tsx | 84 ++-
.../notifications/lib/notification-types.ts | 7 +
.../ui/components/NotificationTypeIcon.tsx | 2 +
.../products/actions/pass-admin-actions.ts | 452 +++++++++++++++++
.../products/actions/pass-booking-actions.ts | 309 +++++++++++
.../products/actions/product-actions.ts | 227 +++++++++
src/modules/products/lib/card-design.ts | 105 ++++
src/modules/products/lib/card-format.ts | 28 +
src/modules/products/lib/card-pricing.ts | 15 +
src/modules/products/lib/pass-queries.ts | 124 +++++
src/modules/products/lib/pass-rules.ts | 75 +++
src/modules/products/lib/pass-webhook.ts | 182 +++++++
.../products/schemas/product-schema.ts | 36 ++
.../ui/components/IssuePassDialog.tsx | 216 ++++++++
.../ui/components/KundePassesSection.tsx | 459 +++++++++++++++++
.../products/ui/components/PassCard.tsx | 119 +++++
.../products/ui/components/ProductEditor.tsx | 480 ++++++++++++++++++
.../products/ui/components/ProductList.tsx | 194 +++++++
.../products/ui/components/PublicPassShop.tsx | 145 ++++++
.../products/ui/views/meine-karten-view.tsx | 150 ++++++
.../ui/views/settings-products-page-view.tsx | 232 +++++++++
src/modules/settings/lib/contrast.ts | 28 +-
.../ui/views/settings-overview-view.tsx | 9 +-
.../zahlungen/actions/invoice-actions.ts | 204 +++++++-
.../zahlungen/actions/zahlungen-actions.ts | 163 ++++--
.../actions/zahlungen-metrics-actions.ts | 22 +-
.../zahlungen/lib/build-zahlungen-where.ts | 71 ++-
.../ui/components/zahlungen-status-badge.tsx | 2 +
.../ui/components/zahlungen-table.tsx | 16 +-
tests/api/stripe/checkout-pass.test.ts | 96 ++++
tests/api/stripe/webhook.test.ts | 59 +++
tests/api/upload/business-logo.test.ts | 75 +++
tests/courses/booking-dialog.test.tsx | 4 +
tests/courses/business-public-view.test.tsx | 1 +
tests/courses/cancel-pass-booking.test.ts | 158 ++++++
tests/courses/delete-course.test.ts | 86 ++++
tests/lib/stripe-fee.test.ts | 33 +-
tests/products/card-design.test.ts | 92 ++++
tests/products/pass-admin-actions.test.ts | 206 ++++++++
tests/products/pass-booking-actions.test.ts | 186 +++++++
tests/products/pass-card.test.tsx | 47 ++
tests/products/pass-rules.test.ts | 58 +++
tests/products/pass-webhook.test.ts | 127 +++++
tests/products/product-actions.test.ts | 159 ++++++
tests/products/product-schema.test.ts | 58 +++
tests/zahlungen/zahlungen-actions.test.ts | 135 +++++
tests/zahlungen/zahlungen-metrics.test.ts | 27 +
85 files changed, 7434 insertions(+), 314 deletions(-)
create mode 100644 prisma/migrations/20261004100000_products/migration.sql
create mode 100644 prisma/migrations/20261004110000_customer_passes/migration.sql
create mode 100644 src/app/(main)/courses/myCards/page.tsx
create mode 100644 src/app/(main)/settings/products/page.tsx
create mode 100644 src/app/api/stripe/checkout/pass/route.ts
create mode 100644 src/app/api/zahlungen/passes/[passId]/invoice/route.tsx
create mode 100644 src/lib/mail/templates/pass-booking-cancellation.ts
create mode 100644 src/lib/mail/templates/pass-confirmation.ts
create mode 100644 src/lib/stripe-checkout.ts
create mode 100644 src/modules/courses/lib/notify-booking.ts
create mode 100644 src/modules/products/actions/pass-admin-actions.ts
create mode 100644 src/modules/products/actions/pass-booking-actions.ts
create mode 100644 src/modules/products/actions/product-actions.ts
create mode 100644 src/modules/products/lib/card-design.ts
create mode 100644 src/modules/products/lib/card-format.ts
create mode 100644 src/modules/products/lib/card-pricing.ts
create mode 100644 src/modules/products/lib/pass-queries.ts
create mode 100644 src/modules/products/lib/pass-rules.ts
create mode 100644 src/modules/products/lib/pass-webhook.ts
create mode 100644 src/modules/products/schemas/product-schema.ts
create mode 100644 src/modules/products/ui/components/IssuePassDialog.tsx
create mode 100644 src/modules/products/ui/components/KundePassesSection.tsx
create mode 100644 src/modules/products/ui/components/PassCard.tsx
create mode 100644 src/modules/products/ui/components/ProductEditor.tsx
create mode 100644 src/modules/products/ui/components/ProductList.tsx
create mode 100644 src/modules/products/ui/components/PublicPassShop.tsx
create mode 100644 src/modules/products/ui/views/meine-karten-view.tsx
create mode 100644 src/modules/products/ui/views/settings-products-page-view.tsx
create mode 100644 tests/api/stripe/checkout-pass.test.ts
create mode 100644 tests/api/upload/business-logo.test.ts
create mode 100644 tests/courses/cancel-pass-booking.test.ts
create mode 100644 tests/courses/delete-course.test.ts
create mode 100644 tests/products/card-design.test.ts
create mode 100644 tests/products/pass-admin-actions.test.ts
create mode 100644 tests/products/pass-booking-actions.test.ts
create mode 100644 tests/products/pass-card.test.tsx
create mode 100644 tests/products/pass-rules.test.ts
create mode 100644 tests/products/pass-webhook.test.ts
create mode 100644 tests/products/product-actions.test.ts
create mode 100644 tests/products/product-schema.test.ts
create mode 100644 tests/zahlungen/zahlungen-actions.test.ts
diff --git a/prisma/migrations/20261004100000_products/migration.sql b/prisma/migrations/20261004100000_products/migration.sql
new file mode 100644
index 0000000..6201f75
--- /dev/null
+++ b/prisma/migrations/20261004100000_products/migration.sql
@@ -0,0 +1,23 @@
+-- CreateTable
+CREATE TABLE "Product" (
+ "id" TEXT NOT NULL,
+ "businessId" TEXT NOT NULL,
+ "name" TEXT NOT NULL,
+ "description" TEXT,
+ "credits" INTEGER NOT NULL,
+ "priceCents" INTEGER NOT NULL,
+ "validityMonths" INTEGER,
+ "sports" TEXT[],
+ "design" JSONB NOT NULL,
+ "isActive" BOOLEAN NOT NULL DEFAULT true,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updatedAt" TIMESTAMP(3) NOT NULL,
+
+ CONSTRAINT "Product_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateIndex
+CREATE INDEX "Product_businessId_idx" ON "Product"("businessId");
+
+-- AddForeignKey
+ALTER TABLE "Product" ADD CONSTRAINT "Product_businessId_fkey" FOREIGN KEY ("businessId") REFERENCES "Business"("id") ON DELETE CASCADE ON UPDATE CASCADE;
diff --git a/prisma/migrations/20261004110000_customer_passes/migration.sql b/prisma/migrations/20261004110000_customer_passes/migration.sql
new file mode 100644
index 0000000..2a156b6
--- /dev/null
+++ b/prisma/migrations/20261004110000_customer_passes/migration.sql
@@ -0,0 +1,92 @@
+-- AlterTable
+ALTER TABLE "CourseBooking" ADD COLUMN "passId" TEXT;
+
+-- CreateTable
+CREATE TABLE "CustomerPass" (
+ "id" TEXT NOT NULL,
+ "productId" TEXT NOT NULL,
+ "businessId" TEXT NOT NULL,
+ "userId" TEXT,
+ "email" TEXT NOT NULL,
+ "name" TEXT NOT NULL,
+ "sports" TEXT[],
+ "creditsTotal" INTEGER NOT NULL,
+ "creditsRemaining" INTEGER NOT NULL,
+ "validUntil" TIMESTAMP(3),
+ "status" TEXT NOT NULL DEFAULT 'active',
+ "source" TEXT NOT NULL,
+ "priceCents" INTEGER,
+ "stripeAccountId" TEXT,
+ "stripeSessionId" TEXT,
+ "stripePaymentIntentId" TEXT,
+ "amountPaid" INTEGER,
+ "platformFee" INTEGER,
+ "stripeFee" INTEGER,
+ "billingAddress" JSONB,
+ "invoiceNumber" TEXT,
+ "invoiceIssuedAt" TIMESTAMP(3),
+ "refundedAmount" INTEGER,
+ "refundedAt" TIMESTAMP(3),
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updatedAt" TIMESTAMP(3) NOT NULL,
+
+ CONSTRAINT "CustomerPass_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "PassAdjustment" (
+ "id" TEXT NOT NULL,
+ "passId" TEXT NOT NULL,
+ "managerId" TEXT NOT NULL,
+ "type" TEXT NOT NULL,
+ "creditsDelta" INTEGER,
+ "previousValidUntil" TIMESTAMP(3),
+ "newValidUntil" TIMESTAMP(3),
+ "reason" TEXT,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+
+ CONSTRAINT "PassAdjustment_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateIndex
+CREATE UNIQUE INDEX "CustomerPass_stripeSessionId_key" ON "CustomerPass"("stripeSessionId");
+
+-- CreateIndex
+CREATE UNIQUE INDEX "CustomerPass_stripePaymentIntentId_key" ON "CustomerPass"("stripePaymentIntentId");
+
+-- CreateIndex
+CREATE UNIQUE INDEX "CustomerPass_invoiceNumber_key" ON "CustomerPass"("invoiceNumber");
+
+-- CreateIndex
+CREATE INDEX "CustomerPass_businessId_idx" ON "CustomerPass"("businessId");
+
+-- CreateIndex
+CREATE INDEX "CustomerPass_userId_idx" ON "CustomerPass"("userId");
+
+-- CreateIndex
+CREATE INDEX "CustomerPass_email_idx" ON "CustomerPass"("email");
+
+-- CreateIndex
+CREATE INDEX "CustomerPass_productId_idx" ON "CustomerPass"("productId");
+
+-- CreateIndex
+CREATE INDEX "PassAdjustment_passId_idx" ON "PassAdjustment"("passId");
+
+-- CreateIndex
+CREATE INDEX "CourseBooking_passId_idx" ON "CourseBooking"("passId");
+
+-- AddForeignKey
+ALTER TABLE "CourseBooking" ADD CONSTRAINT "CourseBooking_passId_fkey" FOREIGN KEY ("passId") REFERENCES "CustomerPass"("id") ON DELETE SET NULL ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "CustomerPass" ADD CONSTRAINT "CustomerPass_productId_fkey" FOREIGN KEY ("productId") REFERENCES "Product"("id") ON DELETE NO ACTION ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "CustomerPass" ADD CONSTRAINT "CustomerPass_businessId_fkey" FOREIGN KEY ("businessId") REFERENCES "Business"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "CustomerPass" ADD CONSTRAINT "CustomerPass_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "PassAdjustment" ADD CONSTRAINT "PassAdjustment_passId_fkey" FOREIGN KEY ("passId") REFERENCES "CustomerPass"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index 3aee80d..35fe908 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -43,6 +43,7 @@ model User {
courseBookings CourseBooking[]
courseWaitlistEntries CourseWaitlistEntry[]
discountRedemptions DiscountRedemption[]
+ passes CustomerPass[]
createdAt DateTime? @default(now())
updatedAt DateTime? @updatedAt
}
@@ -140,6 +141,8 @@ model Business {
manager Manager @relation(fields: [managerId], references: [id])
courses Course[]
discountCodes DiscountCode[]
+ products Product[]
+ customerPasses CustomerPass[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -243,6 +246,13 @@ model CourseBooking {
seatsPurchased Int @default(1) // ursprünglich bezahlte Plätze - Bezugsgröße für den Betrag pro Platz
spotIds String[] @default([]) // Geräte aus dem Raum-Grundriss (Room.floorPlan.items[].id), nur bei Kursen mit Platzwahl, ein Eintrag pro Platz. Belegt, solange die Buchung "paid" ist.
spotLabels String[] @default([]) // Anzeigenamen als Momentaufnahme, z.B. "Reformer 3", parallel zu spotIds - bleiben lesbar, auch wenn der Grundriss sich ändert
+ // Mit einer Karte gebucht: kein Geld, keine Stripe-Felder, keine Rechnung (die
+ // gehört zum Kartenkauf). Pro Platz ist eine Einheit von der Karte abgegangen.
+ // Status wie bei bezahlten Buchungen ("paid" = gebucht), damit Auslastung und
+ // Platzwahl unverändert über seat-counts.ts laufen. Storno: "refunded" = Einheit
+ // zurück auf der Karte, "cancelled" = Einheit verfallen.
+ passId String?
+ pass CustomerPass? @relation(fields: [passId], references: [id], onDelete: SetNull)
course Course @relation(fields: [courseId], references: [id], onDelete: Cascade)
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
guestLead GuestLead? @relation(fields: [guestLeadId], references: [id], onDelete: SetNull)
@@ -256,6 +266,7 @@ model CourseBooking {
@@index([guestLeadId])
@@index([discountCodeId])
@@index([spotIds], type: Gin)
+ @@index([passId])
}
// Hält einen Platz frei, während der Kunde im Stripe-Checkout bezahlt - analog
@@ -400,3 +411,92 @@ model DiscountRedemption {
@@index([userId])
@@index([status])
}
+
+// Karte, die ein Studio verkauft, z.B. eine 10er-Karte. Eingeloggte Kunden
+// kaufen sie auf der öffentlichen Seite und lösen die Einheiten beim Buchen ein:
+// 1 Einheit = 1 Platz in einem Kurs. Zum Preis kommt die Servicegebühr pro
+// Einheit dazu (siehe src/modules/products/lib/card-pricing.ts).
+model Product {
+ id String @id @default(uuid())
+ businessId String
+ name String
+ description String?
+ credits Int // Einheiten pro Karte
+ priceCents Int // Preis der Karte in Cents, ohne Servicegebühr
+ validityMonths Int? // Gültigkeit ab Kauf in Monaten, null = unbegrenzt gültig
+ sports String[] // Kursarten (Course.sport), für die die Karte gilt. Leer = alle Kurse.
+ design Json // Kartendesign, Format siehe src/modules/products/lib/card-design.ts
+ isActive Boolean @default(true) // false = nicht mehr kaufbar
+ business Business @relation(fields: [businessId], references: [id], onDelete: Cascade)
+ passes CustomerPass[]
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+
+ @@index([businessId])
+}
+
+// Eine Karte, die ein Kunde gekauft oder vom Studio bekommen hat. Name, Kursarten,
+// Einheiten und Ablaufdatum sind Momentaufnahmen beim Kauf - spätere Änderungen am
+// Produkt gelten nur für neue Käufe. Das Design kommt live vom Produkt.
+//
+// creditsRemaining ist ein Zähler: Buchen zieht ab (atomar, nur wenn genug übrig
+// ist), Storno innerhalb der Frist und Kursabsage geben zurück, der Manager passt
+// ihn im Kunden-Detail an. Jede manuelle Änderung steht in PassAdjustment.
+model CustomerPass {
+ id String @id @default(uuid())
+ productId String
+ businessId String
+ // null = vom Studio an eine E-Mail vergeben, die noch kein Konto hat. Die Karte
+ // hängt sich an das Konto, sobald sich jemand mit dieser Adresse anmeldet.
+ userId String?
+ email String // Empfänger, immer lowercase
+ name String
+ sports String[] // leer = alle Kurse
+ creditsTotal Int // alle Einheiten, die die Karte je bekommen hat (inkl. Gutschriften)
+ creditsRemaining Int
+ validUntil DateTime? // null = unbegrenzt gültig
+ status String @default("active") // "active" | "ended" (vom Studio beendet) | "refunded"
+ source String // "purchase" | "manual" (vom Studio vergeben, ohne Zahlung)
+ // Zahlung - nur bei source = "purchase"
+ priceCents Int?
+ stripeAccountId String?
+ stripeSessionId String? @unique
+ stripePaymentIntentId String? @unique
+ amountPaid Int? // inkl. Servicegebühr
+ platformFee Int?
+ stripeFee Int?
+ billingAddress Json?
+ invoiceNumber String? @unique
+ invoiceIssuedAt DateTime?
+ refundedAmount Int?
+ refundedAt DateTime?
+ product Product @relation(fields: [productId], references: [id], onDelete: NoAction)
+ business Business @relation(fields: [businessId], references: [id], onDelete: Cascade)
+ user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
+ bookings CourseBooking[]
+ adjustments PassAdjustment[]
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+
+ @@index([businessId])
+ @@index([userId])
+ @@index([email])
+ @@index([productId])
+}
+
+// Protokoll jeder Änderung durch das Studio: Karte vergeben, Einheiten
+// gutgeschrieben oder abgezogen, verlängert, beendet, erstattet.
+model PassAdjustment {
+ id String @id @default(uuid())
+ passId String
+ managerId String
+ type String // "issue" | "credits" | "extend" | "end" | "refund"
+ creditsDelta Int?
+ previousValidUntil DateTime?
+ newValidUntil DateTime?
+ reason String?
+ pass CustomerPass @relation(fields: [passId], references: [id], onDelete: Cascade)
+ createdAt DateTime @default(now())
+
+ @@index([passId])
+}
diff --git a/src/app/(main)/courses/myCards/page.tsx b/src/app/(main)/courses/myCards/page.tsx
new file mode 100644
index 0000000..ca3c7c3
--- /dev/null
+++ b/src/app/(main)/courses/myCards/page.tsx
@@ -0,0 +1,31 @@
+import { redirect } from "next/navigation";
+import { requireAuthWithData, isUser } from "@/src/lib/auth/getUser";
+import { findPassesForUser } from "@/src/modules/products/lib/pass-queries";
+import MeineKartenView from "@/src/modules/products/ui/views/meine-karten-view";
+
+interface MeineKartenPageProps {
+ searchParams: Promise<{ purchased?: string }>;
+}
+
+const MeineKartenPage = async ({ searchParams }: MeineKartenPageProps) => {
+ const userData = await requireAuthWithData();
+ if (!isUser(userData)) {
+ redirect("/dashboard");
+ }
+
+ const [passes, { purchased }] = await Promise.all([findPassesForUser(userData), searchParams]);
+
+ return (
+
+ );
+};
+
+export default MeineKartenPage;
+
+export const metadata = {
+ title: "Meine Karten",
+};
diff --git a/src/app/(main)/kunden/[id]/page.tsx b/src/app/(main)/kunden/[id]/page.tsx
index 6190574..fe5be1a 100644
--- a/src/app/(main)/kunden/[id]/page.tsx
+++ b/src/app/(main)/kunden/[id]/page.tsx
@@ -1,5 +1,6 @@
import { requireManager } from "@/src/lib/auth/getUser";
import { getKundeDetail } from "@/src/modules/kunden/actions/kunde-detail-actions";
+import { getPassIssueOptions } from "@/src/modules/products/actions/pass-admin-actions";
import { ArrowLeft } from "lucide-react";
import Link from "next/link";
import { notFound } from "next/navigation";
@@ -13,7 +14,7 @@ const KundeDetailPage = async ({ params }: Props) => {
await requireManager();
const { id } = await params;
- const result = await getKundeDetail(id);
+ const [result, issueOptions] = await Promise.all([getKundeDetail(id), getPassIssueOptions()]);
if (!result.success) {
if (result.error === "Kunde nicht gefunden.") {
@@ -31,7 +32,7 @@ const KundeDetailPage = async ({ params }: Props) => {
Zurück zur Übersicht
-
+
);
};
diff --git a/src/app/(main)/kunden/page.tsx b/src/app/(main)/kunden/page.tsx
index 9da88c0..fa88a4b 100644
--- a/src/app/(main)/kunden/page.tsx
+++ b/src/app/(main)/kunden/page.tsx
@@ -1,19 +1,31 @@
import { Suspense } from "react";
import { requireManager } from "@/src/lib/auth/getUser";
import { getKunden } from "@/src/modules/kunden/actions/kunden-actions";
+import {
+ getPassIssueOptions,
+ getPendingPasses,
+} from "@/src/modules/products/actions/pass-admin-actions";
import KundenView from "@/src/modules/kunden/ui/views/kunden-view";
import KundenLoading from "./loading";
const KundenPage = async () => {
await requireManager();
- const result = await getKunden();
+ const [result, issueOptions, pendingPasses] = await Promise.all([
+ getKunden(),
+ getPassIssueOptions(),
+ getPendingPasses(),
+ ]);
if (!result.success) {
throw new Error(result.error);
}
return (
}>
-
+
);
};
diff --git a/src/app/(main)/settings/products/page.tsx b/src/app/(main)/settings/products/page.tsx
new file mode 100644
index 0000000..d4eede9
--- /dev/null
+++ b/src/app/(main)/settings/products/page.tsx
@@ -0,0 +1,38 @@
+import { Suspense } from "react";
+import { AlertTriangle } from "lucide-react";
+import { requireManager } from "@/src/lib/auth/getUser";
+import { getPlatformFeeCents } from "@/src/lib/stripe";
+import { getProductsPageData } from "@/src/modules/products/actions/product-actions";
+import SettingsProductsPageView from "@/src/modules/products/ui/views/settings-products-page-view";
+import SettingsLoading from "../loading";
+
+export default async function SettingsProductsPage() {
+ const manager = await requireManager();
+ const businesses = manager.businesses.map((b) => ({ id: b.id, name: b.name }));
+
+ const firstBusinessId = businesses[0]?.id;
+ const result = firstBusinessId ? await getProductsPageData(firstBusinessId) : null;
+
+ if (result && !result.success) {
+ return (
+
+
+
{result.error}
+
+ );
+ }
+
+ return (
+ }>
+
+
+ );
+}
+
+export const metadata = {
+ title: "Produkte",
+};
diff --git a/src/app/api/stripe/checkout/course/route.ts b/src/app/api/stripe/checkout/course/route.ts
index 95fdf29..f27e79c 100644
--- a/src/app/api/stripe/checkout/course/route.ts
+++ b/src/app/api/stripe/checkout/course/route.ts
@@ -1,9 +1,7 @@
import { NextRequest, NextResponse } from "next/server";
-import type Stripe from "stripe";
import { prisma } from "@/src/lib/prisma";
-import { toEmailBranding } from "@/src/modules/settings/lib/to-email-branding";
-import { isAllowedLogoUrl } from "@/src/modules/settings/lib/logo-url";
import { stripe, getPlatformFeeCents, eurosToCents } from "@/src/lib/stripe";
+import { createBrandedCheckoutSession, studioCheckoutBranding } from "@/src/lib/stripe-checkout";
import { getUserData, isUser } from "@/src/lib/auth/getUser";
import { isManagerOrEmployeeEmail } from "@/src/lib/auth/account-role-by-email";
import {
@@ -23,34 +21,6 @@ import {
import { MAX_SEATS_PER_BOOKING, paidSeatCount } from "@/src/modules/courses/lib/seat-counts";
import { spotDisplayName, type FloorPlanItem } from "@/src/modules/rooms/lib/floor-plan";
-/**
- * Erstellt die Checkout-Session im Branding des Studios. Lehnt Stripe das
- * Branding ab (z.B. ein Logo, das Stripe nicht laden kann), wird ohne Branding
- * erneut versucht - ein Logo darf nie eine Buchung verhindern.
- */
-async function createBrandedCheckoutSession(
- params: Stripe.Checkout.SessionCreateParams,
- branding: Stripe.Checkout.SessionCreateParams.BrandingSettings,
- stripeAccountId: string
-) {
- // Direct Charge: die Session gehört dem Studio-Account.
- const options = { stripeAccount: stripeAccountId };
- try {
- return await stripe.checkout.sessions.create(
- { ...params, branding_settings: branding },
- options
- );
- } catch (error) {
- const param = (error as { param?: string }).param;
- if (!param?.startsWith("branding_settings")) throw error;
- console.warn(
- `Checkout-Branding von Stripe abgelehnt (${param}), Session ohne Branding:`,
- error
- );
- return stripe.checkout.sessions.create(params, options);
- }
-}
-
/**
* Pro Person und Kurs läuft höchstens ein offener Checkout mit Platz. Startet
* jemand neu (zurück aus dem Checkout, zweiter Tab), wird die alte Session bei
@@ -495,20 +465,6 @@ export async function POST(request: NextRequest) {
spotHoldIds: spotHoldIds.join(","),
};
- // Der Kunde bucht beim Studio - der Checkout trägt deshalb dessen Farben
- // und Logo, wie die öffentliche Seite davor. Ohne eigenes Branding greifen
- // die Kumasy-Farben.
- const studioBranding = toEmailBranding(course.business);
- const brandingSettings: Stripe.Checkout.SessionCreateParams.BrandingSettings = {
- display_name: course.business.name,
- background_color: studioBranding.primaryColor,
- button_color: studioBranding.accentColor,
- border_style: "rounded",
- ...(studioBranding.logoUrl && isAllowedLogoUrl(studioBranding.logoUrl)
- ? { logo: { type: "url", url: studioBranding.logoUrl } }
- : {}),
- };
-
session = await createBrandedCheckoutSession(
{
mode: "payment",
@@ -568,7 +524,7 @@ export async function POST(request: NextRequest) {
success_url: `${baseUrl}/courses/book/${course.id}/success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${baseUrl}/courses/book/${course.id}/cancel`,
},
- brandingSettings,
+ studioCheckoutBranding(course.business),
course.business.stripeAccountId
);
} catch (error) {
diff --git a/src/app/api/stripe/checkout/pass/route.ts b/src/app/api/stripe/checkout/pass/route.ts
new file mode 100644
index 0000000..aec1f3e
--- /dev/null
+++ b/src/app/api/stripe/checkout/pass/route.ts
@@ -0,0 +1,153 @@
+import { NextRequest, NextResponse } from "next/server";
+import { prisma } from "@/src/lib/prisma";
+import { getPlatformFeeCents } from "@/src/lib/stripe";
+import { createBrandedCheckoutSession, studioCheckoutBranding } from "@/src/lib/stripe-checkout";
+import { getUserData, isUser } from "@/src/lib/auth/getUser";
+import { cardServiceFeeCents } from "@/src/modules/products/lib/card-pricing";
+import { formatValidity } from "@/src/modules/products/lib/card-format";
+import { PASS_CHECKOUT_KIND } from "@/src/modules/products/lib/pass-webhook";
+
+/**
+ * Erstellt eine Stripe Checkout Session für den Kauf einer Karte.
+ *
+ * Wie bei Kursbuchungen eine Direct Charge auf dem Express-Account des Studios:
+ * der Kunde zahlt den Kartenpreis plus die Servicegebühr pro Einheit, die als
+ * application_fee an Kumasy geht. Die Karte selbst legt erst der Webhook an,
+ * wenn Stripe die Zahlung meldet (siehe pass-webhook.ts).
+ *
+ * Nur für eingeloggte Kunden: eine Karte hängt an einem Konto, mit dem später
+ * gebucht wird.
+ *
+ * Body: { productId: string }
+ */
+export async function POST(request: NextRequest) {
+ try {
+ const userData = await getUserData();
+ if (!userData) {
+ return NextResponse.json(
+ { error: "Bitte melden Sie sich an, um eine Karte zu kaufen.", loginRequired: true },
+ { status: 401 }
+ );
+ }
+ // Manager und Mitarbeiter haben keinen User-Eintrag - die Karte könnte an
+ // nichts hängen, und das erst, nachdem das Geld geflossen ist.
+ if (!isUser(userData)) {
+ return NextResponse.json(
+ { error: "Nur Kunden-Accounts können Karten kaufen." },
+ { status: 403 }
+ );
+ }
+
+ const { productId } = (await request.json()) as { productId?: string };
+ if (!productId) {
+ return NextResponse.json({ error: "productId fehlt" }, { status: 400 });
+ }
+
+ const product = await prisma.product.findUnique({
+ where: { id: productId },
+ select: {
+ id: true,
+ name: true,
+ credits: true,
+ priceCents: true,
+ validityMonths: true,
+ isActive: true,
+ business: {
+ select: {
+ id: true,
+ name: true,
+ slug: true,
+ isPublic: true,
+ branding: true,
+ stripeAccountId: true,
+ stripeAccountStatus: true,
+ },
+ },
+ },
+ });
+
+ if (!product || !product.isActive || !product.business.isPublic) {
+ return NextResponse.json({ error: "Diese Karte ist nicht verfügbar." }, { status: 404 });
+ }
+
+ const { business } = product;
+ if (!business.stripeAccountId || business.stripeAccountStatus !== "active") {
+ return NextResponse.json(
+ { error: "Dieser Anbieter hat noch keine Zahlung eingerichtet. Bitte später versuchen." },
+ { status: 400 }
+ );
+ }
+
+ const feePerUnitCents = getPlatformFeeCents();
+ const feeCents = cardServiceFeeCents(product.credits, feePerUnitCents);
+ const baseUrl = process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000";
+
+ // Alles, was der Webhook braucht, um die Karte anzulegen. Liegt an Session
+ // UND PaymentIntent, damit es auch im Stripe-Dashboard an der Zahlung steht.
+ const metadata = {
+ kind: PASS_CHECKOUT_KIND,
+ productId: product.id,
+ userId: userData.id,
+ businessId: business.id,
+ platformFeeModel: "on_top",
+ };
+
+ const session = await createBrandedCheckoutSession(
+ {
+ mode: "payment",
+ // Rechnungsadresse ist Pflicht - sie steht auf der Rechnung zum Kartenkauf.
+ billing_address_collection: "required",
+ line_items: [
+ {
+ price_data: {
+ currency: "eur",
+ product_data: {
+ name: product.name,
+ description: `${business.name} - ${product.credits} ${
+ product.credits === 1 ? "Einheit" : "Einheiten"
+ } · ${formatValidity(product.validityMonths)}`,
+ },
+ unit_amount: product.priceCents,
+ },
+ quantity: 1,
+ },
+ // Servicegebühr pro Einheit - eine 10er-Karte kostet so viel Gebühr wie
+ // zehn Einzelbuchungen. Bei 0 entfällt die Position ganz.
+ ...(feeCents > 0
+ ? [
+ {
+ price_data: {
+ currency: "eur" as const,
+ product_data: {
+ name: "Servicegebühr",
+ description: "Gebühr für die Online-Buchung, pro Einheit",
+ },
+ unit_amount: feePerUnitCents,
+ },
+ quantity: product.credits,
+ },
+ ]
+ : []),
+ ],
+ payment_intent_data: {
+ ...(feeCents > 0 ? { application_fee_amount: feeCents } : {}),
+ metadata,
+ },
+ metadata,
+ customer_email: userData.email,
+ success_url: `${baseUrl}/courses/myCards?purchased=1`,
+ cancel_url: business.slug ? `${baseUrl}/business/${business.slug}` : baseUrl,
+ },
+ studioCheckoutBranding(business),
+ business.stripeAccountId
+ );
+
+ return NextResponse.json({ url: session.url });
+ } catch (error) {
+ console.error("Error creating pass checkout session:", error);
+ return NextResponse.json(
+ { error: "Der Kauf konnte nicht gestartet werden. Bitte versuchen Sie es erneut." },
+ { status: 500 }
+ );
+ }
+}
diff --git a/src/app/api/stripe/webhook/route.ts b/src/app/api/stripe/webhook/route.ts
index 5cc66af..25e75ad 100644
--- a/src/app/api/stripe/webhook/route.ts
+++ b/src/app/api/stripe/webhook/route.ts
@@ -13,7 +13,7 @@ import {
type BookingRefundReason,
} 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 { notifyManagerOfBooking } from "@/src/modules/courses/lib/notify-booking";
import { isManagerOrEmployeeEmail } from "@/src/lib/auth/account-role-by-email";
import {
confirmDiscountReservation,
@@ -28,6 +28,10 @@ import {
type AssignedSpot,
} from "@/src/modules/courses/lib/course-spots";
import { MAX_SEATS_PER_BOOKING, paidSeatCount } from "@/src/modules/courses/lib/seat-counts";
+import {
+ handlePassCheckoutCompleted,
+ isPassCheckout,
+} from "@/src/modules/products/lib/pass-webhook";
/**
* Sucht einen registrierten User anhand der E-Mail, case-insensitive.
@@ -58,6 +62,7 @@ async function findUserByEmailInsensitive(email: string) {
* Die folgenden Events werden verarbeitet:
* - checkout.session.completed → Buchung anlegen, sobald bezahlt. Bei
* verzögerten Zahlungsarten (SEPA) ist die Session hier noch "unpaid".
+ * Kartenkäufe (metadata.kind = "pass") legen stattdessen die Karte an.
* - checkout.session.async_payment_succeeded → die verzögerte Zahlung ist
* angekommen, Buchung anlegen wie oben.
* - checkout.session.expired / async_payment_failed → nie bezahlt: Rabattcode-
@@ -89,12 +94,23 @@ export async function POST(request: NextRequest) {
switch (event.type) {
case "checkout.session.completed":
case "checkout.session.async_payment_succeeded": {
- await handleCheckoutCompleted(event.data.object, stripeAccountId);
+ if (isPassCheckout(event.data.object)) {
+ await handlePassCheckoutCompleted(event.data.object, stripeAccountId);
+ revalidatePath("/courses/myCards");
+ const buyerId = event.data.object.metadata?.userId;
+ if (buyerId) revalidateTag(`kunde-detail-${buyerId}`, { expire: 0 });
+ } else {
+ await handleCheckoutCompleted(event.data.object, stripeAccountId);
+ }
break;
}
case "checkout.session.expired":
case "checkout.session.async_payment_failed": {
- await handleCheckoutUnpaid(event.data.object);
+ // Ein Kartenkauf reserviert nichts (keine Plätze, kein Rabattcode) - nie
+ // bezahlt heißt einfach: keine Karte.
+ if (!isPassCheckout(event.data.object)) {
+ await handleCheckoutUnpaid(event.data.object);
+ }
break;
}
case "charge.updated": {
@@ -606,6 +622,9 @@ function seatColumns(seats: number, spots: AssignedSpot[]) {
refundedAt: null,
cancelledBy: null,
cancellationReason: null,
+ // Eine frühere, stornierte Kartenbuchung derselben Person: diese Buchung ist
+ // bezahlt, nicht mit Karte - sonst gäbe ein Storno Einheiten statt Geld zurück.
+ passId: null,
};
}
@@ -714,6 +733,14 @@ async function runCapacityTransactionUser(params: {
return { result: "duplicate_payment" as const, bookingId: existing.id };
}
+ // Der Kunde hat den Kurs inzwischen mit einer Karte gebucht (z.B. ein
+ // älterer Checkout im zweiten Tab, der danach doch bezahlt wurde). Diese
+ // Zahlung würde die Kartenbuchung überschreiben und die abgebuchte
+ // Einheit ginge verloren - sie wird stattdessen erstattet.
+ if (existing && existing.paymentStatus === "paid" && existing.passId) {
+ return { result: "duplicate_payment" as const, bookingId: existing.id };
+ }
+
const course = await tx.course.findUnique({
where: { id: courseId },
select: { maxParticipants: true, spotBooking: true, room: true },
@@ -980,51 +1007,6 @@ async function removeFromWaitlistIfPresent(courseId: string, bookingId: string):
await prisma.courseWaitlistEntry.deleteMany({ where: { courseId, userId: booking.userId } });
}
-/**
- * Legt für den Studio-Manager eine In-App-Benachrichtigung über eine neue,
- * bezahlte Buchung an - und zusätzlich eine "Kurs ausgebucht"-Benachrichtigung,
- * falls diese Buchung den Kurs gefüllt hat. Wirft nie (notifyBusinessManager
- * kapselt alle Fehler), der Aufrufer hängt zusätzlich ein .catch daran.
- */
-async function notifyManagerOfBooking(
- courseId: string,
- customerLabel: string,
- seats: number
-): Promise {
- const course = await prisma.course.findUnique({
- where: { id: courseId },
- select: { name: true, date: true, businessId: true, maxParticipants: true },
- });
- if (!course?.businessId) return;
-
- const courseDate = course.date.toLocaleDateString("de-DE", {
- day: "numeric",
- month: "long",
- timeZone: "Europe/Berlin",
- });
- const linkUrl = `/courses/edit/${courseId}`;
-
- await notifyBusinessManager(course.businessId, {
- type: "booking.created",
- title: `Neue Buchung: ${course.name}`,
- body:
- seats > 1
- ? `${customerLabel} hat ${seats} Plätze für den ${courseDate} gebucht.`
- : `${customerLabel} hat sich für den ${courseDate} angemeldet.`,
- linkUrl,
- });
-
- const paidCount = await paidSeatCount(prisma, courseId);
- if (paidCount >= course.maxParticipants) {
- await notifyBusinessManager(course.businessId, {
- type: "course.full",
- title: `Kurs ausgebucht: ${course.name}`,
- body: `Der Kurs am ${courseDate} hat mit ${paidCount} Teilnehmern die maximale Kapazität erreicht.`,
- linkUrl,
- });
- }
-}
-
async function sendBookingRefund(
recipientEmail: string,
courseId: string,
diff --git a/src/app/api/upload/business-logo/route.ts b/src/app/api/upload/business-logo/route.ts
index b1972e4..b5eca6a 100644
--- a/src/app/api/upload/business-logo/route.ts
+++ b/src/app/api/upload/business-logo/route.ts
@@ -3,6 +3,8 @@ import { createAdminClient } from "@/src/lib/supabase/admin";
import { getUserData, isManager } from "@/src/lib/auth/getUser";
import { prisma } from "@/src/lib/prisma";
+const FOLDERS = { logo: "business-logos", card: "card-images" } as const;
+
/**
* POST /api/upload/business-logo
* Erwartet multipart/form-data mit "file" und "businessId". Anders als
@@ -15,6 +17,9 @@ import { prisma } from "@/src/lib/prisma";
* einen next/navigation-Redirect aus, der in einer API-Route, die JSON
* zurückgeben soll, falsch wäre (und im try/catch hier sogar als 500
* fehlinterpretiert würde statt als redirect zu funktionieren).
+ *
+ * Optional "purpose": "card" legt das Bild als Hintergrund einer Karte
+ * (Produkte) ab statt als Logo - gleiche Berechtigung, anderer Ordner.
*/
export async function POST(request: Request) {
try {
@@ -26,11 +31,17 @@ export async function POST(request: Request) {
const formData = await request.formData();
const file = formData.get("file") as File | null;
const businessId = formData.get("businessId") as string | null;
+ const purpose = (formData.get("purpose") as string | null) ?? "logo";
if (!file || !businessId) {
return NextResponse.json({ error: "Datei und Business erforderlich" }, { status: 400 });
}
+ if (!Object.hasOwn(FOLDERS, purpose)) {
+ return NextResponse.json({ error: "Unbekannter Verwendungszweck" }, { status: 400 });
+ }
+ const folder = FOLDERS[purpose as keyof typeof FOLDERS];
+
const business = await prisma.business.findUnique({
where: { id: businessId },
select: { managerId: true },
@@ -56,7 +67,7 @@ export async function POST(request: Request) {
.split(".")
.pop()
?.replace(/[^a-zA-Z0-9]/g, "") || "jpg";
- const safePath = `business-logos/${businessId}/${Date.now()}.${extension}`;
+ const safePath = `${folder}/${businessId}/${Date.now()}.${extension}`;
const adminClient = createAdminClient();
const buffer = Buffer.from(await file.arrayBuffer());
diff --git a/src/app/api/zahlungen/invoices/zip/route.tsx b/src/app/api/zahlungen/invoices/zip/route.tsx
index 001627b..4d775f8 100644
--- a/src/app/api/zahlungen/invoices/zip/route.tsx
+++ b/src/app/api/zahlungen/invoices/zip/route.tsx
@@ -1,7 +1,9 @@
import { requireManager } from "@/src/lib/auth/getUser";
import {
assembleInvoiceDataBatch,
+ assemblePassInvoiceDataBatch,
findInvoiceableBookingIds,
+ findInvoiceablePassIds,
} from "@/src/modules/zahlungen/actions/invoice-actions";
import type {
ZahlungenFilters,
@@ -45,9 +47,14 @@ export async function GET(request: Request) {
const filters = parseFilters(searchParams);
try {
- const { ids, truncated } = await findInvoiceableBookingIds(filters, manager);
+ // Kurs- und Kartenrechnungen landen im selben Archiv - die Buchhaltung
+ // braucht alle Belege des Zeitraums.
+ const [{ ids, truncated }, passIds] = await Promise.all([
+ findInvoiceableBookingIds(filters, manager),
+ findInvoiceablePassIds(filters, manager),
+ ]);
- if (ids.length === 0) {
+ if (ids.length === 0 && passIds.length === 0) {
return NextResponse.json(
{ error: "Keine bezahlten Zahlungen für diesen Filter gefunden." },
{ status: 404 }
@@ -55,7 +62,14 @@ export async function GET(request: Request) {
}
// Stammdaten aller Buchungen in einer Query statt einer Query pro ID.
- const { invoices, businessesMissingTax } = await assembleInvoiceDataBatch(ids, manager);
+ const [bookingInvoices, passInvoices] = await Promise.all([
+ assembleInvoiceDataBatch(ids, manager),
+ assemblePassInvoiceDataBatch(passIds, manager),
+ ]);
+ const invoices = [...bookingInvoices.invoices, ...passInvoices.invoices];
+ const businessesMissingTax = [
+ ...new Set([...bookingInvoices.businessesMissingTax, ...passInvoices.businessesMissingTax]),
+ ];
if (invoices.length === 0 && businessesMissingTax.length > 0) {
return NextResponse.json(
diff --git a/src/app/api/zahlungen/passes/[passId]/invoice/route.tsx b/src/app/api/zahlungen/passes/[passId]/invoice/route.tsx
new file mode 100644
index 0000000..e3b76f1
--- /dev/null
+++ b/src/app/api/zahlungen/passes/[passId]/invoice/route.tsx
@@ -0,0 +1,58 @@
+import { requireManager } from "@/src/lib/auth/getUser";
+import { assemblePassInvoiceData } from "@/src/modules/zahlungen/actions/invoice-actions";
+import { InvoicePdfDocument } from "@/src/modules/zahlungen/ui/components/invoice-pdf-document";
+import { renderToBuffer } from "@react-pdf/renderer";
+import { NextResponse } from "next/server";
+
+export const runtime = "nodejs";
+
+export async function GET(request: Request, { params }: { params: Promise<{ passId: string }> }) {
+ // requireManager() löst bei fehlender Session/Rolle einen next/navigation-Redirect aus,
+ // der als spezieller Error implementiert ist - darf nicht im try/catch landen.
+ const manager = await requireManager();
+
+ const { passId } = await params;
+
+ try {
+ const result = await assemblePassInvoiceData(passId, manager);
+
+ if (!result.ok) {
+ return result.reason === "missing_tax_settings"
+ ? NextResponse.json(
+ {
+ error:
+ "Bitte hinterlege zuerst deine Steuerangaben unter Einstellungen → Business. " +
+ "Ohne sie kann keine korrekte Rechnung erstellt werden.",
+ },
+ { status: 409 }
+ )
+ : NextResponse.json(
+ { error: "Rechnung nur für bezahlte Kartenkäufe verfügbar." },
+ { status: 404 }
+ );
+ }
+ const invoiceData = result.data;
+
+ // Ein fehlgeschlagener Logo-Nachlade-Versuch (z.B. unerreichbare/gelöschte
+ // Storage-URL) darf den Download nicht blockieren - zweiter Versuch ohne
+ // Logo, statt dem Manager/Kunden gar keine Rechnung auszuliefern.
+ let buffer: Buffer;
+ try {
+ buffer = await renderToBuffer();
+ } catch (renderError) {
+ console.error("Rechnungs-Rendering mit Logo fehlgeschlagen, Retry ohne Logo:", renderError);
+ buffer = await renderToBuffer();
+ }
+
+ return new NextResponse(new Uint8Array(buffer), {
+ headers: {
+ "Content-Type": "application/pdf",
+ "Cache-Control": "private, no-store",
+ "Content-Disposition": `attachment; filename="Rechnung-${invoiceData.invoiceNumber}.pdf"`,
+ },
+ });
+ } catch (error) {
+ console.error("Fehler beim Erstellen der Rechnung:", error);
+ return NextResponse.json({ error: "Rechnung konnte nicht erstellt werden." }, { status: 500 });
+ }
+}
diff --git a/src/app/business/[slug]/page.tsx b/src/app/business/[slug]/page.tsx
index fde882d..388205f 100644
--- a/src/app/business/[slug]/page.tsx
+++ b/src/app/business/[slug]/page.tsx
@@ -11,6 +11,7 @@ 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 { parseCardDesign } from "@/src/modules/products/lib/card-design";
import {
addDaysToKey,
berlinDayEnd,
@@ -70,11 +71,26 @@ export default async function BusinessPage({ params, searchParams }: BusinessPag
if (linked && linked.date > to) to = berlinDayEnd(dayKey(linked.date));
}
- const [coursesResult, policyRow] = await Promise.all([
+ const [coursesResult, policyRow, products] = await Promise.all([
getPublishedCoursesForBusiness(businessResult.business.id, { from, to }),
prisma.business.findUnique({
where: { id: businessResult.business.id },
- select: { cancellationPolicy: true },
+ select: { cancellationPolicy: true, stripeAccountStatus: true },
+ }),
+ // Karten zum Kaufen - die günstigste zuerst.
+ prisma.product.findMany({
+ where: { businessId: businessResult.business.id, isActive: true },
+ select: {
+ id: true,
+ name: true,
+ description: true,
+ credits: true,
+ priceCents: true,
+ validityMonths: true,
+ sports: true,
+ design: true,
+ },
+ orderBy: [{ priceCents: "asc" }, { createdAt: "asc" }],
}),
]);
// Kunden sollen vor dem Buchen wissen, ob und bis wann sie absagen können.
@@ -94,6 +110,12 @@ export default async function BusinessPage({ params, searchParams }: BusinessPag
// können so nicht auseinanderlaufen.
serviceFeeCents={getPlatformFeeCents()}
cancellationSummary={cancellationSummary}
+ // Ohne aktives Stripe-Konto ließe sich nichts kaufen - dann keine Karten zeigen.
+ products={
+ policyRow?.stripeAccountStatus === "active"
+ ? products.map((p) => ({ ...p, design: parseCardDesign(p.design) }))
+ : []
+ }
/>
);
}
diff --git a/src/lib/mail/templates/booking-confirmation.ts b/src/lib/mail/templates/booking-confirmation.ts
index 58180b6..9e6eed7 100644
--- a/src/lib/mail/templates/booking-confirmation.ts
+++ b/src/lib/mail/templates/booking-confirmation.ts
@@ -24,6 +24,8 @@ interface BookingConfirmationEmailParams {
spotLabels?: string[];
/** true = mindestens ein Wunschplatz war bei Zahlungseingang vergeben und wurde ersetzt. */
spotChanged?: boolean;
+ /** Mit einer Karte gebucht: statt eines Betrags stehen die abgebuchten Einheiten in der Mail. */
+ paidWithPass?: { passName: string; creditsUsed: number; creditsRemaining: number };
myCoursesUrl: string;
coverImageUrl?: string | null;
companyName?: string;
@@ -58,6 +60,7 @@ export function generateBookingConfirmationEmail({
seats = 1,
spotLabels = [],
spotChanged = false,
+ paidWithPass,
myCoursesUrl,
coverImageUrl,
companyName = "Ihr Unternehmen",
@@ -83,6 +86,19 @@ export function generateBookingConfirmationEmail({
const subject = singleLine(`Buchungsbestätigung – ${courseName}`);
const formattedDate = formatDate(courseDate);
const formattedPrice = formatPrice(amountPaidCents);
+ const units = (n: number) => `${n} ${n === 1 ? "Einheit" : "Einheiten"}`;
+ const paymentIntro = paidWithPass
+ ? `Wir haben ${units(paidWithPass.creditsUsed)} von Ihrer Karte „${paidWithPass.passName}“ abgebucht.`
+ : "Ihre Zahlung wurde erfolgreich verarbeitet.";
+ const paymentRowHtml = paidWithPass
+ ? renderPriceRow({
+ label: `Von Ihrer Karte (noch ${units(paidWithPass.creditsRemaining)} frei)`,
+ amountFormatted: units(paidWithPass.creditsUsed),
+ })
+ : renderPriceRow({ label: "Bezahlt", amountFormatted: formattedPrice });
+ const paymentRowText = paidWithPass
+ ? `Von Ihrer Karte: ${units(paidWithPass.creditsUsed)} (noch ${units(paidWithPass.creditsRemaining)} frei)`
+ : `Bezahlt: ${formattedPrice}`;
const bannerHtml = safeCoverImageUrl
? `
@@ -99,7 +115,7 @@ export function generateBookingConfirmationEmail({
- vielen Dank für Ihre Buchung. Ihre Zahlung wurde erfolgreich verarbeitet.
+ vielen Dank für Ihre Buchung. ${escapeHtml(paymentIntro)}
Hier sind die Details Ihres Kurses:
+ Bei Fragen zu Ihrer Karte wenden Sie sich bitte direkt an ${safeBusinessName}.
+
+ `)}
+ `;
+
+ const html = renderEmailLayout({
+ title: reason === "purchase" ? "Karte gekauft" : "Karte gutgeschrieben",
+ headerHeading: reason === "purchase" ? "Karte bereit ✓" : "Neue Karte ✓",
+ bodyHtml,
+ branding,
+ });
+
+ const text = `
+${reason === "purchase" ? "Karte bereit" : "Neue Karte"} – ${passName}
+
+Hallo,
+
+${introText}
+
+Karte: ${passName}
+Anbieter: ${businessName}
+Guthaben: ${creditsLabel}
+Gültig: ${validityLabel}
+${amountPaidCents !== undefined ? `Bezahlt: ${formatPrice(amountPaidCents)}\n` : ""}
+${howToText}
+
+${ctaLabel}: ${sanitizeUrl(ctaUrl)}
+
+Bei Fragen zu Ihrer Karte wenden Sie sich bitte direkt an ${businessName}.
+
+${companyName}
+Diese E-Mail wurde automatisch generiert.
+
+Powered by Kumasy
+ `;
+
+ return { subject, html, text };
+}
diff --git a/src/lib/stripe-checkout.ts b/src/lib/stripe-checkout.ts
new file mode 100644
index 0000000..c472797
--- /dev/null
+++ b/src/lib/stripe-checkout.ts
@@ -0,0 +1,53 @@
+import type Stripe from "stripe";
+import { stripe } from "@/src/lib/stripe";
+import { toEmailBranding } from "@/src/modules/settings/lib/to-email-branding";
+import { isAllowedLogoUrl } from "@/src/modules/settings/lib/logo-url";
+
+/**
+ * Der Kunde kauft beim Studio - der Checkout trägt deshalb dessen Farben und
+ * Logo, wie die öffentliche Seite davor. Ohne eigenes Branding greifen die
+ * Kumasy-Farben.
+ */
+export function studioCheckoutBranding(business: {
+ name: string;
+ branding: unknown;
+}): Stripe.Checkout.SessionCreateParams.BrandingSettings {
+ const studioBranding = toEmailBranding(business);
+ return {
+ display_name: business.name,
+ background_color: studioBranding.primaryColor,
+ button_color: studioBranding.accentColor,
+ border_style: "rounded",
+ ...(studioBranding.logoUrl && isAllowedLogoUrl(studioBranding.logoUrl)
+ ? { logo: { type: "url", url: studioBranding.logoUrl } }
+ : {}),
+ };
+}
+
+/**
+ * Erstellt die Checkout-Session im Branding des Studios. Lehnt Stripe das
+ * Branding ab (z.B. ein Logo, das Stripe nicht laden kann), wird ohne Branding
+ * erneut versucht - ein Logo darf nie eine Buchung verhindern.
+ */
+export async function createBrandedCheckoutSession(
+ params: Stripe.Checkout.SessionCreateParams,
+ branding: Stripe.Checkout.SessionCreateParams.BrandingSettings,
+ stripeAccountId: string
+) {
+ // Direct Charge: die Session gehört dem Studio-Account.
+ const options = { stripeAccount: stripeAccountId };
+ try {
+ return await stripe.checkout.sessions.create(
+ { ...params, branding_settings: branding },
+ options
+ );
+ } catch (error) {
+ const param = (error as { param?: string }).param;
+ if (!param?.startsWith("branding_settings")) throw error;
+ console.warn(
+ `Checkout-Branding von Stripe abgelehnt (${param}), Session ohne Branding:`,
+ error
+ );
+ return stripe.checkout.sessions.create(params, options);
+ }
+}
diff --git a/src/lib/stripe-fee.ts b/src/lib/stripe-fee.ts
index 5ae5d6f..3a8f51f 100644
--- a/src/lib/stripe-fee.ts
+++ b/src/lib/stripe-fee.ts
@@ -3,8 +3,8 @@ import { stripe } from "@/src/lib/stripe";
import { prisma } from "@/src/lib/prisma";
/**
- * Liest die Stripe-Gebühr einer Zahlung aus und speichert sie an der Buchung,
- * damit der Zahlungen-Tab zeigen kann, was beim Studio ankommt.
+ * Liest die Stripe-Gebühr einer Zahlung aus und speichert sie an der Buchung
+ * bzw. am Kartenkauf, damit der Zahlungen-Tab zeigen kann, was beim Studio ankommt.
*
* Bei Direct Charges zieht Stripe die Gebühr selbst beim Studio ab - hier wird
* kein Geld bewegt, nur protokolliert. Zwei Eigenheiten, die beide empirisch
@@ -26,11 +26,13 @@ export function stripeProcessingFeeCents(balanceTransaction: Stripe.BalanceTrans
.reduce((sum, detail) => sum + detail.amount, 0);
}
+// Eine Zahlung gehört entweder zu einer Kursbuchung oder zu einem Kartenkauf -
+// beide Tabellen tragen die Stripe-Gebühr gleich.
async function storeFee(paymentIntentId: string, balanceTransaction: Stripe.BalanceTransaction) {
- await prisma.courseBooking.updateMany({
- where: { stripePaymentIntentId: paymentIntentId, stripeFee: null },
- data: { stripeFee: stripeProcessingFeeCents(balanceTransaction) },
- });
+ const where = { stripePaymentIntentId: paymentIntentId, stripeFee: null };
+ const data = { stripeFee: stripeProcessingFeeCents(balanceTransaction) };
+ await prisma.courseBooking.updateMany({ where, data });
+ await prisma.customerPass.updateMany({ where, data });
}
/**
@@ -67,9 +69,9 @@ export async function recordStripeFeeForCharge(charge: Stripe.Charge, stripeAcco
: charge.balance_transaction?.id;
if (!paymentIntentId || !balanceTransactionId) return;
- const waiting = await prisma.courseBooking.count({
- where: { stripePaymentIntentId: paymentIntentId, stripeFee: null },
- });
+ const where = { stripePaymentIntentId: paymentIntentId, stripeFee: null };
+ const waiting =
+ (await prisma.courseBooking.count({ where })) + (await prisma.customerPass.count({ where }));
if (waiting === 0) return;
const balanceTransaction = await stripe.balanceTransactions.retrieve(
diff --git a/src/modules/auth/ui/views/login-view.tsx b/src/modules/auth/ui/views/login-view.tsx
index 534694a..3c1f135 100644
--- a/src/modules/auth/ui/views/login-view.tsx
+++ b/src/modules/auth/ui/views/login-view.tsx
@@ -7,7 +7,8 @@ import { Button } from "@/src/components/ui/button";
import { Label } from "@/src/components/ui/label";
import { Checkbox } from "@/src/components/ui/checkbox";
import { useState } from "react";
-import { useRouter } from "next/navigation";
+import { useRouter, useSearchParams } from "next/navigation";
+import { sanitizeReturnTo } from "@/src/modules/settings/lib/return-to";
import { useTranslations } from "next-intl";
import { Spinner } from "@/src/components/ui/spinner";
import { useAuth } from "@/src/hooks/useAuth";
@@ -22,6 +23,8 @@ const LoginView = () => {
// ------------- Hooks -------------
const { loginUser, loginWithGoogle, errors, loading } = useAuth();
const router = useRouter();
+ // Z.B. von der Studio-Seite, wenn jemand ohne Anmeldung eine Karte kaufen will.
+ const returnTo = sanitizeReturnTo(useSearchParams().get("returnTo"));
const t = useTranslations("LoginView");
/**
@@ -32,7 +35,7 @@ const LoginView = () => {
e.preventDefault();
const success = await loginUser(email, password, remember);
if (success) {
- router.push("/dashboard");
+ router.push(returnTo ?? "/dashboard");
}
};
diff --git a/src/modules/courses/actions/booking-actions.ts b/src/modules/courses/actions/booking-actions.ts
index b0dd2f1..30cdb4c 100644
--- a/src/modules/courses/actions/booking-actions.ts
+++ b/src/modules/courses/actions/booking-actions.ts
@@ -23,6 +23,10 @@ import {
import { bookableSpots, parseFloorPlan, type FloorPlan } from "@/src/modules/rooms/lib/floor-plan";
import { getBookedSpotIds, getHeldSpotIds, spotHolderKey } from "../lib/course-spots";
import { MAX_SEATS_PER_BOOKING, paidSeatCounts } from "../lib/seat-counts";
+import {
+ findUsablePassesForCourse,
+ type CustomerPassView,
+} from "@/src/modules/products/lib/pass-queries";
export type UserBooking = {
bookingId: string;
@@ -47,6 +51,8 @@ export type UserBooking = {
trainers: string[];
trainersMap: Record;
paymentStatus: string;
+ /** Mit einer Karte gebucht statt bezahlt. */
+ paidWithPass: boolean;
/** Plätze dieser Buchung - mehr als 1, wenn für Begleitpersonen mitgebucht wurde. */
seats: number;
/** Gebuchte Plätze bei Kursen mit Platzwahl, z.B. ["Reformer 3", "Reformer 4"]. */
@@ -76,6 +82,7 @@ export async function getUserBookings(): Promise<
select: {
id: true,
paymentStatus: true,
+ passId: true,
seats: true,
spotLabels: true,
course: {
@@ -172,6 +179,7 @@ export async function getUserBookings(): Promise<
trainers: b.course.trainers,
trainersMap,
paymentStatus: b.paymentStatus,
+ paidWithPass: b.passId !== null,
seats: b.seats,
spotLabels: b.spotLabels,
isPast: courseStart < now,
@@ -256,15 +264,21 @@ export async function checkUserBookingStatus(courseId: string) {
};
}
- // Beide Checks im selben Aufruf statt eines separaten Wartelisten-Calls,
+ // Alle Checks im selben Aufruf statt separater Wartelisten-/Karten-Calls,
// damit der Dialog weiterhin nur einen Roundtrip beim Öffnen braucht.
- const [booking, waitlistEntry] = await Promise.all([
+ const [booking, waitlistEntry, passes] = await Promise.all([
prisma.courseBooking.findUnique({
where: { courseId_userId: { courseId, userId: userData.id } },
}),
prisma.courseWaitlistEntry.findUnique({
where: { courseId_userId: { courseId, userId: userData.id } },
}),
+ // Karten, mit denen der Kunde diesen Kurs ohne Bezahlen buchen kann.
+ isUser(userData)
+ ? prisma.course
+ .findUnique({ where: { id: courseId }, select: { businessId: true, sport: true } })
+ .then((course) => (course ? findUsablePassesForCourse(userData, course) : []))
+ : Promise.resolve([] as CustomerPassView[]),
]);
return {
@@ -275,6 +289,7 @@ export async function checkUserBookingStatus(courseId: string) {
isBooked: booking?.paymentStatus === "paid",
bookedSeats: booking?.paymentStatus === "paid" ? booking.seats : 0,
isOnWaitlist: waitlistEntry !== null,
+ passes,
};
} catch (error) {
console.error("Error checking booking status:", error);
diff --git a/src/modules/courses/actions/cancel-booking-shared.ts b/src/modules/courses/actions/cancel-booking-shared.ts
index 31bc678..c469446 100644
--- a/src/modules/courses/actions/cancel-booking-shared.ts
+++ b/src/modules/courses/actions/cancel-booking-shared.ts
@@ -12,6 +12,10 @@ import {
import { toEmailBranding } from "@/src/modules/settings/lib/to-email-branding";
import { notifyCourseWaitlist } from "./notify-waitlist";
import { notifyBusinessManager } from "@/src/modules/notifications/lib/create-notification";
+import {
+ generatePassBookingCancellationEmail,
+ type PassCancellationReason,
+} from "@/src/lib/mail/templates/pass-booking-cancellation";
export type CancelBookingResult =
| {
@@ -20,6 +24,8 @@ export type CancelBookingResult =
refundPercent: number;
/** Plätze, die nach dem Storno noch gebucht sind (0 = Buchung ganz storniert). */
remainingSeats: number;
+ /** Nur bei Kartenbuchungen: Einheiten, die zurück auf die Karte gingen. */
+ creditsReturned?: number;
}
| { success: false; error: string };
@@ -143,6 +149,8 @@ export async function executeBookingCancellation(
refundedAmount: true,
refundedFeeAmount: true,
refundedAt: true,
+ passId: true,
+ pass: { select: { name: true } },
course: {
select: {
id: true,
@@ -160,6 +168,15 @@ export async function executeBookingCancellation(
if (booking.paymentStatus !== "paid") {
return { success: false, error: "Nur bezahlte Buchungen können storniert werden." };
}
+ // Mit Karte gebucht: kein Geld, das zurückgeht - stattdessen Einheiten.
+ if (booking.passId) {
+ return cancelPassBooking(
+ bookingId,
+ { ...booking, passId: booking.passId, passName: booking.pass?.name ?? "Karte" },
+ cancelledBy,
+ options
+ );
+ }
if (!booking.stripePaymentIntentId || !booking.amountPaid) {
return { success: false, error: "Zahlungsdaten fehlen — Stornierung nicht möglich." };
}
@@ -341,6 +358,47 @@ export async function executeBookingCancellation(
).catch((err) => console.error("Storno-Mail fehlgeschlagen:", err));
await withTimeout(emailPromise, 2000);
+ await notifyAfterCancellation({
+ booking,
+ cancelledBy,
+ forceFullRefund,
+ cancelsAll,
+ count,
+ seatsBefore,
+ });
+
+ return {
+ success: true,
+ refundedAmountCents: amountRefundedCents,
+ refundPercent,
+ remainingSeats,
+ };
+}
+
+type CancelledBookingInfo = {
+ userId: string | null;
+ course: { id: string; name: string; date: Date; businessId: string | null };
+};
+
+/**
+ * Was nach jedem Storno passiert, egal ob Geld oder Einheiten zurückgingen:
+ * Benachrichtigung ans Studio (nur bei Selbst-Stornierung) und Warteliste.
+ */
+async function notifyAfterCancellation({
+ booking,
+ cancelledBy,
+ forceFullRefund,
+ cancelsAll,
+ count,
+ seatsBefore,
+}: {
+ booking: CancelledBookingInfo;
+ cancelledBy: "user" | "manager";
+ forceFullRefund: boolean;
+ cancelsAll: boolean;
+ count: number;
+ seatsBefore: number;
+}): Promise {
// In-App-Benachrichtigung ans Studio nur bei einer echten Selbst-Stornierung
// durch den Kunden. Manager-initiierte Stornos (cancelledBy === "manager")
// und der forceFullRefund-Pfad der Kurs-weiten Absage lösen hier bewusst
@@ -391,11 +449,145 @@ export async function executeBookingCancellation(
console.error("Warteliste-Benachrichtigung fehlgeschlagen:", err)
);
await withTimeout(waitlistPromise, computeWaitlistTimeoutMs(waitlistEntryCount));
+}
+
+/**
+ * Storno einer Buchung, die mit einer Karte bezahlt wurde. Statt Geld geht es
+ * um Einheiten: Sie kommen zurück auf die Karte, wenn die Stornoregel des
+ * Studios in diesem Moment 100 % erstatten würde - sonst verfallen sie. Bei
+ * einer Kursabsage kommen sie immer zurück.
+ *
+ * Kein Stripe-Aufruf, deshalb kein "refunding"-Zwischenstatus: Statuswechsel
+ * und Gutschrift laufen in einer Transaktion, gebunden an den Platzstand, damit
+ * ein Doppelklick nicht zweimal gutschreibt.
+ */
+async function cancelPassBooking(
+ bookingId: string,
+ booking: CancelledBookingInfo & {
+ passId: string;
+ passName: string;
+ seats: number;
+ seatsPurchased: number;
+ spotIds: string[];
+ spotLabels: string[];
+ course: CancelledBookingInfo["course"] & {
+ timeFrom: string;
+ business: { name: string; branding: unknown } | null;
+ };
+ },
+ cancelledBy: "user" | "manager",
+ options?: {
+ forceFullRefund?: boolean;
+ cancellationReason?: string;
+ seatSelection?: SeatSelection;
+ }
+): Promise {
+ const forceFullRefund = options?.forceFullRefund ?? false;
+
+ const resolved = resolveSeatCancellation(booking, forceFullRefund ? {} : options?.seatSelection);
+ if (!resolved.ok) return { success: false, error: resolved.error };
+ const { count, cancelsAll } = resolved.value;
+ const seatsBefore = booking.seats;
+
+ const courseStart = combineDateAndTime(booking.course.date, booking.course.timeFrom);
+ if (!forceFullRefund && courseStart < new Date()) {
+ return {
+ success: false,
+ error: "Vergangene oder bereits begonnene Kurse können nicht storniert werden.",
+ };
+ }
+
+ const eligibility = forceFullRefund
+ ? { cancellationAllowed: true, refundPercent: 100 }
+ : await calculateRefundAmountCents({
+ businessId: booking.course.businessId,
+ amountPaidCents: 0,
+ courseStart,
+ now: new Date(),
+ });
+ if (!eligibility.cancellationAllowed) {
+ return {
+ success: false,
+ error: "Stornierungen sind für dieses Business derzeit nicht möglich.",
+ };
+ }
+ // Eine Einheit lässt sich nicht teilen: zurück gibt es sie nur, wenn die
+ // Regel den vollen Betrag erstatten würde.
+ const creditsReturned = eligibility.refundPercent >= 100;
+
+ const applied = await prisma.$transaction(async (tx) => {
+ const claimed = await tx.courseBooking.updateMany({
+ where: { id: bookingId, paymentStatus: "paid", seats: seatsBefore },
+ data: cancelsAll
+ ? {
+ paymentStatus: creditsReturned ? "refunded" : "cancelled",
+ cancelledBy,
+ ...(options?.cancellationReason
+ ? { cancellationReason: options.cancellationReason }
+ : {}),
+ }
+ : {
+ seats: seatsBefore - count,
+ spotIds: resolved.value.keptSpotIds,
+ spotLabels: resolved.value.keptSpotLabels,
+ },
+ });
+ if (claimed.count !== 1) return false;
+ if (creditsReturned) {
+ await tx.customerPass.update({
+ where: { id: booking.passId },
+ data: { creditsRemaining: { increment: count } },
+ });
+ }
+ return true;
+ });
+ if (!applied) {
+ return { success: false, error: "Stornierung läuft bereits oder wurde bereits verarbeitet." };
+ }
+
+ const remainingSeats = cancelsAll ? 0 : seatsBefore - count;
+
+ const reason: PassCancellationReason = forceFullRefund
+ ? "course_cancelled"
+ : cancelledBy === "manager"
+ ? "cancelled_by_manager"
+ : "cancelled_by_user";
+ const emailPromise = (async () => {
+ if (!booking.userId || !booking.course.business) return;
+ const user = await prisma.user.findUnique({
+ where: { id: booking.userId },
+ select: { email: true },
+ });
+ if (!user) return;
+ const { subject, html, text } = generatePassBookingCancellationEmail({
+ courseName: booking.course.name,
+ businessName: booking.course.business.name,
+ passName: booking.passName,
+ reason,
+ seatsCancelled: count,
+ seatsRemaining: remainingSeats,
+ creditsReturned,
+ companyName: process.env.COMPANY_NAME,
+ branding: toEmailBranding(booking.course.business),
+ });
+ await sendMail({ to: user.email, subject, html, text });
+ })().catch((err) => console.error("Storno-Mail fehlgeschlagen:", err));
+ await withTimeout(emailPromise, 2000);
+
+ await notifyAfterCancellation({
+ booking,
+ cancelledBy,
+ forceFullRefund,
+ cancelsAll,
+ count,
+ seatsBefore,
+ });
return {
success: true,
- refundedAmountCents: amountRefundedCents,
- refundPercent,
+ refundedAmountCents: 0,
+ refundPercent: eligibility.refundPercent,
remainingSeats,
+ creditsReturned: creditsReturned ? count : 0,
};
}
diff --git a/src/modules/courses/actions/cancellation-preview-actions.ts b/src/modules/courses/actions/cancellation-preview-actions.ts
index 0990d48..9a051d3 100644
--- a/src/modules/courses/actions/cancellation-preview-actions.ts
+++ b/src/modules/courses/actions/cancellation-preview-actions.ts
@@ -21,6 +21,11 @@ export type CancellationPreview = {
spots: { id: string; label: string }[];
/** Wie viele Plätze die Vorschau storniert. */
seatsToCancel: number;
+ /**
+ * Mit einer Karte gebucht: statt Geld geht es um Einheiten. creditsReturned =
+ * sie kommen zurück auf die Karte, sonst verfallen sie. null = bezahlte Buchung.
+ */
+ pass: { name: string; creditsReturned: boolean } | null;
};
/**
@@ -54,11 +59,13 @@ export async function previewBookingCancellation(
seatsPurchased: true,
spotIds: true,
spotLabels: true,
+ passId: true,
+ pass: { select: { name: true } },
course: { select: { date: true, timeFrom: true, businessId: true } },
},
});
- if (!booking || !booking.amountPaid) {
+ if (!booking || (!booking.amountPaid && !booking.passId)) {
return { success: false, error: "Buchung nicht gefunden." };
}
@@ -76,6 +83,31 @@ export async function previewBookingCancellation(
if (!resolved.ok) return { success: false, error: resolved.error };
const courseStart = combineDateAndTime(booking.course.date, booking.course.timeFrom);
+ const spots = booking.spotIds.map((id, i) => ({ id, label: booking.spotLabels[i] ?? "" }));
+
+ // Gleiche Regel wie in cancelPassBooking: zurück nur, wenn die Stornoregel
+ // jetzt 100 % erstatten würde.
+ if (booking.passId) {
+ const { cancellationAllowed, refundPercent } = await calculateRefundAmountCents({
+ businessId: booking.course.businessId,
+ amountPaidCents: 0,
+ courseStart,
+ now: new Date(),
+ });
+ return {
+ success: true,
+ preview: {
+ cancellationAllowed,
+ refundPercent,
+ refundAmountCents: 0,
+ amountPaidCents: 0,
+ seats: booking.seats,
+ spots,
+ seatsToCancel: resolved.value.count,
+ pass: { name: booking.pass?.name ?? "Karte", creditsReturned: refundPercent >= 100 },
+ },
+ };
+ }
// Identische Bezugsgröße wie in executeBookingCancellation: der Kursanteil
// der stornierten Plätze ohne Servicegebühr. Sonst würde der Dialog eine
@@ -98,8 +130,9 @@ export async function previewBookingCancellation(
refundAmountCents,
amountPaidCents: courseCents,
seats: booking.seats,
- spots: booking.spotIds.map((id, i) => ({ id, label: booking.spotLabels[i] ?? "" })),
+ spots,
seatsToCancel: resolved.value.count,
+ pass: null,
},
};
}
diff --git a/src/modules/courses/actions/course-actions.ts b/src/modules/courses/actions/course-actions.ts
index dec3d5c..83af89d 100644
--- a/src/modules/courses/actions/course-actions.ts
+++ b/src/modules/courses/actions/course-actions.ts
@@ -777,10 +777,38 @@ export async function deleteCourse(courseId: string, scope: StandingOrderScope =
};
}
- // Schritt 7: Kurs löschen je nach Scope
const isTemplate = !course.parentCourseId;
const templateId = isTemplate ? course.id : course.parentCourseId!;
+ // Schritt 7: Löschen löscht per Kaskade auch alle Buchungen der Kurse - mit
+ // Zahlungen, Rechnungen und mit Karte abgebuchten Einheiten. Kurse, an denen
+ // so etwas hängt, bleiben deshalb stehen. Eine Vorlage nimmt beim Löschen
+ // alle Termine mit, deshalb zählen dann alle.
+ const affectedCourses =
+ scope === "this_and_following"
+ ? { parentCourseId: templateId, date: { gte: course.date } }
+ : scope === "all" || isTemplate
+ ? { OR: [{ id: templateId }, { parentCourseId: templateId }] }
+ : { id: courseId };
+ const bookingsWithHistory = await prisma.courseBooking.count({
+ where: {
+ course: affectedCourses,
+ OR: [
+ { paymentStatus: { in: ["paid", "refunding", "partially_refunded"] } },
+ { invoiceNumber: { not: null } },
+ ],
+ },
+ });
+ if (bookingsWithHistory > 0) {
+ return {
+ success: false,
+ error:
+ "Kurse mit bezahlten Buchungen können nicht gelöscht werden, sonst gingen Zahlungen und Rechnungen verloren. Kommende Kurse kannst du absagen, dann werden die Kunden erstattet.",
+ };
+ }
+
+ // Schritt 8: Kurs löschen je nach Scope
+
if (scope === "this") {
await prisma.course.delete({ where: { id: courseId } });
} else if (scope === "this_and_following") {
@@ -792,7 +820,7 @@ export async function deleteCourse(courseId: string, scope: StandingOrderScope =
await prisma.course.delete({ where: { id: templateId } });
}
- // Schritt 8: Next.js Cache invalidieren
+ // Schritt 9: Next.js Cache invalidieren
revalidatePath("/courses");
refreshPublicCourses(course.businessId);
diff --git a/src/modules/courses/lib/notify-booking.ts b/src/modules/courses/lib/notify-booking.ts
new file mode 100644
index 0000000..bc8c334
--- /dev/null
+++ b/src/modules/courses/lib/notify-booking.ts
@@ -0,0 +1,51 @@
+import { prisma } from "@/src/lib/prisma";
+import { notifyBusinessManager } from "@/src/modules/notifications/lib/create-notification";
+import { paidSeatCount } from "./seat-counts";
+
+/**
+ * Legt für den Studio-Manager eine In-App-Benachrichtigung über eine neue,
+ * bezahlte Buchung an - und zusätzlich eine "Kurs ausgebucht"-Benachrichtigung,
+ * falls diese Buchung den Kurs gefüllt hat. Wirft nie (notifyBusinessManager
+ * kapselt alle Fehler), der Aufrufer hängt zusätzlich ein .catch daran.
+ */
+export async function notifyManagerOfBooking(
+ courseId: string,
+ customerLabel: string,
+ seats: number,
+ /** Mit einer Karte gebucht - steht dann in der Benachrichtigung. */
+ passName?: string
+): Promise {
+ const course = await prisma.course.findUnique({
+ where: { id: courseId },
+ select: { name: true, date: true, businessId: true, maxParticipants: true },
+ });
+ if (!course?.businessId) return;
+
+ const courseDate = course.date.toLocaleDateString("de-DE", {
+ day: "numeric",
+ month: "long",
+ timeZone: "Europe/Berlin",
+ });
+ const linkUrl = `/courses/edit/${courseId}`;
+
+ await notifyBusinessManager(course.businessId, {
+ type: "booking.created",
+ title: `Neue Buchung: ${course.name}`,
+ body:
+ (seats > 1
+ ? `${customerLabel} hat ${seats} Plätze für den ${courseDate} gebucht`
+ : `${customerLabel} hat sich für den ${courseDate} angemeldet`) +
+ (passName ? ` (mit ${passName}).` : "."),
+ linkUrl,
+ });
+
+ const paidCount = await paidSeatCount(prisma, courseId);
+ if (paidCount >= course.maxParticipants) {
+ await notifyBusinessManager(course.businessId, {
+ type: "course.full",
+ title: `Kurs ausgebucht: ${course.name}`,
+ body: `Der Kurs am ${courseDate} hat mit ${paidCount} Teilnehmern die maximale Kapazität erreicht.`,
+ linkUrl,
+ });
+ }
+}
diff --git a/src/modules/courses/ui/components/BookingDialog.tsx b/src/modules/courses/ui/components/BookingDialog.tsx
index 6c8684c..3398884 100644
--- a/src/modules/courses/ui/components/BookingDialog.tsx
+++ b/src/modules/courses/ui/components/BookingDialog.tsx
@@ -3,6 +3,7 @@
import { useState, useEffect, useRef, useSyncExternalStore } from "react";
import Image from "next/image";
import Link from "next/link";
+import { useRouter } from "next/navigation";
import {
Clock,
MapPin,
@@ -15,6 +16,7 @@ import {
LayoutGrid,
Minus,
Plus,
+ WalletCards,
} from "lucide-react";
import { Button } from "@/src/components/ui/button";
import { Dialog, DialogClose, DialogContent, DialogTitle } from "@/src/components/ui/dialog";
@@ -34,6 +36,8 @@ import { formatEuro } from "./course-schedule-shared";
import { SpotPicker } from "@/src/modules/rooms/ui/components/floor-plan/SpotPicker";
import { spotDisplayName } from "@/src/modules/rooms/lib/floor-plan";
import { MAX_SEATS_PER_BOOKING } from "../../lib/seat-counts";
+import { bookCourseWithPass } from "@/src/modules/products/actions/pass-booking-actions";
+import type { CustomerPassView } from "@/src/modules/products/lib/pass-queries";
// useSyncExternalStore: liefert `false` auf dem Server (server snapshot) und
// `true` im Browser. Damit haben Server- und initialer Client-Render denselben
@@ -134,6 +138,8 @@ const TrainerAvatar = ({ trainer, size }: { trainer: TrainerProfile; size: "sm"
const ADMIN_PAYMENT_LABELS: Record = {
paid: { label: "Bezahlt", bg: "bg-green-100", text: "text-green-700" },
+ // Gebucht mit einer Karte - kein Geld geflossen.
+ paid_with_pass: { label: "Mit Karte", bg: "bg-green-100", text: "text-green-700" },
pending: { label: "Ausstehend", bg: "bg-yellow-100", text: "text-yellow-700" },
refunded: { label: "Storniert", bg: "bg-red-100", text: "text-red-600" },
partially_refunded: {
@@ -177,6 +183,7 @@ export const BookingDialog = ({
? { backgroundColor: accentColor, color: getContrastTextColor(accentColor) }
: undefined;
const sanitizedDescription = useSanitizedHtml(course.description);
+ const router = useRouter();
const [isSubmitting, setIsSubmitting] = useState(false);
const [isBooked, setIsBooked] = useState(false);
const [isOnWaitlist, setIsOnWaitlist] = useState(false);
@@ -203,6 +210,10 @@ export const BookingDialog = ({
const [spotMap, setSpotMap] = useState(null);
const [isLoadingSpots, setIsLoadingSpots] = useState(false);
const [selectedSpotIds, setSelectedSpotIds] = useState([]);
+ // Karten, mit denen der Kunde diesen Kurs ohne Bezahlen buchen kann. null =
+ // einzeln bezahlen. Vorausgewählt ist die zuerst ablaufende Karte.
+ const [passes, setPasses] = useState([]);
+ const [selectedPassId, setSelectedPassId] = useState(null);
const [highlightPicker, setHighlightPicker] = useState(false);
const spotPickerRef = useRef(null);
@@ -235,6 +246,9 @@ export const BookingDialog = ({
setIsOnWaitlist(result.isOnWaitlist);
setCanBookMultiple("canBookMultiple" in result && result.canBookMultiple === true);
setBookedSeats("bookedSeats" in result ? (result.bookedSeats ?? 0) : 0);
+ const usablePasses = "passes" in result ? (result.passes ?? []) : [];
+ setPasses(usablePasses);
+ setSelectedPassId(usablePasses[0]?.id ?? null);
// Gäste sehen das Gast-Formular sofort, statt erst nach einem
// gescheiterten Buchungsversuch.
if (result.isLoggedIn === false) setShowGuestForm(true);
@@ -291,10 +305,19 @@ export const BookingDialog = ({
: [];
const needsSpot = spotMode && selectedSpots.length === 0;
+ const selectedPass = passes.find((pass) => pass.id === selectedPassId);
+ const payWithPass = selectedPass !== undefined;
+
// Wie viele Plätze sich höchstens auf einmal buchen lassen: so viele wie frei
- // sind, gedeckelt - und für Gäste genau einer.
+ // sind, gedeckelt - für Gäste genau einer, mit Karte höchstens so viele, wie
+ // Einheiten übrig sind.
const freeSeats = Math.max(0, safeMax - safeCurrent);
- const maxSeats = canBookMultiple ? Math.max(1, Math.min(MAX_SEATS_PER_BOOKING, freeSeats)) : 1;
+ const maxSeats = canBookMultiple
+ ? Math.max(
+ 1,
+ Math.min(MAX_SEATS_PER_BOOKING, freeSeats, selectedPass?.creditsRemaining ?? Infinity)
+ )
+ : 1;
// Bei Platzwahl ergibt sich die Anzahl aus den gewählten Geräten.
const seats = spotMode ? Math.max(1, selectedSpots.length) : Math.min(seatCount, maxSeats);
@@ -383,7 +406,54 @@ export const BookingDialog = ({
}
};
+ // Mit Karte gilt kein Rabattcode, und es lassen sich höchstens so viele
+ // Plätze buchen, wie Einheiten übrig sind.
+ const choosePayment = (pass: CustomerPassView | null) => {
+ setSelectedPassId(pass?.id ?? null);
+ if (!pass) return;
+ setAppliedDiscount(null);
+ setDiscountInput("");
+ setSelectedSpotIds((ids) => ids.slice(0, pass.creditsRemaining));
+ };
+
+ const handleBookingWithPass = async (passId: string) => {
+ setIsSubmitting(true);
+ try {
+ const result = await bookCourseWithPass({
+ courseId: course.id,
+ passId,
+ seats,
+ spotIds: spotMode ? selectedSpots.map((s) => s.id) : undefined,
+ });
+ if (!result.success) {
+ toast.error(result.error);
+ if (result.spotUnavailable) {
+ setSelectedSpotIds([]);
+ setSpotMap(await getCourseSpotMap(course.id));
+ showSpotPicker();
+ }
+ return;
+ }
+ setIsBooked(true);
+ setBookedSeats(result.seats);
+ toast.success(
+ `Gebucht! Auf Ihrer Karte ${result.creditsRemaining === 1 ? "ist" : "sind"} noch ${result.creditsRemaining} ${
+ result.creditsRemaining === 1 ? "Einheit" : "Einheiten"
+ } frei.`
+ );
+ router.refresh();
+ } catch {
+ toast.error("Ein unerwarteter Fehler ist aufgetreten");
+ } finally {
+ setIsSubmitting(false);
+ }
+ };
+
const handleBooking = async (opts?: { asGuest: boolean }) => {
+ if (selectedPass && !opts?.asGuest) {
+ await handleBookingWithPass(selectedPass.id);
+ return;
+ }
setIsSubmitting(true);
try {
const body: Record = { courseId: course.id, seats };
@@ -677,6 +747,53 @@ export const BookingDialog = ({
{/* Sticky Footer */}
+ {/* Bezahlen mit Karte oder einzeln - nur wenn eine passende Karte da ist */}
+ {!isAdminView && !isCheckingStatus && !isFull && !isBooked && passes.length > 0 && (
+
+ )}
{/* Mehrfachbuchung ohne Platzwahl: Anzahl per Zähler. Mit Platzwahl
ergibt sie sich aus den gewählten Geräten. */}
{!isAdminView &&
@@ -720,43 +837,57 @@ export const BookingDialog = ({
{/* Rabattcodes sind nur für eingeloggte User verfügbar (siehe
validateDiscountCode) - im Gast-Formular macht das Feld daher
keinen Sinn und würde nur einen ungültigen Code suggerieren. */}
- {!isAdminView && !isCheckingStatus && !isFull && !isBooked && !showGuestForm && (
-