laravel / cashier-stripe

Laravel Cashier provides an expressive, fluent interface to Stripe's subscription billing services.
https://laravel.com/docs/billing
MIT License
2.38k stars 679 forks source link

add support for VAT ID on Billable #841

Closed lsmith77 closed 4 years ago

lsmith77 commented 4 years ago

related to https://github.com/laravel/cashier/pull/830

Stripe supports setting customer Tax IDs https://stripe.com/docs/billing/taxes/tax-ids This is useful for the following purposes:

It is a MUST to set the Tax ID for B2B cases. Therefore it IMHO would make sense to add native support into the Billable class (less code to write, automate setting the tax exemption status) and provides a clear API that packages like https://github.com/mpociot/vat-calculator can target to add additional features.

Here is some code I have added in my current project to make it work without cashier support. It would of course be nice to support more different country formats of course.

try {
    if ($country !== substr($vat_id, 0, 2)) {
        throw new \Exception('Country does not match VAT ID');
    }

    Stripe::setApiKey(config('services.stripe.secret'));

    /** @var TaxId[] $taxIds */
    $taxIds = Customer::allTaxIds(
        $billable->stripe_id,
        ['limit' => 3]
    );

    $vatIdChanged = true;
    foreach ($taxIds as $taxId) {
        if ($taxId->value !== $vat_id) {
            Customer::deleteTaxId(
                $billable->stripe_id,
                $taxId->id
            );
        } else {
            $vatIdChanged = false;
        }
    }

    if ($vatIdChanged) {
        Customer::createTaxId(
            $billable->stripe_id,
            [
                'type' => $country === 'CH' ? 'ch_vat' : 'eu_vat',
                'value' => $vat_id
            ]
        );
    }

    if ($user->country !== Spark::homeCountry()) {
        $stripeCustomer->tax_exempt = 'exempt';
    } else {
        $stripeCustomer->tax_exempt = 'none';
    }

     $stripeCustomer->save();
} catch (\Stripe\Error\InvalidRequest | \Stripe\Exception\InvalidRequest | \Exception $e) {
    throw ValidationException::withMessages([
        'vat_id' => Lang::get('validation.vat_id'),
    ]);
}

FYI the Stripe Tax ID API is not very mature yet, ie. its not possible to fetch a Tax ID object by VAT ID and more importantly it is not possible to update a Tax ID status, which is actually quite critical, since the company status needs to be validated regularly, to for example determine if the customer is in fact tax exempt or not.

I created a simple command to validate the Tax ID status over night, since this depends on government API's that are not really reliable:

<?php

namespace App\Console\Commands;

use App\User;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Mail;
use Laravel\Spark\Spark;
use Mpociot\VatCalculator\Exceptions\VATCheckUnavailableException;
use Mpociot\VatCalculator\VatCalculator;
use Stripe\Customer;
use Stripe\Stripe;
use Stripe\TaxId;

class ValidateVAT extends Command
{
    /**
     * The name and signature of the console command.
     *
     * @var string
     */
    protected $signature = 'validate:vat {--dump=}';

    /**
     * The console command description.
     *
     * @var string
     */
    protected $description = 'Validate VAT ID of all customers along with their address';

