bavix/laravel-wallet

View on GitHub
src/Traits/HasWallet.php

Summary

Maintainability
A
1 hr
Test Coverage
A
100%
<?php

declare(strict_types=1);

namespace Bavix\Wallet\Traits;

use function app;
use Bavix\Wallet\Exceptions\AmountInvalid;
use Bavix\Wallet\Exceptions\BalanceIsEmpty;
use Bavix\Wallet\Exceptions\InsufficientFunds;
use Bavix\Wallet\External\Contracts\ExtraDtoInterface;
use Bavix\Wallet\Interfaces\Wallet;
use Bavix\Wallet\Internal\Exceptions\ExceptionInterface;
use Bavix\Wallet\Internal\Exceptions\TransactionFailedException;
use Bavix\Wallet\Internal\Service\MathServiceInterface;
use Bavix\Wallet\Models\Transaction;
use Bavix\Wallet\Models\Transfer;
use Bavix\Wallet\Models\Wallet as WalletModel;
use Bavix\Wallet\Services\AtomicServiceInterface;
use Bavix\Wallet\Services\CastServiceInterface;
use Bavix\Wallet\Services\ConsistencyServiceInterface;
use Bavix\Wallet\Services\PrepareServiceInterface;
use Bavix\Wallet\Services\RegulatorServiceInterface;
use Bavix\Wallet\Services\TransactionServiceInterface;
use Bavix\Wallet\Services\TransferServiceInterface;
use function config;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\MorphMany;
use Illuminate\Database\RecordsNotFoundException;

/**
 * Trait HasWallet.
 *
 * @property WalletModel $wallet
 * @property string $balance
 * @property int $balanceInt
 *
 * @psalm-require-extends \Illuminate\Database\Eloquent\Model
 *
 * @psalm-require-implements \Bavix\Wallet\Interfaces\Wallet
 */
trait HasWallet
{
    use MorphOneWallet;

    /**
     * Deposit funds into the wallet.
     *
     * This method executes the deposit transaction within an atomic block to ensure data consistency.
     *
     * @param int|string $amount The amount to deposit.
     * @param array<mixed>|null $meta Additional metadata for the transaction. This can be used to store
     * information about the type of deposit, the source of the funds, or any other relevant details.
     * @param bool $confirmed Whether the transaction is confirmed. This can be used to indicate whether the
     * transaction has been verified and is considered final. Defaults to true.
     * @return Transaction The transaction object representing the deposit.
     *
     * @throws AmountInvalid If the amount is invalid (e.g. negative, not a number, too large).
     * @throws RecordsNotFoundException If the wallet is not found.
     * @throws TransactionFailedException If the transaction fails for any reason (e.g. network issues, insufficient funds).
     * @throws ExceptionInterface If an exception occurs during the transaction process.
     */
    public function deposit(int|string $amount, ?array $meta = null, bool $confirmed = true): Transaction
    {
        // Execute the deposit transaction within an atomic block to ensure data consistency.
        return app(AtomicServiceInterface::class)->block(
            $this,
            // Create a new deposit transaction.
            fn () => app(TransactionServiceInterface::class)
                ->makeOne($this, Transaction::TYPE_DEPOSIT, $amount, $meta, $confirmed)
        );
    }

    /**
     * Magic Laravel framework method that makes it possible to call property balance.
     *
     * This method is called by Laravel's magic getter when the `balance` property is accessed.
     * It returns the current balance of the wallet as a string.
     *
     * @return non-empty-string The current balance of the wallet as a string.
     *
     * @throws \Bavix\Wallet\Internal\Exceptions\ModelNotFoundException If the wallet does not exist and `$save` is set to `false`.
     *
     * @see Wallet
     * @see WalletModel
     */
    public function getBalanceAttribute(): string
    {
        // Get the wallet object from the model.
        // This method uses the CastServiceInterface to retrieve the wallet object from the model.
        // The second argument, `$save = false`, prevents the service from saving the wallet if it does not exist.
        // This is useful to avoid unnecessary database queries when retrieving the balance.
        $wallet = app(CastServiceInterface::class)->getWallet($this, false);

        // Get the current balance of the wallet using the Regulator service.
        // This method uses the RegulatorServiceInterface to retrieve the current balance of the wallet.
        // The Regulator service is responsible for calculating the balance of the wallet based on the transactions.
        // The balance is always returned as a string to preserve the accuracy of the decimal value.
        $balance = app(RegulatorServiceInterface::class)->amount($wallet);

        // Return the balance as a string.
        return $balance;
    }

    /**
     * Returns the balance of the wallet as an integer.
     *
     * This method is called by Laravel's magic getter when the `balanceInt` property is accessed.
     * It retrieves the current balance of the wallet as a string using the `getBalanceAttribute` method.
     * The decimal value is preserved by converting the string to an integer.
     *
     * @return int The current balance of the wallet as an integer.
     *
     * @throws \Bavix\Wallet\Internal\Exceptions\ModelNotFoundException If the wallet does not exist and `$save` is set to `false`.
     *
     * @see Wallet
     * @see WalletModel
     * @see HasWallet::getBalanceAttribute
     */
    public function getBalanceIntAttribute(): int
    {
        // Get the current balance of the wallet as a string.
        // This is done using the `getBalanceAttribute` method.
        $balanceString = $this->getBalanceAttribute();

        // Convert the balance string to an integer.
        // This ensures that the decimal value is preserved while converting it to an integer.
        $balanceInt = (int) $balanceString;

        // Return the balance as an integer.
        return $balanceInt;
    }

    /**
     * Returns all transactions related to the wallet.
     *
     * This method retrieves all transactions associated with the wallet.
     * It uses the `getWallet` method of the `CastServiceInterface` to retrieve the wallet instance.
     * The `false` parameter indicates that the wallet should not be saved if it does not exist.
     * The method then uses the `hasMany` method on the wallet instance to retrieve all transactions related to the wallet.
     * The transaction model class is retrieved from the configuration using `config('wallet.transaction.model', Transaction::class)`.
     * The relationship is defined using the `wallet_id` foreign key.
     *
     * @return HasMany<Transaction> Returns a `HasMany` relationship of transactions related to the wallet.
     */
    public function walletTransactions(): HasMany
    {
        // Retrieve the wallet instance using the `getWallet` method of the `CastServiceInterface`.
        // The `false` parameter indicates that the wallet should not be saved if it does not exist.
        $wallet = app(CastServiceInterface::class)->getWallet($this, false);

        // Retrieve all transactions related to the wallet using the `hasMany` method on the wallet instance.
        // The transaction model class is retrieved from the configuration using `config('wallet.transaction.model', Transaction::class)`.
        // The relationship is defined using the `wallet_id` foreign key.
        $transactions = $wallet->hasMany(config('wallet.transaction.model', Transaction::class), 'wallet_id');

        return $transactions;
    }

    /**
     * Retrieves all user actions related to the wallet.
     *
     * This method returns a `MorphMany` relationship object that represents all transactions and transfers
     * associated with the wallet. It fetches the wallet instance using the `getWallet` method of the
     * `CastServiceInterface` and defines the relationship using the `morphMany` method on the wallet instance.
     * The transaction model class is retrieved from the configuration using `config('wallet.transaction.model', Transaction::class)`.
     * The relationship is defined using the `payable` foreign key.
     *
     * @return MorphMany<Transaction> The `MorphMany` relationship object representing all user actions on the wallet.
     */
    public function transactions(): MorphMany
    {
        // Fetch the wallet instance using the `getWallet` method of the `CastServiceInterface`.
        // The `getWallet` method is responsible for retrieving the wallet instance associated with the current model.
        $wallet = app(CastServiceInterface::class)->getHolder($this);

        // Define the relationship between the wallet and the transactions using the `morphMany` method.
        // The `morphMany` method is used to define a polymorphic one-to-many relationship.
        // In this case, it represents the relationship between the wallet and the transactions.
        // The transaction model class is retrieved from the configuration using `config('wallet.transaction.model', Transaction::class)`.
        // The relationship is defined using the `payable` foreign key.
        // The `payable` foreign key is used to associate the transactions with the wallet.
        return $wallet->morphMany(
            config('wallet.transaction.model', Transaction::class),
            'payable' // The name of the polymorphic relation column.
        );
    }

    /**
     * Safely transfers funds from this wallet to another.
     *
     * This method attempts to transfer funds from this wallet to another wallet.
     * If an error occurs during the process, null is returned.
     *
     * @param Wallet $wallet The wallet to transfer funds to.
     * @param int|string $amount The amount to transfer.
     * @param ExtraDtoInterface|array<mixed>|null $meta Additional information for the transaction.
     *                                                This can be an instance of an ExtraDtoInterface
     *                                                or an array of arbitrary data.
     * @return null|Transfer The created transaction, or null if an error occurred.
     *
     * @throws AmountInvalid If the amount is invalid.
     * @throws BalanceIsEmpty If the balance is empty.
     * @throws InsufficientFunds If the amount exceeds the balance.
     * @throws RecordsNotFoundException If the wallet is not found.
     * @throws TransactionFailedException If the transaction fails.
     * @throws ExceptionInterface If an exception occurs.
     */
    public function safeTransfer(
        Wallet $wallet,
        int|string $amount,
        ExtraDtoInterface|array|null $meta = null
    ): ?Transfer {
        // Attempt to transfer the funds from this wallet to the specified wallet.
        try {
            // Use the `transfer` method to transfer the funds.
            // The `transfer` method is responsible for performing the actual transfer of funds.
            // If an error occurs during the process, an exception is thrown.
            return $this->transfer($wallet, $amount, $meta);
        } catch (ExceptionInterface $e) {
            return null;
        }
    }

    /**
     * A method that transfers funds from host to host.
     *
     * This method attempts to transfer funds from the host wallet to another wallet.
     * It uses the `AtomicServiceInterface` to ensure atomicity and consistency of the transfer.
     * The `ConsistencyServiceInterface` is used to check if the transfer is possible before attempting it.
     *
     * @param Wallet $wallet The wallet to transfer funds to.
     * @param int|string $amount The amount to transfer.
     * @param ExtraDtoInterface|array<mixed>|null $meta Additional information for the transaction.
     *                                                This can be an instance of an ExtraDtoInterface
     *                                                or an array of arbitrary data.
     * @return Transfer The created transaction.
     *
     * @throws AmountInvalid If the amount is invalid.
     * @throws BalanceIsEmpty If the balance is empty.
     * @throws InsufficientFunds If the amount exceeds the balance.
     * @throws RecordsNotFoundException If the wallet is not found.
     * @throws TransactionFailedException If the transaction fails.
     * @throws ExceptionInterface If an exception occurs.
     *
     * @see AtomicServiceInterface
     * @see ConsistencyServiceInterface
     * @see TransactionFailedException
     * @see AmountInvalid
     * @see BalanceIsEmpty
     * @see InsufficientFunds
     * @see RecordsNotFoundException
     */
    public function transfer(Wallet $wallet, int|string $amount, ExtraDtoInterface|array|null $meta = null): Transfer
    {
        // Wrap the transfer in an atomic block to ensure consistency and prevent race conditions.
        return app(AtomicServiceInterface::class)->block($this, function () use ($wallet, $amount, $meta): Transfer {
            /** @var Wallet $this */
            // Check if the transfer is possible before attempting it.
            app(ConsistencyServiceInterface::class)->checkPotential($this, $amount);

            // Perform the transfer.
            return $this->forceTransfer($wallet, $amount, $meta);
        });
    }

    /**
     * Withdraw funds from the system.
     *
     * This method wraps the withdrawal in an atomic block to ensure atomicity and consistency of the withdrawal.
     * It checks if the withdrawal is possible before attempting it.
     *
     * @param int|string $amount The amount to withdraw.
     * @param array<mixed>|null $meta Additional information for the transaction.
     * @param bool $confirmed Whether the withdrawal is confirmed.
     * @return Transaction The created transaction.
     *
     * @throws AmountInvalid If the amount is invalid.
     * @throws BalanceIsEmpty If the balance is empty.
     * @throws InsufficientFunds If the amount exceeds the balance.
     * @throws RecordsNotFoundException If the wallet is not found.
     * @throws TransactionFailedException If the transaction fails.
     * @throws ExceptionInterface If an exception occurs.
     *
     * @see AtomicServiceInterface
     * @see ConsistencyServiceInterface
     * @see TransactionFailedException
     * @see AmountInvalid
     * @see BalanceIsEmpty
     * @see InsufficientFunds
     * @see RecordsNotFoundException
     */
    public function withdraw(int|string $amount, ?array $meta = null, bool $confirmed = true): Transaction
    {
        // Wrap the withdrawal in an atomic block to ensure consistency and prevent race conditions.
        return app(AtomicServiceInterface::class)->block($this, function () use (
            $amount,
            $meta,
            $confirmed
        ): Transaction {
            /** @var Wallet $this */
            // Check if the withdrawal is possible before attempting it.
            app(ConsistencyServiceInterface::class)->checkPotential($this, $amount);

            // Perform the withdrawal.
            return $this->forceWithdraw($amount, $meta, $confirmed);
        });
    }

    /**
     * Checks if the user can withdraw funds based on the provided amount.
     *
     * This method retrieves the math service instance and calculates the total balance of the wallet.
     * It then checks if the withdrawal is possible using the consistency service.
     *
     * @param int|string $amount The amount to be withdrawn.
     * @param bool $allowZero Flag to allow zero balance for withdrawal. Defaults to false.
     * @return bool Returns true if the withdrawal is possible; otherwise, false.
     */
    public function canWithdraw(int|string $amount, bool $allowZero = false): bool
    {
        // Get the math service instance.
        $mathService = app(MathServiceInterface::class);

        // Get the wallet and calculate the total balance.
        $wallet = app(CastServiceInterface::class)->getWallet($this);
        $balance = $mathService->add($this->getBalanceAttribute(), $wallet->getCreditAttribute());

        // Check if the withdrawal is possible.
        return app(ConsistencyServiceInterface::class)
            ->canWithdraw($balance, $amount, $allowZero);
    }

    /**
     * Forced to withdraw funds from the system.
     *
     * This method creates a new withdrawal transaction and returns it. It wraps the transaction creation
     * in an atomic block to ensure atomicity and consistency.
     *
     * @param int|string $amount The amount to withdraw.
     * @param array<mixed>|null $meta Additional information for the transaction.
     * @param bool $confirmed Whether the transaction is confirmed. Defaults to true.
     * @return Transaction The created transaction.
     *
     * @throws AmountInvalid If the amount is invalid.
     * @throws RecordsNotFoundException If the wallet is not found.
     * @throws TransactionFailedException If the transaction fails.
     * @throws ExceptionInterface If an exception occurs.
     */
    public function forceWithdraw(int|string $amount, ?array $meta = null, bool $confirmed = true): Transaction
    {
        // Wrap the transaction creation in an atomic block to ensure atomicity and consistency.
        // The atomic block ensures that the creation of the transaction is atomic,
        // meaning that either the entire transaction is created or none of it is.
        return app(AtomicServiceInterface::class)->block(
        // The wallet instance
            $this,
            function () use ($amount, $meta, $confirmed): Transaction {
                // Create a new withdrawal transaction.
                return app(TransactionServiceInterface::class)->makeOne(
                // The wallet instance
                    $this,
                    // The transaction type
                    Transaction::TYPE_WITHDRAW,
                    // The amount to withdraw
                    $amount,
                    // Additional information for the transaction
                    $meta,
                    // Whether the transaction is confirmed
                    $confirmed
                );
            }
        );
    }

    /**
     * Forces a transfer of funds from this wallet to another, bypassing certain safety checks.
     *
     * This method is intended for use in scenarios where a transfer must be completed regardless of
     * the usual validation checks (e.g., sufficient funds, wallet status). It is critical to use this
     * method with caution as it can result in negative balances or other unintended consequences.
     *
     * @param Wallet $wallet The wallet to transfer funds to.
     * @param int|string $amount The amount to transfer.
     * @param ExtraDtoInterface|array<mixed>|null $meta Additional information for the transaction.
     *                                                This can be an instance of an ExtraDtoInterface
     *                                                or an array of arbitrary data.
     * @return Transfer The created transfer.
     *
     * @throws AmountInvalid If the amount is invalid.
     * @throws RecordsNotFoundException If the wallet is not found.
     * @throws TransactionFailedException If the transaction fails.
     * @throws ExceptionInterface If an exception occurs.
     */
    public function forceTransfer(
        Wallet $wallet,
        int|string $amount,
        ExtraDtoInterface|array|null $meta = null
    ): Transfer {
        // Wrap the transfer creation in an atomic block to ensure atomicity and consistency.
        // The atomic block ensures that the creation of the transfer is atomic,
        // meaning that either the entire transfer is created or none of it is.
        return app(AtomicServiceInterface::class)->block($this, function () use ($wallet, $amount, $meta): Transfer {
            // Create a new transfer transaction.
            // The transfer transaction is created using the PrepareServiceInterface.
            // The transfer status is set to Transfer::STATUS_TRANSFER.
            // The additional information for the transaction is passed as an argument.
            // The created transfer transaction is stored in the $transferLazyDto variable.
            $transferLazyDto = app(PrepareServiceInterface::class)
                ->transferLazy($this, $wallet, Transfer::STATUS_TRANSFER, $amount, $meta);

            // Apply the transfer transaction.
            // The transfer transaction is applied using the TransferServiceInterface.
            // The created transfer is returned.
            // The $transferLazyDto is passed as an array to the apply method
            // to create the transfer transaction.
            // The applied transfer transaction is stored in the $transfers variable.
            // The current transfer transaction is returned.
            $transfers = app(TransferServiceInterface::class)->apply([$transferLazyDto]);

            return current($transfers);
        });
    }

