PaymentModeEnum::class, 'status' => TransactionStatus::class, 'type' => TransactionType::class, ]; /** * The attributes that should be cast. * * @var string[] */ protected $casts = [ 'needs_validation' => 'bool', 'items' => 'object' ]; /** * The model's attributes. * * @var array */ protected $attributes = [ 'items' => '{}', ]; /** * The attributes that are mass assignable. * * ! No attributes can be filled, TAKE THIS SERIOUSLY ! * * @var array */ protected $fillable = []; /** * Perform any actions required after the model boots. * * @return void */ protected static function booted() { // * EVENT CLOSURES. static::creating(function (self $model) { $model->assertHasOrder(); // * 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(); if (!$model->reference) { throw new \RuntimeException('Missing reference'); } $model->mutateOrderStatus(); if ( $model->status->equals(TransactionStatus::canceled()) or $model->status->equals(TransactionStatus::failed()) ) { $model->needs_validation = false; } }); static::updated(function (self $model) { $model->mutateOrderStatusAfterSave(); }); static::deleting(function () { throw new \RuntimeException('Removing a transaction is not allowed'); }); } /** * Get the route key for the model. * * @return string */ public function getRouteKeyName() { return 'reference'; } // * METHODS /** * Create a new instance of the given model. * * @param \App\Enums\Order\PaymentModeEnum $mode The payment mode. * @param \App\Enums\Order\TransactionType $type The transaction type (credit or debit). * @param object $items The basket items concerned by the transaction. * @param integer $amount The amount of the transaction. * @param array $attributes Initial attributes to pass on to the model. * @return static * @throws \RuntimeException If amount is below 0. */ public static function getNewInstance( PaymentModeEnum $mode, TransactionType $type, object $items, int $amount = 0, array $attributes = [] ): self { // phpcs:enable if ($amount < 0) { throw new \RuntimeException('Amount cannot be negative'); } $transaction = new self($attributes); $transaction->amount = new Money($amount, app('phpMoneyCurrency')); $transaction->status = TransactionStatus::pending(); $transaction->type = $type; $transaction->payment_mode = $mode; $transaction->needs_validation = false; $transaction->items = $items; return $transaction; } /** * Return payment adpater * * @return \App\Lib\Payments\PaymentAdapter */ public function getAdapter(): PaymentAdapter { // * Singleton adpater if (!app($this->payment_mode->adapter)) { \app()->singleton($this->payment_mode->adapter, function () { return ($this->payment_mode->adapter)(); }); } return app($this->payment_mode->adapter); } /** * Get updated current session order * * @return null|\App\Models\Payment\Transaction */ public static function getCurrentTransaction(): ?self { /** @var \App\Models\Payment\Transaction|null $transaction */ $transaction = Session::get('currentTransaction'); return $transaction; } /** * Fetch the associated order. * * @return \App\Models\Shop\Order */ protected function fetchOrder(): Order { return $this->order; } /** * Assert is associated with an actual order * * @return void * @throws \RuntimeException If no order is associated. */ public function assertHasOrder(): void { if (!$this->order()->exists()) { throw new \RuntimeException("Transaction associated with an order, id '{$this->id}' that does not exists"); } } /** * Set related Order payment status using this transaction actual status. * * @return void * @throws \RuntimeException If this transaction is not associated with and order. */ public function mutateOrderStatus(): void { // ! Dont handle this here. if ($this->type->equals(TransactionType::credit())) { return; } /** @var \App\Models\Shop\Order $relatedOrder */ if (!($relatedOrder = $this->order()->first())) { throw new \RuntimeException("Any transaction has to be associated with an order, id '{$this->id}'"); } // * Ignore pending here as default status switch ($this->status) { case TransactionStatus::validated(): $relatedOrder->payment_status = PaymentStatus::payed(); if (!$this->payment_mode->equals(PaymentModeEnum::inShop())) { // * La commande est automatiquement validée lors d'un paiement en ligne. $relatedOrder->validation_status = ValidationStatus::validated(); } break; case TransactionStatus::failed(): // * Just set failure on any failed transaction. $relatedOrder->payment_status = PaymentStatus::failed(); $relatedOrder->delivery_status = DeliveryStatus::canceled(); break; case TransactionStatus::canceled(): // * Just set failure on any canceled transaction. $relatedOrder->payment_status = PaymentStatus::canceled(); $relatedOrder->delivery_status = DeliveryStatus::canceled(); break; } $relatedOrder->saveQuietly(); } /** * Set related Order payment status using this transaction actual status. * * @return void * @throws \RuntimeException If this transaction is not associated with and order. */ public function mutateOrderStatusAfterSave() { /** @var \App\Models\Shop\Order $relatedOrder */ if (!($relatedOrder = $this->order()->first())) { throw new \RuntimeException("Any transaction has to be associated with an order, id '{$this->id}'"); } $stillHasARefundTransaction = $relatedOrder->transactions() ->where('type', TransactionType::credit()) ->where('status', TransactionStatus::validated())->exists(); if ($stillHasARefundTransaction) { $relatedOrder->payment_status = PaymentStatus::payed(); } if ( !empty($relatedOrder->total_ttc) and $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(); } } /** * Get the foreign key name as formatted in DB * * @param \App\Enums\Order\PaymentModeEnum $payment_mode_enum * @param string $foreign_id * @return string */ public static function getPaymentForeignKeyName(PaymentModeEnum $payment_mode_enum, string $foreign_id): string { return "{$payment_mode_enum->value}_{$foreign_id}"; } /** * Set the payment foreign_id * * @param string $foreign_id The payment service unique id for this transaction. * @return void * @throws \RuntimeException The $foreign_id constrains are not matched. */ public function assignPaymentId(string $foreign_id): void { $foreign_id = self::getPaymentForeignKeyName($this->payment_mode, $foreign_id); if (($this->attributes['foreign_id'] ?? null) === $foreign_id) { return; } if (!\is_numeric($this->payment_mode->value)) { throw new \RuntimeException('Assign foreign_id needs to have a payment mode'); } if ($this->query()->where('foreign_id', $foreign_id)->where('id', '!=', $this->id)->exists()) { throw new \RuntimeException( "Assign foreign_id needs to have a unique value, '{$foreign_id}' already exists" ); } $this->attributes['foreign_id'] = $foreign_id; } /** * Set the payment foreign_refund_id * * @param string $foreign_refund_id The payment service unique id for this transaction. * @return void * @throws \RuntimeException The $foreign_refund_id constrains are not matched. */ public function assignRefundId(string $foreign_refund_id): void { $foreign_refund_id = self::getPaymentForeignKeyName($this->payment_mode, $foreign_refund_id); if (($this->attributes['foreign_refund_id'] ?? null) === $foreign_refund_id) { return; } if (!\is_numeric($this->payment_mode->value)) { throw new \RuntimeException('Assign foreign_refund_id needs to have a payment mode'); } if ($this->query()->where('foreign_refund_id', $foreign_refund_id)->where('id', '!=', $this->id)->exists()) { throw new \RuntimeException( "Assign foreign_id needs to have a unique value, '{$foreign_refund_id}' already exists" ); } $this->attributes['foreign_refund_id'] = $foreign_refund_id; } /** * Generate Invoice * * @param string $title * @return \Mccarlosen\LaravelMpdf\LaravelMpdf */ public function generateInvoice(string $title): LaravelMpdf { /** @var \App\Models\Shop\Order */ $order = $this->order; /** * * Because of Stripe reusing transactions, therefore use prefix. */ $transactionReferenceSuffix = $order->payment_status->equals(PaymentStatus::refunded()) ? '_1' : ''; $invoiceBasketItems = \collect($this->items); return ToolboxHelper::generateInvoice( $order, $this->created_at, $title, $this->type->equals(TransactionType::debit()) ? Str::ucfirst(\trans('facture')) : Str::ucfirst(\trans('avoir')), $invoiceBasketItems, $this->reference . $transactionReferenceSuffix ); } // * GETTER /** * Get the transation amount * * @return \Money\Money */ public function getAmountAttribute(): Money { return new Money($this->attributes['amount'], $this->currency); } /** * Get the transation currency * * @return \Money\Currency */ public function getCurrencyAttribute(): Currency { return new Currency(Str::upper($this->attributes['currency'])); } /** * Get the foreign_id * * @return string|null * @throws \RuntimeException If the prefix does not match the payment mode. */ public function getForeignIdAttribute(): ?string { if ( !empty($this->attributes['foreign_id']) and \strpos($this->attributes['foreign_id'] ?? '', "{$this->payment_mode->value}_") !== 0 ) { throw new \RuntimeException( "The payment mode {$this->payment_mode->label} does not match the foreign_id prefix" ); } return Str::replace("{$this->payment_mode->value}_", '', $this->attributes['foreign_id'] ?? ''); } /** * Get the foreign_refund_id * * @return string|null * @throws \RuntimeException If the prefix does not match the payment mode. */ public function getForeignRefundIdAttribute(): ?string { if (empty($this->attributes['foreign_refund_id'])) { return null; } if ( !empty($this->attributes['foreign_refund_id']) and \strpos($this->attributes['foreign_refund_id'] ?? '', "{$this->payment_mode->value}_") !== 0 ) { throw new \RuntimeException( "The payment mode {$this->payment_mode->label} does not match the foreign_refund_id prefix" ); } return Str::replace("{$this->payment_mode->value}_", '', $this->attributes['foreign_refund_id']); } // * SETTER /** * Set the transation amount * * @param \Money\Money $value * @return void */ public function setAmountAttribute(Money $value): void { $this->attributes['amount'] = \intval($value->getAmount()); $this->attributes['currency'] = \strval($value->getCurrency()); } /** * Set the transation currency * * @return void * @throws \Error In any case, this is not allowed. */ public function setCurrencyAttribute(): void { throw new \Error('Settings manually currency is not allowed'); } /** * Set the transation foreign_id * * @return void * @throws \Error In any case, this is not allowed. */ public function setForeignIdAttribute(): void { throw new \Error('Settings manually foreign_id is not allowed'); } /** * Set the transation foreign_refund_id * * @return void * @throws \Error In any case, this is not allowed. */ public function setForeignRefundIdAttribute(): void { throw new \Error('Settings manually foreign_refund_id is not allowed'); } // * RELATIONSHIPS /** * BelongsTo order relation * * @return \Illuminate\Database\Eloquent\Relations\BelongsTo */ public function order() { return $this->belongsTo(Order::class); } }