From b145af20c4c2dd3b8c70b76c5bad1f16150f88f1 Mon Sep 17 00:00:00 2001 From: divakar-rajodiya Date: Tue, 25 Jun 2024 17:45:35 +0530 Subject: [PATCH] Initial commit --- .editorconfig | 16 + .gitattributes | 9 + .gitignore | 7 + README.md | 116 +++++++ VERSION | 1 + composer.json | 56 ++++ config/quickbooks.php | 96 ++++++ ...141103_create_quick_books_tokens_table.php | 50 +++ gitattributes.txt | 9 + phpunit.xml.dist | 53 ++++ pint.json | 6 + resources/views/connect.blade.php | 19 ++ resources/views/disconnect.blade.php | 31 ++ src/Client.php | 259 +++++++++++++++ src/HasQuickBooksToken.php | 16 + src/Http/Controllers/Controller.php | 87 +++++ src/Http/Middleware/Filter.php | 42 +++ src/Providers/ClientServiceProvider.php | 45 +++ src/Providers/ServiceProvider.php | 115 +++++++ src/Token.php | 126 ++++++++ tests/ClientTest.php | 299 ++++++++++++++++++ tests/HasQuickBooksTokenTest.php | 39 +++ tests/Http/Controllers/ControllerTest.php | 250 +++++++++++++++ tests/Http/Middleware/FilterTest.php | 133 ++++++++ tests/Stubs/TokenStub.php | 49 +++ tests/Stubs/User.php | 34 ++ tests/TestCase.php | 75 +++++ tests/TokenTest.php | 212 +++++++++++++ 28 files changed, 2250 insertions(+) create mode 100644 .editorconfig create mode 100644 .gitattributes create mode 100644 .gitignore create mode 100644 README.md create mode 100644 VERSION create mode 100644 composer.json create mode 100644 config/quickbooks.php create mode 100644 database/migrations/2018_03_11_141103_create_quick_books_tokens_table.php create mode 100644 gitattributes.txt create mode 100644 phpunit.xml.dist create mode 100644 pint.json create mode 100644 resources/views/connect.blade.php create mode 100644 resources/views/disconnect.blade.php create mode 100644 src/Client.php create mode 100644 src/HasQuickBooksToken.php create mode 100644 src/Http/Controllers/Controller.php create mode 100644 src/Http/Middleware/Filter.php create mode 100644 src/Providers/ClientServiceProvider.php create mode 100644 src/Providers/ServiceProvider.php create mode 100644 src/Token.php create mode 100644 tests/ClientTest.php create mode 100644 tests/HasQuickBooksTokenTest.php create mode 100644 tests/Http/Controllers/ControllerTest.php create mode 100644 tests/Http/Middleware/FilterTest.php create mode 100644 tests/Stubs/TokenStub.php create mode 100644 tests/Stubs/User.php create mode 100644 tests/TestCase.php create mode 100644 tests/TokenTest.php diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..1492202 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,16 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +indent_style = space +indent_size = 4 +trim_trailing_whitespace = true + +[*.md] +trim_trailing_whitespace = false + +[*.yml] +indent_style = space +indent_size = 2 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..2472968 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,9 @@ +* text=auto + +/build export-ignore +/tests export-ignore +.gitattributes export-ignore +.gitignore export-ignore +.travis.yml export-ignore +nitpick.json export-ignore +phpunit.xml.dist export-ignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5b95ddd --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +/vendor +/node_modules +phpunit.xml +/build +.phpunit.result.cache +composer.lock +/.idea/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..d2cd910 --- /dev/null +++ b/README.md @@ -0,0 +1,116 @@ +# Wearepixel's Laravel QuickBooks Client + +[![Latest Stable Version](https://poser.pugx.org/wearepixel/laravel-quickbooks-client/v/stable)](https://packagist.org/packages/wearepixel/laravel-quickbooks-client) +[![Latest Unstable Version](https://poser.pugx.org/wearepixel/laravel-quickbooks-client/v/unstable)](https://packagist.org/packages/wearepixel/laravel-quickbooks-client) +[![Total Downloads](https://poser.pugx.org/wearepixel/laravel-quickbooks-client/downloads)](https://packagist.org/packages/wearepixel/laravel-quickbooks-client) +[![License](https://poser.pugx.org/wearepixel/laravel-quickbooks-client/license)](https://packagist.org/packages/wearepixel/laravel-quickbooks-client) + +PHP client wrapping the [QuickBooks PHP SDK](https://github.com/intuit/QuickBooks-V3-PHP-SDK). + +We solely use [Laravel](https://www.laravel.com) for our applications, so this package is written with Laravel in mind. If there is a request from the community to split this package into 2 parts to allow it to be used outside of Laravel, then we will consider doing that work. + +## Build Status + +| Branch | Status | Coverage | Code Quality | +| ------ | :----: | :------: | :----------: | +| Develop | [![Build Status](https://github.com/wearepixel/laravel-quickbooks-client/workflows/CI/badge.svg?branch=develop)](https://github.com/wearepixel/laravel-quickbooks-client/workflows/CI/badge.svg?branch=develop) | [![Code Coverage](https://scrutinizer-ci.com/g/wearepixel/laravel-quickbooks-client/badges/coverage.png?b=develop)](https://scrutinizer-ci.com/g/wearepixel/laravel-quickbooks-client/?branch=develop) | [![Scrutinizer Code Quality](https://scrutinizer-ci.com/g/wearepixel/laravel-quickbooks-client/badges/quality-score.png?b=develop)](https://scrutinizer-ci.com/g/wearepixel/laravel-quickbooks-client/?branch=develop) | +| Master | [![Build Status](https://github.com/wearepixel/laravel-quickbooks-client/workflows/CI/badge.svg?branch=master)](https://github.com/wearepixel/laravel-quickbooks-client/workflows/CI/badge.svg?branch=master) | [![Code Coverage](https://scrutinizer-ci.com/g/wearepixel/laravel-quickbooks-client/badges/coverage.png?b=master)](https://scrutinizer-ci.com/g/wearepixel/laravel-quickbooks-client/?branch=master) | [![Scrutinizer Code Quality](https://scrutinizer-ci.com/g/wearepixel/laravel-quickbooks-client/badges/quality-score.png?b=master)](https://scrutinizer-ci.com/g/wearepixel/laravel-quickbooks-client/?branch=master) | + +## Installation + +1. Install QuickBooks PHP Client: + +```bash +$ composer require wearepixel/laravel-quickbooks-client +``` + +2. Run our migration to install the `quickbooks_tokens` table: + +```bash +$ php artisan migrate --package=wearepixel/laravel-quickbooks-client +``` + +The package uses the [auto registration feature](https://laravel.com/docs/packages#package-discovery) of Laravel. + +## Configuration + +1. You will need a ```quickBooksToken``` relationship on your ```User``` model. There is a trait named ```Wearepixel\QuickBooks\HasQuickBooksToken```, which you can include on your ```User``` model, which will setup the relationship. To do this implement the following: + +Add ```use Wearepixel\QuickBooks\HasQuickBooksToken;``` to your service container at the top of User.php +and also add the trait within the class. For example: + +```php +class User extends Authenticatable +{ + use Notifiable, HasQuickBooksToken; +``` + +**NOTE: If your ```User``` model is not ```App\Models\User```, then you will need to configure the path in the ```configs/quickbooks.php```.** + +2. Add the appropriate values to your ```.env``` + + #### Minimal Keys + ```bash + QUICKBOOKS_CLIENT_ID= + QUICKBOOKS_CLIENT_SECRET= + ``` + + #### Optional Keys + ```bash + QUICKBOOKS_API_URL= # Defaults to App's env value + QUICKBOOKS_DEBUG= # Defaults to App's debug value + ``` + +3. _[Optional]_ Publish configs & views + + #### Config + A configuration file named ```quickbooks.php``` can be published to ```config/``` by running... + + ```bash + php artisan vendor:publish --tag=quickbooks-config + ``` + + #### Views + View files can be published by running... + + ```bash + php artisan vendor:publish --tag=quickbooks-views + ``` + +## Usage + +Here is an example of getting the company information from QuickBooks: + +### NOTE: Before doing these commands, go to your connect route (default: /quickbooks/connect) to get a QuickBooks token for your user + +```php +php artisan tinker +Psy Shell v0.8.17 (PHP 7.1.14 — cli) by Justin Hileman +>>> Auth::logInUsingId(1) +=> App\Models\User {#1668 + id: 1, + // Other keys removed for example + } +>>> $quickbooks = app('Wearepixel\QuickBooks\Client') // or app('QuickBooks') +=> Wearepixel\QuickBooks\Client {#1613} +>>> $quickbooks->getDataService()->getCompanyInfo(); +=> QuickBooksOnline\API\Data\IPPCompanyInfo {#1673 + +CompanyName: "Sandbox Company_US_1", + +LegalName: "Sandbox Company_US_1", + // Other properties removed for example + } +>>> +``` + +You can call any of the resources as documented [in the SDK](https://intuit.github.io/QuickBooks-V3-PHP-SDK/quickstart.html). + +## Middleware + +If you have routes that will be dependent on the user's account having a usable QuickBooks OAuth token, there is an included middleware ```Wearepixel\QuickBooks\Laravel\Filter``` that gets registered as ```quickbooks``` that will ensure the account is linked and redirect them to the `connect` route if needed. + +Here is an example route definition: + +```php +Route::view('some/route/needing/quickbooks/token/before/using', 'some.view') + ->middleware('quickbooks'); +``` diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..c5106e6 --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +4.0.4 diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..662b65a --- /dev/null +++ b/composer.json @@ -0,0 +1,56 @@ +{ + "name": "wearepixel/laravel-quickbooks", + "description": "Wearepixel's Laravel Client for QuickBooks.", + "keywords": [ + "quickbooks", + "client", + "laravel", + "wearepixel" + ], + "license": "MIT", + "authors": [ + { + "name": "Joel Male", + "email": "joel.male@wearepixel.com" + } + ], + "require": { + "php": "^8.0.2", + "illuminate/database": "^10.0|^11.0", + "illuminate/http": "^10.0|^11.0", + "illuminate/routing": "^10.0|^11.0", + "illuminate/support": "^10.0|^11.0", + "nesbot/carbon": "^2.62.1", + "quickbooks/v3-php-sdk": "^5.4.6" + }, + "require-dev": { + "laravel/pint": "^1.2", + "mockery/mockery": "^1.5.1", + "phpunit/phpunit": "^10.0", + "psy/psysh": "^0.11", + "scrutinizer/ocular": "^1.9" + }, + "autoload": { + "psr-4": { + "Wearepixel\\QuickBooks\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "Wearepixel\\QuickBooks\\": "tests/" + } + }, + "extra": { + "laravel": { + "providers": [ + "Wearepixel\\QuickBooks\\Providers\\ServiceProvider", + "Wearepixel\\QuickBooks\\Providers\\ClientServiceProvider" + ] + } + }, + "config": { + "sort-packages": true + }, + "minimum-stability": "stable", + "prefer-stable": true +} diff --git a/config/quickbooks.php b/config/quickbooks.php new file mode 100644 index 0000000..0dcfd0d --- /dev/null +++ b/config/quickbooks.php @@ -0,0 +1,96 @@ + [ + 'auth_mode' => 'oauth2', + 'base_url' => env('QUICKBOOKS_API_URL', config('app.env') === 'production' ? 'Production' : 'Development'), + 'client_id' => env('QUICKBOOKS_CLIENT_ID'), + 'client_secret' => env('QUICKBOOKS_CLIENT_SECRET'), + 'scope' => 'com.intuit.quickbooks.accounting', + ], + + /* + |-------------------------------------------------------------------------- + | Properties to control logging + |-------------------------------------------------------------------------- + | + | Configures logging to /logs/quickbooks.log when in debug + | mode or when 'QUICKBOOKS_DEBUG' is true. + | + */ + + 'logging' => [ + 'enabled' => env('QUICKBOOKS_DEBUG', config('app.debug')), + + 'location' => storage_path('logs'), + ], + + /* + |-------------------------------------------------------------------------- + | Properties to configure the routes + |-------------------------------------------------------------------------- + | + | There are several routes that are needed for the package, so these + | properties allow configuring them to fit the application as needed. + | + */ + + 'route' => [ + // Controls the middlewares for thr routes. Can be a string or array of strings + 'middleware' => [ + // Added to the protected routes for the package (i.e. connect & disconnect) + 'authenticated' => 'auth', + // Added to all of the routes for the package + 'default' => 'web', + ], + 'paths' => [ + // Show forms to connect/disconnect + 'connect' => 'connect', + // The DELETE takes place to remove token + 'disconnect' => 'disconnect', + // Return URI that QuickBooks sends code to allow getting OAuth token + 'token' => 'token', + ], + 'prefix' => 'quickbooks', + ], + + /* + |-------------------------------------------------------------------------- + | Properties for control the "user" relationship in Token + |-------------------------------------------------------------------------- + | + | The Token class has a "user" relationship, and these properties allow + | configuring the relationship. + | + */ + + 'user' => [ + 'keys' => [ + 'foreign' => 'user_id', + 'owner' => 'id', + ], + 'model' => User::class, + ], + +]; diff --git a/database/migrations/2018_03_11_141103_create_quick_books_tokens_table.php b/database/migrations/2018_03_11_141103_create_quick_books_tokens_table.php new file mode 100644 index 0000000..0d7af8b --- /dev/null +++ b/database/migrations/2018_03_11_141103_create_quick_books_tokens_table.php @@ -0,0 +1,50 @@ +getColumnType('users', 'id') === 'bigint' + ? 'unsignedBigInteger' + : 'unsignedInteger'; + + $table->bigIncrements('id'); + $table->{$user_id_type}('user_id'); + $table->unsignedBigInteger('realm_id'); + $table->longtext('access_token'); + $table->datetime('access_token_expires_at'); + $table->string('refresh_token'); + $table->datetime('refresh_token_expires_at'); + + $table->timestamps(); + + $table + ->foreign('user_id') + ->references('id') + ->on('users') + ->onDelete('cascade'); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::dropIfExists('quickbooks_tokens'); + } +}; diff --git a/gitattributes.txt b/gitattributes.txt new file mode 100644 index 0000000..2472968 --- /dev/null +++ b/gitattributes.txt @@ -0,0 +1,9 @@ +* text=auto + +/build export-ignore +/tests export-ignore +.gitattributes export-ignore +.gitignore export-ignore +.travis.yml export-ignore +nitpick.json export-ignore +phpunit.xml.dist export-ignore diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 0000000..18bcf37 --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,53 @@ + + + + + + + + + ./tests/ + + + + + + + + + + ./src + + ./src/Http/Exceptions + ./src/Providers + + + + + + + + + + + diff --git a/pint.json b/pint.json new file mode 100644 index 0000000..7e265ca --- /dev/null +++ b/pint.json @@ -0,0 +1,6 @@ +{ + "preset": "laravel", + "rules": { + "no_superfluous_phpdoc_tags": true + } +} diff --git a/resources/views/connect.blade.php b/resources/views/connect.blade.php new file mode 100644 index 0000000..9ee3591 --- /dev/null +++ b/resources/views/connect.blade.php @@ -0,0 +1,19 @@ + + + + + + + + {{ config('app.name') }} - QuickBooks Authorization + + +
+

QuickBooks Authorization

+ + {{-- TODO: Text here to explain what is going on --}} + {{-- TODO: Work on UI --}} + Connect to QuickBooks +
+ + diff --git a/resources/views/disconnect.blade.php b/resources/views/disconnect.blade.php new file mode 100644 index 0000000..cb8f9d4 --- /dev/null +++ b/resources/views/disconnect.blade.php @@ -0,0 +1,31 @@ + + + + + + + + {{ config('app.name') }} - QuickBooks Deauthorization + + +
+

QuickBooks Deauthorization

+ + {{-- TODO: Text here to explain what is going on --}} + {{-- TODO: Work on UI --}} + + Disconnect from {{ $company->CompanyName }} + + + +
+ + diff --git a/src/Client.php b/src/Client.php new file mode 100644 index 0000000..c18510d --- /dev/null +++ b/src/Client.php @@ -0,0 +1,259 @@ +configs = $configs; + + $this->setToken($token); + } + + /** + * Build URI to request authorization + * + * @throws SdkException + * @throws ServiceException + */ + public function authorizationUri(): string + { + return $this->getDataService() + ->getOAuth2LoginHelper() + ->getAuthorizationCodeURL(); + } + + /** + * Configure the logging per config/quickbooks.php + */ + public function configureLogging(): DataService + { + // In case any of the keys are not in the configs, just disable logging + try { + if ( + $this->configs['logging']['enabled'] && + dir($this->configs['logging']['location']) + ) { + $this->data_service->setLogLocation($this->configs['logging']['location']); + + return $this->data_service->enableLog(); + } + } catch (Exception $e) { + // TODO: Figure out what to do with this exception + } + + return $this->data_service->disableLog(); + } + + /** + * Delete the token + * + * @throws Exception + */ + public function deleteToken(): self + { + $this->setToken($this->token->remove()); + + return $this; + } + + /** + * Convert code to an access token + * + * Upon the user allowing access to their account, there is a code sent to + * over that needs to be converted to an OAuth token. + * + * @throws SdkException + * @throws ServiceException + */ + public function exchangeCodeForToken(string $code, int $realm_id): self + { + $oauth_token = $this->getDataService() + ->getOAuth2LoginHelper() + ->exchangeAuthorizationCodeForToken($code, $realm_id); + + $this->getDataService()->updateOAuth2Token($oauth_token); + + $this->token->parseOauthToken($oauth_token)->save(); + + return $this; + } + + /** + * Getter for the DataService + * + * Makes sure that it is setup & ready to be used. + * + * @throws SdkException + * @throws ServiceException + */ + public function getDataService(): DataService + { + if (! $this->hasValidAccessToken() || ! isset($this->data_service)) { + $this->data_service = $this->makeDataService(); + + $this->configureLogging(); + } + + return $this->data_service; + } + + /** + * Getter for the ReportService + * + * Makes sure that it is setup & ready to be used. + * + * @throws SdkException + * @throws ServiceException + */ + public function getReportService(): ReportService + { + if (! $this->hasValidAccessToken() || ! isset($this->report_service)) { + $this->report_service = new ReportService($this->getDataService()->getServiceContext()); + } + + return $this->report_service; + } + + /** + * Check to see if the token has a valid access token + */ + public function hasValidAccessToken(): bool + { + return $this->token->hasValidAccessToken; + } + + /** + * Check to see if the token has a valid refresh token + */ + public function hasValidRefreshToken(): bool + { + return $this->token->hasValidRefreshToken; + } + + /** + * Factory to make DataService + * + * There are 3 use cases for making a DataService.... + * + * 1) Have valid access token, so ready to be used + * 2) Have valid refresh token, so renew access token & then use + * 3) No existing token, so need to link account + * + * @throws SdkException + * @throws ServiceException + */ + protected function makeDataService(): DataService + { + // Associative array to use to filter out only the needed config keys when using existing token + $existing_keys = [ + 'auth_mode' => null, + 'baseUrl' => null, + 'ClientID' => null, + 'ClientSecret' => null, + ]; + + // Have good access & refresh, so allow app to run + if ($this->hasValidAccessToken()) { + // Pull in the configs from the token into needed keys from the configs + return DataService::Configure( + array_merge(array_intersect_key($this->parseDataConfigs(), $existing_keys), [ + 'accessTokenKey' => $this->token->access_token, + 'QBORealmID' => $this->token->realm_id, + 'refreshTokenKey' => $this->token->refresh_token, + ]), + ); + } + + // Have refresh, so update access & allow app to run + if ($this->hasValidRefreshToken()) { + // Pull in the configs from the token into needed keys from the configs + $data_service = DataService::Configure( + array_merge(array_intersect_key($this->parseDataConfigs(), $existing_keys), [ + 'QBORealmID' => $this->token->realm_id, + 'refreshTokenKey' => $this->token->refresh_token, + ]), + ); + + $oauth_token = $data_service->getOAuth2LoginHelper()->refreshToken(); + + $data_service->updateOAuth2Token($oauth_token); + + $this->token->parseOauthToken($oauth_token)->save(); + + return $data_service; + } + + // Create new... + return DataService::Configure($this->parseDataConfigs()); + } + + /** + * QuickBooks is not consistent on their naming of variables, so map them + */ + protected function parseDataConfigs(): array + { + return [ + 'auth_mode' => $this->configs['data_service']['auth_mode'], + 'baseUrl' => $this->configs['data_service']['base_url'], + 'ClientID' => $this->configs['data_service']['client_id'], + 'ClientSecret' => $this->configs['data_service']['client_secret'], + 'RedirectURI' => route('quickbooks.token'), + 'scope' => $this->configs['data_service']['scope'], + ]; + } + + /** + * Allow setting a token to switch "user" + */ + public function setToken(Token $token): self + { + $this->token = $token; + + // The DataService is tied to a specific token, so remove it when using a new one + unset($this->data_service); + + return $this; + } +} diff --git a/src/HasQuickBooksToken.php b/src/HasQuickBooksToken.php new file mode 100644 index 0000000..7fa301e --- /dev/null +++ b/src/HasQuickBooksToken.php @@ -0,0 +1,16 @@ +hasOne(Token::class); + } +} diff --git a/src/Http/Controllers/Controller.php b/src/Http/Controllers/Controller.php new file mode 100644 index 0000000..2b33811 --- /dev/null +++ b/src/Http/Controllers/Controller.php @@ -0,0 +1,87 @@ +hasValidRefreshToken()) { + return $view_factory + ->make('quickbooks::disconnect') + ->with('company', $quickbooks->getDataService()->getCompanyInfo()); + } + + // Give view to link account + return $view_factory + ->make('quickbooks::connect') + ->with('authorization_uri', $quickbooks->authorizationUri()); + } + + /** + * Removes the token + * + * @throws Exception + */ + public function disconnect( + Redirector $redirector, + Request $request, + QuickBooks $quickbooks, + ): RedirectResponse|View { + $quickbooks->deleteToken(); + + $request->session()->flash('success', 'Disconnected from QuickBooks'); + + return $redirector->back(); + } + + /** + * Accept the code from QuickBooks to request token + * + * Once a user approves linking account, then QuickBooks sends back + * a code which can be converted to an OAuth token. + * + * @throws SdkException + * @throws ServiceException + */ + public function token( + Redirector $redirector, + Request $request, + QuickBooks $quickbooks, + UrlGenerator $url_generator, + ): RedirectResponse { + // TODO: Deal with exceptions + $quickbooks->exchangeCodeForToken($request->get('code'), $request->get('realmId')); + + $request->session()->flash('success', 'Connected to QuickBooks'); + + return $redirector->intended($url_generator->route('quickbooks.connect')); + } +} diff --git a/src/Http/Middleware/Filter.php b/src/Http/Middleware/Filter.php new file mode 100644 index 0000000..3399b91 --- /dev/null +++ b/src/Http/Middleware/Filter.php @@ -0,0 +1,42 @@ +quickbooks->hasValidRefreshToken()) { + // Set intended route, so that after linking account, user is put where they were going + $this->session->put('url.intended', $this->url_generator->to($request->path())); + + return $this->redirector->route('quickbooks.connect'); + } + + return $next($request); + } +} diff --git a/src/Providers/ClientServiceProvider.php b/src/Providers/ClientServiceProvider.php new file mode 100644 index 0000000..657fd74 --- /dev/null +++ b/src/Providers/ClientServiceProvider.php @@ -0,0 +1,45 @@ +app->bind(Client::class, function (Application $app) { + $token = + $app->auth->user()->quickBooksToken ?: + $app->auth + ->user() + ->quickBooksToken() + ->make(); + + return new Client($app->config->get('quickbooks'), $token); + }); + + $this->app->alias(Client::class, 'QuickBooks'); + } +} diff --git a/src/Providers/ServiceProvider.php b/src/Providers/ServiceProvider.php new file mode 100644 index 0000000..bae144b --- /dev/null +++ b/src/Providers/ServiceProvider.php @@ -0,0 +1,115 @@ +registerMiddleware(); + + $this->registerPublishes(); + + $this->registerRoutes(); + + $this->registerViews(); + } + + /** + * Register the application services. + */ + public function register(): void + { + $this->mergeConfigFrom(__DIR__.'/../../config/quickbooks.php', 'quickbooks'); + } + + /** + * Register the middleware + * + * If a route needs to have the QuickBooks client, then make sure that the user has linked their account. + */ + public function registerMiddleware(): void + { + $this->app->router->aliasMiddleware('quickbooks', Filter::class); + } + + /** + * There are several resources that get published + * + * Only worry about telling the application about them if running in the console. + */ + protected function registerPublishes(): void + { + if ($this->app->runningInConsole()) { + $this->loadMigrationsFrom(__DIR__.'/../../database/migrations'); + + $this->publishes( + [ + __DIR__.'/../../config/quickbooks.php' => config_path('quickbooks.php'), + ], + 'quickbooks-config', + ); + + $this->publishes( + [ + __DIR__.'/../../database/migrations' => database_path('migrations'), + ], + 'quickbooks-migrations', + ); + + $this->publishes( + [ + __DIR__.'/../../resources/views' => base_path( + 'resources/views/vendor/quickbooks', + ), + ], + 'quickbooks-views', + ); + } + } + + /** + * Register the routes needed for the registration flow + */ + protected function registerRoutes(): void + { + $config = $this->app->config->get('quickbooks.route'); + + $this->app->router + ->prefix($config['prefix']) + ->as('quickbooks.') + ->middleware($config['middleware']['default']) + ->namespace('Wearepixel\QuickBooks\Http\Controllers') + ->group(function (Router $router) use ($config) { + $router + ->get($config['paths']['connect'], 'Controller@connect') + ->middleware($config['middleware']['authenticated']) + ->name('connect'); + + $router + ->delete($config['paths']['disconnect'], 'Controller@disconnect') + ->middleware($config['middleware']['authenticated']) + ->name('disconnect'); + + $router->get($config['paths']['token'], 'Controller@token')->name('token'); + }); + } + + /** + * Register the views + */ + protected function registerViews(): void + { + $this->loadViewsFrom(__DIR__.'/../../resources/views', 'quickbooks'); + } +} diff --git a/src/Token.php b/src/Token.php new file mode 100644 index 0000000..0b75ba4 --- /dev/null +++ b/src/Token.php @@ -0,0 +1,126 @@ +access_token_expires_at && Carbon::now()->lt($this->access_token_expires_at); + } + + /** + * Check if refresh token is valid + * + * A token is good for 101 days, so if it expires greater than 101 days from now, it is still valid + */ + public function getHasValidRefreshTokenAttribute(): bool + { + return $this->refresh_token_expires_at && + Carbon::now()->lt($this->refresh_token_expires_at); + } + + /** + * Parse OauthToken. + * + * Process the OAuth token & store it in the persistent storage + * + * @throws SdkException + */ + public function parseOauthToken(OAuth2AccessToken $oauth_token): Token + { + // TODO: Deal with exception + $this->access_token = $oauth_token->getAccessToken(); + $this->access_token_expires_at = Carbon::parse($oauth_token->getAccessTokenExpiresAt()); + $this->realm_id = $oauth_token->getRealmID(); + $this->refresh_token = $oauth_token->getRefreshToken(); + $this->refresh_token_expires_at = Carbon::parse($oauth_token->getRefreshTokenExpiresAt()); + + return $this; + } + + /** + * Remove the token + * + * When a token is deleted, we still need a token for the client for the user. + * + * @throws Exception + */ + public function remove(): Token + { + $user = $this->user; + + $this->delete(); + + return $user->quickBooksToken()->make(); + } + + /** + * Belongs to user. + */ + public function user(): BelongsTo + { + $config = config('quickbooks.user'); + + return $this->belongsTo( + $config['model'], + $config['keys']['foreign'], + $config['keys']['owner'], + ); + } +} diff --git a/tests/ClientTest.php b/tests/ClientTest.php new file mode 100644 index 0000000..9206da2 --- /dev/null +++ b/tests/ClientTest.php @@ -0,0 +1,299 @@ +configs = [ + 'data_service' => [ + 'auth_mode' => 'oauth2', + 'base_url' => 'Development', + 'client_id' => 'QUICKBOOKS_CLIENT_ID', + 'client_secret' => 'QUICKBOOKS_CLIENT_SECRET', + 'scope' => 'com.intuit.quickbooks.accounting', + ], + ]; + + $this->token_mock = Mockery::mock(Token::class); + + $this->client = $this->makeClient(); + } + + private function makeClient($configs = null) + { + if (! is_null($configs)) { + return new Client($configs, $this->token_mock); + } + + return new Client($this->configs, $this->token_mock); + } + + /** + * @test + */ + public function it_can_be_constructed() + { + $this->assertInstanceOf(Client::class, $this->client); + } + + /** + * @test + */ + public function it_returns_a_data_service_configured_to_request_oauth_token_when_token_is_empty() + { + $this->token_mock + ->shouldReceive('getAttribute') + ->twice() + ->with('hasValidAccessToken') + ->andReturnFalse(); + + $this->token_mock + ->shouldReceive('getAttribute') + ->once() + ->with('hasValidRefreshToken') + ->andReturnFalse(); + + $this->assertFalse( + $this->client + ->getDataService() + ->getServiceContext() + ->IppConfiguration->Security->isAccessTokenSet(), + ); + } + + /** + * @test + */ + public function it_caches_the_data_service_once_it_is_made() + { + $this->token_mock + ->shouldReceive('getAttribute') + ->twice() + ->with('hasValidAccessToken') + ->andReturnFalse(); + + $this->token_mock + ->shouldReceive('getAttribute') + ->once() + ->with('hasValidRefreshToken') + ->andReturnFalse(); + + // TODO: Find out if this is the actual goal of this test, to make sure a DataService is received + $this->assertEquals(DataService::class, get_class($this->client->getDataService())); + } + + /** + * @test + */ + public function it_returns_a_data_service_with_oauth_token_when_valid_access_token_exist() + { + $this->token_mock + ->shouldReceive('getAttribute') + ->twice() + ->with('hasValidAccessToken') + ->andReturnTrue(); + + $this->token_mock + ->shouldReceive('getAttribute') + ->once() + ->with('access_token') + ->andReturn('access_token'); + + $this->token_mock + ->shouldReceive('getAttribute') + ->once() + ->with('realm_id') + ->andReturn('realm_id'); + + $this->token_mock + ->shouldReceive('getAttribute') + ->once() + ->with('refresh_token') + ->andReturn('refresh_token'); + + $this->assertTrue( + $this->client + ->getDataService() + ->getServiceContext() + ->IppConfiguration->Security->isAccessTokenSet(), + ); + } + + /** + * @test + */ + public function it_returns_a_data_service_with_refreshed_token_when_access_token_expired_but_refresh_token_valid() + { + $this->expectException(ServiceException::class); + + $this->token_mock + ->shouldReceive('getAttribute') + ->twice() + ->with('hasValidAccessToken') + ->andReturnFalse(); + + $this->token_mock + ->shouldReceive('getAttribute') + ->once() + ->with('hasValidRefreshToken') + ->andReturnTrue(); + + $this->token_mock + ->shouldReceive('getAttribute') + ->never() + ->with('access_token'); + + $this->token_mock + ->shouldReceive('getAttribute') + ->once() + ->with('realm_id') + ->andReturn('realm_id'); + + $this->token_mock + ->shouldReceive('getAttribute') + ->once() + ->with('refresh_token') + ->andReturn('refresh_token'); + + // TODO: This really needs to test that things are getting called correctly on the DataService class + $this->assertFalse( + $this->client + ->getDataService() + ->getServiceContext() + ->IppConfiguration->Security->isAccessTokenSet(), + ); + } + + /** + * @test + */ + public function it_returns_a_report_service_using_the_data_service() + { + $this->markTestSkipped( + 'Once we figure out how to test around the static DataService::Configure', + ); + } + + /** + * @test + */ + public function it_has_logging_off_by_default() + { + $this->markTestSkipped('Have to figure out how to test this with the new code in 5.x'); + + $this->token_mock + ->shouldReceive('getAttribute') + ->once() + ->with('hasValidAccessToken') + ->andReturnFalse(); + + $this->token_mock + ->shouldReceive('getAttribute') + ->once() + ->with('hasValidRefreshToken') + ->andReturnFalse(); + + $this->assertFalse( + filter_var( + $this->client->getDataService()->getServiceContext()->IppConfiguration->Logger + ->RequestLog->EnableRequestResponseLogging, + FILTER_VALIDATE_BOOLEAN, + ), + ); + } + + /** + * @test + */ + public function it_allows_logging_turned_on_and_pointed_to_expected_file() + { + $this->markTestSkipped('Have to figure out how to test this with the new code in 5.x'); + + $this->client = $this->makeClient( + array_merge( + [ + 'logging' => [ + 'enabled' => true, + 'location' => '/some/valid/path', + ], + ], + $this->configs, + ), + ); + + $this->token_mock + ->shouldReceive('getAttribute') + ->once() + ->with('hasValidAccessToken') + ->andReturnFalse(); + + $this->token_mock + ->shouldReceive('getAttribute') + ->once() + ->with('hasValidRefreshToken') + ->andReturnFalse(); + + $this->assertTrue( + filter_var( + $this->client->getDataService()->getServiceContext()->IppConfiguration->Logger + ->RequestLog->EnableRequestResponseLogging, + FILTER_VALIDATE_BOOLEAN, + ), + ); + + $this->assertEquals( + '/some/valid/path', + $this->client->getDataService()->getServiceContext()->IppConfiguration->Logger + ->RequestLog->ServiceRequestLoggingLocation, + ); + } + + /** + * @test + */ + public function it_returns_self_after_deleting_token() + { + $this->token_mock + ->shouldReceive('remove') + ->once() + ->withNoArgs() + ->andReturnSelf(); + + $this->assertInstanceOf(Client::class, $this->client->deleteToken()); + } +} + +function dir($path) +{ + return $path === '/some/valid/path'; +} + +function route($name) +{ + return $name; +} diff --git a/tests/HasQuickBooksTokenTest.php b/tests/HasQuickBooksTokenTest.php new file mode 100644 index 0000000..3920330 --- /dev/null +++ b/tests/HasQuickBooksTokenTest.php @@ -0,0 +1,39 @@ +user = new User(); + } + + /** + * @test + */ + public function it_can_be_constructed() + { + $this->assertInstanceOf(User::class, $this->user); + } + + /** + * @test + */ + public function it_has_a_hasOne_relationship_to_token() + { + $this->assertInstanceOf(Token::class, $this->user->quickBooksToken()->getModel()); + } +} diff --git a/tests/Http/Controllers/ControllerTest.php b/tests/Http/Controllers/ControllerTest.php new file mode 100644 index 0000000..3df4311 --- /dev/null +++ b/tests/Http/Controllers/ControllerTest.php @@ -0,0 +1,250 @@ +data_service_mock = Mockery::mock(DataService::class); + $this->quickbooks_mock = Mockery::mock(QuickBooks::class); + $this->redirector_mock = Mockery::mock(Redirector::class); + $this->request_mock = Mockery::mock(Request::class); + $this->session_mock = Mockery::mock(Store::class); + $this->view_factory_mock = Mockery::mock(ViewFactory::class); + $this->view_mock = Mockery::mock(View::class); + + $this->controller = new Controller(); + } + + /** + * @test + */ + public function it_can_be_constructed() + { + $this->assertInstanceOf(Controller::class, $this->controller); + } + + /** + * @test + */ + public function it_shows_view_to_connect_if_account_not_linked() + { + $this->data_service_mock + ->shouldReceive('getCompanyInfo') + ->once() + ->withNoArgs() + ->andReturn([ + 'name' => 'Company', + ]); + + $this->quickbooks_mock + ->shouldReceive('hasValidRefreshToken') + ->once() + ->withNoArgs() + ->andReturnTrue(); + + $this->quickbooks_mock + ->shouldReceive('getDataService') + ->once() + ->withNoArgs() + ->andReturn($this->data_service_mock); + + $this->view_factory_mock + ->shouldReceive('make') + ->once() + ->with('quickbooks::disconnect') + ->andReturn($this->view_mock); + + $this->view_mock + ->shouldReceive('with') + ->once() + ->withArgs([ + 'company', + [ + 'name' => 'Company', + ], + ]) + ->andReturnSelf(); + + $this->controller->connect($this->quickbooks_mock, $this->view_factory_mock); + } + + /** + * @test + */ + public function it_shows_view_to_disconnect_if_account_linked() + { + $this->quickbooks_mock + ->shouldReceive('hasValidRefreshToken') + ->once() + ->withNoArgs() + ->andReturnFalse(); + + $this->quickbooks_mock + ->shouldReceive('authorizationUri') + ->once() + ->withNoArgs() + ->andReturn('http://uri'); + + $this->view_factory_mock + ->shouldReceive('make') + ->once() + ->with('quickbooks::connect') + ->andReturn($this->view_mock); + + $this->view_mock + ->shouldReceive('with') + ->once() + ->withArgs(['authorization_uri', 'http://uri']) + ->andReturnSelf(); + + $this->controller->connect($this->quickbooks_mock, $this->view_factory_mock); + } + + /** + * @test + */ + public function it_disconnects_from_quickbooks_when_requested() + { + $this->request_mock + ->shouldReceive('session') + ->once() + ->andReturn($this->session_mock); + + $this->session_mock + ->shouldReceive('flash') + ->once() + ->withAnyArgs(); + + $this->redirector_mock + ->shouldReceive('back') + ->once() + ->andReturn(new RedirectResponse('/test', 302)); + + $this->quickbooks_mock + ->shouldReceive('deleteToken') + ->once() + ->withNoArgs(); + + $result = $this->controller->disconnect( + $this->redirector_mock, + $this->request_mock, + $this->quickbooks_mock, + ); + + $this->assertInstanceOf(RedirectResponse::class, $result); + } + + /** + * @test + */ + public function it_finishes_connecting_to_quickbooks_when_given_a_valid_token_by_quickbooks() + { + $this->url_generator_mock = Mockery::mock(UrlGenerator::class); + $realmId = random_int(1, 9999); + + $this->quickbooks_mock + ->shouldReceive('exchangeCodeForToken') + ->once() + ->withArgs(['code', $realmId]); + + $this->request_mock + ->shouldReceive('get') + ->once() + ->withArgs(['code']) + ->andReturn('code'); + + $this->request_mock + ->shouldReceive('get') + ->once() + ->withArgs(['realmId']) + ->andReturn($realmId); + + $this->request_mock + ->shouldReceive('session') + ->once() + ->andReturn($this->session_mock); + + $this->session_mock + ->shouldReceive('flash') + ->once() + ->withAnyArgs(); + + $this->redirector_mock + ->shouldReceive('intended') + ->once() + ->withAnyArgs() + ->andReturn(new RedirectResponse('/test', 302)); + + $this->url_generator_mock + ->shouldReceive('route') + ->withArgs(['quickbooks.connect']) + ->once(); + + $result = $this->controller->token( + $this->redirector_mock, + $this->request_mock, + $this->quickbooks_mock, + $this->url_generator_mock, + ); + + $this->assertInstanceOf(RedirectResponse::class, $result); + } +} diff --git a/tests/Http/Middleware/FilterTest.php b/tests/Http/Middleware/FilterTest.php new file mode 100644 index 0000000..beb18e4 --- /dev/null +++ b/tests/Http/Middleware/FilterTest.php @@ -0,0 +1,133 @@ +quickbooks_mock = Mockery::mock(QuickBooks::class); + $this->redirector_mock = Mockery::mock(Redirector::class); + $this->request_mock = Mockery::mock(Request::class); + $this->session_mock = Mockery::mock(Session::class); + $this->url_generator_mock = Mockery::mock(UrlGenerator::class); + + $this->filter = new Filter( + $this->quickbooks_mock, + $this->redirector_mock, + $this->session_mock, + $this->url_generator_mock, + ); + } + + /** + * @test + */ + public function it_can_be_constructed() + { + $this->assertInstanceOf(Filter::class, $this->filter); + } + + /** + * @test + */ + public function it_just_passes_the_request_to_the_next_middleware_if_account_linked_to_quickbooks() + { + $next_middleware = function ($request) { + $this->assertEquals($this->request_mock, $request); + }; + + $this->quickbooks_mock + ->shouldReceive('hasValidRefreshToken') + ->once() + ->withNoArgs() + ->andReturnTrue(); + + $this->filter->handle($this->request_mock, $next_middleware); + } + + /** + * @test + */ + public function it_redirects_to_quickbooks_connect_route_after_setting_intended_session_if_account_not_linked() + { + $this->request_mock + ->shouldReceive('path') + ->once() + ->withNoArgs() + ->andReturn('path'); + + $this->url_generator_mock + ->shouldReceive('to') + ->once() + ->with('path') + ->andReturn('http://to/path'); + + $this->session_mock + ->shouldReceive('put') + ->once() + ->withArgs(['url.intended', 'http://to/path']) + ->andReturnNull(); + + $this->redirector_mock + ->shouldReceive('route') + ->once() + ->with('quickbooks.connect') + ->andReturnSelf(); + + $next_middleware = function ($request) { + // If this is called, then fail test + $this->assertTrue(false); + }; + + $this->quickbooks_mock + ->shouldReceive('hasValidRefreshToken') + ->once() + ->withNoArgs() + ->andReturnFalse(); + + $this->filter->handle($this->request_mock, $next_middleware); + } +} diff --git a/tests/Stubs/TokenStub.php b/tests/Stubs/TokenStub.php new file mode 100644 index 0000000..99fb9cb --- /dev/null +++ b/tests/Stubs/TokenStub.php @@ -0,0 +1,49 @@ +shouldIgnoreMissing(); + + $child_mock = Mockery::mock(Model::class); + $child_mock->shouldIgnoreMissing(); + + $builder_mock = Mockery::mock(Builder::class); + $builder_mock->shouldIgnoreMissing(); + $builder_mock->shouldReceive('getModel')->andReturn($related_mock); + + return new BelongsTo($builder_mock, $child_mock, $foreignKey, $ownerKey, $relation); + } +} diff --git a/tests/Stubs/User.php b/tests/Stubs/User.php new file mode 100644 index 0000000..f83fb0e --- /dev/null +++ b/tests/Stubs/User.php @@ -0,0 +1,34 @@ +shouldIgnoreMissing(); + + $builder_mock = Mockery::mock(Builder::class); + $builder_mock->shouldIgnoreMissing(); + $builder_mock->shouldReceive('getModel')->andReturn($related_mock); + + $parent_mock = Mockery::mock(Model::class); + $parent_mock->shouldIgnoreMissing(); + + return new HasOne($builder_mock, $parent_mock, 'foreignKey', 'localKey'); + } +} diff --git a/tests/TestCase.php b/tests/TestCase.php new file mode 100644 index 0000000..d077b9c --- /dev/null +++ b/tests/TestCase.php @@ -0,0 +1,75 @@ + $val) { + $mock + ->shouldReceive('offsetGet') + ->with($key) + ->andReturn($val); + + $mock + ->shouldReceive('offsetExists') + ->with($key) + ->andReturn(true); + } + + $mock->shouldReceive('offsetExists')->andReturn(false); + } + + if ($mock instanceof Iterator) { + $counter = 0; + + $mock->shouldReceive('rewind')->andReturnUsing(function () use (&$counter) { + $counter = 0; + }); + + $vals = array_values($items); + $keys = array_values(array_keys($items)); + + $mock->shouldReceive('valid')->andReturnUsing(function () use (&$counter, $vals) { + return isset($vals[$counter]); + }); + + $mock->shouldReceive('current')->andReturnUsing(function () use (&$counter, $vals) { + return $vals[$counter]; + }); + + $mock->shouldReceive('key')->andReturnUsing(function () use (&$counter, $keys) { + return $keys[$counter]; + }); + + $mock->shouldReceive('next')->andReturnUsing(function () use (&$counter) { + $counter++; + }); + } + + if ($mock instanceof Countable) { + $mock->shouldReceive('count')->andReturn(count($items)); + } + } +} diff --git a/tests/TokenTest.php b/tests/TokenTest.php new file mode 100644 index 0000000..1db9418 --- /dev/null +++ b/tests/TokenTest.php @@ -0,0 +1,212 @@ +token = new TokenStub(); + } + + /** + * @test + */ + public function it_can_be_constructed() + { + $this->assertInstanceOf(Token::class, $this->token); + } + + /** + * @test + */ + public function it_has_accessor_for_valid_access_token() + { + $this->assertNotNull($this->token->getHasValidAccessTokenAttribute()); + } + + /** + * @test + */ + public function it_has_accessor_for_valid_refresh_token() + { + $this->assertNotNull($this->token->getHasValidRefreshTokenAttribute()); + } + + /** + * @test + */ + public function it_knows_that_the_access_token_expires_at_has_to_be_valid() + { + $this->assertFalse($this->token->getHasValidAccessTokenAttribute()); + } + + /** + * @test + */ + public function it_knows_that_the_refresh_token_expires_at_has_to_be_valid() + { + $this->assertFalse($this->token->getHasValidRefreshTokenAttribute()); + } + + /** + * @test + */ + public function it_know_if_access_token_expires_at_is_less_than_now_it_is_not_expired() + { + $now = Carbon::now(); + Carbon::setTestNow($now); + + $this->token->access_token_expires_at = Carbon::now()->addSecond(); + + $this->assertTrue($this->token->getHasValidAccessTokenAttribute()); + } + + /** + * @test + */ + public function it_know_if_refresh_token_expires_at_is_less_than_now_it_is_not_expired() + { + $now = Carbon::now(); + Carbon::setTestNow($now); + + $this->token->refresh_token_expires_at = Carbon::now()->addSecond(); + + $this->assertTrue($this->token->getHasValidRefreshTokenAttribute()); + } + + /** + * @test + */ + public function it_know_if_access_token_expires_at_is_greater_than_or_equal_to_now_it_is_expired() + { + $now = Carbon::now(); + Carbon::setTestNow($now); + + $this->token->access_token_expires_at = Carbon::now(); + + $this->assertFalse($this->token->getHasValidAccessTokenAttribute()); + + $this->token->access_token_expires_at->subSecond(); + + $this->assertFalse($this->token->getHasValidAccessTokenAttribute()); + } + + /** + * @test + */ + public function it_know_if_refresh_token_expires_at_is_greater_than_or_equal_to_now_it_is_expired() + { + $now = Carbon::now(); + Carbon::setTestNow($now); + + $this->token->refresh_token_expires_at = Carbon::now(); + + $this->assertFalse($this->token->getHasValidRefreshTokenAttribute()); + + $this->token->refresh_token_expires_at->subSecond(); + + $this->assertFalse($this->token->getHasValidRefreshTokenAttribute()); + } + + /** + * @test + */ + public function it_stores_the_oauth_token_parts_in_expected_properties() + { + $oauth_token_mock = Mockery::mock(OAuth2AccessToken::class); + + $oauth_token_mock + ->shouldReceive('getAccessToken') + ->once() + ->withNoArgs() + ->andReturn('access_token'); + + $oauth_token_mock + ->shouldReceive('getAccessTokenExpiresAt') + ->once() + ->withNoArgs() + ->andReturn('now'); + + $oauth_token_mock + ->shouldReceive('getRealmID') + ->once() + ->withNoArgs() + ->andReturn('realm_id'); + + $oauth_token_mock + ->shouldReceive('getRefreshToken') + ->once() + ->withNoArgs() + ->andReturn('refresh_token'); + + $oauth_token_mock + ->shouldReceive('getRefreshTokenExpiresAt') + ->once() + ->withNoArgs() + ->andReturn('now'); + + $this->token->parseOauthToken($oauth_token_mock); + } + + /** + * @test + */ + public function it_allows_itself_to_be_deleted_and_returns_new_token() + { + $this->token->user = Mockery::mock(User::class); + + $has_one_mock = Mockery::mock(HasOne::class); + $token_mock = Mockery::mock(Token::class); + + $has_one_mock + ->shouldReceive('make') + ->once() + ->withNoArgs() + ->andReturn($token_mock); + + $this->token->user + ->shouldReceive('quickBooksToken') + ->once() + ->withNoArgs() + ->andReturn($has_one_mock); + + $this->token->user->id = 1; + + $this->assertEquals($token_mock, $this->token->remove()); + } + + /** + * @test + */ + public function it_get_related_user_model_from_configuration() + { + $this->assertInstanceOf(User::class, $this->token->user()->getModel()); + } +} + +function config($key) +{ + return [ + 'keys' => [ + 'foreign' => 'user_id', + 'owner' => 'id', + ], + 'model' => User::class, + ]; +}