From 92b9929849a42a50fb6d7d3422e0b1cd435f4996 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=A9my=20Munsch?= Date: Thu, 25 May 2023 20:04:46 +0200 Subject: [PATCH] Tons of fixes --- app/Http/Controllers/Bo/OrderController.php | 15 +- .../Adapters/SystemPayPaymentAdapter.php | 553 +++++++----------- .../PaymentIPN/SystempayEventListener.php | 14 +- app/Models/Payment/Transaction.php | 27 + app/Models/Shop/Order.php | 164 ++++-- resources/views/pages/orders/form.blade.php | 2 +- .../views/pages/orders/prepare.blade.php | 2 +- .../views/pages/orders/resolve.blade.php | 2 +- routes/breadcrumbs-bo.php | 4 +- 9 files changed, 377 insertions(+), 406 deletions(-) diff --git a/app/Http/Controllers/Bo/OrderController.php b/app/Http/Controllers/Bo/OrderController.php index c37e11e..307f262 100644 --- a/app/Http/Controllers/Bo/OrderController.php +++ b/app/Http/Controllers/Bo/OrderController.php @@ -214,7 +214,7 @@ class OrderController extends Controller $transactionModel = $order->transactions()->where('needs_validation', true)->first(); // * Diff between Basket - (Delivered items - refunded transaction items) - $remainingItems = $order->getUnrefundedItems(); + $remainingItems = $order->getRemainingUnrefundedItems(); return \view('back::pages.orders.resolve', \compact('order', 'transactionModel', 'remainingItems')); } @@ -234,7 +234,7 @@ class OrderController extends Controller /** @var \Illuminate\Support\Collection */ $orderedItems = \collect($order->basket); - // * getUnrefundedItems Modifie les rowId originaux, il faut les restaurer pour la comparaison. + // * getRemainingUnrefundedItems Modifie les rowId originaux, il faut les restaurer pour la comparaison. /** @var \Illuminate\Support\Collection */ $refundedItems = $request->collect('items')->mapWithKeys(function (array $item) use ($orderedItems) { $item = (object)$item; @@ -302,7 +302,7 @@ class OrderController extends Controller // ? The resolution means a complete refund ? if ($order->isCompleteRefundWithTransaction($transaction)) { // * Calculate unrefunded tax - $unRefundedTax = \abs(\intval(\collect($order->getUnrefundedItems())->sum(function (object $item) { + $unRefundedTax = \abs(\intval(\collect($order->getRemainingUnrefundedItems())->sum(function (object $item) { return $item->tax * $item->qty; }))); @@ -347,7 +347,7 @@ class OrderController extends Controller { // phpcs:enable $this->authorize('prepare', $order); - $remainingItems = $order->getUnrefundedItems(); + $remainingItems = $order->getRemainingUnrefundedItems(); return \view('back::pages.orders.prepare', \compact('order', 'remainingItems')); } @@ -364,7 +364,7 @@ class OrderController extends Controller return DB::transaction(function () use ($request, $order) { // * Les items qu'il reste sur la commande (non remboursés). - $remainingItems = $order->getUnrefundedItems(); + $remainingItems = $order->getRemainingUnrefundedItems(); // * On met à jour cette liste avec les items effectivement préparés dans la commande. $shipped_basket = \collect(ToolboxHelper::updateBasketAmounts( @@ -447,7 +447,7 @@ class OrderController extends Controller $order, $order->total_ttc - $order->total_refund, // * Negate all items prices (already refunded) - $order->getUnrefundedItems()->map(function (object $basketItem) { + $order->getRemainingUnrefundedItems()->map(function (object $basketItem) { $basketItem->tax = $basketItem->tax * -1; $basketItem->price = $basketItem->price * -1; $basketItem->subtotal = $basketItem->subtotal * -1; @@ -491,9 +491,6 @@ class OrderController extends Controller return $basketItem; }) ); - - // * Mettre a jour la commande. - $order->saveOrFail(); } // * Mettre a jour la commande. diff --git a/app/Lib/Payments/Adapters/SystemPayPaymentAdapter.php b/app/Lib/Payments/Adapters/SystemPayPaymentAdapter.php index 7fc4920..705794c 100644 --- a/app/Lib/Payments/Adapters/SystemPayPaymentAdapter.php +++ b/app/Lib/Payments/Adapters/SystemPayPaymentAdapter.php @@ -1,399 +1,258 @@ event = $event; - $this->reponse = $this->event->getResponse(); - $this->reponseType = $this->reponse->getType(); - - Log::debug(__CLASS__); - Log::debug(\json_encode($this->reponse->getPayload())); - - - Log::debug('Response type ' . $this->reponseType->label); - if ($this->transactionIsRefund()) { - Log::debug('CREDIT'); - $this->handleCreditCall(); - } else { - Log::debug('DEBIT'); - $this->handleDebitCall(); - } - - SystempayResponse::success('Data received.')->throwResponse(); + return app('systempay'); } /** - * Handle a credit call (refund) + * Get success route * - * @return void - * @throws \RuntimeException If reponseType is unhandled. - * @phpcs:disable Generic.Metrics.CyclomaticComplexity.TooHigh + * ! Overload signed route more timeout delay. + * + * @param \App\Models\Shop\Transaction $transaction + * @param array $parameters + * @return string */ - private function handleCreditCall() + protected function successRoute(Transaction $transaction, array $parameters = []): string { - // phpcs:enable - switch ($this->reponseType) { - case ResponseType::acceptedPayment(): - $this->transactionCreditSucceeded(); - break; - case ResponseType::cancelledPayment(): - $canceled = true; - // Fall-Though. - case ResponseType::expiredPayment(): - case ResponseType::refusedPayment(): - $canceled = !empty($canceled) ? true : false; - $this->transactionCreditFailed($canceled); - break; - // ! Ignore these explicitely. - case ResponseType::suspendedPayment(): - case ResponseType::pendingPayment(): - break; - - case ResponseType::needsValidation(): - case ResponseType::suspectedFraud(): - case ResponseType::unknown(): - default: - $message = __CLASS__ . " Unhandled Systempay credit reponse Type '{$this->reponseType}'"; - \report(new \RuntimeException($message)); - SystempayResponse::deny($message)->throwResponse(); - } //end switch + $parameters['transaction'] = $transaction; + return \url(URL::temporarySignedRoute( + 'order-tunnel.checkout', + \now()->addMinutes(5), + $parameters, + false + )); } /** - * Handle a debit call + * Generate the checkout reponse to initiate a transaction. * - * @return void - * @throws \RuntimeException If reponseType is unhandled. - * @phpcs:disable Generic.Metrics.CyclomaticComplexity.TooHigh - */ - private function handleDebitCall() - { - // phpcs:enable - switch ($this->reponseType) { - case ResponseType::acceptedPayment(): - $this->transactionDebitSucceeded(); - break; - case ResponseType::cancelledPayment(): - $canceled = true; - // Fall-Though. - case ResponseType::expiredPayment(): - case ResponseType::refusedPayment(): - $canceled = !empty($canceled) ? true : false; - $this->transactionDebitFailed($canceled); - break; - case ResponseType::needsValidation(): - // FIXME: que faire ici ? - break; - case ResponseType::suspectedFraud(): - // FIXME: que faire ici ? - break; - // ! Ignore these explicitely. - case ResponseType::suspendedPayment(): - case ResponseType::pendingPayment(): - break; - case ResponseType::unknown(): - default: - $message = __CLASS__ . " Unhandled Systempay debit reponse Type '{$this->reponseType}'"; - \report(new \RuntimeException($message)); - SystempayResponse::deny($message)->throwResponse(); - } //end switch - } - - // ! ---- CREDIT ---- - /** - * ! CREDIT - * Set transaction as succeeded. + * * This is a unique transaction with qty 1 and the desired amount for the order. * - * @return void - * @throws \Illuminate\Http\Exceptions\HttpResponseException If transaction could not be fetched. + * @phpcs:disable Squiz.Commenting.FunctionComment.IncorrectTypeHint + * @phpcs:disable Generic.Files.LineLength.TooLong + * @param \App\Models\Customer\Customer $customer + * @param \App\Models\Shop\Order $order + * @param \App\Models\Payment\Transaction $transaction + * @param \Closure(\App\Models\Customer\Customer,\App\Models\Shop\Order,\App\Models\Payment\Transaction): void $succeed + * @param \Closure(\Exception): void $failed + * @return \Symfony\Component\HttpFoundation\Response */ - private function transactionCreditSucceeded(): void - { + public function checkout( + Customer $customer, + Order $order, + Transaction $transaction, + \Closure $succeed, + \Closure $failed + ): Response { + return $this->wrapAction(function () use ($order, $transaction) { + // * Associate transaction to order + $transaction->order()->associate($order); - DB::transaction(function () { - $order = $this->fetchTransactionOrder(); - $transaction = $this->fetchTransactionFromPaymentIntent(); - - // * Si la transaction récupéré est un debit alors que la demande est un crédit. - if ($transaction->type->equals(TransactionType::debit())) { - // * Alors la transaction viens du BO system pay => sauvegarde d'une nouvelle transaction. - $transaction = $this->createCreditTransactionFromCredit( - $transaction, - $this->fetchTransactionAmount() - ); - // * Mise a jour de total_refund sur la commande (car non transaction non initiée par la boutique). - $order->total_refund = $order->total_refund + \intval($transaction->amount->getAmount()); + // * 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') + ]); } - $transaction->status = TransactionStatus::validated(); - $transaction->saveOrFail(); - $transaction->mutateOrderStatus(); - - // * Mise a jour du statut de la commande. - $isCompleteRefunded = false; - $isRefunded = false; - if ($transaction->type->equals(TransactionType::credit())) { - $isCompleteRefunded = $order->isCompleteRefundWithTransaction($transaction); - $isRefunded = $isCompleteRefunded ?: $transaction->type->equals( - TransactionType::credit() - ); - } - $order->payment_status = $isCompleteRefunded ? PaymentStatus::refunded() : ($isRefunded ? - PaymentStatus::refundedPartial() : PaymentStatus::failed()); - if ($order->payment_status->equals(PaymentStatus::failed())) { - $message = __CLASS__ . ' Failed to determine refund status'; - \report(new \RuntimeException($message)); - SystempayResponse::deny($message)->throwResponse(); - } - if ($isCompleteRefunded) { - $order->total_refund = $order->total_ttc; - $order->validation_status = ValidationStatus::canceled(); - } - $order->saveOrFail(); - - OrderIsRefunded::dispatch( - $order, - $transaction + $systemPay = $this->getPayment(); + $request = $systemPay->checkout( + $transaction->amount, + $order->reference, + $order->email, + $this->successRoute($transaction), + $this->cancelRoute($order), + [ + 'order' => $order->reference, + 'transaction' => $transaction->reference, + 'order_label' => trans('Commande :ref', ['ref' => $order->reference]), + 'transaction_label' => trans('Transaction :ref', ['ref' => $transaction->reference]), + ] ); - }); - } - /** - * ! CREDIT - * Set transaction as failed. - * - * @param boolean $canceled Set true if canceled. - * @return void - * @throws \Illuminate\Http\Exceptions\HttpResponseException If transaction could not be fetched. - */ - private function transactionCreditFailed(bool $canceled): void - { - $transaction = $this->fetchTransactionFromPaymentIntent(); - - // * Si la transaction récupéré est un debit alors que la demande est un crédit. - if ($transaction->type->equals(TransactionType::debit())) { - // * Alors la transaction viens du BO system pay => sauvegarde d'une nouvelle transaction. - $transaction = $this->createCreditTransactionFromCredit( - $transaction, - $this->fetchTransactionAmount() - ); - } - - DB::transaction(function () use ($transaction, $canceled) { - $transaction->assignRefundId($this->reponse->getTransactionUuid()); - $transaction->status = $canceled ? - TransactionStatus::canceled() : TransactionStatus::failed(); - $transaction->saveOrFail(); - $transaction->mutateOrderStatus(); - }); - } - - // ! ---- DEBIT ---- - /** - * ! DEBIT - * Set transaction as succeeded. - * - * @return void - * @throws \Illuminate\Http\Exceptions\HttpResponseException If transaction could not be fetched. - */ - private function transactionDebitSucceeded(): void - { - $transaction = $this->fetchTransactionFromPaymentIntent(); - DB::transaction(function () use ($transaction) { - $transaction->assignPaymentId($this->reponse->getTransactionUuid()); - $transaction->status = TransactionStatus::validated(); - $transaction->saveOrFail(); - $transaction->mutateOrderStatus(); - /** @var \App\Models\Shop\Order $order */ - $order = $transaction->order()->first(); - OrderIsPaid::dispatch($order, $transaction); - OrderIsCreated::dispatch($order); - }); - } - - /** - * ! DEBIT - * Set transaction as failed. - * - * @param boolean $canceled Set true if canceled. - * @return void - * @throws \Illuminate\Http\Exceptions\HttpResponseException If transaction could not be fetched. - */ - private function transactionDebitFailed(bool $canceled): void - { - $transaction = $this->fetchTransactionFromPaymentIntent(); - DB::transaction(function () use ($transaction, $canceled) { - /** @var \App\Models\Shop\Order $order */ - $order = $this->fetchTransactionOrder(); - - // ? The order payment has previously been validated ? - if ($order->mail_sent) { - // * Mark the order as validated. - $order->validation_status = ValidationStatus::canceled(); - $order->payment_status = PaymentStatus::canceled(); - $order->saveOrFail(); + /** @var \Lyranetwork\Field|null */ + if (!($transIdField = $request->getFields()->get('vads_trans_id'))) { + throw new \RuntimeException('Missing vads_trans_id form lib'); } - $transaction->assignPaymentId($this->reponse->getTransactionUuid()); - $transaction->status = $canceled ? - TransactionStatus::canceled() : TransactionStatus::failed(); - $transaction->saveOrFail(); - $transaction->mutateOrderStatus(); - }); + // * Save transaction Id (generated by LIB) + $transaction->assignPaymentId($transIdField->getValue()); + if (!$transaction->save()) { + throw new \RuntimeException('Failed to save transaction id'); + } + + // * Generate view response form. + return $systemPay->generateCheckoutRedirectView($request); + }, function () use ($succeed, $customer, $order, $transaction) { + $succeed($customer, $order, $transaction); + }, $failed); + } + + // phpcs:ignore Squiz.Commenting.FunctionComment.MissingParamTag + /** + * @inheritDoc + * + * @phpcs:disable Generic.CodeAnalysis.UnusedFunctionParameter.FoundInExtendedClassBeforeLastUsed + * @phpcs:disable Squiz.Commenting.FunctionComment.MissingReturn + * @phpcs:disable Squiz.Commenting.FunctionCommentThrowTag.Missing + */ + public function checkoutIsResponseIsValid(Customer $customer, Transaction $transaction): ?Transaction + { + if (!request()->hasValidSignature(false)) { + return null; + } + // ! La validation se fait par IPN uniquement. + return $transaction; } /** - * Create a refund transaction (usefull for payment system refund from bo). + * Do a refund using the same payment method as the transaction. * - * @param \App\Models\Payment\Transaction $originalDebitTransaction - * @param \Money\Money $amount - * @return \App\Models\Payment\Transaction + * @param \App\Models\Payment\Transaction $transaction + * @param integer $amount + * @param object $items + * @return \App\Models\Payment\Transaction The new refund transaction. + * @phpcs:ignore Squiz.Commenting.FunctionComment.ThrowsNoFullStop + * @throws \Illuminate\Validation\ValidationException If doing the refund fails. */ - private function createCreditTransactionFromCredit( - Transaction $originalDebitTransaction, - Money $amount + public function doRefundCustomer( + Transaction $transaction, + int $amount, + object $items ): Transaction { - $order = $this->fetchTransactionOrder(); - $transaction = Transaction::getNewInstance( - $originalDebitTransaction->payment_mode, - TransactionType::credit(), - (object)[], - \intval($amount->getAmount()) - ); + try { + return DB::transaction(function () use ($transaction, $amount, $items) { + /** @var \App\Models\Shop\Order */ + $concernedOrder = $transaction->order; - $transaction->amount = $amount; - $transaction->needs_validation = true; - $transaction->assignRefundId($this->reponse->getTransactionUuid()); - $transaction->order()->associate($order); - return $transaction; - } + $orderTotal = new Money($concernedOrder->total_ttc, $transaction->currency); + $orderRefunded = new Money($concernedOrder->total_refund, $transaction->currency); + $amountToRefund = new Money($amount, $transaction->currency); - // * ---- UTILS ---- - /** - * Fetch the order associated with the transaction - * - * @return \App\Models\Shop\Order - */ - private function fetchTransactionOrder(): Order - { - $order_id = $this->reponse->getLyraResponse()->get('vads_order_id'); - $order = Order::query()->where( - 'reference', - $order_id - )->first(); - if (!$order) { - $message = __CLASS__ . " No transaction with id '{$order_id}'"; - \report(new \RuntimeException($message)); - SystempayResponse::deny($message)->throwResponse(); - } - return $order; - } - /** - * Fetch webhook transaction from payment_intent Or The original transaction - * if the one provided is unkown and the previous transaction id is transmitted. - * - * @return \App\Models\Payment\Transaction - * @throws \Illuminate\Http\Exceptions\HttpResponseException If transaction could not be fetched. - */ - private function fetchTransactionFromPaymentIntent(): Transaction - { - $foreign_id = Transaction::getPaymentForeignKeyName( - PaymentModeEnum::systemPay(), - $this->reponse->getTransactionUuid() - ); - $concernedOrder = $this->fetchTransactionOrder(); - $transaction_reference = $this->reponse->getLyraResponse()->get('vads_ext_info_transaction'); - $transactionQuery = Transaction::query()->whereHas('order', function (Builder $q) use ($concernedOrder) { - $q->where('reference', $concernedOrder->reference); - }); + $systemPay = $this->getPayment(); + $client = $systemPay->generateRestClient(); - $transaction = (clone $transactionQuery)->where('foreign_refund_id', $foreign_id)->first(); - $transaction = $transaction ?: (clone $transactionQuery)->where('foreign_id', $foreign_id)->first(); - $transaction = $transaction ?: $transactionQuery->where('reference', $transaction_reference)->first(); - /** @var \App\Models\Payment\Transaction|null $transaction */; - if (!$transaction) { - Log::debug($transactionQuery->toSql()); - $message = __CLASS__ . " {$this->reponseType} : Cannot find transaction - '{$foreign_id}' ref '{$transaction_reference}' order ref '{$concernedOrder->reference}'"; - \report(new \RuntimeException($message)); - SystempayResponse::deny($message)->throwResponse(); - } - return $transaction; - } + $isPartialRefund = $orderTotal->subtract($orderRefunded)->subtract($amountToRefund)->isPositive(); + $transactionIsRemise = $client->canBeRefunded($transaction->foreign_id); - /** - * Check ih the call is a refund call - * - * @return boolean - */ - private function transactionIsRefund(): bool - { - return $this->reponse->getLyraResponse()->get('vads_operation_type') === 'CREDIT'; - } + // ? Transaction can be partially refunded ? + if (!$transactionIsRemise && $isPartialRefund) { + throw ValidationException::withMessages([ + 'error' => \trans('La transaction doit être remisée pour pouvoir effectuer un remboursement partiel') + ]); + } - /** - * Fetch transaction response amount - * - * @return \Money\Money - * @throws \RuntimeException If the response currency is unsupported. - */ - private function fetchTransactionAmount(): Money - { - $lyraResponse = $this->reponse->getLyraResponse(); - $currencyCode = intval($lyraResponse->get('vads_effective_currency')); - /** @var \Lyranetwork\Currency|null */ - if (!($lyraCurrency = Util::findCurrencyByNumCode($currencyCode))) { - $message = __CLASS__ . " Unsupported response currency code {$currencyCode}'"; - \report(new \RuntimeException($message)); - SystempayResponse::deny($message)->throwResponse(); - } - return new Money( - $lyraResponse->get('vads_effective_amount'), - new Currency($lyraCurrency->getAlpha3()) - ); + $refundTransactionResponse = $client->cancelOrRefund( + $transaction->foreign_id, + $amountToRefund + ); + + if ( + !\in_array( + data_get($refundTransactionResponse, 'answer.detailedStatus'), + [ + // * Remboursé full + 'CANCELLED', + // * Remboursé partiel + 'AUTHORISED' + ] + ) + ) { + Log::error($refundTransactionResponse->toJson()); + throw ValidationException::withMessages([ + 'error' => \trans('La transaction n\'a pas pu être remboursée') + ]); + } + + $wasFullyRefunded = data_get($refundTransactionResponse, 'answer.detailedStatus') === 'CANCELLED'; + $wasPartiallyRefunded = !$wasFullyRefunded; + + // ! Just cancel the transaction (elle n'a pas été remisée). + if (!$transactionIsRemise and $wasFullyRefunded) { + $transaction->status = TransactionStatus::canceled(); + $transaction->save(); + return $transaction; + } + + Log::info($refundTransactionResponse->toJson()); + + $refund_foreign_id = Transaction::getPaymentForeignKeyName( + PaymentModeEnum::systemPay(), + data_get($refundTransactionResponse, 'answer.uuid') + ); + + // * Find an existing refund transaction or Create a new transaction as a refund. + /** @var \App\Models\Payment\Transaction|null */ + $refundTransaction = Transaction::query() + ->where('type', TransactionType::credit()) + ->where('foreign_refund_id', $refund_foreign_id)->first(); + + if ($refundTransaction) { + // * Mettre a jour le montant du remboursement. + $refundTransaction->amount = $refundTransaction->amount->add($amountToRefund); + } else { + $refundTransaction = Transaction::getNewInstance( + $transaction->payment_mode, + TransactionType::credit(), + $items, + \intval($amountToRefund->getAmount()) + ); + } + + $refundTransaction->status = TransactionStatus::pending(); + $refundTransaction->assignRefundId(data_get($refundTransactionResponse, 'answer.uuid')); + $refundTransaction->order()->associate($concernedOrder); + $refundTransaction->saveOrFail(); + $concernedOrder->refresh(); + $concernedOrder->total_refund = \intval($amountToRefund->getAmount()) + + \intval($concernedOrder->total_refund); + $concernedOrder->payment_status = $concernedOrder->can_be_refund ? + PaymentStatus::refundedPartial() : PaymentStatus::refunded(); + $concernedOrder->saveQuietly(); + + + OrderIsRefunded::dispatch( + $concernedOrder->refresh(), + $refundTransaction->refresh() + ); + + return $refundTransaction; + }); + } catch (\LDC\LaravelSystempay\Exceptions\LaravelSystempayException $e) { + report($e); + $message = \trans("Le remboursement via Systempay à échoué '{$e->getMessage()}', + veuillez vérifier via le backoffice de systempay"); + throw ValidationException::withMessages(['error' => $message]); + }//end try } } diff --git a/app/Listeners/PaymentIPN/SystempayEventListener.php b/app/Listeners/PaymentIPN/SystempayEventListener.php index 15c2fb2..e5cbbd3 100644 --- a/app/Listeners/PaymentIPN/SystempayEventListener.php +++ b/app/Listeners/PaymentIPN/SystempayEventListener.php @@ -228,6 +228,15 @@ class SystempayEventListener $transaction->status = $canceled ? TransactionStatus::canceled() : TransactionStatus::failed(); $transaction->saveOrFail(); + + /** @var \App\Models\Shop\Order $order */ + $order = $transaction->order()->first(); + + $order->total_refund = $order->getTotalRefunded()->getAmount(); + if ($order->total_refund == 0) { + $order->payment_status = PaymentStatus::payed(); + } + $order->saveOrFail(); $transaction->mutateOrderStatus(); }); } @@ -344,7 +353,7 @@ class SystempayEventListener { $foreign_id = Transaction::getPaymentForeignKeyName( PaymentModeEnum::systemPay(), - $this->reponse->getTransactionId() + $this->reponse->getTransactionUuid() ); $concernedOrder = $this->fetchTransactionOrder(); $transaction_reference = $this->reponse->getLyraResponse()->get('vads_ext_info_transaction'); @@ -352,7 +361,8 @@ class SystempayEventListener $q->where('reference', $concernedOrder->reference); }); - $transaction = (clone $transactionQuery)->where('foreign_id', $foreign_id)->first(); + $transaction = (clone $transactionQuery)->where('foreign_refund_id', $foreign_id)->first(); + $transaction = $transaction ?: (clone $transactionQuery)->where('foreign_id', $foreign_id)->first(); $transaction = $transaction ?: $transactionQuery->where('reference', $transaction_reference)->first(); /** @var \App\Models\Payment\Transaction|null $transaction */; if (!$transaction) { diff --git a/app/Models/Payment/Transaction.php b/app/Models/Payment/Transaction.php index 7051e00..0cec315 100644 --- a/app/Models/Payment/Transaction.php +++ b/app/Models/Payment/Transaction.php @@ -98,6 +98,12 @@ class Transaction extends Model // * Référence unique par jour. $model->reference = ToolboxHelper::getUniqueReference(self::class); $model->mutateOrderStatus(); + if ( + $model->status->equals(TransactionStatus::canceled()) or + $model->status->equals(TransactionStatus::failed()) + ) { + $model->needs_validation = false; + } }); static::updating(function (self $model) { $model->assertHasOrder(); @@ -105,6 +111,12 @@ class Transaction extends Model throw new \RuntimeException('Missing reference'); } $model->mutateOrderStatus(); + if ( + $model->status->equals(TransactionStatus::canceled()) or + $model->status->equals(TransactionStatus::failed()) + ) { + $model->needs_validation = false; + } }); static::deleting(function () { throw new \RuntimeException('Removing a transaction is not allowed'); @@ -244,6 +256,21 @@ class Transaction extends Model $relatedOrder->delivery_status = DeliveryStatus::canceled(); break; } + $stillHasARefundTransaction = $relatedOrder->transactions() + ->where('type', TransactionType::credit()) + ->where('status', TransactionStatus::validated())->exists(); + if ($stillHasARefundTransaction) { + $relatedOrder->payment_status = PaymentStatus::payed(); + } + if ($relatedOrder->total_refund == 0) { + $relatedOrder->payment_status = PaymentStatus::payed(); + } + $stillHasAPaymentTransaction = $relatedOrder->transactions() + ->where('type', TransactionType::debit()) + ->where('status', TransactionStatus::validated())->exists(); + if (!$stillHasAPaymentTransaction) { + $relatedOrder->payment_status = PaymentStatus::failed(); + } $relatedOrder->saveQuietly(); } diff --git a/app/Models/Shop/Order.php b/app/Models/Shop/Order.php index 9c5fb69..cbf0d83 100644 --- a/app/Models/Shop/Order.php +++ b/app/Models/Shop/Order.php @@ -403,13 +403,70 @@ class Order extends Model } /** - * Get remaining unrefunded items - * - * * Diff between Basket - (Delivered items - refunded transaction items) + * Get unrefunded items (items that are mot part of a validated credit transaction) * * @return \Illuminate\Support\Collection */ public function getUnrefundedItems(): Collection + { + /** @var \Illuminate\Support\Collection */ + $orderedItems = \collect($this->basket); + + /** @var \Illuminate\Support\Collection */ + $refundedTransactionsItems = $this->transactions() + ->where('status', '!=', TransactionStatus::canceled()) + ->where('type', TransactionType::credit()) + ->pluck('items')->map(function ($basketItems) { + return (object)\collect($basketItems) + ->filter(function (object $basketItem) { + return !\optional($basketItem->options)->virtual; + })->toArray(); + }); + + // * Collect refunded transaction items. + $refundedTransactionsQtys = \collect(); + $refundedTransactionsItems->each(function (object $basktemItems) use ($refundedTransactionsQtys) { + \collect($basktemItems)->each(function (object $basktemItem) use ($refundedTransactionsQtys) { + $qty = 0; + if ($refundedTransactionsQtys->has($basktemItem->rowId)) { + $qty = $refundedTransactionsQtys->get($basktemItem->rowId)->qty; + } + $basktemItem->qty += $qty; + $refundedTransactionsQtys->put($basktemItem->rowId, $basktemItem); + }); + }); + + /** @var \Illuminate\Support\Collection */ + $remainingItems = \collect(); + $orderedItems->each(function ( + object $basketItem + ) use ( + $refundedTransactionsQtys, + $remainingItems + ) { + $qty = $basketItem->qty; + if ($refundedTransactionsQtys->contains('id', $basketItem->id)) { + $qty -= $refundedTransactionsQtys->where('id', $basketItem->id)->first()->qty; + } + $remainingItems->put( + $basketItem->rowId, + $qty + ); + }); + $remainingItems = $remainingItems->filter(); + + return \collect(ToolboxHelper::updateBasketAmounts( + $orderedItems, + $remainingItems->toArray() + )); + } + + /** + * Get remaining unrefunded items + * + * @return \Illuminate\Support\Collection + */ + public function getRemainingUnrefundedItems(): Collection { /** @var \Illuminate\Support\Collection */ $orderedItems = \collect($this->basket); @@ -420,7 +477,7 @@ class Order extends Model $refundedTransactionsItems = $this->transactions() ->where('status', '!=', TransactionStatus::canceled()) ->where('type', TransactionType::credit()) - ->pluck('items')->map(function (object $basketItems) { + ->pluck('items')->map(function ($basketItems) { return (object)\collect($basketItems) ->filter(function (object $basketItem) { return !\optional($basketItem->options)->virtual; @@ -490,14 +547,19 @@ class Order extends Model */ public function getItemForOrderCancelling(): Collection { + // * Calculate taxes using items that are not in a refund transaction. $itemsToRefund = $this->getUnrefundedItems(); - $remainingToRefund = \intval($itemsToRefund->sum('subtotal')); $remainingTaxToRefund = \intval($itemsToRefund->sum(function ($basketItem) { $basketItem = (object)$basketItem; // * Arrondi a l'entier le plus proche return round($basketItem->qty * $basketItem->tax); })); + // * Real remaining amount to refund. + $remainingToRefund = $this->getTotalPayed()->subtract( + $this->getTotalRefunded() + )->getAmount(); + // * Créer un dummy product pour représenter la transaction. $cartItemOptions = \array_merge(Controller::$cartItemOptionsSkel, [ 'virtual' => true, @@ -538,8 +600,8 @@ class Order extends Model /** @var \Illuminate\Support\Collection */ $refundedTransactionsItems = $this->transactions() ->where('type', TransactionType::credit()) - ->where('status', TransactionStatus::canceled()) - ->where('status', TransactionStatus::failed()) + ->where('status', '!=', TransactionStatus::canceled()) + ->where('status', '!=', TransactionStatus::failed()) ->pluck('items'); $final = \collect($orderedItems); @@ -563,7 +625,11 @@ class Order extends Model // * Transactions de remboursement. /** @var \Illuminate\Database\Eloquent\Collection<\App\Models\Payment\Transaction> */ - $refundTransactions = $this->transactions()->where('type', TransactionType::credit()) + $refundTransactions = $this->transactions() + ->where('type', TransactionType::credit()) + ->where('status', '!=', TransactionStatus::canceled()) + ->where('status', '!=', TransactionStatus::failed()) + ->where('id', '!=', $transaction->id) ->get(['id', 'amount', 'currency']); // * Détermination du dernier montant remboursé. @@ -577,12 +643,18 @@ class Order extends Model } /** @var \Money\Money */ - $totalRembourse = $refundTransactions->map(function (Transaction $transaction) { - return $transaction->amount; - })->add($zeroAmount)->reduce(function ($carry, Money $amount) use ($zeroAmount) { - /** @var \Money\Money|null $carry */ - return !$carry ? $zeroAmount : $carry->add($amount); - }); + $totalRembourse = $refundTransactions->reduce(function ($previous, Transaction $actual) { + /** @var \Money\Money|null $previous */ + /** @var \App\Models\Payment\Transaction $actual */ + $out = new Money(0, app('phpMoneyCurrency')); + if ($previous) { + $out = $out->add($previous); + } + if ($actual->type->equals(TransactionType::credit())) { + $out = $out->add($actual->amount); + } + return $out; + }) ?? new Money(0, app('phpMoneyCurrency')); $totalCommande = (new Money($this->total_ttc, app('phpMoneyCurrency'))); // * Total commande - (total rembousé jusqu'ici + dernier montant a être rembousé). @@ -628,23 +700,26 @@ class Order extends Model */ public function getTotalPayed(): Money { - return $this->transactions->reduce(function ($previous, Transaction $actual) { - /** @var \Money\Money|null $previous */ - /** @var \App\Models\Payment\Transaction $actual */ - $out = new Money(0, app('phpMoneyCurrency')); - if ($previous) { - $out = $out->add($previous); - } - if ( - $actual->type->equals(TransactionType::debit()) and - !$actual->payment_mode->equals(PaymentModeEnum::inShop()) and - $actual->status->equals(TransactionStatus::validated()) - ) { - $out = $out->add($actual->amount); - } - - return $out; - }) ?? new Money(0, app('phpMoneyCurrency')); + return $this->transactions() + ->where('status', '!=', TransactionStatus::canceled()) + ->where('status', '!=', TransactionStatus::failed()) + ->get() + ->reduce(function ($previous, Transaction $actual) { + /** @var \Money\Money|null $previous */ + /** @var \App\Models\Payment\Transaction $actual */ + $out = new Money(0, app('phpMoneyCurrency')); + if ($previous) { + $out = $out->add($previous); + } + if ( + $actual->type->equals(TransactionType::debit()) and + !$actual->payment_mode->equals(PaymentModeEnum::inShop()) and + $actual->status->equals(TransactionStatus::validated()) + ) { + $out = $out->add($actual->amount); + } + return $out; + }) ?? new Money(0, app('phpMoneyCurrency')); } /** @@ -654,18 +729,21 @@ class Order extends Model */ public function getTotalRefunded(): Money { - return $this->transactions->reduce(function ($previous, Transaction $actual) { - /** @var \Money\Money|null $previous */ - /** @var \App\Models\Payment\Transaction $actual */ - $out = new Money(0, app('phpMoneyCurrency')); - if ($previous) { - $out = $out->add($previous); - } - if ($actual->type->equals(TransactionType::credit())) { - $out = $out->add($actual->amount); - } - return $out; - }) ?? new Money(0, app('phpMoneyCurrency')); + return $this->transactions() + ->where('status', '!=', TransactionStatus::canceled()) + ->where('status', '!=', TransactionStatus::failed()) + ->get()->reduce(function ($previous, Transaction $actual) { + /** @var \Money\Money|null $previous */ + /** @var \App\Models\Payment\Transaction $actual */ + $out = new Money(0, app('phpMoneyCurrency')); + if ($previous) { + $out = $out->add($previous); + } + if ($actual->type->equals(TransactionType::credit())) { + $out = $out->add($actual->amount); + } + return $out; + }) ?? new Money(0, app('phpMoneyCurrency')); } /** diff --git a/resources/views/pages/orders/form.blade.php b/resources/views/pages/orders/form.blade.php index 5a1bbb2..16addd6 100644 --- a/resources/views/pages/orders/form.blade.php +++ b/resources/views/pages/orders/form.blade.php @@ -97,7 +97,7 @@ )
- + {{ __('Préparer la commande') }}
@else {{-- La commande est au moins préparé --}} diff --git a/resources/views/pages/orders/prepare.blade.php b/resources/views/pages/orders/prepare.blade.php index 799fc27..417dd90 100644 --- a/resources/views/pages/orders/prepare.blade.php +++ b/resources/views/pages/orders/prepare.blade.php @@ -37,7 +37,7 @@ @if($order->total_refund)
- {{ + {{ __('Montant restant sur la transaction :amount', [ 'amount' => \App\Lib\Helpers\ToolboxHelper::fmtCurrency($order->total_ttc - $order->total_refund) ]) diff --git a/resources/views/pages/orders/resolve.blade.php b/resources/views/pages/orders/resolve.blade.php index cf21333..d19778e 100644 --- a/resources/views/pages/orders/resolve.blade.php +++ b/resources/views/pages/orders/resolve.blade.php @@ -170,7 +170,7 @@ @else
- {{ __('Il ne reste plus de produit remboursable sur cette commande, il est seulement possible de fournir la raison du remboursement.') }} + {{ __('Il ne reste plus de produit remboursable sur cette commande, il est seulement possible de fournir la raison du remboursement.') }}
@endif diff --git a/routes/breadcrumbs-bo.php b/routes/breadcrumbs-bo.php index 17061f7..1046e95 100644 --- a/routes/breadcrumbs-bo.php +++ b/routes/breadcrumbs-bo.php @@ -125,7 +125,7 @@ Breadcrumbs::for('bo.contacts.index', function (Generator $trail) { }); Breadcrumbs::for('bo.contacts.show', function (Generator $trail, Contact $contact) { $trail->parent('bo.contacts.index'); - $trail->push(trans('Apperçu'), route('bo.contacts.show', $contact)); + $trail->push(trans('Aperçu'), route('bo.contacts.show', $contact)); }); // * SHOPS @@ -161,7 +161,7 @@ Breadcrumbs::for('bo.orders.today', function (Generator $trail) { }); Breadcrumbs::for('bo.orders.show', function (Generator $trail, Order $order) { $trail->parent('bo.orders.index'); - $trail->push(trans('Apperçu'), route('bo.orders.show', $order)); + $trail->push(trans('Aperçu'), route('bo.orders.show', $order)); }); Breadcrumbs::for('bo.orders.prepare', function (Generator $trail, Order $order) { $trail->parent('bo.orders.show', $order);