From f10d5c2f047c5c8f12db2022d6804940de812233 Mon Sep 17 00:00:00 2001 From: "Fabian @ Blax Software" Date: Fri, 21 Aug 2026 08:23:56 +0200 Subject: [PATCH] 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) --- config/shop.php | 21 ++++++ .../TaxRateNotConfiguredException.php | 16 +++++ src/Services/TaxService.php | 52 ++++++++++++++ tests/Unit/TaxServiceTest.php | 68 +++++++++++++++++++ 4 files changed, 157 insertions(+) create mode 100644 src/Exceptions/TaxRateNotConfiguredException.php create mode 100644 src/Services/TaxService.php create mode 100644 tests/Unit/TaxServiceTest.php diff --git a/config/shop.php b/config/shop.php index 1997085..0d22487 100644 --- a/config/shop.php +++ b/config/shop.php @@ -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, diff --git a/src/Exceptions/TaxRateNotConfiguredException.php b/src/Exceptions/TaxRateNotConfiguredException.php new file mode 100644 index 0000000..a88788b --- /dev/null +++ b/src/Exceptions/TaxRateNotConfiguredException.php @@ -0,0 +1,16 @@ +|null $rates Explicit rate ids; defaults to config('shop.tax.rates'). + * @return array 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; + } +} diff --git a/tests/Unit/TaxServiceTest.php b/tests/Unit/TaxServiceTest.php new file mode 100644 index 0000000..ebb31d9 --- /dev/null +++ b/tests/Unit/TaxServiceTest.php @@ -0,0 +1,68 @@ + ['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']), + ); + } +}