ldc-boutique-cnc/app/Models/Payment/Transaction.php
Jérémy Munsch 92b9929849
Tons of fixes
2023-05-25 20:04:46 +02:00

487 lines
16 KiB
PHP

<?php
namespace App\Models\Payment;
use App\Enums\Order\DeliveryStatus;
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\Lib\Helpers\ToolboxHelper;
use App\Lib\Payments\PaymentAdapter;
use App\Models\Shop\Order;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Session;
use Illuminate\Support\Str;
use Kwaadpepper\Enum\Traits\CastsEnums;
use Mccarlosen\LaravelMpdf\LaravelMpdf;
use Money\Currency;
use Money\Money;
/**
* Transaction
*
* @property-read string $reference The transaction unique reference string.
* @property-read \Money\Currency $currency The transaction currency.
* @property \Money\Money $amount The transaction amount.
* @property-read string $foreign_id The transaction id of the selected payment system.
* @property-read string $foreign_refund_id The refund transaction id of the selected
* payment system, (for webhook usage)
* @property object $items The serialized basket items
* concerned by the transaction.
* @property boolean $needs_validation The transaction has to be validated
* before performing any action.
*
* @property \App\Enums\Order\PaymentModeEnum $payment_mode The Payment mode for this transaction.
* @property \App\Enums\Order\TransactionStatus $status The transaction status.
* @property \App\Enums\Order\TransactionType $type The transaction type (debit/credit) for customer.
*
* @package App\Models
*
*/
class Transaction extends Model
{
use CastsEnums;
use HasFactory;
/**
* The attributes that should be cast to Enums.
*
* @var array
*/
protected $enumCasts = [
'payment_mode' => 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::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;
}
$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();
}
/**
* 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);
}
}