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');
+ }
+}