Codemason Blog | Codemason
Codemason Blog | Codemason

Codemason is a cloud platform for hosting apps without the hassle. Our superpower is making complex things simple. Build, scale and manage hundreds of sites with ease on Codemason.

Ben Magg
Author

Founder of Codemason

Share


Our Newsletter


Subscribe to our newsletter to get the latest on building, deploying, managing and scaling great apps and businesses.

Tags


Twitter


Codemason Blog | Codemason

Extend Laravel Spark to support add-on subscriptions (Part 2)

Ben MaggBen Magg

Delivering the goods — Provisioning an add-on

When the user clicks "Install", I want to open a modal where they can:

  • Confirm the plan they have selected
  • Specify which team it should belong to (if they have more than one)
  • Enter their credit card details if they haven’t previously provided a credit card.
  • Choose an environment to launch their add-on in

addon-provisioner

Vue component for addon-provisioner. A few things of note in this component:

  • We fetch the team info from /${Spark.pluralTeamString}/${team.id} this gives us access card_last_four which we use to check if there’s a card attached to the team.
  • If there’s no card attached to the team, we’ll use Stripe.card.createToken to get a stripe_token. If they already have a card on file, we won’t need a new stripe_token.
<template>
    <div class="modal fade provisioner" id="provision-modal" role="dialog">
        <div class="modal-dialog">
            <div class="modal-content">
                <!-- Add-on Header -->
                <div class="modal-header">
                    <button type="button" class="close" data-dismiss="modal" aria-hidden="true">&times;</button>
                    <div class="row text-center">
                        <div class="col-md-12">
                            <img :src="addon.logo" width="60">
                            <h4 class="modal-title font-weight-bold">{{ addon.name }}</h4>
                        </div>
                    </div>
                </div>
                <!-- Billing Information -->
                <div class="billing-info modal-body">
                    <div class="row">
                        <div class="col-xs-9">
                            <h4 class="plan-name">{{ plan.name }} Plan</h4>
                            <span class="billing-cycle text-muted">Billed {{ plan.interval }}</span>
                        </div>
                        <div class="col-xs-3 plan">
                            <div class="price">{{ plan.price | currency }}</div>
                            <div class="schedule">/&nbsp;{{ plan.interval == "monthly" ? "month" : "year" }}</div>
                        </div>
                    </div>
                    <div v-if="teams.length > 1" class="row">
                        <div class="col-md-12 form-group m-b-none">
                            <b class="help-block m-b-none">Team</b>
                            <select v-model="team.slug" @change="teamChanged(team)" class="form-control input-medium">
                                <option value="" disabled>Please select</option>
                                <option v-for="team in teams" v-bind:value="team.slug">{{ team.name }}</option>
                            </select>
                        </div>
                    </div>
                    <!-- Request Card -->
                    <div v-if="!team.card_last_four" class="row m-t-xs p-b-sm">
                        <div class="col-md-12">
                            <!-- Card Number -->
                            <div class="row">
                                <div class="col-xs-12 form-group m-b-xs" :class="{'has-error': cardForm.errors.has('number')}">
                                    <b class="help-block m-b-none">Card Number</b>
                                    <input type="text" class="form-control input-medium m-t-none m-b-none" name="number" data-stripe="number" v-model="cardForm.number" placeholder="*****************" required="">
                                    <span class="help-block" v-show="cardForm.errors.has('number')">{{ cardForm.errors.get('number') }}</span>
                                </div>
                            </div>
                            <div class="row">
                                <!-- Expiration -->
                                <div class="col-xs-6 form-group m-b-xs">
                                    <b class="help-block m-b-none">Expiration</b>
                                    <div class="row">
                                        <!-- Month -->
                                        <div class="col-xs-6 p-r-xs">
                                            <input type="text" class="form-control input-medium m-t-none m-b-none" name="month"
                                                   placeholder="MM" maxlength="2" data-stripe="exp-month" v-model="cardForm.month">
                                        </div>
                                        <!-- Year -->
                                        <div class="col-xs-6 p-l-xs">
                                            <input type="text" class="form-control input-medium m-t-none m-b-none" name="year"
                                                   placeholder="YYYY" maxlength="4" data-stripe="exp-year" v-model="cardForm.year">
                                        </div>
                                    </div>
                                </div>
                                <!-- Security Code -->
                                <div class="col-xs-6 form-group m-b-xs">
                                    <b class="help-block m-b-none">CVC</b>
                                    <input type="text" class="form-control input-medium m-t-none m-b-none" name="cvc" data-stripe="cvc" v-model="cardForm.cvc">
                                </div>
                            </div>
                        </div>
                    </div>
                </div>

                <!-- Confirm Order -->
                <div class="modal-footer">
                    <button @click.prevent="subscribe" type="button" class="btn btn-success col-xs-12" :disabled="form.busy">
                        <span v-if="form.busy">
                            <i class="fa fa-btn fa-spinner fa-spin"></i>Provisioning...
                        </span>
                        <span v-else>Confirm</span>
                    </button>
                </div>
            </div>
        </div>
    </div>
