251 lines
8.2 KiB
PHP
251 lines
8.2 KiB
PHP
<?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(),
|
||
|
|
];
|
||
|
|
}
|
||
|
|
}
|