Padding out Quickbooks integration

This commit is contained in:
David Bomba
2026-01-26 10:52:56 +11:00
parent 0b286debca
commit 9add84d058
9 changed files with 474 additions and 32 deletions

View File

@@ -0,0 +1,57 @@
<?php
/**
* Invoice Ninja (https://invoiceninja.com).
*
* @link https://github.com/invoiceninja/invoiceninja source repository
*
* @copyright Copyright (c) 2025. Invoice Ninja LLC (https://invoiceninja.com)
*
* @license https://www.elastic.co/licensing/elastic-license
*/
namespace App\DataMapper;
/**
* QuickbooksPushEvents.
*
* Stores push event configuration for QuickBooks integration.
* This class provides a clean separation of push event settings.
*/
class QuickbooksPushEvents
{
/**
* Push when a new client is created.
*/
public bool $push_on_new_client = false;
/**
* Push when an existing client is updated.
*/
public bool $push_on_updated_client = false;
/**
* Push when an invoice status matches one of these values.
*
* Valid values: 'draft', 'sent', 'paid', 'deleted'
*
* @var array<string>
*/
public array $push_invoice_statuses = [];
public function __construct(array $attributes = [])
{
$this->push_on_new_client = $attributes['push_on_new_client'] ?? false;
$this->push_on_updated_client = $attributes['push_on_updated_client'] ?? false;
$this->push_invoice_statuses = $attributes['push_invoice_statuses'] ?? [];
}
public function toArray(): array
{
return [
'push_on_new_client' => $this->push_on_new_client,
'push_on_updated_client' => $this->push_on_updated_client,
'push_invoice_statuses' => $this->push_invoice_statuses,
];
}
}

View File

@@ -39,6 +39,8 @@ class QuickbooksSync
public string $default_expense_account = '';
public QuickbooksPushEvents $push_events;
public function __construct(array $attributes = [])
{
$this->client = new QuickbooksSyncMap($attributes['client'] ?? []);
@@ -52,6 +54,7 @@ class QuickbooksSync
$this->expense = new QuickbooksSyncMap($attributes['expense'] ?? []);
$this->default_income_account = $attributes['default_income_account'] ?? '';
$this->default_expense_account = $attributes['default_expense_account'] ?? '';
$this->push_events = new QuickbooksPushEvents($attributes['push_events'] ?? []);
}
public function toArray(): array
@@ -68,6 +71,7 @@ class QuickbooksSync
'expense' => $this->expense->toArray(),
'default_income_account' => $this->default_income_account,
'default_expense_account' => $this->default_expense_account,
'push_events' => $this->push_events->toArray(),
];
}
}

View File

@@ -21,20 +21,11 @@ class QuickbooksSyncMap
{
public SyncDirection $direction = SyncDirection::BIDIRECTIONAL;
// Push event settings (for PUSH direction)
public bool $push_on_create = false; // Push when entity is created (e.g., new client)
public bool $push_on_update = false; // Push when entity is updated (e.g., updated client)
public array $push_on_statuses = []; // Push when entity status matches (e.g., invoice statuses: ['draft', 'sent', 'paid', 'deleted'])
public function __construct(array $attributes = [])
{
$this->direction = isset($attributes['direction'])
? SyncDirection::from($attributes['direction'])
: SyncDirection::BIDIRECTIONAL;
$this->push_on_create = $attributes['push_on_create'] ?? false;
$this->push_on_update = $attributes['push_on_update'] ?? false;
$this->push_on_statuses = $attributes['push_on_statuses'] ?? [];
}
public function toArray(): array
@@ -46,9 +37,6 @@ class QuickbooksSyncMap
return [
'direction' => $directionValue,
'push_on_create' => $this->push_on_create,
'push_on_update' => $this->push_on_update,
'push_on_statuses' => $this->push_on_statuses,
];
}
}

View File

@@ -0,0 +1,176 @@
<?php
/**
* Invoice Ninja (https://invoiceninja.com).
*
* @link https://github.com/invoiceninja/invoiceninja source repository
*
* @copyright Copyright (c) 2025. Invoice Ninja LLC (https://invoiceninja.com)
*
* @license https://www.elastic.co/licensing/elastic-license
*/
namespace App\Jobs\Quickbooks;
use App\Libraries\MultiDB;
use App\Models\Client;
use App\Models\Invoice;
use App\Models\Company;
use App\Services\Quickbooks\QuickbooksService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
/**
* Unified job to push entities to QuickBooks.
*
* This job handles pushing different entity types (clients, invoices, etc.) to QuickBooks.
* It is dispatched from model observers when:
* - QuickBooks is configured
* - Push events are enabled for the entity/action
* - Sync direction allows push
*/
class PushToQuickbooks implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public $tries = 3;
public $deleteWhenMissingModels = true;
/**
* Create a new job instance.
*
* @param string $entity_type Entity type: 'client', 'invoice', etc.
* @param int $entity_id The ID of the entity to push
* @param int $company_id The company ID
* @param string $db The database name
* @param string $action Action type: 'create', 'update', 'status'
* @param string|null $status Optional status for status-based pushes (e.g., invoice status: 'draft', 'sent', 'paid', 'deleted')
*/
public function __construct(
private string $entity_type,
private int $entity_id,
private int $company_id,
private string $db,
private string $action,
private ?string $status = null
) {
}
/**
* Execute the job.
*/
public function handle(): void
{
MultiDB::setDb($this->db);
$company = Company::find($this->company_id);
if (!$company) {
return;
}
// Resolve the entity based on type
$entity = $this->resolveEntity($this->entity_type, $this->entity_id);
if (!$entity) {
return;
}
// Double-check push is still enabled (settings might have changed)
if (!$this->shouldPush($company, $this->entity_type, $this->action, $this->status)) {
return;
}
$qbService = new QuickbooksService($company);
// Dispatch to appropriate handler based on entity type
match($this->entity_type) {
'client' => $this->pushClient($qbService, $entity),
'invoice' => $this->pushInvoice($qbService, $entity),
default => nlog("QuickBooks: Unsupported entity type: {$this->entity_type}"),
};
}
/**
* Resolve the entity model based on type.
*
* @param string $entity_type
* @param int $entity_id
* @return Client|Invoice|null
*/
private function resolveEntity(string $entity_type, int $entity_id): Client|Invoice|null
{
return match($entity_type) {
'client' => Client::find($entity_id),
'invoice' => Invoice::find($entity_id),
default => null,
};
}
/**
* Check if push should still occur (settings might have changed since job was queued).
*
* @param Company $company
* @param string $entity_type
* @param string $action
* @param string|null $status
* @return bool
*/
private function shouldPush(Company $company, string $entity_type, string $action, ?string $status): bool
{
return $company->shouldPushToQuickbooks($entity_type, $action, $status);
}
/**
* Push a client to QuickBooks.
*
* @param QuickbooksService $qbService
* @param Client $client
* @return void
*/
private function pushClient(QuickbooksService $qbService, Client $client): void
{
// TODO: Implement actual push logic
// $qbService->client->push($client, $this->action);
nlog("QuickBooks: Pushing client {$client->id} to QuickBooks ({$this->action})");
}
/**
* Push an invoice to QuickBooks.
*
* @param QuickbooksService $qbService
* @param Invoice $invoice
* @return void
*/
private function pushInvoice(QuickbooksService $qbService, Invoice $invoice): void
{
// Use syncToForeign to push the invoice
$qbService->invoice->syncToForeign([$invoice]);
}
/**
* Map invoice status_id and is_deleted to status string.
*
* @param int $statusId
* @param bool $isDeleted
* @return string
*/
private function mapInvoiceStatusToString(int $statusId, bool $isDeleted): string
{
if ($isDeleted) {
return 'deleted';
}
return match($statusId) {
\App\Models\Invoice::STATUS_DRAFT => 'draft',
\App\Models\Invoice::STATUS_SENT => 'sent',
\App\Models\Invoice::STATUS_PAID => 'paid',
default => 'unknown',
};
}
}

