Skip to content
Open
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
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
52 changes: 52 additions & 0 deletions packages/core/src/Models/Cart.php
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@
use Lunar\Exceptions\FingerprintMismatchException;
use Lunar\Facades\DB;
use Lunar\Facades\ShippingManifest;
use Lunar\Models\TaxZone;
use Spatie\LaravelBlink\BlinkFacade as Blink;
use Lunar\Pipelines\Cart\Calculate;
use Lunar\Validation\Cart\ValidateCartForOrderCreation;
use Lunar\Validation\CartLine\CartLineStock;
Expand All @@ -67,6 +69,22 @@ class Cart extends BaseModel implements Contracts\Cart
use LogsActivity;
use SoftDeletes;

/** Restore the tax zone override from the cart's meta JSON column whenever a Cart is loaded from the database. */
protected static function booted(): void
{
parent::booted();

static::retrieved(function (Cart $cart) {
$taxZoneId = $cart->meta['tax_zone_id'] ?? null;
if ($taxZoneId) {
$cart->taxZone = Blink::once(
'cart_tax_zone_'.$taxZoneId,
fn () => TaxZone::find($taxZoneId)
);
}
});
}

/**
* Array of cachable class properties.
*
Expand Down Expand Up @@ -138,6 +156,15 @@ class Cart extends BaseModel implements Contracts\Cart
*/
public ?ShippingOption $shippingOptionOverride = null;

/**
* The tax zone override for this cart.
* When set, this zone will be used instead of resolving a zone from the
* shipping address, enabling middleware to enforce the correct geographic
* tax treatment based on IP geo-location or customer-provided country
* before a full shipping address is known.
*/
public ?TaxZone $taxZone = null;

/**
* Additional shipping estimate meta data.
*/
Expand Down Expand Up @@ -617,4 +644,29 @@ public function getEstimatedShipping(array $params, bool $setOverride = false):

return $option;
}

/**
* Set the tax zone override for this cart.
*
* When set, all tax calculations will 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.
*
* The zone ID is mirrored into the cart's `meta` JSON column so the choice survives across requests.
* Call `->save()` afterwards to persist it to the database; the `booted()` retrieved-event listener
* will then restore the zone automatically on every subsequent page load.
*/
public function setTaxZone(?TaxZone $taxZone): Cart
{
$this->taxZone = $taxZone;
$meta = $this->meta ?? new \ArrayObject;

if ($taxZone) {
$meta['tax_zone_id'] = $taxZone->id;
} else {
unset($meta['tax_zone_id']);
}

$this->meta = $meta;
return $this;
}
}
1 change: 1 addition & 0 deletions packages/core/src/Models/CartLine.php
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ class CartLine extends BaseModel implements Contracts\CartLine
*/
public $cachableProperties = [
'unitPrice',
'unitPriceInclTax',
'subTotal',
'discountTotal',
'taxAmount',
Expand Down
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\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;
}
40 changes: 29 additions & 11 deletions packages/core/src/Models/Price.php
Original file line number Diff line number Diff line change
Expand Up @@ -75,59 +75,77 @@ public function customerGroup(): BelongsTo

/**
* Return the price exclusive of tax.
*
* @param TaxZone|null $taxZone Optional override for the tax zone. Falls back to the
* Blink cart override, then the store's default zone.
*/
public function priceExTax(): \Lunar\DataTypes\Price
public function priceExTax(?TaxZone $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(?TaxZone $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(?TaxZone $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 percentage (as a decimal, e.g. 0.20 for 20 %) for the given
* tax zone + priceable's own tax class combination.
*
* Resolution order
* ─ Tax class : always from the priceable (product-level classification, never overridden here)
* ─ Tax zone : explicit param → Blink cart override (lunar_cart_tax_zone) → store default zone
*
* Results are cached in Blink keyed by "{classId}_{zoneId}" so different combinations
* never collide within the same request.
*/
protected function getPriceableTaxRate(): int|float
protected function getPriceableTaxRate(?TaxZone $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::get('lunar_cart_tax_zone')
?? 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
9 changes: 9 additions & 0 deletions packages/core/src/Pipelines/Cart/CalculateLines.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
use Lunar\DataTypes\Price;
use Lunar\Models\Cart;
use Lunar\Models\Contracts\Cart as CartContract;
use Spatie\LaravelBlink\BlinkFacade as Blink;

class CalculateLines
{
Expand All @@ -18,6 +19,14 @@ class CalculateLines
public function handle(CartContract $cart, Closure $next): mixed
{
/** @var Cart $cart */

// Cache the TaxZone so that price model taxInc methods accounts for it in computation.
if ($cart->taxZone) {
Blink::put('lunar_cart_tax_zone', $cart->taxZone);
} else {
Blink::forget('lunar_cart_tax_zone');
}

foreach ($cart->lines as $line) {
$cartLine = app(Pipeline::class)
->send($line)
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
Loading
Loading