style: pint the files touched by the OrderPaid / shop.models.order fix

Formatting only (laravel preset via pint.json): import ordering, FQCN -> use,
`new X()` -> `new X`, `!` spacing, phpdoc trims, braces on one-line continue.
No behaviour change; suite re-run green (1485 tests).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Fabian @ Blax Software 2026-09-24 08:24:59 +02:00
parent 0d9823df04
commit 08e459959e
3 changed files with 245 additions and 235 deletions

View File

@ -6,8 +6,12 @@ namespace Blax\Shop\Models;
use Blax\Shop\Contracts\Cartable; use Blax\Shop\Contracts\Cartable;
use Blax\Shop\Enums\CartStatus; use Blax\Shop\Enums\CartStatus;
use Blax\Shop\Enums\PriceType;
use Blax\Shop\Enums\PricingStrategy;
use Blax\Shop\Enums\ProductType; use Blax\Shop\Enums\ProductType;
use Blax\Shop\Enums\PurchaseStatus; use Blax\Shop\Enums\PurchaseStatus;
use Blax\Shop\Enums\RecurringInterval;
use Blax\Shop\Events\CartCreated;
use Blax\Shop\Exceptions\CartableInterfaceException; use Blax\Shop\Exceptions\CartableInterfaceException;
use Blax\Shop\Exceptions\CartAlreadyConvertedException; use Blax\Shop\Exceptions\CartAlreadyConvertedException;
use Blax\Shop\Exceptions\CartDatesRequiredException; use Blax\Shop\Exceptions\CartDatesRequiredException;
@ -15,7 +19,9 @@ use Blax\Shop\Exceptions\CartEmptyException;
use Blax\Shop\Exceptions\CartItemMissingInformationException; use Blax\Shop\Exceptions\CartItemMissingInformationException;
use Blax\Shop\Exceptions\ExceedsMaxPerCartException; use Blax\Shop\Exceptions\ExceedsMaxPerCartException;
use Blax\Shop\Exceptions\ExceedsMaxPerUserException; use Blax\Shop\Exceptions\ExceedsMaxPerUserException;
use Blax\Shop\Exceptions\HasNoPriceException;
use Blax\Shop\Exceptions\InvalidDateRangeException; use Blax\Shop\Exceptions\InvalidDateRangeException;
use Blax\Shop\Exceptions\MixedCheckoutModeException;
use Blax\Shop\Exceptions\NotEnoughAvailableInTimespanException; use Blax\Shop\Exceptions\NotEnoughAvailableInTimespanException;
use Blax\Shop\Exceptions\NotEnoughStockException; use Blax\Shop\Exceptions\NotEnoughStockException;
use Blax\Shop\Exceptions\PriceCalculationException; use Blax\Shop\Exceptions\PriceCalculationException;
@ -30,11 +36,16 @@ use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\MorphTo; use Illuminate\Database\Eloquent\Relations\MorphTo;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Stripe\Checkout\Session;
use Stripe\Stripe;
use Workbench\App\Models\User;
class Cart extends Model class Cart extends Model
{ {
use HasUuids, HasExpiration, HasFactory, HasBookingPriceCalculation, ChecksIfBooking; use ChecksIfBooking, HasBookingPriceCalculation, HasExpiration, HasFactory, HasUuids;
protected $fillable = [ protected $fillable = [
'session_id', 'session_id',
@ -66,7 +77,7 @@ class Cart extends Model
]; ];
protected $dispatchesEvents = [ protected $dispatchesEvents = [
'created' => \Blax\Shop\Events\CartCreated::class, 'created' => CartCreated::class,
]; ];
public function __construct(array $attributes = []) public function __construct(array $attributes = [])
@ -97,6 +108,7 @@ class Cart extends Model
{ {
$this->last_activity_at = now(); $this->last_activity_at = now();
$this->saveQuietly(); // Don't trigger events $this->saveQuietly(); // Don't trigger events
return $this; return $this;
} }
@ -158,6 +170,7 @@ class Cart extends Model
{ {
$this->status = CartStatus::EXPIRED; $this->status = CartStatus::EXPIRED;
$this->save(); $this->save();
return $this; return $this;
} }
@ -168,6 +181,7 @@ class Cart extends Model
{ {
$this->status = CartStatus::ABANDONED; $this->status = CartStatus::ABANDONED;
$this->save(); $this->save();
return $this; return $this;
} }
@ -232,7 +246,7 @@ class Cart extends Model
public function purchases(): HasMany public function purchases(): HasMany
{ {
return $this->hasMany(config('shop.models.product_purchase', \Blax\Shop\Models\ProductPurchase::class), 'cart_id'); return $this->hasMany(config('shop.models.product_purchase', ProductPurchase::class), 'cart_id');
} }
/** /**
@ -240,7 +254,7 @@ class Cart extends Model
*/ */
public function order() public function order()
{ {
return $this->hasOne(config('shop.models.order', \Blax\Shop\Models\Order::class), 'cart_id'); return $this->hasOne(config('shop.models.order', Order::class), 'cart_id');
} }
public function getTotal(): float public function getTotal(): float
@ -264,7 +278,7 @@ class Cart extends Model
return false; return false;
} }
return $this->items->every(fn($item) => $item->is_booking); return $this->items->every(fn ($item) => $item->is_booking);
} }
/** /**
@ -276,7 +290,7 @@ class Cart extends Model
return false; return false;
} }
return $this->items->contains(fn($item) => $item->is_booking); return $this->items->contains(fn ($item) => $item->is_booking);
} }
/** /**
@ -284,7 +298,7 @@ class Cart extends Model
*/ */
public function bookingItems(): int public function bookingItems(): int
{ {
return $this->items->filter(fn($item) => $item->is_booking)->count(); return $this->items->filter(fn ($item) => $item->is_booking)->count();
} }
/** /**
@ -317,8 +331,6 @@ class Cart extends Model
* Check if cart is ready for checkout. * Check if cart is ready for checkout.
* *
* Returns true if all cart items are ready for checkout. * Returns true if all cart items are ready for checkout.
*
* @return bool
*/ */
public function getIsReadyToCheckoutAttribute(): bool public function getIsReadyToCheckoutAttribute(): bool
{ {
@ -326,7 +338,7 @@ class Cart extends Model
return false; return false;
} }
return $this->items->every(fn($item) => $item->is_ready_to_checkout); return $this->items->every(fn ($item) => $item->is_ready_to_checkout);
} }
/** /**
@ -347,12 +359,12 @@ class Cart extends Model
* } * }
* ``` * ```
* *
* @return \Illuminate\Support\Collection Collection of CartItem models requiring adjustments * @return Collection Collection of CartItem models requiring adjustments
*/ */
public function getItemsRequiringAdjustments() public function getItemsRequiringAdjustments()
{ {
return $this->items->filter(function ($item) { return $this->items->filter(function ($item) {
return !empty($item->requiredAdjustments()); return ! empty($item->requiredAdjustments());
}); });
} }
@ -377,6 +389,7 @@ class Cart extends Model
* @param \DateTimeInterface|string $until End date (DateTimeInterface or parsable string) * @param \DateTimeInterface|string $until End date (DateTimeInterface or parsable string)
* @param bool $validateAvailability Whether to validate product availability for the timespan * @param bool $validateAvailability Whether to validate product availability for the timespan
* @return $this * @return $this
*
* @throws InvalidDateRangeException * @throws InvalidDateRangeException
* @throws NotEnoughAvailableInTimespanException * @throws NotEnoughAvailableInTimespanException
*/ */
@ -403,7 +416,7 @@ class Cart extends Model
$updateData['until'] = $until; $updateData['until'] = $until;
} }
if (!empty($updateData)) { if (! empty($updateData)) {
$this->update($updateData); $this->update($updateData);
$this->refresh(); $this->refresh();
} }
@ -445,6 +458,7 @@ class Cart extends Model
* @param \DateTimeInterface|string $from Start date (DateTimeInterface or parsable string) * @param \DateTimeInterface|string $from Start date (DateTimeInterface or parsable string)
* @param bool $validateAvailability Whether to validate product availability for the timespan * @param bool $validateAvailability Whether to validate product availability for the timespan
* @return $this * @return $this
*
* @throws NotEnoughAvailableInTimespanException * @throws NotEnoughAvailableInTimespanException
*/ */
public function setFromDate( public function setFromDate(
@ -492,6 +506,7 @@ class Cart extends Model
* @param \DateTimeInterface|string $until End date (DateTimeInterface or parsable string) * @param \DateTimeInterface|string $until End date (DateTimeInterface or parsable string)
* @param bool $validateAvailability Whether to validate product availability for the timespan * @param bool $validateAvailability Whether to validate product availability for the timespan
* @return $this * @return $this
*
* @throws NotEnoughAvailableInTimespanException * @throws NotEnoughAvailableInTimespanException
*/ */
public function setUntilDate(\DateTimeInterface|string|int|float $until, bool $validateAvailability = true): self public function setUntilDate(\DateTimeInterface|string|int|float $until, bool $validateAvailability = true): self
@ -539,6 +554,7 @@ class Cart extends Model
* @param \DateTimeInterface|null $from Optional from date (uses cart's from if not provided) * @param \DateTimeInterface|null $from Optional from date (uses cart's from if not provided)
* @param \DateTimeInterface|null $until Optional until date (uses cart's until if not provided) * @param \DateTimeInterface|null $until Optional until date (uses cart's until if not provided)
* @return $this * @return $this
*
* @throws NotEnoughAvailableInTimespanException * @throws NotEnoughAvailableInTimespanException
*/ */
public function applyDatesToItems( public function applyDatesToItems(
@ -551,7 +567,7 @@ class Cart extends Model
$fromDate = $from ?? $this->from; $fromDate = $from ?? $this->from;
$untilDate = $until ?? $this->until; $untilDate = $until ?? $this->until;
if (!$fromDate || !$untilDate) { if (! $fromDate || ! $untilDate) {
return $this; return $this;
} }
@ -568,10 +584,10 @@ class Cart extends Model
// Only apply to booking items // Only apply to booking items
if ($item->is_booking) { if ($item->is_booking) {
// Determine which dates to apply based on overwrite setting // Determine which dates to apply based on overwrite setting
$shouldApplyFrom = $overwrite || !$item->from; $shouldApplyFrom = $overwrite || ! $item->from;
$shouldApplyUntil = $overwrite || !$item->until; $shouldApplyUntil = $overwrite || ! $item->until;
if (!$shouldApplyFrom && !$shouldApplyUntil) { if (! $shouldApplyFrom && ! $shouldApplyUntil) {
continue; continue;
} }
@ -593,12 +609,13 @@ class Cart extends Model
'from' => $itemFrom, 'from' => $itemFrom,
'until' => $itemUntil, 'until' => $itemUntil,
]); ]);
continue; continue;
} }
$poolKey = $product->id . '|' . $itemFrom->format('Y-m-d H:i:s') . '|' . $itemUntil->format('Y-m-d H:i:s'); $poolKey = $product->id.'|'.$itemFrom->format('Y-m-d H:i:s').'|'.$itemUntil->format('Y-m-d H:i:s');
if (!isset($poolValidation[$poolKey])) { if (! isset($poolValidation[$poolKey])) {
$poolValidation[$poolKey] = [ $poolValidation[$poolKey] = [
'product' => $product, 'product' => $product,
'from' => $itemFrom, 'from' => $itemFrom,
@ -610,7 +627,7 @@ class Cart extends Model
$poolValidation[$poolKey]['requested'] += $item->quantity; $poolValidation[$poolKey]['requested'] += $item->quantity;
$poolValidation[$poolKey]['allocated'] += $item->quantity; $poolValidation[$poolKey]['allocated'] += $item->quantity;
} elseif ($product && !$product->isAvailableForBooking($itemFrom, $itemUntil, $item->quantity)) { } elseif ($product && ! $product->isAvailableForBooking($itemFrom, $itemUntil, $item->quantity)) {
// Non-pool booking item is not available - mark as unavailable // Non-pool booking item is not available - mark as unavailable
// Don't throw exception - let user adjust dates freely // Don't throw exception - let user adjust dates freely
$item->update([ $item->update([
@ -620,6 +637,7 @@ class Cart extends Model
'subtotal' => null, 'subtotal' => null,
'unit_amount' => null, 'unit_amount' => null,
]); ]);
// Skip updateDates() since we already set the dates with null price // Skip updateDates() since we already set the dates with null price
continue; continue;
} }
@ -646,7 +664,6 @@ class Cart extends Model
* @param \DateTimeInterface $from New start date * @param \DateTimeInterface $from New start date
* @param \DateTimeInterface $until New end date * @param \DateTimeInterface $until New end date
* @param bool $overwrite Whether to apply to all items or only those without dates * @param bool $overwrite Whether to apply to all items or only those without dates
* @return void
*/ */
protected function reallocatePoolItems(\DateTimeInterface $from, \DateTimeInterface $until, bool $overwrite = true): void protected function reallocatePoolItems(\DateTimeInterface $from, \DateTimeInterface $until, bool $overwrite = true): void
{ {
@ -654,6 +671,7 @@ class Cart extends Model
$poolItems = $this->items()->get() $poolItems = $this->items()->get()
->filter(function ($item) { ->filter(function ($item) {
$product = $item->purchasable; $product = $item->purchasable;
return $product instanceof Product && $product->isPool(); return $product instanceof Product && $product->isPool();
}) })
->groupBy('purchasable_id'); ->groupBy('purchasable_id');
@ -661,7 +679,7 @@ class Cart extends Model
foreach ($poolItems as $poolId => $items) { foreach ($poolItems as $poolId => $items) {
$poolProduct = $items->first()->purchasable; $poolProduct = $items->first()->purchasable;
if (!$poolProduct) { if (! $poolProduct) {
continue; continue;
} }
@ -707,7 +725,7 @@ class Cart extends Model
// No singles available for this period - mark ALL pool items as unavailable // No singles available for this period - mark ALL pool items as unavailable
foreach ($items as $cartItem) { foreach ($items as $cartItem) {
// Only update if we should overwrite or item has no dates yet // Only update if we should overwrite or item has no dates yet
if (!$overwrite && $cartItem->from && $cartItem->until) { if (! $overwrite && $cartItem->from && $cartItem->until) {
continue; continue;
} }
@ -719,15 +737,16 @@ class Cart extends Model
'unit_amount' => null, 'unit_amount' => null,
]); ]);
} }
continue; continue;
} }
// Sort by pricing strategy // Sort by pricing strategy
usort($singlesWithPrices, function ($a, $b) use ($strategy) { usort($singlesWithPrices, function ($a, $b) use ($strategy) {
return match ($strategy) { return match ($strategy) {
\Blax\Shop\Enums\PricingStrategy::LOWEST => $a['price'] <=> $b['price'], PricingStrategy::LOWEST => $a['price'] <=> $b['price'],
\Blax\Shop\Enums\PricingStrategy::HIGHEST => $b['price'] <=> $a['price'], PricingStrategy::HIGHEST => $b['price'] <=> $a['price'],
\Blax\Shop\Enums\PricingStrategy::AVERAGE => 0, PricingStrategy::AVERAGE => 0,
}; };
}); });
@ -741,7 +760,7 @@ class Cart extends Model
foreach ($items as $cartItem) { foreach ($items as $cartItem) {
// Only reallocate if we should overwrite or item has no dates yet // Only reallocate if we should overwrite or item has no dates yet
if (!$overwrite && $cartItem->from && $cartItem->until) { if (! $overwrite && $cartItem->from && $cartItem->until) {
continue; continue;
} }
@ -773,7 +792,7 @@ class Cart extends Model
} }
} }
if (!$allocated) { if (! $allocated) {
// No single can accommodate the full quantity // No single can accommodate the full quantity
// Try to split: use as much as possible from the first available single, // Try to split: use as much as possible from the first available single,
// then create new cart items for the rest // then create new cart items for the rest
@ -781,13 +800,17 @@ class Cart extends Model
$firstAllocation = true; $firstAllocation = true;
foreach ($orderedSingles as $singleInfo) { foreach ($orderedSingles as $singleInfo) {
if ($remainingQty <= 0) break; if ($remainingQty <= 0) {
break;
}
$single = $singleInfo['single']; $single = $singleInfo['single'];
$usedFromSingle = $singleUsage[$single->id] ?? 0; $usedFromSingle = $singleUsage[$single->id] ?? 0;
$availableFromSingle = $singleInfo['available'] - $usedFromSingle; $availableFromSingle = $singleInfo['available'] - $usedFromSingle;
if ($availableFromSingle <= 0) continue; if ($availableFromSingle <= 0) {
continue;
}
$qtyToAllocate = min($remainingQty, $availableFromSingle); $qtyToAllocate = min($remainingQty, $availableFromSingle);
@ -867,7 +890,7 @@ class Cart extends Model
* *
* @param \DateTimeInterface $from Start date * @param \DateTimeInterface $from Start date
* @param \DateTimeInterface $until End date * @param \DateTimeInterface $until End date
* @return void *
* @throws NotEnoughAvailableInTimespanException * @throws NotEnoughAvailableInTimespanException
*/ */
/** /**
@ -877,17 +900,16 @@ class Cart extends Model
* @param \DateTimeInterface $from Start date * @param \DateTimeInterface $from Start date
* @param \DateTimeInterface $until End date * @param \DateTimeInterface $until End date
* @param bool $useProvidedDates Whether to use provided dates or item's own dates * @param bool $useProvidedDates Whether to use provided dates or item's own dates
* @return void
*/ */
protected function validateDateAvailability(\DateTimeInterface $from, \DateTimeInterface $until, bool $useProvidedDates = false): void protected function validateDateAvailability(\DateTimeInterface $from, \DateTimeInterface $until, bool $useProvidedDates = false): void
{ {
foreach ($this->items as $item) { foreach ($this->items as $item) {
if (!$item->is_booking) { if (! $item->is_booking) {
continue; continue;
} }
$product = $item->purchasable; $product = $item->purchasable;
if (!$product) { if (! $product) {
continue; continue;
} }
@ -900,7 +922,7 @@ class Cart extends Model
$checkFrom = $useProvidedDates ? $from : ($item->from ?? $from); $checkFrom = $useProvidedDates ? $from : ($item->from ?? $from);
$checkUntil = $useProvidedDates ? $until : ($item->until ?? $until); $checkUntil = $useProvidedDates ? $until : ($item->until ?? $until);
if (!$product->isAvailableForBooking($checkFrom, $checkUntil, $item->quantity)) { if (! $product->isAvailableForBooking($checkFrom, $checkUntil, $item->quantity)) {
// Mark item as unavailable instead of throwing exception // Mark item as unavailable instead of throwing exception
// This allows users to freely adjust dates // This allows users to freely adjust dates
$item->update([ $item->update([
@ -940,7 +962,7 @@ class Cart extends Model
public function isConverted(): bool public function isConverted(): bool
{ {
return !is_null($this->converted_at); return ! is_null($this->converted_at);
} }
public function scopeForUser($query, $userOrId) public function scopeForUser($query, $userOrId)
@ -951,7 +973,8 @@ class Cart extends Model
} }
// If just an ID is passed, try to determine the user model class // If just an ID is passed, try to determine the user model class
$userModel = config('auth.providers.users.model', \Workbench\App\Models\User::class); $userModel = config('auth.providers.users.model', User::class);
return $query->where('customer_id', $userOrId) return $query->where('customer_id', $userOrId)
->where('customer_type', $userModel); ->where('customer_type', $userModel);
} }
@ -965,9 +988,6 @@ class Cart extends Model
/** /**
* Store the cart ID in the session for retrieval across requests * Store the cart ID in the session for retrieval across requests
*
* @param Cart $cart
* @return void
*/ */
public static function setSession(Cart $cart): void public static function setSession(Cart $cart): void
{ {
@ -982,19 +1002,19 @@ class Cart extends Model
* @param array<string, mixed> $parameters Additional parameters for the cart item * @param array<string, mixed> $parameters Additional parameters for the cart item
* @param \DateTimeInterface|null $from Optional start date for bookings * @param \DateTimeInterface|null $from Optional start date for bookings
* @param \DateTimeInterface|null $until Optional end date for bookings * @param \DateTimeInterface|null $until Optional end date for bookings
* @return CartItem *
* @throws \Exception If the item doesn't implement Cartable interface * @throws \Exception If the item doesn't implement Cartable interface
*/ */
public function addToCart( public function addToCart(
Model $cartable, Model $cartable,
int $quantity = 1, int $quantity = 1,
array $parameters = [], array $parameters = [],
null|\DateTimeInterface $from = null, ?\DateTimeInterface $from = null,
null|\DateTimeInterface $until = null ?\DateTimeInterface $until = null
): CartItem { ): CartItem {
// $cartable must implement Cartable // $cartable must implement Cartable
if (! $cartable instanceof Cartable) { if (! $cartable instanceof Cartable) {
throw new CartableInterfaceException(); throw new CartableInterfaceException;
} }
// Defaults for cartables that aren't Product / ProductPrice (e.g. an app // Defaults for cartables that aren't Product / ProductPrice (e.g. an app
@ -1016,18 +1036,18 @@ class Cart extends Model
if ($is_booking) { if ($is_booking) {
// Extract dates from parameters if not provided directly // Extract dates from parameters if not provided directly
if (!$from && isset($parameters['from'])) { if (! $from && isset($parameters['from'])) {
$from = is_string($parameters['from']) ? Carbon::parse($parameters['from']) : $parameters['from']; $from = is_string($parameters['from']) ? Carbon::parse($parameters['from']) : $parameters['from'];
} }
if (!$until && isset($parameters['until'])) { if (! $until && isset($parameters['until'])) {
$until = is_string($parameters['until']) ? Carbon::parse($parameters['until']) : $parameters['until']; $until = is_string($parameters['until']) ? Carbon::parse($parameters['until']) : $parameters['until'];
} }
// Fallback to cart dates if no dates provided // Fallback to cart dates if no dates provided
if (!$from && $this->from) { if (! $from && $this->from) {
$from = $this->from; $from = $this->from;
} }
if (!$until && $this->until) { if (! $until && $this->until) {
$until = $this->until; $until = $this->until;
} }
} }
@ -1052,11 +1072,12 @@ class Cart extends Model
->get() ->get()
->filter(function ($item) use ($from, $until) { ->filter(function ($item) use ($from, $until) {
// Only count items with overlapping dates // Only count items with overlapping dates
if (!$item->from || !$item->until) { if (! $item->from || ! $item->until) {
return false; return false;
} }
// Check for overlap: item overlaps if it doesn't end before period starts or start after period ends // Check for overlap: item overlaps if it doesn't end before period starts or start after period ends
return !($item->until < $from || $item->from > $until); return ! ($item->until < $from || $item->from > $until);
}) })
->sum('quantity'); ->sum('quantity');
@ -1094,6 +1115,7 @@ class Cart extends Model
for ($i = 0; $i < $quantity; $i++) { for ($i = 0; $i < $quantity; $i++) {
$lastCartItem = $this->addToCart($cartable, 1, $parameters, $from, $until); $lastCartItem = $this->addToCart($cartable, 1, $parameters, $from, $until);
} }
return $lastCartItem; return $lastCartItem;
} }
@ -1113,7 +1135,7 @@ class Cart extends Model
// Date-based validation will happen at checkout // Date-based validation will happen at checkout
if ( if (
$is_booking $is_booking
&& !$is_pool && ! $is_pool
&& $cartable->manage_stock && $cartable->manage_stock
) { ) {
$totalStock = $cartable->getAvailableStock(); $totalStock = $cartable->getAvailableStock();
@ -1154,7 +1176,7 @@ class Cart extends Model
} }
} elseif ($from || $until) { } elseif ($from || $until) {
// If only one date is provided, it's an error // If only one date is provided, it's an error
throw new CartDatesRequiredException(); throw new CartDatesRequiredException;
} else { } else {
// When adding pool items without dates, validate against total pool capacity // When adding pool items without dates, validate against total pool capacity
// This allows adding items even if currently claimed - date-based validation happens later // This allows adding items even if currently claimed - date-based validation happens later
@ -1280,7 +1302,7 @@ class Cart extends Model
// Still try to find a single item for allocation even with pool's direct price // Still try to find a single item for allocation even with pool's direct price
// This ensures product_id is always set for pool items // This ensures product_id is always set for pool items
if (!$poolSingleItem) { if (! $poolSingleItem) {
$singleItems = $cartable->singleProducts; $singleItems = $cartable->singleProducts;
foreach ($singleItems as $single) { foreach ($singleItems as $single) {
// Find first single with available capacity // Find first single with available capacity
@ -1310,7 +1332,7 @@ class Cart extends Model
if ($pricePerDay === null) { if ($pricePerDay === null) {
if ($is_pool) { if ($is_pool) {
// For pool products, throw specific error when neither pool nor single items have prices // For pool products, throw specific error when neither pool nor single items have prices
throw \Blax\Shop\Exceptions\HasNoPriceException::poolProductNoPriceAndNoSingleItemPrices($cartable->name); throw HasNoPriceException::poolProductNoPriceAndNoSingleItemPrices($cartable->name);
} }
throw new ProductHasNoPriceException($cartable->name); throw new ProductHasNoPriceException($cartable->name);
} }
@ -1370,7 +1392,7 @@ class Cart extends Model
$priceId = $defaultPrice?->id; $priceId = $defaultPrice?->id;
$currency = $defaultPrice?->currency; $currency = $defaultPrice?->currency;
} }
} elseif ($cartable instanceof \Blax\Shop\Models\ProductPrice) { } elseif ($cartable instanceof ProductPrice) {
// If adding a ProductPrice directly, use its ID and currency // If adding a ProductPrice directly, use its ID and currency
$priceId = $cartable->id; $priceId = $cartable->id;
$currency = $cartable->currency; $currency = $cartable->currency;
@ -1433,7 +1455,7 @@ class Cart extends Model
default => null, default => null,
}; };
if (!$product) { if (! $product) {
return; return;
} }
@ -1524,6 +1546,7 @@ class Cart extends Model
: (array) $item->parameters; : (array) $item->parameters;
ksort($existingParams); ksort($existingParams);
ksort($parameters); ksort($parameters);
return $existingParams === $parameters; return $existingParams === $parameters;
}); });
@ -1584,7 +1607,7 @@ class Cart extends Model
$untilDate = Carbon::parse($until ?? $fromDate->copy()->addDays(30))->endOfDay(); $untilDate = Carbon::parse($until ?? $fromDate->copy()->addDays(30))->endOfDay();
// Load items with their purchasable products // Load items with their purchasable products
if (!$this->relationLoaded('items')) { if (! $this->relationLoaded('items')) {
$this->load('items.purchasable'); $this->load('items.purchasable');
} }
@ -1607,12 +1630,12 @@ class Cart extends Model
$productQuantities = []; $productQuantities = [];
foreach ($items as $item) { foreach ($items as $item) {
$product = $item->purchasable; $product = $item->purchasable;
if (!$product) { if (! $product) {
continue; continue;
} }
$productKey = get_class($product) . '|' . $product->id; $productKey = get_class($product).'|'.$product->id;
if (!isset($productQuantities[$productKey])) { if (! isset($productQuantities[$productKey])) {
$productQuantities[$productKey] = [ $productQuantities[$productKey] = [
'product' => $product, 'product' => $product,
'quantity' => 0, 'quantity' => 0,
@ -1831,7 +1854,9 @@ class Cart extends Model
$dates = []; $dates = [];
foreach (($availability['dates'] ?? []) as $iso => $row) { foreach (($availability['dates'] ?? []) as $iso => $row) {
if ($iso < $visibleStartIso || $iso > $visibleEndIso) continue; if ($iso < $visibleStartIso || $iso > $visibleEndIso) {
continue;
}
$dates[$iso] = ($row['max'] ?? 0) >= 1; $dates[$iso] = ($row['max'] ?? 0) >= 1;
} }
@ -1861,8 +1886,7 @@ class Cart extends Model
->pluck('id') ->pluck('id')
->all(); ->all();
$isAvailableOn = fn (string $iso): bool => $isAvailableOn = fn (string $iso): bool => isset($dayRows[$iso]) && ($dayRows[$iso]['max'] ?? 0) >= $required;
isset($dayRows[$iso]) && ($dayRows[$iso]['max'] ?? 0) >= $required;
$availableForSelected = $selectedFromIso && $selectedUntilIso; $availableForSelected = $selectedFromIso && $selectedUntilIso;
if ($availableForSelected) { if ($availableForSelected) {
@ -1880,10 +1904,15 @@ class Cart extends Model
$windowFitsAt = function (string $startIso) use (&$isAvailableOn, $durationDays, $searchEnd): bool { $windowFitsAt = function (string $startIso) use (&$isAvailableOn, $durationDays, $searchEnd): bool {
$cursor = Carbon::parse($startIso); $cursor = Carbon::parse($startIso);
for ($i = 0; $i < $durationDays; $i++) { for ($i = 0; $i < $durationDays; $i++) {
if ($cursor->gt($searchEnd)) return false; if ($cursor->gt($searchEnd)) {
if (! $isAvailableOn($cursor->toDateString())) return false; return false;
}
if (! $isAvailableOn($cursor->toDateString())) {
return false;
}
$cursor->addDay(); $cursor->addDay();
} }
return true; return true;
}; };
@ -1930,7 +1959,9 @@ class Cart extends Model
$datesPartial = []; $datesPartial = [];
$partialWindows = []; $partialWindows = [];
foreach ($dayRows as $iso => $row) { foreach ($dayRows as $iso => $row) {
if ($iso < $visibleStartIso || $iso > $visibleEndIso) continue; if ($iso < $visibleStartIso || $iso > $visibleEndIso) {
continue;
}
$max = $row['max'] ?? 0; $max = $row['max'] ?? 0;
$min = $row['min'] ?? 0; $min = $row['min'] ?? 0;
if ($max < $required) { if ($max < $required) {
@ -1944,7 +1975,7 @@ class Cart extends Model
// (only present on partial days, by design). // (only present on partial days, by design).
$datesPartial[] = $iso; $datesPartial[] = $iso;
$transitions = $transitionsByDay[$iso] ?? []; $transitions = $transitionsByDay[$iso] ?? [];
if (!empty($transitions)) { if (! empty($transitions)) {
$windows = []; $windows = [];
$windowFrom = null; $windowFrom = null;
foreach ($transitions as $t) { foreach ($transitions as $t) {
@ -1953,7 +1984,7 @@ class Cart extends Model
$isOk = $available >= $required; $isOk = $available >= $required;
if ($isOk && $windowFrom === null) { if ($isOk && $windowFrom === null) {
$windowFrom = $time; $windowFrom = $time;
} elseif (!$isOk && $windowFrom !== null) { } elseif (! $isOk && $windowFrom !== null) {
// Skip zero-length windows that show up when // Skip zero-length windows that show up when
// two transitions land on the same minute. // two transitions land on the same minute.
if ($windowFrom !== $time) { if ($windowFrom !== $time) {
@ -1966,7 +1997,7 @@ class Cart extends Model
// Open window runs to end-of-day. // Open window runs to end-of-day.
$windows[] = ['from' => $windowFrom, 'until' => '23:59']; $windows[] = ['from' => $windowFrom, 'until' => '23:59'];
} }
if (!empty($windows)) { if (! empty($windows)) {
$partialWindows[$iso] = $windows; $partialWindows[$iso] = $windows;
} }
} }
@ -2012,7 +2043,7 @@ class Cart extends Model
// Check if cart is already converted // Check if cart is already converted
if ($this->isConverted()) { if ($this->isConverted()) {
if ($throws) { if ($throws) {
throw new CartAlreadyConvertedException(); throw new CartAlreadyConvertedException;
} else { } else {
return false; return false;
} }
@ -2024,7 +2055,7 @@ class Cart extends Model
if ($items->isEmpty()) { if ($items->isEmpty()) {
if ($throws) { if ($throws) {
throw new CartEmptyException(); throw new CartEmptyException;
} else { } else {
return false; return false;
} }
@ -2033,7 +2064,7 @@ class Cart extends Model
// Validate that all items have required information before checkout // Validate that all items have required information before checkout
foreach ($items as $item) { foreach ($items as $item) {
$adjustments = $item->requiredAdjustments(); $adjustments = $item->requiredAdjustments();
if (!empty($adjustments)) { if (! empty($adjustments)) {
$product = $item->purchasable; $product = $item->purchasable;
$productName = $product ? $product->name : 'Unknown Product'; $productName = $product ? $product->name : 'Unknown Product';
$missingFields = implode(', ', array_keys($adjustments)); $missingFields = implode(', ', array_keys($adjustments));
@ -2050,7 +2081,7 @@ class Cart extends Model
foreach ($items as $item) { foreach ($items as $item) {
$product = $item->purchasable; $product = $item->purchasable;
if (!($product instanceof Product)) { if (! ($product instanceof Product)) {
continue; continue;
} }
@ -2067,8 +2098,7 @@ class Cart extends Model
// Calculate how much of this cart's items are already counted // Calculate how much of this cart's items are already counted
// We need to check if there's still enough stock for what's in this cart // We need to check if there's still enough stock for what's in this cart
$cartItemsForPool = $items->filter( $cartItemsForPool = $items->filter(
fn($i) => fn ($i) => $i->purchasable_id === $product->id &&
$i->purchasable_id === $product->id &&
$i->purchasable_type === get_class($product) $i->purchasable_type === get_class($product)
); );
$totalInCart = $cartItemsForPool->sum('quantity'); $totalInCart = $cartItemsForPool->sum('quantity');
@ -2076,7 +2106,7 @@ class Cart extends Model
if ($available !== PHP_INT_MAX && $totalInCart > $available) { if ($available !== PHP_INT_MAX && $totalInCart > $available) {
if ($throws) { if ($throws) {
throw new NotEnoughStockException( throw new NotEnoughStockException(
"Pool product '{$product->name}' has only {$available} items available for the period " . "Pool product '{$product->name}' has only {$available} items available for the period ".
"{$from->format('Y-m-d')} to {$until->format('Y-m-d')}. Cart has: {$totalInCart}" "{$from->format('Y-m-d')} to {$until->format('Y-m-d')}. Cart has: {$totalInCart}"
); );
} else { } else {
@ -2087,8 +2117,7 @@ class Cart extends Model
// Without dates, check general pool availability // Without dates, check general pool availability
$available = $product->getPoolMaxQuantity(); $available = $product->getPoolMaxQuantity();
$totalInCart = $items->filter( $totalInCart = $items->filter(
fn($i) => fn ($i) => $i->purchasable_id === $product->id &&
$i->purchasable_id === $product->id &&
$i->purchasable_type === get_class($product) $i->purchasable_type === get_class($product)
)->sum('quantity'); )->sum('quantity');
@ -2105,10 +2134,10 @@ class Cart extends Model
} elseif ($product->isBooking() && $product->manage_stock) { } elseif ($product->isBooking() && $product->manage_stock) {
// For booking products with managed stock // For booking products with managed stock
if ($from && $until) { if ($from && $until) {
if (!$product->isAvailableForBooking($from, $until, $item->quantity)) { if (! $product->isAvailableForBooking($from, $until, $item->quantity)) {
if ($throws) { if ($throws) {
throw new NotEnoughStockException( throw new NotEnoughStockException(
"Booking product '{$product->name}' is not available for the period " . "Booking product '{$product->name}' is not available for the period ".
"{$from->format('Y-m-d')} to {$until->format('Y-m-d')}. Requested: {$item->quantity}" "{$from->format('Y-m-d')} to {$until->format('Y-m-d')}. Requested: {$item->quantity}"
); );
} else { } else {
@ -2177,10 +2206,10 @@ class Cart extends Model
* *
* @return static The converted cart (fresh state within the transaction scope). * @return static The converted cart (fresh state within the transaction scope).
* *
* @throws \Blax\Shop\Exceptions\CartAlreadyConvertedException * @throws CartAlreadyConvertedException
* @throws \Blax\Shop\Exceptions\CartEmptyException * @throws CartEmptyException
* @throws \Blax\Shop\Exceptions\CartItemMissingInformationException * @throws CartItemMissingInformationException
* @throws \Blax\Shop\Exceptions\NotEnoughStockException * @throws NotEnoughStockException
* @throws \Throwable For any other unexpected failures during checkout/stock claiming. * @throws \Throwable For any other unexpected failures during checkout/stock claiming.
*/ */
public function checkout(): static public function checkout(): static
@ -2211,7 +2240,7 @@ class Cart extends Model
$from = $item->from; $from = $item->from;
$until = $item->until; $until = $item->until;
if (!$from || !$until) { if (! $from || ! $until) {
if (($product->type === ProductType::BOOKING || $product->type === ProductType::POOL) && $item->parameters) { if (($product->type === ProductType::BOOKING || $product->type === ProductType::POOL) && $item->parameters) {
$params = is_array($item->parameters) ? $item->parameters : (array) $item->parameters; $params = is_array($item->parameters) ? $item->parameters : (array) $item->parameters;
$from = $params['from'] ?? null; $from = $params['from'] ?? null;
@ -2230,7 +2259,7 @@ class Cart extends Model
// Handle pool products with booking single items // Handle pool products with booking single items
if ($product instanceof Product && $product->isPool()) { if ($product instanceof Product && $product->isPool()) {
// Check if pool with booking items requires timespan // Check if pool with booking items requires timespan
if ($product->hasBookingSingleItems() && (!$from || !$until)) { if ($product->hasBookingSingleItems() && (! $from || ! $until)) {
throw new \Exception("Pool product '{$product->name}' with booking items requires a timespan (from/until dates)."); throw new \Exception("Pool product '{$product->name}' with booking items requires a timespan (from/until dates).");
} }
@ -2243,7 +2272,7 @@ class Cart extends Model
if ($allocatedSingleId) { if ($allocatedSingleId) {
// Use the pre-allocated single item from product_id // Use the pre-allocated single item from product_id
$singleItem = $item->product; $singleItem = $item->product;
if (!$singleItem) { if (! $singleItem) {
throw new \Exception("Allocated single item not found: {$allocatedSingleId}"); throw new \Exception("Allocated single item not found: {$allocatedSingleId}");
} }
@ -2262,16 +2291,16 @@ class Cart extends Model
} }
// Store claimed items info in purchase meta // Store claimed items info in purchase meta
$item->updateMetaKey('claimed_single_items', array_map(fn($i) => $i->id, $claimedItems)); $item->updateMetaKey('claimed_single_items', array_map(fn ($i) => $i->id, $claimedItems));
$item->save(); $item->save();
} catch (\Exception $e) { } catch (\Exception $e) {
throw new \Exception("Failed to checkout pool product '{$product->name}': " . $e->getMessage()); throw new \Exception("Failed to checkout pool product '{$product->name}': ".$e->getMessage());
} }
} }
} }
// Validate booking products have required dates // Validate booking products have required dates
if ($product instanceof Product && $product->isBooking() && !$product->isPool() && (!$from || !$until)) { if ($product instanceof Product && $product->isBooking() && ! $product->isPool() && (! $from || ! $until)) {
throw new \Exception("Booking product '{$product->name}' requires a timespan (from/until dates)."); throw new \Exception("Booking product '{$product->name}' requires a timespan (from/until dates).");
} }
@ -2321,19 +2350,19 @@ class Cart extends Model
*/ */
protected function stripeRecurringFor($price): ?array protected function stripeRecurringFor($price): ?array
{ {
if (!$price) { if (! $price) {
return null; return null;
} }
$type = $price->type instanceof \Blax\Shop\Enums\PriceType $type = $price->type instanceof PriceType
? $price->type->value ? $price->type->value
: (string) $price->type; : (string) $price->type;
if ($type !== \Blax\Shop\Enums\PriceType::RECURRING->value) { if ($type !== PriceType::RECURRING->value) {
return null; return null;
} }
$interval = $price->interval instanceof \Blax\Shop\Enums\RecurringInterval $interval = $price->interval instanceof RecurringInterval
? $price->interval->value ? $price->interval->value
: (is_string($price->interval) ? $price->interval : null); : (is_string($price->interval) ? $price->interval : null);
@ -2346,7 +2375,7 @@ class Cart extends Model
$count = 1; $count = 1;
} }
if ($interval === \Blax\Shop\Enums\RecurringInterval::QUARTER->value) { if ($interval === RecurringInterval::QUARTER->value) {
return ['interval' => 'month', 'interval_count' => 3 * $count]; return ['interval' => 'month', 'interval_count' => 3 * $count];
} }
@ -2368,18 +2397,18 @@ class Cart extends Model
* *
* @param array $options Optional session parameters (success_url, cancel_url, etc.) * @param array $options Optional session parameters (success_url, cancel_url, etc.)
* @param string|null $url Optional fullPath URL for success and cancel URLs * @param string|null $url Optional fullPath URL for success and cancel URLs
*
* @return mixed Stripe\Checkout\Session instance * @return mixed Stripe\Checkout\Session instance
*
* @throws \Exception * @throws \Exception
*/ */
public function checkoutSession(array $options = [], ?string $url = null) public function checkoutSession(array $options = [], ?string $url = null)
{ {
if (!config('shop.stripe.enabled')) { if (! config('shop.stripe.enabled')) {
throw new \Exception('Stripe is not enabled'); throw new \Exception('Stripe is not enabled');
} }
// Ensure Stripe is initialized // Ensure Stripe is initialized
\Stripe\Stripe::setApiKey(config('services.stripe.secret')); Stripe::setApiKey(config('services.stripe.secret'));
// Validate cart before proceeding (doesn't convert it) // Validate cart before proceeding (doesn't convert it)
$this->validateForCheckout(); $this->validateForCheckout();
@ -2429,7 +2458,7 @@ class Cart extends Model
$priceModel = $item->price()->first(); $priceModel = $item->price()->first();
// Get product name (use short_description if available, otherwise name) // Get product name (use short_description if available, otherwise name)
$productName = $product->name ?? 'Product [' . $product->id . ']'; $productName = $product->name ?? 'Product ['.$product->id.']';
$description = $product->short_description ?? null; $description = $product->short_description ?? null;
// Build description with booking dates if available // Build description with booking dates if available
@ -2464,11 +2493,12 @@ class Cart extends Model
// canonical record Stripe already knows (name, tax behaviour, // canonical record Stripe already knows (name, tax behaviour,
// metadata). Fall back to dynamic price_data + recurring block // metadata). Fall back to dynamic price_data + recurring block
// when no stripe_price_id is on file. // when no stripe_price_id is on file.
if (!empty($priceModel?->stripe_price_id)) { if (! empty($priceModel?->stripe_price_id)) {
$lineItems[] = [ $lineItems[] = [
'price' => $priceModel->stripe_price_id, 'price' => $priceModel->stripe_price_id,
'quantity' => $item->quantity, 'quantity' => $item->quantity,
]; ];
continue; continue;
} }
@ -2484,6 +2514,7 @@ class Cart extends Model
], ],
'quantity' => $item->quantity, 'quantity' => $item->quantity,
]; ];
continue; continue;
} }
@ -2509,7 +2540,7 @@ class Cart extends Model
// session — surface that explicitly rather than letting Stripe reject // session — surface that explicitly rather than letting Stripe reject
// it with an opaque error. // it with an opaque error.
if ($hasRecurring && $hasOneTime) { if ($hasRecurring && $hasOneTime) {
throw new \Blax\Shop\Exceptions\MixedCheckoutModeException(); throw new MixedCheckoutModeException;
} }
$mode = $hasRecurring ? 'subscription' : 'payment'; $mode = $hasRecurring ? 'subscription' : 'payment';
@ -2518,12 +2549,12 @@ class Cart extends Model
$cancel_url = $url ?? $options['cancel_url'] ?? route('shop.stripe.cancel'); $cancel_url = $url ?? $options['cancel_url'] ?? route('shop.stripe.cancel');
$success_url = (strpos($success_url, '?')) $success_url = (strpos($success_url, '?'))
? $success_url . '&session_id={CHECKOUT_SESSION_ID}&cart_id=' . $this->id ? $success_url.'&session_id={CHECKOUT_SESSION_ID}&cart_id='.$this->id
: $success_url . '?session_id={CHECKOUT_SESSION_ID}&cart_id=' . $this->id; : $success_url.'?session_id={CHECKOUT_SESSION_ID}&cart_id='.$this->id;
$cancel_url = (strpos($cancel_url, '?')) $cancel_url = (strpos($cancel_url, '?'))
? $cancel_url . '&cart_id=' . $this->id ? $cancel_url.'&cart_id='.$this->id
: $cancel_url . '?cart_id=' . $this->id; : $cancel_url.'?cart_id='.$this->id;
// Prepare session parameters // Prepare session parameters
$sessionParams = [ $sessionParams = [
@ -2565,24 +2596,24 @@ class Cart extends Model
} }
try { try {
$session = \Stripe\Checkout\Session::create($sessionParams); $session = Session::create($sessionParams);
// Store session ID in cart meta // Store session ID in cart meta
$meta = $this->meta ?? (object)[]; $meta = $this->meta ?? (object) [];
if (is_array($meta)) { if (is_array($meta)) {
$meta = (object)$meta; $meta = (object) $meta;
} }
$meta->stripe_session_id = $session->id; $meta->stripe_session_id = $session->id;
$this->update(['meta' => $meta]); $this->update(['meta' => $meta]);
\Illuminate\Support\Facades\Log::info('Stripe checkout session created', [ Log::info('Stripe checkout session created', [
'cart_id' => $this->id, 'cart_id' => $this->id,
'session_id' => $session->id, 'session_id' => $session->id,
]); ]);
return $session; return $session;
} catch (\Exception $e) { } catch (\Exception $e) {
\Illuminate\Support\Facades\Log::error('Stripe checkout session creation failed', [ Log::error('Stripe checkout session creation failed', [
'cart_id' => $this->id, 'cart_id' => $this->id,
'error' => $e->getMessage(), 'error' => $e->getMessage(),
]); ]);
@ -2598,8 +2629,6 @@ class Cart extends Model
* - string: The checkout session URL if a session exists and is valid * - string: The checkout session URL if a session exists and is valid
* - null: If no session exists or Stripe is not enabled * - null: If no session exists or Stripe is not enabled
* - false: If an error occurred while retrieving the session * - false: If an error occurred while retrieving the session
*
* @return string|null|false
*/ */
public function checkoutSessionLink(array $option = [], ?string $url = null): string|null|false public function checkoutSessionLink(array $option = [], ?string $url = null): string|null|false
{ {
@ -2612,7 +2641,7 @@ class Cart extends Model
if ($checkoutSession) { if ($checkoutSession) {
if ( if (
isset($checkoutSession->url) isset($checkoutSession->url)
&& !empty($checkoutSession->url) && ! empty($checkoutSession->url)
) { ) {
return $checkoutSession->url; return $checkoutSession->url;
} }

View File

@ -6,6 +6,8 @@ namespace Blax\Shop\Models;
use Blax\Shop\Enums\OrderStatus; use Blax\Shop\Enums\OrderStatus;
use Blax\Shop\Enums\PurchaseStatus; use Blax\Shop\Enums\PurchaseStatus;
use Blax\Shop\Events\OrderCreated;
use Blax\Shop\Events\OrderPaid;
use Illuminate\Database\Eloquent\Concerns\HasUuids; use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
@ -14,6 +16,7 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasManyThrough; use Illuminate\Database\Eloquent\Relations\HasManyThrough;
use Illuminate\Database\Eloquent\Relations\MorphTo; use Illuminate\Database\Eloquent\Relations\MorphTo;
use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
/** /**
@ -24,7 +27,7 @@ use Illuminate\Support\Facades\DB;
*/ */
class Order extends Model class Order extends Model
{ {
use HasUuids, HasFactory, SoftDeletes; use HasFactory, HasUuids, SoftDeletes;
protected $fillable = [ protected $fillable = [
'order_number', 'order_number',
@ -89,7 +92,7 @@ class Order extends Model
]; ];
protected $dispatchesEvents = [ protected $dispatchesEvents = [
'created' => \Blax\Shop\Events\OrderCreated::class, 'created' => OrderCreated::class,
]; ];
public function __construct(array $attributes = []) public function __construct(array $attributes = [])
@ -129,19 +132,19 @@ class Order extends Model
); );
// Set timestamp fields based on status // Set timestamp fields based on status
if ($newStatus === OrderStatus::COMPLETED && !$order->completed_at) { if ($newStatus === OrderStatus::COMPLETED && ! $order->completed_at) {
$order->completed_at = now(); $order->completed_at = now();
} }
if ($newStatus === OrderStatus::SHIPPED && !$order->shipped_at) { if ($newStatus === OrderStatus::SHIPPED && ! $order->shipped_at) {
$order->shipped_at = now(); $order->shipped_at = now();
} }
if ($newStatus === OrderStatus::DELIVERED && !$order->delivered_at) { if ($newStatus === OrderStatus::DELIVERED && ! $order->delivered_at) {
$order->delivered_at = now(); $order->delivered_at = now();
} }
if ($newStatus === OrderStatus::CANCELLED && !$order->cancelled_at) { if ($newStatus === OrderStatus::CANCELLED && ! $order->cancelled_at) {
$order->cancelled_at = now(); $order->cancelled_at = now();
} }
if ($newStatus === OrderStatus::REFUNDED && !$order->refunded_at) { if ($newStatus === OrderStatus::REFUNDED && ! $order->refunded_at) {
$order->refunded_at = now(); $order->refunded_at = now();
} }
} }
@ -155,13 +158,13 @@ class Order extends Model
if ($difference > 0) { if ($difference > 0) {
$currency = $order->currency ?? config('shop.currency', 'USD'); $currency = $order->currency ?? config('shop.currency', 'USD');
$order->addNote( $order->addNote(
"Payment received: " . static::formatMoney($difference, $currency), 'Payment received: '.static::formatMoney($difference, $currency),
'payment', 'payment',
true true
); );
// Mark as paid if fully paid // Mark as paid if fully paid
if (!$order->paid_at && $newPaid >= $order->amount_total) { if (! $order->paid_at && $newPaid >= $order->amount_total) {
$order->paid_at = now(); $order->paid_at = now();
} }
} }
@ -174,8 +177,8 @@ class Order extends Model
// an already-paid order (status moves, meta touches) must not re-fire, // an already-paid order (status moves, meta touches) must not re-fire,
// and a host subclass configured via shop.models.order gets it too. // and a host subclass configured via shop.models.order gets it too.
static::updated(function (Order $order) { static::updated(function (Order $order) {
if ($order->wasChanged('paid_at') && $order->paid_at && !$order->getOriginal('paid_at')) { if ($order->wasChanged('paid_at') && $order->paid_at && ! $order->getOriginal('paid_at')) {
\Blax\Shop\Events\OrderPaid::dispatch($order); OrderPaid::dispatch($order);
} }
}); });
} }
@ -211,7 +214,8 @@ class Order extends Model
public static function formatMoney(int $amount, string $currency = 'USD'): string public static function formatMoney(int $amount, string $currency = 'USD'): string
{ {
$formatted = number_format($amount / 100, 2); $formatted = number_format($amount / 100, 2);
return strtoupper($currency) . ' ' . $formatted;
return strtoupper($currency).' '.$formatted;
} }
// ========================================================================= // =========================================================================
@ -309,7 +313,7 @@ class Order extends Model
*/ */
public function updateStatus(OrderStatus $newStatus, ?string $note = null): self public function updateStatus(OrderStatus $newStatus, ?string $note = null): self
{ {
if ($this->status && !$this->status->canTransitionTo($newStatus)) { if ($this->status && ! $this->status->canTransitionTo($newStatus)) {
throw new \InvalidArgumentException( throw new \InvalidArgumentException(
"Cannot transition order from '{$this->status->label()}' to '{$newStatus->label()}'" "Cannot transition order from '{$this->status->label()}' to '{$newStatus->label()}'"
); );
@ -462,7 +466,7 @@ class Order extends Model
$currency = $this->currency ?? config('shop.currency', 'USD'); $currency = $this->currency ?? config('shop.currency', 'USD');
$this->addNote( $this->addNote(
"Refund processed: " . static::formatMoney($amount, $currency) . 'Refund processed: '.static::formatMoney($amount, $currency).
($reason ? " - Reason: {$reason}" : ''), ($reason ? " - Reason: {$reason}" : ''),
'refund', 'refund',
true true
@ -487,7 +491,7 @@ class Order extends Model
* @param string $content The note content * @param string $content The note content
* @param string $type The note type (note, status_change, payment, etc.) * @param string $type The note type (note, status_change, payment, etc.)
* @param bool $isCustomerNote Whether the note is visible to the customer * @param bool $isCustomerNote Whether the note is visible to the customer
* @param \Illuminate\Database\Eloquent\Model|null $author The author model (User, Admin, etc.) * @param Model|null $author The author model (User, Admin, etc.)
* @param array|object|null $meta Additional metadata * @param array|object|null $meta Additional metadata
*/ */
public function addNote( public function addNote(
@ -533,7 +537,7 @@ class Order extends Model
public function getMeta(?string $key = null, $default = null) public function getMeta(?string $key = null, $default = null)
{ {
if ($key === null) { if ($key === null) {
return $this->meta ?? new \stdClass(); return $this->meta ?? new \stdClass;
} }
return $this->meta?->{$key} ?? $default; return $this->meta?->{$key} ?? $default;
@ -544,7 +548,7 @@ class Order extends Model
*/ */
public function updateMetaKey(string $key, $value): self public function updateMetaKey(string $key, $value): self
{ {
$meta = (array) ($this->meta ?? new \stdClass()); $meta = (array) ($this->meta ?? new \stdClass);
$meta[$key] = $value; $meta[$key] = $value;
$this->meta = (object) $meta; $this->meta = (object) $meta;
$this->save(); $this->save();
@ -569,7 +573,7 @@ class Order extends Model
*/ */
public function scopeWithStatuses($query, array $statuses) public function scopeWithStatuses($query, array $statuses)
{ {
return $query->whereIn('status', array_map(fn($s) => $s->value, $statuses)); return $query->whereIn('status', array_map(fn ($s) => $s->value, $statuses));
} }
/** /**
@ -594,8 +598,8 @@ class Order extends Model
public function scopeFinal($query) public function scopeFinal($query)
{ {
$finalStatuses = array_map( $finalStatuses = array_map(
fn(OrderStatus $status) => $status->value, fn (OrderStatus $status) => $status->value,
array_filter(OrderStatus::cases(), fn(OrderStatus $status) => $status->isFinal()) array_filter(OrderStatus::cases(), fn (OrderStatus $status) => $status->isFinal())
); );
return $query->whereIn('status', $finalStatuses); return $query->whereIn('status', $finalStatuses);
@ -616,8 +620,8 @@ class Order extends Model
public function scopeRequiresPayment($query) public function scopeRequiresPayment($query)
{ {
$requiresPaymentStatuses = array_map( $requiresPaymentStatuses = array_map(
fn(OrderStatus $status) => $status->value, fn (OrderStatus $status) => $status->value,
array_filter(OrderStatus::cases(), fn(OrderStatus $status) => $status->requiresPayment()) array_filter(OrderStatus::cases(), fn (OrderStatus $status) => $status->requiresPayment())
); );
return $query->whereIn('status', $requiresPaymentStatuses); return $query->whereIn('status', $requiresPaymentStatuses);
@ -630,8 +634,8 @@ class Order extends Model
public function scopeStatusPaid($query) public function scopeStatusPaid($query)
{ {
$paidStatuses = array_map( $paidStatuses = array_map(
fn(OrderStatus $status) => $status->value, fn (OrderStatus $status) => $status->value,
array_filter(OrderStatus::cases(), fn(OrderStatus $status) => $status->isPaid()) array_filter(OrderStatus::cases(), fn (OrderStatus $status) => $status->isPaid())
); );
return $query->whereIn('status', $paidStatuses); return $query->whereIn('status', $paidStatuses);
@ -789,6 +793,7 @@ class Order extends Model
public static function getNetRevenue(): int public static function getNetRevenue(): int
{ {
$result = static::selectRaw('COALESCE(SUM(amount_paid), 0) - COALESCE(SUM(amount_refunded), 0) as net')->first(); $result = static::selectRaw('COALESCE(SUM(amount_paid), 0) - COALESCE(SUM(amount_refunded), 0) as net')->first();
return (int) $result->net; return (int) $result->net;
} }
@ -827,7 +832,7 @@ class Order extends Model
public static function getRevenueSummary(\DateTimeInterface $from, \DateTimeInterface $until): array public static function getRevenueSummary(\DateTimeInterface $from, \DateTimeInterface $until): array
{ {
$stats = static::createdBetween($from, $until) $stats = static::createdBetween($from, $until)
->selectRaw(" ->selectRaw('
COUNT(*) as total_orders, COUNT(*) as total_orders,
SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) as completed, SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) as completed,
SUM(CASE WHEN amount_paid > 0 THEN 1 ELSE 0 END) as paid, SUM(CASE WHEN amount_paid > 0 THEN 1 ELSE 0 END) as paid,
@ -837,7 +842,7 @@ class Order extends Model
COALESCE(SUM(amount_refunded), 0) as refunded, COALESCE(SUM(amount_refunded), 0) as refunded,
COALESCE(AVG(amount_total), 0) as avg_order_value, COALESCE(AVG(amount_total), 0) as avg_order_value,
COALESCE(AVG(amount_paid), 0) as avg_paid_amount COALESCE(AVG(amount_paid), 0) as avg_paid_amount
", [OrderStatus::COMPLETED->value]) ', [OrderStatus::COMPLETED->value])
->first(); ->first();
return [ return [
@ -867,7 +872,7 @@ class Order extends Model
/** /**
* Get daily revenue breakdown for a date range. * Get daily revenue breakdown for a date range.
*/ */
public static function getDailyRevenue(\DateTimeInterface $from, \DateTimeInterface $until): \Illuminate\Support\Collection public static function getDailyRevenue(\DateTimeInterface $from, \DateTimeInterface $until): Collection
{ {
return static::createdBetween($from, $until) return static::createdBetween($from, $until)
->selectRaw('DATE(created_at) as date') ->selectRaw('DATE(created_at) as date')
@ -883,7 +888,7 @@ class Order extends Model
/** /**
* Get monthly revenue breakdown for a date range. * Get monthly revenue breakdown for a date range.
*/ */
public static function getMonthlyRevenue(\DateTimeInterface $from, \DateTimeInterface $until): \Illuminate\Support\Collection public static function getMonthlyRevenue(\DateTimeInterface $from, \DateTimeInterface $until): Collection
{ {
return static::createdBetween($from, $until) return static::createdBetween($from, $until)
->selectRaw('YEAR(created_at) as year') ->selectRaw('YEAR(created_at) as year')
@ -907,7 +912,7 @@ class Order extends Model
*/ */
public static function createFromCart(Cart $cart): self public static function createFromCart(Cart $cart): self
{ {
if (!$cart->converted_at) { if (! $cart->converted_at) {
throw new \InvalidArgumentException('Cart must be converted before creating an order'); throw new \InvalidArgumentException('Cart must be converted before creating an order');
} }

View File

@ -12,12 +12,15 @@ use Blax\Shop\Models\Order;
use Blax\Shop\Models\Product; use Blax\Shop\Models\Product;
use Blax\Shop\Models\ProductCategory; use Blax\Shop\Models\ProductCategory;
use Blax\Shop\Models\ProductPrice; use Blax\Shop\Models\ProductPrice;
use Blax\Shop\Models\ProductPurchase;
use Blax\Shop\Models\StripeTransaction; use Blax\Shop\Models\StripeTransaction;
use Blax\Shop\Models\Subscription; use Blax\Shop\Models\Subscription;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Collection;
use Carbon\Carbon; use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Log;
use Stripe\Charge;
use Stripe\Stripe;
class ShopService class ShopService
{ {
@ -27,8 +30,6 @@ class ShopService
/** /**
* Get all products query builder * Get all products query builder
*
* @return Builder
*/ */
public function products(): Builder public function products(): Builder
{ {
@ -39,7 +40,6 @@ class ShopService
* Get a product by ID * Get a product by ID
* *
* @param mixed $id * @param mixed $id
* @return Product|null
*/ */
public function product($id): ?Product public function product($id): ?Product
{ {
@ -48,8 +48,6 @@ class ShopService
/** /**
* Get all categories query builder * Get all categories query builder
*
* @return Builder
*/ */
public function categories(): Builder public function categories(): Builder
{ {
@ -58,8 +56,6 @@ class ShopService
/** /**
* Get in-stock products * Get in-stock products
*
* @return Builder
*/ */
public function inStock(): Builder public function inStock(): Builder
{ {
@ -68,8 +64,6 @@ class ShopService
/** /**
* Get featured products * Get featured products
*
* @return Builder
*/ */
public function featured(): Builder public function featured(): Builder
{ {
@ -78,8 +72,6 @@ class ShopService
/** /**
* Get published and visible products * Get published and visible products
*
* @return Builder
*/ */
public function published(): Builder public function published(): Builder
{ {
@ -88,9 +80,6 @@ class ShopService
/** /**
* Search products by query * Search products by query
*
* @param string $query
* @return Builder
*/ */
public function search(string $query): Builder public function search(string $query): Builder
{ {
@ -103,14 +92,10 @@ class ShopService
/** /**
* Check if product has available stock for quantity * Check if product has available stock for quantity
*
* @param Product $product
* @param int $quantity
* @return bool
*/ */
public function checkStock(Product $product, int $quantity): bool public function checkStock(Product $product, int $quantity): bool
{ {
if (!$product->manage_stock) { if (! $product->manage_stock) {
return true; return true;
} }
@ -119,13 +104,10 @@ class ShopService
/** /**
* Get available stock for a product * Get available stock for a product
*
* @param Product $product
* @return int
*/ */
public function getAvailableStock(Product $product): int public function getAvailableStock(Product $product): int
{ {
if (!$product->manage_stock) { if (! $product->manage_stock) {
return PHP_INT_MAX; return PHP_INT_MAX;
} }
@ -134,9 +116,6 @@ class ShopService
/** /**
* Check if product is on sale * Check if product is on sale
*
* @param Product $product
* @return bool
*/ */
public function isOnSale(Product $product): bool public function isOnSale(Product $product): bool
{ {
@ -405,7 +384,7 @@ class ShopService
$startOfYear = Carbon::now()->startOfYear(); $startOfYear = Carbon::now()->startOfYear();
$endOfYear = Carbon::now()->endOfYear(); $endOfYear = Carbon::now()->endOfYear();
$orderStats = $this->orderModel()::selectRaw(" $orderStats = $this->orderModel()::selectRaw('
COUNT(*) as total, COUNT(*) as total,
SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) as pending, SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) as pending,
SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) as processing, SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) as processing,
@ -421,7 +400,7 @@ class ShopService
COALESCE(SUM(CASE WHEN created_at BETWEEN ? AND ? THEN amount_paid ELSE 0 END), 0) as revenue_this_year, COALESCE(SUM(CASE WHEN created_at BETWEEN ? AND ? THEN amount_paid ELSE 0 END), 0) as revenue_this_year,
COALESCE(SUM(amount_refunded), 0) as total_refunded, COALESCE(SUM(amount_refunded), 0) as total_refunded,
COALESCE(AVG(amount_total), 0) as average_order COALESCE(AVG(amount_total), 0) as average_order
", [ ', [
OrderStatus::PENDING->value, OrderStatus::PENDING->value,
OrderStatus::PROCESSING->value, OrderStatus::PROCESSING->value,
OrderStatus::COMPLETED->value, OrderStatus::COMPLETED->value,
@ -925,7 +904,6 @@ class ShopService
/** /**
* Get shop configuration value * Get shop configuration value
* *
* @param string $key
* @param mixed $default * @param mixed $default
* @return mixed * @return mixed
*/ */
@ -936,8 +914,6 @@ class ShopService
/** /**
* Get default shop currency * Get default shop currency
*
* @return string
*/ */
public function currency(): string public function currency(): string
{ {
@ -952,7 +928,7 @@ class ShopService
$currency = $currency ?? $this->currency(); $currency = $currency ?? $this->currency();
$amount = $cents / 100; $amount = $cents / 100;
return number_format($amount, 2) . ' ' . strtoupper($currency); return number_format($amount, 2).' '.strtoupper($currency);
} }
// ========================================================================= // =========================================================================
@ -962,7 +938,7 @@ class ShopService
/** /**
* The configured {@see StripeTransaction} model class. * The configured {@see StripeTransaction} model class.
* *
* @return class-string<\Illuminate\Database\Eloquent\Model> * @return class-string<Model>
*/ */
protected function ledgerModel(): string protected function ledgerModel(): string
{ {
@ -1233,8 +1209,8 @@ class ShopService
} }
try { try {
\Stripe\Stripe::setApiKey($secret); Stripe::setApiKey($secret);
$charge = \Stripe\Charge::retrieve([ $charge = Charge::retrieve([
'id' => $chargeId, 'id' => $chargeId,
'expand' => ['balance_transaction', 'refunds.data.balance_transaction'], 'expand' => ['balance_transaction', 'refunds.data.balance_transaction'],
]); ]);
@ -1254,7 +1230,7 @@ class ShopService
return $count; return $count;
} catch (\Throwable $e) { } catch (\Throwable $e) {
\Illuminate\Support\Facades\Log::warning('[shop:ledger] syncLedgerForCharge failed', [ Log::warning('[shop:ledger] syncLedgerForCharge failed', [
'charge' => $chargeId, 'charge' => $chargeId,
'error' => $e->getMessage(), 'error' => $e->getMessage(),
]); ]);