diff --git a/app/DataMapper/QuickbooksPushEvents.php b/app/DataMapper/QuickbooksPushEvents.php new file mode 100644 index 0000000000..cd0d8e05cf --- /dev/null +++ b/app/DataMapper/QuickbooksPushEvents.php @@ -0,0 +1,57 @@ + + */ + 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, + ]; + } +} diff --git a/app/DataMapper/QuickbooksSync.php b/app/DataMapper/QuickbooksSync.php index edebae1e49..65c1b25079 100644 --- a/app/DataMapper/QuickbooksSync.php +++ b/app/DataMapper/QuickbooksSync.php @@ -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(), ]; } } diff --git a/app/DataMapper/QuickbooksSyncMap.php b/app/DataMapper/QuickbooksSyncMap.php index 4d2fcaa33a..a8a7552eaa 100644 --- a/app/DataMapper/QuickbooksSyncMap.php +++ b/app/DataMapper/QuickbooksSyncMap.php @@ -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, ]; } } diff --git a/app/Jobs/Quickbooks/PushToQuickbooks.php b/app/Jobs/Quickbooks/PushToQuickbooks.php new file mode 100644 index 0000000000..46edfa47f4 --- /dev/null +++ b/app/Jobs/Quickbooks/PushToQuickbooks.php @@ -0,0 +1,176 @@ +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', + }; + } +} diff --git a/app/Models/Company.php b/app/Models/Company.php index 1c31d94482..a2f0b78123 100644 --- a/app/Models/Company.php +++ b/app/Models/Company.php @@ -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); + }); } } diff --git a/app/Observers/ClientObserver.php b/app/Observers/ClientObserver.php index 425cb3a15d..c6ee0e3bad 100644 --- a/app/Observers/ClientObserver.php +++ b/app/Observers/ClientObserver.php @@ -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, diff --git a/app/Observers/InvoiceObserver.php b/app/Observers/InvoiceObserver.php index 9c9619ccb6..6d394e0e0b 100644 --- a/app/Observers/InvoiceObserver.php +++ b/app/Observers/InvoiceObserver.php @@ -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 ); } } diff --git a/app/Services/Quickbooks/Models/QbInvoice.php b/app/Services/Quickbooks/Models/QbInvoice.php index 5688948c7e..26b467b6b9 100644 --- a/app/Services/Quickbooks/Models/QbInvoice.php +++ b/app/Services/Quickbooks/Models/QbInvoice.php @@ -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 diff --git a/app/Services/Quickbooks/Transformers/InvoiceTransformer.php b/app/Services/Quickbooks/Transformers/InvoiceTransformer.php index 3e2c4e2f8c..f807d046a6 100644 --- a/app/Services/Quickbooks/Transformers/InvoiceTransformer.php +++ b/app/Services/Quickbooks/Transformers/InvoiceTransformer.php @@ -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)