added card creation and booking functionality

This commit is contained in:
Rajpreet0 2026-10-02 14:30:54 +02:00
parent 86330af16e
commit 7aa887c5aa
85 changed files with 7434 additions and 314 deletions

View file

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

View file

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

View file

@ -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])
}

View file

@ -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 (
<MeineKartenView
passes={passes}
holderName={userData.name?.trim() || userData.email}
justPurchased={purchased === "1"}
/>
);
};
export default MeineKartenPage;
export const metadata = {
title: "Meine Karten",
};

View file

@ -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) => {
<ArrowLeft size={16} />
Zurück zur Übersicht
</Link>
<KundeDetailView kunde={result.kunde} />
<KundeDetailView kunde={result.kunde} issueOptions={issueOptions} />
</div>
);
};

View file

@ -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 (
<Suspense fallback={<KundenLoading />}>
<KundenView initialKunden={result.kunden} />
<KundenView
initialKunden={result.kunden}
issueOptions={issueOptions}
pendingPasses={pendingPasses}
/>
</Suspense>
);
};

View file

@ -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 (
<div className="flex gap-2 text-sm bg-red-50 border border-red-200 rounded-lg p-4 text-red-700">
<AlertTriangle size={16} className="shrink-0 mt-0.5" />
<p>{result.error}</p>
</div>
);
}
return (
<Suspense fallback={<SettingsLoading />}>
<SettingsProductsPageView
businesses={businesses}
initialData={result?.success ? result.data : null}
feePerUnitCents={getPlatformFeeCents()}
/>
</Suspense>
);
}
export const metadata = {
title: "Produkte",
};

View file

@ -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) {

View file

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

View file

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

View file

@ -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());

View file

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

View file

@ -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(<InvoicePdfDocument data={invoiceData} />);
} catch (renderError) {
console.error("Rechnungs-Rendering mit Logo fehlgeschlagen, Retry ohne Logo:", renderError);
buffer = await renderToBuffer(<InvoicePdfDocument data={invoiceData} includeLogo={false} />);
}
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 });
}
}

View file

@ -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) }))
: []
}
/>
);
}

View file

@ -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({
</p>
<p style="margin: 0 0 30px; color: #333333; font-size: 16px; line-height: 1.6;">
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:
</p>
@ -128,7 +144,7 @@ export function generateBookingConfirmationEmail({
],
})}
${renderDivider(renderPriceRow({ label: "Bezahlt", amountFormatted: formattedPrice }))}
${renderDivider(paymentRowHtml)}
<p style="margin: 30px 0 20px; color: #333333; font-size: 16px; line-height: 1.6;">
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!

View file

@ -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 <strong>${safeCourseName}</strong> wurde bestätigt.`,
text: `Ihre Stornierung für den Kurs "${courseName}" wurde bestätigt.`,
},
cancelled_by_manager: {
html: `Ihre Buchung für den Kurs <strong>${safeCourseName}</strong> wurde durch <strong>${safeBusinessName}</strong> storniert.`,
text: `Ihre Buchung für den Kurs "${courseName}" wurde durch ${businessName} storniert.`,
},
course_cancelled: {
html: `leider muss der Kurs <strong>${safeCourseName}</strong> von <strong>${safeBusinessName}</strong> 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 = `
<p style="margin: 0 0 20px; color: #333333; font-size: 16px; line-height: 1.6;">Hallo,</p>
<p style="margin: 0 0 20px; color: #333333; font-size: 16px; line-height: 1.6;">${intro.html}${escapeHtml(seatsLine)}</p>
${renderInfoBox({
rows: [
{ label: "Kurs", valueHtml: safeCourseName },
{ label: "Karte", valueHtml: escapeHtml(passName) },
{
label: creditsReturned ? "Gutgeschrieben" : "Verfallen",
valueHtml: `<strong>${escapeHtml(units)}</strong>`,
},
],
})}
<p style="margin: 0; color: #333333; font-size: 16px; line-height: 1.6;">${escapeHtml(creditsText)}</p>
`;
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 };
}

View file

@ -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."
: `<strong>${safeBusinessName}</strong> 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 = `
<p style="margin: 0 0 20px; color: #333333; font-size: 16px; line-height: 1.6;">Hallo,</p>
<p style="margin: 0 0 30px; color: #333333; font-size: 16px; line-height: 1.6;">${introHtml}</p>
${renderInfoBox({
rows: [
{
label: "Karte",
valueHtml: `<span style="font-size: 18px; font-weight: 600;">${safePassName}</span>`,
},
{ label: "Anbieter", valueHtml: safeBusinessName },
{ label: "Guthaben", valueHtml: creditsLabel },
{ label: "Gültig", valueHtml: escapeHtml(validityLabel) },
],
})}
${
amountPaidCents !== undefined
? renderDivider(
renderPriceRow({ label: "Bezahlt", amountFormatted: formatPrice(amountPaidCents) })
)
: ""
}
<p style="margin: 30px 0 20px; color: #333333; font-size: 16px; line-height: 1.6;">${escapeHtml(howToText)}</p>
${renderButton({ label: ctaLabel, href: ctaUrl, branding })}
<p style="margin: 8px 0 0; color: #1E293B; font-size: 14px; word-break: break-all; text-align: center;">
${sanitizeUrl(ctaUrl)}
</p>
${renderDivider(`
<p style="margin: 0; color: #999999; font-size: 13px; line-height: 1.6;">
Bei Fragen zu Ihrer Karte wenden Sie sich bitte direkt an <strong>${safeBusinessName}</strong>.
</p>
`)}
`;
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 };
}

View file

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

View file

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

View file

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

View file

@ -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<string, { label: string; pbSrc?: string }>;
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);

View file

@ -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<void> {
// 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<CancelBookingResult> {
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,
};
}

View file

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

View file

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

View file

@ -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<void> {
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,
});
}
}

View file

@ -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<string, { label: string; bg: string; text: string }> = {
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<CourseSpotMap | null>(null);
const [isLoadingSpots, setIsLoadingSpots] = useState(false);
const [selectedSpotIds, setSelectedSpotIds] = useState<string[]>([]);
// 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<CustomerPassView[]>([]);
const [selectedPassId, setSelectedPassId] = useState<string | null>(null);
const [highlightPicker, setHighlightPicker] = useState(false);
const spotPickerRef = useRef<HTMLDivElement>(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<string, string | number | string[]> = { courseId: course.id, seats };
@ -677,6 +747,53 @@ export const BookingDialog = ({
{/* Sticky Footer */}
<div className="shrink-0 border-t border-gray-200 bg-white px-4 sm:px-6 py-3 sm:py-4 flex flex-col gap-3">
{/* Bezahlen mit Karte oder einzeln - nur wenn eine passende Karte da ist */}
{!isAdminView && !isCheckingStatus && !isFull && !isBooked && passes.length > 0 && (
<div role="radiogroup" aria-label="Bezahlen mit" className="flex flex-col gap-1.5">
<span className="text-sm font-medium text-gray-900">Bezahlen mit</span>
{[...passes.map((pass) => ({ id: pass.id, pass })), { id: null, pass: null }].map(
({ id, pass }) => {
const checked = selectedPassId === id;
return (
<button
key={id ?? "single"}
type="button"
role="radio"
aria-checked={checked}
onClick={() => choosePayment(pass)}
className={`flex items-center justify-between gap-3 rounded-lg border px-3 py-2 text-left text-sm transition-colors ${
checked ? "border-gray-900 bg-gray-50" : "border-gray-200 hover:bg-gray-50"
}`}
>
<span className="flex min-w-0 items-center gap-2">
<span
aria-hidden
className={`size-3.5 shrink-0 rounded-full border ${
checked ? "border-[5px] border-gray-900" : "border-gray-300"
}`}
/>
{pass ? (
<>
<WalletCards size={15} className="shrink-0 text-gray-500" />
<span className="truncate font-medium text-gray-900">{pass.name}</span>
</>
) : (
<span className="text-gray-900">Einzeln bezahlen</span>
)}
</span>
<span className="shrink-0 text-xs text-gray-500">
{pass
? `noch ${pass.creditsRemaining} ${
pass.creditsRemaining === 1 ? "Einheit" : "Einheiten"
} frei`
: formattedPrice}
</span>
</button>
);
}
)}
</div>
)}
{/* 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 && (
<div className="flex items-center gap-2">
<input
type="text"
placeholder="Rabattcode"
value={discountInput}
onChange={(e) => {
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"
/>
<Button
variant="outline"
size="sm"
onClick={handleApplyDiscount}
disabled={isCheckingDiscount || !discountInput.trim()}
>
{isCheckingDiscount ? "Prüfe…" : "Anwenden"}
</Button>
</div>
)}
{!isAdminView &&
!isCheckingStatus &&
!isFull &&
!isBooked &&
!showGuestForm &&
!payWithPass && (
<div className="flex items-center gap-2">
<input
type="text"
placeholder="Rabattcode"
value={discountInput}
onChange={(e) => {
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"
/>
<Button
variant="outline"
size="sm"
onClick={handleApplyDiscount}
disabled={isCheckingDiscount || !discountInput.trim()}
>
{isCheckingDiscount ? "Prüfe…" : "Anwenden"}
</Button>
</div>
)}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<div className="flex flex-col">
<span className="text-lg font-bold text-gray-900">
{appliedDiscount ? (
<>
<span className="line-through text-gray-400 font-normal mr-2">
{formattedPrice}
</span>
Preis: {formatEuro(coursePrice + serviceFee * seats)}
</>
) : (
`Preis: ${formattedPrice}`
)}
</span>
{(serviceFee > 0 || seats > 1) && (
{payWithPass && !isAdminView && !isBooked ? (
<>
<span className="text-lg font-bold text-gray-900">
{seats} {seats === 1 ? "Einheit" : "Einheiten"} von Ihrer Karte
</span>
<span className="text-xs text-gray-500">Kein Bezahlen nötig</span>
</>
) : (
<span className="text-lg font-bold text-gray-900">
{appliedDiscount ? (
<>
<span className="line-through text-gray-400 font-normal mr-2">
{formattedPrice}
</span>
Preis: {formatEuro(coursePrice + serviceFee * seats)}
</>
) : (
`Preis: ${formattedPrice}`
)}
</span>
)}
{!(payWithPass && !isAdminView && !isBooked) && (serviceFee > 0 || seats > 1) && (
<span className="text-xs text-gray-500">
{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"}
</Button>
)}
</>

View file

@ -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 <strong>{courseName}</strong>{" "}
{toCancel === 1 ? copy.description : copy.descriptionPlural}
{toCancel === 1 ? description : descriptionPlural}
</>
) : (
<>
Die Buchung für <strong>{courseName}</strong> {copy.description}
Die Buchung für <strong>{courseName}</strong> {description}
</>
)}
</DialogDescription>
@ -241,12 +262,23 @@ function CancelBookingDialogBody({
)}
{loadingPreview && !preview ? (
<p className="text-sm text-gray-500">Berechne Rückerstattung…</p>
<p className="text-sm text-gray-500">Wird berechnet…</p>
) : cancellationBlocked ? (
<div className="flex gap-2 text-sm bg-red-50 border border-red-200 rounded-lg p-3 text-red-700">
<AlertTriangle size={16} className="shrink-0 mt-0.5" />
<p>Stornierungen sind für diese Buchung derzeit nicht möglich.</p>
</div>
) : preview?.pass ? (
preview.pass.creditsReturned ? (
<p className="text-sm text-gray-600">
{copy.passCreditsBack(toCancel, preview.pass.name)}
</p>
) : (
<div className="flex gap-2 text-sm bg-orange-50 border border-orange-200 rounded-lg p-3 text-orange-800">
<AlertTriangle size={16} className="shrink-0 mt-0.5" />
<p>{copy.passCreditsForfeit(toCancel)}</p>
</div>
)
) : preview ? (
isPartialRefund ? (
<div className="flex gap-2 text-sm bg-orange-50 border border-orange-200 rounded-lg p-3 text-orange-800">

View file

@ -25,6 +25,7 @@ const getDiceBearUrl = (seed: string) =>
const PAYMENT_STATUS: Record<string, { label: string; bg: string; text: string }> = {
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", {

View file

@ -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.`}
</p>
</div>
{products.length > 0 && (
<PublicPassShop
products={products}
studioName={business.name}
studioSlug={business.slug}
logoUrl={business.logoUrl}
accentColor={business.accentColor}
accentTextColor={accentTextColor}
feePerUnitCents={serviceFeeCents}
/>
)}
</main>
{deepLinkCourse && (

View file

@ -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<typeof passRevenueCents>[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 } },

View file

@ -11,6 +11,7 @@ import DashboardSearch from "./DashboardSearch";
const menuTitles: Record<string, string> = {
"/dashboard": "Übersicht",
"/courses/myCourses": "Meine Kurse",
"/courses/myCards": "Meine Karten",
"/courses": "Kurs Übersicht",
"/courses/create": "Erstelle einen Kurs",
"/courses/edit": "Bearbeite den Kurs",

View file

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

View file

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

View file

@ -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<string, { label: string; pbSrc?: string }>;
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}`],

View file

@ -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<string, KundeRow>();
@ -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." };

View file

@ -19,6 +19,7 @@ const getDiceBearUrl = (seed: string) =>
const PAYMENT_STATUS: Record<string, { label: string; bg: string; text: string }> = {
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}
/>

View file

@ -19,6 +19,7 @@ const STATUS_STYLES: Record<Kunde["status"], string> = {
"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 {

View file

@ -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<string, string> = {
male: "Männlich",
@ -214,27 +224,18 @@ const KundeDetailView = ({ kunde }: KundeDetailViewProps) => {
)}
</CollapsibleSection>
{/* ── Abonnements & Credits (Dummy) ── */}
{/* ── Karten ── */}
<CollapsibleSection
icon={<CreditCard size={18} />}
title="Abonnements & Credits"
defaultOpen={false}
icon={<WalletCards size={18} />}
title="Karten"
badge={activePassCount}
defaultOpen={kunde.passes.length > 0}
>
<div className="pt-3 flex flex-col gap-2.5">
{[
{ label: "Aktive Abonnements", value: "1 Abo" },
{ label: "Übrige Buchungen", value: "4" },
{ label: "Credits", value: "0" },
].map((row) => (
<div
key={row.label}
className="flex items-center justify-between rounded-xl bg-white/60 border border-white/50 px-4 py-3"
>
<span className="text-sm text-gray-600">{row.label}</span>
<span className="font-semibold text-gray-900 text-sm">{row.value}</span>
</div>
))}
</div>
<KundePassesSection
passes={kunde.passes}
customer={{ name: kunde.name, email: kunde.email }}
issueOptions={issueOptions}
/>
</CollapsibleSection>
{/* ── Benachrichtigungen (Dummy) ── */}

View file

@ -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) => {
<div className="p-2">
<div className="flex flex-col gap-3 mb-6 sm:flex-row sm:items-center sm:justify-between">
<h1 className="text-4xl font-bold">Kunden</h1>
<div className="relative w-full sm:w-60">
<Search
className="absolute left-3 top-1/2 -translate-y-1/2 z-10 pointer-events-none text-muted-foreground"
size={16}
/>
<Input
placeholder="Suchen"
className="pl-9 bg-white/60 backdrop-blur-xl border-white/40 supports-backdrop-filter:bg-white/50 placeholder:text-gray-400 focus-visible:ring-white/40 focus-visible:bg-white/80"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
<div className="flex w-full flex-col gap-3 sm:w-auto sm:flex-row sm:items-center">
<Button
variant="outline"
className="bg-white/60 backdrop-blur-xl"
onClick={() => {
setIssueKey((k) => k + 1);
setIssueOpen(true);
}}
>
<WalletCards size={16} className="mr-1.5" />
Karte vergeben
</Button>
<div className="relative w-full sm:w-60">
<Search
className="absolute left-3 top-1/2 -translate-y-1/2 z-10 pointer-events-none text-muted-foreground"
size={16}
/>
<Input
placeholder="Suchen"
className="pl-9 bg-white/60 backdrop-blur-xl border-white/40 supports-backdrop-filter:bg-white/50 placeholder:text-gray-400 focus-visible:ring-white/40 focus-visible:bg-white/80"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
</div>
</div>
<KundenTable kunden={filtered} />
{pendingPasses.length > 0 && (
<section className="mt-8">
<h2 className="text-xl font-semibold">Vergebene Karten ohne Konto</h2>
<p className="mb-3 mt-1 text-sm text-muted-foreground">
Sobald sich jemand mit dieser E-Mail-Adresse registriert, erscheint die Karte in seinem
Konto.
</p>
<ul className="divide-y divide-white/40 overflow-hidden rounded-xl border border-white/40 bg-white/60 backdrop-blur-xl">
{pendingPasses.map((pass) => (
<li
key={pass.id}
className="flex flex-wrap items-center justify-between gap-2 px-4 py-3 text-sm"
>
<span className="font-medium">{pass.email}</span>
<span className="text-muted-foreground">
{pass.name} · {pass.creditsRemaining}{" "}
{pass.creditsRemaining === 1 ? "Einheit" : "Einheiten"}
{pass.validUntil && ` · bis ${formatPassDate(new Date(pass.validUntil))}`} ·
vergeben am {formatPassDate(new Date(pass.createdAt))}
</span>
</li>
))}
</ul>
</section>
)}
<IssuePassDialog
key={issueKey}
open={issueOpen}
onOpenChange={setIssueOpen}
options={issueOptions}
/>
</div>
);
};

View file

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

View file

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

View file

@ -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<typeof issueSchema>;
/** 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<ActionResult> {
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<ActionResult> {
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<ActionResult> {
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<ActionResult> {
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<ActionResult> {
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<PassIssueOption[]> {
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<PendingPass[]> {
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(),
}));
}

View file

@ -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<T>(promise: Promise<T>, ms: number): Promise<T | null> {
return Promise.race([
promise,
new Promise<null>((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<BookWithPassResult> {
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<ReturnType<typeof run>>;
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<T>(fn: () => Promise<T>, attempts = 3): Promise<T> {
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)
);
}
}
}

View file

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

View file

@ -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<CardStyle, string> = {
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<typeof cardDesignSchema>;
/** 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]);
}
}

View file

@ -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(", ");
}

View file

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

View file

@ -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<void> {
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<CustomerPassView[]> {
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<CustomerPassView[]> {
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);
}

View file

@ -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<PassState, "status" | "validUntil">, 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<PassDisplayStatus, string> = {
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",
});
}

View file

@ -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<T>(promise: Promise<T>, ms: number): Promise<T | null> {
return Promise.race([
promise,
new Promise<null>((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<void> {
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
);
}

View file

@ -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<typeof productSchema>;

View file

@ -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<string | null>(
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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Karte vergeben</DialogTitle>
<DialogDescription>
Ohne Zahlung, z.B. bei Barzahlung im Studio oder für Restguthaben vom alten Anbieter.
</DialogDescription>
</DialogHeader>
{options.length === 0 ? (
<p className="text-sm text-gray-600">
Lege zuerst unter{" "}
<Link href="/settings/products" className="font-medium underline">
Einstellungen → Produkte
</Link>{" "}
eine Karte an. Sie dient als Vorlage für Name und Design.
</p>
) : (
<div className="space-y-4">
<div className="flex flex-col gap-1.5">
<label className="text-sm font-medium">Karte</label>
<Select value={productId} onValueChange={chooseProduct}>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{options.map((option) => (
<SelectItem key={option.id} value={option.id}>
{option.name}
{!option.isActive && " (nicht im Verkauf)"}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-1.5">
<label htmlFor={`${idPrefix}-email`} className="text-sm font-medium">
E-Mail des Kunden
</label>
<Input
id={`${idPrefix}-email`}
type="email"
value={recipient}
onChange={(e) => setRecipient(e.target.value)}
disabled={email !== undefined}
placeholder="kunde@beispiel.de"
/>
{email === undefined && (
<p className="text-xs text-muted-foreground">
Ohne Konto hängt sich die Karte an, sobald sich der Kunde mit dieser Adresse
registriert.
</p>
)}
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="flex flex-col gap-1.5">
<label htmlFor={`${idPrefix}-credits`} className="text-sm font-medium">
Einheiten
</label>
<Input
id={`${idPrefix}-credits`}
type="number"
min={1}
max={100}
value={credits}
onChange={(e) => setCredits(e.target.value)}
/>
</div>
<div className="flex flex-col gap-1.5">
<label htmlFor={`${idPrefix}-valid`} className="text-sm font-medium">
Gültig bis
</label>
<Input
id={`${idPrefix}-valid`}
type="date"
value={validUntil ?? ""}
min={dayKey(new Date())}
onChange={(e) => setValidUntil(e.target.value || null)}
disabled={validUntil === null}
/>
</div>
</div>
<label className="flex items-center gap-2 text-sm">
<Checkbox
checked={validUntil === null}
onCheckedChange={(checked) =>
setValidUntil(checked === true ? null : dayKey(addMonths(new Date(), 6)))
}
/>
Unbegrenzt gültig
</label>
<div className="flex flex-col gap-1.5">
<label htmlFor={`${idPrefix}-reason`} className="text-sm font-medium">
Grund <span className="font-normal text-muted-foreground">(optional)</span>
</label>
<Input
id={`${idPrefix}-reason`}
value={reason}
maxLength={200}
onChange={(e) => setReason(e.target.value)}
placeholder="z.B. Restguthaben vom alten Anbieter"
/>
</div>
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isSaving}>
Abbrechen
</Button>
{options.length > 0 && (
<Button onClick={handleSubmit} disabled={isSaving || !recipient.trim()}>
{isSaving ? "Wird vergeben…" : "Karte vergeben"}
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
);
};
export default IssuePassDialog;

View file

@ -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 (
<div className="flex flex-col gap-4 pt-3">
<div className="flex justify-end">
<Button
size="sm"
onClick={() => {
setIssueKey((k) => k + 1);
setIssueOpen(true);
}}
>
<Plus size={14} className="mr-1.5" />
Karte vergeben
</Button>
</div>
{passes.length === 0 ? (
<p className="text-sm text-muted-foreground">Dieser Kunde hat noch keine Karte.</p>
) : (
<div className="grid grid-cols-1 gap-5 lg:grid-cols-2 2xl:grid-cols-3">
{passes.map((pass) => {
const status = passDisplayStatus(
{ ...pass, validUntil: pass.validUntil ? new Date(pass.validUntil) : null },
now
);
const editable = pass.status === "active";
return (
<div
key={pass.id}
className="flex flex-col gap-3 rounded-xl border border-white/50 bg-white/60 p-4"
>
<PassCard
design={pass.design}
studioName={pass.studioName}
logoUrl={pass.logoUrl}
title={pass.name}
credits={pass.creditsTotal}
remaining={pass.creditsRemaining}
holderName={customer.name}
validity={
pass.validUntil
? formatValidUntil(new Date(pass.validUntil))
: "Unbegrenzt gültig"
}
className={status === "active" ? undefined : "opacity-60 grayscale"}
/>
<div className="space-y-1 text-xs text-gray-500">
<p className="flex items-center justify-between gap-2">
<span>
{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))}`}
</span>
<span
className={`shrink-0 rounded-full px-2 py-0.5 font-semibold ${
status === "active"
? "bg-green-100 text-green-700"
: "bg-gray-200 text-gray-600"
}`}
>
{PASS_STATUS_LABELS[status]}
</span>
</p>
<p>Gilt für: {formatSports(pass.sports)}</p>
</div>
{(editable || pass.canRefund) && (
<div className="flex flex-wrap gap-2">
{editable && (
<>
<Button
size="sm"
variant="outline"
className="bg-white/70"
onClick={() => setActive({ pass, action: "credits" })}
>
Einheiten anpassen
</Button>
<Button
size="sm"
variant="outline"
className="bg-white/70"
onClick={() => setActive({ pass, action: "validity" })}
>
<CalendarClock size={14} className="mr-1.5" />
Verlängern
</Button>
</>
)}
{pass.canRefund && (
<Button
size="sm"
variant="outline"
className="bg-white/70"
onClick={() => setActive({ pass, action: "refund" })}
>
<Undo2 size={14} className="mr-1.5" />
Erstatten
</Button>
)}
{editable && (
<Button
size="sm"
variant="ghost"
className="text-red-600 hover:bg-red-50 hover:text-red-700"
onClick={() => setActive({ pass, action: "end" })}
>
<XCircle size={14} className="mr-1.5" />
Beenden
</Button>
)}
</div>
)}
{pass.adjustments.length > 0 && (
<details className="group text-xs text-gray-600">
<summary className="flex cursor-pointer list-none items-center gap-1.5 font-medium text-gray-500 hover:text-gray-800">
<History size={13} />
Verlauf ({pass.adjustments.length})
</summary>
<ul className="mt-2 space-y-1.5 border-l border-gray-200 pl-3">
{pass.adjustments.map((adjustment) => (
<li key={adjustment.id}>
<span className="text-gray-400">
{formatPassDate(new Date(adjustment.createdAt))}
</span>{" "}
{describeAdjustment(adjustment)}
{adjustment.reason && (
<span className="text-gray-400"> · {adjustment.reason}</span>
)}
</li>
))}
</ul>
</details>
)}
</div>
);
})}
</div>
)}
<IssuePassDialog
key={issueKey}
open={issueOpen}
onOpenChange={setIssueOpen}
options={issueOptions}
email={customer.email}
/>
{active && (
<PassActionDialog
key={`${active.pass.id}-${active.action}`}
pass={active.pass}
action={active.action}
onClose={() => setActive(null)}
onDone={() => {
setActive(null);
router.refresh();
}}
/>
)}
</div>
);
};
/** 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<string | null>(() =>
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<ActionResult>, 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<PassAction, string> = {
credits: "Einheiten anpassen",
validity: "Ablaufdatum ändern",
end: "Karte beenden?",
refund: "Karte erstatten?",
};
return (
<Dialog open onOpenChange={(open) => !open && onClose()}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{titles[action]}</DialogTitle>
<DialogDescription>
{pass.name} · noch {units(pass.creditsRemaining)} frei
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
{action === "credits" && (
<>
<div className="flex gap-2 rounded-xl border border-border p-1">
<Button
type="button"
size="sm"
className="flex-1"
variant={direction === "add" ? "default" : "ghost"}
onClick={() => setDirection("add")}
>
<Plus size={14} className="mr-1" />
Gutschreiben
</Button>
<Button
type="button"
size="sm"
className="flex-1"
variant={direction === "remove" ? "default" : "ghost"}
onClick={() => setDirection("remove")}
>
<Minus size={14} className="mr-1" />
Abziehen
</Button>
</div>
<div className="flex flex-col gap-1.5">
<label htmlFor={`${idPrefix}-amount`} className="text-sm font-medium">
Einheiten
</label>
<Input
id={`${idPrefix}-amount`}
type="number"
min={1}
max={direction === "remove" ? pass.creditsRemaining : 100}
value={amount}
onChange={(e) => setAmount(e.target.value)}
/>
</div>
</>
)}
{action === "validity" && (
<>
<div className="flex flex-col gap-1.5">
<label htmlFor={`${idPrefix}-valid`} className="text-sm font-medium">
Gültig bis einschließlich
</label>
<Input
id={`${idPrefix}-valid`}
type="date"
min={dayKey(new Date())}
value={validUntil ?? ""}
onChange={(e) => setValidUntil(e.target.value || null)}
disabled={validUntil === null}
/>
</div>
<label className="flex items-center gap-2 text-sm">
<Checkbox
checked={validUntil === null}
onCheckedChange={(checked) =>
setValidUntil(checked === true ? null : dayKey(addMonths(new Date(), 3)))
}
/>
Unbegrenzt gültig
</label>
</>
)}
{action === "end" && (
<p className="text-sm text-gray-600">
Die Karte lässt sich danach nicht mehr einlösen. Bereits gebuchte Kurse bleiben
gebucht, es fließt kein Geld zurück.
</p>
)}
{action === "refund" && (
<p className="text-sm text-gray-600">
Der Kunde bekommt{" "}
<strong>
{pass.amountPaidCents !== null
? formatPrice(pass.amountPaidCents)
: "den Kaufpreis"}
</strong>{" "}
vollständig zurück, die Karte wird ungültig. Die Servicegebühr trägst du, wie bei
einer Kursabsage.
</p>
)}
{action !== "refund" && (
<div className="flex flex-col gap-1.5">
<label htmlFor={`${idPrefix}-reason`} className="text-sm font-medium">
Grund{" "}
{action !== "credits" && (
<span className="font-normal text-muted-foreground">(optional)</span>
)}
</label>
<Input
id={`${idPrefix}-reason`}
value={reason}
maxLength={200}
onChange={(e) => setReason(e.target.value)}
placeholder={action === "credits" ? "z.B. Kulanz nach Kursausfall" : ""}
/>
</div>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose} disabled={isSaving}>
Abbrechen
</Button>
<Button
variant={action === "end" || action === "refund" ? "destructive" : "default"}
onClick={() => void submit()}
disabled={
isSaving ||
(action === "credits" && (!Number.isInteger(count) || count < 1 || !reason.trim()))
}
>
{isSaving
? "Speichert…"
: action === "end"
? "Karte beenden"
: action === "refund"
? "Erstatten"
: "Speichern"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
export default KundePassesSection;

View file

@ -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 (
<div className={cn("@container", className)}>
<div
className="relative aspect-[85.6/53.98] overflow-hidden rounded-[4.5cqw] shadow-lg ring-1 ring-black/10"
style={{ background: cardBackground(design), color: cardTextColor(design) }}
>
{/* Lichtreflex wie auf einer echten Karte */}
<div
aria-hidden
className="pointer-events-none absolute inset-0 bg-[radial-gradient(120%_90%_at_0%_0%,rgba(255,255,255,0.22),transparent_55%)]"
/>
<div className="relative flex h-full flex-col justify-between p-[6cqw]">
<div className="flex min-w-0 items-center gap-[2.5cqw]">
{logoUrl && (
<span className="relative size-[9cqw] shrink-0 overflow-hidden rounded-[1.8cqw] bg-white">
<Image
src={logoUrl}
alt=""
fill
sizes="64px"
className="object-contain p-[0.8cqw]"
/>
</span>
)}
<span className="truncate text-[4cqw] font-semibold opacity-90">{studioName}</span>
</div>
<div className="space-y-[2.5cqw]">
<p className="line-clamp-2 text-[7cqw] font-bold leading-tight tracking-tight">
{title}
</p>
<div className="flex items-center justify-between gap-[3cqw]">
<CreditMeter credits={credits} left={left} />
<span className="shrink-0 text-[3.6cqw] font-medium opacity-90">
{remaining === undefined
? `${credits} ${credits === 1 ? "Einheit" : "Einheiten"}`
: `${left} von ${credits} frei`}
</span>
</div>
</div>
<div className="flex items-end justify-between gap-[3cqw] text-[3.4cqw] font-medium uppercase tracking-[0.12em] opacity-90">
<span className="truncate">{holderName}</span>
<span className="shrink-0">{validity}</span>
</div>
</div>
</div>
</div>
);
};
const CreditMeter = ({ credits, left }: { credits: number; left: number }) => {
if (credits > MAX_DOTS) {
return (
<div className="h-[1.4cqw] min-w-0 flex-1 overflow-hidden rounded-full bg-current/25">
<div
className="h-full rounded-full bg-current"
style={{ width: `${(left / credits) * 100}%` }}
/>
</div>
);
}
return (
<div className="flex items-center gap-[1.4cqw]" aria-hidden>
{Array.from({ length: credits }, (_, i) => (
<span
key={i}
className={cn(
"size-[2.6cqw] rounded-full border-[0.4cqw] border-current",
i < left ? "bg-current" : "opacity-50"
)}
/>
))}
</div>
);
};
export default PassCard;

View file

@ -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<boolean>;
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<CardDesign>) =>
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<HTMLInputElement>) => {
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 (
<div className="space-y-6">
<button
type="button"
onClick={onCancel}
className="flex items-center gap-1.5 text-sm text-gray-500 hover:text-gray-800"
>
<ArrowLeft size={16} />
Zurück zu den Produkten
</button>
<div className="grid gap-8 lg:grid-cols-[minmax(0,1fr)_380px] lg:items-start">
{/* Vorschau - auf dem Handy oben, damit man sieht, was man baut */}
<div className="space-y-3 lg:sticky lg:top-6 lg:order-2">
<PassCard
design={form.design}
studioName={branding.companyName}
logoUrl={branding.logoUrl}
title={form.name.trim() || "10er-Karte"}
credits={previewCredits}
remaining={previewCredits}
holderName="Max Mustermann"
validity={
validityMonths === null
? "Unbegrenzt gültig"
: formatValidUntil(addMonths(new Date(), validityMonths))
}
className="mx-auto max-w-sm"
/>
<p className="text-center text-xs text-muted-foreground">
So sehen deine Kunden die Karte nach dem Kauf.
</p>
</div>
<div className="space-y-6 lg:order-1">
{/* Angebot */}
<section className="space-y-5 rounded-xl border border-border bg-card p-6">
<h2 className="text-base font-semibold">
{product ? "Karte bearbeiten" : "Neue Karte"}
</h2>
<div className="flex flex-col gap-1.5">
<label htmlFor={`${idPrefix}-name`} className="text-sm font-medium">
Name
</label>
<Input
id={`${idPrefix}-name`}
value={form.name}
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
placeholder="z.B. 10er-Karte"
maxLength={60}
/>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="flex flex-col gap-1.5">
<label htmlFor={`${idPrefix}-credits`} className="text-sm font-medium">
Einheiten
</label>
<Input
id={`${idPrefix}-credits`}
type="number"
min={1}
max={100}
step={1}
value={form.credits}
onChange={(e) => setForm((f) => ({ ...f, credits: e.target.value }))}
/>
<p className="text-xs text-muted-foreground">1 Einheit = 1 Platz in einem Kurs.</p>
</div>
<div className="flex flex-col gap-1.5">
<label htmlFor={`${idPrefix}-price`} className="text-sm font-medium">
Preis in €
</label>
<Input
id={`${idPrefix}-price`}
type="number"
min={1}
step={0.01}
value={form.price}
onChange={(e) => setForm((f) => ({ ...f, price: e.target.value }))}
placeholder="z.B. 120"
/>
{hasPrice && (
<p className="text-xs text-muted-foreground">
{formatPrice(pricePerUnitCents(priceCents, previewCredits))} pro Einheit
</p>
)}
</div>
</div>
{feePerUnitCents > 0 && (
<p className="rounded-lg bg-muted px-3 py-2 text-xs text-muted-foreground">
Deine Kunden zahlen beim Kauf zusätzlich{" "}
{formatPrice(cardServiceFeeCents(previewCredits, feePerUnitCents))} Servicegebühr (
{formatPrice(feePerUnitCents)} pro Einheit). Du bekommst den vollen Kartenpreis.
</p>
)}
<div className="flex flex-col gap-1.5">
<label className="text-sm font-medium">Gültigkeit ab Kauf</label>
<Select
value={form.validity}
onValueChange={(value) => setForm((f) => ({ ...f, validity: value }))}
>
<SelectTrigger className="w-full sm:w-64">
<SelectValue />
</SelectTrigger>
<SelectContent>
{VALIDITY_OPTIONS.map((months) => (
<SelectItem key={months} value={String(months)}>
{formatValidity(months)}
</SelectItem>
))}
<SelectItem value={UNLIMITED}>{formatValidity(null)}</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-sm font-medium">Gilt für</label>
<div className="flex gap-2 rounded-xl border border-border p-1">
<Button
type="button"
variant={form.limitToSports ? "ghost" : "default"}
size="sm"
className="flex-1"
onClick={() => setForm((f) => ({ ...f, limitToSports: false }))}
>
Alle Kurse
</Button>
<Button
type="button"
variant={form.limitToSports ? "default" : "ghost"}
size="sm"
className="flex-1"
disabled={sports.length === 0}
onClick={() => setForm((f) => ({ ...f, limitToSports: true }))}
>
Bestimmte Kursarten
</Button>
</div>
{sports.length === 0 && (
<p className="text-xs text-muted-foreground">
Sobald du Kurse angelegt hast, kannst du die Karte auf einzelne Kursarten
beschränken.
</p>
)}
{form.limitToSports && (
<MultiSelectDropdown
label="Kursarten"
selected={form.sports}
onSelect={(values) => setForm((f) => ({ ...f, sports: values }))}
options={sportOptions}
/>
)}
</div>
<div className="flex flex-col gap-1.5">
<label htmlFor={`${idPrefix}-description`} className="text-sm font-medium">
Beschreibung <span className="font-normal text-muted-foreground">(optional)</span>
</label>
<textarea
id={`${idPrefix}-description`}
rows={3}
maxLength={300}
value={form.description}
onChange={(e) => setForm((f) => ({ ...f, description: e.target.value }))}
placeholder="z.B. Ideal zum Reinschnuppern - für alle Matten-Kurse."
className="w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-xs outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50"
/>
</div>
</section>
{/* Design */}
<section className="space-y-5 rounded-xl border border-border bg-card p-6">
<div className="flex items-center justify-between gap-2">
<h2 className="text-base font-semibold">Design</h2>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => {
setDesign({ color: branding.primaryColor, secondaryColor: branding.accentColor });
setColorFieldsKey((k) => k + 1);
}}
>
<Palette size={14} className="mr-1.5" />
Branding-Farben übernehmen
</Button>
</div>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
{CARD_STYLES.map((style) => {
const selected = form.design.style === style;
const previewDesign = { ...form.design, style };
return (
<button
key={style}
type="button"
onClick={() => setDesign({ style })}
aria-pressed={selected}
className={cn(
"flex flex-col gap-2 rounded-lg border p-2 text-left text-xs font-medium transition-colors",
selected
? "border-primary ring-2 ring-primary/30"
: "border-border hover:bg-muted"
)}
>
<span
className="flex aspect-[85.6/53.98] items-center justify-center rounded-md text-white"
style={{ background: cardBackground(previewDesign) }}
>
{style === "image" && !form.design.imageUrl && <ImagePlus size={18} />}
</span>
{CARD_STYLE_LABELS[style]}
</button>
);
})}
</div>
{form.design.style === "image" ? (
<div className="flex flex-wrap items-center gap-3">
<label
className={cn(
"inline-flex h-9 cursor-pointer items-center gap-2 rounded-md border border-input px-3 text-sm font-medium shadow-xs hover:bg-muted has-focus-visible:ring-[3px] has-focus-visible:ring-ring/50",
isUploading && "pointer-events-none opacity-60"
)}
>
{isUploading ? (
<Loader2 size={16} className="animate-spin" />
) : (
<ImagePlus size={16} />
)}
{form.design.imageUrl ? "Anderes Bild wählen" : "Bild hochladen"}
<input
type="file"
accept="image/*"
className="sr-only"
disabled={isUploading}
onChange={handleFileSelected}
/>
</label>
{form.design.imageUrl && (
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setDesign({ imageUrl: null })}
>
<X size={14} className="mr-1" />
Bild entfernen
</Button>
)}
<p className="w-full text-xs text-muted-foreground">
Max. 5 MB. Das Bild wird leicht abgedunkelt, damit die Schrift lesbar bleibt.
</p>
</div>
) : (
<div key={colorFieldsKey} className="flex flex-wrap gap-6">
<ColorPickerField
label="Hauptfarbe"
value={form.design.color}
onChange={(color) => setDesign({ color })}
/>
{showSecondaryColor && (
<ColorPickerField
label="Zweitfarbe"
value={form.design.secondaryColor}
onChange={(secondaryColor) => setDesign({ secondaryColor })}
/>
)}
</div>
)}
</section>
{/* Verkauf */}
<section className="flex items-center justify-between gap-4 rounded-xl border border-border bg-card p-6">
<div>
<label htmlFor={`${idPrefix}-active`} className="text-sm font-medium">
Im Verkauf
</label>
<p className="text-xs text-muted-foreground">
Kunden können die Karte auf deiner öffentlichen Seite kaufen.
</p>
</div>
<Switch
id={`${idPrefix}-active`}
checked={form.isActive}
onCheckedChange={(isActive) => setForm((f) => ({ ...f, isActive }))}
/>
</section>
<div className="flex gap-2">
<Button onClick={handleSave} disabled={isSaving || isUploading}>
{isSaving ? "Speichert…" : product ? "Speichern" : "Karte erstellen"}
</Button>
<Button variant="outline" onClick={onCancel} disabled={isSaving}>
Abbrechen
</Button>
</div>
</div>
</div>
</div>
);
};
export default ProductEditor;

