From 4b85e01e93dd47cadb8f141fa8fb72d8fb90d0a7 Mon Sep 17 00:00:00 2001 From: Deon George Date: Thu, 18 Aug 2022 23:29:42 +1000 Subject: [PATCH] Customer account sync with Intuit --- app/Classes/External/Accounting.php | 7 - .../External/Accounting/Quickbooks.php | 68 ----- .../Commands/AccountingAccountSync.php | 48 ++++ app/Console/Commands/QuickAccounts.php | 261 ------------------ app/Jobs/AccountingAccountSync.php | 95 +++++++ app/Models/Account.php | 9 + app/Models/ProviderOauth.php | 33 +++ app/Models/ProviderToken.php | 4 +- composer.lock | 28 +- config/services.php | 8 +- .../2022_08_15_171005_qb_account_sync.php | 38 +++ 11 files changed, 246 insertions(+), 353 deletions(-) delete mode 100644 app/Classes/External/Accounting.php delete mode 100644 app/Classes/External/Accounting/Quickbooks.php create mode 100644 app/Console/Commands/AccountingAccountSync.php delete mode 100644 app/Console/Commands/QuickAccounts.php create mode 100644 app/Jobs/AccountingAccountSync.php create mode 100644 database/migrations/2022_08_15_171005_qb_account_sync.php diff --git a/app/Classes/External/Accounting.php b/app/Classes/External/Accounting.php deleted file mode 100644 index 40f79d5..0000000 --- a/app/Classes/External/Accounting.php +++ /dev/null @@ -1,7 +0,0 @@ -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 getInvoice(int $id,$refresh=FALSE) - { - if ($refresh) - Cache::forget(__METHOD__.$id); - - return Cache::remember(__METHOD__.$id,86400,function() use ($id) { - return $this->api->getDataService()->Query(sprintf("SELECT * FROM Invoice where id = '%s'",$id)); - }); - } - - public function getInvoices($refresh=FALSE): Collection - { - if ($refresh) - Cache::forget(__METHOD__); - - return Cache::remember(__METHOD__,86400,function() { - return collect($this->api->getDataService()->Query('SELECT * FROM Invoice')); - }); - } - - 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/AccountingAccountSync.php b/app/Console/Commands/AccountingAccountSync.php new file mode 100644 index 0000000..144fc89 --- /dev/null +++ b/app/Console/Commands/AccountingAccountSync.php @@ -0,0 +1,48 @@ +argument('siteid')); + Config::set('site',$site); + + $so = ProviderOauth::where('name',$this->argument('provider'))->singleOrFail(); + $uo = User::where('email',$this->argument('user'))->singleOrFail(); + + if (($x=$so->tokens->where('user_id',$uo->id))->count() !== 1) + abort(500,sprintf('Unknown Tokens for [%s]',$uo->email)); + + Job::dispatchSync($x->pop()); + } +} \ No newline at end of file diff --git a/app/Console/Commands/QuickAccounts.php b/app/Console/Commands/QuickAccounts.php deleted file mode 100644 index ba574c9..0000000 --- a/app/Console/Commands/QuickAccounts.php +++ /dev/null @@ -1,261 +0,0 @@ -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/Jobs/AccountingAccountSync.php b/app/Jobs/AccountingAccountSync.php new file mode 100644 index 0000000..388844a --- /dev/null +++ b/app/Jobs/AccountingAccountSync.php @@ -0,0 +1,95 @@ +to = $to; + } + + /** + * Execute the job. + * + * @return void + */ + public function handle() + { + $api = $this->to->provider->API($this->to); + $accounts = Account::get(); + + foreach ($api->getCustomers() as $customer) { + $ao = NULL; + + // See if we are already linked + if (($x=$this->to->provider->accounts->where('pivot.ref',$customer->id))->count() === 1) { + $ao = $x->pop(); + + // If not, see if our reference matches + } elseif (($x=$accounts->filter(function($item) use ($customer) { return $item->sid == $customer->ref; }))->count() === 1) { + $ao = $x->pop(); + + // Look based on Name + } elseif (($x=$accounts->filter(function($item) use ($customer) { return $item->company == $customer->companyname || $item->name == $customer->fullname; }))->count() === 1) { + $ao = $x->pop(); + + } else { + // Log not found + Log::alert(sprintf('%s:Customer not found [%s:%s]',self::LOGKEY,$customer->id,$customer->DisplayName)); + continue; + } + + $ao->providers()->syncWithoutDetaching([ + $this->to->provider->id => [ + 'ref' => $customer->id, + 'synctoken' => $customer->synctoken, + 'created_at'=>Carbon::create($customer->created_at), + 'updated_at'=>Carbon::create($customer->updated_at), + 'site_id'=>$ao->site_id, // @todo See if we can have this handled automatically + ], + ]); + + // Check if QB is out of Sync and update it. + $customer->syncOriginal(); + $customer->PrimaryEmailAddr = (object)['Address'=>$ao->user->email]; + $customer->ResaleNum = $ao->sid; + $customer->GivenName = $ao->user->firstname; + $customer->FamilyName = $ao->user->lastname; + $customer->CompanyName = $ao->name; + $customer->DisplayName = $ao->name; + $customer->FullyQualifiedName = $ao->name; + //$customer->Active = (bool)$ao->active; + + if ($customer->getDirty()) { + Log::info(sprintf('%s:Customer [%s] (%s:%s) has changed',self::LOGKEY,$ao->sid,$customer->id,$customer->DisplayName),['dirty'=>$customer->getDirty()]); + $customer->sparse = 'true'; + + AccountingCustomerUpdate::dispatch($this->to,$customer); + } + } + } +} \ No newline at end of file diff --git a/app/Models/Account.php b/app/Models/Account.php index 9e531f1..c62631d 100644 --- a/app/Models/Account.php +++ b/app/Models/Account.php @@ -7,6 +7,7 @@ use Illuminate\Database\Eloquent\Model; use Leenooks\Traits\ScopeActive; use App\Interfaces\IDs; +use App\Traits\SiteID; /** * Class Account @@ -22,6 +23,7 @@ use App\Interfaces\IDs; */ class Account extends Model implements IDs { + use SiteID; use HasFactory,ScopeActive; /* INTERFACES */ @@ -43,6 +45,13 @@ class Account extends Model implements IDs return $this->hasMany(Charge::class); } + public function providers() + { + return $this->belongsToMany(ProviderOauth::class,'account_provider') + ->where('account_provider.site_id',$this->site_id) + ->withPivot('ref','synctoken','created_at','updated_at'); + } + /** * Return the country the user belongs to */ diff --git a/app/Models/ProviderOauth.php b/app/Models/ProviderOauth.php index c0bbc19..356e305 100644 --- a/app/Models/ProviderOauth.php +++ b/app/Models/ProviderOauth.php @@ -4,14 +4,25 @@ namespace App\Models; use Illuminate\Database\Eloquent\Model; +use App\Traits\SiteID; + class ProviderOauth extends Model { + use SiteID; + protected $table = 'provider_oauth'; protected $fillable = ['name','active']; /* RELATIONS */ + public function accounts() + { + return $this->belongsToMany(Account::class,'account_provider') + ->where('account_provider.site_id',$this->site_id) + ->withPivot('ref','synctoken','created_at','updated_at'); + } + public function tokens() { return $this->hasMany(ProviderToken::class); @@ -21,4 +32,26 @@ class ProviderOauth extends Model { return $this->hasMany(UserOauth::class); } + + /* METHODS */ + + public function api_class(): ?string + { + return config('services.provider.'.strtolower($this->name).'.api'); + } + + public function API(ProviderToken $o,bool $tryprod=FALSE): mixed + { + return ($this->api_class() && $o->access_token) ? new ($this->api_class())($o,$tryprod) : NULL; + } + + /** + * Do we have API details for this supplier + * + * @return bool + */ + public function hasAPIdetails(): bool + { + return $this->api_class() && $this->access_token && (! $this->hasAccessTokenExpired()); + } } \ No newline at end of file diff --git a/app/Models/ProviderToken.php b/app/Models/ProviderToken.php index 6b0dbcd..193f5a3 100644 --- a/app/Models/ProviderToken.php +++ b/app/Models/ProviderToken.php @@ -2,11 +2,11 @@ namespace App\Models; -use Illuminate\Database\Eloquent\Model; +use Intuit\Models\ProviderToken as ProviderTokenBase; use App\Traits\SiteID; -class ProviderToken extends Model +class ProviderToken extends ProviderTokenBase { use SiteID; diff --git a/composer.lock b/composer.lock index c34d097..f724c2b 100644 --- a/composer.lock +++ b/composer.lock @@ -1960,16 +1960,16 @@ }, { "name": "laravel/framework", - "version": "v9.24.0", + "version": "v9.25.1", "source": { "type": "git", "url": "https://github.com/laravel/framework.git", - "reference": "053840f579cf01d353d81333802afced79b1c0af" + "reference": "e8af8c2212e3717757ea7f459a655a2e9e771109" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/framework/zipball/053840f579cf01d353d81333802afced79b1c0af", - "reference": "053840f579cf01d353d81333802afced79b1c0af", + "url": "https://api.github.com/repos/laravel/framework/zipball/e8af8c2212e3717757ea7f459a655a2e9e771109", + "reference": "e8af8c2212e3717757ea7f459a655a2e9e771109", "shasum": "" }, "require": { @@ -2136,7 +2136,7 @@ "issues": "https://github.com/laravel/framework/issues", "source": "https://github.com/laravel/framework" }, - "time": "2022-08-09T13:43:22+00:00" + "time": "2022-08-16T16:36:05+00:00" }, { "name": "laravel/passport", @@ -2862,16 +2862,16 @@ }, { "name": "league/flysystem", - "version": "3.2.0", + "version": "3.2.1", "source": { "type": "git", "url": "https://github.com/thephpleague/flysystem.git", - "reference": "ed0ecc7f9b5c2f4a9872185846974a808a3b052a" + "reference": "81aea9e5217084c7850cd36e1587ee4aad721c6b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/flysystem/zipball/ed0ecc7f9b5c2f4a9872185846974a808a3b052a", - "reference": "ed0ecc7f9b5c2f4a9872185846974a808a3b052a", + "url": "https://api.github.com/repos/thephpleague/flysystem/zipball/81aea9e5217084c7850cd36e1587ee4aad721c6b", + "reference": "81aea9e5217084c7850cd36e1587ee4aad721c6b", "shasum": "" }, "require": { @@ -2932,7 +2932,7 @@ ], "support": { "issues": "https://github.com/thephpleague/flysystem/issues", - "source": "https://github.com/thephpleague/flysystem/tree/3.2.0" + "source": "https://github.com/thephpleague/flysystem/tree/3.2.1" }, "funding": [ { @@ -2948,7 +2948,7 @@ "type": "tidelift" } ], - "time": "2022-07-26T07:26:36+00:00" + "time": "2022-08-14T20:48:34+00:00" }, { "name": "league/mime-type-detection", @@ -3373,11 +3373,11 @@ }, { "name": "leenooks/intuit", - "version": "0.1.0", + "version": "0.1.1", "source": { "type": "git", "url": "https://dev.leenooks.net/leenooks/intuit", - "reference": "bbbdff040fa7d49e5acec38ce3a7c5a35af58a30" + "reference": "1567806b87b27b588545a3a630cffc575ef07141" }, "require": { "jenssegers/model": "^1.5" @@ -3407,7 +3407,7 @@ "laravel", "leenooks" ], - "time": "2022-08-12T04:41:22+00:00" + "time": "2022-08-18T13:18:14+00:00" }, { "name": "leenooks/laravel", diff --git a/config/services.php b/config/services.php index cfcc8f2..6052581 100644 --- a/config/services.php +++ b/config/services.php @@ -43,10 +43,16 @@ return [ 'redirect' => '/auth/google/callback', ], + 'provider' => [ + 'intuit' => [ + 'api'=> \Intuit\API::class, + ] + ], + 'supplier' => [ 'crazydomain' => [ 'api'=> \Dreamscape\API::class, 'registrar' => 'crazydomain', // Key in the domain_registrars table - ] + ], ], ]; diff --git a/database/migrations/2022_08_15_171005_qb_account_sync.php b/database/migrations/2022_08_15_171005_qb_account_sync.php new file mode 100644 index 0000000..8d0625b --- /dev/null +++ b/database/migrations/2022_08_15_171005_qb_account_sync.php @@ -0,0 +1,38 @@ +timestamps(); + $table->integer('account_id')->unsigned(); + $table->integer('provider_oauth_id')->unsigned(); + $table->integer('site_id')->unsigned(); + $table->string('ref'); + $table->integer('synctoken'); + + $table->foreign(['account_id','site_id'])->references(['id','site_id'])->on('accounts'); + $table->foreign(['provider_oauth_id','site_id'])->references(['id','site_id'])->on('provider_oauth'); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::dropIfExists('account_provider'); + } +};