Implementing VAT into a Laravel app with Stripe and Cashier
Tags: tutorial, laravel, php
Published: 2020-05-19 00:37:16

---
title: Implementing VAT into a Laravel app with Stripe and Cashier
published: true
date: 2020-05-16 23:00:00 UTC
tags: Tutorial, Laravel, PHP
canonical_url: https://miguelpiedrafita.com/laravel-cashier-vat/
cover_image: https://miguelpiedrafita.com/content/images/2020/05/VAT-Article.jpg
---

I recently had to update the [Sitesauce](https://sitesauce.app) billing system to account for the European Value Added Tax (VAT). Here's a short article detailing how I implemented it.

First of all, I decided to get rid of as much of the existing billing-related logic as I possibly could. This meant implementing [Stripe's new self-serve portal](https://stripe.com/docs/billing/subscriptions/integrating-self-serve-portal), which can take care of viewing invoices and managing payment methods and subscriptions. Since Cashier already takes care of keeping our database in sync with Stripe, the only thing we need to do is create a new session to redirect the user to.

``` php
public function portal(User $user)
    {
        $this->authorize('update', $user);

        return with(\Stripe\BillingPortal\Session::create([
            'customer' => $user->stripe_id,
            'return_url' => URL::previous(),
        ]), fn ($portal) => redirect($portal->url));
    }

```

Here's how the portal looks in action:

{% youtube bm5BphnQ6aE %}

While we're fixing billing stuff, multiple people have complained that the current credit card input (while looking cool) does not work on Safari, which means you cannot currently complete the onboarding on iOS devices. Let's get rid of it and give the page a quick redesign.

 ![Implementing VAT into a Laravel app with Stripe and Cashier](https://miguelpiedrafita.com/content/images/2020/05/Screenshot-2020-05-16-at-16.14.41.png)

 ![Implementing VAT into a Laravel app with Stripe and Cashier](https://miguelpiedrafita.com/content/images/2020/05/Screenshot-2020-05-16-at-16.14.49.png)

<figcaption>Here's a before and after comparison of the Sitesauce billing page</figcaption>

You may now be wondering, where am I asking them for their credit card details? Instead of using [Stripe Elements](https://stripe.com/elements) to capture their credit card details on page, I'm going to switch to the new Stripe Checkout, which will later make implementing VAT simpler.

To get it working we're gonna need to make two changes. First, we need to create a new checkout session and return the session ID, so we can redirect to checkout from the frontend. I've also added some logic to apply coupon codes, and fail validation if they don't exist.

``` php
public function checkout(Request $request)
    {
        $this->authorize('update', $user = $request->user());

        $request->validate([
            'plan' => ['required', 'string', 'in:monthly,yearly'],
            'coupon' => ['nullable', 'string'],
        ]);

        try {
            return with(\Stripe\Checkout\Session::create([
                'payment_method_types' => ['card'],
                'customer_email' => $user->email,
                'subscription_data' => [
                    'items' => [[ 'plan' => $request->input('plan')] ],
                    'coupon' => $request->input('coupon'),
                    'trial_period_days' => 7,
                ],
                'mode' => 'subscription',
                'client_reference_id' => $user->id,
                'success_url' => route('setup'),
                'cancel_url' => URL::previous(route('setup.billing')),
            ]), fn ($session) => $session->id);
        } catch (\Stripe\Exception\InvalidRequestException $e) {
            throw_unless($e->getStripeParam() == 'subscription_data[coupon]', $e);

            fail_validation('coupon', 'The specified coupon does not exist');
        }
    }

```

We also need to handle the `checkout.session.completed` webhook Stripe returns. We can do this by [extending the default Stripe webhook controller](https://laravel.com/docs/7.x/billing#defining-webhook-event-handlers) and adding the following method:

``` php
public function handleCheckoutSessionCompleted(array $payload)
    {
        $session = $payload['data']['object'];
        $team = Team::findOrFail($session['client_reference_id']);

        DB::transaction(function () use ($session, $team) {
            $team->update(['stripe_id' => $session['customer']]);

            $team->subscriptions()->create([
                'name' => 'default',
                'stripe_id' => $session['subscription'],
                'stripe_status' => 'trialing',
                'stripe_plan' => Arr::get($session, 'display_items.0.plan.id'),
                'quantity' => 1,
                'trial_ends_at' => now()->addDays(7),
                'ends_at' => null,
            ]);
        });

        return $this->successMethod();
    }

```

Here's how our billing flow looks with all this implemented:

{% youtube 0h-qbPDMOUU %}

With both of these refactors done, it's time to dive into our main subject: VAT. Let's first make sure we understand what we need to implement:

``` js
// keep in mind this is based on a conversation I had with someone from the Spanish Tax Agency
// please consult with your accountant, I'm just a random teenager on the internet

it('should help me understand this mess', () => {
    if (! isEu()) return // nothing to do here, you lucky bastards are exempt from VAT

    if (isSpain()) {
        // users from the country I'm registered at need to pay VAT no matter what
        return 'apply 21% tax'
    }

    // okay, here's where it gets complicated
    const vatID = '...'

    if (vatID) {
        // if the customer has a VAT-ID, they handle all this mess by themselves and there's not much I need to do
        // still, I store their ID on Stripe for invoicing
        return addToStripe(vatID)
    }

    // if the customer doesn't have a VAT-ID, I need to apply their country's tax rate
    return `apply ${getTaxRate(getCountry())}% tax`
})

```

The first thing we need to do is detect the country our customer belongs to. For this, we'll use [Cloudflare's IP Geolocation service](https://support.cloudflare.com/hc/en-us/articles/200168236-Configuring-Cloudflare-IP-Geolocation) to make an educated guess and correct it with the customer's credit card origin country after.

Once we know our customer's country, we can conditionally show the VAT-ID field when they're on the EU but not in Spain.

![Implementing VAT into a Laravel app with Stripe and Cashier](https://miguelpiedrafita.com/content/images/2020/05/EYJfQqcXsAEVj9l.jpeg)

As I mentioned at the start, I want to be responsible for as little logic as possible. Luckly, Stripe has a tax system we can use. They unfortunately don't have built-in VAT support though, so we need to seed the data ourselves. Here's a quick script I made which takes an array of country codes and tax percentages and creates tax data on Stripe:

``` php
collect(['BE' => 21, 'BG' => 20, 'CZ' => 21, ...])->each(function ($percentage, $country) {
    \Stripe\TaxRate::create([
    	'display_name' => 'VAT',
      	'percentage' => $percentage,
      	'inclusive' => false,
      	'jurisdiction' => $country,
      	'description' => 'VAT for '.locale_get_display_region('-'.$country, 'en')
    ]);
});

```

Here's how the result looks in our Stripe dashboard:

![Implementing VAT into a Laravel app with Stripe and Cashier](https://miguelpiedrafita.com/content/images/2020/05/EYJvdcTWoAMO6eQ.jpeg)

With the data seeded into Stripe, we can now tell Checkout to apply a specific tax when creating a subscription. To make this easier, I've created a TaxRate model using [Caleb's Sushi package](https://github.com/calebporzio/sushi) that fetches the rates from Stripe.

``` php
<?php

namespace App\Models;

use Sushi\Sushi;
use Stripe\TaxRate as StripeRate;
use Illuminate\Database\Eloquent\Model;

class TaxRate extends Model
{
    use Sushi;

    public function getRows() : array
    {
        return collect(StripeRate::all(['limit' => 100])->data)->map(fn (StripeRate $rate) => [
            'stripe_id' => $rate->id,
            'country' => $rate->jurisdiction,
        ])->toArray();
    }
}

```

Then, we query the model for our tax rate and apply it to Checkout (notice the `default_tax_rates` key on the `subscription_data` array below).

``` php
public function checkout(Request $request)
    {
        $this->authorize('update', $user = $request->user());

        $request->validate([
            'plan' => ['required', 'string', 'in:monthly,yearly'],
            'coupon' => ['nullable', 'string'],
        ]);

        try {
            return with(\Stripe\Checkout\Session::create([
                'payment_method_types' => ['card'],
                'customer_email' => $user->email,
                'subscription_data' => [
                    'items' => [[ 'plan' => $request->input('plan')] ],
                    'coupon' => $request->input('coupon'),
                    'trial_period_days' => 7,
                    'default_tax_rates' => [TaxRate::whereCountry(getCountry())->firstOrFail()->stripe_id],
                ],
                'mode' => 'subscription',
                'client_reference_id' => $user->id,
                'success_url' => route('setup'),
                'cancel_url' => URL::previous(route('setup.billing')),
            ]), fn ($session) => $session->id);
        } catch (\Stripe\Exception\InvalidRequestException $e) {
            throw_unless($e->getStripeParam() == 'subscription_data[coupon]', $e);

            fail_validation('coupon', 'The specified coupon does not exist');
        }
    }

```

This will not only apply the tax to the subscription, but also show our customer a nice breakdown of the costs.

![Implementing VAT into a Laravel app with Stripe and Cashier](https://miguelpiedrafita.com/content/images/2020/05/EYJ3qgHWAAEIP6a.jpeg)

Let's now work on the VAT-ID logic. As we learned before, we don't need to apply any taxes to customers who provide a VAT-ID, but we still want to register their id for invoicing. Checkout doesn't have any way of registering tax exemptions for new customers, but we can work around this by applying the tax after the customer has started their subscription, where we are able to register the exemption. To pass data around, we can use the `metadata` attribute.

``` php
public function checkout(Request $request)
    {
        $this->authorize('update', $user = $request->user());

        $request->validate([
            'plan' => ['required', 'string', 'in:monthly,yearly'],
            'vat' => ['bail', 'nullable', 'string'],
            'coupon' => ['nullable', 'string'],
        ]);

        try {
            return with(\Stripe\Checkout\Session::create([
                'payment_method_types' => ['card'],
                'metadata' => [
                    'vat_id' => $request->input('vat'),
                    'taxrate' => $taxRate = isEu() ? TaxRate::whereCountry(getCountry())->firstOrFail()->stripe_id : null,
                ],
                'customer_email' => $user->email,
                'subscription_data' => [
                    'items' => [[ 'plan' => $request->input('plan')] ],
                    'coupon' => $request->input('coupon'),
                    'trial_period_days' => 7,
                    'default_tax_rates' => $taxRate && is_null($request->input('vat')) ? [$taxRate] : [],
                ],
                'mode' => 'subscription',
                'client_reference_id' => $user->id,
                'success_url' => route('setup'),
                'cancel_url' => URL::previous(route('setup.billing')),
            ]), fn ($session) => $session->id);
        } catch (\Stripe\Exception\InvalidRequestException $e) {
            throw_unless($e->getStripeParam() == 'subscription_data[coupon]', $e);

            fail_validation('coupon', 'The specified coupon does not exist');
        }
    }

```

Then, we update our webhook code to register the exemption and add the tax after the customer has completed the checkout process.

``` php
public function handleCheckoutSessionCompleted(array $payload)
    {
        $session = $payload['data']['object'];
        $team = Team::findOrFail($session['client_reference_id']);

        // Add the subscription to the database as seen above, removed for brevity

        if (! is_null($vatId = Arr::get($session, 'metadata.vat_id'))) {
            \Stripe\Customer::createTaxId($session['customer'], [
                ['type' => 'eu_vat', 'value' => $vatId]
            ]);

            \Stripe\Customer::update($session['customer'], [
                'tax_exempt' => 'reverse'
            ]);

            \Stripe\Subscription::update($session['subscription'], [
                'default_tax_rates' => [Arr::get($session, 'metadata.taxrate')
            ]]);
        }

        return $this->successMethod();
    }

```

Now we just need to ensure our customer's VAT-ID is not only valid but also exists. We can use [Danny Van Kooten's package](https://github.com/dannyvankooten/laravel-vat) to get this without any extra work on our part.

We won't need any special logic for Spanish users since the VAT-ID input is hidden for them, and so they'll always get taxed, ticking our last box on this VAT puzzle. Let's take a look at the result.

{% youtube kzvJKrdZL_w %}

Hope this was useful! This article [started as a Twitter thread](https://twitter.com/m1guelpf/status/1261653978422611970), you may want to [follow me](https://twitter.com/m1guelpf) there for more semi-live-coding and tips. And if you'd like to see me do something like this in video format, you can [subscribe to my YouTube channel](https://i.m1guelpf.me/youtube), planning to try producing more video content this summer. Have a great day!