Initial commit

This commit is contained in:
divakar-rajodiya
2024-06-25 17:45:35 +05:30
commit b145af20c4
28 changed files with 2250 additions and 0 deletions
+259
View File
@@ -0,0 +1,259 @@
<?php
namespace Wearepixel\QuickBooks;
use Exception;
use QuickBooksOnline\API\DataService\DataService;
use QuickBooksOnline\API\Exception\SdkException;
use QuickBooksOnline\API\Exception\ServiceException;
use QuickBooksOnline\API\ReportService\ReportService;
/**
* Class Client
*/
class Client
{
/**
* The configs to set up a DataService
*
* @var array
*/
protected $configs;
/**
* The DataService instance
*
* @var DataService
*/
protected $data_service;
/**
* The ReportService instance
*
* @var ReportService
*/
protected $report_service;
/**
* The Token instance
*
* @var Token
*/
protected $token;
/**
* Client constructor.
*/
public function __construct(array $configs, Token $token)
{
$this->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;
}
}
+16
View File
@@ -0,0 +1,16 @@
<?php
namespace Wearepixel\QuickBooks;
use Illuminate\Database\Eloquent\Relations\HasOne;
trait HasQuickBooksToken
{
/**
* Have a quickBooksToken.
*/
public function quickBooksToken(): HasOne
{
return $this->hasOne(Token::class);
}
}
+87
View File
@@ -0,0 +1,87 @@
<?php
namespace Wearepixel\QuickBooks\Http\Controllers;
use Exception;
use Illuminate\Contracts\Routing\UrlGenerator;
use Illuminate\Contracts\View\Factory as ViewFactory;
use Illuminate\Contracts\View\View as ViewContract;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller as LaravelController;
use Illuminate\Routing\Redirector;
use Illuminate\Support\Facades\View;
use QuickBooksOnline\API\Exception\SdkException;
use QuickBooksOnline\API\Exception\ServiceException;
use Wearepixel\QuickBooks\Client as QuickBooks;
/**
* Class Controller
*/
class Controller extends LaravelController
{
// NOTE: When using constructor injection for QuickBooks, there is a race issue with the boot order of the app
/**
* Form to connect/disconnect user to QuickBooks
*
* If the user has a valid OAuth, then give form to disconnect, otherwise link to connect it
*
* @throws SdkException
* @throws ServiceException
*/
public function connect(QuickBooks $quickbooks, ViewFactory $view_factory): ViewContract|View
{
// Give view to remove token if user already linked account
if ($quickbooks->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'));
}
}
+42
View File
@@ -0,0 +1,42 @@
<?php
namespace Wearepixel\QuickBooks\Http\Middleware;
use Closure;
use Illuminate\Contracts\Routing\UrlGenerator;
use Illuminate\Contracts\Session\Session;
use Illuminate\Http\Request;
use Illuminate\Routing\Redirector;
use Wearepixel\QuickBooks\Client as QuickBooks;
/**
* Class Filter
*/
class Filter
{
/**
* Create a new QuickBooks filter middleware instance.
*/
public function __construct(
protected QuickBooks $quickbooks,
protected Redirector $redirector,
protected Session $session,
protected UrlGenerator $url_generator,
) {
}
/**
* Handle an incoming request.
*/
public function handle(Request $request, Closure $next): mixed
{
if (! $this->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);
}
}
+45
View File
@@ -0,0 +1,45 @@
<?php
namespace Wearepixel\QuickBooks\Providers;
use Illuminate\Contracts\Foundation\Application;
use Illuminate\Support\ServiceProvider as LaravelServiceProvider;
use Wearepixel\QuickBooks\Client;
/**
* Class ClientServiceProvider
*/
class ClientServiceProvider extends LaravelServiceProvider
{
/**
* Indicates if loading of the provider is deferred.
*/
protected bool $defer = true;
/**
* Get the services provided by the provider.
*/
public function provides(): array
{
return [Client::class];
}
/**
* Register the application services.
*/
public function register(): void
{
$this->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');
}
}
+115
View File
@@ -0,0 +1,115 @@
<?php
namespace Wearepixel\QuickBooks\Providers;
use Illuminate\Routing\Router;
use Illuminate\Support\ServiceProvider as LaravelServiceProvider;
use Wearepixel\QuickBooks\Http\Middleware\Filter;
/**
* Class ServiceProvider
*/
class ServiceProvider extends LaravelServiceProvider
{
/**
* Bootstrap the application services.
*/
public function boot(): void
{
$this->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');
}
}
+126
View File
@@ -0,0 +1,126 @@
<?php
namespace Wearepixel\QuickBooks;
use App\Models\User;
use Carbon\Carbon;
use Exception;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use QuickBooksOnline\API\Core\OAuth\OAuth2\OAuth2AccessToken;
use QuickBooksOnline\API\Exception\SdkException;
/**
* Class Token
*
*
* @property bool $hasValidAccessToken Is the access token valid
* @property bool $hasValidRefreshToken Is the refresh token valid
* @property Carbon $access_token_expires_at Timestamp that the access token expires
* @property Carbon $refresh_token_expires_at Timestamp that the refresh token expires
* @property int $user_id Id of the related User
* @property string $access_token The access token
* @property string $realm_id Realm Id from the OAuth token
* @property string $refresh_token The refresh token
* @property User $user
*/
class Token extends Model
{
/**
* The table associated with the model.
*
* @var string
*/
protected $table = 'quickbooks_tokens';
/**
* The attributes that should be mutated to dates.
*
* @var array
*/
protected $dates = ['access_token_expires_at', 'refresh_token_expires_at'];
/**
* The attributes that are mass assignable.
*
* @var array
*/
protected $fillable = [
'access_token',
'access_token_expires_at',
'realm_id',
'refresh_token',
'refresh_token_expires_at',
'user_id',
];
/**
* Check if access token is valid
*
* A token is good for 1 hour, so if it expires greater than 1 hour from now, it is still valid
*/
public function getHasValidAccessTokenAttribute(): bool
{
return $this->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'],
);
}
}