diff --git a/config/shop.php b/config/shop.php index 8df75e9..6ac97f7 100644 --- a/config/shop.php +++ b/config/shop.php @@ -240,6 +240,10 @@ return [ // Cart configuration 'cart' => [ + // Adding a ProductPrice of a plain product stores the product with that price as the + // line (name, photo, checkout, invoice show the product and option). Off by default. + 'prices_as_options' => env('SHOP_CART_PRICES_AS_OPTIONS', false), + 'expire_after_days' => 30, 'auto_cleanup' => true, 'merge_on_login' => true, diff --git a/src/Http/Resources/CartItemResource.php b/src/Http/Resources/CartItemResource.php index f8a8cbb..8af1197 100644 --- a/src/Http/Resources/CartItemResource.php +++ b/src/Http/Resources/CartItemResource.php @@ -33,12 +33,18 @@ class CartItemResource extends JsonResource 'regular_price' => $this->regular_price === null ? null : (int) $this->regular_price, 'subtotal' => $this->subtotal === null ? null : (int) $this->subtotal, 'currency' => $this->currency, + // The chosen price (a product sold in several options) and what the buyer + // attached to this line (e.g. a brief); both null/empty when unused. + 'price_id' => $this->price_id, + 'parameters' => (object) ((array) ($this->parameters ?? [])), 'purchasable' => $purchasable ? [ 'id' => $purchasable->getKey(), 'name' => $purchasable->name ?? null, 'slug' => $purchasable->slug ?? null, 'type' => $this->purchasableType($purchasable), 'image' => $this->resolveImage($purchasable), + // Virtual products (services, downloads) need no shipping. + 'virtual' => (bool) ($purchasable->virtual ?? false), ] : null, ]; } diff --git a/src/Models/Cart.php b/src/Models/Cart.php index 9a4c64d..606c504 100644 --- a/src/Models/Cart.php +++ b/src/Models/Cart.php @@ -1034,6 +1034,22 @@ class Cart extends Model $is_booking = $cartable->purchasable->isBooking(); } + // Price options (shop.cart.prices_as_options): a ProductPrice of a plain Product is + // stored as that Product with the chosen price_id, so the cart, the checkout line and + // the invoice show the product (and the option), not just the price row. + $itemPurchasable = $cartable; + $optionPrice = null; + if ( + config('shop.cart.prices_as_options', false) + && $cartable instanceof ProductPrice + && $cartable->purchasable instanceof Product + && ! $is_pool + && ! $is_booking + ) { + $optionPrice = $cartable; + $itemPurchasable = $cartable->purchasable; + } + if ($is_booking) { // Extract dates from parameters if not provided directly if (! $from && isset($parameters['from'])) { @@ -1225,8 +1241,9 @@ class Cart extends Model // Check if item already exists in cart with same parameters, dates, AND price $existingItem = $this->items() - ->where('purchasable_id', $cartable->getKey()) - ->where('purchasable_type', get_class($cartable)) + ->where('purchasable_id', $itemPurchasable->getKey()) + ->where('purchasable_type', get_class($itemPurchasable)) + ->when($optionPrice, fn ($q) => $q->where('price_id', $optionPrice->getKey())) ->get() ->first(function ($item) use ($parameters, $from, $until, $cartable, $poolPriceId, $is_pool) { $existingParams = is_array($item->parameters) @@ -1400,8 +1417,8 @@ class Cart extends Model // Create new cart item $cartItem = $this->items()->create([ - 'purchasable_id' => $cartable->getKey(), - 'purchasable_type' => get_class($cartable), + 'purchasable_id' => $itemPurchasable->getKey(), + 'purchasable_type' => get_class($itemPurchasable), 'product_id' => ($cartable instanceof Product && $cartable->isPool() && $poolSingleItem) ? $poolSingleItem->id : null, 'price_id' => $priceId, 'quantity' => $quantity, diff --git a/tests/Unit/Resources/CartItemResourceTest.php b/tests/Unit/Resources/CartItemResourceTest.php new file mode 100644 index 0000000..c9652c8 --- /dev/null +++ b/tests/Unit/Resources/CartItemResourceTest.php @@ -0,0 +1,78 @@ + true]); + + $service = Product::factory()->create(['name' => 'Portrait', 'manage_stock' => false, 'virtual' => true]); + ProductPrice::factory()->create(['purchasable_id' => $service->id, 'purchasable_type' => Product::class, 'unit_amount' => 5000, 'is_default' => true, 'name' => 'A5']); + $a4 = ProductPrice::factory()->create(['purchasable_id' => $service->id, 'purchasable_type' => Product::class, 'unit_amount' => 7000, 'is_default' => false, 'name' => 'A4']); + + $physical = Product::factory()->withPrices(unit_amount: 1000)->create(['name' => 'Keychain', 'manage_stock' => false]); + + $cart = Cart::create(['currency' => 'EUR']); + $cart->addToCart($a4, 1, ['brief' => 'Two cats in space']); + $cart->addToCart($physical, 1); + + $items = collect(CartResource::make($cart->fresh())->toArray(new Request)['items']->toArray(new Request))->keyBy(fn ($i) => $i['purchasable']['name']); + + $this->assertSame($a4->id, $items['Portrait']['price_id']); + $this->assertSame('Two cats in space', ((array) $items['Portrait']['parameters'])['brief']); + $this->assertTrue($items['Portrait']['purchasable']['virtual']); + $this->assertSame(7000, $items['Portrait']['price']); + + $this->assertSame([], (array) $items['Keychain']['parameters']); + $this->assertFalse($items['Keychain']['purchasable']['virtual']); + } + + #[Test] + public function without_price_options_a_price_stays_the_purchasable() + { + $service = Product::factory()->create(['name' => 'Portrait', 'manage_stock' => false]); + $a4 = ProductPrice::factory()->create(['purchasable_id' => $service->id, 'purchasable_type' => Product::class, 'unit_amount' => 7000, 'is_default' => true, 'name' => 'A4']); + + $cart = Cart::create(['currency' => 'EUR']); + $item = $cart->addToCart($a4, 1); + + $this->assertSame(ProductPrice::class, $item->purchasable_type); + $this->assertSame($a4->id, $item->purchasable_id); + } + + #[Test] + public function price_options_merge_only_with_the_same_option() + { + config(['shop.cart.prices_as_options' => true]); + + $service = Product::factory()->create(['name' => 'Portrait', 'manage_stock' => false]); + $a5 = ProductPrice::factory()->create(['purchasable_id' => $service->id, 'purchasable_type' => Product::class, 'unit_amount' => 5000, 'is_default' => true, 'name' => 'A5']); + $a4 = ProductPrice::factory()->create(['purchasable_id' => $service->id, 'purchasable_type' => Product::class, 'unit_amount' => 7000, 'is_default' => false, 'name' => 'A4']); + + $cart = Cart::create(['currency' => 'EUR']); + $cart->addToCart($a4, 1); + $cart->addToCart($a4, 1); + $cart->addToCart($a5, 1); + + $items = $cart->fresh()->items; + $this->assertCount(2, $items); + $this->assertSame(2, (int) $items->firstWhere('price_id', $a4->id)->quantity); + $this->assertSame(14000, (int) $items->firstWhere('price_id', $a4->id)->subtotal); + $this->assertSame(5000, (int) $items->firstWhere('price_id', $a5->id)->price); + $this->assertTrue($items->every(fn ($i) => $i->purchasable_type === Product::class)); + } +}