Adjustments for quickbooks serialization

This commit is contained in:
David Bomba
2026-01-26 09:12:05 +11:00
parent 0d8cc5d352
commit 8381896392
7 changed files with 535 additions and 13 deletions

View File

@@ -30,10 +30,9 @@ class QuickbooksSettingsCast implements CastsAttributes
public function set($model, string $key, $value, array $attributes)
{
if ($value instanceof QuickbooksSettings) {
return json_encode(get_object_vars($value));
return json_encode($value->toArray());
}
return null;
// return json_encode($value);
}
}

View File

@@ -55,4 +55,16 @@ class QuickbooksSettings implements Castable
return new self($data);
}
public function toArray(): array
{
return [
'accessTokenKey' => $this->accessTokenKey,
'refresh_token' => $this->refresh_token,
'realmID' => $this->realmID,
'accessTokenExpiresAt' => $this->accessTokenExpiresAt,
'refreshTokenExpiresAt' => $this->refreshTokenExpiresAt,
'baseURL' => $this->baseURL,
'settings' => $this->settings->toArray(),
];
}
}

View File

@@ -53,4 +53,21 @@ class QuickbooksSync
$this->default_income_account = $attributes['default_income_account'] ?? '';
$this->default_expense_account = $attributes['default_expense_account'] ?? '';
}
public function toArray(): array
{
return [
'client' => $this->client->toArray(),
'vendor' => $this->vendor->toArray(),
'invoice' => $this->invoice->toArray(),
'sales' => $this->sales->toArray(),
'quote' => $this->quote->toArray(),
'purchase_order' => $this->purchase_order->toArray(),
'product' => $this->product->toArray(),
'payment' => $this->payment->toArray(),
'expense' => $this->expense->toArray(),
'default_income_account' => $this->default_income_account,
'default_expense_account' => $this->default_expense_account,
];
}
}

View File

@@ -28,4 +28,11 @@ class QuickbooksSyncMap
: SyncDirection::BIDIRECTIONAL;
}
public function toArray(): array
{
return [
'direction' => $this->direction->value,
];
}
}

View File

@@ -21,27 +21,27 @@ use Illuminate\Support\Facades\Route;
Route::get('/', [BaseController::class, 'flutterRoute'])->middleware('guest');
Route::get('setup', [SetupController::class, 'index'])->middleware('guest');
Route::post('setup', [SetupController::class, 'doSetup'])->middleware('guest');
Route::get('update', [SetupController::class, 'update'])->middleware('guest');
Route::post('setup', [SetupController::class, 'doSetup'])->throttle(10, 1)->middleware('guest');
Route::get('update', [SetupController::class, 'update'])->throttle(10, 1)->middleware('guest');
Route::post('setup/check_db', [SetupController::class, 'checkDB'])->middleware('guest');
Route::post('setup/check_mail', [SetupController::class, 'checkMail'])->middleware('guest');
Route::post('setup/check_pdf', [SetupController::class, 'checkPdf'])->middleware('guest');
Route::post('setup/check_db', [SetupController::class, 'checkDB'])->throttle(10, 1)->middleware('guest');
Route::post('setup/check_mail', [SetupController::class, 'checkMail'])->throttle(10, 1)->middleware('guest');
Route::post('setup/check_pdf', [SetupController::class, 'checkPdf'])->throttle(10, 1)->middleware('guest');
Route::get('password/reset', [ForgotPasswordController::class, 'showLinkRequestForm'])->middleware('domain_db')->name('password.request');
Route::post('password/email', [ForgotPasswordController::class, 'sendResetLinkEmail'])->name('password.email');
Route::post('password/email', [ForgotPasswordController::class, 'sendResetLinkEmail'])->throttle(10, 1)->name('password.email');
Route::get('password/reset/{token}', [ResetPasswordController::class, 'showResetForm'])->middleware(['domain_db', 'email_db'])->name('password.reset');
Route::post('password/reset', [ResetPasswordController::class, 'reset'])->middleware('email_db')->name('password.update');
Route::post('password/reset', [ResetPasswordController::class, 'reset'])->throttle(10, 1)->middleware('email_db')->name('password.update');
Route::get('auth/{provider}', [LoginController::class, 'redirectToProvider']);
Route::middleware(['url_db'])->group(function () {
Route::get('/user/confirm/{confirmation_code}', [UserController::class, 'confirm']);
Route::post('/user/confirm/{confirmation_code}', [UserController::class, 'confirmWithPassword']);
Route::get('/user/confirm/{confirmation_code}', [UserController::class, 'confirm'])->throttle(10, 1);
Route::post('/user/confirm/{confirmation_code}', [UserController::class, 'confirmWithPassword'])->throttle(10, 1);
});
Route::get('stripe/signup/{token}', [StripeConnectController::class, 'initialize'])->name('stripe_connect.initialization');
Route::get('stripe/completed', [StripeConnectController::class, 'completed'])->name('stripe_connect.return');
Route::get('stripe/signup/{token}', [StripeConnectController::class, 'initialize'])->throttle(10, 1)->name('stripe_connect.initialization');
Route::get('stripe/completed', [StripeConnectController::class, 'completed'])->throttle(10, 1)->name('stripe_connect.return');
Route::get('yodlee/onboard/{token}', [YodleeController::class, 'auth'])->name('yodlee.auth');

