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:

@@ -128,7 +144,7 @@ export function generateBookingConfirmationEmail({ ], })} - ${renderDivider(renderPriceRow({ label: "Bezahlt", amountFormatted: formattedPrice }))} + ${renderDivider(paymentRowHtml)}

Wir wünschen Ihnen viel Spaß im Kurs! @@ -163,12 +179,12 @@ Buchung bestätigt – ${courseName} Hallo, -vielen Dank für Ihre Buchung. Ihre Zahlung wurde erfolgreich verarbeitet. +vielen Dank für Ihre Buchung. ${paymentIntro} Kurs: ${courseName} Anbieter: ${businessName} Termin: ${formattedDate}, ${timeFrom} – ${timeTo} Uhr -${businessAddress ? `Ort: ${businessAddress}\n` : ""}${seats > 1 ? `Plätze: ${seats}\n` : ""}${spotList ? `${spotTitle}: ${spotList}\n` : ""}${spotChangedNote ? `${spotChangedNote}\n` : ""}Bezahlt: ${formattedPrice} +${businessAddress ? `Ort: ${businessAddress}\n` : ""}${seats > 1 ? `Plätze: ${seats}\n` : ""}${spotList ? `${spotTitle}: ${spotList}\n` : ""}${spotChangedNote ? `${spotChangedNote}\n` : ""}${paymentRowText} Wir wünschen Ihnen viel Spaß im Kurs! diff --git a/src/lib/mail/templates/pass-booking-cancellation.ts b/src/lib/mail/templates/pass-booking-cancellation.ts new file mode 100644 index 0000000..3946f76 --- /dev/null +++ b/src/lib/mail/templates/pass-booking-cancellation.ts @@ -0,0 +1,113 @@ +/** + * E-Mail Template für die Stornierung einer Buchung, die mit einer Karte + * bezahlt wurde. Statt Geld geht eine Einheit zurück auf die Karte - oder sie + * verfällt, wenn die Stornierungsfrist des Studios verstrichen ist. + */ + +import { escapeHtml, singleLine } from "./utils"; +import { renderInfoBox } from "./components"; +import { renderEmailLayout } from "./layout"; +import { KUMASY_BRANDING, type EmailBranding } from "./tokens"; + +export type PassCancellationReason = + | "cancelled_by_user" + | "cancelled_by_manager" + | "course_cancelled"; + +interface PassBookingCancellationEmailParams { + courseName: string; + businessName: string; + passName: string; + reason: PassCancellationReason; + /** Stornierte Plätze = Einheiten, um die es geht. */ + seatsCancelled: number; + /** Plätze, die gebucht bleiben (Teilstorno einer Mehrfachbuchung). */ + seatsRemaining: number; + creditsReturned: boolean; + companyName?: string; + branding?: EmailBranding; +} + +export function generatePassBookingCancellationEmail({ + courseName, + businessName, + passName, + reason, + seatsCancelled, + seatsRemaining, + creditsReturned, + companyName = "Ihr Unternehmen", + branding = KUMASY_BRANDING, +}: PassBookingCancellationEmailParams): { subject: string; html: string; text: string } { + const safeCourseName = escapeHtml(courseName); + const safeBusinessName = escapeHtml(businessName); + const units = seatsCancelled === 1 ? "1 Einheit" : `${seatsCancelled} Einheiten`; + + const intro = { + cancelled_by_user: { + html: `Ihre Stornierung für den Kurs ${safeCourseName} wurde bestätigt.`, + text: `Ihre Stornierung für den Kurs "${courseName}" wurde bestätigt.`, + }, + cancelled_by_manager: { + html: `Ihre Buchung für den Kurs ${safeCourseName} wurde durch ${safeBusinessName} storniert.`, + text: `Ihre Buchung für den Kurs "${courseName}" wurde durch ${businessName} storniert.`, + }, + course_cancelled: { + html: `leider muss der Kurs ${safeCourseName} von ${safeBusinessName} ausfallen.`, + text: `leider muss der Kurs "${courseName}" von ${businessName} ausfallen.`, + }, + }[reason]; + + const seatsLine = + seatsRemaining > 0 + ? ` Storniert ${seatsCancelled === 1 ? "wurde 1 Platz" : `wurden ${seatsCancelled} Plätze`}, ${ + seatsRemaining === 1 ? "1 Platz bleibt" : `${seatsRemaining} Plätze bleiben` + } für Sie gebucht.` + : ""; + + const creditsText = creditsReturned + ? `${units} ${seatsCancelled === 1 ? "wurde" : "wurden"} Ihrer Karte „${passName}“ wieder gutgeschrieben.` + : `Gemäß den Stornierungsbedingungen von ${businessName} ${seatsCancelled === 1 ? "verfällt" : "verfallen"} ${units} Ihrer Karte „${passName}“.`; + + const subject = singleLine(`Stornierung bestätigt – ${courseName}`); + + const bodyHtml = ` +

Hallo,

+

${intro.html}${escapeHtml(seatsLine)}

+ ${renderInfoBox({ + rows: [ + { label: "Kurs", valueHtml: safeCourseName }, + { label: "Karte", valueHtml: escapeHtml(passName) }, + { + label: creditsReturned ? "Gutgeschrieben" : "Verfallen", + valueHtml: `${escapeHtml(units)}`, + }, + ], + })} +

${escapeHtml(creditsText)}

+ `; + + const html = renderEmailLayout({ + title: "Stornierung bestätigt", + headerHeading: "Stornierung bestätigt", + bodyHtml, + branding, + }); + + const text = ` +Stornierung bestätigt – ${courseName} + +Hallo, + +${intro.text}${seatsLine} + +${creditsText} + +${companyName} +Diese E-Mail wurde automatisch generiert. + +Powered by Kumasy + `; + + return { subject, html, text }; +} diff --git a/src/lib/mail/templates/pass-confirmation.ts b/src/lib/mail/templates/pass-confirmation.ts new file mode 100644 index 0000000..78d2884 --- /dev/null +++ b/src/lib/mail/templates/pass-confirmation.ts @@ -0,0 +1,150 @@ +/** + * E-Mail Template für eine neue Karte (z.B. 10er-Karte) + * + * Geht an den Kunden, wenn er eine Karte gekauft hat (Stripe Webhook) oder das + * Studio ihm eine Karte vergeben hat (Kunden-Detail, z.B. Barzahlung oder + * Restguthaben vom alten Anbieter). + */ + +import { formatPrice } from "@/src/lib/currency"; +import { escapeHtml, sanitizeUrl, singleLine } from "./utils"; +import { renderButton, renderDivider, renderInfoBox, renderPriceRow } from "./components"; +import { renderEmailLayout } from "./layout"; +import { KUMASY_BRANDING, type EmailBranding } from "./tokens"; + +interface PassConfirmationEmailParams { + reason: "purchase" | "issued"; + passName: string; + businessName: string; + credits: number; + validUntil: Date | null; + /** Nur beim Kauf: gezahlter Betrag inklusive Servicegebühr. */ + amountPaidCents?: number; + /** Bei "issued": Link zur Registrierung, falls die Person noch kein Konto hat. */ + hasAccount: boolean; + myCardsUrl: string; + registerUrl: string; + companyName?: string; + branding?: EmailBranding; +} + +function formatDate(date: Date): string { + return date.toLocaleDateString("de-DE", { + day: "numeric", + month: "long", + year: "numeric", + timeZone: "Europe/Berlin", + }); +} + +export function generatePassConfirmationEmail({ + reason, + passName, + businessName, + credits, + validUntil, + amountPaidCents, + hasAccount, + myCardsUrl, + registerUrl, + companyName = "Ihr Unternehmen", + branding = KUMASY_BRANDING, +}: PassConfirmationEmailParams): { subject: string; html: string; text: string } { + const safePassName = escapeHtml(passName); + const safeBusinessName = escapeHtml(businessName); + const creditsLabel = `${credits} ${credits === 1 ? "Einheit" : "Einheiten"}`; + const validityLabel = validUntil ? `bis ${formatDate(validUntil)}` : "unbegrenzt"; + const ctaUrl = hasAccount ? myCardsUrl : registerUrl; + const ctaLabel = hasAccount ? "Zu meinen Karten" : "Konto anlegen"; + + const subject = singleLine( + reason === "purchase" + ? `Ihre ${passName} ist bereit` + : `${businessName} hat Ihnen eine Karte gutgeschrieben` + ); + + const introText = + reason === "purchase" + ? "vielen Dank für Ihren Kauf. Ihre Karte ist ab sofort einsatzbereit." + : `${businessName} hat Ihnen eine Karte gutgeschrieben.`; + const introHtml = + reason === "purchase" + ? "vielen Dank für Ihren Kauf. Ihre Karte ist ab sofort einsatzbereit." + : `${safeBusinessName} hat Ihnen eine Karte gutgeschrieben.`; + + const howToText = hasAccount + ? "Beim Buchen eines Kurses wählen Sie einfach Ihre Karte aus - pro Platz wird eine Einheit abgebucht." + : "Legen Sie mit dieser E-Mail-Adresse ein Konto an, dann finden Sie die Karte unter „Meine Karten“ und können damit Kurse buchen."; + + const bodyHtml = ` +

Hallo,

+

${introHtml}

+ + ${renderInfoBox({ + rows: [ + { + label: "Karte", + valueHtml: `${safePassName}`, + }, + { label: "Anbieter", valueHtml: safeBusinessName }, + { label: "Guthaben", valueHtml: creditsLabel }, + { label: "Gültig", valueHtml: escapeHtml(validityLabel) }, + ], + })} + + ${ + amountPaidCents !== undefined + ? renderDivider( + renderPriceRow({ label: "Bezahlt", amountFormatted: formatPrice(amountPaidCents) }) + ) + : "" + } + +

${escapeHtml(howToText)}

+ + ${renderButton({ label: ctaLabel, href: ctaUrl, branding })} + +

+ ${sanitizeUrl(ctaUrl)} +

+ + ${renderDivider(` +

+ 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 && ( +
+ Bezahlen mit + {[...passes.map((pass) => ({ id: pass.id, pass })), { id: null, pass: null }].map( + ({ id, pass }) => { + const checked = selectedPassId === id; + return ( + + ); + } + )} +
+ )} {/* 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 && ( -
- { - setDiscountInput(e.target.value.toUpperCase()); - if (appliedDiscount) setAppliedDiscount(null); - }} - className="flex-1 rounded-md border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-gray-400" - /> - -
- )} + {!isAdminView && + !isCheckingStatus && + !isFull && + !isBooked && + !showGuestForm && + !payWithPass && ( +
+ { + setDiscountInput(e.target.value.toUpperCase()); + if (appliedDiscount) setAppliedDiscount(null); + }} + className="flex-1 rounded-md border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-gray-400" + /> + +
+ )}
- - {appliedDiscount ? ( - <> - - {formattedPrice} - - Preis: {formatEuro(coursePrice + serviceFee * seats)} - - ) : ( - `Preis: ${formattedPrice}` - )} - - {(serviceFee > 0 || seats > 1) && ( + {payWithPass && !isAdminView && !isBooked ? ( + <> + + {seats} {seats === 1 ? "Einheit" : "Einheiten"} von Ihrer Karte + + Kein Bezahlen nötig + + ) : ( + + {appliedDiscount ? ( + <> + + {formattedPrice} + + Preis: {formatEuro(coursePrice + serviceFee * seats)} + + ) : ( + `Preis: ${formattedPrice}` + )} + + )} + {!(payWithPass && !isAdminView && !isBooked) && (serviceFee > 0 || seats > 1) && ( {seats > 1 && !appliedDiscount ? `${seats} × ${formatEuro(course.price)}` @@ -854,9 +985,13 @@ export const BookingDialog = ({ ? seats > 1 || maxSeats > 1 ? "Plätze wählen" : "Platz wählen" - : seats > 1 - ? `${seats} Plätze buchen` - : "Buchen"} + : payWithPass + ? seats > 1 + ? `${seats} Plätze mit Karte buchen` + : "Mit Karte buchen" + : seats > 1 + ? `${seats} Plätze buchen` + : "Buchen"} )} diff --git a/src/modules/courses/ui/components/CancelBookingConfirmDialog.tsx b/src/modules/courses/ui/components/CancelBookingConfirmDialog.tsx index 0874160..4c1edaf 100644 --- a/src/modules/courses/ui/components/CancelBookingConfirmDialog.tsx +++ b/src/modules/courses/ui/components/CancelBookingConfirmDialog.tsx @@ -23,6 +23,8 @@ type CancelResult = { success: true } | { success: false; error: string }; export type CancelBookingCopyVariant = "user" | "manager"; +const unitsLabel = (n: number) => `${n} ${n === 1 ? "Einheit" : "Einheiten"}`; + const VARIANT_COPY: Record< CancelBookingCopyVariant, { @@ -31,6 +33,11 @@ const VARIANT_COPY: Record< descriptionPlural: string; fullRefund: string; partialRefund: (percent: string) => string; + /** Mit Karte gebucht: kein Geld, nur Einheiten. */ + passDescription: string; + passDescriptionPlural: string; + passCreditsBack: (count: number, passName: string) => string; + passCreditsForfeit: (count: number) => string; } > = { user: { @@ -39,6 +46,12 @@ const VARIANT_COPY: Record< fullRefund: "Du erhältst den vollen Betrag zurück.", partialRefund: (percent) => `Da du kurzfristig stornierst, erhältst du gemäß den Stornierungsbedingungen nur ${percent} % zurück:`, + passDescription: "wird storniert.", + passDescriptionPlural: "werden storniert.", + passCreditsBack: (count, passName) => + `${unitsLabel(count)} ${count === 1 ? "geht" : "gehen"} zurück auf deine Karte „${passName}“.`, + passCreditsForfeit: (count) => + `Da du kurzfristig stornierst, ${count === 1 ? "verfällt" : "verfallen"} ${unitsLabel(count)} gemäß den Stornierungsbedingungen.`, }, manager: { description: @@ -48,6 +61,12 @@ const VARIANT_COPY: Record< fullRefund: "Der Kunde erhält den vollen Betrag zurück.", partialRefund: (percent) => `Gemäß den Stornierungsbedingungen erhält der Kunde nur ${percent} % zurück:`, + passDescription: "wird storniert. Dieser Vorgang kann nicht rückgängig gemacht werden.", + passDescriptionPlural: "werden storniert. Dieser Vorgang kann nicht rückgängig gemacht werden.", + passCreditsBack: (count, passName) => + `${unitsLabel(count)} ${count === 1 ? "geht" : "gehen"} zurück auf die Karte „${passName}“ des Kunden.`, + passCreditsForfeit: (count) => + `Gemäß den Stornierungsbedingungen ${count === 1 ? "verfällt" : "verfallen"} ${unitsLabel(count)}.`, }, }; @@ -144,7 +163,9 @@ function CancelBookingDialogBody({ } }; - const isPartialRefund = preview !== null && preview.refundPercent < 100; + const isPartialRefund = preview !== null && preview.pass === null && preview.refundPercent < 100; + const description = preview?.pass ? copy.passDescription : copy.description; + const descriptionPlural = preview?.pass ? copy.passDescriptionPlural : copy.descriptionPlural; const cancellationBlocked = preview !== null && !preview.cancellationAllowed; const multiSeat = preview !== null && preview.seats > 1; const toCancel = preview?.seatsToCancel ?? 0; @@ -168,11 +189,11 @@ function CancelBookingDialogBody({ {multiSeat && !cancelsAll ? ( <> {toCancel} von {preview!.seats} Plätzen für {courseName}{" "} - {toCancel === 1 ? copy.description : copy.descriptionPlural} + {toCancel === 1 ? description : descriptionPlural} ) : ( <> - Die Buchung für {courseName} {copy.description} + Die Buchung für {courseName} {description} )} @@ -241,12 +262,23 @@ function CancelBookingDialogBody({ )} {loadingPreview && !preview ? ( -

Berechne Rückerstattung…

+

Wird berechnet…

) : cancellationBlocked ? (

Stornierungen sind für diese Buchung derzeit nicht möglich.

+ ) : preview?.pass ? ( + preview.pass.creditsReturned ? ( +

+ {copy.passCreditsBack(toCancel, preview.pass.name)} +

+ ) : ( +
+ +

{copy.passCreditsForfeit(toCancel)}

+
+ ) ) : preview ? ( isPartialRefund ? (
diff --git a/src/modules/courses/ui/components/UserBookingCard.tsx b/src/modules/courses/ui/components/UserBookingCard.tsx index 9209762..75a148a 100644 --- a/src/modules/courses/ui/components/UserBookingCard.tsx +++ b/src/modules/courses/ui/components/UserBookingCard.tsx @@ -25,6 +25,7 @@ const getDiceBearUrl = (seed: string) => const PAYMENT_STATUS: Record = { paid: { label: "Bezahlt", bg: "bg-green-100", text: "text-green-700" }, + 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: { @@ -78,7 +79,11 @@ const UserBookingCard = ({ booking }: UserBookingCardProps) => { const [, startTransition] = useTransition(); const [cancelled, setCancelled] = useState(booking.paymentStatus === "refunded"); - const effectiveStatus = cancelled ? "refunded" : booking.paymentStatus; + const effectiveStatus = cancelled + ? "refunded" + : booking.paidWithPass && booking.paymentStatus === "paid" + ? "paid_with_pass" + : booking.paymentStatus; const status = PAYMENT_STATUS[effectiveStatus] ?? PAYMENT_STATUS.pending; const dateFormatted = new Date(booking.date).toLocaleDateString("de-DE", { diff --git a/src/modules/courses/ui/views/business-public-view.tsx b/src/modules/courses/ui/views/business-public-view.tsx index b033643..cece062 100644 --- a/src/modules/courses/ui/views/business-public-view.tsx +++ b/src/modules/courses/ui/views/business-public-view.tsx @@ -5,6 +5,9 @@ import { useSearchParams } from "next/navigation"; import Image from "next/image"; import { CalendarDays, ChevronLeft, ChevronRight, Info, LayoutGrid, MapPin, X } from "lucide-react"; import { getContrastTextColor } from "@/src/modules/settings/lib/contrast"; +import PublicPassShop, { + type PublicProduct, +} from "@/src/modules/products/ui/components/PublicPassShop"; import { BookingDialog, type PublicCourse } from "../components/BookingDialog"; import WeekTimeGrid from "../components/WeekTimeGrid"; import DayTimeline from "../components/DayTimeline"; @@ -45,6 +48,8 @@ interface BusinessPublicViewProps { serviceFeeCents: number; /** Stornoregel des Studios in einem Satz, siehe describeCancellationPolicy. */ cancellationSummary: string; + /** Karten zum Kaufen, z.B. eine 10er-Karte. Leer = Bereich ausgeblendet. */ + products: PublicProduct[]; } const DAY_MS = 24 * 60 * 60 * 1000; @@ -110,6 +115,7 @@ const BusinessPublicView = ({ initialWindow, serviceFeeCents, cancellationSummary, + products, }: BusinessPublicViewProps) => { const searchParams = useSearchParams(); const deepLinkCourseId = searchParams.get("course"); @@ -561,6 +567,18 @@ const BusinessPublicView = ({ {serviceFeeCents > 0 && ` Alle Preise inkl. ${feeLabel} Servicegebühr.`}

+ + {products.length > 0 && ( + + )} {deepLinkCourse && ( diff --git a/src/modules/dashboard/actions/dashboard-actions.ts b/src/modules/dashboard/actions/dashboard-actions.ts index 4d7b5a8..ed74466 100644 --- a/src/modules/dashboard/actions/dashboard-actions.ts +++ b/src/modules/dashboard/actions/dashboard-actions.ts @@ -25,6 +25,22 @@ import { courseAmountCents, sumCourseRevenueCents, } from "@/src/lib/booking-amounts"; +import { passRevenueCents } from "@/src/modules/products/lib/pass-rules"; + +/** + * Kartenverkäufe der Studios eines Managers im Zeitraum. Eine Karte ist Umsatz + * im Monat des Kaufs - die Buchungen damit bringen keinen weiteren Umsatz. + */ +function findPassSales(managerId: string, from: Date, to: Date) { + return prisma.customerPass.findMany({ + where: { business: { managerId }, source: "purchase", createdAt: { gte: from, lte: to } }, + select: { createdAt: true, status: true, amountPaid: true, platformFee: true }, + }); +} + +function sumPassRevenueCents(passes: Parameters[0][]): number { + return passes.reduce((sum, pass) => sum + passRevenueCents(pass), 0); +} export type DashboardStats = { coursesToday: number; @@ -193,13 +209,20 @@ export async function getDashboardStats(): Promise< }), ]); + const [passSalesThisMonth, passSalesLastMonth] = await Promise.all([ + findPassSales(managerId, monthStart, monthEnd), + findPassSales(managerId, lastMonthStart, lastMonthEnd), + ]); + const revenueThisMonth = sumCourseRevenueCents(revenueThisMonthPaid) + - sumCourseRevenueCents(revenueThisMonthPartial); + sumCourseRevenueCents(revenueThisMonthPartial) + + sumPassRevenueCents(passSalesThisMonth); const revenueLastMonth = sumCourseRevenueCents(revenueLastMonthPaid) + - sumCourseRevenueCents(revenueLastMonthPartial); + sumCourseRevenueCents(revenueLastMonthPartial) + + sumPassRevenueCents(passSalesLastMonth); return { coursesToday, @@ -236,7 +259,7 @@ export async function getDashboardRevenueChart(): Promise< const rangeStart = startOfMonth(subMonths(now, 11)); const rangeEnd = endOfMonth(now); - const [paid, partial, courses] = await Promise.all([ + const [paid, partial, courses, passSales] = await Promise.all([ prisma.courseBooking.findMany({ where: { course: { createdBy: managerId }, @@ -262,6 +285,7 @@ export async function getDashboardRevenueChart(): Promise< }, select: { date: true }, }), + findPassSales(managerId, rangeStart, rangeEnd), ]); // Initialise all 12 months to zero @@ -292,6 +316,11 @@ export async function getDashboardRevenueChart(): Promise< } } + for (const pass of passSales) { + const entry = byMonth.get(format(new Date(pass.createdAt), "yyyy-MM")); + if (entry) entry.revenue += passRevenueCents(pass); + } + for (const c of courses) { const key = format(new Date(c.date), "yyyy-MM"); const entry = byMonth.get(key); @@ -393,6 +422,8 @@ export async function getDashboardRecentBookings(): Promise< where: { course: { createdBy: managerId }, paymentStatus: { in: ["paid", "partially_refunded", "pending", "refunded"] }, + // Letzte Zahlungen - mit Karte gebucht ist keine. + passId: null, }, include: { course: { select: { name: true } }, diff --git a/src/modules/dashboard/ui/components/DashboardNavbar.tsx b/src/modules/dashboard/ui/components/DashboardNavbar.tsx index 32076ef..b03aa41 100644 --- a/src/modules/dashboard/ui/components/DashboardNavbar.tsx +++ b/src/modules/dashboard/ui/components/DashboardNavbar.tsx @@ -11,6 +11,7 @@ import DashboardSearch from "./DashboardSearch"; const menuTitles: Record = { "/dashboard": "Übersicht", "/courses/myCourses": "Meine Kurse", + "/courses/myCards": "Meine Karten", "/courses": "Kurs Übersicht", "/courses/create": "Erstelle einen Kurs", "/courses/edit": "Bearbeite den Kurs", diff --git a/src/modules/dashboard/ui/components/DashboardSearch.tsx b/src/modules/dashboard/ui/components/DashboardSearch.tsx index dff27c3..f5f5107 100644 --- a/src/modules/dashboard/ui/components/DashboardSearch.tsx +++ b/src/modules/dashboard/ui/components/DashboardSearch.tsx @@ -19,6 +19,7 @@ import { XCircle, Tag, UserCircle, + WalletCards, } from "lucide-react"; import { Dialog, DialogContent } from "@/src/components/ui/dialog"; @@ -61,6 +62,15 @@ const navItems: NavItem[] = [ roles: ["user"], keywords: ["buchung", "training"], }, + { + label: "Meine Karten", + description: "Gekaufte Karten und übrige Einheiten", + href: "/courses/myCards", + icon: WalletCards, + category: "Navigation", + roles: ["user"], + keywords: ["karte", "10er", "guthaben", "einheiten", "pass"], + }, { label: "Kunden", description: "Kundenliste, Buchungshistorie & Details", @@ -200,6 +210,15 @@ const navItems: NavItem[] = [ roles: ["manager"], keywords: ["rabatt", "gutschein", "code", "discount", "aktions"], }, + { + label: "Produkte", + description: "Karten wie eine 10er-Karte verkaufen", + href: "/settings/products", + icon: WalletCards, + category: "Einstellungen", + roles: ["manager"], + keywords: ["produkt", "karte", "10er", "zehnerkarte", "guthaben", "pass", "paket"], + }, ]; interface DashboardSearchProps { diff --git a/src/modules/dashboard/ui/components/DashboardSidebar.tsx b/src/modules/dashboard/ui/components/DashboardSidebar.tsx index cc38808..e849f59 100644 --- a/src/modules/dashboard/ui/components/DashboardSidebar.tsx +++ b/src/modules/dashboard/ui/components/DashboardSidebar.tsx @@ -10,7 +10,15 @@ import { SidebarMenuButton, SidebarMenuItem, } from "@/src/components/ui/sidebar"; -import { CreditCard, DoorOpen, Dumbbell, IdCardLanyard, LayoutDashboard, User } from "lucide-react"; +import { + CreditCard, + DoorOpen, + Dumbbell, + IdCardLanyard, + LayoutDashboard, + User, + WalletCards, +} from "lucide-react"; import Image from "next/image"; import Link from "next/link"; @@ -42,6 +50,12 @@ const allMenuItems: MenuItem[] = [ href: "/courses/myCourses", roles: ["user"], }, + { + icon: WalletCards, + label: "Meine Karten", + href: "/courses/myCards", + roles: ["user"], + }, { icon: Dumbbell, label: "Kurse", diff --git a/src/modules/kunden/actions/kunde-detail-actions.ts b/src/modules/kunden/actions/kunde-detail-actions.ts index 75cb487..90cba70 100644 --- a/src/modules/kunden/actions/kunde-detail-actions.ts +++ b/src/modules/kunden/actions/kunde-detail-actions.ts @@ -7,6 +7,40 @@ import { combineDateAndTime } from "@/src/lib/course-time"; import { toEmailBranding } from "@/src/modules/settings/lib/to-email-branding"; import { KUMASY_BRANDING } from "@/src/lib/mail/templates/tokens"; import { paidSeatCounts } from "@/src/modules/courses/lib/seat-counts"; +import { parseCardDesign, type CardDesign } from "@/src/modules/products/lib/card-design"; +import { businessBrandingSchema } from "@/src/modules/settings/schemas/business-branding-schema"; + +/** Eine Änderung durch das Studio, für den Verlauf einer Karte. */ +export type KundePassAdjustment = { + id: string; + type: string; + creditsDelta: number | null; + previousValidUntil: string | null; + newValidUntil: string | null; + reason: string | null; + createdAt: string; +}; + +/** Eine Karte des Kunden bei diesem Studio, mit allem fürs Kunden-Detail. */ +export type KundePass = { + id: string; + name: string; + creditsTotal: number; + creditsRemaining: number; + validUntil: string | null; + status: string; + source: string; + sports: string[]; + design: CardDesign; + studioName: string; + logoUrl: string | null; + createdAt: string; + /** Gezahlter Betrag inkl. Servicegebühr, null bei vergebenen Karten. */ + amountPaidCents: number | null; + /** Gekauft, nicht erstattet und keine Einheit genutzt. */ + canRefund: boolean; + adjustments: KundePassAdjustment[]; +}; export type KundeBooking = { bookingId: string; @@ -33,6 +67,8 @@ export type KundeBooking = { 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"]. */ @@ -52,6 +88,7 @@ export type KundeDetail = { pbSrc: string | null; createdAt: Date | null; bookings: KundeBooking[]; + passes: KundePass[]; }; export async function getKundeDetail( @@ -68,10 +105,11 @@ export async function getKundeDetail( where: { id: kundeId, // Sicherheits-Guard: nur zurückgeben wenn der User wirklich - // mindestens eine Buchung bei diesem Manager hat - courseBookings: { - some: { course: { businessId: { in: businessIds } } }, - }, + // mindestens eine Buchung oder Karte bei diesem Manager hat + OR: [ + { courseBookings: { some: { course: { businessId: { in: businessIds } } } } }, + { passes: { some: { businessId: { in: businessIds } } } }, + ], }, select: { id: true, @@ -89,6 +127,7 @@ export async function getKundeDetail( select: { id: true, paymentStatus: true, + passId: true, seats: true, spotLabels: true, course: { @@ -112,6 +151,45 @@ export async function getKundeDetail( }, orderBy: { createdAt: "desc" }, }, + passes: { + where: { businessId: { in: businessIds } }, + orderBy: { createdAt: "desc" }, + select: { + id: true, + name: true, + creditsTotal: true, + creditsRemaining: true, + validUntil: true, + status: true, + source: true, + sports: true, + amountPaid: true, + stripePaymentIntentId: true, + createdAt: true, + product: { select: { design: true } }, + business: { select: { name: true, branding: true } }, + adjustments: { + orderBy: { createdAt: "desc" }, + select: { + id: true, + type: true, + creditsDelta: true, + previousValidUntil: true, + newValidUntil: true, + reason: true, + createdAt: true, + }, + }, + // Genutzt = Buchung aktiv oder Einheit verfallen (siehe refundPass). + _count: { + select: { + bookings: { + where: { paymentStatus: { in: ["paid", "refunding", "cancelled"] } }, + }, + }, + }, + }, + }, }, }), prisma.room.findMany({ @@ -188,6 +266,7 @@ export async function getKundeDetail( trainers: b.course.trainers, trainersMap, paymentStatus: b.paymentStatus, + paidWithPass: b.passId !== null, seats: b.seats, spotLabels: b.spotLabels, isPast: courseStart < now, @@ -210,6 +289,38 @@ export async function getKundeDetail( pbSrc: userWithBookings.pbSrc, createdAt: userWithBookings.createdAt, bookings, + passes: userWithBookings.passes.map((pass) => { + const branding = businessBrandingSchema.safeParse(pass.business.branding); + return { + id: pass.id, + name: pass.name, + creditsTotal: pass.creditsTotal, + creditsRemaining: pass.creditsRemaining, + validUntil: pass.validUntil?.toISOString() ?? null, + status: pass.status, + source: pass.source, + sports: pass.sports, + design: parseCardDesign(pass.product.design), + studioName: branding.success ? branding.data.companyName : pass.business.name, + logoUrl: branding.success ? branding.data.logoUrl : null, + createdAt: pass.createdAt.toISOString(), + amountPaidCents: pass.amountPaid, + canRefund: + pass.source === "purchase" && + pass.status !== "refunded" && + pass.stripePaymentIntentId !== null && + pass._count.bookings === 0, + adjustments: pass.adjustments.map((a) => ({ + id: a.id, + type: a.type, + creditsDelta: a.creditsDelta, + previousValidUntil: a.previousValidUntil?.toISOString() ?? null, + newValidUntil: a.newValidUntil?.toISOString() ?? null, + reason: a.reason, + createdAt: a.createdAt.toISOString(), + })), + }; + }), } satisfies KundeDetail; }, [`kunde-detail-${kundeId}-${manager.id}`], diff --git a/src/modules/kunden/actions/kunden-actions.ts b/src/modules/kunden/actions/kunden-actions.ts index eb26ad0..5feccaa 100644 --- a/src/modules/kunden/actions/kunden-actions.ts +++ b/src/modules/kunden/actions/kunden-actions.ts @@ -8,7 +8,14 @@ export type KundeRow = { name: string; email: string; telefon: string; - status: "Bezahlt" | "Storniert" | "Teilweise erstattet" | "Ausstehend" | "Fehlgeschlagen"; + /** Letzte Buchung - oder "Karte", wenn jemand bisher nur eine Karte hat. */ + status: + | "Bezahlt" + | "Storniert" + | "Teilweise erstattet" + | "Ausstehend" + | "Fehlgeschlagen" + | "Karte"; isGuest: boolean; }; @@ -32,21 +39,29 @@ export async function getKunden(): Promise< return { success: true, kunden: [] }; } - const bookings = await prisma.courseBooking.findMany({ - where: { - course: { businessId: { in: businessIds } }, - }, - select: { - paymentStatus: true, - user: { - select: { id: true, name: true, email: true, tel: true }, + const [bookings, passHolders] = await Promise.all([ + prisma.courseBooking.findMany({ + where: { + course: { businessId: { in: businessIds } }, }, - guestLead: { - select: { id: true, name: true, email: true }, + select: { + paymentStatus: true, + user: { + select: { id: true, name: true, email: true, tel: true }, + }, + guestLead: { + select: { id: true, name: true, email: true }, + }, }, - }, - orderBy: { createdAt: "desc" }, - }); + orderBy: { createdAt: "desc" }, + }), + // Wer eine Karte gekauft oder bekommen hat, ist Kunde - auch ohne Buchung. + prisma.customerPass.findMany({ + where: { businessId: { in: businessIds }, userId: { not: null } }, + select: { user: { select: { id: true, name: true, email: true, tel: true } } }, + orderBy: { createdAt: "desc" }, + }), + ]); // Deduplizieren: pro User-ID bzw. GuestLead-ID nur den letzten Status const seen = new Map(); @@ -79,6 +94,20 @@ export async function getKunden(): Promise< } } + for (const { user } of passHolders) { + if (!user) continue; + const key = `user:${user.id}`; + if (seen.has(key)) continue; + seen.set(key, { + id: user.id, + name: user.name ?? user.email, + email: user.email, + telefon: user.tel ?? "–", + status: "Karte", + isGuest: false, + }); + } + return { success: true, kunden: Array.from(seen.values()) }; } catch { return { success: false, error: "Kunden konnten nicht geladen werden." }; diff --git a/src/modules/kunden/ui/components/kunde-booking-card.tsx b/src/modules/kunden/ui/components/kunde-booking-card.tsx index f0feaad..e18c0ed 100644 --- a/src/modules/kunden/ui/components/kunde-booking-card.tsx +++ b/src/modules/kunden/ui/components/kunde-booking-card.tsx @@ -19,6 +19,7 @@ const getDiceBearUrl = (seed: string) => const PAYMENT_STATUS: Record = { paid: { label: "Bezahlt", bg: "bg-green-100", text: "text-green-700" }, + 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: { @@ -61,7 +62,11 @@ const KundeBookingCard = ({ booking }: KundeBookingCardProps) => { const router = useRouter(); const [dialogOpen, setDialogOpen] = useState(false); const [confirmOpen, setConfirmOpen] = useState(false); - const status = PAYMENT_STATUS[booking.paymentStatus] ?? PAYMENT_STATUS.pending; + const displayStatus = + booking.paidWithPass && booking.paymentStatus === "paid" + ? "paid_with_pass" + : booking.paymentStatus; + const status = PAYMENT_STATUS[displayStatus] ?? PAYMENT_STATUS.pending; const dateFormatted = new Date(booking.date).toLocaleDateString("de-DE", { day: "2-digit", @@ -179,7 +184,7 @@ const KundeBookingCard = ({ booking }: KundeBookingCardProps) => { course={toPublicCourse(booking)} open={dialogOpen} onOpenChange={setDialogOpen} - paymentStatus={booking.paymentStatus} + paymentStatus={displayStatus} accentColor={booking.businessBranding.accentColor} /> diff --git a/src/modules/kunden/ui/components/kunden-table.tsx b/src/modules/kunden/ui/components/kunden-table.tsx index 245644a..ba95aa1 100644 --- a/src/modules/kunden/ui/components/kunden-table.tsx +++ b/src/modules/kunden/ui/components/kunden-table.tsx @@ -19,6 +19,7 @@ const STATUS_STYLES: Record = { "Teilweise erstattet": "bg-orange-100 text-orange-700", Ausstehend: "bg-yellow-100 text-yellow-700", Fehlgeschlagen: "bg-red-100 text-red-600", + Karte: "bg-blue-100 text-blue-700", }; interface KundenTableProps { diff --git a/src/modules/kunden/ui/views/kunde-detail-view.tsx b/src/modules/kunden/ui/views/kunde-detail-view.tsx index 851bf80..c437cee 100644 --- a/src/modules/kunden/ui/views/kunde-detail-view.tsx +++ b/src/modules/kunden/ui/views/kunde-detail-view.tsx @@ -12,13 +12,15 @@ import { ChevronUp, BookOpen, History, - CreditCard, + WalletCards, Bell, Scale, CheckCircle2, } from "lucide-react"; import type { KundeDetail } from "../../actions/kunde-detail-actions"; import KundeBookingCard from "../components/kunde-booking-card"; +import KundePassesSection from "@/src/modules/products/ui/components/KundePassesSection"; +import type { PassIssueOption } from "@/src/modules/products/actions/pass-admin-actions"; // ─── DiceBear Fallback ──────────────────────────────────────────────────────── // Nur anonymisierten Seed senden – keine internen IDs an Drittanbieter @@ -94,13 +96,21 @@ const InfoRow = ({ icon, label, value }: InfoRowProps) => ( // ─── Haupt-View ─────────────────────────────────────────────────────────────── interface KundeDetailViewProps { kunde: KundeDetail; + /** Produkte als Vorlage für "Karte vergeben". */ + issueOptions: PassIssueOption[]; } -const KundeDetailView = ({ kunde }: KundeDetailViewProps) => { +const KundeDetailView = ({ kunde, issueOptions }: KundeDetailViewProps) => { const avatarSrc = kunde.pbSrc ?? getDiceBearUrl(kunde.id); const upcomingBookings = kunde.bookings.filter((b) => !b.isPast); const pastBookings = kunde.bookings.filter((b) => b.isPast); + const activePassCount = kunde.passes.filter( + (p) => + p.status === "active" && + p.creditsRemaining > 0 && + (p.validUntil === null || new Date(p.validUntil) > new Date()) + ).length; const genderLabel: Record = { male: "Männlich", @@ -214,27 +224,18 @@ const KundeDetailView = ({ kunde }: KundeDetailViewProps) => { )} - {/* ── Abonnements & Credits (Dummy) ── */} + {/* ── Karten ── */} } - title="Abonnements & Credits" - defaultOpen={false} + icon={} + title="Karten" + badge={activePassCount} + defaultOpen={kunde.passes.length > 0} > -
- {[ - { label: "Aktive Abonnements", value: "1 Abo" }, - { label: "Übrige Buchungen", value: "4" }, - { label: "Credits", value: "0" }, - ].map((row) => ( -
- {row.label} - {row.value} -
- ))} -
+
{/* ── Benachrichtigungen (Dummy) ── */} diff --git a/src/modules/kunden/ui/views/kunden-view.tsx b/src/modules/kunden/ui/views/kunden-view.tsx index 7e05cac..0a779a2 100644 --- a/src/modules/kunden/ui/views/kunden-view.tsx +++ b/src/modules/kunden/ui/views/kunden-view.tsx @@ -2,18 +2,30 @@ import { useState } from "react"; import { Input } from "@/src/components/ui/input"; -import { Search } from "lucide-react"; +import { Button } from "@/src/components/ui/button"; +import { Search, WalletCards } from "lucide-react"; import KundenTable from "../components/kunden-table"; import type { KundeRow } from "../../actions/kunden-actions"; +import type { + PassIssueOption, + PendingPass, +} from "@/src/modules/products/actions/pass-admin-actions"; +import IssuePassDialog from "@/src/modules/products/ui/components/IssuePassDialog"; +import { formatPassDate } from "@/src/modules/products/lib/pass-rules"; export type Kunde = KundeRow; interface KundenViewProps { initialKunden: KundeRow[]; + issueOptions: PassIssueOption[]; + /** Vergebene Karten, deren Empfänger sich noch nicht registriert hat. */ + pendingPasses: PendingPass[]; } -const KundenView = ({ initialKunden }: KundenViewProps) => { +const KundenView = ({ initialKunden, issueOptions, pendingPasses }: KundenViewProps) => { const [search, setSearch] = useState(""); + const [issueOpen, setIssueOpen] = useState(false); + const [issueKey, setIssueKey] = useState(0); const filtered = initialKunden.filter( (k) => @@ -26,20 +38,66 @@ const KundenView = ({ initialKunden }: KundenViewProps) => {

Kunden

-
- - setSearch(e.target.value)} - /> +
+ +
+ + setSearch(e.target.value)} + /> +
+ + {pendingPasses.length > 0 && ( +
+

Vergebene Karten ohne Konto

+

+ Sobald sich jemand mit dieser E-Mail-Adresse registriert, erscheint die Karte in seinem + Konto. +

+
    + {pendingPasses.map((pass) => ( +
  • + {pass.email} + + {pass.name} · {pass.creditsRemaining}{" "} + {pass.creditsRemaining === 1 ? "Einheit" : "Einheiten"} + {pass.validUntil && ` · bis ${formatPassDate(new Date(pass.validUntil))}`} · + vergeben am {formatPassDate(new Date(pass.createdAt))} + +
  • + ))} +
+
+ )} + +
); }; diff --git a/src/modules/notifications/lib/notification-types.ts b/src/modules/notifications/lib/notification-types.ts index 9088ee2..2393635 100644 --- a/src/modules/notifications/lib/notification-types.ts +++ b/src/modules/notifications/lib/notification-types.ts @@ -52,6 +52,13 @@ export const NOTIFICATION_TYPES = { defaultOn: true, audience: "studio", }, + "pass.sold": { + label: "Karte verkauft", + description: "Ein Kunde hat eine Karte gekauft, z.B. eine 10er-Karte.", + icon: "WalletCards", + defaultOn: true, + audience: "studio", + }, "waitlist.spot": { label: "Wartelisten-Platz frei", description: diff --git a/src/modules/notifications/ui/components/NotificationTypeIcon.tsx b/src/modules/notifications/ui/components/NotificationTypeIcon.tsx index 6ef20f8..6e2f30c 100644 --- a/src/modules/notifications/ui/components/NotificationTypeIcon.tsx +++ b/src/modules/notifications/ui/components/NotificationTypeIcon.tsx @@ -5,6 +5,7 @@ import { CalendarPlus, CalendarX, Users, + WalletCards, type LucideProps, } from "lucide-react"; import { NOTIFICATION_TYPES, type NotificationType } from "../../lib/notification-types"; @@ -23,6 +24,7 @@ const ICONS = { CalendarPlus, CalendarX, Users, + WalletCards, } as const; interface NotificationTypeIconProps extends LucideProps { diff --git a/src/modules/products/actions/pass-admin-actions.ts b/src/modules/products/actions/pass-admin-actions.ts new file mode 100644 index 0000000..8a99bc2 --- /dev/null +++ b/src/modules/products/actions/pass-admin-actions.ts @@ -0,0 +1,452 @@ +"use server"; + +import { revalidatePath, updateTag } from "next/cache"; +import { z } from "zod"; +import { prisma } from "@/src/lib/prisma"; +import { requireManager } from "@/src/lib/auth/getUser"; +import { refundStripePayment } from "@/src/lib/stripe-refund"; +import { sendMail } from "@/src/lib/mail/resend"; +import { generatePassConfirmationEmail } from "@/src/lib/mail/templates/pass-confirmation"; +import { toEmailBranding } from "@/src/modules/settings/lib/to-email-branding"; +import { berlinDayEnd } from "@/src/modules/courses/lib/course-days"; + +/** + * Was der Manager im Kunden-Detail mit Karten machen kann. Jede Änderung steht + * mit Grund in PassAdjustment, damit später nachvollziehbar ist, warum eine + * Karte mehr oder weniger Einheiten hat als gekauft. + */ + +type ActionResult = { success: true } | { success: false; error: string }; + +const DATE_KEY = /^\d{4}-\d{2}-\d{2}$/; +const reasonSchema = z.string().trim().max(200, "Grund: maximal 200 Zeichen"); + +const issueSchema = z.object({ + productId: z.string().min(1), + email: z.string().trim().toLowerCase().email("Bitte eine gültige E-Mail-Adresse angeben"), + credits: z + .number({ error: "Bitte gib die Anzahl der Einheiten an" }) + .int() + .min(1, "Mindestens 1 Einheit") + .max(100, "Höchstens 100 Einheiten"), + /** "YYYY-MM-DD" = gültig bis einschließlich dieses Tages, null = unbegrenzt. */ + validUntil: z.string().regex(DATE_KEY).nullable(), + reason: reasonSchema, +}); + +export type IssuePassInput = z.input; + +/** Läd eine Karte nur, wenn sie zu einem Studio dieses Managers gehört. */ +async function findManagedPass(passId: string, businessIds: string[]) { + return prisma.customerPass.findFirst({ + where: { id: passId, businessId: { in: businessIds } }, + select: { + id: true, + userId: true, + status: true, + source: true, + creditsTotal: true, + creditsRemaining: true, + validUntil: true, + stripePaymentIntentId: true, + stripeAccountId: true, + amountPaid: true, + }, + }); +} + +function refreshCustomer(userId: string | null) { + revalidatePath("/kunden"); + if (!userId) return; + updateTag(`kunde-detail-${userId}`); + revalidatePath(`/kunden/${userId}`); +} + +/** + * Vergibt eine Karte ohne Zahlung - z.B. weil jemand bar bezahlt hat oder + * Restguthaben vom alten Anbieter mitbringt. Vorlage ist ein Produkt (Name, + * Design, Kursarten), Einheiten und Ablaufdatum legt der Manager selbst fest. + * Hat die Adresse noch kein Konto, hängt sich die Karte bei der Anmeldung an. + * Ohne Zahlung fällt auch keine Servicegebühr an. + */ +export async function issuePass(input: IssuePassInput): Promise { + const manager = await requireManager(); + const businessIds = manager.businesses.map((b) => b.id); + + const parsed = issueSchema.safeParse(input); + if (!parsed.success) { + return { success: false, error: parsed.error.issues[0]?.message ?? "Ungültige Eingabe." }; + } + const { productId, email, credits, validUntil, reason } = parsed.data; + + try { + const product = await prisma.product.findFirst({ + where: { id: productId, businessId: { in: businessIds } }, + select: { + id: true, + name: true, + sports: true, + businessId: true, + business: { select: { name: true, branding: true } }, + }, + }); + if (!product) return { success: false, error: "Karte nicht gefunden." }; + + const validUntilDate = validUntil ? berlinDayEnd(validUntil) : null; + if (validUntilDate && validUntilDate <= new Date()) { + return { success: false, error: "Das Ablaufdatum muss in der Zukunft liegen." }; + } + + const user = await prisma.user.findFirst({ + where: { email: { equals: email, mode: "insensitive" } }, + select: { id: true }, + }); + + await prisma.customerPass.create({ + data: { + productId: product.id, + businessId: product.businessId, + userId: user?.id ?? null, + email, + name: product.name, + sports: product.sports, + creditsTotal: credits, + creditsRemaining: credits, + validUntil: validUntilDate, + source: "manual", + adjustments: { + create: { + managerId: manager.id, + type: "issue", + creditsDelta: credits, + newValidUntil: validUntilDate, + reason: reason || null, + }, + }, + }, + }); + + const baseUrl = process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000"; + const mail = generatePassConfirmationEmail({ + reason: "issued", + passName: product.name, + businessName: product.business.name, + credits, + validUntil: validUntilDate, + hasAccount: user !== null, + myCardsUrl: `${baseUrl}/courses/myCards`, + registerUrl: `${baseUrl}/register/user`, + companyName: process.env.COMPANY_NAME, + branding: toEmailBranding(product.business), + }); + await sendMail({ to: email, ...mail }).catch((err) => + console.error("Mail zur vergebenen Karte fehlgeschlagen:", err) + ); + + refreshCustomer(user?.id ?? null); + return { success: true }; + } catch (error) { + console.error("Error issuing pass:", error); + return { success: false, error: "Karte konnte nicht vergeben werden." }; + } +} + +/** + * Schreibt Einheiten gut (z.B. Kulanz) oder zieht sie ab (Korrektur). Eine + * Gutschrift erhöht auch die Gesamtzahl der Karte ("4 von 11 frei"), ein Abzug + * nur die übrigen Einheiten - nie unter null. + */ +export async function adjustPassCredits(input: { + passId: string; + delta: number; + reason: string; +}): Promise { + const manager = await requireManager(); + const reason = reasonSchema.safeParse(input.reason); + if (!reason.success) return { success: false, error: reason.error.issues[0].message }; + if (!reason.data) return { success: false, error: "Bitte gib einen Grund an." }; + const { delta } = input; + if (!Number.isInteger(delta) || delta === 0 || Math.abs(delta) > 100) { + return { success: false, error: "Bitte zwischen 1 und 100 Einheiten angeben." }; + } + + try { + const pass = await findManagedPass( + input.passId, + manager.businesses.map((b) => b.id) + ); + if (!pass) return { success: false, error: "Karte nicht gefunden." }; + if (pass.status === "refunded") { + return { success: false, error: "Eine erstattete Karte lässt sich nicht mehr ändern." }; + } + + // Atomar und an "genug übrig" gebunden - eine gleichzeitige Buchung kann + // die Karte sonst unter null ziehen. + const updated = await prisma.$transaction(async (tx) => { + const result = await tx.customerPass.updateMany({ + where: { + id: pass.id, + ...(delta < 0 ? { creditsRemaining: { gte: -delta } } : {}), + }, + data: + delta > 0 + ? { creditsRemaining: { increment: delta }, creditsTotal: { increment: delta } } + : { creditsRemaining: { decrement: -delta } }, + }); + if (result.count !== 1) return false; + await tx.passAdjustment.create({ + data: { + passId: pass.id, + managerId: manager.id, + type: "credits", + creditsDelta: delta, + reason: reason.data, + }, + }); + return true; + }); + if (!updated) { + return { success: false, error: "So viele Einheiten sind auf der Karte nicht mehr frei." }; + } + + refreshCustomer(pass.userId); + return { success: true }; + } catch (error) { + console.error("Error adjusting pass credits:", error); + return { success: false, error: "Einheiten konnten nicht geändert werden." }; + } +} + +/** Setzt ein neues Ablaufdatum ("YYYY-MM-DD", einschließlich) oder macht die Karte unbegrenzt gültig. */ +export async function setPassValidUntil(input: { + passId: string; + validUntil: string | null; + reason: string; +}): Promise { + const manager = await requireManager(); + const reason = reasonSchema.safeParse(input.reason); + if (!reason.success) return { success: false, error: reason.error.issues[0].message }; + if (input.validUntil !== null && !DATE_KEY.test(input.validUntil)) { + return { success: false, error: "Ungültiges Datum." }; + } + const newValidUntil = input.validUntil ? berlinDayEnd(input.validUntil) : null; + if (newValidUntil && newValidUntil <= new Date()) { + return { success: false, error: "Das Ablaufdatum muss in der Zukunft liegen." }; + } + + try { + const pass = await findManagedPass( + input.passId, + manager.businesses.map((b) => b.id) + ); + if (!pass) return { success: false, error: "Karte nicht gefunden." }; + if (pass.status !== "active") { + return { success: false, error: "Nur aktive Karten lassen sich verlängern." }; + } + + await prisma.customerPass.update({ + where: { id: pass.id }, + data: { + validUntil: newValidUntil, + adjustments: { + create: { + managerId: manager.id, + type: "extend", + previousValidUntil: pass.validUntil, + newValidUntil, + reason: reason.data || null, + }, + }, + }, + }); + + refreshCustomer(pass.userId); + return { success: true }; + } catch (error) { + console.error("Error extending pass:", error); + return { success: false, error: "Ablaufdatum konnte nicht geändert werden." }; + } +} + +/** + * Beendet eine Karte: Sie lässt sich nicht mehr einlösen. Bereits gebuchte + * Kurse bleiben gebucht. Kein Geld fließt - dafür gibt es refundPass. + */ +export async function endPass(input: { passId: string; reason: string }): Promise { + const manager = await requireManager(); + const reason = reasonSchema.safeParse(input.reason); + if (!reason.success) return { success: false, error: reason.error.issues[0].message }; + + try { + const pass = await findManagedPass( + input.passId, + manager.businesses.map((b) => b.id) + ); + if (!pass) return { success: false, error: "Karte nicht gefunden." }; + + const { count } = await prisma.customerPass.updateMany({ + where: { id: pass.id, status: "active" }, + data: { status: "ended" }, + }); + if (count !== 1) return { success: false, error: "Die Karte ist nicht mehr aktiv." }; + await prisma.passAdjustment.create({ + data: { passId: pass.id, managerId: manager.id, type: "end", reason: reason.data || null }, + }); + + refreshCustomer(pass.userId); + return { success: true }; + } catch (error) { + console.error("Error ending pass:", error); + return { success: false, error: "Karte konnte nicht beendet werden." }; + } +} + +/** + * Erstattet eine gekaufte Karte komplett - nur solange keine Einheit genutzt + * wurde. "Genutzt" heißt: eine Buchung mit der Karte ist aktiv oder ihre + * Einheit ist bei einem späten Storno verfallen. Eine stornierte Buchung, deren + * Einheit zurückkam, zählt nicht. + * + * Der Kunde bekommt den vollen Betrag inklusive Servicegebühr zurück. Die + * Gebühr behält Kumasy, das Studio trägt sie - wie bei einer Kursabsage: wer + * zurückzahlt, trägt die Kosten (siehe stripe-refund.ts). + */ +export async function refundPass(input: { passId: string }): Promise { + const manager = await requireManager(); + + try { + const pass = await findManagedPass( + input.passId, + manager.businesses.map((b) => b.id) + ); + if (!pass) return { success: false, error: "Karte nicht gefunden." }; + if (pass.source !== "purchase" || !pass.stripePaymentIntentId || !pass.amountPaid) { + return { success: false, error: "Nur gekaufte Karten lassen sich erstatten." }; + } + if (pass.status === "refunded") { + return { success: false, error: "Die Karte wurde bereits erstattet." }; + } + + const used = await prisma.courseBooking.count({ + where: { passId: pass.id, paymentStatus: { in: ["paid", "refunding", "cancelled"] } }, + }); + if (used > 0) { + return { + success: false, + error: "Die Karte wurde schon genutzt und kann nicht mehr erstattet werden.", + }; + } + + // Vor dem Stripe-Aufruf sperren: ein Doppelklick erstattet nicht zweimal, + // und eine gleichzeitige Buchung findet die Karte nicht mehr aktiv. Die + // Bindung an den gelesenen Stand fängt eine Buchung ab, die zwischen der + // Prüfung oben und hier Einheiten abgezogen hat. + const { count } = await prisma.customerPass.updateMany({ + where: { + id: pass.id, + status: { in: ["active", "ended"] }, + creditsRemaining: pass.creditsRemaining, + }, + data: { status: "refunded", creditsRemaining: 0 }, + }); + if (count !== 1) { + return { + success: false, + error: + "Die Karte hat sich gerade geändert. Bitte lade die Seite neu und versuche es erneut.", + }; + } + + const refund = await refundStripePayment({ + paymentIntentId: pass.stripePaymentIntentId, + stripeAccountId: pass.stripeAccountId, + idempotencyKey: `refund-pass-${pass.id}`, + refundApplicationFee: false, + }); + if (!refund.success) { + await prisma.customerPass.update({ + where: { id: pass.id }, + data: { status: pass.status, creditsRemaining: pass.creditsRemaining }, + }); + return { success: false, error: `Erstattung fehlgeschlagen: ${refund.error}` }; + } + + await prisma.customerPass.update({ + where: { id: pass.id }, + data: { + refundedAmount: refund.amountRefundedCents, + refundedAt: new Date(), + adjustments: { + create: { + managerId: manager.id, + type: "refund", + creditsDelta: -pass.creditsRemaining, + reason: null, + }, + }, + }, + }); + + refreshCustomer(pass.userId); + return { success: true }; + } catch (error) { + console.error("Error refunding pass:", error); + return { success: false, error: "Erstattung fehlgeschlagen." }; + } +} + +export type PassIssueOption = { + id: string; + name: string; + credits: number; + validityMonths: number | null; + isActive: boolean; +}; + +/** + * Produkte als Vorlage für "Karte vergeben" - auch solche, die nicht mehr im + * Verkauf sind (z.B. eine alte Karte für übernommenes Restguthaben). + */ +export async function getPassIssueOptions(): Promise { + const manager = await requireManager(); + return prisma.product.findMany({ + where: { businessId: { in: manager.businesses.map((b) => b.id) } }, + select: { id: true, name: true, credits: true, validityMonths: true, isActive: true }, + orderBy: [{ isActive: "desc" }, { createdAt: "asc" }], + }); +} + +export type PendingPass = { + id: string; + email: string; + name: string; + creditsRemaining: number; + validUntil: string | null; + createdAt: string; +}; + +/** Vergebene Karten, deren Empfänger noch kein Konto hat. */ +export async function getPendingPasses(): Promise { + const manager = await requireManager(); + const passes = await prisma.customerPass.findMany({ + where: { + businessId: { in: manager.businesses.map((b) => b.id) }, + userId: null, + status: "active", + }, + select: { + id: true, + email: true, + name: true, + creditsRemaining: true, + validUntil: true, + createdAt: true, + }, + orderBy: { createdAt: "desc" }, + }); + return passes.map((p) => ({ + ...p, + validUntil: p.validUntil?.toISOString() ?? null, + createdAt: p.createdAt.toISOString(), + })); +} diff --git a/src/modules/products/actions/pass-booking-actions.ts b/src/modules/products/actions/pass-booking-actions.ts new file mode 100644 index 0000000..d56b27d --- /dev/null +++ b/src/modules/products/actions/pass-booking-actions.ts @@ -0,0 +1,309 @@ +"use server"; + +import { revalidatePath, updateTag } from "next/cache"; +import { Prisma } from "@/src/generated/prisma/client"; +import { prisma } from "@/src/lib/prisma"; +import { getUserData, isUser } from "@/src/lib/auth/getUser"; +import { combineDateAndTime } from "@/src/lib/course-time"; +import { sendMail } from "@/src/lib/mail/resend"; +import { generateBookingConfirmationEmail } from "@/src/lib/mail/templates/booking-confirmation"; +import { toEmailBranding } from "@/src/modules/settings/lib/to-email-branding"; +import { MAX_SEATS_PER_BOOKING, paidSeatCount } from "@/src/modules/courses/lib/seat-counts"; +import { + getBookedSpotIds, + getCourseSpots, + getHeldSpotIds, + spotHolderKey, +} from "@/src/modules/courses/lib/course-spots"; +import { notifyManagerOfBooking } from "@/src/modules/courses/lib/notify-booking"; +import { spotDisplayName } from "@/src/modules/rooms/lib/floor-plan"; +import { canRedeemPass, passCoversCourse } from "../lib/pass-rules"; + +export type BookWithPassResult = + | { success: true; seats: number; creditsRemaining: number } + | { success: false; error: string; spotUnavailable?: boolean }; + +/** Fachlicher Abbruch innerhalb der Transaktion - wird zur Fehlermeldung für den Kunden. */ +class BookingRejected extends Error { + constructor( + message: string, + readonly spotUnavailable = false + ) { + super(message); + } +} + +async function withTimeout(promise: Promise, ms: number): Promise { + return Promise.race([ + promise, + new Promise((resolve) => setTimeout(() => resolve(null), ms)), + ]); +} + +const units = (n: number) => `${n} ${n === 1 ? "Einheit" : "Einheiten"}`; + +/** + * Bucht einen Kurs mit einer Karte - ohne Checkout, sofort bestätigt. + * + * Alles in einer Serializable-Transaktion: Karte gültig und passend, Kurs noch + * nicht begonnen, genug Plätze und Geräte frei, dann Einheiten abziehen und + * Buchung anlegen. Das Abziehen ist zusätzlich an "genug übrig" gebunden, damit + * zwei gleichzeitige Buchungen die Karte nie überziehen. + */ +export async function bookCourseWithPass(input: { + courseId: string; + passId: string; + seats: number; + spotIds?: string[]; +}): Promise { + const userData = await getUserData(); + if (!userData || !isUser(userData)) { + return { success: false, error: "Bitte melden Sie sich mit Ihrem Kundenkonto an." }; + } + + const { courseId, passId, seats } = input; + const requestedSpotIds = [...new Set(input.spotIds ?? [])]; + if (!Number.isInteger(seats) || seats < 1 || seats > MAX_SEATS_PER_BOOKING) { + return { + success: false, + error: `Bitte zwischen 1 und ${MAX_SEATS_PER_BOOKING} Plätzen wählen.`, + }; + } + + const run = () => + prisma.$transaction( + async (tx) => { + const now = new Date(); + const course = await tx.course.findUnique({ + where: { id: courseId }, + select: { + id: true, + name: true, + date: true, + timeFrom: true, + timeTo: true, + status: true, + sport: true, + maxParticipants: true, + spotBooking: true, + room: true, + coverImage: true, + businessId: true, + business: { select: { isPublic: true, name: true, address: true, branding: true } }, + }, + }); + if (!course || course.status !== "published" || !course.business?.isPublic) { + throw new BookingRejected("Dieser Kurs ist nicht verfügbar."); + } + if (combineDateAndTime(course.date, course.timeFrom) <= now) { + throw new BookingRejected("Dieser Kurs hat bereits begonnen."); + } + + const pass = await tx.customerPass.findUnique({ where: { id: passId } }); + if (!pass || pass.userId !== userData.id || pass.businessId !== course.businessId) { + throw new BookingRejected("Karte nicht gefunden."); + } + if (!passCoversCourse(pass.sports, course.sport)) { + throw new BookingRejected("Diese Karte gilt nicht für diesen Kurs."); + } + if (!canRedeemPass(pass, 1, now)) { + throw new BookingRejected("Diese Karte ist abgelaufen oder nicht mehr aktiv."); + } + if (pass.creditsRemaining < seats) { + throw new BookingRejected( + `Auf Ihrer Karte ${pass.creditsRemaining === 1 ? "ist" : "sind"} nur noch ${units(pass.creditsRemaining)} frei.` + ); + } + + const existing = await tx.courseBooking.findUnique({ + where: { courseId_userId: { courseId, userId: userData.id } }, + select: { paymentStatus: true, stripePaymentIntentId: true }, + }); + if (existing && ["paid", "refunding", "pending"].includes(existing.paymentStatus)) { + throw new BookingRejected("Sie haben diesen Kurs bereits gebucht."); + } + // Eine frühere, bezahlte und stornierte Buchung trägt Zahlung, Erstattung + // und ggf. Rechnung. Pro Person und Kurs gibt es nur eine Buchungszeile - + // eine Kartenbuchung darf diese Belege nicht überschreiben. + if (existing?.stripePaymentIntentId) { + throw new BookingRejected( + "Sie hatten diesen Kurs schon einmal bezahlt und storniert. Bitte buchen Sie ihn erneut über die normale Zahlung." + ); + } + + const freeSeats = course.maxParticipants - (await paidSeatCount(tx, courseId)); + if (seats > freeSeats) { + throw new BookingRejected( + freeSeats <= 0 + ? "Dieser Kurs ist bereits ausgebucht." + : freeSeats === 1 + ? "Es ist nur noch 1 Platz frei." + : `Es sind nur noch ${freeSeats} Plätze frei.` + ); + } + + // Platzwahl: genau die gewählten Geräte, und keins davon darf gebucht + // oder gerade von jemand anderem im Checkout gehalten sein. + let spotIds: string[] = []; + let spotLabels: string[] = []; + const spots = await getCourseSpots(tx, course); + if (spots) { + const chosen = requestedSpotIds + .map((id) => spots.find((spot) => spot.id === id)) + .filter((spot) => spot !== undefined); + if (chosen.length !== requestedSpotIds.length || chosen.length !== seats) { + throw new BookingRejected( + requestedSpotIds.length === 0 + ? "Bitte wählen Sie einen Platz aus." + : "Bitte wählen Sie so viele Plätze, wie Sie buchen möchten.", + true + ); + } + const [booked, held] = await Promise.all([ + getBookedSpotIds(tx, courseId), + getHeldSpotIds(tx, courseId, { + exceptHolderKey: spotHolderKey({ userId: userData.id }), + }), + ]); + const taken = chosen.find((spot) => booked.has(spot.id) || held.has(spot.id)); + if (taken) { + throw new BookingRejected(`${spotDisplayName(taken)} ist nicht mehr frei.`, true); + } + spotIds = chosen.map((spot) => spot.id); + spotLabels = chosen.map(spotDisplayName); + } + + const debited = await tx.customerPass.updateMany({ + where: { id: passId, status: "active", creditsRemaining: { gte: seats } }, + data: { creditsRemaining: { decrement: seats } }, + }); + if (debited.count !== 1) { + throw new BookingRejected("Auf Ihrer Karte sind nicht mehr genug Einheiten frei."); + } + + const bookingColumns = { + paymentStatus: "paid", + passId, + seats, + seatsPurchased: seats, + spotIds, + spotLabels, + // Eine frühere, stornierte Kartenbuchung derselben Person wird neu belegt. + stripeSessionId: null, + stripePaymentIntentId: null, + stripeAccountId: null, + amountPaid: null, + platformFee: null, + stripeFee: null, + platformFeeOnTop: false, + billingAddress: Prisma.DbNull, + discountCodeId: null, + discountAmount: null, + refundedAmount: null, + refundedFeeAmount: null, + refundedAt: null, + cancelledBy: null, + cancellationReason: null, + }; + await tx.courseBooking.upsert({ + where: { courseId_userId: { courseId, userId: userData.id } }, + update: bookingColumns, + create: { courseId, userId: userData.id, ...bookingColumns }, + }); + + return { + course, + passName: pass.name, + creditsRemaining: pass.creditsRemaining - seats, + spotLabels, + }; + }, + { isolationLevel: "Serializable" } + ); + + let outcome: Awaited>; + try { + outcome = await runWithSerializationRetry(run); + } catch (error) { + if (error instanceof BookingRejected) { + return { success: false, error: error.message, spotUnavailable: error.spotUnavailable }; + } + console.error("Buchung mit Karte fehlgeschlagen:", error); + return { + success: false, + error: "Die Buchung ist fehlgeschlagen. Bitte versuchen Sie es erneut.", + }; + } + + const { course } = outcome; + + // Wer gebucht hat, steht nicht mehr auf der Warteliste dieses Kurses. + await prisma.courseWaitlistEntry + .deleteMany({ where: { courseId, userId: userData.id } }) + .catch((err) => console.error("Warteliste-Aufräumen fehlgeschlagen:", err)); + + const baseUrl = process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000"; + const mail = generateBookingConfirmationEmail({ + courseName: course.name, + courseDate: course.date, + timeFrom: course.timeFrom, + timeTo: course.timeTo, + businessName: course.business!.name, + businessAddress: course.business!.address, + amountPaidCents: 0, + seats, + spotLabels: outcome.spotLabels, + paidWithPass: { + passName: outcome.passName, + creditsUsed: seats, + creditsRemaining: outcome.creditsRemaining, + }, + myCoursesUrl: `${baseUrl}/courses/myCourses`, + coverImageUrl: course.coverImage, + companyName: process.env.COMPANY_NAME, + branding: toEmailBranding(course.business!), + }); + await withTimeout( + sendMail({ to: userData.email, ...mail }).catch((err) => + console.error("Buchungs-Bestätigungs-Mail fehlgeschlagen:", err) + ), + 2000 + ); + + await withTimeout( + notifyManagerOfBooking( + courseId, + userData.name?.trim() || userData.email, + seats, + outcome.passName + ).catch((err) => console.error("Buchungs-Benachrichtigung fehlgeschlagen:", err)), + 2000 + ); + + if (course.businessId) updateTag(`business-courses-${course.businessId}`); + updateTag(`user-bookings-${userData.id}`); + updateTag(`kunde-detail-${userData.id}`); + revalidatePath("/courses/myCourses"); + revalidatePath("/courses/myCards"); + + return { success: true, seats, creditsRemaining: outcome.creditsRemaining }; +} + +/** + * Serializable-Transaktionen können bei gleichzeitigen Buchungen mit P2034 + * abbrechen - dann einfach erneut versuchen, wie im Stripe-Webhook. + */ +async function runWithSerializationRetry(fn: () => Promise, attempts = 3): Promise { + for (let attempt = 1; ; attempt++) { + try { + return await fn(); + } catch (error) { + const isSerializationError = + error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2034"; + if (!isSerializationError || attempt >= attempts) throw error; + await new Promise((resolve) => + setTimeout(resolve, 50 * 2 ** (attempt - 1) + Math.random() * 30) + ); + } + } +} diff --git a/src/modules/products/actions/product-actions.ts b/src/modules/products/actions/product-actions.ts new file mode 100644 index 0000000..b203a73 --- /dev/null +++ b/src/modules/products/actions/product-actions.ts @@ -0,0 +1,227 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { Prisma } from "@/src/generated/prisma/client"; +import { prisma } from "@/src/lib/prisma"; +import { requireManager } from "@/src/lib/auth/getUser"; +import { KUMASY_BRANDING } from "@/src/lib/mail/templates/tokens"; +import { businessBrandingSchema } from "@/src/modules/settings/schemas/business-branding-schema"; +import { parseCardDesign, type CardDesign } from "../lib/card-design"; +import { productSchema, type ProductInput } from "../schemas/product-schema"; + +export type ProductDTO = { + id: string; + name: string; + description: string | null; + credits: number; + priceCents: number; + validityMonths: number | null; + sports: string[]; + design: CardDesign; + isActive: boolean; + /** Verkaufte und vergebene Karten - ab 1 lässt sich das Produkt nicht mehr löschen. */ + passCount: number; +}; + +/** Was die Karte vom Branding des Studios braucht: Logo, Name und die Startfarben. */ +export type CardBranding = { + companyName: string; + logoUrl: string | null; + primaryColor: string; + accentColor: string; +}; + +export type ProductsPageData = { + products: ProductDTO[]; + branding: CardBranding; + /** Kursarten aus den Kursen des Studios, zur Auswahl "Gilt für". */ + sports: string[]; +}; + +const PRODUCT_SELECT = { + id: true, + name: true, + description: true, + credits: true, + priceCents: true, + validityMonths: true, + sports: true, + design: true, + isActive: true, + _count: { select: { passes: true } }, +} satisfies Prisma.ProductSelect; + +type ProductRow = Prisma.ProductGetPayload<{ select: typeof PRODUCT_SELECT }>; + +function toDTO({ _count, ...row }: ProductRow): ProductDTO { + return { ...row, design: parseCardDesign(row.design), passCount: _count.passes }; +} + +async function findOwnedBusiness(businessId: string, managerId: string) { + const business = await prisma.business.findUnique({ + where: { id: businessId }, + select: { managerId: true, name: true, branding: true }, + }); + return business && business.managerId === managerId ? business : null; +} + +function validationError(error: { issues: { message: string }[] }): string { + return error.issues[0]?.message ?? "Ungültige Eingabe."; +} + +export async function getProductsPageData( + businessId: string +): Promise<{ success: true; data: ProductsPageData } | { success: false; error: 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(); + + try { + const business = await findOwnedBusiness(businessId, manager.id); + if (!business) { + return { success: false, error: "Keine Berechtigung für dieses Business." }; + } + + const [products, courses] = await Promise.all([ + prisma.product.findMany({ + where: { businessId }, + select: PRODUCT_SELECT, + orderBy: { createdAt: "asc" }, + }), + prisma.course.findMany({ where: { businessId }, select: { sport: true } }), + ]); + + // Die Karte ist nur eine Vorschau des Brandings - ein unlesbarer Wert soll + // die Produktseite nicht blockieren, dann gelten Business-Name und Standardfarben. + const parsedBranding = businessBrandingSchema.safeParse(business.branding); + const branding: CardBranding = parsedBranding.success + ? parsedBranding.data + : { + companyName: business.name, + logoUrl: null, + primaryColor: KUMASY_BRANDING.primaryColor, + accentColor: KUMASY_BRANDING.accentColor, + }; + + // Kursarten, die in Produkten stehen, aber in keinem Kurs mehr vorkommen, + // bleiben auswählbar - sonst verschwänden sie still aus der Auswahl. + const sports = [ + ...new Set([...courses.flatMap((c) => c.sport), ...products.flatMap((p) => p.sports)]), + ].sort((a, b) => a.localeCompare(b, "de")); + + return { success: true, data: { products: products.map(toDTO), branding, sports } }; + } catch (error) { + console.error("Error fetching products:", error); + return { success: false, error: "Produkte konnten nicht geladen werden." }; + } +} + +export async function createProduct( + businessId: string, + input: ProductInput +): Promise<{ success: true; product: ProductDTO } | { success: false; error: string }> { + const manager = await requireManager(); + + try { + if (!(await findOwnedBusiness(businessId, manager.id))) { + return { success: false, error: "Keine Berechtigung für dieses Business." }; + } + + const parsed = productSchema.safeParse(input); + if (!parsed.success) { + return { success: false, error: validationError(parsed.error) }; + } + + const created = await prisma.product.create({ + data: { + ...parsed.data, + design: parsed.data.design as Prisma.InputJsonValue, + businessId, + }, + select: PRODUCT_SELECT, + }); + + revalidatePath("/settings/products"); + + return { success: true, product: toDTO(created) }; + } catch (error) { + console.error("Error creating product:", error); + return { success: false, error: "Karte konnte nicht erstellt werden." }; + } +} + +export async function updateProduct( + businessId: string, + productId: string, + input: ProductInput +): Promise<{ success: true; product: ProductDTO } | { success: false; error: string }> { + const manager = await requireManager(); + + try { + if (!(await findOwnedBusiness(businessId, manager.id))) { + return { success: false, error: "Keine Berechtigung für dieses Business." }; + } + + const parsed = productSchema.safeParse(input); + if (!parsed.success) { + return { success: false, error: validationError(parsed.error) }; + } + + const existing = await prisma.product.findFirst({ + where: { id: productId, businessId }, + select: { id: true }, + }); + if (!existing) { + return { success: false, error: "Karte nicht gefunden." }; + } + + const updated = await prisma.product.update({ + where: { id: productId }, + data: { ...parsed.data, design: parsed.data.design as Prisma.InputJsonValue }, + select: PRODUCT_SELECT, + }); + + revalidatePath("/settings/products"); + + return { success: true, product: toDTO(updated) }; + } catch (error) { + console.error("Error updating product:", error); + return { success: false, error: "Karte konnte nicht gespeichert werden." }; + } +} + +export async function deleteProduct( + businessId: string, + productId: string +): Promise<{ success: true } | { success: false; error: string }> { + const manager = await requireManager(); + + try { + if (!(await findOwnedBusiness(businessId, manager.id))) { + return { success: false, error: "Keine Berechtigung für dieses Business." }; + } + + // Verkaufte oder vergebene Karten verweisen auf das Produkt (Design, Herkunft). + // Dann bleibt es bestehen und wird nur aus dem Verkauf genommen. + const sold = await prisma.customerPass.count({ where: { productId, businessId } }); + if (sold > 0) { + return { + success: false, + error: + "Diese Karte wurde schon verkauft oder vergeben und kann nicht gelöscht werden. Nimm sie stattdessen aus dem Verkauf.", + }; + } + + const { count } = await prisma.product.deleteMany({ where: { id: productId, businessId } }); + if (count === 0) { + return { success: false, error: "Karte nicht gefunden." }; + } + + revalidatePath("/settings/products"); + + return { success: true }; + } catch (error) { + console.error("Error deleting product:", error); + return { success: false, error: "Karte konnte nicht gelöscht werden." }; + } +} diff --git a/src/modules/products/lib/card-design.ts b/src/modules/products/lib/card-design.ts new file mode 100644 index 0000000..f67fd58 --- /dev/null +++ b/src/modules/products/lib/card-design.ts @@ -0,0 +1,105 @@ +import { z } from "zod"; +import { KUMASY_BRANDING } from "@/src/lib/mail/templates/tokens"; +import { isAllowedLogoUrl } from "@/src/modules/settings/lib/logo-url"; +import { getContrastTextColorForAll } from "@/src/modules/settings/lib/contrast"; + +/** + * Kartendesign eines Produkts (Product.design). + * + * Bewusst kein freier Editor: Die Anordnung auf der Karte ist fest, der Manager + * wählt nur eine Vorlage, zwei Farben und bei "image" ein eigenes Bild. So sieht + * jede Karte ordentlich aus, egal was gewählt wird, und die Schriftfarbe ergibt + * sich automatisch aus dem Kontrast. + */ +export const CARD_STYLES = ["gradient", "solid", "shapes", "image"] as const; +export type CardStyle = (typeof CARD_STYLES)[number]; + +export const CARD_STYLE_LABELS: Record = { + gradient: "Verlauf", + solid: "Vollfarbe", + shapes: "Formen", + image: "Eigenes Bild", +}; + +const hexColorSchema = z.string().regex(/^#[0-9a-fA-F]{6}$/, "Ungültige Farbe"); + +export const cardDesignSchema = z + .object({ + style: z.enum(CARD_STYLES), + color: hexColorSchema, + secondaryColor: hexColorSchema, + imageUrl: z + .string() + .url() + .refine(isAllowedLogoUrl, "Bild muss aus dem eigenen Storage stammen") + .nullable(), + }) + .refine((design) => design.style !== "image" || design.imageUrl !== null, { + path: ["imageUrl"], + message: "Bitte lade ein Bild für die Karte hoch", + }); + +export type CardDesign = z.infer; + +/** Startwert für neue Karten: ein Verlauf aus den Branding-Farben des Studios. */ +export function defaultCardDesign(colors: { + primaryColor: string; + accentColor: string; +}): CardDesign { + return { + style: "gradient", + color: colors.primaryColor, + secondaryColor: colors.accentColor, + imageUrl: null, + }; +} + +/** + * Liest ein gespeichertes Design. Ein unlesbarer Wert fällt auf das + * Kumasy-Standarddesign zurück, damit die Karte trotzdem angezeigt wird. + */ +export function parseCardDesign(value: unknown): CardDesign { + const parsed = cardDesignSchema.safeParse(value); + return parsed.success + ? parsed.data + : defaultCardDesign({ + primaryColor: KUMASY_BRANDING.primaryColor, + accentColor: KUMASY_BRANDING.accentColor, + }); +} + +/** CSS-Hintergrund der Karte. */ +export function cardBackground(design: CardDesign): string { + const { color, secondaryColor } = design; + switch (design.style) { + case "solid": + return color; + case "gradient": + return `linear-gradient(135deg, ${color} 0%, ${secondaryColor} 100%)`; + case "shapes": + // Zwei große, angeschnittene Kreise in der Zweitfarbe auf der Hauptfarbe. + return [ + `radial-gradient(circle at 100% 0%, ${secondaryColor} 0 34%, transparent 34.5%)`, + `radial-gradient(circle at 8% 115%, ${secondaryColor}b3 0 38%, transparent 38.5%)`, + color, + ].join(", "); + case "image": + // Die Abdunklung hält weiße Schrift auf jedem Foto lesbar. + return design.imageUrl + ? `linear-gradient(rgba(0, 0, 0, 0.3), rgba(0, 0, 0, 0.55)), center / cover no-repeat url(${JSON.stringify(design.imageUrl)}), ${color}` + : color; + } +} + +/** Schriftfarbe, die auf allen Flächen der Karte lesbar ist. */ +export function cardTextColor(design: CardDesign): string { + switch (design.style) { + case "solid": + return getContrastTextColorForAll([design.color]); + case "gradient": + case "shapes": + return getContrastTextColorForAll([design.color, design.secondaryColor]); + case "image": + return design.imageUrl ? "#FFFFFF" : getContrastTextColorForAll([design.color]); + } +} diff --git a/src/modules/products/lib/card-format.ts b/src/modules/products/lib/card-format.ts new file mode 100644 index 0000000..f8feb3c --- /dev/null +++ b/src/modules/products/lib/card-format.ts @@ -0,0 +1,28 @@ +/** "6 Monate gültig" bzw. "Unbegrenzt gültig" - für Produkte, die noch nicht gekauft sind. */ +export function formatValidity(validityMonths: number | null): string { + if (validityMonths === null) return "Unbegrenzt gültig"; + return `${validityMonths} ${validityMonths === 1 ? "Monat" : "Monate"} gültig`; +} + +const monthYearFormatter = new Intl.DateTimeFormat("de-DE", { + month: "2-digit", + year: "2-digit", + timeZone: "Europe/Berlin", +}); + +/** Ablaufdatum wie auf einer Kreditkarte: "Gültig bis 03/27". */ +export function formatValidUntil(date: Date): string { + const parts = monthYearFormatter.formatToParts(date); + const part = (type: "month" | "year") => parts.find((p) => p.type === type)?.value ?? ""; + return `Gültig bis ${part("month")}/${part("year")}`; +} + +/** Kursart so, wie sie im Kursformular angelegt wurde, mit großem Anfangsbuchstaben. */ +export function formatSport(sport: string): string { + return sport.charAt(0).toUpperCase() + sport.slice(1); +} + +/** "Alle Kurse" oder die Kursarten, für die die Karte gilt. */ +export function formatSports(sports: string[]): string { + return sports.length === 0 ? "Alle Kurse" : sports.map(formatSport).join(", "); +} diff --git a/src/modules/products/lib/card-pricing.ts b/src/modules/products/lib/card-pricing.ts new file mode 100644 index 0000000..c22e202 --- /dev/null +++ b/src/modules/products/lib/card-pricing.ts @@ -0,0 +1,15 @@ +/** + * Servicegebühr einer Karte: die Plattformgebühr pro Einheit mal Anzahl der + * Einheiten. Eine 10er-Karte bringt Kumasy damit genauso viel wie zehn + * Einzelbuchungen - beim Einlösen der Einheiten fließt kein Geld mehr, also + * auch keine Gebühr. Wie bei Einzelbuchungen zahlt der Kunde sie zusätzlich + * zum Kartenpreis. + */ +export function cardServiceFeeCents(credits: number, feePerUnitCents: number): number { + return credits * feePerUnitCents; +} + +/** Preis pro Einheit, auf ganze Cents gerundet - nur zur Anzeige. */ +export function pricePerUnitCents(priceCents: number, credits: number): number { + return credits > 0 ? Math.round(priceCents / credits) : 0; +} diff --git a/src/modules/products/lib/pass-queries.ts b/src/modules/products/lib/pass-queries.ts new file mode 100644 index 0000000..5ea8831 --- /dev/null +++ b/src/modules/products/lib/pass-queries.ts @@ -0,0 +1,124 @@ +import { prisma } from "@/src/lib/prisma"; +import { businessBrandingSchema } from "@/src/modules/settings/schemas/business-branding-schema"; +import { parseCardDesign, type CardDesign } from "./card-design"; +import { canRedeemPass, passCoversCourse } from "./pass-rules"; + +/** + * Eine Karte so, wie Kunde und Buchungsdialog sie sehen - mit allem, was die + * Kartenansicht (PassCard) braucht. + */ +export type CustomerPassView = { + id: string; + name: string; + creditsTotal: number; + creditsRemaining: number; + validUntil: string | null; // ISO + status: string; + sports: string[]; + design: CardDesign; + studioName: string; + studioSlug: string | null; + logoUrl: string | null; +}; + +const passViewSelect = { + id: true, + name: true, + creditsTotal: true, + creditsRemaining: true, + validUntil: true, + status: true, + sports: true, + createdAt: true, + product: { select: { design: true } }, + business: { select: { name: true, slug: true, branding: true } }, +} as const; + +type PassViewRow = { + id: string; + name: string; + creditsTotal: number; + creditsRemaining: number; + validUntil: Date | null; + status: string; + sports: string[]; + product: { design: unknown }; + business: { name: string; slug: string | null; branding: unknown }; +}; + +export function toCustomerPassView(row: PassViewRow): CustomerPassView { + const branding = businessBrandingSchema.safeParse(row.business.branding); + return { + id: row.id, + name: row.name, + creditsTotal: row.creditsTotal, + creditsRemaining: row.creditsRemaining, + validUntil: row.validUntil?.toISOString() ?? null, + status: row.status, + sports: row.sports, + design: parseCardDesign(row.product.design), + studioName: branding.success ? branding.data.companyName : row.business.name, + studioSlug: row.business.slug, + logoUrl: branding.success ? branding.data.logoUrl : null, + }; +} + +/** + * Hängt Karten, die ein Studio an diese E-Mail-Adresse vergeben hat, an das + * Konto. Läuft beim Öffnen von "Meine Karten" und des Buchungsdialogs - die + * Adresse ist durch den Login bestätigt. + */ +export async function claimPassesForUser(user: { id: string; email: string }): Promise { + await prisma.customerPass.updateMany({ + where: { userId: null, email: user.email.toLowerCase() }, + data: { userId: user.id }, + }); +} + +/** Alle Karten eines Kunden, neueste zuerst. */ +export async function findPassesForUser(user: { + id: string; + email: string; +}): Promise { + await claimPassesForUser(user); + const rows = await prisma.customerPass.findMany({ + where: { userId: user.id }, + select: passViewSelect, + orderBy: { createdAt: "desc" }, + }); + return rows.map(toCustomerPassView); +} + +/** + * Karten, mit denen der Kunde diesen Kurs jetzt buchen kann: vom selben Studio, + * aktiv, nicht abgelaufen, mit passender Kursart und mindestens einer freien + * Einheit. Die zuerst ablaufende Karte steht vorn - die soll zuerst verbraucht werden. + */ +export async function findUsablePassesForCourse( + user: { id: string; email: string }, + course: { businessId: string | null; sport: string[] } +): Promise { + if (!course.businessId) return []; + await claimPassesForUser(user); + + const now = new Date(); + const rows = await prisma.customerPass.findMany({ + where: { + userId: user.id, + businessId: course.businessId, + status: "active", + creditsRemaining: { gt: 0 }, + OR: [{ validUntil: null }, { validUntil: { gt: now } }], + }, + select: passViewSelect, + }); + + return rows + .filter((row) => canRedeemPass(row, 1, now) && passCoversCourse(row.sports, course.sport)) + .sort( + (a, b) => + (a.validUntil?.getTime() ?? Number.POSITIVE_INFINITY) - + (b.validUntil?.getTime() ?? Number.POSITIVE_INFINITY) + ) + .map(toCustomerPassView); +} diff --git a/src/modules/products/lib/pass-rules.ts b/src/modules/products/lib/pass-rules.ts new file mode 100644 index 0000000..ea4d14c --- /dev/null +++ b/src/modules/products/lib/pass-rules.ts @@ -0,0 +1,75 @@ +import { addMonths } from "date-fns"; + +/** + * Regeln für gekaufte Karten (CustomerPass). Bewusst ohne Datenbank, damit + * Buchen, Anzeige und Kunden-Detail dieselben Entscheidungen treffen. + */ + +type PassState = { + status: string; + validUntil: Date | null; + creditsRemaining: number; +}; + +/** Ablaufdatum beim Kauf oder Vergeben. null = unbegrenzt gültig. */ +export function validUntilFrom(start: Date, validityMonths: number | null): Date | null { + return validityMonths === null ? null : addMonths(start, validityMonths); +} + +/** Gilt die Karte für einen Kurs mit diesen Kursarten? Leere Kursarten auf der Karte = alle Kurse. */ +export function passCoversCourse(passSports: string[], courseSports: string[]): boolean { + return passSports.length === 0 || courseSports.some((sport) => passSports.includes(sport)); +} + +/** Aktiv und nicht abgelaufen - unabhängig davon, ob noch Einheiten übrig sind. */ +export function isPassActive(pass: Pick, now: Date): boolean { + return pass.status === "active" && (pass.validUntil === null || pass.validUntil > now); +} + +/** Lässt sich die Karte jetzt für `seats` Plätze einlösen? */ +export function canRedeemPass(pass: PassState, seats: number, now: Date): boolean { + return isPassActive(pass, now) && pass.creditsRemaining >= seats; +} + +export type PassDisplayStatus = "active" | "used_up" | "expired" | "ended" | "refunded"; + +/** Ein Status für Badge und Sortierung. Erstattet und beendet gehen vor abgelaufen. */ +export function passDisplayStatus(pass: PassState, now: Date): PassDisplayStatus { + if (pass.status === "refunded") return "refunded"; + if (pass.status === "ended") return "ended"; + if (pass.validUntil !== null && pass.validUntil <= now) return "expired"; + if (pass.creditsRemaining <= 0) return "used_up"; + return "active"; +} + +export const PASS_STATUS_LABELS: Record = { + active: "Aktiv", + used_up: "Aufgebraucht", + expired: "Abgelaufen", + ended: "Beendet", + refunded: "Erstattet", +}; + +/** + * Studio-Umsatz aus einem Kartenkauf: was der Kunde gezahlt hat, ohne die + * Servicegebühr. Eine Erstattung geht immer über den vollen Betrag, danach + * bleibt nichts. Vergebene Karten bringen keinen Umsatz. + */ +export function passRevenueCents(pass: { + status: string; + amountPaid: number | null; + platformFee: number | null; +}): number { + if (pass.status === "refunded" || pass.amountPaid === null) return 0; + return pass.amountPaid - (pass.platformFee ?? 0); +} + +/** "14.03.2027" in deutscher Zeit, für Listen und E-Mails. */ +export function formatPassDate(date: Date): string { + return date.toLocaleDateString("de-DE", { + day: "2-digit", + month: "2-digit", + year: "numeric", + timeZone: "Europe/Berlin", + }); +} diff --git a/src/modules/products/lib/pass-webhook.ts b/src/modules/products/lib/pass-webhook.ts new file mode 100644 index 0000000..3e0a185 --- /dev/null +++ b/src/modules/products/lib/pass-webhook.ts @@ -0,0 +1,182 @@ +import type Stripe from "stripe"; +import { Prisma } from "@/src/generated/prisma/client"; +import { prisma } from "@/src/lib/prisma"; +import { stripe, onAccount } from "@/src/lib/stripe"; +import { recordStripeFee } from "@/src/lib/stripe-fee"; +import { refundStripePayment } from "@/src/lib/stripe-refund"; +import { billingAddressFromCheckout } from "@/src/lib/billing-address"; +import { sendMail } from "@/src/lib/mail/resend"; +import { generatePassConfirmationEmail } from "@/src/lib/mail/templates/pass-confirmation"; +import { toEmailBranding } from "@/src/modules/settings/lib/to-email-branding"; +import { notifyBusinessManager } from "@/src/modules/notifications/lib/create-notification"; +import { validUntilFrom } from "./pass-rules"; + +/** Markiert Checkout-Sessions für einen Kartenkauf (siehe api/stripe/checkout/pass). */ +export const PASS_CHECKOUT_KIND = "pass"; + +export function isPassCheckout(session: Stripe.Checkout.Session): boolean { + return session.metadata?.kind === PASS_CHECKOUT_KIND; +} + +async function withTimeout(promise: Promise, ms: number): Promise { + return Promise.race([ + promise, + new Promise((resolve) => setTimeout(() => resolve(null), ms)), + ]); +} + +/** + * Legt die gekaufte Karte an, sobald Stripe die Zahlung meldet - analog zur + * Kursbuchung nie auf der Success-Page. Idempotent über stripeSessionId: eine + * erneute Zustellung derselben Session legt keine zweite Karte an. + * + * Verzögerte Zahlungsarten (SEPA) sind bei checkout.session.completed noch + * "unpaid" - dann passiert nichts, die Karte entsteht mit + * checkout.session.async_payment_succeeded. + */ +export async function handlePassCheckoutCompleted( + session: Stripe.Checkout.Session, + stripeAccountId: string | null +): Promise { + if (session.payment_status !== "paid") { + console.info( + `Kartenkauf ${session.id} ist noch nicht bezahlt (${session.payment_status}) - ` + + `warte auf checkout.session.async_payment_succeeded.` + ); + return; + } + + const existing = await prisma.customerPass.findUnique({ + where: { stripeSessionId: session.id }, + select: { id: true }, + }); + if (existing) return; + + const productId = session.metadata?.productId ?? ""; + const userId = session.metadata?.userId ?? ""; + const paymentIntentId = (session.payment_intent as string | null) ?? null; + const amountPaidCents = session.amount_total ?? 0; + + const [product, user] = await Promise.all([ + prisma.product.findUnique({ + where: { id: productId }, + select: { + id: true, + name: true, + credits: true, + validityMonths: true, + sports: true, + businessId: true, + business: { select: { name: true, branding: true } }, + }, + }), + prisma.user.findUnique({ + where: { id: userId }, + select: { id: true, email: true, name: true }, + }), + ]); + + // Produkt in der Zwischenzeit gelöscht oder Konto weg: Es gibt nichts, woran + // die Karte hängen könnte. Das Geld geht vollständig zurück, Kumasy gibt die + // Gebühr an das Studio zurück - der Fehler liegt nicht beim Studio. + if (!product || !user) { + console.error( + `Kartenkauf ${session.id}: ${!product ? `Produkt ${productId}` : `User ${userId}`} existiert nicht. ` + + `Zahlung wird erstattet.` + ); + if (paymentIntentId) { + const refund = await refundStripePayment({ + paymentIntentId, + stripeAccountId, + refundApplicationFee: true, + }); + if (!refund.success) { + console.error( + `Erstattung für Kartenkauf ${session.id} fehlgeschlagen: ${refund.error}. ` + + `Manuelle Erstattung im Stripe Dashboard nötig.` + ); + } + } + return; + } + + // Ein Fehler hier wird weitergeworfen: Mit 0 als Ersatzwert stünde der Kauf mit + // falscher Gebühr in der DB, und eine erneute Zustellung änderte ihn nicht mehr. + const platformFeeCents = paymentIntentId + ? (( + await stripe.paymentIntents.retrieve(paymentIntentId, undefined, onAccount(stripeAccountId)) + ).application_fee_amount ?? 0) + : 0; + + const billingAddress = billingAddressFromCheckout(session.customer_details); + const now = new Date(); + const validUntil = validUntilFrom(now, product.validityMonths); + + let passId: string; + try { + const pass = await prisma.customerPass.create({ + data: { + productId: product.id, + businessId: product.businessId, + userId: user.id, + email: user.email.toLowerCase(), + name: product.name, + sports: product.sports, + creditsTotal: product.credits, + creditsRemaining: product.credits, + validUntil, + source: "purchase", + priceCents: amountPaidCents - platformFeeCents, + stripeAccountId, + stripeSessionId: session.id, + stripePaymentIntentId: paymentIntentId, + amountPaid: amountPaidCents, + platformFee: platformFeeCents, + ...(billingAddress ? { billingAddress } : {}), + }, + select: { id: true }, + }); + passId = pass.id; + } catch (error) { + // Zwei Zustellungen gleichzeitig: die andere hat die Karte schon angelegt. + if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002") return; + throw error; + } + + if (paymentIntentId && stripeAccountId) { + await recordStripeFee(paymentIntentId, stripeAccountId).catch((err) => + console.error(`Stripe-Gebühr für Kartenkauf ${passId} konnte nicht gelesen werden:`, err) + ); + } + + const baseUrl = process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000"; + const { subject, html, text } = generatePassConfirmationEmail({ + reason: "purchase", + passName: product.name, + businessName: product.business.name, + credits: product.credits, + validUntil, + amountPaidCents, + hasAccount: true, + myCardsUrl: `${baseUrl}/courses/myCards`, + registerUrl: `${baseUrl}/register/user`, + companyName: process.env.COMPANY_NAME, + branding: toEmailBranding(product.business), + }); + await withTimeout( + sendMail({ to: user.email, subject, html, text }).catch((err) => + console.error("Kartenkauf-Mail fehlgeschlagen:", err) + ), + 2000 + ); + + await withTimeout( + notifyBusinessManager(product.businessId, { + type: "pass.sold", + title: `Karte verkauft: ${product.name}`, + body: `${user.name?.trim() || user.email} hat eine ${product.name} gekauft.`, + linkUrl: `/kunden/${user.id}`, + }).catch((err) => console.error("Kartenkauf-Benachrichtigung fehlgeschlagen:", err)), + 2000 + ); +} diff --git a/src/modules/products/schemas/product-schema.ts b/src/modules/products/schemas/product-schema.ts new file mode 100644 index 0000000..5021687 --- /dev/null +++ b/src/modules/products/schemas/product-schema.ts @@ -0,0 +1,36 @@ +import { z } from "zod"; +import { cardDesignSchema } from "../lib/card-design"; + +export const productSchema = z.object({ + name: z.string().trim().min(1, "Bitte gib der Karte einen Namen").max(60, "Maximal 60 Zeichen"), + description: z + .string() + .trim() + .max(300, "Beschreibung: maximal 300 Zeichen") + .nullable() + .transform((value) => value || null), + credits: z + .number({ error: "Bitte gib die Anzahl der Einheiten an" }) + .int("Einheiten müssen eine ganze Zahl sein") + .min(1, "Mindestens 1 Einheit") + .max(100, "Höchstens 100 Einheiten"), + priceCents: z + .number({ error: "Bitte gib einen Preis an" }) + .int() + .min(100, "Der Preis muss mindestens 1,00 € betragen") + .max(500000, "Der Preis darf höchstens 5.000 € betragen"), + validityMonths: z + .number() + .int() + .min(1, "Mindestens 1 Monat gültig") + .max(60, "Höchstens 60 Monate gültig") + .nullable(), + sports: z + .array(z.string().trim().min(1)) + .max(50) + .transform((sports) => [...new Set(sports)]), + design: cardDesignSchema, + isActive: z.boolean(), +}); + +export type ProductInput = z.input; diff --git a/src/modules/products/ui/components/IssuePassDialog.tsx b/src/modules/products/ui/components/IssuePassDialog.tsx new file mode 100644 index 0000000..79ab47a --- /dev/null +++ b/src/modules/products/ui/components/IssuePassDialog.tsx @@ -0,0 +1,216 @@ +"use client"; + +import { useId, useState } from "react"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { addMonths } from "date-fns"; +import { toast } from "sonner"; +import { Button } from "@/src/components/ui/button"; +import { Input } from "@/src/components/ui/input"; +import { Checkbox } from "@/src/components/ui/checkbox"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/src/components/ui/dialog"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/src/components/ui/select"; +import { dayKey } from "@/src/modules/courses/lib/course-days"; +import { issuePass, type PassIssueOption } from "../../actions/pass-admin-actions"; + +interface IssuePassDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + options: PassIssueOption[]; + /** Vorbelegt und gesperrt im Kunden-Detail - leer in der Kundenübersicht. */ + email?: string; +} + +function defaultValidUntil(validityMonths: number | null): string | null { + return validityMonths === null ? null : dayKey(addMonths(new Date(), validityMonths)); +} + +/** + * Karte ohne Zahlung vergeben: z.B. bar im Studio bezahlt oder Restguthaben vom + * alten Anbieter. Das Produkt ist nur die Vorlage (Name, Design, Kursarten) - + * Einheiten und Ablaufdatum sind frei wählbar. + */ +const IssuePassDialog = ({ open, onOpenChange, options, email }: IssuePassDialogProps) => { + const idPrefix = useId(); + const router = useRouter(); + const first = options[0]; + const [productId, setProductId] = useState(first?.id ?? ""); + const [recipient, setRecipient] = useState(email ?? ""); + const [credits, setCredits] = useState(String(first?.credits ?? 10)); + const [validUntil, setValidUntil] = useState( + defaultValidUntil(first?.validityMonths ?? null) + ); + const [reason, setReason] = useState(""); + const [isSaving, setIsSaving] = useState(false); + + const chooseProduct = (id: string) => { + const option = options.find((o) => o.id === id); + setProductId(id); + if (!option) return; + setCredits(String(option.credits)); + setValidUntil(defaultValidUntil(option.validityMonths)); + }; + + const handleSubmit = async () => { + setIsSaving(true); + try { + const result = await issuePass({ + productId, + email: recipient, + credits: credits.trim() === "" ? Number.NaN : Number(credits), + validUntil, + reason, + }); + if (!result.success) { + toast.error(result.error); + return; + } + toast.success("Karte vergeben. Der Kunde bekommt eine E-Mail."); + onOpenChange(false); + router.refresh(); + } catch { + toast.error("Karte konnte nicht vergeben werden."); + } finally { + setIsSaving(false); + } + }; + + return ( + + + + Karte vergeben + + Ohne Zahlung, z.B. bei Barzahlung im Studio oder für Restguthaben vom alten Anbieter. + + + + {options.length === 0 ? ( +

+ Lege zuerst unter{" "} + + Einstellungen → Produkte + {" "} + eine Karte an. Sie dient als Vorlage für Name und Design. +

+ ) : ( +
+
+ + +
+ +
+ + setRecipient(e.target.value)} + disabled={email !== undefined} + placeholder="kunde@beispiel.de" + /> + {email === undefined && ( +

+ Ohne Konto hängt sich die Karte an, sobald sich der Kunde mit dieser Adresse + registriert. +

+ )} +
+ +
+
+ + setCredits(e.target.value)} + /> +
+
+ + setValidUntil(e.target.value || null)} + disabled={validUntil === null} + /> +
+
+ + +
+ + setReason(e.target.value)} + placeholder="z.B. Restguthaben vom alten Anbieter" + /> +
+
+ )} + + + + {options.length > 0 && ( + + )} + +
+
+ ); +}; + +export default IssuePassDialog; diff --git a/src/modules/products/ui/components/KundePassesSection.tsx b/src/modules/products/ui/components/KundePassesSection.tsx new file mode 100644 index 0000000..089b9c3 --- /dev/null +++ b/src/modules/products/ui/components/KundePassesSection.tsx @@ -0,0 +1,459 @@ +"use client"; + +import { useId, useState } from "react"; +import { useRouter } from "next/navigation"; +import { addMonths } from "date-fns"; +import { CalendarClock, History, Minus, Plus, Undo2, XCircle } from "lucide-react"; +import { toast } from "sonner"; +import { Button } from "@/src/components/ui/button"; +import { Input } from "@/src/components/ui/input"; +import { Checkbox } from "@/src/components/ui/checkbox"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/src/components/ui/dialog"; +import { formatPrice } from "@/src/lib/currency"; +import { dayKey } from "@/src/modules/courses/lib/course-days"; +import type { + KundePass, + KundePassAdjustment, +} from "@/src/modules/kunden/actions/kunde-detail-actions"; +import { formatSports, formatValidUntil } from "../../lib/card-format"; +import { PASS_STATUS_LABELS, formatPassDate, passDisplayStatus } from "../../lib/pass-rules"; +import { + adjustPassCredits, + endPass, + refundPass, + setPassValidUntil, + type PassIssueOption, +} from "../../actions/pass-admin-actions"; +import PassCard from "./PassCard"; +import IssuePassDialog from "./IssuePassDialog"; + +type ActionResult = { success: true } | { success: false; error: string }; +type PassAction = "credits" | "validity" | "end" | "refund"; + +interface KundePassesSectionProps { + passes: KundePass[]; + customer: { name: string; email: string }; + issueOptions: PassIssueOption[]; +} + +const units = (n: number) => `${n} ${Math.abs(n) === 1 ? "Einheit" : "Einheiten"}`; + +function describeAdjustment(adjustment: KundePassAdjustment): string { + const delta = adjustment.creditsDelta ?? 0; + switch (adjustment.type) { + case "issue": + return `Vergeben mit ${units(delta)}`; + case "credits": + return delta > 0 ? `${units(delta)} gutgeschrieben` : `${units(-delta)} abgezogen`; + case "extend": + return adjustment.newValidUntil + ? `Gültig bis ${formatPassDate(new Date(adjustment.newValidUntil))}` + : "Unbegrenzt gültig gemacht"; + case "end": + return "Beendet"; + case "refund": + return "Erstattet"; + default: + return adjustment.type; + } +} + +/** Karten des Kunden bei diesem Studio, mit allem, was das Studio daran ändern kann. */ +const KundePassesSection = ({ passes, customer, issueOptions }: KundePassesSectionProps) => { + const router = useRouter(); + const [issueOpen, setIssueOpen] = useState(false); + const [issueKey, setIssueKey] = useState(0); + const [active, setActive] = useState<{ pass: KundePass; action: PassAction } | null>(null); + + const now = new Date(); + + return ( +
+
+ +
+ + {passes.length === 0 ? ( +

Dieser Kunde hat noch keine Karte.

+ ) : ( +
+ {passes.map((pass) => { + const status = passDisplayStatus( + { ...pass, validUntil: pass.validUntil ? new Date(pass.validUntil) : null }, + now + ); + const editable = pass.status === "active"; + return ( +
+ + +
+

+ + {pass.source === "purchase" + ? `Gekauft am ${formatPassDate(new Date(pass.createdAt))}${ + pass.amountPaidCents !== null + ? ` für ${formatPrice(pass.amountPaidCents)}` + : "" + }` + : `Vergeben am ${formatPassDate(new Date(pass.createdAt))}`} + + + {PASS_STATUS_LABELS[status]} + +

+

Gilt für: {formatSports(pass.sports)}

+
+ + {(editable || pass.canRefund) && ( +
+ {editable && ( + <> + + + + )} + {pass.canRefund && ( + + )} + {editable && ( + + )} +
+ )} + + {pass.adjustments.length > 0 && ( +
+ + + Verlauf ({pass.adjustments.length}) + +
    + {pass.adjustments.map((adjustment) => ( +
  • + + {formatPassDate(new Date(adjustment.createdAt))} + {" "} + {describeAdjustment(adjustment)} + {adjustment.reason && ( + · {adjustment.reason} + )} +
  • + ))} +
+
+ )} +
+ ); + })} +
+ )} + + + + {active && ( + setActive(null)} + onDone={() => { + setActive(null); + router.refresh(); + }} + /> + )} +
+ ); +}; + +/** Ein Dialog für die vier Aktionen an einer Karte. */ +const PassActionDialog = ({ + pass, + action, + onClose, + onDone, +}: { + pass: KundePass; + action: PassAction; + onClose: () => void; + onDone: () => void; +}) => { + const idPrefix = useId(); + const [direction, setDirection] = useState<"add" | "remove">("add"); + const [amount, setAmount] = useState("1"); + const [validUntil, setValidUntil] = useState(() => + dayKey(addMonths(pass.validUntil ? new Date(pass.validUntil) : new Date(), 3)) + ); + const [reason, setReason] = useState(""); + const [isSaving, setIsSaving] = useState(false); + + const run = async (fn: () => Promise, successMessage: string) => { + setIsSaving(true); + try { + const result = await fn(); + if (!result.success) { + toast.error(result.error); + return; + } + toast.success(successMessage); + onDone(); + } catch { + toast.error("Das hat nicht geklappt. Bitte versuche es erneut."); + } finally { + setIsSaving(false); + } + }; + + const count = Number(amount); + const submit = () => { + switch (action) { + case "credits": + return run( + () => + adjustPassCredits({ + passId: pass.id, + delta: direction === "add" ? count : -count, + reason, + }), + direction === "add" ? "Einheiten gutgeschrieben." : "Einheiten abgezogen." + ); + case "validity": + return run( + () => setPassValidUntil({ passId: pass.id, validUntil, reason }), + "Ablaufdatum geändert." + ); + case "end": + return run(() => endPass({ passId: pass.id, reason }), "Karte beendet."); + case "refund": + return run(() => refundPass({ passId: pass.id }), "Karte erstattet."); + } + }; + + const titles: Record = { + credits: "Einheiten anpassen", + validity: "Ablaufdatum ändern", + end: "Karte beenden?", + refund: "Karte erstatten?", + }; + + return ( + !open && onClose()}> + + + {titles[action]} + + {pass.name} · noch {units(pass.creditsRemaining)} frei + + + +
+ {action === "credits" && ( + <> +
+ + +
+
+ + setAmount(e.target.value)} + /> +
+ + )} + + {action === "validity" && ( + <> +
+ + setValidUntil(e.target.value || null)} + disabled={validUntil === null} + /> +
+ + + )} + + {action === "end" && ( +

+ Die Karte lässt sich danach nicht mehr einlösen. Bereits gebuchte Kurse bleiben + gebucht, es fließt kein Geld zurück. +

+ )} + + {action === "refund" && ( +

+ Der Kunde bekommt{" "} + + {pass.amountPaidCents !== null + ? formatPrice(pass.amountPaidCents) + : "den Kaufpreis"} + {" "} + vollständig zurück, die Karte wird ungültig. Die Servicegebühr trägst du, wie bei + einer Kursabsage. +

+ )} + + {action !== "refund" && ( +
+ + setReason(e.target.value)} + placeholder={action === "credits" ? "z.B. Kulanz nach Kursausfall" : ""} + /> +
+ )} +
+ + + + + +
+
+ ); +}; + +export default KundePassesSection; diff --git a/src/modules/products/ui/components/PassCard.tsx b/src/modules/products/ui/components/PassCard.tsx new file mode 100644 index 0000000..69c0313 --- /dev/null +++ b/src/modules/products/ui/components/PassCard.tsx @@ -0,0 +1,119 @@ +import Image from "next/image"; +import { cn } from "@/src/lib/utils"; +import { cardBackground, cardTextColor, type CardDesign } from "../../lib/card-design"; + +/** Bis zu so vielen Einheiten zeigt die Karte einzelne Punkte, darüber einen Balken. */ +const MAX_DOTS = 12; + +interface PassCardProps { + design: CardDesign; + studioName: string; + logoUrl: string | null; + title: string; + credits: number; + /** Übrige Einheiten einer gekauften Karte. Ohne Angabe zeigt die Karte das Produkt mit allen Einheiten. */ + remaining?: number; + holderName?: string; + /** Rechts unten, z.B. "Gültig bis 03/27" oder "6 Monate gültig". */ + validity: string; + className?: string; +} + +/** + * Eine Karte im Format einer Kreditkarte (85,6 × 54 mm). Schrift und Abstände + * hängen über Container-Einheiten (cqw) an der Kartenbreite, damit die Karte in + * jeder Größe gleich aussieht - in der Produktliste genauso wie in der Vorschau. + */ +const PassCard = ({ + design, + studioName, + logoUrl, + title, + credits, + remaining, + holderName, + validity, + className, +}: PassCardProps) => { + const left = Math.max(0, Math.min(credits, remaining ?? credits)); + + return ( +
+
+ {/* Lichtreflex wie auf einer echten Karte */} +
+ +
+
+ {logoUrl && ( + + + + )} + {studioName} +
+ +
+

+ {title} +

+
+ + + {remaining === undefined + ? `${credits} ${credits === 1 ? "Einheit" : "Einheiten"}` + : `${left} von ${credits} frei`} + +
+
+ +
+ {holderName} + {validity} +
+
+
+
+ ); +}; + +const CreditMeter = ({ credits, left }: { credits: number; left: number }) => { + if (credits > MAX_DOTS) { + return ( +
+
+
+ ); + } + + return ( +
+ {Array.from({ length: credits }, (_, i) => ( + + ))} +
+ ); +}; + +export default PassCard; diff --git a/src/modules/products/ui/components/ProductEditor.tsx b/src/modules/products/ui/components/ProductEditor.tsx new file mode 100644 index 0000000..54a618b --- /dev/null +++ b/src/modules/products/ui/components/ProductEditor.tsx @@ -0,0 +1,480 @@ +"use client"; + +import { useId, useState } from "react"; +import { addMonths } from "date-fns"; +import { ArrowLeft, ImagePlus, Loader2, Palette, X } from "lucide-react"; +import { toast } from "sonner"; +import { Button } from "@/src/components/ui/button"; +import { Input } from "@/src/components/ui/input"; +import { Switch } from "@/src/components/ui/switch"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/src/components/ui/select"; +import MultiSelectDropdown from "@/src/components/layout/MultiSelectDropdown"; +import { downsizeImage } from "@/src/lib/image/downsize"; +import { formatPrice } from "@/src/lib/currency"; +import { cn } from "@/src/lib/utils"; +import ColorPickerField from "@/src/modules/settings/ui/components/ColorPickerField"; +import { + CARD_STYLES, + CARD_STYLE_LABELS, + cardBackground, + defaultCardDesign, + type CardDesign, +} from "../../lib/card-design"; +import { cardServiceFeeCents, pricePerUnitCents } from "../../lib/card-pricing"; +import { formatSport, formatValidUntil, formatValidity } from "../../lib/card-format"; +import type { CardBranding, ProductDTO } from "../../actions/product-actions"; +import type { ProductInput } from "../../schemas/product-schema"; +import PassCard from "./PassCard"; + +const VALIDITY_OPTIONS = [1, 2, 3, 4, 6, 9, 12, 18, 24]; +const UNLIMITED = "unlimited"; + +interface ProductEditorProps { + businessId: string; + /** null = neue Karte */ + product: ProductDTO | null; + branding: CardBranding; + sports: string[]; + feePerUnitCents: number; + isSaving: boolean; + onSave: (input: ProductInput) => Promise; + onCancel: () => void; +} + +type FormState = { + name: string; + description: string; + credits: string; + price: string; + validity: string; + limitToSports: boolean; + sports: string[]; + design: CardDesign; + isActive: boolean; +}; + +function initialForm(product: ProductDTO | null, branding: CardBranding): FormState { + if (!product) { + return { + name: "", + description: "", + credits: "10", + price: "", + validity: "6", + limitToSports: false, + sports: [], + design: defaultCardDesign(branding), + isActive: true, + }; + } + return { + name: product.name, + description: product.description ?? "", + credits: String(product.credits), + price: String(product.priceCents / 100), + validity: product.validityMonths === null ? UNLIMITED : String(product.validityMonths), + limitToSports: product.sports.length > 0, + sports: product.sports, + design: product.design, + isActive: product.isActive, + }; +} + +/** Leere oder ungültige Eingaben werden zu NaN - die Server-Validierung meldet sie dann. */ +function parseWholeNumber(value: string): number { + return value.trim() === "" ? Number.NaN : Number(value); +} + +function parseCents(value: string): number { + const euros = parseWholeNumber(value.replace(",", ".")); + return Number.isFinite(euros) ? Math.round(euros * 100) : Number.NaN; +} + +const ProductEditor = ({ + businessId, + product, + branding, + sports, + feePerUnitCents, + isSaving, + onSave, + onCancel, +}: ProductEditorProps) => { + const idPrefix = useId(); + const [form, setForm] = useState(() => initialForm(product, branding)); + const [isUploading, setIsUploading] = useState(false); + // ColorPickerField hält seinen Hex-Text selbst - nach "Branding-Farben + // übernehmen" muss es neu starten, sonst zeigt es noch den alten Wert. + const [colorFieldsKey, setColorFieldsKey] = useState(0); + + const setDesign = (patch: Partial) => + setForm((f) => ({ ...f, design: { ...f.design, ...patch } })); + + const credits = parseWholeNumber(form.credits); + const priceCents = parseCents(form.price); + const validityMonths = form.validity === UNLIMITED ? null : Number(form.validity); + const previewCredits = Number.isInteger(credits) && credits >= 1 && credits <= 100 ? credits : 10; + const hasPrice = Number.isFinite(priceCents) && priceCents > 0; + + const uploadImage = async (file: File) => { + setIsUploading(true); + try { + const resized = await downsizeImage(file, 1600); + const formData = new FormData(); + formData.append("file", resized); + formData.append("businessId", businessId); + formData.append("purpose", "card"); + const res = await fetch("/api/upload/business-logo", { method: "POST", body: formData }); + const json = await res.json(); + if (!res.ok) throw new Error(json.error); + setDesign({ style: "image", imageUrl: json.publicUrl }); + } catch (err) { + toast.error(err instanceof Error ? err.message : "Fehler beim Hochladen"); + } finally { + setIsUploading(false); + } + }; + + const handleFileSelected = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + // Auswahl zurücksetzen, damit dieselbe Datei danach erneut gewählt werden kann. + e.target.value = ""; + if (file) void uploadImage(file); + }; + + const handleSave = async () => { + // Leere Auswahl hieße in der Datenbank "alle Kurse" - das wäre das Gegenteil + // von dem, was der Manager gerade eingestellt hat. + if (form.limitToSports && form.sports.length === 0) { + toast.error("Wähle mindestens eine Kursart aus."); + return; + } + await onSave({ + name: form.name, + description: form.description, + credits, + priceCents, + validityMonths, + sports: form.limitToSports ? form.sports : [], + design: form.design, + isActive: form.isActive, + }); + }; + + const sportOptions = sports.map((sport) => ({ value: sport, label: formatSport(sport) })); + const showSecondaryColor = form.design.style === "gradient" || form.design.style === "shapes"; + + return ( +
+ + +
+ {/* Vorschau - auf dem Handy oben, damit man sieht, was man baut */} +
+ +

+ So sehen deine Kunden die Karte nach dem Kauf. +

+
+ +
+ {/* Angebot */} +
+

+ {product ? "Karte bearbeiten" : "Neue Karte"} +

+ +
+ + setForm((f) => ({ ...f, name: e.target.value }))} + placeholder="z.B. 10er-Karte" + maxLength={60} + /> +
+ +
+
+ + setForm((f) => ({ ...f, credits: e.target.value }))} + /> +

1 Einheit = 1 Platz in einem Kurs.

+
+
+ + setForm((f) => ({ ...f, price: e.target.value }))} + placeholder="z.B. 120" + /> + {hasPrice && ( +

+ {formatPrice(pricePerUnitCents(priceCents, previewCredits))} pro Einheit +

+ )} +
+
+ + {feePerUnitCents > 0 && ( +

+ Deine Kunden zahlen beim Kauf zusätzlich{" "} + {formatPrice(cardServiceFeeCents(previewCredits, feePerUnitCents))} Servicegebühr ( + {formatPrice(feePerUnitCents)} pro Einheit). Du bekommst den vollen Kartenpreis. +

+ )} + +
+ + +
+ +
+ +
+ + +
+ {sports.length === 0 && ( +

+ Sobald du Kurse angelegt hast, kannst du die Karte auf einzelne Kursarten + beschränken. +

+ )} + {form.limitToSports && ( + setForm((f) => ({ ...f, sports: values }))} + options={sportOptions} + /> + )} +
+ +
+ +