feat(tax): configurable TaxService applied across every checkout path

Blax\Shop\Services\TaxService::rates($exempt, $rates=null) is one source of
truth that turns "which rate(s)" (config shop.tax.rates or an explicit list)
and "is this customer exempt" into the array Stripe expects. It fails loud
(TaxRateNotConfiguredException) when shop.tax.require is set and no rate is
configured for a non-exempt charge, instead of silently billing 0% VAT.

New config('shop.tax') block (SHOP_TAX_RATES / SHOP_TAX_REQUIRE). Unit tested.

Consumers (learn-atc #1509) delegate getApplicableTaxRates() here so no path
can apply the rate twice (Stripe rejects duplicate tax rates) or drop VAT to 0%.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Fabian @ Blax Software 2026-08-21 08:23:56 +02:00
parent b154d8fea2
commit f10d5c2f04
4 changed files with 157 additions and 0 deletions

View File

@ -95,6 +95,27 @@ return [
'revoked_event' => 'seat.revoked',
],
/*
* Tax rates applied to taxable charges and subscriptions.
*
* TaxService::rates() is the single source of truth for the applied VAT
* rate on every checkout path. Point `rates` at your Stripe tax-rate id(s)
* (e.g. 'txr_...' for 19% German VAT) via SHOP_TAX_RATES, either one id or
* a comma-separated list. The host app decides tax-exemption (reverse-charge
* / zero-rated) and passes it to TaxService::rates($exempt); a repo may also
* hand rates() an explicit list instead of reading this config.
*
* Set SHOP_TAX_REQUIRE=true in production once a rate is configured: a
* non-exempt charge with no rate then throws instead of silently billing 0%.
*/
'tax' => [
'rates' => array_values(array_filter(
array_map('trim', explode(',', (string) env('SHOP_TAX_RATES', ''))),
static fn ($id) => $id !== '',
)),
'require' => (bool) env('SHOP_TAX_REQUIRE', false),
],
// API Routes configuration
'routes' => [
'enabled' => true,

View File

@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace Blax\Shop\Exceptions;
use Exception;
class TaxRateNotConfiguredException extends Exception
{
public function __construct(
string $message = 'No tax rate configured (config shop.tax.rates is empty) while shop.tax.require is enabled; refusing to bill a non-exempt charge at 0% tax.'
) {
parent::__construct($message);
}
}

View File

@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
namespace Blax\Shop\Services;
use Blax\Shop\Exceptions\TaxRateNotConfiguredException;
/**
* Single source of truth for the tax rate(s) applied to taxable charges and
* subscriptions across every checkout path (Cashier subscriptions, raw Stripe
* Checkout Sessions, invoice items).
*
* The host app owns two decisions and passes them in; this service owns the
* policy that turns them into the array Stripe expects.
* - WHICH rate(s) apply: `config('shop.tax.rates')`, or an explicit list.
* - WHETHER this customer is exempt (reverse-charge / zero-rated): `$exempt`.
*
* Keeping the policy in one place stops a repo applying the rate twice (Stripe
* rejects duplicates with "You cannot attach more than one of the same tax
* rate") or silently billing 0% VAT. When a rate is required but none is
* configured for a non-exempt customer, this throws.
*/
class TaxService
{
/**
* Resolve the Stripe tax-rate ids to apply.
*
* @param bool $exempt True for reverse-charge / zero-rated customers no rate.
* @param array<int, string|null>|null $rates Explicit rate ids; defaults to config('shop.tax.rates').
* @return array<int, string> Stripe tax-rate ids (e.g. ['txr_...']), or [] when exempt.
*
* @throws TaxRateNotConfiguredException When non-exempt, no rate resolved, and config('shop.tax.require') is true.
*/
public static function rates(bool $exempt = false, ?array $rates = null): array
{
if ($exempt) {
return [];
}
$resolved = array_values(array_filter(
$rates ?? (array) config('shop.tax.rates', []),
static fn ($id): bool => is_string($id) && $id !== '',
));
if ($resolved === [] && config('shop.tax.require', false)) {
throw new TaxRateNotConfiguredException();
}
return $resolved;
}
}

View File

@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
namespace Blax\Shop\Tests\Unit;
use Blax\Shop\Exceptions\TaxRateNotConfiguredException;
use Blax\Shop\Services\TaxService;
use Blax\Shop\Tests\TestCase;
use PHPUnit\Framework\Attributes\Test;
class TaxServiceTest extends TestCase
{
#[Test]
public function it_returns_the_configured_rates_for_a_non_exempt_customer(): void
{
config(['shop.tax.rates' => ['txr_19']]);
$this->assertSame(['txr_19'], TaxService::rates(false));
}
#[Test]
public function an_explicit_rate_list_overrides_config(): void
{
config(['shop.tax.rates' => ['txr_from_config']]);
$this->assertSame(['txr_explicit'], TaxService::rates(false, ['txr_explicit']));
}
#[Test]
public function an_exempt_customer_gets_no_rate_even_when_one_is_configured(): void
{
config(['shop.tax.rates' => ['txr_19'], 'shop.tax.require' => true]);
// Reverse charge / zero-rated short-circuits before the require guard.
$this->assertSame([], TaxService::rates(true));
$this->assertSame([], TaxService::rates(true, ['txr_19']));
}
#[Test]
public function empty_rates_return_empty_when_not_required(): void
{
config(['shop.tax.rates' => [], 'shop.tax.require' => false]);
$this->assertSame([], TaxService::rates(false));
}
#[Test]
public function empty_rates_throw_when_required(): void
{
config(['shop.tax.rates' => [], 'shop.tax.require' => true]);
$this->expectException(TaxRateNotConfiguredException::class);
TaxService::rates(false);
}
#[Test]
public function it_filters_blanks_and_non_strings_and_reindexes(): void
{
// null / '' / non-string entries are dropped; keys are reindexed so the
// result is a clean list Stripe accepts (a gappy array would 400).
$this->assertSame(
['txr_a', 'txr_b'],
TaxService::rates(false, ['txr_a', '', null, 0, false, 'txr_b']),
);
}
}