Customer account sync with Intuit

This commit is contained in:
Deon George
2022-08-18 23:29:42 +10:00
parent c1a64e2094
commit 4b85e01e93
11 changed files with 246 additions and 353 deletions

View File

@@ -1,7 +0,0 @@
<?php
namespace App\Classes\External;
abstract class Accounting
{
}

View File

@@ -1,68 +0,0 @@
<?php
namespace App\Classes\External\Accounting;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Cache;
use QuickBooksOnline\API\Data\IPPCustomer;
use App\Classes\External\Accounting as Base;
use App\Models\User;
class Quickbooks extends Base
{
private $api = NULL;
public function __construct(User $uo)
{
if (Auth::user())
throw new \Exception('User logged in - *TODO* handle this');
Auth::loginUsingId($uo->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);
}
}

View File

@@ -0,0 +1,48 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Config;
use App\Models\{ProviderOauth,Site,User};
use App\Jobs\AccountingAccountSync as Job;
class AccountingAccountSync extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'accounting:account:sync'
.' {siteid : Site ID}'
.' {provider : Provider Name}'
.' {user : User Email}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Command description';
/**
* Execute the console command.
*
* @return int
*/
public function handle()
{
$site = Site::findOrFail($this->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());
}
}

View File

@@ -1,261 +0,0 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use QuickBooksOnline\API\Data\IPPEmailAddress;
use QuickBooksOnline\API\Data\IPPPhysicalAddress;
use App\Classes\External\Accounting\Quickbooks;
use App\Models\{Account,External\Integrations,User};
class QuickAccounts extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'external:sync:accounts {--m|match : Match Display Name}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Sync Account numbers with External Sources';
/**
* Create a new command instance.
*
* @return void
*/
public function __construct()
{
parent::__construct();
}
/**
* Execute the console command.
*
* @return mixed
* @throws \Exception
*/
public function handle()
{
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;
}
}

View File

@@ -0,0 +1,95 @@
<?php
namespace App\Jobs;
use Carbon\Carbon;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeUnique;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
use Intuit\Jobs\AccountingCustomerUpdate;
use App\Models\{Account,ProviderToken};
class AccountingAccountSync implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
private const LOGKEY = 'JAS';
private ProviderToken $to;
/**
* Create a new job instance.
*
* @return void
*/
public function __construct(ProviderToken $to)
{
$this->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);
}
}
}
}

View File

@@ -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
*/

View File

@@ -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());
}
}

View File

@@ -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;