middleware(function (Request $request, callable $next) { // * On peut passer commande ? $this->authorize('orderThings', Order::class); return $next($request); }); } // * --- TUNNEL DE COMMANDE --- /** * Récapitualtif du panier * * * --- 1 --- * * @return \Illuminate\Contracts\View\View */ public function basketRecap() { $this->assertDoesNotHaveEmptyBasket(); $this->assertBasketDidNotChange(); $this->assertBasketHasMinAmount(); $this->setOrderProcessStep(OrderProcess::reviewBasket()); /** @var \Gloudemans\Shoppingcart\Cart */ $basket = app('basket'); $messages = []; $basket = $this->filterBasketKeepOnlyOrderableItems($basket, $messages); if (!empty($messages)) { Session::flash('warning', \collect($messages)->implode(",\n")); } $basketItems = $this->basketToList($basket); return view('theme::pages.order-tunnel.basket-recap', compact( 'basket', 'basketItems' )); } /** * Select shipping Mode And create Session Order * * * --- 2 --- * * @return \Illuminate\Contracts\View\View */ public function shippingMode() { $this->assertDoesNotHaveEmptyBasket(); $this->assertBasketDidNotChange(); $this->assertBasketHasMinAmount(); $this->setOrderProcessStep(OrderProcess::shippingMethod(), OrderProcess::reviewBasket()); // * CREATE ORDER /** @var \App\Models\Customer\Customer $customer */ $customer = auth('frontend')->user(); /** @var \Gloudemans\Shoppingcart\Cart */ $basket = app('basket'); /** @var \App\Models\Shop\Order|null $order */ $order = Order::getCurrentOrder(); // * If there was a previous order that was not ended use the ID. /** @var \App\Models\Shop\Order */ $order = $order ?? new Order(); $order->mail_sent = false; $order->delivery_status = DeliveryStatus::pending(); $order->payment_status = PaymentStatus::pending(); $order->validation_status = ValidationStatus::pending(); $order->assignBasket($basket); $order->customer()->associate($customer); // * Save Session Order Session::put('currentOrder', $order); /** @var \Illuminate\Database\Eloquent\Collection<\App\Models\Shop\DeliveryMode> */ $deliveryModes = app('DeliveryModesGlobal'); return view('theme::pages.order-tunnel.shipping-mode', \compact('deliveryModes')); } /** * Save shipping Mode * * * --- 2 --- * * @param \App\Http\Requests\Fo\Orders\ShippingModeFormRequest $request * @return \Illuminate\Contracts\View\View|\Illuminate\Contracts\View\Factory */ public function shippingModeSubmit(ShippingModeFormRequest $request) { $this->assertDoesNotHaveEmptyBasket(); $this->assertBasketDidNotChange(); $this->assertBasketHasMinAmount(); $this->assertDoesHaveCurrentOrder(); $this->setOrderProcessStep(OrderProcess::shippingMethod(), OrderProcess::reviewBasket()); /** @var \App\Models\Shop\DeliveryMode $mode */ if (!($mode = DeliveryMode::availables()->where('mode', $request->mode)->first())) { $e = new \RuntimeException('Mode de livraison non disponible', 1); report($e); Session::flash('error', trans('Mode de livraison non disponible')); return \back()->withException($e); } $this->allowTimeSlotSelect(); /** @var \App\Models\Shop\Order $order */ $order = Order::getCurrentOrder(); $order->deliveryMode()->associate($mode); Session::put('currentOrder', $order); return \response()->redirectToRoute('order-tunnel.shipping'); } /** * Shipping informations * * * --- 3 --- * * ! Pour - Click and collect * * @return \Illuminate\Contracts\View\View|\Illuminate\Contracts\View\Factory * @throws \RuntimeException If delivery mode is unhandled. */ public function shipping() { $this->assertDoesNotHaveEmptyBasket(); $this->assertBasketDidNotChange(); $this->assertBasketHasMinAmount(); $this->assertDoesHaveCurrentOrder(); $this->assertDeliveryModeIsValid(); $this->setOrderProcessStep(OrderProcess::shippment(), OrderProcess::shippingMethod()); /** @var \App\Models\Shop\Order $order */ $order = Order::getCurrentOrder(); // * Switch Delivery modes // * Pour afficher formulaire qui devra retourner une adresse 'shipping' /** * * Switch Delivery modes * - Pour afficher formulaire qui devra retourner une adresse 'shipping'. * - Qui devra fournir un timeslot dans le cas de clickAndCollect. */ /** @var \App\Enums\Order\DeliveryModeEnum|null */ $delivery_mode = \optional($order->deliveryMode)->mode; switch ($delivery_mode) { case DeliveryModeEnum::inShop(): // * 1 Select timeslot if not already done. if (!$order->timeslot()->first() or !Session::get('deliveryModePickSlot')) { $shops = Shop::all()->map(function (Shop $shop) { return new ShopResource($shop); }); $this->allowTimeSlotSelect(true); View::share('mustSelectTimeSlot', true); return \view('theme::pages.order-tunnel.pick-slot', \compact('order', 'shops')); } return \view('theme::pages.order-tunnel.shipping', \compact('order')); default: throw new \RuntimeException('Unhandled delivery mode '); } throw new \RuntimeException('This should never happen'); } /** * Save and validate timeslot * * * --- 3 --- * * @param \App\Http\Requests\Fo\Orders\SetOrderTimeSlotRequest $request * @return \Illuminate\Http\RedirectResponse * @throws \Illuminate\Validation\ValidationException If Creneau is not available anomore. */ public function validateSelectedTimeSlot(SetOrderTimeSlotRequest $request) { $this->assertDoesNotHaveEmptyBasket(); $this->assertBasketDidNotChange(); $this->assertBasketHasMinAmount(); $this->assertDoesHaveCurrentOrder(); $this->assertDeliveryModeIsValid(); $this->setOrderProcessStep(OrderProcess::shippment(), OrderProcess::shippingMethod()); /** @var \App\Models\Shop\Shop */ $shop = Shop::query()->where('id', $request->shop_id)->firstOrFail(); $creneau = Carbon::createFromFormat('d/m/Y H:i', "{$request->selected_day} {$request->selected_time}"); if (!$shop->isCreneauAvailable($creneau)) { throw ValidationException::withMessages([ 'error' => 'Ce créneau n\'est plus disponible' ]); } DB::transaction(function () use ($shop, $creneau) { /** @var \App\Models\Shop\Order $order */ $order = Order::getCurrentOrder(); // * Assign order id we would use, if none already. if (!$order->id) { $order->id = Str::uuid(); } Session::put('currentOrder', $order); /** @var \App\Models\Shop\Timeslot */ $timeSlot = Timeslot::query()->whereHas('order', function (Builder $q) use ($order) { $q->where('id', $order->id); })->firstOrNew(); $timeSlot->order()->associate($order); $timeSlot->shop()->associate($shop); $timeSlot->slot = $creneau; $timeSlot->saveOrFail(); }); return response()->redirectToRoute('order-tunnel.shipping'); } /** * SaveShippingAdress * * * --- 3 --- * * @param \App\Http\Requests\Fo\Orders\ShippingFormRequest $request * @return \Illuminate\Http\RedirectResponse * @throws \RuntimeException If somehow no email can be found. */ public function shippingSubmit(ShippingFormRequest $request) { $this->assertDoesNotHaveEmptyBasket(); $this->assertDoesHaveCurrentOrder(); $this->assertBasketDidNotChange(); $this->assertBasketHasMinAmount(); $this->assertDeliveryModeIsValid(); $this->setOrderProcessStep(OrderProcess::shippment(), OrderProcess::shippingMethod()); /** @var \App\Models\Shop\Order $order */ $order = Order::getCurrentOrder(); $order->fill($request->validated()); /** @var object */ $billing = $order->billing; $billing->country = Country::france(); $order->billing = $billing; $this->allowTimeSlotSelect(); // Fetch right email to send in. if ($order->billing->email) { $order->email = $order->billing->email; } elseif ($order->shipping->email) { $order->email = $order->shipping->email; } else { $order->email = $order->customer->api_data->email; } if (!$order->email) { throw new \RuntimeException('Somehow no email was provided'); } $this->fillShippingFromShopInfo($order); // * Allow timeslot select Session::put('currentOrder', $order); return \response()->redirectToRoute('order-tunnel.review'); } /** * Valider le panier avant achat. * * * --- 4 --- * * @return \Illuminate\Contracts\View\View|\Illuminate\Contracts\View\Factory */ public function review() { $this->assertDoesNotHaveEmptyBasket(); $this->assertBasketDidNotChange(); $this->assertBasketHasMinAmount(); $this->assertDoesHaveCurrentOrder(); $this->assertDeliveryModeIsValid(); $this->setOrderProcessStep(OrderProcess::paymentReview(), OrderProcess::shippment()); $this->allowTimeSlotSelect(); /** @var \App\Models\Customer\Customer $customer */ $customer = auth('frontend')->user(); /** @var \App\Models\Shop\Order $order */ $order = Order::getCurrentOrder(); // * Order completed email already sent. if ($order->mail_sent) { self::detroyCurrentBasket(); return \view('theme::pages.order-tunnel.done', \compact('order')); } /** @var \Gloudemans\Shoppingcart\Cart */ $basket = app('basket'); $availablePaymentModes = app('PaymentModesGlobal')->where('status', true); return view('theme::pages.order-tunnel.review', \compact('basket', 'order', 'availablePaymentModes')); } /** * Setup Order for submit * * * --- 4 --- * * @param \App\Http\Requests\Fo\Orders\CheckoutRequest $request * @return \Illuminate\Contracts\View\View|\Illuminate\Contracts\View\Factory * @throws \RuntimeException If payment method is unhandled somehow. */ public function reviewSubmit(CheckoutRequest $request) { $this->assertDoesNotHaveEmptyBasket(); $this->assertBasketDidNotChange(); $this->assertDoesHaveCurrentOrder(); $this->assertDeliveryModeIsValid(); $this->setOrderProcessStep(OrderProcess::paymentReview(), OrderProcess::shippment()); /** @var \App\Models\Customer\Customer $customer */ $customer = auth('frontend')->user(); // * Force clone here to prevent session save model state (If something goes wrong). /** @var \App\Models\Shop\Order $order */ $order = clone Order::getCurrentOrder(); // * Order completed email already sent. if (!$order->exists and $order->mail_sent) { self::detroyCurrentBasket(); return \view('theme::pages.order-tunnel.done', \compact('order')); } /** @var \App\Enums\Order\PaymentModeEnum */ $paymentMode = $request->modeEnum; // * Check Payment mode Gate::authorize('isAvailable', [PaymentMode::class, $paymentMode]); // * Get Transaction $transaction = Transaction::getCurrentTransaction() ?? Transaction::getNewInstance( $paymentMode, TransactionType::debit(), $order->total_ttc, ); $transaction->refresh(); // * If transaction is already payed. if ($transaction->status->equals(TransactionStatus::validated())) { // ! Prevent double checkout (double transaction), just let IPN (webhooks) handle this. $e = new \RuntimeException('La transaction a déjà été traitée'); report($e); $this->detroyCurrentBasket(); abort(500, trans($e->getMessage())); } // * If previously failed get a new one. if ($transaction->status->equals(TransactionStatus::failed())) { $transaction = Transaction::getNewInstance( $paymentMode, TransactionType::debit(), $order->total_ttc, ); } // * If customer switched transaction mode. if (!$transaction->payment_mode->equals($paymentMode)) { // * Reuse previous $transaction = Transaction::query()->whereHas('order', function (Builder $q) use ($order) { $q->where('id', $order->id); })->where('payment_mode', $paymentMode)->where('status', TransactionStatus::pending()) ->first() ?? Transaction::getNewInstance( $paymentMode, TransactionType::debit(), $order->total_ttc, ); // * Remove other pending in shop transactions Transaction::query()->whereHas('order', function (Builder $q) use ($order) { $q->where('id', $order->id); })->where('status', TransactionStatus::pending())->where('id', '!=', $transaction->id) ->where('payment_mode', PaymentModeEnum::inShop())->delete(); } // * If payment mode is boutique just use the previous transaction if there was one. if ($transaction->payment_mode->equals(PaymentModeEnum::inShop())) { $transaction = Transaction::query()->whereHas('order', function (Builder $q) use ($order) { $q->where('id', $order->id); })->where('payment_mode', PaymentModeEnum::inShop()) ->where('status', TransactionStatus::pending())->first() ?? $transaction; } // * Checkout using selected Payment system. return $transaction->getAdapter()->checkout( $customer, $order, $transaction, // * When succeeded function (Customer $customer, Order $order, Transaction $transaction) { // * Associate transaction to order $transaction->order()->associate($order); // * Enregistrement commande avant checkout. if (!$order->save() or !$transaction->save()) { report(new \RuntimeException("Order saving error happened.")); throw ValidationException::withMessages([ 'error' => trans('Une erreur est survenue, veuillez réssayer') ]); } // * Save Transaction Session::put('currentTransaction', $transaction->refresh()); // * Save Order Session::put('currentOrder', $order->refresh()); }, // ! If failed. function () { throw ValidationException::withMessages([ 'error' => trans('Une erreur est survenue, veuillez réssayer') ]); } ); } /** * Redirection après paiement et validation * * * --- 5 --- * * ! Middleware Url signée ici * * * La méthode sers a récupérer la session après redirection du module de paiment. * * @return \Illuminate\Contracts\View\View|\Illuminate\Contracts\View\Factory */ public function checkout() { /** @var \App\Models\Customer\Customer|null $customer */ $customer = auth('frontend')->user(); /** @var \App\Models\Shop\Order|null $order */ $order = Order::getCurrentOrder(); /** @var \App\Models\Payment\Transaction|null $transaction */ $transaction = Transaction::getCurrentTransaction(); if (!$customer or !$order or !$transaction) { \redirect()->route('order-tunnel.review') ->with('warning', trans('La session est requise')) ->throwResponse(); } // * Force clone here to prevent session save model state (If something goes wrong). $order = clone $order; $transaction = clone $transaction; // * If transaction has already been processed. if (!$transaction->status->equals(TransactionStatus::pending())) { \redirect()->route('order-tunnel.review') ->with('warning', trans('Étape précédente requise')) ->throwResponse(); } if (!($transaction = $transaction->getAdapter()->checkoutIsResponseIsValid($customer, $transaction))) { \abort(400, trans('Impossible de valider la transaction')); } // * Save Transaction Session::put('currentTransaction', $transaction); // * Save Order Session::put('currentOrder', $order); return view('back::pages.order-tunnel-checkout'); } /** * Redirection après paiement et validation * * * --- 5 --- * * * La méthode sers a récupérer la session après redirection du module de paiment. * * @return \Illuminate\Contracts\View\View|\Illuminate\Contracts\View\Factory */ public function checkoutVerify() { $this->assertDoesNotHaveEmptyBasket(); $this->assertDoesHaveCurrentOrder(); $this->assertDoesHaveCurrentTransaction(); if (!$this->checkThatActualProcessStepIs(OrderProcess::paymentReview())) { \redirect()->route('order-tunnel.review') ->with('warning', trans('Étape précédente requise')) ->throwResponse(); } return DB::transaction(function () { /** @var \App\Models\Customer\Customer $customer */ $customer = auth('frontend')->user(); // * Force clone here to prevent session save model state (If something goes wrong). /** @var \App\Models\Shop\Order $order */ $order = clone Order::getCurrentOrder(); // * Force clone here to prevent session save model state (If something goes wrong). /** @var \App\Models\Payment\Transaction $transaction */ $transaction = clone Transaction::getCurrentTransaction(); // * Order completed email already sent. if ($order->exists and $transaction->exists and $order->mail_sent) { self::detroyCurrentBasket(); return \view('theme::pages.order-tunnel.done', \compact('order')); } $transaction->getAdapter()->checkoutVerify($transaction); $transaction->saveOrFail(); // * Notification has been sent Order::query()->where('id', $order->id)->update([ 'mail_sent' => true ]); $order->refresh(); // * Notifiy customer $customer->notify(new OrderCreated($order)); /** @var object|null */ $notificationMail = \optional(\optional(GeneralConfig::query() ->where('type', GeneralConfigType::global()) ->first())->data)->confirmed_orders_destination_email; if (!\optional($notificationMail)->value) { throw new \RuntimeException('Could not get new order email notification setting'); } if ($order->validation_status->equals(ValidationStatus::pending())) { // * Send mail after reponse to BO user. dispatch(function () use ( $notificationMail, $order ) { Mail::to($notificationMail->value)->send(new NewOrderIncome($order)); })->afterResponse(); } self::detroyCurrentBasket(); return view('theme::pages.order-tunnel.done', \compact('order')); }); } // * --- FIN TUNNEL DE COMMANDE --- // * PRIVATE METHODS /** * Allow timeslot select in the tunnel even if already selected * * @param boolean $disallow To force disallow timeslot picker select. * @return void */ private function allowTimeSlotSelect(bool $disallow = false) { // * Allow timeslot select Session::put('deliveryModePickSlot', $disallow); } /** * Assert user does not have an empty basket * or redirect with warning * * @return void */ private function assertDoesHaveCurrentOrder() { if ( $order = Order::getCurrentOrder() and $order->payment_status and $order->payment_status->equals(PaymentStatus::payed()) ) { self::detroyCurrentBasket(); abort(\response()->view('theme::pages.order-tunnel.done', \compact('order'))); } if (!$order) { \redirect()->route('order-tunnel.basket.recap')->throwResponse(); } } /** * Delete order steps, removes basket, ends tunnel transaction. * * @return void */ public static function detroyCurrentBasket() { /** @var \Gloudemans\Shoppingcart\Cart */ $basket = app('basket'); $basket->destroy(); // * Remove timeslot if order is not saved into DB. /** @var \App\Models\Shop\Order|null */ if ($order = Session::get('currentOrder') and $order->timeslot and !$order->exists) { $order->timeslot->delete(); } Session::remove('currentOrder'); Session::remove('currentTransaction'); Session::remove('PreviousOrderStep'); Session::remove('OrderStep'); Session::remove('deliveryModePickSlot'); Session::remove('basketFingerPrint'); } /** * Assert user does not have an empty basket * or redirect with warning * * @return void */ private function assertDoesNotHaveEmptyBasket(): void { /** @var \Gloudemans\Shoppingcart\Cart */ $basket = app('basket'); if (!$basket->count()) { \redirect()->route('home') ->with('warning', trans('Vous n\'avez aucun produit dans votre panier')) ->throwResponse(); } } /** * Assert user does not have an empty basket * or redirect with warning * * @return void */ private function assertBasketDidNotChange(): void { /** @var \Gloudemans\Shoppingcart\Cart */ $basket = app('basket'); $actual_finger_print = hash('sha256', \json_encode($basket->content()->jsonSerialize())); $session_finger_print = Session::get('basketFingerPrint'); Session::put('basketFingerPrint', $actual_finger_print); if ($session_finger_print !== $actual_finger_print) { Session::remove('deliveryModePickSlot'); \redirect()->route('order-tunnel.basket.recap') ->with('warning', trans('Votre panier à été modifié')) ->throwResponse(); } } /** * Assert basket has the minimal amount required for payment * * @return void */ private function assertBasketHasMinAmount(): void { /** @var \Gloudemans\Shoppingcart\Cart */ $basket = app('basket'); $sampleOrder = new Order(); $sampleOrder->assignBasket($basket); $minAmountForTransaction = \collect(PaymentModeEnum::toArray()) ->max(function (PaymentModeEnum $paymentMode) { return $paymentMode->minAmount; }); if ($sampleOrder->total_ttc < $minAmountForTransaction) { \redirect()->route('home') ->with('warning', trans('Le montant minimal pour une commande est de :minAmount', [ 'minAmount' => ToolboxHelper::fmtCurrency($minAmountForTransaction) ]))->throwResponse(); } } /** * Assert user does not have an empty basket * or redirect with warning * * @return void */ private function assertDoesHaveCurrentTransaction() { $transaction = Transaction::getCurrentTransaction(); if ( !$transaction or ($transaction->status and $transaction->status->equals(TransactionStatus::failed()) ) ) { \redirect()->route('order-tunnel.review') ->with('warning', trans('La transaction a échoué')) ->throwResponse(); } } /** * Assert delivery mode si still available. * * @return void */ private function assertDeliveryModeIsValid(): void { $order = Order::getCurrentOrder(); if (!Gate::check('orderUsingDeliveryMode', $order)) { Session::remove('deliveryModePickSlot'); \redirect()->route('order-tunnel.shipping-mode') ->with('warning', trans('Le mode de livraison n\'est pas disponible')) ->throwResponse(); } } /** * Validate Process step * * @param \App\Enums\Order\OrderProcess $step * @param \App\Enums\Order\OrderProcess|null $assertDid * @return void * @throws \Illuminate\Validation\ValidationException If previous was not done. */ private function setOrderProcessStep(OrderProcess $step, OrderProcess $assertDid = null): void { if ($assertDid) { if (!Session::get('PreviousOrderStep')) { Session::put('PreviousOrderStep', collect()); } /** @var \Illuminate\Support\Collection */ $previousSteps = Session::get('PreviousOrderStep'); if (Session::get('OrderStep') < $step) { /** @var \App\Enums\OrderProcess */ $previousStep = Session::get('OrderStep'); $previousSteps->put($previousStep->value, $previousStep); Session::put('PreviousOrderStep', $previousSteps); } $this->assertDidProcessStep($assertDid); } //end if Session::put('OrderStep', $step); } /** * Assert the the step is done. * * @param \App\Enums\Order\OrderProcess $assertDid * @return void * @throws \Illuminate\Validation\ValidationException If previous was not done. */ private function assertDidProcessStep(OrderProcess $assertDid): void { if (!Session::get('PreviousOrderStep')) { Session::put('PreviousOrderStep', collect()); } /** @var \Illuminate\Support\Collection */ $previousSteps = Session::get('PreviousOrderStep'); if (!$previousSteps->count()) { \redirect()->route('home') ->with('warning', trans('Vous devez d\'abord valider votre panier')) ->throwResponse(); } if (!$previousSteps->contains($assertDid)) { throw ValidationException::withMessages(['Étape précédente requise']); } } /** * Assert the actual step is. * * @param \App\Enums\Order\OrderProcess $assertActual * @return boolean * @throws \Illuminate\Validation\ValidationException If previous was not done. */ private function checkThatActualProcessStepIs(OrderProcess $assertActual): bool { /** @var \App\Enums\OrderProcess|null */ $orderStep = Session::get('OrderStep'); return $orderStep and $orderStep->equals($assertActual); } /** * Fill order shipping with in shop infos * * @param \App\Models\Shop\Order $order * @return void * @throws \Illuminate\Validation\ValidationException Si le créneau de reservation n'est plus trouvable. */ private function fillShippingFromShopInfo(Order $order) { if ($order->deliveryMode->mode->equals(DeliveryModeEnum::inShop())) { /** @var \App\Models\Shop\Timeslot|null */ if (!($timeslot = $order->timeslot()->first())) { throw ValidationException::withMessages([ 'error' => trans('Créneau de réservation introuvable') ]); } $order->shipping = [ 'firstname' => $timeslot->shop->name, 'lastname' => '', 'email' => null, 'phone' => $timeslot->shop->phone, 'line1' => $timeslot->shop->address, 'postal_code' => $timeslot->shop->postal_code, 'city' => $timeslot->shop->city, 'country' => $timeslot->shop->country ]; } } }