DjaoDjin, besides various SaaS related functionality, contains a flexible and powerful checkout feature. It supports paying for multiple subscription plans, charging customers for multiple subscription periods in advance and even paying for the product on behalf of the users who are going to use it. We are going to use it to implement a typical checkout workflow on the frontend.

Displaying cart contents

First, let’s create a Django View to serve an HTML template which is going to be used by Vue.

# saas/views/billing.py

...

class CheckoutView(TemplateView):
    """  
    A checkout view
    """

    template_name = 'saas/billing/checkout.html'

...

# saas/urls/subscriber/billing/payment.py

...

urlpatterns = [
...
	url(r'^billing/(?P<organization>%s)/checkout/' % ACCT_REGEX,
		CheckoutView.as_view(), name='saas_checkout'),
...
]

There’s also a basic HTML markup, it is going to serve as a tepmplate for a Vue view.

# saas/templates/billing/checkout.html

{% extends "saas/base.html" %}
{% block saas_content %}
	<div id="checkout-container">
		vue template goes here
	</div>
{% endblock %}

Once we have a Vue template in place, we need to create a new Vue instance that will use this template.

if($('#checkout-container').length > 0){
var app = new Vue({
    el: "#checkout-container",
    mixins: [itemListMixin],
    data: {
    	url: djaodjinSettings.urls.organization.api_checkout,
    },
    mounted: function(){
	this.get();
    }    
})
}

This code uses a DjaoDjin itemListMixin mixin, which implements basic functionality for fetching data from an endpoint, which we specify in url data property. Once this app is mounted, a get function will be called, this function loads the data and puts this data into items data property.

The djaodjinSettings is an object with various server side settings including urls, in our case it is filled by the DjaoDjin server. djaodjinSettings.urls.organization.api_checkout represents a /billing/cowork/ endpoint which returns a list of items. Each item has 3 properties: subscription, lines and options. subscription is the actual subscription that user wishes to purchase/extend, options is a list of extra options you might want to choose from (for example, prepaying a subscription for a year with 15% discount), lines is a list of items that will be shown on the final invoice (selected option and potentially any additional charges like one-time setup fee, etc). Let’s modify our template to render the cart items (along with lines and options) fetched from the API endpoint.

{% extends "saas/base.html" %}

{% block saas_content %}
    <div id="checkout-container">
        <table>
            <tbody>
                <tr v-for="(item, itemIndex) in items.items">
                    <td>
                        [[item.subscription.plan.title]] from
                      [[item.subscription.plan.organization]]
                        <table>
                            <tbody>
                                <tr v-for="line in item.lines" class="invoice-item">
                                    <td></td>
                                    <td>[[line.amount]]</td>
                                    <td>[[line.description]]</td>
                                </tr>
                                <tr v-for="option in item.options">
                                    <td><input type="radio"></input></td>
                                    <td>[[option.amount]]</td>
                                    <td v-html="option.description"></td>
                                </tr>
                            </tbody>
                        </table>
                    </td>
                </tr>
                <tfoot>
                    <tr>
                        <th class="total-amount">[[linesPrice[0] | currency($options.filters.currencyToSymbol(linesPrice[1]))]]</th>
                        <th>Charged Today</th>
                    </tr>
                </tfoot>
            </tbody>
        </table>
    </div>
{% endblock %}

At the end of the template we have an unknown property linesPrice — it is a total amount based on all lines and selected options and a unit (usually usd). currency and currencyToSymbol are filters defined in djaodjin-saas-vue.js, they improve the number formatting. Let’s add linesPrice as a computed property to our Vue instance:

...

computed: {
        linesPrice: function(){
            var total = 0;
            var unit = 'usd';
            if(this.items.results){
                this.items.results.map(function(e){
                    e.lines.map(function(l){
                        total += l.dest_amount;
                        unit = l.dest_unit;
                    });
                });
            }
            return [total / 100, unit];
        },
},

...

