From 95db1b638b37ac7cc2487d24e5f629581bd7d639 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?D=2E=20Nagy=20Gerg=C5=91?= Date: Wed, 25 Sep 2024 22:39:54 +0200 Subject: [PATCH] tax rework --- database/factories/TaxFactory.php | 39 ----------- database/factories/TaxRateFactory.php | 4 +- ...28_082227_create_bazar_tax_rates_table.php | 2 +- src/Interfaces/Buyable.php | 10 +-- src/Interfaces/Inventoryable.php | 2 +- src/Interfaces/Models/Tax.php | 13 ---- src/Interfaces/Models/TaxRate.php | 7 +- src/Interfaces/Taxable.php | 4 +- src/Models/Item.php | 8 ++- src/Models/Product.php | 11 ++- src/Models/Shipping.php | 15 ++-- src/Models/Tax.php | 35 +--------- src/Models/TaxRate.php | 69 +++++++++++++++++-- src/Models/Variant.php | 16 ++--- src/Traits/InteractsWithStock.php | 4 +- src/Traits/InteractsWithTaxes.php | 13 ++-- tests/Models/ItemTest.php | 11 +-- tests/Models/ProductTest.php | 14 ++++ tests/Models/ShippingTest.php | 8 ++- tests/Models/TaxRateTest.php | 24 ++++++- tests/Models/TaxTest.php | 15 ---- tests/Models/VariantTest.php | 14 ++++ 22 files changed, 191 insertions(+), 147 deletions(-) delete mode 100644 database/factories/TaxFactory.php delete mode 100644 tests/Models/TaxTest.php diff --git a/database/factories/TaxFactory.php b/database/factories/TaxFactory.php deleted file mode 100644 index d31c5b8e..00000000 --- a/database/factories/TaxFactory.php +++ /dev/null @@ -1,39 +0,0 @@ - - */ - protected $model = Tax::class; - - /** - * Get the name of the model that is generated by the factory. - * - * @return class-string<\Illuminate\Database\Eloquent\Model|TModel> - */ - public function modelName(): string - { - return $this->model::getProxiedClass(); - } - - /** - * Define the model's default state. - */ - public function definition(): array - { - return [ - // - ]; - } -} diff --git a/database/factories/TaxRateFactory.php b/database/factories/TaxRateFactory.php index 2c0a9fb4..581368c3 100644 --- a/database/factories/TaxRateFactory.php +++ b/database/factories/TaxRateFactory.php @@ -33,7 +33,9 @@ public function modelName(): string public function definition(): array { return [ - // + 'value' => $value = mt_rand(10, 30), + 'name' => sprintf('%d%% Tax Rate', $value), + 'shipping' => false, ]; } } diff --git a/database/migrations/2024_07_28_082227_create_bazar_tax_rates_table.php b/database/migrations/2024_07_28_082227_create_bazar_tax_rates_table.php index 411b8f92..bd3c6b0a 100644 --- a/database/migrations/2024_07_28_082227_create_bazar_tax_rates_table.php +++ b/database/migrations/2024_07_28_082227_create_bazar_tax_rates_table.php @@ -14,7 +14,7 @@ public function up(): void Schema::create('bazar_tax_rates', static function (Blueprint $table): void { $table->id(); $table->string('name'); - $table->integer('value')->unsigned(); + $table->float('value')->unsigned(); $table->boolean('shipping')->default(false); $table->timestamps(); }); diff --git a/src/Interfaces/Buyable.php b/src/Interfaces/Buyable.php index 0bd8eaae..ca6e97b4 100644 --- a/src/Interfaces/Buyable.php +++ b/src/Interfaces/Buyable.php @@ -3,7 +3,7 @@ namespace Cone\Bazar\Interfaces; use Cone\Bazar\Models\Item; -use Illuminate\Database\Eloquent\Relations\MorphToMany; +use Illuminate\Support\Collection; interface Buyable { @@ -13,12 +13,12 @@ interface Buyable public function buyable(Checkoutable $checkoutable): bool; /** - * Get the tax rates for the variant. + * Get the item representation of the buyable instance. */ - public function taxRates(): MorphToMany; + public function toItem(Checkoutable $checkoutable, array $attributes = []): Item; /** - * Get the item representation of the buyable instance. + * Get the applicable tax rates. */ - public function toItem(Checkoutable $checkoutable, array $attributes = []): Item; + public function getApplicableTaxRates(): Collection; } diff --git a/src/Interfaces/Inventoryable.php b/src/Interfaces/Inventoryable.php index bbf08b1e..17878198 100644 --- a/src/Interfaces/Inventoryable.php +++ b/src/Interfaces/Inventoryable.php @@ -7,7 +7,7 @@ interface Inventoryable /** * Get the formatted dimensions. */ - public function getFormattedDimensions(string $glue = 'x'): ?string; + public function getFormattedDimensions(): ?string; /** * Get the formatted weight. diff --git a/src/Interfaces/Models/Tax.php b/src/Interfaces/Models/Tax.php index a59a5a26..ed2052f4 100644 --- a/src/Interfaces/Models/Tax.php +++ b/src/Interfaces/Models/Tax.php @@ -2,21 +2,8 @@ namespace Cone\Bazar\Interfaces\Models; -use Illuminate\Database\Eloquent\Relations\BelongsTo; -use Illuminate\Database\Eloquent\Relations\MorphTo; - interface Tax { - /** - * Get the taxable model for the model. - */ - public function taxable(): MorphTo; - - /** - * Get the tax rate for the model. - */ - public function taxRate(): BelongsTo; - /** * Get the formatted tax. */ diff --git a/src/Interfaces/Models/TaxRate.php b/src/Interfaces/Models/TaxRate.php index 2772f043..75c446f1 100644 --- a/src/Interfaces/Models/TaxRate.php +++ b/src/Interfaces/Models/TaxRate.php @@ -2,7 +2,12 @@ namespace Cone\Bazar\Interfaces\Models; +use Cone\Bazar\Interfaces\Taxable; + interface TaxRate { - // + /** + * Calculate the tax for the taxable model. + */ + public function calculate(Taxable $taxable): float; } diff --git a/src/Interfaces/Taxable.php b/src/Interfaces/Taxable.php index d745674b..bea33d6b 100644 --- a/src/Interfaces/Taxable.php +++ b/src/Interfaces/Taxable.php @@ -2,14 +2,14 @@ namespace Cone\Bazar\Interfaces; -use Illuminate\Database\Eloquent\Relations\MorphMany; +use Illuminate\Database\Eloquent\Relations\MorphToMany; interface Taxable { /** * Get the taxes for the model. */ - public function taxes(): MorphMany; + public function taxes(): MorphToMany; /** * Get the tax base. diff --git a/src/Models/Item.php b/src/Models/Item.php index 9a9f266b..7b49abda 100644 --- a/src/Models/Item.php +++ b/src/Models/Item.php @@ -294,10 +294,12 @@ public function isFee(): bool */ public function calculateTaxes(): float { - $this->buyable->taxRates->each(function (TaxRate $taxRate): void { - $taxRate->calculate($this); + $taxes = $this->buyable->getApplicableTaxRates()->mapWithKeys(function (TaxRate $taxRate): array { + return [$taxRate->getKey() => ['value' => $taxRate->calculate($this)]]; }); - return $this->getTaxTotal(); + $this->taxes()->sync($taxes); + + return $taxes->sum('value'); } } diff --git a/src/Models/Product.php b/src/Models/Product.php index bbe9c40a..73379ade 100644 --- a/src/Models/Product.php +++ b/src/Models/Product.php @@ -20,6 +20,7 @@ use Illuminate\Database\Eloquent\Relations\MorphToMany; use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Database\Query\Builder as QueryBuilder; +use Illuminate\Support\Collection; class Product extends Model implements Contract { @@ -92,13 +93,21 @@ public function variants(): HasMany } /** - * Get the tax rates. + * Get the tax rates for the product. */ public function taxRates(): MorphToMany { return $this->morphToMany(TaxRate::getProxiedClass(), 'buyable', 'bazar_buyable_tax_rate'); } + /** + * Get the applicable tax rates. + */ + public function getApplicableTaxRates(): Collection + { + return $this->taxRates; + } + /** * Determine whether the buyable object is available for the checkoutable instance. */ diff --git a/src/Models/Shipping.php b/src/Models/Shipping.php index 43bc2411..8f797635 100644 --- a/src/Models/Shipping.php +++ b/src/Models/Shipping.php @@ -10,7 +10,6 @@ use Cone\Bazar\Traits\InteractsWithTaxes; use Cone\Root\Traits\InteractsWithProxy; use Illuminate\Database\Eloquent\Casts\Attribute; -use Illuminate\Database\Eloquent\Factories\Factory; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\MorphTo; @@ -291,10 +290,16 @@ public function calculateFee(): float */ public function calculateTaxes(): float { - TaxRate::proxy()->newQuery()->applicableForShipping()->cursor()->each(function (TaxRate $taxRate): void { - $taxRate->calculate($this); - }); + $taxes = TaxRate::proxy() + ->newQuery() + ->applicableForShipping() + ->get() + ->mapWithKeys(function (TaxRate $taxRate): array { + return [$taxRate->getKey() => ['value' => $taxRate->calculate($this)]]; + }); + + $this->taxes()->sync($taxes); - return $this->getTaxTotal(); + return $taxes->sum('value'); } } diff --git a/src/Models/Tax.php b/src/Models/Tax.php index b7bc3377..636a1336 100644 --- a/src/Models/Tax.php +++ b/src/Models/Tax.php @@ -2,19 +2,14 @@ namespace Cone\Bazar\Models; -use Cone\Bazar\Database\Factories\TaxFactory; use Cone\Bazar\Interfaces\Models\Tax as Contract; use Cone\Bazar\Support\Currency; use Cone\Root\Traits\InteractsWithProxy; use Illuminate\Database\Eloquent\Casts\Attribute; -use Illuminate\Database\Eloquent\Factories\HasFactory; -use Illuminate\Database\Eloquent\Model; -use Illuminate\Database\Eloquent\Relations\BelongsTo; -use Illuminate\Database\Eloquent\Relations\MorphTo; +use Illuminate\Database\Eloquent\Relations\MorphPivot; -class Tax extends Model implements Contract +class Tax extends MorphPivot implements Contract { - use HasFactory; use InteractsWithProxy; /** @@ -59,30 +54,6 @@ public static function getProxiedInterface(): string return Contract::class; } - /** - * Create a new factory instance for the model. - */ - protected static function newFactory(): TaxFactory - { - return TaxFactory::new(); - } - - /** - * Get the taxable model for the model. - */ - public function taxable(): MorphTo - { - return $this->morphTo(); - } - - /** - * Get the tax rate for the model. - */ - public function taxRate(): BelongsTo - { - return $this->belongsTo(TaxRate::getProxiedClass()); - } - /** * Get the formatted value attribute. */ @@ -98,6 +69,6 @@ protected function formattedValue(): Attribute */ public function format(): string { - return (new Currency($this->value, $this->taxable?->checkoutable?->getCurrency()))->format(); + return (new Currency($this->value, $this->pivotParent?->checkoutable?->getCurrency()))->format(); } } diff --git a/src/Models/TaxRate.php b/src/Models/TaxRate.php index 95197583..b9d8fc17 100644 --- a/src/Models/TaxRate.php +++ b/src/Models/TaxRate.php @@ -7,14 +7,56 @@ use Cone\Bazar\Interfaces\Taxable; use Cone\Root\Traits\InteractsWithProxy; use Illuminate\Database\Eloquent\Builder; +use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; +use Illuminate\Support\Number; class TaxRate extends Model implements Contract { use HasFactory; use InteractsWithProxy; + /** + * The accessors to append to the model's array form. + * + * @var list + */ + protected $appends = [ + 'formatted_value', + 'rate', + ]; + + /** + * The attributes that should have default values. + * + * @var array + */ + protected $attributes = [ + 'shipping' => false, + ]; + + /** + * The attributes that should be cast to native types. + * + * @var array + */ + protected $casts = [ + 'shipping' => 'bool', + 'value' => 'float', + ]; + + /** + * The attributes that are mass assignable. + * + * @var list + */ + protected $fillable = [ + 'name', + 'shipping', + 'value', + ]; + /** * The table associated with the model. * @@ -39,18 +81,33 @@ protected static function newFactory(): TaxRateFactory } /** - * Calculate the tax for the taxable model. + * Get the rate attribute. */ - public function calculate(Taxable $taxable): Tax + protected function rate(): Attribute { - $value = round($taxable->getTaxBase() * $this->rate, 2); + return new Attribute( + get: fn (): float => round($this->value / 100, 2) + ); + } - return $taxable->taxes()->updateOrCreate( - ['tax_rate_id' => $this->getKey()], - ['value' => $value] + /** + * Get the formatted value attribute. + */ + protected function formattedValue(): Attribute + { + return new Attribute( + get: fn (): string => Number::percentage($this->value) ); } + /** + * Calculate the tax for the taxable model. + */ + public function calculate(Taxable $taxable): float + { + return round($taxable->getTaxBase() * $this->rate, 2); + } + /** * Scope the query for the results that are applicable for shipping. */ diff --git a/src/Models/Variant.php b/src/Models/Variant.php index b3472dd8..1e803443 100644 --- a/src/Models/Variant.php +++ b/src/Models/Variant.php @@ -16,8 +16,8 @@ use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; -use Illuminate\Database\Eloquent\Relations\MorphToMany; use Illuminate\Database\Eloquent\SoftDeletes; +use Illuminate\Support\Collection; class Variant extends Model implements Contract { @@ -75,7 +75,7 @@ public function getMorphClass(): string } /** - * Get the product for the transaction. + * Get the product for the variant. */ public function product(): BelongsTo { @@ -84,11 +84,11 @@ public function product(): BelongsTo } /** - * Get the tax rates. + * Get the applicable tax rates. */ - public function taxRates(): MorphToMany + public function getApplicableTaxRates(): Collection { - return $this->morphToMany(TaxRate::getProxiedClass(), 'buyable', 'bazar_buyable_tax_rate'); + return $this->product->getApplicableTaxRates(); } /** @@ -99,9 +99,7 @@ public function taxRates(): MorphToMany protected function name(): Attribute { return new Attribute( - get: function (): string { - return sprintf('%s - %s', $this->product->name, $this->alias); - } + get: fn (): string => sprintf('%s - %s', $this->product->name, $this->alias) ); } @@ -124,7 +122,7 @@ protected function alias(): Attribute */ public function buyable(Checkoutable $checkoutable): bool { - return true; + return $this->product->buyable($checkoutable); } /** diff --git a/src/Traits/InteractsWithStock.php b/src/Traits/InteractsWithStock.php index 2dcb5b97..d21e846d 100644 --- a/src/Traits/InteractsWithStock.php +++ b/src/Traits/InteractsWithStock.php @@ -9,7 +9,7 @@ trait InteractsWithStock /** * Get the formatted dimensions. */ - public function getFormattedDimensions(string $glue = 'x'): ?string + public function getFormattedDimensions(): ?string { $dimensions = $this->metaData->whereIn('key', ['length', 'width', 'height'])->filter()->values(); @@ -17,7 +17,7 @@ public function getFormattedDimensions(string $glue = 'x'): ?string return null; } - return sprintf('%s %s', $dimensions->implode('value', $glue), Config::get('bazar.dimension_unit')); + return sprintf('%s %s', $dimensions->implode('value', 'x'), Config::get('bazar.dimension_unit')); } /** diff --git a/src/Traits/InteractsWithTaxes.php b/src/Traits/InteractsWithTaxes.php index 989c626b..ccfc0633 100644 --- a/src/Traits/InteractsWithTaxes.php +++ b/src/Traits/InteractsWithTaxes.php @@ -3,17 +3,22 @@ namespace Cone\Bazar\Traits; use Cone\Bazar\Models\Tax; +use Cone\Bazar\Models\TaxRate; use Illuminate\Database\Eloquent\Casts\Attribute; -use Illuminate\Database\Eloquent\Relations\MorphMany; +use Illuminate\Database\Eloquent\Relations\MorphToMany; trait InteractsWithTaxes { /** * Get the taxes for the model. */ - public function taxes(): MorphMany + public function taxes(): MorphToMany { - return $this->morphMany(Tax::getProxiedClass(), 'taxable'); + return $this->morphToMany(TaxRate::getProxiedClass(), 'taxable', 'bazar_taxes') + ->as('tax') + ->using(Tax::getProxiedClass()) + ->withPivot('value') + ->withTimestamps(); } /** @@ -45,6 +50,6 @@ protected function formattedTaxTotal(): Attribute */ public function getTaxTotal(): float { - return $this->taxes->sum('value'); + return $this->taxes->sum('tax.value'); } } diff --git a/tests/Models/ItemTest.php b/tests/Models/ItemTest.php index a8fd1a73..153b419b 100644 --- a/tests/Models/ItemTest.php +++ b/tests/Models/ItemTest.php @@ -2,10 +2,10 @@ namespace Cone\Bazar\Tests\Models; -use Cone\Bazar\Interfaces\Taxable; use Cone\Bazar\Models\Cart; use Cone\Bazar\Models\Item; use Cone\Bazar\Models\Product; +use Cone\Bazar\Models\TaxRate; use Cone\Bazar\Support\Currency; use Cone\Bazar\Tests\TestCase; @@ -20,6 +20,9 @@ public function setUp(): void $cart = Cart::factory()->create(); $product = Product::factory()->create(); + $taxRate = TaxRate::factory()->create(); + $product->taxRates()->attach($taxRate); + $this->item = Item::factory()->make([ 'properties' => ['text' => 'test-text'], ]); @@ -29,12 +32,10 @@ public function setUp(): void public function test_item_is_taxable(): void { - $this->assertInstanceOf(Taxable::class, $this->item); $this->assertSame( - (new Currency($this->item->getTaxTotal(), $this->item->checkoutable->currency))->format(), - $this->item->getFormattedTaxTotal() + $this->item->calculateTaxes(), + $this->item->getTaxTotal() ); - $this->assertSame($this->item->getFormattedTaxTotal(), $this->item->formattedTaxTotal); } public function test_item_has_price_attribute(): void diff --git a/tests/Models/ProductTest.php b/tests/Models/ProductTest.php index 8276fb15..717bfb4b 100644 --- a/tests/Models/ProductTest.php +++ b/tests/Models/ProductTest.php @@ -9,6 +9,7 @@ use Cone\Bazar\Models\Product; use Cone\Bazar\Models\Property; use Cone\Bazar\Models\PropertyValue; +use Cone\Bazar\Models\TaxRate; use Cone\Bazar\Models\Variant; use Cone\Bazar\Tests\TestCase; @@ -97,6 +98,19 @@ public function test_product_has_variants(): void $this->assertNull($this->product->toVariant(['size' => 'fake'])); } + public function test_a_product_belongs_to_tax_rates(): void + { + $taxRate = TaxRate::factory()->create(); + + $this->assertTrue($this->product->taxRates->isEmpty()); + + $this->product->taxRates()->attach($taxRate); + + $this->product->refresh(); + + $this->assertTrue($this->product->taxRates->contains($taxRate)); + } + public function test_product_interacts_with_stock(): void { $this->assertTrue(true); diff --git a/tests/Models/ShippingTest.php b/tests/Models/ShippingTest.php index 9524b063..ccd00719 100644 --- a/tests/Models/ShippingTest.php +++ b/tests/Models/ShippingTest.php @@ -6,6 +6,7 @@ use Cone\Bazar\Models\Cart; use Cone\Bazar\Models\Order; use Cone\Bazar\Models\Shipping; +use Cone\Bazar\Models\TaxRate; use Cone\Bazar\Support\Currency; use Cone\Bazar\Support\Facades\Shipping as ShippingManager; use Cone\Bazar\Tests\TestCase; @@ -77,7 +78,12 @@ public function test_shipping_can_calculate_fee(): void public function test_shipping_is_taxable(): void { - $this->assertSame($this->shipping->getFormattedTaxTotal(), $this->shipping->formattedTaxTotal); + TaxRate::factory()->create(['shipping' => true]); + + $this->assertSame( + $this->shipping->calculateTaxes(), + $this->shipping->getTaxTotal() + ); } public function testt_has_total_attribute(): void diff --git a/tests/Models/TaxRateTest.php b/tests/Models/TaxRateTest.php index ea60a5d1..d5eb3097 100644 --- a/tests/Models/TaxRateTest.php +++ b/tests/Models/TaxRateTest.php @@ -2,14 +2,36 @@ namespace Cone\Bazar\Tests\Models; +use Cone\Bazar\Models\Shipping; +use Cone\Bazar\Models\TaxRate; use Cone\Bazar\Tests\TestCase; class TaxRateTest extends TestCase { + protected TaxRate $taxRate; + public function setUp(): void { parent::setUp(); - // + $this->taxRate = TaxRate::factory()->create(); + } + + public function test_a_tax_rate_calculates_tax_value(): void + { + $taxable = Shipping::factory()->make(); + + $this->assertSame( + round($taxable->getTaxBase() * $this->taxRate->rate, 2), + $this->taxRate->calculate($taxable) + ); + } + + public function test_a_tax_rate_has_query_scopes(): void + { + $this->assertSame( + $this->taxRate->newQuery()->where('bazar_tax_rates.shipping', true)->toSql(), + $this->taxRate->newQuery()->applicableForShipping()->toSql() + ); } } diff --git a/tests/Models/TaxTest.php b/tests/Models/TaxTest.php deleted file mode 100644 index f56ea0ce..00000000 --- a/tests/Models/TaxTest.php +++ /dev/null @@ -1,15 +0,0 @@ -assertEquals($this->product->id, $this->variant->product_id); } + public function test_a_product_belongs_to_tax_rates(): void + { + $taxRate = TaxRate::factory()->create(); + + $this->assertTrue($this->product->taxRates->isEmpty()); + + $this->product->taxRates()->attach($taxRate); + + $this->product->refresh(); + + $this->assertTrue($this->product->taxRates->contains($taxRate)); + } + public function test_variant_has_alias_attribute(): void { $variant = Variant::factory()->make(['alias' => 'Fake']);