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:
+29
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+85
@@ -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
|
||||||
|
) {}
|
||||||
+14
@@ -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))
|
.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
||||||
.authorizeHttpRequests(auth -> auth
|
.authorizeHttpRequests(auth -> auth
|
||||||
.requestMatchers("/api/v1/auth/**").permitAll()
|
.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/admin/**").hasRole("ADMIN")
|
||||||
.requestMatchers("/api/v1/staff/**").hasRole("ADMIN")
|
.requestMatchers("/api/v1/staff/**").hasRole("ADMIN")
|
||||||
.requestMatchers("/api/v1/members/**").hasAnyRole("ADMIN", "STAFF", "MEMBER")
|
.requestMatchers("/api/v1/members/**").hasAnyRole("ADMIN", "STAFF", "MEMBER")
|
||||||
|
|||||||
@@ -26,6 +26,11 @@ cannamanage.security.jwt.refresh-token-expiry=2592000
|
|||||||
# Stripe
|
# Stripe
|
||||||
stripe.secret-key=${STRIPE_SECRET_KEY}
|
stripe.secret-key=${STRIPE_SECRET_KEY}
|
||||||
stripe.webhook-secret=${STRIPE_WEBHOOK_SECRET}
|
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
|
# Error handling — never expose internals
|
||||||
server.error.include-message=never
|
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
|
||||||
|
}
|
||||||
@@ -370,5 +370,34 @@
|
|||||||
"deleteButton": "Konto endgültig löschen",
|
"deleteButton": "Konto endgültig löschen",
|
||||||
"deleteConfirm": "Bist du sicher? Diese Aktion kann nicht rückgängig gemacht werden.",
|
"deleteConfirm": "Bist du sicher? Diese Aktion kann nicht rückgängig gemacht werden.",
|
||||||
"deleteSuccess": "Dein Konto wurde gelöscht. Du wirst jetzt abgemeldet."
|
"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",
|
"deleteButton": "Permanently delete account",
|
||||||
"deleteConfirm": "Are you sure? This action cannot be undone.",
|
"deleteConfirm": "Are you sure? This action cannot be undone.",
|
||||||
"deleteSuccess": "Your account has been deleted. You will now be logged out."
|
"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
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -79,6 +79,12 @@
|
|||||||
<artifactId>commons-csv</artifactId>
|
<artifactId>commons-csv</artifactId>
|
||||||
<version>1.12.0</version>
|
<version>1.12.0</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
<!-- Stripe Java SDK -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.stripe</groupId>
|
||||||
|
<artifactId>stripe-java</artifactId>
|
||||||
|
<version>28.2.0</version>
|
||||||
|
</dependency>
|
||||||
</dependencies>
|
</dependencies>
|
||||||
|
|
||||||
<build>
|
<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;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
+18
@@ -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);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user