├── .github ├── FUNDING.yml └── workflows │ └── php.yml ├── .gitignore ├── changelog.md ├── composer.json ├── config └── xero.php ├── license.md ├── phpstan.neon ├── phpunit.xml ├── pint.json ├── readme.md ├── src ├── Actions │ ├── StoreTokenAction.php │ ├── formatQueryStringsAction.php │ └── tokenExpiredAction.php ├── Console │ └── Commands │ │ ├── XeroKeepAliveCommand.php │ │ └── XeroShowAllTokensCommand.php ├── DTOs │ ├── ContactDTO.php │ └── InvoiceDTO.php ├── Enums │ ├── ContactStatus.php │ ├── FilterOptions.php │ ├── InvoiceLineAmountType.php │ ├── InvoiceStatus.php │ └── InvoiceType.php ├── Facades │ └── Xero.php ├── Models │ └── XeroToken.php ├── Resources │ ├── Contacts.php │ ├── CreditNotes.php │ ├── Invoices.php │ └── Webhooks.php ├── Traits │ └── XeroHelpersTrait.php ├── Xero.php ├── XeroAuthenticated.php ├── XeroServiceProvider.php └── database │ ├── factories │ └── TokenFactory.php │ └── migrations │ ├── 2024_03_15_135030_increase_xero_token_tables_string_length.php │ └── create_xero_tokens_table.php └── tests ├── Actions ├── HandleTokenExpiredActionTest.php ├── StoreTokenActionTest.php └── formatQueryStringsActionTest.php ├── Console └── Commands │ ├── XeroKeepAliveCommandTest.php │ └── XeroShowAllTokensCommandTest.php ├── DTOs ├── ContactDTOTest.php └── InvoiceDTOTest.php ├── Enums ├── ContactStatusTest.php ├── FilterOptionsTest.php ├── InvoiceLineAmountTypeTest.php ├── InvoiceStatusTest.php └── InvoiceTypeTest.php ├── Models └── XeroTokenTest.php ├── Pest.php ├── Resources ├── ContactsTest.php ├── CreditNotesTest.php ├── InvoicesTest.php └── WebhooksTest.php ├── TestCase.php ├── XeroAuthenticatedTest.php └── XeroTest.php /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [dcblogdev] 4 | -------------------------------------------------------------------------------- /.github/workflows/php.yml: -------------------------------------------------------------------------------- 1 | name: PHP Pipeline 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | build: 7 | 8 | runs-on: ubuntu-latest 9 | 10 | strategy: 11 | max-parallel: 2 12 | matrix: 13 | php-versions: ['8.3', '8.4'] 14 | 15 | name: PHP ${{ matrix.php-versions }} 16 | 17 | steps: 18 | - uses: actions/checkout@v1 19 | 20 | - name: Setup PHP 21 | uses: shivammathur/setup-php@master 22 | with: 23 | php-version: ${{ matrix.php-versions }} 24 | coverage: xdebug 25 | 26 | - name: Validate composer.json and composer.lock 27 | run: composer validate 28 | 29 | - name: Install dependencies 30 | run: composer install --prefer-dist --no-progress --no-suggest 31 | 32 | - name: Run test suite 33 | run: ./vendor/bin/pest 34 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .idea 3 | .vscode 4 | vendor 5 | composer.lock 6 | .phpunit.result.cache 7 | .php-cs-fixer.cache -------------------------------------------------------------------------------- /changelog.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to `Laravel Xero` will be documented in this file. 4 | 5 | ## Version 1.0.0 6 | 7 | - Everything 8 | 9 | ## Version 1.0.1 10 | 11 | - changed id_token to be text 12 | 13 | ## Version 1.0.2 14 | 15 | - all records by default and added raw option 16 | 17 | ## Version 1.0.3 18 | 19 | - Add support for multiple tenant storing. 20 | 21 | ## Version 1.1.0 22 | 23 | - only expect tenant id when tenantData is being used 24 | 25 | ## Version 1.1.1 26 | 27 | - Add new command keep-alive 28 | 29 | ## Version 1.1.2 30 | 31 | - Added command `xero:show-all` to show all tokens stored in the database. 32 | 33 | ## Version 1.1.3 34 | 35 | - Update keep-alive for multi-tenant use, and storeToken where id when using tenant_id in the class 36 | 37 | ## Version 1.1.4 38 | 39 | - Added support for Laravel 10 40 | 41 | ## Version 1.1.5 42 | 43 | - applied fix for returning access token 44 | 45 | ## Version 1.1.6 46 | 47 | - Update Xero.php expiry check to use the accessor instead of expired_in column 48 | - Fixed failing test for getting access token -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "dcblogdev/laravel-xero", 3 | "description": "A Laravel Xero package", 4 | "license": "MIT", 5 | "authors": [ 6 | { 7 | "name": "David Carr", 8 | "email": "dave@dcblog.dev", 9 | "homepage": "https://dcblog.dev" 10 | } 11 | ], 12 | "homepage": "https://github.com/dcblogdev/laravel-xero", 13 | "keywords": ["Laravel", "Xero"], 14 | "require": { 15 | "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", 16 | "league/oauth2-client": "^1.4|^2.8.1", 17 | "guzzlehttp/guzzle": "6.x|^7.9.3", 18 | "ext-json": "*", 19 | "ext-fileinfo": "*", 20 | "ext-curl": "*" 21 | }, 22 | "require-dev": { 23 | "orchestra/testbench": "^7.0|^10.3", 24 | "pestphp/pest": "^1.21|^v3.8.2", 25 | "pestphp/pest-plugin-laravel": "^v3.2.0", 26 | "pestphp/pest-plugin-type-coverage": "^3.5.1", 27 | "larastan/larastan": "^3.4", 28 | "mockery/mockery": "^1.6.12", 29 | "laravel/pint": "^1.22.1" 30 | }, 31 | "autoload": { 32 | "psr-4": { 33 | "Dcblogdev\\Xero\\": "src/", 34 | "Dcblogdev\\Xero\\database\\factories\\": "database/factories/", 35 | "Dcblogdev\\Xero\\Tests\\": "tests" 36 | } 37 | }, 38 | "autoload-dev": { 39 | "classmap": [ 40 | "tests/TestCase.php" 41 | ] 42 | }, 43 | "scripts": { 44 | "pint": "vendor/bin/pint", 45 | "stan": "phpstan analyse", 46 | "pest": "pest", 47 | "pest-type-coverage": "pest --type-coverage", 48 | "pest-coverage": "pest --parallel --coverage", 49 | "check": [ 50 | "@pint", 51 | "@stan", 52 | "@pest-type-coverage", 53 | "@pest-coverage" 54 | ] 55 | }, 56 | "extra": { 57 | "laravel": { 58 | "providers": [ 59 | "Dcblogdev\\Xero\\XeroServiceProvider" 60 | ], 61 | "aliases": { 62 | "Xero": "Dcblogdev\\Xero\\Facades\\Xero" 63 | } 64 | } 65 | }, 66 | "config": { 67 | "allow-plugins": { 68 | "pestphp/pest-plugin": true 69 | } 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /config/xero.php: -------------------------------------------------------------------------------- 1 | env('XERO_CLIENT_ID'), 11 | 12 | /* 13 | * set the client secret 14 | */ 15 | 'clientSecret' => env('XERO_CLIENT_SECRET'), 16 | 17 | /* 18 | * Set the url to trigger the oauth process 19 | */ 20 | 'redirectUri' => env('XERO_REDIRECT_URL'), 21 | 22 | /* 23 | * Set the url to redirecto once authenticated; 24 | */ 25 | 'landingUri' => env('XERO_LANDING_URL', '/'), 26 | 27 | /** 28 | * Set access token, when set will bypass the oauth2 process 29 | */ 30 | 'accessToken' => env('XERO_ACCESS_TOKEN', ''), 31 | 32 | /** 33 | * Set webhook token 34 | */ 35 | 'webhookKey' => env('XERO_WEBHOOK_KEY', ''), 36 | 37 | /** 38 | * Set the scopes 39 | */ 40 | 'scopes' => env('XERO_SCOPES', 'openid email profile offline_access accounting.settings accounting.transactions accounting.contacts'), 41 | 42 | /** 43 | * Encrypt tokens in database? 44 | */ 45 | 'encrypt' => env('XERO_ENCRYPT', false), 46 | ]; 47 | -------------------------------------------------------------------------------- /license.md: -------------------------------------------------------------------------------- 1 | # The license 2 | 3 | The MIT License (MIT) 4 | 5 | Copyright (c) 2020 dcblogdev 6 | 7 | Permission is hereby granted, free of charge, to any person obtaining a copy 8 | of this software and associated documentation files (the "Software"), to deal 9 | in the Software without restriction, including without limitation the rights 10 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 11 | copies of the Software, and to permit persons to whom the Software is 12 | furnished to do so, subject to the following conditions: 13 | 14 | The above copyright notice and this permission notice shall be included in 15 | all copies or substantial portions of the Software. 16 | 17 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 18 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 19 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 20 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 21 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 22 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 23 | THE SOFTWARE. 24 | -------------------------------------------------------------------------------- /phpstan.neon: -------------------------------------------------------------------------------- 1 | includes: 2 | - vendor/larastan/larastan/extension.neon 3 | 4 | parameters: 5 | 6 | paths: 7 | - src/ 8 | 9 | # Level 9 is the highest level 10 | level: 5 11 | 12 | # ignoreErrors: 13 | # - '#PHPDoc tag @var#' 14 | # 15 | # excludePaths: 16 | # - ./*/*/FileToBeExcluded.php 17 | # 18 | # checkMissingIterableValueType: false -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 16 | 17 | 18 | src/ 19 | 20 | 21 | 22 | 23 | ./tests 24 | 25 | 26 | 27 | 28 | ./src 29 | 30 | 31 | 32 | 33 | 34 | 35 | -------------------------------------------------------------------------------- /pint.json: -------------------------------------------------------------------------------- 1 | { 2 | "preset": "laravel", 3 | "rules": { 4 | "array_push": true, 5 | "backtick_to_shell_exec": true, 6 | "date_time_immutable": true, 7 | "declare_strict_types": true, 8 | "lowercase_keywords": true, 9 | "lowercase_static_reference": true, 10 | "final_class": false, 11 | "final_internal_class": false, 12 | "final_public_method_for_abstract_class": false, 13 | "fully_qualified_strict_types": true, 14 | "global_namespace_import": { 15 | "import_classes": true, 16 | "import_constants": true, 17 | "import_functions": true 18 | }, 19 | "mb_str_functions": true, 20 | "modernize_types_casting": true, 21 | "new_with_parentheses": false, 22 | "no_superfluous_elseif": true, 23 | "no_useless_else": true, 24 | "no_multiple_statements_per_line": true, 25 | "ordered_class_elements": { 26 | "order": [ 27 | "use_trait", 28 | "case", 29 | "constant", 30 | "constant_public", 31 | "constant_protected", 32 | "constant_private", 33 | "property_public", 34 | "property_protected", 35 | "property_private", 36 | "construct", 37 | "destruct", 38 | "magic", 39 | "phpunit", 40 | "method_abstract", 41 | "method_public_static", 42 | "method_public", 43 | "method_protected_static", 44 | "method_protected", 45 | "method_private_static", 46 | "method_private" 47 | ], 48 | "sort_algorithm": "none" 49 | }, 50 | "ordered_interfaces": true, 51 | "ordered_traits": true, 52 | "protected_to_private": true, 53 | "self_accessor": true, 54 | "self_static_accessor": true, 55 | "strict_comparison": true, 56 | "visibility_required": true 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | [![Latest Version on Packagist](https://img.shields.io/packagist/v/dcblogdev/laravel-xero.svg?style=flat-square)](https://packagist.org/packages/dcblogdev/laravel-xero) 2 | [![Total Downloads](https://img.shields.io/packagist/dt/dcblogdev/laravel-xero.svg?style=flat-square)](https://packagist.org/packages/dcblogdev/laravel-xero) 3 | 4 | ![Logo](https://repository-images.githubusercontent.com/317929912/1e40a180-49c1-11eb-893d-af9c59d29ad5) 5 | 6 | Laravel package for working with the Xero API 7 | 8 | Watch a video walkthrough https://www.youtube.com/watch?v=sORX2z-AH1k 9 | 10 | ## Documentation 11 | 12 | Complete docs at https://dcblog.dev/docs/laravel-xero/v1/introduction 13 | 14 | ## Demo application 15 | 16 | A demo application is available at https://github.com/dcblogdev/laravel-xero-demo 17 | 18 | ## Community 19 | 20 | There is a Discord community. https://discord.gg/VYau8hgwrm For quick help, ask questions in the appropriate channel. 21 | 22 | ## Change log 23 | 24 | Please see the [changelog][3] for more information on what has changed recently. 25 | 26 | ## Contributing 27 | 28 | Contributions are welcome and will be fully credited. 29 | 30 | Contributions are accepted via Pull Requests on [Github][4]. 31 | 32 | ## Pull Requests 33 | 34 | - **Document any change in behaviour** - Make sure the `readme.md` and any other relevant documentation are kept up-to-date. 35 | 36 | - **Consider our release cycle** - We try to follow [SemVer v2.0.0][5]. Randomly breaking public APIs is not an option. 37 | 38 | - **One pull request per feature** - If you want to do more than one thing, send multiple pull requests. 39 | 40 | ## Security 41 | 42 | If you discover any security related issues, please email dave@dcblog.dev email instead of using the issue tracker. 43 | 44 | ## License 45 | 46 | license. Please see the [license file][6] for more information. 47 | 48 | [3]: changelog.md 49 | [4]: https://github.com/dcblogdev/laravel-xero 50 | [5]: http://semver.org/ 51 | [6]: license.md 52 | -------------------------------------------------------------------------------- /src/Actions/StoreTokenAction.php: -------------------------------------------------------------------------------- 1 | $token['id_token'], 16 | 'access_token' => config('xero.encrypt') ? Crypt::encryptString($token['access_token']) : $token['access_token'], 17 | 'expires_in' => $token['expires_in'], 18 | 'token_type' => $token['token_type'], 19 | 'refresh_token' => config('xero.encrypt') ? Crypt::encryptString($token['refresh_token']) : $token['refresh_token'], 20 | 'scopes' => $token['scope'], 21 | ]; 22 | 23 | if ($tenantId) { 24 | if ($tenantData !== []) { 25 | $data = array_merge($data, $tenantData); 26 | } 27 | $where = ['tenant_id' => $tenantId]; 28 | } elseif ($tenantData !== []) { 29 | $data = array_merge($data, $tenantData); 30 | $where = ['tenant_id' => $data['tenant_id']]; 31 | } else { 32 | $where = ['id' => 1]; 33 | } 34 | 35 | return XeroToken::updateOrCreate($where, $data); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/Actions/formatQueryStringsAction.php: -------------------------------------------------------------------------------- 1 | delete(); 17 | 18 | if ($this->isRunningInConsole()) { 19 | throw new Exception('Xero token has expired, please re-authenticate.'); 20 | } 21 | 22 | return redirect()->away(config('xero.redirectUri')); 23 | 24 | } 25 | 26 | return null; 27 | } 28 | 29 | /** 30 | * @throws Exception 31 | */ 32 | protected function isRunningInConsole(): bool 33 | { 34 | return app()->runningInConsole(); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/Console/Commands/XeroKeepAliveCommand.php: -------------------------------------------------------------------------------- 1 | newLine(); 20 | // Fetch all tenants for when multiple tenants are in use. 21 | $tenants = XeroToken::all(); 22 | 23 | foreach ($tenants as $tenant) { 24 | 25 | // Set the tenant ID 26 | Xero::setTenantId($tenant->tenant_id); 27 | 28 | if (Xero::isConnected()) { 29 | Xero::getAccessToken($redirectWhenNotConnected = false); 30 | $this->info('Refreshing Token for Tenant: '.$tenant->tenant_name.' - Successful'); 31 | } else { 32 | $this->info('Refreshing Token for Tenant: '.$tenant->tenant_name.' - Not Connected'); 33 | } 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/Console/Commands/XeroShowAllTokensCommand.php: -------------------------------------------------------------------------------- 1 | newLine(); 31 | $this->line('All XERO Tokens in storage'); 32 | $this->newLine(); 33 | 34 | $dataToDisplay = [ 35 | 'id', 36 | 'tenant_name', 37 | 'tenant_id', 38 | 'updated_at', 39 | ]; 40 | 41 | // Fetch all access tokens 42 | $tokens = XeroToken::select($dataToDisplay)->get(); 43 | 44 | if (config('xero.encrypt')) { 45 | $tokens->map(function (XeroToken $token) { 46 | try { 47 | $access_token = Crypt::decryptString($token->access_token); 48 | } catch (DecryptException $e) { 49 | $access_token = $token->access_token; 50 | } 51 | 52 | // Split them as a refresh token may not exist... 53 | try { 54 | $refresh_token = Crypt::decryptString($token->refresh_token); 55 | } catch (DecryptException $e) { 56 | $refresh_token = $token->refresh_token; 57 | } 58 | 59 | $token->access_token = $access_token; 60 | $token->refresh_token = $refresh_token; 61 | 62 | return $token; 63 | }); 64 | } 65 | 66 | $this->table( 67 | $dataToDisplay, 68 | $tokens->toArray() 69 | ); 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/DTOs/ContactDTO.php: -------------------------------------------------------------------------------- 1 | value, 22 | public ?bool $isSupplier = false, 23 | public ?bool $isCustomer = false, 24 | public ?string $defaultCurrency = null, 25 | public ?string $website = null, 26 | public ?string $purchasesDefaultAccountCode = null, 27 | public ?string $salesDefaultAccountCode = null, 28 | /** @var array> */ 29 | public ?array $addresses = [], 30 | /** @var array> */ 31 | public ?array $phones = [], 32 | /** @var array> */ 33 | public ?array $contactPersons = [], 34 | public ?bool $hasAttachments = false, 35 | public ?bool $hasValidationErrors = false, 36 | ) {} 37 | 38 | /** 39 | * Create an address array for the contact 40 | * 41 | * @return array 42 | */ 43 | public static function createAddress( 44 | string $addressType, 45 | ?string $addressLine1 = null, 46 | ?string $addressLine2 = null, 47 | ?string $addressLine3 = null, 48 | ?string $addressLine4 = null, 49 | ?string $city = null, 50 | ?string $region = null, 51 | ?string $postalCode = null, 52 | ?string $country = null, 53 | ?string $attentionTo = null 54 | ): array { 55 | return [ 56 | 'AddressType' => $addressType, 57 | 'AddressLine1' => $addressLine1, 58 | 'AddressLine2' => $addressLine2, 59 | 'AddressLine3' => $addressLine3, 60 | 'AddressLine4' => $addressLine4, 61 | 'City' => $city, 62 | 'Region' => $region, 63 | 'PostalCode' => $postalCode, 64 | 'Country' => $country, 65 | 'AttentionTo' => $attentionTo, 66 | ]; 67 | } 68 | 69 | /** 70 | * Create a phone array for the contact 71 | * 72 | * @return array 73 | */ 74 | public static function createPhone( 75 | string $phoneType, 76 | ?string $phoneNumber = null, 77 | ?string $phoneAreaCode = null, 78 | ?string $phoneCountryCode = null 79 | ): array { 80 | return [ 81 | 'PhoneType' => $phoneType, 82 | 'PhoneNumber' => $phoneNumber, 83 | 'PhoneAreaCode' => $phoneAreaCode, 84 | 'PhoneCountryCode' => $phoneCountryCode, 85 | ]; 86 | } 87 | 88 | /** 89 | * Create a contact person array for the contact 90 | * 91 | * @return array 92 | */ 93 | public static function createContactPerson( 94 | ?string $firstName = null, 95 | ?string $lastName = null, 96 | ?string $emailAddress = null, 97 | ?bool $includeInEmails = false 98 | ): array { 99 | return [ 100 | 'FirstName' => $firstName, 101 | 'LastName' => $lastName, 102 | 'EmailAddress' => $emailAddress, 103 | 'IncludeInEmails' => $includeInEmails, 104 | ]; 105 | } 106 | 107 | /** 108 | * Convert the DTO to an array for the Xero API 109 | * 110 | * @return array 111 | */ 112 | public function toArray(): array 113 | { 114 | return array_filter([ 115 | 'Name' => $this->name, 116 | 'FirstName' => $this->firstName, 117 | 'LastName' => $this->lastName, 118 | 'EmailAddress' => $this->emailAddress, 119 | 'AccountNumber' => $this->accountNumber, 120 | 'BankAccountDetails' => $this->bankAccountDetails, 121 | 'TaxNumber' => $this->taxNumber, 122 | 'AccountsReceivableTaxType' => $this->accountsReceivableTaxType, 123 | 'AccountsPayableTaxType' => $this->accountsPayableTaxType, 124 | 'ContactStatus' => $this->contactStatus, 125 | 'IsSupplier' => $this->isSupplier, 126 | 'IsCustomer' => $this->isCustomer, 127 | 'DefaultCurrency' => $this->defaultCurrency, 128 | 'Website' => $this->website, 129 | 'PurchasesDefaultAccountCode' => $this->purchasesDefaultAccountCode, 130 | 'SalesDefaultAccountCode' => $this->salesDefaultAccountCode, 131 | 'Addresses' => $this->addresses, 132 | 'Phones' => $this->phones, 133 | 'ContactPersons' => $this->contactPersons, 134 | 'HasAttachments' => $this->hasAttachments, 135 | 'HasValidationErrors' => $this->hasValidationErrors, 136 | ], function (mixed $value) { 137 | return $value !== null; 138 | }); 139 | } 140 | } 141 | -------------------------------------------------------------------------------- /src/DTOs/InvoiceDTO.php: -------------------------------------------------------------------------------- 1 | value, // ACCREC for sales invoices, ACCPAY for bills 16 | public ?string $invoiceNumber = null, 17 | public ?string $reference = null, 18 | public ?string $date = null, 19 | public ?string $dueDate = null, 20 | public ?string $status = InvoiceStatus::Draft->value, 21 | public ?string $lineAmountTypes = InvoiceLineAmountType::Exclusive->value, 22 | public ?string $currencyCode = null, 23 | public ?string $currencyRate = null, 24 | public ?string $subTotal = null, 25 | public ?string $totalTax = null, 26 | public ?string $total = null, 27 | public ?string $contactID = null, 28 | /** @var array> */ 29 | public ?array $contact = null, 30 | /** @var array> */ 31 | public ?array $lineItems = [], 32 | /** @var array> */ 33 | public ?array $payments = [], 34 | /** @var array> */ 35 | public ?array $creditNotes = [], 36 | /** @var array> */ 37 | public ?array $prepayments = [], 38 | /** @var array> */ 39 | public ?array $overpayments = [], 40 | public ?bool $hasAttachments = false, 41 | public ?bool $isDiscounted = false, 42 | public ?bool $hasErrors = false, 43 | ) {} 44 | 45 | /** 46 | * Create a line item array for the invoice 47 | * 48 | * @param array>|null $tracking 49 | * @return array 50 | */ 51 | public static function createLineItem( 52 | ?string $description = null, 53 | string|int|null $quantity = null, 54 | string|float|null $unitAmount = null, 55 | ?int $accountCode = null, 56 | ?string $itemCode = null, 57 | ?string $taxType = null, 58 | ?string $taxAmount = null, 59 | ?string $lineAmount = null, 60 | ?string $discountRate = null, 61 | ?array $tracking = null, 62 | ): array { 63 | return array_filter([ 64 | 'Description' => $description, 65 | 'Quantity' => $quantity, 66 | 'UnitAmount' => $unitAmount, 67 | 'AccountCode' => $accountCode, 68 | 'ItemCode' => $itemCode, 69 | 'TaxType' => $taxType, 70 | 'TaxAmount' => $taxAmount, 71 | 'LineAmount' => $lineAmount, 72 | 'DiscountRate' => $discountRate, 73 | 'Tracking' => $tracking, 74 | ], function (mixed $value) { 75 | return $value !== null; 76 | }); 77 | } 78 | 79 | /** 80 | * Convert the DTO to an array for the Xero API 81 | * 82 | * @return array 83 | */ 84 | public function toArray(): array 85 | { 86 | return array_filter([ 87 | 'InvoiceID' => $this->invoiceID, 88 | 'Type' => $this->type, 89 | 'InvoiceNumber' => $this->invoiceNumber, 90 | 'Reference' => $this->reference, 91 | 'Date' => $this->date, 92 | 'DueDate' => $this->dueDate, 93 | 'Status' => $this->status, 94 | 'LineAmountTypes' => $this->lineAmountTypes, 95 | 'CurrencyCode' => $this->currencyCode, 96 | 'CurrencyRate' => $this->currencyRate, 97 | 'SubTotal' => $this->subTotal, 98 | 'TotalTax' => $this->totalTax, 99 | 'Total' => $this->total, 100 | 'Contact' => $this->contactID ? ['ContactID' => $this->contactID] : $this->contact, 101 | 'LineItems' => $this->lineItems, 102 | 'HasAttachments' => $this->hasAttachments, 103 | 'IsDiscounted' => $this->isDiscounted, 104 | ], fn (mixed $value) => $value !== null && $value !== []); 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /src/Enums/ContactStatus.php: -------------------------------------------------------------------------------- 1 | $case->value, self::cases()); 16 | 17 | return in_array($value, $validValues); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/Enums/FilterOptions.php: -------------------------------------------------------------------------------- 1 | $case->value, self::cases()); 21 | 22 | return in_array($value, $validValues); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/Enums/InvoiceLineAmountType.php: -------------------------------------------------------------------------------- 1 | $case->value, self::cases()); 16 | 17 | return in_array($value, $validValues); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/Enums/InvoiceStatus.php: -------------------------------------------------------------------------------- 1 | $case->value, self::cases()); 19 | 20 | return in_array($value, $validValues); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/Enums/InvoiceType.php: -------------------------------------------------------------------------------- 1 | $case->value, self::cases()); 15 | 16 | return in_array($value, $validValues); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/Facades/Xero.php: -------------------------------------------------------------------------------- 1 | 'integer', 33 | ]; 34 | 35 | protected static function newFactory(): TokenFactory 36 | { 37 | return TokenFactory::new(); 38 | } 39 | 40 | /** 41 | * @return Attribute 42 | */ 43 | protected function expires(): Attribute 44 | { 45 | return Attribute::get( 46 | fn (): DateTimeInterface => $this->updated_at->addSeconds((int) $this->expires_in) 47 | ); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/Resources/Contacts.php: -------------------------------------------------------------------------------- 1 | queryString[$key] = $value; 22 | 23 | return $this; 24 | } 25 | 26 | public function get(): array 27 | { 28 | $queryString = $this->formatQueryStrings($this->queryString); 29 | 30 | $result = parent::get('Contacts?'.$queryString); 31 | 32 | return $result['body']['Contacts']; 33 | } 34 | 35 | public function find(string $contactId): array 36 | { 37 | $result = parent::get('Contacts/'.$contactId); 38 | 39 | return $result['body']['Contacts'][0]; 40 | } 41 | 42 | public function update(string $contactId, array $data): array 43 | { 44 | $result = $this->post('Contacts/'.$contactId, $data); 45 | 46 | return $result['body']['Contacts'][0]; 47 | } 48 | 49 | public function store(array $data): array 50 | { 51 | $result = $this->post('Contacts', $data); 52 | 53 | return $result['body']['Contacts'][0]; 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/Resources/CreditNotes.php: -------------------------------------------------------------------------------- 1 | queryString[$key] = $value; 22 | 23 | return $this; 24 | } 25 | 26 | public function get(): array 27 | { 28 | $queryString = $this->formatQueryStrings($this->queryString); 29 | 30 | $result = parent::get('CreditNotes?'.$queryString); 31 | 32 | return $result['body']['CreditNotes']; 33 | } 34 | 35 | public function find(string $contactId): array 36 | { 37 | $result = parent::get('CreditNotes/'.$contactId); 38 | 39 | return $result['body']['CreditNotes'][0]; 40 | } 41 | 42 | public function update(string $contactId, array $data): array 43 | { 44 | $result = $this->post('CreditNotes/'.$contactId, $data); 45 | 46 | return $result['body']['CreditNotes'][0]; 47 | } 48 | 49 | public function store(array $data): array 50 | { 51 | $result = $this->post('CreditNotes', $data); 52 | 53 | return $result['body']['CreditNotes'][0]; 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/Resources/Invoices.php: -------------------------------------------------------------------------------- 1 | queryString[$key] = $value; 22 | 23 | return $this; 24 | } 25 | 26 | public function get(): array 27 | { 28 | $queryString = $this->formatQueryStrings($this->queryString); 29 | 30 | $result = parent::get('Invoices?'.$queryString); 31 | 32 | return $result['body']['Invoices']; 33 | } 34 | 35 | public function find(string $invoiceId): array 36 | { 37 | $result = parent::get('Invoices/'.$invoiceId); 38 | 39 | return $result['body']['Invoices'][0]; 40 | } 41 | 42 | public function onlineUrl(string $invoiceId): string 43 | { 44 | $result = parent::get('Invoices/'.$invoiceId.'/OnlineInvoice'); 45 | 46 | return $result['body']['OnlineInvoices'][0]['OnlineInvoiceUrl']; 47 | } 48 | 49 | public function update(string $invoiceId, array $data): array 50 | { 51 | $result = parent::post('Invoices/'.$invoiceId, $data); 52 | 53 | return $result['body']['Invoices'][0]; 54 | } 55 | 56 | public function store(array $data): array 57 | { 58 | $result = parent::post('Invoices', $data); 59 | 60 | return $result['body']['Invoices'][0]; 61 | } 62 | 63 | public function attachments(string $invoiceId): array 64 | { 65 | $result = parent::get('Invoices/'.$invoiceId.'/Attachments'); 66 | 67 | return $result['body']['Attachments']; 68 | } 69 | 70 | public function attachment(string $invoiceId, ?string $attachmentId = null, ?string $fileName = null): string 71 | { 72 | // Depending on the application, we may want to get it by the FileName instead of the AttachmentId 73 | $nameOrId = $attachmentId ? $attachmentId : $fileName; 74 | 75 | $result = parent::get('Invoices/'.$invoiceId.'/Attachments/'.$nameOrId); 76 | 77 | return $result['body']; 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/Resources/Webhooks.php: -------------------------------------------------------------------------------- 1 | payload = file_get_contents('php://input'); 16 | $signature = $_SERVER['HTTP_X_XERO_SIGNATURE']; 17 | 18 | return hash_equals($this->getSignature(), $signature); 19 | } 20 | 21 | public function getSignature(): string 22 | { 23 | return base64_encode(hash_hmac('sha256', $this->payload, config('xero.webhookKey'), true)); 24 | } 25 | 26 | public function getEvents(): array 27 | { 28 | $this->validate(); 29 | 30 | $payload = json_decode($this->payload); 31 | 32 | return $payload->events; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/Traits/XeroHelpersTrait.php: -------------------------------------------------------------------------------- 1 | setTimezone(new DateTimeZone($tzOffset)); 29 | 30 | return $dt->format($format); 31 | } 32 | 33 | // Fallback to default DateTime parsing 34 | $dt = new DateTimeImmutable($date); 35 | 36 | return $dt->format($format); 37 | 38 | } catch (Throwable $e) { 39 | // Invalid date input, return empty string instead of crashing 40 | return ''; 41 | } 42 | } 43 | 44 | /** 45 | * Format query strings for API requests. 46 | */ 47 | public static function formatQueryStrings(array $params): string 48 | { 49 | return app(formatQueryStringsAction::class)($params); 50 | } 51 | } -------------------------------------------------------------------------------- /src/Xero.php: -------------------------------------------------------------------------------- 1 | guzzle($function, $path, $data, $raw, $accept, $headers); 69 | } 70 | // request verb is not in the $options array 71 | throw new RuntimeException($function.' is not a valid HTTP Verb'); 72 | } 73 | 74 | 75 | public function setTenantId(string $tenant_id): void 76 | { 77 | $this->tenant_id = $tenant_id; 78 | } 79 | 80 | public function contacts(): Contacts 81 | { 82 | return new Contacts; 83 | } 84 | 85 | public function creditnotes(): CreditNotes 86 | { 87 | return new CreditNotes; 88 | } 89 | 90 | public function invoices(): Invoices 91 | { 92 | return new Invoices; 93 | } 94 | 95 | public function webhooks(): Webhooks 96 | { 97 | return new Webhooks; 98 | } 99 | 100 | public function isTokenValid(): bool 101 | { 102 | $token = $this->getTokenData(); 103 | 104 | if ($token === null) { 105 | return false; 106 | } 107 | 108 | $now = now()->addMinutes(5); 109 | 110 | if ($token->expires < $now) { 111 | return false; 112 | } 113 | 114 | return true; 115 | } 116 | 117 | public function isConnected(): bool 118 | { 119 | return ! ($this->getTokenData() === null); 120 | } 121 | 122 | public function disconnect(): void 123 | { 124 | try { 125 | $token = $this->getTokenData(); 126 | 127 | Http::withHeaders([ 128 | 'authorization' => 'Basic '.base64_encode(config('xero.clientId').':'.config('xero.clientSecret')), 129 | ]) 130 | ->asForm() 131 | ->post(self::$revokeUrl, [ 132 | 'token' => $token->refresh_token, 133 | ])->throw(); 134 | 135 | $token->delete(); 136 | } catch (Exception $e) { 137 | throw new RuntimeException('error getting tenant: '.$e->getMessage()); 138 | } 139 | } 140 | 141 | /** 142 | * Make a connection or return a token where it's valid 143 | * 144 | * 145 | * @throws Exception 146 | */ 147 | public function connect(): RedirectResponse|Application|Redirector 148 | { 149 | // when no code param redirect to Microsoft 150 | if (request()->has('code')) { 151 | // With the authorization code, we can retrieve access tokens and other data. 152 | try { 153 | $params = [ 154 | 'grant_type' => 'authorization_code', 155 | 'code' => request('code'), 156 | 'redirect_uri' => config('xero.redirectUri'), 157 | ]; 158 | 159 | $result = $this->sendPost(self::$tokenUrl, $params); 160 | 161 | try { 162 | $response = Http::withHeaders([ 163 | 'Authorization' => 'Bearer '.$result['access_token'], 164 | ]) 165 | ->acceptJson() 166 | ->get(self::$connectionUrl) 167 | ->throw() 168 | ->json(); 169 | 170 | foreach ($response as $tenant) { 171 | $tenantData = [ 172 | 'auth_event_id' => $tenant['authEventId'], 173 | 'tenant_id' => $tenant['tenantId'], 174 | 'tenant_type' => $tenant['tenantType'], 175 | 'tenant_name' => $tenant['tenantName'], 176 | 'created_date_utc' => $tenant['createdDateUtc'], 177 | 'updated_date_utc' => $tenant['updatedDateUtc'], 178 | ]; 179 | 180 | app(StoreTokenAction::class)($result, $tenantData, $tenant['tenantId']); 181 | } 182 | } catch (Exception $e) { 183 | throw new Exception('Error getting tenant: '.$e->getMessage()); 184 | } 185 | 186 | return redirect(config('xero.landingUri')); 187 | } catch (Exception $e) { 188 | throw new Exception($e->getMessage()); 189 | } 190 | } 191 | 192 | $url = self::$authorizeUrl.'?'.http_build_query([ 193 | 'response_type' => 'code', 194 | 'client_id' => config('xero.clientId'), 195 | 'redirect_uri' => config('xero.redirectUri'), 196 | 'scope' => config('xero.scopes'), 197 | ]); 198 | 199 | return redirect()->away($url); 200 | } 201 | 202 | public function getTokenData(): ?XeroToken 203 | { 204 | if ($this->tenant_id) { 205 | $token = XeroToken::where('tenant_id', '=', $this->tenant_id)->first(); 206 | } else { 207 | $token = XeroToken::first(); 208 | } 209 | 210 | if ($token && config('xero.encrypt')) { 211 | try { 212 | $access_token = Crypt::decryptString($token->access_token); 213 | } catch (DecryptException $e) { 214 | $access_token = $token->access_token; 215 | } 216 | 217 | // Split them as a refresh token may not exist... 218 | try { 219 | $refresh_token = Crypt::decryptString($token->refresh_token); 220 | } catch (DecryptException $e) { 221 | $refresh_token = $token->refresh_token; 222 | } 223 | 224 | $token->access_token = $access_token; 225 | $token->refresh_token = $refresh_token; 226 | } 227 | 228 | return $token; 229 | } 230 | 231 | /** 232 | * @throws Exception 233 | */ 234 | public function getAccessToken(bool $redirectWhenNotConnected = true): string 235 | { 236 | /* @var XeroToken $token */ 237 | $token = $this->getTokenData(); 238 | 239 | $this->redirectIfNoToken($token, $redirectWhenNotConnected); 240 | 241 | $now = now()->addMinutes(5); 242 | 243 | if ($token->expires < $now) { 244 | return $this->renewExpiringToken($token); 245 | } 246 | 247 | return $token->access_token; 248 | } 249 | 250 | /** 251 | * @throws Exception 252 | */ 253 | public function renewExpiringToken(XeroToken $token): string 254 | { 255 | $params = [ 256 | 'grant_type' => 'refresh_token', 257 | 'refresh_token' => $token->refresh_token, 258 | 'redirect_uri' => config('xero.redirectUri'), 259 | ]; 260 | 261 | $result = $this->sendPost(self::$tokenUrl, $params); 262 | 263 | app(tokenExpiredAction::class)($result, $token); 264 | app(StoreTokenAction::class)($result, ['tenant_id' => $token->tenant_id], $this->tenant_id); 265 | 266 | return $result['access_token']; 267 | } 268 | 269 | public function getTenantId(): string 270 | { 271 | $token = $this->getTokenData(); 272 | 273 | $this->redirectIfNoToken($token); 274 | 275 | return $token->tenant_id; 276 | } 277 | 278 | public function getTenantName(): ?string 279 | { 280 | // use id if passed otherwise use logged-in user 281 | $token = $this->getTokenData(); 282 | 283 | $this->redirectIfNoToken($token); 284 | 285 | // Token is still valid, just return it 286 | return $token->tenant_name; 287 | } 288 | 289 | 290 | /** 291 | * @throws Exception 292 | */ 293 | protected static function sendPost(string $url, array $params): array 294 | { 295 | try { 296 | $response = Http::withHeaders([ 297 | 'authorization' => 'Basic '.base64_encode(config('xero.clientId').':'.config('xero.clientSecret')), 298 | ]) 299 | ->asForm() 300 | ->acceptJson() 301 | ->post($url, $params); 302 | 303 | return $response->json(); 304 | 305 | } catch (Exception $e) { 306 | throw new Exception($e->getMessage()); 307 | } 308 | } 309 | 310 | protected function redirectIfNoToken(XeroToken $token, bool $redirectWhenNotConnected = true): RedirectResponse|bool 311 | { 312 | // Check if tokens exist otherwise run the oauth request 313 | if (! $this->isConnected() && $redirectWhenNotConnected === true) { 314 | return redirect()->away(config('xero.redirectUri')); 315 | } 316 | 317 | return false; 318 | } 319 | 320 | /** 321 | * run Guzzle to process the requested url 322 | * 323 | * @throws Exception 324 | */ 325 | protected function guzzle(string $type, string $request, array $data = [], bool $raw = false, string $accept = 'application/json', array $headers = []): array 326 | { 327 | if ($data === []) { 328 | $data = null; 329 | } 330 | 331 | try { 332 | $response = Http::withToken($this->getAccessToken()) 333 | ->withHeaders(array_merge(['Xero-tenant-id' => $this->getTenantId()], $headers)) 334 | ->accept($accept) 335 | ->$type(self::$baseUrl.$request, $data) 336 | ->throw(); 337 | 338 | return [ 339 | 'body' => $raw ? $response->body() : $response->json(), 340 | 'headers' => $response->getHeaders(), 341 | ]; 342 | } catch (RequestException $e) { 343 | $response = json_decode($e->response->body()); 344 | throw new Exception($response->Detail ?? "Type: $response?->Type Message: $response?->Message Error Number: $response?->ErrorNumber"); 345 | } catch (Exception $e) { 346 | throw new Exception($e->getMessage()); 347 | } 348 | } 349 | } 350 | -------------------------------------------------------------------------------- /src/XeroAuthenticated.php: -------------------------------------------------------------------------------- 1 | loadMigrationsFrom(__DIR__.'/../database/migrations'); 20 | $this->registerCommands(); 21 | $this->registerMiddleware($router); 22 | $this->configurePublishing(); 23 | } 24 | 25 | public function registerCommands(): void 26 | { 27 | if (! $this->app->runningInConsole()) { 28 | return; 29 | } 30 | 31 | $this->commands([ 32 | XeroKeepAliveCommand::class, 33 | XeroShowAllTokensCommand::class 34 | ]); 35 | } 36 | 37 | public function registerMiddleware(Router $router): void 38 | { 39 | // add middleware 40 | $router->aliasMiddleware('XeroAuthenticated', XeroAuthenticated::class); 41 | } 42 | 43 | public function configurePublishing(): void 44 | { 45 | if (! $this->app->runningInConsole()) { 46 | return; 47 | } 48 | 49 | // Publishing the configuration file. 50 | $this->publishes([ 51 | __DIR__.'/../config/xero.php' => config_path('xero.php'), 52 | ], 'config'); 53 | 54 | $timestamp = date('Y_m_d_His', time()); 55 | 56 | $this->publishes([ 57 | __DIR__.'/database/migrations/create_xero_tokens_table.php' => $this->app->databasePath()."/migrations/{$timestamp}_create_xero_tokens_table.php", 58 | ], 'migrations'); 59 | } 60 | 61 | /** 62 | * Register any package services. 63 | */ 64 | public function register(): void 65 | { 66 | $this->mergeConfigFrom(__DIR__.'/../config/xero.php', 'xero'); 67 | 68 | // Register the service the package provides. 69 | $this->app->singleton('xero', function () { 70 | return new Xero; 71 | }); 72 | } 73 | 74 | /** 75 | * Get the services provided by the provider. 76 | */ 77 | public function provides(): array 78 | { 79 | return ['xero']; 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/database/factories/TokenFactory.php: -------------------------------------------------------------------------------- 1 | $this->faker->uuid, 18 | 'tenant_name' => $this->faker->name, 19 | 'access_token' => $this->faker->uuid, 20 | 'refresh_token' => $this->faker->uuid, 21 | 'expires_in' => $this->faker->randomNumber(), 22 | 'created_at' => $this->faker->dateTime, 23 | 'updated_at' => $this->faker->dateTime, 24 | 'scopes' => config('xero.scopes'), 25 | ]; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/database/migrations/2024_03_15_135030_increase_xero_token_tables_string_length.php: -------------------------------------------------------------------------------- 1 | text('refresh_token')->change(); 18 | }); 19 | } 20 | 21 | /** 22 | * Reverse the migrations. 23 | */ 24 | public function down(): void 25 | { 26 | Schema::table('xero_tokens', function (Blueprint $table) { 27 | $table->string('refresh_token')->change(); 28 | }); 29 | } 30 | }; 31 | -------------------------------------------------------------------------------- /src/database/migrations/create_xero_tokens_table.php: -------------------------------------------------------------------------------- 1 | increments('id'); 18 | $table->text('id_token')->nullable(); 19 | $table->text('access_token'); 20 | $table->string('expires_in')->nullable(); 21 | $table->string('token_type')->nullable(); 22 | $table->string('refresh_token')->nullable(); 23 | $table->string('scopes'); 24 | $table->string('auth_event_id')->nullable(); 25 | $table->string('tenant_id')->nullable(); 26 | $table->string('tenant_type')->nullable(); 27 | $table->string('tenant_name')->nullable(); 28 | $table->string('created_date_utc')->nullable(); 29 | $table->string('updated_date_utc')->nullable(); 30 | $table->timestamps(); 31 | }); 32 | } 33 | 34 | /** 35 | * Reverse the migrations. 36 | */ 37 | public function down(): void 38 | { 39 | Schema::dropIfExists('xero_tokens'); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /tests/Actions/HandleTokenExpiredActionTest.php: -------------------------------------------------------------------------------- 1 | create(); 15 | 16 | $result = ['error' => 'invalid_grant']; 17 | 18 | app(tokenExpiredAction::class)($result, $token); 19 | 20 | assertDatabaseCount(XeroToken::class, 0); 21 | 22 | })->throws(Exception::class, 'Xero token has expired, please re-authenticate.'); 23 | 24 | test('token refresh does not throw an exception and token is not deleted', function () { 25 | 26 | $token = XeroToken::factory()->create(); 27 | 28 | $result = []; 29 | 30 | $response = app(tokenExpiredAction::class)($result, $token); 31 | 32 | expect($response)->toBeNull(); 33 | 34 | assertDatabaseCount(XeroToken::class, 1); 35 | }); 36 | 37 | test('token refresh redirects when expired and not running in console', function () { 38 | // Create a test-specific version of the tokenExpiredAction class 39 | // that overrides isRunningInConsole to return false 40 | $testAction = new class extends tokenExpiredAction 41 | { 42 | protected function isRunningInConsole(): bool 43 | { 44 | return false; 45 | } 46 | }; 47 | 48 | // Set a test redirect URI 49 | Config::set('xero.redirectUri', 'https://example.com/auth'); 50 | 51 | $token = XeroToken::factory()->create(); 52 | 53 | $result = ['error' => 'invalid_grant']; 54 | 55 | $response = $testAction($result, $token); 56 | 57 | // Assert token was deleted 58 | assertDatabaseCount(XeroToken::class, 0); 59 | 60 | // Assert response is a redirect to the configured URI 61 | expect($response)->toBeInstanceOf(RedirectResponse::class); 62 | expect($response->getTargetUrl())->toBe('https://example.com/auth'); 63 | }); 64 | -------------------------------------------------------------------------------- /tests/Actions/StoreTokenActionTest.php: -------------------------------------------------------------------------------- 1 | 'test_id_token', 14 | 'access_token' => 'test_access_token', 15 | 'expires_in' => 3600, 16 | 'token_type' => 'Bearer', 17 | 'refresh_token' => 'test_refresh_token', 18 | 'scope' => 'test_scope', 19 | ]; 20 | 21 | $result = app(StoreTokenAction::class)($token); 22 | 23 | expect($result)->toBeInstanceOf(XeroToken::class); 24 | assertDatabaseCount(XeroToken::class, 1); 25 | assertDatabaseHas('xero_tokens', [ 26 | 'id' => 1, 27 | 'access_token' => config('xero.encrypt') ? null : 'test_access_token', 28 | 'refresh_token' => config('xero.encrypt') ? null : 'test_refresh_token', 29 | 'scopes' => 'test_scope', 30 | ]); 31 | }); 32 | 33 | test('store token action creates a new token with tenant data', function () { 34 | $token = [ 35 | 'id_token' => 'test_id_token', 36 | 'access_token' => 'test_access_token', 37 | 'expires_in' => 3600, 38 | 'token_type' => 'Bearer', 39 | 'refresh_token' => 'test_refresh_token', 40 | 'scope' => 'test_scope', 41 | ]; 42 | 43 | $tenantData = [ 44 | 'tenant_id' => 'test_tenant_id', 45 | 'tenant_name' => 'Test Tenant', 46 | ]; 47 | 48 | $result = app(StoreTokenAction::class)($token, $tenantData); 49 | 50 | expect($result)->toBeInstanceOf(XeroToken::class); 51 | assertDatabaseCount(XeroToken::class, 1); 52 | assertDatabaseHas('xero_tokens', [ 53 | 'tenant_id' => 'test_tenant_id', 54 | 'tenant_name' => 'Test Tenant', 55 | 'access_token' => config('xero.encrypt') ? null : 'test_access_token', 56 | 'refresh_token' => config('xero.encrypt') ? null : 'test_refresh_token', 57 | 'scopes' => 'test_scope', 58 | ]); 59 | }); 60 | 61 | test('store token action updates an existing token with tenant id and tenant data', function () { 62 | // First, create a token 63 | $token = XeroToken::factory()->create([ 64 | 'tenant_id' => 'test_tenant_id', 65 | 'tenant_name' => 'Old Tenant Name', 66 | ]); 67 | 68 | // Then update it with new data 69 | $newToken = [ 70 | 'id_token' => 'new_id_token', 71 | 'access_token' => 'new_access_token', 72 | 'expires_in' => 7200, 73 | 'token_type' => 'Bearer', 74 | 'refresh_token' => 'new_refresh_token', 75 | 'scope' => 'new_scope', 76 | ]; 77 | 78 | $tenantData = [ 79 | 'tenant_name' => 'New Tenant Name', 80 | ]; 81 | 82 | $result = app(StoreTokenAction::class)($newToken, $tenantData, 'test_tenant_id'); 83 | 84 | expect($result)->toBeInstanceOf(XeroToken::class); 85 | assertDatabaseCount(XeroToken::class, 1); 86 | assertDatabaseHas('xero_tokens', [ 87 | 'tenant_id' => 'test_tenant_id', 88 | 'tenant_name' => 'New Tenant Name', 89 | 'access_token' => config('xero.encrypt') ? null : 'new_access_token', 90 | 'refresh_token' => config('xero.encrypt') ? null : 'new_refresh_token', 91 | 'scopes' => 'new_scope', 92 | ]); 93 | }); 94 | -------------------------------------------------------------------------------- /tests/Actions/formatQueryStringsActionTest.php: -------------------------------------------------------------------------------- 1 | 'value1', 11 | 'key2' => 'value2', 12 | 'key3' => 'value3', 13 | ]; 14 | 15 | $response = app(formatQueryStringsAction::class)($params); 16 | 17 | expect($response)->toBe('key1=value1&key2=value2&key3=value3'); 18 | 19 | }); 20 | 21 | test('throws type error when non-array is passed', function ($value) { 22 | $this->expectException(TypeError::class); 23 | 24 | app(formatQueryStringsAction::class)($value); 25 | })->with([ 26 | 'string', 27 | 123, 28 | 1.23, 29 | true, 30 | false, 31 | null, 32 | new stdClass(), 33 | ]); 34 | -------------------------------------------------------------------------------- /tests/Console/Commands/XeroKeepAliveCommandTest.php: -------------------------------------------------------------------------------- 1 | app->singleton('command.xero.keep-alive', fn () => new XeroKeepAliveCommand()); 13 | Artisan::registerCommand($this->app->make('command.xero.keep-alive')); 14 | }); 15 | 16 | test('command handles no tokens gracefully', function () { 17 | // Ensure no tokens exist 18 | XeroToken::query()->delete(); 19 | 20 | // Run the command 21 | $this->artisan('xero:keep-alive') 22 | ->assertExitCode(0); 23 | }); 24 | 25 | test('command refreshes token for connected tenant', function () { 26 | // Create a token 27 | $token = XeroToken::factory()->create([ 28 | 'tenant_name' => 'Connected Tenant', 29 | 'tenant_id' => 'connected-tenant-id', 30 | ]); 31 | 32 | // Mock the Xero facade 33 | Xero::shouldReceive('setTenantId') 34 | ->once() 35 | ->with('connected-tenant-id'); 36 | 37 | Xero::shouldReceive('isConnected') 38 | ->once() 39 | ->andReturn(true); 40 | 41 | Xero::shouldReceive('getAccessToken') 42 | ->once() 43 | ->with(false) 44 | ->andReturn('refreshed-token'); 45 | 46 | // Run the command 47 | $this->artisan('xero:keep-alive') 48 | ->expectsOutput('Refreshing Token for Tenant: Connected Tenant - Successful') 49 | ->assertExitCode(0); 50 | }); 51 | 52 | test('command handles not connected tenant', function () { 53 | // Create a token 54 | $token = XeroToken::factory()->create([ 55 | 'tenant_name' => 'Not Connected Tenant', 56 | 'tenant_id' => 'not-connected-tenant-id', 57 | ]); 58 | 59 | // Mock the Xero facade 60 | Xero::shouldReceive('setTenantId') 61 | ->once() 62 | ->with('not-connected-tenant-id'); 63 | 64 | Xero::shouldReceive('isConnected') 65 | ->once() 66 | ->andReturn(false); 67 | 68 | // Run the command 69 | $this->artisan('xero:keep-alive') 70 | ->expectsOutput('Refreshing Token for Tenant: Not Connected Tenant - Not Connected') 71 | ->assertExitCode(0); 72 | }); 73 | 74 | test('command handles multiple tenants', function () { 75 | // Create multiple tokens 76 | $token1 = XeroToken::factory()->create([ 77 | 'tenant_name' => 'Tenant 1', 78 | 'tenant_id' => 'tenant-id-1', 79 | ]); 80 | 81 | $token2 = XeroToken::factory()->create([ 82 | 'tenant_name' => 'Tenant 2', 83 | 'tenant_id' => 'tenant-id-2', 84 | ]); 85 | 86 | // Mock the Xero facade for first tenant (connected) 87 | Xero::shouldReceive('setTenantId') 88 | ->once() 89 | ->with('tenant-id-1'); 90 | 91 | Xero::shouldReceive('isConnected') 92 | ->once() 93 | ->andReturn(true); 94 | 95 | Xero::shouldReceive('getAccessToken') 96 | ->once() 97 | ->with(false) 98 | ->andReturn('refreshed-token-1'); 99 | 100 | // Mock the Xero facade for second tenant (not connected) 101 | Xero::shouldReceive('setTenantId') 102 | ->once() 103 | ->with('tenant-id-2'); 104 | 105 | Xero::shouldReceive('isConnected') 106 | ->once() 107 | ->andReturn(false); 108 | 109 | // Run the command 110 | $this->artisan('xero:keep-alive') 111 | ->expectsOutput('Refreshing Token for Tenant: Tenant 1 - Successful') 112 | ->expectsOutput('Refreshing Token for Tenant: Tenant 2 - Not Connected') 113 | ->assertExitCode(0); 114 | }); 115 | -------------------------------------------------------------------------------- /tests/Console/Commands/XeroShowAllTokensCommandTest.php: -------------------------------------------------------------------------------- 1 | app->singleton('command.xero.show-all-tokens', fn () => new XeroShowAllTokensCommand()); 14 | Artisan::registerCommand($this->app->make('command.xero.show-all-tokens')); 15 | }); 16 | 17 | test('command displays message when no tokens exist', function () { 18 | // Ensure no tokens exist 19 | XeroToken::query()->delete(); 20 | 21 | // Run the command 22 | $this->artisan('xero:show-all-tokens') 23 | ->expectsOutput('All XERO Tokens in storage') 24 | ->assertExitCode(0); 25 | }); 26 | 27 | test('command displays tokens in table format', function () { 28 | // Create a token 29 | $token = XeroToken::factory()->create([ 30 | 'tenant_name' => 'Test Tenant', 31 | 'tenant_id' => 'test-tenant-id', 32 | ]); 33 | 34 | // Run the command 35 | $this->artisan('xero:show-all-tokens') 36 | ->expectsOutput('All XERO Tokens in storage') 37 | ->assertExitCode(0); 38 | 39 | // Verify the token exists in the database 40 | $this->assertDatabaseHas('xero_tokens', [ 41 | 'tenant_name' => 'Test Tenant', 42 | 'tenant_id' => 'test-tenant-id', 43 | ]); 44 | }); 45 | 46 | test('command handles encrypted tokens correctly', function () { 47 | // Enable encryption 48 | Config::set('xero.encrypt', true); 49 | 50 | // Create a token with encrypted values 51 | $token = XeroToken::factory()->create([ 52 | 'tenant_name' => 'Encrypted Tenant', 53 | 'tenant_id' => 'encrypted-tenant-id', 54 | 'access_token' => Crypt::encryptString('encrypted-access-token'), 55 | 'refresh_token' => Crypt::encryptString('encrypted-refresh-token'), 56 | ]); 57 | 58 | // Run the command 59 | $this->artisan('xero:show-all-tokens') 60 | ->expectsOutput('All XERO Tokens in storage') 61 | ->assertExitCode(0); 62 | 63 | // Verify the token exists in the database with encrypted values 64 | $this->assertDatabaseHas('xero_tokens', [ 65 | 'tenant_name' => 'Encrypted Tenant', 66 | 'tenant_id' => 'encrypted-tenant-id', 67 | ]); 68 | 69 | // Reset config 70 | Config::set('xero.encrypt', false); 71 | }); 72 | 73 | test('command handles decryption exceptions', function () { 74 | // Enable encryption 75 | Config::set('xero.encrypt', true); 76 | 77 | // Create a token with non-encrypted values that will cause decryption to fail 78 | $token = XeroToken::factory()->create([ 79 | 'tenant_name' => 'Exception Tenant', 80 | 'tenant_id' => 'exception-tenant-id', 81 | 'access_token' => 'non-encrypted-access-token', 82 | 'refresh_token' => 'non-encrypted-refresh-token', 83 | ]); 84 | 85 | // Run the command 86 | $this->artisan('xero:show-all-tokens') 87 | ->expectsOutput('All XERO Tokens in storage') 88 | ->assertExitCode(0); 89 | 90 | // Verify the token exists in the database 91 | $this->assertDatabaseHas('xero_tokens', [ 92 | 'tenant_name' => 'Exception Tenant', 93 | 'tenant_id' => 'exception-tenant-id', 94 | 'access_token' => 'non-encrypted-access-token', 95 | 'refresh_token' => 'non-encrypted-refresh-token', 96 | ]); 97 | 98 | // Reset config 99 | Config::set('xero.encrypt', false); 100 | }); 101 | -------------------------------------------------------------------------------- /tests/DTOs/ContactDTOTest.php: -------------------------------------------------------------------------------- 1 | toBeInstanceOf(ContactDTO::class) 12 | ->and($contactDTO->name)->toBeNull() 13 | ->and($contactDTO->firstName)->toBeNull() 14 | ->and($contactDTO->lastName)->toBeNull() 15 | ->and($contactDTO->emailAddress)->toBeNull() 16 | ->and($contactDTO->accountNumber)->toBeNull() 17 | ->and($contactDTO->bankAccountDetails)->toBeNull() 18 | ->and($contactDTO->taxNumber)->toBeNull() 19 | ->and($contactDTO->accountsReceivableTaxType)->toBeNull() 20 | ->and($contactDTO->accountsPayableTaxType)->toBeNull() 21 | ->and($contactDTO->contactStatus)->toBe(ContactStatus::Active->value) 22 | ->and($contactDTO->isSupplier)->toBeFalse() 23 | ->and($contactDTO->isCustomer)->toBeFalse() 24 | ->and($contactDTO->defaultCurrency)->toBeNull() 25 | ->and($contactDTO->website)->toBeNull() 26 | ->and($contactDTO->purchasesDefaultAccountCode)->toBeNull() 27 | ->and($contactDTO->salesDefaultAccountCode)->toBeNull() 28 | ->and($contactDTO->addresses)->toBe([]) 29 | ->and($contactDTO->phones)->toBe([]) 30 | ->and($contactDTO->contactPersons)->toBe([]) 31 | ->and($contactDTO->hasAttachments)->toBeFalse() 32 | ->and($contactDTO->hasValidationErrors)->toBeFalse(); 33 | }); 34 | 35 | test('contact dto can be instantiated with custom values', function () { 36 | $contactDTO = new ContactDTO( 37 | name: 'Test Company', 38 | firstName: 'John', 39 | lastName: 'Doe', 40 | emailAddress: 'john.doe@example.com', 41 | accountNumber: 'ACC123', 42 | bankAccountDetails: '123456789', 43 | taxNumber: 'TAX123', 44 | accountsReceivableTaxType: 'OUTPUT', 45 | accountsPayableTaxType: 'INPUT', 46 | contactStatus: ContactStatus::Archived->value, 47 | isSupplier: true, 48 | isCustomer: true, 49 | defaultCurrency: 'USD', 50 | website: 'https://example.com', 51 | purchasesDefaultAccountCode: 'P123', 52 | salesDefaultAccountCode: 'S123', 53 | addresses: [['AddressType' => 'POBOX']], 54 | phones: [['PhoneType' => 'MOBILE']], 55 | contactPersons: [['FirstName' => 'Jane']], 56 | hasAttachments: true, 57 | hasValidationErrors: true 58 | ); 59 | 60 | expect($contactDTO)->toBeInstanceOf(ContactDTO::class) 61 | ->and($contactDTO->name)->toBe('Test Company') 62 | ->and($contactDTO->firstName)->toBe('John') 63 | ->and($contactDTO->lastName)->toBe('Doe') 64 | ->and($contactDTO->emailAddress)->toBe('john.doe@example.com') 65 | ->and($contactDTO->accountNumber)->toBe('ACC123') 66 | ->and($contactDTO->bankAccountDetails)->toBe('123456789') 67 | ->and($contactDTO->taxNumber)->toBe('TAX123') 68 | ->and($contactDTO->accountsReceivableTaxType)->toBe('OUTPUT') 69 | ->and($contactDTO->accountsPayableTaxType)->toBe('INPUT') 70 | ->and($contactDTO->contactStatus)->toBe(ContactStatus::Archived->value) 71 | ->and($contactDTO->isSupplier)->toBeTrue() 72 | ->and($contactDTO->isCustomer)->toBeTrue() 73 | ->and($contactDTO->defaultCurrency)->toBe('USD') 74 | ->and($contactDTO->website)->toBe('https://example.com') 75 | ->and($contactDTO->purchasesDefaultAccountCode)->toBe('P123') 76 | ->and($contactDTO->salesDefaultAccountCode)->toBe('S123') 77 | ->and($contactDTO->addresses)->toBe([['AddressType' => 'POBOX']]) 78 | ->and($contactDTO->phones)->toBe([['PhoneType' => 'MOBILE']]) 79 | ->and($contactDTO->contactPersons)->toBe([['FirstName' => 'Jane']]) 80 | ->and($contactDTO->hasAttachments)->toBeTrue() 81 | ->and($contactDTO->hasValidationErrors)->toBeTrue(); 82 | }); 83 | 84 | test('createAddress static method returns correct array', function () { 85 | $address = ContactDTO::createAddress( 86 | addressType: 'POBOX', 87 | addressLine1: '123 Main St', 88 | addressLine2: 'Suite 100', 89 | addressLine3: 'Building A', 90 | addressLine4: 'Floor 2', 91 | city: 'Anytown', 92 | region: 'State', 93 | postalCode: '12345', 94 | country: 'USA', 95 | attentionTo: 'John Doe' 96 | ); 97 | 98 | expect($address)->toBe([ 99 | 'AddressType' => 'POBOX', 100 | 'AddressLine1' => '123 Main St', 101 | 'AddressLine2' => 'Suite 100', 102 | 'AddressLine3' => 'Building A', 103 | 'AddressLine4' => 'Floor 2', 104 | 'City' => 'Anytown', 105 | 'Region' => 'State', 106 | 'PostalCode' => '12345', 107 | 'Country' => 'USA', 108 | 'AttentionTo' => 'John Doe', 109 | ]); 110 | }); 111 | 112 | test('createPhone static method returns correct array', function () { 113 | $phone = ContactDTO::createPhone( 114 | phoneType: 'MOBILE', 115 | phoneNumber: '555-1234', 116 | phoneAreaCode: '123', 117 | phoneCountryCode: '1' 118 | ); 119 | 120 | expect($phone)->toBe([ 121 | 'PhoneType' => 'MOBILE', 122 | 'PhoneNumber' => '555-1234', 123 | 'PhoneAreaCode' => '123', 124 | 'PhoneCountryCode' => '1', 125 | ]); 126 | }); 127 | 128 | test('createContactPerson static method returns correct array', function () { 129 | $contactPerson = ContactDTO::createContactPerson( 130 | firstName: 'Jane', 131 | lastName: 'Smith', 132 | emailAddress: 'jane.smith@example.com', 133 | includeInEmails: true 134 | ); 135 | 136 | expect($contactPerson)->toBe([ 137 | 'FirstName' => 'Jane', 138 | 'LastName' => 'Smith', 139 | 'EmailAddress' => 'jane.smith@example.com', 140 | 'IncludeInEmails' => true, 141 | ]); 142 | }); 143 | 144 | test('toArray method returns correct array structure', function () { 145 | $contactDTO = new ContactDTO( 146 | name: 'Test Company', 147 | firstName: 'John', 148 | lastName: 'Doe', 149 | emailAddress: 'john.doe@example.com', 150 | isSupplier: true, 151 | addresses: [ 152 | ContactDTO::createAddress('POBOX', '123 Main St'), 153 | ], 154 | phones: [ 155 | ContactDTO::createPhone('MOBILE', '555-1234'), 156 | ], 157 | contactPersons: [ 158 | ContactDTO::createContactPerson('Jane', 'Smith'), 159 | ] 160 | ); 161 | 162 | $array = $contactDTO->toArray(); 163 | 164 | expect($array)->toBeArray() 165 | ->and($array)->toHaveKey('Name', 'Test Company') 166 | ->and($array)->toHaveKey('FirstName', 'John') 167 | ->and($array)->toHaveKey('LastName', 'Doe') 168 | ->and($array)->toHaveKey('EmailAddress', 'john.doe@example.com') 169 | ->and($array)->toHaveKey('ContactStatus', ContactStatus::Active->value) 170 | ->and($array)->toHaveKey('IsSupplier', true) 171 | ->and($array)->toHaveKey('IsCustomer', false) 172 | ->and($array)->toHaveKey('Addresses') 173 | ->and($array['Addresses'][0])->toHaveKey('AddressType', 'POBOX') 174 | ->and($array['Addresses'][0])->toHaveKey('AddressLine1', '123 Main St') 175 | ->and($array)->toHaveKey('Phones') 176 | ->and($array['Phones'][0])->toHaveKey('PhoneType', 'MOBILE') 177 | ->and($array['Phones'][0])->toHaveKey('PhoneNumber', '555-1234') 178 | ->and($array)->toHaveKey('ContactPersons') 179 | ->and($array['ContactPersons'][0])->toHaveKey('FirstName', 'Jane') 180 | ->and($array['ContactPersons'][0])->toHaveKey('LastName', 'Smith'); 181 | }); 182 | 183 | test('toArray method filters out null values', function () { 184 | $contactDTO = new ContactDTO( 185 | name: 'Test Company', 186 | emailAddress: null, 187 | website: null 188 | ); 189 | 190 | $array = $contactDTO->toArray(); 191 | 192 | expect($array)->toHaveKey('Name') 193 | ->and($array)->not->toHaveKey('EmailAddress') 194 | ->and($array)->not->toHaveKey('Website'); 195 | }); 196 | -------------------------------------------------------------------------------- /tests/DTOs/InvoiceDTOTest.php: -------------------------------------------------------------------------------- 1 | toBeInstanceOf(InvoiceDTO::class) 14 | ->and($invoiceDTO->invoiceID)->toBeNull() 15 | ->and($invoiceDTO->type)->toBe(InvoiceType::AccRec->value) 16 | ->and($invoiceDTO->invoiceNumber)->toBeNull() 17 | ->and($invoiceDTO->reference)->toBeNull() 18 | ->and($invoiceDTO->date)->toBeNull() 19 | ->and($invoiceDTO->dueDate)->toBeNull() 20 | ->and($invoiceDTO->status)->toBe(InvoiceStatus::Draft->value) 21 | ->and($invoiceDTO->lineAmountTypes)->toBe(InvoiceLineAmountType::Exclusive->value) 22 | ->and($invoiceDTO->currencyCode)->toBeNull() 23 | ->and($invoiceDTO->currencyRate)->toBeNull() 24 | ->and($invoiceDTO->subTotal)->toBeNull() 25 | ->and($invoiceDTO->totalTax)->toBeNull() 26 | ->and($invoiceDTO->total)->toBeNull() 27 | ->and($invoiceDTO->contactID)->toBeNull() 28 | ->and($invoiceDTO->contact)->toBeNull() 29 | ->and($invoiceDTO->lineItems)->toBe([]) 30 | ->and($invoiceDTO->payments)->toBe([]) 31 | ->and($invoiceDTO->creditNotes)->toBe([]) 32 | ->and($invoiceDTO->prepayments)->toBe([]) 33 | ->and($invoiceDTO->overpayments)->toBe([]) 34 | ->and($invoiceDTO->hasAttachments)->toBeFalse() 35 | ->and($invoiceDTO->isDiscounted)->toBeFalse() 36 | ->and($invoiceDTO->hasErrors)->toBeFalse(); 37 | }); 38 | 39 | test('invoice dto can be instantiated with custom values', function () { 40 | $invoiceDTO = new InvoiceDTO( 41 | invoiceID: 'INV-123', 42 | type: InvoiceType::AccPay->value, 43 | invoiceNumber: '123', 44 | reference: 'REF-123', 45 | date: '2023-01-01', 46 | dueDate: '2023-01-31', 47 | status: InvoiceStatus::Submitted->value, 48 | lineAmountTypes: InvoiceLineAmountType::Inclusive->value, 49 | currencyCode: 'USD', 50 | currencyRate: '1.0', 51 | subTotal: '100.00', 52 | totalTax: '10.00', 53 | total: '110.00', 54 | contactID: 'CONTACT-123', 55 | contact: [['ContactID' => 'CONTACT-123', 'Name' => 'Test Contact']], 56 | lineItems: [['Description' => 'Test Item']], 57 | payments: [['PaymentID' => 'PAYMENT-123']], 58 | creditNotes: [['CreditNoteID' => 'CREDIT-123']], 59 | prepayments: [['PrepaymentID' => 'PREPAY-123']], 60 | overpayments: [['OverpaymentID' => 'OVERPAY-123']], 61 | hasAttachments: true, 62 | isDiscounted: true, 63 | hasErrors: true 64 | ); 65 | 66 | expect($invoiceDTO)->toBeInstanceOf(InvoiceDTO::class) 67 | ->and($invoiceDTO->invoiceID)->toBe('INV-123') 68 | ->and($invoiceDTO->type)->toBe(InvoiceType::AccPay->value) 69 | ->and($invoiceDTO->invoiceNumber)->toBe('123') 70 | ->and($invoiceDTO->reference)->toBe('REF-123') 71 | ->and($invoiceDTO->date)->toBe('2023-01-01') 72 | ->and($invoiceDTO->dueDate)->toBe('2023-01-31') 73 | ->and($invoiceDTO->status)->toBe(InvoiceStatus::Submitted->value) 74 | ->and($invoiceDTO->lineAmountTypes)->toBe(InvoiceLineAmountType::Inclusive->value) 75 | ->and($invoiceDTO->currencyCode)->toBe('USD') 76 | ->and($invoiceDTO->currencyRate)->toBe('1.0') 77 | ->and($invoiceDTO->subTotal)->toBe('100.00') 78 | ->and($invoiceDTO->totalTax)->toBe('10.00') 79 | ->and($invoiceDTO->total)->toBe('110.00') 80 | ->and($invoiceDTO->contactID)->toBe('CONTACT-123') 81 | ->and($invoiceDTO->contact)->toBe([['ContactID' => 'CONTACT-123', 'Name' => 'Test Contact']]) 82 | ->and($invoiceDTO->lineItems)->toBe([['Description' => 'Test Item']]) 83 | ->and($invoiceDTO->payments)->toBe([['PaymentID' => 'PAYMENT-123']]) 84 | ->and($invoiceDTO->creditNotes)->toBe([['CreditNoteID' => 'CREDIT-123']]) 85 | ->and($invoiceDTO->prepayments)->toBe([['PrepaymentID' => 'PREPAY-123']]) 86 | ->and($invoiceDTO->overpayments)->toBe([['OverpaymentID' => 'OVERPAY-123']]) 87 | ->and($invoiceDTO->hasAttachments)->toBeTrue() 88 | ->and($invoiceDTO->isDiscounted)->toBeTrue() 89 | ->and($invoiceDTO->hasErrors)->toBeTrue(); 90 | }); 91 | 92 | test('createLineItem static method returns correct array with all values', function () { 93 | $lineItem = InvoiceDTO::createLineItem( 94 | description: 'Test Item', 95 | quantity: 2, 96 | unitAmount: 10.50, 97 | accountCode: 123, 98 | itemCode: 'ITEM-123', 99 | taxType: 'OUTPUT', 100 | taxAmount: '2.10', 101 | lineAmount: '21.00', 102 | discountRate: '10', 103 | tracking: [['Name' => 'Region', 'Option' => 'North']] 104 | ); 105 | 106 | expect($lineItem)->toBe([ 107 | 'Description' => 'Test Item', 108 | 'Quantity' => 2, 109 | 'UnitAmount' => 10.50, 110 | 'AccountCode' => 123, 111 | 'ItemCode' => 'ITEM-123', 112 | 'TaxType' => 'OUTPUT', 113 | 'TaxAmount' => '2.10', 114 | 'LineAmount' => '21.00', 115 | 'DiscountRate' => '10', 116 | 'Tracking' => [['Name' => 'Region', 'Option' => 'North']], 117 | ]); 118 | }); 119 | 120 | test('createLineItem static method filters out null values', function () { 121 | $lineItem = InvoiceDTO::createLineItem( 122 | description: 'Test Item', 123 | quantity: 2, 124 | unitAmount: 10.50, 125 | accountCode: null, 126 | itemCode: null 127 | ); 128 | 129 | expect($lineItem)->toBe([ 130 | 'Description' => 'Test Item', 131 | 'Quantity' => 2, 132 | 'UnitAmount' => 10.50, 133 | ]); 134 | }); 135 | 136 | test('createLineItem accepts string values for numeric fields', function () { 137 | $lineItem = InvoiceDTO::createLineItem( 138 | description: 'Test Item', 139 | quantity: '2', 140 | unitAmount: '10.50' 141 | ); 142 | 143 | expect($lineItem)->toBe([ 144 | 'Description' => 'Test Item', 145 | 'Quantity' => '2', 146 | 'UnitAmount' => '10.50', 147 | ]); 148 | }); 149 | 150 | test('toArray method returns correct array structure', function () { 151 | $invoiceDTO = new InvoiceDTO( 152 | type: InvoiceType::AccRec->value, 153 | invoiceNumber: '123', 154 | date: '2023-01-01', 155 | dueDate: '2023-01-31', 156 | status: InvoiceStatus::Draft->value, 157 | contactID: 'CONTACT-123', 158 | lineItems: [ 159 | InvoiceDTO::createLineItem( 160 | description: 'Test Item', 161 | quantity: 2, 162 | unitAmount: 10.50 163 | ), 164 | ] 165 | ); 166 | 167 | $array = $invoiceDTO->toArray(); 168 | 169 | expect($array)->toBeArray() 170 | ->and($array)->toHaveKey('Type', InvoiceType::AccRec->value) 171 | ->and($array)->toHaveKey('InvoiceNumber', '123') 172 | ->and($array)->toHaveKey('Date', '2023-01-01') 173 | ->and($array)->toHaveKey('DueDate', '2023-01-31') 174 | ->and($array)->toHaveKey('Status', InvoiceStatus::Draft->value) 175 | ->and($array)->toHaveKey('LineAmountTypes', InvoiceLineAmountType::Exclusive->value) 176 | ->and($array)->toHaveKey('Contact') 177 | ->and($array['Contact'])->toBe(['ContactID' => 'CONTACT-123']) 178 | ->and($array)->toHaveKey('LineItems') 179 | ->and($array['LineItems'][0])->toHaveKey('Description', 'Test Item') 180 | ->and($array['LineItems'][0])->toHaveKey('Quantity', 2) 181 | ->and($array['LineItems'][0])->toHaveKey('UnitAmount', 10.50); 182 | }); 183 | 184 | test('toArray method uses contact array when contactID is null', function () { 185 | $invoiceDTO = new InvoiceDTO( 186 | contact: [ 187 | 'ContactID' => 'CONTACT-123', 188 | 'Name' => 'Test Contact', 189 | ] 190 | ); 191 | 192 | $array = $invoiceDTO->toArray(); 193 | 194 | expect($array)->toHaveKey('Contact') 195 | ->and($array['Contact'])->toBe([ 196 | 'ContactID' => 'CONTACT-123', 197 | 'Name' => 'Test Contact', 198 | ]); 199 | }); 200 | 201 | test('toArray method filters out null and empty array values', function () { 202 | $invoiceDTO = new InvoiceDTO( 203 | invoiceNumber: '123', 204 | reference: null, 205 | lineItems: [] 206 | ); 207 | 208 | $array = $invoiceDTO->toArray(); 209 | 210 | expect($array)->toHaveKey('InvoiceNumber') 211 | ->and($array)->toHaveKey('Type') 212 | ->and($array)->toHaveKey('Status') 213 | ->and($array)->toHaveKey('LineAmountTypes') 214 | ->and($array)->not->toHaveKey('Reference') 215 | ->and($array)->not->toHaveKey('LineItems'); 216 | }); 217 | -------------------------------------------------------------------------------- /tests/Enums/ContactStatusTest.php: -------------------------------------------------------------------------------- 1 | create(); 10 | 11 | expect($token)->toBeInstanceOf(XeroToken::class) 12 | ->and($token->tenant_id)->not->toBeEmpty() 13 | ->and($token->tenant_name)->not->toBeEmpty() 14 | ->and($token->access_token)->not->toBeEmpty() 15 | ->and($token->refresh_token)->not->toBeEmpty() 16 | ->and($token->expires_in)->toBeInt(); 17 | }); 18 | 19 | test('xero token can be created with mass assignment', function () { 20 | $data = [ 21 | 'tenant_id' => 'test_tenant_id', 22 | 'tenant_name' => 'Test Tenant', 23 | 'access_token' => 'test_access_token', 24 | 'refresh_token' => 'test_refresh_token', 25 | 'expires_in' => 3600, 26 | 'scopes' => 'test_scope', 27 | ]; 28 | 29 | $token = XeroToken::create($data); 30 | 31 | expect($token)->toBeInstanceOf(XeroToken::class) 32 | ->and($token->tenant_id)->toBe('test_tenant_id') 33 | ->and($token->tenant_name)->toBe('Test Tenant') 34 | ->and($token->access_token)->toBe('test_access_token') 35 | ->and($token->refresh_token)->toBe('test_refresh_token') 36 | ->and($token->expires_in)->toBe(3600) 37 | ->and($token->scopes)->toBe('test_scope'); 38 | }); 39 | 40 | test('expires attribute returns correct expiration time', function () { 41 | // Freeze time for predictable testing 42 | Carbon::setTestNow('2023-01-01 12:00:00'); 43 | 44 | $token = XeroToken::create([ 45 | 'tenant_id' => 'test_tenant_id', 46 | 'access_token' => 'test_access_token', 47 | 'refresh_token' => 'test_refresh_token', 48 | 'expires_in' => 3600, // 1 hour 49 | 'scopes' => 'test_scope', 50 | ]); 51 | 52 | // The expires attribute should be 1 hour after the updated_at timestamp 53 | expect($token->expires->format('Y-m-d H:i:s'))->toBe('2023-01-01 13:00:00'); 54 | 55 | // Clean up 56 | Carbon::setTestNow(); 57 | }); 58 | 59 | test('expires_in is cast to integer', function () { 60 | $token = XeroToken::create([ 61 | 'tenant_id' => 'test_tenant_id', 62 | 'access_token' => 'test_access_token', 63 | 'refresh_token' => 'test_refresh_token', 64 | 'expires_in' => '3600', // String value 65 | 'scopes' => 'test_scope', 66 | ]); 67 | 68 | expect($token->expires_in)->toBeInt() 69 | ->and($token->expires_in)->toBe(3600); 70 | }); 71 | -------------------------------------------------------------------------------- /tests/Pest.php: -------------------------------------------------------------------------------- 1 | in(__DIR__); 8 | -------------------------------------------------------------------------------- /tests/Resources/ContactsTest.php: -------------------------------------------------------------------------------- 1 | filter('bogus', 1) 11 | ->get(); 12 | })->throws(InvalidArgumentException::class, "Filter option 'bogus' is not valid."); 13 | 14 | test('filter returns object', function () { 15 | 16 | $filter = (new Contacts)->filter('ids', '1234'); 17 | 18 | expect($filter)->toBeObject(); 19 | }); 20 | -------------------------------------------------------------------------------- /tests/Resources/CreditNotesTest.php: -------------------------------------------------------------------------------- 1 | filter('bogus', 1) 11 | ->get(); 12 | })->throws(InvalidArgumentException::class, "Filter option 'bogus' is not valid."); 13 | 14 | test('filter returns object', function () { 15 | 16 | $filter = (new CreditNotes)->filter('ids', '1234'); 17 | 18 | expect($filter)->toBeObject(); 19 | }); 20 | -------------------------------------------------------------------------------- /tests/Resources/InvoicesTest.php: -------------------------------------------------------------------------------- 1 | filter('bogus', 1) 11 | ->get(); 12 | })->throws(InvalidArgumentException::class, "Filter option 'bogus' is not valid."); 13 | 14 | test('filter returns object', function () { 15 | 16 | $filter = (new Invoices)->filter('ids', '1234'); 17 | 18 | expect($filter)->toBeObject(); 19 | }); 20 | -------------------------------------------------------------------------------- /tests/Resources/WebhooksTest.php: -------------------------------------------------------------------------------- 1 | webhooks = new Webhooks(); 10 | }); 11 | 12 | // Helper function to mock file_get_contents 13 | function mockFileGetContents($payload) 14 | { 15 | // Create a stream wrapper to mock php://input 16 | stream_wrapper_unregister('php'); 17 | stream_wrapper_register('php', MockPhpStream::class); 18 | file_put_contents('php://input', $payload); 19 | } 20 | 21 | // Mock stream wrapper class 22 | class MockPhpStream 23 | { 24 | protected static $data = ''; 25 | 26 | public function stream_open($path, $mode, $options, &$opened_path) 27 | { 28 | return true; 29 | } 30 | 31 | public function stream_read($count) 32 | { 33 | $ret = mb_substr(self::$data, 0, $count); 34 | self::$data = mb_substr(self::$data, $count); 35 | 36 | return $ret; 37 | } 38 | 39 | public function stream_write($data) 40 | { 41 | self::$data = $data; 42 | 43 | return mb_strlen($data); 44 | } 45 | 46 | public function stream_tell() 47 | { 48 | return 0; 49 | } 50 | 51 | public function stream_eof() 52 | { 53 | return true; 54 | } 55 | 56 | public function stream_stat() 57 | { 58 | return []; 59 | } 60 | } 61 | 62 | afterEach(function () { 63 | // Restore the original stream wrapper 64 | if (in_array('php', stream_get_wrappers())) { 65 | stream_wrapper_unregister('php'); 66 | stream_wrapper_restore('php'); 67 | } 68 | }); 69 | 70 | test('getSignature returns correct signature', function () { 71 | // Mock the payload 72 | $payload = json_encode(['events' => [['eventType' => 'test']]]); 73 | 74 | // Use reflection to set protected property 75 | $reflection = new ReflectionClass($this->webhooks); 76 | $property = $reflection->getProperty('payload'); 77 | $property->setAccessible(true); 78 | $property->setValue($this->webhooks, $payload); 79 | 80 | // Set webhook key in config 81 | Config::set('xero.webhookKey', 'test-webhook-key'); 82 | 83 | // Calculate expected signature 84 | $expectedSignature = base64_encode(hash_hmac('sha256', $payload, 'test-webhook-key', true)); 85 | 86 | // Test the method 87 | expect($this->webhooks->getSignature())->toBe($expectedSignature); 88 | }); 89 | 90 | test('validate returns true when signatures match', function () { 91 | // Mock the payload and server variables 92 | $payload = json_encode(['events' => [['eventType' => 'test']]]); 93 | 94 | // Mock file_get_contents to return our payload 95 | mockFileGetContents($payload); 96 | 97 | // Set webhook key in config 98 | Config::set('xero.webhookKey', 'test-webhook-key'); 99 | 100 | // Calculate signature 101 | $signature = base64_encode(hash_hmac('sha256', $payload, 'test-webhook-key', true)); 102 | 103 | // Mock $_SERVER 104 | $_SERVER['HTTP_X_XERO_SIGNATURE'] = $signature; 105 | 106 | // Test the method 107 | expect($this->webhooks->validate())->toBeTrue(); 108 | }); 109 | 110 | test('validate returns false when signatures do not match', function () { 111 | // Mock the payload and server variables 112 | $payload = json_encode(['events' => [['eventType' => 'test']]]); 113 | 114 | // Mock file_get_contents to return our payload 115 | mockFileGetContents($payload); 116 | 117 | // Set webhook key in config 118 | Config::set('xero.webhookKey', 'test-webhook-key'); 119 | 120 | // Set incorrect signature 121 | $_SERVER['HTTP_X_XERO_SIGNATURE'] = 'incorrect-signature'; 122 | 123 | // Test the method 124 | expect($this->webhooks->validate())->toBeFalse(); 125 | }); 126 | 127 | test('getEvents returns events from payload', function () { 128 | // Create a mock of Webhooks with validate method always returning true 129 | $webhooks = Mockery::mock(Webhooks::class)->makePartial(); 130 | $webhooks->shouldReceive('validate')->andReturn(true); 131 | 132 | // Mock payload with events 133 | $events = [ 134 | (object) ['eventType' => 'test1', 'resourceId' => '1'], 135 | (object) ['eventType' => 'test2', 'resourceId' => '2'], 136 | ]; 137 | $payload = json_encode(['events' => $events]); 138 | 139 | // Use reflection to set protected property 140 | $reflection = new ReflectionClass($webhooks); 141 | $property = $reflection->getProperty('payload'); 142 | $property->setAccessible(true); 143 | $property->setValue($webhooks, $payload); 144 | 145 | // Test the method 146 | $result = $webhooks->getEvents(); 147 | 148 | // Verify each event has the expected properties 149 | expect($result[0]->eventType)->toBe('test1'); 150 | expect($result[0]->resourceId)->toBe('1'); 151 | expect($result[1]->eventType)->toBe('test2'); 152 | expect($result[1]->resourceId)->toBe('2'); 153 | }); 154 | -------------------------------------------------------------------------------- /tests/TestCase.php: -------------------------------------------------------------------------------- 1 | set('database.default', 'mysql'); 30 | $app['config']->set('database.connections.mysql', [ 31 | 'driver' => 'sqlite', 32 | 'database' => ':memory:', 33 | 'prefix' => '', 34 | ]); 35 | 36 | require_once 'src/database/migrations/create_xero_tokens_table.php'; 37 | 38 | // run the up() method of that migration class 39 | (new CreateXeroTokensTable)->up(); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /tests/XeroAuthenticatedTest.php: -------------------------------------------------------------------------------- 1 | once()->andReturn(false); 13 | Xero::shouldReceive('connect')->once()->andReturn(new RedirectResponse('xero-connect-url')); 14 | 15 | // Create middleware instance 16 | $middleware = new XeroAuthenticated(); 17 | 18 | // Create a request 19 | $request = Request::create('/test', 'GET'); 20 | 21 | // Execute middleware 22 | $response = $middleware->handle($request, function () { 23 | return 'next middleware'; 24 | }); 25 | 26 | // Assert that we get a redirect response 27 | expect($response)->toBeInstanceOf(RedirectResponse::class) 28 | ->and($response->getTargetUrl())->toBe('xero-connect-url'); 29 | }); 30 | 31 | test('middleware continues when connected', function () { 32 | // Mock the Xero facade 33 | Xero::shouldReceive('isConnected')->once()->andReturn(true); 34 | 35 | // Create middleware instance 36 | $middleware = new XeroAuthenticated(); 37 | 38 | // Create a request 39 | $request = Request::create('/test', 'GET'); 40 | 41 | // Execute middleware 42 | $response = $middleware->handle($request, function () { 43 | return 'next middleware'; 44 | }); 45 | 46 | // Assert that we continue to the next middleware 47 | expect($response)->toBe('next middleware'); 48 | }); 49 | -------------------------------------------------------------------------------- /tests/XeroTest.php: -------------------------------------------------------------------------------- 1 | XeroMock = Mockery::mock(Xero::class); 15 | }); 16 | 17 | test('can initialise', function () { 18 | $this->assertInstanceOf(Xero::class, $this->XeroMock); 19 | }); 20 | 21 | test('redirected when connect is called', function () { 22 | $connect = XeroFacade::connect(); 23 | 24 | $this->assertInstanceOf(RedirectResponse::class, $connect); 25 | }); 26 | 27 | test('is connected returns false when no data in db', function () { 28 | $connect = XeroFacade::isConnected(); 29 | 30 | expect($connect)->toBeFalse(); 31 | }); 32 | 33 | test('is connected returns true when data exists in db', function () { 34 | 35 | XeroToken::create([ 36 | 'id' => 0, 37 | 'access_token' => '1234', 38 | 'expires_in' => strtotime('+1 day'), 39 | 'scopes' => 'contacts', 40 | ]); 41 | 42 | $connect = XeroFacade::isConnected(); 43 | 44 | expect($connect)->toBeTrue(); 45 | }); 46 | 47 | test('disconnect returns true when data exists in db', function () { 48 | 49 | Http::fake(); 50 | 51 | XeroToken::create([ 52 | 'id' => 0, 53 | 'access_token' => '1234', 54 | 'expires_in' => strtotime('+1 day'), 55 | 'scopes' => 'contacts', 56 | ]); 57 | 58 | XeroFacade::disconnect(); 59 | 60 | $this->assertDatabaseCount('xero_tokens', 0); 61 | }); 62 | 63 | test('getTokenData returns XeroToken data', function () { 64 | 65 | XeroToken::create([ 66 | 'id' => 0, 67 | 'access_token' => '1234', 68 | 'expires_in' => strtotime('+1 day'), 69 | 'scopes' => 'contacts', 70 | ]); 71 | 72 | $data = XeroFacade::getTokenData(); 73 | 74 | expect($data)->toBeObject(); 75 | }); 76 | 77 | test('getTokenData when no tokens exist returns null', function () { 78 | expect(XeroFacade::getTokenData())->toBeNull; 79 | }); 80 | 81 | test('getTenantId returns id', function () { 82 | XeroToken::create([ 83 | 'id' => 0, 84 | 'access_token' => '1234', 85 | 'expires_in' => strtotime('+1 day'), 86 | 'scopes' => 'contacts', 87 | 'tenant_id' => '1234', 88 | ]); 89 | 90 | expect(XeroFacade::getTenantId())->toBe('1234'); 91 | }); 92 | 93 | test('getTenantName returns name', function () { 94 | XeroToken::create([ 95 | 'id' => 0, 96 | 'access_token' => '1234', 97 | 'expires_in' => strtotime('+1 day'), 98 | 'scopes' => 'contacts', 99 | 'tenant_id' => '1234', 100 | 'tenant_name' => 'Jones', 101 | ]); 102 | 103 | expect(XeroFacade::getTenantName())->toBe('Jones'); 104 | }); 105 | 106 | test('can return getAccessToken when it has not expired ', function () { 107 | 108 | Http::fake(); 109 | 110 | XeroToken::create([ 111 | 'id' => 0, 112 | 'access_token' => '1234', 113 | 'expires_in' => now()->addMinutes(25), 114 | 'updated_at' => strtotime('+1 day'), 115 | 'scopes' => 'contacts', 116 | ]); 117 | 118 | $data = XeroFacade::getAccessToken(); 119 | 120 | expect($data)->toBe('1234'); 121 | }); 122 | 123 | test('can get tokens when not-encrypted but encryption is enabled', function () { 124 | 125 | Config::set('xero.encrypt', true); 126 | 127 | XeroToken::create([ 128 | 'id' => 0, 129 | 'access_token' => '1234', 130 | 'expires_in' => strtotime('+1 day'), 131 | 'scopes' => 'contacts', 132 | ]); 133 | 134 | $data = XeroFacade::getAccessToken(); 135 | 136 | expect($data)->toBe('1234'); 137 | }); 138 | 139 | test('can get tokens when encrypted', function () { 140 | 141 | Config::set('xero.encrypt', true); 142 | 143 | XeroToken::create([ 144 | 'id' => 0, 145 | 'access_token' => Crypt::encryptString('1234'), 146 | 'expires_in' => strtotime('+1 day'), 147 | 'scopes' => 'contacts', 148 | ]); 149 | 150 | $data = XeroFacade::getAccessToken(); 151 | 152 | expect($data)->toBe('1234'); 153 | }); 154 | 155 | test('formats Microsoft JSON date with timezone offset', function () { 156 | $input = '/Date(1663257600000+0100)/'; 157 | $formatted = Xero::formatDate($input); 158 | 159 | expect($formatted)->toBe('2022-09-15 16:00:00'); 160 | }); 161 | 162 | test('formats standard ISO 8601 date string', function () { 163 | $input = '2023-05-19T14:00:00+00:00'; 164 | $formatted = Xero::formatDate($input); 165 | 166 | expect($formatted)->toBe('2023-05-19 14:00:00'); 167 | }); 168 | 169 | test('returns empty string for invalid date input', function () { 170 | $input = 'invalid-date-format'; 171 | $formatted = Xero::formatDate($input); 172 | 173 | expect($formatted)->toBe(''); 174 | }); 175 | 176 | test('formats Microsoft JSON date with UTC offset', function () { 177 | $input = '/Date(1663257600000+0000)/'; 178 | $formatted = Xero::formatDate($input); 179 | 180 | expect($formatted)->toBe('2022-09-15 16:00:00'); 181 | }); 182 | --------------------------------------------------------------------------------