Skip to content

Build fintech application with Spring Boot, Vavr and Vue.js (Part 6 – Subscriptions – backend part)

Hi again! We worked on our accounting application so hard that now we can make it to pay back. The typical approach for SaaS (software as a service) like Cashtrack is to use subscriptions. Today we will implement server-side subscription handling.

Table of contents

What we will do in this part

In this part we will work on the subscription mechanism. We put so much work in our SaaS product, so it deserves to make us some money, isn’t it? There are many ways to do it, my personal preference are two providers: PayPal and Stripe. Here we will use Stripe as it provides very easy API to work with subscriptions, compare to PayPal. Here we will set up Stripe credentials to handle subscriptions and then implement backend part of payments using Spring Boot.

Step 1. Obtain Stripe credentials

Before we will start, I have to say you, that this part (or better say two parts) requires you to have a Stripe account. But please don’t worry, as you can get developer account 100% free and you even don’t need to undergo through any type of business validation to obtain testing credentials.

Step 1.1 Get Stripe key

First we need to get an API key. There are two types of secret keys in Stripe:

  1. Pushable key
  2. Secret key

On the backend level we will need secret key. It is not a rocket science to obtain it. On the left navbar select Developers -> API keys and then find Secret key value on the Standard keys tab:

Image 1. Stripe API keys

Due to the fact, that we actually have subscriptions in our app, we need also to create some plans.

Step 1.2. Create subscription plans

We will create two plans: Basic and Premium. Here I will show you a process to initialize the Basic plan, and for the other one just repeat it with different values. On the left navbar select Products tab and then press + New button:

Image 2. Create a new product

Here we need to provide some data about the product we want to create:

  • Product name = this is what you will in dashboard
  • Unit label = this what users would see on invoices
  • Statement descriptor = this is what what would be in bank statements

Click on Create product. Then you need to add a pricing plan:

Image 3. Create a pricing plan – 1

First add following data:

  • Plan nickname = this would see only you
  • ID = this is ID we will use later in API calls. Should be unique or you can leave field empty to get some generated value from Stripe
  • Pricing = I select here Recurring quantity as it is typical model for SaaS products like our
Image 4. Create a pricing plan – 2

You also can configure following values:

  • Currency – I am in EU, so I selected euros (also we use euros as a single currency for documents).
  • Price per unit = here is 9.99 EUR
  • Billing interval = Monthly

NB that you can’t modify currency, price per unit and billing interval once pricing plan was created

Finally press Add pricing plan. Once Stripe created it, you will get its unique ID:

Image 5. Create a pricing plan – 3

Step 2. Configure a project

Before we will implement server side for payments, we need to add Stripe Java SDK as dependency and also provide secret key as a value. First go to build.gradle and add new dependency:

dependencies {
    //..
    implementation 'com.stripe:stripe-java:16.4.0'
}

By the way, Stripe Java SDK depends on Lombok. So if you to keep things “Lombok-clear”, well, not there, you will still get it. Next go to application.properties and following line:

app.stripe.key={YOUR SECRET KEY}

Step 3. Entities

What makes this part unique from companies API or documents API is that here we will use 3 types of entities:

  • Subscription entity (what we will persist in DB)
  • Subscription request (what user will send to API)
  • Subscription response (what user will get back)

Take a look on the graph below:

Image 6. Entities

Let start with the definition of Subscription

Step 3.1. Subscription (Persistence entity)

This is a data model that we will save into database:

@Value
@AllArgsConstructor
@Document(collection="subscriptions")
public class Subscription {
    @Id @NonFinal String subscriptionId;
    String providerId;
    String userId;
    String tier;
    LocalDate startedDate;
}

Note, that here we don’t have validation annotations. Because this kind of entity is used internaly and generated by service, it does not make really a sense to validate it.

Step 3.2 Subscription request

This model defines a request that client sends to the server:

@Value
@AllArgsConstructor
public class SubscriptionRequest {
    @NonNull String userId;
    @NonNull String token;
    @NonNull String tier;
}

And here we have validation. What do these fields contain?

  • userId = is an ID of user
  • token = is Stripe specific value generated by Stripe client-side library and which is need to process transaction on backend
  • tier = a unique ID of plan selected by user – that is what we get in step 1.2

Let test that validation does work. Create a new unit test for this:

public class SubscriptionRequestNonNullTest {

    @Test
    public void validateNonNullTest(){
        assertThrows(NullPointerException.class, () -> {
            SubscriptionRequest request = new SubscriptionRequest(null, null, null);
            request.toString();
        });
    }
}

Run it and check that @NonNull annotation do their work:

Image 7. Subscription request non null validation test result

Step 3.3. Subscription response

Finally here is a payload we will send back to user:

@Value
@AllArgsConstructor
public class SubscriptionResponse {
    boolean status;
    String subscriptionId;
    String tier;
}

What does this mean:

  • status = is a boolean value that is true if subscription is active and false if not active (as well is cancelled or user does not have subscription)
  • subscriptionId = subscription ID from step 3.1. We hide Stripe-specific providerId behind our custom ID

Step 4. Repository

Next step as usual is to define a repository interface:

public interface ISubscriptionRepository 
    extends CrudRepository<Subscription, String> {

    Option<Subscription> findByUserId (String userId);

    Option<Subscription> findBySubscriptionId (String subscriptionId);
}

We use Vavr Option for both query methods, which means that we assume that there is only one subscription for user in time.

Step 5. Stripe client

After repository we normally move to services, but not in this case. Before we will start with SubscriptionService we need to obtain also a client that will do dirty work with Stripe API. Inside clients package create a new contract IStripeClient.java:

public interface IStripeClient {

    String createSubscription (SubscriptionRequest request);

    boolean cancelSubscription (String providerId);
}

Here I need to comment a bit on how Stripe works. This client SubscriptionRequest object to create the Stripe subscription and returns its ID, which corresponds in our data model to providerId of Subscription. When user cancels subscription, we use this value to call the client to cancel subscription. Let now do an implementation:

@Component("IStripeClient")
public class StripeClientImpl implements IStripeClient {

    @Value("${app.stripe.key}")
    private String apiKey;

    @Override
    public String createSubscription(SubscriptionRequest request) {
        String customerId = createCustomer(request.getUserId(), request.getToken());
        try {
            Map<String, Object> item = new HashMap<>();
            item.put("plan", request.getTier());
            Map<String, Object> items = new HashMap<>();
            items.put("0", item);
            Map<String, Object> subscription = new HashMap<>();
            subscription.put("customer", customerId);
            subscription.put("items", items);

            Subscription result = Subscription.create(subscription);
            return result.getId();
        } catch (Exception ex){
            ex.printStackTrace();
            throw new RuntimeException();
        }
    }

    @Override
    public boolean cancelSubscription(String providerId) {
        try {
            Stripe.apiKey = apiKey;
            Subscription subscription = Subscription.retrieve(providerId);
            subscription.cancel();
            return true;
        } catch (Exception ex){
            ex.printStackTrace();
            throw new RuntimeException();
        }
    }

    String createCustomer(String userId, String token){
        try {
            Stripe.apiKey = apiKey;
            Map<String, Object> customer = new HashMap<>();
            customer.put("source", token);
            customer.put("description", userId);
            Customer result = Customer.create(customer);
            return result.getId();
        } catch (Exception ex){
            ex.printStackTrace();
            throw new RuntimeException();
        }
    }

}

First thing to note is @Value annotation that is used to inject a variable from application.properties to this component. Next thing is custom method createCustomer. We need to create Stripe’s customer first before we will assign a subscription. Of course wiser approach is to create it once and persist in our database, although this is tutorial, so it will bring some unnecessary complexity.

Final point you may wonder is exception handling. Why do we “intercept” checked exceptions thrown by Stripe and rethrow unchecked exceptions? Basically, Spring is designed around unchecked exceptions as this means that it is something application could recover from. I recommend you to check this post on this topic if you are unsure about using unchecked exceptions.

Step 6. Service

Step 6.1. Service interface

Now we can go to the service itself. As we used to do before we start with contract’s defintion:

public interface ISubscriptionService {

    SubscriptionResponse createSubscription (SubscriptionRequest request);

    SubscriptionResponse getSubscriptionForUser (String userId);

    SubscriptionResponse cancelSubscription (String subscriptionId);
}

Look that basically our service does not expose Subscription model outside: it accepts SubscriptionRequest and returns SubscriptionResponse, keeping entity’s model protected. This is considered a good design practice to avoid exposing of database entities to the API:

Exposing your entities creates a strong coupling between your API and your persistence model. Any difference between the 2 models introduces extra complexity, and you need to find a way to bridge the gap between them. Unfortunately, there are always differences between your API and your persistence model.

Thorben Janssen, source

Step 6.2. Service implementation test

Then, write a unit test to validate that subscription service implementation works:

@ExtendWith(MockitoExtension.class)
public class SubscriptionServiceImplTest {

    @Mock private ISubscriptionRepository repository;
    @Mock private IStripeClient stripeClient;
    @InjectMocks private SubscriptionServiceImpl service;

