├── .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 | [](https://packagist.org/packages/dcblogdev/laravel-xero)
2 | [](https://packagist.org/packages/dcblogdev/laravel-xero)
3 |
4 | 
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 |
--------------------------------------------------------------------------------