Implementing VAT into a Laravel app with Stripe and Cashier

May 17, 20208 min read

I recently had to update the Sitesauce 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, 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:

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.

Here's a before and after comparison of the Sitesauce billing page (click to zoom)

You may now be wondering, where am I asking them for their credit card details? Instead of using Stripe 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 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:

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 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 on Spain.

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
$rates = Http::get('https://raw.githubusercontent.com/ibericode/vat-rates/master/vat-rates.json')->json();

$rates = collect($rates['items'])->map(fn ($rate) => $rate[0]['rates']['standard'])->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:

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 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.

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 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.

Hope this was useful! This article started as a Twitter thread, you may want to follow me 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, planning to try producing more video content this summer. Have a great day!

Enjoyed the article? Consider sharing it on Twitter so others can enjoy it too :)

Share on Twitter

Receive project updates, article drafts & thoughts on your inbox every saturday.

Subscribe