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']), + ); + } +}