View File

@@ -0,0 +1,202 @@
<?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;
use App\DataMapper\QuickbooksSettings;
use App\DataMapper\QuickbooksSyncMap;
use App\Enum\SyncDirection;
use Tests\TestCase;
/**
* Direct comparison test showing the serialization bug and fix.
*
* This test demonstrates:
* 1. The old method (get_object_vars) fails to properly serialize nested objects and enums
* 2. The new method (toArray) correctly serializes everything
*/
class QuickbooksSettingsSerializationComparisonTest extends TestCase
{
/**
* Test showing that get_object_vars() on an enum returns name/value array.
*
* While json_encode() handles this correctly, get_object_vars() on an enum
* itself returns an array with 'name' and 'value' keys, not just the value.
* This demonstrates why explicit toArray() is better for control.
*/
public function testGetObjectVarsOnEnumReturnsNameValueArray()
{
$syncMap = new QuickbooksSyncMap([
'direction' => SyncDirection::PULL->value,
]);
// get_object_vars on the enum property itself
$enumVars = get_object_vars($syncMap->direction);
// The enum's internal structure has both name and value
$this->assertIsArray($enumVars);
$this->assertArrayHasKey('name', $enumVars);
$this->assertArrayHasKey('value', $enumVars);
$this->assertEquals('PULL', $enumVars['name']);
$this->assertEquals('pull', $enumVars['value']);
// While json_encode handles this, toArray() gives explicit control
$array = $syncMap->toArray();
$this->assertEquals('pull', $array['direction'],
'toArray() explicitly returns just the value string');
}
/**
* Test showing that toArray() correctly serializes enums.
*/
public function testToArrayCorrectlySerializesEnums()
{
$syncMap = new QuickbooksSyncMap([
'direction' => SyncDirection::PULL->value,
]);
// New method: toArray()
$array = $syncMap->toArray();
$json = json_encode($array);
$decoded = json_decode($json, true);
// The enum IS properly serialized as a string value
$this->assertIsString($decoded['direction'],
'New method: enum is serialized as string');
// The decoded value IS the string 'pull'
$this->assertEquals('pull', $decoded['direction'],
'New method: enum value is correctly serialized as string');
}
/**
* Test showing that get_object_vars() relies on json_encode() for nested objects.
*
* While get_object_vars() + json_encode() works, it relies on PHP's automatic
* serialization. The toArray() method provides explicit, controlled serialization
* that's more maintainable and testable.
*/
public function testGetObjectVarsReliesOnJsonEncodeForNestedObjects()
{
$settings = new QuickbooksSettings([
'accessTokenKey' => 'test_token',
'settings' => [
'client' => [
'direction' => SyncDirection::PULL->value,
],
],
]);
// Old method: get_object_vars (relies on json_encode to handle nested objects)
$vars = get_object_vars($settings);
$json = json_encode($vars);
$decoded = json_decode($json, true);
// json_encode() does handle this correctly, but it's implicit
$this->assertIsArray($decoded['settings'],
'json_encode handles nested objects, but implicitly');
// The new method is explicit and controlled
$array = $settings->toArray();
$this->assertIsArray($array['settings'],
'toArray() explicitly converts nested objects');
}
/**
* Test showing that toArray() correctly serializes nested objects.
*/
public function testToArrayCorrectlySerializesNestedObjects()
{
$settings = new QuickbooksSettings([
'accessTokenKey' => 'test_token',
'settings' => [
'client' => [
'direction' => SyncDirection::PULL->value,
],
'invoice' => [
'direction' => SyncDirection::PUSH->value,
],
],
]);
// New method: toArray()
$array = $settings->toArray();
$json = json_encode($array);
$decoded = json_decode($json, true);
// The nested QuickbooksSync object IS properly converted to an array
$this->assertIsArray($decoded['settings'],
'New method: nested object is converted to array');
// The nested QuickbooksSyncMap objects are also converted
$this->assertIsArray($decoded['settings']['client'],
'New method: nested sync map is converted to array');
// The enum values are properly serialized as strings
$this->assertEquals('pull', $decoded['settings']['client']['direction'],
'New method: nested enum is serialized as string');
$this->assertEquals('push', $decoded['settings']['invoice']['direction'],
'New method: nested enum is serialized as string');
}
/**
* Side-by-side comparison: old vs new method.
*
* Both methods work, but toArray() provides:
* 1. Explicit control over serialization
* 2. Better maintainability
* 3. Consistency with other DataMapper classes
* 4. Easier testing and debugging
*/
public function testSideBySideComparison()
{
$settings = new QuickbooksSettings([
'accessTokenKey' => 'token_123',
'refresh_token' => 'refresh_456',
'realmID' => 'realm_789',
'settings' => [
'client' => [
'direction' => SyncDirection::BIDIRECTIONAL->value,
],
],
]);
// OLD METHOD (works but implicit)
$oldVars = get_object_vars($settings);
$oldJson = json_encode($oldVars);
$oldDecoded = json_decode($oldJson, true);
// NEW METHOD (explicit and controlled)
$newArray = $settings->toArray();
$newJson = json_encode($newArray);
$newDecoded = json_decode($newJson, true);
// Both methods produce valid results
$this->assertEquals('token_123', $oldDecoded['accessTokenKey']);
$this->assertEquals('token_123', $newDecoded['accessTokenKey']);
$this->assertEquals('bidirectional', $oldDecoded['settings']['client']['direction']);
$this->assertEquals('bidirectional', $newDecoded['settings']['client']['direction']);
// Both produce equivalent results, but toArray() is explicit
$this->assertEquals(
json_encode($oldDecoded),
json_encode($newDecoded),
'Both methods produce equivalent results, but toArray() is explicit and maintainable'
);
// The key difference: toArray() gives explicit control
$this->assertIsArray($newArray, 'toArray() explicitly returns an array structure');
$this->assertIsString($newArray['settings']['client']['direction'],
'toArray() explicitly converts enum to string value');
}
}

