Skip to main content

Subscription Management Deep Dive

Overview

This comprehensive guide covers subscription lifecycle management in the Ink platform, including subscription creation workflows, billing cycle management, upgrade/downgrade logic, cancellation and refund processes, Stripe integration, and state machine diagrams.

Target Audience: Backend developers working with subscriptions
Prerequisites: Service Layer Architecture, Stripe Integration
Estimated Time: 50-60 minutes

Prerequisites

Subscription Lifecycle Architecture

Installation Steps

The Installation Steps section establishes the foundational infrastructure for subscription billing and management in the Ink platform. These steps integrate Stripe's payment processing capabilities and configure the business rules that govern subscription behavior throughout the entire lifecycle.

What You'll Set Up:

  1. Subscription Dependencies - Stripe Java SDK for payment processing and subscription management
  2. Subscription Configuration - Business rules, policies, and default behaviors for subscription operations

Why This Matters:

  • Enables recurring billing and payment processing through Stripe
  • Provides centralized configuration for trial periods, billing cycles, and cancellation policies
  • Establishes consistent subscription behavior across all environments
  • Supports flexible business rule changes without code modifications
  • Separates payment infrastructure concerns from business logic

When to Use:

  • Initial application setup for subscription billing features
  • Adding subscription capabilities to new microservices
  • Environment provisioning (development, staging, production)
  • Updating subscription policies or payment provider configurations
  • Implementing new billing models or pricing strategies

Key Capabilities Enabled:

  • Payment Processing: Charge customers and handle recurring payments
  • Subscription Lifecycle: Create, upgrade, downgrade, and cancel subscriptions
  • Trial Management: Offer trial periods with configurable duration
  • Proration: Calculate prorated charges for mid-cycle plan changes
  • Webhook Integration: Receive real-time payment and subscription events from Stripe
  • Grace Periods: Handle failed payments with retry logic and grace periods
  • Refund Processing: Automatically calculate and process prorated refunds

Configuration Hierarchy:

subscription
├── trial
│ ├── defaultDays: 14
│ └── allowMultipleTrials: false
├── billing
│ ├── gracePeriodDays: 7
│ ├── retryAttempts: 3
│ ├── retryIntervalDays: 3
│ └── prorationBehavior: CREATE_PRORATIONS
├── cancellation
│ ├── allowImmediateCancellation: true
│ ├── refundPolicy: PRORATED
│ └── endOfPeriodGraceDays: 0
├── upgrade
│ ├── effectiveImmediately: true
│ └── prorationEnabled: true
└── downgrade
├── effectiveEndOfPeriod: true
└── prorationEnabled: false

1. Subscription Dependencies

What Are Subscription Dependencies?

Subscription dependencies are the external libraries and SDKs required to implement subscription billing functionality. The primary dependency is the Stripe Java SDK, which provides the interface to Stripe's payment processing and subscription management platform.

Why Are They Needed?

  • Payment Processing: Handle credit card charges and recurring billing
  • Subscription Management: Create, update, and cancel subscriptions programmatically
  • Webhook Handling: Receive real-time notifications about subscription events
  • Customer Management: Maintain customer payment methods and billing information
  • Invoice Generation: Automatically create and send invoices

How/When Are They Used?

These dependencies are used throughout the subscription lifecycle:

  • Subscription Creation: When a user signs up for a plan
  • Payment Processing: During initial charge and recurring billing cycles
  • Plan Changes: When users upgrade or downgrade their subscription
  • Cancellation: When terminating subscriptions
  • Webhook Processing: Continuously to stay synchronized with Stripe

Dependency Relationships

Code Example (XML)

<!-- filepath: /Users/jetstart/dev/jetrev/ink/pom.xml -->
<!-- ...existing code... -->
<dependencies>
<!-- Stripe SDK for subscription billing -->
<dependency>
<groupId>com.stripe</groupId>
<artifactId>stripe-java</artifactId>
<version>24.3.0</version>
</dependency>

<!-- Jackson for JSON processing (used by Stripe SDK) -->
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
</dependency>
</dependencies>
<!-- ...existing code... -->

2. Subscription Configuration

What Is Subscription Configuration?

Subscription configuration defines the business rules, policies, and defaults for how subscriptions behave in your system. These settings control trial periods, billing cycles, cancellation policies, grace periods, and proration behavior.

Why Is It Needed?

  • Business Rules: Encode subscription policies in configuration
  • Flexibility: Adjust behavior without code changes
  • Consistency: Ensure uniform subscription handling across the platform
  • Environment-Specific: Different settings for dev/staging/production
  • Feature Flags: Enable/disable subscription features dynamically

How/When Is It Used?

Configuration is loaded at application startup and referenced throughout subscription operations:

  • Trial Periods: Determine trial duration when creating subscriptions
  • Grace Periods: Define how long to wait before canceling past-due subscriptions
  • Proration: Control how to handle mid-cycle plan changes
  • Retries: Configure payment retry attempts and intervals
  • Refunds: Define refund calculation and processing rules

Configuration Flow

Code Example (YAML)

# filepath: /Users/jetstart/dev/jetrev/ink/src/main/resources/application.yml
# ...existing code...
subscription:
# Trial period configuration
trial:
defaultDays: 14 # Default trial period length
allowMultipleTrials: false # Prevent users from getting multiple trials
requirePaymentMethod: true # Require payment method during trial signup

# Billing cycle configuration
billing:
gracePeriodDays: 7 # Days to wait before expiring past-due subscriptions
retryAttempts: 3 # Number of payment retry attempts
retryIntervalDays: 3 # Days between retry attempts
prorationBehavior: CREATE_PRORATIONS # How to handle mid-cycle changes
invoiceDaysBefore: 3 # Send invoice N days before period end

# Cancellation policy configuration
cancellation:
allowImmediateCancellation: true # Allow instant cancellation
refundPolicy: PRORATED # NONE, FULL, PRORATED
endOfPeriodGraceDays: 0 # Extra days after period end
requireReason: true # Force users to provide cancellation reason

# Upgrade configuration
upgrade:
effectiveImmediately: true # Apply upgrades immediately
prorationEnabled: true # Charge prorated amount for upgrade
allowDowngrade: true # Allow downgrades

# Downgrade configuration
downgrade:
effectiveEndOfPeriod: true # Apply at end of current period
prorationEnabled: false # No refund for downgrade
notifyDaysBefore: 7 # Notify user N days before downgrade
# ...existing code...

Configuration

Subscription Entity

What Is the Subscription Entity?

The Subscription entity is the core JPA entity that represents a subscription in the database. It stores all subscription state including the user, plan, billing periods, trial information, payment details, and Stripe references.

Why Is It Needed?

  • Data Persistence: Store subscription state in the database
  • State Tracking: Monitor subscription status and lifecycle
  • Billing History: Track billing periods and payment attempts
  • Stripe Synchronization: Maintain references to Stripe objects
  • Audit Trail: Record subscription changes over time

How/When Is It Used?

The entity is used throughout the subscription lifecycle:

  • Creation: Persisting new subscriptions
  • Updates: Tracking state changes (trial → active → canceled)
  • Queries: Finding active subscriptions for renewal
  • Relationships: Linking users to their subscriptions
  • Reporting: Analyzing subscription metrics

Entity Relationships

Code Example (Java)

// filepath: /Users/jetstart/dev/jetrev/ink/src/main/java/com/jetrev/ink/entity/Subscription.java
@Entity
@Table(name = "subscriptions", indexes = {
@Index(name = "idx_subscription_user", columnList = "user_id"),
@Index(name = "idx_subscription_status", columnList = "status"),
@Index(name = "idx_subscription_period_end", columnList = "current_period_end"),
@Index(name = "idx_subscription_stripe", columnList = "stripe_subscription_id")
})
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
@EqualsAndHashCode(of = "id")
public class Subscription implements Serializable {

@Serial
private static final long serialVersionUID = 1L;

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;

@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id", nullable = false)
private User user;

@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "plan_id", nullable = false)
private SubscriptionPlan plan;

@Enumerated(EnumType.STRING)
@Column(name = "status", nullable = false, length = 20)
private SubscriptionStatus status;

@Column(name = "current_period_start", nullable = false)
private Instant currentPeriodStart;

@Column(name = "current_period_end", nullable = false)
private Instant currentPeriodEnd;

@Column(name = "trial_start")
private Instant trialStart;

@Column(name = "trial_end")
private Instant trialEnd;

@Column(name = "canceled_at")
private Instant canceledAt;

@Column(name = "cancel_at_period_end")
private boolean cancelAtPeriodEnd = false;

@Column(name = "ended_at")
private Instant endedAt;

@Column(name = "stripe_subscription_id", unique = true)
private String stripeSubscriptionId;

@Column(name = "stripe_customer_id")
private String stripeCustomerId;

@Column(name = "stripe_latest_invoice_id")
private String stripeLatestInvoiceId;

@OneToMany(mappedBy = "subscription", cascade = CascadeType.ALL, orphanRemoval = true)
@BatchSize(size = 25)
private List<SubscriptionItem> items = new ArrayList<>();

@Column(name = "metadata", columnDefinition = "jsonb")
@JdbcTypeCode(SqlTypes.JSON)
private Map<String, Object> metadata = new HashMap<>();

@Column(name = "created_at", nullable = false, updatable = false)
private Instant createdAt;

@Column(name = "updated_at", nullable = false)
private Instant updatedAt;

@Version
private Integer version;

@PrePersist
protected void onCreate() {
createdAt = Instant.now();
updatedAt = Instant.now();
}

@PreUpdate
protected void onUpdate() {
updatedAt = Instant.now();
}

public boolean isActive() {
return status == SubscriptionStatus.ACTIVE || status == SubscriptionStatus.TRIALING;
}

public boolean isInTrial() {
return status == SubscriptionStatus.TRIALING &&
trialEnd != null &&
Instant.now().isBefore(trialEnd);
}

public boolean shouldRenew() {
return isActive() &&
!cancelAtPeriodEnd &&
Instant.now().isAfter(currentPeriodEnd);
}

public void addItem(SubscriptionItem item) {
items.add(item);
item.setSubscription(this);
}
}

@Getter
public enum SubscriptionStatus {
PENDING("Pending"),
TRIALING("Trialing"),
ACTIVE("Active"),
PAST_DUE("Past Due"),
CANCELED("Canceled"),
EXPIRED("Expired"),
PAYMENT_FAILED("Payment Failed");

private final String displayName;

SubscriptionStatus(String displayName) {
this.displayName = displayName;
}
}

Subscription Plan Entity

What Is the Subscription Plan Entity?

The Subscription Plan entity represents a subscription tier or pricing plan (e.g., Basic, Pro, Enterprise). It defines the price, billing period, trial length, and features included in each plan.

Why Is It Needed?

  • Plan Management: Define and modify subscription tiers
  • Pricing Control: Set prices and billing frequencies
  • Feature Definition: Specify what's included in each plan
  • Stripe Integration: Link to Stripe Price and Product objects
  • Flexibility: Support multiple plans and pricing strategies

How/When Is It Used?

Plans are referenced when:

  • Subscription Creation: Users select a plan to subscribe to
  • Plan Upgrades: Users switch to higher-tier plans
  • Plan Downgrades: Users switch to lower-tier plans
  • Pricing Display: Show available plans on pricing page
  • Feature Access: Check if user's plan includes specific features

Plan Hierarchy

Code Example (Java)

// filepath: /Users/jetstart/dev/jetrev/ink/src/main/java/com/jetrev/ink/entity/SubscriptionPlan.java
@Entity
@Table(name = "subscription_plans")
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class SubscriptionPlan {

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;

@Column(nullable = false, unique = true)
private String name;

@Column(columnDefinition = "TEXT")
private String description;

@Enumerated(EnumType.STRING)
@Column(name = "billing_period", nullable = false)
private BillingPeriod billingPeriod;

@Column(nullable = false)
private BigDecimal price;

@Column(length = 3)
private String currency = "USD";

@Column(name = "trial_days")
private Integer trialDays = 0;

@Column(name = "stripe_price_id")
private String stripePriceId;

@Column(name = "stripe_product_id")
private String stripeProductId;

@Column(name = "features", columnDefinition = "jsonb")
@JdbcTypeCode(SqlTypes.JSON)
private Map<String, Object> features = new HashMap<>();

@Column(nullable = false)
private boolean active = true;

@Column(name = "created_at", nullable = false, updatable = false)
private Instant createdAt;

@Column(name = "updated_at", nullable = false)
private Instant updatedAt;

@PrePersist
protected void onCreate() {
createdAt = Instant.now();
updatedAt = Instant.now();
}

@PreUpdate
protected void onUpdate() {
updatedAt = Instant.now();
}
}

@Getter
public enum BillingPeriod {
MONTHLY("month", 30),
QUARTERLY("quarter", 90),
YEARLY("year", 365);

private final String stripeName;
private final int days;

BillingPeriod(String stripeName, int days) {
this.stripeName = stripeName;
this.days = days;
}
}

Subscription Service Implementation

What Is the Subscription Service?

The Subscription Service is the core business logic layer that orchestrates all subscription operations. It coordinates between the database, Stripe API, event publishing, and implements all subscription workflows.

Why Is It Needed?

  • Business Logic: Encapsulate subscription rules and workflows
  • Transaction Management: Ensure data consistency across operations
  • Integration Orchestration: Coordinate between local DB and Stripe
  • Event Publishing: Notify other services of subscription changes
  • Error Handling: Manage failures and edge cases gracefully

How/When Is It Used?

The service is invoked for all subscription operations:

  • Creation: When users sign up for a subscription
  • Upgrades: When users switch to higher-tier plans
  • Downgrades: When users switch to lower-tier plans
  • Cancellation: When users or system cancels subscriptions
  • Renewal: Automatically via scheduled jobs
  • Payment Recovery: After failed payment attempts

Service Operation Flow

Code Example (Java)

// filepath: /Users/jetstart/dev/jetrev/ink/src/main/java/com/jetrev/ink/service/subscription/SubscriptionService.java
@Service
@Transactional
@RequiredArgsConstructor
@Slf4j
public class SubscriptionService {

private final SubscriptionRepository subscriptionRepository;
private final SubscriptionPlanRepository planRepository;
private final UserRepository userRepository;
private final StripeClient stripeClient;
private final SubscriptionEventProducer eventProducer;
private final SubscriptionMapper subscriptionMapper;

@Value("${subscription.trial.defaultDays:14}")
private int defaultTrialDays;

@Value("${subscription.billing.gracePeriodDays:7}")
private int gracePeriodDays;

/**
* Create a new subscription
*/
public SubscriptionDto createSubscription(CreateSubscriptionRequest request) {
log.info("Creating subscription: userId={}, planId={}",
request.getUserId(), request.getPlanId());

User user = userRepository.findById(request.getUserId())
.orElseThrow(() -> new UserNotFoundException(request.getUserId()));

SubscriptionPlan plan = planRepository.findById(request.getPlanId())
.orElseThrow(() -> new SubscriptionPlanNotFoundException(request.getPlanId()));

// Check if user already has active subscription
subscriptionRepository.findByUserIdAndStatusIn(
user.getId(),
List.of(SubscriptionStatus.ACTIVE, SubscriptionStatus.TRIALING)
).ifPresent(existing -> {
throw new ActiveSubscriptionExistsException(
"User already has an active subscription");
});

// Determine trial period
int trialDays = determineTrialDays(request, plan);
Instant now = Instant.now();
Instant trialEnd = trialDays > 0 ? now.plus(trialDays, ChronoUnit.DAYS) : null;

// Create Stripe subscription
StripeSubscription stripeSubscription = createStripeSubscription(user, plan, trialDays);

// Create local subscription
Subscription subscription = Subscription.builder()
.user(user)
.plan(plan)
.status(trialDays > 0 ? SubscriptionStatus.TRIALING : SubscriptionStatus.PENDING)
.currentPeriodStart(now)
.currentPeriodEnd(calculatePeriodEnd(now, plan.getBillingPeriod()))
.trialStart(trialDays > 0 ? now : null)
.trialEnd(trialEnd)
.stripeSubscriptionId(stripeSubscription.getId())
.stripeCustomerId(stripeSubscription.getCustomerId())
.metadata(request.getMetadata() != null ? request.getMetadata() : new HashMap<>())
.build();

Subscription saved = subscriptionRepository.save(subscription);

eventProducer.publishSubscriptionCreatedEvent(saved);

log.info("Subscription created successfully: id={}, stripeId={}",
saved.getId(), saved.getStripeSubscriptionId());

return subscriptionMapper.toDto(saved);
}

/**
* Upgrade subscription to a higher plan
*/
public SubscriptionDto upgradeSubscription(Long subscriptionId, Long newPlanId) {
log.info("Upgrading subscription: id={}, newPlanId={}", subscriptionId, newPlanId);

Subscription subscription = subscriptionRepository.findById(subscriptionId)
.orElseThrow(() -> new SubscriptionNotFoundException(subscriptionId));

if (!subscription.isActive()) {
throw new IllegalStateException("Cannot upgrade inactive subscription");
}

SubscriptionPlan newPlan = planRepository.findById(newPlanId)
.orElseThrow(() -> new SubscriptionPlanNotFoundException(newPlanId));

SubscriptionPlan currentPlan = subscription.getPlan();

// Validate it's an upgrade
if (newPlan.getPrice().compareTo(currentPlan.getPrice()) <= 0) {
throw new IllegalArgumentException("New plan must have higher price for upgrade");
}

// Update Stripe subscription with proration
stripeClient.updateSubscription(
subscription.getStripeSubscriptionId(),
UpdateStripeSubscriptionRequest.builder()
.priceId(newPlan.getStripePriceId())
.prorationBehavior("create_prorations")
.build()
);

// Update local subscription
subscription.setPlan(newPlan);
subscription.getMetadata().put("previousPlanId", currentPlan.getId());
subscription.getMetadata().put("upgradedAt", Instant.now().toString());

Subscription updated = subscriptionRepository.save(subscription);

eventProducer.publishSubscriptionUpgradedEvent(updated, currentPlan, newPlan);

log.info("Subscription upgraded successfully: id={}", subscriptionId);

return subscriptionMapper.toDto(updated);
}

/**
* Downgrade subscription to a lower plan
*/
public SubscriptionDto downgradeSubscription(Long subscriptionId, Long newPlanId) {
log.info("Downgrading subscription: id={}, newPlanId={}", subscriptionId, newPlanId);

Subscription subscription = subscriptionRepository.findById(subscriptionId)
.orElseThrow(() -> new SubscriptionNotFoundException(subscriptionId));

if (!subscription.isActive()) {
throw new IllegalStateException("Cannot downgrade inactive subscription");
}

SubscriptionPlan newPlan = planRepository.findById(newPlanId)
.orElseThrow(() -> new SubscriptionPlanNotFoundException(newPlanId));

SubscriptionPlan currentPlan = subscription.getPlan();

// Validate it's a downgrade
if (newPlan.getPrice().compareTo(currentPlan.getPrice()) >= 0) {
throw new IllegalArgumentException("New plan must have lower price for downgrade");
}

// Schedule downgrade for end of current period (no proration)
subscription.getMetadata().put("scheduledPlanId", newPlan.getId());
subscription.getMetadata().put("scheduledDowngradeAt", subscription.getCurrentPeriodEnd().toString());

Subscription updated = subscriptionRepository.save(subscription);

eventProducer.publishSubscriptionDowngradeScheduledEvent(updated, currentPlan, newPlan);

log.info("Subscription downgrade scheduled: id={}, effectiveDate={}",
subscriptionId, subscription.getCurrentPeriodEnd());

return subscriptionMapper.toDto(updated);
}

/**
* Cancel subscription
*/
public SubscriptionDto cancelSubscription(
Long subscriptionId,
CancelSubscriptionRequest request) {

log.info("Canceling subscription: id={}, immediate={}",
subscriptionId, request.isImmediateCancellation());

Subscription subscription = subscriptionRepository.findById(subscriptionId)
.orElseThrow(() -> new SubscriptionNotFoundException(subscriptionId));

if (subscription.getStatus() == SubscriptionStatus.CANCELED) {
throw new IllegalStateException("Subscription is already canceled");
}

if (request.isImmediateCancellation()) {
// Cancel immediately with optional refund
cancelImmediately(subscription, request);
} else {
// Cancel at end of current period
cancelAtPeriodEnd(subscription, request);
}

Subscription updated = subscriptionRepository.save(subscription);

eventProducer.publishSubscriptionCanceledEvent(updated, request.getReason());

log.info("Subscription canceled: id={}, canceledAt={}",
subscriptionId, subscription.getCanceledAt());

return subscriptionMapper.toDto(updated);
}

/**
* Reactivate a canceled subscription
*/
public SubscriptionDto reactivateSubscription(Long subscriptionId) {
log.info("Reactivating subscription: id={}", subscriptionId);

Subscription subscription = subscriptionRepository.findById(subscriptionId)
.orElseThrow(() -> new SubscriptionNotFoundException(subscriptionId));

if (subscription.getStatus() != SubscriptionStatus.CANCELED) {
throw new IllegalStateException("Only canceled subscriptions can be reactivated");
}

if (!subscription.isCancelAtPeriodEnd()) {
throw new IllegalStateException("Cannot reactivate immediately canceled subscription");
}

// Reactivate in Stripe
stripeClient.updateSubscription(
subscription.getStripeSubscriptionId(),
UpdateStripeSubscriptionRequest.builder()
.cancelAtPeriodEnd(false)
.build()
);

// Update local subscription
subscription.setStatus(SubscriptionStatus.ACTIVE);
subscription.setCancelAtPeriodEnd(false);
subscription.setCanceledAt(null);

Subscription updated = subscriptionRepository.save(subscription);

eventProducer.publishSubscriptionReactivatedEvent(updated);

log.info("Subscription reactivated: id={}", subscriptionId);

return subscriptionMapper.toDto(updated);
}

/**
* Renew subscription (called by scheduled job)
*/
@Transactional
public void renewSubscription(Long subscriptionId) {
log.info("Renewing subscription: id={}", subscriptionId);

Subscription subscription = subscriptionRepository.findById(subscriptionId)
.orElseThrow(() -> new SubscriptionNotFoundException(subscriptionId));

if (!subscription.shouldRenew()) {
log.warn("Subscription should not be renewed: id={}", subscriptionId);
return;
}

try {
// Get latest invoice from Stripe
StripeInvoice invoice = stripeClient.getLatestInvoice(
subscription.getStripeSubscriptionId());

if ("paid".equals(invoice.getStatus())) {
// Successful renewal
Instant now = Instant.now();
subscription.setCurrentPeriodStart(subscription.getCurrentPeriodEnd());
subscription.setCurrentPeriodEnd(
calculatePeriodEnd(subscription.getCurrentPeriodEnd(),
subscription.getPlan().getBillingPeriod())
);
subscription.setStripeLatestInvoiceId(invoice.getId());

// Apply scheduled downgrade if exists
applyScheduledPlanChange(subscription);

subscriptionRepository.save(subscription);

eventProducer.publishSubscriptionRenewedEvent(subscription);

log.info("Subscription renewed successfully: id={}", subscriptionId);
} else {
// Payment failed
handleRenewalPaymentFailure(subscription);
}

} catch (Exception e) {
log.error("Failed to renew subscription: id={}", subscriptionId, e);
handleRenewalPaymentFailure(subscription);
}
}

// Helper methods

private int determineTrialDays(CreateSubscriptionRequest request, SubscriptionPlan plan) {
if (request.getTrialDays() != null) {
return request.getTrialDays();
}
return plan.getTrialDays() != null ? plan.getTrialDays() : defaultTrialDays;
}

private Instant calculatePeriodEnd(Instant start, BillingPeriod period) {
return start.plus(period.getDays(), ChronoUnit.DAYS);
}

private StripeSubscription createStripeSubscription(
User user,
SubscriptionPlan plan,
int trialDays) {

// Create or get Stripe customer
String customerId = user.getStripeCustomerId();
if (customerId == null) {
StripeCustomer customer = stripeClient.createCustomer(
CreateStripeCustomerRequest.builder()
.email(user.getEmail())
.name(user.getFullName())
.metadata(Map.of("userId", user.getId().toString()))
.build()
);
customerId = customer.getId();
user.setStripeCustomerId(customerId);
userRepository.save(user);
}

// Create subscription
return stripeClient.createSubscription(
CreateStripeSubscriptionRequest.builder()
.customerId(customerId)
.priceId(plan.getStripePriceId())
.trialPeriodDays(trialDays > 0 ? trialDays : null)
.build()
);
}

private void cancelImmediately(Subscription subscription, CancelSubscriptionRequest request) {
// Cancel in Stripe
stripeClient.cancelSubscription(subscription.getStripeSubscriptionId(), true);

// Process refund if requested
if (request.isRefund()) {
processRefund(subscription, request.getRefundReason());
}

subscription.setStatus(SubscriptionStatus.CANCELED);
subscription.setCanceledAt(Instant.now());
subscription.setEndedAt(Instant.now());
subscription.getMetadata().put("cancellationReason", request.getReason());
}

private void cancelAtPeriodEnd(Subscription subscription, CancelSubscriptionRequest request) {
// Schedule cancellation in Stripe
stripeClient.updateSubscription(
subscription.getStripeSubscriptionId(),
UpdateStripeSubscriptionRequest.builder()
.cancelAtPeriodEnd(true)
.build()
);

subscription.setCancelAtPeriodEnd(true);
subscription.setCanceledAt(Instant.now());
subscription.getMetadata().put("cancellationReason", request.getReason());
subscription.getMetadata().put("willEndAt", subscription.getCurrentPeriodEnd().toString());
}

private void processRefund(Subscription subscription, String reason) {
// Calculate prorated refund amount
BigDecimal refundAmount = calculateProratedRefund(subscription);

if (refundAmount.compareTo(BigDecimal.ZERO) > 0) {
log.info("Processing refund: subscriptionId={}, amount={}",
subscription.getId(), refundAmount);

// Process refund in Stripe
// stripeClient.createRefund(...)

subscription.getMetadata().put("refundAmount", refundAmount.toString());
subscription.getMetadata().put("refundReason", reason);
}
}

private BigDecimal calculateProratedRefund(Subscription subscription) {
long totalPeriodSeconds = Duration.between(
subscription.getCurrentPeriodStart(),
subscription.getCurrentPeriodEnd()
).getSeconds();

long remainingSeconds = Duration.between(
Instant.now(),
subscription.getCurrentPeriodEnd()
).getSeconds();

if (remainingSeconds <= 0) {
return BigDecimal.ZERO;
}

BigDecimal planPrice = subscription.getPlan().getPrice();
BigDecimal refundPercentage = new BigDecimal(remainingSeconds)
.divide(new BigDecimal(totalPeriodSeconds), 4, RoundingMode.HALF_UP);

return planPrice.multiply(refundPercentage)
.setScale(2, RoundingMode.HALF_UP);
}

private void applyScheduledPlanChange(Subscription subscription) {
Object scheduledPlanId = subscription.getMetadata().get("scheduledPlanId");
if (scheduledPlanId != null) {
Long newPlanId = Long.parseLong(scheduledPlanId.toString());
SubscriptionPlan newPlan = planRepository.findById(newPlanId)
.orElse(null);

if (newPlan != null) {
SubscriptionPlan oldPlan = subscription.getPlan();
subscription.setPlan(newPlan);
subscription.getMetadata().remove("scheduledPlanId");
subscription.getMetadata().remove("scheduledDowngradeAt");
subscription.getMetadata().put("previousPlanId", oldPlan.getId());
subscription.getMetadata().put("downgradedAt", Instant.now().toString());

log.info("Applied scheduled plan change: subscriptionId={}, newPlanId={}",
subscription.getId(), newPlanId);
}
}
}

private void handleRenewalPaymentFailure(Subscription subscription) {
subscription.setStatus(SubscriptionStatus.PAST_DUE);

// Set grace period end
Instant gracePeriodEnd = Instant.now().plus(gracePeriodDays, ChronoUnit.DAYS);
subscription.getMetadata().put("gracePeriodEnd", gracePeriodEnd.toString());

subscriptionRepository.save(subscription);

eventProducer.publishSubscriptionPaymentFailedEvent(subscription);

log.warn("Subscription payment failed, now PAST_DUE: id={}", subscription.getId());
}
}

