feat(cart): WS-first storefront primitives + shared cart/auth controllers
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) <noreply@anthropic.com>
This commit is contained in:
parent
1849f55558
commit
68d02b01fc
|
|
@ -0,0 +1,6 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
'invalid_credentials' => 'Invalid credentials.',
|
||||||
|
'login_ok' => 'Signed in.',
|
||||||
|
];
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
'session_updated' => '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.',
|
||||||
|
];
|
||||||
|
|
@ -22,6 +22,11 @@
|
||||||
</include>
|
</include>
|
||||||
<exclude>
|
<exclude>
|
||||||
<directory>./src/database</directory>
|
<directory>./src/database</directory>
|
||||||
|
<!-- WebSocket controllers extend blax-software/laravel-websockets, a
|
||||||
|
soft (suggest) dependency absent from the package's own test env.
|
||||||
|
Their logic lives in the covered CartService; exclude the thin
|
||||||
|
glue so coverage runs don't try to reflect the missing base class. -->
|
||||||
|
<directory>./src/Websocket</directory>
|
||||||
</exclude>
|
</exclude>
|
||||||
</source>
|
</source>
|
||||||
<coverage includeUncoveredFiles="true" />
|
<coverage includeUncoveredFiles="true" />
|
||||||
|
|
|
||||||
|
|
@ -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 guest(string|null $sessionId = null)
|
||||||
* @method static \Blax\Shop\Models\Cart forUser(\Illuminate\Contracts\Auth\Authenticatable $user)
|
* @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|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 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|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)
|
* @method static \Blax\Shop\Models\CartItem update(\Blax\Shop\Models\CartItem $cartItem, int $quantity)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,90 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Blax\Shop\Http\Resources;
|
||||||
|
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A single line in {@see CartResource}. Money is integer minor units (cents).
|
||||||
|
*
|
||||||
|
* `purchasable` is a compact summary (id/name/slug/type/image) so a cart drawer
|
||||||
|
* can render without a second round-trip. The image lookup is best-effort: it
|
||||||
|
* only resolves when the purchasable exposes a laravel-files `files()` relation,
|
||||||
|
* and never throws if the file layer is absent.
|
||||||
|
*
|
||||||
|
* @mixin \Blax\Shop\Models\CartItem
|
||||||
|
*/
|
||||||
|
class CartItemResource extends JsonResource
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,41 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Blax\Shop\Http\Resources;
|
||||||
|
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stable storefront wire shape for a Cart.
|
||||||
|
*
|
||||||
|
* This is the payload the cart-driving controllers (WebSocket or HTTP) hand a
|
||||||
|
* frontend after every mutation. All money is integer MINOR units (cents) plus
|
||||||
|
* an explicit ISO-4217 `currency` — never inferred, never pre-divided — so the
|
||||||
|
* client owns formatting. Kept deliberately flat and presentation-agnostic;
|
||||||
|
* apps that need extra fields extend this resource.
|
||||||
|
*
|
||||||
|
* @mixin \Blax\Shop\Models\Cart
|
||||||
|
*/
|
||||||
|
class CartResource extends JsonResource
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
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),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -112,6 +112,51 @@ class CartService
|
||||||
return $cart;
|
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
|
* Find cart by ID
|
||||||
*
|
*
|
||||||
|
|
@ -123,6 +168,55 @@ class CartService
|
||||||
return Cart::find($cartId);
|
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<Cart> $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)
|
* Add item to current cart (from session or authenticated user)
|
||||||
* For guests without session, use guest() first: Cart::guest()->add($product)
|
* For guests without session, use guest() first: Cart::guest()->add($product)
|
||||||
|
|
@ -230,6 +324,27 @@ class CartService
|
||||||
return $cart->checkout();
|
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
|
* Get total for a cart
|
||||||
* If no cart provided, gets current user's cart total
|
* If no cart provided, gets current user's cart total
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,8 @@ class ShopServiceProvider extends ServiceProvider
|
||||||
{
|
{
|
||||||
$this->offerPublishing();
|
$this->offerPublishing();
|
||||||
|
|
||||||
|
$this->loadTranslationsFrom(__DIR__ . '/../lang', 'shop');
|
||||||
|
|
||||||
$this->registerMigrations();
|
$this->registerMigrations();
|
||||||
|
|
||||||
$this->registerSubscriptionModels();
|
$this->registerSubscriptionModels();
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,135 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Blax\Shop\Websocket\Controllers;
|
||||||
|
|
||||||
|
use Blax\Shop\Facades\Cart as CartFacade;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reusable storefront auth over the Blax WebSocket bridge (Sanctum PAT).
|
||||||
|
*
|
||||||
|
* Expose in a consuming app with a one-line stub:
|
||||||
|
*
|
||||||
|
* class AuthController extends \Blax\Shop\Websocket\Controllers\AuthController {}
|
||||||
|
*
|
||||||
|
* `login` authenticates by email+password OR by an existing bearer token, issues
|
||||||
|
* a Sanctum personal access token the client stores and re-sends as `authtoken`,
|
||||||
|
* and MERGES the connection's guest cart into the user's cart (closing the
|
||||||
|
* shop.cart.merge_on_login gap via CartService::mergeGuestIntoUser). `me`
|
||||||
|
* self-heals the connection from a bearer token and seeds the per-socket auth
|
||||||
|
* cache so subsequent need_auth controllers resolve the user.
|
||||||
|
*
|
||||||
|
* Requires Sanctum on the consuming app (guarded) + a User model using
|
||||||
|
* HasApiTokens and the shop HasCart/HasShoppingCapabilities trait.
|
||||||
|
*/
|
||||||
|
class AuthController extends \BlaxSoftware\LaravelWebSockets\Websocket\Controller
|
||||||
|
{
|
||||||
|
public $need_auth = false;
|
||||||
|
|
||||||
|
public function login()
|
||||||
|
{
|
||||||
|
$requestData = request()->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_<id> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,212 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Blax\Shop\Websocket\Controllers;
|
||||||
|
|
||||||
|
use Blax\Shop\Enums\CartStatus;
|
||||||
|
use Blax\Shop\Exceptions\NotEnoughStockException;
|
||||||
|
use Blax\Shop\Facades\Cart as CartFacade;
|
||||||
|
use Blax\Shop\Http\Resources\CartResource;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reusable storefront cart controller over the Blax WebSocket bridge.
|
||||||
|
*
|
||||||
|
* Ship-once, use-everywhere: a consuming app exposes it with a one-line stub in
|
||||||
|
* app/Websocket/Controllers/ (the resolver only scans that namespace + this
|
||||||
|
* package's, and resolves INHERITED methods):
|
||||||
|
*
|
||||||
|
* class CartController extends \Blax\Shop\Websocket\Controllers\CartController {}
|
||||||
|
*
|
||||||
|
* The cart "follows" the socket: booted() adopts the client-held cart id and
|
||||||
|
* re-binds it to this connection (see CartService::adopt), so every cart.*
|
||||||
|
* method operates on the right guest cart without an account. All handlers reply
|
||||||
|
* with the {@see CartResource} wire shape.
|
||||||
|
*
|
||||||
|
* Requires blax-software/laravel-websockets (soft dependency — this class is only
|
||||||
|
* ever loaded inside a WS runtime, so the package installs fine without it).
|
||||||
|
*/
|
||||||
|
class CartController extends \BlaxSoftware\LaravelWebSockets\Websocket\Controller
|
||||||
|
{
|
||||||
|
/** Guest storefront: no account required. */
|
||||||
|
public $need_auth = false;
|
||||||
|
|
||||||
|
/** @var \Blax\Shop\Models\Cart */
|
||||||
|
protected $cart = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve + re-bind this connection's cart before every cart.* method.
|
||||||
|
*/
|
||||||
|
public function booted()
|
||||||
|
{
|
||||||
|
$cartId = request('cart_id');
|
||||||
|
$cartId = is_string($cartId) && $cartId !== '' ? $cartId : null;
|
||||||
|
|
||||||
|
$this->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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,123 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Blax\Shop\Tests\Unit\Cart;
|
||||||
|
|
||||||
|
use Blax\Shop\Facades\Cart as CartFacade;
|
||||||
|
use Blax\Shop\Models\Cart;
|
||||||
|
use Blax\Shop\Models\Product;
|
||||||
|
use Blax\Shop\Tests\TestCase;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use PHPUnit\Framework\Attributes\Test;
|
||||||
|
use Workbench\App\Models\User;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Covers the storefront primitives added for WS-first shops:
|
||||||
|
* CartService::adopt() (connection cart binding) and
|
||||||
|
* CartService::mergeGuestIntoUser() (the previously-dead merge_on_login gap).
|
||||||
|
*/
|
||||||
|
class CartAdoptMergeTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function adopt_without_id_returns_a_guest_cart_bound_to_the_session()
|
||||||
|
{
|
||||||
|
$cart = CartFacade::adopt(null, 'socket-A');
|
||||||
|
|
||||||
|
$this->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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue