diff --git a/lang/en/auth.php b/lang/en/auth.php
new file mode 100644
index 0000000..25c2d0e
--- /dev/null
+++ b/lang/en/auth.php
@@ -0,0 +1,6 @@
+ 'Invalid credentials.',
+ 'login_ok' => 'Signed in.',
+];
diff --git a/lang/en/cart.php b/lang/en/cart.php
new file mode 100644
index 0000000..5b69740
--- /dev/null
+++ b/lang/en/cart.php
@@ -0,0 +1,15 @@
+ 'Cart updated.',
+ 'not_found' => 'That item could not be found.',
+ 'item_not_found' => 'That cart item could not be found.',
+ 'out_of_stock' => 'Not enough stock available.',
+ 'add_failed' => 'Could not add the item to your cart.',
+ 'added' => 'Added to cart.',
+ 'removed' => 'Removed from cart.',
+ 'converted' => 'This cart has already been checked out.',
+ 'checkout_failed' => 'Checkout could not be started.',
+ 'checkout_unavailable' => 'Checkout is currently unavailable.',
+ 'checkout_created' => 'Redirecting to payment.',
+];
diff --git a/phpunit.xml b/phpunit.xml
index 14f79ae..a9acd87 100644
--- a/phpunit.xml
+++ b/phpunit.xml
@@ -22,6 +22,11 @@
./src/database
+
+ ./src/Websocket
diff --git a/src/Facades/Cart.php b/src/Facades/Cart.php
index 471c24a..5ac1c32 100644
--- a/src/Facades/Cart.php
+++ b/src/Facades/Cart.php
@@ -11,6 +11,9 @@ use Illuminate\Support\Facades\Facade;
* @method static \Blax\Shop\Models\Cart guest(string|null $sessionId = null)
* @method static \Blax\Shop\Models\Cart forUser(\Illuminate\Contracts\Auth\Authenticatable $user)
* @method static \Blax\Shop\Models\Cart|null find(string $cartId)
+ * @method static \Blax\Shop\Models\Cart adopt(string|null $cartId, string $sessionId)
+ * @method static \Blax\Shop\Models\Cart mergeGuestIntoUser(\Blax\Shop\Models\Cart $guestCart, \Illuminate\Contracts\Auth\Authenticatable $user)
+ * @method static string|null|false checkoutSessionLink(\Blax\Shop\Models\Cart|null $cart = null, array $options = [], string|null $url = null)
* @method static \Blax\Shop\Models\CartItem add(\Illuminate\Database\Eloquent\Model $product, int $quantity = 1, array $parameters = [])
* @method static \Blax\Shop\Models\CartItem|true remove(\Illuminate\Database\Eloquent\Model $product, int $quantity = 1, array $parameters = [])
* @method static \Blax\Shop\Models\CartItem update(\Blax\Shop\Models\CartItem $cartItem, int $quantity)
diff --git a/src/Http/Resources/CartItemResource.php b/src/Http/Resources/CartItemResource.php
new file mode 100644
index 0000000..f8a8cbb
--- /dev/null
+++ b/src/Http/Resources/CartItemResource.php
@@ -0,0 +1,90 @@
+
+ */
+ public function toArray(Request $request): array
+ {
+ $purchasable = $this->purchasable;
+
+ return [
+ 'id' => $this->id,
+ 'quantity' => (int) $this->quantity,
+ 'price' => $this->price === null ? null : (int) $this->price,
+ 'regular_price' => $this->regular_price === null ? null : (int) $this->regular_price,
+ 'subtotal' => $this->subtotal === null ? null : (int) $this->subtotal,
+ 'currency' => $this->currency,
+ 'purchasable' => $purchasable ? [
+ 'id' => $purchasable->getKey(),
+ 'name' => $purchasable->name ?? null,
+ 'slug' => $purchasable->slug ?? null,
+ 'type' => $this->purchasableType($purchasable),
+ 'image' => $this->resolveImage($purchasable),
+ ] : null,
+ ];
+ }
+
+ /**
+ * Stringify a product type enum (or pass through a scalar) without assuming
+ * the purchasable is a Product.
+ */
+ private function purchasableType(mixed $purchasable): ?string
+ {
+ $type = $purchasable->type ?? null;
+
+ if ($type === null) {
+ return null;
+ }
+
+ return $type instanceof \BackedEnum ? (string) $type->value : (string) $type;
+ }
+
+ /**
+ * Best-effort primary image URL via the laravel-files `files()` relation.
+ * Prefers a single-slot image (thumbnail/cover/icon), then the first
+ * gallery/any file. Returns null and swallows errors when files aren't wired.
+ */
+ private function resolveImage(mixed $purchasable): ?string
+ {
+ if (! is_object($purchasable) || ! method_exists($purchasable, 'files')) {
+ return null;
+ }
+
+ try {
+ $files = $purchasable->files()->get();
+
+ if ($files->isEmpty()) {
+ return null;
+ }
+
+ $preferred = $files->first(function ($file) {
+ $as = $file->pivot->as ?? null;
+
+ return in_array($as, ['thumbnail', 'cover', 'icon', 'banner', 'logo'], true);
+ });
+
+ return ($preferred ?? $files->first())->url ?? null;
+ } catch (\Throwable) {
+ return null;
+ }
+ }
+}
diff --git a/src/Http/Resources/CartResource.php b/src/Http/Resources/CartResource.php
new file mode 100644
index 0000000..556d1fe
--- /dev/null
+++ b/src/Http/Resources/CartResource.php
@@ -0,0 +1,41 @@
+
+ */
+ public function toArray(Request $request): array
+ {
+ $items = $this->items()->with('purchasable')->get();
+
+ return [
+ 'id' => $this->id,
+ 'currency' => $this->currency ?? config('shop.currency'),
+ // Sum of line subtotals in minor units (cents).
+ 'total' => (int) $items->sum('subtotal'),
+ 'item_count' => (int) $items->sum('quantity'),
+ 'is_converted' => $this->isConverted(),
+ 'is_ready_to_checkout' => $this->is_ready_to_checkout,
+ 'items' => CartItemResource::collection($items),
+ ];
+ }
+}
diff --git a/src/Services/CartService.php b/src/Services/CartService.php
index 4fab22d..3d22e7a 100644
--- a/src/Services/CartService.php
+++ b/src/Services/CartService.php
@@ -112,6 +112,51 @@ class CartService
return $cart;
}
+ /**
+ * Merge a guest cart into a user's current cart at login.
+ *
+ * laravel-shop had `shop.cart.merge_on_login` in config but no code ever
+ * honoured it: `current()` merely *forgets* a guest session cart when a user
+ * authenticates, it never moves the items over. This closes that gap.
+ *
+ * Items are RE-PARENTED (their cart_id is repointed) rather than re-added,
+ * which preserves each line's already-claimed stock, resolved price and
+ * booking dates instead of re-running add-to-cart side effects (and the
+ * stock claim) a second time. The emptied guest cart is detached from its
+ * session so it can't be re-adopted.
+ *
+ * Guarded: a cart that is the user's own cart, already owned by a customer,
+ * or already converted, is returned untouched.
+ *
+ * @param Cart $guestCart Guest cart to drain.
+ * @param Authenticatable $user User to receive the items (needs HasCart).
+ * @return Cart The user's current cart.
+ */
+ public function mergeGuestIntoUser(Cart $guestCart, Authenticatable $user): Cart
+ {
+ if (!method_exists($user, 'currentCart')) {
+ throw new \InvalidArgumentException('User model must have shopping capabilities (use the HasCart / HasShoppingCapabilities trait).');
+ }
+
+ $userCart = $user->currentCart();
+
+ // Nothing to do: same cart, or the source isn't a drainable guest cart.
+ if ($guestCart->is($userCart) || $guestCart->customer_id || $guestCart->isConverted()) {
+ return $userCart;
+ }
+
+ $moved = $guestCart->items()->update(['cart_id' => $userCart->getKey()]);
+
+ if ($moved > 0) {
+ $userCart->touchActivity();
+ }
+
+ // Detach the emptied guest cart so adopt()/guest() can't resurrect it.
+ $guestCart->update(['session_id' => 'merged_' . $guestCart->getKey()]);
+
+ return $userCart->fresh();
+ }
+
/**
* Find cart by ID
*
@@ -123,6 +168,55 @@ class CartService
return Cart::find($cartId);
}
+ /**
+ * Resolve the guest cart for a connection, optionally adopting a cart the
+ * client already holds an id for, and re-bind it to this connection so the
+ * cart "follows" the socket across reconnects.
+ *
+ * This is the server-side counterpart to a stateless client (e.g. a
+ * WebSocket connection) that persists its cart id locally and re-sends it
+ * on every message: pass that id as $cartId and the connection's stable id
+ * (socket id / session id) as $sessionId.
+ *
+ * - Unknown / missing id, or a cart already converted to an order → a fresh
+ * guest cart bound to $sessionId.
+ * - A guest cart bound to a different session → re-bound to $sessionId
+ * (and any stale guest cart already on $sessionId is removed first, so a
+ * connection never accumulates orphan guest carts).
+ * - A cart already owned by a customer is never stolen — it is returned as-is.
+ *
+ * @param string|null $cartId Cart id the client claims to hold, if any.
+ * @param string $sessionId Stable connection identifier to bind to.
+ * @return Cart
+ */
+ public function adopt(?string $cartId, string $sessionId): Cart
+ {
+ /** @var class-string $cartModel */
+ $cartModel = config('shop.models.cart', Cart::class);
+
+ $cart = ($cartId !== null && $cartId !== '')
+ ? $cartModel::find($cartId)
+ : null;
+
+ // Unknown id, or a cart that's already an order: start fresh.
+ if (! $cart || $cart->isConverted()) {
+ return $this->guest($sessionId);
+ }
+
+ // Re-bind a guest cart to this connection. Never steal a customer's cart.
+ if (! $cart->customer_id && $cart->session_id !== $sessionId) {
+ $cartModel::query()
+ ->where('session_id', $sessionId)
+ ->whereNull('customer_id')
+ ->whereKeyNot($cart->getKey())
+ ->delete();
+
+ $cart->update(['session_id' => $sessionId]);
+ }
+
+ return $cart;
+ }
+
/**
* Add item to current cart (from session or authenticated user)
* For guests without session, use guest() first: Cart::guest()->add($product)
@@ -230,6 +324,27 @@ class CartService
return $cart->checkout();
}
+ /**
+ * Create (or reuse) a Stripe Checkout Session for a cart and return its
+ * hosted-payment URL. Facade passthrough to Cart::checkoutSessionLink() so
+ * callers can drive Stripe checkout straight off the facade without reaching
+ * into the model — the shape a WS/HTTP controller wants (`{ redirect: url }`).
+ *
+ * Unlike checkout(), this works for GUEST carts (Stripe collects the buyer),
+ * so no authenticated user is required.
+ *
+ * @param Cart|null $cart Cart to check out; defaults to current().
+ * @param array $options Extra Stripe Checkout Session options.
+ * @param string|null $url Return/success base URL.
+ * @return string|null|false URL string, null if Stripe disabled / no session, false on error.
+ */
+ public function checkoutSessionLink(?Cart $cart = null, array $options = [], ?string $url = null): string|null|false
+ {
+ $cart = $cart ?? $this->current();
+
+ return $cart->checkoutSessionLink($options, $url);
+ }
+
/**
* Get total for a cart
* If no cart provided, gets current user's cart total
diff --git a/src/ShopServiceProvider.php b/src/ShopServiceProvider.php
index 39e51bd..86aa85d 100644
--- a/src/ShopServiceProvider.php
+++ b/src/ShopServiceProvider.php
@@ -43,6 +43,8 @@ class ShopServiceProvider extends ServiceProvider
{
$this->offerPublishing();
+ $this->loadTranslationsFrom(__DIR__ . '/../lang', 'shop');
+
$this->registerMigrations();
$this->registerSubscriptionModels();
diff --git a/src/Websocket/Controllers/AuthController.php b/src/Websocket/Controllers/AuthController.php
new file mode 100644
index 0000000..0ce8914
--- /dev/null
+++ b/src/Websocket/Controllers/AuthController.php
@@ -0,0 +1,135 @@
+all();
+
+ if (! empty($requestData['token'])) {
+ $data = request()->validate([
+ 'token' => 'required|string',
+ 'email' => 'sometimes',
+ 'password' => 'sometimes',
+ ]);
+ } else {
+ $data = request()->validate([
+ 'email' => 'required|email',
+ 'password' => 'required|string',
+ ]);
+ }
+
+ $token = null;
+
+ // Path 1: validate an existing bearer token.
+ if (! empty($data['token']) && $this->sanctumAvailable()) {
+ $tokenable = optional(\Laravel\Sanctum\PersonalAccessToken::findToken($data['token']))->tokenable;
+
+ if ($tokenable) {
+ Auth::login($tokenable);
+ $token = $data['token'];
+ }
+ }
+
+ // Path 2: email + password → new token.
+ if (! Auth::check() && isset($data['email'], $data['password']) && Auth::attempt($data)) {
+ $user = Auth::user();
+
+ if (method_exists($user, 'createToken')) {
+ $token = explode('|', $user->createToken('authtoken')->plainTextToken)[1] ?? null;
+ }
+ }
+
+ if (! Auth::check()) {
+ return $this->error(__('shop::auth.invalid_credentials'));
+ }
+
+ $user = Auth::user();
+
+ $this->seedSocketAuthCache();
+ $this->mergeGuestCart($user);
+
+ return $this->success([
+ 'message' => __('shop::auth.login_ok'),
+ 'user' => $user,
+ 'token' => $token,
+ ]);
+ }
+
+ public function me()
+ {
+ $token = request('authtoken') ?? request('token');
+
+ if ($token && ! Auth::check() && $this->sanctumAvailable()) {
+ $tokenable = optional(\Laravel\Sanctum\PersonalAccessToken::findToken($token))->tokenable;
+
+ if ($tokenable) {
+ Auth::login($tokenable);
+ }
+ }
+
+ if (Auth::check()) {
+ $this->seedSocketAuthCache();
+ }
+
+ return $this->success(['user' => Auth::user()]);
+ }
+
+ /**
+ * Merge this connection's guest cart into the just-authenticated user's cart.
+ */
+ protected function mergeGuestCart($user): void
+ {
+ try {
+ $guest = CartFacade::guest($this->connection->socketId);
+ CartFacade::mergeGuestIntoUser($guest, $user);
+ } catch (\Throwable) {
+ // A cart merge must never block login.
+ }
+ }
+
+ /**
+ * Cache {id,type} under socket_ so subsequent need_auth controllers on
+ * this connection can resolve the user without another token round-trip.
+ */
+ protected function seedSocketAuthCache(): void
+ {
+ $key = 'socket_' . $this->connection->socketId;
+ cache()->forget($key);
+ cache()->remember($key, 60 * 15, fn () => [
+ 'id' => Auth::id(),
+ 'type' => Auth::user() ? get_class(Auth::user()) : null,
+ ]);
+ }
+
+ protected function sanctumAvailable(): bool
+ {
+ return class_exists(\Laravel\Sanctum\PersonalAccessToken::class);
+ }
+}
diff --git a/src/Websocket/Controllers/CartController.php b/src/Websocket/Controllers/CartController.php
new file mode 100644
index 0000000..f734d91
--- /dev/null
+++ b/src/Websocket/Controllers/CartController.php
@@ -0,0 +1,212 @@
+cart = CartFacade::adopt($cartId, $this->connection->socketId);
+ }
+
+ /**
+ * Echo the (already-adopted) cart back to the client. The frontend persists
+ * `cart.id` locally and re-sends it as `cart_id` on subsequent calls.
+ */
+ public function setCart()
+ {
+ return $this->success([
+ 'message' => __('shop::cart.session_updated'),
+ 'cart' => CartResource::make($this->cart),
+ ]);
+ }
+
+ /**
+ * Add a purchasable to the cart. `cart_item_id` is overloaded (frontend
+ * contract): it may be a CartItem id, a ProductPrice id, or a Product id.
+ */
+ public function add()
+ {
+ $data = request()->validate([
+ 'cart_item_id' => 'sometimes|string',
+ 'quantity' => 'required|integer|min:1',
+ ]);
+
+ $addable = $this->resolveAddable($data['cart_item_id'] ?? null);
+
+ if (! $addable) {
+ return $this->error(['message' => __('shop::cart.not_found')]);
+ }
+
+ $this->alignCartCurrency($addable);
+
+ try {
+ $this->cart->addToCart(cartable: $addable, quantity: $data['quantity']);
+ } catch (NotEnoughStockException) {
+ return $this->error(['message' => __('shop::cart.out_of_stock')]);
+ } catch (\Throwable $e) {
+ return $this->error([
+ 'message' => __('shop::cart.add_failed'),
+ 'detail' => $e->getMessage(),
+ ]);
+ }
+
+ return $this->success([
+ 'message' => __('shop::cart.added'),
+ 'cart' => CartResource::make($this->cart->fresh()),
+ ]);
+ }
+
+ /**
+ * Remove (or decrement) a line by cart item id.
+ */
+ public function remove()
+ {
+ $cartItemModel = config('shop.models.cart_item');
+
+ $data = request()->validate([
+ 'cart_item_id' => 'required|string',
+ 'quantity' => 'required|integer|min:1',
+ ]);
+
+ $item = $cartItemModel::find($data['cart_item_id']);
+
+ if (! $item) {
+ return $this->error(['message' => __('shop::cart.item_not_found')]);
+ }
+
+ $this->cart->removeFromCart(cartable: $item, quantity: $data['quantity']);
+
+ return $this->success([
+ 'message' => __('shop::cart.removed'),
+ 'cart' => CartResource::make($this->cart->fresh()),
+ ]);
+ }
+
+ /**
+ * Return the current cart.
+ */
+ public function show()
+ {
+ return $this->success(CartResource::make($this->cart));
+ }
+
+ /**
+ * Return the cart, streaming a conversion frame if it has already become an
+ * order (e.g. the client is polling after a Stripe redirect-back).
+ */
+ public function checkoutDetails()
+ {
+ if ($this->cart->isConverted()) {
+ $this->progress([
+ 'message' => __('shop::cart.converted'),
+ 'status' => CartStatus::CONVERTED,
+ 'order' => $this->cart->order,
+ ]);
+ }
+
+ return $this->success(CartResource::make($this->cart));
+ }
+
+ /**
+ * Create a Stripe Checkout Session and hand back its hosted-payment URL.
+ */
+ public function checkout()
+ {
+ $data = request()->validate([
+ 'url' => 'sometimes|url',
+ ]);
+
+ try {
+ $url = CartFacade::checkoutSessionLink($this->cart, [], $data['url'] ?? null);
+ } catch (\Throwable $e) {
+ return $this->error([
+ 'message' => __('shop::cart.checkout_failed'),
+ 'detail' => $e->getMessage(),
+ ]);
+ }
+
+ if (! $url) {
+ return $this->error(['message' => __('shop::cart.checkout_unavailable')]);
+ }
+
+ return $this->success([
+ 'message' => __('shop::cart.checkout_created'),
+ 'redirect' => $url,
+ ]);
+ }
+
+ /**
+ * Resolve the overloaded `cart_item_id` into a purchasable (CartItem's
+ * purchasable, else a ProductPrice, else a Product).
+ */
+ protected function resolveAddable(?string $id)
+ {
+ if (! $id) {
+ return null;
+ }
+
+ $cartItemModel = config('shop.models.cart_item');
+ $productPriceModel = config('shop.models.product_price');
+ $productModel = config('shop.models.product');
+
+ $addable = $cartItemModel::find($id)?->purchasable;
+ $addable ??= $productPriceModel::find($id);
+ $addable ??= $productModel::find($id);
+
+ return $addable;
+ }
+
+ /**
+ * Keep the cart's currency in step with the purchasable being added so a
+ * mixed-currency cart can't form.
+ */
+ protected function alignCartCurrency($addable): void
+ {
+ $productPriceModel = config('shop.models.product_price');
+
+ $currency = $addable instanceof $productPriceModel
+ ? $addable->currency
+ : $addable->defaultPrice()->first()?->currency;
+
+ if ($currency && $currency !== $this->cart->currency) {
+ $this->cart->update(['currency' => $currency]);
+ }
+ }
+}
diff --git a/tests/Unit/Cart/CartAdoptMergeTest.php b/tests/Unit/Cart/CartAdoptMergeTest.php
new file mode 100644
index 0000000..46098fa
--- /dev/null
+++ b/tests/Unit/Cart/CartAdoptMergeTest.php
@@ -0,0 +1,123 @@
+assertInstanceOf(Cart::class, $cart);
+ $this->assertSame('socket-A', $cart->session_id);
+ $this->assertNull($cart->customer_id);
+ }
+
+ #[Test]
+ public function adopt_with_unknown_id_falls_back_to_a_fresh_guest_cart()
+ {
+ $cart = CartFacade::adopt('00000000-0000-0000-0000-000000000000', 'socket-A');
+
+ $this->assertSame('socket-A', $cart->session_id);
+ }
+
+ #[Test]
+ public function adopt_rebinds_a_guest_cart_to_the_new_session_and_clears_stale_ones()
+ {
+ $held = Cart::factory()->create(['session_id' => 'socket-old', 'customer_id' => null]);
+ $stale = Cart::factory()->create(['session_id' => 'socket-B', 'customer_id' => null]);
+
+ $adopted = CartFacade::adopt($held->id, 'socket-B');
+
+ $this->assertTrue($held->is($adopted));
+ $this->assertSame('socket-B', $adopted->fresh()->session_id);
+ // The stale guest cart already sitting on socket-B is removed.
+ $this->assertNull(Cart::find($stale->id));
+ }
+
+ #[Test]
+ public function adopt_never_steals_a_customer_owned_cart()
+ {
+ $user = User::factory()->create();
+ $owned = Cart::factory()->create([
+ 'session_id' => 'socket-old',
+ 'customer_id' => $user->id,
+ 'customer_type' => get_class($user),
+ ]);
+
+ $adopted = CartFacade::adopt($owned->id, 'socket-Z');
+
+ $this->assertTrue($owned->is($adopted));
+ // Untouched: still owned, session not rebound.
+ $this->assertSame('socket-old', $adopted->fresh()->session_id);
+ }
+
+ #[Test]
+ public function merge_moves_guest_items_onto_the_user_cart_and_retires_the_guest()
+ {
+ $user = User::factory()->create();
+ $product = Product::factory()->withPrices()->create(['manage_stock' => false]);
+
+ $guest = Cart::factory()->create(['session_id' => 'socket-A', 'customer_id' => null]);
+ $guest->addToCart($product, 2);
+
+ $userCart = CartFacade::mergeGuestIntoUser($guest, $user);
+
+ $this->assertSame(2, $userCart->items()->sum('quantity'));
+ $this->assertSame(0, $guest->fresh()->items()->count());
+ $this->assertStringStartsWith('merged_', $guest->fresh()->session_id);
+ }
+
+ #[Test]
+ public function merge_is_a_noop_for_a_converted_or_owned_source_cart()
+ {
+ $user = User::factory()->create();
+ $product = Product::factory()->withPrices()->create(['manage_stock' => false]);
+
+ // A cart already owned by (another) customer must not be drained.
+ $owned = Cart::factory()->create([
+ 'session_id' => 'socket-A',
+ 'customer_id' => $user->id,
+ 'customer_type' => get_class($user),
+ ]);
+ $owned->addToCart($product, 1);
+
+ $userCart = CartFacade::mergeGuestIntoUser($owned, $user);
+
+ // Items stayed on the owned cart; nothing moved.
+ $this->assertSame(1, $owned->fresh()->items()->count());
+ $this->assertInstanceOf(Cart::class, $userCart);
+ }
+
+ #[Test]
+ public function checkout_session_link_delegates_to_the_cart_model()
+ {
+ config()->set('shop.stripe.enabled', false);
+
+ $product = Product::factory()->withPrices()->create(['manage_stock' => false]);
+ $guest = Cart::factory()->create(['session_id' => 'socket-A', 'customer_id' => null]);
+ $guest->addToCart($product, 1);
+
+ // The facade passthrough reaches Cart::checkoutSessionLink(), which hits
+ // the Stripe gate — proving delegation is wired (a no-op wrapper would
+ // never surface this error).
+ $this->expectExceptionMessage('Stripe is not enabled');
+ CartFacade::checkoutSessionLink($guest);
+ }
+}