</template>


<script>
    export default {

        props: ['teams', 'currentTeam'],

        /**
         * The component's data.
         */
        data: function () {
            return {
                addon: {},
                plan: {
                    features: [],
                },
                team: "",
                form: new SparkForm({
                    stripe_token: null,
                    plan: '',
                    coupon: null,
                    team: "",
                    environment: "",
                }),
                cardForm: new SparkForm({
                    number: '',
                    cvc: '',
                    month: '',
                    year: '',
                })
            };
        },

        /**
         * Prepare the component.
         */
        mounted: function() {

            var self = this;

            Stripe.setPublishableKey(Spark.stripeKey);

            // Select the current team
            if(this.currentTeam) {
                this.team = this.currentTeam;
                this.getTeam(this.team.slug);
            }

            // Set the plan
            this.form.plan = this.plan.id;

            // Listen for a plan being selected
            Bus.$on('addonPlanInstall', function (addon, plan) {
                self.addon = addon;
                self.plan = plan;
                $("#provision-modal").modal('show');
            });

        },

        methods: {


            /**
             * The user has changed the selected team.
             *
             * @param team
             */
            teamChanged: function(team) {
                this.getTeam(team.slug);
            },

            /**
             * Get the team
             *
             * @param slug
             */
            getTeam: function(slug) {

                var team = this.findTeamBySlug(slug);

                axios.get(`/${Spark.pluralTeamString}/${team.id}`)
                    .then(response => {
                        this.team = response.data;
                    });

            },

            /**
             * Subscribe to the specified add-on plan.
             */
            subscribe: function() {

                // Prepare the form
                this.form.plan = this.plan.id;
                this.form.startProcessing();
                this.cardForm.errors.forget();

                if(!this.team.card_last_four) {

                    const payload = {
                        number: this.cardForm.number,
                        cvc: this.cardForm.cvc,
                        exp_month: this.cardForm.month,
                        exp_year: this.cardForm.year,
                    };

                    Stripe.card.createToken(payload, (status, response) => {
                        if (response.error) {
                            this.cardForm.errors.set({number: [
                                response.error.message
                            ]});

                            this.form.busy = false;
                        } else {
                            this.form.stripe_token = response.id;
                            this.provision();
                        }
                    });

                } else {
                    this.provision();
                }

            },

            /**
             * Create add-on subscription.
             */
            provision: function() {

                Spark.post(`/api/${this.team.slug}/addons/${this.addon.id}/subscribe`, this.form)
                    .then(function(response) {

                        $("#provision-modal").modal('hide');

                        swal({
                            title: 'Add-on Created',
                            text: 'Your add-on will be up and running in a few minutes.',
                            type: 'success',
                            showConfirmButton: false,
                        });

                        // Redirect to your new add-on

                    });

            },

            /**
             * Find a team based on the slug
             *
             * @param slug
             */
            findTeamBySlug: function(slug) {
                return _.find(this.teams, {slug: slug})
            }

        },

    }
</script>

Next we’ll create a new endpoint /api/{slug}/addons/{addon}/subscribe, we’ll use this endpoint to create add-on subscriptions. For completeness, lets also implement the /api/{slug}/addons/{$addon}/cancel endpoint now too (in routes/api.php).

