feat(cart): price options and richer cart items (both additive)
- shop.cart.prices_as_options (env SHOP_CART_PRICES_AS_OPTIONS, default off): adding a ProductPrice of a plain (non-pool, non-booking) product stores the product as the line with that price_id, so cart, checkout line and invoice show the product and its option. Lines merge only with the same option. - CartItemResource adds price_id, parameters (what the buyer attached, e.g. a brief) and purchasable.virtual (no shipping needed). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
961ed5af4c
commit
4f0084b7e4
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,78 @@
|
|||
<?php
|
||||
|
||||
namespace Blax\Shop\Tests\Unit\Resources;
|
||||
|
||||
use Blax\Shop\Http\Resources\CartResource;
|
||||
use Blax\Shop\Models\Cart;
|
||||
use Blax\Shop\Models\Product;
|
||||
use Blax\Shop\Models\ProductPrice;
|
||||
use Blax\Shop\Tests\TestCase;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Http\Request;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
|
||||
class CartItemResourceTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
#[Test]
|
||||
public function items_carry_the_chosen_price_their_parameters_and_the_virtual_flag()
|
||||
{
|
||||
config(['shop.cart.prices_as_options' => 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));
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue