feat(tax): multi-purpose vouchers are sold without VAT

Add Product::TAX_CLASS_MULTI_PURPOSE_VOUCHER and TaxService::ratesFor($product, ...),
which applies no tax rate to a product whose tax class is untaxed at sale
(config shop.tax.untaxed_classes, default the multi-purpose voucher). Under EU
VAT (Directive Art. 30a/30b, DE § 3 Abs. 15 UStG) selling a multi-purpose
voucher is not a taxable supply; VAT is due when it is redeemed, on the full
price of what it pays for. rates() is unchanged; ratesFor() is additive.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Fabian @ Blax Software 2026-10-01 09:55:01 +02:00
parent 8aa727fb5e
commit 459b561567
4 changed files with 104 additions and 0 deletions

View File

@ -161,6 +161,10 @@ return [
static fn ($id) => $id !== '', static fn ($id) => $id !== '',
)), )),
'require' => (bool) env('SHOP_TAX_REQUIRE', false), '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 // API Routes configuration

View File

@ -96,6 +96,18 @@ class Product extends Model implements Purchasable, Cartable
{ {
use HasFactory, HasUuids, HasMetaTranslation, HasStocks, HasPrices, HasPricingStrategy, HasCategories, HasProductRelations, MayBePoolProduct, MayBeLoanableProduct, ChecksIfBooking; 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 = [ protected $fillable = [
'slug', 'slug',
'sku', '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 public function isVisible(): bool
{ {
if (!$this->is_visible || $this->status !== ProductStatus::PUBLISHED) { if (!$this->is_visible || $this->status !== ProductStatus::PUBLISHED) {

View File

@ -5,6 +5,7 @@ declare(strict_types=1);
namespace Blax\Shop\Services; namespace Blax\Shop\Services;
use Blax\Shop\Exceptions\TaxRateNotConfiguredException; use Blax\Shop\Exceptions\TaxRateNotConfiguredException;
use Blax\Shop\Models\Product;
/** /**
* Single source of truth for the tax rate(s) applied to taxable charges and * 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. * policy that turns them into the array Stripe expects.
* - WHICH rate(s) apply: `config('shop.tax.rates')`, or an explicit list. * - WHICH rate(s) apply: `config('shop.tax.rates')`, or an explicit list.
* - WHETHER this customer is exempt (reverse-charge / zero-rated): `$exempt`. * - 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 * 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 * rejects duplicates with "You cannot attach more than one of the same tax
@ -49,4 +52,39 @@ class TaxService
return $resolved; 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<int, string|null>|null $rates Explicit rate ids; defaults to config('shop.tax.rates').
* @return array<int, string>
*
* @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);
}
} }

View File

@ -5,6 +5,7 @@ declare(strict_types=1);
namespace Blax\Shop\Tests\Unit; namespace Blax\Shop\Tests\Unit;
use Blax\Shop\Exceptions\TaxRateNotConfiguredException; use Blax\Shop\Exceptions\TaxRateNotConfiguredException;
use Blax\Shop\Models\Product;
use Blax\Shop\Services\TaxService; use Blax\Shop\Services\TaxService;
use Blax\Shop\Tests\TestCase; use Blax\Shop\Tests\TestCase;
use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\Attributes\Test;
@ -65,4 +66,47 @@ class TaxServiceTest extends TestCase
TaxService::rates(false, ['txr_a', '', null, 0, false, 'txr_b']), 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])));
}
} }