This commit is contained in:
David Bomba 2024-09-22 19:27:34 +10:00
parent df52a48701
commit 01a42bb7e2
18 changed files with 472 additions and 58 deletions

View File

@ -0,0 +1,41 @@
<?php
/**
* Invoice Ninja (https://invoiceninja.com).
*
* @link https://github.com/invoiceninja/invoiceninja source repository
*
* @copyright Copyright (c) 2021. Invoice Ninja LLC (https://invoiceninja.com)
*
* @license https://www.elastic.co/licensing/elastic-license
*/
namespace App\Casts;
use App\DataMapper\ClientSync;
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
class ClientSyncCast implements CastsAttributes
{
public function get($model, string $key, $value, array $attributes)
{
$data = json_decode($value, true);
if (!is_array($data)) {
return null;
}
$is = new ClientSync();
$is->qb_id = $data['qb_id'];
return $is;
}
public function set($model, string $key, $value, array $attributes)
{
return [
$key => json_encode([
'qb_id' => $value->qb_id,
])
];
}
}

View File

@ -0,0 +1,41 @@
<?php
/**
* Invoice Ninja (https://invoiceninja.com).
*
* @link https://github.com/invoiceninja/invoiceninja source repository
*
* @copyright Copyright (c) 2021. Invoice Ninja LLC (https://invoiceninja.com)
*
* @license https://www.elastic.co/licensing/elastic-license
*/
namespace App\Casts;
use App\DataMapper\InvoiceSync;
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
class InvoiceSyncCast implements CastsAttributes
{
public function get($model, string $key, $value, array $attributes)
{
$data = json_decode($value, true);
if (!is_array($data)) {
return null;
}
$is = new InvoiceSync();
$is->qb_id = $data['qb_id'];
return $is;
}
public function set($model, string $key, $value, array $attributes)
{
return [
$key => json_encode([
'qb_id' => $value->qb_id,
])
];
}
}

View File

@ -0,0 +1,41 @@
<?php
/**
* Invoice Ninja (https://invoiceninja.com).
*
* @link https://github.com/invoiceninja/invoiceninja source repository
*
* @copyright Copyright (c) 2021. Invoice Ninja LLC (https://invoiceninja.com)
*
* @license https://www.elastic.co/licensing/elastic-license
*/
namespace App\Casts;
use App\DataMapper\ProductSync;
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
class ProductSyncCast implements CastsAttributes
{
public function get($model, string $key, $value, array $attributes)
{
$data = json_decode($value, true);
if (!is_array($data)) {
return null;
}
$ps = new ProductSync();
$ps->qb_id = $data['qb_id'];
return $ps;
}
public function set($model, string $key, $value, array $attributes)
{
return [
$key => json_encode([
'qb_id' => $value->qb_id,
])
];
}
}

View File

@ -0,0 +1,35 @@
<?php
/**
* Invoice Ninja (https://invoiceninja.com).
*
* @link https://github.com/invoiceninja/invoiceninja source repository
*
* @copyright Copyright (c) 2024. Invoice Ninja LLC (https://invoiceninja.com)
*
* @license https://www.elastic.co/licensing/elastic-license
*/
namespace App\DataMapper;
use App\Casts\ClientSyncCast;
use Illuminate\Contracts\Database\Eloquent\Castable;
/**
* ClientSync.
*/
class ClientSync implements Castable
{
public string $qb_id;
/**
* Get the name of the caster class to use when casting from / to this cast target.
*
* @param array<string, mixed> $arguments
*/
public static function castUsing(array $arguments): string
{
return ClientSyncCast::class;
}
}

View File

