mirror of
https://github.com/Since-3/Kuma.git
synced 2026-10-12 21:41:17 +00:00
Merge pull request #173 from Since-3/feature/cards
added card creation and booking functionality
This commit is contained in:
commit
a9f248c91d
89 changed files with 7878 additions and 316 deletions
23
prisma/migrations/20261004100000_products/migration.sql
Normal file
23
prisma/migrations/20261004100000_products/migration.sql
Normal 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;
|
||||
|
|
@ -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 SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "PassAdjustment" ADD CONSTRAINT "PassAdjustment_passId_fkey" FOREIGN KEY ("passId") REFERENCES "CustomerPass"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
|
|
@ -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,94 @@ 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 = noch ohne Konto: vom Studio an eine E-Mail vergeben, oder das Konto
|
||||
// wurde gelöscht. Die Karte hängt sich an das Konto, sobald sich jemand mit
|
||||
// dieser Adresse anmeldet. Ein gelöschtes Konto nimmt die Karte bewusst nicht
|
||||
// mit: Kauf, Rechnung und Erstattung müssen erhalten bleiben.
|
||||
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) | "refunding" (Erstattung angestoßen, Ausgang offen) | "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: SetNull)
|
||||
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" | "refund_failed"
|
||||
creditsDelta Int?
|
||||
previousValidUntil DateTime?
|
||||
newValidUntil DateTime?
|
||||
reason String?
|
||||
pass CustomerPass @relation(fields: [passId], references: [id], onDelete: Cascade)
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([passId])
|
||||
}
|
||||
|
|
|
|||
31
src/app/(main)/courses/myCards/page.tsx
Normal file
31
src/app/(main)/courses/myCards/page.tsx
Normal 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",
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
38
src/app/(main)/settings/products/page.tsx
Normal file
38
src/app/(main)/settings/products/page.tsx
Normal 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",
|
||||
};
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
153
src/app/api/stripe/checkout/pass/route.ts
Normal file
153
src/app/api/stripe/checkout/pass/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,11 @@ 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";
|
||||
import { reconcilePassRefund } from "@/src/modules/products/lib/pass-refund";
|
||||
|
||||
/**
|
||||
* Sucht einen registrierten User anhand der E-Mail, case-insensitive.
|
||||
|
|
@ -58,11 +63,13 @@ 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-
|
||||
* Reservierung und Platz-Hold freigeben, Pending-Buchung aufräumen.
|
||||
* - charge.updated → Stripe-Gebühr nachtragen, sobald Stripe sie kennt.
|
||||
* - charge.refunded → offene Kartenerstattung bestätigen (siehe pass-refund.ts).
|
||||
* - account.updated → Stripe Connect Account-Status synchronisieren.
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
|
|
@ -89,12 +96,31 @@ 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.refunded": {
|
||||
// Bestätigt eine Kartenerstattung, deren Ausgang beim Anstoßen offen
|
||||
// blieb (siehe pass-refund.ts). Kursbuchungen brauchen das nicht.
|
||||
if (stripeAccountId) {
|
||||
await reconcilePassRefund(event.data.object);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "charge.updated": {
|
||||
|
|
@ -606,6 +632,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 +743,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 +1017,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,
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
58
src/app/api/zahlungen/passes/[passId]/invoice/route.tsx
Normal file
58
src/app/api/zahlungen/passes/[passId]/invoice/route.tsx
Normal 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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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) }))
|
||||
: []
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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!
|
||||
|
||||
|
|
|
|||
122
src/lib/mail/templates/pass-booking-cancellation.ts
Normal file
122
src/lib/mail/templates/pass-booking-cancellation.ts
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
/**
|
||||
* 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;
|
||||
/**
|
||||
* Warum Einheiten verfallen: Frist verstrichen ("policy") oder die Karte ist
|
||||
* nicht mehr gültig ("pass_inactive", z.B. abgelaufen). null = zurückgebucht.
|
||||
*/
|
||||
forfeitReason?: "policy" | "pass_inactive" | null;
|
||||
companyName?: string;
|
||||
branding?: EmailBranding;
|
||||
}
|
||||
|
||||
export function generatePassBookingCancellationEmail({
|
||||
courseName,
|
||||
businessName,
|
||||
passName,
|
||||
reason,
|
||||
seatsCancelled,
|
||||
seatsRemaining,
|
||||
creditsReturned,
|
||||
forfeitReason = null,
|
||||
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 verfaellt = seatsCancelled === 1 ? "verfällt" : "verfallen";
|
||||
const creditsText = creditsReturned
|
||||
? `${units} ${seatsCancelled === 1 ? "wurde" : "wurden"} Ihrer Karte „${passName}“ wieder gutgeschrieben.`
|
||||
: forfeitReason === "pass_inactive"
|
||||
? `Ihre Karte „${passName}“ ist nicht mehr gültig, deshalb ${verfaellt} ${units}. Bei Fragen wenden Sie sich bitte an ${businessName}.`
|
||||
: `Gemäß den Stornierungsbedingungen von ${businessName} ${verfaellt} ${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 };
|
||||
}
|
||||
150
src/lib/mail/templates/pass-confirmation.ts
Normal file
150
src/lib/mail/templates/pass-confirmation.ts
Normal 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 };
|
||||
}
|
||||
53
src/lib/stripe-checkout.ts
Normal file
53
src/lib/stripe-checkout.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -2,7 +2,32 @@ import { stripe } from "@/src/lib/stripe";
|
|||
|
||||
export type RefundResult =
|
||||
| { success: true; stripeRefundId: string; amountRefundedCents: number }
|
||||
| { success: false; error: string };
|
||||
| {
|
||||
success: false;
|
||||
error: string;
|
||||
/**
|
||||
* true = Stripe hat die Anfrage sicher abgelehnt, es ist kein Geld
|
||||
* geflossen. false = Ausgang unbekannt (Netzwerk, Zeitüberschreitung,
|
||||
* Stripe-Serverfehler) - die Erstattung kann trotzdem durchgegangen sein.
|
||||
*/
|
||||
definitive: boolean;
|
||||
/** Stripe-Fehlercode, z.B. "charge_already_refunded". */
|
||||
code?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Fehlerarten, bei denen Stripe die Anfrage nachweislich nicht ausgeführt hat:
|
||||
* abgelehnt wegen Karte, ungültiger Anfrage (z.B. zu wenig Guthaben), fehlender
|
||||
* Berechtigung oder Rate Limit. Alles andere - Verbindungsabbruch, Timeout,
|
||||
* StripeAPIError - lässt offen, ob die Erstattung durchging.
|
||||
*/
|
||||
const DEFINITIVE_ERROR_TYPES = new Set([
|
||||
"StripeCardError",
|
||||
"StripeInvalidRequestError",
|
||||
"StripeAuthenticationError",
|
||||
"StripePermissionError",
|
||||
"StripeRateLimitError",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Erstattet eine Kurszahlung über Stripe.
|
||||
|
|
@ -58,9 +83,12 @@ export async function refundStripePayment(params: {
|
|||
};
|
||||
} catch (err) {
|
||||
console.error(`Stripe-Refund für PaymentIntent ${paymentIntentId} fehlgeschlagen:`, err);
|
||||
const { type, code } = err as { type?: string; code?: string };
|
||||
return {
|
||||
success: false,
|
||||
error: err instanceof Error ? err.message : "Stripe-Rückerstattung fehlgeschlagen",
|
||||
definitive: type !== undefined && DEFINITIVE_ERROR_TYPES.has(type),
|
||||
...(code ? { code } : {}),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -12,6 +12,13 @@ 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";
|
||||
|
||||
/** Die Buchung wurde parallel schon storniert - rollt eine bereits gebuchte Gutschrift zurück. */
|
||||
class CancellationConflict extends Error {}
|
||||
|
||||
export type CancelBookingResult =
|
||||
| {
|
||||
|
|
@ -20,6 +27,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 +152,8 @@ export async function executeBookingCancellation(
|
|||
refundedAmount: true,
|
||||
refundedFeeAmount: true,
|
||||
refundedAt: true,
|
||||
passId: true,
|
||||
pass: { select: { name: true } },
|
||||
course: {
|
||||
select: {
|
||||
id: true,
|
||||
|
|
@ -160,6 +171,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 +361,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 +452,169 @@ 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.
|
||||
*
|
||||
* Ausnahme: Die Karte selbst ist inzwischen abgelaufen, beendet oder erstattet.
|
||||
* Dann verfallen die Einheiten auch bei einer Kursabsage - der Kunde könnte sie
|
||||
* nicht mehr einlösen. Das Studio kann im Kunden-Detail verlängern und
|
||||
* gutschreiben, wenn es den Kunden entschädigen will. Mail und Storno-Dialog
|
||||
* nennen diesen Grund ausdrücklich.
|
||||
*
|
||||
* 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 policyAllowsReturn = eligibility.refundPercent >= 100;
|
||||
|
||||
let creditsReturned: boolean;
|
||||
try {
|
||||
creditsReturned = await prisma.$transaction(async (tx) => {
|
||||
const now = new Date();
|
||||
// Gutschrift nur auf eine Karte, die noch einlösbar ist - geprüft in
|
||||
// derselben Anweisung, damit eine gleichzeitig beendete Karte nichts
|
||||
// mehr bekommt.
|
||||
const returned =
|
||||
policyAllowsReturn &&
|
||||
(
|
||||
await tx.customerPass.updateMany({
|
||||
where: {
|
||||
id: booking.passId,
|
||||
status: "active",
|
||||
OR: [{ validUntil: null }, { validUntil: { gt: now } }],
|
||||
},
|
||||
data: { creditsRemaining: { increment: count } },
|
||||
})
|
||||
).count === 1;
|
||||
|
||||
const claimed = await tx.courseBooking.updateMany({
|
||||
where: { id: bookingId, paymentStatus: "paid", seats: seatsBefore },
|
||||
data: cancelsAll
|
||||
? {
|
||||
paymentStatus: returned ? "refunded" : "cancelled",
|
||||
cancelledBy,
|
||||
...(options?.cancellationReason
|
||||
? { cancellationReason: options.cancellationReason }
|
||||
: {}),
|
||||
}
|
||||
: {
|
||||
seats: seatsBefore - count,
|
||||
spotIds: resolved.value.keptSpotIds,
|
||||
spotLabels: resolved.value.keptSpotLabels,
|
||||
},
|
||||
});
|
||||
if (claimed.count !== 1) throw new CancellationConflict();
|
||||
return returned;
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof CancellationConflict) {
|
||||
return { success: false, error: "Stornierung läuft bereits oder wurde bereits verarbeitet." };
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
const forfeitReason = creditsReturned ? null : policyAllowsReturn ? "pass_inactive" : "policy";
|
||||
|
||||
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,
|
||||
forfeitReason,
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
import { prisma } from "@/src/lib/prisma";
|
||||
import { getUserData, isUser, isManager } from "@/src/lib/auth/getUser";
|
||||
import { isPassActive } from "@/src/modules/products/lib/pass-rules";
|
||||
import { combineDateAndTime, calculateRefundAmountCents } from "./cancellation-policy";
|
||||
import {
|
||||
resolveSeatCancellation,
|
||||
|
|
@ -21,6 +22,17 @@ 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 - wegen der Frist
|
||||
* ("policy") oder weil die Karte nicht mehr gültig ist ("pass_inactive").
|
||||
* null = bezahlte Buchung.
|
||||
*/
|
||||
pass: {
|
||||
name: string;
|
||||
creditsReturned: boolean;
|
||||
forfeitReason: "policy" | "pass_inactive" | null;
|
||||
} | null;
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
@ -54,11 +66,13 @@ export async function previewBookingCancellation(
|
|||
seatsPurchased: true,
|
||||
spotIds: true,
|
||||
spotLabels: true,
|
||||
passId: true,
|
||||
pass: { select: { name: true, status: true, validUntil: 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 +90,38 @@ 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 und die Karte noch einlösbar ist.
|
||||
if (booking.passId) {
|
||||
const now = new Date();
|
||||
const { cancellationAllowed, refundPercent } = await calculateRefundAmountCents({
|
||||
businessId: booking.course.businessId,
|
||||
amountPaidCents: 0,
|
||||
courseStart,
|
||||
now,
|
||||
});
|
||||
const passUsable = booking.pass !== null && isPassActive(booking.pass, now);
|
||||
const creditsReturned = refundPercent >= 100 && passUsable;
|
||||
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,
|
||||
forfeitReason: creditsReturned ? null : passUsable ? "policy" : "pass_inactive",
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Identische Bezugsgröße wie in executeBookingCancellation: der Kursanteil
|
||||
// der stornierten Plätze ohne Servicegebühr. Sonst würde der Dialog eine
|
||||
|
|
@ -98,8 +144,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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
51
src/modules/courses/lib/notify-booking.ts
Normal file
51
src/modules/courses/lib/notify-booking.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
)}
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -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,13 @@ 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;
|
||||
/** Die Karte ist abgelaufen, beendet oder erstattet - Einheiten können nicht zurück. */
|
||||
passCreditsForfeitInactive: (count: number) => string;
|
||||
}
|
||||
> = {
|
||||
user: {
|
||||
|
|
@ -39,6 +48,14 @@ 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.`,
|
||||
passCreditsForfeitInactive: (count) =>
|
||||
`Deine Karte ist nicht mehr gültig, deshalb ${count === 1 ? "verfällt" : "verfallen"} ${unitsLabel(count)}.`,
|
||||
},
|
||||
manager: {
|
||||
description:
|
||||
|
|
@ -48,6 +65,14 @@ 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)}.`,
|
||||
passCreditsForfeitInactive: (count) =>
|
||||
`Die Karte des Kunden ist nicht mehr gültig, deshalb ${count === 1 ? "verfällt" : "verfallen"} ${unitsLabel(count)}. Du kannst sie im Kunden-Detail verlängern und gutschreiben.`,
|
||||
},
|
||||
};
|
||||
|
||||
|
|
@ -144,7 +169,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 +195,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 +268,27 @@ 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>
|
||||
{preview.pass.forfeitReason === "pass_inactive"
|
||||
? copy.passCreditsForfeitInactive(toCancel)
|
||||
: 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">
|
||||
|
|
|
|||
|
|
@ -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", {
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
|
|
|
|||
|
|
@ -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 } },
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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}`],
|
||||
|
|
|
|||
|
|
@ -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." };
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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) ── */}
|
||||
|
|
|
|||
|
|
@ -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[];
|
||||
/** Aktive Karten ohne Konto - vergeben vor der Registrierung oder Konto gelöscht. */
|
||||
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">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))}`} · seit{" "}
|
||||
{formatPassDate(new Date(pass.createdAt))}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<IssuePassDialog
|
||||
key={issueKey}
|
||||
open={issueOpen}
|
||||
onOpenChange={setIssueOpen}
|
||||
options={issueOptions}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
481
src/modules/products/actions/pass-admin-actions.ts
Normal file
481
src/modules/products/actions/pass-admin-actions.ts
Normal file
|
|
@ -0,0 +1,481 @@
|
|||
"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";
|
||||
import { markPassRefunded, restorePassAfterFailedRefund } from "../lib/pass-refund";
|
||||
|
||||
/**
|
||||
* 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" || pass.status === "refunding") {
|
||||
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." };
|
||||
|
||||
// Status und Protokoll gemeinsam - eine beendete Karte ohne Eintrag im
|
||||
// Verlauf wäre später nicht mehr nachvollziehbar.
|
||||
const ended = await prisma.$transaction(async (tx) => {
|
||||
const { count } = await tx.customerPass.updateMany({
|
||||
where: { id: pass.id, status: "active" },
|
||||
data: { status: "ended" },
|
||||
});
|
||||
if (count !== 1) return false;
|
||||
await tx.passAdjustment.create({
|
||||
data: { passId: pass.id, managerId: manager.id, type: "end", reason: reason.data || null },
|
||||
});
|
||||
return true;
|
||||
});
|
||||
if (!ended) return { success: false, error: "Die Karte ist nicht mehr aktiv." };
|
||||
|
||||
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).
|
||||
*
|
||||
* Ablauf (siehe pass-refund.ts): Erst sperren und protokollieren, dann Stripe.
|
||||
* Zurückgesetzt wird nur, wenn Stripe sicher abgelehnt hat. Bleibt der Ausgang
|
||||
* offen, bleibt die Karte gesperrt, bis der Webhook die Erstattung bestätigt
|
||||
* oder der Manager erneut prüft. Ein erneuter Aufruf ist sicher: Der
|
||||
* Idempotency-Key verhindert eine zweite Erstattung, und eine schon erstattete
|
||||
* Zahlung meldet Stripe als charge_already_refunded.
|
||||
*/
|
||||
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." };
|
||||
}
|
||||
|
||||
// Beim ersten Versuch sperren - bei "refunding" lief schon einer, dessen
|
||||
// Ausgang offen ist; dann nur erneut bei Stripe nachfragen.
|
||||
if (pass.status !== "refunding") {
|
||||
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.",
|
||||
};
|
||||
}
|
||||
|
||||
// Sperre und Protokoll gemeinsam: Danach ist die Karte nicht mehr
|
||||
// einlösbar, egal was mit dem Stripe-Aufruf passiert. Die Bindung an den
|
||||
// gelesenen Stand fängt eine Buchung ab, die zwischen der Prüfung oben
|
||||
// und hier Einheiten abgezogen hat.
|
||||
const locked = await prisma.$transaction(async (tx) => {
|
||||
const { count } = await tx.customerPass.updateMany({
|
||||
where: {
|
||||
id: pass.id,
|
||||
status: { in: ["active", "ended"] },
|
||||
creditsRemaining: pass.creditsRemaining,
|
||||
},
|
||||
data: { status: "refunding", creditsRemaining: 0 },
|
||||
});
|
||||
if (count !== 1) return false;
|
||||
await tx.passAdjustment.create({
|
||||
data: {
|
||||
passId: pass.id,
|
||||
managerId: manager.id,
|
||||
type: "refund",
|
||||
creditsDelta: -pass.creditsRemaining,
|
||||
},
|
||||
});
|
||||
return true;
|
||||
});
|
||||
if (!locked) {
|
||||
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 || refund.code === "charge_already_refunded") {
|
||||
await markPassRefunded(
|
||||
pass.id,
|
||||
refund.success ? refund.amountRefundedCents : pass.amountPaid
|
||||
);
|
||||
refreshCustomer(pass.userId);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
if (refund.definitive) {
|
||||
await restorePassAfterFailedRefund(pass.id, manager.id, refund.error);
|
||||
refreshCustomer(pass.userId);
|
||||
return { success: false, error: `Erstattung fehlgeschlagen: ${refund.error}` };
|
||||
}
|
||||
|
||||
refreshCustomer(pass.userId);
|
||||
return {
|
||||
success: false,
|
||||
error:
|
||||
"Stripe hat nicht eindeutig geantwortet. Die Karte bleibt gesperrt, bis die Erstattung bestätigt ist - Stripe meldet das von selbst, oder du prüfst es später erneut.",
|
||||
};
|
||||
} 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;
|
||||
};
|
||||
|
||||
/**
|
||||
* Aktive Karten ohne Konto: vom Studio vergeben, bevor sich der Empfänger
|
||||
* registriert hat, oder gekauft von einem inzwischen gelöschten Konto.
|
||||
*/
|
||||
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(),
|
||||
}));
|
||||
}
|
||||
309
src/modules/products/actions/pass-booking-actions.ts
Normal file
309
src/modules/products/actions/pass-booking-actions.ts
Normal 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)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
227
src/modules/products/actions/product-actions.ts
Normal file
227
src/modules/products/actions/product-actions.ts
Normal 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." };
|
||||
}
|
||||
}
|
||||
105
src/modules/products/lib/card-design.ts
Normal file
105
src/modules/products/lib/card-design.ts
Normal 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]);
|
||||
}
|
||||
}
|
||||
28
src/modules/products/lib/card-format.ts
Normal file
28
src/modules/products/lib/card-format.ts
Normal 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(", ");
|
||||
}
|
||||
15
src/modules/products/lib/card-pricing.ts
Normal file
15
src/modules/products/lib/card-pricing.ts
Normal 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;
|
||||
}
|
||||
124
src/modules/products/lib/pass-queries.ts
Normal file
124
src/modules/products/lib/pass-queries.ts
Normal 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);
|
||||
}
|
||||
78
src/modules/products/lib/pass-refund.ts
Normal file
78
src/modules/products/lib/pass-refund.ts
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
import type Stripe from "stripe";
|
||||
import { prisma } from "@/src/lib/prisma";
|
||||
|
||||
/**
|
||||
* Abschluss einer Kartenerstattung. Eine Erstattung läuft in zwei Schritten:
|
||||
* refundPass sperrt die Karte ("refunding", keine freien Einheiten) und
|
||||
* protokolliert das, dann geht der Auftrag an Stripe. Erst wenn Stripe sie
|
||||
* bestätigt - direkt in der Antwort oder später per Webhook - steht die Karte
|
||||
* auf "refunded". Bleibt der Ausgang offen (Timeout, Netzwerk), bleibt sie
|
||||
* gesperrt: Der Kunde hat dann vielleicht schon sein Geld und darf die
|
||||
* Einheiten nicht mehr einlösen.
|
||||
*/
|
||||
|
||||
/** Setzt eine gesperrte Karte auf erstattet. Wirkt nur einmal, auch bei doppelter Bestätigung. */
|
||||
export async function markPassRefunded(passId: string, amountRefundedCents: number) {
|
||||
return prisma.customerPass.updateMany({
|
||||
where: { id: passId, status: "refunding" },
|
||||
data: { status: "refunded", refundedAmount: amountRefundedCents, refundedAt: new Date() },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Stripe hat die Erstattung sicher abgelehnt: Die Karte bekommt Status und
|
||||
* Einheiten von vor der Sperre zurück. Beides steht im Protokoll - die Sperre
|
||||
* hat die Einheiten als "refund" abgezogen, und "beendet" kann eine Karte nur
|
||||
* über endPass werden (und nie zurück).
|
||||
*/
|
||||
export async function restorePassAfterFailedRefund(
|
||||
passId: string,
|
||||
managerId: string,
|
||||
error: string
|
||||
): Promise<void> {
|
||||
await prisma.$transaction(async (tx) => {
|
||||
const [lock, ended] = await Promise.all([
|
||||
tx.passAdjustment.findFirst({
|
||||
where: { passId, type: "refund" },
|
||||
orderBy: { createdAt: "desc" },
|
||||
select: { creditsDelta: true },
|
||||
}),
|
||||
tx.passAdjustment.count({ where: { passId, type: "end" } }),
|
||||
]);
|
||||
const credits = -(lock?.creditsDelta ?? 0);
|
||||
|
||||
const { count } = await tx.customerPass.updateMany({
|
||||
where: { id: passId, status: "refunding" },
|
||||
data: { status: ended > 0 ? "ended" : "active", creditsRemaining: credits },
|
||||
});
|
||||
if (count !== 1) return;
|
||||
|
||||
await tx.passAdjustment.create({
|
||||
data: {
|
||||
passId,
|
||||
managerId,
|
||||
type: "refund_failed",
|
||||
creditsDelta: credits,
|
||||
reason: error.slice(0, 200),
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Webhook charge.refunded: bestätigt eine Kartenerstattung, deren Ausgang beim
|
||||
* Anstoßen offen blieb. Nur vollständig erstattete Zahlungen zählen - eine
|
||||
* Karte wird nie teilweise erstattet.
|
||||
*/
|
||||
export async function reconcilePassRefund(charge: Stripe.Charge): Promise<void> {
|
||||
if (!charge.refunded) return;
|
||||
const paymentIntentId =
|
||||
typeof charge.payment_intent === "string" ? charge.payment_intent : charge.payment_intent?.id;
|
||||
if (!paymentIntentId) return;
|
||||
|
||||
const pass = await prisma.customerPass.findFirst({
|
||||
where: { stripePaymentIntentId: paymentIntentId, status: "refunding" },
|
||||
select: { id: true },
|
||||
});
|
||||
if (pass) await markPassRefunded(pass.id, charge.amount_refunded);
|
||||
}
|
||||
83
src/modules/products/lib/pass-rules.ts
Normal file
83
src/modules/products/lib/pass-rules.ts
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
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"
|
||||
| "refunding"
|
||||
| "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 === "refunding") return "refunding";
|
||||
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",
|
||||
refunding: "Erstattung läuft",
|
||||
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",
|
||||
});
|
||||
}
|
||||
182
src/modules/products/lib/pass-webhook.ts
Normal file
182
src/modules/products/lib/pass-webhook.ts
Normal 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
|
||||
);
|
||||
}
|
||||
36
src/modules/products/schemas/product-schema.ts
Normal file
36
src/modules/products/schemas/product-schema.ts
Normal 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>;
|
||||
216
src/modules/products/ui/components/IssuePassDialog.tsx
Normal file
216
src/modules/products/ui/components/IssuePassDialog.tsx
Normal 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;
|
||||
469
src/modules/products/ui/components/KundePassesSection.tsx
Normal file
469
src/modules/products/ui/components/KundePassesSection.tsx
Normal file
|
|
@ -0,0 +1,469 @@
|
|||
"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 "Erstattung angestoßen";
|
||||
case "refund_failed":
|
||||
return `Erstattung von Stripe abgelehnt, ${units(delta)} wieder frei`;
|
||||
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" />
|
||||
{pass.status === "refunding" ? "Erstattung prüfen" : "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: pass.status === "refunding" ? "Erstattung prüfen" : "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" &&
|
||||
(pass.status === "refunding" ? (
|
||||
<p className="text-sm text-gray-600">
|
||||
Stripe hat beim letzten Versuch nicht eindeutig geantwortet. Erneut prüfen ist
|
||||
sicher: Stripe erstattet höchstens einmal.
|
||||
</p>
|
||||
) : (
|
||||
<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"
|
||||
? pass.status === "refunding"
|
||||
? "Erneut prüfen"
|
||||
: "Erstatten"
|
||||
: "Speichern"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default KundePassesSection;
|
||||
119
src/modules/products/ui/components/PassCard.tsx
Normal file
119
src/modules/products/ui/components/PassCard.tsx
Normal 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;
|
||||
480
src/modules/products/ui/components/ProductEditor.tsx
Normal file
480
src/modules/products/ui/components/ProductEditor.tsx
Normal 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;
|
||||
194
src/modules/products/ui/components/ProductList.tsx
Normal file
194
src/modules/products/ui/components/ProductList.tsx
Normal 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;
|
||||
145
src/modules/products/ui/components/PublicPassShop.tsx
Normal file
145
src/modules/products/ui/components/PublicPassShop.tsx
Normal 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;
|
||||
150
src/modules/products/ui/views/meine-karten-view.tsx
Normal file
150
src/modules/products/ui/views/meine-karten-view.tsx
Normal 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 & 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;
|
||||
232
src/modules/products/ui/views/settings-products-page-view.tsx
Normal file
232
src/modules/products/ui/views/settings-products-page-view.tsx
Normal 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;
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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] };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,42 @@ 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"
|
||||
: p.status === "refunding"
|
||||
? "pass_refunding"
|
||||
: "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." };
|
||||
|
|
|
|||
|
|
@ -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(",")}`],
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,10 @@ 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 - bzw. dessen
|
||||
// Erstattung angestoßen, aber von Stripe noch nicht bestätigt ist.
|
||||
pass_refunded: { label: "Erstattet", bg: "bg-red-100", text: "text-red-600" },
|
||||
pass_refunding: { label: "Wird erstattet…", bg: "bg-yellow-100", text: "text-yellow-700" },
|
||||
};
|
||||
|
||||
interface ZahlungStatusBadgeProps {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
96
tests/api/stripe/checkout-pass.test.ts
Normal file
96
tests/api/stripe/checkout-pass.test.ts
Normal 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" });
|
||||
});
|
||||
});
|
||||
|
|
@ -49,6 +49,11 @@ 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(),
|
||||
}));
|
||||
vi.mock("@/src/modules/products/lib/pass-refund", () => ({ reconcilePassRefund: vi.fn() }));
|
||||
|
||||
import { POST } from "@/src/app/api/stripe/webhook/route";
|
||||
import { stripe } from "@/src/lib/stripe";
|
||||
|
|
@ -57,6 +62,8 @@ 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";
|
||||
import { reconcilePassRefund } from "@/src/modules/products/lib/pass-refund";
|
||||
|
||||
// Die Datenbank-Transaktion der Kapazitätsprüfung.
|
||||
const tx = {
|
||||
|
|
@ -511,3 +518,66 @@ 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 });
|
||||
});
|
||||
|
||||
it("bestätigt eine offene Kartenerstattung über charge.refunded", async () => {
|
||||
const charge = { id: "ch_1", refunded: true, payment_intent: "pi_1", amount_refunded: 18500 };
|
||||
await deliver(
|
||||
{ type: "charge.refunded", account: "acct_studio", data: { object: charge } },
|
||||
"whsec_connect"
|
||||
);
|
||||
expect(reconcilePassRefund).toHaveBeenCalledWith(charge);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
75
tests/api/upload/business-logo.test.ts
Normal file
75
tests/api/upload/business-logo.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -86,6 +86,7 @@ function renderView(initialCourses = [...thisWeek, ...later], initialWindow = wi
|
|||
initialWindow={initialWindow}
|
||||
serviceFeeCents={50}
|
||||
cancellationSummary="Kostenlos stornierbar bis Kursbeginn."
|
||||
products={[]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
178
tests/courses/cancel-pass-booking.test.ts
Normal file
178
tests/courses/cancel-pass-booking.test.ts
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
const tx = {
|
||||
courseBooking: { updateMany: vi.fn() },
|
||||
customerPass: { updateMany: 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.updateMany.mockResolvedValue({ count: 1 });
|
||||
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");
|
||||
const { where, data } = tx.customerPass.updateMany.mock.calls[0][0];
|
||||
// Gutschrift nur auf eine Karte, die noch einlösbar ist.
|
||||
expect(where).toMatchObject({ id: "pass-1", status: "active" });
|
||||
expect(data).toEqual({ 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.updateMany).not.toHaveBeenCalled();
|
||||
expect(vi.mocked(generatePassBookingCancellationEmail).mock.calls[0][0]).toMatchObject({
|
||||
creditsReturned: false,
|
||||
forfeitReason: "policy",
|
||||
});
|
||||
});
|
||||
|
||||
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("schreibt einer abgelaufenen oder beendeten Karte nichts gut - auch nicht bei Kursabsage", async () => {
|
||||
// Die Karte ist nicht mehr aktiv: die bedingte Gutschrift trifft keine Zeile.
|
||||
tx.customerPass.updateMany.mockResolvedValue({ count: 0 });
|
||||
|
||||
const result = await executeBookingCancellation("booking-1", "manager", {
|
||||
forceFullRefund: true,
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({ success: true, creditsReturned: 0 });
|
||||
expect(tx.courseBooking.updateMany.mock.calls[0][0].data.paymentStatus).toBe("cancelled");
|
||||
expect(vi.mocked(generatePassBookingCancellationEmail).mock.calls[0][0]).toMatchObject({
|
||||
creditsReturned: false,
|
||||
forfeitReason: "pass_inactive",
|
||||
});
|
||||
});
|
||||
|
||||
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.updateMany.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");
|
||||
|
||||
// Die Gutschrift lief in derselben Transaktion und wird mit ihr zurückgerollt.
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
error: "Stornierung läuft bereits oder wurde bereits verarbeitet.",
|
||||
});
|
||||
});
|
||||
});
|
||||
86
tests/courses/delete-course.test.ts
Normal file
86
tests/courses/delete-course.test.ts
Normal 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" } });
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -51,6 +51,28 @@ describe("refundStripePayment", () => {
|
|||
refundApplicationFee: false,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ success: false, error: "insufficient balance" });
|
||||
expect(result).toEqual({ success: false, error: "insufficient balance", definitive: false });
|
||||
});
|
||||
|
||||
it("unterscheidet eine sichere Ablehnung von einem offenen Ausgang", async () => {
|
||||
const rejected = Object.assign(new Error("Charge already refunded"), {
|
||||
type: "StripeInvalidRequestError",
|
||||
code: "charge_already_refunded",
|
||||
});
|
||||
vi.mocked(stripe.refunds.create).mockRejectedValueOnce(rejected);
|
||||
const timeout = Object.assign(new Error("timeout"), { type: "StripeConnectionError" });
|
||||
vi.mocked(stripe.refunds.create).mockRejectedValueOnce(timeout);
|
||||
|
||||
const params = {
|
||||
paymentIntentId: "pi_1",
|
||||
stripeAccountId: "acct",
|
||||
refundApplicationFee: false,
|
||||
};
|
||||
expect(await refundStripePayment(params)).toMatchObject({
|
||||
success: false,
|
||||
definitive: true,
|
||||
code: "charge_already_refunded",
|
||||
});
|
||||
expect(await refundStripePayment(params)).toMatchObject({ success: false, definitive: false });
|
||||
});
|
||||
});
|
||||
|
|
|
|||
92
tests/products/card-design.test.ts
Normal file
92
tests/products/card-design.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
314
tests/products/pass-admin-actions.test.ts
Normal file
314
tests/products/pass-admin-actions.test.ts
Normal file
|
|
@ -0,0 +1,314 @@
|
|||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
const tx = {
|
||||
customerPass: { updateMany: vi.fn() },
|
||||
passAdjustment: { create: vi.fn(), findFirst: vi.fn(), count: 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,
|
||||
endPass,
|
||||
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({});
|
||||
tx.passAdjustment.findFirst.mockResolvedValue({ creditsDelta: -10 });
|
||||
tx.passAdjustment.count.mockResolvedValue(0);
|
||||
});
|
||||
|
||||
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("endPass", () => {
|
||||
it("beendet und protokolliert in einer Transaktion", async () => {
|
||||
const result = await endPass({ passId: "pass-1", reason: "Kunde zieht weg" });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(tx.customerPass.updateMany).toHaveBeenCalledWith({
|
||||
where: { id: "pass-1", status: "active" },
|
||||
data: { status: "ended" },
|
||||
});
|
||||
expect(tx.passAdjustment.create.mock.calls[0][0].data).toMatchObject({ type: "end" });
|
||||
expect(prisma.passAdjustment.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("protokolliert nichts, wenn die Karte schon nicht mehr aktiv war", async () => {
|
||||
tx.customerPass.updateMany.mockResolvedValue({ count: 0 });
|
||||
expect((await endPass({ passId: "pass-1", reason: "" })).success).toBe(false);
|
||||
expect(tx.passAdjustment.create).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("refundPass", () => {
|
||||
const refunded = {
|
||||
success: true as const,
|
||||
stripeRefundId: "re_1",
|
||||
amountRefundedCents: 12500,
|
||||
};
|
||||
|
||||
it("sperrt und protokolliert zuerst, dann erstattet Stripe komplett - die Gebühr trägt das Studio", async () => {
|
||||
vi.mocked(refundStripePayment).mockResolvedValue(refunded);
|
||||
|
||||
const result = await refundPass({ passId: "pass-1" });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
// Sperre: nicht mehr einlösbar, an den gelesenen Stand gebunden.
|
||||
expect(tx.customerPass.updateMany).toHaveBeenCalledWith({
|
||||
where: { id: "pass-1", status: { in: ["active", "ended"] }, creditsRemaining: 10 },
|
||||
data: { status: "refunding", creditsRemaining: 0 },
|
||||
});
|
||||
expect(tx.passAdjustment.create.mock.calls[0][0].data).toMatchObject({
|
||||
type: "refund",
|
||||
creditsDelta: -10,
|
||||
});
|
||||
expect(refundStripePayment).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
paymentIntentId: "pi_1",
|
||||
refundApplicationFee: false,
|
||||
idempotencyKey: "refund-pass-pass-1",
|
||||
})
|
||||
);
|
||||
expect(vi.mocked(refundStripePayment).mock.calls[0][0].amountCents).toBeUndefined();
|
||||
// Abschluss erst nach Stripes Bestätigung.
|
||||
expect(prisma.customerPass.updateMany).toHaveBeenCalledWith({
|
||||
where: { id: "pass-1", status: "refunding" },
|
||||
data: expect.objectContaining({ status: "refunded", refundedAmount: 12500 }),
|
||||
});
|
||||
});
|
||||
|
||||
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(tx.customerPass.updateMany).not.toHaveBeenCalled();
|
||||
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 nur zurück, wenn Stripe sicher abgelehnt hat", async () => {
|
||||
vi.mocked(refundStripePayment).mockResolvedValue({
|
||||
success: false,
|
||||
error: "Insufficient funds",
|
||||
definitive: true,
|
||||
});
|
||||
|
||||
const result = await refundPass({ passId: "pass-1" });
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
// Status und Einheiten von vor der Sperre, aus dem Protokoll.
|
||||
expect(tx.customerPass.updateMany).toHaveBeenLastCalledWith({
|
||||
where: { id: "pass-1", status: "refunding" },
|
||||
data: { status: "active", creditsRemaining: 10 },
|
||||
});
|
||||
expect(tx.passAdjustment.create).toHaveBeenLastCalledWith({
|
||||
data: expect.objectContaining({ type: "refund_failed", creditsDelta: 10 }),
|
||||
});
|
||||
});
|
||||
|
||||
it("stellt eine vorher beendete Karte als beendet wieder her", async () => {
|
||||
vi.mocked(prisma.customerPass.findFirst).mockResolvedValue({
|
||||
...purchasedPass,
|
||||
status: "ended",
|
||||
} as never);
|
||||
tx.passAdjustment.count.mockResolvedValue(1);
|
||||
vi.mocked(refundStripePayment).mockResolvedValue({
|
||||
success: false,
|
||||
error: "nein",
|
||||
definitive: true,
|
||||
});
|
||||
|
||||
await refundPass({ passId: "pass-1" });
|
||||
|
||||
expect(tx.customerPass.updateMany).toHaveBeenLastCalledWith({
|
||||
where: { id: "pass-1", status: "refunding" },
|
||||
data: { status: "ended", creditsRemaining: 10 },
|
||||
});
|
||||
});
|
||||
|
||||
it("lässt die Karte gesperrt, wenn der Ausgang offen ist", async () => {
|
||||
vi.mocked(refundStripePayment).mockResolvedValue({
|
||||
success: false,
|
||||
error: "timeout",
|
||||
definitive: false,
|
||||
});
|
||||
|
||||
const result = await refundPass({ passId: "pass-1" });
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
// Nur die Sperre - kein Zurücksetzen, kein Abschluss.
|
||||
expect(tx.customerPass.updateMany).toHaveBeenCalledTimes(1);
|
||||
expect(prisma.customerPass.updateMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("fragt bei einer gesperrten Karte nur erneut bei Stripe nach", async () => {
|
||||
vi.mocked(prisma.customerPass.findFirst).mockResolvedValue({
|
||||
...purchasedPass,
|
||||
status: "refunding",
|
||||
creditsRemaining: 0,
|
||||
} as never);
|
||||
vi.mocked(refundStripePayment).mockResolvedValue({
|
||||
success: false,
|
||||
error: "Charge already refunded",
|
||||
definitive: true,
|
||||
code: "charge_already_refunded",
|
||||
});
|
||||
|
||||
const result = await refundPass({ passId: "pass-1" });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(tx.customerPass.updateMany).not.toHaveBeenCalled();
|
||||
expect(prisma.customerPass.updateMany).toHaveBeenCalledWith({
|
||||
where: { id: "pass-1", status: "refunding" },
|
||||
data: expect.objectContaining({ status: "refunded", refundedAmount: 12500 }),
|
||||
});
|
||||
});
|
||||
});
|
||||
186
tests/products/pass-booking-actions.test.ts
Normal file
186
tests/products/pass-booking-actions.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
47
tests/products/pass-card.test.tsx
Normal file
47
tests/products/pass-card.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
49
tests/products/pass-refund.test.ts
Normal file
49
tests/products/pass-refund.test.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
vi.mock("@/src/lib/prisma", () => ({
|
||||
prisma: { customerPass: { findFirst: vi.fn(), updateMany: vi.fn() } },
|
||||
}));
|
||||
|
||||
import { reconcilePassRefund } from "@/src/modules/products/lib/pass-refund";
|
||||
import { prisma } from "@/src/lib/prisma";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(prisma.customerPass.updateMany).mockResolvedValue({ count: 1 } as never);
|
||||
});
|
||||
|
||||
describe("reconcilePassRefund", () => {
|
||||
it("schließt eine offene Kartenerstattung ab, sobald Stripe sie meldet", async () => {
|
||||
vi.mocked(prisma.customerPass.findFirst).mockResolvedValue({ id: "pass-1" } as never);
|
||||
|
||||
await reconcilePassRefund({
|
||||
refunded: true,
|
||||
payment_intent: "pi_1",
|
||||
amount_refunded: 12500,
|
||||
} as never);
|
||||
|
||||
expect(prisma.customerPass.findFirst).toHaveBeenCalledWith({
|
||||
where: { stripePaymentIntentId: "pi_1", status: "refunding" },
|
||||
select: { id: true },
|
||||
});
|
||||
expect(prisma.customerPass.updateMany).toHaveBeenCalledWith({
|
||||
where: { id: "pass-1", status: "refunding" },
|
||||
data: expect.objectContaining({ status: "refunded", refundedAmount: 12500 }),
|
||||
});
|
||||
});
|
||||
|
||||
it("ignoriert Teilerstattungen - eine Karte wird nur komplett erstattet", async () => {
|
||||
await reconcilePassRefund({ refunded: false, payment_intent: "pi_1" } as never);
|
||||
expect(prisma.customerPass.findFirst).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("tut nichts, wenn keine Kartenerstattung auf diese Zahlung wartet", async () => {
|
||||
vi.mocked(prisma.customerPass.findFirst).mockResolvedValue(null);
|
||||
await reconcilePassRefund({
|
||||
refunded: true,
|
||||
payment_intent: "pi_x",
|
||||
amount_refunded: 1,
|
||||
} as never);
|
||||
expect(prisma.customerPass.updateMany).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
58
tests/products/pass-rules.test.ts
Normal file
58
tests/products/pass-rules.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
127
tests/products/pass-webhook.test.ts
Normal file
127
tests/products/pass-webhook.test.ts
Normal 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 })
|
||||
);
|
||||
});
|
||||
});
|
||||
159
tests/products/product-actions.test.ts
Normal file
159
tests/products/product-actions.test.ts
Normal 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.",
|
||||
});
|
||||
});
|
||||
});
|
||||
58
tests/products/product-schema.test.ts
Normal file
58
tests/products/product-schema.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
135
tests/zahlungen/zahlungen-actions.test.ts
Normal file
135
tests/zahlungen/zahlungen-actions.test.ts
Normal 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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue