JIRA-4521: Implement Subscription Billing System
Problem Context
Product Requirements: We need to implement a subscription billing system that: 1. Supports multiple plan types (BASIC, PRO, ENTERPRISE) 2. Handles monthly and yearly billing cycles 3. Processes payments via Stripe 4. Sends email notifications for billing events 5. Manages subscription upgrades/downgrades 6. Tracks billing history This PR implements the core subscription management functionality. The team has been working on this for 2 sprints. Technical context: - Spring Boot 3.x application - PostgreSQL database - Stripe for payments - SendGrid for emails
Expert
300 points
File Changes (7)
src/main/java/com/example/subscription/service/SubscriptionService.java
ADDED
@@ -0 +1 @@
| 1 | +package com.example.subscription.service; |
|
| 2 | + |
|
| 3 | +import com.stripe.Stripe; |
|
| 4 | +import com.stripe.model.PaymentIntent; |
|
| 5 | +import com.sendgrid.SendGrid; |
|
| 6 | + |
|
| 7 | +@Service |
|
| 8 | +@Slf4j |
|
| 9 | +public class SubscriptionService { |
|
| 10 | + |
|
| 11 | + private final SubscriptionRepository subscriptionRepository; |
|
| 12 | + private final UserRepository userRepository; |
|
| 13 | + // DIP Violation: directly instantiating concrete implementations |
|
| 14 | + private StripeClient stripeClient = new StripeClient("sk_live_xxx"); |
|
| 15 | + private SendGridClient emailClient = new SendGridClient("SG.xxx"); |
|
| 16 | + private AnalyticsClient analyticsClient = new AnalyticsClient(); |
|
| 17 | + |
|
| 18 | + public SubscriptionService(SubscriptionRepository subscriptionRepository, |
|
| 19 | + UserRepository userRepository) { |
|
| 20 | + this.subscriptionRepository = subscriptionRepository; |
|
| 21 | + this.userRepository = userRepository; |
|
| 22 | + } |
|
| 23 | + |
|
| 24 | + // SRP Violation: This method does WAY too much |
|
| 25 | + public Subscription createSubscription(SubscriptionRequest request) { |
|
| 26 | + // DRY Violation: Email validation duplicated from controller |
|
| 27 | + String emailRegex = "^[A-Za-z0-9+_.-]+@[A-Za-z0-9.-]+$"; |
|
| 28 | + if (!request.getEmail().matches(emailRegex)) { |
|
| 29 | + throw new ValidationException("Invalid email format"); |
|
| 30 | + } |
|
| 31 | + |
|
| 32 | + User user = userRepository.findById(request.getUserId()) |
|
| 33 | + .orElseThrow(() -> new UserNotFoundException(request.getUserId())); |
|
| 34 | + |
|
| 35 | + // OCP Violation: Switch on plan type - must modify for new plans |
|
| 36 | + BigDecimal price; |
|
| 37 | + switch (request.getPlanType()) { |
|
| 38 | + case BASIC: |
|
| 39 | + price = new BigDecimal("9.99"); |
|
| 40 | + break; |
|
| 41 | + case PRO: |
|
| 42 | + price = new BigDecimal("29.99"); |
|
| 43 | + if (request.getBillingCycle() == BillingCycle.YEARLY) { |
|
| 44 | + price = price.multiply(new BigDecimal("10")); // 2 months free |
|
| 45 | + } |
|
| 46 | + break; |
|
| 47 | + case ENTERPRISE: |
|
| 48 | + price = new BigDecimal("99.99"); |
|
| 49 | + if (request.getBillingCycle() == BillingCycle.YEARLY) { |
|
| 50 | + price = price.multiply(new BigDecimal("10")); |
|
| 51 | + } |
|
| 52 | + break; |
|
| 53 | + default: |
|
| 54 | + throw new IllegalArgumentException("Unknown plan type"); |
|
| 55 | + } |
|
| 56 | + |
|
| 57 | + // SRP Violation: Payment processing in subscription service |
|
| 58 | + PaymentIntent intent = stripeClient.paymentIntents().create( |
|
| 59 | + PaymentIntentCreateParams.builder() |
|
| 60 | + .setAmount(price.multiply(new BigDecimal(100)).longValue()) |
|
| 61 | + .setCurrency("usd") |
|
| 62 | + .setPaymentMethod(request.getPaymentMethodId()) |
|
| 63 | + .setConfirm(true) |
|
| 64 | + .build()); |
|
| 65 | + |
|
| 66 | + Subscription subscription = Subscription.builder() |
|
| 67 | + .userId(user.getId()) |
|
| 68 | + .planType(request.getPlanType()) |
|
| 69 | + .billingCycle(request.getBillingCycle()) |
|
| 70 | + .price(price) |
|
| 71 | + .status(SubscriptionStatus.ACTIVE) |
|
| 72 | + .stripePaymentIntentId(intent.getId()) |
|
| 73 | + .startDate(LocalDateTime.now()) |
|
| 74 | + .build(); |
|
| 75 | + |
|
| 76 | + subscriptionRepository.save(subscription); |
|
| 77 | + |
|
| 78 | + // DRY Violation: Audit logging duplicated in every method |
|
| 79 | + log.info("AUDIT: action=CREATE_SUBSCRIPTION userId={} subscriptionId={} timestamp={}", |
|
| 80 | + user.getId(), subscription.getId(), Instant.now()); |
|
| 81 | + |
|
| 82 | + // SRP Violation: Email sending in subscription service |
|
| 83 | + Email email = new Email(); |
|
| 84 | + email.setFrom(new Email("billing@example.com")); |
|
| 85 | + email.setSubject("Welcome to " + request.getPlanType() + " Plan!"); |
|
| 86 | + email.addTo(new Email(user.getEmail())); |
|
| 87 | + email.setContent(new Content("text/html", buildWelcomeEmailHtml(user, subscription))); |
|
| 88 | + emailClient.send(email); |
|
| 89 | + |
|
| 90 | + // SRP Violation: Analytics in subscription service |
|
| 91 | + analyticsClient.track("subscription_created", Map.of( |
|
| 92 | + "user_id", user.getId(), |
|
| 93 | + "plan", request.getPlanType().name(), |
|
| 94 | + "price", price.doubleValue() |
|
| 95 | + )); |
|
| 96 | + |
|
| 97 | + return subscription; |
|
| 98 | + } |
|
| 99 | + |
|
| 100 | + public Subscription cancelSubscription(Long subscriptionId, String reason) { |
|
| 101 | + Subscription subscription = subscriptionRepository.findById(subscriptionId) |
|
| 102 | + .orElseThrow(() -> new SubscriptionNotFoundException(subscriptionId)); |
|
| 103 | + |
|
| 104 | + User user = userRepository.findById(subscription.getUserId()) |
|
| 105 | + .orElseThrow(() -> new UserNotFoundException(subscription.getUserId())); |
|
| 106 | + |
|
| 107 | + // Cancel Stripe subscription |
|
| 108 | + stripeClient.subscriptions().cancel(subscription.getStripeSubscriptionId()); |
|
| 109 | + |
|
| 110 | + subscription.setStatus(SubscriptionStatus.CANCELLED); |
|
| 111 | + subscription.setCancelledAt(LocalDateTime.now()); |
|
| 112 | + subscription.setCancellationReason(reason); |
|
| 113 | + subscriptionRepository.save(subscription); |
|
| 114 | + |
|
| 115 | + // DRY Violation: Same audit logging pattern |
|
| 116 | + log.info("AUDIT: action=CANCEL_SUBSCRIPTION userId={} subscriptionId={} timestamp={}", |
|
| 117 | + user.getId(), subscription.getId(), Instant.now()); |
|
| 118 | + |
|
| 119 | + // DRY Violation: Same email sending pattern |
|
| 120 | + Email email = new Email(); |
|
| 121 | + email.setFrom(new Email("billing@example.com")); |
|
| 122 | + email.setSubject("Your subscription has been cancelled"); |
|
| 123 | + email.addTo(new Email(user.getEmail())); |
|
| 124 | + email.setContent(new Content("text/html", buildCancellationEmailHtml(user, subscription))); |
|
| 125 | + emailClient.send(email); |
|
| 126 | + |
|
| 127 | + analyticsClient.track("subscription_cancelled", Map.of( |
|
| 128 | + "user_id", user.getId(), |
|
| 129 | + "reason", reason |
|
| 130 | + )); |
|
| 131 | + |
|
| 132 | + return subscription; |
|
| 133 | + } |
|
| 134 | + |
|
| 135 | + private String buildWelcomeEmailHtml(User user, Subscription sub) { /* ... */ } |
|
| 136 | + private String buildCancellationEmailHtml(User user, Subscription sub) { /* ... */ } |
|
| 137 | +} |
src/main/java/com/example/subscription/controller/SubscriptionController.java
ADDED
@@ -0 +1 @@
| 1 | +package com.example.subscription.controller; |
|
| 2 | + |
|
| 3 | +@RestController |
|
| 4 | +@RequestMapping("/api/subscriptions") |
|
| 5 | +public class SubscriptionController { |
|
| 6 | + |
|
| 7 | + private final SubscriptionService subscriptionService; |
|
| 8 | + |
|
| 9 | + @PostMapping |
|
| 10 | + public ResponseEntity<?> createSubscription(@RequestBody SubscriptionRequest request) { |
|
| 11 | + // DRY Violation: Same email validation as in service |
|
| 12 | + String emailRegex = "^[A-Za-z0-9+_.-]+@[A-Za-z0-9.-]+$"; |
|
| 13 | + if (!request.getEmail().matches(emailRegex)) { |
|
| 14 | + return ResponseEntity.badRequest().body("Invalid email"); |
|
| 15 | + } |
|
| 16 | + |
|
| 17 | + // SRP Violation: Business logic in controller - price validation |
|
| 18 | + BigDecimal expectedPrice; |
|
| 19 | + switch (request.getPlanType()) { |
|
| 20 | + case BASIC: expectedPrice = new BigDecimal("9.99"); break; |
|
| 21 | + case PRO: expectedPrice = new BigDecimal("29.99"); break; |
|
| 22 | + case ENTERPRISE: expectedPrice = new BigDecimal("99.99"); break; |
|
| 23 | + default: return ResponseEntity.badRequest().body("Invalid plan"); |
|
| 24 | + } |
|
| 25 | + |
|
| 26 | + try { |
|
| 27 | + Subscription sub = subscriptionService.createSubscription(request); |
|
| 28 | + return ResponseEntity.ok(sub); |
|
| 29 | + } catch (Exception e) { |
|
| 30 | + return ResponseEntity.status(500).body("Error creating subscription"); |
|
| 31 | + } |
|
| 32 | + } |
|
| 33 | + |
|
| 34 | + @GetMapping("/{id}/eligible") |
|
| 35 | + public ResponseEntity<Boolean> checkUpgradeEligibility(@PathVariable Long id) { |
|
| 36 | + // KISS Violation: Overly complex eligibility check |
|
| 37 | + Subscription sub = subscriptionService.findById(id); |
|
| 38 | + boolean eligible = Optional.ofNullable(sub) |
|
| 39 | + .map(s -> s.getStatus() != null |
|
| 40 | + ? s.getStatus() == SubscriptionStatus.ACTIVE |
|
| 41 | + ? s.getPlanType() != PlanType.ENTERPRISE |
|
| 42 | + ? s.getStartDate().plusDays(30).isBefore(LocalDateTime.now()) |
|
| 43 | + ? Boolean.TRUE |
|
| 44 | + : Boolean.FALSE |
|
| 45 | + : Boolean.FALSE |
|
| 46 | + : Boolean.FALSE |
|
| 47 | + : Boolean.FALSE) |
|
| 48 | + .orElse(Boolean.FALSE); |
|
| 49 | + return ResponseEntity.ok(eligible); |
|
| 50 | + } |
|
| 51 | +} |
src/main/java/com/example/subscription/model/FreeSubscription.java
ADDED
@@ -0 +1 @@
| 1 | +package com.example.subscription.model; |
|
| 2 | + |
|
| 3 | +// LSP Violation: FreeSubscription breaks the Subscription contract |
|
| 4 | +public class FreeSubscription extends Subscription { |
|
| 5 | + |
|
| 6 | + public FreeSubscription(Long userId) { |
|
| 7 | + super(userId, PlanType.FREE, BillingCycle.NONE, BigDecimal.ZERO); |
|
| 8 | + } |
|
| 9 | + |
|
| 10 | + @Override |
|
| 11 | + public PaymentResult charge() { |
|
| 12 | + // LSP Violation: Throws exception instead of fulfilling contract |
|
| 13 | + throw new UnsupportedOperationException("Cannot charge free subscription"); |
|
| 14 | + } |
|
| 15 | + |
|
| 16 | + @Override |
|
| 17 | + public void updatePaymentMethod(String paymentMethodId) { |
|
| 18 | + throw new UnsupportedOperationException("Free subscriptions don't have payment methods"); |
|
| 19 | + } |
|
| 20 | + |
|
| 21 | + @Override |
|
| 22 | + public Invoice generateInvoice() { |
|
| 23 | + throw new UnsupportedOperationException("Free subscriptions don't generate invoices"); |
|
| 24 | + } |
|
| 25 | +} |
src/main/java/com/example/subscription/billing/BillingProvider.java
ADDED
@@ -0 +1 @@
| 1 | +package com.example.subscription.billing; |
|
| 2 | + |
|
| 3 | +// ISP Violation: Fat interface forces implementations to implement unused methods |
|
| 4 | +public interface BillingProvider { |
|
| 5 | + // Common methods |
|
| 6 | + PaymentResult charge(BigDecimal amount, String paymentMethodId); |
|
| 7 | + PaymentResult refund(String transactionId, BigDecimal amount); |
|
| 8 | + |
|
| 9 | + // Stripe-specific |
|
| 10 | + String createStripeCustomer(String email); |
|
| 11 | + void attachPaymentMethodToCustomer(String customerId, String paymentMethodId); |
|
| 12 | + |
|
| 13 | + // PayPal-specific |
|
| 14 | + String getPayPalAuthorizationUrl(BigDecimal amount); |
|
| 15 | + PaymentResult capturePayPalPayment(String paypalOrderId); |
|
| 16 | + |
|
| 17 | + // Apple Pay specific |
|
| 18 | + String getMerchantSessionForApplePay(String validationUrl); |
|
| 19 | + PaymentResult processApplePayToken(String applePayToken); |
|
| 20 | +} |
src/main/java/com/example/subscription/billing/StripeBillingProvider.java
ADDED
@@ -0 +1 @@
| 1 | +package com.example.subscription.billing; |
|
| 2 | + |
|
| 3 | +@Component |
|
| 4 | +public class StripeBillingProvider implements BillingProvider { |
|
| 5 | + |
|
| 6 | + @Override |
|
| 7 | + public PaymentResult charge(BigDecimal amount, String paymentMethodId) { |
|
| 8 | + // Actual Stripe implementation |
|
| 9 | + return new PaymentResult(/* ... */); |
|
| 10 | + } |
|
| 11 | + |
|
| 12 | + @Override |
|
| 13 | + public PaymentResult refund(String transactionId, BigDecimal amount) { |
|
| 14 | + // Actual Stripe refund |
|
| 15 | + return new PaymentResult(/* ... */); |
|
| 16 | + } |
|
| 17 | + |
|
| 18 | + @Override |
|
| 19 | + public String createStripeCustomer(String email) { |
|
| 20 | + // Stripe-specific - makes sense here |
|
| 21 | + return "cus_xxx"; |
|
| 22 | + } |
|
| 23 | + |
|
| 24 | + @Override |
|
| 25 | + public void attachPaymentMethodToCustomer(String customerId, String paymentMethodId) { |
|
| 26 | + // Stripe-specific |
|
| 27 | + } |
|
| 28 | + |
|
| 29 | + // ISP Violation: Forced to implement PayPal methods |
|
| 30 | + @Override |
|
| 31 | + public String getPayPalAuthorizationUrl(BigDecimal amount) { |
|
| 32 | + throw new UnsupportedOperationException("Stripe doesn't support PayPal"); |
|
| 33 | + } |
|
| 34 | + |
|
| 35 | + @Override |
|
| 36 | + public PaymentResult capturePayPalPayment(String paypalOrderId) { |
|
| 37 | + throw new UnsupportedOperationException("Stripe doesn't support PayPal"); |
|
| 38 | + } |
|
| 39 | + |
|
| 40 | + // ISP Violation: Forced to implement Apple Pay methods |
|
| 41 | + @Override |
|
| 42 | + public String getMerchantSessionForApplePay(String validationUrl) { |
|
| 43 | + throw new UnsupportedOperationException("Use Stripe's built-in Apple Pay"); |
|
| 44 | + } |
|
| 45 | + |
|
| 46 | + @Override |
|
| 47 | + public PaymentResult processApplePayToken(String applePayToken) { |
|
| 48 | + throw new UnsupportedOperationException("Use Stripe's built-in Apple Pay"); |
|
| 49 | + } |
|
| 50 | +} |
src/main/java/com/example/subscription/factory/SubscriptionFactory.java
ADDED
@@ -0 +1 @@
| 1 | +package com.example.subscription.factory; |
|
| 2 | + |
|
| 3 | +// KISS Violation: Massively over-engineered for creating a simple object |
|
| 4 | +@Component |
|
| 5 | +public class SubscriptionFactory { |
|
| 6 | + |
|
| 7 | + private final SubscriptionBuilderStrategySelector strategySelector; |
|
| 8 | + private final SubscriptionValidatorChain validatorChain; |
|
| 9 | + private final SubscriptionEnricherPipeline enricherPipeline; |
|
| 10 | + private final SubscriptionPostProcessorRegistry postProcessorRegistry; |
|
| 11 | + |
|
| 12 | + public Subscription createSubscription(SubscriptionRequest request) { |
|
| 13 | + // Step 1: Select appropriate builder strategy |
|
| 14 | + SubscriptionBuilderStrategy strategy = strategySelector |
|
| 15 | + .selectStrategy(request.getPlanType()); |
|
| 16 | + |
|
| 17 | + // Step 2: Create builder context |
|
| 18 | + SubscriptionBuilderContext context = SubscriptionBuilderContext.builder() |
|
| 19 | + .request(request) |
|
| 20 | + .timestamp(Instant.now()) |
|
| 21 | + .correlationId(UUID.randomUUID().toString()) |
|
| 22 | + .build(); |
|
| 23 | + |
|
| 24 | + // Step 3: Run validation chain |
|
| 25 | + ValidationResult validationResult = validatorChain.validate(context); |
|
| 26 | + if (!validationResult.isValid()) { |
|
| 27 | + throw new ValidationException(validationResult.getErrors()); |
|
| 28 | + } |
|
| 29 | + |
|
| 30 | + // Step 4: Build subscription using strategy |
|
| 31 | + Subscription subscription = strategy.build(context); |
|
| 32 | + |
|
| 33 | + // Step 5: Enrich subscription |
|
| 34 | + subscription = enricherPipeline.enrich(subscription, context); |
|
| 35 | + |
|
| 36 | + // Step 6: Run post-processors |
|
| 37 | + postProcessorRegistry.getProcessors().forEach(p -> p.process(subscription)); |
|
| 38 | + |
|
| 39 | + return subscription; |
|
| 40 | + } |
|
| 41 | + |
|
| 42 | + // This entire class could be replaced with: |
|
| 43 | + // return Subscription.builder() |
|
| 44 | + // .userId(request.getUserId()) |
|
| 45 | + // .planType(request.getPlanType()) |
|
| 46 | + // .build(); |
|
| 47 | +} |
src/main/java/com/example/subscription/service/BillingService.java
ADDED
@@ -0 +1 @@
| 1 | +package com.example.subscription.service; |
|
| 2 | + |
|
| 3 | +@Service |
|
| 4 | +public class BillingService { |
|
| 5 | + |
|
| 6 | + // DRY Violation: Same price calculation as in SubscriptionService |
|
| 7 | + public Invoice generateInvoice(Subscription subscription) { |
|
| 8 | + BigDecimal price; |
|
| 9 | + switch (subscription.getPlanType()) { |
|
| 10 | + case BASIC: |
|
| 11 | + price = new BigDecimal("9.99"); |
|
| 12 | + break; |
|
| 13 | + case PRO: |
|
| 14 | + price = new BigDecimal("29.99"); |
|
| 15 | + if (subscription.getBillingCycle() == BillingCycle.YEARLY) { |
|
| 16 | + price = price.multiply(new BigDecimal("10")); |
|
| 17 | + } |
|
| 18 | + break; |
|
| 19 | + case ENTERPRISE: |
|
| 20 | + price = new BigDecimal("99.99"); |
|
| 21 | + if (subscription.getBillingCycle() == BillingCycle.YEARLY) { |
|
| 22 | + price = price.multiply(new BigDecimal("10")); |
|
| 23 | + } |
|
| 24 | + break; |
|
| 25 | + default: |
|
| 26 | + price = BigDecimal.ZERO; |
|
| 27 | + } |
|
| 28 | + |
|
| 29 | + return Invoice.builder() |
|
| 30 | + .subscriptionId(subscription.getId()) |
|
| 31 | + .amount(price) |
|
| 32 | + .dueDate(LocalDate.now().plusDays(30)) |
|
| 33 | + .build(); |
|
| 34 | + } |
|
| 35 | + |
|
| 36 | + // DRY Violation: Same audit logging pattern as SubscriptionService |
|
| 37 | + public void processPayment(Invoice invoice) { |
|
| 38 | + // ... payment processing ... |
|
| 39 | + log.info("AUDIT: action=PROCESS_PAYMENT invoiceId={} amount={} timestamp={}", |
|
| 40 | + invoice.getId(), invoice.getAmount(), Instant.now()); |
|
| 41 | + } |
|
| 42 | +} |
Login Required: You must be registered to submit reviews and receive AI feedback.
Register or
login to start reviewing!
Your Review
Review Tips
- Look for security vulnerabilities (SQL injection, XSS, etc.)
- Check for null pointer exceptions and error handling
- Consider performance implications
- Evaluate code maintainability and readability
- Check for proper resource management
- Look for logic errors or edge cases