This version only computes total based on lines. However, we left out the functionality which allows a user to select a specific plan option — we’ll do it later. Let’s do a delete button next.

Removing items from cart

To remove an item we need to make a DELETE request to /cart endpoint, it accepts a plan slug as an argument. Let’s add a button to the template and a handler to the view.

...
<button @click="remove(item.subscription.plan.slug)">delete</button>
...
...

methods: {
        remove: function(plan){
            var vm = this;
            var url = djaodjinSettings.urls.api_cart + plan + '/';
            $.ajax({
                method: 'DELETE',
                url: url,
            }).done(function() {
                vm.get();
            }).fail(function(resp){
                showErrorMessages(resp);
            });
        },
},
...

The code is pretty straightforward: once a user clicks on a delete button, we send a DELETE request to /cart endpoint and reload cart items on success.

Coupon redeeming functionality

This is going to be simple too. Once again we need to add a few lines of HTML and a Vue handler. This handler will use a /cart/redeem/ endpoint, which accepts a coupon code as an argument.

...
<form @submit.prevent="redeem">
	<input name="code" type="text" placeholder="Coupon code" v-model="coupon">
	<button type="submit" class="submit-code">Redeem</button>
</form>
...

Here we are attaching a handler redeem, preventing default behavior (in this case: submitting this form and reloading a page) and binding an input to coupon model.

...
methods: {
...
	redeem: function(){
            var vm = this;
            $.ajax({
                method: 'POST',
                url: djaodjinSettings.urls.api_redeem_coupon,
                data: {code: vm.coupon},
            }).done(function(resp) {
                showMessages(["Coupon was successfully applied."], "success");
                vm.get()
            }).fail(function(resp){
                showErrorMessages(resp);
            });
	},
...
}
...

The handler code is pretty straightforward: issue a POST request to endpoint, reload cart items.

Selecting plan options

A plan can offer user to prepay multiple periods in advance with discount — this use case is implemented as plan options. If a plan provides a discount, the /checkout endpoint will return a list of available options in options array of a plan. To save the selected option we need to either make a POST request to /cart endpoint with option id or POST request to /checkout with a list of option ids. Option id is an index of an option in options array incremented by one. OK, let’s start with the view code.

...
data: {
...
	plansOption: {},
...
},
methods: {
...
	optionSelected: function(plan, index){
            this.$set(this.plansOption, plan, index);
        },
        isOptionSelected: function(plan, index){
            var selected = this.plansOption[plan];
            return selected !== undefined && selected == index;
        },
...
},

optionSelected is called when user selects an option, isOptionSelected is used to determine whether an option is selected. They both accept a plan slug and an option id.

Now we are going to hook up those functions to the template.

...

<tr v-for="(option, index) in item.options"">
...
<td><input type="radio" @change="optionSelected(item.subscription.plan.slug, index+1)" :checked="isOptionSelected(item.subscription.plan.slug, index+1)"></input></td>
...

Now that we can determine which option is selected, let’s modify a linesPrice method so that the total amount reflects the selected options:

...
computed: {
	linesPrice: function(){
            var vm = this;
            var total = 0;
            var unit = 'usd';
            if(this.items.results){
                this.items.results.map(function(e){
                    if(e.options.length > 0){
                        var option = vm.plansOption[e.subscription.plan.slug];
                        if(option !== undefined){
                            total += e.options[option-1].dest_amount;
                            unit = e.options[option-1].dest_unit;
                        }
                    }
                    e.lines.map(function(l){
                        total += l.dest_amount;
                        unit = l.dest_unit;
                    });
                });
            }
            return [total / 100, unit];
        },
},
...

Cool. There is still a use case we need to fix: when user adds a plan to cart, we need to preselect the first option for them (this first option being the actual plan). Preselecting the options should be done on the initial page load. Luckily, itemListMixin provides a hook to override the default get handler.

