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
@@ -0,0 +1,29 @@
package de.cannamanage.api.controller;
import de.cannamanage.service.StripeService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
@Slf4j
@RestController
@RequestMapping("/api/v1/webhooks")
@RequiredArgsConstructor
public class StripeWebhookController {
private final StripeService stripeService;
@PostMapping("/stripe")
public ResponseEntity<String> handleStripeWebhook(
@RequestBody String payload,
@RequestHeader("Stripe-Signature") String sigHeader) {
try {
stripeService.handleWebhook(payload, sigHeader);
return ResponseEntity.ok("ok");
} catch (IllegalArgumentException e) {
log.error("Stripe webhook processing failed: {}", e.getMessage());
return ResponseEntity.badRequest().body(e.getMessage());
}
}
}
@@ -0,0 +1,85 @@
package de.cannamanage.api.controller;
import com.stripe.exception.StripeException;
import de.cannamanage.api.dto.billing.CheckoutRequest;
import de.cannamanage.api.dto.billing.SubscriptionResponse;
import de.cannamanage.domain.entity.Subscription;
import de.cannamanage.domain.entity.TenantContext;
import de.cannamanage.domain.enums.PlanTier;
import de.cannamanage.service.StripeService;
import de.cannamanage.service.repository.ClubRepository;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
import java.util.UUID;
@Slf4j
@RestController
@RequestMapping("/api/v1/billing")
@RequiredArgsConstructor
@Tag(name = "Billing", description = "Subscription and payment management")
public class SubscriptionController {
private final StripeService stripeService;
private final ClubRepository clubRepository;
@GetMapping("/subscription")
@Operation(summary = "Get current subscription", description = "Returns the current plan and subscription status")
@PreAuthorize("hasRole('ADMIN')")
public ResponseEntity<SubscriptionResponse> getSubscription() {
UUID tenantId = TenantContext.getCurrentTenant();
UUID clubId = clubRepository.findByTenantId(tenantId)
.orElseThrow(() -> new IllegalStateException("No club for tenant"))
.getId();
return stripeService.getSubscription(clubId)
.map(sub -> ResponseEntity.ok(toResponse(sub)))
.orElseGet(() -> ResponseEntity.notFound().build());
}
@PostMapping("/checkout")
@Operation(summary = "Create checkout session", description = "Creates a Stripe Checkout session for plan upgrade")
@PreAuthorize("hasRole('ADMIN')")
public ResponseEntity<Map<String, String>> createCheckout(@RequestBody CheckoutRequest request) throws StripeException {
UUID tenantId = TenantContext.getCurrentTenant();
UUID clubId = clubRepository.findByTenantId(tenantId)
.orElseThrow(() -> new IllegalStateException("No club for tenant"))
.getId();
PlanTier planTier = PlanTier.valueOf(request.planTier().toUpperCase());
String checkoutUrl = stripeService.createCheckoutSession(clubId, planTier);
return ResponseEntity.ok(Map.of("url", checkoutUrl));
}
@PostMapping("/portal")
@Operation(summary = "Create billing portal session", description = "Creates a Stripe Billing Portal session for self-service")
@PreAuthorize("hasRole('ADMIN')")
public ResponseEntity<Map<String, String>> createPortalSession() throws StripeException {
UUID tenantId = TenantContext.getCurrentTenant();
UUID clubId = clubRepository.findByTenantId(tenantId)
.orElseThrow(() -> new IllegalStateException("No club for tenant"))
.getId();
String portalUrl = stripeService.createBillingPortalSession(clubId);
return ResponseEntity.ok(Map.of("url", portalUrl));
}
private SubscriptionResponse toResponse(Subscription sub) {
return new SubscriptionResponse(
sub.getPlanTier().name(),
sub.getStatus().name(),
sub.getMemberLimit(),
sub.getTrialEndsAt(),
sub.getCurrentPeriodStart(),
sub.getCurrentPeriodEnd(),
sub.getCanceledAt(),
sub.getStripeSubscriptionId() != null
);
}
}
@@ -0,0 +1,7 @@
package de.cannamanage.api.dto.billing;
import jakarta.validation.constraints.NotBlank;
public record CheckoutRequest(
@NotBlank String planTier
) {}
@@ -0,0 +1,14 @@
package de.cannamanage.api.dto.billing;
import java.time.Instant;
public record SubscriptionResponse(
String planTier,
String status,
int memberLimit,
Instant trialEndsAt,
Instant currentPeriodStart,
Instant currentPeriodEnd,
Instant canceledAt,
boolean hasStripeSubscription
) {}
@@ -49,6 +49,8 @@ public class SecurityConfig {
.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/v1/auth/**").permitAll()
.requestMatchers("/api/v1/webhooks/**").permitAll()
.requestMatchers("/api/v1/billing/**").hasRole("ADMIN")
.requestMatchers("/api/v1/admin/**").hasRole("ADMIN")
.requestMatchers("/api/v1/staff/**").hasRole("ADMIN")
.requestMatchers("/api/v1/members/**").hasAnyRole("ADMIN", "STAFF", "MEMBER")
@@ -26,6 +26,11 @@ cannamanage.security.jwt.refresh-token-expiry=2592000
# Stripe
stripe.secret-key=${STRIPE_SECRET_KEY}
stripe.webhook-secret=${STRIPE_WEBHOOK_SECRET}
stripe.starter-price-id=${STRIPE_STARTER_PRICE_ID}
stripe.pro-price-id=${STRIPE_PRO_PRICE_ID}
# App
app.base-url=${APP_BASE_URL:https://app.cannamanage.de}
# Error handling — never expose internals
server.error.include-message=never
@@ -0,0 +1,21 @@
-- V7: Stripe subscription management
CREATE TABLE IF NOT EXISTS subscriptions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
club_id UUID NOT NULL REFERENCES clubs(id),
stripe_customer_id VARCHAR(255) NOT NULL,
stripe_subscription_id VARCHAR(255),
plan_tier VARCHAR(20) NOT NULL DEFAULT 'TRIAL',
member_limit INTEGER NOT NULL DEFAULT 500,
trial_ends_at TIMESTAMP WITH TIME ZONE,
current_period_start TIMESTAMP WITH TIME ZONE,
current_period_end TIMESTAMP WITH TIME ZONE,
status VARCHAR(20) NOT NULL DEFAULT 'TRIALING',
canceled_at TIMESTAMP WITH TIME ZONE,
tenant_id UUID NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE UNIQUE INDEX idx_subscriptions_club ON subscriptions(club_id);
CREATE INDEX idx_subscriptions_stripe_customer ON subscriptions(stripe_customer_id);
CREATE INDEX idx_subscriptions_status ON subscriptions(status);
@@ -0,0 +1,95 @@
package de.cannamanage.domain.entity;
import de.cannamanage.domain.enums.PlanTier;
import de.cannamanage.domain.enums.SubscriptionStatus;
import jakarta.persistence.*;
import java.time.Instant;
import java.util.UUID;
@Entity
@Table(name = "subscriptions")
public class Subscription extends AbstractTenantEntity {
@Column(name = "club_id", nullable = false, unique = true)
private UUID clubId;
@Column(name = "stripe_customer_id", nullable = false)
private String stripeCustomerId;
@Column(name = "stripe_subscription_id")
private String stripeSubscriptionId;
@Enumerated(EnumType.STRING)
@Column(name = "plan_tier", nullable = false, length = 20)
private PlanTier planTier = PlanTier.TRIAL;
@Column(name = "member_limit", nullable = false)
private Integer memberLimit = 500;
@Column(name = "trial_ends_at")
private Instant trialEndsAt;
@Column(name = "current_period_start")
private Instant currentPeriodStart;
@Column(name = "current_period_end")
private Instant currentPeriodEnd;
@Enumerated(EnumType.STRING)
@Column(name = "status", nullable = false, length = 20)
private SubscriptionStatus status = SubscriptionStatus.TRIALING;
@Column(name = "canceled_at")
private Instant canceledAt;
@Column(name = "updated_at")
private Instant updatedAt;
@PrePersist
@Override
void onCreate() {
super.onCreate();
this.updatedAt = Instant.now();
}
@PreUpdate
void onUpdate() {
this.updatedAt = Instant.now();
}
// Getters and Setters
public UUID getClubId() { return clubId; }
public void setClubId(UUID clubId) { this.clubId = clubId; }
public String getStripeCustomerId() { return stripeCustomerId; }
public void setStripeCustomerId(String stripeCustomerId) { this.stripeCustomerId = stripeCustomerId; }
public String getStripeSubscriptionId() { return stripeSubscriptionId; }
public void setStripeSubscriptionId(String stripeSubscriptionId) { this.stripeSubscriptionId = stripeSubscriptionId; }
public PlanTier getPlanTier() { return planTier; }
public void setPlanTier(PlanTier planTier) { this.planTier = planTier; }
public Integer getMemberLimit() { return memberLimit; }
public void setMemberLimit(Integer memberLimit) { this.memberLimit = memberLimit; }
public Instant getTrialEndsAt() { return trialEndsAt; }
public void setTrialEndsAt(Instant trialEndsAt) { this.trialEndsAt = trialEndsAt; }
public Instant getCurrentPeriodStart() { return currentPeriodStart; }
public void setCurrentPeriodStart(Instant currentPeriodStart) { this.currentPeriodStart = currentPeriodStart; }
public Instant getCurrentPeriodEnd() { return currentPeriodEnd; }
public void setCurrentPeriodEnd(Instant currentPeriodEnd) { this.currentPeriodEnd = currentPeriodEnd; }
public SubscriptionStatus getStatus() { return status; }
public void setStatus(SubscriptionStatus status) { this.status = status; }
public Instant getCanceledAt() { return canceledAt; }
public void setCanceledAt(Instant canceledAt) { this.canceledAt = canceledAt; }
public Instant getUpdatedAt() { return updatedAt; }
public void setUpdatedAt(Instant updatedAt) { this.updatedAt = updatedAt; }
}
@@ -0,0 +1,8 @@
package de.cannamanage.domain.enums;
public enum PlanTier {
TRIAL,
STARTER,
PRO,
ENTERPRISE
}
@@ -0,0 +1,9 @@
package de.cannamanage.domain.enums;
public enum SubscriptionStatus {
TRIALING,
ACTIVE,
PAST_DUE,
CANCELED,
UNPAID
}
+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
},
})
}
+6
View File
@@ -79,6 +79,12 @@
<artifactId>commons-csv</artifactId>
<version>1.12.0</version>
</dependency>
<!-- Stripe Java SDK -->
<dependency>
<groupId>com.stripe</groupId>
<artifactId>stripe-java</artifactId>
<version>28.2.0</version>
</dependency>
</dependencies>
<build>
@@ -0,0 +1,276 @@
package de.cannamanage.service;
import com.stripe.Stripe;
import com.stripe.exception.SignatureVerificationException;
import com.stripe.exception.StripeException;
import com.stripe.model.*;
import com.stripe.model.checkout.Session;
import com.stripe.net.Webhook;
import com.stripe.param.CustomerCreateParams;
import com.stripe.param.checkout.SessionCreateParams;
import de.cannamanage.domain.entity.Club;
import de.cannamanage.domain.entity.Subscription;
import de.cannamanage.domain.entity.TenantContext;
import de.cannamanage.domain.enums.PlanTier;
import de.cannamanage.domain.enums.SubscriptionStatus;
import de.cannamanage.service.repository.ClubRepository;
import de.cannamanage.service.repository.SubscriptionRepository;
import jakarta.annotation.PostConstruct;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.Instant;
import java.util.Optional;
import java.util.UUID;
@Slf4j
@Service
@RequiredArgsConstructor
public class StripeService {
private final SubscriptionRepository subscriptionRepository;
private final ClubRepository clubRepository;
@Value("${stripe.secret-key:}")
private String stripeSecretKey;
@Value("${stripe.webhook-secret:}")
private String webhookSecret;
@Value("${stripe.starter-price-id:}")
private String starterPriceId;
@Value("${stripe.pro-price-id:}")
private String proPriceId;
@Value("${app.base-url:http://localhost:3000}")
private String appBaseUrl;
@PostConstruct
void init() {
if (!stripeSecretKey.isBlank()) {
Stripe.apiKey = stripeSecretKey;
log.info("Stripe API initialized");
} else {
log.warn("Stripe API key not configured — billing features disabled");
}
}
/**
* Get the current subscription for a club.
*/
public Optional<Subscription> getSubscription(UUID clubId) {
return subscriptionRepository.findByClubId(clubId);
}
/**
* Create a Stripe customer for a club and initialize a trial subscription record.
*/
@Transactional
public Subscription createTrialSubscription(UUID clubId) throws StripeException {
Club club = clubRepository.findById(clubId)
.orElseThrow(() -> new IllegalArgumentException("Club not found: " + clubId));
// Create Stripe customer
CustomerCreateParams customerParams = CustomerCreateParams.builder()
.setEmail(club.getContactEmail())
.setName(club.getName())
.putMetadata("clubId", clubId.toString())
.putMetadata("tenantId", club.getTenantId().toString())
.build();
Customer customer = Customer.create(customerParams);
// Create local subscription record
Subscription subscription = new Subscription();
subscription.setClubId(clubId);
subscription.setTenantId(club.getTenantId());
subscription.setStripeCustomerId(customer.getId());
subscription.setPlanTier(PlanTier.TRIAL);
subscription.setMemberLimit(500);
subscription.setStatus(SubscriptionStatus.TRIALING);
subscription.setTrialEndsAt(Instant.now().plusSeconds(90L * 24 * 60 * 60)); // 3 months
return subscriptionRepository.save(subscription);
}
/**
* Create a Stripe Checkout Session for the given plan tier.
*/
public String createCheckoutSession(UUID clubId, PlanTier planTier) throws StripeException {
Subscription subscription = subscriptionRepository.findByClubId(clubId)
.orElseThrow(() -> new IllegalArgumentException("No subscription found for club: " + clubId));
String priceId = switch (planTier) {
case STARTER -> starterPriceId;
case PRO -> proPriceId;
case ENTERPRISE -> throw new IllegalArgumentException("Enterprise plans require custom setup");
case TRIAL -> throw new IllegalArgumentException("Cannot checkout for trial plan");
};
SessionCreateParams params = SessionCreateParams.builder()
.setCustomer(subscription.getStripeCustomerId())
.setMode(SessionCreateParams.Mode.SUBSCRIPTION)
.addLineItem(SessionCreateParams.LineItem.builder()
.setPrice(priceId)
.setQuantity(1L)
.build())
.setSuccessUrl(appBaseUrl + "/settings/billing?success=true")
.setCancelUrl(appBaseUrl + "/settings/billing?canceled=true")
.addPaymentMethodType(SessionCreateParams.PaymentMethodType.CARD)
.addPaymentMethodType(SessionCreateParams.PaymentMethodType.SEPA_DEBIT)
.addPaymentMethodType(SessionCreateParams.PaymentMethodType.PAYPAL)
.putMetadata("clubId", clubId.toString())
.putMetadata("planTier", planTier.name())
.build();
Session session = Session.create(params);
return session.getUrl();
}
/**
* Create a Stripe Billing Portal session for self-service management.
*/
public String createBillingPortalSession(UUID clubId) throws StripeException {
Subscription subscription = subscriptionRepository.findByClubId(clubId)
.orElseThrow(() -> new IllegalArgumentException("No subscription found for club: " + clubId));
com.stripe.param.billingportal.SessionCreateParams params =
com.stripe.param.billingportal.SessionCreateParams.builder()
.setCustomer(subscription.getStripeCustomerId())
.setReturnUrl(appBaseUrl + "/settings/billing")
.build();
com.stripe.model.billingportal.Session session =
com.stripe.model.billingportal.Session.create(params);
return session.getUrl();
}
/**
* Handle incoming Stripe webhook events.
*/
@Transactional
public void handleWebhook(String payload, String sigHeader) {
Event event;
try {
event = Webhook.constructEvent(payload, sigHeader, webhookSecret);
} catch (SignatureVerificationException e) {
log.error("Stripe webhook signature verification failed", e);
throw new IllegalArgumentException("Invalid webhook signature");
}
log.info("Processing Stripe event: {} ({})", event.getType(), event.getId());
switch (event.getType()) {
case "checkout.session.completed" -> handleCheckoutCompleted(event);
case "invoice.paid" -> handleInvoicePaid(event);
case "invoice.payment_failed" -> handleInvoicePaymentFailed(event);
case "customer.subscription.deleted" -> handleSubscriptionDeleted(event);
case "customer.subscription.updated" -> handleSubscriptionUpdated(event);
default -> log.debug("Unhandled Stripe event type: {}", event.getType());
}
}
private void handleCheckoutCompleted(Event event) {
Session session = (Session) event.getDataObjectDeserializer()
.getObject().orElse(null);
if (session == null) return;
String clubIdStr = session.getMetadata().get("clubId");
String planTierStr = session.getMetadata().get("planTier");
if (clubIdStr == null || planTierStr == null) return;
UUID clubId = UUID.fromString(clubIdStr);
PlanTier planTier = PlanTier.valueOf(planTierStr);
subscriptionRepository.findByClubId(clubId).ifPresent(sub -> {
sub.setStripeSubscriptionId(session.getSubscription());
sub.setPlanTier(planTier);
sub.setMemberLimit(getMemberLimit(planTier));
sub.setStatus(SubscriptionStatus.ACTIVE);
sub.setCurrentPeriodStart(Instant.now());
sub.setCurrentPeriodEnd(Instant.now().plusSeconds(30L * 24 * 60 * 60));
subscriptionRepository.save(sub);
log.info("Subscription activated for club {} — plan: {}", clubId, planTier);
});
}
private void handleInvoicePaid(Event event) {
Invoice invoice = (Invoice) event.getDataObjectDeserializer()
.getObject().orElse(null);
if (invoice == null || invoice.getSubscription() == null) return;
subscriptionRepository.findByStripeSubscriptionId(invoice.getSubscription())
.ifPresent(sub -> {
sub.setStatus(SubscriptionStatus.ACTIVE);
sub.setCurrentPeriodStart(Instant.ofEpochSecond(invoice.getPeriodStart()));
sub.setCurrentPeriodEnd(Instant.ofEpochSecond(invoice.getPeriodEnd()));
subscriptionRepository.save(sub);
log.info("Invoice paid for subscription: {}", invoice.getSubscription());
});
}
private void handleInvoicePaymentFailed(Event event) {
Invoice invoice = (Invoice) event.getDataObjectDeserializer()
.getObject().orElse(null);
if (invoice == null || invoice.getSubscription() == null) return;
subscriptionRepository.findByStripeSubscriptionId(invoice.getSubscription())
.ifPresent(sub -> {
sub.setStatus(SubscriptionStatus.PAST_DUE);
subscriptionRepository.save(sub);
log.warn("Payment failed for subscription: {}", invoice.getSubscription());
});
}
private void handleSubscriptionDeleted(Event event) {
com.stripe.model.Subscription stripeSub = (com.stripe.model.Subscription) event.getDataObjectDeserializer()
.getObject().orElse(null);
if (stripeSub == null) return;
subscriptionRepository.findByStripeSubscriptionId(stripeSub.getId())
.ifPresent(sub -> {
sub.setStatus(SubscriptionStatus.CANCELED);
sub.setCanceledAt(Instant.now());
subscriptionRepository.save(sub);
log.info("Subscription canceled: {}", stripeSub.getId());
});
}
private void handleSubscriptionUpdated(Event event) {
com.stripe.model.Subscription stripeSub = (com.stripe.model.Subscription) event.getDataObjectDeserializer()
.getObject().orElse(null);
if (stripeSub == null) return;
subscriptionRepository.findByStripeSubscriptionId(stripeSub.getId())
.ifPresent(sub -> {
// Update period dates
sub.setCurrentPeriodStart(Instant.ofEpochSecond(stripeSub.getCurrentPeriodStart()));
sub.setCurrentPeriodEnd(Instant.ofEpochSecond(stripeSub.getCurrentPeriodEnd()));
// Map Stripe status
String stripeStatus = stripeSub.getStatus();
switch (stripeStatus) {
case "active" -> sub.setStatus(SubscriptionStatus.ACTIVE);
case "past_due" -> sub.setStatus(SubscriptionStatus.PAST_DUE);
case "canceled" -> sub.setStatus(SubscriptionStatus.CANCELED);
case "unpaid" -> sub.setStatus(SubscriptionStatus.UNPAID);
case "trialing" -> sub.setStatus(SubscriptionStatus.TRIALING);
}
subscriptionRepository.save(sub);
log.info("Subscription updated: {} → status={}", stripeSub.getId(), stripeStatus);
});
}
private int getMemberLimit(PlanTier tier) {
return switch (tier) {
case TRIAL -> 500;
case STARTER -> 30;
case PRO -> 100;
case ENTERPRISE -> 500;
};
}
}
@@ -0,0 +1,18 @@
package de.cannamanage.service.repository;
import de.cannamanage.domain.entity.Subscription;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.Optional;
import java.util.UUID;
@Repository
public interface SubscriptionRepository extends JpaRepository<Subscription, UUID> {
Optional<Subscription> findByClubId(UUID clubId);
Optional<Subscription> findByStripeCustomerId(String stripeCustomerId);
Optional<Subscription> findByStripeSubscriptionId(String stripeSubscriptionId);
}