Files
core/src/Cart/Services/CartService.php
T

251 lines
8.2 KiB
PHP
Raw Normal View History

<?php
namespace Modules\Core\Cart\Services;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Event;
use Lunar\Actions\Carts\GetExistingCartLine;
use Lunar\Base\Purchasable;
use Lunar\Facades\CartSession;
use Lunar\Facades\Discounts;
use Lunar\Models\Cart;
use Lunar\Models\CartLine;
use Modules\Core\Cart\Events\CartCleared;
use Modules\Core\Cart\Events\CartCouponApplied;
use Modules\Core\Cart\Events\CartCouponRemoved;
use Modules\Core\Cart\Events\CartLineAdded;
use Modules\Core\Cart\Events\CartLineMovedToCart;
use Modules\Core\Cart\Events\CartLineRemoved;
use Modules\Core\Cart\Events\CartLineSaved;
use Modules\Core\Cart\Events\CartLineUpdated;
use Modules\Core\Cart\Exceptions\InvalidCouponException;
/**
* Storefront-facing cart operations, mirroring Modules\Core\Catalog\Services\
* ProductService/CollectionService's shape — one boboko-owned API a storefront
* calls, so Lunar's own CartSession/Cart stay an implementation detail rather
* than something a consuming app depends on directly.
*
* Every mutating method dispatches a matching domain event
* (Modules\Core\Cart\Events\*) after the underlying Lunar operation completes —
* Lunar itself dispatches zero cart events (see docs/lunar.md's Cart gotchas),
* so without this, nothing in a consuming app has anything to react to when a
* cart actually changes (reindexing, notifications, analytics, etc.).
*
* All mutating methods return the recalculated Cart — matching Lunar's own
* Cart::add()/updateLine()/etc., which already return $this after
* refresh()->recalculate() — so a caller gets fresh totals in the same call,
* no second fetch needed.
*/
class CartService
{
/**
* The current session's cart, or null if none exists yet. Does NOT
* auto-create one — see currentOrCreate() for that.
*/
public function current(): ?Cart
{
return CartSession::current();
}
/**
* The current session's cart, creating one if none exists yet — the right
* call for "add to cart" style flows where a cart must exist by the time
* the method returns.
*/
public function currentOrCreate(): Cart
{
return CartSession::manager();
}
public function addLine(Purchasable $purchasable, int $quantity = 1, array $meta = []): Cart
{
$cart = $this->currentOrCreate()->add($purchasable, $quantity, $meta);
$line = app(config('lunar.cart.actions.get_existing_cart_line', GetExistingCartLine::class))
->execute($cart, $purchasable, $meta);
if ($line !== null) {
Event::dispatch(new CartLineAdded($cart, $line));
}
return $cart;
}
public function updateLine(int $cartLineId, int $quantity, ?array $meta = null): Cart
{
$before = CartLine::findOrFail($cartLineId);
$old = ['quantity' => $before->quantity, 'meta' => $before->meta->toArray()];
$cart = $this->currentOrCreate()->updateLine($cartLineId, $quantity, $meta);
$line = $cart->lines->firstWhere('id', $cartLineId);
if ($line !== null) {
Event::dispatch(new CartLineUpdated($cart, $line, $old));
}
return $cart;
}
public function removeLine(int $cartLineId): Cart
{
$line = CartLine::findOrFail($cartLineId);
$snapshot = $this->snapshotLine($line);
$cart = $this->currentOrCreate()->remove($cartLineId);
Event::dispatch(new CartLineRemoved($cart, $snapshot));
return $cart;
}
public function clear(): Cart
{
$cart = $this->currentOrCreate();
$snapshots = $cart->lines->map($this->snapshotLine(...))->all();
$cart = $cart->clear();
Event::dispatch(new CartCleared($cart, $snapshots));
return $cart;
}
/**
* Sets the cart's coupon code, which the ApplyDiscounts pipeline step picks
* up on the next calculate() — there's no dedicated Lunar action for this
* (unlike add/update/remove, coupon_code is a plain cast attribute), so
* this is the closest thing to one for a consuming app to call.
*
* Validated via Discounts::validateCoupon() (does a matching, currently
* active, non-exhausted Discount exist?) before it's set — CouponString's
* cast only normalizes casing, it doesn't validate anything, so setting
* coupon_code directly would silently accept a bogus code and just not
* discount anything once calculated.
*
* @throws InvalidCouponException if the code doesn't match a valid, active,
* non-exhausted Discount
*/
public function applyCoupon(string $code): Cart
{
if (! Discounts::validateCoupon($code)) {
throw new InvalidCouponException($code);
}
$cart = $this->currentOrCreate();
$cart->coupon_code = $code;
$cart->save();
$cart = $cart->recalculate();
Event::dispatch(new CartCouponApplied($cart, $cart->coupon_code));
return $cart;
}
public function removeCoupon(): Cart
{
$cart = $this->currentOrCreate();
$code = $cart->coupon_code;
if ($code === null) {
return $cart;
}
$cart->coupon_code = null;
$cart->save();
$cart = $cart->recalculate();
Event::dispatch(new CartCouponRemoved($cart, $code));
return $cart;
}
/**
* Lines currently counted toward the cart's totals — everything except
* ones flagged meta.saved_for_later (see savedLines()). This is the set a
* cart page's main list / checkout would iterate, since a saved line
* isn't pending purchase.
*
* @return Collection<int, CartLine>
*/
public function activeLines(?Cart $cart = null): Collection
{
$cart ??= $this->currentOrCreate();
return $cart->lines->reject(fn (CartLine $line) => $line->meta['saved_for_later'] ?? false)->values();
}
/**
* Lines a shopper has deliberately parked rather than deleted — excluded
* from Cart totals (see Modules\Core\Cart\Pipelines\ZeroSavedForLaterPrice)
* and from activeLines(). A cart page's "Saved for later" section iterates
* this set.
*
* @return Collection<int, CartLine>
*/
public function savedLines(?Cart $cart = null): Collection
{
$cart ??= $this->currentOrCreate();
return $cart->lines->filter(fn (CartLine $line) => $line->meta['saved_for_later'] ?? false)->values();
}
/**
* Moves a line OUT of the purchasable cart without deleting it — it stays
* on the cart (still visible, still re-addable) but is excluded from
* totals via meta.saved_for_later, zeroed by ZeroSavedForLaterPrice before
* Lunar's own CalculateLines sums the cart (which has no meta-based
* exclusion of its own).
*/
public function saveForLater(int $cartLineId): Cart
{
$line = CartLine::findOrFail($cartLineId);
$meta = [...$line->meta->toArray(), 'saved_for_later' => true];
$cart = $this->currentOrCreate()->updateLine($cartLineId, $line->quantity, $meta);
$line = $cart->lines->firstWhere('id', $cartLineId);
if ($line !== null) {
Event::dispatch(new CartLineSaved($cart, $line));
}
return $cart;
}
/**
* The reverse of saveForLater() — moves a line back into the purchasable
* cart, counted in totals again.
*/
public function moveToCart(int $cartLineId): Cart
{
$line = CartLine::findOrFail($cartLineId);
$meta = [...$line->meta->toArray(), 'saved_for_later' => false];
$cart = $this->currentOrCreate()->updateLine($cartLineId, $line->quantity, $meta);
$line = $cart->lines->firstWhere('id', $cartLineId);
if ($line !== null) {
Event::dispatch(new CartLineMovedToCart($cart, $line));
}
return $cart;
}
/**
* @return array{id: int, purchasable_type: string, purchasable_id: int, quantity: int, meta: array}
*/
private function snapshotLine(CartLine $line): array
{
return [
'id' => $line->id,
'purchasable_type' => $line->purchasable_type,
'purchasable_id' => $line->purchasable_id,
'quantity' => $line->quantity,
'meta' => $line->meta->toArray(),
];
}
}