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:
Fabian @ Blax Software 2026-09-25 10:37:59 +02:00
parent 961ed5af4c
commit 4f0084b7e4
4 changed files with 109 additions and 4 deletions

View File

@ -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,

View File

@ -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,
];
}

View File

@ -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,

View File

@ -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));
}
}