diff --git a/VERSION.txt b/VERSION.txt index 13ee93ba38..ef425f93f1 100644 --- a/VERSION.txt +++ b/VERSION.txt @@ -1 +1 @@ -5.12.49 \ No newline at end of file +5.12.50 \ No newline at end of file diff --git a/app/DataMapper/QuickbooksPushEvents.php b/app/DataMapper/QuickbooksPushEvents.php deleted file mode 100644 index cd0d8e05cf..0000000000 --- a/app/DataMapper/QuickbooksPushEvents.php +++ /dev/null @@ -1,57 +0,0 @@ - - */ - 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/QuickbooksSettings.php b/app/DataMapper/QuickbooksSettings.php index f6feb087f4..b5ca2a6976 100644 --- a/app/DataMapper/QuickbooksSettings.php +++ b/app/DataMapper/QuickbooksSettings.php @@ -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(), ]; } diff --git a/app/DataMapper/QuickbooksSync.php b/app/DataMapper/QuickbooksSync.php index 65c1b25079..edebae1e49 100644 --- a/app/DataMapper/QuickbooksSync.php +++ b/app/DataMapper/QuickbooksSync.php @@ -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(), ]; } } diff --git a/app/DataMapper/QuickbooksSyncMap.php b/app/DataMapper/QuickbooksSyncMap.php index a8a7552eaa..897f6e2bf2 100644 --- a/app/DataMapper/QuickbooksSyncMap.php +++ b/app/DataMapper/QuickbooksSyncMap.php @@ -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 diff --git a/app/Http/Controllers/ImportQuickbooksController.php b/app/Http/Controllers/ImportQuickbooksController.php index 295e5d6a3b..63c6f1e7dc 100644 --- a/app/Http/Controllers/ImportQuickbooksController.php +++ b/app/Http/Controllers/ImportQuickbooksController.php @@ -33,8 +33,6 @@ class ImportQuickbooksController extends BaseController $authorizationUrl = $qb->sdk()->getAuthorizationUrl(); - nlog($authorizationUrl); - $state = $qb->sdk()->getState(); Cache::put($state, $token, 190); diff --git a/app/Http/Controllers/QuickbooksController.php b/app/Http/Controllers/QuickbooksController.php new file mode 100644 index 0000000000..cd2451b01d --- /dev/null +++ b/app/Http/Controllers/QuickbooksController.php @@ -0,0 +1,63 @@ +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(); + } +} \ No newline at end of file diff --git a/app/Http/Requests/Quickbooks/ConfigQuickbooksRequest.php b/app/Http/Requests/Quickbooks/ConfigQuickbooksRequest.php new file mode 100644 index 0000000000..fa6a95c582 --- /dev/null +++ b/app/Http/Requests/Quickbooks/ConfigQuickbooksRequest.php @@ -0,0 +1,49 @@ +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', + ]; + } + +} diff --git a/app/Http/Requests/Quickbooks/DisconnectQuickbooksRequest.php b/app/Http/Requests/Quickbooks/DisconnectQuickbooksRequest.php new file mode 100644 index 0000000000..f5381b10d3 --- /dev/null +++ b/app/Http/Requests/Quickbooks/DisconnectQuickbooksRequest.php @@ -0,0 +1,44 @@ +user()->isAdmin(); + } + + /** + * Get the validation rules that apply to the request. + * + * @return array + */ + public function rules(): array + { + return [ + // + ]; + } + +} diff --git a/app/Http/Requests/Quickbooks/SyncQuickbooksRequest.php b/app/Http/Requests/Quickbooks/SyncQuickbooksRequest.php new file mode 100644 index 0000000000..90864a51b4 --- /dev/null +++ b/app/Http/Requests/Quickbooks/SyncQuickbooksRequest.php @@ -0,0 +1,97 @@ +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); + } + +} diff --git a/app/Models/Company.php b/app/Models/Company.php index a2f0b78123..4fab2e6164 100644 --- a/app/Models/Company.php +++ b/app/Models/Company.php @@ -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'; }); } } diff --git a/config/ninja.php b/config/ninja.php index da8a268256..87ca61bc35 100644 --- a/config/ninja.php +++ b/config/ninja.php @@ -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), diff --git a/routes/api.php b/routes/api.php index 4f95293b51..cb345e579d 100644 --- a/routes/api.php +++ b/routes/api.php @@ -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']); diff --git a/tests/Feature/Quickbooks/Validation/SyncQuickbooksRequestTest.php b/tests/Feature/Quickbooks/Validation/SyncQuickbooksRequestTest.php new file mode 100644 index 0000000000..1488ad97df --- /dev/null +++ b/tests/Feature/Quickbooks/Validation/SyncQuickbooksRequestTest.php @@ -0,0 +1,467 @@ +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'); + } +}