Article
In the subscription economy, managing recurring billing, subscriptions, and revenue operations requires specialized tools. Recurly is a powerful enterprise-grade subscription management platform that handles billing, payments, and revenue operations, while Java provides the robust backend infrastructure needed for scalable subscription businesses. Integrating Recurly with Java applications enables companies to automate complex billing scenarios while maintaining control over their subscription logic.
What is Recurly?
Recurly is a subscription management platform that provides:
- Recurring Billing: Automated invoicing and payment collection
- Subscription Lifecycle Management: Handle upgrades, downgrades, and cancellations
- Revenue Optimization: Reduce churn and increase lifetime value
- Multiple Payment Gateways: Support for various payment methods
- Dunning Management: Automated failed payment recovery
- Analytics and Reporting: Revenue recognition and business insights
Why Recurly with Java?
- Complex Billing Scenarios: Handle usage-based pricing, tiers, and add-ons
- Enterprise Scale: Manage millions of subscriptions reliably
- Revenue Operations: Automated invoicing, taxes, and revenue recognition
- Payment Optimization: Reduce failed payments and churn
- Compliance: PCI DSS compliant, SOC 1 & 2 certified
Recurly Architecture for Java Applications
Java Application → Recurly Java Client → Recurly REST API → Subscription Management ↓ Webhooks → Event Processing → Database ↓ Payment Gateways → Transaction Processing
Setting Up Recurly Java Client
1. Add Dependencies:
<!-- pom.xml --> <dependencies> <!-- Recurly Official Client --> <dependency> <groupId>com.recurly</groupId> <artifactId>recurly-client</artifactId> <version>3.35.0</version> </dependency> <!-- Spring Boot Starters --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-jpa</artifactId> </dependency> <!-- Database --> <dependency> <groupId>org.postgresql</groupId> <artifactId>postgresql</artifactId> <scope>runtime</scope> </dependency> <!-- Validation --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-validation</artifactId> </dependency> <!-- JSON Processing --> <dependency> <groupId>com.fasterxml.jackson.core</groupId> <artifactId>jackson-databind</artifactId> </dependency> </dependencies>
2. Configuration Properties:
# application.yml
recurly:
api-key: ${RECURLY_API_KEY:your-recurly-api-key}
subdomain: ${RECURLY_SUBDOMAIN:your-company}
default-currency: ${RECURLY_DEFAULT_CURRENCY:USD}
timeout-ms: 30000
page-size: 50
app:
base-url: ${APP_BASE_URL:http://localhost:8080}
webhook-secret: ${WEBHOOK_SECRET:your-webhook-secret}
3. Recurly Configuration:
@Configuration
@ConfigurationProperties(prefix = "recurly")
@Data
public class RecurlyConfig {
private String apiKey;
private String subdomain;
private String defaultCurrency;
private Integer timeoutMs;
private Integer pageSize;
@Bean
public com.recurly.v3.Client recurlyClient() {
return new com.recurly.v3.Client(apiKey);
}
@Bean
public com.recurly.v3.resources.Subscriptions subscriptionsClient() {
return recurlyClient().newSubscriptions();
}
@Bean
public com.recurly.v3.resources.Accounts accountsClient() {
return recurlyClient().newAccounts();
}
@Bean
public com.recurly.v3.resources.Plans plansClient() {
return recurlyClient().newPlans();
}
}
Core Subscription Service
1. Account Management Service:
@Service
@Slf4j
public class RecurlyAccountService {
private final com.recurly.v3.resources.Accounts accountsClient;
private final AccountRepository accountRepository;
private final RecurlyConfig recurlyConfig;
public RecurlyAccountService(com.recurly.v3.resources.Accounts accountsClient,
AccountRepository accountRepository,
RecurlyConfig recurlyConfig) {
this.accountsClient = accountsClient;
this.accountRepository = accountRepository;
this.recurlyConfig = recurlyConfig;
}
public Account createAccount(CreateAccountRequest request) {
try {
AccountCreate accountCreate = new AccountCreate();
accountCreate.setCode(request.getAccountCode());
accountCreate.setEmail(request.getEmail());
accountCreate.setFirstName(request.getFirstName());
accountCreate.setLastName(request.getLastName());
accountCreate.setCompany(request.getCompany());
// Set billing info if provided
if (request.getBillingInfo() != null) {
accountCreate.setBillingInfo(createBillingInfo(request.getBillingInfo()));
}
Account account = accountsClient.createAccount(accountCreate);
// Save to local database
saveLocalAccountRecord(account, request);
log.info("Created Recurly account: {}", account.getCode());
return account;
} catch (com.recurly.v3.exception.ApiException e) {
log.error("Failed to create Recurly account", e);
throw new RecurlyServiceException("Failed to create account: " + e.getMessage(), e);
}
}
public Account getAccount(String accountCode) {
try {
return accountsClient.getAccount(accountCode);
} catch (com.recurly.v3.exception.ApiException e) {
log.error("Failed to get Recurly account: {}", accountCode, e);
throw new RecurlyServiceException("Failed to get account: " + e.getMessage(), e);
}
}
public Account updateAccount(String accountCode, UpdateAccountRequest request) {
try {
AccountUpdate accountUpdate = new AccountUpdate();
accountUpdate.setEmail(request.getEmail());
accountUpdate.setFirstName(request.getFirstName());
accountUpdate.setLastName(request.getLastName());
accountUpdate.setCompany(request.getCompany());
Account account = accountsClient.updateAccount(accountCode, accountUpdate);
// Update local database
updateLocalAccountRecord(account);
log.info("Updated Recurly account: {}", accountCode);
return account;
} catch (com.recurly.v3.exception.ApiException e) {
log.error("Failed to update Recurly account: {}", accountCode, e);
throw new RecurlyServiceException("Failed to update account: " + e.getMessage(), e);
}
}
public void closeAccount(String accountCode) {
try {
accountsClient.closeAccount(accountCode);
// Update local database
closeLocalAccountRecord(accountCode);
log.info("Closed Recurly account: {}", accountCode);
} catch (com.recurly.v3.exception.ApiException e) {
log.error("Failed to close Recurly account: {}", accountCode, e);
throw new RecurlyServiceException("Failed to close account: " + e.getMessage(), e);
}
}
public BillingInfo updateBillingInfo(String accountCode, BillingInfoRequest request) {
try {
BillingInfoCreate billingInfoCreate = createBillingInfo(request);
BillingInfo billingInfo = accountsClient.createBillingInfo(accountCode, billingInfoCreate);
log.info("Updated billing info for account: {}", accountCode);
return billingInfo;
} catch (com.recurly.v3.exception.ApiException e) {
log.error("Failed to update billing info for account: {}", accountCode, e);
throw new RecurlyServiceException("Failed to update billing info: " + e.getMessage(), e);
}
}
private BillingInfoCreate createBillingInfo(BillingInfoRequest request) {
BillingInfoCreate billingInfo = new BillingInfoCreate();
billingInfo.setFirstName(request.getFirstName());
billingInfo.setLastName(request.getLastName());
billingInfo.setNumber(request.getCardNumber());
billingInfo.setMonth(request.getExpMonth());
billingInfo.setYear(request.getExpYear());
billingInfo.setCvv(request.getCvv());
billingInfo.setAddress(request.getAddress().getStreet1());
billingInfo.setCity(request.getAddress().getCity());
billingInfo.setState(request.getAddress().getState());
billingInfo.setZip(request.getAddress().getPostalCode());
billingInfo.setCountry(request.getAddress().getCountry());
return billingInfo;
}
private void saveLocalAccountRecord(Account account, CreateAccountRequest request) {
AccountRecord record = AccountRecord.builder()
.accountCode(account.getCode())
.email(account.getEmail())
.firstName(account.getFirstName())
.lastName(account.getLastName())
.company(account.getCompany())
.state(account.getState())
.createdAt(Instant.now())
.build();
accountRepository.save(record);
}
private void updateLocalAccountRecord(Account account) {
accountRepository.findByAccountCode(account.getCode())
.ifPresent(record -> {
record.setEmail(account.getEmail());
record.setFirstName(account.getFirstName());
record.setLastName(account.getLastName());
record.setCompany(account.getCompany());
record.setState(account.getState());
record.setUpdatedAt(Instant.now());
accountRepository.save(record);
});
}
private void closeLocalAccountRecord(String accountCode) {
accountRepository.findByAccountCode(accountCode)
.ifPresent(record -> {
record.setState("closed");
record.setUpdatedAt(Instant.now());
accountRepository.save(record);
});
}
}
2. Subscription Management Service:
@Service
@Slf4j
public class RecurlySubscriptionService {
private final com.recurly.v3.resources.Subscriptions subscriptionsClient;
private final com.recurly.v3.resources.Plans plansClient;
private final SubscriptionRepository subscriptionRepository;
private final RecurlyConfig recurlyConfig;
public RecurlySubscriptionService(com.recurly.v3.resources.Subscriptions subscriptionsClient,
com.recurly.v3.resources.Plans plansClient,
SubscriptionRepository subscriptionRepository,
RecurlyConfig recurlyConfig) {
this.subscriptionsClient = subscriptionsClient;
this.plansClient = plansClient;
this.subscriptionRepository = subscriptionRepository;
this.recurlyConfig = recurlyConfig;
}
public Subscription createSubscription(CreateSubscriptionRequest request) {
try {
SubscriptionCreate subscriptionCreate = new SubscriptionCreate();
subscriptionCreate.setAccount(createAccountReference(request.getAccountCode()));
subscriptionCreate.setPlanCode(request.getPlanCode());
subscriptionCreate.setCurrency(recurlyConfig.getDefaultCurrency());
subscriptionCreate.setUnitAmount(request.getUnitAmount());
// Set custom fields if provided
if (request.getCustomFields() != null) {
subscriptionCreate.setCustomFields(createCustomFields(request.getCustomFields()));
}
// Set add-ons if provided
if (request.getAddOns() != null && !request.getAddOns().isEmpty()) {
subscriptionCreate.setAddOns(createSubscriptionAddOns(request.getAddOns()));
}
// Set billing info if provided
if (request.getBillingInfo() != null) {
subscriptionCreate.setBillingInfo(createBillingInfo(request.getBillingInfo()));
}
Subscription subscription = subscriptionsClient.createSubscription(subscriptionCreate);
// Save to local database
saveLocalSubscriptionRecord(subscription, request);
log.info("Created subscription: {} for account: {}",
subscription.getId(), request.getAccountCode());
return subscription;
} catch (com.recurly.v3.exception.ApiException e) {
log.error("Failed to create subscription for account: {}", request.getAccountCode(), e);
throw new RecurlyServiceException("Failed to create subscription: " + e.getMessage(), e);
}
}
public Subscription getSubscription(String subscriptionId) {
try {
return subscriptionsClient.getSubscription(subscriptionId);
} catch (com.recurly.v3.exception.ApiException e) {
log.error("Failed to get subscription: {}", subscriptionId, e);
throw new RecurlyServiceException("Failed to get subscription: " + e.getMessage(), e);
}
}
public Subscription updateSubscription(String subscriptionId, UpdateSubscriptionRequest request) {
try {
SubscriptionUpdate subscriptionUpdate = new SubscriptionUpdate();
if (request.getPlanCode() != null) {
subscriptionUpdate.setPlanCode(request.getPlanCode());
}
if (request.getUnitAmount() != null) {
subscriptionUpdate.setUnitAmount(request.getUnitAmount());
}
if (request.getAddOns() != null) {
subscriptionUpdate.setAddOns(createSubscriptionAddOns(request.getAddOns()));
}
Subscription subscription = subscriptionsClient.updateSubscription(subscriptionId, subscriptionUpdate);
// Update local database
updateLocalSubscriptionRecord(subscription);
log.info("Updated subscription: {}", subscriptionId);
return subscription;
} catch (com.recurly.v3.exception.ApiException e) {
log.error("Failed to update subscription: {}", subscriptionId, e);
throw new RecurlyServiceException("Failed to update subscription: " + e.getMessage(), e);
}
}
public Subscription cancelSubscription(String subscriptionId, CancelSubscriptionRequest request) {
try {
SubscriptionCancel subscriptionCancel = new SubscriptionCancel();
if (request.getTimeframe() != null) {
subscriptionCancel.setTimeframe(SubscriptionCancel.Timeframe.fromValue(request.getTimeframe()));
}
Subscription subscription = subscriptionsClient.cancelSubscription(subscriptionId, subscriptionCancel);
// Update local database
cancelLocalSubscriptionRecord(subscriptionId);
log.info("Canceled subscription: {}", subscriptionId);
return subscription;
} catch (com.recurly.v3.exception.ApiException e) {
log.error("Failed to cancel subscription: {}", subscriptionId, e);
throw new RecurlyServiceException("Failed to cancel subscription: " + e.getMessage(), e);
}
}
public Subscription reactivateSubscription(String subscriptionId) {
try {
Subscription subscription = subscriptionsClient.reactivateSubscription(subscriptionId);
// Update local database
reactivateLocalSubscriptionRecord(subscriptionId);
log.info("Reactivated subscription: {}", subscriptionId);
return subscription;
} catch (com.recurly.v3.exception.ApiException e) {
log.error("Failed to reactivate subscription: {}", subscriptionId, e);
throw new RecurlyServiceException("Failed to reactivate subscription: " + e.getMessage(), e);
}
}
public Pause cancelSubscriptionPause(String subscriptionId) {
try {
Pause pause = subscriptionsClient.removeSubscriptionPause(subscriptionId);
log.info("Removed pause from subscription: {}", subscriptionId);
return pause;
} catch (com.recurly.v3.exception.ApiException e) {
log.error("Failed to remove subscription pause: {}", subscriptionId, e);
throw new RecurlyServiceException("Failed to remove subscription pause: " + e.getMessage(), e);
}
}
public List<Subscription> getAccountSubscriptions(String accountCode) {
try {
SubscriptionList result = subscriptionsClient.listSubscriptions(
new QueryParams().withLimit(recurlyConfig.getPageSize())
.withParam("account", accountCode)
);
return result.getData();
} catch (com.recurly.v3.exception.ApiException e) {
log.error("Failed to get subscriptions for account: {}", accountCode, e);
throw new RecurlyServiceException("Failed to get subscriptions: " + e.getMessage(), e);
}
}
private AccountPurchase createAccountReference(String accountCode) {
AccountPurchase accountRef = new AccountPurchase();
accountRef.setCode(accountCode);
return accountRef;
}
private List<SubscriptionAddOn> createSubscriptionAddOns(List<AddOnRequest> addOns) {
return addOns.stream()
.map(this::createSubscriptionAddOn)
.collect(Collectors.toList());
}
private SubscriptionAddOn createSubscriptionAddOn(AddOnRequest addOn) {
SubscriptionAddOn subscriptionAddOn = new SubscriptionAddOn();
subscriptionAddOn.setCode(addOn.getCode());
subscriptionAddOn.setUnitAmount(addOn.getUnitAmount());
subscriptionAddOn.setQuantity(addOn.getQuantity());
return subscriptionAddOn;
}
private BillingInfoCreate createBillingInfo(BillingInfoRequest request) {
BillingInfoCreate billingInfo = new BillingInfoCreate();
billingInfo.setFirstName(request.getFirstName());
billingInfo.setLastName(request.getLastName());
billingInfo.setNumber(request.getCardNumber());
billingInfo.setMonth(request.getExpMonth());
billingInfo.setYear(request.getExpYear());
billingInfo.setCvv(request.getCvv());
return billingInfo;
}
private List<CustomField> createCustomFields(Map<String, String> customFields) {
return customFields.entrySet().stream()
.map(entry -> {
CustomField field = new CustomField();
field.setName(entry.getKey());
field.setValue(entry.getValue());
return field;
})
.collect(Collectors.toList());
}
private void saveLocalSubscriptionRecord(Subscription subscription, CreateSubscriptionRequest request) {
SubscriptionRecord record = SubscriptionRecord.builder()
.subscriptionId(subscription.getId())
.accountCode(request.getAccountCode())
.planCode(request.getPlanCode())
.state(subscription.getState())
.unitAmount(request.getUnitAmount())
.currency(recurlyConfig.getDefaultCurrency())
.startDate(subscription.getCurrentPeriodStartedAt().toInstant())
.createdAt(Instant.now())
.build();
subscriptionRepository.save(record);
}
private void updateLocalSubscriptionRecord(Subscription subscription) {
subscriptionRepository.findBySubscriptionId(subscription.getId())
.ifPresent(record -> {
record.setState(subscription.getState());
record.setUnitAmount(subscription.getUnitAmount());
record.setUpdatedAt(Instant.now());
subscriptionRepository.save(record);
});
}
private void cancelLocalSubscriptionRecord(String subscriptionId) {
subscriptionRepository.findBySubscriptionId(subscriptionId)
.ifPresent(record -> {
record.setState("canceled");
record.setUpdatedAt(Instant.now());
subscriptionRepository.save(record);
});
}
private void reactivateLocalSubscriptionRecord(String subscriptionId) {
subscriptionRepository.findBySubscriptionId(subscriptionId)
.ifPresent(record -> {
record.setState("active");
record.setUpdatedAt(Instant.now());
subscriptionRepository.save(record);
});
}
}
Plan and Pricing Management
1. Plan Management Service:
@Service
@Slf4j
public class RecurlyPlanService {
private final com.recurly.v3.resources.Plans plansClient;
private final PlanRepository planRepository;
public RecurlyPlanService(com.recurly.v3.resources.Plans plansClient,
PlanRepository planRepository) {
this.plansClient = plansClient;
this.planRepository = planRepository;
}
public Plan createPlan(CreatePlanRequest request) {
try {
PlanCreate planCreate = new PlanCreate();
planCreate.setCode(request.getCode());
planCreate.setName(request.getName());
planCreate.setDescription(request.getDescription());
planCreate.setIntervalUnit(PlanCreate.IntervalUnit.fromValue(request.getIntervalUnit()));
planCreate.setIntervalLength(request.getIntervalLength());
// Set pricing
List<Pricing> pricingList = new ArrayList<>();
Pricing pricing = new Pricing();
pricing.setCurrency(request.getCurrency());
pricing.setUnitAmount(request.getUnitAmount());
pricingList.add(pricing);
planCreate.setCurrencies(pricingList);
// Set trial if provided
if (request.getTrialUnit() != null && request.getTrialLength() != null) {
planCreate.setTrialUnit(PlanCreate.TrialUnit.fromValue(request.getTrialUnit()));
planCreate.setTrialLength(request.getTrialLength());
}
Plan plan = plansClient.createPlan(planCreate);
// Save to local database
saveLocalPlanRecord(plan);
log.info("Created plan: {}", plan.getCode());
return plan;
} catch (com.recurly.v3.exception.ApiException e) {
log.error("Failed to create plan: {}", request.getCode(), e);
throw new RecurlyServiceException("Failed to create plan: " + e.getMessage(), e);
}
}
public Plan getPlan(String planCode) {
try {
return plansClient.getPlan(planCode);
} catch (com.recurly.v3.exception.ApiException e) {
log.error("Failed to get plan: {}", planCode, e);
throw new RecurlyServiceException("Failed to get plan: " + e.getMessage(), e);
}
}
public List<Plan> getAllPlans() {
try {
PlanList result = plansClient.listPlans(
new QueryParams().withLimit(100) // Adjust based on your needs
);
return result.getData();
} catch (com.recurly.v3.exception.ApiException e) {
log.error("Failed to list plans", e);
throw new RecurlyServiceException("Failed to list plans: " + e.getMessage(), e);
}
}
public Plan updatePlan(String planCode, UpdatePlanRequest request) {
try {
PlanUpdate planUpdate = new PlanUpdate();
planUpdate.setName(request.getName());
planUpdate.setDescription(request.getDescription());
Plan plan = plansClient.updatePlan(planCode, planUpdate);
// Update local database
updateLocalPlanRecord(plan);
log.info("Updated plan: {}", planCode);
return plan;
} catch (com.recurly.v3.exception.ApiException e) {
log.error("Failed to update plan: {}", planCode, e);
throw new RecurlyServiceException("Failed to update plan: " + e.getMessage(), e);
}
}
public void deletePlan(String planCode) {
try {
plansClient.deactivatePlan(planCode);
// Update local database
deactivateLocalPlanRecord(planCode);
log.info("Deleted plan: {}", planCode);
} catch (com.recurly.v3.exception.ApiException e) {
log.error("Failed to delete plan: {}", planCode, e);
throw new RecurlyServiceException("Failed to delete plan: " + e.getMessage(), e);
}
}
private void saveLocalPlanRecord(Plan plan) {
PlanRecord record = PlanRecord.builder()
.planCode(plan.getCode())
.name(plan.getName())
.description(plan.getDescription())
.intervalUnit(plan.getIntervalUnit().toString())
.intervalLength(plan.getIntervalLength())
.state(plan.getState())
.createdAt(Instant.now())
.build();
planRepository.save(record);
}
private void updateLocalPlanRecord(Plan plan) {
planRepository.findByPlanCode(plan.getCode())
.ifPresent(record -> {
record.setName(plan.getName());
record.setDescription(plan.getDescription());
record.setState(plan.getState());
record.setUpdatedAt(Instant.now());
planRepository.save(record);
});
}
private void deactivateLocalPlanRecord(String planCode) {
planRepository.findByPlanCode(planCode)
.ifPresent(record -> {
record.setState("inactive");
record.setUpdatedAt(Instant.now());
planRepository.save(record);
});
}
}
Data Models
1. Request/Response Models:
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class CreateAccountRequest {
@NotBlank
private String accountCode;
@Email
@NotBlank
private String email;
@NotBlank
private String firstName;
@NotBlank
private String lastName;
private String company;
private BillingInfoRequest billingInfo;
}
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class CreateSubscriptionRequest {
@NotBlank
private String accountCode;
@NotBlank
private String planCode;
@NotNull
private Integer unitAmount;
private BillingInfoRequest billingInfo;
private List<AddOnRequest> addOns;
private Map<String, String> customFields;
}
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class BillingInfoRequest {
@NotBlank
private String firstName;
@NotBlank
private String lastName;
@NotBlank
private String cardNumber;
@NotBlank
private String expMonth;
@NotBlank
private String expYear;
private String cvv;
private AddressRequest address;
}
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class AddOnRequest {
@NotBlank
private String code;
@NotNull
private Integer unitAmount;
@NotNull
private Integer quantity;
}
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class CreatePlanRequest {
@NotBlank
private String code;
@NotBlank
private String name;
private String description;
@NotBlank
private String intervalUnit; // "months" or "days"
@NotNull
private Integer intervalLength;
@NotBlank
private String currency;
@NotNull
private Integer unitAmount;
private String trialUnit;
private Integer trialLength;
}
2. Database Entities:
@Entity
@Table(name = "account_records")
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class AccountRecord {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "account_code", unique = true, nullable = false)
private String accountCode;
@Column(name = "email")
private String email;
@Column(name = "first_name")
private String firstName;
@Column(name = "last_name")
private String lastName;
@Column(name = "company")
private String company;
@Column(name = "state")
private String state;
@Column(name = "created_at")
private Instant createdAt;
@Column(name = "updated_at")
private Instant updatedAt;
@PrePersist
protected void onCreate() {
createdAt = Instant.now();
}
@PreUpdate
protected void onUpdate() {
updatedAt = Instant.now();
}
}
@Entity
@Table(name = "subscription_records")
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class SubscriptionRecord {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "subscription_id", unique = true, nullable = false)
private String subscriptionId;
@Column(name = "account_code", nullable = false)
private String accountCode;
@Column(name = "plan_code", nullable = false)
private String planCode;
@Column(name = "state")
private String state;
@Column(name = "unit_amount")
private Integer unitAmount;
@Column(name = "currency")
private String currency;
@Column(name = "start_date")
private Instant startDate;
@Column(name = "created_at")
private Instant createdAt;
@Column(name = "updated_at")
private Instant updatedAt;
@PrePersist
protected void onCreate() {
createdAt = Instant.now();
}
@PreUpdate
protected void onUpdate() {
updatedAt = Instant.now();
}
}
REST Controllers
1. Account Controller:
@RestController
@RequestMapping("/api/accounts")
@Slf4j
@Validated
public class AccountController {
private final RecurlyAccountService accountService;
public AccountController(RecurlyAccountService accountService) {
this.accountService = accountService;
}
@PostMapping
public ResponseEntity<Account> createAccount(@Valid @RequestBody CreateAccountRequest request) {
try {
Account account = accountService.createAccount(request);
return ResponseEntity.status(HttpStatus.CREATED).body(account);
} catch (RecurlyServiceException e) {
log.error("Account creation failed", e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
}
}
@GetMapping("/{accountCode}")
public ResponseEntity<Account> getAccount(@PathVariable String accountCode) {
try {
Account account = accountService.getAccount(accountCode);
return ResponseEntity.ok(account);
} catch (RecurlyServiceException e) {
log.error("Failed to get account: {}", accountCode, e);
return ResponseEntity.notFound().build();
}
}
@PutMapping("/{accountCode}")
public ResponseEntity<Account> updateAccount(@PathVariable String accountCode,
@Valid @RequestBody UpdateAccountRequest request) {
try {
Account account = accountService.updateAccount(accountCode, request);
return ResponseEntity.ok(account);
} catch (RecurlyServiceException e) {
log.error("Failed to update account: {}", accountCode, e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
}
}
@DeleteMapping("/{accountCode}")
public ResponseEntity<Void> closeAccount(@PathVariable String accountCode) {
try {
accountService.closeAccount(accountCode);
return ResponseEntity.noContent().build();
} catch (RecurlyServiceException e) {
log.error("Failed to close account: {}", accountCode, e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
}
}
@PutMapping("/{accountCode}/billing-info")
public ResponseEntity<BillingInfo> updateBillingInfo(@PathVariable String accountCode,
@Valid @RequestBody BillingInfoRequest request) {
try {
BillingInfo billingInfo = accountService.updateBillingInfo(accountCode, request);
return ResponseEntity.ok(billingInfo);
} catch (RecurlyServiceException e) {
log.error("Failed to update billing info for account: {}", accountCode, e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
}
}
}
2. Subscription Controller:
@RestController
@RequestMapping("/api/subscriptions")
@Slf4j
@Validated
public class SubscriptionController {
private final RecurlySubscriptionService subscriptionService;
public SubscriptionController(RecurlySubscriptionService subscriptionService) {
this.subscriptionService = subscriptionService;
}
@PostMapping
public ResponseEntity<Subscription> createSubscription(@Valid @RequestBody CreateSubscriptionRequest request) {
try {
Subscription subscription = subscriptionService.createSubscription(request);
return ResponseEntity.status(HttpStatus.CREATED).body(subscription);
} catch (RecurlyServiceException e) {
log.error("Subscription creation failed", e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
}
}
@GetMapping("/{subscriptionId}")
public ResponseEntity<Subscription> getSubscription(@PathVariable String subscriptionId) {
try {
Subscription subscription = subscriptionService.getSubscription(subscriptionId);
return ResponseEntity.ok(subscription);
} catch (RecurlyServiceException e) {
log.error("Failed to get subscription: {}", subscriptionId, e);
return ResponseEntity.notFound().build();
}
}
@PutMapping("/{subscriptionId}")
public ResponseEntity<Subscription> updateSubscription(@PathVariable String subscriptionId,
@Valid @RequestBody UpdateSubscriptionRequest request) {
try {
Subscription subscription = subscriptionService.updateSubscription(subscriptionId, request);
return ResponseEntity.ok(subscription);
} catch (RecurlyServiceException e) {
log.error("Failed to update subscription: {}", subscriptionId, e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
}
}
@PostMapping("/{subscriptionId}/cancel")
public ResponseEntity<Subscription> cancelSubscription(@PathVariable String subscriptionId,
@Valid @RequestBody CancelSubscriptionRequest request) {
try {
Subscription subscription = subscriptionService.cancelSubscription(subscriptionId, request);
return ResponseEntity.ok(subscription);
} catch (RecurlyServiceException e) {
log.error("Failed to cancel subscription: {}", subscriptionId, e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
}
}
@PostMapping("/{subscriptionId}/reactivate")
public ResponseEntity<Subscription> reactivateSubscription(@PathVariable String subscriptionId) {
try {
Subscription subscription = subscriptionService.reactivateSubscription(subscriptionId);
return ResponseEntity.ok(subscription);
} catch (RecurlyServiceException e) {
log.error("Failed to reactivate subscription: {}", subscriptionId, e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
}
}
@GetMapping("/account/{accountCode}")
public ResponseEntity<List<Subscription>> getAccountSubscriptions(@PathVariable String accountCode) {
try {
List<Subscription> subscriptions = subscriptionService.getAccountSubscriptions(accountCode);
return ResponseEntity.ok(subscriptions);
} catch (RecurlyServiceException e) {
log.error("Failed to get subscriptions for account: {}", accountCode, e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
}
}
}
Webhook Handling
1. Webhook Controller:
@RestController
@RequestMapping("/webhooks/recurly")
@Slf4j
public class RecurlyWebhookController {
private final SubscriptionRepository subscriptionRepository;
private final AccountRepository accountRepository;
public RecurlyWebhookController(SubscriptionRepository subscriptionRepository,
AccountRepository accountRepository) {
this.subscriptionRepository = subscriptionRepository;
this.accountRepository = accountRepository;
}
@PostMapping
public ResponseEntity<String> handleWebhook(@RequestBody String payload,
@RequestHeader("Recurly-Signature") String signature) {
try {
// Verify webhook signature (simplified - use proper verification in production)
if (!verifyWebhookSignature(payload, signature)) {
log.warn("Invalid webhook signature");
return ResponseEntity.status(HttpStatus.BAD_REQUEST).build();
}
// Parse webhook notification
JsonNode notification = new ObjectMapper().readTree(payload);
String type = notification.get("type").asText();
log.info("Received Recurly webhook: {}", type);
// Handle different webhook types
switch (type) {
case "new_subscription_notification":
handleNewSubscription(notification);
break;
case "updated_subscription_notification":
handleUpdatedSubscription(notification);
break;
case "canceled_subscription_notification":
handleCanceledSubscription(notification);
break;
case "renewed_subscription_notification":
handleRenewedSubscription(notification);
break;
case "successful_payment_notification":
handleSuccessfulPayment(notification);
break;
case "failed_payment_notification":
handleFailedPayment(notification);
break;
default:
log.debug("Unhandled webhook type: {}", type);
}
return ResponseEntity.ok("Webhook processed");
} catch (Exception e) {
log.error("Webhook processing failed", e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
}
}
private boolean verifyWebhookSignature(String payload, String signature) {
// Implement proper webhook signature verification
// Recurly provides signatures for verification
return true; // Simplified for example
}
private void handleNewSubscription(JsonNode notification) {
JsonNode subscription = notification.get("subscription");
String subscriptionId = subscription.get("uuid").asText();
String accountCode = subscription.get("account").get("code").asText();
subscriptionRepository.findBySubscriptionId(subscriptionId)
.ifPresentOrElse(
record -> log.info("Subscription already exists: {}", subscriptionId),
() -> {
// Create new subscription record
SubscriptionRecord newRecord = SubscriptionRecord.builder()
.subscriptionId(subscriptionId)
.accountCode(accountCode)
.planCode(subscription.get("plan").get("code").asText())
.state(subscription.get("state").asText())
.unitAmount(subscription.get("unit_amount").asInt())
.currency(subscription.get("currency").asText())
.startDate(Instant.parse(subscription.get("activated_at").asText()))
.createdAt(Instant.now())
.build();
subscriptionRepository.save(newRecord);
log.info("Created subscription record from webhook: {}", subscriptionId);
}
);
}
private void handleUpdatedSubscription(JsonNode notification) {
JsonNode subscription = notification.get("subscription");
String subscriptionId = subscription.get("uuid").asText();
subscriptionRepository.findBySubscriptionId(subscriptionId)
.ifPresent(record -> {
record.setState(subscription.get("state").asText());
record.setUnitAmount(subscription.get("unit_amount").asInt());
record.setUpdatedAt(Instant.now());
subscriptionRepository.save(record);
log.info("Updated subscription from webhook: {}", subscriptionId);
});
}
private void handleCanceledSubscription(JsonNode notification) {
JsonNode subscription = notification.get("subscription");
String subscriptionId = subscription.get("uuid").asText();
subscriptionRepository.findBySubscriptionId(subscriptionId)
.ifPresent(record -> {
record.setState("canceled");
record.setUpdatedAt(Instant.now());
subscriptionRepository.save(record);
log.info("Canceled subscription from webhook: {}", subscriptionId);
});
}
private void handleRenewedSubscription(JsonNode notification) {
JsonNode subscription = notification.get("subscription");
String subscriptionId = subscription.get("uuid").asText();
log.info("Subscription renewed: {}", subscriptionId);
// Handle renewal logic
}
private void handleSuccessfulPayment(JsonNode notification) {
JsonNode transaction = notification.get("transaction");
String accountCode = transaction.get("account").get("code").asText();
log.info("Successful payment for account: {}", accountCode);
// Handle successful payment logic
}
private void handleFailedPayment(JsonNode notification) {
JsonNode transaction = notification.get("transaction");
String accountCode = transaction.get("account").get("code").asText();
log.warn("Failed payment for account: {}", accountCode);
// Handle failed payment logic
}
}
Error Handling
1. Custom Exceptions:
public class RecurlyServiceException extends RuntimeException {
public RecurlyServiceException(String message) {
super(message);
}
public RecurlyServiceException(String message, Throwable cause) {
super(message, cause);
}
}
@RestControllerAdvice
@Slf4j
public class RecurlyExceptionHandler {
@ExceptionHandler(RecurlyServiceException.class)
public ResponseEntity<ErrorResponse> handleRecurlyException(RecurlyServiceException e) {
log.error("Recurly service error", e);
ErrorResponse error = ErrorResponse.builder()
.code("RECURLY_SERVICE_ERROR")
.message("Subscription service temporarily unavailable")
.timestamp(Instant.now())
.build();
return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE).body(error);
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ErrorResponse> handleValidationException(MethodArgumentNotValidException e) {
List<String> errors = e.getBindingResult()
.getFieldErrors()
.stream()
.map(error -> error.getField() + ": " + error.getDefaultMessage())
.collect(Collectors.toList());
ErrorResponse error = ErrorResponse.builder()
.code("VALIDATION_ERROR")
.message("Invalid request parameters")
.details(errors)
.timestamp(Instant.now())
.build();
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(error);
}
}
@Data
@Builder
class ErrorResponse {
private String code;
private String message;
private Object details;
private Instant timestamp;
}
Best Practices
1. Health Check:
@Component
public class RecurlyHealthIndicator implements HealthIndicator {
private final RecurlyPlanService planService;
public RecurlyHealthIndicator(RecurlyPlanService planService) {
this.planService = planService;
}
@Override
public Health health() {
try {
// Try to list plans to verify connectivity
planService.getAllPlans();
return Health.up().withDetail("recurly", "connected").build();
} catch (Exception e) {
return Health.down()
.withDetail("recurly", "disconnected")
.withDetail("error", e.getMessage())
.build();
}
}
}
Conclusion
Integrating Recurly with Java applications provides a robust foundation for managing complex subscription businesses at scale. The combination of Recurly's enterprise-grade subscription management with Java's reliability and performance creates a powerful platform for SaaS companies, subscription services, and any business relying on recurring revenue models.
By leveraging the Recurly Java client, developers can implement sophisticated billing scenarios, handle subscription lifecycle events, and maintain synchronization between Recurly and their application data. The comprehensive webhook support enables real-time updates and ensures data consistency across systems, making it possible to build responsive, event-driven subscription management systems that scale with business growth.