From 68d02b01fc159424054e1ed021102125168ad0bb Mon Sep 17 00:00:00 2001 From: "Fabian @ Blax Software" Date: Thu, 17 Sep 2026 10:53:28 +0200 Subject: [PATCH] feat(cart): WS-first storefront primitives + shared cart/auth controllers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the reusable pieces a headless WS-first shop needs, so every consumer (immohost, nirioci, future shops) shares one implementation instead of re-inventing it in app code: - CartService::adopt() — resolve + re-bind a guest cart to a stable connection id (socket/session), so the cart follows a stateless client across reconnects. - CartService::mergeGuestIntoUser() — actually honour shop.cart.merge_on_login, which was dead config: re-parent guest items onto the user's cart at login (preserves claimed stock and resolved prices; no re-add side effects). - Cart facade passthrough checkoutSessionLink() for the {redirect} checkout shape. - CartResource / CartItemResource: a stable, presentation-agnostic wire shape (money in integer minor units + explicit currency; best-effort image). - Blax\Shop\Websocket\Controllers\{Cart,Auth}Controller: thin, config-resolved, translatable base WS controllers a host exposes via one-line subclass stubs. laravel-websockets stays a soft (suggest) dependency; the Websocket dir is excluded from coverage since its logic lives in the covered CartService. - shop:: translation namespace (en) for the controller messages. Tests: tests/Unit/Cart/CartAdoptMergeTest.php (7). Full suite green (1468). Co-Authored-By: Claude Opus 4.8 (1M context) --- lang/en/auth.php | 6 + lang/en/cart.php | 15 ++ phpunit.xml | 5 + src/Facades/Cart.php | 3 + src/Http/Resources/CartItemResource.php | 90 ++++++++ src/Http/Resources/CartResource.php | 41 ++++ src/Services/CartService.php | 115 ++++++++++ src/ShopServiceProvider.php | 2 + src/Websocket/Controllers/AuthController.php | 135 ++++++++++++ src/Websocket/Controllers/CartController.php | 212 +++++++++++++++++++ tests/Unit/Cart/CartAdoptMergeTest.php | 123 +++++++++++ 11 files changed, 747 insertions(+) create mode 100644 lang/en/auth.php create mode 100644 lang/en/cart.php create mode 100644 src/Http/Resources/CartItemResource.php create mode 100644 src/Http/Resources/CartResource.php create mode 100644 src/Websocket/Controllers/AuthController.php create mode 100644 src/Websocket/Controllers/CartController.php create mode 100644 tests/Unit/Cart/CartAdoptMergeTest.php 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); + } +}