Quickbooks validation tests

This commit is contained in:
David Bomba
2026-01-26 15:03:42 +11:00
parent 8952f07d75
commit 6d2c242973
14 changed files with 734 additions and 89 deletions

View File

@@ -1 +1 @@
5.12.49
5.12.50

View File

@@ -1,57 +0,0 @@
<?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

@@ -32,6 +32,8 @@ class QuickbooksSettings implements Castable
public string $baseURL;
public string $companyName;
public QuickbooksSync $settings;
public function __construct(array $attributes = [])
@@ -42,6 +44,7 @@ class QuickbooksSettings implements Castable
$this->accessTokenExpiresAt = $attributes['accessTokenExpiresAt'] ?? 0;
$this->refreshTokenExpiresAt = $attributes['refreshTokenExpiresAt'] ?? 0;
$this->baseURL = $attributes['baseURL'] ?? '';
$this->companyName = $attributes['companyName'] ?? '';
$this->settings = new QuickbooksSync($attributes['settings'] ?? []);
}
@@ -64,6 +67,7 @@ class QuickbooksSettings implements Castable
'accessTokenExpiresAt' => $this->accessTokenExpiresAt,
'refreshTokenExpiresAt' => $this->refreshTokenExpiresAt,
'baseURL' => $this->baseURL,
'companyName' => $this->companyName,
'settings' => $this->settings->toArray(),
];
}

View File

@@ -39,8 +39,6 @@ class QuickbooksSync
public string $default_expense_account = '';
public QuickbooksPushEvents $push_events;
public function __construct(array $attributes = [])
{
$this->client = new QuickbooksSyncMap($attributes['client'] ?? []);
@@ -54,7 +52,6 @@ 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
@@ -71,7 +68,6 @@ 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

@@ -19,13 +19,13 @@ use App\Enum\SyncDirection;
*/
class QuickbooksSyncMap
{
public SyncDirection $direction = SyncDirection::BIDIRECTIONAL;
public SyncDirection $direction = SyncDirection::NONE;
public function __construct(array $attributes = [])
{
$this->direction = isset($attributes['direction'])
? SyncDirection::from($attributes['direction'])
: SyncDirection::BIDIRECTIONAL;
: SyncDirection::NONE;
}
public function toArray(): array

View File

@@ -33,8 +33,6 @@ class ImportQuickbooksController extends BaseController
$authorizationUrl = $qb->sdk()->getAuthorizationUrl();
nlog($authorizationUrl);
$state = $qb->sdk()->getState();
Cache::put($state, $token, 190);

View File

@@ -0,0 +1,63 @@
<?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\Http\Controllers;
use App\Libraries\MultiDB;
use Illuminate\Http\Response;
use App\Http\Requests\Quickbooks\ConfigQuickbooksRequest;
use App\Http\Requests\Quickbooks\DisconnectQuickbooksRequest;
use App\Http\Requests\Quickbooks\SyncQuickbooksRequest;
class QuickbooksController extends BaseController
{
public function sync(SyncQuickbooksRequest $request)
{
return response()->noContent();
}
public function configuration(ConfigQuickbooksRequest $request)
{
$user = auth()->user();
$company = $user->company();
$quickbooks = $company->quickbooks;
$quickbooks->settings->client->direction = $request->clients ? SyncDirection::PUSH : SyncDirection::NONE;
$quickbooks->settings->vendor->direction = $request->vendors ? SyncDirection::PUSH : SyncDirection::NONE;
$quickbooks->settings->product->direction = $request->products ? SyncDirection::PUSH : SyncDirection::NONE;
$quickbooks->settings->invoice->direction = $request->invoices ? SyncDirection::PUSH : SyncDirection::NONE;
$quickbooks->settings->quote->direction = $request->quotes ? SyncDirection::PUSH : SyncDirection::NONE;
$quickbooks->settings->payment->direction = $request->payments ? SyncDirection::PUSH : SyncDirection::NONE;
$company->quickbooks = $quickbooks;
$company->save();
return response()->noContent();
}
public function disconnect(DisconnectQuickbooksRequest $request)
{
$user = auth()->user();
$company = $user->company();
$qb = new QuickbooksService($company);
$qb->sdk()->revokeAccessToken();
$company->quickbooks = null;
$company->save();
return response()->noContent();
}
}

View File

@@ -0,0 +1,49 @@
<?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\Http\Requests\Quickbooks;
use App\Models\Company;
use App\Models\User;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Facades\Cache;
class ConfigQuickbooksRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*
* @return bool
*/
public function authorize(): bool
{
return auth()->user()->isAdmin();
}
/**
* Get the validation rules that apply to the request.
*
* @return array
*/
public function rules(): array
{
return [
'invoices' => 'required|boolean|bail',
'quotes' => 'required|boolean|bail',
'payments' => 'required|boolean|bail',
'products' => 'required|boolean|bail',
'vendors' => 'required|boolean|bail',
'clients' => 'required|boolean|bail',
];
}
}

View File

@@ -0,0 +1,44 @@
<?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\Http\Requests\Quickbooks;
use App\Models\Company;
use App\Models\User;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Facades\Cache;
class DisconnectQuickbooksRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*
* @return bool
*/
public function authorize(): bool
{
return auth()->user()->isAdmin();
}
/**
* Get the validation rules that apply to the request.
*
* @return array
*/
public function rules(): array
{
return [
//
];
}
}

