Fixes for multi region tax issues for QB sync

This commit is contained in:
David Bomba
2026-02-12 18:01:10 +11:00
parent 68b88b1846
commit 72028ac739
7 changed files with 189 additions and 45 deletions

View File

@@ -60,6 +60,8 @@ class QuickbooksSync
public ?string $default_exempt_code = null;
public ?string $country = null;
public function __construct(array $attributes = [])
{
$this->client = new QuickbooksSyncMap($attributes['client'] ?? []);
@@ -78,6 +80,7 @@ class QuickbooksSync
$this->automatic_taxes = $attributes['automatic_taxes'] ?? false; //requires us to syncronously push the invoice to QB, and return fully formed Invoice with taxes included.
$this->default_taxable_code = $attributes['default_taxable_code'] ?? null;
$this->default_exempt_code = $attributes['default_exempt_code'] ?? null;
$this->country = $attributes['country'] ?? null;
}
public function toArray(): array
@@ -99,6 +102,7 @@ class QuickbooksSync
'automatic_taxes' => $this->automatic_taxes,
'default_taxable_code' => $this->default_taxable_code,
'default_exempt_code' => $this->default_exempt_code,
'country' => $this->country,
];
}
}

View File

@@ -187,6 +187,9 @@ class BatchPushToQuickbooks implements ShouldQueue
// Process single entity
$this->processEntity($qbService, $entity);
$entity->refresh();
$this->logActivitySuccess($entity);
$rateLimiter->trackRequest();
$successCount++;
@@ -351,6 +354,37 @@ class BatchPushToQuickbooks implements ShouldQueue
return mb_substr($cleaned, 0, 500);
}
/**
* Log a QuickBooks push success as an Activity record visible to the user.
*/
private function logActivitySuccess($entity): void
{
try {
$qb_id = $entity->sync->qb_id ?? null;
$number = $entity->number ?? ($entity->present()->name() ?? $entity->id);
$notes = "{$this->entity_type} #{$number} synced to QuickBooks (QB ID: {$qb_id})";
$activity = new Activity();
$activity->user_id = $entity->user_id ?? null;
$activity->company_id = $entity->company_id;
$activity->account_id = $entity->company->account_id;
$activity->activity_type_id = Activity::QUICKBOOKS_PUSH_SUCCESS;
$activity->is_system = true;
$activity->notes = $notes;
match ($this->entity_type) {
'client' => $activity->client_id = $entity->id,
'invoice' => $activity->invoice_id = $entity->id,
'payment' => $activity->payment_id = $entity->id,
default => null,
};
$activity->save();
} catch (\Throwable $e) {
nlog("QB Batch: Failed to log success activity for {$this->entity_type} {$entity->id}: " . $e->getMessage());
}
}
/**
* Log a QuickBooks push failure as an Activity record visible to the user.
*/

View File

@@ -93,6 +93,9 @@ class PushToQuickbooks implements ShouldQueue
'product' => $this->pushProduct($qbService, $entity),
default => nlog("QuickBooks: Unsupported entity type: {$this->entity_type}"),
};
$entity->refresh();
$this->logActivitySuccess($entity);
} catch (\Throwable $e) {
nlog("Quickbooks push to Quickbooks job failed => " . $e->getMessage());
$this->logActivityFailure($entity, $this->extractReadableError($e->getMessage()));
@@ -194,6 +197,33 @@ class PushToQuickbooks implements ShouldQueue
return mb_substr($cleaned, 0, 500);
}
private function logActivitySuccess($entity): void
{
try {
$qb_id = $entity->sync->qb_id ?? null;
$number = $entity->number ?? ($entity->present()->name() ?? $entity->id);
$notes = "{$this->entity_type} #{$number} synced to QuickBooks (QB ID: {$qb_id})";
$activity = new Activity();
$activity->user_id = $entity->user_id ?? null;
$activity->company_id = $entity->company_id;
$activity->account_id = $entity->company->account_id;
$activity->activity_type_id = Activity::QUICKBOOKS_PUSH_SUCCESS;
$activity->is_system = true;
$activity->notes = $notes;
match ($this->entity_type) {
'client' => $activity->client_id = $entity->id,
'invoice' => $activity->invoice_id = $entity->id,
default => null,
};
$activity->save();
} catch (\Throwable $e) {
nlog("QuickBooks: Failed to log success activity for {$this->entity_type} {$this->entity_id}: " . $e->getMessage());
}
}
private function logActivityFailure($entity, string $errorMessage): void
{
try {

View File

@@ -310,6 +310,8 @@ class Activity extends StaticModel
public const QUICKBOOKS_PUSH_FAILURE = 164;
public const QUICKBOOKS_PUSH_SUCCESS = 165;
protected $casts = [
'is_system' => 'boolean',
'updated_at' => 'timestamp',

View File

@@ -663,6 +663,10 @@ class QuickbooksService
$this->company->quickbooks->companyName = $companyInfo->CompanyName ?? '';
$this->company->quickbooks->settings->automatic_taxes = $automatic_taxes;
// Extract QB company country for region-aware tax code handling
$qb_country = $this->extractCompanyCountry($companyInfo);
$this->company->quickbooks->settings->country = $qb_country;
// Resolve TaxCode IDs for multi-region support (US uses 'TAX'/'NON', CA/AU/UK use numeric IDs)
// Build TaxRate→TaxCode map so each line item can resolve the correct TaxCodeRef
$tax_codes = $this->fetchTaxCodes();
@@ -766,4 +770,45 @@ class QuickbooksService
return $this;
}
/**
* Extract the country code from QuickBooks CompanyInfo.
*
* Checks CompanyAddr.Country, LegalAddr.Country, and root-level Country.
* Returns a normalized 2-letter ISO code (e.g. "US", "CA", "AU", "GB").
* Falls back to "US" if no country can be determined.
*/
private function extractCompanyCountry(mixed $companyInfo): string
{
$country_raw = data_get($companyInfo, 'CompanyAddr.Country')
?? data_get($companyInfo, 'CompanyAddr.CountryCode')
?? data_get($companyInfo, 'LegalAddr.Country')
?? data_get($companyInfo, 'LegalAddr.CountryCode')
?? data_get($companyInfo, 'Country')
?? '';
$country_raw = trim((string) $country_raw);
if ($country_raw === '') {
nlog("QB companySync: No country found in CompanyInfo, defaulting to 'US'");
return 'US';
}
$normalized = strtoupper($country_raw);
$country_map = [
'US' => 'US', 'USA' => 'US', 'UNITED STATES' => 'US', 'UNITED STATES OF AMERICA' => 'US',
'CA' => 'CA', 'CAN' => 'CA', 'CANADA' => 'CA',
'AU' => 'AU', 'AUS' => 'AU', 'AUSTRALIA' => 'AU',
'GB' => 'GB', 'GBR' => 'GB', 'UNITED KINGDOM' => 'GB', 'UK' => 'GB',
'IN' => 'IN', 'IND' => 'IN', 'INDIA' => 'IN',
'FR' => 'FR', 'FRA' => 'FR', 'FRANCE' => 'FR',
'DE' => 'DE', 'DEU' => 'DE', 'GERMANY' => 'DE',
];
$result = $country_map[$normalized] ?? $normalized;
nlog("QB companySync: Resolved company country '{$country_raw}' to '{$result}'");
return $result;
}
}

View File

@@ -60,12 +60,14 @@ class InvoiceTransformer extends BaseTransformer
$exempt_code = $qb_service->company->quickbooks->settings->default_exempt_code ?? 'NON';
$tax_rate_map = $qb_service->company->quickbooks->settings->tax_rate_map ?? [];
// Non-US regions (CA/AU/UK) require TaxCodeRef on EVERY line item.
// If exempt code wasn't resolved, fall back to taxable code (safe for $0 lines;
// for non-zero exempt lines, user must re-run companySync to resolve the exempt code).
$is_non_us = $taxable_code !== 'TAX';
if ($is_non_us && $exempt_code === 'NON') {
nlog("QB Warning: exempt TaxCode not resolved for company {$qb_service->company->id}, falling back to taxable code '{$taxable_code}' — run companySync to fix");
// Determine region from stored QB company country (set by companySync)
$qb_country = $qb_service->company->quickbooks->settings->country ?? 'US';
$is_us = ($qb_country === 'US');
// Non-US regions (CA/AU/UK) require TaxCodeRef on EVERY line item using numeric tax code IDs.
// US companies MUST use only "TAX" or "NON" as TaxCodeRef values.
if (!$is_us && $exempt_code === 'NON') {
nlog("QB Warning: exempt TaxCode not resolved for non-US company {$qb_service->company->id} (country={$qb_country}), falling back to taxable code '{$taxable_code}' — run companySync to fix");
$exempt_code = $taxable_code;
}
@@ -78,8 +80,11 @@ class InvoiceTransformer extends BaseTransformer
$tax_code_id = $exempt_code;
} elseif ($ast) {
$tax_code_id = $taxable_code;
} elseif ($is_us) {
// US companies: TaxCodeRef MUST be "TAX" or "NON" — never numeric IDs
$tax_code_id = $this->resolveLineTaxCodeUS($line_item, $taxable_code, $exempt_code);
} else {
// Resolve TaxCodeRef per line item by matching tax name/rate to tax_rate_map
// Non-US companies (CA/AU/UK): resolve to numeric TaxCode ID from tax_rate_map
$tax_code_id = $this->resolveLineTaxCode($line_item, $tax_rate_map, $taxable_code, $exempt_code);
}
@@ -178,12 +183,12 @@ class InvoiceTransformer extends BaseTransformer
'ApplyTaxAfterDiscount' => true,
'PrintStatus' => 'NeedToPrint',
'EmailStatus' => 'NotSet',
'GlobalTaxCalculation' => ($ast || $taxable_code !== 'TAX') ? 'TaxExcluded' : 'NotApplicable',
'GlobalTaxCalculation' => ($ast || !$is_us) ? 'TaxExcluded' : 'NotApplicable',
];
// Only send TxnTaxDetail for US companies (using default TAX/NON codes) without AST.
// Only send TxnTaxDetail for US companies without AST.
// Non-US companies use resolved TaxCodeRef per line item — QB calculates taxes from those.
if (!$ast && $taxable_code === 'TAX') {
if (!$ast && $is_us) {
$tax_detail = $this->buildTxnTaxDetail($invoice, $total_taxes, $taxable_amount, $qb_service);
if ($tax_detail) {
$invoice_data['TxnTaxDetail'] = $tax_detail;
@@ -253,7 +258,6 @@ class InvoiceTransformer extends BaseTransformer
return $exempt_code;
}
// Try to find a specific TaxCode by matching the line item's tax name/rate to the map
foreach (['tax_name1' => 'tax_rate1', 'tax_name2' => 'tax_rate2', 'tax_name3' => 'tax_rate3'] as $name_key => $rate_key) {
$rate = floatval($line_item->$rate_key ?? 0);
if ($rate <= 0) {
@@ -261,37 +265,33 @@ class InvoiceTransformer extends BaseTransformer
}
$name = trim((string) ($line_item->$name_key ?? ''));
$tax_code_id = $this->findTaxCodeIdByRate($tax_rate_map, $rate, $name);
// Search tax_rate_map for a matching entry with a tax_code_id
foreach ($tax_rate_map as $map_entry) {
if (!isset($map_entry['tax_code_id']) || empty($map_entry['tax_code_id'])) {
continue;
}
// Match by rate; also match by name if both are available
if (floatval($map_entry['rate']) == $rate) {
// If the line tax name contains the map entry name, it's a match
if ($name === '' || stripos($name, $map_entry['name']) !== false || stripos($map_entry['name'], $name) !== false) {
return $map_entry['tax_code_id'];
}
}
}
// Rate match only (ignore name) as fallback
foreach ($tax_rate_map as $map_entry) {
if (!isset($map_entry['tax_code_id']) || empty($map_entry['tax_code_id'])) {
continue;
}
if (floatval($map_entry['rate']) == $rate) {
return $map_entry['tax_code_id'];
}
if ($tax_code_id) {
return $tax_code_id;
}
}
// Fallback to default taxable code
return $taxable_code;
}
/**
* Resolve the TaxCodeRef for a US company line item.
*
* US QuickBooks companies ONLY accept "TAX" or "NON" as line-level TaxCodeRef values.
* Checks whether the line item has any non-zero tax rates and returns the appropriate code.
*/
private function resolveLineTaxCodeUS(object $line_item, string $taxable_code, string $exempt_code): string
{
$has_line_tax = (
(isset($line_item->tax_rate1) && $line_item->tax_rate1 > 0)
|| (isset($line_item->tax_rate2) && $line_item->tax_rate2 > 0)
|| (isset($line_item->tax_rate3) && $line_item->tax_rate3 > 0)
);
return $has_line_tax ? $taxable_code : $exempt_code;
}
/**
* Build TxnTaxDetail for invoice-level tax calculation.
* This handles total taxes applied to the invoice.
@@ -313,16 +313,7 @@ class InvoiceTransformer extends BaseTransformer
foreach ($invoice->calc()->getTaxMap() ?? [] as $tax) {
$tax_components = $qb_service->helper->splitTaxName($tax['name']);
$tax_rate_id = null;
foreach ($tax_rate_map as $rate_map) {
if (floatval($rate_map['rate']) == floatval($tax_components['percentage']) && $rate_map['name'] == $tax_components['name']) {
$tax_rate_id = $rate_map['id'];
break;
}
}
$tax_rate_id = $this->findTaxRateIdByRateAndName($tax_rate_map, floatval($tax_components['percentage']), $tax_components['name']);
if (!$tax_rate_id) {
continue;
@@ -359,6 +350,43 @@ class InvoiceTransformer extends BaseTransformer
}
/**
* Find a TaxCode ID from the tax rate map by matching rate and name.
* Uses fuzzy name matching (stripos) with a rate-only fallback.
*/
private function findTaxCodeIdByRate(array $tax_rate_map, float $rate, string $name): ?string
{
$rate_only_match = null;
foreach ($tax_rate_map as $entry) {
if (empty($entry['tax_code_id']) || floatval($entry['rate']) != $rate) {
continue;
}
if ($name === '' || stripos($name, $entry['name']) !== false || stripos($entry['name'], $name) !== false) {
return $entry['tax_code_id'];
}
$rate_only_match ??= $entry['tax_code_id'];
}
return $rate_only_match;
}
/**
* Find a TaxRate ID from the tax rate map by exact rate and name match.
*/
private function findTaxRateIdByRateAndName(array $tax_rate_map, float $rate, string $name): ?string
{
foreach ($tax_rate_map as $entry) {
if (floatval($entry['rate']) == $rate && $entry['name'] == $name) {
return $entry['id'];
}
}
return null;
}
/**
* transform
*

View File

@@ -5723,6 +5723,7 @@ $lang = array(
'activity_162' => 'Purchase Order :purchase_order for :vendor was signed',
'activity_163' => 'Custom Document :document for :client was signed',
'activity_164' => 'QuickBooks sync failed. :notes',
'activity_165' => 'QuickBooks sync successful. :notes',
'create_your_own' => 'Create your own',
'create_your_own_description' => 'Create your own template using your own PDF document to upload',
'new_template_description' => 'Select one of our prebuilt templates to get started, ie NDA, Sales agreement, etc',