<?php

/**
 * Create the add-on subscription for the team.
 *
 * @param  \Illuminate\Http\Request  $request
 * @param  string  $team
 * @param  string  $addon
 * @return \Illuminate\Http\Response
 */
Route::middleware('auth:api')->post('{slug}/addons/{addon}/subscribe', function(\Illuminate\Http\Request $request, $slug, $addon) {

    // Get the team
    $team = \App\Team::where('slug', $slug)->firstOrFail();
    abort_unless($request->user()->onTeam($team), 404);

    // Validate request
    \Validator::make($request->all(), [
        'stripe_token' => 'nullable',
        'plan' => 'required|in:' . \App\Spark::activeAddonPlanIdList(),
    ]);

    // Get the add-on plan
    $plan = \App\Spark::allAddonPlans($addon)->where('id', $request->plan)->first();

    // Create subscription
    $subscription = $team->newSubscription($addon, $plan->id);
    if($team->hasEverSubscribedTo($addon, $plan->id)) {
        $subscription->skipTrial();
    } else if($plan->trialDays > 0) {
        $subscription->trialDays($plan->trialDays);
    }
    if($request->coupon) {
        $subscription->withCoupon($request->coupon);
    }
    $subscription = $subscription->create($request->stripe_token);

    //
    // ... Launch the add-on
    //

    // Switch to the team so they can jump right in
    $request->user()->switchToTeam($team);

    return response()->json('OK');

});


/**
 * Cancel the team's add-on subscription.
 *
 * @param  \Illuminate\Http\Request  $request
 * @param  string  $slug
 * @param  string  $addon
 * @return \Illuminate\Http\Response
 */
Route::middleware('auth:api')->post('{slug}/addons/{addonId}/cancel', function(\Illuminate\Http\Request $request, $slug, $addonId) {

    // Get the team
    $team = \App\Team::where('slug', $slug)->firstOrFail();
    abort_unless($request->user()->onTeam($team), 404);

    // Get the add-on subscription
    $subscription = $team->subscriptions()->findOrFail($addonId);

    // Cancel subscription
    $subscription = $subscription->cancel();

    //
    // ... Cancel the add-on
    //

});

NOTE: I’ve introduced new properties for the AddonPlan, onSubscribe and onCancel. These accept a job class which contains the code to handle subscriptions or cancellations.

Bringing it all together

Ok! Now we load all our new Vue components into our app by including them in our resources/assets/js/components/bootstrap.js file


/*
 |--------------------------------------------------------------------------
 | Laravel Spark Components
 |--------------------------------------------------------------------------
 |
 | Here we will load the Spark components which makes up the core client
 | application. This is also a convenient spot for you to load all of
 | your components that you write while building your applications.
 */

// ...

Vue.component('addon-plans', require('./AddonPlans.vue'));
Vue.component('addon-plan-preview', require('./AddonPlanPreview.vue'));
Vue.component('addon-provisioner', require('./AddonProvisioner.vue'));
Vue.component('addon-subscriptions', require('./AddonSubscriptions.vue'));

And finally, we can now create our show add-on view that will actually show the add-on (in resources/views/addon.blade.php)

@extends('spark::layouts.app')

@section('scripts')
    @if (Spark::billsUsingStripe())
        <script src="https://js.stripe.com/v2/"></script>
    @else
        <script src="https://js.braintreegateway.com/v2/braintree.js"></script>
    @endif
@endsection

@section('content')
    <div class="container">

        <div class="row">
            <div class="col-md-12">
                <h1>{{ $addon->name }}</h1>
                <h4>{{ $addon->description }}</h4>
            </div>
        </div>

        <div class="row">
            <div class="col-md-7">
                <addon-plans :addon='<?php echo json_encode($addon); ?>'></addon-plans>
            </div>
            <div class="col-md-5">
                <addon-plan-preview
                        :addon='<?php echo json_encode($addon); ?>'
                        :teams="teams"
                        :current-team="currentTeam">
                </addon-plan-preview>
            </div>
        </div>
        
    </div>

    <addon-provisioner :teams="teams"
                       :current-team="currentTeam">
    </addon-provisioner>