View File

@@ -0,0 +1,97 @@
<?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\Http\Requests\Quickbooks;
use App\Models\Company;
use App\Models\User;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Facades\Cache;
use Illuminate\Validation\Rule;
class SyncQuickbooksRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*
* @return bool
*/
public function authorize(): bool
{
return auth()->user()->isAdmin();
}
/**
* Get the validation rules that apply to the request.
*
* @return array
*/
public function rules(): array
{
return [
'clients' => [
'present_with:invoices,quotes,payments',
'nullable',
function ($attribute, $value, $fail) {
// If value is provided and not empty, validate it
if ($value !== null && $value !== '' && !in_array($value, ['email', 'name'])) {
$fail('The ' . $attribute . ' must be one of: email, name.');
}
},
],
'products' => ['sometimes', 'nullable', function ($attribute, $value, $fail) {
if ($value !== null && $value !== '' && $value !== 'product_key') {
$fail('The ' . $attribute . ' must be product_key.');
}
}],
'invoices' => ['sometimes', 'nullable', function ($attribute, $value, $fail) {
if ($value !== null && $value !== '' && $value !== 'number') {
$fail('The ' . $attribute . ' must be number.');
}
}],
'quotes' => ['sometimes', 'nullable', function ($attribute, $value, $fail) {
if ($value !== null && $value !== '' && $value !== 'number') {
$fail('The ' . $attribute . ' must be number.');
}
}],
'payments' => 'sometimes|nullable',
'vendors' => ['sometimes', 'nullable', function ($attribute, $value, $fail) {
if ($value !== null && $value !== '' && !in_array($value, ['email', 'name'])) {
$fail('The ' . $attribute . ' must be one of: email, name.');
}
}],
];
}
/**
* Prepare the data for validation.
* Convert empty strings to null for nullable fields.
*
* @return void
*/
protected function prepareForValidation(): void
{
$input = $this->all();
// Convert empty strings to null for nullable fields
$nullableFields = ['clients', 'products', 'invoices', 'quotes', 'payments', 'vendors'];
foreach ($nullableFields as $field) {
if (isset($input[$field]) && $input[$field] === '') {
$input[$field] = null;
}
}
$this->replace($input);
}
}

View File

@@ -1089,7 +1089,7 @@ class Company extends BaseModel
// Cache the detailed check for this request lifecycle
// This prevents re-checking if called multiple times in the same request
return once(function () use ($entity, $action, $status) {
return once(function () use ($entity) {
// Check if QuickBooks is actually configured (has token)
if (!$this->quickbooks->isConfigured()) {
return false;
@@ -1104,26 +1104,7 @@ class Company extends BaseModel
$direction = $entitySettings->direction->value;
// Check if sync direction allows push
if ($direction !== 'push' && $direction !== 'bidirectional') {
return false;
}
// Get push events from settings
$pushEvents = $this->quickbooks->settings->push_events;
// Check action-specific settings from QuickbooksPushEvents
return match($action) {
'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,
};
return $direction === 'push' || $direction === 'bidirectional';
});
}
}

View File

@@ -17,8 +17,8 @@ return [
'require_https' => env('REQUIRE_HTTPS', true),
'app_url' => rtrim(env('APP_URL', ''), '/'),
'app_domain' => env('APP_DOMAIN', 'invoicing.co'),
'app_version' => env('APP_VERSION', '5.12.49'),
'app_tag' => env('APP_TAG', '5.12.49'),
'app_version' => env('APP_VERSION', '5.12.50'),
'app_tag' => env('APP_TAG', '5.12.50'),
'minimum_client_version' => '5.0.16',
'terms_version' => '1.0.1',
'api_secret' => env('API_SECRET', false),

View File

@@ -336,6 +336,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_e_quote', [QuoteController::class, 'downloadEQuote'])->name('quotes.downloadEQuote');
Route::post('quickbooks/sync', [ImportQuickbooksController::class, 'sync'])->name('quickbooks.sync');
Route::post('quickbooks/configuration', [ImportQuickbooksController::class, 'configuration'])->name('quickbooks.configuration');
Route::resource('recurring_expenses', RecurringExpenseController::class);
Route::post('recurring_expenses/bulk', [RecurringExpenseController::class, 'bulk'])->name('recurring_expenses.bulk');
Route::put('recurring_expenses/{recurring_expense}/upload', [RecurringExpenseController::class, 'upload']);

View File

@@ -0,0 +1,467 @@
<?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\Feature\Quickbooks\Validation;
use Tests\TestCase;
use Illuminate\Support\Facades\Validator;
use App\Http\Requests\Quickbooks\SyncQuickbooksRequest;
use Illuminate\Routing\Middleware\ThrottleRequests;
use Tests\MockAccountData;
class SyncQuickbooksRequestTest extends TestCase
{
use MockAccountData;
protected SyncQuickbooksRequest $request;
protected function setUp(): void
{
parent::setUp();
$this->request = new SyncQuickbooksRequest();
$this->withoutMiddleware(
ThrottleRequests::class
);
$this->makeTestData();
}
/**
* Test that clients can be provided on its own (without invoices/quotes/payments)
*/
public function testClientsCanBeProvidedAlone(): void
{
$this->actingAs($this->user);
$data = [
'clients' => 'email',
];
$this->request->initialize($data);
$validator = Validator::make($data, $this->request->rules());
$this->assertTrue($validator->passes(), 'Clients should be valid when provided alone');
}
public function testClientsCanBeProvidedAloneWithEmptyString(): void
{
$this->actingAs($this->user);
$data = [
'clients' => '',
];
$this->request->initialize($data);
$validator = Validator::make($data, $this->request->rules());
$this->assertTrue($validator->passes(), 'Clients should be valid when provided alone');
}
/**
* Test that clients can be null/empty when provided alone
*/
public function testClientsCanBeNullWhenAlone(): void
{
$this->actingAs($this->user);
$data = [
'clients' => null,
];
$this->request->initialize($data);
$validator = Validator::make($data, $this->request->rules());
$this->assertTrue($validator->passes(), 'Clients should be valid when null and no invoices/quotes/payments');
}
/**
* Test that clients can be empty string when provided alone
*/
public function testClientsCanBeEmptyStringWhenAlone(): void
{
$this->actingAs($this->user);
$data = [
'clients' => '',
];
$this->request->initialize($data);
$validator = Validator::make($data, $this->request->rules());
$this->assertTrue($validator->passes(), 'Clients should be valid when empty string and no invoices/quotes/payments');
}
/**
* Test that clients is required when invoices is present
*/
public function testClientsIsRequiredWhenInvoicesPresent(): void
{
$this->actingAs($this->user);
$data = [
'invoices' => 'number',
];
$this->request->initialize($data);
$validator = Validator::make($data, $this->request->rules());
$this->assertFalse($validator->passes(), 'Clients should be required when invoices is present');
$this->assertArrayHasKey('clients', $validator->errors()->toArray());
}
/**
* Test that clients is required when quotes is present
*/
public function testClientsIsRequiredWhenQuotesPresent(): void
{
$this->actingAs($this->user);
$data = [
'quotes' => 'number',
];
$this->request->initialize($data);
$validator = Validator::make($data, $this->request->rules());
$this->assertFalse($validator->passes(), 'Clients should be required when quotes is present');
$this->assertArrayHasKey('clients', $validator->errors()->toArray());
}
/**
* Test that clients is required when payments is present
*/
public function testClientsIsRequiredWhenPaymentsPresent(): void
{
$this->actingAs($this->user);
$data = [
'payments' => true,
];
$this->request->initialize($data);
$validator = Validator::make($data, $this->request->rules());
$this->assertFalse($validator->passes(), 'Clients should be required when payments is present');
$this->assertArrayHasKey('clients', $validator->errors()->toArray());
}
/**
* Test that clients is required when multiple dependent fields are present
*/
public function testClientsIsRequiredWhenMultipleDependentFieldsPresent(): void
{
$this->actingAs($this->user);
$data = [
'invoices' => 'number',
'quotes' => 'number',
'payments' => true,
];
$this->request->initialize($data);
$validator = Validator::make($data, $this->request->rules());
$this->assertFalse($validator->passes(), 'Clients should be required when invoices, quotes, and payments are present');
$this->assertArrayHasKey('clients', $validator->errors()->toArray());
}
/**
* Test that clients with valid value 'email' passes when invoices is present
*/
public function testClientsWithEmailPassesWhenInvoicesPresent(): void
{
$this->actingAs($this->user);
$data = [
'clients' => 'email',
'invoices' => 'number',
];
$this->request->initialize($data);
$validator = Validator::make($data, $this->request->rules());
$this->assertTrue($validator->passes(), 'Clients with email should be valid when invoices is present');
}
/**
* Test that clients with valid value 'name' passes when invoices is present
*/
public function testClientsWithNamePassesWhenInvoicesPresent(): void
{
$this->actingAs($this->user);
$data = [
'clients' => 'name',
'invoices' => 'number',
];
$this->request->initialize($data);
$validator = Validator::make($data, $this->request->rules());
$this->assertTrue($validator->passes(), 'Clients with name should be valid when invoices is present');
}
/**
* Test that clients with empty string passes when invoices is present (nullable)
*/
public function testClientsWithEmptyStringPassesWhenInvoicesPresent(): void
{
$this->actingAs($this->user);
$data = [
'clients' => '',
'invoices' => 'number',
];
$this->request->initialize($data);
$validator = Validator::make($data, $this->request->rules());
$this->assertTrue($validator->passes(), 'Clients with empty string should be valid when invoices is present (nullable)');
}
/**
* Test that clients with invalid value fails
*/
public function testClientsWithInvalidValueFails(): void
{
$this->actingAs($this->user);
$data = [
'clients' => 'invalid_value',
];
$this->request->initialize($data);
$validator = Validator::make($data, $this->request->rules());
$this->assertFalse($validator->passes(), 'Clients with invalid value should fail');
$this->assertArrayHasKey('clients', $validator->errors()->toArray());
}
/**
* Test that products with valid value passes
*/
public function testProductsWithValidValuePasses(): void
{
$this->actingAs($this->user);
$data = [
'products' => 'product_key',
];
$this->request->initialize($data);
$validator = Validator::make($data, $this->request->rules());
$this->assertTrue($validator->passes(), 'Products with product_key should be valid');
}
/**
* Test that products with invalid value fails
*/
public function testProductsWithInvalidValueFails(): void
{
$this->actingAs($this->user);
$data = [
'products' => 'invalid_value',
];
$this->request->initialize($data);
$validator = Validator::make($data, $this->request->rules());
$this->assertFalse($validator->passes(), 'Products with invalid value should fail');
$this->assertArrayHasKey('products', $validator->errors()->toArray());
}
/**
* Test that invoices with valid value passes
*/
public function testInvoicesWithValidValuePasses(): void
{
$this->actingAs($this->user);
$data = [
'clients' => 'email',
'invoices' => 'number',
];
$this->request->initialize($data);
$validator = Validator::make($data, $this->request->rules());
$this->assertTrue($validator->passes(), 'Invoices with number should be valid');
}
/**
* Test that invoices with invalid value fails
*/
public function testInvoicesWithInvalidValueFails(): void
{
$this->actingAs($this->user);
$data = [
'clients' => 'email',
'invoices' => 'invalid_value',
];
$this->request->initialize($data);
$validator = Validator::make($data, $this->request->rules());
$this->assertFalse($validator->passes(), 'Invoices with invalid value should fail');
$this->assertArrayHasKey('invoices', $validator->errors()->toArray());
}
/**
* Test that quotes with valid value passes
*/
public function testQuotesWithValidValuePasses(): void
{
$this->actingAs($this->user);
$data = [
'clients' => 'email',
'quotes' => 'number',
];
$this->request->initialize($data);
$validator = Validator::make($data, $this->request->rules());
$this->assertTrue($validator->passes(), 'Quotes with number should be valid');
}
/**
* Test that quotes with invalid value fails
*/
public function testQuotesWithInvalidValueFails(): void
{
$this->actingAs($this->user);
$data = [
'clients' => 'email',
'quotes' => 'invalid_value',
];
$this->request->initialize($data);
$validator = Validator::make($data, $this->request->rules());
$this->assertFalse($validator->passes(), 'Quotes with invalid value should fail');
$this->assertArrayHasKey('quotes', $validator->errors()->toArray());
}
/**
* Test that vendors with valid value passes
*/
public function testVendorsWithValidValuePasses(): void
{
$this->actingAs($this->user);
$data = [
'vendors' => 'email',
];
$this->request->initialize($data);
$validator = Validator::make($data, $this->request->rules());
$this->assertTrue($validator->passes(), 'Vendors with email should be valid');
}
/**
* Test that vendors with name value passes
*/
public function testVendorsWithNameValuePasses(): void
{
$this->actingAs($this->user);
$data = [
'vendors' => 'name',
];
$this->request->initialize($data);
$validator = Validator::make($data, $this->request->rules());
$this->assertTrue($validator->passes(), 'Vendors with name should be valid');
}
/**
* Test that vendors with invalid value fails
*/
public function testVendorsWithInvalidValueFails(): void
{
$this->actingAs($this->user);
$data = [
'vendors' => 'invalid_value',
];
$this->request->initialize($data);
$validator = Validator::make($data, $this->request->rules());
$this->assertFalse($validator->passes(), 'Vendors with invalid value should fail');
$this->assertArrayHasKey('vendors', $validator->errors()->toArray());
}
/**
* Test that all fields can be provided together with valid values
*/
public function testAllFieldsWithValidValuesPasses(): void
{
$this->actingAs($this->user);
$data = [
'clients' => 'email',
'products' => 'product_key',
'invoices' => 'number',
'quotes' => 'number',
'payments' => true,
'vendors' => 'name',
];
$this->request->initialize($data);
$validator = Validator::make($data, $this->request->rules());
$this->assertTrue($validator->passes(), 'All fields with valid values should pass');
}
/**
* Test that empty request passes (all fields are optional)
*/
public function testEmptyRequestPasses(): void
{
$this->actingAs($this->user);
$data = [];
$this->request->initialize($data);
$validator = Validator::make($data, $this->request->rules());
$this->assertTrue($validator->passes(), 'Empty request should pass (all fields are optional)');
}
/**
* Test that payments can be any value (no validation on payments field itself)
*/
public function testPaymentsCanBeAnyValue(): void
{
$this->actingAs($this->user);
$data = [
'clients' => 'email',
'payments' => 'any_value_here',
];
$this->request->initialize($data);
$validator = Validator::make($data, $this->request->rules());
$this->assertTrue($validator->passes(), 'Payments can be any value when clients is provided');
}
}