diff --git a/config/shop.php b/config/shop.php index 6ac97f7..c845d45 100644 --- a/config/shop.php +++ b/config/shop.php @@ -161,6 +161,10 @@ return [ static fn ($id) => $id !== '', )), 'require' => (bool) env('SHOP_TAX_REQUIRE', false), + // Product tax classes whose SALE carries no VAT (TaxService::ratesFor): a + // multi-purpose voucher is taxed when redeemed, not when sold (EU VAT + // Directive Art. 30b, DE § 3 Abs. 15 UStG). See Product::TAX_CLASS_*. + 'untaxed_classes' => ['multi-purpose-voucher'], ], // API Routes configuration diff --git a/src/Models/Product.php b/src/Models/Product.php index 08d0e7f..cfff670 100644 --- a/src/Models/Product.php +++ b/src/Models/Product.php @@ -96,6 +96,18 @@ class Product extends Model implements Purchasable, Cartable { use HasFactory, HasUuids, HasMetaTranslation, HasStocks, HasPrices, HasPricingStrategy, HasCategories, HasProductRelations, MayBePoolProduct, MayBeLoanableProduct, ChecksIfBooking; + /** + * Tax class of a multi-purpose voucher: a voucher (gift card, store credit) that + * pays for any product. Under EU VAT (Directive 2006/112/EC Art. 30a/30b; in + * Germany § 3 Abs. 15 UStG) selling it is not a taxable supply: VAT is due only + * when it is redeemed, on the full price of what it pays for, because the voucher + * is a means of payment there, not a discount. So no tax rate applies when it is + * sold ({@see \Blax\Shop\Services\TaxService::ratesFor()}). A single-purpose + * voucher (one product, VAT known at sale) is taxed at sale like the product and + * keeps its normal tax class. + */ + public const TAX_CLASS_MULTI_PURPOSE_VOUCHER = 'multi-purpose-voucher'; + protected $fillable = [ 'slug', 'sku', @@ -545,6 +557,12 @@ class Product extends Model implements Purchasable, Cartable }); } + /** A multi-purpose voucher: sold without VAT, taxed when redeemed ({@see TAX_CLASS_MULTI_PURPOSE_VOUCHER}). */ + public function isMultiPurposeVoucher(): bool + { + return $this->tax_class === self::TAX_CLASS_MULTI_PURPOSE_VOUCHER; + } + public function isVisible(): bool { if (!$this->is_visible || $this->status !== ProductStatus::PUBLISHED) { diff --git a/src/Services/TaxService.php b/src/Services/TaxService.php index 1bc1080..12bbae9 100644 --- a/src/Services/TaxService.php +++ b/src/Services/TaxService.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace Blax\Shop\Services; use Blax\Shop\Exceptions\TaxRateNotConfiguredException; +use Blax\Shop\Models\Product; /** * Single source of truth for the tax rate(s) applied to taxable charges and @@ -15,6 +16,8 @@ use Blax\Shop\Exceptions\TaxRateNotConfiguredException; * 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`. + * Per product, a tax class can be untaxed at sale (a multi-purpose voucher): + * ratesFor() gives such a line no rate. * * 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 @@ -49,4 +52,39 @@ class TaxService return $resolved; } + + /** + * The tax rate(s) for a charge of one product: none when the product's tax class + * is not taxed at sale (see untaxedAtSale(), e.g. a multi-purpose voucher), else + * the same as rates(). Use this on every line item so a voucher sold next to + * taxed products in one checkout still carries no VAT. + * + * @param Product|null $product null = no product context, same as rates(). + * @param bool $exempt True for reverse-charge / zero-rated customers → no rate. + * @param array|null $rates Explicit rate ids; defaults to config('shop.tax.rates'). + * @return array + * + * @throws TaxRateNotConfiguredException As rates(), for a taxable product. + */ + public static function ratesFor(?Product $product, bool $exempt = false, ?array $rates = null): array + { + if ($product && self::untaxedAtSale($product)) { + return []; + } + + return self::rates($exempt, $rates); + } + + /** + * True when selling this product is not a taxable supply, by its tax class: + * config('shop.tax.untaxed_classes'), by default the multi-purpose voucher + * ({@see Product::TAX_CLASS_MULTI_PURPOSE_VOUCHER}). VAT on such a product is + * charged later, when what it pays for is sold. + */ + public static function untaxedAtSale(Product $product): bool + { + $classes = (array) config('shop.tax.untaxed_classes', [Product::TAX_CLASS_MULTI_PURPOSE_VOUCHER]); + + return $product->tax_class !== null && in_array($product->tax_class, $classes, true); + } } diff --git a/tests/Unit/TaxServiceTest.php b/tests/Unit/TaxServiceTest.php index ebb31d9..bcef841 100644 --- a/tests/Unit/TaxServiceTest.php +++ b/tests/Unit/TaxServiceTest.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace Blax\Shop\Tests\Unit; use Blax\Shop\Exceptions\TaxRateNotConfiguredException; +use Blax\Shop\Models\Product; use Blax\Shop\Services\TaxService; use Blax\Shop\Tests\TestCase; use PHPUnit\Framework\Attributes\Test; @@ -65,4 +66,47 @@ class TaxServiceTest extends TestCase TaxService::rates(false, ['txr_a', '', null, 0, false, 'txr_b']), ); } + + #[Test] + public function a_multi_purpose_voucher_is_sold_without_a_rate(): void + { + // Its sale is not a taxable supply; VAT is charged when it is redeemed. + config(['shop.tax.rates' => ['txr_19'], 'shop.tax.require' => true]); + $voucher = new Product(['tax_class' => Product::TAX_CLASS_MULTI_PURPOSE_VOUCHER]); + + $this->assertTrue($voucher->isMultiPurposeVoucher()); + $this->assertTrue(TaxService::untaxedAtSale($voucher)); + $this->assertSame([], TaxService::ratesFor($voucher)); + $this->assertSame([], TaxService::ratesFor($voucher, false, ['txr_19'])); + } + + #[Test] + public function a_taxable_product_gets_the_same_rates_as_rates(): void + { + config(['shop.tax.rates' => ['txr_19']]); + $course = new Product(['tax_class' => 'standard']); + + $this->assertFalse($course->isMultiPurposeVoucher()); + $this->assertSame(['txr_19'], TaxService::ratesFor($course)); + $this->assertSame(['txr_x'], TaxService::ratesFor($course, false, ['txr_x'])); + $this->assertSame(['txr_19'], TaxService::ratesFor(new Product())); + $this->assertSame(['txr_19'], TaxService::ratesFor(null)); + } + + #[Test] + public function an_exempt_customer_stays_exempt_for_a_taxable_product(): void + { + config(['shop.tax.rates' => ['txr_19']]); + + $this->assertSame([], TaxService::ratesFor(new Product(['tax_class' => 'standard']), true)); + } + + #[Test] + public function the_untaxed_classes_come_from_config(): void + { + config(['shop.tax.rates' => ['txr_19'], 'shop.tax.untaxed_classes' => ['gift-card']]); + + $this->assertSame([], TaxService::ratesFor(new Product(['tax_class' => 'gift-card']))); + $this->assertSame(['txr_19'], TaxService::ratesFor(new Product(['tax_class' => Product::TAX_CLASS_MULTI_PURPOSE_VOUCHER]))); + } }