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:
@@ -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;
|
||||
};
|
||||
}
|
||||
}
|
||||
+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