diff --git a/composer.json b/composer.json index ca2067647..3971e259b 100644 --- a/composer.json +++ b/composer.json @@ -19,6 +19,7 @@ "php": "^8.0.2|^8.1", "blade-ui-kit/blade-heroicons": "^2.0", "cviebrock/laravel-elasticsearch": "^9.0|^10.0", + "gehrisandro/tailwind-merge-laravel": "^0.2.1", "illuminate/database": "^9.0|^10.0", "illuminate/events": "^9.0|^10.0", "illuminate/queue": "^9.0|^10.0", diff --git a/config/rapidez/models.php b/config/rapidez/models.php index aed1d04ac..ef4d36fd4 100644 --- a/config/rapidez/models.php +++ b/config/rapidez/models.php @@ -8,6 +8,7 @@ 'category' => Rapidez\Core\Models\Category::class, 'category_product' => Rapidez\Core\Models\CategoryProduct::class, 'customer' => Rapidez\Core\Models\Customer::class, + 'customer_group' => Rapidez\Core\Models\CustomerGroup::class, 'config' => Rapidez\Core\Models\Config::class, 'oauth_token' => Rapidez\Core\Models\OauthToken::class, 'option_swatch' => Rapidez\Core\Models\OptionSwatch::class, @@ -20,6 +21,7 @@ 'product_option_type_title' => Rapidez\Core\Models\ProductOptionTypeTitle::class, 'product_option_type_price' => Rapidez\Core\Models\ProductOptionTypePrice::class, 'product_option_type_value' => Rapidez\Core\Models\ProductOptionTypeValue::class, + 'product_tier_price' => Rapidez\Core\Models\ProductTierPrice::class, 'quote' => Rapidez\Core\Models\Quote::class, 'quote_item' => Rapidez\Core\Models\QuoteItem::class, 'quote_item_option' => Rapidez\Core\Models\QuoteItemOption::class, @@ -32,4 +34,5 @@ 'sales_order_item' => Rapidez\Core\Models\SalesOrderItem::class, 'sales_order_payment' => Rapidez\Core\Models\SalesOrderPayment::class, 'search_query' => Rapidez\Core\Models\SearchQuery::class, + 'tax_calculation' => Rapidez\Core\Models\TaxCalculation::class, ]; diff --git a/package.json b/package.json index 2099c9a32..f0d587210 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "@tailwindcss/typography": "^0.5.9", "@vitejs/plugin-vue2": "^2.2.0", "@vueuse/core": "^9.12.0", + "@vueuse/integrations": "^10.5.0", "autoprefixer": "^10.4.15", "axios": "^1.2.6", "cross-env": "^7.0.3", @@ -23,6 +24,7 @@ "rollup-plugin-visualizer": "^5.9.0", "tailwind-scrollbar-hide": "^1.1.7", "tailwindcss": "^3.3.3", + "universal-cookie": "^6.1.1", "vite": "^4.0.4", "vue": "^2.7", "vue-clickaway": "^2.2.2", diff --git a/phpunit.dusk.xml.dist b/phpunit.dusk.xml.dist index 907d4bb20..b7d22490a 100644 --- a/phpunit.dusk.xml.dist +++ b/phpunit.dusk.xml.dist @@ -31,5 +31,6 @@ + diff --git a/resources/js/axios.js b/resources/js/axios.js index 5e36655a9..a3108e4fa 100644 --- a/resources/js/axios.js +++ b/resources/js/axios.js @@ -1,6 +1,7 @@ import axios from 'axios' -window.axios = axios +import { token } from './stores/useUser' +window.axios = axios window.axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest' window.magento = axios.create() @@ -8,7 +9,7 @@ window.magento.defaults.baseURL = config.magento_url + '/rest/' + config.store_c window.magentoUser = axios.create() window.magentoUser.defaults.baseURL = config.magento_url + '/rest/' + config.store_code + '/V1/' -window.magentoUser.defaults.headers.common['Authorization'] = `Bearer ${localStorage.token}` +window.magentoUser.defaults.headers.common['Authorization'] = `Bearer ${token.value || ''}` // It's not possible to set global interceptors like headers // or the base url can; so we set them for all instances. diff --git a/resources/js/components/Checkout/CheckoutSuccess.vue b/resources/js/components/Checkout/CheckoutSuccess.vue index e59c83ab9..1ca75efdb 100644 --- a/resources/js/components/Checkout/CheckoutSuccess.vue +++ b/resources/js/components/Checkout/CheckoutSuccess.vue @@ -1,5 +1,6 @@ diff --git a/resources/js/filters.js b/resources/js/filters.js index 68603bb29..b4926efe5 100644 --- a/resources/js/filters.js +++ b/resources/js/filters.js @@ -6,12 +6,14 @@ Vue.filter('truncate', function (value, limit) { return value }) -Vue.filter('price', function (value) { +window.price = function (value) { return new Intl.NumberFormat(config.locale.replace('_', '-'), { style: 'currency', currency: config.currency, }).format(value) -}) +} + +Vue.filter('price', window.price) window.url = function (path = '') { // Transform urls starting with / into url with domain diff --git a/resources/js/jwt.js b/resources/js/jwt.js new file mode 100644 index 000000000..264d8851a --- /dev/null +++ b/resources/js/jwt.js @@ -0,0 +1,26 @@ +export const isJwt = function (token) { + return !!token.match(/^(?:[\w-]*\.){2}[\w-]*$/) +} +export const decode = function (token) { + if (!isJwt(token)) { + return null + } + const base64Url = token.split('.')[1] + const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/') + const jsonPayload = decodeURIComponent( + window + .atob(base64) + .split('') + .map(function (c) { + return '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2) + }) + .join(''), + ) + + let jwt = JSON.parse(jsonPayload) + jwt.isExpired = () => this.exp * 1000 < Date.now() + + return jwt +} + +export default { isJwt: isJwt, decode: decode } diff --git a/resources/js/mixins.js b/resources/js/mixins.js index df6e2f78d..9219a9429 100644 --- a/resources/js/mixins.js +++ b/resources/js/mixins.js @@ -13,5 +13,13 @@ Vue.mixin({ return await magento[method]('guest-carts/' + localStorage.mask + '/' + endpoint, data) } }, + + includeTaxAt(location) { + return location === true || location === false ? location : (window.config.tax.display[location] ?? 0) >= 2 + }, + + decideTax(including, excluding, location) { + return this.includeTaxAt(location) ? including : excluding + }, }, }) diff --git a/resources/js/stores/useCart.js b/resources/js/stores/useCart.js index 5e2c29f2b..cb648f0b1 100644 --- a/resources/js/stores/useCart.js +++ b/resources/js/stores/useCart.js @@ -1,7 +1,7 @@ import { useSessionStorage, StorageSerializers, useLocalStorage } from '@vueuse/core' import { computed, watch } from 'vue' import { mask, clear as clearMask } from './useMask' -import { token } from './useUser' +import { token, clear as clearUser } from './useUser' const cartStorage = useSessionStorage('cart', {}, { serializer: StorageSerializers.object }) let hasRefreshed = false @@ -29,7 +29,11 @@ export const refresh = async function () { cartStorage.value = !mask.value && !token.value ? {} : response.data window.app.$emit('cart-refreshed') } catch (error) { - if (error.response.status == 404) { + if ([401, 403].includes(error?.response?.status) && token.value) { + clearUser() + } + + if ([401, 403, 404].includes(error?.response?.status)) { mask.value = null return false } diff --git a/resources/js/stores/useUser.js b/resources/js/stores/useUser.js index 623b59b7f..8e7e49804 100644 --- a/resources/js/stores/useUser.js +++ b/resources/js/stores/useUser.js @@ -1,8 +1,23 @@ -import { useLocalStorage, useSessionStorage, StorageSerializers } from '@vueuse/core' +import { useSessionStorage, StorageSerializers } from '@vueuse/core' import { clear as clearCart } from './useCart' import { computed, watch } from 'vue' +import { useCookies } from '@vueuse/integrations/useCookies' +import Jwt from '../jwt' -export const token = useLocalStorage('token', '') +const cookies = useCookies(['customer_token']) + +export const token = computed({ + get() { + return cookies.get('customer_token') + }, + set(value) { + if (value === null || value === undefined || value === '') { + cookies.remove('customer_token') + } + + cookies.set('customer_token', value) + }, +}) const userStorage = useSessionStorage('user', {}, { serializer: StorageSerializers.object }) let isRefreshing = false @@ -12,6 +27,12 @@ export const refresh = async function () { return false } + if (Jwt.isJwt(token.value) && Jwt.decode(token.value)?.isExpired()) { + console.debug('Token has expired') + clear() + return false + } + if (isRefreshing) { console.debug('Refresh canceled, request already in progress...') return @@ -45,6 +66,11 @@ export const clear = async function () { export const user = computed({ get() { + if (!token.value && userStorage.value?.id) { + // Token has been removed externally + clear() + } + if (token.value && !userStorage.value?.id) { refresh() } diff --git a/resources/js/vue-components.js b/resources/js/vue-components.js index 8507ab70a..0d76af3e4 100644 --- a/resources/js/vue-components.js +++ b/resources/js/vue-components.js @@ -24,6 +24,9 @@ Vue.component('notifications', notifications) import images from './components/Product/Images.vue' Vue.component('images', images) +import price from './components/Product/Price.vue' +Vue.component('price', price) + Vue.component('autocomplete', () => import('./components/Search/Autocomplete.vue')) Vue.component('login', () => import('./components/Checkout/Login.vue')) Vue.component('listing', () => import('./components/Listing/Listing.vue')) diff --git a/resources/views/cart/overview.blade.php b/resources/views/cart/overview.blade.php index 5e6bcbc3b..ef9658cee 100644 --- a/resources/views/cart/overview.blade.php +++ b/resources/views/cart/overview.blade.php @@ -30,14 +30,14 @@
- @{{ item.price | price }} + @{{ $root.decideTax(item.price, item.price_excl_tax, 'cart_price') | price }}
- @{{ item.total | price }} + @{{ $root.decideTax(item.total, item.total_excl_tax, 'cart_price') | price }}
@@ -48,11 +48,11 @@
@lang('Subtotal')
-
@{{ cart.subtotal | price }}
+
@{{ $root.decideTax(cart.subtotal, cart.subtotal_excl_tax, 'cart_subtotal') | price }}
@lang('Tax')
@{{ cart.tax | price }}
@lang('Shipping')
@{{ cart.shipping_description }}
-
@{{ cart.shipping_amount | price }}
+
@{{ $root.decideTax(cart.shipping_amount, cart.shipping_amount_excl_tax, 'cart_shipping') | price }}
@lang('Discount'): @{{ cart.discount_name }}
@lang('Discount')
@{{ cart.discount_amount | price }}
diff --git a/resources/views/components/price.blade.php b/resources/views/components/price.blade.php new file mode 100644 index 000000000..d245b5747 --- /dev/null +++ b/resources/views/components/price.blade.php @@ -0,0 +1,29 @@ +@props(['product' => 'simpleProduct', 'type' => 'catalog', 'options' => '{}']) +@slots(['special']) + + +
twMerge('mt-1 flex items-center gap-2') }} + slot-scope="{ price, specialPrice, isDiscounted, range }" + > + + {{ $slot }} + + attributes->twMerge('text-13 font-normal line-through') }} + v-if="isDiscounted && !range" + v-text="$options.filters.price(price)" + > + {{ $special }} + + + + + + +
+
diff --git a/resources/views/layouts/partials/header/autocomplete.blade.php b/resources/views/layouts/partials/header/autocomplete.blade.php index 8ca7f4480..409737d17 100644 --- a/resources/views/layouts/partials/header/autocomplete.blade.php +++ b/resources/views/layouts/partials/header/autocomplete.blade.php @@ -56,7 +56,10 @@ class="{{ config('rapidez.frontend.z-indexes.header-dropdowns') }} absolute -ins
@{{ suggestion.source.name }} -
@{{ suggestion.source.price | price }}
+
diff --git a/resources/views/listing/partials/item.blade.php b/resources/views/listing/partials/item.blade.php index 4a6a62e62..bc657dab7 100644 --- a/resources/views/listing/partials/item.blade.php +++ b/resources/views/listing/partials/item.blade.php @@ -18,10 +18,12 @@ class="mb-3 h-48 w-full rounded-t object-contain" :alt="item.name" :loading="con
@{{ item.name }}
@if (!Rapidez::config('catalog/frontend/show_swatches_in_product_list', 1)) -
-
@{{ (item.special_price || item.price) | price }}
-
@{{ item.price | price }}
-
+ + + @endif
diff --git a/resources/views/listing/partials/item/addtocart.blade.php b/resources/views/listing/partials/item/addtocart.blade.php index f4db6d543..3454671a4 100644 --- a/resources/views/listing/partials/item/addtocart.blade.php +++ b/resources/views/listing/partials/item/addtocart.blade.php @@ -1,9 +1,6 @@
-
-
@{{ (simpleProduct.special_price || simpleProduct.price) | price }}
-
@{{ simpleProduct.price | price }}
-
+