View file

@ -0,0 +1,194 @@
"use client";
import { useState } from "react";
import { CalendarClock, Layers, Pencil, Plus, Trash2, WalletCards } from "lucide-react";
import { Button } from "@/src/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/src/components/ui/dialog";
import { formatPrice } from "@/src/lib/currency";
import { pricePerUnitCents } from "../../lib/card-pricing";
import { formatSports, formatValidity } from "../../lib/card-format";
import type { CardBranding, ProductDTO } from "../../actions/product-actions";
import PassCard from "./PassCard";
interface ProductListProps {
products: ProductDTO[];
branding: CardBranding;
isSaving: boolean;
onCreate: () => void;
onEdit: (product: ProductDTO) => void;
onToggleActive: (product: ProductDTO) => Promise<void>;
onDelete: (product: ProductDTO) => Promise<boolean>;
}
const ProductList = ({
products,
branding,
isSaving,
onCreate,
onEdit,
onToggleActive,
onDelete,
}: ProductListProps) => {
const [productToDelete, setProductToDelete] = useState<ProductDTO | null>(null);
const confirmDelete = async () => {
if (!productToDelete) return;
if (await onDelete(productToDelete)) setProductToDelete(null);
};
if (products.length === 0) {
return (
<div className="flex flex-col items-center gap-3 rounded-xl border border-white/60 bg-white/55 p-8 text-center backdrop-blur-xl">
<div className="flex size-11 items-center justify-center rounded-xl bg-blue-50 text-blue-600">
<WalletCards size={20} />
</div>
<div>
<p className="font-semibold text-gray-900">Noch keine Karten</p>
<p className="mt-1 text-sm text-gray-500">
Lege zum Beispiel eine 10er-Karte an. Deine Kunden kaufen sie einmal und buchen damit
mehrere Kurse.
</p>
</div>
<Button size="sm" onClick={onCreate}>
<Plus size={14} className="mr-1.5" />
Karte erstellen
</Button>
</div>
);
}
return (
<div className="space-y-4">
<div className="flex justify-end">
<Button size="sm" onClick={onCreate}>
<Plus size={14} className="mr-1.5" />
Karte erstellen
</Button>
</div>
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
{products.map((product) => (
<div
key={product.id}
className="space-y-4 rounded-xl border border-white/60 bg-white/55 p-4 backdrop-blur-xl"
>
<PassCard
design={product.design}
studioName={branding.companyName}
logoUrl={branding.logoUrl}
title={product.name}
credits={product.credits}
validity={formatValidity(product.validityMonths)}
className={product.isActive ? undefined : "opacity-60 grayscale"}
/>
<div className="space-y-1.5">
<div className="flex items-start justify-between gap-2">
<p className="text-lg font-bold text-gray-900">
{formatPrice(product.priceCents)}
<span className="ml-1.5 text-xs font-normal text-gray-500">
{formatPrice(pricePerUnitCents(product.priceCents, product.credits))} pro
Einheit
</span>
</p>
<span
className={
product.isActive
? "shrink-0 rounded-full bg-green-100 px-2 py-0.5 text-xs font-semibold text-green-700"
: "shrink-0 rounded-full bg-gray-200 px-2 py-0.5 text-xs font-semibold text-gray-600"
}
>
{product.isActive ? "Im Verkauf" : "Nicht im Verkauf"}
</span>
</div>
<p className="flex items-center gap-1.5 text-xs text-gray-500">
<CalendarClock size={13} className="shrink-0 text-gray-400" />
{formatValidity(product.validityMonths)}
{product.validityMonths !== null && " ab Kauf"}
</p>
<p className="flex items-center gap-1.5 text-xs text-gray-500">
<Layers size={13} className="shrink-0 text-gray-400" />
<span className="truncate">Gilt für: {formatSports(product.sports)}</span>
</p>
{product.passCount > 0 && (
<p className="flex items-center gap-1.5 text-xs text-gray-500">
<WalletCards size={13} className="shrink-0 text-gray-400" />
{product.passCount}× verkauft oder vergeben
</p>
)}
</div>
<div className="flex items-center gap-2">
<Button
size="sm"
variant="outline"
className="bg-white/60"
onClick={() => onEdit(product)}
disabled={isSaving}
>
<Pencil size={14} className="mr-1.5" />
Bearbeiten
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => void onToggleActive(product)}
disabled={isSaving}
>
{product.isActive ? "Aus dem Verkauf nehmen" : "Wieder verkaufen"}
</Button>
{/* Ausgegebene Karten verweisen auf das Produkt - dann bleibt nur
"Aus dem Verkauf nehmen". */}
{product.passCount === 0 && (
<Button
size="icon"
variant="ghost"
className="ml-auto text-red-500 hover:bg-red-50 hover:text-red-600"
onClick={() => setProductToDelete(product)}
disabled={isSaving}
title="Löschen"
>
<Trash2 size={16} />
</Button>
)}
</div>
</div>
))}
</div>
<Dialog
open={productToDelete !== null}
onOpenChange={(open) => {
if (!open) setProductToDelete(null);
}}
>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Karte löschen?</DialogTitle>
<DialogDescription>
„{productToDelete?.name}“ wird dauerhaft gelöscht. Wenn du die Karte nur nicht mehr
verkaufen willst, nimm sie stattdessen aus dem Verkauf.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setProductToDelete(null)} disabled={isSaving}>
Abbrechen
</Button>
<Button variant="destructive" onClick={confirmDelete} disabled={isSaving}>
{isSaving ? "Löscht…" : "Löschen"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
};
export default ProductList;

View file

@ -0,0 +1,145 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { CalendarClock, Layers } from "lucide-react";
import { toast } from "sonner";
import { formatPrice } from "@/src/lib/currency";
import { cardServiceFeeCents, pricePerUnitCents } from "../../lib/card-pricing";
import { formatSports, formatValidity } from "../../lib/card-format";
import type { CardDesign } from "../../lib/card-design";
import PassCard from "./PassCard";
export type PublicProduct = {
id: string;
name: string;
description: string | null;
credits: number;
priceCents: number;
validityMonths: number | null;
sports: string[];
design: CardDesign;
};
interface PublicPassShopProps {
products: PublicProduct[];
studioName: string;
studioSlug: string;
logoUrl: string | null;
accentColor: string;
accentTextColor: string;
feePerUnitCents: number;
}
/**
* Karten zum Kaufen auf der öffentlichen Studio-Seite. Der Preis steht wie bei
* den Kursen als Endpreis inklusive Servicegebühr da. Kaufen geht nur mit
* Konto - ohne Anmeldung geht es zum Login und danach zurück hierher.
*/
const PublicPassShop = ({
products,
studioName,
studioSlug,
logoUrl,
accentColor,
accentTextColor,
feePerUnitCents,
}: PublicPassShopProps) => {
const router = useRouter();
const [buyingId, setBuyingId] = useState<string | null>(null);
const buy = async (productId: string) => {
setBuyingId(productId);
try {
const response = await fetch("/api/stripe/checkout/pass", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ productId }),
});
const data = (await response.json().catch(() => ({}))) as {
url?: string;
error?: string;
loginRequired?: boolean;
};
if (data.loginRequired) {
toast.info("Bitte melden Sie sich an, um eine Karte zu kaufen.");
router.push(`/login?returnTo=${encodeURIComponent(`/business/${studioSlug}`)}`);
return;
}
if (!response.ok || !data.url) {
toast.error(data.error ?? "Der Kauf konnte nicht gestartet werden.");
setBuyingId(null);
return;
}
window.location.assign(data.url);
} catch {
toast.error("Ein unerwarteter Fehler ist aufgetreten.");
setBuyingId(null);
}
};
return (
<section className="mt-6 rounded-3xl bg-white p-4 shadow-xl shadow-gray-900/5 ring-1 ring-gray-900/5 sm:p-6">
<h2 className="text-xl font-semibold tracking-tight text-gray-900 sm:text-2xl">Karten</h2>
<p className="mt-1 text-sm text-gray-500">
Einmal kaufen, mehrfach buchen: Pro Platz wird eine Einheit von Ihrer Karte abgebucht.
</p>
<div className="mt-5 grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
{products.map((product) => {
const feeCents = cardServiceFeeCents(product.credits, feePerUnitCents);
return (
<div key={product.id} className="flex flex-col gap-4">
<PassCard
design={product.design}
studioName={studioName}
logoUrl={logoUrl}
title={product.name}
credits={product.credits}
validity={formatValidity(product.validityMonths)}
/>
<div className="flex flex-1 flex-col gap-1.5 px-1">
<p className="text-lg font-bold text-gray-900">
{formatPrice(product.priceCents + feeCents)}
<span className="ml-1.5 text-xs font-normal text-gray-500">
{formatPrice(pricePerUnitCents(product.priceCents, product.credits))} pro
Einheit
</span>
</p>
{feeCents > 0 && (
<p className="text-xs text-gray-500">
{formatPrice(product.priceCents)} Karte + {formatPrice(feeCents)} Servicegebühr
</p>
)}
<p className="flex items-center gap-1.5 text-xs text-gray-500">
<CalendarClock size={13} className="shrink-0 text-gray-400" />
{formatValidity(product.validityMonths)}
{product.validityMonths !== null && " ab Kauf"}
</p>
<p className="flex items-center gap-1.5 text-xs text-gray-500">
<Layers size={13} className="shrink-0 text-gray-400" />
Gilt für: {formatSports(product.sports)}
</p>
{product.description && (
<p className="text-sm text-gray-600">{product.description}</p>
)}
<button
type="button"
onClick={() => void buy(product.id)}
disabled={buyingId !== null}
className="mt-auto rounded-full px-5 py-2.5 text-sm font-semibold shadow-sm transition-[scale,opacity] duration-150 active:scale-95 disabled:opacity-60"
style={{ backgroundColor: accentColor, color: accentTextColor }}
>
{buyingId === product.id ? "Wird geöffnet…" : "Kaufen"}
</button>
</div>
</div>
);
})}
</div>
</section>
);
};
export default PublicPassShop;

View file

@ -0,0 +1,150 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { ChevronDown, ChevronUp, Info, Layers, RefreshCw, WalletCards } from "lucide-react";
import { Button } from "@/src/components/ui/button";
import { formatSports, formatValidUntil } from "../../lib/card-format";
import { PASS_STATUS_LABELS, passDisplayStatus } from "../../lib/pass-rules";
import type { CustomerPassView } from "../../lib/pass-queries";
import PassCard from "../components/PassCard";
interface MeineKartenViewProps {
passes: CustomerPassView[];
holderName: string;
/** Direkt nach dem Stripe-Checkout - die Karte kann noch unterwegs sein. */
justPurchased: boolean;
}
function toState(pass: CustomerPassView) {
return { ...pass, validUntil: pass.validUntil ? new Date(pass.validUntil) : null };
}
const CustomerPass = ({ pass, holderName }: { pass: CustomerPassView; holderName: string }) => {
const status = passDisplayStatus(toState(pass), new Date());
return (
<div className="flex flex-col gap-3">
<PassCard
design={pass.design}
studioName={pass.studioName}
logoUrl={pass.logoUrl}
title={pass.name}
credits={pass.creditsTotal}
remaining={pass.creditsRemaining}
holderName={holderName}
validity={
pass.validUntil ? formatValidUntil(new Date(pass.validUntil)) : "Unbegrenzt gültig"
}
className={status === "active" ? undefined : "opacity-60 grayscale"}
/>
<div className="flex items-start justify-between gap-2 px-1">
<div className="min-w-0 space-y-1">
<p className="flex items-center gap-1.5 text-xs text-gray-500">
<Layers size={13} className="shrink-0 text-gray-400" />
<span className="truncate">Gilt für: {formatSports(pass.sports)}</span>
</p>
{status !== "active" && (
<span className="inline-block rounded-full bg-gray-200 px-2 py-0.5 text-xs font-semibold text-gray-600">
{PASS_STATUS_LABELS[status]}
</span>
)}
</div>
{status === "active" && pass.studioSlug && (
<Button asChild size="sm" variant="outline" className="shrink-0 bg-white/60">
<Link href={`/business/${pass.studioSlug}`}>Kurse buchen</Link>
</Button>
)}
</div>
</div>
);
};
const MeineKartenView = ({ passes, holderName, justPurchased }: MeineKartenViewProps) => {
const router = useRouter();
const [showInactive, setShowInactive] = useState(false);
const now = new Date();
const active = passes.filter((p) => passDisplayStatus(toState(p), now) === "active");
const inactive = passes.filter((p) => passDisplayStatus(toState(p), now) !== "active");
return (
<div className="flex w-full flex-col gap-5">
<h1 className="text-4xl font-bold">Meine Karten</h1>
{justPurchased && (
<div className="flex flex-col gap-3 rounded-2xl border border-blue-200 bg-blue-50/70 px-4 py-3 text-sm text-blue-900 sm:flex-row sm:items-center sm:justify-between">
<p className="flex items-start gap-2">
<Info size={16} className="mt-0.5 shrink-0" />
Vielen Dank für Ihren Kauf! Ihre Karte erscheint hier, sobald die Zahlung bestätigt ist
- meist nach wenigen Sekunden.
</p>
<Button
size="sm"
variant="outline"
className="shrink-0 bg-white"
onClick={() => router.refresh()}
>
<RefreshCw size={14} className="mr-1.5" />
Aktualisieren
</Button>
</div>
)}
{passes.length === 0 ? (
<div className="flex flex-col items-center gap-3 rounded-2xl border border-white/60 bg-white/55 p-8 text-center backdrop-blur-xl">
<div className="flex size-11 items-center justify-center rounded-xl bg-blue-50 text-blue-600">
<WalletCards size={20} />
</div>
<p className="font-semibold text-gray-900">Noch keine Karten</p>
<p className="max-w-sm text-sm text-gray-500">
Karten wie eine 10er-Karte kaufen Sie direkt auf der Seite Ihres Studios. Danach buchen
Sie damit Kurse, ohne jedes Mal zu bezahlen.
</p>
</div>
) : (
<>
{active.length > 0 ? (
<div className="grid gap-6 sm:grid-cols-2 xl:grid-cols-3">
{active.map((pass) => (
<CustomerPass key={pass.id} pass={pass} holderName={holderName} />
))}
</div>
) : (
<p className="rounded-2xl border border-white/60 bg-white/55 p-4 text-sm text-gray-500 backdrop-blur-xl">
Sie haben gerade keine aktive Karte.
</p>
)}
{inactive.length > 0 && (
<div className="rounded-2xl border border-white/60 bg-white/55 backdrop-blur-xl">
<button
type="button"
aria-expanded={showInactive}
onClick={() => setShowInactive((v) => !v)}
className="flex w-full items-center justify-between px-5 py-4 text-left"
>
<span className="font-semibold text-gray-800">
Abgelaufen &amp; aufgebraucht ({inactive.length})
</span>
{showInactive ? (
<ChevronUp size={18} className="text-gray-400" />
) : (
<ChevronDown size={18} className="text-gray-400" />
)}
</button>
{showInactive && (
<div className="grid gap-6 border-t border-white/40 px-5 pb-5 pt-4 sm:grid-cols-2 xl:grid-cols-3">
{inactive.map((pass) => (
<CustomerPass key={pass.id} pass={pass} holderName={holderName} />
))}
</div>
)}
</div>
)}
</>
)}
</div>
);
};
export default MeineKartenView;

View file

