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:
parent
8aa727fb5e
commit
459b561567
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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])));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue