From 7f3c4ca5101504836ec195e805405c1d115ddc4d Mon Sep 17 00:00:00 2001 From: Hamed Panjeh <16323354+HPWebdeveloper@users.noreply.github.com> Date: Tue, 28 Nov 2023 14:51:04 -0300 Subject: [PATCH] Initial commit --- .editorconfig | 15 ++ .gitattributes | 20 +++ .github/ISSUE_TEMPLATE/bug.yml | 66 +++++++ .github/ISSUE_TEMPLATE/config.yml | 11 ++ .github/dependabot.yml | 12 ++ .github/workflows/dependabot-auto-merge.yml | 32 ++++ .../workflows/fix-php-code-style-issues.yml | 27 +++ .github/workflows/phpstan.yml | 26 +++ .github/workflows/run-tests.yml | 51 ++++++ .github/workflows/update-changelog.yml | 31 ++++ .gitignore | 11 ++ CHANGELOG.md | 3 + Enums/WalletEnums.php | 23 +++ LICENSE.md | 21 +++ README.md | 167 ++++++++++++++++++ composer.json | 88 +++++++++ config/pay-pocket.php | 6 + database/factories/ModelFactory.php | 19 ++ .../create_wallets_logs_table.php.stub | 34 ++++ .../migrations/create_wallets_table.php.stub | 29 +++ phpstan-baseline.neon | 0 phpstan.neon.dist | 14 ++ phpunit.xml.dist | 38 ++++ resources/views/.gitkeep | 0 src/Commands/LaravelPayPocketCommand.php | 19 ++ .../InsufficientBalanceException.php | 19 ++ src/Exceptions/InvalidDepositException.php | 19 ++ src/Exceptions/InvalidValueException.php | 19 ++ src/Exceptions/InvalidWalletTypeException.php | 13 ++ src/Exceptions/WalletNotFoundException.php | 13 ++ src/Facades/LaravelPayPocket.php | 16 ++ src/Interfaces/WalletOperations.php | 16 ++ src/LaravelPayPocketServiceProvider.php | 30 ++++ src/Models/Wallet.php | 34 ++++ src/Models/WalletsLog.php | 33 ++++ src/Services/PocketServices.php | 26 +++ src/Traits/BalanceOperation.php | 55 ++++++ src/Traits/GetWallets.php | 18 ++ src/Traits/HandlesDeposit.php | 66 +++++++ src/Traits/HandlesPayment.php | 49 +++++ src/Traits/HasWallet.php | 72 ++++++++ src/Traits/Loggable.php | 13 ++ src/Traits/ManagesWallet.php | 8 + tests/ArchTest.php | 5 + tests/ExceptionsWithoutFacadeTest.php | 66 +++++++ tests/Models/User.php | 37 ++++ tests/OperationsWithFacadeTest.php | 99 +++++++++++ tests/OperationsWithoutFacadeTest.php | 100 +++++++++++ tests/Pest.php | 5 + tests/TestCase.php | 58 ++++++ tests/database/factories/UserFactory.php | 23 +++ .../migrations/create_users_tables.php | 26 +++ .../Providers/WorkbenchServiceProvider.php | 25 +++ 53 files changed, 1726 insertions(+) create mode 100644 .editorconfig create mode 100644 .gitattributes create mode 100644 .github/ISSUE_TEMPLATE/bug.yml create mode 100644 .github/ISSUE_TEMPLATE/config.yml create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/dependabot-auto-merge.yml create mode 100644 .github/workflows/fix-php-code-style-issues.yml create mode 100644 .github/workflows/phpstan.yml create mode 100644 .github/workflows/run-tests.yml create mode 100644 .github/workflows/update-changelog.yml create mode 100644 .gitignore create mode 100644 CHANGELOG.md create mode 100644 Enums/WalletEnums.php create mode 100644 LICENSE.md create mode 100644 README.md create mode 100644 composer.json create mode 100644 config/pay-pocket.php create mode 100644 database/factories/ModelFactory.php create mode 100644 database/migrations/create_wallets_logs_table.php.stub create mode 100644 database/migrations/create_wallets_table.php.stub create mode 100644 phpstan-baseline.neon create mode 100644 phpstan.neon.dist create mode 100644 phpunit.xml.dist create mode 100644 resources/views/.gitkeep create mode 100644 src/Commands/LaravelPayPocketCommand.php create mode 100644 src/Exceptions/InsufficientBalanceException.php create mode 100644 src/Exceptions/InvalidDepositException.php create mode 100644 src/Exceptions/InvalidValueException.php create mode 100644 src/Exceptions/InvalidWalletTypeException.php create mode 100644 src/Exceptions/WalletNotFoundException.php create mode 100644 src/Facades/LaravelPayPocket.php create mode 100644 src/Interfaces/WalletOperations.php create mode 100644 src/LaravelPayPocketServiceProvider.php create mode 100644 src/Models/Wallet.php create mode 100644 src/Models/WalletsLog.php create mode 100644 src/Services/PocketServices.php create mode 100644 src/Traits/BalanceOperation.php create mode 100644 src/Traits/GetWallets.php create mode 100644 src/Traits/HandlesDeposit.php create mode 100644 src/Traits/HandlesPayment.php create mode 100644 src/Traits/HasWallet.php create mode 100644 src/Traits/Loggable.php create mode 100644 src/Traits/ManagesWallet.php create mode 100644 tests/ArchTest.php create mode 100644 tests/ExceptionsWithoutFacadeTest.php create mode 100644 tests/Models/User.php create mode 100644 tests/OperationsWithFacadeTest.php create mode 100644 tests/OperationsWithoutFacadeTest.php create mode 100644 tests/Pest.php create mode 100644 tests/TestCase.php create mode 100644 tests/database/factories/UserFactory.php create mode 100644 tests/database/migrations/create_users_tables.php create mode 100644 workbench/app/Providers/WorkbenchServiceProvider.php diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..dd9a2b5 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,15 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_size = 4 +indent_style = space +insert_final_newline = true +trim_trailing_whitespace = true + +[*.md] +trim_trailing_whitespace = false + +[*.{yml,yaml}] +indent_size = 2 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..c09f81e --- /dev/null +++ b/.gitattributes @@ -0,0 +1,20 @@ +# Path-based git attributes +# https://www.kernel.org/pub/software/scm/git/docs/gitattributes.html + +# Ignore all test and documentation with "export-ignore". +/.github export-ignore +/.gitattributes export-ignore +/.gitignore export-ignore +/phpunit.xml.dist export-ignore +/art export-ignore +/docs export-ignore +/tests export-ignore +/workbench export-ignore +/.editorconfig export-ignore +/.php_cs.dist.php export-ignore +/psalm.xml export-ignore +/psalm.xml.dist export-ignore +/testbench.yaml export-ignore +/UPGRADING.md export-ignore +/phpstan.neon.dist export-ignore +/phpstan-baseline.neon export-ignore diff --git a/.github/ISSUE_TEMPLATE/bug.yml b/.github/ISSUE_TEMPLATE/bug.yml new file mode 100644 index 0000000..fe4cfe6 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug.yml @@ -0,0 +1,66 @@ +name: Bug Report +description: Report an Issue or Bug with the Package +title: "[Bug]: " +labels: ["bug"] +body: + - type: markdown + attributes: + value: | + We're sorry to hear you have a problem. Can you help us solve it by providing the following details. + - type: textarea + id: what-happened + attributes: + label: What happened? + description: What did you expect to happen? + placeholder: I cannot currently do X thing because when I do, it breaks X thing. + validations: + required: true + - type: textarea + id: how-to-reproduce + attributes: + label: How to reproduce the bug + description: How did this occur, please add any config values used and provide a set of reliable steps if possible. + placeholder: When I do X I see Y. + validations: + required: true + - type: input + id: package-version + attributes: + label: Package Version + description: What version of our Package are you running? Please be as specific as possible + placeholder: 2.0.0 + validations: + required: true + - type: input + id: php-version + attributes: + label: PHP Version + description: What version of PHP are you running? Please be as specific as possible + placeholder: 8.2.0 + validations: + required: true + - type: input + id: laravel-version + attributes: + label: Laravel Version + description: What version of Laravel are you running? Please be as specific as possible + placeholder: 9.0.0 + validations: + required: true + - type: dropdown + id: operating-systems + attributes: + label: Which operating systems does with happen with? + description: You may select more than one. + multiple: true + options: + - macOS + - Windows + - Linux + - type: textarea + id: notes + attributes: + label: Notes + description: Use this field to provide any other notes that you feel might be relevant to the issue. + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..ab63307 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,11 @@ +blank_issues_enabled: false +contact_links: + - name: Ask a question + url: https://github.com/Hamed Panjeh/laravel-pay-pocket/discussions/new?category=q-a + about: Ask the community for help + - name: Request a feature + url: https://github.com/Hamed Panjeh/laravel-pay-pocket/discussions/new?category=ideas + about: Share ideas for new features + - name: Report a security issue + url: https://github.com/Hamed Panjeh/laravel-pay-pocket/security/policy + about: Learn how to notify us for sensitive bugs diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..30c8a49 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,12 @@ +# Please see the documentation for all configuration options: +# https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates + +version: 2 +updates: + + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + labels: + - "dependencies" \ No newline at end of file diff --git a/.github/workflows/dependabot-auto-merge.yml b/.github/workflows/dependabot-auto-merge.yml new file mode 100644 index 0000000..ca2197d --- /dev/null +++ b/.github/workflows/dependabot-auto-merge.yml @@ -0,0 +1,32 @@ +name: dependabot-auto-merge +on: pull_request_target + +permissions: + pull-requests: write + contents: write + +jobs: + dependabot: + runs-on: ubuntu-latest + if: ${{ github.actor == 'dependabot[bot]' }} + steps: + + - name: Dependabot metadata + id: metadata + uses: dependabot/fetch-metadata@v1.6.0 + with: + github-token: "${{ secrets.GITHUB_TOKEN }}" + + - name: Auto-merge Dependabot PRs for semver-minor updates + if: ${{steps.metadata.outputs.update-type == 'version-update:semver-minor'}} + run: gh pr merge --auto --merge "$PR_URL" + env: + PR_URL: ${{github.event.pull_request.html_url}} + GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} + + - name: Auto-merge Dependabot PRs for semver-patch updates + if: ${{steps.metadata.outputs.update-type == 'version-update:semver-patch'}} + run: gh pr merge --auto --merge "$PR_URL" + env: + PR_URL: ${{github.event.pull_request.html_url}} + GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} diff --git a/.github/workflows/fix-php-code-style-issues.yml b/.github/workflows/fix-php-code-style-issues.yml new file mode 100644 index 0000000..28816d3 --- /dev/null +++ b/.github/workflows/fix-php-code-style-issues.yml @@ -0,0 +1,27 @@ +name: Fix PHP code style issues + +on: + push: + paths: + - '**.php' + +permissions: + contents: write + +jobs: + php-code-styling: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + ref: ${{ github.head_ref }} + + - name: Fix PHP code style issues + uses: aglipanci/laravel-pint-action@2.3.0 + + - name: Commit changes + uses: stefanzweifel/git-auto-commit-action@v5 + with: + commit_message: Fix styling diff --git a/.github/workflows/phpstan.yml b/.github/workflows/phpstan.yml new file mode 100644 index 0000000..ccfa9d9 --- /dev/null +++ b/.github/workflows/phpstan.yml @@ -0,0 +1,26 @@ +name: PHPStan + +on: + push: + paths: + - '**.php' + - 'phpstan.neon.dist' + +jobs: + phpstan: + name: phpstan + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.1' + coverage: none + + - name: Install composer dependencies + uses: ramsey/composer-install@v2 + + - name: Run PHPStan + run: ./vendor/bin/phpstan --error-format=github diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml new file mode 100644 index 0000000..7fefe35 --- /dev/null +++ b/.github/workflows/run-tests.yml @@ -0,0 +1,51 @@ +name: run-tests + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + test: + runs-on: ${{ matrix.os }} + strategy: + fail-fast: true + matrix: + os: [ubuntu-latest, windows-latest] + php: [8.2, 8.1] + laravel: [10.*] + stability: [prefer-lowest, prefer-stable] + include: + - laravel: 10.* + testbench: 8.* + carbon: ^2.63 + + name: P${{ matrix.php }} - L${{ matrix.laravel }} - ${{ matrix.stability }} - ${{ matrix.os }} + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite, bcmath, soap, intl, gd, exif, iconv, imagick, fileinfo + coverage: none + + - name: Setup problem matchers + run: | + echo "::add-matcher::${{ runner.tool_cache }}/php.json" + echo "::add-matcher::${{ runner.tool_cache }}/phpunit.json" + + - name: Install dependencies + run: | + composer require "laravel/framework:${{ matrix.laravel }}" "orchestra/testbench:${{ matrix.testbench }}" "nesbot/carbon:${{ matrix.carbon }}" --no-interaction --no-update + composer update --${{ matrix.stability }} --prefer-dist --no-interaction + + - name: List Installed Dependencies + run: composer show -D + + - name: Execute tests + run: vendor/bin/pest --ci diff --git a/.github/workflows/update-changelog.yml b/.github/workflows/update-changelog.yml new file mode 100644 index 0000000..ec40921 --- /dev/null +++ b/.github/workflows/update-changelog.yml @@ -0,0 +1,31 @@ +name: "Update Changelog" + +on: + release: + types: [released] + +permissions: + contents: write + +jobs: + update: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + ref: main + + - name: Update Changelog + uses: stefanzweifel/changelog-updater-action@v1 + with: + latest-version: ${{ github.event.release.name }} + release-notes: ${{ github.event.release.body }} + + - name: Commit updated CHANGELOG + uses: stefanzweifel/git-auto-commit-action@v5 + with: + branch: main + commit_message: Update CHANGELOG + file_pattern: CHANGELOG.md diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a7f372d --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +.idea +.phpunit.cache +build +composer.lock +coverage +docs +phpunit.xml +phpstan.neon +testbench.yaml +vendor +node_modules diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..c9dbbda --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,3 @@ +# Changelog + +All notable changes to `laravel-pay-pocket` will be documented in this file. diff --git a/Enums/WalletEnums.php b/Enums/WalletEnums.php new file mode 100644 index 0000000..d199b82 --- /dev/null +++ b/Enums/WalletEnums.php @@ -0,0 +1,23 @@ +value === $type) { + return true; + } + } + + return false; + } +} diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..e4de0c8 --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) Hamed Panjeh + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..55bebfd --- /dev/null +++ b/README.md @@ -0,0 +1,167 @@ +# Laravel Pay Pocket + +[![Latest Version on Packagist](https://img.shields.io/packagist/v/hpwebdeveloper/laravel-pay-pocket.svg?style=flat-square)](https://packagist.org/packages/hpwebdeveloper/laravel-pay-pocket) +[![GitHub Tests Action Status](https://img.shields.io/github/actions/workflow/status/hpwebdeveloper/laravel-pay-pocket/run-tests.yml?branch=main&label=tests&style=flat-square)](https://github.com/hpwebdeveloper/laravel-pay-pocket/actions?query=workflow%3Arun-tests+branch%3Amain) +[![GitHub Code Style Action Status](https://img.shields.io/github/actions/workflow/status/hpwebdeveloper/laravel-pay-pocket/fix-php-code-style-issues.yml?branch=main&label=code%20style&style=flat-square)](https://github.com/hpwebdeveloper/laravel-pay-pocket/actions?query=workflow%3A"Fix+PHP+code+style+issues"+branch%3Amain) +[![Total Downloads](https://img.shields.io/packagist/dt/hpwebdeveloper/laravel-pay-pocket.svg?style=flat-square)](https://packagist.org/packages/hpwebdeveloper/laravel-pay-pocket) + +**Laravel Pay Pocket** is a simple package designed for Laravel applications, offering the flexibility to manage multiple wallet types within two dedicated database tables, `wallets` and `wallets_logs`. + +* **Author**: Hamed Panjeh +* **Vendor**: hpwebdeveloper +* **Package**: laravel-pay-pocket +* **Version**: +* **PHP Version**: 8.1+ +* **Laravel Version**: `10.x` +* **[Composer](https://getcomposer.org/):** `composer require hpwebdeveloper/laravel-pay-pocket` + + +### Support Policy + +| Version | Laravel | PHP | Release date | End of improvements | End of support | +|------------|----------------|-------------|--------------|---------------------|----------------| +| 1.x | ^10.0 | 8.1 | Nov 30, 2023 | Mar 1, 2024 | Sep 6, 2024 | | + + + +## Installation + + + +- **Step 1:** You can install the package via composer: + +```bash +composer require hpwebdeveloper/laravel-pay-pocket +``` + +- **Step 2:** Publish and run the migrations with: + +```bash +php artisan vendor:publish --tag="pay-pocket-migrations" +php artisan migrate +``` + + +- **Step 3:** Publish the wallet types using + +```bash +php artisan vendor:publish --tag="pay-pocket-wallets" +``` + +This command will automatically publish the `WalletEnums.php` file into your application's `app/Enums` directory. + +## Usage + +### Prepare User Model + +To use this package you need to implements the `WalletOperations` into `User` model and utlize the `ManagesWallet` trait. + +```php + +use HPWebdeveloper\LaravelPayPocket\Interfaces\WalletOperations; +use HPWebdeveloper\LaravelPayPocket\Traits\ManagesWallet; + +class User extends Authenticatable implements WalletOperations +{ + use ManagesWallet; +} +``` + +### Prepare Wallets + +In Laravel Pay Pocket, you have the flexibility to define the order in which wallets are prioritized for payments through the use of Enums. The order of wallets in the Enum file determines their priority level. The first wallet listed has the highest priority and will be used first for deducting order values. + +For example, consider the following wallet types defined in an Enum: +```php +namespace App\Enums; + +enum WalletEnums: string +{ + case WALLET1 = 'wallet_1'; + case WALLET2 = 'wallet_2'; +} + +``` +You have complete freedom to name your wallets as per your requirements and even add more wallet types to the Enum list. + + +In this particular setup, `wallet_1` (`WALLET1`) is given the highest priority. When an order payment is processed, the system will first attempt to use `wallet_1` to cover the cost. If `wallet_1` does not have sufficient funds, `wallet_2` (`WALLET2`) will be used next. + +If the balance in `wallet_1` is 10 and the balance in `wallet_2` is 20, and you need to pay an order value of 15, the payment process will first utilize the entire balance of `wallet_1`. Since `wallet_1`'s balance is insufficient to cover the full amount, the remaining 5 will be deducted from `wallet_2`. After the payment, `wallet_2` will have a remaining balance of 15." + + +### Deposit + +```php +$user = auth()->user(); + +$user->deposit('wallet_1', 100.22); // Deposit funds into 'wallet_1' + +$user->deposit('wallet_2', 100); // Deposit funds into 'wallet_2' + +// Or using provided facade + +use HPWebdeveloper\LaravelPayPocket\Facades\LaravelPayPocket; + +LaravelPayPocket::deposit($user, 'wallet_1', 100.22); + +``` + +### Pay +```php +$user->pay(200); // Pay the value using the total combined balance available across all wallets + +// Or using provided facade + +use HPWebdeveloper\LaravelPayPocket\Facades\LaravelPayPocket; + +LaravelPayPocket::pay($user, 100.22); +``` + +### Balance + +- **Wallets** +```php +$user->walletBalance // Total combined balance available across all wallets + +// Or using provided facade + +LaravelPayPocket::checkBalance($user); +``` + +- **Particular Wallet** +```php +$user->getWalletBalanceByType('wallet_1') // Balance available in wallet_1 +$user->getWalletBalanceByType('wallet_2') // Balance available in wallet_2 + +// Or using provided facade + +LaravelPayPocket::walletBalanceByType('wallet_1'); +``` + +## Testing + +```bash +./vender/bin/pest +``` + +## Changelog + +Please see [CHANGELOG](CHANGELOG.md) for more information on what has changed recently. + +## Contributing + +Please see [CONTRIBUTING](CONTRIBUTING.md) for details. + +## Security Vulnerabilities + +Please review [our security policy](../../security/policy) on how to report security vulnerabilities. + +## Credits + +- [Hamed Panjeh](https://github.com/HPWebdeveloper) +- [All Contributors](../../contributors) + +## License + +The MIT License (MIT). Please see [License File](LICENSE.md) for more information. diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..b5f6bf8 --- /dev/null +++ b/composer.json @@ -0,0 +1,88 @@ +{ + "name": "hpwebdeveloper/laravel-pay-pocket", + "description": "Laravel Pay Pocket", + "keywords": [ + "Hamed Panjeh", + "laravel", + "laravel-pay-pocket" + ], + "homepage": "https://github.com/hpwebdeveloper/laravel-pay-pocket", + "license": "MIT", + "authors": [ + { + "name": "Hamed Panjeh", + "email": "panjeh@gmail.com", + "role": "Developer" + } + ], + "require": { + "php": "^8.1", + "spatie/laravel-package-tools": "^1.14.0", + "illuminate/contracts": "^10.0" + }, + "require-dev": { + "laravel/pint": "^1.0", + "nunomaduro/collision": "^7.8", + "nunomaduro/larastan": "^2.0.1", + "orchestra/testbench": "^8.8", + "pestphp/pest": "^2.20", + "pestphp/pest-plugin-arch": "^2.0", + "pestphp/pest-plugin-laravel": "^2.0", + "phpstan/extension-installer": "^1.1", + "phpstan/phpstan-deprecation-rules": "^1.0", + "phpstan/phpstan-phpunit": "^1.0", + "spatie/laravel-ray": "^1.26" + }, + "autoload": { + "psr-4": { + "HPWebdeveloper\\LaravelPayPocket\\": "src/", + "HPWebdeveloper\\LaravelPayPocket\\": "src/", + "HPWebdeveloper\\LaravelPayPocket\\Database\\Factories\\": "database/factories/", + "HPWebdeveloper\\LaravelPayPocket\\Tests\\Database\\Factories\\": "tests/database/factories" + } + }, + "autoload-dev": { + "psr-4": { + "HPWebdeveloper\\LaravelPayPocket\\Tests\\": "tests/", + "App\\Enums\\": "Enums/", + "Workbench\\App\\": "workbench/app/" + } + }, + "scripts": { + "post-autoload-dump": "@composer run prepare", + "clear": "@php vendor/bin/testbench package:purge-laravel-pay-pocket --ansi", + "prepare": "@php vendor/bin/testbench package:discover --ansi", + "build": [ + "@composer run prepare", + "@php vendor/bin/testbench workbench:build --ansi" + ], + "start": [ + "Composer\\Config::disableProcessTimeout", + "@composer run build", + "@php vendor/bin/testbench serve" + ], + "analyse": "vendor/bin/phpstan analyse", + "test": "vendor/bin/pest", + "test-coverage": "vendor/bin/pest --coverage", + "format": "vendor/bin/pint" + }, + "config": { + "sort-packages": true, + "allow-plugins": { + "pestphp/pest-plugin": true, + "phpstan/extension-installer": true + } + }, + "extra": { + "laravel": { + "providers": [ + "HPWebdeveloper\\LaravelPayPocket\\LaravelPayPocketServiceProvider" + ], + "aliases": { + "LaravelPayPocket": "HPWebdeveloper\\LaravelPayPocket\\Facades\\LaravelPayPocket" + } + } + }, + "minimum-stability": "dev", + "prefer-stable": true +} diff --git a/config/pay-pocket.php b/config/pay-pocket.php new file mode 100644 index 0000000..4cb9728 --- /dev/null +++ b/config/pay-pocket.php @@ -0,0 +1,6 @@ +id(); + $table->morphs('loggable'); + $table->string('wallet_name'); + $table->decimal('value', 16, 2); + $table->decimal('from', 16, 2); + $table->decimal('to', 16, 2); + $table->string('type'); + $table->string('status')->default('Pending'); + $table->ipAddress('ip'); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('balance_logs'); + } +}; diff --git a/database/migrations/create_wallets_table.php.stub b/database/migrations/create_wallets_table.php.stub new file mode 100644 index 0000000..1cf7235 --- /dev/null +++ b/database/migrations/create_wallets_table.php.stub @@ -0,0 +1,29 @@ +id(); + $table->morphs('owner'); + $table->string('type', 20); // add validation + $table->decimal('balance', 16, 2)->default(0); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('wallets'); + } +}; diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon new file mode 100644 index 0000000..e69de29 diff --git a/phpstan.neon.dist b/phpstan.neon.dist new file mode 100644 index 0000000..a91953b --- /dev/null +++ b/phpstan.neon.dist @@ -0,0 +1,14 @@ +includes: + - phpstan-baseline.neon + +parameters: + level: 4 + paths: + - src + - config + - database + tmpDir: build/phpstan + checkOctaneCompatibility: true + checkModelProperties: true + checkMissingIterableValueType: false + diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 0000000..c76dae1 --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,38 @@ + + + + + tests + + + + + + + + + + + + + + + ./src + + + diff --git a/resources/views/.gitkeep b/resources/views/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/src/Commands/LaravelPayPocketCommand.php b/src/Commands/LaravelPayPocketCommand.php new file mode 100644 index 0000000..deab4c8 --- /dev/null +++ b/src/Commands/LaravelPayPocketCommand.php @@ -0,0 +1,19 @@ +comment('All done'); + + return self::SUCCESS; + } +} diff --git a/src/Exceptions/InsufficientBalanceException.php b/src/Exceptions/InsufficientBalanceException.php new file mode 100644 index 0000000..e7332f1 --- /dev/null +++ b/src/Exceptions/InsufficientBalanceException.php @@ -0,0 +1,19 @@ +name('laravel-pay-pocket') + ->hasConfigFile() + ->hasViews() + ->hasMigrations('create_wallets_logs_table', 'create_wallets_table'); + } + + public function bootingPackage() + { + $this->publishes([ + __DIR__.'/../Enums/' => app_path('Enums'), + ], 'pay-pocket-wallets'); + } +} diff --git a/src/Models/Wallet.php b/src/Models/Wallet.php new file mode 100644 index 0000000..3e07524 --- /dev/null +++ b/src/Models/Wallet.php @@ -0,0 +1,34 @@ + WalletEnums::class, + ]; + + public function owner(): MorphTo + { + return $this->morphTo(); + } +} diff --git a/src/Models/WalletsLog.php b/src/Models/WalletsLog.php new file mode 100644 index 0000000..ca751e2 --- /dev/null +++ b/src/Models/WalletsLog.php @@ -0,0 +1,33 @@ +morphTo(); + } + + public function changeStatus($status) + { + $this->status = $status; + + return $this->save(); + } +} diff --git a/src/Services/PocketServices.php b/src/Services/PocketServices.php new file mode 100644 index 0000000..1fe31cc --- /dev/null +++ b/src/Services/PocketServices.php @@ -0,0 +1,26 @@ +deposit($type, $amount); + } + + public function pay($user, $orderValue) + { + return $user->pay($orderValue); + } + + public function checkBalance($user) + { + return $user->walletBalance; + } + + public function walletBalanceByType($user, $type) + { + return $user->getWalletBalanceByType($type); + } +} diff --git a/src/Traits/BalanceOperation.php b/src/Traits/BalanceOperation.php new file mode 100644 index 0000000..ae895ef --- /dev/null +++ b/src/Traits/BalanceOperation.php @@ -0,0 +1,55 @@ +balance > 0; + } + + /** + * Decrement Balance and create a log entry. + */ + public function decrementAndCreateLog($value): void + { + $this->createLog('dec', $value); + $this->decrement('balance', $value); + } + + /** + * Increment Balance and create a log entry. + */ + public function incrementAndCreateLog($value): void + { + $this->createLog('inc', $value); + $this->increment('balance', $value); + } + + /** + * Create a new log record + */ + protected function createLog($logType, $value): void + { + $currentBalance = $this->balance ?? 0; + + $newBalance = $logType === 'dec' ? $currentBalance - $value : $currentBalance + $value; + + $this->createdLog = $this->logs()->create([ + 'wallet_name' => $this->type->value, + 'from' => $currentBalance, + 'to' => $newBalance, + 'type' => $logType, + 'ip' => \Request::ip(), + 'value' => $value, + ]); + + $this->createdLog->changeStatus('Done'); + } +} diff --git a/src/Traits/GetWallets.php b/src/Traits/GetWallets.php new file mode 100644 index 0000000..4606dd4 --- /dev/null +++ b/src/Traits/GetWallets.php @@ -0,0 +1,18 @@ +value; + }, + WalletEnums::cases() + ); + } +} diff --git a/src/Traits/HandlesDeposit.php b/src/Traits/HandlesDeposit.php new file mode 100644 index 0000000..108c1ac --- /dev/null +++ b/src/Traits/HandlesDeposit.php @@ -0,0 +1,66 @@ +getDepositableTypes(); + + if (! $this->isRequestValid($type, $depositable)) { + throw new InvalidDepositException('Invalid deposit request.'); + } + + if ($amount <= 0) { + throw new InvalidValueException(); + } + + DB::transaction(function () use ($type, $amount) { + $type = WalletEnums::tryFrom($type); + $wallet = $this->wallets()->firstOrCreate(['type' => $type]); + $wallet->incrementAndCreateLog($amount); + }); + + return true; + } + + /** + * Get depositable types from WalletEnums. + */ + private function getDepositableTypes(): array + { + $depositableTypes = []; + foreach (WalletEnums::cases() as $enumCase) { + $depositableTypes[$enumCase->value] = strtolower($enumCase->name); + } + + return $depositableTypes; + } + + /** + * Check if the given tyep is valid. + * + * @param string $type + * @return bool + */ + private function isRequestValid($type, array $depositable) + { + if (! array_key_exists($type, $depositable)) { + throw new InvalidWalletTypeException('Invalid deposit type.'); + } + + return true; + } +} diff --git a/src/Traits/HandlesPayment.php b/src/Traits/HandlesPayment.php new file mode 100644 index 0000000..b673372 --- /dev/null +++ b/src/Traits/HandlesPayment.php @@ -0,0 +1,49 @@ +hasSufficientBalance($orderValue)) { + throw new InsufficientBalanceException('Insufficient balance to cover the order.'); + } + + DB::transaction(function () use ($orderValue) { + $remainingOrderValue = $orderValue; + + foreach ($this->walletsInOrder() as $walletInOrder) { + $walletEnumType = WalletEnums::tryFrom($walletInOrder); + $wallet = $this->wallets()->where('type', $walletEnumType)->first(); + + if (! $wallet || ! $wallet->hasBalance()) { + continue; + } + + $amountToDeduct = min($wallet->balance, $remainingOrderValue); + $wallet->decrementAndCreateLog($amountToDeduct); + $remainingOrderValue -= $amountToDeduct; + + if ($remainingOrderValue <= 0) { + break; + } + } + + if ($remainingOrderValue > 0) { + throw new InsufficientBalanceException('Insufficient total wallet balance to cover the order.'); + } + }); + } +} diff --git a/src/Traits/HasWallet.php b/src/Traits/HasWallet.php new file mode 100644 index 0000000..d30ca89 --- /dev/null +++ b/src/Traits/HasWallet.php @@ -0,0 +1,72 @@ +morphMany(Wallet::class, 'owner'); + } + + /** + * Get User's Wallet Balance + */ + public function getWalletBalanceAttribute() + { + + $totalBalance = 0; + + foreach ($this->walletsInOrder() as $walletInOrder) { + $walletEnumType = WalletEnums::tryFrom($walletInOrder); + $wallet = $this->wallets()->where('type', $walletEnumType)->first(); + + if ($wallet) { + $totalBalance += $wallet->balance; + } + } + + return $totalBalance; + + } + + /** + * Check if User's wallet balance is more than given value + */ + public function hasSufficientBalance($value): bool + { + return (int) $this->walletBalance >= (int) $value; + } + + /** + * Get the balance of a specific wallet type. + * + * @return float|int + */ + public function getWalletBalanceByType(string $walletType) + { + if (! WalletEnums::isValid($walletType)) { + throw new InvalidWalletTypeException("Invalid wallet type '{$walletType}'."); + } + + $wallet = $this->wallets()->where('type', $walletType)->first(); + + if (! $wallet) { + throw new WalletNotFoundException("Wallet of type '{$walletType}' not found."); + } + + return $wallet->balance; + } +} diff --git a/src/Traits/Loggable.php b/src/Traits/Loggable.php new file mode 100644 index 0000000..deddcf9 --- /dev/null +++ b/src/Traits/Loggable.php @@ -0,0 +1,13 @@ +morphMany(WalletsLog::class, 'loggable'); + } +} diff --git a/src/Traits/ManagesWallet.php b/src/Traits/ManagesWallet.php new file mode 100644 index 0000000..13fcaa8 --- /dev/null +++ b/src/Traits/ManagesWallet.php @@ -0,0 +1,8 @@ +expect(['dd', 'dump', 'ray']) + ->each->not->toBeUsed(); diff --git a/tests/ExceptionsWithoutFacadeTest.php b/tests/ExceptionsWithoutFacadeTest.php new file mode 100644 index 0000000..5226676 --- /dev/null +++ b/tests/ExceptionsWithoutFacadeTest.php @@ -0,0 +1,66 @@ +create(); + + $type = 'wallet_1'; + + $user->deposit($type, 0); +})->throws(InvalidValueException::class); + +test('deposit invalid value (-1) should throw exception', function () { + $user = User::factory()->create(); + + $type = 'wallet_1'; + + $user->deposit($type, -1); +})->throws(InvalidValueException::class); + +test('deposit to invalid wallet type should throw exception', function () { + $user = User::factory()->create(); + + $type = 'wallet_invalid'; + + $user->deposit($type, 100); +})->throws(InvalidWalletTypeException::class); + +test('deposit two times, the second time to invalid wallet type should throw exception', function () { + $user = User::factory()->create(); + + $type = 'wallet_1'; + + $user->deposit($type, 100); + + $type = 'wallet_invalid'; + + $user->deposit($type, 100); + +})->throws(InvalidWalletTypeException::class); + +test('insufficent balance should throw exception', function () { + + $user = User::factory()->create(); + + $type = 'wallet_1'; + + $user->deposit($type, 234.56); + + $user->pay(234.57); + +})->throws(InsufficientBalanceException::class); + +test('User does not have such wallet should throw exception', function () { + + $user = User::factory()->create(); + + $type = 'wallet_1'; + + $user->getWalletBalanceByType('wallet_2'); + +})->throws(WalletNotFoundException::class); diff --git a/tests/Models/User.php b/tests/Models/User.php new file mode 100644 index 0000000..7a10991 --- /dev/null +++ b/tests/Models/User.php @@ -0,0 +1,37 @@ + + */ + protected $fillable = [ + 'name', + 'email', + 'password', + ]; + + /** + * The attributes that should be hidden for serialization. + * + * @var array + */ + protected $hidden = [ + 'password', + 'remember_token', + ]; +} diff --git a/tests/OperationsWithFacadeTest.php b/tests/OperationsWithFacadeTest.php new file mode 100644 index 0000000..24a5c5c --- /dev/null +++ b/tests/OperationsWithFacadeTest.php @@ -0,0 +1,99 @@ +toBeTrue(); +}); + +test('user can deposit fund', function () { + + $user = User::factory()->create(); + + $type = 'wallet_2'; + + LaravelPayPocket::deposit($user, $type, 234.56); + + expect(LaravelPayPocket::walletBalanceByType($user, 'wallet_2'))->toBeFloat(234.56); + + expect(LaravelPayPocket::checkBalance($user))->toBeFloat(234.56); + +}); + +test('user can deposit two times', function () { + + $user = User::factory()->create(); + + $type = 'wallet_2'; + + LaravelPayPocket::deposit($user, $type, 234.56); + + LaravelPayPocket::deposit($user, $type, 789.12); + + expect(LaravelPayPocket::walletBalanceByType($user, 'wallet_2'))->toBeFloat(1023.68); + + expect(LaravelPayPocket::checkBalance($user))->toBeFloat(1023.68); + +}); + +test('user can pay order', function () { + + $user = User::factory()->create(); + + $type = 'wallet_2'; + + LaravelPayPocket::deposit($user, $type, 234.56); + + LaravelPayPocket::pay($user, 100.16); + + expect(LaravelPayPocket::walletBalanceByType($user, 'wallet_2'))->toBeFloat(134.40); + + expect(LaravelPayPocket::checkBalance($user))->toBeFloat(134.40); +}); + +test('user can deposit two times and pay an order', function () { + + $user = User::factory()->create(); + + $type = 'wallet_1'; + + LaravelPayPocket::deposit($user, $type, 234.11); + + expect(LaravelPayPocket::walletBalanceByType($user, 'wallet_1'))->toBeFloat(234.11); + + $type = 'wallet_2'; + LaravelPayPocket::deposit($user, $type, 100.12); + + expect(LaravelPayPocket::walletBalanceByType($user, 'wallet_2'))->toBeFloat(100.12); + + LaravelPayPocket::pay($user, 100); + + expect(LaravelPayPocket::walletBalanceByType($user, 'wallet_1'))->toBeFloat(134.11); + + expect(LaravelPayPocket::checkBalance($user))->toBeFloat(234.33); +}); + +test('user pay from two wallets', function () { + + $user = User::factory()->create(); + + $type = 'wallet_1'; + + LaravelPayPocket::deposit($user, $type, 234.11); + + expect(LaravelPayPocket::walletBalanceByType($user, 'wallet_1'))->toBeFloat(234.11); + + $type = 'wallet_2'; + LaravelPayPocket::deposit($user, $type, 100.12); + + expect(LaravelPayPocket::walletBalanceByType($user, 'wallet_2'))->toBeFloat(100.12); + + LaravelPayPocket::pay($user, 334.11); + + expect(LaravelPayPocket::walletBalanceByType($user, 'wallet_1'))->toBe(0); + + expect(LaravelPayPocket::walletBalanceByType($user, 'wallet_2'))->toBeFloat(0.12); + + expect(LaravelPayPocket::checkBalance($user))->toBeFloat(0.12); +}); diff --git a/tests/OperationsWithoutFacadeTest.php b/tests/OperationsWithoutFacadeTest.php new file mode 100644 index 0000000..66e09db --- /dev/null +++ b/tests/OperationsWithoutFacadeTest.php @@ -0,0 +1,100 @@ +toBeTrue(); +}); + +test('user can deposit fund', function () { + + $user = User::factory()->create(); + + $type = 'wallet_2'; + + $user->deposit($type, 234.56); + + expect($user->getWalletBalanceByType('wallet_2'))->toBeFloat(234.56); + + expect($user->walletBalance)->toBeFloat(234.56); + +}); + +test('user can deposit two times', function () { + + $user = User::factory()->create(); + + $type = 'wallet_2'; + + $user->deposit($type, 234.56); + + $user->deposit($type, 789.12); + + expect($user->getWalletBalanceByType('wallet_2'))->toBeFloat(1023.68); + + expect($user->walletBalance)->toBeFloat(1023.68); + +}); + +test('user can pay order', function () { + + $user = User::factory()->create(); + + $type = 'wallet_2'; + + $user->deposit($type, 234.56); + + $user->pay(100.16); + + expect($user->getWalletBalanceByType('wallet_2'))->toBeFloat(134.40); + + expect($user->walletBalance)->toBeFloat(134.40); +}); + +test('user can deposit two times and pay an order', function () { + + $user = User::factory()->create(); + + $type = 'wallet_1'; + + $user->deposit($type, 234.11); + + expect($user->getWalletBalanceByType('wallet_1'))->toBeFloat(234.11); + + $type = 'wallet_2'; + + $user->deposit($type, 100.12); + + expect($user->getWalletBalanceByType('wallet_2'))->toBeFloat(100.12); + + $user->pay(100); + + expect($user->getWalletBalanceByType('wallet_1'))->toBeFloat(134.11); + + expect($user->walletBalance)->toBeFloat(234.33); +}); + +test('user pay from two wallets', function () { + + $user = User::factory()->create(); + + $type = 'wallet_1'; + + $user->deposit($type, 234.11); + + expect($user->getWalletBalanceByType('wallet_1'))->toBeFloat(234.11); + + $type = 'wallet_2'; + + $user->deposit($type, 100.12); + + expect($user->getWalletBalanceByType('wallet_2'))->toBeFloat(100.12); + + $user->pay(334.11); + + expect($user->getWalletBalanceByType('wallet_1'))->toBe(0); + + expect($user->getWalletBalanceByType('wallet_2'))->toBeFloat(0.12); + + expect($user->walletBalance)->toBeFloat(0.12); +}); diff --git a/tests/Pest.php b/tests/Pest.php new file mode 100644 index 0000000..51812fe --- /dev/null +++ b/tests/Pest.php @@ -0,0 +1,5 @@ +in(__DIR__); diff --git a/tests/TestCase.php b/tests/TestCase.php new file mode 100644 index 0000000..55ad4e2 --- /dev/null +++ b/tests/TestCase.php @@ -0,0 +1,58 @@ + 'HPWebdeveloper\\LaravelPayPocket\\Database\\Factories\\'.class_basename($modelName).'Factory' + ); + */ + + Factory::guessFactoryNamesUsing( + fn (string $modelName) => 'HPWebdeveloper\\LaravelPayPocket\\Tests\\Database\\Factories\\'.class_basename( + $modelName + ).'Factory' + ); + } + + protected function getPackageProviders($app) + { + return [ + LaravelPayPocketServiceProvider::class, + ]; + } + + public function getEnvironmentSetUp($app) + { + config()->set('app.key', 'base64:EWcFBKBT8lKlGK8nQhTHY+wg19QlfmbhtO9Qnn3NfcA='); + + config()->set('database.default', 'testing'); + + /* + $migration = include __DIR__.'/../database/migrations/create_laravel-pay-pocket_table.php.stub'; + $migration->up(); + */ + + $migration = include __DIR__.'/database/migrations/create_users_tables.php'; + $migration->up(); + + $migration = include __DIR__.'/../database/migrations/create_wallets_logs_table.php.stub'; + $migration->up(); + + $migration = include __DIR__.'/../database/migrations/create_wallets_table.php.stub'; + $migration->up(); + } +} diff --git a/tests/database/factories/UserFactory.php b/tests/database/factories/UserFactory.php new file mode 100644 index 0000000..1fdcf29 --- /dev/null +++ b/tests/database/factories/UserFactory.php @@ -0,0 +1,23 @@ + $this->faker->name(), + 'email' => $this->faker->unique()->safeEmail(), + 'email_verified_at' => now(), + 'password' => '$2y$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi', // password + 'remember_token' => Str::random(10), + ]; + } +} diff --git a/tests/database/migrations/create_users_tables.php b/tests/database/migrations/create_users_tables.php new file mode 100644 index 0000000..2bf4180 --- /dev/null +++ b/tests/database/migrations/create_users_tables.php @@ -0,0 +1,26 @@ +id(); + $table->string('name'); + $table->string('email')->unique(); + $table->timestamp('email_verified_at')->nullable(); + $table->string('password'); + $table->rememberToken(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('users'); + } +}; diff --git a/workbench/app/Providers/WorkbenchServiceProvider.php b/workbench/app/Providers/WorkbenchServiceProvider.php new file mode 100644 index 0000000..001d06d --- /dev/null +++ b/workbench/app/Providers/WorkbenchServiceProvider.php @@ -0,0 +1,25 @@ +