View File

@@ -1089,7 +1089,6 @@ class Company extends BaseModel
// Cache the detailed check for this request lifecycle
// This prevents re-checking if called multiple times in the same request
// Note: once() caches per closure, so we need separate closures for different entity/action combinations
return once(function () use ($entity, $action, $status) {
// Check if QuickBooks is actually configured (has token)
if (!$this->quickbooks->isConfigured()) {
@@ -1109,13 +1108,22 @@ class Company extends BaseModel
return false;
}
// Check action-specific settings
// Get push events from settings
$pushEvents = $this->quickbooks->settings->push_events;
// Check action-specific settings from QuickbooksPushEvents
return match($action) {
'create' => $entitySettings->push_on_create ?? false,
'update' => $entitySettings->push_on_update ?? false,
'status' => $status && in_array($status, $entitySettings->push_on_statuses ?? []),
'create' => match($entity) {
'client' => $pushEvents->push_on_new_client ?? false,
default => false, // Other entities can be added here
},
'update' => match($entity) {
'client' => $pushEvents->push_on_updated_client ?? false,
default => false, // Other entities can be added here
},
'status' => $status && in_array($status, $pushEvents->push_invoice_statuses ?? []),
default => false,
};
}, $cacheKey);
});
}
}

View File

@@ -80,7 +80,8 @@ class ClientObserver
// QuickBooks push - efficient check in observer (zero overhead if not configured)
if ($client->company->shouldPushToQuickbooks('client', 'create')) {
\App\Jobs\Quickbooks\PushClientToQuickbooks::dispatch(
\App\Jobs\Quickbooks\PushToQuickbooks::dispatch(
'client',
$client->id,
$client->company->id,
$client->company->db,
@@ -128,7 +129,8 @@ class ClientObserver
// QuickBooks push - efficient check in observer (zero overhead if not configured)
if ($client->company->shouldPushToQuickbooks('client', 'update')) {
\App\Jobs\Quickbooks\PushClientToQuickbooks::dispatch(
\App\Jobs\Quickbooks\PushToQuickbooks::dispatch(
'client',
$client->id,
$client->company->id,
$client->company->db,

View File

@@ -36,13 +36,18 @@ class InvoiceObserver
WebhookHandler::dispatch(Webhook::EVENT_CREATE_INVOICE, $invoice, $invoice->company, 'client')->delay(0);
}
// QuickBooks push - efficient check in observer (zero overhead if not configured)
if ($invoice->company->shouldPushToQuickbooks('invoice', 'create')) {
\App\Jobs\Quickbooks\PushInvoiceToQuickbooks::dispatch(
// QuickBooks push - check if invoice status matches push_invoice_statuses
// Map invoice status to string for status-based push check
$invoiceStatus = $this->mapInvoiceStatusToString($invoice->status_id, $invoice->is_deleted);
if ($invoice->company->shouldPushToQuickbooks('invoice', 'status', $invoiceStatus)) {
\App\Jobs\Quickbooks\PushToQuickbooks::dispatch(
'invoice',
$invoice->id,
$invoice->company->id,
$invoice->company->db,
'create'
'create',
$invoiceStatus
);
}
}
@@ -74,19 +79,18 @@ class InvoiceObserver
WebhookHandler::dispatch($event, $invoice, $invoice->company, 'client')->delay(0);
}
// QuickBooks push - check push_on_update OR push_on_statuses
// QuickBooks push - check if invoice status matches push_invoice_statuses
// Map invoice status to string for status-based push check
$invoiceStatus = $this->mapInvoiceStatusToString($invoice->status_id, $invoice->is_deleted);
$shouldPush = $invoice->company->shouldPushToQuickbooks('invoice', 'update') ||
$invoice->company->shouldPushToQuickbooks('invoice', 'status', $invoiceStatus);
if ($shouldPush) {
\App\Jobs\Quickbooks\PushInvoiceToQuickbooks::dispatch(
if ($invoice->company->shouldPushToQuickbooks('invoice', 'status', $invoiceStatus)) {
\App\Jobs\Quickbooks\PushToQuickbooks::dispatch(
'invoice',
$invoice->id,
$invoice->company->id,
$invoice->company->db,
'update'
'update',
$invoiceStatus
);
}
}

View File

@@ -101,7 +101,53 @@ class QbInvoice implements SyncInterface
public function syncToForeign(array $records): void
{
foreach ($records as $invoice) {
if (!$invoice instanceof Invoice) {
continue;
}
// Check if sync direction allows push
if (!$this->service->syncable('invoice', \App\Enum\SyncDirection::PUSH)) {
continue;
}
try {
// Transform invoice to QuickBooks format
$qb_invoice_data = $this->invoice_transformer->ninjaToQb($invoice, $this->service);
// If updating, fetch SyncToken using existing find() method
if (isset($invoice->sync->qb_id) && !empty($invoice->sync->qb_id)) {
$existing_qb_invoice = $this->find($invoice->sync->qb_id);
if ($existing_qb_invoice) {
$qb_invoice_data['SyncToken'] = $existing_qb_invoice->SyncToken ?? '0';
}
}
// Create or update invoice in QuickBooks
$qb_invoice = \QuickBooksOnline\API\Facades\Invoice::create($qb_invoice_data);
if (isset($invoice->sync->qb_id) && !empty($invoice->sync->qb_id)) {
// Update existing invoice
$result = $this->service->sdk->Update($qb_invoice);
nlog("QuickBooks: Updated invoice {$invoice->id} (QB ID: {$invoice->sync->qb_id})");
} else {
// Create new invoice
$result = $this->service->sdk->Add($qb_invoice);
// Store QB ID in invoice sync
$sync = new InvoiceSync();
$sync->qb_id = data_get($result, 'Id') ?? data_get($result, 'Id.value');
$invoice->sync = $sync;
$invoice->saveQuietly();
nlog("QuickBooks: Created invoice {$invoice->id} (QB ID: {$sync->qb_id})");
}
} catch (\Exception $e) {
nlog("QuickBooks: Error pushing invoice {$invoice->id} to QuickBooks: {$e->getMessage()}");
// Continue with next invoice instead of failing completely
continue;
}
}
}
private function qbInvoiceUpdate(array $ninja_invoice_data, Invoice $invoice): void

View File

@@ -29,8 +29,165 @@ class InvoiceTransformer extends BaseTransformer
return $this->transform($qb_data);
}
public function ninjaToQb()
public function ninjaToQb(Invoice $invoice, \App\Services\Quickbooks\QuickbooksService $qb_service): array
{
// Get client's QuickBooks ID
$client_qb_id = $invoice->client->sync->qb_id ?? null;
// If client doesn't have QB ID, create it first
if (!$client_qb_id) {
$client_qb_id = $this->createClientInQuickbooks($invoice->client, $qb_service);
}
// Build line items
$line_items = [];
$line_num = 1;
foreach ($invoice->line_items as $line_item) {
// Get product's QuickBooks ID if it exists
$product = \App\Models\Product::where('company_id', $this->company->id)
->where('product_key', $line_item->product_key)
->first();
if (!$product || !isset($product->sync->qb_id)) {
// If product doesn't exist in QB, we'll need to create it or use a default item
// For now, skip items without QB product mapping
continue;
}
$tax_code = 'TAX';
if (isset($line_item->tax_id)) {
// Check if tax exempt (similar to test pattern)
if (in_array($line_item->tax_id, [5, 8])) {
$tax_code = 'NON';
}
}
$line_items[] = [
'LineNum' => $line_num,
'DetailType' => 'SalesItemLineDetail',
'SalesItemLineDetail' => [
'ItemRef' => [
'value' => $product->sync->qb_id,
],
'Qty' => $line_item->quantity ?? 1,
'UnitPrice' => $line_item->cost ?? 0,
'TaxCodeRef' => [
'value' => $tax_code,
],
],
'Description' => $line_item->notes ?? '',
'Amount' => $line_item->line_total ?? ($line_item->cost * ($line_item->quantity ?? 1)),
];
$line_num++;
}
// Get primary contact email
$primary_contact = $invoice->client->contacts()->orderBy('is_primary', 'desc')->first();
$email = $primary_contact?->email ?? $invoice->client->contacts()->first()?->email ?? '';
// Build invoice data
$invoice_data = [
'Line' => $line_items,
'CustomerRef' => [
'value' => $client_qb_id,
],
'BillEmail' => [
'Address' => $email,
],
'TxnDate' => $invoice->date,
'DueDate' => $invoice->due_date,
'TotalAmt' => $invoice->amount,
'DocNumber' => $invoice->number,
'ApplyTaxAfterDiscount' => true,
'PrintStatus' => 'NeedToPrint',
'EmailStatus' => 'NotSet',
'GlobalTaxCalculation' => 'TaxExcluded',
];
// Add optional fields
if ($invoice->public_notes) {
$invoice_data['CustomerMemo'] = [
'value' => $invoice->public_notes,
];
}
if ($invoice->private_notes) {
$invoice_data['PrivateNote'] = $invoice->private_notes;
}
if ($invoice->po_number) {
$invoice_data['PONumber'] = $invoice->po_number;
}
// If invoice already has a QB ID, include it for updates
// Note: SyncToken will be fetched in QbInvoice::syncToForeign using the existing find() method
if (isset($invoice->sync->qb_id) && !empty($invoice->sync->qb_id)) {
$invoice_data['Id'] = $invoice->sync->qb_id;
}
return $invoice_data;
}
/**
* Create a client in QuickBooks if it doesn't exist.
*
* @param \App\Models\Client $client
* @param \App\Services\Quickbooks\QuickbooksService $qb_service
* @return string The QuickBooks customer ID
*/
private function createClientInQuickbooks(\App\Models\Client $client, \App\Services\Quickbooks\QuickbooksService $qb_service): string
{
$primary_contact = $client->contacts()->orderBy('is_primary', 'desc')->first();
$customer_data = [
'DisplayName' => $client->present()->name(),
'PrimaryEmailAddr' => [
'Address' => $primary_contact?->email ?? '',
],
'PrimaryPhone' => [
'FreeFormNumber' => $primary_contact?->phone ?? '',
],
'CompanyName' => $client->present()->name(),
'BillAddr' => [
'Line1' => $client->address1 ?? '',
'City' => $client->city ?? '',
'CountrySubDivisionCode' => $client->state ?? '',
'PostalCode' => $client->postal_code ?? '',
'Country' => $client->country?->iso_3166_3 ?? '',
],
'ShipAddr' => [
'Line1' => $client->shipping_address1 ?? '',
'City' => $client->shipping_city ?? '',
'CountrySubDivisionCode' => $client->shipping_state ?? '',
'PostalCode' => $client->shipping_postal_code ?? '',
'Country' => $client->shipping_country?->iso_3166_3 ?? '',
],
'GivenName' => $primary_contact?->first_name ?? '',
'FamilyName' => $primary_contact?->last_name ?? '',
'PrintOnCheckName' => $client->present()->primary_contact_name(),
'Notes' => $client->public_notes ?? '',
'BusinessNumber' => $client->id_number ?? '',
'Active' => $client->deleted_at ? false : true,
'V4IDPseudonym' => $client->client_hash ?? \Illuminate\Support\Str::random(32),
'WebAddr' => $client->website ?? '',
];
$customer = \QuickBooksOnline\API\Facades\Customer::create($customer_data);
$resulting_customer = $qb_service->sdk->Add($customer);
$qb_id = data_get($resulting_customer, 'Id') ?? data_get($resulting_customer, 'Id.value');
// Store QB ID in client sync
$sync = new \App\DataMapper\ClientSync();
$sync->qb_id = $qb_id;
$client->sync = $sync;
$client->saveQuietly();
nlog("QuickBooks: Auto-created client {$client->id} in QuickBooks (QB ID: {$qb_id})");
return $qb_id;
}
public function transform($qb_data)