Quickbooks sync
This commit is contained in:
parent
d932eb954d
commit
4ed6fe81a2
|
|
@ -42,7 +42,7 @@ class QBBackup extends BaseImport implements ImportInterface
|
|||
|
||||
public function import(string $entity)
|
||||
{
|
||||
if (in_array($entity, ['client', 'invoice', 'product', 'payment', 'vendor', 'expense'])) {
|
||||
if (in_array($entity, ['client', 'invoice', 'quote', 'product', 'payment', 'vendor', 'expense'])) {
|
||||
$this->{$entity}();
|
||||
}
|
||||
}
|
||||
|
|
@ -53,17 +53,30 @@ class QBBackup extends BaseImport implements ImportInterface
|
|||
|
||||
public function client()
|
||||
{
|
||||
$this->qb->client->importToNinja($this->qb_data['clients']);
|
||||
if(isset($this->qb_data['clients'])) {
|
||||
$this->qb->client->importToNinja($this->qb_data['clients']);
|
||||
}
|
||||
}
|
||||
|
||||
public function product()
|
||||
{
|
||||
$this->qb->product->syncToNinja($this->qb_data['products']);
|
||||
if(isset($this->qb_data['products'])) {
|
||||
$this->qb->product->syncToNinja($this->qb_data['products']);
|
||||
}
|
||||
}
|
||||
|
||||
public function invoice()
|
||||
{
|
||||
$this->qb->invoice->importToNinja($this->qb_data['invoices']);
|
||||
if(isset($this->qb_data['invoices'])) {
|
||||
$this->qb->invoice->importToNinja($this->qb_data['invoices']);
|
||||
}
|
||||
}
|
||||
|
||||
public function quote()
|
||||
{
|
||||
if(isset($this->qb_data['quotes'])) {
|
||||
$this->qb->quote->importToNinja($this->qb_data['quotes']);
|
||||
}
|
||||
}
|
||||
|
||||
public function payment()
|
||||
|
|
|
|||
|
|
@ -0,0 +1,248 @@
|
|||
<?php
|
||||
|
||||
/**
|
||||
* Quote Ninja (https://quoteninja.com).
|
||||
*
|
||||
* @link https://github.com/quoteninja/quoteninja source repository
|
||||
*
|
||||
* @copyright Copyright (c) 2025. Quote Ninja LLC (https://quoteninja.com)
|
||||
*
|
||||
* @license https://www.elastic.co/licensing/elastic-license
|
||||
*/
|
||||
|
||||
namespace App\Services\Quickbooks\Models;
|
||||
|
||||
use Carbon\Carbon;
|
||||
use App\Models\Quote;
|
||||
use App\DataMapper\QuoteSync;
|
||||
use App\Factory\QuoteFactory;
|
||||
use App\Interfaces\SyncInterface;
|
||||
use App\Repositories\QuoteRepository;
|
||||
use App\Services\Quickbooks\QuickbooksService;
|
||||
use App\Services\Quickbooks\Transformers\QuoteTransformer;
|
||||
use App\Services\Quickbooks\Transformers\PaymentTransformer;
|
||||
|
||||
class QbQuote implements SyncInterface
|
||||
{
|
||||
protected QuoteTransformer $quote_transformer;
|
||||
|
||||
protected QuoteRepository $quote_repository;
|
||||
|
||||
public function __construct(public QuickbooksService $service)
|
||||
{
|
||||
$this->quote_transformer = new QuoteTransformer($this->service->company);
|
||||
$this->quote_repository = new QuoteRepository();
|
||||
}
|
||||
|
||||
public function find(string $id): mixed
|
||||
{
|
||||
return $this->service->sdk->FindById('Quote', $id);
|
||||
}
|
||||
|
||||
public function syncToNinja(array $records): void
|
||||
{
|
||||
|
||||
foreach ($records as $record) {
|
||||
|
||||
$this->syncNinjaQuote($record);
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
public function importToNinja(array $records): void
|
||||
{
|
||||
|
||||
foreach ($records as $record) {
|
||||
|
||||
$ninja_quote_data = $this->quote_transformer->qbToNinja($record);
|
||||
|
||||
$client_id = $ninja_quote_data['client_id'] ?? null;
|
||||
|
||||
if (is_null($client_id)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($quote = $this->findQuote($ninja_quote_data['id'], $ninja_quote_data['client_id'])) {
|
||||
|
||||
if ($quote->id) {
|
||||
$this->qbQuoteUpdate($ninja_quote_data, $quote);
|
||||
}
|
||||
|
||||
if (Quote::where('company_id', $this->service->company->id)
|
||||
->whereNotNull('number')
|
||||
->where('number', $ninja_quote_data['number'])
|
||||
->exists()) {
|
||||
$ninja_quote_data['number'] = 'qb_'.$ninja_quote_data['number'].'_'.rand(1000, 99999);
|
||||
}
|
||||
|
||||
$quote->fill($ninja_quote_data);
|
||||
$quote->saveQuietly();
|
||||
|
||||
|
||||
$quote = $quote->calc()->getQuote()->service()->markSent()->applyNumber()->createInvitations()->save();
|
||||
|
||||
}
|
||||
|
||||
$ninja_quote_data = false;
|
||||
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
public function syncToForeign(array $records): void
|
||||
{
|
||||
|
||||
}
|
||||
|
||||
private function qbQuoteUpdate(array $ninja_quote_data, Quote $quote): void
|
||||
{
|
||||
$current_ninja_quote_balance = $quote->balance;
|
||||
$qb_quote_balance = $ninja_quote_data['balance'];
|
||||
|
||||
if (floatval($current_ninja_quote_balance) == floatval($qb_quote_balance)) {
|
||||
nlog('Quote balance is the same, skipping update of line items');
|
||||
unset($ninja_quote_data['line_items']);
|
||||
$quote->fill($ninja_quote_data);
|
||||
$quote->saveQuietly();
|
||||
} else {
|
||||
nlog('Quote balance is different, updating line items');
|
||||
$this->quote_repository->save($ninja_quote_data, $quote);
|
||||
}
|
||||
}
|
||||
|
||||
private function findQuote(string $id, ?string $client_id = null): ?Quote
|
||||
{
|
||||
$search = Quote::query()
|
||||
->withTrashed()
|
||||
->where('company_id', $this->service->company->id)
|
||||
->where('sync->qb_id', $id);
|
||||
|
||||
if ($search->count() == 0) {
|
||||
$quote = QuoteFactory::create($this->service->company->id, $this->service->company->owner()->id);
|
||||
$quote->client_id = (int)$client_id;
|
||||
|
||||
$sync = new QuoteSync();
|
||||
$sync->qb_id = $id;
|
||||
$quote->sync = $sync;
|
||||
|
||||
return $quote;
|
||||
} elseif ($search->count() == 1) {
|
||||
return $this->service->syncable('quote', \App\Enum\SyncDirection::PULL) ? $search->first() : null;
|
||||
}
|
||||
|
||||
return null;
|
||||
|
||||
}
|
||||
|
||||
public function sync($id, string $last_updated): void
|
||||
{
|
||||
|
||||
$qb_record = $this->find($id);
|
||||
|
||||
|
||||
if ($this->service->syncable('quote', \App\Enum\SyncDirection::PULL)) {
|
||||
|
||||
$quote = $this->findQuote($id);
|
||||
|
||||
nlog("Comparing QB last updated: " . $last_updated);
|
||||
nlog("Comparing Ninja last updated: " . $quote->updated_at);
|
||||
|
||||
if (data_get($qb_record, 'TxnStatus') === 'Voided') {
|
||||
$this->delete($id);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!$quote->id) {
|
||||
$this->syncNinjaQuote($qb_record);
|
||||
} elseif (Carbon::parse($last_updated)->gt(Carbon::parse($quote->updated_at)) || $qb_record->SyncToken == '0') {
|
||||
$ninja_quote_data = $this->quote_transformer->qbToNinja($qb_record);
|
||||
|
||||
$this->quote_repository->save($ninja_quote_data, $quote);
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* syncNinjaQuote
|
||||
*
|
||||
* @param $record
|
||||
* @return void
|
||||
*/
|
||||
public function syncNinjaQuote($record): void
|
||||
{
|
||||
|
||||
$ninja_quote_data = $this->quote_transformer->qbToNinja($record);
|
||||
|
||||
$payment_ids = $ninja_quote_data['payment_ids'] ?? [];
|
||||
|
||||
$client_id = $ninja_quote_data['client_id'] ?? null;
|
||||
|
||||
if (is_null($client_id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
unset($ninja_quote_data['payment_ids']);
|
||||
|
||||
if ($quote = $this->findQuote($ninja_quote_data['id'], $ninja_quote_data['client_id'])) {
|
||||
|
||||
if ($quote->id) {
|
||||
$this->qbQuoteUpdate($ninja_quote_data, $quote);
|
||||
}
|
||||
//new quote scaffold
|
||||
$quote->fill($ninja_quote_data);
|
||||
$quote->saveQuietly();
|
||||
|
||||
$quote = $quote->calc()->getQuote()->service()->markSent()->applyNumber()->createInvitations()->save();
|
||||
|
||||
foreach ($payment_ids as $payment_id) {
|
||||
|
||||
$payment = $this->service->sdk->FindById('Payment', $payment_id);
|
||||
|
||||
$payment_transformer = new PaymentTransformer($this->service->company);
|
||||
|
||||
$transformed = $payment_transformer->qbToNinja($payment);
|
||||
|
||||
$ninja_payment = $payment_transformer->buildPayment($payment);
|
||||
$ninja_payment->service()->applyNumber()->save();
|
||||
|
||||
$paymentable = new \App\Models\Paymentable();
|
||||
$paymentable->payment_id = $ninja_payment->id;
|
||||
$paymentable->paymentable_id = $quote->id;
|
||||
$paymentable->paymentable_type = 'quotes';
|
||||
$paymentable->amount = $transformed['applied'] + $ninja_payment->credits->sum('amount');
|
||||
$paymentable->created_at = $ninja_payment->date; //@phpstan-ignore-line
|
||||
$paymentable->save();
|
||||
|
||||
$quote->service()->applyPayment($ninja_payment, $paymentable->amount);
|
||||
|
||||
}
|
||||
|
||||
if ($record instanceof \QuickBooksOnline\API\Data\IPPSalesReceipt) {
|
||||
$quote->service()->markPaid()->save();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes the quote from Ninja and sets the sync to null
|
||||
*
|
||||
* @param string $id
|
||||
* @return void
|
||||
*/
|
||||
public function delete($id): void
|
||||
{
|
||||
$qb_record = $this->find($id);
|
||||
|
||||
if ($this->service->syncable('quote', \App\Enum\SyncDirection::PULL) && $quote = $this->findQuote($id)) {
|
||||
$quote->sync = null;
|
||||
$quote->saveQuietly();
|
||||
$this->quote_repository->delete($quote);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -20,6 +20,7 @@ use App\Factory\InvoiceFactory;
|
|||
use App\Factory\ProductFactory;
|
||||
use App\DataMapper\QuickbooksSync;
|
||||
use App\Factory\ClientContactFactory;
|
||||
use App\Services\Quickbooks\Models\QbQuote;
|
||||
use App\Services\Quickbooks\Models\QbClient;
|
||||
use QuickBooksOnline\API\Core\CoreConstants;
|
||||
use App\Services\Quickbooks\Models\QbInvoice;
|
||||
|
|
@ -44,6 +45,8 @@ class QuickbooksService
|
|||
|
||||
public QbPayment $payment;
|
||||
|
||||
public QbQuote $quote;
|
||||
|
||||
public QuickbooksSync $settings;
|
||||
|
||||
private bool $testMode = true;
|
||||
|
|
@ -79,6 +82,8 @@ class QuickbooksService
|
|||
|
||||
$this->invoice = new QbInvoice($this);
|
||||
|
||||
$this->quote = new QbQuote($this);
|
||||
|
||||
$this->product = new QbProduct($this);
|
||||
|
||||
$this->client = new QbClient($this);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,228 @@
|
|||
<?php
|
||||
|
||||
/**
|
||||
* Quote Ninja (https://quoteninja.com).
|
||||
*
|
||||
* @link https://github.com/quoteninja/quoteninja source repository
|
||||
*
|
||||
* @copyright Copyright (c) 2022. Quote Ninja LLC (https://quoteninja.com)
|
||||
*
|
||||
* @license https://www.elastic.co/licensing/elastic-license
|
||||
*/
|
||||
|
||||
namespace App\Services\Quickbooks\Transformers;
|
||||
|
||||
use App\DataMapper\InvoiceItem;
|
||||
use App\Models\Client;
|
||||
use App\Models\Company;
|
||||
use App\Models\Quote;
|
||||
use App\Models\Product;
|
||||
use App\DataMapper\QuoteItem;
|
||||
use App\Models\TaxRate;
|
||||
|
||||
/**
|
||||
* Class QuoteTransformer.
|
||||
*/
|
||||
class QuoteTransformer extends BaseTransformer
|
||||
{
|
||||
public function qbToNinja(mixed $qb_data)
|
||||
{
|
||||
return $this->transform($qb_data);
|
||||
}
|
||||
|
||||
public function ninjaToQb()
|
||||
{
|
||||
}
|
||||
|
||||
public function transform($qb_data)
|
||||
{
|
||||
$client_id = $this->getClientId(data_get($qb_data, 'CustomerRef', null));
|
||||
$tax_array = $this->calculateTotalTax($qb_data);
|
||||
|
||||
return $client_id ? [
|
||||
'id' => data_get($qb_data, 'Id', false),
|
||||
'client_id' => $client_id,
|
||||
'number' => data_get($qb_data, 'DocNumber', false),
|
||||
'date' => data_get($qb_data, 'TxnDate', now()->format('Y-m-d')),
|
||||
'private_notes' => data_get($qb_data, 'PrivateNote', ''),
|
||||
'public_notes' => data_get($qb_data, 'CustomerMemo', false),
|
||||
'due_date' => data_get($qb_data, 'ExpirationDate', null),
|
||||
'line_items' => $this->getLineItems($qb_data, $tax_array),
|
||||
'status_id' => Quote::STATUS_SENT,
|
||||
'custom_surcharge1' => $this->checkIfDiscountAfterTax($qb_data),
|
||||
'balance' => data_get($qb_data, 'Balance', 0),
|
||||
|
||||
] : false;
|
||||
}
|
||||
|
||||
private function checkIfDiscountAfterTax($qb_data)
|
||||
{
|
||||
|
||||
if (data_get($qb_data, 'ApplyTaxAfterDiscount') == 'true') {
|
||||
return 0;
|
||||
}
|
||||
|
||||
foreach (data_get($qb_data, 'Line', []) as $line) {
|
||||
|
||||
if (data_get($line, 'DetailType') == 'DiscountLineDetail') {
|
||||
|
||||
if (!isset($this->company->custom_fields->surcharge1)) {
|
||||
$this->company->custom_fields->surcharge1 = ctrans('texts.discount');
|
||||
$this->company->save();
|
||||
}
|
||||
|
||||
return (float)data_get($line, 'Amount', 0) * -1;
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
private function calculateTotalTax($qb_data)
|
||||
{
|
||||
$total_tax = data_get($qb_data, 'TxnTaxDetail.TotalTax', false);
|
||||
|
||||
$tax_rate = 0;
|
||||
$tax_name = '';
|
||||
|
||||
if ($total_tax == "0") {
|
||||
return [$tax_rate, $tax_name];
|
||||
}
|
||||
|
||||
$taxLines = data_get($qb_data, 'TxnTaxDetail.TaxLine', []) ?? [];
|
||||
|
||||
if (!empty($taxLines) && !isset($taxLines[0])) {
|
||||
$taxLines = [$taxLines];
|
||||
}
|
||||
|
||||
$totalTaxRate = 0;
|
||||
|
||||
foreach ($taxLines as $taxLine) {
|
||||
$taxRate = data_get($taxLine, 'TaxLineDetail.TaxPercent', 0);
|
||||
$totalTaxRate += $taxRate;
|
||||
}
|
||||
|
||||
|
||||
if ($totalTaxRate > 0) {
|
||||
$formattedTaxRate = rtrim(rtrim(number_format($totalTaxRate, 6), '0'), '.');
|
||||
$formattedTaxRate = trim($formattedTaxRate);
|
||||
|
||||
$tr = \App\Models\TaxRate::firstOrNew(
|
||||
[
|
||||
'company_id' => $this->company->id,
|
||||
'rate' => $formattedTaxRate,
|
||||
],
|
||||
[
|
||||
'name' => "Sales Tax [{$formattedTaxRate}]",
|
||||
'rate' => $formattedTaxRate,
|
||||
]
|
||||
);
|
||||
$tr->company_id = $this->company->id;
|
||||
$tr->user_id = $this->company->owner()->id;
|
||||
$tr->save();
|
||||
|
||||
$tax_rate = $tr->rate;
|
||||
$tax_name = $tr->name;
|
||||
}
|
||||
|
||||
return [$tax_rate, $tax_name];
|
||||
|
||||
}
|
||||
|
||||
|
||||
private function getPayments(mixed $qb_data)
|
||||
{
|
||||
$payments = [];
|
||||
|
||||
$qb_payments = data_get($qb_data, 'LinkedTxn', false) ?? [];
|
||||
|
||||
if (!empty($qb_payments) && !isset($qb_payments[0])) {
|
||||
$qb_payments = [$qb_payments];
|
||||
}
|
||||
|
||||
foreach ($qb_payments as $payment) {
|
||||
if (data_get($payment, 'TxnType', false) == 'Payment') {
|
||||
$payments[] = data_get($payment, 'TxnId', false);
|
||||
}
|
||||
}
|
||||
|
||||
return $payments;
|
||||
|
||||
}
|
||||
|
||||
private function getLineItems(mixed $qb_data, array $tax_array)
|
||||
{
|
||||
$qb_items = data_get($qb_data, 'Line', []);
|
||||
|
||||
$include_discount = data_get($qb_data, 'ApplyTaxAfterDiscount', 'true');
|
||||
|
||||
$items = [];
|
||||
|
||||
if (!empty($qb_items) && !isset($qb_items[0])) {
|
||||
|
||||
//handle weird statement charges
|
||||
$tax_rate = (float)data_get($qb_data, 'TxnTaxDetail.TaxLine.TaxLineDetail.TaxPercent', 0);
|
||||
$tax_name = $tax_rate > 0 ? "Sales Tax [{$tax_rate}]" : '';
|
||||
|
||||
$item = new InvoiceItem();
|
||||
$item->product_key = '';
|
||||
$item->notes = 'Recurring Charge';
|
||||
$item->quantity = 1;
|
||||
$item->cost = (float)data_get($qb_items, 'Amount', 0);
|
||||
$item->discount = 0;
|
||||
$item->is_amount_discount = false;
|
||||
$item->type_id = '1';
|
||||
$item->tax_id = '1';
|
||||
$item->tax_rate1 = $tax_rate;
|
||||
$item->tax_name1 = $tax_name;
|
||||
|
||||
$items[] = (object)$item;
|
||||
|
||||
return $items;
|
||||
}
|
||||
|
||||
foreach ($qb_items as $qb_item) {
|
||||
|
||||
$taxCodeRef = data_get($qb_item, 'TaxCodeRef', data_get($qb_item, 'SalesItemLineDetail.TaxCodeRef', 'TAX'));
|
||||
|
||||
if (data_get($qb_item, 'DetailType') == 'SalesItemLineDetail') {
|
||||
$item = new InvoiceItem();
|
||||
$item->product_key = data_get($qb_item, 'SalesItemLineDetail.ItemRef.name', '');
|
||||
$item->notes = data_get($qb_item, 'Description', '');
|
||||
$item->quantity = (float)(data_get($qb_item, 'SalesItemLineDetail.Qty') ?? 1);
|
||||
$item->cost = (float)(data_get($qb_item, 'SalesItemLineDetail.UnitPrice') ?? data_get($qb_item, 'SalesItemLineDetail.MarkupInfo.Value', 0));
|
||||
$item->discount = (float)data_get($item, 'DiscountRate', data_get($qb_item, 'DiscountAmount', 0));
|
||||
$item->is_amount_discount = data_get($qb_item, 'DiscountAmount', 0) > 0 ? true : false;
|
||||
$item->type_id = stripos(data_get($qb_item, 'ItemAccountRef.name') ?? '', 'Service') !== false ? '2' : '1';
|
||||
$item->tax_id = $taxCodeRef == 'NON' ? Product::PRODUCT_TYPE_EXEMPT : $item->type_id;
|
||||
$item->tax_rate1 = $taxCodeRef == 'NON' ? 0 : $tax_array[0];
|
||||
$item->tax_name1 = $taxCodeRef == 'NON' ? '' : $tax_array[1];
|
||||
|
||||
$items[] = (object)$item;
|
||||
}
|
||||
|
||||
if (data_get($qb_item, 'DetailType') == 'DiscountLineDetail' && $include_discount == 'true') {
|
||||
|
||||
$item = new InvoiceItem();
|
||||
$item->product_key = ctrans('texts.discount');
|
||||
$item->notes = ctrans('texts.discount');
|
||||
$item->quantity = 1;
|
||||
$item->cost = (float)data_get($qb_item, 'Amount', 0) * -1;
|
||||
$item->discount = 0;
|
||||
$item->is_amount_discount = true;
|
||||
|
||||
$item->tax_rate1 = $include_discount == 'true' ? $tax_array[0] : 0;
|
||||
$item->tax_name1 = $include_discount == 'true' ? $tax_array[1] : '';
|
||||
|
||||
$item->type_id = '1';
|
||||
$item->tax_id = Product::PRODUCT_TYPE_PHYSICAL;
|
||||
$items[] = (object)$item;
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
return $items;
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
<?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('quotes', function (Blueprint $table) {
|
||||
$table->text('sync')->nullable();
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
//
|
||||
}
|
||||
};
|
||||
Loading…
Reference in New Issue