    /**
     * Execute the console command.
     *
     * @return mixed
     */
    public function handle()
    {
        $vatCalculator = new VatCalculator();
        $vatCalculator->setBusinessCountryCode(Spark::homeCountry());

        $users = User::query()
            ->whereNotNull('vat_id')
            ->where('billing_country', '!=', Spark::homeCountry())
            ->whereNotNull('stripe_id')
            ->get();

        $userCount = count($users);
        $this->info("Read $userCount users VAT ID");

        Stripe::setApiKey(config('services.stripe.secret'));

        $errorCount = 0;
        $validations = [];
        foreach ($users as $user) {
            try {
                $errors = [];
                $stripeTaxId = null;

                $details = $vatCalculator->getVATDetails($user->vat_id);

                if (!$details || !$details->valid) {
                    $errors[] = "VAT '{$user->vat_id}' not valid";
                } else {
                    if ($details->countryCode !== $user->billing_country) {
                        $errors[] = "Billing country expected '{$details->countryCode}'";
                    }
                    if ($details->name !== strtoupper($user->billing_company)) {
                        $errors[] = "Billing company expected '{$details->name}'";
                    }
                }

                try {
                    $taxIds = Customer::allTaxIds(
                        $user->stripe_id,
                        ['limit' => 3]
                    );

                    foreach ($taxIds as $taxId) {
                        if ($taxId->value === $user->vat_id) {
                            $stripeTaxId = $taxId;
                            break;
                        }
                    }
                } catch (\Stripe\Error\InvalidRequest | \Stripe\Exception\InvalidRequest $e) {
                    $errors[] = "Unable to find VAT on stripe for stripe customer '{$user->stripe_id}'";
                }

                $result = [
                    "Validated VAT ID '{$user->vat_id}':",
                    config('app.url')."/spark/kiosk#/users/".$user->id,
                    "country '{$user->billing_country}' and company name '{$user->billing_company}'",
                ];

                if (empty($errors)) {
                    $result[] = 'Valid';

                    $stripeVerification = [
                        'status' => TaxId::VERIFICATION_STATUS_VERIFIED,
                        'verified_name' => true,
                    ];
                } else {
                    ++$errorCount;

                    $stripeVerification = [
                        'status' => TaxId::VERIFICATION_STATUS_UNVERIFIED,
                        'verified_name' => null,
                    ];

                    $result = array_merge($result, $errors);
                }

                $validations[] = implode("\n", $result);
            } catch(VATCheckUnavailableException $e ){
                ++$errorCount;

                $stripeVerification = [
                    'status' => TaxId::VERIFICATION_STATUS_UNAVAILABLE,
                    'verified_name' => null,
                ];

                $validations[] = 'Validation service failed';
            }

            try {
                if ($stripeTaxId
                    && $stripeTaxId->verification['status'] !== $stripeVerification['status']
                ) {
                    Customer::deleteTaxId(
                        $user->stripe_id,
                        $taxId->id
                    );

                    Customer::createTaxId(
                        $user->stripe_id,
                        [
                            'type' => 'eu_vat',
                            'value' => $user->vat_id,
                            'verification' => $stripeVerification,
                        ]
                    );
                }
            } catch (\Stripe\Error\InvalidRequest | \Stripe\Exception\InvalidRequest $e) {
            }
        }

        $message = implode("\n\n", $validations);

        $dump = $this->option('dump');
        if ($dump) {
            $this->info($message);
            return;
        }

        $data = [
            'from' => Spark::supportAddress(),
            'subject' => "VAT Report with $errorCount Errors founds",
            'message' => $message,
        ];

        Mail::raw($data['message'], function ($m) use ($data) {
            $m->to(Spark::supportAddress())->subject(__('Publish Request: ').$data['subject']);

            $m->replyTo($data['from']);
        });

        $this->info("Massage send to: ".Spark::supportAddress());
    }
}
lsmith77 commented 4 years ago

@mpociot FYI ^

driesvints commented 4 years ago

Thanks, I'll try to have a look at this when I'm back from vacation in two weeks.

driesvints commented 4 years ago

@lsmith77 I only see CRUD operations in your examples and no real other use cases. There's lots of other CRUD operations that aren't directly integrated into Cashier and which you can simply use the Stripe SDK for. So I don't think it's necessary to add anything to Cashier as most of it will only be minimal syntactic sugar on top of the Stripe SDK.

I've btw already added methods to the Billable trait to easily determine if a customer is tax exempt or not.

lsmith77 commented 4 years ago

FYI Stripe now supports validation for some countries. If the status changes there is a webhook that is triggered https://stripe.com/docs/billing/taxes/tax-ids?lang=php#validation