...
data: {
...
	getCb: 'getAndPrepareData',
...
},
methods: {
...
	getAndPrepareData: function(res){
            var results = res.items
            var periods = {}
            results.map(function(e){
                var plan = e.subscription.plan.slug;
                if(e.options.length > 0){
                    periods[plan] = 1;
                }
            });

            this.items = {
                results: results,
                count: results.length
            }
            this.plansOption = periods;
            this.itemsLoaded = true;
        },
...
},

Finally, after selecting and confirming the options, we need to display them as invoice lines and hide everything else.

...
data: {
...
	optionsConfirmed: false,
...
},
...
methods: {
...
	getAndPrepareData: function(res){
            var results = res.items
            var periods = {}
            var optionsConfirmed = results.length > 0 ? true : false;
            results.map(function(e){
                var plan = e.subscription.plan.slug;
                if(e.options.length > 0){
                    optionsConfirmed = false;
                    periods[plan] = 1;
                }
            });

            this.items = {
                results: results,
                count: results.length
            }
            this.plansOption = periods;
            this.itemsLoaded = true;
            this.optionsConfirmed = optionsConfirmed;
        },
        // used to display selected option as a line
        activeOption: function(item){
            var index = this.plansOption[item.subscription.plan.slug];
            if(index !== undefined){
                var option = item.options[index - 1];
                if(option) return option;
            }
            return {};
        },
...
},
...

Here’s the complete template code for the lines and options form.

...
<form @submit.prevent="optionsConfirmed = true">
            <table>
                <tbody>
                    <tr v-for="(item, itemIndex) in items.results">
                        <td>
                            [[item.subscription.plan.title]] from
                          [[item.subscription.plan.organization]]
                            <button type="button" @click="remove(item.subscription.plan.slug)">delete</button>
                            <table>
                                <tbody>
                                    <tr v-for="line in item.lines" class="invoice-item">
                                        <td></td>
                                        <td>[[line.amount]]</td>
                                        <td v-html="line.description"></td>
                                    </tr>
                                    <tr v-show="optionsConfirmed">
                                        <td></td>
                                        <td>[[activeOption(item).amount]]</td>
                                        <td v-html="activeOption(item).description"></td>
                                    </tr>
                                    <tr v-for="(option, index) in item.options" v-show="!optionsConfirmed">
                                        <td><input type="radio" @change="optionSelected(item.subscription.plan.slug, index + 1)" :checked="isOptionSelected(item.subscription.plan.slug, index + 1)"></input></td>
                                        <td>[[option.amount]]</td>
                                        <td v-html="option.description"></td>
                                    </tr>
                                </tbody>
                            </table>
                        </td>
                    </tr>
                    <tfoot>
                        <tr>
                            <th class="total-amount">[[linesPrice[0] | currency($options.filters.currencyToSymbol(linesPrice[1]))]]</th>
                            <th>Charged Today</th>
                        </tr>
                    </tfoot>
                </tbody>
            </table>
            <button type="submit">next &raquo;</button>
        </form>

Notice that we don’t yet submit anything to the server, this is done later — either at checkout or when ordering multiple subscriptions, which we’re going to cover next.

Buying subscription for another user

In SaaS world it is a common use case where a user who purchases the subscription is not the one who is going to use it. In DjaoDjin as an organization it is possible to buy subscriptions for multiple accounts. To do this an organization needs to be a bulk_buyer. The actual purchase is done by submitting a POST request to /cart endpoint with plan and user details for each account you’d like to create and then checking out as usual.