Usage Examples

Creating Subscriptions

What Is Subscription Creation?

Subscription creation is the process of establishing a new recurring billing arrangement for a user. This involves selecting a plan, optionally starting a trial, setting up payment in Stripe, and creating the local subscription record.

When Is It Used?

  • New Signups: When users first subscribe to a paid plan
  • Plan Activation: After trial ends and payment succeeds
  • Re-subscription: When previously canceled users re-subscribe

Creation Flow

Code Example (Java)

// Create subscription with trial
CreateSubscriptionRequest request = CreateSubscriptionRequest.builder()
.userId(1L)
.planId(100L)
.trialDays(14)
.metadata(Map.of(
"source", "web_signup",
"campaign", "summer_promo"
))
.build();

SubscriptionDto subscription = subscriptionService.createSubscription(request);

// Create subscription without trial
CreateSubscriptionRequest noTrialRequest = CreateSubscriptionRequest.builder()
.userId(1L)
.planId(100L)
.trialDays(0)
.build();

Plan Changes

What Are Plan Changes?

Plan changes include upgrades (moving to a higher-tier plan) and downgrades (moving to a lower-tier plan). These operations must handle proration, scheduling, and Stripe synchronization.

When Are They Used?

  • Upgrades: User wants more features immediately
  • Downgrades: User wants to reduce costs at period end
  • Plan Migration: Moving users between different billing structures

Upgrade/Downgrade Decision Flow

Code Example (Java)

// Upgrade (immediate with proration)
SubscriptionDto upgraded = subscriptionService.upgradeSubscription(
subscriptionId,
premiumPlanId
);

// Downgrade (scheduled for period end)
SubscriptionDto downgraded = subscriptionService.downgradeSubscription(
subscriptionId,
basicPlanId
);

// Check for scheduled downgrade
if (subscription.getMetadata().containsKey("scheduledPlanId")) {
Long scheduledPlanId = (Long) subscription.getMetadata().get("scheduledPlanId");
String effectiveDate = (String) subscription.getMetadata().get("scheduledDowngradeAt");
log.info("Downgrade scheduled for: {}", effectiveDate);
}

Cancellation Scenarios

What Is Subscription Cancellation?

Cancellation is the process of terminating a subscription, either immediately or at the end of the current billing period. It may include refunds based on the cancellation policy.

When Is It Used?

  • User Request: Customer wants to cancel
  • Payment Failure: After exhausting retry attempts
  • Policy Violation: Administrative cancellation
  • Account Closure: User deletes their account

Cancellation Decision Tree

Code Example (Java)

// Cancel at period end (no refund)
CancelSubscriptionRequest endOfPeriodCancel = CancelSubscriptionRequest.builder()
.immediateCancellation(false)
.reason("User requested downgrade")
.requireConfirmation(true)
.build();

