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))
|
||||
.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")
|
||||
|
||||
Reference in New Issue
Block a user