@lang('Sorry! This product is currently out of stock.')

diff --git a/resources/views/product/partials/addtocart.blade.php b/resources/views/product/partials/addtocart.blade.php index 53666736c..c4cfd2e50 100644 --- a/resources/views/product/partials/addtocart.blade.php +++ b/resources/views/product/partials/addtocart.blade.php @@ -1,32 +1,24 @@ -
+

{{ $product->name }}

@if (!$product->in_stock)

@lang('Sorry! This product is currently out of stock.')

@else -
- @{{ superAttribute.label }} - - - -
+ @include('rapidez::product.partials.addtocart.configurations') + @include('rapidez::product.partials.addtocart.options') + @include('rapidez::product.partials.addtocart.tierprices') - @include('rapidez::product.partials.options') -
-
-
- {{ price($product->special_price ?: $product->price) }} -
-
+
+ + {{ price($product->special_price ?: $product->price) }} + {{ $product->special_price ? price($product->price) : '' }} -
-
+ + + - @for ($i = $product->qty_increments; $i <= $product->qty_increments * 10; $i += $product->qty_increments) + @for ($i = $product->qty_increments; $i <= $product->qty_increments * min(10, floor(($product->qty ?? $product->qty_increments * 10)/ $product->qty_increments)); $i += $product->qty_increments) @endfor + - - + + @lang('Add to cart') @lang('Adding')... @lang('Added') diff --git a/resources/views/product/partials/addtocart/configurations.blade.php b/resources/views/product/partials/addtocart/configurations.blade.php new file mode 100644 index 000000000..fe7414951 --- /dev/null +++ b/resources/views/product/partials/addtocart/configurations.blade.php @@ -0,0 +1,12 @@ +
+ @{{ superAttribute.label }} + + + +
diff --git a/resources/views/product/partials/options.blade.php b/resources/views/product/partials/addtocart/options.blade.php similarity index 66% rename from resources/views/product/partials/options.blade.php rename to resources/views/product/partials/addtocart/options.blade.php index 201a95716..f746bad6d 100644 --- a/resources/views/product/partials/options.blade.php +++ b/resources/views/product/partials/addtocart/options.blade.php @@ -2,7 +2,7 @@
@foreach($product->options->sortBy('sort_order') as $option)
- @include('rapidez::product.partials.options.'.$option->type_id) + @includeIf('rapidez::product.partials.addtocart.options.'.($option->type_id ?? $option->type))
@endforeach
diff --git a/resources/views/product/partials/addtocart/options/area.blade.php b/resources/views/product/partials/addtocart/options/area.blade.php new file mode 100644 index 000000000..f7767a81b --- /dev/null +++ b/resources/views/product/partials/addtocart/options/area.blade.php @@ -0,0 +1,21 @@ + + + + + + diff --git a/resources/views/product/partials/addtocart/options/drop_down.blade.php b/resources/views/product/partials/addtocart/options/drop_down.blade.php new file mode 100644 index 000000000..58db14bbc --- /dev/null +++ b/resources/views/product/partials/addtocart/options/drop_down.blade.php @@ -0,0 +1,25 @@ + + {{ $option->title }} + + + + diff --git a/resources/views/product/partials/addtocart/options/field.blade.php b/resources/views/product/partials/addtocart/options/field.blade.php new file mode 100644 index 000000000..edae5a6ef --- /dev/null +++ b/resources/views/product/partials/addtocart/options/field.blade.php @@ -0,0 +1,22 @@ + + + + + + + diff --git a/resources/views/product/partials/options/file.blade.php b/resources/views/product/partials/addtocart/options/file.blade.php similarity index 63% rename from resources/views/product/partials/options/file.blade.php rename to resources/views/product/partials/addtocart/options/file.blade.php index 8d3a819b0..5bd89d6bb 100644 --- a/resources/views/product/partials/options/file.blade.php +++ b/resources/views/product/partials/addtocart/options/file.blade.php @@ -1,5 +1,15 @@ - {{ $option->title }} {{ $option->price_label }} + + + +
    +
  • + @lang('Order :amount and pay :price per item', [ + 'amount' => '@{{ Math.round(tier.qty) }}', + 'price' => '@{{ calculatePrice({price: tier.price}) | price }}', + ]) +
  • +
