Merge pull request #11608 from turbo124/v5-develop

Bug Fix - Webhook creation erroneously
This commit is contained in:
David Bomba
2026-01-28 09:14:02 +11:00
committed by GitHub
22 changed files with 696 additions and 32 deletions

View File

@@ -65,7 +65,7 @@ class RecurringInvoiceItemExport extends BaseExport
if (count($this->input['report_keys']) == 0) { if (count($this->input['report_keys']) == 0) {
$this->force_keys = true; $this->force_keys = true;
$this->input['report_keys'] = array_values($this->mergeItemsKeys('recurring_invoice_report_keys')); $this->input['report_keys'] = array_values($this->mergeItemsKeys('recurring_invoice_report_keys'));
nlog($this->input['report_keys']); // nlog($this->input['report_keys']);
} }
$this->input['report_keys'] = array_merge($this->input['report_keys'], array_diff($this->forced_client_fields, $this->input['report_keys'])); $this->input['report_keys'] = array_merge($this->input['report_keys'], array_diff($this->forced_client_fields, $this->input['report_keys']));

View File

@@ -20,15 +20,23 @@ use App\Services\Quickbooks\QuickbooksService;
class ImportQuickbooksController extends BaseController class ImportQuickbooksController extends BaseController
{ {
/** /**
* Determine if the user is authorized to make this request. * authorizeQuickbooks
* *
* Starts the Quickbooks authorization process.
*
* @param mixed $request
* @param string $token
* @return RedirectResponse
*/ */
public function authorizeQuickbooks(AuthQuickbooksRequest $request, string $token) public function authorizeQuickbooks(AuthQuickbooksRequest $request, string $token)
{ {
MultiDB::findAndSetDbByCompanyKey($request->getTokenContent()['company_key']); MultiDB::findAndSetDbByCompanyKey($request->getTokenContent()['company_key']);
$company = $request->getCompany(); $company = $request->getCompany();
$qb = new QuickbooksService($company); $qb = new QuickbooksService($company);
$authorizationUrl = $qb->sdk()->getAuthorizationUrl(); $authorizationUrl = $qb->sdk()->getAuthorizationUrl();
@@ -39,18 +47,45 @@ class ImportQuickbooksController extends BaseController
return redirect()->to($authorizationUrl); return redirect()->to($authorizationUrl);
} }
/**
* onAuthorized
*
* Handles the callback from Quickbooks after authorization.
*
* @param AuthorizedQuickbooksRequest $request
* @return RedirectResponse
*/
public function onAuthorized(AuthorizedQuickbooksRequest $request) public function onAuthorized(AuthorizedQuickbooksRequest $request)
{ {
nlog($request->all());
MultiDB::findAndSetDbByCompanyKey($request->getTokenContent()['company_key']); MultiDB::findAndSetDbByCompanyKey($request->getTokenContent()['company_key']);
$company = $request->getCompany(); $company = $request->getCompany();
$qb = new QuickbooksService($company); $qb = new QuickbooksService($company);
$realm = $request->query('realmId'); $realm = $request->query('realmId');
nlog($realm);
$access_token_object = $qb->sdk()->accessTokenFromCode($request->query('code'), $realm); $access_token_object = $qb->sdk()->accessTokenFromCode($request->query('code'), $realm);
nlog($access_token_object);
$qb->sdk()->saveOAuthToken($access_token_object); $qb->sdk()->saveOAuthToken($access_token_object);
// Refresh the service to initialize SDK with the new access token
$qb->refresh();
$companyInfo = $qb->sdk()->company();
$company->quickbooks->companyName = $companyInfo->CompanyName;
$company->save();
nlog($companyInfo);
return redirect(config('ninja.react_url')); return redirect(config('ninja.react_url'));
} }

View File

@@ -14,9 +14,10 @@ namespace App\Http\Controllers;
use App\Libraries\MultiDB; use App\Libraries\MultiDB;
use Illuminate\Http\Response; use Illuminate\Http\Response;
use App\Services\Quickbooks\QuickbooksService;
use App\Http\Requests\Quickbooks\SyncQuickbooksRequest;
use App\Http\Requests\Quickbooks\ConfigQuickbooksRequest; use App\Http\Requests\Quickbooks\ConfigQuickbooksRequest;
use App\Http\Requests\Quickbooks\DisconnectQuickbooksRequest; use App\Http\Requests\Quickbooks\DisconnectQuickbooksRequest;
use App\Http\Requests\Quickbooks\SyncQuickbooksRequest;
class QuickbooksController extends BaseController class QuickbooksController extends BaseController
{ {
@@ -53,7 +54,9 @@ class QuickbooksController extends BaseController
$company = $user->company(); $company = $user->company();
$qb = new QuickbooksService($company); $qb = new QuickbooksService($company);
$qb->sdk()->revokeAccessToken(); $rs = $qb->sdk()->revokeAccessToken();
nlog($rs);
$company->quickbooks = null; $company->quickbooks = null;
$company->save(); $company->save();

View File

@@ -303,7 +303,6 @@ class WebhookController extends BaseController
$webhook = WebhookFactory::create($user->company()->id, $user->id); $webhook = WebhookFactory::create($user->company()->id, $user->id);
$webhook->fill($request->all()); $webhook->fill($request->all());
$webhook->save();
return $this->itemResponse($webhook); return $this->itemResponse($webhook);
} }

View File

@@ -118,7 +118,7 @@ class PreviewInvoiceRequest extends Request
}; };
if ($invitation) { if ($invitation) {
nlog($invitation->toArray()); // nlog($invitation->toArray());
return $invitation; return $invitation;
} }

View File

@@ -32,11 +32,15 @@ class ClientTransformer extends BaseTransformer
throw new ImportException('Client already exists'); throw new ImportException('Client already exists');
} }
$address1 = data_get($data, 'Street', data_get($data, 'Address Line 1', ''));
$address2 = data_get($data, 'Address Line 2', '');
return [ return [
'company_id' => $this->company->id, 'company_id' => $this->company->id,
'name' => $this->getString($data, 'Organization'), 'name' => $this->getString($data, 'Organization'),
'phone' => $this->getString($data, 'Phone'), 'phone' => $this->getString($data, 'Phone'),
'address1' => $this->getString($data, 'Street'), 'address1' => $address1,
'address2' => $address2,
'city' => $this->getString($data, 'City'), 'city' => $this->getString($data, 'City'),
'state' => $this->getString($data, 'Province/State'), 'state' => $this->getString($data, 'Province/State'),
'postal_code' => $this->getString($data, 'Postal Code'), 'postal_code' => $this->getString($data, 'Postal Code'),

View File

@@ -119,13 +119,19 @@ class EmailMailable extends Mailable
$file = $document->getFile(); $file = $document->getFile();
if (empty($file)) {
nlog("EmailMailable: Document file is empty: {$document->url}");
return null;
}
$finfo = finfo_open(FILEINFO_MIME_TYPE); $finfo = finfo_open(FILEINFO_MIME_TYPE);
$mime = finfo_buffer($finfo, $file); $mime = finfo_buffer($finfo, $file);
$mime = $mime ?: 'application/octet-stream'; $mime = $mime ?: 'application/octet-stream';
finfo_close($finfo); finfo_close($finfo);
return Attachment::fromData(fn () => $file, $document->name)->withMime($mime); return Attachment::fromData(fn () => $file, $document->name)->withMime($mime);
}); })
->filter();
return $attachments->merge($documents)->toArray(); return $attachments->merge($documents)->toArray();
} }

View File

