generated from boboko/starter
stock check, variants in cart, variant buttons in product page
This commit is contained in:
@@ -3,8 +3,11 @@
|
||||
namespace App\Http\Controllers\Checkout;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
use Lunar\Exceptions\Carts\CartException;
|
||||
use Lunar\Models\CartLine;
|
||||
use Lunar\Models\ProductVariant;
|
||||
use Modules\Core\Cart\Exceptions\InvalidCouponException;
|
||||
use Modules\Core\Cart\Services\CartService;
|
||||
@@ -22,7 +25,18 @@ public function __construct(
|
||||
private readonly CartService $cart,
|
||||
) {}
|
||||
|
||||
public function add(string $locale, Request $request): View
|
||||
/**
|
||||
* CartException here is Lunar's own add_to_cart validation pipeline
|
||||
* (CartLineQuantity/CartLineStock) rejecting the line — most commonly
|
||||
* "not enough stock at this quantity" for a tracked (purchasable =
|
||||
* in_stock) variant. Its own message is an untranslated, hardcoded
|
||||
* English string not meant for storefront display, so this returns our
|
||||
* own translated one instead rather than passing it through — a
|
||||
* storefront.* key rather than checkout.*, since this is a catalog/stock
|
||||
* concern the storefront owns, not something specific to the portable
|
||||
* checkout module.
|
||||
*/
|
||||
public function add(string $locale, Request $request): View|JsonResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'purchasable_id' => ['required', 'integer'],
|
||||
@@ -31,24 +45,49 @@ public function add(string $locale, Request $request): View
|
||||
|
||||
$variant = ProductVariant::findOrFail($data['purchasable_id']);
|
||||
|
||||
$this->cart->addLine($variant, $data['quantity'] ?? 1);
|
||||
try {
|
||||
$this->cart->addLine($variant, $data['quantity'] ?? 1);
|
||||
} catch (CartException) {
|
||||
return $this->stockError($variant);
|
||||
}
|
||||
|
||||
return view('checkout::partials.cart-body');
|
||||
}
|
||||
|
||||
public function updateLine(string $locale, Request $request, int $line): View
|
||||
public function updateLine(string $locale, Request $request, int $line): View|JsonResponse
|
||||
{
|
||||
$quantity = (int) $request->validate([
|
||||
'quantity' => ['required', 'integer', 'min:0'],
|
||||
])['quantity'];
|
||||
|
||||
$quantity === 0
|
||||
? $this->cart->removeLine($line)
|
||||
: $this->cart->updateLine($line, $quantity);
|
||||
try {
|
||||
$quantity === 0
|
||||
? $this->cart->removeLine($line)
|
||||
: $this->cart->updateLine($line, $quantity);
|
||||
} catch (CartException) {
|
||||
$variant = CartLine::find($line)?->purchasable;
|
||||
|
||||
return $this->stockError($variant instanceof ProductVariant ? $variant : null);
|
||||
}
|
||||
|
||||
return view('checkout::partials.cart-body');
|
||||
}
|
||||
|
||||
/**
|
||||
* getTotalInventory() is the same number canBeFulfilledAtQuantity()
|
||||
* checked against (stock, for a tracked in_stock variant) — telling the
|
||||
* shopper how many are actually left beats a generic "not enough stock"
|
||||
* they'd otherwise have to guess around by trial and error.
|
||||
*/
|
||||
private function stockError(?ProductVariant $variant): JsonResponse
|
||||
{
|
||||
$available = $variant?->getTotalInventory() ?? 0;
|
||||
|
||||
return response()->json([
|
||||
'error' => trans_choice('storefront.product.add_to_cart_failed', $available, ['count' => $available]),
|
||||
], 422);
|
||||
}
|
||||
|
||||
public function remove(string $locale, int $line): View
|
||||
{
|
||||
$this->cart->removeLine($line);
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
use Modules\Core\Checkout\Exceptions\UnknownPaymentTypeException;
|
||||
use Modules\Core\Checkout\Services\CheckoutService;
|
||||
use Modules\Core\Payment\Enums\PaymentResultStatus;
|
||||
use Modules\Core\Payment\Models\PaymentMethod;
|
||||
|
||||
/**
|
||||
* The checkout page — one page, sections (contact / billing / shipping /
|
||||
@@ -275,6 +276,30 @@ public function placeOrder(string $locale, Request $request): JsonResponse
|
||||
|
||||
$cart = $this->cart->current();
|
||||
|
||||
// Captured now, before initiatePayment() can place the order — Lunar's
|
||||
// CartSessionManager::fetchOrCreate() silently swaps the session onto a
|
||||
// BRAND NEW empty cart the moment the current one hasCompletedOrders()
|
||||
// (i.e. has an order with placed_at set), which happens synchronously
|
||||
// for an immediately-captured payment. Any later $this->cart->current()
|
||||
// call in this same flow (here, or in a subsequent orderStatus() poll
|
||||
// once the 3-D Secure webhook sets placed_at) would then resolve to
|
||||
// that fresh, order-less cart instead of the one that was just placed.
|
||||
// Storing the real cart id ourselves, under our own session key,
|
||||
// sidesteps CartSession entirely for the rest of the placement flow.
|
||||
session(['checkout.cart_id' => $cart?->id]);
|
||||
|
||||
// Lunar's own ValidateCartForOrderCreation (order_create validator)
|
||||
// never checks for this — an empty cart with a valid billing address
|
||||
// sails straight through it and would place a real, zero-line order.
|
||||
// The disabled "place order" button is only the client-side half of
|
||||
// this fix; this is the half that actually matters.
|
||||
if ($cart === null || $this->cart->activeLines($cart)->isEmpty()) {
|
||||
return response()->json([
|
||||
'status' => 'invalid',
|
||||
'message' => __('checkout.page.cart_empty'),
|
||||
], 422);
|
||||
}
|
||||
|
||||
// The one incomplete-cart case worth a specific message + pointing the
|
||||
// shopper at the right section: a region resolving 2+ methods needs an
|
||||
// explicit pick (no auto-select), easy to miss since nothing else on
|
||||
@@ -312,13 +337,20 @@ public function placeOrder(string $locale, Request $request): JsonResponse
|
||||
return response()->json(['error' => __('checkout.page.terms_required')], 422);
|
||||
}
|
||||
|
||||
return match ($result->status) {
|
||||
PaymentResultStatus::Succeeded => $this->orderPlacedResponse($locale),
|
||||
PaymentResultStatus::Pending => response()->json([
|
||||
// Pending with no continuation (cash-on-delivery, or any other
|
||||
// deferred/offline method) means CheckoutService::initiatePayment()
|
||||
// already created the placed order — money just hasn't changed
|
||||
// hands yet. Only a Pending WITH a continuation (Stripe's client
|
||||
// secret) means the shopper still has something to do before the
|
||||
// order exists as far as the storefront is concerned.
|
||||
return match (true) {
|
||||
$result->status === PaymentResultStatus::Succeeded => $this->orderPlacedResponse($locale),
|
||||
$result->status === PaymentResultStatus::Pending && $result->continuation === null => $this->orderPlacedResponse($locale),
|
||||
$result->status === PaymentResultStatus::Pending => response()->json([
|
||||
'status' => 'pending',
|
||||
'clientSecret' => $result->continuation?->value,
|
||||
]),
|
||||
PaymentResultStatus::Failed => response()->json([
|
||||
default => response()->json([
|
||||
'status' => 'failed',
|
||||
'message' => $result->failureReason ?: __('checkout.page.payment_failed'),
|
||||
'retriable' => $result->retriable,
|
||||
@@ -350,14 +382,24 @@ public function confirmation(string $locale): View|RedirectResponse
|
||||
$orderId = session('checkout.order_id');
|
||||
|
||||
$order = $orderId
|
||||
? Order::with(['lines', 'shippingAddress', 'billingAddress'])->find($orderId)
|
||||
? Order::with(['lines.purchasable.product', 'shippingAddress', 'billingAddress'])->find($orderId)
|
||||
: null;
|
||||
|
||||
if (! $order) {
|
||||
return redirect()->route('products', $locale);
|
||||
}
|
||||
|
||||
return view('checkout::confirmation', ['order' => $order]);
|
||||
// Looked up by type rather than a stored relation — the method may since
|
||||
// have been disabled/deleted, but the order still needs to show what was
|
||||
// actually used at the time.
|
||||
$paymentMethodName = PaymentMethod::where('type', $order->meta['payment_method'] ?? null)
|
||||
->first()
|
||||
?->translate('name');
|
||||
|
||||
return view('checkout::confirmation', [
|
||||
'order' => $order,
|
||||
'paymentMethodName' => $paymentMethodName,
|
||||
]);
|
||||
}
|
||||
|
||||
private function orderPlacedResponse(string $locale): JsonResponse
|
||||
@@ -373,8 +415,13 @@ private function orderPlacedResponse(string $locale): JsonResponse
|
||||
|
||||
private function placedOrder(): ?Order
|
||||
{
|
||||
return $this->cart->current()
|
||||
?->orders()
|
||||
$cartId = session('checkout.cart_id');
|
||||
|
||||
if ($cartId === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Order::where('cart_id', $cartId)
|
||||
->whereNotNull('placed_at')
|
||||
->latest('placed_at')
|
||||
->first();
|
||||
|
||||
Reference in New Issue
Block a user