From 9fa773d283caa654580266b3a37a1132bf4bec35 Mon Sep 17 00:00:00 2001 From: Deon George Date: Wed, 12 Jun 2019 16:25:15 +1000 Subject: [PATCH] Initial integration with Quickbooks --- app/Classes/External/Accounting.php | 7 + .../External/Accounting/Quickbooks.php | 50 ++++ app/Console/Commands/QuickAccounts.php | 269 ++++++++++++++++++ app/Models/Account.php | 25 ++ app/Models/External/Integrations.php | 27 ++ app/User.php | 3 +- composer.json | 1 + composer.lock | 63 +++- config/quickbooks.php | 96 +++++++ ...141103_create_quick_books_tokens_table.php | 43 +++ .../2019_06_11_160940_add_external.php | 37 +++ ...2019_06_11_161150_add_external_account.php | 39 +++ 12 files changed, 658 insertions(+), 2 deletions(-) create mode 100644 app/Classes/External/Accounting.php create mode 100644 app/Classes/External/Accounting/Quickbooks.php create mode 100644 app/Console/Commands/QuickAccounts.php create mode 100644 app/Models/External/Integrations.php create mode 100644 config/quickbooks.php create mode 100644 database/migrations/2018_03_11_141103_create_quick_books_tokens_table.php create mode 100644 database/migrations/2019_06_11_160940_add_external.php create mode 100644 database/migrations/2019_06_11_161150_add_external_account.php diff --git a/app/Classes/External/Accounting.php b/app/Classes/External/Accounting.php new file mode 100644 index 0000000..40f79d5 --- /dev/null +++ b/app/Classes/External/Accounting.php @@ -0,0 +1,7 @@ +id); + + $this->api = app('Spinen\QuickBooks\Client'); + } + + public function getCustomers($refresh=FALSE): Collection + { + if ($refresh) + Cache::forget(__METHOD__); + + return Cache::remember(__METHOD__,86400,function() { + return collect($this->api->getDataService()->Query('SELECT * FROM Customer')); + }); + } + + public function updateCustomer(IPPCustomer $r,array $args) + { + $r->sparse = TRUE; + + foreach ($args as $k=>$v) + { + $r->{$k} = $v; + } + + return $this->api->getDataService()->Update($r); + } +} \ No newline at end of file diff --git a/app/Console/Commands/QuickAccounts.php b/app/Console/Commands/QuickAccounts.php new file mode 100644 index 0000000..9593af4 --- /dev/null +++ b/app/Console/Commands/QuickAccounts.php @@ -0,0 +1,269 @@ +$query->sql,'binding'=>$query->bindings]); + }); + + foreach (Integrations::active()->type('ACCOUNTING')->get() as $into) + { + switch ($into->name) + { + case 'quickbooks': + $api = new Quickbooks($into->user); + break; + + default: + throw new \Exception('No handler for: ',$into-name); + } + + foreach ($api->getCustomers(TRUE) as $r) + { + $this->info(sprintf('Checking [%s] (%s)',$r->Id,$r->DisplayName)); + + if ($r->Notes == 'Cash Only') + { + $this->warn(sprintf('Skipping [%s] (%s)',$r->Id,$r->DisplayName)); + continue; + } + + if (! $this->option('match') AND (! $r->CompanyName AND (! $r->FamilyName AND ! $r->GivenName))) + { + $this->error(sprintf('No COMPANY or PERSONAL details for [%s] (%s)',$r->Id,$r->DisplayName)); + continue; + } + + if ($this->option('match')) { + $ao = Account::where('company',$r->DisplayName); + + if (! $ao->count()) { + $uo = User::where('lastname',$r->FamilyName); + + if ($r->GivenName) + $uo->where('firstname',$r->GivenName); + + if ($uo->count() > 1 OR (! $uo->count())) + { + $this->error(sprintf('No SINGLE Users matched for [%s] (%s)',$r->Id,$r->DisplayName)); + continue; + } + + $uo = $uo->first(); + $ao = $uo->accounts->where('active',TRUE); + } + + } else { + if ($r->CompanyName) { + $ao = Account::where('company',$this->option('match') ? $r->DisplayName : $r->CompanyName); + } else { + $uo = User::where('lastname',$r->FamilyName); + + if ($r->GivenName) + $uo->where('firstname',$r->GivenName); + + if ($uo->count() > 1 OR (! $uo->count())) + { + $this->error(sprintf('No SINGLE matched for [%s] (%s)',$r->Id,$r->DisplayName)); + continue; + } + + $uo = $uo->first(); + $ao = $uo->accounts->where('active',TRUE); + } + } + + if (! $ao->count()) + { + $this->error(sprintf('No Accounts matched for [%s] (%s)',$r->Id,$r->DisplayName)); + continue; + } + + if ($ao->count() > 1) + { + $this->error(sprintf('Too Many Accounts (%s) matched for [%s] (%s)',$ao->count(),$r->Id,$r->DisplayName)); + continue; + } + + $ao = $ao->first(); + + // If we are matching on DisplayName, make sure the account is updated correct for Business or Personal Accounts + $oldr = clone $r; + + // @NOTE: This overwrites the ABN if it exists. + if ($r->PrimaryTaxIdentifier) + { + $this->warn(sprintf('ABN Overwrite for (%s)',$r->DisplayName)); + } + + switch ($ao->type) + { + case 'Business': + $r->CompanyName = $ao->company; + $r->ResaleNum = $ao->AccountId; + $r->SalesTermRef = '7'; // @todo + + if ($ao->first_name) + $r->GivenName = chop($ao->user->firstname); // @todo shouldnt be required + if ($ao->last_name) + $r->FamilyName = $ao->user->lastname; + + if ($ao->address1) + { + if (! $r->BillAddr) + $r->BillAddr = new IPPPhysicalAddress; + + $r->BillAddr->Line1 = $ao->user->address1; + $r->BillAddr->Line2 = $ao->user->address2; + $r->BillAddr->City = $ao->user->city; + $r->BillAddr->CountrySubDivisionCode = strtoupper($ao->user->state); + $r->BillAddr->PostalCode = $ao->user->postcode; + $r->BillAddr->Country = 'Australia'; // @todo + + //$r->ShipAddr = $r->BillAddr; + } + + if ($ao->email) { + if (! $r->PrimaryEmailAddr) + $r->PrimaryEmailAddr = new IPPEmailAddress; + + $r->PrimaryEmailAddr->Address = $ao->user->email; + $r->PreferredDeliveryMethod = 'Email'; + } + + if (! $r->Balance) + $r->Active = $ao->active ? 'true' : 'false'; + + break; + + case 'Private': + $r->CompanyName = NULL; + $r->DisplayName = sprintf('%s %s',$ao->user->lastname,$ao->user->firstname); + $r->ResaleNum = $ao->AccountId; + $r->SalesTermRef = '7'; // @todo + + if ($ao->first_name) + $r->GivenName = chop($ao->user->firstname); // @todo shouldnt be required + if ($ao->last_name) + $r->FamilyName = $ao->user->lastname; + + if ($ao->address1) + { + if (! $r->BillAddr) + $r->BillAddr = new IPPPhysicalAddress; + + $r->BillAddr->Line1 = $ao->user->address1; + $r->BillAddr->Line2 = $ao->user->address2; + $r->BillAddr->City = $ao->user->city; + $r->BillAddr->CountrySubDivisionCode = strtoupper($ao->user->state); + $r->BillAddr->PostalCode = $ao->user->postcode; + $r->BillAddr->Country = 'Australia'; // @todo + + //$r->ShipAddr = $r->BillAddr; + } + + if ($ao->email) { + if (! $r->PrimaryEmailAddr) + $r->PrimaryEmailAddr = new IPPEmailAddress; + + $r->PrimaryEmailAddr->Address = $ao->user->email; + $r->PreferredDeliveryMethod = 'Email'; + } + + if (! $r->Balance) + $r->Active = $ao->active ? 'true' : 'false'; + + break; + + default: + throw new \Exception('Unhandled account type: '.$ao->type); + + } + + // If something changed, lets update it. + if (count(array_diff_assoc(object_to_array($r,FALSE),object_to_array($oldr,FALSE)))) + { + $api->updateCustomer($r,[]); + } + + // If external integration doesnt exist, lets create it. + if (! $ao->ExternalAccounting($into)) + { + $ao->external()->attach([$into->id=>['site_id'=>1,'link'=>$r->Id]]); // @todo site_id + } + + // If the integration ID doesnt exist in the integration source, add it. + if (! $r->ResaleNum) + { + $api->updateCustomer($r,['ResaleNum'=>$ao->AccountId]); + } + + // If integration exist, double check the numbers match + if ($r->ResaleNum != $ao->AccountId) { + $this->warn(sprintf('Integration ID Mismatch AID [%s] ID [%s]',$ao->id,$r->Id)); + continue; + } + } + } + } +} + +function object_to_array($object,$encode=TRUE) +{ + // For child arrays, we just encode + if ($encode) + return json_encode($object); + + if (is_object($object)) { + return array_map(__FUNCTION__,get_object_vars($object)); + } else if (is_array($object)) { + return array_map(__FUNCTION__,$object); + } else { + return $object; + } +} \ No newline at end of file diff --git a/app/Models/Account.php b/app/Models/Account.php index 5a70466..d26d05d 100644 --- a/app/Models/Account.php +++ b/app/Models/Account.php @@ -36,6 +36,11 @@ class Account extends Model return $this->belongsTo(Country::class); } + public function external() + { + return $this->belongsToMany(External\Integrations::class,'external_account',NULL,'external_integration_id'); + } + public function invoices() { return $this->hasMany(Invoice::class); @@ -91,6 +96,11 @@ class Account extends Model return sprintf('',$this->user_id); } + public function getTypeAttribute() + { + return $this->company ? 'Business' : 'Private'; + } + private function _address() { $return = []; @@ -131,4 +141,19 @@ class Account extends Model return $item->active AND $item->due > 0; }); } + + /** + * Get the external account ID for a specific integration + * + * @param External\Integrations $o + * @return mixed + */ + public function ExternalAccounting(External\Integrations $o) + { + return $this + ->external() + ->where('id','=',$o->id) + ->where('site_id','=',$this->site_id) + ->first(); + } } \ No newline at end of file diff --git a/app/Models/External/Integrations.php b/app/Models/External/Integrations.php new file mode 100644 index 0000000..fe39825 --- /dev/null +++ b/app/Models/External/Integrations.php @@ -0,0 +1,27 @@ +belongsTo(User::class); + } + + function scopeActive() + { + return $this->where('active',TRUE); + } + + function scopeType($query,string $type) + { + return $query->where('type',$type); + } +} \ No newline at end of file diff --git a/app/User.php b/app/User.php index 71aca89..bcc1da5 100644 --- a/app/User.php +++ b/app/User.php @@ -10,10 +10,11 @@ use Leenooks\Carbon; use Leenooks\Traits\UserSwitch; use App\Notifications\ResetPasswordNotification; use App\Models\Service; +use Spinen\QuickBooks\HasQuickBooksToken; class User extends Authenticatable { - use HasApiTokens,Notifiable,UserSwitch; + use HasApiTokens,Notifiable,UserSwitch,HasQuickBooksToken; protected $dates = ['created_at','updated_at','last_access']; protected $with = ['accounts.services']; diff --git a/composer.json b/composer.json index 52278e2..cd026d1 100644 --- a/composer.json +++ b/composer.json @@ -28,6 +28,7 @@ "spatie/laravel-demo-mode": "^2.2", "spatie/laravel-failed-job-monitor": "^3.0", "spatie/laravel-sitemap": "^5.2", + "spinen/laravel-quickbooks-client": "^3.0", "tymon/jwt-auth": "^0.5.12" }, "require-dev": { diff --git a/composer.lock b/composer.lock index fa8fdb7..721c17a 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "811c846e37c98427b477d58a8e8ded48", + "content-hash": "8d997e88df489137705fe8a879e16642", "packages": [ { "name": "acacha/user", @@ -4084,6 +4084,67 @@ ], "time": "2018-04-12T09:34:43+00:00" }, + { + "name": "spinen/laravel-quickbooks-client", + "version": "3.0.0", + "source": { + "type": "git", + "url": "https://github.com/spinen/laravel-quickbooks-client.git", + "reference": "7360de3e07f1b397452c0b668787cfc0bb54b674" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/spinen/laravel-quickbooks-client/zipball/7360de3e07f1b397452c0b668787cfc0bb54b674", + "reference": "7360de3e07f1b397452c0b668787cfc0bb54b674", + "shasum": "" + }, + "require": { + "illuminate/database": "5.5.*|5.6.*|5.7.*|5.8.*", + "illuminate/http": "5.5.*|5.6.*|5.7.*|5.8.*", + "illuminate/routing": "5.5.*|5.6.*|5.7.*|5.8.*", + "nesbot/carbon": "^1.26.3 || ^2.0", + "php": ">=7.2", + "quickbooks/v3-php-sdk": "^5.0.3" + }, + "require-dev": { + "mockery/mockery": "^1", + "phpunit/phpunit": "~7.0.1|~8.0", + "psy/psysh": "^0.5.1", + "symfony/thanks": "^1.0" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Spinen\\QuickBooks\\Providers\\ServiceProvider", + "Spinen\\QuickBooks\\Providers\\ClientServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Spinen\\QuickBooks\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jimmy Puckett", + "email": "jimmy.puckett@spinen.com" + } + ], + "description": "SPINEN's Laravel Client for QuickBooks.", + "keywords": [ + "client", + "laravel", + "quickbooks", + "spinen" + ], + "time": "2019-02-27T02:38:48+00:00" + }, { "name": "swiftmailer/swiftmailer", "version": "v6.2.1", diff --git a/config/quickbooks.php b/config/quickbooks.php new file mode 100644 index 0000000..ed994e4 --- /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..8171f7f --- /dev/null +++ b/database/migrations/2018_03_11_141103_create_quick_books_tokens_table.php @@ -0,0 +1,43 @@ +increments('id'); + $table->unsignedInteger('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_token'); + } +} diff --git a/database/migrations/2019_06_11_160940_add_external.php b/database/migrations/2019_06_11_160940_add_external.php new file mode 100644 index 0000000..644941f --- /dev/null +++ b/database/migrations/2019_06_11_160940_add_external.php @@ -0,0 +1,37 @@ +increments('id'); + $table->string('name'); + $table->longText('description'); + $table->string('type'); + $table->boolean('active'); + + $table->integer('user_id')->unsigned(); + $table->foreign('user_id')->references('id')->on('users'); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::dropIfExists('external_integrations'); + } +} diff --git a/database/migrations/2019_06_11_161150_add_external_account.php b/database/migrations/2019_06_11_161150_add_external_account.php new file mode 100644 index 0000000..2c323e9 --- /dev/null +++ b/database/migrations/2019_06_11_161150_add_external_account.php @@ -0,0 +1,39 @@ +bigInteger('account_id'); + $table->integer('site_id')->nullable(); + $table->integer('external_integration_id')->unsigned(); + $table->string('link'); + + $table->foreign('site_id')->references('id')->on('ab_setup'); + $table->foreign('external_integration_id')->references('id')->on('external_integrations'); + $table->foreign('account_id')->references('id')->on('ab_account'); + + $table->unique(['site_id','account_id','external_integration_id'],'sae'); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::dropIfExists('external_account'); + } +}