...
data: {
...
	plansUser: {},
	isBulkBuyer: djaodjinSettings.bulkBuyer,
        seatsConfirmed: false,
        init: true,
...
},
methods: {
...
	// updated method which creates user models for each plan
	getAndPrepareData: function(res){
            var results = res.items
            var periods = {}
            var users = {}
            var optionsConfirmed = results.length > 0 ? true : false;
            results.map(function(e){
                var plan = e.subscription.plan.slug;
                if(e.options.length > 0){
                    optionsConfirmed = false;
                    if(vm.init){
	                    periods[plan] = 1;
	            }
                }
                users[plan] = {
                    firstName: '', lastName: '', email: ''
                }
            });

            this.items = {
                results: results,
                count: results.length
            }
            this.itemsLoaded = true;
            if(this.init){
                this.plansOption = periods;
                this.plansUser = users;
                this.optionsConfirmed = optionsConfirmed;
                this.seatsConfirmed = false;
            }
        },
        // returns a payer for specific plan
        planUser: function(plan){
            return this.plansUser[plan] && this.plansUser[plan] || {}
        },
        // called each time an account is added
        addPlanUser: function(plan){
            var vm = this;
            var user = this.planUser(plan);
            var data = {
                plan: plan,
                first_name: user.firstName,
                last_name: user.lastName,
                sync_on: user.email
            }
            var option = vm.plansOption[plan];
            if(option){
                data.option = option
            }
            $.ajax({
                method: 'POST',
                url: djaodjinSettings.urls.api_cart,
                data: data,
            }).done(function(resp) {
                showMessages(["User was added."], "success");
                vm.init = false;
                vm.$set(vm.plansUser, plan, {
                    firstName: '',
                    lastName: '',
                    email: ''
                });
                vm.get()
            }).fail(function(resp){
                showErrorMessages(resp);
            });
        },
        saveChanges: function(){
            if(this.optionsConfirmed){
                if(this.isBulkBuyer){
                    this.seatsConfirmed = true;
                }
            }
            else {
                this.optionsConfirmed = true;
                if(!this.isBulkBuyer){
                    this.seatsConfirmed = true;
                }
            }
        },
...
},
...

There is a lot going on here. First, we have an updated getAndPrepareData method which creates user models for each plan in the cart, they will contain account details, it also preserves the selected options after an account is created and the cart items have been reloaded. Next we have a addPlanUser method which submits account data to the server. Finally, there is a saveChanges method — it controls the checkout flow. This method will determine, based on whether an organization is a bulk_buyer, what to display after an option is selected: a screen where multiple accounts can be purchased or a screen where credit card details can be entered.

In template we need to add an account form below the options list and an updated “next” button:

...
<tr v-show="optionsConfirmed && !seatsConfirmed">
	<td colspan="3">
		<input type="text" v-model="planUser(item.subscription.plan.slug).firstName" placeholder="First Name" />
		<input type="text" v-model="planUser(item.subscription.plan.slug).lastName" placeholder="Last Name" />
		<input type="text" v-model="planUser(item.subscription.plan.slug).email" placeholder="Email" />
		<button @click.prevent="addPlanUser(item.subscription.plan.slug)" type="button">Add</button>
	</td>
</tr>
...
<button v-show="!seatsConfirmed" type="submit">next &raquo;</button>
...

The problem with this code is that for each account in a plan there would be a separate cart item with its own account form. Ideally, we need only one form per plan. In order to achieve this let’s add a couple of methods:

...
methods: {
...
	getLastUserPlanIndex: function(plan){
            var lastItemIndex = -1;
            this.items.results.map(function(e, i){
                if(e.subscription.plan.slug === plan){
                    lastItemIndex = i;
                }
            });
            return lastItemIndex;
        },
        isLastUserPlan: function(index){
            var plan = this.items.results[index].subscription.plan.slug;
            var lastItemIndex = this.getLastUserPlanIndex(plan);
            return lastItemIndex === index;
        },
...
},

Those methods determine whether a particular cart item is a last in the list — we need this to know when to display the account form (after the last account in a plan). Here’s the updated account form tag:

...
<tr class="seat" v-show="optionsConfirmed && !seatsConfirmed && isLastUserPlan(itemIndex)">
...

OK, that’s it for the cart management, next step: actual checkout.

Checkout

At this point a user has reviewed and confirmed the list of stuff that they are going to buy. The next step is to collect the card data and get a card token from Stripe, unless we already have a customer payment data. DjaoDjin doesn’t store the actual card data, only Stripe customer id, which is then used for future payments — this simplifies the PCI compliance. Finally, we submit the selected options along with a Stripe token to DjaoDjin, which will charge a user’s card and do further processing.

