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

Tip: Be thorough! Consider security, performance, code quality, and best practices.
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
Analyzing Your Review
Our AI is carefully evaluating your code review against best practices