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