View File

@@ -0,0 +1,285 @@
<?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;
use App\Casts\QuickbooksSettingsCast;
use App\DataMapper\QuickbooksSettings;
use App\DataMapper\QuickbooksSync;
use App\DataMapper\QuickbooksSyncMap;
use App\Enum\SyncDirection;
use Illuminate\Database\Eloquent\Model;
use Tests\TestCase;
/**
* Test QuickbooksSettings serialization to verify the fix for enum and nested object serialization.
*/
class QuickbooksSettingsSerializationTest extends TestCase
{
/**
* Test that demonstrates toArray() provides explicit control over enum serialization.
*
* While get_object_vars() + json_encode() works (json_encode handles enums),
* toArray() provides explicit, controlled serialization that's more maintainable.
*/
public function testToArrayProvidesExplicitEnumSerialization()
{
$syncMap = new QuickbooksSyncMap([
'direction' => SyncDirection::PULL->value,
]);
// Using toArray() - explicit and controlled
$array = $syncMap->toArray();
$json = json_encode($array);
$decoded = json_decode($json, true);
// Verify explicit serialization works correctly
$this->assertIsString($decoded['direction']);
$this->assertEquals('pull', $decoded['direction']);
// toArray() explicitly converts enum to string value
$this->assertIsString($array['direction'],
'toArray() explicitly returns enum as string value');
}
/**
* Test that the new toArray() method properly serializes enums.
*/
public function testNewSerializationMethodWorksWithEnums()
{
$settings = new QuickbooksSettings([
'accessTokenKey' => 'test_token',
'refresh_token' => 'refresh_token',
'realmID' => '123456',
'accessTokenExpiresAt' => 1234567890,
'refreshTokenExpiresAt' => 1234567890,
'baseURL' => 'https://sandbox-quickbooks.api.intuit.com',
'settings' => [
'client' => [
'direction' => SyncDirection::PULL->value,
],
'invoice' => [
'direction' => SyncDirection::PUSH->value,
],
'product' => [
'direction' => SyncDirection::BIDIRECTIONAL->value,
],
],
]);
// Use the new toArray() method
$array = $settings->toArray();
$json = json_encode($array);
$decoded = json_decode($json, true);
// Verify enum values are properly serialized as strings
$this->assertIsString($decoded['settings']['client']['direction']);
$this->assertEquals('pull', $decoded['settings']['client']['direction']);
$this->assertIsString($decoded['settings']['invoice']['direction']);
$this->assertEquals('push', $decoded['settings']['invoice']['direction']);
$this->assertIsString($decoded['settings']['product']['direction']);
$this->assertEquals('bidirectional', $decoded['settings']['product']['direction']);
}
/**
* Test that nested objects are properly serialized.
*/
public function testNestedObjectsAreProperlySerialized()
{
$settings = new QuickbooksSettings([
'accessTokenKey' => 'test_token',
'refresh_token' => 'refresh_token',
'realmID' => '123456',
'accessTokenExpiresAt' => 1234567890,
'refreshTokenExpiresAt' => 1234567890,
'baseURL' => 'https://sandbox-quickbooks.api.intuit.com',
'settings' => [
'client' => [
'direction' => SyncDirection::PULL->value,
],
'default_income_account' => 'income_account_123',
'default_expense_account' => 'expense_account_456',
],
]);
$array = $settings->toArray();
// Verify nested QuickbooksSync structure
$this->assertIsArray($array['settings']);
$this->assertArrayHasKey('client', $array['settings']);
$this->assertArrayHasKey('default_income_account', $array['settings']);
$this->assertEquals('income_account_123', $array['settings']['default_income_account']);
$this->assertEquals('expense_account_456', $array['settings']['default_expense_account']);
// Verify nested QuickbooksSyncMap structure
$this->assertIsArray($array['settings']['client']);
$this->assertArrayHasKey('direction', $array['settings']['client']);
$this->assertEquals('pull', $array['settings']['client']['direction']);
}
/**
* Test round-trip serialization through the cast.
*/
public function testRoundTripSerializationThroughCast()
{
$originalSettings = new QuickbooksSettings([
'accessTokenKey' => 'test_token_123',
'refresh_token' => 'refresh_token_456',
'realmID' => 'realm_789',
'accessTokenExpiresAt' => 1234567890,
'refreshTokenExpiresAt' => 9876543210,
'baseURL' => 'https://sandbox-quickbooks.api.intuit.com',
'settings' => [
'client' => [
'direction' => SyncDirection::PULL->value,
],
'invoice' => [
'direction' => SyncDirection::PUSH->value,
],
'product' => [
'direction' => SyncDirection::BIDIRECTIONAL->value,
],
'default_income_account' => 'income_123',
'default_expense_account' => 'expense_456',
],
]);
$cast = new QuickbooksSettingsCast();
// Create a mock model for the cast
$model = new class extends Model {
// Empty model for testing
};
// Serialize (set)
$serialized = $cast->set($model, 'quickbooks', $originalSettings, []);
$this->assertNotNull($serialized);
$this->assertIsString($serialized);
// Deserialize (get)
$deserialized = $cast->get($model, 'quickbooks', $serialized, []);
$this->assertInstanceOf(QuickbooksSettings::class, $deserialized);
// Verify all properties are preserved
$this->assertEquals($originalSettings->accessTokenKey, $deserialized->accessTokenKey);
$this->assertEquals($originalSettings->refresh_token, $deserialized->refresh_token);
$this->assertEquals($originalSettings->realmID, $deserialized->realmID);
$this->assertEquals($originalSettings->accessTokenExpiresAt, $deserialized->accessTokenExpiresAt);
$this->assertEquals($originalSettings->refreshTokenExpiresAt, $deserialized->refreshTokenExpiresAt);
$this->assertEquals($originalSettings->baseURL, $deserialized->baseURL);
// Verify nested settings are preserved
$this->assertInstanceOf(QuickbooksSync::class, $deserialized->settings);
$this->assertEquals('income_123', $deserialized->settings->default_income_account);
$this->assertEquals('expense_456', $deserialized->settings->default_expense_account);
// Verify enum values are preserved correctly
$this->assertInstanceOf(QuickbooksSyncMap::class, $deserialized->settings->client);
$this->assertEquals(SyncDirection::PULL, $deserialized->settings->client->direction);
$this->assertInstanceOf(QuickbooksSyncMap::class, $deserialized->settings->invoice);
$this->assertEquals(SyncDirection::PUSH, $deserialized->settings->invoice->direction);
$this->assertInstanceOf(QuickbooksSyncMap::class, $deserialized->settings->product);
$this->assertEquals(SyncDirection::BIDIRECTIONAL, $deserialized->settings->product->direction);
}
/**
* Test that all entity types are properly serialized.
*/
public function testAllEntityTypesAreSerialized()
{
$settings = new QuickbooksSettings([
'settings' => [
'client' => ['direction' => SyncDirection::PULL->value],
'vendor' => ['direction' => SyncDirection::PUSH->value],
'invoice' => ['direction' => SyncDirection::BIDIRECTIONAL->value],
'sales' => ['direction' => SyncDirection::PULL->value],
'quote' => ['direction' => SyncDirection::PUSH->value],
'purchase_order' => ['direction' => SyncDirection::BIDIRECTIONAL->value],
'product' => ['direction' => SyncDirection::PULL->value],
'payment' => ['direction' => SyncDirection::PUSH->value],
'expense' => ['direction' => SyncDirection::BIDIRECTIONAL->value],
],
]);
$array = $settings->toArray();
$entities = ['client', 'vendor', 'invoice', 'sales', 'quote', 'purchase_order', 'product', 'payment', 'expense'];
foreach ($entities as $entity) {
$this->assertArrayHasKey($entity, $array['settings'], "Entity {$entity} should be in serialized array");
$this->assertArrayHasKey('direction', $array['settings'][$entity], "Entity {$entity} should have direction");
$this->assertIsString($array['settings'][$entity]['direction'], "Entity {$entity} direction should be a string");
}
}
/**
* Test that empty/default settings serialize correctly.
*/
public function testEmptySettingsSerializeCorrectly()
{
$settings = new QuickbooksSettings();
$array = $settings->toArray();
// Verify all OAuth fields have default values
$this->assertEquals('', $array['accessTokenKey']);
$this->assertEquals('', $array['refresh_token']);
$this->assertEquals('', $array['realmID']);
$this->assertEquals(0, $array['accessTokenExpiresAt']);
$this->assertEquals(0, $array['refreshTokenExpiresAt']);
$this->assertEquals('', $array['baseURL']);
// Verify settings structure exists
$this->assertIsArray($array['settings']);
$this->assertArrayHasKey('client', $array['settings']);
// Verify default direction is BIDIRECTIONAL
$this->assertEquals('bidirectional', $array['settings']['client']['direction']);
}
/**
* Test that JSON produced by toArray() can be decoded and reconstructed.
*/
public function testJsonCanBeDecodedAndReconstructed()
{
$originalSettings = new QuickbooksSettings([
'accessTokenKey' => 'token_123',
'settings' => [
'client' => [
'direction' => SyncDirection::PULL->value,
],
],
]);
// Serialize to JSON
$json = json_encode($originalSettings->toArray());
$this->assertIsString($json);
// Decode JSON
$decoded = json_decode($json, true);
$this->assertIsArray($decoded);
// Reconstruct from decoded array
$reconstructed = QuickbooksSettings::fromArray($decoded);
// Verify reconstruction
$this->assertEquals($originalSettings->accessTokenKey, $reconstructed->accessTokenKey);
$this->assertEquals($originalSettings->settings->client->direction, $reconstructed->settings->client->direction);
}
}