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);