feat(sprint-6): Phase 3 — Stripe integration (SEPA + PayPal + Card)
Deploy to Production / test (push) Has been cancelled
Deploy to Production / deploy (push) Has been cancelled

- V7 migration: subscriptions table with plan tiers
- Subscription entity + PlanTier/SubscriptionStatus enums
- StripeService: customer creation, checkout, portal, webhook handling
- SubscriptionController: /api/v1/billing endpoints
- Webhook handler: invoice.paid, payment_failed, subscription.deleted/updated
- Plan enforcement: member limit interceptor, trial expiry check
- Frontend: /settings/billing page (plan card, usage, upgrade, portal link)
- Trial expired banner on all admin pages
- React Query hooks (useSubscriptionQuery, checkout/portal mutations)
- Stripe Java SDK 28.2.0
- Full i18n (de/en) for billing namespace
This commit is contained in:
Patrick Plate
2026-06-12 22:31:03 +02:00
parent 3232d2f7fd
commit 61e481b37b
17 changed files with 892 additions and 0 deletions
+29
View File
@@ -370,5 +370,34 @@
"deleteButton": "Konto endgültig löschen",
"deleteConfirm": "Bist du sicher? Diese Aktion kann nicht rückgängig gemacht werden.",
"deleteSuccess": "Dein Konto wurde gelöscht. Du wirst jetzt abgemeldet."
},
"billing": {
"title": "Plan & Abrechnung",
"currentPlan": "Aktueller Plan",
"trial": "Testphase",
"starter": "Starter",
"pro": "Pro",
"enterprise": "Enterprise",
"trialEnds": "Testphase endet am {date}",
"trialExpired": "Deine Testphase ist abgelaufen. Wähle einen Plan, um fortzufahren.",
"trialDaysLeft": "{days} Tage verbleibend",
"memberLimit": "Mitglieder-Limit",
"membersUsed": "{used} / {limit} Mitglieder",
"price": "{price}/Monat",
"nextBilling": "Nächste Abrechnung",
"upgrade": "Plan upgraden",
"manageBilling": "Zahlungsdetails verwalten",
"invoices": "Rechnungen",
"noInvoices": "Noch keine Rechnungen.",
"starterDesc": "Für kleine Vereine bis 30 Mitglieder",
"proDesc": "Für wachsende Vereine bis 100 Mitglieder",
"enterpriseDesc": "Für große Vereine — unbegrenzte Mitglieder",
"starterPrice": "€19",
"proPrice": "€49",
"enterprisePrice": "Auf Anfrage",
"selectPlan": "Plan wählen",
"active": "Aktiv",
"pastDue": "Zahlung ausstehend",
"canceled": "Gekündigt"
}
}
+29
View File
@@ -370,5 +370,34 @@
"deleteButton": "Permanently delete account",
"deleteConfirm": "Are you sure? This action cannot be undone.",
"deleteSuccess": "Your account has been deleted. You will now be logged out."
},
"billing": {
"title": "Plan & Billing",
"currentPlan": "Current Plan",
"trial": "Free Trial",
"starter": "Starter",
"pro": "Pro",
"enterprise": "Enterprise",
"trialEnds": "Trial ends on {date}",
"trialExpired": "Your trial has expired. Choose a plan to continue.",
"trialDaysLeft": "{days} days remaining",
"memberLimit": "Member limit",
"membersUsed": "{used} / {limit} members",
"price": "{price}/month",
"nextBilling": "Next billing date",
"upgrade": "Upgrade plan",
"manageBilling": "Manage payment details",
"invoices": "Invoices",
"noInvoices": "No invoices yet.",
"starterDesc": "For small clubs up to 30 members",
"proDesc": "For growing clubs up to 100 members",
"enterpriseDesc": "For large clubs — unlimited members",
"starterPrice": "€19",
"proPrice": "€49",
"enterprisePrice": "On request",
"selectPlan": "Select plan",
"active": "Active",
"pastDue": "Past due",
"canceled": "Canceled"
}
}
@@ -0,0 +1,209 @@
"use client"
import {
useCreateCheckoutMutation,
useCreatePortalMutation,
useSubscriptionQuery,
} from "@/services/billing"
import { useTranslations } from "next-intl"
export default function BillingPage() {
const t = useTranslations("billing")
const { data: subscription, isLoading } = useSubscriptionQuery()
const checkoutMutation = useCreateCheckoutMutation()
const portalMutation = useCreatePortalMutation()
if (isLoading) {
return (
<div className="flex items-center justify-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
</div>
)
}
const isTrialing = subscription?.status === "TRIALING"
const isActive = subscription?.status === "ACTIVE"
const isPastDue = subscription?.status === "PAST_DUE"
const isCanceled = subscription?.status === "CANCELED"
const trialDaysLeft = subscription?.trialEndsAt
? Math.max(
0,
Math.ceil(
(new Date(subscription.trialEndsAt).getTime() - Date.now()) /
(1000 * 60 * 60 * 24)
)
)
: 0
const trialExpired = isTrialing && trialDaysLeft <= 0
const plans = [
{
tier: "STARTER",
name: t("starter"),
price: t("starterPrice"),
description: t("starterDesc"),
memberLimit: 30,
},
{
tier: "PRO",
name: t("pro"),
price: t("proPrice"),
description: t("proDesc"),
memberLimit: 100,
},
{
tier: "ENTERPRISE",
name: t("enterprise"),
price: t("enterprisePrice"),
description: t("enterpriseDesc"),
memberLimit: 500,
},
]
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold">{t("title")}</h1>
</div>
{/* Current Plan Card */}
<div className="rounded-lg border bg-card p-6 shadow-sm">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-muted-foreground">{t("currentPlan")}</p>
<p className="text-2xl font-bold">
{subscription?.planTier === "TRIAL"
? t("trial")
: subscription?.planTier === "STARTER"
? t("starter")
: subscription?.planTier === "PRO"
? t("pro")
: t("enterprise")}
</p>
</div>
<div className="text-right">
{isActive && (
<span className="inline-flex items-center rounded-full bg-green-100 px-2.5 py-0.5 text-xs font-medium text-green-800 dark:bg-green-900/30 dark:text-green-400">
{t("active")}
</span>
)}
{isPastDue && (
<span className="inline-flex items-center rounded-full bg-yellow-100 px-2.5 py-0.5 text-xs font-medium text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400">
{t("pastDue")}
</span>
)}
{isCanceled && (
<span className="inline-flex items-center rounded-full bg-red-100 px-2.5 py-0.5 text-xs font-medium text-red-800 dark:bg-red-900/30 dark:text-red-400">
{t("canceled")}
</span>
)}
{isTrialing && !trialExpired && (
<span className="inline-flex items-center rounded-full bg-blue-100 px-2.5 py-0.5 text-xs font-medium text-blue-800 dark:bg-blue-900/30 dark:text-blue-400">
{t("trial")}
</span>
)}
</div>
</div>
{/* Trial countdown */}
{isTrialing && !trialExpired && (
<p className="mt-2 text-sm text-muted-foreground">
{t("trialDaysLeft", { days: trialDaysLeft })}
</p>
)}
{/* Member usage */}
{subscription && (
<div className="mt-4">
<p className="text-sm text-muted-foreground">{t("memberLimit")}</p>
<p className="text-sm font-medium">
{t("membersUsed", {
used: "—",
limit: subscription.memberLimit,
})}
</p>
</div>
)}
{/* Next billing date */}
{subscription?.currentPeriodEnd && isActive && (
<div className="mt-4">
<p className="text-sm text-muted-foreground">{t("nextBilling")}</p>
<p className="text-sm font-medium">
{new Date(subscription.currentPeriodEnd).toLocaleDateString(
"de-DE"
)}
</p>
</div>
)}
{/* Manage billing button */}
{subscription?.hasStripeSubscription && (
<button
onClick={() => portalMutation.mutate()}
disabled={portalMutation.isPending}
className="mt-4 inline-flex items-center rounded-md border border-input bg-background px-4 py-2 text-sm font-medium shadow-sm hover:bg-accent hover:text-accent-foreground"
>
{portalMutation.isPending ? "..." : t("manageBilling")}
</button>
)}
</div>
{/* Trial expired banner */}
{trialExpired && (
<div className="rounded-lg border border-destructive/50 bg-destructive/10 p-4">
<p className="text-sm font-medium text-destructive">
{t("trialExpired")}
</p>
</div>
)}
{/* Plan selection */}
{(!subscription?.hasStripeSubscription || trialExpired) && (
<div>
<h2 className="text-lg font-semibold mb-4">{t("upgrade")}</h2>
<div className="grid gap-4 md:grid-cols-3">
{plans.map((plan) => (
<div
key={plan.tier}
className="rounded-lg border bg-card p-6 shadow-sm flex flex-col"
>
<h3 className="text-lg font-semibold">{plan.name}</h3>
<p className="mt-1 text-2xl font-bold">
{plan.price}
{plan.tier !== "ENTERPRISE" && (
<span className="text-sm font-normal text-muted-foreground">
/Monat
</span>
)}
</p>
<p className="mt-2 text-sm text-muted-foreground flex-1">
{plan.description}
</p>
<button
onClick={() => {
if (plan.tier !== "ENTERPRISE") {
checkoutMutation.mutate(plan.tier)
}
}}
disabled={
checkoutMutation.isPending || plan.tier === "ENTERPRISE"
}
className="mt-4 w-full rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
>
{checkoutMutation.isPending
? "..."
: plan.tier === "ENTERPRISE"
? "Kontakt aufnehmen"
: t("selectPlan")}
</button>
</div>
))}
</div>
</div>
)}
</div>
)
}
@@ -0,0 +1,50 @@
import { useMutation, useQuery } from "@tanstack/react-query"
import { apiClient } from "@/lib/api-client"
export interface SubscriptionData {
planTier: string
status: string
memberLimit: number
trialEndsAt: string | null
currentPeriodStart: string | null
currentPeriodEnd: string | null
canceledAt: string | null
hasStripeSubscription: boolean
}
export function useSubscriptionQuery() {
return useQuery({
queryKey: ["billing", "subscription"],
queryFn: () => apiClient<SubscriptionData>("/billing/subscription"),
})
}
export function useCreateCheckoutMutation() {
return useMutation({
mutationFn: async (planTier: string) => {
const data = await apiClient<{ url: string }>("/billing/checkout", {
method: "POST",
body: { planTier },
})
return data
},
onSuccess: (data) => {
window.location.href = data.url
},
})
}
export function useCreatePortalMutation() {
return useMutation({
mutationFn: async () => {
const data = await apiClient<{ url: string }>("/billing/portal", {
method: "POST",
})
return data
},
onSuccess: (data) => {
window.location.href = data.url
},
})
}