...
data: {
...
	cardNumber: '',
        cardCvc: '',
        cardExpMonth: '',
        cardExpYear: '',
        name: '',
        addressCountry: '',
        addressRegion: '',
        addressCity: '',
        addressLine1: '',
        addressZip: '',
...
},
methods: {
...
	getCardToken: function(cb){
            var vm = this;
            Stripe.setPublishableKey(djaodjinSettings.stripePubKey);
            Stripe.createToken({
                number: vm.cardNumber,
                cvc: vm.cardCvc,
                exp_month: vm.cardExpMonth,
                exp_year: vm.cardExpYear,
                name: vm.name,
                address_line1: vm.addressLine1,
                address_city: vm.addressCity,
                address_state: vm.addressRegion,
                address_zip: vm.addressZip,
                address_country: vm.addressCountry
            }, function(code, res){
                if(code === 200) {
                    if(cb) cb(res.id)
                } else {
                    showMessages([res.error.message], "error");
                }
            });
        },
        // this method prepares a list of selected options in a format DjaoDjin expects
        getOptions: function(){
            var vm = this;
            var res = [];
            vm.items.results.map(function(item, index){
                var plan = item.subscription.plan.slug
                var option = vm.plansOption[plan];
                if(option){
                    res[index] = {option: option}
                }
            });
            return res;
        },
        checkout: function(){
            var vm = this;
            var opts = vm.getOptions();
            var data = {
                remember_card: true,
                items: opts,
                street_address: vm.addressLine1,
                locality: vm.addressCity,
                postal_code: vm.addressZip,
                country: vm.addressCountry,
                region: vm.addressRegion,
            }
            vm.getCardToken(function(token){
            	data.processor_token = token;
                $.ajax({
                	method: 'POST',
	                url: djaodjinSettings.urls.organization.api_checkout,
        	        contentType: 'application/json',
                	data: JSON.stringify(data),
            	}).done(function(resp) {
			vm.get();
                	vm.optionsConfirmed = false;
	                showMessages(["Success."], "success");
            	}).fail(function(resp){
                	showErrorMessages(resp);
            	});
            });
        },
...
},
...

Here we define a few data properties which will serve as models for card and billing address inputs. There are also three methods: first gets a Stripe token and the second submits the token to DjaoDjin for checkout, there is also a helper method to prepare the options data for submission. remember_card parameter tells the server to create a Stripe customer which can be used in future payments instead of entering the card data again. Here’s what the HTML will look like:

...
	<div v-show="seatsConfirmed">
            <fieldset>
                  <legend>Credit Card Information</legend>
                  <div class="form-group">
                        <label for="card-number">Card Number</label>
                        <input id="card-number" v-model="cardNumber"
                               type="text" size="16" placeholder="Card number"
                               autocomplete="off" />
                  </div>
                  <div class="form-group">
                        <label>Expiration</label>
                        <input v-model="cardExpMonth"
                               type="text" maxlength="2" size="2" placeholder="MM" />
                        <input v-model="cardExpYear"
                               type="text" maxlength="4" size="4" placeholder="YYYY" />
                  </div>
                  <div class="form-group">
                        <label>Security Code</label>
                        <input v-model="cardCvc"
                               type="text" maxlength="3" size="3" placeholder="CVC"
                               autocomplete="off" />
                  </div>

                  <legend>Billing Address</legend>
                  <div class="form-group">
                    <input v-model="name" type="text" placeholder="Name">
                  </div>
                  <div class="form-group">
                    <input v-model="addressLine1" type="text" placeholder="Address line">
                  </div>
                  <div class="form-group">
                    <input v-model="addressCity" type="text" placeholder="City">
                  </div>
                  <div class="form-group">
                    <input v-model="addressState" type="text" placeholder="State">
                  </div>
                  <div class="form-group">
                    <input v-model="addressZip" type="text" placeholder="ZIP">
                  </div>
                  <div class="form-group">
                    <input v-model="addressRegion" type="text" placeholder="Region">
                  </div>
                  <div class="form-group">
                    <input v-model="addressCountry" type="text" placeholder="Country">
                  </div>
            </fieldset>
            <div>
                <button @click="checkout" type="submit">Submit</button>
            </div>
        </div>