    private Subscription createMockSubscription(){
        Subscription subscription = new Subscription("subscriptionId", "stripe-id", "userId", "tier", LocalDate.now());
        return subscription;
    }

    @Test
    public void createSubscriptionTest(){
        SubscriptionRequest request = new SubscriptionRequest("userId", "token", "tier");
        when(stripeClient.createSubscription(request)).thenReturn("stripe-id");
        Subscription subscription = createMockSubscription();
        when(repository.save(any(Subscription.class))).thenReturn(subscription);

        SubscriptionResponse result = service.createSubscription(request);
        assertTrue(result.isStatus());
        assertEquals(subscription.getSubscriptionId(), result.getSubscriptionId());
    }

    @Test
    public void cancelSubscriptionSucessTest(){
        Subscription subscription = createMockSubscription();
        String id = "subscriptionId";
        when(repository.findBySubscriptionId(id)).thenReturn(Option.of(subscription));
        when(stripeClient.cancelSubscription(subscription.getProviderId())).thenReturn(true);
        SubscriptionResponse result = service.cancelSubscription(id);
        assertTrue(result.isStatus());
    }

    @Test
    public void cancelSubscriptionFailedTest(){
        String id = "subscriptionId";
        when(repository.findBySubscriptionId(id)).thenReturn(Option.none());
        SubscriptionResponse result = service.cancelSubscription(id);
        assertFalse(result.isStatus());
    }
}

Step 6.3. Service implementation

Final thing here is to provide an actual implementation for service’s contract:

@Component("ISubscriptionService")
public class SubscriptionServiceImpl implements ISubscriptionService {

    @Autowired private ISubscriptionRepository repository;
    @Autowired private IStripeClient stripeClient;

    @Override
    public SubscriptionResponse createSubscription(SubscriptionRequest request) {
        String providerId = stripeClient.createSubscription(request);
        Subscription subscription = new Subscription(null, providerId, request.getUserId(), request.getTier(), LocalDate.now());
        Subscription result = repository.save(subscription);
        SubscriptionResponse response = new SubscriptionResponse(true, result.getSubscriptionId(), result.getTier());
        return response;
    }

    @Override
    public SubscriptionResponse getSubscriptionForUser(String userId) {
        Option<Subscription> subscription = repository.findByUserId(userId);
        if (subscription.isDefined()){
            SubscriptionResponse response = new SubscriptionResponse(true, subscription.get().getSubscriptionId(), subscription.get().getTier());
            return response;
        } else {
            SubscriptionResponse response = new SubscriptionResponse(false, null, null);
            return response;
        }
    }

    @Override
    public SubscriptionResponse cancelSubscription(String subscriptionId) {
        Option<Subscription> result = repository.findBySubscriptionId(subscriptionId);
        if (result.isDefined()){
            Subscription subscription = result.get();
            boolean status = stripeClient.cancelSubscription(subscription.getProviderId());
            return new SubscriptionResponse(status, subscription.getSubscriptionId(), subscription.getTier());
        } else {
            return new SubscriptionResponse(false, null, null);
        }
    }
}

What is important here to note:

  • When we create a subscription, we first call stripe client to obtain providerId. Than we create Subscription object and persists it in the database.
  • To cancel subscription we need to check if it exists already. In this case we use providerId to cancel it with Stripe API

Run the test to validate that service works as we designed:

Image 8. SubscriptionServiceImpl test results

Step 7. REST controller

At the end of this tutorial we will write a REST controller:

@RestController
@RequestMapping("/v1/subscriptions")
@CrossOrigin(origins = "*")
public class SubscriptionRestController {

    @Autowired private ISubscriptionService service;

    @PostMapping("/")
    public ResponseEntity<SubscriptionResponse> createSubscription (@Valid @RequestBody SubscriptionRequest payload){
        SubscriptionResponse result = service.createSubscription(payload);
        return ResponseEntity.ok(result);
    }

    @GetMapping("/{userId}")
    public ResponseEntity<SubscriptionResponse> getSubscriptionForUser (@PathVariable String userId){
        SubscriptionResponse result = service.getSubscriptionForUser(userId);
        return ResponseEntity.ok(result);
    }

    @DeleteMapping("/{subscriptionId}")
    public ResponseEntity<SubscriptionResponse> cancelSubscription(@PathVariable String subscriptionId){
        SubscriptionResponse result = service.cancelSubscription(subscriptionId);
        return ResponseEntity.ok(result);
    }

}

Nothing is really special here, unless that we will return Response entity object at all cases. We indicate the abscence of subscription not by returning 404 error but by sending status: false back to the client.

Code

You can obtain source code for backend part in this Github repository

Copy link
Powered by Social Snap