feat(sprint-6): Phase 3 — Stripe integration (SEPA + PayPal + Card)
- 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:
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
},
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user