Tons of fixes

This commit is contained in:
Jérémy Munsch 2023-05-25 20:04:46 +02:00
parent ffe65f6ec6
commit 92b9929849
No known key found for this signature in database
GPG key ID: 18361AF3BFEC0FEC
9 changed files with 377 additions and 406 deletions

View file

@ -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<object> */
$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<string,string> */
$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.

View file

@ -1,399 +1,258 @@
<?php
namespace App\Listeners\PaymentIPN;
namespace App\Lib\Payments\Adapters;
use App\Enums\Order\PaymentModeEnum;
use App\Enums\Order\PaymentStatus;
use App\Enums\Order\TransactionStatus;
use App\Enums\Order\TransactionType;
use App\Enums\Order\ValidationStatus;
use App\Events\OrderIsCreated;
use App\Events\OrderIsPaid;
use App\Events\OrderIsRefunded;
use App\Lib\Payments\PaymentAdapter;
use App\Models\Customer\Customer;
use App\Models\Payment\Transaction;
use App\Models\Shop\Order;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use LDC\LaravelSystempay\Enums\ResponseType;
use LDC\LaravelSystempay\Events\SystempayWebhookEvent;
use LDC\LaravelSystempay\Lib\SystempayResponse;
use Lyranetwork\Util;
use Money\Currency;
use Illuminate\Support\Facades\URL;
use Illuminate\Validation\ValidationException;
use LDC\LaravelSystempay\LaravelSystempay;
use Money\Money;
use Symfony\Component\HttpFoundation\Response;
class SystempayEventListener
class SystemPayPaymentAdapter extends PaymentAdapter
{
/** @var \LDC\LaravelSystempay\Events\SystempayWebhookEvent */
private $event;
/** @var \LDC\LaravelSystempay\Lib\Response */
private $reponse;
/** @var \LDC\LaravelSystempay\Enums\ResponseType */
private $reponseType;
// * ---- DISPATCH ----
/**
* Handle received Systempay webhooks.
* Get Systempay adapter
*
*
* @param \LDC\LaravelSystempay\Events\SystempayWebhookEvent $event
* @return void
* @throws \RuntimeException If payment status is unhandled.
* @return \LDC\LaravelSystempay\LaravelSystempay
*/
public function handle(SystempayWebhookEvent $event)
private function getPayment(): LaravelSystempay
{
$this->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
}
}

View file

@ -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) {

View file

@ -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();
}

View file

@ -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<object>
*/
public function getUnrefundedItems(): Collection
{
/** @var \Illuminate\Support\Collection<object> */
$orderedItems = \collect($this->basket);
/** @var \Illuminate\Support\Collection<object> */
$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<string,integer> */
$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<object>
*/
public function getRemainingUnrefundedItems(): Collection
{
/** @var \Illuminate\Support\Collection<object> */
$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<object> */
$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'));
}
/**

View file

@ -97,7 +97,7 @@
)
<div class="col-12 col-sm-8 d-flex flex-column">
<input type="hidden" name="delivery_status" value="{{ App\Enums\Order\DeliveryStatus::preparation() }}">
<button class="btn btn-info" type="submit" href="{{ route('bo.orders.prepare', $order) }}">{{ __('Préparer la commande') }}</button>
<a class="btn btn-info" href="{{ route('bo.orders.prepare', $order) }}">{{ __('Préparer la commande') }}</a>
</div>
@else
{{-- La commande est au moins préparé --}}

View file

@ -37,7 +37,7 @@
</div>
@if($order->total_refund)
<div class="text-center p-2">
<span class="fs-3 badge-warning text-bg-warning">{{
<span class="fs-3 badge bg-warning text-bg-warning">{{
__('Montant restant sur la transaction :amount', [
'amount' => \App\Lib\Helpers\ToolboxHelper::fmtCurrency($order->total_ttc - $order->total_refund)
])

View file

@ -170,7 +170,7 @@
@else
<div class="row mb-3">
<div class="col-12 col-lg-6">
<span class="h5 badge-info text-bg-info">{{ __('Il ne reste plus de produit remboursable sur cette commande, il est seulement possible de fournir la raison du remboursement.') }}</span>
<span class="h5 badge bg-info text-bg-info">{{ __('Il ne reste plus de produit remboursable sur cette commande, il est seulement possible de fournir la raison du remboursement.') }}</span>
</div>
</div>
@endif

View file

@ -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);