@@ -69,7 +69,7 @@ class QuickbooksService
'ClientSecret' => config('services.quickbooks.client_secret'), 'ClientSecret' => config('services.quickbooks.client_secret'),
'auth_mode' => 'oauth2', 'auth_mode' => 'oauth2',
'scope' => "com.intuit.quickbooks.accounting", 'scope' => "com.intuit.quickbooks.accounting",
'RedirectURI' => $this->testMode ? 'https://grok.romulus.com.au/quickbooks/authorized' : 'https://invoicing.co/quickbooks/authorized', 'RedirectURI' => $this->testMode ? 'https://qb.romulus.com.au/quickbooks/authorized' : 'https://invoicing.co/quickbooks/authorized',
'baseUrl' => $this->testMode ? CoreConstants::SANDBOX_DEVELOPMENT : CoreConstants::QBO_BASEURL, 'baseUrl' => $this->testMode ? CoreConstants::SANDBOX_DEVELOPMENT : CoreConstants::QBO_BASEURL,
]; ];
@@ -134,6 +134,24 @@ class QuickbooksService
// return $this; // return $this;
// } // }
/**
* Refresh the service after OAuth token has been updated.
* This reloads the company from the database and reinitializes the SDK
* with the new access token.
*
* @return self
*/
public function refresh(): self
{
// Reload company from database to get fresh token data
$this->company = $this->company->fresh();
// Reinitialize the SDK with the updated token
$this->init();
return $this;
}
private function checkToken(): self private function checkToken(): self
{ {
@@ -201,6 +219,52 @@ class QuickbooksService
return isset($this->settings->{$entity}->direction) && ($this->settings->{$entity}->direction === $direction || $this->settings->{$entity}->direction === \App\Enum\SyncDirection::BIDIRECTIONAL); return isset($this->settings->{$entity}->direction) && ($this->settings->{$entity}->direction === $direction || $this->settings->{$entity}->direction === \App\Enum\SyncDirection::BIDIRECTIONAL);
} }
// [
// QuickBooksOnline\API\Data\IPPAccount {#7706
// +Id: "30",
// +SyncToken: "0",
// +MetaData: QuickBooksOnline\API\Data\IPPModificationMetaData {#7707
// +CreatedByRef: null,
// +CreateTime: "2024-05-22T14:46:30-07:00",
// +LastModifiedByRef: null,
// +LastUpdatedTime: "2024-05-22T14:46:30-07:00",
// +LastChangedInQB: null,
// +Synchronized: null,
// },
// +CustomField: null,
// +AttachableRef: null,
// +domain: null,
// +status: null,
// +sparse: null,
// +Name: "Uncategorized Income",
// +SubAccount: "false",
// +ParentRef: null,
// +Description: null,
// +FullyQualifiedName: "Uncategorized Income",
// +AccountAlias: null,
// +TxnLocationType: null,
// +Active: "true",
// +Classification: "Revenue",
// +AccountType: "Income",
// +AccountSubType: "ServiceFeeIncome",
// +AccountPurposes: null,
// +AcctNum: null,
// +AcctNumExtn: null,
// +BankNum: null,
// +OpeningBalance: null,
// +OpeningBalanceDate: null,
// +CurrentBalance: "0",
// +CurrentBalanceWithSubAccounts: "0",
// +CurrencyRef: "USD",
// +TaxAccount: null,
// +TaxCodeRef: null,
// +OnlineBankingEnabled: null,
// +FIName: null,
// +JournalCodeRef: null,
// +AccountEx: null,
// },
// ]
/** /**
* Fetch income accounts from QuickBooks. * Fetch income accounts from QuickBooks.
* *
@@ -223,6 +287,52 @@ class QuickbooksService
} }
} }
// [
// QuickBooksOnline\API\Data\IPPAccount {#7709
// +Id: "57",
// +SyncToken: "0",
// +MetaData: QuickBooksOnline\API\Data\IPPModificationMetaData {#7698
// +CreatedByRef: null,
// +CreateTime: "2024-05-27T10:17:24-07:00",
// +LastModifiedByRef: null,
// +LastUpdatedTime: "2024-05-27T10:17:24-07:00",
// +LastChangedInQB: null,
// +Synchronized: null,
// },
// +CustomField: null,
// +AttachableRef: null,
// +domain: null,
// +status: null,
// +sparse: null,
// +Name: "Workers Compensation",
// +SubAccount: "true",
// +ParentRef: "11",
// +Description: null,
// +FullyQualifiedName: "Insurance:Workers Compensation",
// +AccountAlias: null,
// +TxnLocationType: null,
// +Active: "true",
// +Classification: "Expense",
// +AccountType: "Expense",
// +AccountSubType: "Insurance",
// +AccountPurposes: null,
// +AcctNum: null,
// +AcctNumExtn: null,
// +BankNum: null,
// +OpeningBalance: null,
// +OpeningBalanceDate: null,
// +CurrentBalance: "0",
// +CurrentBalanceWithSubAccounts: "0",
// +CurrencyRef: "USD",
// +TaxAccount: null,
// +TaxCodeRef: null,
// +OnlineBankingEnabled: null,
// +FIName: null,
// +JournalCodeRef: null,
// +AccountEx: null,
// },
// ]
/** /**
* Fetch expense accounts from QuickBooks. * Fetch expense accounts from QuickBooks.
* *

View File

@@ -55,6 +55,11 @@ class SdkWrapper
return $this->accessToken()->getRefreshToken(); return $this->accessToken()->getRefreshToken();
} }
public function revokeAccessToken()
{
return $this->sdk->getOAuth2LoginHelper()->revokeToken($this->accessToken()->getAccessToken());
}
public function company() public function company()
{ {
return $this->sdk->getCompanyInfo(); return $this->sdk->getCompanyInfo();

View File

@@ -50,6 +50,21 @@ class BaseTransformer
return $currency ? (string) $currency->id : $this->company->settings->currency_id; return $currency ? (string) $currency->id : $this->company->settings->currency_id;
} }
public function resolveTimezone(?string $timezone_name): string
{
if (empty($timezone_name)) {
return (string) $this->company->settings->timezone_id;
}
/** @var \App\Models\Timezone $timezone */
$timezone = app('timezones')->first(function ($t) use ($timezone_name) {
/** @var \App\Models\Timezone $t */
return $t->name === $timezone_name;
});
return $timezone ? (string) $timezone->id : (string) $this->company->settings->timezone_id;
}
public function getShipAddrCountry($data, $field) public function getShipAddrCountry($data, $field)
{ {
return is_null(($c = $this->getString($data, $field))) ? null : $this->getCountryId($c); return is_null(($c = $this->getString($data, $field))) ? null : $this->getCountryId($c);

View File

@@ -0,0 +1,99 @@
<?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\Services\Quickbooks\Transformers;
/**
* Transforms QuickBooks IPPCompanyInfo into Invoice Ninja company data.
*
* QB fields: CompanyName, LegalName, CompanyAddr, LegalAddr, CustomerCommunicationAddr,
* Email, CustomerCommunicationEmailAddr, PrimaryPhone, WebAddr, CompanyURL,
* Country, DefaultTimeZone.
*/
class CompanyTransformer extends BaseTransformer
{
/**
* Transform QuickBooks company info to Ninja structure.
*
* @param mixed $qb_data QuickBooksOnline\API\Data\IPPCompanyInfo (or array)
* @return array{quickbooks: array, settings: array}
*/
public function qbToNinja(mixed $qb_data): array
{
return $this->transform($qb_data);
}
public function ninjaToQb(): void
{
// Reserved for Ninja → QB sync when needed.
}
/**
* @param mixed $data IPPCompanyInfo object or array
* @return array{quickbooks: array<string, mixed>, settings: array<string, mixed>}
*/
public function transform(mixed $data): array
{
$addr = $this->pickAddress($data);
$country_raw = data_get($addr, 'Country') ?? data_get($addr, 'CountryCode') ?? data_get($data, 'Country');
$country_id = $this->resolveCountry($country_raw);
$quickbooks = [
'companyName' => data_get($data, 'CompanyName', '') ?: data_get($data, 'LegalName', ''),
];
$settings = [
'address1' => data_get($addr, 'Line1', ''),
'address2' => data_get($addr, 'Line2', ''),
'city' => data_get($addr, 'City', ''),
'state' => data_get($addr, 'CountrySubDivisionCode', ''),
'postal_code' => data_get($addr, 'PostalCode', ''),
'country_id' => $country_id,
'phone' => $this->pickPhone($data),
'email' => $this->pickEmail($data),
'website' => data_get($data, 'WebAddr', '') ?: data_get($data, 'CompanyURL', ''),
'timezone_id' => $this->resolveTimezone(data_get($data, 'DefaultTimeZone')),
];
return [
'quickbooks' => $quickbooks,
'settings' => $settings,
];
}
/**
* Prefer CompanyAddr, then LegalAddr, then CustomerCommunicationAddr.
*
* @param mixed $data
* @return object|array|null
*/
private function pickAddress(mixed $data)
{
$addr = data_get($data, 'CompanyAddr') ?? data_get($data, 'LegalAddr') ?? data_get($data, 'CustomerCommunicationAddr');
return is_object($addr) ? $addr : (is_array($addr) ? $addr : []);
}
private function pickPhone(mixed $data): string
{
$phone = data_get($data, 'PrimaryPhone.FreeFormNumber');
return is_string($phone) ? $phone : '';
}
private function pickEmail(mixed $data): string
{
$email = data_get($data, 'Email.Address') ?? data_get($data, 'CustomerCommunicationEmailAddr.Address') ?? data_get($data, 'CompanyEmailAddr');
return is_string($email) ? $email : '';
}
}

View File

@@ -828,6 +828,11 @@ class TemplateService
foreach ($refund['invoices'] as $refunded_invoice) { foreach ($refund['invoices'] as $refunded_invoice) {
$invoice = Invoice::withTrashed()->find($refunded_invoice['invoice_id']); $invoice = Invoice::withTrashed()->find($refunded_invoice['invoice_id']);
if (!$invoice) {
continue;
}
$amount = Number::formatMoney($refunded_invoice['amount'], $payment->client); $amount = Number::formatMoney($refunded_invoice['amount'], $payment->client);
$notes = ctrans('texts.status_partially_refunded_amount', ['amount' => $amount]); $notes = ctrans('texts.status_partially_refunded_amount', ['amount' => $amount]);

View File

@@ -194,6 +194,8 @@ class HtmlEngine
$data['$payment_schedule'] = ['value' => '', 'label' => ctrans('texts.payment_schedule')]; $data['$payment_schedule'] = ['value' => '', 'label' => ctrans('texts.payment_schedule')];
$data['$payment_schedule_interval'] = ['value' => '', 'label' => ctrans('texts.payment_schedule')]; $data['$payment_schedule_interval'] = ['value' => '', 'label' => ctrans('texts.payment_schedule')];
$data['$days_overdue'] = ['value' => $this->daysOverdue(), 'label' => ctrans('texts.overdue')];
if(method_exists($this->entity, 'paymentSchedule')) { if(method_exists($this->entity, 'paymentSchedule')) {
$data['$payment_schedule'] = ['value' => $this->entity->paymentSchedule(true), 'label' => ctrans('texts.payment_schedule')]; $data['$payment_schedule'] = ['value' => $this->entity->paymentSchedule(true), 'label' => ctrans('texts.payment_schedule')];
$data['$payment_schedule_interval'] = ['value' => $this->entity->paymentScheduleInterval(), 'label' => ctrans('texts.payment_schedule')]; $data['$payment_schedule_interval'] = ['value' => $this->entity->paymentScheduleInterval(), 'label' => ctrans('texts.payment_schedule')];
@@ -692,6 +694,7 @@ class HtmlEngine
$data['$task.rate'] = ['value' => '', 'label' => ctrans('texts.rate')]; $data['$task.rate'] = ['value' => '', 'label' => ctrans('texts.rate')];
$data['$task.cost'] = ['value' => '', 'label' => ctrans('texts.rate')]; $data['$task.cost'] = ['value' => '', 'label' => ctrans('texts.rate')];
$data['$task.hours'] = ['value' => '', 'label' => ctrans('texts.hours')]; $data['$task.hours'] = ['value' => '', 'label' => ctrans('texts.hours')];
$data['$task.total_hours'] = ['value' => $this->totalTaskHours(), 'label' => ctrans('texts.total_hours')];
$data['$task.tax'] = ['value' => '', 'label' => ctrans('texts.tax')]; $data['$task.tax'] = ['value' => '', 'label' => ctrans('texts.tax')];
$data['$task.tax_name1'] = ['value' => '', 'label' => ctrans('texts.tax')]; $data['$task.tax_name1'] = ['value' => '', 'label' => ctrans('texts.tax')];
$data['$task.tax_name2'] = ['value' => '', 'label' => ctrans('texts.tax')]; $data['$task.tax_name2'] = ['value' => '', 'label' => ctrans('texts.tax')];
@@ -899,6 +902,48 @@ Código seguro de verificación (CSV): {$verifactu_log->status}";
return "<tr><td>{$text}</td></tr><tr><td><img src=\"data:image/png;base64,{$qr_code}\" alt=\"Verifactu QR Code\"></td></tr>"; return "<tr><td>{$text}</td></tr><tr><td><img src=\"data:image/png;base64,{$qr_code}\" alt=\"Verifactu QR Code\"></td></tr>";
} }
/**
* totalTaskHours
*
* calculates the total hours of all tasks in the invoice
*
* @return int
*/
private function totalTaskHours()
{
return collect($this->entity->line_items)
->filter(function ($item) {
return $item->type_id == '2';
})
->sum('quantity');
}
/**
* daysOverdue
*
* calculates the number of days overdue the entity is
*
* @return int
*/
private function daysOverdue()
{
if($this->entity->partial > 0 && !empty($this->entity->partial_due_date)) {
$days_overdue = \Carbon\Carbon::parse($this->entity->partial_due_date)->diffInDays(now()->startOfDay()->setTimezone($this->entity->company->timezone()->name));
return max($days_overdue, 0);
}
if(!empty($this->entity->due_date)) {
$days_overdue = \Carbon\Carbon::parse($this->entity->due_date)->diffInDays(now()->startOfDay()->setTimezone($this->entity->company->timezone()->name));
return max($days_overdue, 0);
}
return 0;
}
private function getPaymentMeta(\App\Models\Payment $payment) private function getPaymentMeta(\App\Models\Payment $payment)
{ {

View File

@@ -12,19 +12,20 @@
namespace App\Utils; namespace App\Utils;
use Exception;
use App\Utils\Ninja;
use App\Models\Account; use App\Models\Account;
use App\Models\Country; use App\Models\Country;
use App\Models\CreditInvitation;
use App\Models\InvoiceInvitation;
use App\Models\PurchaseOrderInvitation;
use App\Models\QuoteInvitation;
use App\Models\RecurringInvoiceInvitation;
use App\Utils\Traits\AppSetup; use App\Utils\Traits\AppSetup;
use App\Utils\Traits\DesignCalculator; use App\Models\QuoteInvitation;
use App\Models\CreditInvitation;
use App\Utils\Traits\MakesDates; use App\Utils\Traits\MakesDates;
use Exception; use App\Models\InvoiceInvitation;
use Illuminate\Support\Facades\App; use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Cache;
use App\Utils\Traits\DesignCalculator;
use App\Models\PurchaseOrderInvitation;
use App\Models\RecurringInvoiceInvitation;
/** /**
* Note the premise used here is that any currencies will be formatted back to the company currency and not * Note the premise used here is that any currencies will be formatted back to the company currency and not
@@ -350,8 +351,13 @@ class VendorHtmlEngine
$data['$signature'] = ['value' => $this->settings->email_signature ?: '&nbsp;', 'label' => '']; $data['$signature'] = ['value' => $this->settings->email_signature ?: '&nbsp;', 'label' => ''];
$data['$emailSignature'] = &$data['$signature']; $data['$emailSignature'] = &$data['$signature'];
$logo = $this->company->present()->logo_base64($this->settings); if (Ninja::isHosted()) {
$logo = $this->company->present()->logo($this->settings);
} else {
$logo = $this->company->present()->logo_base64($this->settings);
}
$data['$company.logo'] = ['value' => $logo ?: '&nbsp;', 'label' => ctrans('texts.logo')]; $data['$company.logo'] = ['value' => $logo ?: '&nbsp;', 'label' => ctrans('texts.logo')];
$data['$company_logo'] = &$data['$company.logo']; $data['$company_logo'] = &$data['$company.logo'];
$data['$company1'] = ['value' => $this->helpers->formatCustomFieldValue($this->company->custom_fields, 'company1', $this->settings->custom_value1, $this->vendor) ?: '&nbsp;', 'label' => $this->helpers->makeCustomField($this->company->custom_fields, 'company1')]; $data['$company1'] = ['value' => $this->helpers->formatCustomFieldValue($this->company->custom_fields, 'company1', $this->settings->custom_value1, $this->vendor) ?: '&nbsp;', 'label' => $this->helpers->makeCustomField($this->company->custom_fields, 'company1')];

View File

@@ -152,6 +152,7 @@ return [
'quickbooks_webhook' => [ 'quickbooks_webhook' => [
'verifier_token' => env('QUICKBOOKS_VERIFIER_TOKEN', false), 'verifier_token' => env('QUICKBOOKS_VERIFIER_TOKEN', false),
], ],
'verifactu' => [ 'verifactu' => [
'sender_nif' => env('VERIFACTU_SENDER_NIF', ''), 'sender_nif' => env('VERIFACTU_SENDER_NIF', ''),
'certificate' => env('VERIFACTU_CERTIFICATE', ''), 'certificate' => env('VERIFACTU_CERTIFICATE', ''),
@@ -159,6 +160,14 @@ return [
'sender_name' => env('VERIFACTU_SENDER_NAME', 'CERTIFICADO FISICA PRUEBAS'), 'sender_name' => env('VERIFACTU_SENDER_NAME', 'CERTIFICADO FISICA PRUEBAS'),
'test_mode' => env('VERIFACTU_TEST_MODE', false), 'test_mode' => env('VERIFACTU_TEST_MODE', false),
], ],
'quickbooks' => [
'client_id' => env('QUICKBOOKS_CLIENT_ID', false),
'client_secret' => env('QUICKBOOKS_CLIENT_SECRET', false),
'redirect' => env('QUICKBOOKS_REDIRECT_URI'),
'test_redirect' => env('QUICKBOOKS_TEST_REDIRECT_URI'),
'env' => env('QUICKBOOKS_ENV', 'sandbox'),
'debug' => env('APP_DEBUG',false)
],
'cloudflare' => [ 'cloudflare' => [
'zone_id' => env('CLOUDFLARE_SAAS_ZONE_ID', false), 'zone_id' => env('CLOUDFLARE_SAAS_ZONE_ID', false),
'api_token' => env('CLOUDFLARE_SAAS_API_TOKEN', false), 'api_token' => env('CLOUDFLARE_SAAS_API_TOKEN', false),

View File

@@ -5691,6 +5691,8 @@ $lang = array(
'peppol_sending_success' => 'E-Invoice sent successfully!', 'peppol_sending_success' => 'E-Invoice sent successfully!',
'auto_generate' => 'Auto Generate', 'auto_generate' => 'Auto Generate',
'mollie_payment_pending' => 'Your payment is pending. Please wait for it to be processed. We will email you when it is completed.', 'mollie_payment_pending' => 'Your payment is pending. Please wait for it to be processed. We will email you when it is completed.',
'over_payment_helper' => 'Optional: you can pay more than the amount shown here. (ie tip, round up)',
'new_resource' => 'New Resource',
); );
return $lang; return $lang;

View File

@@ -8,14 +8,24 @@
<dd class="text-sm leading-5 text-gray-900 sm:mt-0 sm:col-span-2 flex flex-col"> <dd class="text-sm leading-5 text-gray-900 sm:mt-0 sm:col-span-2 flex flex-col">
<template x-for="(invoice, index) in payableInvoices" :key="index"> <template x-for="(invoice, index) in payableInvoices" :key="index">
<div class="flex items-center mb-2"> <div class="mb-2">
<label> <div class="flex items-center">
<span x-text="'{{ ctrans('texts.invoice') }} ' + invoice.number" class="mt-2"></span> <label>
<span class="pr-2">{{ $currency->code }} ({{ $currency->symbol }})</span> <span x-text="'{{ ctrans('texts.invoice') }} ' + invoice.number" class="mt-2"></span>
<input type="text" class="input mt-0 mr-4 relative" name="payable_invoices[]" <span class="pr-2">{{ $currency->code }} ({{ $currency->symbol }})</span>
x-model="payableInvoices[index].formatted_amount" /> <input type="text" class="input mt-0 mr-4 relative" name="payable_invoices[]"
</label> x-model="payableInvoices[index].formatted_amount" />
</label>
</div>
@if($settings->client_portal_allow_over_payment)
<div class="mt-1">
<span class="text-xs text-gray-800 italic">{{ ctrans('texts.over_payment_helper') }}</span>
</div>
@endif
</div> </div>
</template> </template>
<template x-if="errors.length > 0"> <template x-if="errors.length > 0">
@@ -26,6 +36,8 @@
<span class="mt-1 text-sm text-gray-800">{{ ctrans('texts.minimum_payment') }}: <span class="mt-1 text-sm text-gray-800">{{ ctrans('texts.minimum_payment') }}:
{{ $settings->client_portal_under_payment_minimum }}</span> {{ $settings->client_portal_under_payment_minimum }}</span>
@endif @endif
</dd> </dd>
<div class="bg-white px-4 py-5 flex items-center w-full justify-end space-x-3"> <div class="bg-white px-4 py-5 flex items-center w-full justify-end space-x-3">

View File

@@ -102,6 +102,12 @@
{{ $invoice->client->currency()->code }} ({{ $invoice->client->currency()->symbol }}) {{ $invoice->client->currency()->code }} ({{ $invoice->client->currency()->symbol }})
{{ $invoice->partial > 0 ? $invoice->partial : $invoice->balance }} {{ $invoice->partial > 0 ? $invoice->partial : $invoice->balance }}
</dd> </dd>
@if($settings->client_portal_allow_over_payment)
<div class="mt-1">
<span class="mt-1 text-sm text-gray-800">{{ ctrans('texts.over_payment_helper') }}</span>
</div>
@endif
</div> </div>
@endif @endif

View File

@@ -61,6 +61,7 @@ use App\Http\Controllers\SystemLogController;
use App\Http\Controllers\TwoFactorController; use App\Http\Controllers\TwoFactorController;
use App\Http\Controllers\Auth\LoginController; use App\Http\Controllers\Auth\LoginController;
use App\Http\Controllers\ImportJsonController; use App\Http\Controllers\ImportJsonController;
use App\Http\Controllers\QuickbooksController;
use App\Http\Controllers\SelfUpdateController; use App\Http\Controllers\SelfUpdateController;
use App\Http\Controllers\TaskStatusController; use App\Http\Controllers\TaskStatusController;
use App\Http\Controllers\Bank\YodleeController; use App\Http\Controllers\Bank\YodleeController;
@@ -336,8 +337,9 @@ Route::group(['middleware' => ['throttle:api', 'token_auth', 'valid_json','local
Route::get('quote/{invitation_key}/download', [QuoteController::class, 'downloadPdf'])->name('quotes.downloadPdf'); Route::get('quote/{invitation_key}/download', [QuoteController::class, 'downloadPdf'])->name('quotes.downloadPdf');
Route::get('quote/{invitation_key}/download_e_quote', [QuoteController::class, 'downloadEQuote'])->name('quotes.downloadEQuote'); Route::get('quote/{invitation_key}/download_e_quote', [QuoteController::class, 'downloadEQuote'])->name('quotes.downloadEQuote');
Route::post('quickbooks/sync', [ImportQuickbooksController::class, 'sync'])->name('quickbooks.sync'); Route::post('quickbooks/sync', [QuickbooksController::class, 'sync'])->name('quickbooks.sync');
Route::post('quickbooks/configuration', [ImportQuickbooksController::class, 'configuration'])->name('quickbooks.configuration'); Route::post('quickbooks/configuration', [QuickbooksController::class, 'configuration'])->name('quickbooks.configuration');
Route::post('quickbooks/disconnect', [QuickbooksController::class, 'disconnect'])->name('quickbooks.disconnect');
Route::resource('recurring_expenses', RecurringExpenseController::class); Route::resource('recurring_expenses', RecurringExpenseController::class);
Route::post('recurring_expenses/bulk', [RecurringExpenseController::class, 'bulk'])->name('recurring_expenses.bulk'); Route::post('recurring_expenses/bulk', [RecurringExpenseController::class, 'bulk'])->name('recurring_expenses.bulk');

View File

@@ -71,7 +71,7 @@ class DesignApiTest extends TestCase
$this->assertEquals($this->user->id, $d->user_id); $this->assertEquals($this->user->id, $d->user_id);
$this->assertEquals($this->company->id, $d->company_id); $this->assertEquals($this->company->id, $d->company_id);
$this->assertEquals($design->name.' clone '.date('Y-m-d H:i:s'), $d->name); $this->assertStringContainsString($design->name.' clone ', $d->name);
// $dsd = Design::all()->pluck('name')->toArray(); // $dsd = Design::all()->pluck('name')->toArray();
} }

View File

@@ -174,9 +174,11 @@ class SubscriptionApiTest extends TestCase
// nlog($i->count()); // nlog($i->count());
// nlog($i->toArray()); // nlog($i->toArray());
}
$this->assertFalse($i); $this->assertCount(0, $i);
}
else
$this->assertFalse($i);
$this->travelTo($timezone_now->copy()->startOfDay()); $this->travelTo($timezone_now->copy()->startOfDay());
@@ -213,9 +215,9 @@ class SubscriptionApiTest extends TestCase
->whereDate('due_date', '<=', now()->setTimezone($company->timezone()->name)->addDay()->startOfDay()) ->whereDate('due_date', '<=', now()->setTimezone($company->timezone()->name)->addDay()->startOfDay())
->get(); ->get();
} $this->assertCount(0, $i);
$this->assertFalse($i); }
$count = Invoice::whereNotNull('subscription_id')->whereIn('company_id', [$c2->id, $c->id])->count(); $count = Invoice::whereNotNull('subscription_id')->whereIn('company_id', [$c2->id, $c->id])->count();

View File

@@ -0,0 +1,299 @@
<?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 Tests\Unit\Services\Quickbooks\Transformers;
use Tests\TestCase;
use Tests\MockAccountData;
use App\Models\Company;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use App\Services\Quickbooks\Transformers\CompanyTransformer;
class CompanyTransformerTest extends TestCase
{
use MockAccountData;
use DatabaseTransactions;
private CompanyTransformer $transformer;
private array $qbCompanyInfo;
protected function setUp(): void
{
parent::setUp();
$this->makeTestData();
$this->transformer = new CompanyTransformer($this->company);
// Mock QuickBooks IPPCompanyInfo structure based on the payload provided
$this->qbCompanyInfo = [
'Id' => '1',
'SyncToken' => '9',
'CompanyName' => 'Sandbox Company_US_1',
'LegalName' => 'Sandbox Company_US_1',
'CompanyAddr' => [
'Id' => '1',
'Line1' => '123 Sierra Way',
'Line2' => '',
'Line3' => '',
'Line4' => '',
'Line5' => '',
'City' => 'San Pablo',
'Country' => 'USA',
'CountryCode' => '',
'County' => '',
'CountrySubDivisionCode' => 'CA',
'PostalCode' => '87999',
'PostalCodeSuffix' => '',
],
'CustomerCommunicationAddr' => [
'Id' => '387',
'Line1' => '123 Sierra Way',
'Line2' => '',
'City' => 'San Pablo',
'Country' => '',
'CountryCode' => '',
'CountrySubDivisionCode' => 'CA',
'PostalCode' => '87999',
],
'LegalAddr' => [
'Id' => '386',
'Line1' => '123 Sierra Way',
'Line2' => '',
'City' => 'San Pablo',
'Country' => '',
'CountryCode' => '',
'CountrySubDivisionCode' => 'CA',
'PostalCode' => '87999',
],
'CompanyEmailAddr' => null,
'CustomerCommunicationEmailAddr' => [
'Id' => '',
'Address' => 'david@invoiceninja.com',
'Default' => null,
'Tag' => '',
],
'CompanyURL' => '',
'PrimaryPhone' => [
'Id' => '',
'DeviceType' => '',
'CountryCode' => '',
'AreaCode' => '',
'ExchangeCode' => '',
'Extension' => '',
'FreeFormNumber' => '4081234567',
'Default' => null,
'Tag' => '',
],
'Email' => [
'Id' => '',
'Address' => 'david@invoiceninja.com',
'Default' => null,
'Tag' => '',
],
'WebAddr' => '',
'Country' => 'US',
'DefaultTimeZone' => 'America/Los_Angeles',
'SupportedLanguages' => 'en',
];
}
public function testTransformerInstance(): void
{
$this->assertInstanceOf(CompanyTransformer::class, $this->transformer);
}
public function testTransformReturnsArray(): void
{
$result = $this->transformer->transform($this->qbCompanyInfo);
$this->assertIsArray($result);
$this->assertArrayHasKey('quickbooks', $result);
$this->assertArrayHasKey('settings', $result);
}
public function testQuickbooksDataStructure(): void
{
$result = $this->transformer->transform($this->qbCompanyInfo);
$this->assertArrayHasKey('companyName', $result['quickbooks']);
$this->assertEquals('Sandbox Company_US_1', $result['quickbooks']['companyName']);
}
public function testSettingsDataStructure(): void
{
$result = $this->transformer->transform($this->qbCompanyInfo);
$settings = $result['settings'];
$this->assertArrayHasKey('address1', $settings);
$this->assertArrayHasKey('address2', $settings);
$this->assertArrayHasKey('city', $settings);
$this->assertArrayHasKey('state', $settings);
$this->assertArrayHasKey('postal_code', $settings);
$this->assertArrayHasKey('country_id', $settings);
$this->assertArrayHasKey('phone', $settings);
$this->assertArrayHasKey('email', $settings);
$this->assertArrayHasKey('website', $settings);
$this->assertArrayHasKey('timezone_id', $settings);
}
public function testAddressMapping(): void
{
$result = $this->transformer->transform($this->qbCompanyInfo);
$settings = $result['settings'];
// Should use CompanyAddr as primary
$this->assertEquals('123 Sierra Way', $settings['address1']);
$this->assertEquals('San Pablo', $settings['city']);
$this->assertEquals('CA', $settings['state']);
$this->assertEquals('87999', $settings['postal_code']);
}
public function testContactInformationMapping(): void
{
$result = $this->transformer->transform($this->qbCompanyInfo);
$settings = $result['settings'];
$this->assertEquals('4081234567', $settings['phone']);
$this->assertEquals('david@invoiceninja.com', $settings['email']);
}
public function testCountryResolution(): void
{
$result = $this->transformer->transform($this->qbCompanyInfo);
$settings = $result['settings'];
// Country should be resolved to a valid country_id
$this->assertNotEmpty($settings['country_id']);
$this->assertIsString($settings['country_id']);
}
public function testTimezoneResolution(): void
{
$result = $this->transformer->transform($this->qbCompanyInfo);
$settings = $result['settings'];
// Timezone should be resolved to a valid timezone_id
$this->assertNotEmpty($settings['timezone_id']);
$this->assertIsString($settings['timezone_id']);
}
public function testCanPersistQuickbooksData(): void
{
$result = $this->transformer->transform($this->qbCompanyInfo);
// Get fresh company instance
$company = Company::find($this->company->id);
// Update quickbooks data
$company->quickbooks->companyName = $result['quickbooks']['companyName'];
// Should not throw exception
$company->save();
// Verify it was saved
$company->refresh();
$this->assertEquals('Sandbox Company_US_1', $company->quickbooks->companyName);
}
public function testCanPersistSettingsData(): void
{
$result = $this->transformer->transform($this->qbCompanyInfo);
// Get fresh company instance
$company = Company::find($this->company->id);
// Merge settings data
$company->saveSettings($result['settings'], $company);
// Should not throw exception
$company->save();
// Verify settings were saved
$company->refresh();
$this->assertEquals('123 Sierra Way', $company->settings->address1);
$this->assertEquals('San Pablo', $company->settings->city);
$this->assertEquals('CA', $company->settings->state);
$this->assertEquals('87999', $company->settings->postal_code);
$this->assertEquals('4081234567', $company->settings->phone);
$this->assertEquals('david@invoiceninja.com', $company->settings->email);
}
public function testCanPersistBothQuickbooksAndSettings(): void
{
$result = $this->transformer->transform($this->qbCompanyInfo);
// Get fresh company instance
$company = Company::find($this->company->id);
// Update both quickbooks and settings
$company->quickbooks->companyName = $result['quickbooks']['companyName'];
$company->saveSettings($result['settings'], $company);
// Should not throw exception
$company->save();
// Verify both were saved
$company->refresh();
$this->assertEquals('Sandbox Company_US_1', $company->quickbooks->companyName);
$this->assertEquals('123 Sierra Way', $company->settings->address1);
$this->assertEquals('david@invoiceninja.com', $company->settings->email);
}
public function testAddressFallbackToLegalAddr(): void
{
// Remove CompanyAddr to test fallback
$qbData = $this->qbCompanyInfo;
unset($qbData['CompanyAddr']);
$result = $this->transformer->transform($qbData);
// Should fallback to LegalAddr
$this->assertEquals('123 Sierra Way', $result['settings']['address1']);
$this->assertEquals('San Pablo', $result['settings']['city']);
}
public function testEmailFallback(): void
{
// Remove Email to test fallback to CustomerCommunicationEmailAddr
$qbData = $this->qbCompanyInfo;
unset($qbData['Email']);
$result = $this->transformer->transform($qbData);
// Should fallback to CustomerCommunicationEmailAddr
$this->assertEquals('david@invoiceninja.com', $result['settings']['email']);
}
public function testHandlesEmptyData(): void
{
$emptyData = [
'CompanyName' => '',
'LegalName' => 'Test Legal Name',
];
$result = $this->transformer->transform($emptyData);
// Should use LegalName when CompanyName is empty
$this->assertEquals('Test Legal Name', $result['quickbooks']['companyName']);
// Settings should have empty strings for missing data
$this->assertEquals('', $result['settings']['address1']);
$this->assertEquals('', $result['settings']['phone']);
$this->assertEquals('', $result['settings']['email']);
}
}