diff --git a/packages/core/database/migrations/2026_04_25_100000_add_tax_zone_id_to_carts_table.php b/packages/core/database/migrations/2026_04_25_100000_add_tax_zone_id_to_carts_table.php new file mode 100644 index 0000000000..e6be248ae4 --- /dev/null +++ b/packages/core/database/migrations/2026_04_25_100000_add_tax_zone_id_to_carts_table.php @@ -0,0 +1,28 @@ +prefix.'carts', function (Blueprint $table) { + $table->foreignId('tax_zone_id')->after('channel_id') + ->nullable() + ->constrained($this->prefix.'tax_zones') + ->nullOnDelete(); + }); + } + + public function down(): void + { + Schema::table($this->prefix.'carts', function (Blueprint $table) { + if ($this->canDropForeignKeys()) { + $table->dropForeign(['tax_zone_id']); + } + $table->dropColumn('tax_zone_id'); + }); + } +}; diff --git a/packages/core/src/Actions/Carts/CalculateLineSubtotal.php b/packages/core/src/Actions/Carts/CalculateLineSubtotal.php index ca92eda51b..aa0a05c5b0 100644 --- a/packages/core/src/Actions/Carts/CalculateLineSubtotal.php +++ b/packages/core/src/Actions/Carts/CalculateLineSubtotal.php @@ -45,7 +45,7 @@ public function execute( ); $priceInclTax = new Price( - $priceResponse->matched->priceIncTax()->value, + $priceResponse->matched->priceIncTax($cart->taxZone)->value, $cart->currency, $purchasable->getUnitQuantity() ); diff --git a/packages/core/src/Base/TaxDriver.php b/packages/core/src/Base/TaxDriver.php index 3ce5af7417..8cb47da08d 100644 --- a/packages/core/src/Base/TaxDriver.php +++ b/packages/core/src/Base/TaxDriver.php @@ -5,6 +5,7 @@ use Lunar\Base\ValueObjects\Cart\TaxBreakdown; use Lunar\Models\Contracts\CartLine; use Lunar\Models\Contracts\Currency; +use Lunar\Models\Contracts\TaxZone; interface TaxDriver { @@ -33,6 +34,15 @@ public function setPurchasable(Purchasable $purchasable): self; */ public function setCartLine(CartLine $cartLine): self; + /** + * Set a tax zone override. + * + * When provided, this zone is used directly instead of resolving one from the shipping address, allowing + * the developer to handle cases like taxation on IP address basis. Just set the tax zone as and let it + * flow smoothly. + */ + public function setTaxZone(?TaxZone $taxZone = null): self; + /** * Return the tax breakdown from a given sub total. * diff --git a/packages/core/src/Drivers/SystemTaxDriver.php b/packages/core/src/Drivers/SystemTaxDriver.php index c29bf649ca..2eefa7b186 100644 --- a/packages/core/src/Drivers/SystemTaxDriver.php +++ b/packages/core/src/Drivers/SystemTaxDriver.php @@ -11,6 +11,7 @@ use Lunar\DataTypes\Price; use Lunar\Models\Contracts\CartLine; use Lunar\Models\Contracts\Currency; +use Lunar\Models\Contracts\TaxZone as TaxZoneContract; use Lunar\Models\TaxZone; use Spatie\LaravelBlink\BlinkFacade as Blink; @@ -41,6 +42,12 @@ class SystemTaxDriver implements TaxDriver */ protected ?CartLine $cartLine = null; + /** + * An optional tax zone override supplied at the cart level. + * When set this takes precedence over the address-derived zone. + */ + protected ?TaxZoneContract $taxZone = null; + /** * {@inheritDoc} */ @@ -91,12 +98,22 @@ public function setCartLine(CartLine $cartLine): self return $this; } + /** + * {@inheritDoc} + */ + public function setTaxZone(?TaxZoneContract $taxZone = null): self + { + $this->taxZone = $taxZone; + + return $this; + } + /** * {@inheritDoc} */ public function getBreakdown($subTotal): TaxBreakdown { - $taxZone = app(GetTaxZone::class)->execute($this->shippingAddress); + $taxZone = $this->taxZone ?? app(GetTaxZone::class)->execute($this->shippingAddress); $taxClass = $this->purchasable->getTaxClass(); $taxAmounts = Blink::once('tax_zone_rates_'.$taxZone->id.'_'.$taxClass->id, function () use ($taxClass, $taxZone) { diff --git a/packages/core/src/Models/Cart.php b/packages/core/src/Models/Cart.php index e562fd76df..f5197d1200 100644 --- a/packages/core/src/Models/Cart.php +++ b/packages/core/src/Models/Cart.php @@ -42,6 +42,7 @@ use Lunar\Exceptions\FingerprintMismatchException; use Lunar\Facades\DB; use Lunar\Facades\ShippingManifest; +use Lunar\Models\Contracts\TaxZone as TaxZoneContract; use Lunar\Pipelines\Cart\Calculate; use Lunar\Validation\Cart\ValidateCartForOrderCreation; use Lunar\Validation\CartLine\CartLineStock; @@ -53,6 +54,7 @@ * @property ?int $merged_id * @property int $currency_id * @property int $channel_id + * @property ?int $tax_zone_id * @property ?int $order_id * @property ?string $coupon_code * @property ?Carbon $completed_at @@ -230,6 +232,11 @@ public function customer(): BelongsTo return $this->belongsTo(Customer::modelClass()); } + public function taxZone(): BelongsTo + { + return $this->belongsTo(TaxZone::modelClass()); + } + public function scopeUnmerged(Builder $query): Builder { return $query->whereNull('merged_id'); @@ -493,8 +500,12 @@ public function addAddress(array|Addressable $address, string $type, bool $refre ->then(fn () => $refresh ? $this->refresh()->recalculate() : $this); } - public function setShippingAddress(array|Addressable $address): Cart + public function setShippingAddress(array|Addressable $address, bool $clearTaxZone = true): Cart { + if ($clearTaxZone && $this->tax_zone_id) { + $this->taxZone()->dissociate()->save(); + } + return $this->addAddress($address, 'shipping'); } @@ -622,4 +633,28 @@ public function getEstimatedShipping(array $params, bool $setOverride = false): return $option; } + + /** + * Set the tax zone override for this cart. + * + * When set, all tax calculations use this zone instead of resolving one from the shipping address. + * Pass null to clear the override and fall back to the address-derived (or default) zone. + * Pass `$refresh = false` to skip persistence and recalculation (useful for previewing without writing). + */ + public function setTaxZone(?TaxZoneContract $taxZone, bool $refresh = true): Cart + { + if ($taxZone) { + $this->taxZone()->associate($taxZone); + } else { + $this->taxZone()->dissociate(); + } + + if (! $refresh) { + return $this; + } + + $this->save(); + + return $this->refresh()->recalculate(); + } } diff --git a/packages/core/src/Models/CartLine.php b/packages/core/src/Models/CartLine.php index 4571e70e8f..f50e026cc4 100644 --- a/packages/core/src/Models/CartLine.php +++ b/packages/core/src/Models/CartLine.php @@ -41,6 +41,7 @@ class CartLine extends BaseModel implements Contracts\CartLine */ public $cachableProperties = [ 'unitPrice', + 'unitPriceInclTax', 'subTotal', 'discountTotal', 'taxAmount', diff --git a/packages/core/src/Models/Contracts/Cart.php b/packages/core/src/Models/Contracts/Cart.php index 04ef4578d0..1528134243 100644 --- a/packages/core/src/Models/Contracts/Cart.php +++ b/packages/core/src/Models/Contracts/Cart.php @@ -139,8 +139,12 @@ public function addAddress(array|Addressable $address, string $type, bool $refre /** * Set the shipping address. + * + * By default any tax-zone override set on the cart is cleared, since + * the address is now the authoritative source for tax-zone resolution. + * Pass `$clearTaxZone = false` to keep a previously-pinned zone. */ - public function setShippingAddress(array|Addressable $address): \Lunar\Models\Cart; + public function setShippingAddress(array|Addressable $address, bool $clearTaxZone = true): \Lunar\Models\Cart; /** * Set the billing address. @@ -188,4 +192,13 @@ public function checkFingerprint(string $fingerprint): bool; * Return the estimated shipping cost for a cart. */ public function getEstimatedShipping(array $params, bool $setOverride = false): ?ShippingOption; + + /** + * Set the tax zone override for this cart. + * + * When set, all tax calculations use this zone instead of resolving one + * from the shipping address. Pass null to clear the override. + * Pass `$refresh = false` to skip persistence and recalculation. + */ + public function setTaxZone(?TaxZone $taxZone, bool $refresh = true): \Lunar\Models\Cart; } diff --git a/packages/core/src/Models/Contracts/Price.php b/packages/core/src/Models/Contracts/Price.php index 38781074c2..49ea35daa8 100644 --- a/packages/core/src/Models/Contracts/Price.php +++ b/packages/core/src/Models/Contracts/Price.php @@ -4,6 +4,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\MorphTo; +use Lunar\Models\Contracts\TaxZone; interface Price { @@ -24,11 +25,22 @@ public function customerGroup(): BelongsTo; /** * Return the price exclusive of tax. + * + * @param TaxZone|null $taxZone */ - public function priceExTax(): \Lunar\DataTypes\Price; + public function priceExTax(?TaxZone $taxZone = null): \Lunar\DataTypes\Price; /** * Return the price inclusive of tax. + * + * @param TaxZone|null $taxZone */ - public function priceIncTax(): int|\Lunar\DataTypes\Price; + public function priceIncTax(?TaxZone $taxZone = null): int|\Lunar\DataTypes\Price; + + /** + * Return the compare price inclusive of tax. + * + * @param TaxZone|null $taxZone + */ + public function comparePriceIncTax(?TaxZone $taxZone = null): int|\Lunar\DataTypes\Price; } diff --git a/packages/core/src/Models/Price.php b/packages/core/src/Models/Price.php index 76e960cbb7..c6e1e286e2 100644 --- a/packages/core/src/Models/Price.php +++ b/packages/core/src/Models/Price.php @@ -10,6 +10,7 @@ use Lunar\Base\Casts\Price as CastsPrice; use Lunar\Base\Traits\HasMacros; use Lunar\Database\Factories\PriceFactory; +use Lunar\Models\Contracts\TaxZone as TaxZoneContract; use Spatie\LaravelBlink\BlinkFacade as Blink; /** @@ -76,8 +77,10 @@ public function customerGroup(): BelongsTo /** * Return the price exclusive of tax. + * + * @param TaxZone|null $taxZone Optional override for the tax zone. Falls back to the store's default zone. */ - public function priceExTax(): \Lunar\DataTypes\Price + public function priceExTax(?TaxZoneContract $taxZone = null): \Lunar\DataTypes\Price { if (! prices_inc_tax()) { return $this->price; @@ -85,50 +88,60 @@ public function priceExTax(): \Lunar\DataTypes\Price $priceExTax = clone $this->price; - $priceExTax->value = (int) round($priceExTax->value / (1 + $this->getPriceableTaxRate())); + $priceExTax->value = (int) round($priceExTax->value / (1 + $this->getPriceableTaxRate($taxZone))); return $priceExTax; } /** * Return the price inclusive of tax. + * + * @param TaxZone|null $taxZone Optional override for the tax zone. */ - public function priceIncTax(): int|\Lunar\DataTypes\Price + public function priceIncTax(?TaxZoneContract $taxZone = null): int|\Lunar\DataTypes\Price { if (prices_inc_tax()) { return $this->price; } $priceIncTax = clone $this->price; - $priceIncTax->value = (int) round($priceIncTax->value * (1 + $this->getPriceableTaxRate())); + $priceIncTax->value = (int) round($priceIncTax->value * (1 + $this->getPriceableTaxRate($taxZone))); return $priceIncTax; } /** * Return the compare price inclusive of tax. + * + * @param TaxZone|null $taxZone Optional override for the tax zone. */ - public function comparePriceIncTax(): int|\Lunar\DataTypes\Price + public function comparePriceIncTax(?TaxZoneContract $taxZone = null): int|\Lunar\DataTypes\Price { if (prices_inc_tax()) { return $this->compare_price; } $comparePriceIncTax = clone $this->compare_price; - $comparePriceIncTax->value = (int) round($comparePriceIncTax->value * (1 + $this->getPriceableTaxRate())); + $comparePriceIncTax->value = (int) round($comparePriceIncTax->value * (1 + $this->getPriceableTaxRate($taxZone))); return $comparePriceIncTax; } /** - * Return the total tax rate amount within the predefined tax zone for the related priceable + * Return the total tax rate (as a decimal, e.g. 0.20 = 20%) for the given tax zone + * combined with the priceable's own tax class. + * + * Tax zone resolution: explicit param → store default zone. + * Results are memoised per "{classId}_{zoneId}" so unrelated combinations never collide. */ - protected function getPriceableTaxRate(): int|float + protected function getPriceableTaxRate(?TaxZoneContract $taxZone = null): int|float { - return Blink::once('price_tax_rate_'.$this->priceable->getTaxClass()->id, function () { - $taxZone = TaxZone::where('default', '=', 1)->first(); + $taxClass = $this->priceable->getTaxClass(); + $taxZone ??= Blink::once('lunar_default_tax_zone', fn () => TaxZone::where('default', '=', 1)->first()); + $cacheKey = 'price_tax_rate_'.$taxClass->id.'_'.($taxZone?->id ?? 'none'); - if ($taxZone && ! is_null($taxClass = $this->priceable->getTaxClass())) { + return Blink::once($cacheKey, function () use ($taxClass, $taxZone) { + if ($taxZone && $taxClass) { return $taxClass->taxRateAmounts ->whereIn('tax_rate_id', $taxZone->taxRates->pluck('id')) ->sum('percentage') / 100; diff --git a/packages/core/src/Pipelines/Cart/CalculateTax.php b/packages/core/src/Pipelines/Cart/CalculateTax.php index bc83324f15..b7b964e01c 100644 --- a/packages/core/src/Pipelines/Cart/CalculateTax.php +++ b/packages/core/src/Pipelines/Cart/CalculateTax.php @@ -37,6 +37,7 @@ public function handle(CartContract $cart, Closure $next): mixed ->setCurrency($cart->currency) ->setPurchasable($cartLine->purchasable) ->setCartLine($cartLine) + ->setTaxZone($cart->taxZone) ->getBreakdown($subTotal); $taxBreakDownAmounts = $taxBreakDownAmounts->merge( @@ -70,6 +71,7 @@ public function handle(CartContract $cart, Closure $next): mixed $shippingTax = Taxes::setShippingAddress($cart->shippingAddress) ->setCurrency($cart->currency) ->setPurchasable($shippingOption) + ->setTaxZone($cart->taxZone) ->getBreakdown($shippingSubTotal); $shippingTaxTotal = $shippingTax->amounts->sum('price.value'); diff --git a/packages/core/src/Pipelines/CartLine/GetUnitPrice.php b/packages/core/src/Pipelines/CartLine/GetUnitPrice.php index 7bfde0f20d..da77934b90 100644 --- a/packages/core/src/Pipelines/CartLine/GetUnitPrice.php +++ b/packages/core/src/Pipelines/CartLine/GetUnitPrice.php @@ -47,7 +47,7 @@ public function handle(CartLineContract $cartLine, Closure $next) ); $cartLine->unitPriceInclTax = new Price( - $priceResponse->matched->priceIncTax()->value, + $priceResponse->matched->priceIncTax($cart->taxZone)->value, $cart->currency, $purchasable->getUnitQuantity() ); diff --git a/tests/core/Feature/Drivers/SystemTaxDriverTest.php b/tests/core/Feature/Drivers/SystemTaxDriverTest.php index bb01da059e..3a1e0b8bac 100644 --- a/tests/core/Feature/Drivers/SystemTaxDriverTest.php +++ b/tests/core/Feature/Drivers/SystemTaxDriverTest.php @@ -123,6 +123,86 @@ expect($breakdown->amounts[0]->price->value)->toEqual(166); }); +test('can set tax zone', function () { + $taxZone = TaxZone::factory()->create(); + + $driver = (new SystemTaxDriver) + ->setTaxZone($taxZone); + + expect($driver)->toBeInstanceOf(SystemTaxDriver::class); +}); + +test('uses cart tax zone override instead of default zone', function () { + $address = Address::factory()->create(); + $currency = Currency::factory()->create(); + $defaultTaxZone = TaxZone::factory()->state(['default' => true])->create(); + $overrideTaxZone = TaxZone::factory()->state(['default' => false])->create(); + + $taxClass = TaxClass::factory()->create(); + + // Default zone: 20 % + TaxRateAmount::factory()->create([ + 'tax_class_id' => $taxClass->id, + 'tax_rate_id' => TaxRate::factory()->state(['tax_zone_id' => $defaultTaxZone])->create()->id, + 'percentage' => 20, + ]); + + // Override zone: 5 % + TaxRateAmount::factory()->create([ + 'tax_class_id' => $taxClass->id, + 'tax_rate_id' => TaxRate::factory()->state(['tax_zone_id' => $overrideTaxZone])->create()->id, + 'percentage' => 5, + ]); + + $variant = ProductVariant::factory(['tax_class_id' => $taxClass->id])->create(); + $line = CartLine::factory(['purchasable_id' => $variant->id])->create(); + + $breakdown = (new SystemTaxDriver) + ->setShippingAddress($address) + ->setBillingAddress($address) + ->setCurrency($currency) + ->setPurchasable($variant) + ->setCartLine($line) + ->setTaxZone($overrideTaxZone) + ->getBreakdown(1000); + + // 5 % of 1000 = 50, not 20 % = 200 + expect($breakdown)->toBeInstanceOf(TaxBreakdown::class); + expect($breakdown->amounts->count())->toEqual(1); + expect($breakdown->amounts[0]->price->value)->toEqual(50); +}); + +test('falls back to address-derived zone when no override is set', function () { + $address = Address::factory()->create(); + $currency = Currency::factory()->create(); + $defaultTaxZone = TaxZone::factory()->state(['default' => true])->create(); + + $taxClass = TaxClass::factory()->create(); + + TaxRateAmount::factory()->create([ + 'tax_class_id' => $taxClass->id, + 'tax_rate_id' => TaxRate::factory()->state(['tax_zone_id' => $defaultTaxZone])->create()->id, + 'percentage' => 20, + ]); + + $variant = ProductVariant::factory(['tax_class_id' => $taxClass->id])->create(); + $line = CartLine::factory(['purchasable_id' => $variant->id])->create(); + + // No setTaxZone() call + $breakdown = (new SystemTaxDriver) + ->setShippingAddress($address) + ->setBillingAddress($address) + ->setCurrency($currency) + ->setPurchasable($variant) + ->setCartLine($line) + ->getBreakdown(1000); + + // Should use address-derived (default) zone → 20 % + expect($breakdown)->toBeInstanceOf(TaxBreakdown::class); + expect($breakdown->amounts->count())->toEqual(1); + expect($breakdown->amounts[0]->price->value)->toEqual(200); +}); + test('can get breakdown with correct tax zone', function () { $address = Address::factory()->create(); $currency = Currency::factory()->create(); diff --git a/tests/core/Stubs/TestTaxDriver.php b/tests/core/Stubs/TestTaxDriver.php index 2e4fa299e6..23e375452b 100644 --- a/tests/core/Stubs/TestTaxDriver.php +++ b/tests/core/Stubs/TestTaxDriver.php @@ -10,6 +10,7 @@ use Lunar\DataTypes\Price; use Lunar\Models\Contracts\CartLine as CartLineContract; use Lunar\Models\Contracts\Currency as CurrencyContract; +use Lunar\Models\Contracts\TaxZone as TaxZoneContract; use Lunar\Models\Currency; use Lunar\Models\ProductVariant; use Lunar\Models\TaxRateAmount; @@ -41,6 +42,11 @@ class TestTaxDriver implements TaxDriver */ protected CartLineContract $cartLine; + /** + * The optional tax zone override. + */ + protected ?TaxZoneContract $taxZone = null; + /** * {@inheritDoc} */ @@ -91,6 +97,16 @@ public function setCartLine(CartLineContract $cartLine): self return $this; } + /** + * {@inheritDoc} + */ + public function setTaxZone(?TaxZoneContract $taxZone = null): self + { + $this->taxZone = $taxZone; + + return $this; + } + /** * {@inheritDoc} */ @@ -100,7 +116,14 @@ public function getBreakdown($subTotal): TaxBreakdown if ($this->purchasable) { $taxClass = $this->purchasable->getTaxClass(); - $taxAmounts = $taxClass->taxRateAmounts; + + // When a zone override is provided, restrict to that zone's rate amounts + // (mirrors SystemTaxDriver behaviour so cart-level zone tests work correctly). + if ($this->taxZone) { + $taxAmounts = $this->taxZone->taxAmounts()->whereTaxClassId($taxClass->id)->get(); + } else { + $taxAmounts = $taxClass->taxRateAmounts; + } } else { $taxAmounts = TaxRateAmount::factory(2)->create(); } diff --git a/tests/core/Unit/Models/CartTest.php b/tests/core/Unit/Models/CartTest.php index 0d941413a8..18bab4d905 100644 --- a/tests/core/Unit/Models/CartTest.php +++ b/tests/core/Unit/Models/CartTest.php @@ -1167,6 +1167,90 @@ }); +test('cart tax zone override is applied through the full calculation pipeline', function () { + // Prices are stored ex-tax; tax is added on top during cart calculation. + Config::set('lunar.pricing.stored_inclusive_of_tax', false); + + $currency = Currency::factory()->state(['code' => 'GBP'])->create(); + $cart = Cart::factory()->create(['currency_id' => $currency->id]); + + $taxClass = TaxClass::factory()->create(['name' => 'Standard', 'default' => true]); + + // Default zone: 0 % – simulates a store where no tax applies for unknown locations. + $defaultTaxZone = TaxZone::factory()->state(['default' => true])->create(); + $defaultRate = TaxRate::factory()->state(['tax_zone_id' => $defaultTaxZone])->create(['name' => 'Default Rate']); + TaxRateAmount::factory()->create([ + 'tax_class_id' => $taxClass->id, + 'tax_rate_id' => $defaultRate->id, + 'percentage' => 0, + ]); + + // UAE zone: 20 % – the override set by IP-detection middleware. + $uaeZone = TaxZone::factory()->state(['default' => false])->create(['name' => 'UAE']); + $uaeRate = TaxRate::factory()->state(['tax_zone_id' => $uaeZone])->create(['name' => 'UAE VAT']); + TaxRateAmount::factory()->create([ + 'tax_class_id' => $taxClass->id, + 'tax_rate_id' => $uaeRate->id, + 'percentage' => 20, + ]); + + $purchasable = ProductVariant::factory(['tax_class_id' => $taxClass->id])->create(); + + Price::factory()->create([ + 'price' => 1000, + 'min_quantity' => 1, + 'currency_id' => $currency->id, + 'priceable_type' => $purchasable->getMorphClass(), + 'priceable_id' => $purchasable->id, + ]); + + $cart->lines()->create([ + 'purchasable_type' => $purchasable->getMorphClass(), + 'purchasable_id' => $purchasable->id, + 'quantity' => 1, + ]); + + // Default zone (0 %) – the cart-level zone is passed through the full pipeline: + // CalculateLines publishes the Blink key; CalculateTax forwards it to the driver. + $cart->setTaxZone($defaultTaxZone)->calculate(); + expect($cart->taxTotal->value)->toEqual(0); + expect($cart->total->value)->toEqual(1000); + + // Switch to UAE zone (20 %) – the override is correctly picked up. + $cart->setTaxZone($uaeZone)->recalculate(); + expect($cart->taxTotal->value)->toEqual(200); // 20 % of 1000 + expect($cart->total->value)->toEqual(1200); + + // Switch back to the default zone – pipeline correctly reverts to 0 %. + $cart->setTaxZone($defaultTaxZone)->recalculate(); + expect($cart->taxTotal->value)->toEqual(0); + expect($cart->total->value)->toEqual(1000); +}); + +test('setShippingAddress clears the tax zone override by default', function () { + $currency = Currency::factory()->create(); + $cart = Cart::factory()->create(['currency_id' => $currency->id]); + + $taxZone = TaxZone::factory()->state(['default' => false])->create(); + $cart->setTaxZone($taxZone, refresh: false)->save(); + + $cart->setShippingAddress(CartAddress::factory()->make()->toArray()); + + expect($cart->fresh()->tax_zone_id)->toBeNull(); +}); + +test('setShippingAddress preserves the tax zone override when opted out', function () { + $currency = Currency::factory()->create(); + $cart = Cart::factory()->create(['currency_id' => $currency->id]); + + $taxZone = TaxZone::factory()->state(['default' => false])->create(); + $cart->setTaxZone($taxZone, refresh: false)->save(); + + $cart->setShippingAddress(CartAddress::factory()->make()->toArray(), clearTaxZone: false); + + expect($cart->fresh()->tax_zone_id)->toEqual($taxZone->id); +}); + test('active scope correctly filters unmerged carts and isolates users', function () { setAuthUserConfig(); diff --git a/tests/core/Unit/Models/PriceTest.php b/tests/core/Unit/Models/PriceTest.php index 7a05469519..123ece1b50 100644 --- a/tests/core/Unit/Models/PriceTest.php +++ b/tests/core/Unit/Models/PriceTest.php @@ -3,10 +3,16 @@ use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Config; use Lunar\DataTypes\Price as DataTypesPrice; +use Lunar\Models\Country; use Lunar\Models\Currency; use Lunar\Models\CustomerGroup; use Lunar\Models\Price; use Lunar\Models\ProductVariant; +use Lunar\Models\TaxClass; +use Lunar\Models\TaxRate; +use Lunar\Models\TaxRateAmount; +use Lunar\Models\TaxZone; +use Lunar\Models\TaxZoneCountry; use Lunar\Tests\Core\TestCase; uses(TestCase::class); @@ -294,3 +300,185 @@ function can_handle_non_int_values() expect($price->comparePriceIncTax()->value)->toEqual(2000); }); + +test('priceIncTax falls back to the default tax zone when no zone is given', function () { + Config::set('lunar.pricing.stored_inclusive_of_tax', false); + + $currency = Currency::factory()->create([ + 'code' => 'GBP', + 'decimal_places' => 2, + 'default' => true, + ]); + + $taxClass = TaxClass::factory()->create(); + + // Default zone: 0 % + $defaultTaxZone = TaxZone::factory()->state(['default' => true])->create(); + $defaultRate = TaxRate::factory()->state(['tax_zone_id' => $defaultTaxZone])->create(); + TaxRateAmount::factory()->create([ + 'tax_class_id' => $taxClass->id, + 'tax_rate_id' => $defaultRate->id, + 'percentage' => 0, + ]); + + // Override zone: 20 % + $overrideZone = TaxZone::factory()->state(['default' => false])->create(); + $overrideRate = TaxRate::factory()->state(['tax_zone_id' => $overrideZone])->create(); + TaxRateAmount::factory()->create([ + 'tax_class_id' => $taxClass->id, + 'tax_rate_id' => $overrideRate->id, + 'percentage' => 20, + ]); + + $variant = ProductVariant::factory(['tax_class_id' => $taxClass->id])->create(); + + $price = Price::factory()->create([ + 'currency_id' => $currency->id, + 'priceable_id' => $variant->id, + 'priceable_type' => $variant->getMorphClass(), + 'price' => 1000, + 'min_quantity' => 1, + ]); + + // No param → falls back to the store's default zone (0 %). + expect($price->priceIncTax()->value)->toEqual(1000); + + // Explicit override zone → 20 % applies. + expect($price->priceIncTax($overrideZone)->value)->toEqual(1200); +}); + +test('priceIncTax explicit taxZone param overrides the default zone', function () { + // Prices stored ex-tax. + Config::set('lunar.pricing.stored_inclusive_of_tax', false); + + $currency = Currency::factory()->create(['code' => 'AED', 'decimal_places' => 2, 'default' => true]); + + $taxClass = TaxClass::factory()->create(['name' => 'Standard']); + + // Zone A: 5 % (default) + $zoneA = TaxZone::factory()->state(['default' => true])->create(['name' => 'Zone A']); + $rateA = TaxRate::factory()->state(['tax_zone_id' => $zoneA])->create(); + TaxRateAmount::factory()->create([ + 'tax_class_id' => $taxClass->id, + 'tax_rate_id' => $rateA->id, + 'percentage' => 5, + ]); + + // Zone B: 20 % + $zoneB = TaxZone::factory()->state(['default' => false])->create(['name' => 'Zone B']); + $rateB = TaxRate::factory()->state(['tax_zone_id' => $zoneB])->create(); + TaxRateAmount::factory()->create([ + 'tax_class_id' => $taxClass->id, + 'tax_rate_id' => $rateB->id, + 'percentage' => 20, + ]); + + $variant = ProductVariant::factory(['tax_class_id' => $taxClass->id])->create(); + $price = Price::factory()->create([ + 'currency_id' => $currency->id, + 'priceable_id' => $variant->id, + 'priceable_type' => $variant->getMorphClass(), + 'price' => 1000, + 'min_quantity' => 1, + ]); + + // Default would resolve to Zone A (5 %), but explicit Zone B (20 %) must win. + expect($price->priceIncTax(taxZone: $zoneB)->value)->toEqual(1200); + + // Also works on priceExTax (prices stored inc-tax scenario) + Config::set('lunar.pricing.stored_inclusive_of_tax', true); + $priceInc = Price::factory()->create([ + 'currency_id' => $currency->id, + 'priceable_id' => $variant->id, + 'priceable_type' => $variant->getMorphClass(), + 'price' => 1200, // stored inc 20 % + 'min_quantity' => 1, + ]); + expect($priceInc->priceExTax(taxZone: $zoneB)->value)->toEqual(1000); +}); + +test('priceIncTax accepts an explicit tax zone param', function () { + Config::set('lunar.pricing.stored_inclusive_of_tax', false); + + $currency = Currency::factory()->create(['code' => 'AED', 'decimal_places' => 2, 'default' => true]); + + // Default zone: 0 % (global default for unknown locations) + $defaultTaxZone = TaxZone::factory()->state(['default' => true])->create(['name' => 'Default']); + $defaultTaxClass = TaxClass::factory()->create(['name' => 'Default', 'default' => true]); + $defaultRate = TaxRate::factory()->state(['tax_zone_id' => $defaultTaxZone])->create(); + TaxRateAmount::factory()->create([ + 'tax_class_id' => $defaultTaxClass->id, + 'tax_rate_id' => $defaultRate->id, + 'percentage' => 0, + ]); + + // UAE zone: 10 % VAT + $uaeZone = TaxZone::factory()->state(['default' => false])->create(['name' => 'UAE']); + $uae = Country::factory()->create(['iso3' => 'ARE', 'name' => 'United Arab Emirates']); + TaxZoneCountry::factory()->create(['tax_zone_id' => $uaeZone->id, 'country_id' => $uae->id]); + $uaeRate = TaxRate::factory()->state(['tax_zone_id' => $uaeZone])->create(['name' => 'UAE VAT']); + $vatTaxClass = TaxClass::factory()->create(['name' => 'UAE VAT 10%']); + TaxRateAmount::factory()->create([ + 'tax_class_id' => $vatTaxClass->id, + 'tax_rate_id' => $uaeRate->id, + 'percentage' => 10, + ]); + + $variant = ProductVariant::factory(['tax_class_id' => $vatTaxClass->id])->create(); + $price = Price::factory()->create([ + 'currency_id' => $currency->id, + 'priceable_id' => $variant->id, + 'priceable_type' => $variant->getMorphClass(), + 'price' => 1000, + 'min_quantity' => 1, + ]); + + // Without zone override – falls back to default zone → 0 % even though + // the variant's class has UAE rates configured + expect($price->priceIncTax()->value)->toEqual(1000); + + // Passing the UAE zone explicitly returns the 10 % rate for that zone + expect($price->priceIncTax(taxZone: $uaeZone)->value)->toEqual(1100); +}); + +test('comparePriceIncTax accepts explicit taxZone param', function () { + Config::set('lunar.pricing.stored_inclusive_of_tax', false); + + $currency = Currency::factory()->create(['code' => 'AED', 'decimal_places' => 2, 'default' => true]); + + $taxClass = TaxClass::factory()->create(['default' => true]); + + // Default zone: 0 % + $defaultTaxZone = TaxZone::factory()->state(['default' => true])->create(); + $defaultRate = TaxRate::factory()->state(['tax_zone_id' => $defaultTaxZone])->create(); + TaxRateAmount::factory()->create([ + 'tax_class_id' => $taxClass->id, + 'tax_rate_id' => $defaultRate->id, + 'percentage' => 0, + ]); + + // UAE zone: 10 % + $uaeZone = TaxZone::factory()->state(['default' => false])->create(['name' => 'UAE']); + $uaeRate = TaxRate::factory()->state(['tax_zone_id' => $uaeZone])->create(); + TaxRateAmount::factory()->create([ + 'tax_class_id' => $taxClass->id, + 'tax_rate_id' => $uaeRate->id, + 'percentage' => 10, + ]); + + $variant = ProductVariant::factory(['tax_class_id' => $taxClass->id])->create(); + $price = Price::factory()->create([ + 'currency_id' => $currency->id, + 'priceable_id' => $variant->id, + 'priceable_type' => $variant->getMorphClass(), + 'price' => 1000, + 'compare_price' => 1500, + 'min_quantity' => 1, + ]); + + // Default zone → 0 % on compare price + expect($price->comparePriceIncTax()->value)->toEqual(1500); + + // UAE zone → 10 % on compare price + expect($price->comparePriceIncTax(taxZone: $uaeZone)->value)->toEqual(1650); +});