...

Organization profile might already have address data, in this case we can fill the billing address inputs on page load automatically.

...
methods: {
...
	getOrgAddress: function(){
            var vm = this;
            $.ajax({
                method: 'GET',
                url: djaodjinSettings.urls.organization.api_base,
            }).done(function(org) {
                if(org.street_address){
                    vm.addressLine1 = org.street_address;
                }
                if(org.locality){
                    vm.addressCity = org.locality;
                }
                if(org.postal_code){
                    vm.addressZip = org.postal_code;
                }
                if(org.country){
                    vm.addressCountry = org.country;
                }
                if(org.region){
                    vm.addressRegion = org.region;
                }
                vm.organization = org;
            });
        },
...
},
mounted: {
	this.get();
	this.getOrgAddress();
},
...

Now let’s handle a use case where a user has submitted their card data previously, which means that there is no need to ask them for this info again. For that we’d need to make an API call to /api/billing/<org>/card" endpoint, which will return a saved card data, then if there is a saved card, we will either let them checkout right away or show a card form to fill out. Checkout API endpoint usually accepts Stripe card token. However, if user has a card data, we don’t need to fill that field — the endpoint will use a previously saved Stripe customer automatically. In our Vue code, there is a new method to fetch the card data and an update checkout method which was split into two separate methods.

...
data: {
...
	haveCardData: false,
	savedCard: {},
...
},
methods: {
...
	getUserCard: function(){
            var vm = this;
            $.ajax({
                method: 'GET',
                url: djaodjinSettings.urls.organization.api_card,
            }).done(function(resp) {
                if(resp.last4){
                    var savedCard = {
                        last4: resp.last4,
                        exp_date: resp.exp_date,
                        card_name: resp.card_name,
                    }
                    vm.savedCard = savedCard;
                    vm.haveCardData = true;
                }
            });
        },
	doCheckout: function(token){
            var vm = this;
            var opts = vm.getOptions();
            var data = {
                remember_card: true,
                items: opts,
                street_address: vm.addressLine1,
                locality: vm.addressCity,
                postal_code: vm.addressZip,
                country: vm.addressCountry,
                region: vm.addressRegion,
            }
            if(token){
                data.processor_token = token;
            }
            $.ajax({
                method: 'POST',
                url: djaodjinSettings.urls.organization.api_checkout,
                contentType: 'application/json',
                data: JSON.stringify(data),
            }).done(function(resp) {
                vm.optionsConfirmed = false;
                showMessages(["Success."], "success");
                // redirect user to a page with receipt
                var id = resp.processor_key;
                location = djaodjinSettings.urls.organization.receipt.replace('_', id);
            }).fail(function(resp){
                showErrorMessages(resp);
            });
        },
        checkout: function(){
            var vm = this;
            if(vm.haveCardData){
                vm.doCheckout();
            } else {
                vm.getCardToken(vm.doCheckout);
            }
        },
...
},
...
mounted: {
	this.get()
        this.getUserCard();
        this.getOrgAddress();
},

Here’s the updated template:

...
		<div v-show="haveCardData" v-cloak>
                    Update
                    <div>
                        <dl>
                              <dt>card</dt>
                              <dd>[[savedCard.last4]]</dd>
                        </dl>
                        <dl>
                              <dt>expires</dt>
                              <dd>[[savedCard.exp_date]]</dd>
                        </dl>
                    </div>
                </div>
                <div v-show="!haveCardData" v-cloak>
                	...
	                
	                ...
                </div>
...

Summary

Phew, that was quite a lot of code, but that’s because the checkout is a complex piece of functionality, especially in a SaaS application.