@ -0,0 +1,35 @@
<?php
/**
* Invoice Ninja (https://invoiceninja.com).
*
* @link https://github.com/invoiceninja/invoiceninja source repository
*
* @copyright Copyright (c) 2024. Invoice Ninja LLC (https://invoiceninja.com)
*
* @license https://www.elastic.co/licensing/elastic-license
*/
namespace App\DataMapper;
use App\Casts\InvoiceSyncCast;
use Illuminate\Contracts\Database\Eloquent\Castable;
/**
* InvoiceSync.
*/
class InvoiceSync implements Castable
{
public string $qb_id;
/**
* Get the name of the caster class to use when casting from / to this cast target.
*
* @param array<string, mixed> $arguments
*/
public static function castUsing(array $arguments): string
{
return InvoiceSyncCast::class;
}
}

View File

@ -0,0 +1,35 @@
<?php
/**
* Invoice Ninja (https://invoiceninja.com).
*
* @link https://github.com/invoiceninja/invoiceninja source repository
*
* @copyright Copyright (c) 2024. Invoice Ninja LLC (https://invoiceninja.com)
*
* @license https://www.elastic.co/licensing/elastic-license
*/
namespace App\DataMapper;
use App\Casts\ProductSyncCast;
use Illuminate\Contracts\Database\Eloquent\Castable;
/**
* ProductSync.
*/
class ProductSync implements Castable
{
public string $qb_id;
/**
* Get the name of the caster class to use when casting from / to this cast target.
*
* @param array<string, mixed> $arguments
*/
public static function castUsing(array $arguments): string
{
return ProductSyncCast::class;
}
}

View File

@ -18,6 +18,7 @@ use App\Utils\Traits\MakesDates;
use App\DataMapper\FeesAndLimits; use App\DataMapper\FeesAndLimits;
use App\Models\Traits\Excludable; use App\Models\Traits\Excludable;
use App\DataMapper\ClientSettings; use App\DataMapper\ClientSettings;
use App\DataMapper\ClientSync;
use App\DataMapper\CompanySettings; use App\DataMapper\CompanySettings;
use App\Services\Client\ClientService; use App\Services\Client\ClientService;
use App\Utils\Traits\GeneratesCounter; use App\Utils\Traits\GeneratesCounter;
@ -70,6 +71,7 @@ use Illuminate\Contracts\Translation\HasLocalePreference;
* @property int|null $shipping_country_id * @property int|null $shipping_country_id
* @property object|null $settings * @property object|null $settings
* @property object|null $group_settings * @property object|null $group_settings
* @property object|null $sync
* @property bool $is_deleted * @property bool $is_deleted
* @property int|null $group_settings_id * @property int|null $group_settings_id
* @property string|null $vat_number * @property string|null $vat_number
@ -190,6 +192,7 @@ class Client extends BaseModel implements HasLocalePreference
'last_login' => 'timestamp', 'last_login' => 'timestamp',
'tax_data' => 'object', 'tax_data' => 'object',
'e_invoice' => 'object', 'e_invoice' => 'object',
'sync' => ClientSync::class,
]; ];
protected $touches = []; protected $touches = [];

View File

@ -11,6 +11,7 @@
namespace App\Models; namespace App\Models;
use App\DataMapper\InvoiceSync;
use App\Utils\Ninja; use App\Utils\Ninja;
use Laravel\Scout\Searchable; use Laravel\Scout\Searchable;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
@ -53,6 +54,7 @@ use App\Events\Invoice\InvoiceReminderWasEmailed;
* @property bool $is_deleted * @property bool $is_deleted
* @property object|array|string $line_items * @property object|array|string $line_items
* @property object|null $backup * @property object|null $backup
* @property object|null $sync
* @property string|null $footer * @property string|null $footer
* @property string|null $public_notes * @property string|null $public_notes
* @property string|null $private_notes * @property string|null $private_notes
@ -213,6 +215,8 @@ class Invoice extends BaseModel
'custom_surcharge_tax3' => 'bool', 'custom_surcharge_tax3' => 'bool',
'custom_surcharge_tax4' => 'bool', 'custom_surcharge_tax4' => 'bool',
'e_invoice' => 'object', 'e_invoice' => 'object',
'sync' => InvoiceSync::class,
]; ];
protected $with = []; protected $with = [];