subscriptionService.cancelSubscription(subscriptionId, endOfPeriodCancel);

// Cancel immediately with prorated refund
CancelSubscriptionRequest immediateCancel = CancelSubscriptionRequest.builder()
.immediateCancellation(true)
.refund(true)
.refundReason("Service quality issue")
.reason("Customer complaint - technical issues")
.build();

SubscriptionDto canceled = subscriptionService.cancelSubscription(
subscriptionId,
immediateCancel
);

// Reactivate a canceled subscription (only if cancel_at_period_end = true)
SubscriptionDto reactivated = subscriptionService.reactivateSubscription(subscriptionId);

Verification

Subscription State Tests

What Are State Tests?

State tests verify that subscriptions transition correctly through their lifecycle states and that business rules are enforced at each stage.

Why Are They Important?

  • State Integrity: Ensure valid state transitions
  • Business Rules: Verify policies are enforced
  • Edge Cases: Test boundary conditions
  • Stripe Sync: Confirm local state matches Stripe
  • Event Publishing: Verify events are published correctly

Test Coverage Map

Code Example (Java)

// filepath: /Users/jetstart/dev/jetrev/ink/src/test/java/com/jetrev/ink/service/subscription/SubscriptionServiceTest.java
@SpringBootTest
@Transactional
class SubscriptionServiceTest {

@Autowired
private SubscriptionService subscriptionService;

@Autowired
private SubscriptionRepository subscriptionRepository;

@MockBean
private StripeClient stripeClient;

@MockBean
private SubscriptionEventProducer eventProducer;

@Test
@DisplayName("Should create subscription with trial period")
void shouldCreateSubscriptionWithTrial() {
// Given
CreateSubscriptionRequest request = CreateSubscriptionRequest.builder()
.userId(1L)
.planId(100L)
.trialDays(14)
.build();

when(stripeClient.createSubscription(any()))
.thenReturn(mockStripeSubscription());

// When
SubscriptionDto result = subscriptionService.createSubscription(request);

// Then
assertThat(result.getStatus()).isEqualTo(SubscriptionStatus.TRIALING);
assertThat(result.getTrialEnd()).isAfter(Instant.now());
verify(eventProducer).publishSubscriptionCreatedEvent(any());
}

@Test
@DisplayName("Should upgrade subscription with immediate proration")
void shouldUpgradeWithProration() {
// Given
Subscription existing = createActiveSubscription();
SubscriptionPlan premiumPlan = createPremiumPlan();

when(stripeClient.updateSubscription(any(), any()))
.thenReturn(mockStripeSubscription());

// When
SubscriptionDto upgraded = subscriptionService.upgradeSubscription(
existing.getId(),
premiumPlan.getId()
);

// Then
assertThat(upgraded.getPlanId()).isEqualTo(premiumPlan.getId());
assertThat(upgraded.getStatus()).isEqualTo(SubscriptionStatus.ACTIVE);
verify(stripeClient).updateSubscription(
eq(existing.getStripeSubscriptionId()),
argThat(req -> "create_prorations".equals(req.getProrationBehavior()))
);
verify(eventProducer).publishSubscriptionUpgradedEvent(any(), any(), any());
}

@Test
@DisplayName("Should schedule downgrade for end of period")
void shouldScheduleDowngrade() {
// Given
Subscription existing = createActiveSubscription();
SubscriptionPlan basicPlan = createBasicPlan();

// When
SubscriptionDto downgraded = subscriptionService.downgradeSubscription(
existing.getId(),
basicPlan.getId()
);

// Then
assertThat(downgraded.getPlanId()).isEqualTo(existing.getPlan().getId()); // Still current plan
assertThat(downgraded.getMetadata().get("scheduledPlanId"))
.isEqualTo(basicPlan.getId());
verify(eventProducer).publishSubscriptionDowngradeScheduledEvent(any(), any(), any());
}

@Test
@DisplayName("Should process immediate cancellation with refund")
void shouldCancelImmediatelyWithRefund() {
// Given
Subscription subscription = createActiveSubscription();
CancelSubscriptionRequest request = CancelSubscriptionRequest.builder()
.immediateCancellation(true)
.refund(true)
.refundReason("User request")
.reason("Not satisfied with service")
.build();

when(stripeClient.cancelSubscription(any(), anyBoolean()))
.thenReturn(mockCanceledStripeSubscription());

// When
SubscriptionDto canceled = subscriptionService.cancelSubscription(
subscription.getId(),
request
);

// Then
assertThat(canceled.getStatus()).isEqualTo(SubscriptionStatus.CANCELED);
assertThat(canceled.getCanceledAt()).isNotNull();
assertThat(canceled.getEndedAt()).isNotNull();
verify(eventProducer).publishSubscriptionCanceledEvent(any(), eq("Not satisfied with service"));
}

@Test
@DisplayName("Should handle payment failure and set grace period")
void shouldHandlePaymentFailure() {
// Given
Subscription subscription = createActiveSubscription();

// When
subscriptionService.handleRenewalPaymentFailure(subscription);

// Then
Subscription updated = subscriptionRepository.findById(subscription.getId()).orElseThrow();
assertThat(updated.getStatus()).isEqualTo(SubscriptionStatus.PAST_DUE);
assertThat(updated.getMetadata()).containsKey("gracePeriodEnd");
verify(eventProducer).publishSubscriptionPaymentFailedEvent(any());
}

@Test
@DisplayName("Should apply scheduled downgrade on renewal")
void shouldApplyScheduledDowngradeOnRenewal() {
// Given
Subscription subscription = createActiveSubscription();
SubscriptionPlan basicPlan = createBasicPlan();
subscription.getMetadata().put("scheduledPlanId", basicPlan.getId());
subscription.setCurrentPeriodEnd(Instant.now().minus(1, ChronoUnit.DAYS));
subscriptionRepository.save(subscription);

when(stripeClient.getLatestInvoice(any()))
.thenReturn(mockPaidInvoice());

// When
subscriptionService.renewSubscription(subscription.getId());

// Then
Subscription renewed = subscriptionRepository.findById(subscription.getId()).orElseThrow();
assertThat(renewed.getPlan().getId()).isEqualTo(basicPlan.getId());
assertThat(renewed.getMetadata()).doesNotContainKey("scheduledPlanId");
assertThat(renewed.getMetadata()).containsKey("downgradedAt");
}

// Helper methods
private Subscription createActiveSubscription() {
// Create test subscription
return null;
}

private SubscriptionPlan createPremiumPlan() {
// Create premium plan
return null;
}

private SubscriptionPlan createBasicPlan() {
// Create basic plan
return null;
}

private StripeSubscription mockStripeSubscription() {
// Mock Stripe response
return null;
}

private StripeSubscription mockCanceledStripeSubscription() {
// Mock canceled Stripe response
return null;
}

private StripeInvoice mockPaidInvoice() {
// Mock paid invoice
return null;
}
}

Next Steps: Explore Docker Deployment Guide for deployment strategies.