mirror of
https://github.com/invoiceninja/invoiceninja.git
synced 2026-03-03 02:47:02 +00:00
Fixes for multi region tax issues for QB sync
This commit is contained in:
@@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
*
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user