View File

@ -11,6 +11,7 @@
namespace App\Models; namespace App\Models;
use App\DataMapper\ProductSync;
use App\Utils\Traits\MakesHash; use App\Utils\Traits\MakesHash;
use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Database\Eloquent\SoftDeletes;
use League\CommonMark\CommonMarkConverter; use League\CommonMark\CommonMarkConverter;
@ -43,6 +44,7 @@ use League\CommonMark\CommonMarkConverter;
* @property int|null $deleted_at * @property int|null $deleted_at
* @property int|null $created_at * @property int|null $created_at
* @property int|null $updated_at * @property int|null $updated_at
* @property object|null $sync
* @property bool $is_deleted * @property bool $is_deleted
* @property float $in_stock_quantity * @property float $in_stock_quantity
* @property bool $stock_notification * @property bool $stock_notification
@ -100,6 +102,13 @@ class Product extends BaseModel
'tax_id', 'tax_id',
]; ];
protected $casts = [
'updated_at' => 'timestamp',
'created_at' => 'timestamp',
'deleted_at' => 'timestamp',
'sync' => ProductSync::class,
];
public array $ubl_tax_map = [ public array $ubl_tax_map = [
self::PRODUCT_TYPE_REVERSE_TAX => 'AE', // VAT_REVERSE_CHARGE = self::PRODUCT_TYPE_REVERSE_TAX => 'AE', // VAT_REVERSE_CHARGE =
self::PRODUCT_TYPE_EXEMPT => 'E', // EXEMPT_FROM_TAX = self::PRODUCT_TYPE_EXEMPT => 'E', // EXEMPT_FROM_TAX =

View File

@ -52,12 +52,12 @@ class QuickbooksSync implements ShouldQueue
'product' => 'Item', 'product' => 'Item',
'client' => 'Customer', 'client' => 'Customer',
'invoice' => 'Invoice', 'invoice' => 'Invoice',
'quote' => 'Estimate', // 'quote' => 'Estimate',
'purchase_order' => 'PurchaseOrder', // 'purchase_order' => 'PurchaseOrder',
'payment' => 'Payment', // 'payment' => 'Payment',
'sales' => 'SalesReceipt', 'sales' => 'SalesReceipt',
'vendor' => 'Vendor', // 'vendor' => 'Vendor',
'expense' => 'Purchase', // 'expense' => 'Purchase',
]; ];
private QuickbooksService $qbs; private QuickbooksService $qbs;
@ -81,8 +81,8 @@ class QuickbooksSync implements ShouldQueue
$this->qbs = new QuickbooksService($this->company); $this->qbs = new QuickbooksService($this->company);
$this->settings = $this->company->quickbooks->settings; $this->settings = $this->company->quickbooks->settings;
nlog("here we go!");
foreach($this->entities as $key => $entity) { foreach($this->entities as $key => $entity) {
if(!$this->syncGate($key, 'pull')) { if(!$this->syncGate($key, 'pull')) {
continue; continue;
} }
@ -95,31 +95,46 @@ class QuickbooksSync implements ShouldQueue
} }
/**
* Determines whether a sync is allowed based on the settings
*
* @param string $entity
* @param string $direction
* @return bool
*/
private function syncGate(string $entity, string $direction): bool private function syncGate(string $entity, string $direction): bool
{ {
return (bool) $this->settings[$entity]['sync'] && in_array($this->settings[$entity]['direction'], [$direction,'bidirectional']); return (bool) $this->settings[$entity]['sync'] && in_array($this->settings[$entity]['direction'], [$direction,'bidirectional']);
} }
/**
* Updates the gate for a given entity
*
* @param string $entity
* @return bool
*/
private function updateGate(string $entity): bool private function updateGate(string $entity): bool
{ {
return (bool) $this->settings[$entity]['sync'] && $this->settings[$entity]['update_record']; return (bool) $this->settings[$entity]['sync'] && $this->settings[$entity]['update_record'];
} }
// private function harvestQbEntityName(string $entity): string /**
// { * Processes the sync for a given entity
// return $this->entities[$entity]; *
// } * @param string $entity
* @param mixed $records
private function processEntitySync(string $entity, $records) * @return void
*/
private function processEntitySync(string $entity, $records): void
{ {
match($entity){ match($entity){
'client' => $this->syncQbToNinjaClients($records), // 'client' => $this->syncQbToNinjaClients($records),
'product' => $this->syncQbToNinjaProducts($records), 'product' => $this->qbs->product->syncToNinja($records),
'invoice' => $this->syncQbToNinjaInvoices($records), // 'invoice' => $this->syncQbToNinjaInvoices($records),
'sales' => $this->syncQbToNinjaInvoices($records), // 'sales' => $this->syncQbToNinjaInvoices($records),
'vendor' => $this->syncQbToNinjaVendors($records), // 'vendor' => $this->syncQbToNinjaVendors($records),
// 'quote' => $this->syncInvoices($records), // 'quote' => $this->syncInvoices($records),
'expense' => $this->syncQbToNinjaExpenses($records), // 'expense' => $this->syncQbToNinjaExpenses($records),
// 'purchase_order' => $this->syncInvoices($records), // 'purchase_order' => $this->syncInvoices($records),
// 'payment' => $this->syncPayment($records), // 'payment' => $this->syncPayment($records),
default => false, default => false,
@ -140,6 +155,7 @@ class QuickbooksSync implements ShouldQueue
nlog($ninja_invoice_data); nlog($ninja_invoice_data);
$payment_ids = $ninja_invoice_data['payment_ids'] ?? []; $payment_ids = $ninja_invoice_data['payment_ids'] ?? [];
$client_id = $ninja_invoice_data['client_id'] ?? null; $client_id = $ninja_invoice_data['client_id'] ?? null;
if(is_null($client_id)) if(is_null($client_id))
@ -152,7 +168,7 @@ class QuickbooksSync implements ShouldQueue
$invoice->fill($ninja_invoice_data); $invoice->fill($ninja_invoice_data);
$invoice->saveQuietly(); $invoice->saveQuietly();
$invoice = $invoice->calc()->getInvoice()->service()->markSent()->save(); $invoice = $invoice->calc()->getInvoice()->service()->markSent()->createInvitations()->save();
foreach($payment_ids as $payment_id) foreach($payment_ids as $payment_id)
{ {
@ -196,7 +212,8 @@ class QuickbooksSync implements ShouldQueue
$search = Invoice::query() $search = Invoice::query()
->withTrashed() ->withTrashed()
->where('company_id', $this->company->id) ->where('company_id', $this->company->id)
->where('number', $ninja_invoice_data['number']); // ->where('number', $ninja_invoice_data['number']);
->where('sync->qb_id', $ninja_invoice_data['id']);
if($search->count() == 0) { if($search->count() == 0) {
//new invoice //new invoice
@ -400,27 +417,7 @@ class QuickbooksSync implements ShouldQueue
return null; return null;
} }
private function findProduct(string $key): ?Product
{
$search = Product::query()
->withTrashed()
->where('company_id', $this->company->id)
->where('hash', $key);
if($search->count() == 0) {
//new product
$product = ProductFactory::create($this->company->id, $this->company->owner()->id);
$product->hash = $key;
return $product;
} elseif($search->count() == 1) {
return $this->settings['product']['update_record'] ? $search->first() : null;
}
return null;
}
public function middleware() public function middleware()
{ {

View File

@ -0,0 +1,30 @@
<?php
/**
* Invoice Ninja (https://invoiceninja.com).
*
* @link https://github.com/invoiceninja/invoiceninja source repository
*
* @copyright Copyright (c) 2024. Invoice Ninja LLC (https://invoiceninja.com)
*
* @license https://www.elastic.co/licensing/elastic-license
*/
namespace App\Services\Quickbooks\Models;
use App\Services\Quickbooks\QuickbooksService;
class QbInvoice
{
public function __construct(public QuickbooksService $service)
{
}
public function find(int $id)
{
return $this->service->sdk->FindById('Invoice', $id);
}
}

View File

@ -0,0 +1,76 @@
<?php
/**
* Invoice Ninja (https://invoiceninja.com).
*
* @link https://github.com/invoiceninja/invoiceninja source repository
*
* @copyright Copyright (c) 2024. Invoice Ninja LLC (https://invoiceninja.com)
*
* @license https://www.elastic.co/licensing/elastic-license
*/
namespace App\Services\Quickbooks\Models;
use App\DataMapper\ProductSync;
use App\Services\Quickbooks\QuickbooksService;
use App\Models\Product;
use App\Factory\ProductFactory;
use App\Services\Quickbooks\Transformers\ProductTransformer;
class QbProduct
{
public function __construct(public QuickbooksService $service)
{
}
public function find(int $id)
{
return $this->service->sdk->FindById('Item', $id);
}
public function syncToNinja(array $records)
{
$product_transformer = new ProductTransformer($this->service->company);
foreach ($records as $record) {
$ninja_data = $product_transformer->qbToNinja($record);
if ($product = $this->findProduct($ninja_data['id'])) {
$product->fill($ninja_data);
$product->save();
}
}
}
private function findProduct(string $key): ?Product
{
$search = Product::query()
->withTrashed()
->where('company_id', $this->service->company->id)
->where('sync->qb_id', $key);
if($search->count() == 0) {
$product = ProductFactory::create($this->service->company->id, $this->service->company->owner()->id);
$sync = new ProductSync();
$sync->qb_id = $key;
$product->sync = $sync;
return $product;
} elseif($search->count() == 1) {
return $this->service->settings['product']['update_record'] ? $search->first() : null;
}
return null;
}
}

View File

@ -11,16 +11,19 @@
namespace App\Services\Quickbooks; namespace App\Services\Quickbooks;
use App\Factory\ClientContactFactory;
use App\Factory\ClientFactory;
use App\Factory\InvoiceFactory;
use App\Factory\ProductFactory;
use App\Models\Client; use App\Models\Client;
use App\Models\Company; use App\Models\Company;
use App\Models\Invoice; use App\Models\Invoice;
use App\Models\Product; use App\Models\Product;
use App\Services\Quickbooks\Jobs\QuickbooksSync; use App\Factory\ClientFactory;
use App\Factory\InvoiceFactory;
use App\Factory\ProductFactory;
use App\Factory\ClientContactFactory;
use App\DataMapper\QuickbooksSettings;
use QuickBooksOnline\API\Core\CoreConstants; use QuickBooksOnline\API\Core\CoreConstants;
use App\Services\Quickbooks\Models\QbInvoice;
use App\Services\Quickbooks\Models\QbProduct;
use App\Services\Quickbooks\Jobs\QuickbooksSync;
use QuickBooksOnline\API\DataService\DataService; use QuickBooksOnline\API\DataService\DataService;
use App\Services\Quickbooks\Transformers\ClientTransformer; use App\Services\Quickbooks\Transformers\ClientTransformer;
use App\Services\Quickbooks\Transformers\InvoiceTransformer; use App\Services\Quickbooks\Transformers\InvoiceTransformer;
@ -31,9 +34,15 @@ class QuickbooksService
{ {
public DataService $sdk; public DataService $sdk;
public QbInvoice $invoice;
public QbProduct $product;
public array $settings;
private bool $testMode = true; private bool $testMode = true;
public function __construct(private Company $company) public function __construct(public Company $company)
{ {
$this->init(); $this->init();
} }
@ -61,6 +70,12 @@ class QuickbooksService
$this->sdk->setMinorVersion("73"); $this->sdk->setMinorVersion("73");
$this->sdk->throwExceptionOnError(true); $this->sdk->throwExceptionOnError(true);
$this->invoice = new QbInvoice($this);
$this->product = new QbProduct($this);
$this->settings = $this->company->quickbooks->settings;
return $this; return $this;
} }

View File

@ -66,7 +66,8 @@ class BaseTransformer
$client = Client::query() $client = Client::query()
->withTrashed() ->withTrashed()
->where('company_id', $this->company->id) ->where('company_id', $this->company->id)
->where('number', $customer_reference_id) // ->where('number', $customer_reference_id)
->where('sync->qb_id', $customer_reference_id)
->first(); ->first();
return $client ? $client->id : null; return $client ? $client->id : null;

View File

@ -38,6 +38,7 @@ class InvoiceTransformer extends BaseTransformer
$client_id = $this->getClientId(data_get($qb_data, 'CustomerRef.value', null)); $client_id = $this->getClientId(data_get($qb_data, 'CustomerRef.value', null));
return $client_id ? [ return $client_id ? [
'id' => data_get($qb_data, 'Id.value', false),
'client_id' => $client_id, 'client_id' => $client_id,
'number' => data_get($qb_data, 'DocNumber', false), 'number' => data_get($qb_data, 'DocNumber', false),
'date' => data_get($qb_data, 'TxnDate', now()->format('Y-m-d')), 'date' => data_get($qb_data, 'TxnDate', now()->format('Y-m-d')),

View File

@ -33,7 +33,7 @@ class ProductTransformer extends BaseTransformer
nlog(data_get($data, 'Id', null)); nlog(data_get($data, 'Id', null));
return [ return [
'hash' => data_get($data, 'Id.value', null), 'id' => data_get($data, 'Id.value', null),
'product_key' => data_get($data, 'Name', data_get($data, 'FullyQualifiedName','')), 'product_key' => data_get($data, 'Name', data_get($data, 'FullyQualifiedName','')),
'notes' => data_get($data, 'Description', ''), 'notes' => data_get($data, 'Description', ''),
'cost' => data_get($data, 'PurchaseCost', 0), 'cost' => data_get($data, 'PurchaseCost', 0),

View File

@ -0,0 +1,35 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('clients', function (Blueprint $table) {
$table->text('sync')->nullable();
});
Schema::table('invoices', function (Blueprint $table) {
$table->text('sync')->nullable();
});
Schema::table('products', function (Blueprint $table) {
$table->text('sync')->nullable();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
//
}
};

View File

@ -12,11 +12,14 @@ use Tests\MockAccountData;
use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Cache;
use Mockery; use Mockery;
use App\Models\Client; use App\Models\Client;
use App\Models\Company;
use App\Models\Product; use App\Models\Product;
use App\Models\Invoice; use App\Models\Invoice;
use App\Services\Quickbooks\QuickbooksService;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use ReflectionClass; use ReflectionClass;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use QuickBooksOnline\API\Facades\Invoice as QbInvoice;
class QuickbooksTest extends TestCase class QuickbooksTest extends TestCase
{ {
@ -30,12 +33,24 @@ class QuickbooksTest extends TestCase
protected function setUp(): void protected function setUp(): void
{ {
parent::setUp(); parent::setUp();
$this->markTestSkipped('no bueno');
} if(config('ninja.is_travis'))
public function testCustomerSync()
{ {
$data = (json_decode(file_get_contents(base_path('tests/Feature/Import/Quickbooks/customers.json')), false)); $this->markTestSkipped('No need to run this test on Travis');
}
elseif(Company::whereNotNull('quickbooks')->count() == 0){
$this->markTestSkipped('No need to run this test on Travis');
}
}
public function testCreateInvoiceInQb()
{
$c = Company::whereNotNull('quickbooks')->first();
$qb = new QuickbooksService($c);
} }
} }