@ -0,0 +1,232 @@
"use client";
import { useRef, useState } from "react";
import { AlertTriangle, WalletCards } from "lucide-react";
import { toast } from "sonner";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/src/components/ui/select";
import {
createProduct,
deleteProduct,
getProductsPageData,
updateProduct,
type ProductDTO,
type ProductsPageData,
} from "../../actions/product-actions";
import type { ProductInput } from "../../schemas/product-schema";
import ProductEditor from "../components/ProductEditor";
import ProductList from "../components/ProductList";
interface SettingsProductsPageViewProps {
businesses: { id: string; name: string }[];
initialData: ProductsPageData | null;
feePerUnitCents: number;
}
/** Wert für "neue Karte" im Editor-Zustand - sonst ist es die bearbeitete Karte. */
const NEW = "new";
function toInput(product: ProductDTO): ProductInput {
return {
name: product.name,
description: product.description,
credits: product.credits,
priceCents: product.priceCents,
validityMonths: product.validityMonths,
sports: product.sports,
design: product.design,
isActive: product.isActive,
};
}
const SettingsProductsPageView = ({
businesses,
initialData,
feePerUnitCents,
}: SettingsProductsPageViewProps) => {
const [businessId, setBusinessId] = useState(businesses[0]?.id ?? "");
const [data, setData] = useState(initialData);
const [loadError, setLoadError] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [editing, setEditing] = useState<ProductDTO | typeof NEW | null>(null);
// Verhindert, dass eine spät ankommende Antwort für ein bereits verlassenes
// Business die Anzeige des inzwischen ausgewählten überschreibt.
const requestSeq = useRef(0);
const handleBusinessChange = async (nextBusinessId: string) => {
const seq = ++requestSeq.current;
setBusinessId(nextBusinessId);
setEditing(null);
setLoadError(null);
setIsLoading(true);
try {
const result = await getProductsPageData(nextBusinessId);
if (seq !== requestSeq.current) return;
if (result.success) {
setData(result.data);
} else {
setLoadError(result.error);
}
} catch {
if (seq === requestSeq.current) setLoadError("Produkte konnten nicht geladen werden.");
} finally {
if (seq === requestSeq.current) setIsLoading(false);
}
};
const replaceProduct = (product: ProductDTO) =>
setData((d) =>
d ? { ...d, products: d.products.map((p) => (p.id === product.id ? product : p)) } : d
);
/** Führt eine Action aus, meldet Fehler per Toast und liefert, ob sie geklappt hat. */
const run = async <T,>(
action: () => Promise<({ success: true } & T) | { success: false; error: string }>,
onSuccess: (result: T) => void,
fallbackError: string
): Promise<boolean> => {
setIsSaving(true);
try {
const result = await action();
if (!result.success) {
toast.error(result.error);
return false;
}
onSuccess(result);
return true;
} catch {
toast.error(fallbackError);
return false;
} finally {
setIsSaving(false);
}
};
const handleSave = (input: ProductInput) => {
if (editing === NEW) {
return run(
() => createProduct(businessId, input),
({ product }: { product: ProductDTO }) => {
setData((d) => (d ? { ...d, products: [...d.products, product] } : d));
setEditing(null);
toast.success("Karte erstellt.");
},
"Karte konnte nicht erstellt werden."
);
}
if (!editing) return Promise.resolve(false);
const productId = editing.id;
return run(
() => updateProduct(businessId, productId, input),
({ product }: { product: ProductDTO }) => {
replaceProduct(product);
setEditing(null);
toast.success("Karte gespeichert.");
},
"Karte konnte nicht gespeichert werden."
);
};
const handleToggleActive = async (product: ProductDTO) => {
await run(
() =>
updateProduct(businessId, product.id, { ...toInput(product), isActive: !product.isActive }),
({ product: updated }: { product: ProductDTO }) => {
replaceProduct(updated);
toast.success(
updated.isActive ? "Karte ist wieder im Verkauf." : "Karte aus dem Verkauf genommen."
);
},
"Karte konnte nicht gespeichert werden."
);
};
const handleDelete = (product: ProductDTO) =>
run(
() => deleteProduct(businessId, product.id),
() => {
setData((d) => (d ? { ...d, products: d.products.filter((p) => p.id !== product.id) } : d));
toast.success("Karte gelöscht.");
},
"Karte konnte nicht gelöscht werden."
);
return (
<div className="space-y-6">
<div>
<h1 className="flex items-center gap-2 text-2xl font-bold">
<WalletCards /> Produkte
</h1>
<p className="mt-1 text-sm text-gray-500">
Verkaufe Karten wie eine 10er-Karte. Deine Kunden kaufen sie auf deiner öffentlichen Seite
und buchen damit Kurse, ohne jedes Mal zu bezahlen.
</p>
</div>
{businesses.length === 0 ? (
<div className="rounded-xl border border-white/60 bg-white/55 p-6 text-center backdrop-blur-xl">
<p className="text-sm text-gray-500">Du hast noch keine Businesses angelegt.</p>
</div>
) : (
<div className="space-y-4">
{businesses.length > 1 && !editing && (
<Select value={businessId} onValueChange={(value) => void handleBusinessChange(value)}>
<SelectTrigger className="w-full border-white/40 bg-white/60 backdrop-blur-xl sm:w-64">
<SelectValue placeholder="Business" />
</SelectTrigger>
<SelectContent>
{businesses.map((b) => (
<SelectItem key={b.id} value={b.id}>
{b.name}
</SelectItem>
))}
</SelectContent>
</Select>
)}
{loadError || !data ? (
<div className="flex gap-2 rounded-lg border border-red-200 bg-red-50 p-4 text-sm text-red-700">
<AlertTriangle size={16} className="mt-0.5 shrink-0" />
<p>
{loadError ?? "Produkte konnten nicht geladen werden."} Bitte lade die Seite neu.
</p>
</div>
) : editing ? (
<ProductEditor
key={editing === NEW ? NEW : editing.id}
businessId={businessId}
product={editing === NEW ? null : editing}
branding={data.branding}
sports={data.sports}
feePerUnitCents={feePerUnitCents}
isSaving={isSaving}
onSave={handleSave}
onCancel={() => setEditing(null)}
/>
) : (
<div className={isLoading ? "opacity-60 transition-opacity" : "transition-opacity"}>
<ProductList
products={data.products}
branding={data.branding}
isSaving={isSaving}
onCreate={() => setEditing(NEW)}
onEdit={setEditing}
onToggleActive={handleToggleActive}
onDelete={handleDelete}
/>
</div>
)}
</div>
)}
</div>
);
};
export default SettingsProductsPageView;

View file

@ -35,12 +35,24 @@ const LIGHT_TEXT = "#FFFFFF";
* (4.5:1) klar verfehlt, weil beide Kandidaten dort ähnlich schlecht sind.
*/
export function getContrastTextColor(hex: string): string {
const match = /^#[0-9a-fA-F]{6}$/.test(hex);
if (!match) return DARK_TEXT;
const backgroundLuminance = getRelativeLuminance(hex);
const darkContrast = contrastRatio(backgroundLuminance, getRelativeLuminance(DARK_TEXT));
const lightContrast = contrastRatio(backgroundLuminance, getRelativeLuminance(LIGHT_TEXT));
return lightContrast >= darkContrast ? LIGHT_TEXT : DARK_TEXT;
return getContrastTextColorForAll([hex]);
}
/**
* Wie getContrastTextColor, aber für Text, der über mehreren Farben liegt
* (z.B. ein Farbverlauf): gewählt wird die Textfarbe, deren SCHLECHTESTER
* Kontrast über alle Hintergründe am besten ist.
*/
export function getContrastTextColorForAll(hexes: string[]): string {
const backgrounds = hexes.filter((hex) => /^#[0-9a-fA-F]{6}$/.test(hex));
if (backgrounds.length === 0) return DARK_TEXT;
const worstContrast = (text: string) =>
Math.min(
...backgrounds.map((hex) =>
contrastRatio(getRelativeLuminance(hex), getRelativeLuminance(text))
)
);
return worstContrast(LIGHT_TEXT) >= worstContrast(DARK_TEXT) ? LIGHT_TEXT : DARK_TEXT;
}

View file

@ -11,6 +11,7 @@ import {
TicketPercent,
TicketX,
User,
WalletCards,
} from "lucide-react";
type SettingsMenuItem = {
@ -55,7 +56,13 @@ const settingsMenuTitles: SettingsMenuItem[] = [
icon: TicketPercent,
name: "Rabatt",
href: "/settings/discounts",
description: "Verwalte deine Rabattaktionen und Pakete",
description: "Verwalte deine Rabattaktionen",
},
{
icon: WalletCards,
name: "Produkte",
href: "/settings/products",
description: "Verkaufe Karten wie eine 10er-Karte",
},
{
icon: Bell,

View file

@ -1,6 +1,6 @@
import { prisma } from "@/src/lib/prisma";
import type { ManagerData } from "@/src/lib/auth/getUser";
import { buildZahlungenWhere } from "../lib/build-zahlungen-where";
import { buildPassSalesWhere, buildZahlungenWhere } from "../lib/build-zahlungen-where";
import type { ZahlungenFilters } from "./zahlungen-actions";
import { toEmailBranding } from "@/src/modules/settings/lib/to-email-branding";
import { courseAmountCents, refundedCourseCents } from "@/src/lib/booking-amounts";
@ -73,20 +73,47 @@ function formatInvoiceNumber(businessId: string, counter: number): string {
* eine zweite zu erzeugen (der gezogene Zählerwert verfällt in diesem Fall ungenutzt,
* das ist für eine reine Lückenfreiheit-vor-Wiederverwendung-Garantie akzeptabel).
*/
/**
* Wofür eine Rechnung entsteht: eine Kursbuchung oder ein Kartenkauf. Beide
* ziehen ihre Nummer aus demselben Zähler des Studios - der Nummernkreis bleibt
* so über beide Arten hinweg lückenlos und eindeutig.
*/
type InvoiceTarget = { kind: "booking" | "pass"; id: string };
type InvoiceTx = Parameters<Parameters<typeof prisma.$transaction>[0]>[0];
type InvoiceStamp = { invoiceNumber: string | null; invoiceIssuedAt: Date | null };
function readInvoiceStamp(tx: InvoiceTx, target: InvoiceTarget): Promise<InvoiceStamp> {
const args = { where: { id: target.id }, select: { invoiceNumber: true, invoiceIssuedAt: true } };
return target.kind === "booking"
? tx.courseBooking.findUniqueOrThrow(args)
: tx.customerPass.findUniqueOrThrow(args);
}
async function claimInvoiceStamp(
tx: InvoiceTx,
target: InvoiceTarget,
data: { invoiceNumber: string; invoiceIssuedAt: Date }
): Promise<boolean> {
const where = { id: target.id, invoiceNumber: null };
const result =
target.kind === "booking"
? await tx.courseBooking.updateMany({ where, data })
: await tx.customerPass.updateMany({ where, data });
return result.count === 1;
}
async function ensureInvoiceNumber(
bookingId: string,
target: InvoiceTarget,
businessId: string
): Promise<{ invoiceNumber: string; issuedAt: Date }> {
return prisma.$transaction(async (tx) => {
const booking = await tx.courseBooking.findUniqueOrThrow({
where: { id: bookingId },
select: { invoiceNumber: true, invoiceIssuedAt: true },
});
const existing = await readInvoiceStamp(tx, target);
if (booking.invoiceNumber) {
if (existing.invoiceNumber) {
return {
invoiceNumber: booking.invoiceNumber,
issuedAt: booking.invoiceIssuedAt ?? new Date(),
invoiceNumber: existing.invoiceNumber,
issuedAt: existing.invoiceIssuedAt ?? new Date(),
};
}
@ -99,18 +126,12 @@ async function ensureInvoiceNumber(
const invoiceNumber = formatInvoiceNumber(businessId, business.invoiceCounter);
const issuedAt = new Date();
const claimed = await tx.courseBooking.updateMany({
where: { id: bookingId, invoiceNumber: null },
data: { invoiceNumber, invoiceIssuedAt: issuedAt },
});
if (claimed.count === 1) return { invoiceNumber, issuedAt };
if (await claimInvoiceStamp(tx, target, { invoiceNumber, invoiceIssuedAt: issuedAt })) {
return { invoiceNumber, issuedAt };
}
// Ein paralleler Request war schneller - dessen Nummer/Datum gilt.
const current = await tx.courseBooking.findUniqueOrThrow({
where: { id: bookingId },
select: { invoiceNumber: true, invoiceIssuedAt: true },
});
const current = await readInvoiceStamp(tx, target);
return { invoiceNumber: current.invoiceNumber!, issuedAt: current.invoiceIssuedAt! };
});
}
@ -269,7 +290,7 @@ export async function assembleInvoiceData(
if (!tax) return { ok: false, reason: "missing_tax_settings" };
const { invoiceNumber, issuedAt } = await ensureInvoiceNumber(
bookingId,
{ kind: "booking", id: bookingId },
booking.course.business.id
);
@ -314,7 +335,7 @@ export async function assembleInvoiceDataBatch(
}
const { invoiceNumber, issuedAt } = await ensureInvoiceNumber(
booking.id,
{ kind: "booking", id: booking.id },
booking.course.business.id
);
const invoiceData = toInvoiceData(booking, invoiceNumber, issuedAt, tax);
@ -363,3 +384,144 @@ export async function findInvoiceableBookingIds(
return { ids, truncated };
}
// ─── Kartenkäufe ──────────────────────────────────────────────────────────────
//
// Eine Karte wird beim Kauf abgerechnet, nicht beim Einlösen: Die Rechnung
// weist den Kartenpreis aus (ohne Servicegebühr, wie bei Kursen), Leistungsdatum
// ist der Kauftag. Vergebene Karten (ohne Zahlung) und erstattete Käufe bekommen
// keine Rechnung.
const invoicePassSelect = {
id: true,
name: true,
email: true,
billingAddress: true,
priceCents: true,
createdAt: true,
business: { select: invoiceBookingSelect.course.select.business.select },
user: invoiceBookingSelect.user,
} as const;
type InvoicePassRow = Awaited<
ReturnType<typeof prisma.customerPass.findFirstOrThrow<{ select: typeof invoicePassSelect }>>
>;
const invoiceablePassWhere = {
source: "purchase",
status: { not: "refunded" },
priceCents: { not: null },
} as const;
function toPassInvoiceData(
pass: InvoicePassRow,
invoiceNumber: string,
issuedAt: Date,
tax: TaxSettings
): InvoiceData {
const billing = readBillingAddress(pass.billingAddress);
const branding = toEmailBranding(pass.business);
const amountCents = pass.priceCents ?? 0;
return {
invoiceNumber,
issueDate: issuedAt.toISOString(),
business: {
name: pass.business.name,
address: pass.business.address,
email: pass.business.email,
tax,
banking: pass.business.banking,
logoUrl: branding.logoUrl,
accentColor: branding.accentColor,
},
course: { name: pass.name, date: pass.createdAt.toISOString(), seats: 1 },
customer: {
name: billing?.name ?? pass.user?.name ?? pass.email,
email: pass.user?.email ?? pass.email,
isGuest: false,
addressLines: billing ? formatBillingAddressLines(billing) : [],
},
amountCents,
originalAmountCents: amountCents,
refundedAmountCents: 0,
};
}
/** Wie assembleInvoiceData, für einen Kartenkauf. */
export async function assemblePassInvoiceData(
passId: string,
manager: ManagerData
): Promise<InvoiceResult> {
const businessIds = manager.businesses.map((b) => b.id);
const pass = await prisma.customerPass.findFirst({
where: { id: passId, businessId: { in: businessIds }, ...invoiceablePassWhere },
select: invoicePassSelect,
});
if (!pass) return { ok: false, reason: "not_invoiceable" };
// Wie bei Buchungen: ohne Steuerangaben keine Nummer ziehen.
const tax = readTaxSettings(pass.business);
if (!tax) return { ok: false, reason: "missing_tax_settings" };
const { invoiceNumber, issuedAt } = await ensureInvoiceNumber(
{ kind: "pass", id: pass.id },
pass.business.id
);
return { ok: true, data: toPassInvoiceData(pass, invoiceNumber, issuedAt, tax) };
}
/**
* Kartenkäufe für den ZIP-Export, mit denselben Filtern wie die Zahlungsliste:
* Studio, Zeitraum, Kunde. Ein Status-Filter außer "Bezahlt" schließt sie aus.
*/
export async function findInvoiceablePassIds(
filters: ZahlungenFilters,
manager: ManagerData
): Promise<string[]> {
// Rechnungen gibt es nur für bezahlte Käufe - ein Filter "Erstattet" findet keine.
if (filters.status && filters.status !== "paid") return [];
const where = buildPassSalesWhere(
filters,
manager.businesses.map((b) => b.id)
);
if (!where) return [];
const passes = await prisma.customerPass.findMany({
where: { AND: [where, invoiceablePassWhere] },
take: MAX_ZIP_INVOICES,
orderBy: [{ createdAt: "desc" }, { id: "desc" }],
select: { id: true },
});
return passes.map((p) => p.id);
}
/** Batch-Variante von assemblePassInvoiceData für den ZIP-Export. */
export async function assemblePassInvoiceDataBatch(
passIds: string[],
manager: ManagerData
): Promise<{ invoices: InvoiceData[]; businessesMissingTax: string[] }> {
if (passIds.length === 0) return { invoices: [], businessesMissingTax: [] };
const businessIds = manager.businesses.map((b) => b.id);
const passes = await prisma.customerPass.findMany({
where: { id: { in: passIds }, businessId: { in: businessIds }, ...invoiceablePassWhere },
select: invoicePassSelect,
});
const invoices: InvoiceData[] = [];
const businessesMissingTax = new Set<string>();
for (const pass of passes) {
const tax = readTaxSettings(pass.business);
if (!tax) {
businessesMissingTax.add(pass.business.name);
continue;
}
const { invoiceNumber, issuedAt } = await ensureInvoiceNumber(
{ kind: "pass", id: pass.id },
pass.business.id
);
invoices.push(toPassInvoiceData(pass, invoiceNumber, issuedAt, tax));
}
return { invoices, businessesMissingTax: [...businessesMissingTax] };
}

View file

@ -2,7 +2,7 @@
import { prisma } from "@/src/lib/prisma";
import { requireManager } from "@/src/lib/auth/getUser";
import { buildZahlungenWhere } from "../lib/build-zahlungen-where";
import { buildPassSalesWhere, buildZahlungenWhere } from "../lib/build-zahlungen-where";
import {
courseAmountCents,
payoutAmountCents,
@ -29,10 +29,13 @@ export type ZahlungenFilters = {
export type ZahlungRow = {
id: string;
/** Kursbuchung oder Kartenkauf - bestimmt Rechnungs-Link und Anzeige. */
kind: "booking" | "pass";
createdAt: string;
customerName: string;
customerEmail: string;
isGuest: boolean;
/** Kursname, bei einem Kartenkauf der Name der Karte. */
courseName: string;
/** Ursprünglich bezahlte Plätze - mehr als 1 bei einer Mehrfachbuchung. */
seats: number;
@ -59,6 +62,43 @@ export type ZahlungRow = {
const DEFAULT_LIMIT = 20;
/**
* Seitenmarke für "Mehr laden": Zeitpunkt und ID der letzten Zeile. Die Liste
* mischt zwei Tabellen, deshalb reicht eine ID allein (Prisma-cursor) nicht -
* beide Quellen setzen hinter demselben (createdAt, id) wieder an.
*/
type PageCursor = { createdAt: Date; id: string };
function encodeCursor(row: PageCursor): string {
return `${row.createdAt.toISOString()}_${row.id}`;
}
function decodeCursor(value?: string): PageCursor | null {
if (!value) return null;
const separator = value.indexOf("_");
if (separator < 0) return null;
const createdAt = new Date(value.slice(0, separator));
const id = value.slice(separator + 1);
return Number.isNaN(createdAt.getTime()) || !id ? null : { createdAt, id };
}
/** Zeilen nach der Seitenmarke, in absteigender (createdAt, id)-Reihenfolge. */
function afterCursor(cursor: PageCursor) {
return {
OR: [
{ createdAt: { lt: cursor.createdAt } },
{ createdAt: cursor.createdAt, id: { lt: cursor.id } },
],
};
}
const newestFirst = [{ createdAt: "desc" as const }, { id: "desc" as const }];
/**
* Alle Zahlungen der Studios des Managers: Kursbuchungen und Kartenkäufe in
* einer Liste, neueste zuerst. Mit Karte gebuchte Kurse fehlen (siehe
* buildZahlungenWhere) - ihr Geld steht beim Kartenkauf.
*/
export async function getZahlungen(
filters: ZahlungenFilters
): Promise<
@ -73,45 +113,64 @@ export async function getZahlungen(
}
const limit = filters.limit ?? DEFAULT_LIMIT;
const where = buildZahlungenWhere(filters, businessIds);
const cursor = decodeCursor(filters.cursor);
const bookingWhere = buildZahlungenWhere(filters, businessIds);
const passWhere = buildPassSalesWhere(filters, businessIds);
try {
const bookings = await prisma.courseBooking.findMany({
where,
take: limit + 1,
...(filters.cursor ? { cursor: { id: filters.cursor }, skip: 1 } : {}),
// id als Tiebreaker, da createdAt allein bei gleichem Zeitstempel keine
// stabile Reihenfolge garantiert - sonst können beim Cursor-Paging
// Zeilen übersprungen oder doppelt geliefert werden.
orderBy: [{ createdAt: "desc" }, { id: "desc" }],
select: {
id: true,
createdAt: true,
paymentStatus: true,
amountPaid: true,
platformFee: true,
platformFeeOnTop: true,
stripeFee: true,
stripeAccountId: true,
refundedAmount: true,
refundedFeeAmount: true,
seatsPurchased: true,
invoiceNumber: true,
course: {
select: {
name: true,
business: { select: { id: true, name: true } },
// Aus jeder Quelle limit + 1 Zeilen: Die ersten `limit` der gemischten
// Liste stimmen dann sicher, und eine Zeile mehr verrät, ob es weitergeht.
const [bookings, passes] = await Promise.all([
prisma.courseBooking.findMany({
where: cursor ? { AND: [bookingWhere, afterCursor(cursor)] } : bookingWhere,
take: limit + 1,
orderBy: newestFirst,
select: {
id: true,
createdAt: true,
paymentStatus: true,
amountPaid: true,
platformFee: true,
platformFeeOnTop: true,
stripeFee: true,
stripeAccountId: true,
refundedAmount: true,
refundedFeeAmount: true,
seatsPurchased: true,
invoiceNumber: true,
course: {
select: {
name: true,
business: { select: { id: true, name: true } },
},
},
user: { select: { name: true, email: true } },
guestLead: { select: { name: true, email: true } },
},
user: { select: { name: true, email: true } },
guestLead: { select: { name: true, email: true } },
},
});
}),
passWhere
? prisma.customerPass.findMany({
where: cursor ? { AND: [passWhere, afterCursor(cursor)] } : passWhere,
take: limit + 1,
orderBy: newestFirst,
select: {
id: true,
createdAt: true,
name: true,
email: true,
status: true,
amountPaid: true,
platformFee: true,
stripeFee: true,
invoiceNumber: true,
user: { select: { name: true } },
business: { select: { id: true, name: true } },
},
})
: [],
]);
const hasMore = bookings.length > limit;
const page = hasMore ? bookings.slice(0, limit) : bookings;
const zahlungen: ZahlungRow[] = page.map((b) => {
const bookingRows = bookings.map((b) => {
const isGuest = !b.user;
const customerName =
b.user?.name ?? b.user?.email ?? b.guestLead?.name ?? b.guestLead?.email ?? "–";
@ -145,7 +204,8 @@ export async function getZahlungen(
return {
id: b.id,
createdAt: b.createdAt.toISOString(),
kind: "booking" as const,
createdAt: b.createdAt,
customerName,
customerEmail,
isGuest,
@ -161,10 +221,37 @@ export async function getZahlungen(
};
});
// Ein Kartenkauf folgt derselben Konvention: Betrag = Kartenpreis ohne
// Servicegebühr, eine Erstattung (immer komplett) zeigt das Status-Badge.
const passRows = passes.map((p) => ({
id: p.id,
kind: "pass" as const,
createdAt: p.createdAt,
customerName: p.user?.name ?? p.email,
customerEmail: p.email,
isGuest: false,
courseName: p.name,
seats: 1,
businessId: p.business.id,
businessName: p.business.name,
amountPaid: (p.amountPaid ?? 0) - (p.platformFee ?? 0),
stripeFeeCents: p.stripeFee,
payoutCents: (p.amountPaid ?? 0) - (p.platformFee ?? 0) - (p.stripeFee ?? 0),
paymentStatus: p.status === "refunded" ? "pass_refunded" : "paid",
invoiceNumber: p.invoiceNumber,
}));
const merged = [...bookingRows, ...passRows].sort(
(a, b) =>
b.createdAt.getTime() - a.createdAt.getTime() || (a.id < b.id ? 1 : a.id > b.id ? -1 : 0)
);
const page = merged.slice(0, limit);
const last = page[page.length - 1];
return {
success: true,
zahlungen,
nextCursor: hasMore ? page[page.length - 1].id : null,
zahlungen: page.map((row) => ({ ...row, createdAt: row.createdAt.toISOString() })),
nextCursor: merged.length > limit && last ? encodeCursor(last) : null,
};
} catch {
return { success: false, error: "Zahlungen konnten nicht geladen werden." };

View file

@ -6,6 +6,7 @@ import { scopeBusinessIds } from "../lib/build-zahlungen-where";
import { unstable_cache } from "next/cache";
import { startOfMonth } from "date-fns";
import { bookingRevenueSelect, sumCourseRevenueCents } from "@/src/lib/booking-amounts";
import { passRevenueCents } from "@/src/modules/products/lib/pass-rules";
export type ZahlungMetricsFilters = {
businessId?: string;
@ -40,11 +41,13 @@ export async function getZahlungMetrics(
// Umsatz = Kurspreise, ohne die Servicegebühr, die der Kunde zusätzlich
// an Kumasy gezahlt hat (siehe bookingRevenueSelect). Deshalb einzelne
// Zeilen statt _sum über amountPaid.
const [paidBookings, partialBookings, pendingGroups] = await Promise.all([
const [paidBookings, partialBookings, pendingGroups, passSales] = await Promise.all([
prisma.courseBooking.findMany({
where: {
course: { businessId: { in: scopedBusinessIds } },
paymentStatus: "paid",
// Mit Karte gebucht ist keine Zahlung - der Umsatz kam beim Kartenkauf.
passId: null,
createdAt: { gte: monthStart },
},
select: bookingRevenueSelect,
@ -73,6 +76,15 @@ export async function getZahlungMetrics(
},
_count: { _all: true },
}),
// Kartenverkäufe sind Umsatz im Monat des Kaufs.
prisma.customerPass.findMany({
where: {
businessId: { in: scopedBusinessIds },
source: "purchase",
createdAt: { gte: monthStart },
},
select: { status: true, amountPaid: true, platformFee: true },
}),
]);
const pendingCourseIds = pendingGroups.map((g) => g.courseId);
@ -92,12 +104,16 @@ export async function getZahlungMetrics(
pendingAmount += Math.round(price * 100) * group._count._all;
}
const paidPassSales = passSales.filter((p) => p.status !== "refunded");
return {
revenueThisMonth:
sumCourseRevenueCents(paidBookings) + sumCourseRevenueCents(partialBookings),
sumCourseRevenueCents(paidBookings) +
sumCourseRevenueCents(partialBookings) +
paidPassSales.reduce((sum, p) => sum + passRevenueCents(p), 0),
pendingCount,
pendingAmount,
paidCount: paidBookings.length + partialBookings.length,
paidCount: paidBookings.length + partialBookings.length + paidPassSales.length,
};
},
[`zahlungen-metrics-${manager.id}-${scopedBusinessIds.join(",")}`],

View file

@ -25,6 +25,22 @@ function endOfDay(date: Date): Date {
return end;
}
/**
* Zeitraum-Filter der Zahlungsliste, gemeinsam für Buchungen und Kartenkäufe.
* dateTo kommt aus dem Datumspicker als Mitternacht des gewählten Tages - ohne
* Normalisierung würde "lte" Zahlungen ausschließen, die später am selben Tag
* erstellt wurden. Deshalb wird auf das Tagesende verschoben.
*/
export function createdAtRange(
filters: Pick<ZahlungenFilters, "dateFrom" | "dateTo">
): { gte?: Date; lte?: Date } | undefined {
const gte = parseDate(filters.dateFrom);
const rawLte = parseDate(filters.dateTo);
const lte = rawLte ? endOfDay(rawLte) : undefined;
if (!gte && !lte) return undefined;
return { ...(gte ? { gte } : {}), ...(lte ? { lte } : {}) };
}
/**
* Baut die Prisma-where-Klausel für die Zahlungsliste aus den UI-Filtern.
* Wird sowohl von getZahlungen (Tabelle) als auch vom ZIP-Sammel-Export
@ -41,22 +57,17 @@ export function buildZahlungenWhere(
const where: Prisma.CourseBookingWhereInput = {
course: { businessId: { in: scopedBusinessIds } },
// Mit Karte gebucht ist keine Zahlung - das Geld floss beim Kartenkauf, und
// der steht mit eigener Rechnung bei den Kartenverkäufen.
passId: null,
};
if (filters.status) {
where.paymentStatus = filters.status;
}
// dateTo kommt aus dem Datumspicker als Mitternacht des gewählten Tages -
// ohne Normalisierung würde "lte" Zahlungen ausschließen, die später am
// selben Tag erstellt wurden. Deshalb wird auf das Tagesende verschoben.
const gte = parseDate(filters.dateFrom);
const rawLte = parseDate(filters.dateTo);
const lte = rawLte ? endOfDay(rawLte) : undefined;
if (gte || lte) {
where.createdAt = { ...(gte ? { gte } : {}), ...(lte ? { lte } : {}) };
}
const createdAt = createdAtRange(filters);
if (createdAt) where.createdAt = createdAt;
if (filters.customerQuery) {
const q = filters.customerQuery;
@ -70,3 +81,43 @@ export function buildZahlungenWhere(
return where;
}
/**
* Dieselben Filter für Kartenkäufe, damit die Zahlungsliste beide Quellen gleich
* filtert. Eine Karte ist nur bezahlt oder erstattet - jeder andere
* Status-Filter schließt Kartenkäufe aus (null). Vergebene Karten sind keine
* Zahlung und fehlen immer.
*/
export function buildPassSalesWhere(
filters: ZahlungenFilters,
businessIds: string[]
): Prisma.CustomerPassWhereInput | null {
const statusWhere: Prisma.CustomerPassWhereInput | null =
filters.status === undefined
? {}
: filters.status === "paid"
? { status: { not: "refunded" } }
: filters.status === "refunded"
? { status: "refunded" }
: null;
if (statusWhere === null) return null;
const where: Prisma.CustomerPassWhereInput = {
businessId: { in: scopeBusinessIds(businessIds, filters.businessId) },
source: "purchase",
...statusWhere,
};
const createdAt = createdAtRange(filters);
if (createdAt) where.createdAt = createdAt;
if (filters.customerQuery) {
const q = filters.customerQuery;
where.OR = [
{ email: { contains: q, mode: "insensitive" } },
{ user: { name: { contains: q, mode: "insensitive" } } },
];
}
return where;
}

View file

@ -10,6 +10,8 @@ export const PAYMENT_STATUS: Record<string, { label: string; bg: string; text: s
cancelled: { label: "Storniert", bg: "bg-red-100", text: "text-red-600" },
refunding: { label: "Wird storniert…", bg: "bg-yellow-100", text: "text-yellow-700" },
failed: { label: "Fehlgeschlagen", bg: "bg-red-100", text: "text-red-600" },
// Kartenkauf, den das Studio vollständig erstattet hat.
pass_refunded: { label: "Erstattet", bg: "bg-red-100", text: "text-red-600" },
};
interface ZahlungStatusBadgeProps {

View file

@ -80,7 +80,7 @@ const ZahlungenTable = ({
<TableHead className="hidden md:table-cell font-semibold text-gray-700 pt-4 pb-3">
<span className="flex items-center gap-2">
<Dumbbell size={14} className="text-gray-400" />
Kurs
Kurs / Karte
</span>
</TableHead>
{showBusinessColumn && (
@ -152,6 +152,11 @@ const ZahlungenTable = ({
</TableCell>
<TableCell className="hidden md:table-cell text-muted-foreground">
{z.courseName}
{z.kind === "pass" && (
<span className="ml-2 text-xs font-semibold px-2 py-0.5 rounded-full bg-blue-100 text-blue-700">
Karte
</span>
)}
{z.seats > 1 && <span className="text-xs"> · {z.seats} Plätze</span>}
</TableCell>
{showBusinessColumn && (
@ -180,7 +185,14 @@ const ZahlungenTable = ({
<TableCell>
{(z.paymentStatus === "paid" || z.paymentStatus === "partially_refunded") &&
!invoiceBlockedBusinessIds.includes(z.businessId) && (
<a href={`/api/zahlungen/${z.id}/invoice`} download>
<a
href={
z.kind === "pass"
? `/api/zahlungen/passes/${z.id}/invoice`
: `/api/zahlungen/${z.id}/invoice`
}
download
>
<Button
variant="ghost"
size="icon"

View file

@ -0,0 +1,96 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("@/src/lib/prisma", () => ({ prisma: { product: { findUnique: vi.fn() } } }));
vi.mock("@/src/lib/auth/getUser", () => ({
getUserData: vi.fn(),
isUser: (user: { role: string }) => user.role === "user",
}));
vi.mock("@/src/lib/stripe", () => ({ getPlatformFeeCents: () => 50 }));
vi.mock("@/src/lib/stripe-checkout", () => ({
createBrandedCheckoutSession: vi.fn(),
studioCheckoutBranding: () => ({}),
}));
import { POST } from "@/src/app/api/stripe/checkout/pass/route";
import { prisma } from "@/src/lib/prisma";
import { getUserData } from "@/src/lib/auth/getUser";
import { createBrandedCheckoutSession } from "@/src/lib/stripe-checkout";
const product = {
id: "prod-1",
name: "10er-Karte",
credits: 10,
priceCents: 12000,
validityMonths: 6,
isActive: true,
business: {
id: "biz-1",
name: "Studio",
slug: "studio",
isPublic: true,
branding: null,
stripeAccountId: "acct_studio",
stripeAccountStatus: "active",
},
};
function request(body: unknown) {
return new Request("http://localhost/api/stripe/checkout/pass", {
method: "POST",
body: JSON.stringify(body),
}) as never;
}
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(getUserData).mockResolvedValue({
id: "user-1",
email: "kunde@example.com",
role: "user",
} as never);
vi.mocked(prisma.product.findUnique).mockResolvedValue(product as never);
vi.mocked(createBrandedCheckoutSession).mockResolvedValue({ url: "https://stripe/x" } as never);
});
describe("POST /api/stripe/checkout/pass", () => {
it("schickt Gäste zum Login", async () => {
vi.mocked(getUserData).mockResolvedValue(null);
const res = await POST(request({ productId: "prod-1" }));
expect(res.status).toBe(401);
expect((await res.json()).loginRequired).toBe(true);
});
it("lässt Team-Accounts keine Karte kaufen", async () => {
vi.mocked(getUserData).mockResolvedValue({ id: "m1", role: "manager" } as never);
expect((await POST(request({ productId: "prod-1" }))).status).toBe(403);
});
it("verkauft keine Karte, die nicht im Verkauf ist", async () => {
vi.mocked(prisma.product.findUnique).mockResolvedValue({
...product,
isActive: false,
} as never);
expect((await POST(request({ productId: "prod-1" }))).status).toBe(404);
expect(createBrandedCheckoutSession).not.toHaveBeenCalled();
});
it("nimmt die Servicegebühr pro Einheit als application_fee auf dem Studio-Account", async () => {
const res = await POST(request({ productId: "prod-1" }));
expect(res.status).toBe(200);
const [params, , accountId] = vi.mocked(createBrandedCheckoutSession).mock.calls[0];
expect(accountId).toBe("acct_studio");
expect(params.payment_intent_data?.application_fee_amount).toBe(500);
expect(params.line_items).toEqual([
expect.objectContaining({
quantity: 1,
price_data: expect.objectContaining({ unit_amount: 12000 }),
}),
expect.objectContaining({
quantity: 10,
price_data: expect.objectContaining({ unit_amount: 50 }),
}),
]);
expect(params.metadata).toMatchObject({ kind: "pass", productId: "prod-1", userId: "user-1" });
});
});

View file

@ -49,6 +49,10 @@ vi.mock("@/src/modules/settings/lib/discount-validation", () => ({
confirmDiscountReservation: vi.fn(),
releaseDiscountReservation: vi.fn(),
}));
vi.mock("@/src/modules/products/lib/pass-webhook", async (importOriginal) => ({
...(await importOriginal<object>()),
handlePassCheckoutCompleted: vi.fn(),
}));
import { POST } from "@/src/app/api/stripe/webhook/route";
import { stripe } from "@/src/lib/stripe";
@ -57,6 +61,7 @@ import { refundStripePayment } from "@/src/lib/stripe-refund";
import { recordStripeFee, recordStripeFeeForCharge } from "@/src/lib/stripe-fee";
import { isManagerOrEmployeeEmail } from "@/src/lib/auth/account-role-by-email";
import { releaseDiscountReservation } from "@/src/modules/settings/lib/discount-validation";
import { handlePassCheckoutCompleted } from "@/src/modules/products/lib/pass-webhook";
// Die Datenbank-Transaktion der Kapazitätsprüfung.
const tx = {
@ -511,3 +516,57 @@ describe("Stripe-Webhook – Mehrfachbuchung", () => {
expect(tx.courseBooking.upsert.mock.calls[0][0].create).toMatchObject({ seats: 1 });
});
});
describe("Stripe-Webhook – Karten", () => {
const passMetadata = { kind: "pass", productId: "prod-1", courseId: "" };
it("legt bei einem Kartenkauf die Karte an, keine Kursbuchung", async () => {
const res = await deliver(
sessionEvent("checkout.session.completed", {
account: "acct_studio",
metadata: passMetadata,
}),
"whsec_connect"
);
expect(res.status).toBe(200);
expect(handlePassCheckoutCompleted).toHaveBeenCalledWith(
expect.objectContaining({ id: "cs_1" }),
"acct_studio"
);
expect(tx.courseBooking.upsert).not.toHaveBeenCalled();
});
it("räumt bei einem abgelaufenen Kartenkauf nichts auf - es war nichts reserviert", async () => {
await deliver(sessionEvent("checkout.session.expired", { metadata: passMetadata }));
expect(prisma.courseSpotHold.deleteMany).not.toHaveBeenCalled();
expect(releaseDiscountReservation).not.toHaveBeenCalled();
});
it("erstattet eine Zahlung für einen Kurs, der inzwischen mit Karte gebucht ist", async () => {
tx.courseBooking.findUnique.mockResolvedValue({
id: "booking-1",
stripeSessionId: null,
paymentStatus: "paid",
passId: "pass-1",
});
await deliver(
sessionEvent("checkout.session.completed", { account: "acct_studio" }),
"whsec_connect"
);
expect(tx.courseBooking.upsert).not.toHaveBeenCalled();
expect(refundStripePayment).toHaveBeenCalledWith(
expect.objectContaining({ paymentIntentId: "pi_1", refundApplicationFee: true })
);
});
it("löst eine alte, stornierte Kartenbuchung von der Karte, wenn neu bezahlt wird", async () => {
await deliver(
sessionEvent("checkout.session.completed", { account: "acct_studio" }),
"whsec_connect"
);
expect(tx.courseBooking.upsert.mock.calls[0][0].update).toMatchObject({ passId: null });
});
});

View file

@ -0,0 +1,75 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { POST } from "@/src/app/api/upload/business-logo/route";
vi.mock("@/src/lib/supabase/admin", () => ({
createAdminClient: vi.fn(),
}));
vi.mock("@/src/lib/auth/getUser", () => ({
getUserData: vi.fn(),
isManager: (user: { role: string }) => user.role === "manager",
}));
vi.mock("@/src/lib/prisma", () => ({
prisma: {
business: { findUnique: vi.fn() },
},
}));
import { createAdminClient } from "@/src/lib/supabase/admin";
import { getUserData } from "@/src/lib/auth/getUser";
import { prisma } from "@/src/lib/prisma";
const mockUpload = vi.fn();
const mockStorageBucket = {
upload: mockUpload,
getPublicUrl: vi.fn().mockReturnValue({ data: { publicUrl: "https://example.com/x.jpg" } }),
};
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(createAdminClient).mockReturnValue({
storage: { from: vi.fn().mockReturnValue(mockStorageBucket) },
} as never);
vi.mocked(getUserData).mockResolvedValue({ id: "mgr-1", role: "manager" } as never);
vi.mocked(prisma.business.findUnique).mockResolvedValue({ managerId: "mgr-1" } as never);
mockUpload.mockResolvedValue({ data: { path: "uploaded.jpg" }, error: null });
});
function makeImageFile(): File {
const content = new Uint8Array(1024);
const file = new File([content], "photo.jpg", { type: "image/jpeg" });
if (typeof file.arrayBuffer !== "function") {
Object.defineProperty(file, "arrayBuffer", { value: async () => content.buffer });
}
return file;
}
// jsdom verliert File.type beim Multipart-Parsen, deshalb formData() direkt mocken.
function makeRequest(fields: Record<string, string | File>): Request {
const req = new Request("http://localhost/api/upload/business-logo", { method: "POST" });
req.formData = async () => ({ get: (key: string) => fields[key] ?? null }) as unknown as FormData;
return req;
}
describe("POST /api/upload/business-logo", () => {
it("legt Logos ohne Zweck im Logo-Ordner ab", async () => {
const res = await POST(makeRequest({ file: makeImageFile(), businessId: "b1" }));
expect(res.status).toBe(201);
expect(mockUpload.mock.calls[0][0]).toMatch(/^business-logos\/b1\//);
});
it("legt Kartenbilder im eigenen Ordner ab", async () => {
const res = await POST(
makeRequest({ file: makeImageFile(), businessId: "b1", purpose: "card" })
);
expect(res.status).toBe(201);
expect(mockUpload.mock.calls[0][0]).toMatch(/^card-images\/b1\//);
});
it.each(["../secret", "__proto__", "toString"])("lehnt den Zweck %s ab", async (purpose) => {
const res = await POST(makeRequest({ file: makeImageFile(), businessId: "b1", purpose }));
expect(res.status).toBe(400);
expect(mockUpload).not.toHaveBeenCalled();
});
});

View file

@ -9,6 +9,10 @@ vi.mock("@/src/modules/courses/actions/booking-actions", () => ({
vi.mock("@/src/modules/courses/actions/waitlist-actions", () => ({
joinWaitlistAsUser: vi.fn(),
}));
vi.mock("@/src/modules/products/actions/pass-booking-actions", () => ({
bookCourseWithPass: vi.fn(),
}));
vi.mock("next/navigation", () => ({ useRouter: () => ({ refresh: vi.fn() }) }));
vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
import { BookingDialog } from "@/src/modules/courses/ui/components/BookingDialog";

View file

@ -86,6 +86,7 @@ function renderView(initialCourses = [...thisWeek, ...later], initialWindow = wi
initialWindow={initialWindow}
serviceFeeCents={50}
cancellationSummary="Kostenlos stornierbar bis Kursbeginn."
products={[]}
/>
);
}

View file

@ -0,0 +1,158 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
const tx = {
courseBooking: { updateMany: vi.fn() },
customerPass: { update: vi.fn() },
};
vi.mock("@/src/lib/prisma", () => ({
prisma: {
courseBooking: { findUnique: vi.fn(), updateMany: vi.fn(), update: vi.fn() },
courseWaitlistEntry: { count: vi.fn() },
user: { findUnique: vi.fn() },
guestLead: { findUnique: vi.fn() },
$transaction: vi.fn((fn: (t: typeof tx) => unknown) => fn(tx)),
},
}));
vi.mock("@/src/lib/stripe-refund", () => ({ refundStripePayment: vi.fn() }));
vi.mock("@/src/lib/mail/resend", () => ({ sendMail: vi.fn() }));
vi.mock("@/src/lib/mail/templates/pass-booking-cancellation", () => ({
generatePassBookingCancellationEmail: vi
.fn()
.mockReturnValue({ subject: "s", html: "h", text: "t" }),
}));
vi.mock("@/src/modules/courses/actions/notify-waitlist", () => ({ notifyCourseWaitlist: vi.fn() }));
vi.mock("@/src/modules/notifications/lib/create-notification", () => ({
notifyBusinessManager: vi.fn(),
}));
vi.mock("@/src/modules/settings/lib/to-email-branding", () => ({
toEmailBranding: vi.fn().mockReturnValue({}),
}));
vi.mock("@/src/modules/courses/actions/cancellation-policy", async (importOriginal) => {
const actual = await importOriginal<object>();
return { ...actual, calculateRefundAmountCents: vi.fn() };
});
import { executeBookingCancellation } from "@/src/modules/courses/actions/cancel-booking-shared";
import { calculateRefundAmountCents } from "@/src/modules/courses/actions/cancellation-policy";
import { refundStripePayment } from "@/src/lib/stripe-refund";
import { prisma } from "@/src/lib/prisma";
import { generatePassBookingCancellationEmail } from "@/src/lib/mail/templates/pass-booking-cancellation";
import { notifyCourseWaitlist } from "@/src/modules/courses/actions/notify-waitlist";
import { notifyBusinessManager } from "@/src/modules/notifications/lib/create-notification";
import { sendMail } from "@/src/lib/mail/resend";
// Mit einer 10er-Karte gebucht: kein Geld, keine Stripe-Felder.
const passBooking = {
paymentStatus: "paid",
amountPaid: null,
platformFee: null,
platformFeeOnTop: false,
stripePaymentIntentId: null,
stripeAccountId: null,
userId: "user-1",
guestLeadId: null,
seats: 2,
seatsPurchased: 2,
spotIds: [] as string[],
spotLabels: [] as string[],
refundedAmount: null,
refundedFeeAmount: null,
refundedAt: null,
passId: "pass-1",
pass: { name: "10er-Karte" },
course: {
id: "course-1",
name: "Yoga",
date: new Date("2099-01-01"),
timeFrom: "10:00",
businessId: "biz-1",
business: { name: "Studio", branding: null },
},
};
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(prisma.courseBooking.findUnique).mockResolvedValue(passBooking as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({ email: "kunde@example.com" } as never);
vi.mocked(prisma.courseWaitlistEntry.count).mockResolvedValue(0 as never);
vi.mocked(notifyCourseWaitlist).mockResolvedValue(undefined as never);
vi.mocked(notifyBusinessManager).mockResolvedValue(undefined as never);
vi.mocked(sendMail).mockResolvedValue(undefined as never);
vi.mocked(generatePassBookingCancellationEmail).mockReturnValue({
subject: "s",
html: "h",
text: "t",
});
tx.courseBooking.updateMany.mockResolvedValue({ count: 1 });
tx.customerPass.update.mockResolvedValue({});
vi.mocked(calculateRefundAmountCents).mockResolvedValue({
cancellationAllowed: true,
refundPercent: 100,
refundAmountCents: 0,
});
});
describe("executeBookingCancellation – Kartenbuchung", () => {
it("gibt die Einheiten zurück, wenn die Stornoregel 100 % erstatten würde", async () => {
const result = await executeBookingCancellation("booking-1", "user");
expect(result).toMatchObject({ success: true, creditsReturned: 2, refundedAmountCents: 0 });
expect(refundStripePayment).not.toHaveBeenCalled();
expect(tx.courseBooking.updateMany.mock.calls[0][0].data.paymentStatus).toBe("refunded");
expect(tx.customerPass.update).toHaveBeenCalledWith({
where: { id: "pass-1" },
data: { creditsRemaining: { increment: 2 } },
});
});
it("lässt die Einheiten verfallen, wenn die Frist verstrichen ist", async () => {
vi.mocked(calculateRefundAmountCents).mockResolvedValue({
cancellationAllowed: true,
refundPercent: 50,
refundAmountCents: 0,
});
const result = await executeBookingCancellation("booking-1", "user");
expect(result).toMatchObject({ success: true, creditsReturned: 0 });
expect(tx.courseBooking.updateMany.mock.calls[0][0].data.paymentStatus).toBe("cancelled");
expect(tx.customerPass.update).not.toHaveBeenCalled();
expect(vi.mocked(generatePassBookingCancellationEmail).mock.calls[0][0].creditsReturned).toBe(
false
);
});
it("gibt bei einer Kursabsage immer zurück, ohne die Stornoregel zu fragen", async () => {
const result = await executeBookingCancellation("booking-1", "manager", {
forceFullRefund: true,
});
expect(result).toMatchObject({ success: true, creditsReturned: 2 });
expect(calculateRefundAmountCents).not.toHaveBeenCalled();
expect(vi.mocked(generatePassBookingCancellationEmail).mock.calls[0][0].reason).toBe(
"course_cancelled"
);
});
it("storniert einzelne Plätze und gibt nur deren Einheiten zurück", async () => {
const result = await executeBookingCancellation("booking-1", "user", {
seatSelection: { seats: 1 },
});
expect(result).toMatchObject({ success: true, remainingSeats: 1, creditsReturned: 1 });
expect(tx.courseBooking.updateMany.mock.calls[0][0].data).toMatchObject({ seats: 1 });
expect(tx.customerPass.update.mock.calls[0][0].data).toEqual({
creditsRemaining: { increment: 1 },
});
});
it("schreibt bei einem Doppelklick nicht zweimal gut", async () => {
tx.courseBooking.updateMany.mockResolvedValue({ count: 0 });
const result = await executeBookingCancellation("booking-1", "user");
expect(result.success).toBe(false);
expect(tx.customerPass.update).not.toHaveBeenCalled();
});
});

View file

@ -0,0 +1,86 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("@/src/lib/prisma", () => ({
prisma: {
course: { findUnique: vi.fn(), delete: vi.fn(), deleteMany: vi.fn() },
courseBooking: { count: vi.fn() },
},
}));
vi.mock("@/src/lib/auth/getUser", () => ({
getUserData: vi.fn(),
isManager: () => true,
isEmployee: () => false,
getEffectiveManagerId: (user: { id: string }) => user.id,
}));
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
vi.mock("@/src/modules/courses/lib/public-courses-cache", () => ({
refreshPublicCourses: vi.fn(),
}));
import { deleteCourse } from "@/src/modules/courses/actions/course-actions";
import { prisma } from "@/src/lib/prisma";
import { getUserData } from "@/src/lib/auth/getUser";
const instance = {
id: "course-2",
createdBy: "mgr-1",
parentCourseId: "template-1",
date: new Date("2099-01-08"),
businessId: "biz-1",
};
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(getUserData).mockResolvedValue({ id: "mgr-1", role: "manager" } as never);
vi.mocked(prisma.course.findUnique).mockResolvedValue(instance as never);
vi.mocked(prisma.courseBooking.count).mockResolvedValue(0 as never);
});
describe("deleteCourse – Buchungen mit Zahlungen", () => {
it("löscht keinen Kurs mit bezahlten Buchungen oder Rechnungen", async () => {
vi.mocked(prisma.courseBooking.count).mockResolvedValue(2 as never);
const result = await deleteCourse("course-2");
expect(result.success).toBe(false);
expect(prisma.course.delete).not.toHaveBeenCalled();
const { where } = vi.mocked(prisma.courseBooking.count).mock.calls[0][0]!;
expect(where).toMatchObject({
course: { id: "course-2" },
OR: [
{ paymentStatus: { in: ["paid", "refunding", "partially_refunded"] } },
{ invoiceNumber: { not: null } },
],
});
});
it("prüft bei 'diesen und folgende' alle betroffenen Termine", async () => {
await deleteCourse("course-2", "this_and_following");
expect(vi.mocked(prisma.courseBooking.count).mock.calls[0][0]?.where?.course).toEqual({
parentCourseId: "template-1",
date: { gte: instance.date },
});
expect(prisma.course.deleteMany).toHaveBeenCalled();
});
it("prüft beim Löschen einer Vorlage auch alle Termine, die mitgelöscht werden", async () => {
vi.mocked(prisma.course.findUnique).mockResolvedValue({
...instance,
id: "template-1",
parentCourseId: null,
} as never);
await deleteCourse("template-1");
expect(vi.mocked(prisma.courseBooking.count).mock.calls[0][0]?.where?.course).toEqual({
OR: [{ id: "template-1" }, { parentCourseId: "template-1" }],
});
});
it("löscht Kurse ohne solche Buchungen wie bisher", async () => {
const result = await deleteCourse("course-2");
expect(result.success).toBe(true);
expect(prisma.course.delete).toHaveBeenCalledWith({ where: { id: "course-2" } });
});
});

View file

@ -8,7 +8,10 @@ vi.mock("@/src/lib/stripe", () => ({
}));
vi.mock("@/src/lib/prisma", () => ({
prisma: { courseBooking: { updateMany: vi.fn(), count: vi.fn() } },
prisma: {
courseBooking: { updateMany: vi.fn(), count: vi.fn() },
customerPass: { updateMany: vi.fn(), count: vi.fn() },
},
}));
import {
@ -31,6 +34,8 @@ const balanceTransaction = {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(prisma.courseBooking.updateMany).mockResolvedValue({ count: 1 } as never);
vi.mocked(prisma.customerPass.updateMany).mockResolvedValue({ count: 0 } as never);
vi.mocked(prisma.customerPass.count).mockResolvedValue(0 as never);
});
describe("stripeProcessingFeeCents", () => {
@ -67,6 +72,19 @@ describe("recordStripeFee", () => {
});
});
it("trägt die Gebühr auch an einem Kartenkauf ein", async () => {
vi.mocked(stripe.paymentIntents.retrieve).mockResolvedValue({
latest_charge: { balance_transaction: balanceTransaction },
} as never);
await recordStripeFee("pi_pass", "acct_studio");
expect(prisma.customerPass.updateMany).toHaveBeenCalledWith({
where: { stripePaymentIntentId: "pi_pass", stripeFee: null },
data: { stripeFee: 121 },
});
});
it("tut nichts, solange Stripe die Zahlung nicht abgerechnet hat", async () => {
vi.mocked(stripe.paymentIntents.retrieve).mockResolvedValue({
latest_charge: { balance_transaction: null },
@ -96,6 +114,19 @@ describe("recordStripeFeeForCharge", () => {
});
});
it("trägt die Gebühr nach, wenn ein Kartenkauf auf sie wartet", async () => {
vi.mocked(prisma.courseBooking.count).mockResolvedValue(0 as never);
vi.mocked(prisma.customerPass.count).mockResolvedValue(1 as never);
vi.mocked(stripe.balanceTransactions.retrieve).mockResolvedValue(balanceTransaction as never);
await recordStripeFeeForCharge(charge as never, "acct_studio");
expect(prisma.customerPass.updateMany).toHaveBeenCalledWith({
where: { stripePaymentIntentId: "pi_1", stripeFee: null },
data: { stripeFee: 121 },
});
});
it("fragt Stripe gar nicht erst, wenn keine Buchung wartet", async () => {
// charge.updated feuert auch bei Erstattungen - dann ist die Gebühr längst da.
vi.mocked(prisma.courseBooking.count).mockResolvedValue(0 as never);

View file

@ -0,0 +1,92 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import {
cardBackground,
cardDesignSchema,
cardTextColor,
defaultCardDesign,
parseCardDesign,
type CardDesign,
} from "@/src/modules/products/lib/card-design";
import { KUMASY_BRANDING } from "@/src/lib/mail/templates/tokens";
const STORAGE = "https://project.supabase.co";
const ownImage = `${STORAGE}/storage/v1/object/public/avatars/card-images/b1/1.jpg`;
const design = (patch: Partial<CardDesign> = {}): CardDesign => ({
style: "gradient",
color: "#112233",
secondaryColor: "#445566",
imageUrl: null,
...patch,
});
beforeEach(() => {
vi.stubEnv("NEXT_PUBLIC_SUPABASE_URL", STORAGE);
});
describe("cardDesignSchema", () => {
it("akzeptiert ein gültiges Design", () => {
expect(cardDesignSchema.safeParse(design()).success).toBe(true);
});
it("lehnt Farben ab, die kein 6-stelliges Hex sind", () => {
expect(cardDesignSchema.safeParse(design({ color: "red" })).success).toBe(false);
});
it("verlangt bei der Vorlage 'image' ein Bild", () => {
const result = cardDesignSchema.safeParse(design({ style: "image", imageUrl: null }));
expect(result.success).toBe(false);
expect(cardDesignSchema.safeParse(design({ style: "image", imageUrl: ownImage })).success).toBe(
true
);
});
it("lehnt Bilder ab, die nicht aus dem eigenen Storage kommen", () => {
const result = cardDesignSchema.safeParse(
design({ style: "image", imageUrl: "https://evil.example.com/a.jpg" })
);
expect(result.success).toBe(false);
});
});
describe("parseCardDesign", () => {
it("fällt bei unlesbaren Werten auf das Kumasy-Standarddesign zurück", () => {
expect(parseCardDesign({ style: "neon" })).toEqual(
defaultCardDesign({
primaryColor: KUMASY_BRANDING.primaryColor,
accentColor: KUMASY_BRANDING.accentColor,
})
);
});
});
describe("cardTextColor", () => {
it("nimmt weiße Schrift auf dunkler Vollfarbe und dunkle auf heller", () => {
expect(cardTextColor(design({ style: "solid", color: "#000000" }))).toBe("#FFFFFF");
expect(cardTextColor(design({ style: "solid", color: "#FFFFFF" }))).toBe("#1E293B");
});
it("wählt beim Verlauf die Farbe, die auf BEIDEN Enden lesbar bleibt", () => {
// Auf dem Blau allein wäre Weiß besser, auf dem hellen Gelb aber kaum lesbar.
expect(cardTextColor(design({ style: "solid", color: "#2563EB" }))).toBe("#FFFFFF");
const blueToYellow = design({ color: "#2563EB", secondaryColor: "#FDE68A" });
expect(cardTextColor(blueToYellow)).toBe("#1E293B");
});
it("nimmt auf Fotos immer Weiß, weil das Bild abgedunkelt wird", () => {
expect(cardTextColor(design({ style: "image", color: "#FFFFFF", imageUrl: ownImage }))).toBe(
"#FFFFFF"
);
});
});
describe("cardBackground", () => {
it("setzt die Bild-URL in Anführungszeichen", () => {
const background = cardBackground(design({ style: "image", imageUrl: ownImage }));
expect(background).toContain(`url("${ownImage}")`);
});
it("nutzt ohne Bild die Hauptfarbe", () => {
expect(cardBackground(design({ style: "image", imageUrl: null }))).toBe("#112233");
});
});

View file

@ -0,0 +1,206 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
const tx = {
customerPass: { updateMany: vi.fn() },
passAdjustment: { create: vi.fn() },
};
vi.mock("@/src/lib/prisma", () => ({
prisma: {
$transaction: vi.fn((fn: (t: typeof tx) => unknown) => fn(tx)),
product: { findFirst: vi.fn() },
user: { findFirst: vi.fn() },
customerPass: { findFirst: vi.fn(), create: vi.fn(), update: vi.fn(), updateMany: vi.fn() },
courseBooking: { count: vi.fn() },
passAdjustment: { create: vi.fn() },
},
}));
vi.mock("@/src/lib/auth/getUser", () => ({ requireManager: vi.fn() }));
vi.mock("next/cache", () => ({ revalidatePath: vi.fn(), updateTag: vi.fn() }));
vi.mock("@/src/lib/mail/resend", () => ({ sendMail: vi.fn() }));
vi.mock("@/src/lib/stripe-refund", () => ({ refundStripePayment: vi.fn() }));
import {
adjustPassCredits,
issuePass,
refundPass,
} from "@/src/modules/products/actions/pass-admin-actions";
import { prisma } from "@/src/lib/prisma";
import { requireManager } from "@/src/lib/auth/getUser";
import { sendMail } from "@/src/lib/mail/resend";
import { refundStripePayment } from "@/src/lib/stripe-refund";
const purchasedPass = {
id: "pass-1",
userId: "user-1",
status: "active",
source: "purchase",
creditsTotal: 10,
creditsRemaining: 10,
validUntil: null,
stripePaymentIntentId: "pi_1",
stripeAccountId: "acct_studio",
amountPaid: 12500,
};
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(requireManager).mockResolvedValue({
id: "mgr-1",
businesses: [{ id: "biz-1" }],
} as never);
vi.mocked(prisma.customerPass.findFirst).mockResolvedValue(purchasedPass as never);
vi.mocked(prisma.customerPass.updateMany).mockResolvedValue({ count: 1 } as never);
vi.mocked(prisma.courseBooking.count).mockResolvedValue(0 as never);
vi.mocked(sendMail).mockResolvedValue(undefined as never);
tx.customerPass.updateMany.mockResolvedValue({ count: 1 });
tx.passAdjustment.create.mockResolvedValue({});
});
describe("issuePass", () => {
beforeEach(() => {
vi.mocked(prisma.product.findFirst).mockResolvedValue({
id: "prod-1",
name: "10er-Karte",
sports: ["pilates"],
businessId: "biz-1",
business: { name: "Studio", branding: null },
} as never);
vi.mocked(prisma.customerPass.create).mockResolvedValue({} as never);
});
it("vergibt eine Karte an ein bestehendes Konto, mit eigenem Guthaben und Protokoll", async () => {
vi.mocked(prisma.user.findFirst).mockResolvedValue({ id: "user-1" } as never);
const result = await issuePass({
productId: "prod-1",
email: "Kunde@Example.com",
credits: 6,
validUntil: null,
reason: "Restguthaben vom alten Anbieter",
});
expect(result.success).toBe(true);
const { data } = vi.mocked(prisma.customerPass.create).mock.calls[0][0];
expect(data).toMatchObject({
userId: "user-1",
email: "kunde@example.com",
creditsTotal: 6,
creditsRemaining: 6,
source: "manual",
sports: ["pilates"],
});
expect(data.adjustments).toEqual({
create: expect.objectContaining({ type: "issue", creditsDelta: 6, managerId: "mgr-1" }),
});
});
it("hält die Karte für eine Adresse ohne Konto bereit", async () => {
vi.mocked(prisma.user.findFirst).mockResolvedValue(null);
await issuePass({
productId: "prod-1",
email: "neu@example.com",
credits: 10,
validUntil: "2099-03-14",
reason: "",
});
const { data } = vi.mocked(prisma.customerPass.create).mock.calls[0][0];
expect(data.userId).toBeNull();
expect(data.validUntil).toBeInstanceOf(Date);
});
it("lehnt ein Produkt eines fremden Studios ab", async () => {
vi.mocked(prisma.product.findFirst).mockResolvedValue(null);
const result = await issuePass({
productId: "prod-x",
email: "a@b.de",
credits: 1,
validUntil: null,
reason: "",
});
expect(result.success).toBe(false);
expect(prisma.customerPass.create).not.toHaveBeenCalled();
});
});
describe("adjustPassCredits", () => {
it("verlangt einen Grund", async () => {
const result = await adjustPassCredits({ passId: "pass-1", delta: 1, reason: " " });
expect(result).toEqual({ success: false, error: "Bitte gib einen Grund an." });
});
it("erhöht bei einer Gutschrift auch die Gesamtzahl der Karte", async () => {
await adjustPassCredits({ passId: "pass-1", delta: 2, reason: "Kulanz" });
expect(tx.customerPass.updateMany.mock.calls[0][0].data).toEqual({
creditsRemaining: { increment: 2 },
creditsTotal: { increment: 2 },
});
});
it("zieht nie unter null ab", async () => {
tx.customerPass.updateMany.mockResolvedValue({ count: 0 });
const result = await adjustPassCredits({ passId: "pass-1", delta: -20, reason: "Korrektur" });
expect(tx.customerPass.updateMany.mock.calls[0][0].where).toMatchObject({
creditsRemaining: { gte: 20 },
});
expect(result.success).toBe(false);
expect(tx.passAdjustment.create).not.toHaveBeenCalled();
});
});
describe("refundPass", () => {
it("erstattet eine ungenutzte Karte komplett, die Gebühr trägt das Studio", async () => {
vi.mocked(refundStripePayment).mockResolvedValue({
success: true,
stripeRefundId: "re_1",
amountRefundedCents: 12500,
});
vi.mocked(prisma.customerPass.update).mockResolvedValue({} as never);
const result = await refundPass({ passId: "pass-1" });
expect(result.success).toBe(true);
expect(refundStripePayment).toHaveBeenCalledWith(
expect.objectContaining({ paymentIntentId: "pi_1", refundApplicationFee: false })
);
expect(vi.mocked(refundStripePayment).mock.calls[0][0].amountCents).toBeUndefined();
});
it("erstattet keine Karte, mit der schon gebucht wurde", async () => {
vi.mocked(prisma.courseBooking.count).mockResolvedValue(1 as never);
const result = await refundPass({ passId: "pass-1" });
expect(result.success).toBe(false);
expect(refundStripePayment).not.toHaveBeenCalled();
});
it("erstattet keine vergebene Karte - da floss kein Geld", async () => {
vi.mocked(prisma.customerPass.findFirst).mockResolvedValue({
...purchasedPass,
source: "manual",
stripePaymentIntentId: null,
amountPaid: null,
} as never);
expect((await refundPass({ passId: "pass-1" })).success).toBe(false);
expect(refundStripePayment).not.toHaveBeenCalled();
});
it("setzt die Karte zurück, wenn Stripe die Erstattung ablehnt", async () => {
vi.mocked(refundStripePayment).mockResolvedValue({ success: false, error: "kaputt" });
vi.mocked(prisma.customerPass.update).mockResolvedValue({} as never);
const result = await refundPass({ passId: "pass-1" });
expect(result.success).toBe(false);
expect(prisma.customerPass.update).toHaveBeenCalledWith({
where: { id: "pass-1" },
data: { status: "active", creditsRemaining: 10 },
});
});
});

View file

@ -0,0 +1,186 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
const tx = {
course: { findUnique: vi.fn() },
customerPass: { findUnique: vi.fn(), updateMany: vi.fn() },
courseBooking: { findUnique: vi.fn(), upsert: vi.fn() },
};
vi.mock("@/src/lib/prisma", () => ({
prisma: {
$transaction: vi.fn((fn: (t: typeof tx) => unknown) => fn(tx)),
courseWaitlistEntry: { deleteMany: vi.fn() },
},
}));
vi.mock("@/src/lib/auth/getUser", () => ({
getUserData: vi.fn(),
isUser: (user: { role: string }) => user.role === "user",
}));
vi.mock("next/cache", () => ({ revalidatePath: vi.fn(), updateTag: vi.fn() }));
vi.mock("@/src/lib/mail/resend", () => ({ sendMail: vi.fn() }));
vi.mock("@/src/modules/courses/lib/notify-booking", () => ({ notifyManagerOfBooking: vi.fn() }));
vi.mock("@/src/modules/courses/lib/seat-counts", async (importOriginal) => ({
...(await importOriginal<object>()),
paidSeatCount: vi.fn(),
}));
vi.mock("@/src/modules/courses/lib/course-spots", async (importOriginal) => ({
...(await importOriginal<object>()),
getCourseSpots: vi.fn(),
getBookedSpotIds: vi.fn(),
getHeldSpotIds: vi.fn(),
}));
import { bookCourseWithPass } from "@/src/modules/products/actions/pass-booking-actions";
import { prisma } from "@/src/lib/prisma";
import { getUserData } from "@/src/lib/auth/getUser";
import { sendMail } from "@/src/lib/mail/resend";
import { notifyManagerOfBooking } from "@/src/modules/courses/lib/notify-booking";
import { paidSeatCount } from "@/src/modules/courses/lib/seat-counts";
import {
getBookedSpotIds,
getCourseSpots,
getHeldSpotIds,
} from "@/src/modules/courses/lib/course-spots";
const course = {
id: "course-1",
name: "Mat Pilates",
date: new Date("2099-01-01"),
timeFrom: "10:00",
timeTo: "11:00",
status: "published",
sport: ["pilates"],
maxParticipants: 10,
spotBooking: false,
room: "room-1",
coverImage: null,
businessId: "biz-1",
business: { isPublic: true, name: "Studio", address: "Weg 1", branding: null },
};
const pass = {
id: "pass-1",
userId: "user-1",
businessId: "biz-1",
name: "10er-Karte",
sports: [] as string[],
status: "active",
validUntil: null as Date | null,
creditsRemaining: 5,
};
const book = (overrides: Partial<Parameters<typeof bookCourseWithPass>[0]> = {}) =>
bookCourseWithPass({ courseId: "course-1", passId: "pass-1", seats: 1, ...overrides });
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(getUserData).mockResolvedValue({
id: "user-1",
email: "kunde@example.com",
name: "Anna",
role: "user",
} as never);
tx.course.findUnique.mockResolvedValue(course);
tx.customerPass.findUnique.mockResolvedValue(pass);
tx.customerPass.updateMany.mockResolvedValue({ count: 1 });
tx.courseBooking.findUnique.mockResolvedValue(null);
tx.courseBooking.upsert.mockResolvedValue({});
vi.mocked(paidSeatCount).mockResolvedValue(3);
vi.mocked(getCourseSpots).mockResolvedValue(null);
vi.mocked(prisma.courseWaitlistEntry.deleteMany).mockResolvedValue({ count: 0 } as never);
vi.mocked(sendMail).mockResolvedValue(undefined as never);
vi.mocked(notifyManagerOfBooking).mockResolvedValue(undefined);
});
describe("bookCourseWithPass", () => {
it("bucht, zieht die Einheiten atomar ab und legt eine Kartenbuchung ohne Zahlung an", async () => {
const result = await book({ seats: 2 });
expect(result).toEqual({ success: true, seats: 2, creditsRemaining: 3 });
expect(tx.customerPass.updateMany).toHaveBeenCalledWith({
where: { id: "pass-1", status: "active", creditsRemaining: { gte: 2 } },
data: { creditsRemaining: { decrement: 2 } },
});
const { create } = tx.courseBooking.upsert.mock.calls[0][0];
expect(create).toMatchObject({
courseId: "course-1",
userId: "user-1",
paymentStatus: "paid",
passId: "pass-1",
seats: 2,
amountPaid: null,
stripePaymentIntentId: null,
});
expect(sendMail).toHaveBeenCalled();
});
it("lehnt Gäste und Team-Accounts ab", async () => {
vi.mocked(getUserData).mockResolvedValue({ id: "m1", role: "manager" } as never);
expect((await book()).success).toBe(false);
expect(prisma.$transaction).not.toHaveBeenCalled();
});
it("lehnt die Karte einer anderen Person ab", async () => {
tx.customerPass.findUnique.mockResolvedValue({ ...pass, userId: "someone-else" });
expect(await book()).toMatchObject({ success: false, error: "Karte nicht gefunden." });
expect(tx.customerPass.updateMany).not.toHaveBeenCalled();
});
it("lehnt eine Karte ab, die nicht für diese Kursart gilt", async () => {
tx.customerPass.findUnique.mockResolvedValue({ ...pass, sports: ["yoga"] });
expect(await book()).toMatchObject({
success: false,
error: "Diese Karte gilt nicht für diesen Kurs.",
});
});
it("lehnt eine abgelaufene Karte ab", async () => {
tx.customerPass.findUnique.mockResolvedValue({ ...pass, validUntil: new Date("2000-01-01") });
expect((await book()).success).toBe(false);
});
it("lehnt mehr Plätze ab, als Einheiten übrig sind", async () => {
tx.customerPass.findUnique.mockResolvedValue({ ...pass, creditsRemaining: 1 });
expect(await book({ seats: 2 })).toMatchObject({
success: false,
error: "Auf Ihrer Karte ist nur noch 1 Einheit frei.",
});
});
it("lehnt ab, wenn der Kurs voll ist", async () => {
vi.mocked(paidSeatCount).mockResolvedValue(10);
expect(await book()).toMatchObject({
success: false,
error: "Dieser Kurs ist bereits ausgebucht.",
});
expect(tx.customerPass.updateMany).not.toHaveBeenCalled();
});
it("überschreibt keine frühere, bezahlte Buchung mit ihren Zahlungsbelegen", async () => {
tx.courseBooking.findUnique.mockResolvedValue({
paymentStatus: "refunded",
stripePaymentIntentId: "pi_old",
});
expect((await book()).success).toBe(false);
expect(tx.courseBooking.upsert).not.toHaveBeenCalled();
});
it("lehnt einen Platz ab, den gerade jemand anderes im Checkout hält", async () => {
vi.mocked(getCourseSpots).mockResolvedValue([
{ id: "r1", type: "reformer", x: 0, y: 0, rotation: 0, label: "Reformer 1" },
] as never);
vi.mocked(getBookedSpotIds).mockResolvedValue(new Set());
vi.mocked(getHeldSpotIds).mockResolvedValue(new Set(["r1"]));
const result = await book({ spotIds: ["r1"] });
expect(result).toMatchObject({ success: false, spotUnavailable: true });
expect(tx.customerPass.updateMany).not.toHaveBeenCalled();
});
it("meldet, wenn eine gleichzeitige Buchung die Einheiten schon verbraucht hat", async () => {
tx.customerPass.updateMany.mockResolvedValue({ count: 0 });
expect((await book()).success).toBe(false);
expect(tx.courseBooking.upsert).not.toHaveBeenCalled();
});
});

View file

@ -0,0 +1,47 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
vi.mock("next/image", () => ({ default: () => null }));
import PassCard from "@/src/modules/products/ui/components/PassCard";
import type { CardDesign } from "@/src/modules/products/lib/card-design";
const design: CardDesign = {
style: "solid",
color: "#112233",
secondaryColor: "#445566",
imageUrl: null,
};
const baseProps = {
design,
studioName: "Studio Mia",
logoUrl: null,
title: "10er-Karte",
credits: 10,
validity: "6 Monate gültig",
};
describe("PassCard", () => {
it("zeigt als Produkt alle Einheiten", () => {
render(<PassCard {...baseProps} />);
expect(screen.getByText("10 Einheiten")).toBeInTheDocument();
expect(screen.getByText("6 Monate gültig")).toBeInTheDocument();
});
it("zeigt auf einer gekauften Karte die übrigen Einheiten und den Namen", () => {
const { container } = render(
<PassCard {...baseProps} remaining={7} holderName="Anna Müller" />
);
expect(screen.getByText("7 von 10 frei")).toBeInTheDocument();
expect(screen.getByText("Anna Müller")).toBeInTheDocument();
// 7 gefüllte von 10 Punkten
expect(container.querySelectorAll("span.bg-current")).toHaveLength(7);
});
it("zeigt bei vielen Einheiten einen Balken statt Punkten", () => {
const { container } = render(<PassCard {...baseProps} credits={30} remaining={15} />);
expect(container.querySelectorAll("span.rounded-full")).toHaveLength(0);
expect(container.querySelector('[style*="width: 50%"]')).not.toBeNull();
});
});

View file

@ -0,0 +1,58 @@
import { describe, it, expect } from "vitest";
import {
canRedeemPass,
passCoversCourse,
passDisplayStatus,
passRevenueCents,
validUntilFrom,
} from "@/src/modules/products/lib/pass-rules";
const now = new Date("2026-10-02T12:00:00Z");
const pass = { status: "active", validUntil: null as Date | null, creditsRemaining: 3 };
describe("passCoversCourse", () => {
it("gilt ohne Kursarten für alle Kurse", () => {
expect(passCoversCourse([], ["yoga"])).toBe(true);
});
it("gilt nur für Kurse mit mindestens einer passenden Kursart", () => {
expect(passCoversCourse(["pilates"], ["pilates", "reformer"])).toBe(true);
expect(passCoversCourse(["pilates"], ["yoga"])).toBe(false);
});
});
describe("canRedeemPass", () => {
it("braucht eine aktive, nicht abgelaufene Karte mit genug Einheiten", () => {
expect(canRedeemPass(pass, 3, now)).toBe(true);
expect(canRedeemPass(pass, 4, now)).toBe(false);
expect(canRedeemPass({ ...pass, status: "ended" }, 1, now)).toBe(false);
expect(canRedeemPass({ ...pass, validUntil: new Date("2026-10-01") }, 1, now)).toBe(false);
});
});
describe("passDisplayStatus", () => {
it("zeigt erstattet und beendet vor abgelaufen", () => {
const expired = new Date("2026-01-01");
expect(passDisplayStatus({ ...pass, status: "refunded", validUntil: expired }, now)).toBe(
"refunded"
);
expect(passDisplayStatus({ ...pass, validUntil: expired }, now)).toBe("expired");
expect(passDisplayStatus({ ...pass, creditsRemaining: 0 }, now)).toBe("used_up");
expect(passDisplayStatus(pass, now)).toBe("active");
});
});
describe("passRevenueCents", () => {
it("zählt den Kartenpreis ohne Servicegebühr, nach Erstattung nichts", () => {
expect(passRevenueCents({ status: "active", amountPaid: 12500, platformFee: 500 })).toBe(12000);
expect(passRevenueCents({ status: "refunded", amountPaid: 12500, platformFee: 500 })).toBe(0);
expect(passRevenueCents({ status: "active", amountPaid: null, platformFee: null })).toBe(0);
});
});
describe("validUntilFrom", () => {
it("rechnet Monate ab dem Kauf, ohne Gültigkeit unbegrenzt", () => {
expect(validUntilFrom(now, 6)?.toISOString()).toBe("2027-04-02T12:00:00.000Z");
expect(validUntilFrom(now, null)).toBeNull();
});
});

View file

@ -0,0 +1,127 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("@/src/lib/prisma", () => ({
prisma: {
customerPass: { findUnique: vi.fn(), create: vi.fn() },
product: { findUnique: vi.fn() },
user: { findUnique: vi.fn() },
},
}));
vi.mock("@/src/lib/stripe", () => ({
stripe: { paymentIntents: { retrieve: vi.fn() } },
onAccount: (id: string | null) => (id ? { stripeAccount: id } : undefined),
}));
vi.mock("@/src/lib/stripe-fee", () => ({ recordStripeFee: vi.fn() }));
vi.mock("@/src/lib/stripe-refund", () => ({ refundStripePayment: vi.fn() }));
vi.mock("@/src/lib/mail/resend", () => ({ sendMail: vi.fn() }));
vi.mock("@/src/modules/notifications/lib/create-notification", () => ({
notifyBusinessManager: vi.fn(),
}));
import {
handlePassCheckoutCompleted,
isPassCheckout,
} from "@/src/modules/products/lib/pass-webhook";
import { prisma } from "@/src/lib/prisma";
import { stripe } from "@/src/lib/stripe";
import { recordStripeFee } from "@/src/lib/stripe-fee";
import { refundStripePayment } from "@/src/lib/stripe-refund";
import { sendMail } from "@/src/lib/mail/resend";
import { notifyBusinessManager } from "@/src/modules/notifications/lib/create-notification";
const session = {
id: "cs_1",
payment_status: "paid",
payment_intent: "pi_1",
amount_total: 12500,
customer_details: null,
metadata: { kind: "pass", productId: "prod-1", userId: "user-1" },
};
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(prisma.customerPass.findUnique).mockResolvedValue(null);
vi.mocked(prisma.customerPass.create).mockResolvedValue({ id: "pass-1" } as never);
vi.mocked(prisma.product.findUnique).mockResolvedValue({
id: "prod-1",
name: "10er-Karte",
credits: 10,
validityMonths: 6,
sports: ["pilates"],
businessId: "biz-1",
business: { name: "Studio", branding: null },
} as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
id: "user-1",
email: "Kunde@Example.com",
name: "Anna",
} as never);
vi.mocked(stripe.paymentIntents.retrieve).mockResolvedValue({
application_fee_amount: 500,
} as never);
vi.mocked(recordStripeFee).mockResolvedValue(undefined);
vi.mocked(sendMail).mockResolvedValue(undefined as never);
vi.mocked(notifyBusinessManager).mockResolvedValue(undefined as never);
});
describe("isPassCheckout", () => {
it("erkennt Kartenkäufe an den Metadaten", () => {
expect(isPassCheckout(session as never)).toBe(true);
expect(isPassCheckout({ metadata: { courseId: "c1" } } as never)).toBe(false);
});
});
describe("handlePassCheckoutCompleted", () => {
it("legt die Karte mit Momentaufnahme des Produkts und Ablaufdatum an", async () => {
await handlePassCheckoutCompleted(session as never, "acct_studio");
const { data } = vi.mocked(prisma.customerPass.create).mock.calls[0][0];
expect(data).toMatchObject({
productId: "prod-1",
businessId: "biz-1",
userId: "user-1",
email: "kunde@example.com",
name: "10er-Karte",
sports: ["pilates"],
creditsTotal: 10,
creditsRemaining: 10,
source: "purchase",
amountPaid: 12500,
platformFee: 500,
priceCents: 12000,
stripeAccountId: "acct_studio",
stripeSessionId: "cs_1",
});
expect(data.validUntil).toBeInstanceOf(Date);
expect(recordStripeFee).toHaveBeenCalledWith("pi_1", "acct_studio");
expect(sendMail).toHaveBeenCalled();
expect(vi.mocked(notifyBusinessManager).mock.calls[0][1].type).toBe("pass.sold");
});
it("legt bei erneuter Zustellung derselben Session keine zweite Karte an", async () => {
vi.mocked(prisma.customerPass.findUnique).mockResolvedValue({ id: "pass-1" } as never);
await handlePassCheckoutCompleted(session as never, "acct_studio");
expect(prisma.customerPass.create).not.toHaveBeenCalled();
});
it("wartet bei SEPA, bis die Zahlung angekommen ist", async () => {
await handlePassCheckoutCompleted({ ...session, payment_status: "unpaid" } as never, "acct");
expect(prisma.customerPass.create).not.toHaveBeenCalled();
});
it("erstattet, wenn das Produkt inzwischen gelöscht wurde", async () => {
vi.mocked(prisma.product.findUnique).mockResolvedValue(null);
vi.mocked(refundStripePayment).mockResolvedValue({
success: true,
stripeRefundId: "re_1",
amountRefundedCents: 12500,
});
await handlePassCheckoutCompleted(session as never, "acct_studio");
expect(prisma.customerPass.create).not.toHaveBeenCalled();
expect(refundStripePayment).toHaveBeenCalledWith(
expect.objectContaining({ paymentIntentId: "pi_1", refundApplicationFee: true })
);
});
});

View file

@ -0,0 +1,159 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("@/src/lib/prisma", () => ({
prisma: {
business: { findUnique: vi.fn() },
course: { findMany: vi.fn() },
customerPass: { count: vi.fn() },
product: {
findMany: vi.fn(),
findFirst: vi.fn(),
create: vi.fn(),
update: vi.fn(),
deleteMany: vi.fn(),
},
},
}));
vi.mock("@/src/lib/auth/getUser", () => ({
requireManager: vi.fn(),
}));
vi.mock("next/cache", () => ({
revalidatePath: vi.fn(),
}));
import { prisma } from "@/src/lib/prisma";
import { requireManager } from "@/src/lib/auth/getUser";
import {
createProduct,
deleteProduct,
getProductsPageData,
updateProduct,
} from "@/src/modules/products/actions/product-actions";
import type { ProductInput } from "@/src/modules/products/schemas/product-schema";
const business = vi.mocked(prisma.business);
const course = vi.mocked(prisma.course);
const product = vi.mocked(prisma.product);
const input: ProductInput = {
name: "10er-Karte",
description: null,
credits: 10,
priceCents: 12000,
validityMonths: 6,
sports: [],
design: { style: "solid", color: "#112233", secondaryColor: "#445566", imageUrl: null },
isActive: true,
};
const row = { id: "p1", ...input, design: input.design, _count: { passes: 0 } };
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(requireManager).mockResolvedValue({ id: "mgr-1" } as never);
business.findUnique.mockResolvedValue({
managerId: "mgr-1",
name: "Studio Mia",
branding: null,
} as never);
vi.mocked(prisma.customerPass.count).mockResolvedValue(0 as never);
});
describe("Berechtigung", () => {
beforeEach(() => {
business.findUnique.mockResolvedValue({
managerId: "someone-else",
name: "Fremdes Studio",
branding: null,
} as never);
});
it("lädt keine Produkte eines fremden Business", async () => {
expect((await getProductsPageData("b1")).success).toBe(false);
expect(product.findMany).not.toHaveBeenCalled();
});
it("legt keine Karte für ein fremdes Business an", async () => {
expect((await createProduct("b1", input)).success).toBe(false);
expect(product.create).not.toHaveBeenCalled();
});
it("löscht keine Karte eines fremden Business", async () => {
expect((await deleteProduct("b1", "p1")).success).toBe(false);
expect(product.deleteMany).not.toHaveBeenCalled();
});
});
describe("getProductsPageData", () => {
it("sammelt die Kursarten aus Kursen und Produkten und fällt beim Branding auf den Studionamen zurück", async () => {
product.findMany.mockResolvedValue([{ ...row, sports: ["reformer"] }] as never);
course.findMany.mockResolvedValue([
{ sport: ["yoga", "pilates"] },
{ sport: ["pilates"] },
] as never);
const result = await getProductsPageData("b1");
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.sports).toEqual(["pilates", "reformer", "yoga"]);
expect(result.data.branding.companyName).toBe("Studio Mia");
expect(result.data.products).toHaveLength(1);
}
});
});
describe("createProduct", () => {
it("speichert die validierte Karte am Business", async () => {
product.create.mockResolvedValue(row as never);
const result = await createProduct("b1", input);
expect(result.success).toBe(true);
expect(product.create.mock.calls[0][0].data).toMatchObject({
businessId: "b1",
name: "10er-Karte",
credits: 10,
priceCents: 12000,
});
});
it("gibt die erste Validierungsmeldung zurück", async () => {
const result = await createProduct("b1", { ...input, credits: 0 });
expect(result).toEqual({ success: false, error: "Mindestens 1 Einheit" });
expect(product.create).not.toHaveBeenCalled();
});
});
describe("updateProduct", () => {
it("ändert nur Karten, die zu diesem Business gehören", async () => {
product.findFirst.mockResolvedValue(null);
const result = await updateProduct("b1", "p-other", input);
expect(result.success).toBe(false);
expect(product.findFirst.mock.calls[0][0]?.where).toEqual({ id: "p-other", businessId: "b1" });
expect(product.update).not.toHaveBeenCalled();
});
});
describe("deleteProduct", () => {
it("löscht keine Karte, die schon verkauft oder vergeben wurde", async () => {
vi.mocked(prisma.customerPass.count).mockResolvedValue(3 as never);
const result = await deleteProduct("b1", "p1");
expect(result.success).toBe(false);
expect(product.deleteMany).not.toHaveBeenCalled();
});
it("meldet, wenn die Karte nicht (mehr) zu diesem Business gehört", async () => {
product.deleteMany.mockResolvedValue({ count: 0 } as never);
expect(await deleteProduct("b1", "p1")).toEqual({
success: false,
error: "Karte nicht gefunden.",
});
});
});

View file

@ -0,0 +1,58 @@
import { describe, it, expect } from "vitest";
import { productSchema, type ProductInput } from "@/src/modules/products/schemas/product-schema";
import { cardServiceFeeCents, pricePerUnitCents } from "@/src/modules/products/lib/card-pricing";
const valid: ProductInput = {
name: "10er-Karte",
description: "",
credits: 10,
priceCents: 12000,
validityMonths: 6,
sports: ["pilates", "pilates", "yoga"],
design: { style: "solid", color: "#112233", secondaryColor: "#445566", imageUrl: null },
isActive: true,
};
describe("productSchema", () => {
it("normalisiert leere Beschreibung zu null und entfernt doppelte Kursarten", () => {
const result = productSchema.safeParse(valid);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.description).toBeNull();
expect(result.data.sports).toEqual(["pilates", "yoga"]);
}
});
it.each([
["0 Einheiten", { credits: 0 }],
["101 Einheiten", { credits: 101 }],
["halbe Einheiten", { credits: 2.5 }],
["Preis unter 1 €", { priceCents: 99 }],
["leerer Name", { name: " " }],
["0 Monate gültig", { validityMonths: 0 }],
])("lehnt %s ab", (_label, patch) => {
expect(productSchema.safeParse({ ...valid, ...patch }).success).toBe(false);
});
it("meldet leere Zahlenfelder auf Deutsch statt als NaN", () => {
const result = productSchema.safeParse({ ...valid, priceCents: Number.NaN });
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0].message).toBe("Bitte gib einen Preis an");
}
});
it("erlaubt unbegrenzte Gültigkeit", () => {
expect(productSchema.safeParse({ ...valid, validityMonths: null }).success).toBe(true);
});
});
describe("card-pricing", () => {
it("berechnet die Servicegebühr pro Einheit", () => {
expect(cardServiceFeeCents(10, 50)).toBe(500);
});
it("rundet den Preis pro Einheit auf ganze Cents", () => {
expect(pricePerUnitCents(10000, 3)).toBe(3333);
});
});

View file

@ -0,0 +1,135 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("@/src/lib/auth/getUser", () => ({ requireManager: vi.fn() }));
vi.mock("@/src/lib/prisma", () => ({
prisma: {
courseBooking: { findMany: vi.fn() },
customerPass: { findMany: vi.fn() },
},
}));
import { getZahlungen } from "@/src/modules/zahlungen/actions/zahlungen-actions";
import { requireManager } from "@/src/lib/auth/getUser";
import { prisma } from "@/src/lib/prisma";
const booking = (id: string, createdAt: string) => ({
id,
createdAt: new Date(createdAt),
paymentStatus: "paid",
amountPaid: 2950,
platformFee: 50,
platformFeeOnTop: true,
stripeFee: 69,
stripeAccountId: "acct_studio",
refundedAmount: null,
refundedFeeAmount: null,
seatsPurchased: 1,
invoiceNumber: null,
course: { name: "Yoga", business: { id: "biz-1", name: "Studio" } },
user: { name: "Anna", email: "anna@example.com" },
guestLead: null,
});
const pass = (id: string, createdAt: string, status = "active") => ({
id,
createdAt: new Date(createdAt),
name: "10er-Karte",
email: "ben@example.com",
status,
amountPaid: 18500,
platformFee: 500,
stripeFee: 302,
invoiceNumber: null,
user: { name: "Ben" },
business: { id: "biz-1", name: "Studio" },
});
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(requireManager).mockResolvedValue({ businesses: [{ id: "biz-1" }] } as never);
});
describe("getZahlungen – Kursbuchungen und Kartenkäufe", () => {
it("mischt beide nach Datum und zeigt den Kartenpreis ohne Servicegebühr", async () => {
vi.mocked(prisma.courseBooking.findMany).mockResolvedValue([
booking("b2", "2026-10-03T10:00:00Z"),
booking("b1", "2026-10-01T10:00:00Z"),
] as never);
vi.mocked(prisma.customerPass.findMany).mockResolvedValue([
pass("p1", "2026-10-02T10:00:00Z"),
] as never);
const result = await getZahlungen({});
expect(result.success).toBe(true);
if (!result.success) return;
expect(result.zahlungen.map((z) => z.id)).toEqual(["b2", "p1", "b1"]);
expect(result.zahlungen[1]).toMatchObject({
kind: "pass",
courseName: "10er-Karte",
amountPaid: 18000,
stripeFeeCents: 302,
payoutCents: 17698,
paymentStatus: "paid",
});
expect(result.nextCursor).toBeNull();
});
it("blättert über beide Quellen mit Zeitpunkt und ID der letzten Zeile", async () => {
vi.mocked(prisma.courseBooking.findMany).mockResolvedValue([
booking("b2", "2026-10-03T10:00:00Z"),
booking("b1", "2026-10-01T10:00:00Z"),
] as never);
vi.mocked(prisma.customerPass.findMany).mockResolvedValue([
pass("p1", "2026-10-02T10:00:00Z"),
] as never);
const first = await getZahlungen({ limit: 2 });
if (!first.success) throw new Error(first.error);
expect(first.zahlungen.map((z) => z.id)).toEqual(["b2", "p1"]);
expect(first.nextCursor).toBe("2026-10-02T10:00:00.000Z_p1");
vi.mocked(prisma.courseBooking.findMany).mockResolvedValue([
booking("b1", "2026-10-01T10:00:00Z"),
] as never);
vi.mocked(prisma.customerPass.findMany).mockResolvedValue([] as never);
await getZahlungen({ limit: 2, cursor: first.nextCursor! });
const after = {
OR: [
{ createdAt: { lt: new Date("2026-10-02T10:00:00Z") } },
{ createdAt: new Date("2026-10-02T10:00:00Z"), id: { lt: "p1" } },
],
};
expect(vi.mocked(prisma.courseBooking.findMany).mock.calls[1][0]?.where).toMatchObject({
AND: [expect.anything(), after],
});
expect(vi.mocked(prisma.customerPass.findMany).mock.calls[1][0]?.where).toMatchObject({
AND: [expect.anything(), after],
});
});
it("lässt Kartenkäufe weg, wenn nach einem Status gefiltert wird, den Karten nicht haben", async () => {
vi.mocked(prisma.courseBooking.findMany).mockResolvedValue([] as never);
await getZahlungen({ status: "partially_refunded" });
expect(prisma.customerPass.findMany).not.toHaveBeenCalled();
});
it("zeigt erstattete Kartenkäufe als erstattet, ohne Rechnung", async () => {
vi.mocked(prisma.courseBooking.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.customerPass.findMany).mockResolvedValue([
pass("p1", "2026-10-02T10:00:00Z", "refunded"),
] as never);
const result = await getZahlungen({ status: "refunded" });
if (!result.success) throw new Error(result.error);
expect(result.zahlungen[0].paymentStatus).toBe("pass_refunded");
expect(vi.mocked(prisma.customerPass.findMany).mock.calls[0][0]?.where).toMatchObject({
status: "refunded",
source: "purchase",
});
});
});

View file

@ -7,6 +7,7 @@ vi.mock("@/src/lib/prisma", () => ({
prisma: {
courseBooking: { findMany: vi.fn(), groupBy: vi.fn() },
course: { findMany: vi.fn() },
customerPass: { findMany: vi.fn() },
},
}));
// Cache ausschalten - der Test soll die Rechnung sehen, nicht den Cache.
@ -25,6 +26,7 @@ beforeEach(() => {
businesses: [{ id: "biz-1" }],
} as never);
vi.mocked(prisma.courseBooking.groupBy).mockResolvedValue([] as never);
vi.mocked(prisma.customerPass.findMany).mockResolvedValue([] as never);
});
describe("getZahlungMetrics – Umsatz diesen Monat", () => {
@ -51,4 +53,29 @@ describe("getZahlungMetrics – Umsatz diesen Monat", () => {
expect(result.metrics.revenueThisMonth).toBe(2900 + 2900 + 1450);
expect(result.metrics.paidCount).toBe(3);
});
it("zählt Kartenverkäufe als Umsatz, erstattete nicht", async () => {
vi.mocked(prisma.courseBooking.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.customerPass.findMany).mockResolvedValue([
{ status: "active", amountPaid: 12500, platformFee: 500 },
{ status: "refunded", amountPaid: 6250, platformFee: 250 },
] as never);
const result = await getZahlungMetrics({});
expect(result.success).toBe(true);
if (!result.success) return;
expect(result.metrics.revenueThisMonth).toBe(12000);
expect(result.metrics.paidCount).toBe(1);
});
it("zählt Kartenbuchungen nicht als bezahlte Zahlung", async () => {
vi.mocked(prisma.courseBooking.findMany).mockResolvedValue([] as never);
await getZahlungMetrics({});
expect(vi.mocked(prisma.courseBooking.findMany).mock.calls[0][0]?.where).toMatchObject({
passId: null,
});
});
});