    /**
     * Retrieves all transfers related to the wallet.
     *
     * This method retrieves all transfers associated with the wallet.
     * It uses the `getWallet` method of the `CastServiceInterface` to retrieve the wallet instance.
     * The `false` parameter indicates that the wallet should not be saved if it does not exist.
     * The method then uses the `hasMany` method on the wallet instance to retrieve all transfers related to the wallet.
     * The transfer model class is retrieved from the configuration using `config('wallet.transfer.model', Transfer::class)`.
     * The relationship is defined using the `from_id` foreign key.
     *
     * @return HasMany<Transfer> The `HasMany` relationship object representing all transfers related to the wallet.
     */
    public function transfers(): HasMany
    {
        // Retrieve the wallet instance associated with the current model.
        // The `getWallet` method of the `CastServiceInterface` is used to retrieve the wallet instance.
        // The `false` parameter indicates that the wallet should not be saved if it does not exist.
        $wallet = app(CastServiceInterface::class)
            ->getWallet($this, false);

        // Retrieve all transfers associated with the wallet.
        // The `hasMany` method is used on the wallet instance to retrieve all transfers related to the wallet.
        // The transfer model class is retrieved from the configuration using `config('wallet.transfer.model', Transfer::class)`.
        // The relationship is defined using the `from_id` foreign key.
        return $wallet
            ->hasMany(
            // Retrieve the transfer model class from the configuration.
            // The default value is `Transfer::class`.
                config('wallet.transfer.model', Transfer::class),
                // Define the foreign key for the relationship.
                // The foreign key is `from_id`.
                'from_id'
            );
    }

    /**
     * Retrieves all the receiving transfers to this wallet.
     *
     * This method retrieves all receiving transfers associated with the wallet.
     * It uses the `getWallet` method of the `CastServiceInterface` to retrieve the wallet instance.
     * The `false` parameter indicates that the wallet should not be saved if it does not exist.
     * The method then uses the `hasMany` method on the wallet instance to retrieve all receiving transfers related to the wallet.
     * The transfer model class is retrieved from the configuration using `config('wallet.transfer.model', Transfer::class)`.
     * The relationship is defined using the `to_id` foreign key.
     *
     * @return HasMany<Transfer> The `HasMany` relationship object representing all receiving transfers related to the wallet.
     */
    public function receivedTransfers(): HasMany
    {
        // Retrieve the wallet instance associated with the current model.
        // The `getWallet` method of the `CastServiceInterface` is used to retrieve the wallet instance.
        // The `false` parameter indicates that the wallet should not be saved if it does not exist.
        $wallet = app(CastServiceInterface::class)
            ->getWallet($this, false);

        // Retrieve all receiving transfers associated with the wallet.
        // The `hasMany` method is used on the wallet instance to retrieve all receiving transfers related to the wallet.
        // The transfer model class is retrieved from the configuration using `config('wallet.transfer.model', Transfer::class)`.
        // The relationship is defined using the `to_id` foreign key.
        return $wallet
            ->hasMany(
            // Retrieve the transfer model class from the configuration.
            // The default value is `Transfer::class`.
                config('wallet.transfer.model', Transfer::class),
                // Define the foreign key for the relationship.
                // The foreign key is `to_id`.
                'to_id'
            );
    }
}