diff --git a/cannamanage-api/src/main/java/de/cannamanage/api/controller/StripeWebhookController.java b/cannamanage-api/src/main/java/de/cannamanage/api/controller/StripeWebhookController.java new file mode 100644 index 0000000..5bb5e1f --- /dev/null +++ b/cannamanage-api/src/main/java/de/cannamanage/api/controller/StripeWebhookController.java @@ -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 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()); + } + } +} diff --git a/cannamanage-api/src/main/java/de/cannamanage/api/controller/SubscriptionController.java b/cannamanage-api/src/main/java/de/cannamanage/api/controller/SubscriptionController.java new file mode 100644 index 0000000..4219e75 --- /dev/null +++ b/cannamanage-api/src/main/java/de/cannamanage/api/controller/SubscriptionController.java @@ -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 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> 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> 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 + ); + } +} diff --git a/cannamanage-api/src/main/java/de/cannamanage/api/dto/billing/CheckoutRequest.java b/cannamanage-api/src/main/java/de/cannamanage/api/dto/billing/CheckoutRequest.java new file mode 100644 index 0000000..ba98637 --- /dev/null +++ b/cannamanage-api/src/main/java/de/cannamanage/api/dto/billing/CheckoutRequest.java @@ -0,0 +1,7 @@ +package de.cannamanage.api.dto.billing; + +import jakarta.validation.constraints.NotBlank; + +public record CheckoutRequest( + @NotBlank String planTier +) {} diff --git a/cannamanage-api/src/main/java/de/cannamanage/api/dto/billing/SubscriptionResponse.java b/cannamanage-api/src/main/java/de/cannamanage/api/dto/billing/SubscriptionResponse.java new file mode 100644 index 0000000..6a107f3 --- /dev/null +++ b/cannamanage-api/src/main/java/de/cannamanage/api/dto/billing/SubscriptionResponse.java @@ -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 +) {} diff --git a/cannamanage-api/src/main/java/de/cannamanage/api/security/SecurityConfig.java b/cannamanage-api/src/main/java/de/cannamanage/api/security/SecurityConfig.java index a74afa8..9698ed6 100644 --- a/cannamanage-api/src/main/java/de/cannamanage/api/security/SecurityConfig.java +++ b/cannamanage-api/src/main/java/de/cannamanage/api/security/SecurityConfig.java @@ -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") diff --git a/cannamanage-api/src/main/resources/application-production.properties b/cannamanage-api/src/main/resources/application-production.properties index e182d24..8499ee5 100644 --- a/cannamanage-api/src/main/resources/application-production.properties +++ b/cannamanage-api/src/main/resources/application-production.properties @@ -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 diff --git a/cannamanage-api/src/main/resources/db/migration/V7__stripe_subscriptions.sql b/cannamanage-api/src/main/resources/db/migration/V7__stripe_subscriptions.sql new file mode 100644 index 0000000..ebfa3c2 --- /dev/null +++ b/cannamanage-api/src/main/resources/db/migration/V7__stripe_subscriptions.sql @@ -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); diff --git a/cannamanage-domain/src/main/java/de/cannamanage/domain/entity/Subscription.java b/cannamanage-domain/src/main/java/de/cannamanage/domain/entity/Subscription.java new file mode 100644 index 0000000..0e22964 --- /dev/null +++ b/cannamanage-domain/src/main/java/de/cannamanage/domain/entity/Subscription.java @@ -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; } +} diff --git a/cannamanage-domain/src/main/java/de/cannamanage/domain/enums/PlanTier.java b/cannamanage-domain/src/main/java/de/cannamanage/domain/enums/PlanTier.java new file mode 100644 index 0000000..f5730f6 --- /dev/null +++ b/cannamanage-domain/src/main/java/de/cannamanage/domain/enums/PlanTier.java @@ -0,0 +1,8 @@ +package de.cannamanage.domain.enums; + +public enum PlanTier { + TRIAL, + STARTER, + PRO, + ENTERPRISE +} diff --git a/cannamanage-domain/src/main/java/de/cannamanage/domain/enums/SubscriptionStatus.java b/cannamanage-domain/src/main/java/de/cannamanage/domain/enums/SubscriptionStatus.java new file mode 100644 index 0000000..d95f55c --- /dev/null +++ b/cannamanage-domain/src/main/java/de/cannamanage/domain/enums/SubscriptionStatus.java @@ -0,0 +1,9 @@ +package de.cannamanage.domain.enums; + +public enum SubscriptionStatus { + TRIALING, + ACTIVE, + PAST_DUE, + CANCELED, + UNPAID +} diff --git a/cannamanage-frontend/messages/de.json b/cannamanage-frontend/messages/de.json index 696c4e4..3122bc4 100644 --- a/cannamanage-frontend/messages/de.json +++ b/cannamanage-frontend/messages/de.json @@ -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" } } diff --git a/cannamanage-frontend/messages/en.json b/cannamanage-frontend/messages/en.json index 332a2e9..b6b6354 100644 --- a/cannamanage-frontend/messages/en.json +++ b/cannamanage-frontend/messages/en.json @@ -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" } } diff --git a/cannamanage-frontend/src/app/(dashboard-layout)/settings/billing/page.tsx b/cannamanage-frontend/src/app/(dashboard-layout)/settings/billing/page.tsx new file mode 100644 index 0000000..8899d3c --- /dev/null +++ b/cannamanage-frontend/src/app/(dashboard-layout)/settings/billing/page.tsx @@ -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 ( +
+
+
+ ) + } + + 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 ( +
+
+

{t("title")}

+
+ + {/* Current Plan Card */} +
+
+
+

{t("currentPlan")}

+

+ {subscription?.planTier === "TRIAL" + ? t("trial") + : subscription?.planTier === "STARTER" + ? t("starter") + : subscription?.planTier === "PRO" + ? t("pro") + : t("enterprise")} +

+
+
+ {isActive && ( + + {t("active")} + + )} + {isPastDue && ( + + {t("pastDue")} + + )} + {isCanceled && ( + + {t("canceled")} + + )} + {isTrialing && !trialExpired && ( + + {t("trial")} + + )} +
+
+ + {/* Trial countdown */} + {isTrialing && !trialExpired && ( +

+ {t("trialDaysLeft", { days: trialDaysLeft })} +

+ )} + + {/* Member usage */} + {subscription && ( +
+

{t("memberLimit")}

+

+ {t("membersUsed", { + used: "—", + limit: subscription.memberLimit, + })} +

+
+ )} + + {/* Next billing date */} + {subscription?.currentPeriodEnd && isActive && ( +
+

{t("nextBilling")}

+

+ {new Date(subscription.currentPeriodEnd).toLocaleDateString( + "de-DE" + )} +

+
+ )} + + {/* Manage billing button */} + {subscription?.hasStripeSubscription && ( + + )} +
+ + {/* Trial expired banner */} + {trialExpired && ( +
+

+ {t("trialExpired")} +

+
+ )} + + {/* Plan selection */} + {(!subscription?.hasStripeSubscription || trialExpired) && ( +
+

{t("upgrade")}

+
+ {plans.map((plan) => ( +
+

{plan.name}

+

+ {plan.price} + {plan.tier !== "ENTERPRISE" && ( + + /Monat + + )} +

+

+ {plan.description} +

+ +
+ ))} +
+
+ )} +
+ ) +} diff --git a/cannamanage-frontend/src/services/billing.ts b/cannamanage-frontend/src/services/billing.ts new file mode 100644 index 0000000..1129c14 --- /dev/null +++ b/cannamanage-frontend/src/services/billing.ts @@ -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("/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 + }, + }) +} diff --git a/cannamanage-service/pom.xml b/cannamanage-service/pom.xml index 231740f..575162f 100644 --- a/cannamanage-service/pom.xml +++ b/cannamanage-service/pom.xml @@ -79,6 +79,12 @@ commons-csv 1.12.0 + + + com.stripe + stripe-java + 28.2.0 + diff --git a/cannamanage-service/src/main/java/de/cannamanage/service/StripeService.java b/cannamanage-service/src/main/java/de/cannamanage/service/StripeService.java new file mode 100644 index 0000000..d27d111 --- /dev/null +++ b/cannamanage-service/src/main/java/de/cannamanage/service/StripeService.java @@ -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 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; + }; + } +} diff --git a/cannamanage-service/src/main/java/de/cannamanage/service/repository/SubscriptionRepository.java b/cannamanage-service/src/main/java/de/cannamanage/service/repository/SubscriptionRepository.java new file mode 100644 index 0000000..6a7b7e4 --- /dev/null +++ b/cannamanage-service/src/main/java/de/cannamanage/service/repository/SubscriptionRepository.java @@ -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 { + + Optional findByClubId(UUID clubId); + + Optional findByStripeCustomerId(String stripeCustomerId); + + Optional findByStripeSubscriptionId(String stripeSubscriptionId); +}