Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<?php

use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
use Lunar\Base\Migration;

return new class extends Migration
{
public function up(): void
{
Schema::table($this->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');
});
}
};
2 changes: 1 addition & 1 deletion packages/core/src/Actions/Carts/CalculateLineSubtotal.php
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ public function execute(
);

$priceInclTax = new Price(
$priceResponse->matched->priceIncTax()->value,
$priceResponse->matched->priceIncTax($cart->taxZone)->value,
$cart->currency,
$purchasable->getUnitQuantity()
);
Expand Down
10 changes: 10 additions & 0 deletions packages/core/src/Base/TaxDriver.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand Down Expand Up @@ -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.
*
Expand Down
19 changes: 18 additions & 1 deletion packages/core/src/Drivers/SystemTaxDriver.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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}
*/
Expand Down Expand Up @@ -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) {
Expand Down
37 changes: 36 additions & 1 deletion packages/core/src/Models/Cart.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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
Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -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');
}

Expand Down Expand Up @@ -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();
}
}
1 change: 1 addition & 0 deletions packages/core/src/Models/CartLine.php
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ class CartLine extends BaseModel implements Contracts\CartLine
*/
public $cachableProperties = [
'unitPrice',
'unitPriceInclTax',
'subTotal',
'discountTotal',
'taxAmount',
Expand Down
15 changes: 14 additions & 1 deletion packages/core/src/Models/Contracts/Cart.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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;
}
16 changes: 14 additions & 2 deletions packages/core/src/Models/Contracts/Price.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\MorphTo;
use Lunar\Models\Contracts\TaxZone;

interface Price
{
Expand All @@ -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;
}
35 changes: 24 additions & 11 deletions packages/core/src/Models/Price.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

/**
Expand Down Expand Up @@ -76,59 +77,71 @@ 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;
}

$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;
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/Pipelines/Cart/CalculateTax.php
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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');
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/Pipelines/CartLine/GetUnitPrice.php
Original file line number Diff line number Diff line change
Expand Up @@ -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()
);
Expand Down
Loading