+ diff --git a/resources/views/product/partials/grouped.blade.php b/resources/views/product/partials/grouped.blade.php index 6add24d86..1d4a966f2 100644 --- a/resources/views/product/partials/grouped.blade.php +++ b/resources/views/product/partials/grouped.blade.php @@ -4,10 +4,7 @@
@{{ simpleProduct.name }} -
-
@{{ (simpleProduct.special_price || simpleProduct.price) | price }}
-
@{{ simpleProduct.price | price }}
-
+

diff --git a/resources/views/product/partials/options/area.blade.php b/resources/views/product/partials/options/area.blade.php deleted file mode 100644 index 5d1aaec2e..000000000 --- a/resources/views/product/partials/options/area.blade.php +++ /dev/null @@ -1,11 +0,0 @@ - - {{ $option->title }} {{ $option->price_label }} - - diff --git a/resources/views/product/partials/options/drop_down.blade.php b/resources/views/product/partials/options/drop_down.blade.php deleted file mode 100644 index 7addfa89b..000000000 --- a/resources/views/product/partials/options/drop_down.blade.php +++ /dev/null @@ -1,16 +0,0 @@ - - {{ $option->title }} - - - - @foreach($option->values as $value) - - @endforeach - diff --git a/resources/views/product/partials/options/field.blade.php b/resources/views/product/partials/options/field.blade.php deleted file mode 100644 index 5e0b6ecda..000000000 --- a/resources/views/product/partials/options/field.blade.php +++ /dev/null @@ -1,12 +0,0 @@ - - {{ $option->title }} {{ $option->price_label }} - - - diff --git a/src/Auth/MagentoCustomerTokenGuard.php b/src/Auth/MagentoCustomerTokenGuard.php index 3d616cea2..21ba6bf5b 100644 --- a/src/Auth/MagentoCustomerTokenGuard.php +++ b/src/Auth/MagentoCustomerTokenGuard.php @@ -4,10 +4,21 @@ use Illuminate\Auth\TokenGuard; use Illuminate\Contracts\Auth\Guard; -use Illuminate\Http\Request; +use Lcobucci\JWT\Validation\RequiredConstraintsViolated; class MagentoCustomerTokenGuard extends TokenGuard implements Guard { + public function getTokenForRequest() + { + $token = parent::getTokenForRequest(); + + if (empty($token)) { + $token = $_COOKIE[$this->inputKey] ?? null; + } + + return $token; + } + /** * Get the currently authenticated user. * @@ -43,6 +54,15 @@ public function validate(array $credentials = []) protected function retrieveByToken($token) { - return config('rapidez.models.customer')::whereToken($token)->first(); + try { + return config('rapidez.models.customer')::whereToken($token)->first(); + } catch (RequiredConstraintsViolated $e) { + // Token is expired or invalid by incorrect signature. + if (isset($_COOKIE[$this->inputKey])) { + unset($_COOKIE[$this->inputKey]); + } + } + + return null; } } diff --git a/src/Http/Controllers/ProductController.php b/src/Http/Controllers/ProductController.php index 461dc4c42..8cf98bdc8 100644 --- a/src/Http/Controllers/ProductController.php +++ b/src/Http/Controllers/ProductController.php @@ -13,9 +13,26 @@ public function show(int $productId) $product = $productModel::selectForProductPage() ->withEventyGlobalScopes('productpage.scopes') ->with('options') + ->with('tierPrices') ->findOrFail($productId); - $attributes = ['entity_id', 'name', 'sku', 'super_attributes', 'children', 'grouped', 'options', 'price', 'special_price', 'images', 'url', 'min_sale_qty']; + $attributes = [ + 'entity_id', + 'name', + 'sku', + 'super_attributes', + 'children', + 'grouped', + 'options', + 'price', + 'special_price', + 'tax_class_id', + 'tierPrices', + 'images', + 'url', + 'min_sale_qty', + ]; + $attributes = Eventy::filter('productpage.frontend.attributes', $attributes); foreach ($product->super_attributes ?: [] as $superAttribute) { diff --git a/src/Http/ViewComposers/ConfigComposer.php b/src/Http/ViewComposers/ConfigComposer.php index 331d5223e..77d2aef70 100644 --- a/src/Http/ViewComposers/ConfigComposer.php +++ b/src/Http/ViewComposers/ConfigComposer.php @@ -8,6 +8,7 @@ use Illuminate\Support\Str; use Illuminate\View\View; use Rapidez\Core\Facades\Rapidez; +use Rapidez\Core\Models\TaxCalculation; class ConfigComposer { @@ -42,6 +43,7 @@ public function compose(View $view) Config::set('frontend.customer_fields_show', $this->getCustomerFields()); Config::set('frontend.grid_per_page', Rapidez::config('catalog/frontend/grid_per_page', 12)); Config::set('frontend.grid_per_page_values', explode(',', Rapidez::config('catalog/frontend/grid_per_page_values', '12,24,36'))); + Config::set('frontend.tax', $this->getTaxConfiguration()); } public function getCustomerFields() @@ -60,4 +62,35 @@ public function getCustomerFields() 'company' => Rapidez::config('customer/address/company_show', 'opt'), ]; } + + public function getTaxConfiguration() + { + $customerGroupId = auth('magento-customer')->user()?->group_id ?? 0; + $values = Cache::remember('tax-configuration-' . $customerGroupId, 3600, function () use ($customerGroupId) { + return TaxCalculation::select('tax_country_id', 'tax_region_id', 'tax_postcode', 'rate', 'product_tax_class_id') + ->whereHas('customerGroups', fn ($query) => $query->where('customer_group_id', $customerGroupId)) + ->get() + ->groupBy('product_tax_class_id'); + }); + + return [ + 'rates' => $values, + 'calculation' => [ + 'price_includes_tax' => boolval(Rapidez::config('tax/calculation/price_includes_tax', 0)), + 'based_on' => Rapidez::config('tax/calculation/based_on', 'shipping'), + ], + 'display' => [ + 'catalog' => Rapidez::config('tax/display/type', 1), + 'shipping' => Rapidez::config('tax/display/shipping', 1), + 'cart_price' => Rapidez::config('tax/cart_display/price', 1), + 'cart_shipping' => Rapidez::config('tax/cart_display/shipping', 1), + 'cart_subtotal' => Rapidez::config('tax/cart_display/subtotal', 1), + ], + 'defaults' => [ + 'country_id' => Rapidez::config('tax/defaults/country', 'US'), + 'postcode' => Rapidez::config('tax/defaults/postcode', null), + 'region_id' => Rapidez::config('tax/defaults/region', 0), + ], + ]; + } } diff --git a/src/Models/Customer.php b/src/Models/Customer.php index e406aa752..80349d1ed 100644 --- a/src/Models/Customer.php +++ b/src/Models/Customer.php @@ -27,6 +27,11 @@ public function oauthTokens() return $this->hasMany(config('rapidez.models.oauth_token'), 'customer_id'); } + public function customerGroup() + { + return $this->hasOne(config('rapidez.models.customer_group'), 'customer_group_id', 'group_id'); + } + public function getRememberTokenName() { return ''; @@ -38,7 +43,7 @@ public function scopeWhereToken(Builder $query, string $token) DecodeJwt::isJwt($token), fn (Builder $query) => $query ->where( - $this->qualifyColumn('customer_id'), + $this->getQualifiedKeyName(), DecodeJwt::decode($token) ->claims() ->get('uid') diff --git a/src/Models/CustomerGroup.php b/src/Models/CustomerGroup.php new file mode 100644 index 000000000..9984161bc --- /dev/null +++ b/src/Models/CustomerGroup.php @@ -0,0 +1,18 @@ +hasMany(config('rapidez.models.tax_calculation'), 'customer_tax_class_id', 'tax_class_id'); + } +} diff --git a/src/Models/Product.php b/src/Models/Product.php index a4fc372bd..e2845d1ec 100644 --- a/src/Models/Product.php +++ b/src/Models/Product.php @@ -10,6 +10,7 @@ use Rapidez\Core\Casts\CommaSeparatedToArray; use Rapidez\Core\Casts\CommaSeparatedToIntegerArray; use Rapidez\Core\Casts\DecodeHtmlEntities; +use Rapidez\Core\Facades\Rapidez; use Rapidez\Core\Models\Scopes\Product\WithProductAttributesScope; use Rapidez\Core\Models\Scopes\Product\WithProductCategoryInfoScope; use Rapidez\Core\Models\Scopes\Product\WithProductChildrenScope; @@ -34,7 +35,7 @@ class Product extends Model protected $primaryKey = 'entity_id'; - protected $appends = ['url']; + protected $appends = ['url', 'price_without_tax']; protected static function booting(): void { @@ -92,14 +93,6 @@ public function gallery(): BelongsToMany ); } - public function views(): HasMany - { - return $this->hasMany( - config('rapidez.models.product_view'), - 'product_id', - ); - } - public function options(): HasMany { return $this->hasMany( @@ -116,22 +109,62 @@ public function rewrites(): HasMany ->where('entity_type', 'product'); } + public function tierPrices(): HasMany + { + return $this->hasMany( + config('rapidez.models.product_tier_price'), + 'entity_id' + ) + ->whereIn('website_id', [0, config('rapidez.website')]) + ->where(fn ($query) => $query + ->where('all_groups', 1) + ->orWhere('customer_group_id', auth('magento-customer')->user()?->group_id ?? 0) + ); + } + + public function views(): HasMany + { + return $this->hasMany( + config('rapidez.models.product_view'), + 'product_id', + ); + } + public function scopeByIds(Builder $query, array $productIds): Builder { return $query->whereIn($this->getQualifiedKeyName(), $productIds); } - public function getPriceAttribute($price) + public function price(): Attribute { - if ($this->type_id == 'configurable') { - return collect($this->children)->min->price; - } + return Attribute::make( + get: function ($price) { + if ($this->type_id == 'configurable') { + $price = collect($this->children)->min->price; + } - if ($this->type_id == 'grouped') { - return collect($this->grouped)->min->price; - } + if ($this->type_id == 'grouped') { + $price = collect($this->grouped)->min->price; + } + + if (Rapidez::config('tax/calculation/price_includes_tax', 0)) { + return $price; + } - return $price; + // TODO: Get the tax from the settings. + return $price * 1.21; + } + ); + } + + public function priceWithoutTax(): Attribute + { + return Attribute::make( + get: function () { + // TODO: Get the tax from the settings. + return $this->price / 1.21; + } + ); } public function getSpecialPriceAttribute($specialPrice) diff --git a/src/Models/ProductOption.php b/src/Models/ProductOption.php index a8e157207..8d6440765 100644 --- a/src/Models/ProductOption.php +++ b/src/Models/ProductOption.php @@ -30,7 +30,7 @@ public function product() protected function title(): Attribute { return Attribute::make( - get: fn () => $this->titles->firstForCurrentStore()->title, + get: fn () => $this->titles->firstForCurrentStore()->title ?? null, )->shouldCache(); } diff --git a/src/Models/ProductOptionTypeValue.php b/src/Models/ProductOptionTypeValue.php index d0f9bc55e..2413ac775 100644 --- a/src/Models/ProductOptionTypeValue.php +++ b/src/Models/ProductOptionTypeValue.php @@ -26,7 +26,7 @@ public function option() protected function title(): Attribute { return Attribute::make( - get: fn () => $this->titles->firstForCurrentStore()->title, + get: fn () => $this->titles->firstForCurrentStore()->title ?? null, )->shouldCache(); } diff --git a/src/Models/ProductTierPrice.php b/src/Models/ProductTierPrice.php new file mode 100644 index 000000000..02b40f33a --- /dev/null +++ b/src/Models/ProductTierPrice.php @@ -0,0 +1,17 @@ +belongsTo(config('rapidez.models.product'), 'entity_id'); + } +} diff --git a/src/Models/QuoteItemOption.php b/src/Models/QuoteItemOption.php index 7fa7cf84e..9fb2d9423 100644 --- a/src/Models/QuoteItemOption.php +++ b/src/Models/QuoteItemOption.php @@ -48,7 +48,7 @@ protected function value(): Attribute protected function label(): Attribute { return Attribute::make( - get: fn () => $this->option?->titles->firstForCurrentStore()->title + get: fn () => $this->option?->titles->firstForCurrentStore()->title ?? null )->shouldCache(); } diff --git a/src/Models/Scopes/Product/WithProductAttributesScope.php b/src/Models/Scopes/Product/WithProductAttributesScope.php index 8187d79a6..5cb2bd2b5 100644 --- a/src/Models/Scopes/Product/WithProductAttributesScope.php +++ b/src/Models/Scopes/Product/WithProductAttributesScope.php @@ -27,6 +27,7 @@ public function apply(Builder $builder, Model $model) $model->qualifyColumn('sku'), $model->qualifyColumn('visibility'), $model->qualifyColumn('type_id'), + $model->qualifyColumn('tax_class_id'), $model->getQualifiedCreatedAtColumn(), ]); diff --git a/src/Models/Scopes/Product/WithProductChildrenScope.php b/src/Models/Scopes/Product/WithProductChildrenScope.php index ac316202f..33a5e72ab 100644 --- a/src/Models/Scopes/Product/WithProductChildrenScope.php +++ b/src/Models/Scopes/Product/WithProductChildrenScope.php @@ -30,6 +30,7 @@ public function apply(Builder $builder, Model $model) $builder ->selectRaw('JSON_REMOVE(JSON_OBJECTAGG(IFNULL(children.entity_id, "null__"), JSON_OBJECT( ' . Eventy::filter('product.children.select', << $builder + ->join('tax_calculation_rate', 'tax_calculation.tax_calculation_rate_id', '=', 'tax_calculation_rate.tax_calculation_rate_id') + ); + } + + public function customerGroups() + { + return $this->hasOne(config('rapidez.models.customer_group'), 'tax_class_id', 'customer_tax_class_id'); + } + + public function products() + { + return $this->hasMany(config('rapidez.models.product'), 'tax_class_id', 'product_tax_class_id'); + } +} diff --git a/src/RapidezServiceProvider.php b/src/RapidezServiceProvider.php index 199d80f43..2b64d26b9 100644 --- a/src/RapidezServiceProvider.php +++ b/src/RapidezServiceProvider.php @@ -73,7 +73,7 @@ public function register() protected function bootAuth(): self { auth()->extend('magento-customer', function (Application $app, string $name, array $config) { - return new MagentoCustomerTokenGuard(auth()->createUserProvider($config['provider']), request(), 'token', 'token'); + return new MagentoCustomerTokenGuard(auth()->createUserProvider($config['provider']), request(), 'customer_token', 'customer_token'); }); config([ diff --git a/tests/Browser/PriceTest.php b/tests/Browser/PriceTest.php new file mode 100644 index 000000000..6f1192add --- /dev/null +++ b/tests/Browser/PriceTest.php @@ -0,0 +1,91 @@ +browseWithConfigs([ + 'tax/calculation/price_includes_tax' => 0, + 'tax/display/type' => 1, + ], function (Browser $browser) { + $browser + ->visit($this->testProduct->url) + ->assertSeePrice($this->testProduct->price_without_tax); + }); + } + + /** + * @test + */ + public function priceBaseWithoutTaxShowWithTax() + { + $this->browseWithConfigs([ + 'tax/calculation/price_includes_tax' => 0, + 'tax/display/type' => 2, + ], function (Browser $browser) { + $browser + ->visit($this->testProduct->url) + ->assertSeePrice($this->testProduct->price); + }); + } + + /** + * @test + */ + public function priceBaseWithTaxShowWithoutTax() + { + dump($this->testProduct->price_without_tax); + $this->browseWithConfigs([ + 'tax/calculation/price_includes_tax' => 1, + 'tax/display/type' => 1, + ], function (Browser $browser) { + $browser + ->visit($this->testProduct->url) + ->assertSeePrice($this->testProduct->price_without_tax); + }); + } + + /** + * @test + */ + public function priceBaseWithTaxShowWithTax() + { + $this->browseWithConfigs([ + 'tax/calculation/price_includes_tax' => 1, + 'tax/display/type' => 2, + ], function (Browser $browser) { + $browser + ->visit($this->testProduct->url) + ->assertSeePrice($this->testProduct->price); + }); + } + + /** + * @test + */ + public function specialPrice() + { + $previousSpecialPrice = $this->testProduct->special_price; + $this->testProduct->newQueryWithoutScopes()->update(['special_price' => 20]); + + try { + $this->browse(function (Browser $browser) { + $browser + ->visit($this->testProduct->url) + ->assertSeePrice($this->testProduct->refresh()->special_price); + }); + } finally { + $this->testProduct->newQueryWithoutScopes()->update(['special_price' => $previousSpecialPrice]); + } + } + + // TODO: Test special price dates. +} diff --git a/tests/DuskTestCase.php b/tests/DuskTestCase.php index 70913ce36..4894719e3 100644 --- a/tests/DuskTestCase.php +++ b/tests/DuskTestCase.php @@ -8,6 +8,7 @@ use Rapidez\BladeDirectives\BladeDirectivesServiceProvider; use Rapidez\Core\Facades\Rapidez; use Rapidez\Core\RapidezServiceProvider; +use TailwindMerge\Laravel\TailwindMergeServiceProvider; use TorMorten\Eventy\EventServiceProvider; abstract class DuskTestCase extends BaseTestCase @@ -17,11 +18,12 @@ abstract class DuskTestCase extends BaseTestCase protected function getPackageProviders($app) { return [ + BladeDirectivesServiceProvider::class, + BladeHeroiconsServiceProvider::class, + BladeIconsServiceProvider::class, EventServiceProvider::class, RapidezServiceProvider::class, - BladeIconsServiceProvider::class, - BladeHeroiconsServiceProvider::class, - BladeDirectivesServiceProvider::class, + TailwindMergeServiceProvider::class, ]; } diff --git a/tests/DuskTestCaseSetup.php b/tests/DuskTestCaseSetup.php index 93c6e3806..588349e7c 100644 --- a/tests/DuskTestCaseSetup.php +++ b/tests/DuskTestCaseSetup.php @@ -2,6 +2,8 @@ namespace Rapidez\Core\Tests; +use Closure; +use Illuminate\Support\Facades\DB; use Laravel\Dusk\Browser; use PHPUnit\Framework\Assert; use Rapidez\Core\Models\Product; @@ -56,12 +58,136 @@ protected function setUp(): void return $this; }); + Browser::macro('assertSeePrice', function ($price) { + /** @var Browser $this */ + $this->assertSee(preg_replace("/\s+/u", ' ', price($price))); + + return $this; + }); + $this->flat = (new Product)->getTable(); $this->testProduct = Product::selectAttributes([ 'name', 'price', + 'special_price', 'url_key', ])->firstWhere($this->flat . '.sku', env('TEST_PRODUCT', '24-WB02')); + + // We're running with the database from the + // michielgerritsen/magento-project-community-edition + // image and the tax is not configured yet. + if (DB::table('admin_user')->where('email', 'user@example.com')->exists() + && DB::table('tax_calculation_rate')->where('code', 'Default Tax Rate NL')->doesntExist()) { + $this->configureTax(); + } + } + + protected function configureTax() + { + $taxRateDefaultId = DB::table('tax_calculation_rate')->insertGetId([ + 'tax_country_id' => 'NL', + 'tax_region_id' => 0, + 'tax_postcode' => '*', + 'code' => 'Default Tax Rate NL', + 'rate' => 21, + ]); + + $taxRateNoneId = DB::table('tax_calculation_rate')->insertGetId([ + 'tax_country_id' => 'NL', + 'tax_region_id' => 0, + 'tax_postcode' => '*', + 'code' => 'No Tax Rate NL', + 'rate' => 0, + ]); + + $taxRuleDefaultId = DB::table('tax_calculation_rule')->insertGetId([ + 'code' => 'Default Tax Rate NL', + 'priority' => 0, + 'position' => 0, + 'calculate_subtotal' => 0, + ]); + + $taxRuleNoneId = DB::table('tax_calculation_rule')->insertGetId([ + 'code' => 'No Tax Rate NL', + 'priority' => 0, + 'position' => 0, + 'calculate_subtotal' => 0, + ]); + + $retailCustomerClassId = DB::table('tax_class')->where('class_name', 'Retail Customer')->pluck('class_id')->first(); + + $noTaxClassId = DB::table('tax_class')->insertGetId([ + 'class_name' => 'No Tax', + 'class_type' => 'CUSTOMER', + ]); + + $productTaxClassId = DB::table('tax_class')->where('class_name', 'Taxable Goods')->pluck('class_id')->first(); + + DB::table('tax_calculation')->insert([ + [ + 'tax_calculation_rate_id' => $taxRateDefaultId, + 'tax_calculation_rule_id' => $taxRuleDefaultId, + 'customer_tax_class_id' => $retailCustomerClassId, + 'product_tax_class_id' => $productTaxClassId, + ], + [ + 'tax_calculation_rate_id' => $taxRateNoneId, + 'tax_calculation_rule_id' => $taxRuleNoneId, + 'customer_tax_class_id' => $noTaxClassId, + 'product_tax_class_id' => $productTaxClassId, + ], + ]); + + DB::table('core_config_data')->upsert( + [ + [ + 'scope' => 'default', + 'scope_id' => 0, + 'path' => 'tax/defaults/country', + 'value' => 'NL', + ], + [ + 'scope' => 'default', + 'scope_id' => 0, + 'path' => 'tax/display/type', + 'value' => 2, + ], + [ + 'scope' => 'default', + 'scope_id' => 0, + 'path' => 'tax/display/shipping', + 'value' => 2, + ], + ], + ['scope', 'scope_id', 'path'], + ['value'] + ); + + DB::table('customer_group')->where('customer_group_code', 'General')->update(['tax_class_id' => $noTaxClassId]); + } + + public function browseWithConfigs(array $configs, Closure $callback) + { + $configModel = config('rapidez.models.config'); + + try { + // First we get the current value and store the new value. + foreach ($configs as $path => $value) { + $previousValues[$path] = $configModel::where(compact('path'))->first(); + $configModel::query()->updateOrInsert(compact('path'), compact('value')); + } + + $this->browse($callback); + } finally { + // Afterwards we update the previous values or remove new ones. + foreach ($configs as $path => $value) { + if ($previousValues[$path]) { + $previousValues[$path]->query()->update(['value' => $previousValues[$path]->value]); + } else { + $configModel::query()->where(compact('path'))->delete(); + } + } + } } } diff --git a/yarn.lock b/yarn.lock index 4de33064b..1a1233983 100644 --- a/yarn.lock +++ b/yarn.lock @@ -344,6 +344,11 @@ lodash.merge "^4.6.2" postcss-selector-parser "6.0.10" +"@types/cookie@^0.5.1": + version "0.5.3" + resolved "https://registry.yarnpkg.com/@types/cookie/-/cookie-0.5.3.tgz#3f98076ede5e467783507284d3c19215327fff8f" + integrity sha512-SLg07AS9z1Ab2LU+QxzU8RCmzsja80ywjf/t5oqw+4NSH20gIGlhLOrBDm1L3PBWzPa4+wkgFQVZAjE6Ioj2ug== + "@types/parse-json@^4.0.0": version "4.0.0" resolved "https://registry.yarnpkg.com/@types/parse-json/-/parse-json-4.0.0.tgz#2f8bb441434d163b35fb8ffdccd7138927ffb8c0" @@ -354,6 +359,11 @@ resolved "https://registry.yarnpkg.com/@types/web-bluetooth/-/web-bluetooth-0.0.16.tgz#1d12873a8e49567371f2a75fe3e7f7edca6662d8" integrity sha512-oh8q2Zc32S6gd/j50GowEjKLoOVOwHP/bWVjKJInBwQqdOYMdPrf1oVlelTlyfFK3CKxL1uahMDAr+vy8T7yMQ== +"@types/web-bluetooth@^0.0.18": + version "0.0.18" + resolved "https://registry.yarnpkg.com/@types/web-bluetooth/-/web-bluetooth-0.0.18.tgz#74bd1c8fd3a2058cb6fc76b188fcded50a83d866" + integrity sha512-v/ZHEj9xh82usl8LMR3GarzFY1IrbXJw5L4QfQhokjRV91q+SelFqxQWSep1ucXEZ22+dSTwLFkXeur25sPIbw== + "@vitejs/plugin-vue2@^2.2.0": version "2.2.0" resolved "https://registry.yarnpkg.com/@vitejs/plugin-vue2/-/plugin-vue2-2.2.0.tgz#7453207197d6ac2b7023cedc7133b142c604c356" @@ -373,6 +383,16 @@ postcss "^8.4.14" source-map "^0.6.1" +"@vueuse/core@10.5.0": + version "10.5.0" + resolved "https://registry.yarnpkg.com/@vueuse/core/-/core-10.5.0.tgz#04d1e6d26592bb997bb755a4830ea7583c3e8612" + integrity sha512-z/tI2eSvxwLRjOhDm0h/SXAjNm8N5ld6/SC/JQs6o6kpJ6Ya50LnEL8g5hoYu005i28L0zqB5L5yAl8Jl26K3A== + dependencies: + "@types/web-bluetooth" "^0.0.18" + "@vueuse/metadata" "10.5.0" + "@vueuse/shared" "10.5.0" + vue-demi ">=0.14.6" + "@vueuse/core@^9.12.0": version "9.12.0" resolved "https://registry.yarnpkg.com/@vueuse/core/-/core-9.12.0.tgz#e5b20f901e081c7ae5fe0e5f3af217929034eefe" @@ -383,11 +403,32 @@ "@vueuse/shared" "9.12.0" vue-demi "*" +"@vueuse/integrations@^10.5.0": + version "10.5.0" + resolved "https://registry.yarnpkg.com/@vueuse/integrations/-/integrations-10.5.0.tgz#38f00bd5a1cd0160645f0c75efd5d9579061e3d6" + integrity sha512-fm5sXLCK0Ww3rRnzqnCQRmfjDURaI4xMsx+T+cec0ngQqHx/JgUtm8G0vRjwtonIeTBsH1Q8L3SucE+7K7upJQ== + dependencies: + "@vueuse/core" "10.5.0" + "@vueuse/shared" "10.5.0" + vue-demi ">=0.14.6" + +"@vueuse/metadata@10.5.0": + version "10.5.0" + resolved "https://registry.yarnpkg.com/@vueuse/metadata/-/metadata-10.5.0.tgz#7501a88cf5cbf7a515a03f0b8bbe3cecf30cad11" + integrity sha512-fEbElR+MaIYyCkeM0SzWkdoMtOpIwO72x8WsZHRE7IggiOlILttqttM69AS13nrDxosnDBYdyy3C5mR1LCxHsw== + "@vueuse/metadata@9.12.0": version "9.12.0" resolved "https://registry.yarnpkg.com/@vueuse/metadata/-/metadata-9.12.0.tgz#19a0fefcba6a66a2382af10a7a67ebad6eec1f27" integrity sha512-9oJ9MM9lFLlmvxXUqsR1wLt1uF7EVbP5iYaHJYqk+G2PbMjY6EXvZeTjbdO89HgoF5cI6z49o2zT/jD9SVoNpQ== +"@vueuse/shared@10.5.0": + version "10.5.0" + resolved "https://registry.yarnpkg.com/@vueuse/shared/-/shared-10.5.0.tgz#b3ac8c190a5dae41db5e1b60fe304a9b4247393c" + integrity sha512-18iyxbbHYLst9MqU1X1QNdMHIjks6wC7XTVf0KNOv5es/Ms6gjVFCAAWTVP2JStuGqydg3DT+ExpFORUEi9yhg== + dependencies: + vue-demi ">=0.14.6" + "@vueuse/shared@9.12.0": version "9.12.0" resolved "https://registry.yarnpkg.com/@vueuse/shared/-/shared-9.12.0.tgz#e6597da80084cba8fc3d6545f4c2fa9817b80428" @@ -640,6 +681,11 @@ convert-source-map@^1.5.0, convert-source-map@^1.5.1: dependencies: safe-buffer "~5.1.1" +cookie@^0.5.0: + version "0.5.0" + resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.5.0.tgz#d1f5d71adec6558c58f389987c366aa47e994f8b" + integrity sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw== + core-js@^3.6.5: version "3.26.1" resolved "https://registry.yarnpkg.com/core-js/-/core-js-3.26.1.tgz#7a9816dabd9ee846c1c0fe0e8fcad68f3709134e" @@ -1693,6 +1739,14 @@ ts-interface-checker@^0.1.9: resolved "https://registry.yarnpkg.com/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz#784fd3d679722bc103b1b4b8030bcddb5db2a699" integrity sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA== +universal-cookie@^6.1.1: + version "6.1.1" + resolved "https://registry.yarnpkg.com/universal-cookie/-/universal-cookie-6.1.1.tgz#2d619e21804c93b39ff0c77fe47dafd7a492346d" + integrity sha512-33S9x3CpdUnnjwTNs2Fgc41WGve2tdLtvaK2kPSbZRc5pGpz2vQFbRWMxlATsxNNe/Cy8SzmnmbuBM85jpZPtA== + dependencies: + "@types/cookie" "^0.5.1" + cookie "^0.5.0" + update-browserslist-db@^1.0.11: version "1.0.11" resolved "https://registry.yarnpkg.com/update-browserslist-db/-/update-browserslist-db-1.0.11.tgz#9a2a641ad2907ae7b3616506f4b977851db5b940" @@ -1758,6 +1812,11 @@ vue-demi@*: resolved "https://registry.yarnpkg.com/vue-demi/-/vue-demi-0.13.11.tgz#7d90369bdae8974d87b1973564ad390182410d99" integrity sha512-IR8HoEEGM65YY3ZJYAjMlKygDQn25D5ajNFNoKh9RSDMQtlzCxtfQjdQgv9jjK+m3377SsJXY8ysq8kLCZL25A== +vue-demi@>=0.14.6: + version "0.14.6" + resolved "https://registry.yarnpkg.com/vue-demi/-/vue-demi-0.14.6.tgz#dc706582851dc1cdc17a0054f4fec2eb6df74c92" + integrity sha512-8QA7wrYSHKaYgUxDA5ZC24w+eHm3sYCbp0EzcDwKqN3p6HqtTCGR/GVsPyZW92unff4UlcSh++lmqDWN3ZIq4w== + vue-highlight-words@^1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/vue-highlight-words/-/vue-highlight-words-1.2.0.tgz#73c2d49a46ecdb0638358b7ad749013c190ece5c"