false, 'shipped_basket' => '{}', 'total_refund' => 0 ]; /** * Map attribute names to enum classes. * * @var array */ protected $enumCasts = [ 'validation_status' => ValidationStatus::class, 'payment_status' => PaymentStatus::class, 'delivery_status' => DeliveryStatus::class ]; /** * The attributes that should be cast to native types. * * @var array */ protected $casts = [ 'basket' => 'object', 'shipped_basket' => 'object', 'shipping' => 'object', 'billing' => 'object', 'mail_sent' => 'bool' ]; /** * The attributes that are mass assignable. * * @var array */ protected $fillable = [ 'shipping', 'billing' ]; /** * Perform any actions required after the model boots. * * @return void */ protected static function booted() { static::creating(function (self $model) { // * ASSERTIONS static::assertValidateStatus($model); static::assertCancelStatus($model); $model->setUniqueOrderReference(); }); static::updating(function (self $model) { static::assertValidateStatus($model); static::assertCancelStatus($model); if ($model->validation_status->equals(ValidationStatus::validated())) { OrderIsValidated::dispatchUnless($model->mail_sent, $model); } /** @var \App\Models\Customer\Customer|null */ $customer = $model->customer; if ($model->validation_status->value !== $model->getOriginal('validation_status')) { // * Notifiy customer $notification = new OrderValidationStatusChanged($model); if ($customer) { $model->customer->notify($notification); } else { // * AnonymousNotifiable with Notification Fake Notification::route('mail', $model->email)->notifyNow($notification); } Session::flash('bo.info', trans('Le client recevra une notification sur l\'état de validation.')); } //end if // * Commande est annulée if ( $model->validation_status->value !== $model->getOriginal('validation_status') and $model->validation_status->equals(ValidationStatus::canceled()) ) { // * Set le timeslot as soft deleted $model->timeslot()->delete(); $model->delivery_status = DeliveryStatus::canceled(); // ? La transaction utilisée est inShop ? $transaction = $model->getTransactionUsedForPayment(); if ($transaction and $transaction->payment_mode->equals(PaymentModeEnum::inShop())) { // * Set la transaction en annulée. $transaction->status = TransactionStatus::canceled(); $model->payment_status = PaymentStatus::canceled(); $transaction->saveQuietly(); } } // * Empecher la réactivation d'une commande (car le timeslot aura été perdu). if ( $model->getOriginal('validation_status') === ValidationStatus::canceled()->value and !$model->validation_status->equals(ValidationStatus::canceled()) ) { throw new \Error('This is not allowed'); } // * Commande est prête. if ( $model->delivery_status->equals(DeliveryStatus::ready()) and !$model->payment_status->equals(PaymentStatus::failed()) and !$model->payment_status->equals(PaymentStatus::canceled()) ) { // * Notifiy customer $notification = new OrderDeliveryStatusChanged($model); if ($customer) { $model->customer->notify($notification); } else { // * AnonymousNotifiable with Notification Fake Notification::route('mail', $model->email)->notifyNow($notification); } Session::flash('bo.info', trans('Le client recevra une notification sur l\'état de livraison.')); } }); static::deleting(function () { throw new \Error('This is not allowed'); }); } /** * Get the route key for the model. * * @return string */ public function getRouteKeyName() { return 'reference'; } // * RELATIONSHIPS /** * HasMany transactions relation * * @return \Illuminate\Database\Eloquent\Relations\HasMany */ public function transactions() { return $this->hasMany(Transaction::class); } /** * BelongsToMany products relation * * @return \Illuminate\Database\Eloquent\Relations\BelongsToMany */ public function products() { return $this->belongsToMany(Product::class, 'order_lines', 'order_id', 'product_id'); } /** * BelongsTo customer relation * * @return \Illuminate\Database\Eloquent\Relations\BelongsTo */ public function customer() { return $this->belongsTo(Customer::class); } /** * BelongsTo DeliveryMode relation * * @return \Illuminate\Database\Eloquent\Relations\BelongsTo */ public function deliveryMode() { return $this->belongsTo(DeliveryMode::class); } /** * hasOne timeslot relation * * @return \Illuminate\Database\Eloquent\Relations\HasOne */ public function timeslot() { return $this->hasOne(Timeslot::class); } /** * hasOne timeslot relation * * @return \Illuminate\Database\Eloquent\Relations\HasOne */ public function trashedTimeslot() { return $this->timeslot()->withTrashed(); } // * GETTER /** * Getter can_be_canceled * * ! Logique modifiée pour refonte only here (sans paiement) * * On peut annuler la commande si : * - la livraison n'a pas encore été initié * - la commande n'est pas déjà annulée * * @return boolean */ public function getCanBeCanceledAttribute(): bool { // * Si le status de validation n'est pas annulé return $this->getOriginal('validation_status') !== ValidationStatus::canceled()->value && // * Si le statut de livraison n'est pas récupéré !\in_array($this->getOriginal('delivery_status'), [ DeliveryStatus::recovered()->value, DeliveryStatus::canceled()->value, ]); } /** * Getter can_be_refund * * * On peut rembourser la commande si le montant déjà remboursé est inférieur au montant payé * * @return boolean */ public function getCanBeRefundAttribute(): bool { return $this->total_ttc > $this->total_refund and !\in_array($this->getOriginal('payment_status'), [ PaymentStatus::refunded()->value, PaymentStatus::canceled()->value, PaymentStatus::failed()->value, ]); } /** * Getter has_warning * * Une des transactions de la commande doit être validée avant de continuer. * * @return boolean */ public function getHasWarningAttribute(): bool { return $this->transactions() ->where('needs_validation', true) ->where('type', TransactionType::credit()) ->exists(); } // * SETTER /** * Setter reference * * @return void * @throws \RuntimeException This is readonly. */ public function setReferenceAttribute(): void { throw new \RuntimeException('Reference is readonly'); } /** * Setter has_warning * * @return void * @throws \RuntimeException This is readonly. */ public function setHasWarningAttribute(): void { throw new \RuntimeException('has_warning is readonly'); } // * METHODS /** * Get refunded items * * @return \Illuminate\Support\Collection */ public function getRefundedItems(): Collection { /** @var \Illuminate\Support\Collection */ $orderedItems = clone \collect($this->basket); // * Collect refunded transaction items. $unRefundedTransactionItems = $this->getUnrefundedItems(); $unRefundedTransactionsQtys = \collect(); $orderedItems->each(function (object $basktemItem) use ( $unRefundedTransactionItems, $unRefundedTransactionsQtys ) { $qty = 0; if ($unRefundedTransactionItems->has($basktemItem->rowId)) { $qty = $unRefundedTransactionItems->get($basktemItem->rowId)->qty; } $basktemItem->qty -= $qty; $unRefundedTransactionsQtys->put($basktemItem->rowId, $basktemItem); }); /** @var \Illuminate\Support\Collection */ $remainingItems = \collect(); $unRefundedTransactionsQtys->each(function ( object $basketItem ) use ( $remainingItems ) { $qty = $basketItem->qty; $remainingItems->put( $basketItem->rowId, $qty ); }); $remainingItems = $remainingItems->filter(); return \collect(ToolboxHelper::updateBasketAmounts( $this->basket, $remainingItems->toArray() )); } /** * 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); /** @var \Illuminate\Support\Collection */ $shippedTransactionsItems = \collect($this->shipped_basket)->filter(); /** @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 shipped transaction items. $shippedTransactionsQtys = \collect(); $shippedTransactionsItems->each(function (object $basktemItem) use ($shippedTransactionsQtys) { $qty = 0; if ($shippedTransactionsQtys->has($basktemItem->rowId)) { $qty = $shippedTransactionsQtys->get($basktemItem->rowId)->qty; } $basktemItem->qty += $qty; $shippedTransactionsQtys->put($basktemItem->rowId, $basktemItem); }); // * 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 ( $shippedTransactionsQtys, $refundedTransactionsQtys, $remainingItems ) { $qty = $basketItem->qty; if ($shippedTransactionsQtys->contains('id', $basketItem->id)) { $qty -= $shippedTransactionsQtys->where('id', $basketItem->id)->first()->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 + Refunds from Payment system backend * ! for final complete refund only. * * * Diff between Basket - all credit transactions items * * @return \Illuminate\Support\Collection */ public function getItemForOrderCancelling(): Collection { // * Calculate taxes using items that are not in a refund transaction. $itemsToRefund = $this->getUnrefundedItems(); $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, 'price_ttc' => $remainingToRefund, ]); $cartItem = CartItem::fromArray([ 'id' => \str_replace('.', '', \uniqid('', true)), 'name' => trans('Remboursement complet'), 'price' => $remainingToRefund, 'weight' => 0, 'options' => $cartItemOptions, ]); $cartItem->setQuantity(1); // * Négation du prix $basketItem = \json_decode($cartItem->toJson()); $basketItem->price = $basketItem->price * -1; $basketItem->options->price_ttc = $basketItem->options->price_ttc * -1; $basketItem->subtotal = $basketItem->subtotal * -1; $basketItem->tax = $remainingTaxToRefund * -1; $basketItem->subtotal = $basketItem->price; return collect()->add($basketItem)->values(); } /** * Fetch all transaction items * ! For final bill. * * @return \Illuminate\Support\Collection */ public function getAllTransactionsItems(): Collection { // * Negate all items prices (already refunded) $orderedItems = \collect(\optional($this->getTransactionUsedForPayment())->items); /** @var \Illuminate\Support\Collection */ $refundedTransactionsItems = $this->transactions() ->where('type', TransactionType::credit()) ->where('status', '!=', TransactionStatus::canceled()) ->where('status', '!=', TransactionStatus::failed()) ->pluck('items'); $final = \collect($orderedItems); $refundedTransactionsItems->each(function ($items) use (&$final) { $final = $final->concat(\collect($items)); }); return $final->values(); } /** * Check if the Order is or will be completely refunded using a transaction. * * @param \App\Models\Payment\Transaction $transaction * @return boolean */ public function isCompleteRefundWithTransaction(Transaction $transaction): bool { $zeroAmount = new Money(0, app('phpMoneyCurrency')); $lastAmount = clone $zeroAmount; // * Transactions de remboursement. /** @var \Illuminate\Database\Eloquent\Collection<\App\Models\Payment\Transaction> */ $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é. if ( !$refundTransactions->contains(function (Transaction $refundTransaction) use ($transaction) { return $refundTransaction === $transaction->id; }) ) { // * La transaction n'est pas enregistrée, le dernier montant remboursé sera notre transaction. $lastAmount = $transaction->amount; } /** @var \Money\Money */ $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é). $resteDevantEtreRembourse = $totalCommande->subtract($totalRembourse->add($lastAmount)); return $resteDevantEtreRembourse->lessThanOrEqual($zeroAmount); } /** * Get the transaction used for payment * * @return null|\App\Models\Payment\Transaction */ public function getTransactionUsedForPayment(): ?Transaction { $query = $this->transactions()->orderBy('created_at', 'DESC'); /** @var \App\Models\Payment\Transaction|null $transaction */ $transaction = (clone $query)->where('type', TransactionType::debit()) ->where('status', TransactionStatus::validated())->first(); // If no paid transaction there is, try to fetch an inshop transaction. if (!$transaction and $this->deliveryMode->mode->equals(DeliveryModeEnum::inShop())) { $transaction = (clone $query)->where('type', TransactionType::debit()) ->where('payment_mode', PaymentModeEnum::inShop()) ->where('status', TransactionStatus::pending()) ->first(); } // If no paid transaction there is, try to fetch the last failed or canceled debit transaction. if (!$transaction and $this->deliveryMode->mode->equals(DeliveryModeEnum::inShop())) { $transaction = (clone $query)->where('type', TransactionType::debit()) ->where('payment_mode', '!=', PaymentModeEnum::inShop()) ->whereIn('status', [TransactionStatus::failed(), TransactionStatus::canceled()]) ->first(); } return $transaction; } /** * Total payed by the customer * * @return \Money\Money */ public function getTotalPayed(): Money { 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')); } /** * Total refunded to the customer * * @return \Money\Money */ public function getTotalRefunded(): Money { 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')); } /** * Total ttc * * @return \Money\Money */ public function getTotalTtc(): Money { $totalTax = \intval(\collect(\optional($this->getTransactionUsedForPayment())->items) ->sum('subtotal')); return new Money($totalTax, app('phpMoneyCurrency')); } /** * Total refunded to the customer * * @return \Money\Money */ public function getTotalTax(): Money { $totalTax = \intval(\collect(\optional($this->getTransactionUsedForPayment())->items) ->sum(function (object $item) { return $item->tax * $item->qty; })); return new Money($totalTax, app('phpMoneyCurrency')); } /** * Setup reference * * @return void */ public function setUniqueOrderReference() { if (!$this->reference) { $this->setRawAttributes(\array_merge( $this->attributes, [ 'reference' => ToolboxHelper::getUniqueReference(self::class) ] )); } } /** * Check if order can be canceled * * @param self $model * @return void * @throws \Illuminate\Validation\ValidationException If the order is canceled while it cannot be. */ public static function assertCancelStatus(self $model) { if ( !$model->can_be_canceled and $model->validation_status->equals(ValidationStatus::canceled()) ) { throw ValidationException::withMessages([ 'messages' => "La commande '{$model->id}' ne peut être annulée" ]); } if ( $model->validation_status->equals(ValidationStatus::canceled()) and $model->getTransactionUsedForPayment()->payment_mode->equals(PaymentModeEnum::inShop()) and !\in_array($model->payment_status, [PaymentStatus::failed(), PaymentStatus::pending()]) ) { throw ValidationException::withMessages([ 'messages' => "La commande '{$model->id}' ne peut être annulée car le remboursement click and collect n'est pas géré" ]); } } /** * Check if order can be validated * * @param self $model * @return void * @throws \Illuminate\Validation\ValidationException If the order is canceled while it cannot be. */ public static function assertValidateStatus(self $model) { if ($model->getOriginal('validation_status') === ValidationStatus::canceled()->value) { throw ValidationException::withMessages([ 'messages' => "La commande '{$model->id}' ne peut être validée après avoir été annulée" ]); } } /** * Get updated current session order * * @return null|\App\Models\Shop\Order */ public static function getCurrentOrder(): ?self { /** @var \App\Models\Shop\Order|null $order */ if ($order = Session::get('currentOrder')) { // * Refresh order if exists in DB. $order = Order::query()->where('id', $order->id) ->orWhere('reference', $order->reference) ->first() ?? $order; } return $order; } /** * Inject the basket items into the order. * * @param \Gloudemans\Shoppingcart\Cart $basket * @return void * @throws \Illuminate\Validation\ValidationException If a product from the basket is not accessible anymore. */ public function assignBasket(Cart $basket): void { $this->basket = $basket->content(); $this->total_discounted = \intval($basket->discount()); $this->total_ttc = \intval($basket->total()); $this->total_ht = \intval($this->total_ttc - $basket->tax()); $this->total_weight = \intval($basket->weight()); } /** * Generate final Invoice * * @param string $title * @return \Mccarlosen\LaravelMpdf\LaravelMpdf */ public function generateInvoice(string $title): LaravelMpdf { return ToolboxHelper::generateInvoice( $this, $this->created_at, $title, Str::ucfirst(\trans('facture')), $this->getAllTransactionsItems(), $this->reference ); } }