From 4ed6fe81a2dcd238e642f83a201fa386c3a29e4f Mon Sep 17 00:00:00 2001 From: David Bomba Date: Fri, 21 Mar 2025 14:25:12 +1100 Subject: [PATCH] Quickbooks sync --- app/Import/Providers/QBBackup.php | 21 +- app/Services/Quickbooks/Models/QbQuote.php | 248 ++++++++++++++++++ app/Services/Quickbooks/QuickbooksService.php | 5 + .../Transformers/QuoteTransformer.php | 228 ++++++++++++++++ ...3_21_032428_add_sync_column_for_quotes.php | 28 ++ 5 files changed, 526 insertions(+), 4 deletions(-) create mode 100644 app/Services/Quickbooks/Models/QbQuote.php create mode 100644 app/Services/Quickbooks/Transformers/QuoteTransformer.php create mode 100644 database/migrations/2025_03_21_032428_add_sync_column_for_quotes.php diff --git a/app/Import/Providers/QBBackup.php b/app/Import/Providers/QBBackup.php index bdd4bc092a..529d8a06aa 100644 --- a/app/Import/Providers/QBBackup.php +++ b/app/Import/Providers/QBBackup.php @@ -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() diff --git a/app/Services/Quickbooks/Models/QbQuote.php b/app/Services/Quickbooks/Models/QbQuote.php new file mode 100644 index 0000000000..59cb35ea2a --- /dev/null +++ b/app/Services/Quickbooks/Models/QbQuote.php @@ -0,0 +1,248 @@ +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); + } + } +} diff --git a/app/Services/Quickbooks/QuickbooksService.php b/app/Services/Quickbooks/QuickbooksService.php index 815dd0c7f1..4afdef23d9 100644 --- a/app/Services/Quickbooks/QuickbooksService.php +++ b/app/Services/Quickbooks/QuickbooksService.php @@ -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); diff --git a/app/Services/Quickbooks/Transformers/QuoteTransformer.php b/app/Services/Quickbooks/Transformers/QuoteTransformer.php new file mode 100644 index 0000000000..e628bdeee8 --- /dev/null +++ b/app/Services/Quickbooks/Transformers/QuoteTransformer.php @@ -0,0 +1,228 @@ +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; + + } + +} diff --git a/database/migrations/2025_03_21_032428_add_sync_column_for_quotes.php b/database/migrations/2025_03_21_032428_add_sync_column_for_quotes.php new file mode 100644 index 0000000000..27957b5453 --- /dev/null +++ b/database/migrations/2025_03_21_032428_add_sync_column_for_quotes.php @@ -0,0 +1,28 @@ +text('sync')->nullable(); + }); + + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + // + } +};