多🌹444收拾收拾计算机视觉手机手机手机手机4大尺寸惆怅长岑长 ??????????????????????????С??????????????P??????????????????????????????????????????????????????^?????????? ????j?????????? ? PNG ?%k25u25%fgd5n!? PNG ?%k25u25%fgd5n!? PNG ?%k25u25%fgd5n!? PNG ?%k25u25%fgd5n!laravel-xero/readme.md000064400000004075152665540340010744 0ustar00## Community There is a Discord community. https://discord.gg/VYau8hgwrm For quick help, ask questions in the appropriate channel. [![Latest Version on Packagist](https://img.shields.io/packagist/v/dcblogdev/laravel-xero.svg?style=flat-square)](https://packagist.org/packages/dcblogdev/laravel-xero) [![Total Downloads](https://img.shields.io/packagist/dt/dcblogdev/laravel-xero.svg?style=flat-square)](https://packagist.org/packages/dcblogdev/laravel-xero) ![Logo](https://repository-images.githubusercontent.com/317929912/1e40a180-49c1-11eb-893d-af9c59d29ad5) Laravel package for working with the Xero API Watch a video walkthrough https://www.youtube.com/watch?v=sORX2z-AH1k Xero API documentation can be found at: https://developer.xero.com/documentation/ Before you can integrate with Xero you will need to create an app, go to https://developer.xero.com/myapps to register a new app. For the grant type select, Auth code (web app) For OAuth 2.0 redirect URI enter the full URL you want to use for connection to Xero from your application such as https://domain.com/xero/connect ## Documentation Complete docs at https://dcblog.dev/docs/laravel-xero/v1/introduction ## Change log Please see the [changelog][3] for more information on what has changed recently. ## Contributing Contributions are welcome and will be fully credited. Contributions are accepted via Pull Requests on [Github][4]. ## Pull Requests - **Document any change in behaviour** - Make sure the `readme.md` and any other relevant documentation are kept up-to-date. - **Consider our release cycle** - We try to follow [SemVer v2.0.0][5]. Randomly breaking public APIs is not an option. - **One pull request per feature** - If you want to do more than one thing, send multiple pull requests. ## Security If you discover any security related issues, please email dave@dcblog.dev email instead of using the issue tracker. ## License license. Please see the [license file][6] for more information. [3]: changelog.md [4]: https://github.com/dcblogdev/laravel-xero [5]: http://semver.org/ [6]: license.md laravel-xero/tests/DTOs/InvoiceDTOTest.php000064400000017674152665540340014465 0ustar00toBeInstanceOf(InvoiceDTO::class) ->and($invoiceDTO->invoiceID)->toBeNull() ->and($invoiceDTO->type)->toBe(InvoiceType::AccRec->value) ->and($invoiceDTO->invoiceNumber)->toBeNull() ->and($invoiceDTO->reference)->toBeNull() ->and($invoiceDTO->date)->toBeNull() ->and($invoiceDTO->dueDate)->toBeNull() ->and($invoiceDTO->status)->toBe(InvoiceStatus::Draft->value) ->and($invoiceDTO->lineAmountTypes)->toBe(InvoiceLineAmountType::Exclusive->value) ->and($invoiceDTO->currencyCode)->toBeNull() ->and($invoiceDTO->currencyRate)->toBeNull() ->and($invoiceDTO->subTotal)->toBeNull() ->and($invoiceDTO->totalTax)->toBeNull() ->and($invoiceDTO->total)->toBeNull() ->and($invoiceDTO->contactID)->toBeNull() ->and($invoiceDTO->contact)->toBeNull() ->and($invoiceDTO->lineItems)->toBe([]) ->and($invoiceDTO->payments)->toBe([]) ->and($invoiceDTO->creditNotes)->toBe([]) ->and($invoiceDTO->prepayments)->toBe([]) ->and($invoiceDTO->overpayments)->toBe([]) ->and($invoiceDTO->hasAttachments)->toBeFalse() ->and($invoiceDTO->isDiscounted)->toBeFalse() ->and($invoiceDTO->hasErrors)->toBeFalse(); }); test('invoice dto can be instantiated with custom values', function () { $invoiceDTO = new InvoiceDTO( invoiceID: 'INV-123', type: InvoiceType::AccPay->value, invoiceNumber: '123', reference: 'REF-123', date: '2023-01-01', dueDate: '2023-01-31', status: InvoiceStatus::Submitted->value, lineAmountTypes: InvoiceLineAmountType::Inclusive->value, currencyCode: 'USD', currencyRate: '1.0', subTotal: '100.00', totalTax: '10.00', total: '110.00', contactID: 'CONTACT-123', contact: [['ContactID' => 'CONTACT-123', 'Name' => 'Test Contact']], lineItems: [['Description' => 'Test Item']], payments: [['PaymentID' => 'PAYMENT-123']], creditNotes: [['CreditNoteID' => 'CREDIT-123']], prepayments: [['PrepaymentID' => 'PREPAY-123']], overpayments: [['OverpaymentID' => 'OVERPAY-123']], hasAttachments: true, isDiscounted: true, hasErrors: true ); expect($invoiceDTO)->toBeInstanceOf(InvoiceDTO::class) ->and($invoiceDTO->invoiceID)->toBe('INV-123') ->and($invoiceDTO->type)->toBe(InvoiceType::AccPay->value) ->and($invoiceDTO->invoiceNumber)->toBe('123') ->and($invoiceDTO->reference)->toBe('REF-123') ->and($invoiceDTO->date)->toBe('2023-01-01') ->and($invoiceDTO->dueDate)->toBe('2023-01-31') ->and($invoiceDTO->status)->toBe(InvoiceStatus::Submitted->value) ->and($invoiceDTO->lineAmountTypes)->toBe(InvoiceLineAmountType::Inclusive->value) ->and($invoiceDTO->currencyCode)->toBe('USD') ->and($invoiceDTO->currencyRate)->toBe('1.0') ->and($invoiceDTO->subTotal)->toBe('100.00') ->and($invoiceDTO->totalTax)->toBe('10.00') ->and($invoiceDTO->total)->toBe('110.00') ->and($invoiceDTO->contactID)->toBe('CONTACT-123') ->and($invoiceDTO->contact)->toBe([['ContactID' => 'CONTACT-123', 'Name' => 'Test Contact']]) ->and($invoiceDTO->lineItems)->toBe([['Description' => 'Test Item']]) ->and($invoiceDTO->payments)->toBe([['PaymentID' => 'PAYMENT-123']]) ->and($invoiceDTO->creditNotes)->toBe([['CreditNoteID' => 'CREDIT-123']]) ->and($invoiceDTO->prepayments)->toBe([['PrepaymentID' => 'PREPAY-123']]) ->and($invoiceDTO->overpayments)->toBe([['OverpaymentID' => 'OVERPAY-123']]) ->and($invoiceDTO->hasAttachments)->toBeTrue() ->and($invoiceDTO->isDiscounted)->toBeTrue() ->and($invoiceDTO->hasErrors)->toBeTrue(); }); test('createLineItem static method returns correct array with all values', function () { $lineItem = InvoiceDTO::createLineItem( description: 'Test Item', quantity: 2, unitAmount: 10.50, accountCode: 123, itemCode: 'ITEM-123', taxType: 'OUTPUT', taxAmount: '2.10', lineAmount: '21.00', discountRate: '10', tracking: [['Name' => 'Region', 'Option' => 'North']] ); expect($lineItem)->toBe([ 'Description' => 'Test Item', 'Quantity' => 2, 'UnitAmount' => 10.50, 'AccountCode' => 123, 'ItemCode' => 'ITEM-123', 'TaxType' => 'OUTPUT', 'TaxAmount' => '2.10', 'LineAmount' => '21.00', 'DiscountRate' => '10', 'Tracking' => [['Name' => 'Region', 'Option' => 'North']], ]); }); test('createLineItem static method filters out null values', function () { $lineItem = InvoiceDTO::createLineItem( description: 'Test Item', quantity: 2, unitAmount: 10.50, accountCode: null, itemCode: null ); expect($lineItem)->toBe([ 'Description' => 'Test Item', 'Quantity' => 2, 'UnitAmount' => 10.50, ]); }); test('createLineItem accepts string values for numeric fields', function () { $lineItem = InvoiceDTO::createLineItem( description: 'Test Item', quantity: '2', unitAmount: '10.50' ); expect($lineItem)->toBe([ 'Description' => 'Test Item', 'Quantity' => '2', 'UnitAmount' => '10.50', ]); }); test('toArray method returns correct array structure', function () { $invoiceDTO = new InvoiceDTO( type: InvoiceType::AccRec->value, invoiceNumber: '123', date: '2023-01-01', dueDate: '2023-01-31', status: InvoiceStatus::Draft->value, contactID: 'CONTACT-123', lineItems: [ InvoiceDTO::createLineItem( description: 'Test Item', quantity: 2, unitAmount: 10.50 ), ] ); $array = $invoiceDTO->toArray(); expect($array)->toBeArray() ->and($array)->toHaveKey('Type', InvoiceType::AccRec->value) ->and($array)->toHaveKey('InvoiceNumber', '123') ->and($array)->toHaveKey('Date', '2023-01-01') ->and($array)->toHaveKey('DueDate', '2023-01-31') ->and($array)->toHaveKey('Status', InvoiceStatus::Draft->value) ->and($array)->toHaveKey('LineAmountTypes', InvoiceLineAmountType::Exclusive->value) ->and($array)->toHaveKey('Contact') ->and($array['Contact'])->toBe(['ContactID' => 'CONTACT-123']) ->and($array)->toHaveKey('LineItems') ->and($array['LineItems'][0])->toHaveKey('Description', 'Test Item') ->and($array['LineItems'][0])->toHaveKey('Quantity', 2) ->and($array['LineItems'][0])->toHaveKey('UnitAmount', 10.50); }); test('toArray method uses contact array when contactID is null', function () { $invoiceDTO = new InvoiceDTO( contact: [ 'ContactID' => 'CONTACT-123', 'Name' => 'Test Contact', ] ); $array = $invoiceDTO->toArray(); expect($array)->toHaveKey('Contact') ->and($array['Contact'])->toBe([ 'ContactID' => 'CONTACT-123', 'Name' => 'Test Contact', ]); }); test('toArray method filters out null and empty array values', function () { $invoiceDTO = new InvoiceDTO( invoiceNumber: '123', reference: null, lineItems: [] ); $array = $invoiceDTO->toArray(); expect($array)->toHaveKey('InvoiceNumber') ->and($array)->toHaveKey('Type') ->and($array)->toHaveKey('Status') ->and($array)->toHaveKey('LineAmountTypes') ->and($array)->not->toHaveKey('Reference') ->and($array)->not->toHaveKey('LineItems'); }); laravel-xero/tests/DTOs/ContactDTOTest.php000064400000016424152665540340014454 0ustar00toBeInstanceOf(ContactDTO::class) ->and($contactDTO->name)->toBeNull() ->and($contactDTO->firstName)->toBeNull() ->and($contactDTO->lastName)->toBeNull() ->and($contactDTO->emailAddress)->toBeNull() ->and($contactDTO->accountNumber)->toBeNull() ->and($contactDTO->bankAccountDetails)->toBeNull() ->and($contactDTO->taxNumber)->toBeNull() ->and($contactDTO->accountsReceivableTaxType)->toBeNull() ->and($contactDTO->accountsPayableTaxType)->toBeNull() ->and($contactDTO->contactStatus)->toBe(ContactStatus::Active->value) ->and($contactDTO->isSupplier)->toBeFalse() ->and($contactDTO->isCustomer)->toBeFalse() ->and($contactDTO->defaultCurrency)->toBeNull() ->and($contactDTO->website)->toBeNull() ->and($contactDTO->purchasesDefaultAccountCode)->toBeNull() ->and($contactDTO->salesDefaultAccountCode)->toBeNull() ->and($contactDTO->addresses)->toBe([]) ->and($contactDTO->phones)->toBe([]) ->and($contactDTO->contactPersons)->toBe([]) ->and($contactDTO->hasAttachments)->toBeFalse() ->and($contactDTO->hasValidationErrors)->toBeFalse(); }); test('contact dto can be instantiated with custom values', function () { $contactDTO = new ContactDTO( name: 'Test Company', firstName: 'John', lastName: 'Doe', emailAddress: 'john.doe@example.com', accountNumber: 'ACC123', bankAccountDetails: '123456789', taxNumber: 'TAX123', accountsReceivableTaxType: 'OUTPUT', accountsPayableTaxType: 'INPUT', contactStatus: ContactStatus::Archived->value, isSupplier: true, isCustomer: true, defaultCurrency: 'USD', website: 'https://example.com', purchasesDefaultAccountCode: 'P123', salesDefaultAccountCode: 'S123', addresses: [['AddressType' => 'POBOX']], phones: [['PhoneType' => 'MOBILE']], contactPersons: [['FirstName' => 'Jane']], hasAttachments: true, hasValidationErrors: true ); expect($contactDTO)->toBeInstanceOf(ContactDTO::class) ->and($contactDTO->name)->toBe('Test Company') ->and($contactDTO->firstName)->toBe('John') ->and($contactDTO->lastName)->toBe('Doe') ->and($contactDTO->emailAddress)->toBe('john.doe@example.com') ->and($contactDTO->accountNumber)->toBe('ACC123') ->and($contactDTO->bankAccountDetails)->toBe('123456789') ->and($contactDTO->taxNumber)->toBe('TAX123') ->and($contactDTO->accountsReceivableTaxType)->toBe('OUTPUT') ->and($contactDTO->accountsPayableTaxType)->toBe('INPUT') ->and($contactDTO->contactStatus)->toBe(ContactStatus::Archived->value) ->and($contactDTO->isSupplier)->toBeTrue() ->and($contactDTO->isCustomer)->toBeTrue() ->and($contactDTO->defaultCurrency)->toBe('USD') ->and($contactDTO->website)->toBe('https://example.com') ->and($contactDTO->purchasesDefaultAccountCode)->toBe('P123') ->and($contactDTO->salesDefaultAccountCode)->toBe('S123') ->and($contactDTO->addresses)->toBe([['AddressType' => 'POBOX']]) ->and($contactDTO->phones)->toBe([['PhoneType' => 'MOBILE']]) ->and($contactDTO->contactPersons)->toBe([['FirstName' => 'Jane']]) ->and($contactDTO->hasAttachments)->toBeTrue() ->and($contactDTO->hasValidationErrors)->toBeTrue(); }); test('createAddress static method returns correct array', function () { $address = ContactDTO::createAddress( addressType: 'POBOX', addressLine1: '123 Main St', addressLine2: 'Suite 100', addressLine3: 'Building A', addressLine4: 'Floor 2', city: 'Anytown', region: 'State', postalCode: '12345', country: 'USA', attentionTo: 'John Doe' ); expect($address)->toBe([ 'AddressType' => 'POBOX', 'AddressLine1' => '123 Main St', 'AddressLine2' => 'Suite 100', 'AddressLine3' => 'Building A', 'AddressLine4' => 'Floor 2', 'City' => 'Anytown', 'Region' => 'State', 'PostalCode' => '12345', 'Country' => 'USA', 'AttentionTo' => 'John Doe', ]); }); test('createPhone static method returns correct array', function () { $phone = ContactDTO::createPhone( phoneType: 'MOBILE', phoneNumber: '555-1234', phoneAreaCode: '123', phoneCountryCode: '1' ); expect($phone)->toBe([ 'PhoneType' => 'MOBILE', 'PhoneNumber' => '555-1234', 'PhoneAreaCode' => '123', 'PhoneCountryCode' => '1', ]); }); test('createContactPerson static method returns correct array', function () { $contactPerson = ContactDTO::createContactPerson( firstName: 'Jane', lastName: 'Smith', emailAddress: 'jane.smith@example.com', includeInEmails: true ); expect($contactPerson)->toBe([ 'FirstName' => 'Jane', 'LastName' => 'Smith', 'EmailAddress' => 'jane.smith@example.com', 'IncludeInEmails' => true, ]); }); test('toArray method returns correct array structure', function () { $contactDTO = new ContactDTO( name: 'Test Company', firstName: 'John', lastName: 'Doe', emailAddress: 'john.doe@example.com', isSupplier: true, addresses: [ ContactDTO::createAddress('POBOX', '123 Main St'), ], phones: [ ContactDTO::createPhone('MOBILE', '555-1234'), ], contactPersons: [ ContactDTO::createContactPerson('Jane', 'Smith'), ] ); $array = $contactDTO->toArray(); expect($array)->toBeArray() ->and($array)->toHaveKey('Name', 'Test Company') ->and($array)->toHaveKey('FirstName', 'John') ->and($array)->toHaveKey('LastName', 'Doe') ->and($array)->toHaveKey('EmailAddress', 'john.doe@example.com') ->and($array)->toHaveKey('ContactStatus', ContactStatus::Active->value) ->and($array)->toHaveKey('IsSupplier', true) ->and($array)->toHaveKey('IsCustomer', false) ->and($array)->toHaveKey('Addresses') ->and($array['Addresses'][0])->toHaveKey('AddressType', 'POBOX') ->and($array['Addresses'][0])->toHaveKey('AddressLine1', '123 Main St') ->and($array)->toHaveKey('Phones') ->and($array['Phones'][0])->toHaveKey('PhoneType', 'MOBILE') ->and($array['Phones'][0])->toHaveKey('PhoneNumber', '555-1234') ->and($array)->toHaveKey('ContactPersons') ->and($array['ContactPersons'][0])->toHaveKey('FirstName', 'Jane') ->and($array['ContactPersons'][0])->toHaveKey('LastName', 'Smith'); }); test('toArray method filters out null values', function () { $contactDTO = new ContactDTO( name: 'Test Company', emailAddress: null, website: null ); $array = $contactDTO->toArray(); expect($array)->toHaveKey('Name') ->and($array)->not->toHaveKey('EmailAddress') ->and($array)->not->toHaveKey('Website'); }); laravel-xero/tests/Enums/InvoiceTypeTest.php000064400000000641152665540340015220 0ustar00create(); expect($token)->toBeInstanceOf(XeroToken::class) ->and($token->tenant_id)->not->toBeEmpty() ->and($token->tenant_name)->not->toBeEmpty() ->and($token->access_token)->not->toBeEmpty() ->and($token->refresh_token)->not->toBeEmpty() ->and($token->expires_in)->toBeInt(); }); test('xero token can be created with mass assignment', function () { $data = [ 'tenant_id' => 'test_tenant_id', 'tenant_name' => 'Test Tenant', 'access_token' => 'test_access_token', 'refresh_token' => 'test_refresh_token', 'expires_in' => 3600, 'scopes' => 'test_scope', ]; $token = XeroToken::create($data); expect($token)->toBeInstanceOf(XeroToken::class) ->and($token->tenant_id)->toBe('test_tenant_id') ->and($token->tenant_name)->toBe('Test Tenant') ->and($token->access_token)->toBe('test_access_token') ->and($token->refresh_token)->toBe('test_refresh_token') ->and($token->expires_in)->toBe(3600) ->and($token->scopes)->toBe('test_scope'); }); test('expires attribute returns correct expiration time', function () { // Freeze time for predictable testing Carbon::setTestNow('2023-01-01 12:00:00'); $token = XeroToken::create([ 'tenant_id' => 'test_tenant_id', 'access_token' => 'test_access_token', 'refresh_token' => 'test_refresh_token', 'expires_in' => 3600, // 1 hour 'scopes' => 'test_scope', ]); // The expires attribute should be 1 hour after the updated_at timestamp expect($token->expires->format('Y-m-d H:i:s'))->toBe('2023-01-01 13:00:00'); // Clean up Carbon::setTestNow(); }); test('expires_in is cast to integer', function () { $token = XeroToken::create([ 'tenant_id' => 'test_tenant_id', 'access_token' => 'test_access_token', 'refresh_token' => 'test_refresh_token', 'expires_in' => '3600', // String value 'scopes' => 'test_scope', ]); expect($token->expires_in)->toBeInt() ->and($token->expires_in)->toBe(3600); }); laravel-xero/tests/TestCase.php000064400000001755152665540340012555 0ustar00set('database.default', 'mysql'); $app['config']->set('database.connections.mysql', [ 'driver' => 'sqlite', 'database' => ':memory:', 'prefix' => '', ]); require_once 'src/database/migrations/create_xero_tokens_table.php'; // run the up() method of that migration class (new CreateXeroTokensTable)->up(); } } laravel-xero/tests/Actions/StoreTokenActionTest.php000064400000005742152665540340016535 0ustar00 'test_id_token', 'access_token' => 'test_access_token', 'expires_in' => 3600, 'token_type' => 'Bearer', 'refresh_token' => 'test_refresh_token', 'scope' => 'test_scope', ]; $result = app(StoreTokenAction::class)($token); expect($result)->toBeInstanceOf(XeroToken::class); assertDatabaseCount(XeroToken::class, 1); assertDatabaseHas('xero_tokens', [ 'id' => 1, 'access_token' => config('xero.encrypt') ? null : 'test_access_token', 'refresh_token' => config('xero.encrypt') ? null : 'test_refresh_token', 'scopes' => 'test_scope', ]); }); test('store token action creates a new token with tenant data', function () { $token = [ 'id_token' => 'test_id_token', 'access_token' => 'test_access_token', 'expires_in' => 3600, 'token_type' => 'Bearer', 'refresh_token' => 'test_refresh_token', 'scope' => 'test_scope', ]; $tenantData = [ 'tenant_id' => 'test_tenant_id', 'tenant_name' => 'Test Tenant', ]; $result = app(StoreTokenAction::class)($token, $tenantData); expect($result)->toBeInstanceOf(XeroToken::class); assertDatabaseCount(XeroToken::class, 1); assertDatabaseHas('xero_tokens', [ 'tenant_id' => 'test_tenant_id', 'tenant_name' => 'Test Tenant', 'access_token' => config('xero.encrypt') ? null : 'test_access_token', 'refresh_token' => config('xero.encrypt') ? null : 'test_refresh_token', 'scopes' => 'test_scope', ]); }); test('store token action updates an existing token with tenant id and tenant data', function () { // First, create a token $token = XeroToken::factory()->create([ 'tenant_id' => 'test_tenant_id', 'tenant_name' => 'Old Tenant Name', ]); // Then update it with new data $newToken = [ 'id_token' => 'new_id_token', 'access_token' => 'new_access_token', 'expires_in' => 7200, 'token_type' => 'Bearer', 'refresh_token' => 'new_refresh_token', 'scope' => 'new_scope', ]; $tenantData = [ 'tenant_name' => 'New Tenant Name', ]; $result = app(StoreTokenAction::class)($newToken, $tenantData, 'test_tenant_id'); expect($result)->toBeInstanceOf(XeroToken::class); assertDatabaseCount(XeroToken::class, 1); assertDatabaseHas('xero_tokens', [ 'tenant_id' => 'test_tenant_id', 'tenant_name' => 'New Tenant Name', 'access_token' => config('xero.encrypt') ? null : 'new_access_token', 'refresh_token' => config('xero.encrypt') ? null : 'new_refresh_token', 'scopes' => 'new_scope', ]); }); laravel-xero/tests/Actions/formatQueryStringsActionTest.php000064400000001213152665540340020315 0ustar00 'value1', 'key2' => 'value2', 'key3' => 'value3', ]; $response = app(formatQueryStringsAction::class)($params); expect($response)->toBe('key1=value1&key2=value2&key3=value3'); }); test('throws type error when non-array is passed', function ($value) { $this->expectException(TypeError::class); app(formatQueryStringsAction::class)($value); })->with([ 'string', 123, 1.23, true, false, null, new stdClass(), ]); laravel-xero/tests/Actions/HandleTokenExpiredActionTest.php000064400000003440152665540340020146 0ustar00create(); $result = ['error' => 'invalid_grant']; app(tokenExpiredAction::class)($result, $token); assertDatabaseCount(XeroToken::class, 0); })->throws(Exception::class, 'Xero token has expired, please re-authenticate.'); test('token refresh does not throw an exception and token is not deleted', function () { $token = XeroToken::factory()->create(); $result = []; $response = app(tokenExpiredAction::class)($result, $token); expect($response)->toBeNull(); assertDatabaseCount(XeroToken::class, 1); }); test('token refresh redirects when expired and not running in console', function () { // Create a test-specific version of the tokenExpiredAction class // that overrides isRunningInConsole to return false $testAction = new class extends tokenExpiredAction { protected function isRunningInConsole(): bool { return false; } }; // Set a test redirect URI Config::set('xero.redirectUri', 'https://example.com/auth'); $token = XeroToken::factory()->create(); $result = ['error' => 'invalid_grant']; $response = $testAction($result, $token); // Assert token was deleted assertDatabaseCount(XeroToken::class, 0); // Assert response is a redirect to the configured URI expect($response)->toBeInstanceOf(RedirectResponse::class); expect($response->getTargetUrl())->toBe('https://example.com/auth'); }); laravel-xero/tests/Pest.php000064400000000151152665540340011742 0ustar00in(__DIR__); laravel-xero/tests/Console/Commands/XeroShowAllTokensCommandTest.php000064400000006217152665540340021735 0ustar00app->singleton('command.xero.show-all-tokens', fn () => new XeroShowAllTokensCommand()); Artisan::registerCommand($this->app->make('command.xero.show-all-tokens')); }); test('command displays message when no tokens exist', function () { // Ensure no tokens exist XeroToken::query()->delete(); // Run the command $this->artisan('xero:show-all-tokens') ->expectsOutput('All XERO Tokens in storage') ->assertExitCode(0); }); test('command displays tokens in table format', function () { // Create a token $token = XeroToken::factory()->create([ 'tenant_name' => 'Test Tenant', 'tenant_id' => 'test-tenant-id', ]); // Run the command $this->artisan('xero:show-all-tokens') ->expectsOutput('All XERO Tokens in storage') ->assertExitCode(0); // Verify the token exists in the database $this->assertDatabaseHas('xero_tokens', [ 'tenant_name' => 'Test Tenant', 'tenant_id' => 'test-tenant-id', ]); }); test('command handles encrypted tokens correctly', function () { // Enable encryption Config::set('xero.encrypt', true); // Create a token with encrypted values $token = XeroToken::factory()->create([ 'tenant_name' => 'Encrypted Tenant', 'tenant_id' => 'encrypted-tenant-id', 'access_token' => Crypt::encryptString('encrypted-access-token'), 'refresh_token' => Crypt::encryptString('encrypted-refresh-token'), ]); // Run the command $this->artisan('xero:show-all-tokens') ->expectsOutput('All XERO Tokens in storage') ->assertExitCode(0); // Verify the token exists in the database with encrypted values $this->assertDatabaseHas('xero_tokens', [ 'tenant_name' => 'Encrypted Tenant', 'tenant_id' => 'encrypted-tenant-id', ]); // Reset config Config::set('xero.encrypt', false); }); test('command handles decryption exceptions', function () { // Enable encryption Config::set('xero.encrypt', true); // Create a token with non-encrypted values that will cause decryption to fail $token = XeroToken::factory()->create([ 'tenant_name' => 'Exception Tenant', 'tenant_id' => 'exception-tenant-id', 'access_token' => 'non-encrypted-access-token', 'refresh_token' => 'non-encrypted-refresh-token', ]); // Run the command $this->artisan('xero:show-all-tokens') ->expectsOutput('All XERO Tokens in storage') ->assertExitCode(0); // Verify the token exists in the database $this->assertDatabaseHas('xero_tokens', [ 'tenant_name' => 'Exception Tenant', 'tenant_id' => 'exception-tenant-id', 'access_token' => 'non-encrypted-access-token', 'refresh_token' => 'non-encrypted-refresh-token', ]); // Reset config Config::set('xero.encrypt', false); }); laravel-xero/tests/Console/Commands/XeroKeepAliveCommandTest.php000064400000006207152665540340021044 0ustar00app->singleton('command.xero.keep-alive', fn () => new XeroKeepAliveCommand()); Artisan::registerCommand($this->app->make('command.xero.keep-alive')); }); test('command handles no tokens gracefully', function () { // Ensure no tokens exist XeroToken::query()->delete(); // Run the command $this->artisan('xero:keep-alive') ->assertExitCode(0); }); test('command refreshes token for connected tenant', function () { // Create a token $token = XeroToken::factory()->create([ 'tenant_name' => 'Connected Tenant', 'tenant_id' => 'connected-tenant-id', ]); // Mock the Xero facade Xero::shouldReceive('setTenantId') ->once() ->with('connected-tenant-id'); Xero::shouldReceive('isConnected') ->once() ->andReturn(true); Xero::shouldReceive('getAccessToken') ->once() ->with(false) ->andReturn('refreshed-token'); // Run the command $this->artisan('xero:keep-alive') ->expectsOutput('Refreshing Token for Tenant: Connected Tenant - Successful') ->assertExitCode(0); }); test('command handles not connected tenant', function () { // Create a token $token = XeroToken::factory()->create([ 'tenant_name' => 'Not Connected Tenant', 'tenant_id' => 'not-connected-tenant-id', ]); // Mock the Xero facade Xero::shouldReceive('setTenantId') ->once() ->with('not-connected-tenant-id'); Xero::shouldReceive('isConnected') ->once() ->andReturn(false); // Run the command $this->artisan('xero:keep-alive') ->expectsOutput('Refreshing Token for Tenant: Not Connected Tenant - Not Connected') ->assertExitCode(0); }); test('command handles multiple tenants', function () { // Create multiple tokens $token1 = XeroToken::factory()->create([ 'tenant_name' => 'Tenant 1', 'tenant_id' => 'tenant-id-1', ]); $token2 = XeroToken::factory()->create([ 'tenant_name' => 'Tenant 2', 'tenant_id' => 'tenant-id-2', ]); // Mock the Xero facade for first tenant (connected) Xero::shouldReceive('setTenantId') ->once() ->with('tenant-id-1'); Xero::shouldReceive('isConnected') ->once() ->andReturn(true); Xero::shouldReceive('getAccessToken') ->once() ->with(false) ->andReturn('refreshed-token-1'); // Mock the Xero facade for second tenant (not connected) Xero::shouldReceive('setTenantId') ->once() ->with('tenant-id-2'); Xero::shouldReceive('isConnected') ->once() ->andReturn(false); // Run the command $this->artisan('xero:keep-alive') ->expectsOutput('Refreshing Token for Tenant: Tenant 1 - Successful') ->expectsOutput('Refreshing Token for Tenant: Tenant 2 - Not Connected') ->assertExitCode(0); }); laravel-xero/tests/XeroTest.php000064400000010517152665540340012613 0ustar00XeroMock = Mockery::mock(Xero::class); }); test('can initialise', function () { $this->assertInstanceOf(Xero::class, $this->XeroMock); }); test('redirected when connect is called', function () { $connect = XeroFacade::connect(); $this->assertInstanceOf(RedirectResponse::class, $connect); }); test('is connected returns false when no data in db', function () { $connect = XeroFacade::isConnected(); expect($connect)->toBeFalse(); }); test('is connected returns true when data exists in db', function () { XeroToken::create([ 'id' => 0, 'access_token' => '1234', 'expires_in' => strtotime('+1 day'), 'scopes' => 'contacts', ]); $connect = XeroFacade::isConnected(); expect($connect)->toBeTrue(); }); test('disconnect returns true when data exists in db', function () { Http::fake(); XeroToken::create([ 'id' => 0, 'access_token' => '1234', 'expires_in' => strtotime('+1 day'), 'scopes' => 'contacts', ]); XeroFacade::disconnect(); $this->assertDatabaseCount('xero_tokens', 0); }); test('getTokenData returns XeroToken data', function () { XeroToken::create([ 'id' => 0, 'access_token' => '1234', 'expires_in' => strtotime('+1 day'), 'scopes' => 'contacts', ]); $data = XeroFacade::getTokenData(); expect($data)->toBeObject(); }); test('getTokenData when no tokens exist returns null', function () { expect(XeroFacade::getTokenData())->toBeNull; }); test('getTenantId returns id', function () { XeroToken::create([ 'id' => 0, 'access_token' => '1234', 'expires_in' => strtotime('+1 day'), 'scopes' => 'contacts', 'tenant_id' => '1234', ]); expect(XeroFacade::getTenantId())->toBe('1234'); }); test('getTenantName returns name', function () { XeroToken::create([ 'id' => 0, 'access_token' => '1234', 'expires_in' => strtotime('+1 day'), 'scopes' => 'contacts', 'tenant_id' => '1234', 'tenant_name' => 'Jones', ]); expect(XeroFacade::getTenantName())->toBe('Jones'); }); test('can return getAccessToken when it has not expired ', function () { Http::fake(); XeroToken::create([ 'id' => 0, 'access_token' => '1234', 'expires_in' => now()->addMinutes(25), 'updated_at' => strtotime('+1 day'), 'scopes' => 'contacts', ]); $data = XeroFacade::getAccessToken(); expect($data)->toBe('1234'); }); test('can get tokens when not-encrypted but encryption is enabled', function () { Config::set('xero.encrypt', true); XeroToken::create([ 'id' => 0, 'access_token' => '1234', 'expires_in' => strtotime('+1 day'), 'scopes' => 'contacts', ]); $data = XeroFacade::getAccessToken(); expect($data)->toBe('1234'); }); test('can get tokens when encrypted', function () { Config::set('xero.encrypt', true); XeroToken::create([ 'id' => 0, 'access_token' => Crypt::encryptString('1234'), 'expires_in' => strtotime('+1 day'), 'scopes' => 'contacts', ]); $data = XeroFacade::getAccessToken(); expect($data)->toBe('1234'); }); test('formats Microsoft JSON date with timezone offset', function () { $input = '/Date(1663257600000+0100)/'; $formatted = Xero::formatDate($input); expect($formatted)->toBe('2022-09-15 16:00:00'); }); test('formats standard ISO 8601 date string', function () { $input = '2023-05-19T14:00:00+00:00'; $formatted = Xero::formatDate($input); expect($formatted)->toBe('2023-05-19 14:00:00'); }); test('returns empty string for invalid date input', function () { $input = 'invalid-date-format'; $formatted = Xero::formatDate($input); expect($formatted)->toBe(''); }); test('formats Microsoft JSON date with UTC offset', function () { $input = '/Date(1663257600000+0000)/'; $formatted = Xero::formatDate($input); expect($formatted)->toBe('2022-09-15 16:00:00'); }); laravel-xero/tests/XeroAuthenticatedTest.php000064400000002635152665540340015320 0ustar00once()->andReturn(false); Xero::shouldReceive('connect')->once()->andReturn(new RedirectResponse('xero-connect-url')); // Create middleware instance $middleware = new XeroAuthenticated(); // Create a request $request = Request::create('/test', 'GET'); // Execute middleware $response = $middleware->handle($request, function () { return 'next middleware'; }); // Assert that we get a redirect response expect($response)->toBeInstanceOf(RedirectResponse::class) ->and($response->getTargetUrl())->toBe('xero-connect-url'); }); test('middleware continues when connected', function () { // Mock the Xero facade Xero::shouldReceive('isConnected')->once()->andReturn(true); // Create middleware instance $middleware = new XeroAuthenticated(); // Create a request $request = Request::create('/test', 'GET'); // Execute middleware $response = $middleware->handle($request, function () { return 'next middleware'; }); // Assert that we continue to the next middleware expect($response)->toBe('next middleware'); }); laravel-xero/tests/Resources/CreditNotesTest.php000064400000000722152665540340016070 0ustar00filter('bogus', 1) ->get(); })->throws(InvalidArgumentException::class, "Filter option 'bogus' is not valid."); test('filter returns object', function () { $filter = (new CreditNotes)->filter('ids', '1234'); expect($filter)->toBeObject(); }); laravel-xero/tests/Resources/WebhooksTest.php000064400000010312152665540340015422 0ustar00webhooks = new Webhooks(); }); // Helper function to mock file_get_contents function mockFileGetContents($payload) { // Create a stream wrapper to mock php://input stream_wrapper_unregister('php'); stream_wrapper_register('php', MockPhpStream::class); file_put_contents('php://input', $payload); } // Mock stream wrapper class class MockPhpStream { protected static $data = ''; public function stream_open($path, $mode, $options, &$opened_path) { return true; } public function stream_read($count) { $ret = mb_substr(self::$data, 0, $count); self::$data = mb_substr(self::$data, $count); return $ret; } public function stream_write($data) { self::$data = $data; return mb_strlen($data); } public function stream_tell() { return 0; } public function stream_eof() { return true; } public function stream_stat() { return []; } } afterEach(function () { // Restore the original stream wrapper if (in_array('php', stream_get_wrappers())) { stream_wrapper_unregister('php'); stream_wrapper_restore('php'); } }); test('getSignature returns correct signature', function () { // Mock the payload $payload = json_encode(['events' => [['eventType' => 'test']]]); // Use reflection to set protected property $reflection = new ReflectionClass($this->webhooks); $property = $reflection->getProperty('payload'); $property->setAccessible(true); $property->setValue($this->webhooks, $payload); // Set webhook key in config Config::set('xero.webhookKey', 'test-webhook-key'); // Calculate expected signature $expectedSignature = base64_encode(hash_hmac('sha256', $payload, 'test-webhook-key', true)); // Test the method expect($this->webhooks->getSignature())->toBe($expectedSignature); }); test('validate returns true when signatures match', function () { // Mock the payload and server variables $payload = json_encode(['events' => [['eventType' => 'test']]]); // Mock file_get_contents to return our payload mockFileGetContents($payload); // Set webhook key in config Config::set('xero.webhookKey', 'test-webhook-key'); // Calculate signature $signature = base64_encode(hash_hmac('sha256', $payload, 'test-webhook-key', true)); // Mock $_SERVER $_SERVER['HTTP_X_XERO_SIGNATURE'] = $signature; // Test the method expect($this->webhooks->validate())->toBeTrue(); }); test('validate returns false when signatures do not match', function () { // Mock the payload and server variables $payload = json_encode(['events' => [['eventType' => 'test']]]); // Mock file_get_contents to return our payload mockFileGetContents($payload); // Set webhook key in config Config::set('xero.webhookKey', 'test-webhook-key'); // Set incorrect signature $_SERVER['HTTP_X_XERO_SIGNATURE'] = 'incorrect-signature'; // Test the method expect($this->webhooks->validate())->toBeFalse(); }); test('getEvents returns events from payload', function () { // Create a mock of Webhooks with validate method always returning true $webhooks = Mockery::mock(Webhooks::class)->makePartial(); $webhooks->shouldReceive('validate')->andReturn(true); // Mock payload with events $events = [ (object) ['eventType' => 'test1', 'resourceId' => '1'], (object) ['eventType' => 'test2', 'resourceId' => '2'], ]; $payload = json_encode(['events' => $events]); // Use reflection to set protected property $reflection = new ReflectionClass($webhooks); $property = $reflection->getProperty('payload'); $property->setAccessible(true); $property->setValue($webhooks, $payload); // Test the method $result = $webhooks->getEvents(); // Verify each event has the expected properties expect($result[0]->eventType)->toBe('test1'); expect($result[0]->resourceId)->toBe('1'); expect($result[1]->eventType)->toBe('test2'); expect($result[1]->resourceId)->toBe('2'); }); laravel-xero/tests/Resources/ContactsTest.php000064400000000711152665540340015421 0ustar00filter('bogus', 1) ->get(); })->throws(InvalidArgumentException::class, "Filter option 'bogus' is not valid."); test('filter returns object', function () { $filter = (new Contacts)->filter('ids', '1234'); expect($filter)->toBeObject(); }); laravel-xero/tests/Resources/InvoicesTest.php000064400000000711152665540340015422 0ustar00filter('bogus', 1) ->get(); })->throws(InvalidArgumentException::class, "Filter option 'bogus' is not valid."); test('filter returns object', function () { $filter = (new Invoices)->filter('ids', '1234'); expect($filter)->toBeObject(); }); laravel-xero/pint.json000064400000003524152665540340011030 0ustar00{ "preset": "laravel", "rules": { "array_push": true, "backtick_to_shell_exec": true, "date_time_immutable": true, "declare_strict_types": true, "lowercase_keywords": true, "lowercase_static_reference": true, "final_class": false, "final_internal_class": false, "final_public_method_for_abstract_class": false, "fully_qualified_strict_types": true, "global_namespace_import": { "import_classes": true, "import_constants": true, "import_functions": true }, "mb_str_functions": true, "modernize_types_casting": true, "new_with_parentheses": false, "no_superfluous_elseif": true, "no_useless_else": true, "no_multiple_statements_per_line": true, "ordered_class_elements": { "order": [ "use_trait", "case", "constant", "constant_public", "constant_protected", "constant_private", "property_public", "property_protected", "property_private", "construct", "destruct", "magic", "phpunit", "method_abstract", "method_public_static", "method_public", "method_protected_static", "method_protected", "method_private_static", "method_private" ], "sort_algorithm": "none" }, "ordered_interfaces": true, "ordered_traits": true, "protected_to_private": true, "self_accessor": true, "self_static_accessor": true, "strict_comparison": true, "visibility_required": true } } laravel-xero/phpunit.xml000064400000001712152665540340011371 0ustar00 src/ ./tests ./src laravel-xero/phpstan.neon000064400000000452152665540340011516 0ustar00includes: - vendor/larastan/larastan/extension.neon parameters: paths: - src/ # Level 9 is the highest level level: 5 # ignoreErrors: # - '#PHPDoc tag @var#' # # excludePaths: # - ./*/*/FileToBeExcluded.php # # checkMissingIterableValueType: falselaravel-xero/src/Facades/Xero.php000064400000001247152665540340012726 0ustar00value, // ACCREC for sales invoices, ACCPAY for bills public ?string $invoiceNumber = null, public ?string $reference = null, public ?string $date = null, public ?string $dueDate = null, public ?string $status = InvoiceStatus::Draft->value, public ?string $lineAmountTypes = InvoiceLineAmountType::Exclusive->value, public ?string $currencyCode = null, public ?string $currencyRate = null, public ?string $subTotal = null, public ?string $totalTax = null, public ?string $total = null, public ?string $contactID = null, /** @var array> */ public ?array $contact = null, /** @var array> */ public ?array $lineItems = [], /** @var array> */ public ?array $payments = [], /** @var array> */ public ?array $creditNotes = [], /** @var array> */ public ?array $prepayments = [], /** @var array> */ public ?array $overpayments = [], public ?bool $hasAttachments = false, public ?bool $isDiscounted = false, public ?bool $hasErrors = false, ) {} /** * Create a line item array for the invoice * * @param array>|null $tracking * @return array */ public static function createLineItem( ?string $description = null, string|int|null $quantity = null, string|float|null $unitAmount = null, ?int $accountCode = null, ?string $itemCode = null, ?string $taxType = null, ?string $taxAmount = null, ?string $lineAmount = null, ?string $discountRate = null, ?array $tracking = null, ): array { return array_filter([ 'Description' => $description, 'Quantity' => $quantity, 'UnitAmount' => $unitAmount, 'AccountCode' => $accountCode, 'ItemCode' => $itemCode, 'TaxType' => $taxType, 'TaxAmount' => $taxAmount, 'LineAmount' => $lineAmount, 'DiscountRate' => $discountRate, 'Tracking' => $tracking, ], function (mixed $value) { return $value !== null; }); } /** * Convert the DTO to an array for the Xero API * * @return array */ public function toArray(): array { return array_filter([ 'InvoiceID' => $this->invoiceID, 'Type' => $this->type, 'InvoiceNumber' => $this->invoiceNumber, 'Reference' => $this->reference, 'Date' => $this->date, 'DueDate' => $this->dueDate, 'Status' => $this->status, 'LineAmountTypes' => $this->lineAmountTypes, 'CurrencyCode' => $this->currencyCode, 'CurrencyRate' => $this->currencyRate, 'SubTotal' => $this->subTotal, 'TotalTax' => $this->totalTax, 'Total' => $this->total, 'Contact' => $this->contactID ? ['ContactID' => $this->contactID] : $this->contact, 'LineItems' => $this->lineItems, 'HasAttachments' => $this->hasAttachments, 'IsDiscounted' => $this->isDiscounted, ], fn (mixed $value) => $value !== null && $value !== []); } } laravel-xero/src/DTOs/ContactDTO.php000064400000011224152665540340013232 0ustar00value, public ?bool $isSupplier = false, public ?bool $isCustomer = false, public ?string $defaultCurrency = null, public ?string $website = null, public ?string $purchasesDefaultAccountCode = null, public ?string $salesDefaultAccountCode = null, /** @var array> */ public ?array $addresses = [], /** @var array> */ public ?array $phones = [], /** @var array> */ public ?array $contactPersons = [], public ?bool $hasAttachments = false, public ?bool $hasValidationErrors = false, ) {} /** * Create an address array for the contact * * @return array */ public static function createAddress( string $addressType, ?string $addressLine1 = null, ?string $addressLine2 = null, ?string $addressLine3 = null, ?string $addressLine4 = null, ?string $city = null, ?string $region = null, ?string $postalCode = null, ?string $country = null, ?string $attentionTo = null ): array { return [ 'AddressType' => $addressType, 'AddressLine1' => $addressLine1, 'AddressLine2' => $addressLine2, 'AddressLine3' => $addressLine3, 'AddressLine4' => $addressLine4, 'City' => $city, 'Region' => $region, 'PostalCode' => $postalCode, 'Country' => $country, 'AttentionTo' => $attentionTo, ]; } /** * Create a phone array for the contact * * @return array */ public static function createPhone( string $phoneType, ?string $phoneNumber = null, ?string $phoneAreaCode = null, ?string $phoneCountryCode = null ): array { return [ 'PhoneType' => $phoneType, 'PhoneNumber' => $phoneNumber, 'PhoneAreaCode' => $phoneAreaCode, 'PhoneCountryCode' => $phoneCountryCode, ]; } /** * Create a contact person array for the contact * * @return array */ public static function createContactPerson( ?string $firstName = null, ?string $lastName = null, ?string $emailAddress = null, ?bool $includeInEmails = false ): array { return [ 'FirstName' => $firstName, 'LastName' => $lastName, 'EmailAddress' => $emailAddress, 'IncludeInEmails' => $includeInEmails, ]; } /** * Convert the DTO to an array for the Xero API * * @return array */ public function toArray(): array { return array_filter([ 'Name' => $this->name, 'FirstName' => $this->firstName, 'LastName' => $this->lastName, 'EmailAddress' => $this->emailAddress, 'AccountNumber' => $this->accountNumber, 'BankAccountDetails' => $this->bankAccountDetails, 'TaxNumber' => $this->taxNumber, 'AccountsReceivableTaxType' => $this->accountsReceivableTaxType, 'AccountsPayableTaxType' => $this->accountsPayableTaxType, 'ContactStatus' => $this->contactStatus, 'IsSupplier' => $this->isSupplier, 'IsCustomer' => $this->isCustomer, 'DefaultCurrency' => $this->defaultCurrency, 'Website' => $this->website, 'PurchasesDefaultAccountCode' => $this->purchasesDefaultAccountCode, 'SalesDefaultAccountCode' => $this->salesDefaultAccountCode, 'Addresses' => $this->addresses, 'Phones' => $this->phones, 'ContactPersons' => $this->contactPersons, 'HasAttachments' => $this->hasAttachments, 'HasValidationErrors' => $this->hasValidationErrors, ], function (mixed $value) { return $value !== null; }); } } laravel-xero/src/Enums/InvoiceStatus.php000064400000000733152665540340014351 0ustar00 $case->value, self::cases()); return in_array($value, $validValues); } } laravel-xero/src/Enums/InvoiceLineAmountType.php000064400000000617152665540340016004 0ustar00 $case->value, self::cases()); return in_array($value, $validValues); } } laravel-xero/src/Enums/InvoiceType.php000064400000000537152665540340014011 0ustar00 $case->value, self::cases()); return in_array($value, $validValues); } } laravel-xero/src/Enums/ContactStatus.php000064400000000613152665540340014345 0ustar00 $case->value, self::cases()); return in_array($value, $validValues); } } laravel-xero/src/Enums/FilterOptions.php000064400000001043152665540340014345 0ustar00 $case->value, self::cases()); return in_array($value, $validValues); } } laravel-xero/src/Models/XeroToken.php000064400000002126152665540340013621 0ustar00 'integer', ]; protected static function newFactory(): TokenFactory { return TokenFactory::new(); } /** * @return Attribute */ protected function expires(): Attribute { return Attribute::get( fn (): DateTimeInterface => $this->updated_at->addSeconds((int) $this->expires_in) ); } } laravel-xero/src/database/migrations/2024_03_15_135030_increase_xero_token_tables_string_length.php000064400000001206152665540340026776 0ustar00text('refresh_token')->change(); }); } /** * Reverse the migrations. */ public function down(): void { Schema::table('xero_tokens', function (Blueprint $table) { $table->string('refresh_token')->change(); }); } }; laravel-xero/src/database/migrations/create_xero_tokens_table.php000064400000002306152665540340021432 0ustar00increments('id'); $table->text('id_token')->nullable(); $table->text('access_token'); $table->string('expires_in')->nullable(); $table->string('token_type')->nullable(); $table->string('refresh_token')->nullable(); $table->string('scopes'); $table->string('auth_event_id')->nullable(); $table->string('tenant_id')->nullable(); $table->string('tenant_type')->nullable(); $table->string('tenant_name')->nullable(); $table->string('created_date_utc')->nullable(); $table->string('updated_date_utc')->nullable(); $table->timestamps(); }); } /** * Reverse the migrations. */ public function down(): void { Schema::dropIfExists('xero_tokens'); } } laravel-xero/src/database/factories/TokenFactory.php000064400000001340152665540340016610 0ustar00 $this->faker->uuid, 'tenant_name' => $this->faker->name, 'access_token' => $this->faker->uuid, 'refresh_token' => $this->faker->uuid, 'expires_in' => $this->faker->randomNumber(), 'created_at' => $this->faker->dateTime, 'updated_at' => $this->faker->dateTime, 'scopes' => config('xero.scopes'), ]; } } laravel-xero/src/Traits/XeroHelpersTrait.php000064400000002601152665540340015170 0ustar00setTimezone(new DateTimeZone($tzOffset)); return $dt->format($format); } // Fallback to default DateTime parsing $dt = new DateTimeImmutable($date); return $dt->format($format); } catch (Throwable $e) { // Invalid date input, return empty string instead of crashing return ''; } } /** * Format query strings for API requests. */ public static function formatQueryStrings(array $params): string { return app(formatQueryStringsAction::class)($params); } }laravel-xero/src/Actions/formatQueryStringsAction.php000064400000000355152665540340017110 0ustar00delete(); if ($this->isRunningInConsole()) { throw new Exception('Xero token has expired, please re-authenticate.'); } return redirect()->away(config('xero.redirectUri')); } return null; } /** * @throws Exception */ protected function isRunningInConsole(): bool { return app()->runningInConsole(); } } laravel-xero/src/Actions/StoreTokenAction.php000064400000002274152665540340015317 0ustar00 $token['id_token'], 'access_token' => config('xero.encrypt') ? Crypt::encryptString($token['access_token']) : $token['access_token'], 'expires_in' => $token['expires_in'], 'token_type' => $token['token_type'], 'refresh_token' => config('xero.encrypt') ? Crypt::encryptString($token['refresh_token']) : $token['refresh_token'], 'scopes' => $token['scope'], ]; if ($tenantId) { if ($tenantData !== []) { $data = array_merge($data, $tenantData); } $where = ['tenant_id' => $tenantId]; } elseif ($tenantData !== []) { $data = array_merge($data, $tenantData); $where = ['tenant_id' => $data['tenant_id']]; } else { $where = ['id' => 1]; } return XeroToken::updateOrCreate($where, $data); } } laravel-xero/src/XeroServiceProvider.php000064400000004057152665540340014436 0ustar00loadMigrationsFrom(__DIR__.'/../database/migrations'); $this->registerCommands(); $this->registerMiddleware($router); $this->configurePublishing(); } public function registerCommands(): void { if (! $this->app->runningInConsole()) { return; } $this->commands([ XeroKeepAliveCommand::class, XeroShowAllTokensCommand::class ]); } public function registerMiddleware(Router $router): void { // add middleware $router->aliasMiddleware('XeroAuthenticated', XeroAuthenticated::class); } public function configurePublishing(): void { if (! $this->app->runningInConsole()) { return; } // Publishing the configuration file. $this->publishes([ __DIR__.'/../config/xero.php' => config_path('xero.php'), ], 'config'); $timestamp = date('Y_m_d_His', time()); $this->publishes([ __DIR__.'/database/migrations/create_xero_tokens_table.php' => $this->app->databasePath()."/migrations/{$timestamp}_create_xero_tokens_table.php", ], 'migrations'); } /** * Register any package services. */ public function register(): void { $this->mergeConfigFrom(__DIR__.'/../config/xero.php', 'xero'); // Register the service the package provides. $this->app->singleton('xero', function () { return new Xero; }); } /** * Get the services provided by the provider. */ public function provides(): array { return ['xero']; } } laravel-xero/src/Console/Commands/XeroShowAllTokensCommand.php000064400000003515152665540340020520 0ustar00newLine(); $this->line('All XERO Tokens in storage'); $this->newLine(); $dataToDisplay = [ 'id', 'tenant_name', 'tenant_id', 'updated_at', ]; // Fetch all access tokens $tokens = XeroToken::select($dataToDisplay)->get(); if (config('xero.encrypt')) { $tokens->map(function (XeroToken $token) { try { $access_token = Crypt::decryptString($token->access_token); } catch (DecryptException $e) { $access_token = $token->access_token; } // Split them as a refresh token may not exist... try { $refresh_token = Crypt::decryptString($token->refresh_token); } catch (DecryptException $e) { $refresh_token = $token->refresh_token; } $token->access_token = $access_token; $token->refresh_token = $refresh_token; return $token; }); } $this->table( $dataToDisplay, $tokens->toArray() ); } } laravel-xero/src/Console/Commands/XeroKeepAliveCommand.php000064400000002114152665540340017622 0ustar00newLine(); // Fetch all tenants for when multiple tenants are in use. $tenants = XeroToken::all(); foreach ($tenants as $tenant) { // Set the tenant ID Xero::setTenantId($tenant->tenant_id); if (Xero::isConnected()) { Xero::getAccessToken($redirectWhenNotConnected = false); $this->info('Refreshing Token for Tenant: '.$tenant->tenant_name.' - Successful'); } else { $this->info('Refreshing Token for Tenant: '.$tenant->tenant_name.' - Not Connected'); } } } } laravel-xero/src/Xero.php000064400000024656152665540340011411 0ustar00guzzle($function, $path, $data, $raw, $accept, $headers); } // request verb is not in the $options array throw new RuntimeException($function.' is not a valid HTTP Verb'); } public function setTenantId(string $tenant_id): void { $this->tenant_id = $tenant_id; } public function contacts(): Contacts { return new Contacts; } public function creditnotes(): CreditNotes { return new CreditNotes; } public function invoices(): Invoices { return new Invoices; } public function webhooks(): Webhooks { return new Webhooks; } public function isTokenValid(): bool { $token = $this->getTokenData(); if ($token === null) { return false; } $now = now()->addMinutes(5); if ($token->expires < $now) { return false; } return true; } public function isConnected(): bool { return ! ($this->getTokenData() === null); } public function disconnect(): void { try { $token = $this->getTokenData(); Http::withHeaders([ 'authorization' => 'Basic '.base64_encode(config('xero.clientId').':'.config('xero.clientSecret')), ]) ->asForm() ->post(self::$revokeUrl, [ 'token' => $token->refresh_token, ])->throw(); $token->delete(); } catch (Exception $e) { throw new RuntimeException('error getting tenant: '.$e->getMessage()); } } /** * Make a connection or return a token where it's valid * * * @throws Exception */ public function connect(): RedirectResponse|Application|Redirector { // when no code param redirect to Microsoft if (request()->has('code')) { // With the authorization code, we can retrieve access tokens and other data. try { $params = [ 'grant_type' => 'authorization_code', 'code' => request('code'), 'redirect_uri' => config('xero.redirectUri'), ]; $result = $this->sendPost(self::$tokenUrl, $params); try { $response = Http::withHeaders([ 'Authorization' => 'Bearer '.$result['access_token'], ]) ->acceptJson() ->get(self::$connectionUrl) ->throw() ->json(); foreach ($response as $tenant) { $tenantData = [ 'auth_event_id' => $tenant['authEventId'], 'tenant_id' => $tenant['tenantId'], 'tenant_type' => $tenant['tenantType'], 'tenant_name' => $tenant['tenantName'], 'created_date_utc' => $tenant['createdDateUtc'], 'updated_date_utc' => $tenant['updatedDateUtc'], ]; app(StoreTokenAction::class)($result, $tenantData, $tenant['tenantId']); } } catch (Exception $e) { throw new Exception('Error getting tenant: '.$e->getMessage()); } return redirect(config('xero.landingUri')); } catch (Exception $e) { throw new Exception($e->getMessage()); } } $url = self::$authorizeUrl.'?'.http_build_query([ 'response_type' => 'code', 'client_id' => config('xero.clientId'), 'redirect_uri' => config('xero.redirectUri'), 'scope' => config('xero.scopes'), ]); return redirect()->away($url); } public function getTokenData(): ?XeroToken { if ($this->tenant_id) { $token = XeroToken::where('tenant_id', '=', $this->tenant_id)->first(); } else { $token = XeroToken::first(); } if ($token && config('xero.encrypt')) { try { $access_token = Crypt::decryptString($token->access_token); } catch (DecryptException $e) { $access_token = $token->access_token; } // Split them as a refresh token may not exist... try { $refresh_token = Crypt::decryptString($token->refresh_token); } catch (DecryptException $e) { $refresh_token = $token->refresh_token; } $token->access_token = $access_token; $token->refresh_token = $refresh_token; } return $token; } /** * @throws Exception */ public function getAccessToken(bool $redirectWhenNotConnected = true): string { /* @var XeroToken $token */ $token = $this->getTokenData(); $this->redirectIfNoToken($token, $redirectWhenNotConnected); $now = now()->addMinutes(5); if ($token->expires < $now) { return $this->renewExpiringToken($token); } return $token->access_token; } /** * @throws Exception */ public function renewExpiringToken(XeroToken $token): string { $params = [ 'grant_type' => 'refresh_token', 'refresh_token' => $token->refresh_token, 'redirect_uri' => config('xero.redirectUri'), ]; $result = $this->sendPost(self::$tokenUrl, $params); app(tokenExpiredAction::class)($result, $token); app(StoreTokenAction::class)($result, ['tenant_id' => $token->tenant_id], $this->tenant_id); return $result['access_token']; } public function getTenantId(): string { $token = $this->getTokenData(); $this->redirectIfNoToken($token); return $token->tenant_id; } public function getTenantName(): ?string { // use id if passed otherwise use logged-in user $token = $this->getTokenData(); $this->redirectIfNoToken($token); // Token is still valid, just return it return $token->tenant_name; } /** * @throws Exception */ protected static function sendPost(string $url, array $params): array { try { $response = Http::withHeaders([ 'authorization' => 'Basic '.base64_encode(config('xero.clientId').':'.config('xero.clientSecret')), ]) ->asForm() ->acceptJson() ->post($url, $params); return $response->json(); } catch (Exception $e) { throw new Exception($e->getMessage()); } } protected function redirectIfNoToken(XeroToken $token, bool $redirectWhenNotConnected = true): RedirectResponse|bool { // Check if tokens exist otherwise run the oauth request if (! $this->isConnected() && $redirectWhenNotConnected === true) { return redirect()->away(config('xero.redirectUri')); } return false; } /** * run Guzzle to process the requested url * * @throws Exception */ protected function guzzle(string $type, string $request, array $data = [], bool $raw = false, string $accept = 'application/json', array $headers = []): array { if ($data === []) { $data = null; } try { $response = Http::withToken($this->getAccessToken()) ->withHeaders(array_merge(['Xero-tenant-id' => $this->getTenantId()], $headers)) ->accept($accept) ->$type(self::$baseUrl.$request, $data) ->throw(); return [ 'body' => $raw ? $response->body() : $response->json(), 'headers' => $response->getHeaders(), ]; } catch (RequestException $e) { $response = json_decode($e->response->body()); throw new Exception($response->Detail ?? "Type: $response?->Type Message: $response?->Message Error Number: $response?->ErrorNumber"); } catch (Exception $e) { throw new Exception($e->getMessage()); } } } laravel-xero/src/Resources/CreditNotes.php000064400000002405152665540340014655 0ustar00queryString[$key] = $value; return $this; } public function get(): array { $queryString = $this->formatQueryStrings($this->queryString); $result = parent::get('CreditNotes?'.$queryString); return $result['body']['CreditNotes']; } public function find(string $contactId): array { $result = parent::get('CreditNotes/'.$contactId); return $result['body']['CreditNotes'][0]; } public function update(string $contactId, array $data): array { $result = $this->post('CreditNotes/'.$contactId, $data); return $result['body']['CreditNotes'][0]; } public function store(array $data): array { $result = $this->post('CreditNotes', $data); return $result['body']['CreditNotes'][0]; } } laravel-xero/src/Resources/Contacts.php000064400000002350152665540340014207 0ustar00queryString[$key] = $value; return $this; } public function get(): array { $queryString = $this->formatQueryStrings($this->queryString); $result = parent::get('Contacts?'.$queryString); return $result['body']['Contacts']; } public function find(string $contactId): array { $result = parent::get('Contacts/'.$contactId); return $result['body']['Contacts'][0]; } public function update(string $contactId, array $data): array { $result = $this->post('Contacts/'.$contactId, $data); return $result['body']['Contacts'][0]; } public function store(array $data): array { $result = $this->post('Contacts', $data); return $result['body']['Contacts'][0]; } } laravel-xero/src/Resources/Invoices.php000064400000004033152665540340014210 0ustar00queryString[$key] = $value; return $this; } public function get(): array { $queryString = $this->formatQueryStrings($this->queryString); $result = parent::get('Invoices?'.$queryString); return $result['body']['Invoices']; } public function find(string $invoiceId): array { $result = parent::get('Invoices/'.$invoiceId); return $result['body']['Invoices'][0]; } public function onlineUrl(string $invoiceId): string { $result = parent::get('Invoices/'.$invoiceId.'/OnlineInvoice'); return $result['body']['OnlineInvoices'][0]['OnlineInvoiceUrl']; } public function update(string $invoiceId, array $data): array { $result = parent::post('Invoices/'.$invoiceId, $data); return $result['body']['Invoices'][0]; } public function store(array $data): array { $result = parent::post('Invoices', $data); return $result['body']['Invoices'][0]; } public function attachments(string $invoiceId): array { $result = parent::get('Invoices/'.$invoiceId.'/Attachments'); return $result['body']['Attachments']; } public function attachment(string $invoiceId, ?string $attachmentId = null, ?string $fileName = null): string { // Depending on the application, we may want to get it by the FileName instead of the AttachmentId $nameOrId = $attachmentId ? $attachmentId : $fileName; $result = parent::get('Invoices/'.$invoiceId.'/Attachments/'.$nameOrId); return $result['body']; } } laravel-xero/src/Resources/Webhooks.php000064400000001302152665540340014206 0ustar00payload = file_get_contents('php://input'); $signature = $_SERVER['HTTP_X_XERO_SIGNATURE']; return hash_equals($this->getSignature(), $signature); } public function getSignature(): string { return base64_encode(hash_hmac('sha256', $this->payload, config('xero.webhookKey'), true)); } public function getEvents(): array { $this->validate(); $payload = json_decode($this->payload); return $payload->events; } } laravel-xero/composer.json000064400000003717152665540340011711 0ustar00{ "name": "dcblogdev/laravel-xero", "description": "A Laravel Xero package", "license": "MIT", "authors": [ { "name": "David Carr", "email": "dave@dcblog.dev", "homepage": "https://dcblog.dev" } ], "homepage": "https://github.com/dcblogdev/laravel-xero", "keywords": ["Laravel", "Xero"], "require": { "illuminate/support": "5.5.x|5.6.x|5.7.x|5.8.x|6.x|7.x|8.x|9.x|10.x|11.x|12.x", "league/oauth2-client": "^1.4|^2.8.1", "guzzlehttp/guzzle": "6.x|^7.9.3", "ext-json": "*", "ext-fileinfo": "*", "ext-curl": "*" }, "require-dev": { "orchestra/testbench": "^7.0|^10.3", "pestphp/pest": "^1.21|^v3.8.2", "pestphp/pest-plugin-laravel": "^v3.2.0", "pestphp/pest-plugin-type-coverage": "^3.5.1", "larastan/larastan": "^3.4", "mockery/mockery": "^1.6.12", "laravel/pint": "^1.22.1" }, "autoload": { "psr-4": { "Dcblogdev\\Xero\\": "src/", "Dcblogdev\\Xero\\database\\factories\\": "database/factories/", "Dcblogdev\\Xero\\Tests\\": "tests" } }, "autoload-dev": { "classmap": [ "tests/TestCase.php" ] }, "scripts": { "pint": "vendor/bin/pint", "stan": "phpstan analyse", "pest": "pest", "pest-type-coverage": "pest --type-coverage", "pest-coverage": "pest --parallel --coverage", "check": [ "@pint", "@stan", "@pest-type-coverage", "@pest-coverage" ] }, "extra": { "laravel": { "providers": [ "Dcblogdev\\Xero\\XeroServiceProvider" ], "aliases": { "Xero": "Dcblogdev\\Xero\\Facades\\Xero" } } }, "config": { "allow-plugins": { "pestphp/pest-plugin": true } } } laravel-xero/.gitignore000064400000000126152665540340011146 0ustar00.DS_Store .idea .vscode vendor composer.lock .phpunit.result.cache .php-cs-fixer.cachelaravel-xero/changelog.md000064400000001560152665540340011432 0ustar00# Changelog All notable changes to `Laravel Xero` will be documented in this file. ## Version 1.0.0 - Everything ## Version 1.0.1 - changed id_token to be text ## Version 1.0.2 - all records by default and added raw option ## Version 1.0.3 - Add support for multiple tenant storing. ## Version 1.1.0 - only expect tenant id when tenantData is being used ## Version 1.1.1 - Add new command keep-alive ## Version 1.1.2 - Added command `xero:show-all` to show all tokens stored in the database. ## Version 1.1.3 - Update keep-alive for multi-tenant use, and storeToken where id when using tenant_id in the class ## Version 1.1.4 - Added support for Laravel 10 ## Version 1.1.5 - applied fix for returning access token ## Version 1.1.6 - Update Xero.php expiry check to use the accessor instead of expired_in column - Fixed failing test for getting access tokenlaravel-xero/config/xero.php000064400000001655152665540340012121 0ustar00 env('XERO_CLIENT_ID'), /* * set the client secret */ 'clientSecret' => env('XERO_CLIENT_SECRET'), /* * Set the url to trigger the oauth process */ 'redirectUri' => env('XERO_REDIRECT_URL'), /* * Set the url to redirecto once authenticated; */ 'landingUri' => env('XERO_LANDING_URL', '/'), /** * Set access token, when set will bypass the oauth2 process */ 'accessToken' => env('XERO_ACCESS_TOKEN', ''), /** * Set webhook token */ 'webhookKey' => env('XERO_WEBHOOK_KEY', ''), /** * Set the scopes */ 'scopes' => env('XERO_SCOPES', 'openid email profile offline_access accounting.settings accounting.transactions accounting.contacts'), /** * Encrypt tokens in database? */ 'encrypt' => env('XERO_ENCRYPT', false), ]; laravel-xero/.github/FUNDING.yml000064400000000103152665540340012326 0ustar00# These are supported funding model platforms github: [dcblogdev] laravel-xero/.github/workflows/php.yml000064400000001220152665540340014061 0ustar00name: PHP Pipeline on: [push, pull_request] jobs: build: runs-on: ubuntu-latest strategy: max-parallel: 2 matrix: php-versions: ['8.3', '8.4'] name: PHP ${{ matrix.php-versions }} steps: - uses: actions/checkout@v1 - name: Setup PHP uses: shivammathur/setup-php@master with: php-version: ${{ matrix.php-versions }} coverage: xdebug - name: Validate composer.json and composer.lock run: composer validate - name: Install dependencies run: composer install --prefer-dist --no-progress --no-suggest - name: Run test suite run: ./vendor/bin/pest laravel-xero/license.md000064400000002103152665540340011117 0ustar00# The license The MIT License (MIT) Copyright (c) 2020 dcblogdev Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.