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 */ 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 */ 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(), ]; } }