@endsection

Managing Add-on Subscriptions

We want the user to be able to manage their add-on subscriptions.

Extend the Team class by introducing an addonSubscriptions method which will return all the add-on subscriptions for the team.

<?php 

/**
 * Get all of the add-on subscriptions for the team.
 *
 * @return \Illuminate\Support\Collection
 */
public function addonSubscriptions()
{
    return $this->subscriptions->reject(function($subscription) {
        return $subscription->name == "default" || $subscription->cancelled();
    })->map(function($subscription) {
        $subscription->addon = Spark::findAddonById(array_get(explode(":", $subscription->provider_plan), 0));
        $subscription->addonPlan = Spark::findAddonPlanById($subscription->provider_plan);
        return $subscription;
    });
}

We also need to listen for the DeletingTeam event so when a user deletes their team, we can cancel any remaining active add-on subscriptions. Add the event listener to boot method your EventServiceProvider.php file.

<?php
\Event::listen('Laravel\Spark\Events\Teams\DeletingTeam', function ($event) {
    $team = $event->team;
    $event->team->addonSubscriptions()->pluck('id')->map(function($subscriptionId) use ($team) {
        $subscription = $team->subscriptions()->findOrFail($subscriptionId);
        $subscription->cancelNow();
    });
});

Implement the endpoint to display all the team’s subscriptions in routes/api.php

<?php
/**
 * Return all of the add-on subscriptions for the team.
 *
 * @param  Request  $request
 * @param  string  $slug
 * @return Response
 */
Route::middleware('auth:api')->get('{slug}/addons/subscriptions', function(\Illuminate\Http\Request $request, $slug) {
    
    // Get the team
    $team = \App\Team::where('slug', $slug)->firstOrFail();
    abort_unless($request->user()->onTeam($team), 404);
    return response()->json($team->addonSubscriptions());
});

Create a component that will list all the add-on subscriptions. We’ll use the settings/subscription/update-subscription mixin Spark provides. This will give us access to some of the existing computed properties we will use to display the user’s subscriptions.

<template>
    <div>
        <div class="panel panel-default">
            <div class="panel-heading">
                <div class="pull-left" :class="{'btn-table-align': hasMonthlyAndYearlyPlans}">
                    Add-on Subscriptions
                </div>
                <!-- Interval Selector Button Group -->
                <div class="pull-right">
                    <div class="btn-group" v-if="hasMonthlyAndYearlyPlans">
                        <!-- Monthly Plans -->
                        <button type="button" class="btn btn-default"
                                @click="showMonthlyPlans"
                                :class="{'active': showingMonthlyPlans}">
                            Monthly
                        </button>
                        <!-- Yearly Plans -->
                        <button type="button" class="btn btn-default"
                                @click="showYearlyPlans"
                                :class="{'active': showingYearlyPlans}">
                            Yearly
                        </button>
                    </div>
                </div>
                <div class="clearfix"></div>
            </div>
            <div class="panel-body table-responsive">
                <table class="table table-borderless m-b-none">
                    <thead></thead>
                    <tbody>
                    <tr v-for="plan in addonPlans">
                        <!-- Plan Name -->
                        <td>
                            <div class="btn-table-align" @click="showPlanDetails(plan.addonPlan)">
                                <span style="cursor: pointer;"><strong>{{ plan.addon.name }} {{ plan.addonPlan.name }}</strong> ({{ plan.name }})</span>
                            </div>
                        </td>
                        <!-- Plan Features Button -->
                        <td>
                            <button class="btn btn-default m-l-sm" @click="showPlanDetails(plan.addonPlan)">
                                <i class="fa fa-btn fa-star-o"></i>Plan Features
                            </button>
                        </td>
                        <!-- Plan Price -->
                        <td>
                            <div class="btn-table-align">
                                <span v-if="plan.addonPlan.price == 0">
                                    Free
                                </span>
                                <span v-else>
                                    {{ priceWithTax(plan.addonPlan) | currency }}
                                    /
                                    {{ plan.addonPlan.interval == "monthly" ? "month" : "year" }}
                                </span>
                            </div>
                        </td>
                        <!-- Plan Select Button -->
                        <td class="text-right">
                            <button class="btn btn-danger-outline" @click="confirmCancellation(plan)">Cancel</button>
                        </td>
                    </tr>
                    </tbody>
                </table>
            </div>
        </div>
        
        <!-- Confirm Cancellation Modal -->
        <div class="modal fade" id="modal-confirm-addon-cancellation" tabindex="-1" role="dialog">
            <div class="modal-dialog">
                <div class="modal-content" v-if="cancelling">
                    <div class="modal-header">
                        <button type="button " class="close" data-dismiss="modal" aria-hidden="true">&times;</button>

                        <h4 class="modal-title">
                            Cancel Subscription
                        </h4>
                    </div>

                    <div class="modal-body">
                        <div class="well">
                            <strong>@{{ cancelling.addon.name }} @{{ cancelling.addonPlan.name }}</strong> (@{{ cancelling.name }})</span>
                            &#8212;
                            <span v-if="cancelling.addonPlan.price == 0">Free</span>
                            <span v-else>
                            @{{ priceWithTax(cancelling.addonPlan) | currency }}
                                /
                            @{{ cancelling.addonPlan.interval == "monthly" ? "month" : "year" }}
                        </span>
                        </div>

                        <p>Are you sure you want to cancel your add-on subscription?</p>
                        <p>If you choose to cancel the add-on, all of the add-on's data will be <b>permanently deleted</b>.</p>
                    </div>

                    <!-- Modal Actions -->
                    <div class="modal-footer">
                        <button type="button" class="btn btn-default" data-dismiss="modal">No, Go Back</button>

                        <button type="button" class="btn btn-danger" @click="cancel" :disabled="form.busy">
                            <span v-if="form.busy">
                                <i class="fa fa-btn fa-spinner fa-spin"></i>Cancelling
                            </span>

                            <span v-else>
                                Yes, Cancel
                            </span>
                        </button>
                    </div>
                </div>
            </div>
        </div>
    </div>
</template>

<script>
    export default {

        mixins: [require('settings/subscription/update-subscription')],

        data: function() {
            return {        
                form: new SparkForm({}),
                cancelling: null,
                addonPlans: []
            }
        },

        /**
         * Prepare the component.
         */
        mounted: function() {
            this.getPlans();
        },

        methods: {

            /**
             * Get the active plans for the application.
             */
            getPlans: function() {
                axios.get('/api/' + this.team.slug + '/addons/subscriptions')
                    .then(response => {
                        this.addonPlans = response.data;
                    });
            },

            /**
             * Confirm the cancellation operation.
             */
            confirmCancellation(plan) {
                this.cancelling = plan;
                $('#modal-confirm-addon-cancellation').modal('show');
            },

            /**
             * Cancel the current subscription.
             */
            cancel() {
                Spark.delete('/api/' + this.team.slug + '/addons/' + this.cancelling.id + '/cancel', this.form)
                    .then(() => {
                        this.getPlans();
                        $('#modal-confirm-addon-cancellation').modal('hide');
                    });
            }

        }
    }
</script>

Update the views/vendor/spark/settings/subscription.blade.php view and add the addon-subscriptions component.

<addon-subscriptions
    :user="user"
    :team="team"
    :billable-type="billableType">
</addon-subscriptions>

Now when you go to your team’s subscription page you will see a list of your add-on subscriptions.

addon-subscriptions


Now you can expand your Laravel Spark app to support optional add-on subscriptions.

As you can see, this is a fairly long process but will no doubt be worth it if you are wanting to grow your business in that way.


About Codemason

We ❤️ Laravel Spark. We used Laravel Spark for Codemason so we could focus on building functionality our users loved, instead of writing boilerplate code. Codemason is the Laravel Spark of servers/hosting 🙃

Working on a Laravel Spark project? People who use Spark know how important their time is. Save yourself hours of fighting with servers by using Codemason.

Don't forget to use the coupon SPARK-3-FREE to get 3 months free access!

Ben Magg
Author

Ben Magg

Founder of Codemason

Comments