Tons of fixes
This commit is contained in:
parent
ffe65f6ec6
commit
92b9929849
9 changed files with 377 additions and 406 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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'));
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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é --}}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
])
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue