├── .github └── workflows │ └── phpunit.yml ├── .gitignore ├── .styleci.yml ├── LICENSE ├── README.md ├── composer.json ├── example ├── example.php └── storage.json ├── phpunit.xml.dist ├── phpunit.xml.dist.bak ├── src └── Picqer │ └── Financials │ └── Moneybird │ ├── Actions │ ├── Attachment.php │ ├── BaseTrait.php │ ├── Downloadable.php │ ├── Filterable.php │ ├── FindAll.php │ ├── FindOne.php │ ├── Noteable.php │ ├── Removable.php │ ├── Search.php │ ├── Storable.php │ └── Synchronizable.php │ ├── Connection.php │ ├── Entities │ ├── Administration.php │ ├── Contact.php │ ├── ContactCustomField.php │ ├── ContactPeople.php │ ├── CustomField.php │ ├── DocumentStyle.php │ ├── Estimate.php │ ├── EstimateAttachment.php │ ├── EstimateEvent.php │ ├── EstimateTaxTotal.php │ ├── ExternalSalesInvoice.php │ ├── ExternalSalesInvoiceAttachment.php │ ├── ExternalSalesInvoiceDetail.php │ ├── ExternalSalesInvoicePayment.php │ ├── FinancialAccount.php │ ├── FinancialMutation.php │ ├── FinancialStatement.php │ ├── GeneralDocument.php │ ├── GeneralDocumentAttachment.php │ ├── GeneralJournalDocument.php │ ├── GeneralJournalDocumentAttachment.php │ ├── GeneralJournalDocumentEntry.php │ ├── Generic │ │ ├── Attachment.php │ │ ├── CustomField.php │ │ ├── Event.php │ │ ├── InvoiceDetail.php │ │ └── InvoicePayment.php │ ├── Identity.php │ ├── ImportMapping.php │ ├── LedgerAccount.php │ ├── LedgerAccountBooking.php │ ├── Note.php │ ├── Product.php │ ├── Project.php │ ├── PurchaseInvoice.php │ ├── PurchaseInvoiceAttachment.php │ ├── PurchaseInvoiceDetail.php │ ├── PurchaseInvoicePayment.php │ ├── Receipt.php │ ├── ReceiptAttachment.php │ ├── ReceiptDetail.php │ ├── ReceiptPayment.php │ ├── RecurringSalesInvoice.php │ ├── RecurringSalesInvoiceCustomField.php │ ├── RecurringSalesInvoiceDetail.php │ ├── SalesInvoice.php │ ├── SalesInvoice │ │ └── SendInvoiceOptions.php │ ├── SalesInvoiceAttachment.php │ ├── SalesInvoiceCustomField.php │ ├── SalesInvoiceDetail.php │ ├── SalesInvoiceEvent.php │ ├── SalesInvoicePayment.php │ ├── SalesInvoiceReminder.php │ ├── SalesInvoiceTaxTotal.php │ ├── Subscription.php │ ├── TaxRate.php │ ├── TimeEntry.php │ ├── TypelessDocument.php │ ├── TypelessDocumentAttachment.php │ ├── User.php │ ├── Webhook.php │ └── Workflow.php │ ├── Exceptions │ ├── Api │ │ └── TooManyRequestsException.php │ └── ApiException.php │ ├── Model.php │ └── Moneybird.php └── tests ├── ApiTest.php ├── ConnectionTest.php ├── EntityTest.php └── PicqerTest └── Financials └── Moneybird ├── Entities ├── SalesInvoice │ └── SendInvoiceOptionsTest.php └── SalesInvoiceTest.php └── ModelTest.php /.github/workflows/phpunit.yml: -------------------------------------------------------------------------------- 1 | name: Run phpunit 2 | on: [ push ] 3 | 4 | jobs: 5 | test: 6 | strategy: 7 | matrix: 8 | php-versions: [ '8.2', '8.3', '8.4' ] 9 | name: PHP ${{ matrix.php-versions }} 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Checkout code 13 | uses: actions/checkout@v4 14 | - name: Setup PHP 15 | uses: shivammathur/setup-php@v2 16 | with: 17 | php-version: ${{ matrix.php-versions }} 18 | - name: PHP version check 19 | run: php -v 20 | - name: Composer install 21 | run: composer install --prefer-dist --no-interaction --no-progress --no-suggest 22 | - name: Run tests 23 | run: vendor/bin/phpunit 24 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | composer.phar 2 | composer.lock 3 | vendor/ 4 | phpunit.xml 5 | .idea 6 | .phpunit.result.cache 7 | -------------------------------------------------------------------------------- /.styleci.yml: -------------------------------------------------------------------------------- 1 | preset: laravel 2 | risky: false 3 | enabled: 4 | - concat_with_spaces 5 | disabled: 6 | - concat_without_spaces 7 | finder: 8 | name: "*.php" 9 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Picqer 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Moneybird PHP Client 2 | 3 | ![Run phpunit](https://github.com/picqer/moneybird-php-client/workflows/Run%20phpunit/badge.svg) 4 | 5 | PHP Client for the [Moneybird API](https://developer.moneybird.com/). This client lets you integrate with Moneybird, for example by: 6 | - Creating and sending invoices 7 | - Creating and updating contact 8 | - Uploading incoming invoices of purchases 9 | - Create manual journal bookings 10 | 11 | This library is created and maintained by [Picqer](https://picqer.com). We are [looking for PHP developers to join our team](https://picqer.com/nl/vacatures/senior-php-developer)! 12 | 13 | ## Installation 14 | This project can easily be installed through Composer. 15 | 16 | ``` 17 | composer require picqer/moneybird-php-client 18 | ``` 19 | 20 | ## Usage 21 | You need to have to following credentials and information ready. You can get this from your Moneybird account. 22 | - Client ID 23 | - Client Secret 24 | - Callback URL 25 | 26 | You need to be able to store some data locally: 27 | - The three credentials mentioned above 28 | - Authorizationcode 29 | - Accesstoken 30 | 31 | ### Authorization code 32 | If you have no authorization code yet, you will need this first. The client supports fetching the authorization code as follows. 33 | 34 | ```php 35 | setRedirectUrl('REDIRECTURL'); 41 | $connection->setClientId('CLIENTID'); 42 | $connection->setClientSecret('CLIENTSECRET'); 43 | $connection->redirectForAuthorization(); 44 | ``` 45 | 46 | This will perform a redirect to Moneybird at which you can login and authorize the app for a specific Moneybird administration. 47 | After login, Moneybird will redirect you to the callback URL with request param "code" which you should save as the authorization code. 48 | 49 | ### Setting the administration ID 50 | 51 | Most methods require you to set the Administration ID to fetch the correct data. You can get the Administration ID from the URL at MoneyBird, but you can also list the administrations your user has access to running the following method after connecting. In the code samples below there's an example on how to set the first administrations from the results of the call below: 52 | 53 | ```php 54 | $administrations = $moneybird->administration()->getAll(); 55 | ``` 56 | 57 | ### Normal actions 58 | After you have the authorization code as described above, you can perform normal requests. The client will take care of the accesstoken automatically. 59 | 60 | ```php 61 | setRedirectUrl('REDIRECTURL'); 67 | $connection->setClientId('CLIENTID'); 68 | $connection->setClientSecret('CLIENTSECRET'); 69 | 70 | // Get authorization code as described in readme (always set this when available) 71 | $connection->setAuthorizationCode('AUTHORIZATIONCODE'); 72 | 73 | // Set this in case you got the access token, otherwise client will fetch it (always set this when available) 74 | $connection->setAccessToken('ACCESSTOKEN'); 75 | 76 | try { 77 | $connection->connect(); 78 | } catch (\Exception $e) { 79 | throw new Exception('Could not connect to Moneybird: ' . $e->getMessage()); 80 | } 81 | 82 | // After connection save the last access token for reuse 83 | $connection->getAccessToken(); // will return the access token you need to save 84 | 85 | // Set up a new Moneybird instance and inject the connection 86 | $moneybird = new \Picqer\Financials\Moneybird\Moneybird($connection); 87 | 88 | // Example: Get administrations and set the first result as active administration 89 | $administrations = $moneybird->administration()->getAll(); 90 | $connection->setAdministrationId($administrations[0]->id); 91 | 92 | // Example: Fetch list of salesinvoices 93 | $salesInvoices = $moneybird->salesInvoice()->get(); 94 | var_dump($salesInvoices); // Array with SalesInvoice objects 95 | 96 | // Example: Fetch a sales invoice 97 | $salesInvoice = $moneybird->salesInvoice()->find(3498576378625); 98 | var_dump($salesInvoice); // SalesInvoice object 99 | 100 | // Example: Get sales invoice PDF contents 101 | $pdfContents = $salesInvoice->download(); 102 | 103 | // Example: Create credit invoice based on existing invoice 104 | $creditInvoice = $salesInvoice->duplicateToCreditInvoice(); 105 | var_dump($creditInvoice); // SalesInvoice object 106 | 107 | // Example: Create a new contact 108 | $contact = $moneybird->contact(); 109 | 110 | $contact->company_name = 'Picqer'; 111 | $contact->firstname = 'Stephan'; 112 | $contact->lastname = 'Groen'; 113 | $contact->save(); 114 | var_dump($contact); // Contact object (as saved in Moneybird) 115 | 116 | // Example: Update existing contact, change email address 117 | $contact = $moneybird->contact()->find(89672345789233); 118 | $contact->email = 'example@example.org'; 119 | $contact->save(); 120 | var_dump($contact); // Contact object (as saved in Moneybird) 121 | 122 | // Example: Use the Moneybird synchronisation API 123 | $contactVersions = $moneybird->contact()->listVersions(); 124 | var_dump($contactVersions); // Array with ids and versions to compare to your own 125 | 126 | // Example: Use the Moneybird synchronisation API to get new versions of specific ids 127 | $contacts = $moneybird->contact()->getVersions([ 128 | 2389475623478568, 129 | 2384563478959922 130 | ]); 131 | var_dump($contacts); // Array with two Contact objects 132 | 133 | // Example: List sales invoices that are in draft (max 100) 134 | $salesInvoices = $moneybird->salesInvoice()->filter([ 135 | 'state' => 'draft' 136 | ]); 137 | var_dump($salesInvoices); // Array with filtered SalesInvoice objects 138 | 139 | // Example: Get import mappings for contacts 140 | $mappings = $moneybird->importMapping()->setType('contact')->get(); 141 | var_dump($mappings); // Array with ImportMapping objects 142 | 143 | // Example: Register a payment for a sales invoice 144 | $salesInvoicePayment = $moneybird->salesInvoicePayment(); 145 | $salesInvoicePayment->price = 153.75; 146 | $salesInvoicePayment->payment_date = '2015-12-03'; 147 | 148 | $salesInvoice = $moneybird->salesInvoice()->find(3498576378625); 149 | $salesInvoice->registerPayment($salesInvoicePayment); 150 | 151 | // How to add SalesInvoiceDetails (invoice lines) to a SalesInvoice 152 | $salesInvoiceDetailsArray = []; 153 | 154 | foreach ($invoiceLines as $invoiceLine) { // Your invoice lines 155 | $salesInvoiceDetail = $moneybird->salesInvoiceDetail(); 156 | $salesInvoiceDetail->price = 34.33; 157 | ... 158 | 159 | $salesInvoiceDetailsArray[] = $salesInvoiceDetail; 160 | } 161 | 162 | $salesInvoice = $moneybird->salesInvoice(); 163 | $salesInvoice->details = $salesInvoiceDetailsArray; 164 | 165 | ``` 166 | 167 | ## Code example 168 | See for example: [example/example.php](example/example.php) 169 | 170 | ## TODO 171 | - Receiving webhooks support (would be nice) 172 | - Some linked/nested entities (notes, attachments etcetera) 173 | - Dedicated Exception for RateLimit reached and return of Retry-After value 174 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "picqer/moneybird-php-client", 3 | "type": "library", 4 | "description": "A PHP Client for the Moneybird V2 API", 5 | "keywords": [ 6 | "api", 7 | "php", 8 | "moneybird" 9 | ], 10 | "homepage": "https://github.com/picqer/moneybird-php-client", 11 | "license": "MIT", 12 | "authors": [ 13 | { 14 | "name": "Stephan Groen", 15 | "email": "info@picqer.com" 16 | } 17 | ], 18 | "require": { 19 | "php": ">=8.2.0", 20 | "guzzlehttp/guzzle": "^6.3.1|^7.0", 21 | "psr/http-client": "^1.0", 22 | "ext-json": "*" 23 | }, 24 | "require-dev": { 25 | "phpunit/phpunit": "^8.5|^9.3", 26 | "phpspec/prophecy-phpunit": "^2.0" 27 | }, 28 | "autoload": { 29 | "psr-4": { 30 | "Picqer\\Financials\\Moneybird\\": "src/Picqer/Financials/Moneybird" 31 | } 32 | }, 33 | "autoload-dev": { 34 | "psr-4": { 35 | "Picqer\\Tests\\": "tests/" 36 | } 37 | }, 38 | "scripts": { 39 | "test": "vendor/bin/phpunit" 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /example/example.php: -------------------------------------------------------------------------------- 1 | setRedirectUrl($redirectUrl); 55 | $connection->setClientId($clientId); 56 | $connection->setClientSecret($clientSecret); 57 | $connection->redirectForAuthorization(); 58 | } 59 | 60 | /** 61 | * Function to connect to Moneybird, this creates the client and automatically retrieves oAuth tokens if needed. 62 | * 63 | * @param string $redirectUrl 64 | * @param string $clientId 65 | * @param string $clientSecret 66 | * @return \Picqer\Financials\Moneybird\Connection 67 | * 68 | * @throws Exception 69 | */ 70 | function connect($redirectUrl, $clientId, $clientSecret) 71 | { 72 | $connection = new \Picqer\Financials\Moneybird\Connection(); 73 | $connection->setRedirectUrl($redirectUrl); 74 | $connection->setClientId($clientId); 75 | $connection->setClientSecret($clientSecret); 76 | 77 | // Retrieves authorizationcode from database 78 | if (getValue('authorizationcode')) { 79 | $connection->setAuthorizationCode(getValue('authorizationcode')); 80 | } 81 | 82 | // Retrieves accesstoken from database 83 | if (getValue('accesstoken')) { 84 | $connection->setAccessToken(getValue('accesstoken')); 85 | } 86 | 87 | // Make the client connect and exchange tokens 88 | try { 89 | $connection->connect(); 90 | } catch (\Exception $e) { 91 | throw new Exception('Could not connect to Moneybird: ' . $e->getMessage()); 92 | } 93 | 94 | // Save the new tokens for next connections 95 | setValue('accesstoken', $connection->getAccessToken()); 96 | 97 | return $connection; 98 | } 99 | 100 | // If authorization code is returned from Moneybird, save this to use for token request 101 | if (isset($_GET['code']) && null === getValue('authorizationcode')) { 102 | setValue('authorizationcode', $_GET['code']); 103 | } 104 | 105 | // If we do not have a authorization code, authorize first to setup tokens 106 | if (getValue('authorizationcode') === null) { 107 | authorize($redirectUrl, $clientId, $clientSecret); 108 | } 109 | 110 | // Create the Moneybird client 111 | $connection = connect($redirectUrl, $clientId, $clientSecret); 112 | $connection->setAdministrationId($administrationId); 113 | $moneybird = new \Picqer\Financials\Moneybird\Moneybird($connection); 114 | 115 | // Get the sales invoices from our administration 116 | try { 117 | $salesInvoices = $moneybird->salesInvoice()->get(); 118 | 119 | foreach ($salesInvoices as $salesInvoice) { 120 | var_dump($salesInvoice); 121 | } 122 | } catch (\Exception $e) { 123 | echo get_class($e) . ' : ' . $e->getMessage(); 124 | } 125 | -------------------------------------------------------------------------------- /example/storage.json: -------------------------------------------------------------------------------- 1 | [] -------------------------------------------------------------------------------- /phpunit.xml.dist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | src 6 | 7 | 8 | 9 | 10 | tests 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /phpunit.xml.dist.bak: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | tests 7 | 8 | 9 | 10 | 11 | src 12 | 13 | 14 | -------------------------------------------------------------------------------- /src/Picqer/Financials/Moneybird/Actions/Attachment.php: -------------------------------------------------------------------------------- 1 | id)) { 30 | throw new ApiException('This method can only be used on existing records.'); 31 | } 32 | 33 | $this->connection()->upload($this->getEndpoint() . '/' . urlencode($this->id) . '/' . $this->attachmentPath, [ 34 | 'multipart' => [ 35 | [ 36 | 'name' => 'file', 37 | 'contents' => $contents, 38 | 'filename' => $filename, 39 | ], 40 | ], 41 | ]); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/Picqer/Financials/Moneybird/Actions/BaseTrait.php: -------------------------------------------------------------------------------- 1 | connection()->download($this->getEndpoint() . '/' . urlencode($this->id) . '/download_pdf'); 22 | 23 | return $response->getBody()->getContents(); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/Picqer/Financials/Moneybird/Actions/Filterable.php: -------------------------------------------------------------------------------- 1 | $value) { 24 | $filterList[] = $key . ':' . $value; 25 | } 26 | 27 | $result = $this->connection()->get($this->getFilterEndpoint(), [ 28 | 'filter' => implode(',', $filterList), 29 | 'per_page' => $perPage, 30 | 'page' => $page, 31 | ], false); 32 | 33 | return $this->collectionFromResult($result); 34 | } 35 | 36 | /** 37 | * @param array $filters 38 | * @return mixed 39 | * 40 | * @throws \Picqer\Financials\Moneybird\Exceptions\ApiException 41 | */ 42 | public function filterAll(array $filters) 43 | { 44 | $filterList = []; 45 | foreach ($filters as $key => $value) { 46 | $filterList[] = $key . ':' . $value; 47 | } 48 | 49 | $result = $this->connection()->get($this->getFilterEndpoint(), ['filter' => implode(',', $filterList)], true); 50 | 51 | return $this->collectionFromResult($result); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/Picqer/Financials/Moneybird/Actions/FindAll.php: -------------------------------------------------------------------------------- 1 | connection()->get($this->getEndpoint(), $params); 21 | 22 | return $this->collectionFromResult($result); 23 | } 24 | 25 | /** 26 | * @param array $params 27 | * @return mixed 28 | * 29 | * @throws \Picqer\Financials\Moneybird\Exceptions\ApiException 30 | */ 31 | public function getAll($params = []) 32 | { 33 | $result = $this->connection()->get($this->getEndpoint(), $params, true); 34 | 35 | return $this->collectionFromResult($result); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/Picqer/Financials/Moneybird/Actions/FindOne.php: -------------------------------------------------------------------------------- 1 | connection()->get($this->getEndpoint() . '/' . urlencode($id)); 21 | 22 | return $this->makeFromResponse($result); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/Picqer/Financials/Moneybird/Actions/Noteable.php: -------------------------------------------------------------------------------- 1 | connection()->post($this->getEndpoint() . '/' . urlencode($this->id) . '/notes', 26 | $note->jsonWithNamespace() 27 | ); 28 | 29 | return $this; 30 | } 31 | 32 | /** 33 | * Delete a note from the current object. 34 | * 35 | * @param Note|string $note Note object or note ID 36 | * @return $this 37 | * 38 | * @throws ApiException 39 | */ 40 | public function deleteNote($note) 41 | { 42 | if (! is_string($note)) { 43 | $note = $note->id; 44 | } 45 | 46 | $this->connection()->delete($this->getEndpoint() . '/' . urlencode($this->id) . '/notes/' . $note); 47 | 48 | return $this; 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/Picqer/Financials/Moneybird/Actions/Removable.php: -------------------------------------------------------------------------------- 1 | connection()->delete( 20 | $this->getEndpoint() . '/' . urlencode($this->id), 21 | null, 22 | [$this->namespace => $params] 23 | ); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/Picqer/Financials/Moneybird/Actions/Search.php: -------------------------------------------------------------------------------- 1 | connection()->get($this->getEndpoint(), [ 16 | 'query' => $query, 17 | 'per_page' => $perPage, 18 | 'page' => $page, 19 | ], true); 20 | 21 | return $this->collectionFromResult($result); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/Picqer/Financials/Moneybird/Actions/Storable.php: -------------------------------------------------------------------------------- 1 | exists()) { 20 | return $this->update(); 21 | } else { 22 | return $this->insert(); 23 | } 24 | } 25 | 26 | /** 27 | * @return mixed 28 | * 29 | * @throws \Picqer\Financials\Moneybird\Exceptions\ApiException 30 | */ 31 | public function insert() 32 | { 33 | $result = $this->connection()->post($this->getEndpoint(), $this->jsonWithNamespace()); 34 | 35 | if (method_exists($this, 'clearDirty')) { 36 | $this->clearDirty(); 37 | } 38 | 39 | return $this->selfFromResponse($result); 40 | } 41 | 42 | /** 43 | * @return mixed 44 | * 45 | * @throws \Picqer\Financials\Moneybird\Exceptions\ApiException 46 | */ 47 | public function update() 48 | { 49 | $result = $this->connection()->patch($this->getEndpoint() . '/' . urlencode($this->id), $this->jsonWithNamespace()); 50 | 51 | if ($result === 200) { 52 | if (method_exists($this, 'clearDirty')) { 53 | $this->clearDirty(); 54 | } 55 | 56 | return true; 57 | } 58 | 59 | return $this->selfFromResponse($result); 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/Picqer/Financials/Moneybird/Actions/Synchronizable.php: -------------------------------------------------------------------------------- 1 | $value) { 25 | $filterList[] = $key . ':' . $value; 26 | } 27 | 28 | $filter = ['filter' => implode(',', $filterList)]; 29 | } 30 | 31 | $result = $this->connection()->get($this->getEndpoint() . '/synchronization', $filter); 32 | 33 | return $this->collectionFromResult($result); 34 | } 35 | 36 | /** 37 | * @param array $ids 38 | * @return mixed 39 | * 40 | * @throws \Picqer\Financials\Moneybird\Exceptions\ApiException 41 | */ 42 | public function getVersions(array $ids) 43 | { 44 | $result = $this->connection()->post($this->getEndpoint() . '/synchronization', json_encode([ 45 | 'ids' => $ids, 46 | ])); 47 | 48 | return $this->collectionFromResult($result); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/Picqer/Financials/Moneybird/Connection.php: -------------------------------------------------------------------------------- 1 | client) { 102 | return $this->client; 103 | } 104 | 105 | $handlerStack = HandlerStack::create(); 106 | foreach ($this->middleWares as $middleWare) { 107 | $handlerStack->push($middleWare); 108 | } 109 | 110 | $this->client = new Client([ 111 | 'http_errors' => true, 112 | 'handler' => $handlerStack, 113 | 'expect' => false, 114 | ]); 115 | 116 | return $this->client; 117 | } 118 | 119 | /** 120 | * Insert a custom Guzzle client. 121 | * 122 | * @param Client $client 123 | */ 124 | public function setClient($client) 125 | { 126 | $this->client = $client; 127 | } 128 | 129 | /** 130 | * Insert a Middleware for the Guzzle Client. 131 | * 132 | * @param $middleWare 133 | */ 134 | public function insertMiddleWare($middleWare) 135 | { 136 | $this->middleWares[] = $middleWare; 137 | } 138 | 139 | /** 140 | * @return Client 141 | * 142 | * @throws ApiException 143 | */ 144 | public function connect() 145 | { 146 | // If access token is not set or token has expired, acquire new token 147 | if (empty($this->accessToken)) { 148 | $this->acquireAccessToken(); 149 | } 150 | 151 | $client = $this->client(); 152 | 153 | return $client; 154 | } 155 | 156 | /** 157 | * @param string $method 158 | * @param string $endpoint 159 | * @param null $body 160 | * @param array $params 161 | * @param array $headers 162 | * @return \GuzzleHttp\Psr7\Request 163 | * 164 | * @throws \Picqer\Financials\Moneybird\Exceptions\ApiException 165 | */ 166 | private function createRequest($method = 'GET', $endpoint = '', $body = null, array $params = [], array $headers = []) 167 | { 168 | // Add default json headers to the request 169 | $headers = array_merge($headers, [ 170 | 'Accept' => 'application/json', 171 | 'Content-Type' => 'application/json', 172 | ]); 173 | 174 | // If access token is not set or token has expired, acquire new token 175 | if (empty($this->accessToken)) { 176 | $this->acquireAccessToken(); 177 | } 178 | 179 | // If we have a token, sign the request 180 | if (! empty($this->accessToken)) { 181 | $headers['Authorization'] = 'Bearer ' . $this->accessToken; 182 | } 183 | 184 | // Create param string 185 | if (! empty($params)) { 186 | $endpoint .= '?' . http_build_query($params); 187 | } 188 | 189 | // Create the request 190 | $request = new Request($method, $endpoint, $headers, $body); 191 | 192 | return $request; 193 | } 194 | 195 | /** 196 | * @param string $method 197 | * @param $endpoint 198 | * @param null $body 199 | * @param array $params 200 | * @param array $headers 201 | * @return \GuzzleHttp\Psr7\Request 202 | * 203 | * @throws \Picqer\Financials\Moneybird\Exceptions\ApiException 204 | */ 205 | private function createRequestNoJson($method = 'GET', $endpoint = '', $body = null, array $params = [], array $headers = []) 206 | { 207 | // If access token is not set or token has expired, acquire new token 208 | if (empty($this->accessToken)) { 209 | $this->acquireAccessToken(); 210 | } 211 | // If we have a token, sign the request 212 | if (! empty($this->accessToken)) { 213 | $headers['Authorization'] = 'Bearer ' . $this->accessToken; 214 | } 215 | // Create param string 216 | if (! empty($params)) { 217 | $endpoint .= '?' . http_build_query($params); 218 | } 219 | // Create the request 220 | $request = new Request($method, $endpoint, $headers, $body); 221 | 222 | return $request; 223 | } 224 | 225 | /** 226 | * @param string $url 227 | * @param array $params 228 | * @param bool $fetchAll 229 | * @return mixed 230 | * 231 | * @throws ApiException 232 | */ 233 | public function get($url, array $params = [], $fetchAll = false) 234 | { 235 | try { 236 | $request = $this->createRequest('GET', $this->formatUrl($url, 'get'), null, $params); 237 | $response = $this->client()->send($request); 238 | 239 | $json = $this->parseResponse($response); 240 | 241 | if ($fetchAll === true) { 242 | if ($nextParams = $this->getNextParams($response->getHeaderLine('Link'))) { 243 | $json = array_merge($json, $this->get($url, $nextParams, $fetchAll)); 244 | } 245 | } 246 | 247 | return $json; 248 | } catch (Exception $e) { 249 | throw $this->parseExceptionForErrorMessages($e); 250 | } 251 | } 252 | 253 | /** 254 | * @param string $url 255 | * @param string $body 256 | * @return mixed 257 | * 258 | * @throws ApiException 259 | */ 260 | public function post($url, $body) 261 | { 262 | try { 263 | $request = $this->createRequest('POST', $this->formatUrl($url, 'post'), $body); 264 | $response = $this->client()->send($request); 265 | 266 | return $this->parseResponse($response); 267 | } catch (Exception $e) { 268 | throw $this->parseExceptionForErrorMessages($e); 269 | } 270 | } 271 | 272 | /** 273 | * @param string $url 274 | * @param string $body 275 | * @return mixed 276 | * 277 | * @throws ApiException 278 | */ 279 | public function patch($url, $body) 280 | { 281 | try { 282 | $request = $this->createRequest('PATCH', $this->formatUrl($url, 'patch'), $body); 283 | $response = $this->client()->send($request); 284 | 285 | return $this->parseResponse($response); 286 | } catch (Exception $e) { 287 | throw $this->parseExceptionForErrorMessages($e); 288 | } 289 | } 290 | 291 | /** 292 | * @param string $url 293 | * @param string $body 294 | * @return mixed 295 | * 296 | * @throws ApiException 297 | */ 298 | public function delete($url, $body = null, $params = []) 299 | { 300 | try { 301 | $request = $this->createRequest('DELETE', $this->formatUrl($url, 'delete'), $body, $params = []); 302 | $response = $this->client()->send($request); 303 | 304 | return $this->parseResponse($response); 305 | } catch (Exception $e) { 306 | throw $this->parseExceptionForErrorMessages($e); 307 | } 308 | } 309 | 310 | /** 311 | * @param string $url 312 | * @param array $options 313 | * @return mixed 314 | * 315 | * @throws ApiException 316 | */ 317 | public function download($url, $options = []) 318 | { 319 | try { 320 | $request = $this->createRequestNoJson('GET', $this->formatUrl($url, 'get'), null); 321 | 322 | return $this->client()->send($request, $options); 323 | } catch (Exception $e) { 324 | $this->parseExceptionForErrorMessages($e); 325 | } 326 | } 327 | 328 | /** 329 | * @param string $url 330 | * @param array $options 331 | * @return mixed 332 | * 333 | * @throws ApiException 334 | */ 335 | public function upload($url, $options) 336 | { 337 | try { 338 | $request = $this->createRequestNoJson('POST', $this->formatUrl($url, 'post'), null); 339 | 340 | $response = $this->client()->send($request, $options); 341 | 342 | return $this->parseResponse($response); 343 | } catch (Exception $e) { 344 | $this->parseExceptionForErrorMessages($e); 345 | } 346 | } 347 | 348 | /** 349 | * @return string 350 | */ 351 | public function getAuthUrl() 352 | { 353 | return $this->authUrl . '?' . http_build_query([ 354 | 'client_id' => $this->clientId, 355 | 'redirect_uri' => $this->redirectUrl, 356 | 'response_type' => 'code', 357 | 'state' => $this->state, 358 | 'scope' => $this->scopes ? implode(' ', $this->scopes) : 'sales_invoices documents estimates bank time_entries settings', 359 | ]); 360 | } 361 | 362 | /** 363 | * @param mixed $clientId 364 | */ 365 | public function setClientId($clientId) 366 | { 367 | $this->clientId = $clientId; 368 | } 369 | 370 | /** 371 | * @param mixed $clientSecret 372 | */ 373 | public function setClientSecret($clientSecret) 374 | { 375 | $this->clientSecret = $clientSecret; 376 | } 377 | 378 | /** 379 | * @param mixed $authorizationCode 380 | */ 381 | public function setAuthorizationCode($authorizationCode) 382 | { 383 | $this->authorizationCode = $authorizationCode; 384 | } 385 | 386 | /** 387 | * @param mixed $accessToken 388 | */ 389 | public function setAccessToken($accessToken) 390 | { 391 | $this->accessToken = $accessToken; 392 | } 393 | 394 | /** 395 | * @return void 396 | */ 397 | public function redirectForAuthorization() 398 | { 399 | $authUrl = $this->getAuthUrl(); 400 | header('Location: ' . $authUrl); 401 | exit; 402 | } 403 | 404 | /** 405 | * @param mixed $redirectUrl 406 | */ 407 | public function setRedirectUrl($redirectUrl) 408 | { 409 | $this->redirectUrl = $redirectUrl; 410 | } 411 | 412 | /** 413 | * @param string $state 414 | */ 415 | public function setState(string $state): void 416 | { 417 | $this->state = $state; 418 | } 419 | 420 | /** 421 | * @return bool 422 | */ 423 | public function needsAuthentication() 424 | { 425 | return empty($this->authorizationCode); 426 | } 427 | 428 | /** 429 | * @param Response $response 430 | * @return mixed 431 | * 432 | * @throws ApiException 433 | */ 434 | private function parseResponse(Response $response) 435 | { 436 | try { 437 | $response->getBody()->rewind(); 438 | $json = json_decode($response->getBody()->getContents(), true); 439 | 440 | return $json; 441 | } catch (\RuntimeException $e) { 442 | throw new ApiException($e->getMessage()); 443 | } 444 | } 445 | 446 | /** 447 | * @param $headerLine 448 | * @return bool | array 449 | */ 450 | private function getNextParams($headerLine) 451 | { 452 | $links = Psr7\Header::parse($headerLine); 453 | 454 | foreach ($links as $link) { 455 | if (isset($link['rel']) && $link['rel'] === 'next') { 456 | $query = parse_url(trim($link[0], '<>'), PHP_URL_QUERY); 457 | parse_str($query, $params); 458 | 459 | return $params; 460 | } 461 | } 462 | 463 | return false; 464 | } 465 | 466 | /** 467 | * @return mixed 468 | */ 469 | public function getAccessToken() 470 | { 471 | return $this->accessToken; 472 | } 473 | 474 | /** 475 | * @return mixed 476 | */ 477 | public function getRefreshToken() 478 | { 479 | return $this->refreshToken; 480 | } 481 | 482 | /** 483 | * @throws ApiException 484 | */ 485 | private function acquireAccessToken() 486 | { 487 | $body = [ 488 | 'form_params' => [ 489 | 'redirect_uri' => $this->redirectUrl, 490 | 'grant_type' => 'authorization_code', 491 | 'client_id' => $this->clientId, 492 | 'client_secret' => $this->clientSecret, 493 | 'code' => $this->authorizationCode, 494 | ], 495 | ]; 496 | 497 | $response = $this->client()->post($this->getTokenUrl(), $body); 498 | 499 | if ($response->getStatusCode() == 200) { 500 | $response->getBody()->rewind(); 501 | $body = json_decode($response->getBody()->getContents(), true); 502 | 503 | if (json_last_error() === JSON_ERROR_NONE) { 504 | $this->accessToken = array_key_exists('access_token', $body) ? $body['access_token'] : null; 505 | $this->refreshToken = array_key_exists('refresh_token', $body) ? $body['refresh_token'] : null; 506 | } else { 507 | throw new ApiException('Could not acquire tokens, json decode failed. Got response: ' . $response->getBody()->getContents()); 508 | } 509 | } else { 510 | throw new ApiException('Could not acquire or refresh tokens'); 511 | } 512 | } 513 | 514 | /** 515 | * Parse the response in the Exception to return the Exact error messages. 516 | * 517 | * @param Exception $exception 518 | * @return \Picqer\Financials\Moneybird\Exceptions\ApiException 519 | * 520 | * @throws \Picqer\Financials\Moneybird\Exceptions\Api\TooManyRequestsException 521 | */ 522 | private function parseExceptionForErrorMessages(Exception $exception) 523 | { 524 | if (! $exception instanceof BadResponseException) { 525 | return new ApiException($exception->getMessage(), 0, $exception); 526 | } 527 | 528 | $response = $exception->getResponse(); 529 | 530 | if (null === $response) { 531 | return new ApiException('Response is NULL.', 0, $exception); 532 | } 533 | 534 | $response->getBody()->rewind(); 535 | $responseBody = $response->getBody()->getContents(); 536 | $decodedResponseBody = json_decode($responseBody, true); 537 | 538 | if (null !== $decodedResponseBody && isset($decodedResponseBody['error']['message']['value'])) { 539 | $errorMessage = $decodedResponseBody['error']['message']['value']; 540 | } else { 541 | $errorMessage = $responseBody; 542 | } 543 | 544 | $this->checkWhetherRateLimitHasBeenReached($response, $errorMessage); 545 | 546 | return new ApiException('Error ' . $response->getStatusCode() . ': ' . $errorMessage, $response->getStatusCode(), $exception); 547 | } 548 | 549 | /** 550 | * @param ResponseInterface $response 551 | * @param string $errorMessage 552 | * @return void 553 | * 554 | * @throws TooManyRequestsException 555 | */ 556 | private function checkWhetherRateLimitHasBeenReached(ResponseInterface $response, $errorMessage) 557 | { 558 | $rateLimitRemainingHeaders = $response->getHeader('RateLimit-Remaining'); 559 | if ($response->getStatusCode() === 429 && count($rateLimitRemainingHeaders) > 0) { 560 | $exception = new TooManyRequestsException('Error ' . $response->getStatusCode() . ': ' . $errorMessage, $response->getStatusCode()); 561 | $exception->retryAfterNumberOfSeconds = (int) current($rateLimitRemainingHeaders); 562 | $exception->currentRateLimit = (int) current($response->getHeader('RateLimit-Limit')); 563 | $exception->rateLimitResetsAfterTimestamp = (int) current($response->getHeader('RateLimit-Reset')); 564 | 565 | throw $exception; 566 | } 567 | } 568 | 569 | /** 570 | * @param string $url 571 | * @param string $method 572 | * @return string 573 | */ 574 | private function formatUrl($url, $method = 'get') 575 | { 576 | if ($this->testing) { 577 | return 'https://httpbin.org/' . $method; 578 | } 579 | 580 | return $this->apiUrl . '/' . ($this->administrationId ? $this->administrationId . '/' : '') . $url . '.json'; 581 | } 582 | 583 | /** 584 | * @return mixed 585 | */ 586 | public function getAdministrationId() 587 | { 588 | return $this->administrationId; 589 | } 590 | 591 | /** 592 | * @param mixed $administrationId 593 | */ 594 | public function setAdministrationId($administrationId) 595 | { 596 | $this->administrationId = $administrationId; 597 | } 598 | 599 | /** 600 | * @param int|string $administrationId 601 | * @return static 602 | */ 603 | public function withAdministrationId($administrationId) 604 | { 605 | $clone = clone $this; 606 | $clone->administrationId = $administrationId; 607 | 608 | return $clone; 609 | } 610 | 611 | /** 612 | * @return static 613 | */ 614 | public function withoutAdministrationId() 615 | { 616 | $clone = clone $this; 617 | $clone->administrationId = null; 618 | 619 | return $clone; 620 | } 621 | 622 | /** 623 | * @return bool 624 | */ 625 | public function isTesting() 626 | { 627 | return $this->testing; 628 | } 629 | 630 | /** 631 | * @param bool $testing 632 | */ 633 | public function setTesting($testing) 634 | { 635 | $this->testing = $testing; 636 | } 637 | 638 | /** 639 | * @return string 640 | */ 641 | public function getTokenUrl() 642 | { 643 | if ($this->testing) { 644 | return 'https://httpbin.org/post'; 645 | } 646 | 647 | return $this->tokenUrl; 648 | } 649 | 650 | /** 651 | * @param array $scopes 652 | */ 653 | public function setScopes($scopes) 654 | { 655 | $this->scopes = $scopes; 656 | } 657 | } 658 | -------------------------------------------------------------------------------- /src/Picqer/Financials/Moneybird/Entities/Administration.php: -------------------------------------------------------------------------------- 1 | [ 95 | 'entity' => ContactCustomField::class, 96 | 'type' => self::NESTING_TYPE_NESTED_OBJECTS, 97 | ], 98 | 'contact_people' => [ 99 | 'entity' => ContactPeople::class, 100 | 'type' => self::NESTING_TYPE_NESTED_OBJECTS, 101 | ], 102 | ]; 103 | 104 | /** 105 | * @param string|int $customerId 106 | * @return static 107 | * 108 | * @throws ApiException 109 | */ 110 | public function findByCustomerId($customerId) 111 | { 112 | $result = $this->connection()->get($this->getEndpoint() . '/customer_id/' . urlencode($customerId)); 113 | 114 | return $this->makeFromResponse($result); 115 | } 116 | 117 | /** 118 | * @throws ApiException 119 | */ 120 | public function getPaymentsMandate(): array 121 | { 122 | return $this->connection()->get( 123 | $this->getEndpoint() . '/' . $this->id . '/moneybird_payments_mandate' 124 | ); 125 | } 126 | 127 | public function addContactPerson(array $attributes): ContactPeople 128 | { 129 | $attributes['contact_id'] = $this->id; 130 | $contactPerson = new ContactPeople($this->connection(), $attributes); 131 | 132 | return $contactPerson->save(); 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /src/Picqer/Financials/Moneybird/Entities/ContactCustomField.php: -------------------------------------------------------------------------------- 1 | contact_id . '/contact_people'; 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/Picqer/Financials/Moneybird/Entities/CustomField.php: -------------------------------------------------------------------------------- 1 | [ 95 | 'entity' => EstimateAttachment::class, 96 | 'type' => self::NESTING_TYPE_ARRAY_OF_OBJECTS, 97 | ], 98 | 'custom_fields' => [ 99 | 'entity' => SalesInvoiceCustomField::class, 100 | 'type' => self::NESTING_TYPE_ARRAY_OF_OBJECTS, 101 | ], 102 | 'details' => [ 103 | 'entity' => SalesInvoiceDetail::class, 104 | 'type' => self::NESTING_TYPE_ARRAY_OF_OBJECTS, 105 | ], 106 | 'notes' => [ 107 | 'entity' => Note::class, 108 | 'type' => self::NESTING_TYPE_ARRAY_OF_OBJECTS, 109 | ], 110 | 'events' => [ 111 | 'entity' => EstimateEvent::class, 112 | 'type' => self::NESTING_TYPE_ARRAY_OF_OBJECTS, 113 | ], 114 | 'tax_totals' => [ 115 | 'entity' => EstimateTaxTotal::class, 116 | 'type' => self::NESTING_TYPE_ARRAY_OF_OBJECTS, 117 | ], 118 | ]; 119 | 120 | /** 121 | * Instruct Moneybird to send the estimate to the contact. 122 | * 123 | * @param string|SendInvoiceOptions $deliveryMethodOrOptions 124 | * @return $this 125 | * 126 | * @throws ApiException 127 | */ 128 | public function sendEstimate($deliveryMethodOrOptions = SendInvoiceOptions::METHOD_EMAIL) 129 | { 130 | if (is_string($deliveryMethodOrOptions)) { 131 | $options = new SendInvoiceOptions($deliveryMethodOrOptions); 132 | } else { 133 | $options = $deliveryMethodOrOptions; 134 | } 135 | unset($deliveryMethodOrOptions); 136 | 137 | if (! $options instanceof SendInvoiceOptions) { 138 | $options = is_object($options) ? get_class($options) : gettype($options); 139 | throw new InvalidArgumentException("Expected string or options instance. Received: '$options'"); 140 | } 141 | 142 | $response = $this->connection->patch($this->endpoint . '/' . $this->id . '/send_estimate', json_encode([ 143 | 'estimate_sending' => $options->jsonSerialize(), 144 | ])); 145 | 146 | if (is_array($response)) { 147 | $this->selfFromResponse($response); 148 | } 149 | 150 | return $this; 151 | } 152 | 153 | /** 154 | * Change the state of the estimate. 155 | * 156 | * @see https://developer.moneybird.com/api/estimates/#patch_estimates_id_change_state 157 | * 158 | * @param string $state 159 | * @return $this 160 | * 161 | * @throws ApiException 162 | */ 163 | public function changeState(string $state) 164 | { 165 | $response = $this->connection()->patch($this->getEndpoint() . '/' . urlencode($this->id) . '/change_state', json_encode([ 166 | 'state' => $state, 167 | ])); 168 | 169 | if (is_array($response)) { 170 | $this->selfFromResponse($response); 171 | } 172 | 173 | return $this; 174 | } 175 | } 176 | -------------------------------------------------------------------------------- /src/Picqer/Financials/Moneybird/Entities/EstimateAttachment.php: -------------------------------------------------------------------------------- 1 | Contact::class, 74 | ]; 75 | 76 | /** 77 | * @var array 78 | */ 79 | protected $multipleNestedEntities = [ 80 | 'attachments' => [ 81 | 'entity' => ExternalSalesInvoiceAttachment::class, 82 | 'type' => self::NESTING_TYPE_ARRAY_OF_OBJECTS, 83 | ], 84 | 'details' => [ 85 | 'entity' => ExternalSalesInvoiceDetail::class, 86 | 'type' => self::NESTING_TYPE_ARRAY_OF_OBJECTS, 87 | ], 88 | 'payments' => [ 89 | 'entity' => ExternalSalesInvoicePayment::class, 90 | 'type' => self::NESTING_TYPE_ARRAY_OF_OBJECTS, 91 | ], 92 | ]; 93 | 94 | public function __construct(Connection $connection, array $attributes = []) 95 | { 96 | parent::__construct($connection, $attributes); 97 | 98 | $this->attachmentPath = 'attachment'; 99 | } 100 | 101 | /** 102 | * Register a payment for the current external sales invoice. 103 | * 104 | * @param ExternalSalesInvoicePayment $externalSalesInvoicePayment (payment_date and price are required) 105 | * @return $this 106 | * 107 | * @throws ApiException 108 | */ 109 | public function registerPayment(ExternalSalesInvoicePayment $externalSalesInvoicePayment) 110 | { 111 | if (! isset($externalSalesInvoicePayment->payment_date)) { 112 | throw new ApiException('Required [payment_date] is missing'); 113 | } 114 | 115 | if (! isset($externalSalesInvoicePayment->price)) { 116 | throw new ApiException('Required [price] is missing'); 117 | } 118 | 119 | $this->connection()->post($this->endpoint . '/' . $this->id . '/payments', 120 | $externalSalesInvoicePayment->jsonWithNamespace() 121 | ); 122 | 123 | return $this; 124 | } 125 | 126 | /** 127 | * Delete a payment for the current external sales invoice. 128 | * 129 | * @param ExternalSalesInvoicePayment $externalSalesInvoicePayment (id is required) 130 | * @return $this 131 | * 132 | * @throws ApiException 133 | */ 134 | public function deletePayment(ExternalSalesInvoicePayment $externalSalesInvoicePayment) 135 | { 136 | if (! isset($externalSalesInvoicePayment->id)) { 137 | throw new ApiException('Required [id] is missing'); 138 | } 139 | 140 | $this->connection()->delete($this->endpoint . '/' . $this->id . '/payments/' . $externalSalesInvoicePayment->id); 141 | 142 | return $this; 143 | } 144 | } 145 | -------------------------------------------------------------------------------- /src/Picqer/Financials/Moneybird/Entities/ExternalSalesInvoiceAttachment.php: -------------------------------------------------------------------------------- 1 | [ 88 | 'entity' => LedgerAccountBooking::class, 89 | 'type' => self::NESTING_TYPE_ARRAY_OF_OBJECTS, 90 | ], 91 | ]; 92 | 93 | /** 94 | * @param string $bookingType 95 | * @param string | int $bookingId 96 | * @param string | float $priceBase 97 | * @param string | float $price 98 | * @param string $description 99 | * @param string $paymentBatchIdentifier 100 | * @return int 101 | * 102 | * @throws \Picqer\Financials\Moneybird\Exceptions\ApiException 103 | */ 104 | public function linkToBooking($bookingType, $bookingId, $priceBase, $price = null, $description = null, $paymentBatchIdentifier = null) 105 | { 106 | if (! in_array($bookingType, self::$allowedBookingTypesToLinkToFinancialMutation, true)) { 107 | throw new ApiException('Invalid booking type to link to FinancialMutation, allowed booking types: ' . implode(', ', self::$allowedBookingTypesToLinkToFinancialMutation)); 108 | } 109 | if (! is_numeric($bookingId)) { 110 | throw new ApiException('Invalid Booking identifier to link to FinancialMutation'); 111 | } 112 | 113 | //Filter out potential NULL values 114 | $parameters = array_filter( 115 | [ 116 | 'booking_type' => $bookingType, 117 | 'booking_id' => $bookingId, 118 | 'price_base' => $priceBase, 119 | 'price' => $price, 120 | 'description' => $description, 121 | 'payment_batch_identifier' => $paymentBatchIdentifier, 122 | ] 123 | ); 124 | 125 | return $this->connection->patch($this->endpoint . '/' . $this->id . '/link_booking', json_encode($parameters)); 126 | } 127 | 128 | /** 129 | * @param string $bookingType 130 | * @param string | int $bookingId 131 | * @return array 132 | * 133 | * @throws ApiException 134 | */ 135 | public function unlinkFromBooking($bookingType, $bookingId) 136 | { 137 | if (! in_array($bookingType, self::$allowedBookingTypesToUnlinkFromFinancialMutation, true)) { 138 | throw new ApiException('Invalid booking type to unlink from FinancialMutation, allowed booking types: ' . implode(', ', self::$allowedBookingTypesToUnlinkFromFinancialMutation)); 139 | } 140 | if (! is_numeric($bookingId)) { 141 | throw new ApiException('Invalid Booking identifier to unlink from FinancialMutation'); 142 | } 143 | 144 | $parameters = [ 145 | 'booking_type' => $bookingType, 146 | 'booking_id' => $bookingId, 147 | ]; 148 | 149 | return $this->connection->delete($this->endpoint . '/' . $this->id . '/unlink_booking', json_encode($parameters)); 150 | } 151 | } 152 | -------------------------------------------------------------------------------- /src/Picqer/Financials/Moneybird/Entities/FinancialStatement.php: -------------------------------------------------------------------------------- 1 | [ 45 | 'entity' => FinancialMutation::class, 46 | 'type' => self::NESTING_TYPE_ARRAY_OF_OBJECTS, 47 | ], 48 | ]; 49 | } 50 | -------------------------------------------------------------------------------- /src/Picqer/Financials/Moneybird/Entities/GeneralDocument.php: -------------------------------------------------------------------------------- 1 | [ 54 | 'entity' => GeneralDocumentAttachment::class, 55 | 'type' => self::NESTING_TYPE_ARRAY_OF_OBJECTS, 56 | ], 57 | ]; 58 | } 59 | -------------------------------------------------------------------------------- /src/Picqer/Financials/Moneybird/Entities/GeneralDocumentAttachment.php: -------------------------------------------------------------------------------- 1 | [ 49 | 'entity' => GeneralJournalDocumentAttachment::class, 50 | 'type' => self::NESTING_TYPE_ARRAY_OF_OBJECTS, 51 | ], 52 | 'general_journal_document_entries' => [ 53 | 'entity' => GeneralJournalDocumentEntry::class, 54 | 'type' => self::NESTING_TYPE_ARRAY_OF_OBJECTS, 55 | ], 56 | ]; 57 | } 58 | -------------------------------------------------------------------------------- /src/Picqer/Financials/Moneybird/Entities/GeneralJournalDocumentAttachment.php: -------------------------------------------------------------------------------- 1 | connection()->download($this->getEndpoint() . '/' . urlencode($this->attachable_id) . '/' . $this->attachmentPath . '/' . urlencode($this->id) . '/download'); 43 | 44 | return $response->getBody()->getContents(); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/Picqer/Financials/Moneybird/Entities/Generic/CustomField.php: -------------------------------------------------------------------------------- 1 | type = $type; 47 | 48 | return $this; 49 | } 50 | 51 | public function getEndpoint() 52 | { 53 | if (null === $this->type) { 54 | return $this->endpoint; 55 | } 56 | 57 | return $this->endpoint . '/' . $this->type; 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/Picqer/Financials/Moneybird/Entities/LedgerAccount.php: -------------------------------------------------------------------------------- 1 | connection()->get($this->getEndpoint() . '/identifier/' . urlencode($identifier)); 60 | 61 | return $this->makeFromResponse($result); 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/Picqer/Financials/Moneybird/Entities/Project.php: -------------------------------------------------------------------------------- 1 | Contact::class, 69 | ]; 70 | 71 | /** 72 | * @var array 73 | */ 74 | protected $multipleNestedEntities = [ 75 | 'attachments' => [ 76 | 'entity' => PurchaseInvoiceAttachment::class, 77 | 'type' => self::NESTING_TYPE_ARRAY_OF_OBJECTS, 78 | ], 79 | 'details' => [ 80 | 'entity' => PurchaseInvoiceDetail::class, 81 | 'type' => self::NESTING_TYPE_ARRAY_OF_OBJECTS, 82 | ], 83 | 'payments' => [ 84 | 'entity' => PurchaseInvoicePayment::class, 85 | 'type' => self::NESTING_TYPE_ARRAY_OF_OBJECTS, 86 | ], 87 | ]; 88 | 89 | /** 90 | * Register a payment for the current purchase invoice. 91 | * 92 | * @param PurchaseInvoicePayment $purchaseInvoicePayment (payment_date and price are required) 93 | * @return $this 94 | * 95 | * @throws ApiException 96 | */ 97 | public function registerPayment(PurchaseInvoicePayment $purchaseInvoicePayment) 98 | { 99 | if (! isset($purchaseInvoicePayment->payment_date)) { 100 | throw new ApiException('Required [payment_date] is missing'); 101 | } 102 | 103 | if (! isset($purchaseInvoicePayment->price)) { 104 | throw new ApiException('Required [price] is missing'); 105 | } 106 | 107 | $this->connection()->patch($this->endpoint . '/' . $this->id . '/register_payment', 108 | $purchaseInvoicePayment->jsonWithNamespace() 109 | ); 110 | 111 | return $this; 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /src/Picqer/Financials/Moneybird/Entities/PurchaseInvoiceAttachment.php: -------------------------------------------------------------------------------- 1 | connection()->delete('/documents/purchase_invoices/' . urlencode($this->invoice_id) . '/payments/' . urlencode($this->id)); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/Picqer/Financials/Moneybird/Entities/Receipt.php: -------------------------------------------------------------------------------- 1 | [ 69 | 'entity' => ReceiptAttachment::class, 70 | 'type' => self::NESTING_TYPE_ARRAY_OF_OBJECTS, 71 | ], 72 | 'details' => [ 73 | 'entity' => ReceiptDetail::class, 74 | 'type' => self::NESTING_TYPE_ARRAY_OF_OBJECTS, 75 | ], 76 | ]; 77 | 78 | /** 79 | * Register a payment for the current purchase invoice. 80 | * 81 | * @param ReceiptPayment $receiptPayment (payment_date and price are required) 82 | * @return $this 83 | * 84 | * @throws ApiException 85 | */ 86 | public function registerPayment(ReceiptPayment $receiptPayment) 87 | { 88 | if (! isset($receiptPayment->payment_date)) { 89 | throw new ApiException('Required [payment_date] is missing'); 90 | } 91 | 92 | if (! isset($receiptPayment->price)) { 93 | throw new ApiException('Required [price] is missing'); 94 | } 95 | 96 | $this->connection()->post($this->endpoint . '/' . $this->id . '/payments', 97 | $receiptPayment->jsonWithNamespace() 98 | ); 99 | 100 | return $this; 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /src/Picqer/Financials/Moneybird/Entities/ReceiptAttachment.php: -------------------------------------------------------------------------------- 1 | [ 77 | 'entity' => RecurringSalesInvoiceDetail::class, 78 | 'type' => self::NESTING_TYPE_ARRAY_OF_OBJECTS, 79 | ], 80 | 'custom_fields' => [ 81 | 'entity' => RecurringSalesInvoiceCustomField::class, 82 | 'type' => self::NESTING_TYPE_ARRAY_OF_OBJECTS, 83 | ], 84 | ]; 85 | } 86 | -------------------------------------------------------------------------------- /src/Picqer/Financials/Moneybird/Entities/RecurringSalesInvoiceCustomField.php: -------------------------------------------------------------------------------- 1 | Contact::class, 102 | ]; 103 | 104 | /** 105 | * @var array 106 | */ 107 | protected $multipleNestedEntities = [ 108 | 'attachments' => [ 109 | 'entity' => SalesInvoiceAttachment::class, 110 | 'type' => self::NESTING_TYPE_ARRAY_OF_OBJECTS, 111 | ], 112 | 'custom_fields' => [ 113 | 'entity' => SalesInvoiceCustomField::class, 114 | 'type' => self::NESTING_TYPE_ARRAY_OF_OBJECTS, 115 | ], 116 | 'details' => [ 117 | 'entity' => SalesInvoiceDetail::class, 118 | 'type' => self::NESTING_TYPE_ARRAY_OF_OBJECTS, 119 | ], 120 | 'payments' => [ 121 | 'entity' => SalesInvoicePayment::class, 122 | 'type' => self::NESTING_TYPE_ARRAY_OF_OBJECTS, 123 | ], 124 | 'notes' => [ 125 | 'entity' => Note::class, 126 | 'type' => self::NESTING_TYPE_ARRAY_OF_OBJECTS, 127 | ], 128 | 'events' => [ 129 | 'entity' => SalesInvoiceEvent::class, 130 | 'type' => self::NESTING_TYPE_ARRAY_OF_OBJECTS, 131 | ], 132 | 'tax_totals' => [ 133 | 'entity' => SalesInvoiceTaxTotal::class, 134 | 'type' => self::NESTING_TYPE_ARRAY_OF_OBJECTS, 135 | ], 136 | ]; 137 | 138 | /** 139 | * Instruct Moneybird to send the invoice to the contact. 140 | * 141 | * @param string|SendInvoiceOptions $deliveryMethodOrOptions 142 | * @return $this 143 | * 144 | * @throws ApiException 145 | */ 146 | public function sendInvoice($deliveryMethodOrOptions = SendInvoiceOptions::METHOD_EMAIL) 147 | { 148 | if (is_string($deliveryMethodOrOptions)) { 149 | $options = new SendInvoiceOptions($deliveryMethodOrOptions); 150 | } else { 151 | $options = $deliveryMethodOrOptions; 152 | } 153 | unset($deliveryMethodOrOptions); 154 | 155 | if (! $options instanceof SendInvoiceOptions) { 156 | $options = is_object($options) ? get_class($options) : gettype($options); 157 | throw new InvalidArgumentException("Expected string or options instance. Received: '$options'"); 158 | } 159 | 160 | $response = $this->connection->patch($this->endpoint . '/' . $this->id . '/send_invoice', json_encode([ 161 | 'sales_invoice_sending' => $options->jsonSerialize(), 162 | ])); 163 | 164 | if (is_array($response)) { 165 | $this->selfFromResponse($response); 166 | } 167 | 168 | return $this; 169 | } 170 | 171 | /** 172 | * Find SalesInvoice by invoice_id. 173 | * 174 | * @param string|int $invoiceId 175 | * @return static 176 | * 177 | * @throws \Picqer\Financials\Moneybird\Exceptions\ApiException 178 | */ 179 | public function findByInvoiceId($invoiceId) 180 | { 181 | $result = $this->connection()->get($this->getEndpoint() . '/find_by_invoice_id/' . urlencode($invoiceId)); 182 | 183 | return $this->makeFromResponse($result); 184 | } 185 | 186 | /** 187 | * Register a payment for the current invoice. 188 | * 189 | * @param SalesInvoicePayment $salesInvoicePayment (payment_date and price are required) 190 | * @return $this 191 | * 192 | * @throws ApiException 193 | */ 194 | public function registerPayment(SalesInvoicePayment $salesInvoicePayment) 195 | { 196 | if (! isset($salesInvoicePayment->payment_date)) { 197 | throw new ApiException('Required [payment_date] is missing'); 198 | } 199 | 200 | if (! isset($salesInvoicePayment->price)) { 201 | throw new ApiException('Required [price] is missing'); 202 | } 203 | 204 | $this->connection()->post($this->endpoint . '/' . $this->id . '/payments', 205 | $salesInvoicePayment->jsonWithNamespace() 206 | ); 207 | 208 | return $this; 209 | } 210 | 211 | /** 212 | * Delete a payment for the current invoice. 213 | * 214 | * @param SalesInvoicePayment $salesInvoicePayment (id is required) 215 | * @return $this 216 | * 217 | * @throws ApiException 218 | */ 219 | public function deletePayment(SalesInvoicePayment $salesInvoicePayment) 220 | { 221 | if (! isset($salesInvoicePayment->id)) { 222 | throw new ApiException('Required [id] is missing'); 223 | } 224 | 225 | $this->connection()->delete($this->endpoint . '/' . $this->id . '/payments/' . $salesInvoicePayment->id); 226 | 227 | return $this; 228 | } 229 | 230 | /** 231 | * Create a credit invoice based on the current invoice. 232 | * 233 | * @return \Picqer\Financials\Moneybird\Entities\SalesInvoice 234 | * 235 | * @throws \Picqer\Financials\Moneybird\Exceptions\ApiException 236 | */ 237 | public function duplicateToCreditInvoice() 238 | { 239 | $response = $this->connection()->patch($this->getEndpoint() . '/' . $this->id . '/duplicate_creditinvoice', 240 | json_encode([]) // No body needed for this call. The patch method however needs one. 241 | ); 242 | 243 | return $this->makeFromResponse($response); 244 | } 245 | 246 | /** 247 | * Register a payment for a credit invoice. 248 | * 249 | * @return \Picqer\Financials\Moneybird\Entities\SalesInvoice 250 | * 251 | * @throws \Picqer\Financials\Moneybird\Exceptions\ApiException 252 | */ 253 | public function registerPaymentForCreditInvoice() 254 | { 255 | $response = $this->connection()->patch($this->getEndpoint() . '/' . $this->id . '/register_payment_creditinvoice', 256 | json_encode([]) // No body needed for this call. The patch method however needs one. 257 | ); 258 | 259 | return $this->makeFromResponse($response); 260 | } 261 | 262 | /** 263 | * Pauses the sales invoice. The automatic workflow steps will not be executed while the sales invoice is paused. 264 | * 265 | * @return bool 266 | * 267 | * @throws \Picqer\Financials\Moneybird\Exceptions\ApiException 268 | */ 269 | public function pauseWorkflow() 270 | { 271 | try { 272 | $this->connection()->post($this->endpoint . '/' . $this->id . '/pause', json_encode([])); 273 | } catch (ApiException $exception) { 274 | if (strpos($exception->getMessage(), 'The sales_invoice is already paused') !== false) { 275 | return true; // Everything is fine since the sales invoice was already paused we don't need an error. 276 | } 277 | 278 | throw $exception; 279 | } 280 | 281 | return true; 282 | } 283 | 284 | /** 285 | * Resumes the sales invoice. The automatic workflow steps will execute again after resuming. 286 | * 287 | * @return bool 288 | * 289 | * @throws \Picqer\Financials\Moneybird\Exceptions\ApiException 290 | */ 291 | public function resumeWorkflow() 292 | { 293 | try { 294 | $this->connection()->post($this->endpoint . '/' . $this->id . '/resume', json_encode([])); 295 | } catch (ApiException $exception) { 296 | if (strpos($exception->getMessage(), "The sales_invoice isn't paused") !== false) { 297 | return true; // Everything is fine since the sales invoice wasn't paused we don't need an error. 298 | } 299 | 300 | throw $exception; 301 | } 302 | 303 | return true; 304 | } 305 | 306 | /** 307 | * Download as UBL. 308 | * 309 | * @return string PDF file data 310 | * 311 | * @throws \Picqer\Financials\Moneybird\Exceptions\ApiException 312 | */ 313 | public function downloadUBL() 314 | { 315 | $response = $this->connection()->download($this->getEndpoint() . '/' . urlencode($this->id) . '/download_ubl'); 316 | 317 | return $response->getBody()->getContents(); 318 | } 319 | 320 | /** 321 | * Download as Packaging slip. 322 | * 323 | * @return string PDF file data 324 | * 325 | * @throws \Picqer\Financials\Moneybird\Exceptions\ApiException 326 | */ 327 | public function downloadPackageSlip() 328 | { 329 | $response = $this->connection()->download($this->getEndpoint() . '/' . urlencode($this->id) . '/download_packing_slip_pdf'); 330 | 331 | return $response->getBody()->getContents(); 332 | } 333 | } 334 | -------------------------------------------------------------------------------- /src/Picqer/Financials/Moneybird/Entities/SalesInvoice/SendInvoiceOptions.php: -------------------------------------------------------------------------------- 1 | setMethod($deliveryMethod ?: self::METHOD_EMAIL); 47 | $this->setEmailAddress($emailAddress); 48 | $this->setEmailMessage($emailMessage); 49 | } 50 | 51 | private static function getValidMethods() 52 | { 53 | // TODO move this to a private const VALID_METHODS when php 7 is supported 54 | return [ 55 | self::METHOD_EMAIL, 56 | self::METHOD_SIMPLER_INVOICING, 57 | self::METHOD_POST, 58 | self::METHOD_MANUAL, 59 | ]; 60 | } 61 | 62 | public function schedule(DateTime $date) 63 | { 64 | $this->scheduleDate = $date; 65 | $this->scheduled = true; 66 | } 67 | 68 | public function isScheduled() 69 | { 70 | return $this->scheduled === true; 71 | } 72 | 73 | #[ReturnTypeWillChange] 74 | public function jsonSerialize() 75 | { 76 | return array_filter([ 77 | 'delivery_method' => $this->getMethod(), 78 | 'sending_scheduled' => $this->isScheduled() ?: null, 79 | 'deliver_ubl' => $this->getDeliverUbl(), 80 | 'mergeable' => $this->getMergeable(), 81 | 'email_address' => $this->getEmailAddress(), 82 | 'email_message' => $this->getEmailMessage(), 83 | 'invoice_date' => $this->getScheduleDate() ? $this->getScheduleDate()->format('Y-m-d') : null, 84 | ], function ($item) { 85 | return $item !== null; 86 | }); 87 | } 88 | 89 | /** 90 | * @return mixed 91 | */ 92 | public function getScheduleDate() 93 | { 94 | return $this->scheduleDate; 95 | } 96 | 97 | /** 98 | * @return string 99 | */ 100 | public function getMethod() 101 | { 102 | return $this->method; 103 | } 104 | 105 | /** 106 | * @param string $method 107 | */ 108 | public function setMethod($method) 109 | { 110 | $validMethods = self::getValidMethods(); 111 | if (! in_array($method, $validMethods)) { 112 | $method = is_object($method) ? get_class($method) : $method; 113 | $validMethodNames = implode(',', $validMethods); 114 | throw new InvalidArgumentException("Invalid method: '$method'. Expected one of: '$validMethodNames'"); 115 | } 116 | 117 | $this->method = $method; 118 | } 119 | 120 | /** 121 | * @return null|string 122 | */ 123 | public function getEmailAddress() 124 | { 125 | return $this->emailAddress; 126 | } 127 | 128 | /** 129 | * @param null|string $emailAddress 130 | */ 131 | public function setEmailAddress($emailAddress) 132 | { 133 | $this->emailAddress = $emailAddress; 134 | } 135 | 136 | /** 137 | * @return string 138 | */ 139 | public function getEmailMessage() 140 | { 141 | return $this->emailMessage; 142 | } 143 | 144 | /** 145 | * @param string $emailMessage 146 | */ 147 | public function setEmailMessage($emailMessage) 148 | { 149 | $this->emailMessage = $emailMessage; 150 | } 151 | 152 | /** 153 | * @return bool 154 | */ 155 | public function getMergeable() 156 | { 157 | return $this->mergeable; 158 | } 159 | 160 | /** 161 | * @param bool $mergeable 162 | */ 163 | public function setMergeable($mergeable) 164 | { 165 | $this->mergeable = $mergeable; 166 | } 167 | 168 | /** 169 | * @return bool 170 | */ 171 | public function getDeliverUbl() 172 | { 173 | return $this->deliverUbl; 174 | } 175 | 176 | /** 177 | * @param bool $deliverUbl 178 | */ 179 | public function setDeliverUbl($deliverUbl) 180 | { 181 | $this->deliverUbl = $deliverUbl; 182 | } 183 | } 184 | -------------------------------------------------------------------------------- /src/Picqer/Financials/Moneybird/Entities/SalesInvoiceAttachment.php: -------------------------------------------------------------------------------- 1 | json(); 41 | $aReminder = json_decode($aReminder, true); 42 | 43 | $aReminder['sales_invoice_ids'] = array_map(function ($salesInvoice) { 44 | if (is_object($salesInvoice)) { 45 | return $salesInvoice->id; 46 | } else { 47 | return $salesInvoice; 48 | } 49 | }, $this->sales_invoices); 50 | unset($aReminder['sales_invoices']); 51 | 52 | $this->connection->post($this->endpoint . '/send_reminders', json_encode([ 53 | 'sales_invoice_reminders' => [$aReminder], 54 | ])); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/Picqer/Financials/Moneybird/Entities/SalesInvoiceTaxTotal.php: -------------------------------------------------------------------------------- 1 | User::class, 49 | 'project' => Project::class, 50 | 'detail' => SalesInvoiceDetail::class, 51 | ]; 52 | } 53 | -------------------------------------------------------------------------------- /src/Picqer/Financials/Moneybird/Entities/TypelessDocument.php: -------------------------------------------------------------------------------- 1 | [ 50 | 'entity' => TypelessDocumentAttachment::class, 51 | 'type' => self::NESTING_TYPE_ARRAY_OF_OBJECTS, 52 | ], 53 | ]; 54 | } 55 | -------------------------------------------------------------------------------- /src/Picqer/Financials/Moneybird/Entities/TypelessDocumentAttachment.php: -------------------------------------------------------------------------------- 1 | connection = $connection; 84 | $this->fill($attributes, false); 85 | } 86 | 87 | /** 88 | * Get the connection instance. 89 | * 90 | * @return \Picqer\Financials\Moneybird\Connection 91 | */ 92 | public function connection() 93 | { 94 | return $this->connection; 95 | } 96 | 97 | /** 98 | * Get the model's attributes. 99 | * 100 | * @return array 101 | */ 102 | public function attributes() 103 | { 104 | return $this->attributes; 105 | } 106 | 107 | /** 108 | * Fill the entity from an array. 109 | * 110 | * @param array $attributes 111 | * @param bool $first_initialize 112 | */ 113 | protected function fill(array $attributes, $first_initialize) 114 | { 115 | if ($first_initialize) { 116 | $this->enableFirstInitialize(); 117 | } 118 | 119 | foreach ($this->fillableFromArray($attributes) as $key => $value) { 120 | if ($this->isFillable($key)) { 121 | $this->setAttribute($key, $value); 122 | } 123 | } 124 | 125 | if ($first_initialize) { 126 | $this->disableFirstInitialize(); 127 | } 128 | } 129 | 130 | /** 131 | * Register the current model as initializing. 132 | */ 133 | protected function enableFirstInitialize() 134 | { 135 | $this->initializing = true; 136 | } 137 | 138 | /** 139 | * Register the current model as initialized. 140 | */ 141 | protected function disableFirstInitialize() 142 | { 143 | $this->initializing = false; 144 | } 145 | 146 | /** 147 | * Get the fillable attributes of an array. 148 | * 149 | * @param array $attributes 150 | * @return array 151 | */ 152 | protected function fillableFromArray(array $attributes) 153 | { 154 | if (count($this->fillable) > 0) { 155 | return array_intersect_key($attributes, array_flip($this->fillable)); 156 | } 157 | 158 | return $attributes; 159 | } 160 | 161 | /** 162 | * @param string $key 163 | * @return bool 164 | */ 165 | protected function isFillable($key) 166 | { 167 | return in_array($key, $this->fillable, true); 168 | } 169 | 170 | /** 171 | * @param string $key 172 | * @param mixed $value 173 | */ 174 | protected function setAttribute($key, $value) 175 | { 176 | if (! isset($this->attribute_changes[$key])) { 177 | $from = null; 178 | 179 | if (isset($this->attributes[$key])) { 180 | $from = $this->attributes[$key]; 181 | } 182 | 183 | $this->attribute_changes[$key] = [ 184 | 'from' => $from, 185 | 'to' => $value, 186 | ]; 187 | } else { 188 | $this->attribute_changes[$key]['to'] = $value; 189 | } 190 | 191 | $this->attributes[$key] = $value; 192 | } 193 | 194 | /** 195 | * All keys that are changed in this model. 196 | * 197 | * @return array 198 | */ 199 | public function getDirty() 200 | { 201 | return array_keys($this->attribute_changes); 202 | } 203 | 204 | /** 205 | * All changed keys with it values. 206 | * 207 | * @return array 208 | */ 209 | public function getDirtyValues() 210 | { 211 | return $this->attribute_changes; 212 | } 213 | 214 | /** 215 | * Check if the attribute is changed since the last save/update/create action. 216 | * 217 | * @param $attributeName 218 | * @return bool 219 | */ 220 | public function isAttributeDirty($attributeName) 221 | { 222 | if (array_key_exists($attributeName, $this->attribute_changes)) { 223 | return true; 224 | } 225 | 226 | return false; 227 | } 228 | 229 | /** 230 | * Clear the changed/dirty attribute in this model. 231 | */ 232 | public function clearDirty() 233 | { 234 | $this->attribute_changes = []; 235 | } 236 | 237 | /** 238 | * @param string $key 239 | * @return mixed 240 | */ 241 | public function __get($key) 242 | { 243 | if (isset($this->attributes[$key])) { 244 | return $this->attributes[$key]; 245 | } 246 | 247 | return null; 248 | } 249 | 250 | /** 251 | * @param string $key 252 | * @param mixed $value 253 | */ 254 | public function __set($key, $value) 255 | { 256 | if ($this->isFillable($key)) { 257 | $this->setAttribute($key, $value); 258 | } 259 | } 260 | 261 | /** 262 | * @return bool 263 | */ 264 | public function exists() 265 | { 266 | if (! array_key_exists($this->primaryKey, $this->attributes)) { 267 | return false; 268 | } 269 | 270 | return ! empty($this->attributes[$this->primaryKey]); 271 | } 272 | 273 | /** 274 | * @return string 275 | */ 276 | public function json() 277 | { 278 | $array = $this->getArrayWithNestedObjects(); 279 | 280 | return json_encode($array, static::JSON_OPTIONS); 281 | } 282 | 283 | /** 284 | * @return string 285 | */ 286 | public function jsonWithNamespace() 287 | { 288 | if ($this->namespace !== '') { 289 | return json_encode([$this->namespace => $this->getArrayWithNestedObjects()], static::JSON_OPTIONS); 290 | } else { 291 | return $this->json(); 292 | } 293 | } 294 | 295 | /** 296 | * @param bool $useAttributesAppend 297 | * @return array 298 | */ 299 | private function getArrayWithNestedObjects($useAttributesAppend = true) 300 | { 301 | $result = []; 302 | $multipleNestedEntities = $this->getMultipleNestedEntities(); 303 | 304 | foreach ($this->attributes as $attributeName => $attributeValue) { 305 | if (! is_object($attributeValue)) { 306 | //check if result is changed 307 | if ($this->isAttributeDirty($attributeName)) { 308 | $result[$attributeName] = $attributeValue; 309 | } 310 | } 311 | 312 | if (array_key_exists($attributeName, $this->getSingleNestedEntities())) { 313 | $result[$attributeName] = $attributeValue->attributes; 314 | } 315 | 316 | if (array_key_exists($attributeName, $multipleNestedEntities)) { 317 | if ($useAttributesAppend) { 318 | $attributeNameToUse = $attributeName . '_attributes'; 319 | } else { 320 | $attributeNameToUse = $attributeName; 321 | } 322 | 323 | $result[$attributeNameToUse] = []; 324 | foreach ($attributeValue as $attributeEntity) { 325 | $result[$attributeNameToUse][] = $attributeEntity->attributes; 326 | } 327 | 328 | if ($multipleNestedEntities[$attributeName]['type'] === self::NESTING_TYPE_NESTED_OBJECTS) { 329 | $result[$attributeNameToUse] = (object) $result[$attributeNameToUse]; 330 | } 331 | 332 | if ( 333 | $multipleNestedEntities[$attributeName]['type'] === self::NESTING_TYPE_NESTED_OBJECTS 334 | && empty($result[$attributeNameToUse]) 335 | ) { 336 | $result[$attributeNameToUse] = new \stdClass(); 337 | } 338 | } 339 | } 340 | 341 | return $result; 342 | } 343 | 344 | /** 345 | * Create a new object with the response from the API. 346 | * 347 | * @param array $response 348 | * @return static 349 | */ 350 | public function makeFromResponse(array $response) 351 | { 352 | $entity = new static($this->connection); 353 | $entity->selfFromResponse($response); 354 | 355 | return $entity; 356 | } 357 | 358 | /** 359 | * Recreate this object with the response from the API. 360 | * 361 | * @param array $response 362 | * @return $this 363 | */ 364 | public function selfFromResponse(array $response) 365 | { 366 | $this->fill($response, true); 367 | 368 | foreach ($this->getSingleNestedEntities() as $key => $value) { 369 | if (isset($response[$key])) { 370 | $entityName = $value; 371 | $this->$key = new $entityName($this->connection, $response[$key]); 372 | } 373 | } 374 | 375 | foreach ($this->getMultipleNestedEntities() as $key => $value) { 376 | if (isset($response[$key])) { 377 | $entityName = $value['entity']; 378 | /** @var self $instantiatedEntity */ 379 | $instantiatedEntity = new $entityName($this->connection); 380 | $this->$key = $instantiatedEntity->collectionFromResult($response[$key]); 381 | } 382 | } 383 | 384 | return $this; 385 | } 386 | 387 | /** 388 | * @param array $result 389 | * @return array 390 | */ 391 | public function collectionFromResult(array $result) 392 | { 393 | // If we have one result which is not an assoc array, make it the first element of an array for the 394 | // collectionFromResult function so we always return a collection from filter 395 | if ((bool) count(array_filter(array_keys($result), 'is_string'))) { 396 | $result = [$result]; 397 | } 398 | 399 | $collection = []; 400 | foreach ($result as $r) { 401 | $collection[] = $this->makeFromResponse($r); 402 | } 403 | 404 | return $collection; 405 | } 406 | 407 | /** 408 | * @return mixed 409 | */ 410 | public function getSingleNestedEntities() 411 | { 412 | return $this->singleNestedEntities; 413 | } 414 | 415 | /** 416 | * @return array 417 | */ 418 | public function getMultipleNestedEntities() 419 | { 420 | return $this->multipleNestedEntities; 421 | } 422 | 423 | /** 424 | * Make var_dump and print_r look pretty. 425 | * 426 | * @return array 427 | */ 428 | public function __debugInfo() 429 | { 430 | $result = []; 431 | 432 | foreach ($this->fillable as $attribute) { 433 | $result[$attribute] = $this->$attribute; 434 | } 435 | 436 | return $result; 437 | } 438 | 439 | /** 440 | * @return string 441 | */ 442 | public function getEndpoint() 443 | { 444 | return $this->endpoint; 445 | } 446 | 447 | /** 448 | * @return string 449 | */ 450 | public function getFilterEndpoint() 451 | { 452 | return $this->filter_endpoint ?: $this->endpoint; 453 | } 454 | 455 | /** 456 | * Determine if an attribute exists on the model. 457 | * 458 | * @param string $name 459 | * @return bool 460 | */ 461 | public function __isset($name) 462 | { 463 | return isset($this->attributes[$name]) && null !== $this->attributes[$name]; 464 | } 465 | } 466 | -------------------------------------------------------------------------------- /src/Picqer/Financials/Moneybird/Moneybird.php: -------------------------------------------------------------------------------- 1 | connection = $connection; 69 | } 70 | 71 | /** 72 | * @param array $attributes 73 | * @return \Picqer\Financials\Moneybird\Entities\Administration 74 | */ 75 | public function administration($attributes = []) 76 | { 77 | return new Administration( 78 | $this->connection->withoutAdministrationId(), 79 | $attributes); 80 | } 81 | 82 | /** 83 | * @param array $attributes 84 | * @return \Picqer\Financials\Moneybird\Entities\Contact 85 | */ 86 | public function contact($attributes = []) 87 | { 88 | return new Contact($this->connection, $attributes); 89 | } 90 | 91 | /** 92 | * @param array $attributes 93 | * @return \Picqer\Financials\Moneybird\Entities\ContactPeople 94 | */ 95 | public function contactPerson($attributes = []) 96 | { 97 | return new ContactPeople($this->connection, $attributes); 98 | } 99 | 100 | /** 101 | * @param array $attributes 102 | * @return \Picqer\Financials\Moneybird\Entities\ContactCustomField 103 | */ 104 | public function contactCustomField($attributes = []) 105 | { 106 | return new ContactCustomField($this->connection, $attributes); 107 | } 108 | 109 | /** 110 | * @param array $attributes 111 | * @return \Picqer\Financials\Moneybird\Entities\Note 112 | */ 113 | public function note($attributes = []) 114 | { 115 | return new Note($this->connection, $attributes); 116 | } 117 | 118 | /** 119 | * @param array $attributes 120 | * @return \Picqer\Financials\Moneybird\Entities\CustomField 121 | */ 122 | public function customField($attributes = []) 123 | { 124 | return new CustomField($this->connection, $attributes); 125 | } 126 | 127 | /** 128 | * @param array $attributes 129 | * @return \Picqer\Financials\Moneybird\Entities\DocumentStyle 130 | */ 131 | public function documentStyle($attributes = []) 132 | { 133 | return new DocumentStyle($this->connection, $attributes); 134 | } 135 | 136 | /** 137 | * @param array $attributes 138 | * @return \Picqer\Financials\Moneybird\Entities\Estimate 139 | */ 140 | public function estimate($attributes = []) 141 | { 142 | return new Estimate($this->connection, $attributes); 143 | } 144 | 145 | /** 146 | * @param array $attributes 147 | * @return \Picqer\Financials\Moneybird\Entities\ExternalSalesInvoice 148 | */ 149 | public function externalSalesInvoice($attributes = []) 150 | { 151 | return new ExternalSalesInvoice($this->connection, $attributes); 152 | } 153 | 154 | /** 155 | * @param array $attributes 156 | * @return \Picqer\Financials\Moneybird\Entities\ExternalSalesInvoiceDetail 157 | */ 158 | public function externalSalesInvoiceDetail($attributes = []) 159 | { 160 | return new ExternalSalesInvoiceDetail($this->connection, $attributes); 161 | } 162 | 163 | /** 164 | * @param array $attributes 165 | * @return \Picqer\Financials\Moneybird\Entities\ExternalSalesInvoicePayment 166 | */ 167 | public function externalSalesInvoicePayment($attributes = []) 168 | { 169 | return new ExternalSalesInvoicePayment($this->connection, $attributes); 170 | } 171 | 172 | /** 173 | * @param array $attributes 174 | * @return \Picqer\Financials\Moneybird\Entities\FinancialAccount 175 | */ 176 | public function financialAccount($attributes = []) 177 | { 178 | return new FinancialAccount($this->connection, $attributes); 179 | } 180 | 181 | /** 182 | * @param array $attributes 183 | * @return \Picqer\Financials\Moneybird\Entities\FinancialMutation 184 | */ 185 | public function financialMutation($attributes = []) 186 | { 187 | return new FinancialMutation($this->connection, $attributes); 188 | } 189 | 190 | /** 191 | * @param array $attributes 192 | * @return \Picqer\Financials\Moneybird\Entities\FinancialStatement 193 | */ 194 | public function financialStatement($attributes = []) 195 | { 196 | return new FinancialStatement($this->connection, $attributes); 197 | } 198 | 199 | /** 200 | * @param array $attributes 201 | * @return \Picqer\Financials\Moneybird\Entities\GeneralDocument 202 | */ 203 | public function generalDocument($attributes = []) 204 | { 205 | return new GeneralDocument($this->connection, $attributes); 206 | } 207 | 208 | /** 209 | * @param array $attributes 210 | * @return \Picqer\Financials\Moneybird\Entities\GeneralJournalDocument 211 | */ 212 | public function generalJournalDocument($attributes = []) 213 | { 214 | return new GeneralJournalDocument($this->connection, $attributes); 215 | } 216 | 217 | /** 218 | * @param array $attributes 219 | * @return \Picqer\Financials\Moneybird\Entities\GeneralJournalDocumentEntry 220 | */ 221 | public function generalJournalDocumentEntry($attributes = []) 222 | { 223 | return new GeneralJournalDocumentEntry($this->connection, $attributes); 224 | } 225 | 226 | /** 227 | * @return \Picqer\Financials\Moneybird\Connection 228 | */ 229 | public function getConnection() 230 | { 231 | return $this->connection; 232 | } 233 | 234 | /** 235 | * @param array $attributes 236 | * @return \Picqer\Financials\Moneybird\Entities\Identity 237 | */ 238 | public function identity($attributes = []) 239 | { 240 | return new Identity($this->connection, $attributes); 241 | } 242 | 243 | /** 244 | * @param array $attributes 245 | * @return \Picqer\Financials\Moneybird\Entities\ImportMapping 246 | */ 247 | public function importMapping($attributes = []) 248 | { 249 | return new ImportMapping($this->connection, $attributes); 250 | } 251 | 252 | /** 253 | * @param array $attributes 254 | * @return \Picqer\Financials\Moneybird\Entities\LedgerAccount 255 | */ 256 | public function ledgerAccount($attributes = []) 257 | { 258 | return new LedgerAccount($this->connection, $attributes); 259 | } 260 | 261 | /** 262 | * @param array $attributes 263 | * @return \Picqer\Financials\Moneybird\Entities\Product 264 | */ 265 | public function product($attributes = []) 266 | { 267 | return new Product($this->connection, $attributes); 268 | } 269 | 270 | /** 271 | * @param array $attributes 272 | * @return \Picqer\Financials\Moneybird\Entities\Project 273 | */ 274 | public function project($attributes = []) 275 | { 276 | return new Project($this->connection, $attributes); 277 | } 278 | 279 | /** 280 | * @param array $attributes 281 | * @return \Picqer\Financials\Moneybird\Entities\PurchaseInvoice 282 | */ 283 | public function purchaseInvoice($attributes = []) 284 | { 285 | return new PurchaseInvoice($this->connection, $attributes); 286 | } 287 | 288 | /** 289 | * @param array $attributes 290 | * @return \Picqer\Financials\Moneybird\Entities\PurchaseInvoiceDetail 291 | */ 292 | public function purchaseInvoiceDetail($attributes = []) 293 | { 294 | return new PurchaseInvoiceDetail($this->connection, $attributes); 295 | } 296 | 297 | /** 298 | * @param array $attributes 299 | * @return \Picqer\Financials\Moneybird\Entities\PurchaseInvoicePayment 300 | */ 301 | public function purchaseInvoicePayment($attributes = []) 302 | { 303 | return new PurchaseInvoicePayment($this->connection, $attributes); 304 | } 305 | 306 | /** 307 | * @param array $attributes 308 | * @return \Picqer\Financials\Moneybird\Entities\Receipt 309 | */ 310 | public function receipt($attributes = []) 311 | { 312 | return new Receipt($this->connection, $attributes); 313 | } 314 | 315 | /** 316 | * @param array $attributes 317 | * @return \Picqer\Financials\Moneybird\Entities\ReceiptDetail 318 | */ 319 | public function receiptDetail($attributes = []) 320 | { 321 | return new ReceiptDetail($this->connection, $attributes); 322 | } 323 | 324 | /** 325 | * @param array $attributes 326 | * @return ReceiptPayment 327 | */ 328 | public function receiptPayment($attributes = []) 329 | { 330 | return new ReceiptPayment($this->connection, $attributes); 331 | } 332 | 333 | /** 334 | * @param array $attributes 335 | * @return \Picqer\Financials\Moneybird\Entities\RecurringSalesInvoice 336 | */ 337 | public function recurringSalesInvoice($attributes = []) 338 | { 339 | return new RecurringSalesInvoice($this->connection, $attributes); 340 | } 341 | 342 | /** 343 | * @param array $attributes 344 | * @return \Picqer\Financials\Moneybird\Entities\RecurringSalesInvoiceCustomField 345 | */ 346 | public function recurringSalesInvoiceCustomField($attributes = []) 347 | { 348 | return new RecurringSalesInvoiceCustomField($this->connection, $attributes); 349 | } 350 | 351 | /** 352 | * @param array $attributes 353 | * @return \Picqer\Financials\Moneybird\Entities\RecurringSalesInvoiceDetail 354 | */ 355 | public function recurringSalesInvoiceDetail($attributes = []) 356 | { 357 | return new RecurringSalesInvoiceDetail($this->connection, $attributes); 358 | } 359 | 360 | /** 361 | * @param array $attributes 362 | * @return \Picqer\Financials\Moneybird\Entities\SalesInvoice 363 | */ 364 | public function salesInvoice($attributes = []) 365 | { 366 | return new SalesInvoice($this->connection, $attributes); 367 | } 368 | 369 | /** 370 | * @param array $attributes 371 | * @return \Picqer\Financials\Moneybird\Entities\SalesInvoiceCustomField 372 | */ 373 | public function salesInvoiceCustomField($attributes = []) 374 | { 375 | return new SalesInvoiceCustomField($this->connection, $attributes); 376 | } 377 | 378 | /** 379 | * @param array $attributes 380 | * @return \Picqer\Financials\Moneybird\Entities\SalesInvoiceDetail 381 | */ 382 | public function salesInvoiceDetail($attributes = []) 383 | { 384 | return new SalesInvoiceDetail($this->connection, $attributes); 385 | } 386 | 387 | /** 388 | * @param array $attributes 389 | * @return \Picqer\Financials\Moneybird\Entities\SalesInvoicePayment 390 | */ 391 | public function salesInvoicePayment($attributes = []) 392 | { 393 | return new SalesInvoicePayment($this->connection, $attributes); 394 | } 395 | 396 | /** 397 | * @param array $attributes 398 | * @return \Picqer\Financials\Moneybird\Entities\SalesInvoiceReminder 399 | */ 400 | public function salesInvoiceReminder($attributes = []) 401 | { 402 | return new SalesInvoiceReminder($this->connection, $attributes); 403 | } 404 | 405 | /** 406 | * @param array $attributes 407 | * @return \Picqer\Financials\Moneybird\Entities\TaxRate 408 | */ 409 | public function taxRate($attributes = []) 410 | { 411 | return new TaxRate($this->connection, $attributes); 412 | } 413 | 414 | /** 415 | * @param array $attributes 416 | * @return \Picqer\Financials\Moneybird\Entities\TimeEntry 417 | */ 418 | public function timeEntry($attributes = []) 419 | { 420 | return new TimeEntry($this->connection, $attributes); 421 | } 422 | 423 | /** 424 | * @param array $attributes 425 | * @return \Picqer\Financials\Moneybird\Entities\TypelessDocument 426 | */ 427 | public function typelessDocument($attributes = []) 428 | { 429 | return new TypelessDocument($this->connection, $attributes); 430 | } 431 | 432 | /** 433 | * @param array $attributes 434 | * @return \Picqer\Financials\Moneybird\Entities\User 435 | */ 436 | public function user($attributes = []) 437 | { 438 | return new User($this->connection, $attributes); 439 | } 440 | 441 | /** 442 | * @param array $attributes 443 | * @return \Picqer\Financials\Moneybird\Entities\Webhook 444 | */ 445 | public function webhook($attributes = []) 446 | { 447 | return new Webhook($this->connection, $attributes); 448 | } 449 | 450 | /** 451 | * @param array $attributes 452 | * @return \Picqer\Financials\Moneybird\Entities\Workflow 453 | */ 454 | public function workflow($attributes = []) 455 | { 456 | return new Workflow($this->connection, $attributes); 457 | } 458 | 459 | /** 460 | * @param array $attributes 461 | * @return \Picqer\Financials\Moneybird\Entities\Subscription 462 | */ 463 | public function subscription($attributes = []) 464 | { 465 | return new Subscription($this->connection, $attributes); 466 | } 467 | } 468 | -------------------------------------------------------------------------------- /tests/ApiTest.php: -------------------------------------------------------------------------------- 1 | connection = $this->getMockBuilder(Connection::class)->getMock(); 39 | $this->connection->setTesting(true); 40 | $this->mockedConnection = $this->connection; 41 | $this->client = new Moneybird($this->connection); 42 | } 43 | 44 | public function testConnectionIsProperlyMocked() 45 | { 46 | $this->assertInstanceOf(Connection::class, $this->connection); 47 | } 48 | 49 | public function testTestModeIsProperlySet() 50 | { 51 | $this->mockedConnection->expects($this->once()) 52 | ->method('isTesting') 53 | ->will($this->returnValue(true)); 54 | 55 | $this->assertTrue($this->connection->isTesting()); 56 | } 57 | 58 | public function testSetAdministrationIdSet() 59 | { 60 | $administrationId = 123456789; 61 | $this->mockedConnection->expects($this->once()) 62 | ->method('setAdministrationId') 63 | ->with($administrationId); 64 | 65 | $this->connection->setAdministrationId($administrationId); 66 | } 67 | 68 | /** 69 | * @throws \Picqer\Financials\Moneybird\Exceptions\ApiException 70 | */ 71 | public function testFindAllWebHooksTriggersHttpGet() 72 | { 73 | $attributes = [ 74 | 'url' => 'https://www.domain.ext', 75 | ]; 76 | 77 | $this->mockedConnection->expects($this->once()) 78 | ->method('get') 79 | ->will($this->returnValue($attributes)); 80 | 81 | $webHook = $this->client->webhook($attributes); 82 | $webHook->get(); 83 | } 84 | 85 | /** 86 | * @throws \Picqer\Financials\Moneybird\Exceptions\ApiException 87 | */ 88 | public function testStoreWebHookTriggersHttpPost() 89 | { 90 | $attributes = [ 91 | 'url' => 'https://www.domain.ext', 92 | ]; 93 | 94 | $this->mockedConnection->expects($this->once()) 95 | ->method('post') 96 | ->will($this->returnValue($attributes)); 97 | 98 | $webHook = $this->client->webhook($attributes); 99 | $webHook->save(); 100 | } 101 | 102 | /** 103 | * @throws \Picqer\Financials\Moneybird\Exceptions\ApiException 104 | */ 105 | public function testFinancialMutationLinkToBooking() 106 | { 107 | $financialMutationId = 1; 108 | $bookingType = 'LedgerAccount'; 109 | $bookingId = 100; 110 | $priceBase = 100.00; 111 | $parameters = [ 112 | 'booking_type' => $bookingType, 113 | 'booking_id' => $bookingId, 114 | 'price_base' => $priceBase, 115 | ]; 116 | $httpResponseCode = 200; 117 | 118 | $this->mockedConnection->expects($this->once()) 119 | ->method('patch') 120 | ->with('financial_mutations/' . $financialMutationId . '/link_booking', json_encode($parameters)) 121 | ->will($this->returnValue($httpResponseCode)); 122 | 123 | $financialMutation = $this->client->financialMutation(); 124 | $financialMutation->id = $financialMutationId; 125 | $this->assertEquals($httpResponseCode, $financialMutation->linkToBooking($bookingType, $bookingId, $priceBase)); 126 | } 127 | 128 | /** 129 | * @throws \Picqer\Financials\Moneybird\Exceptions\ApiException 130 | */ 131 | public function testFinancialMutationUnlinkFromBooking() 132 | { 133 | $financialMutationId = 1; 134 | $bookingType = 'LedgerAccountBooking'; 135 | $bookingId = 100; 136 | $parameters = [ 137 | 'booking_type' => $bookingType, 138 | 'booking_id' => $bookingId, 139 | ]; 140 | $response = []; 141 | 142 | $this->mockedConnection->expects($this->once()) 143 | ->method('delete') 144 | ->with('financial_mutations/' . $financialMutationId . '/unlink_booking', json_encode($parameters)) 145 | ->will($this->returnValue($response)); 146 | 147 | $financialMutation = $this->client->financialMutation(); 148 | $financialMutation->id = $financialMutationId; 149 | $this->assertEquals($response, $financialMutation->unlinkFromBooking($bookingType, $bookingId)); 150 | } 151 | } 152 | -------------------------------------------------------------------------------- /tests/ConnectionTest.php: -------------------------------------------------------------------------------- 1 | container = []; 37 | $history = Middleware::history($this->container); 38 | 39 | $connection = new Connection(); 40 | $connection->insertMiddleWare($history); 41 | if (count($additionalMiddlewares) > 0) { 42 | foreach ($additionalMiddlewares as $additionalMiddleware) { 43 | $connection->insertMiddleWare($additionalMiddleware); 44 | } 45 | } 46 | $connection->setClientId('testClientId'); 47 | $connection->setClientSecret('testClientSecret'); 48 | $connection->setAccessToken('testAccessToken'); 49 | $connection->setAuthorizationCode('testAuthorizationCode'); 50 | $connection->setRedirectUrl('testRedirectUrl'); 51 | $connection->setTesting(true); 52 | 53 | return $connection; 54 | } 55 | 56 | /** 57 | * @param int $requestNumber 58 | * @return RequestInterface 59 | */ 60 | private function getRequestFromHistoryContainer($requestNumber = 0) 61 | { 62 | $this->assertArrayHasKey($requestNumber, $this->container); 63 | $this->assertArrayHasKey('request', $this->container[$requestNumber]); 64 | $this->assertInstanceOf(RequestInterface::class, $this->container[$requestNumber]['request']); 65 | 66 | return $this->container[$requestNumber]['request']; 67 | } 68 | 69 | /** 70 | * @throws ApiException 71 | */ 72 | public function testClientIncludesAuthenticationHeader() 73 | { 74 | $connection = $this->getConnectionForTesting(); 75 | 76 | $contact = new Contact($connection); 77 | $contact->get(); 78 | 79 | $request = $this->getRequestFromHistoryContainer(); 80 | $this->assertEquals('Bearer testAccessToken', $request->getHeaderLine('Authorization')); 81 | } 82 | 83 | /** 84 | * @throws ApiException 85 | */ 86 | public function testClientIncludesJsonHeaders() 87 | { 88 | $connection = $this->getConnectionForTesting(); 89 | 90 | $contact = new Contact($connection); 91 | $contact->get(); 92 | 93 | $request = $this->getRequestFromHistoryContainer(); 94 | $this->assertEquals('application/json', $request->getHeaderLine('Accept')); 95 | $this->assertEquals('application/json', $request->getHeaderLine('Content-Type')); 96 | } 97 | 98 | /** 99 | * @throws ApiException 100 | */ 101 | public function testClientTriesToGetAccessTokenWhenNoneGiven() 102 | { 103 | $connection = $this->getConnectionForTesting(); 104 | $connection->setAccessToken(null); 105 | 106 | $contact = new Contact($connection); 107 | $contact->get(); 108 | 109 | $request = $this->getRequestFromHistoryContainer(); 110 | $this->assertEquals('POST', $request->getMethod()); 111 | 112 | $request->getBody()->rewind(); 113 | $this->assertEquals( 114 | 'redirect_uri=testRedirectUrl&grant_type=authorization_code&client_id=testClientId&client_secret=testClientSecret&code=testAuthorizationCode', 115 | $request->getBody()->getContents() 116 | ); 117 | } 118 | 119 | /** 120 | * @throws ApiException 121 | */ 122 | public function testClientContinuesWithRequestAfterGettingAccessTokenWhenNoneGiven() 123 | { 124 | $connection = $this->getConnectionForTesting(); 125 | $connection->setAccessToken(null); 126 | 127 | $contact = new Contact($connection); 128 | $contact->get(); 129 | 130 | $request = $this->getRequestFromHistoryContainer(1); 131 | $this->assertEquals('GET', $request->getMethod()); 132 | } 133 | 134 | /** 135 | * @throws ApiException 136 | */ 137 | public function testClientDetectsApiRateLimit() 138 | { 139 | $responseStatusCode = 429; 140 | $responseHeaderName = 'RateLimit-Remaining'; 141 | $responseHeaderValue = 60; 142 | 143 | // Note that middlewares are processed 'LIFO': first the response header should be added, then an exception thrown 144 | $additionalMiddlewares = [ 145 | $this->getMiddleWareThatThrowsBadResponseException($responseStatusCode), 146 | $this->getMiddleWareThatAddsResponseHeader($responseHeaderName, $responseHeaderValue), 147 | ]; 148 | 149 | $connection = $this->getConnectionForTesting($additionalMiddlewares); 150 | $contact = new Contact($connection); 151 | try { 152 | $contact->get(); 153 | } catch (TooManyRequestsException $exception) { 154 | $this->assertEquals($responseStatusCode, $exception->getCode()); 155 | $this->assertEquals($responseHeaderValue, $exception->retryAfterNumberOfSeconds); 156 | } 157 | } 158 | 159 | private function getMiddleWareThatAddsResponseHeader($header, $value) 160 | { 161 | return function (callable $handler) use ($header, $value) { 162 | return function (RequestInterface $request, array $options) use ($handler, $header, $value) { 163 | /* @var PromiseInterface $promise */ 164 | $promise = $handler($request, $options); 165 | 166 | return $promise->then( 167 | function (ResponseInterface $response) use ($header, $value) { 168 | return $response->withHeader($header, $value); 169 | } 170 | ); 171 | }; 172 | }; 173 | } 174 | 175 | private function getMiddleWareThatThrowsBadResponseException($statusCode = null) 176 | { 177 | return function (callable $handler) use ($statusCode) { 178 | return function (RequestInterface $request, array $options) use ($handler, $statusCode) { 179 | /* @var PromiseInterface $promise */ 180 | $promise = $handler($request, $options); 181 | 182 | return $promise->then( 183 | function (ResponseInterface $response) use ($request, $statusCode) { 184 | if (is_int($statusCode)) { 185 | $response = $response->withStatus($statusCode); 186 | } 187 | 188 | throw new BadResponseException('DummyException as injected by: ' . __METHOD__, $request, $response); 189 | } 190 | ); 191 | }; 192 | }; 193 | } 194 | } 195 | -------------------------------------------------------------------------------- /tests/EntityTest.php: -------------------------------------------------------------------------------- 1 | performEntityTest(Administration::class); 60 | } 61 | 62 | public function testContactEntity() 63 | { 64 | $this->performEntityTest(Contact::class); 65 | } 66 | 67 | public function testContactPeopleEntity() 68 | { 69 | $this->performEntityTest(ContactPeople::class); 70 | } 71 | 72 | public function testContactCustomFieldEntity() 73 | { 74 | $this->performEntityTest(ContactCustomField::class); 75 | } 76 | 77 | public function testCustomFieldEntity() 78 | { 79 | $this->performEntityTest(CustomField::class); 80 | } 81 | 82 | public function testDocumentStyleEntity() 83 | { 84 | $this->performEntityTest(DocumentStyle::class); 85 | } 86 | 87 | public function testEstimateEntity() 88 | { 89 | $this->performEntityTest(Estimate::class); 90 | } 91 | 92 | public function testExternalSalesInvoice() 93 | { 94 | $this->performEntityTest(ExternalSalesInvoice::class); 95 | } 96 | 97 | public function testExternalSalesInvoiceDetail() 98 | { 99 | $this->performEntityTest(ExternalSalesInvoiceDetail::class); 100 | } 101 | 102 | public function testExternalSalesInvoicePayment() 103 | { 104 | $this->performEntityTest(ExternalSalesInvoicePayment::class); 105 | } 106 | 107 | public function testFinancialAccountEntity() 108 | { 109 | $this->performEntityTest(FinancialAccount::class); 110 | } 111 | 112 | public function testFinancialMutationEntity() 113 | { 114 | $this->performEntityTest(FinancialMutation::class); 115 | } 116 | 117 | public function testGeneralDocumentEntity() 118 | { 119 | $this->performEntityTest(GeneralDocument::class); 120 | } 121 | 122 | public function testGeneralJournalDocumentEntity() 123 | { 124 | $this->performEntityTest(GeneralJournalDocument::class); 125 | } 126 | 127 | public function testIdentityEntity() 128 | { 129 | $this->performEntityTest(Identity::class); 130 | } 131 | 132 | public function testImportMappingEntity() 133 | { 134 | $this->performEntityTest(ImportMapping::class); 135 | } 136 | 137 | public function testLedgerAccountEntity() 138 | { 139 | $this->performEntityTest(LedgerAccount::class); 140 | } 141 | 142 | public function testProductEntity() 143 | { 144 | $this->performEntityTest(Product::class); 145 | } 146 | 147 | public function testProjectEntity() 148 | { 149 | $this->performEntityTest(Project::class); 150 | } 151 | 152 | public function testPurchaseInvoiceEntity() 153 | { 154 | $this->performEntityTest(PurchaseInvoice::class); 155 | } 156 | 157 | public function testPurchaseDetailInvoiceEntity() 158 | { 159 | $this->performEntityTest(PurchaseInvoiceDetail::class); 160 | } 161 | 162 | public function testReceiptEntity() 163 | { 164 | $this->performEntityTest(Receipt::class); 165 | } 166 | 167 | public function testReceiptDetailEntity() 168 | { 169 | $this->performEntityTest(ReceiptDetail::class); 170 | } 171 | 172 | public function testReceiptPaymentEntity() 173 | { 174 | $this->performEntityTest(ReceiptPayment::class); 175 | } 176 | 177 | public function testRecurringSalesInvoiceEntity() 178 | { 179 | $this->performEntityTest(RecurringSalesInvoice::class); 180 | } 181 | 182 | public function testRecurringSalesInvoiceDetailEntity() 183 | { 184 | $this->performEntityTest(RecurringSalesInvoiceDetail::class); 185 | } 186 | 187 | public function testRecurringSalesInvoiceCustomFieldEntity() 188 | { 189 | $this->performEntityTest(RecurringSalesInvoiceCustomField::class); 190 | } 191 | 192 | public function testSalesInvoiceEntity() 193 | { 194 | $this->performEntityTest(SalesInvoice::class); 195 | } 196 | 197 | public function testSalesInvoiceDetailEntity() 198 | { 199 | $this->performEntityTest(SalesInvoiceDetail::class); 200 | } 201 | 202 | public function testSalesInvoiceEventEntity() 203 | { 204 | $this->performEntityTest(SalesInvoiceEvent::class); 205 | } 206 | 207 | public function testSalesInvoicePaymentEntity() 208 | { 209 | $this->performEntityTest(SalesInvoicePayment::class); 210 | } 211 | 212 | public function testSalesInvoiceReminderEntity() 213 | { 214 | $this->performEntityTest(SalesInvoiceReminder::class); 215 | } 216 | 217 | public function testSalesInvoiceTaxTotalEntity() 218 | { 219 | $this->performEntityTest(SalesInvoiceTaxTotal::class); 220 | } 221 | 222 | public function testSubscriptionEntity() 223 | { 224 | $this->performEntityTest(Subscription::class); 225 | } 226 | 227 | public function testNoteEntity() 228 | { 229 | $this->performEntityTest(Note::class); 230 | } 231 | 232 | public function testTaxRateEntity() 233 | { 234 | $this->performEntityTest(TaxRate::class); 235 | } 236 | 237 | public function testTimeEntryEntity() 238 | { 239 | $this->performEntityTest(TimeEntry::class); 240 | } 241 | 242 | public function testTypelessDocumentEntity() 243 | { 244 | $this->performEntityTest(TypelessDocument::class); 245 | } 246 | 247 | public function testUserEntity() 248 | { 249 | $this->performEntityTest(User::class); 250 | } 251 | 252 | public function testWebhookEntity() 253 | { 254 | $this->performEntityTest(Webhook::class); 255 | } 256 | 257 | public function testWorkflowEntity() 258 | { 259 | $this->performEntityTest(Workflow::class); 260 | } 261 | 262 | public function testMoneybirdClass() 263 | { 264 | $reflectionClass = new \ReflectionClass(Moneybird::class); 265 | 266 | $this->assertTrue($reflectionClass->isInstantiable()); 267 | $this->assertTrue($reflectionClass->hasProperty('connection')); 268 | $this->assertEquals('Picqer\Financials\Moneybird', $reflectionClass->getNamespaceName()); 269 | } 270 | 271 | private function performEntityTest($entityName) 272 | { 273 | $reflectionClass = new \ReflectionClass($entityName); 274 | 275 | $this->assertTrue($reflectionClass->isInstantiable()); 276 | $this->assertTrue($reflectionClass->hasProperty('fillable')); 277 | $this->assertTrue($reflectionClass->hasProperty('endpoint')); 278 | $this->assertEquals('Picqer\Financials\Moneybird\Entities', $reflectionClass->getNamespaceName()); 279 | } 280 | } 281 | -------------------------------------------------------------------------------- /tests/PicqerTest/Financials/Moneybird/Entities/SalesInvoice/SendInvoiceOptionsTest.php: -------------------------------------------------------------------------------- 1 | getMethod()); 25 | self::assertEquals('my-email@foo.com', $options->getEmailAddress()); 26 | self::assertEquals('my message', $options->getEmailMessage()); 27 | } 28 | 29 | public function testDefaultMethodIsEmail() 30 | { 31 | $options = new SendInvoiceOptions(); 32 | self::assertEquals(SendInvoiceOptions::METHOD_EMAIL, $options->getMethod()); 33 | } 34 | 35 | public function testMethodIsValidated() 36 | { 37 | try { 38 | new SendInvoiceOptions('some-invalid-method'); 39 | self::fail('Should have thrown exception'); 40 | } catch (InvalidArgumentException $e) { 41 | foreach ($this->validMethods as $validMethod) { 42 | self::assertStringContainsString($validMethod, $e->getMessage()); 43 | } 44 | 45 | self::assertStringContainsString('some-invalid-method', $e->getMessage(), 46 | 'Did not provide which value is invalid'); 47 | } 48 | } 49 | 50 | public function testIsSerializable() 51 | { 52 | $options = new SendInvoiceOptions(); 53 | self::assertInstanceOf(JsonSerializable::class, $options); 54 | } 55 | 56 | public function testSerializes() 57 | { 58 | $options = new SendInvoiceOptions(null, 'test@foo.com', 'my message'); 59 | $options->setDeliverUbl(true); 60 | $options->setMergeable(true); 61 | $options->schedule(new DateTime('2018-01-01')); 62 | 63 | $json = $options->jsonSerialize(); 64 | self::assertEquals(SendInvoiceOptions::METHOD_EMAIL, $json['delivery_method']); 65 | self::assertEquals(true, $json['sending_scheduled']); 66 | self::assertEquals(true, $json['mergeable']); 67 | self::assertEquals('test@foo.com', $json['email_address']); 68 | self::assertEquals('my message', $json['email_message']); 69 | self::assertEquals('2018-01-01', $json['invoice_date']); 70 | } 71 | 72 | public function testOmitsNullValues() 73 | { 74 | $options = new SendInvoiceOptions(null, 'test@foo.com'); 75 | 76 | $json = $options->jsonSerialize(); 77 | self::assertEquals(SendInvoiceOptions::METHOD_EMAIL, $json['delivery_method']); 78 | self::assertEquals('test@foo.com', $json['email_address']); 79 | 80 | $omittedKeys = [ 81 | 'sending_scheduled', 'email_message', 'invoice_date', 82 | 'mergeable', 83 | ]; 84 | foreach ($omittedKeys as $key) { 85 | self::assertArrayNotHasKey($key, $json); 86 | } 87 | } 88 | 89 | public function testGetSetEmailAddress() 90 | { 91 | $options = new SendInvoiceOptions(); 92 | self::assertNull($options->getEmailAddress()); 93 | 94 | $options->setEmailAddress('foo@bar.com'); 95 | self::assertEquals('foo@bar.com', $options->getEmailAddress()); 96 | } 97 | 98 | public function testGetSetEmailMessage() 99 | { 100 | $options = new SendInvoiceOptions(); 101 | self::assertNull($options->getEmailAddress()); 102 | 103 | $options->setEmailMessage('my message'); 104 | self::assertEquals('my message', $options->getEmailMessage()); 105 | } 106 | 107 | public function testGetSetSchedule() 108 | { 109 | $options = new SendInvoiceOptions(); 110 | self::assertFalse($options->isScheduled()); 111 | self::assertNull($options->getScheduleDate()); 112 | 113 | $date = new DateTime(); 114 | $options->schedule($date); 115 | 116 | self::assertEquals($date, $options->getScheduleDate()); 117 | self::assertTrue($options->isScheduled()); 118 | } 119 | 120 | public function testGetSetDeliverUbl() 121 | { 122 | $options = new SendInvoiceOptions(); 123 | self::assertNull($options->getDeliverUbl()); 124 | 125 | $options->setDeliverUbl(true); 126 | self::assertTrue($options->getDeliverUbl()); 127 | } 128 | 129 | public function testGetSetMethod() 130 | { 131 | $options = new SendInvoiceOptions(); 132 | 133 | foreach ($this->validMethods as $method) { 134 | $options->setMethod($method); 135 | self::assertEquals($method, $options->getMethod()); 136 | } 137 | } 138 | 139 | public function testGetSetMergeable() 140 | { 141 | $options = new SendInvoiceOptions(); 142 | self::assertNull($options->getMergeable()); 143 | 144 | $options->setMergeable(true); 145 | self::assertTrue($options->getMergeable()); 146 | } 147 | } 148 | -------------------------------------------------------------------------------- /tests/PicqerTest/Financials/Moneybird/Entities/SalesInvoiceTest.php: -------------------------------------------------------------------------------- 1 | connection = $this->prophesize(Connection::class); 31 | $this->options = $this->prophesize(SalesInvoice\SendInvoiceOptions::class); 32 | $this->optionsJson = [ 33 | 'my-key' => 'my value', 34 | ]; 35 | $this->options->jsonSerialize()->willReturn($this->optionsJson); 36 | 37 | $this->salesInvoice = new SalesInvoice($this->connection->reveal()); 38 | } 39 | 40 | public function testSendInvoiceThrowsExceptionWhenNonOptionsPassed() 41 | { 42 | try { 43 | $this->salesInvoice->sendInvoice(false); 44 | self::fail('Should have thrown exception'); 45 | } catch (InvalidArgumentException $e) { 46 | $this->addToAssertionCount(1); 47 | } 48 | 49 | try { 50 | $this->salesInvoice->sendInvoice(new \stdClass()); 51 | self::fail('Should have thrown exception'); 52 | } catch (InvalidArgumentException $e) { 53 | $this->addToAssertionCount(1); 54 | } 55 | } 56 | 57 | public function testSendWithoutArguments() 58 | { 59 | $this->connection->patch(new AnyValueToken(), json_encode([ 60 | 'sales_invoice_sending' => [ 61 | 'delivery_method' => SalesInvoice\SendInvoiceOptions::METHOD_EMAIL, 62 | ], 63 | ]))->shouldBeCalled(); 64 | 65 | $this->salesInvoice->sendInvoice(); 66 | } 67 | 68 | public function testSendWithMethodAsString() 69 | { 70 | $this->connection->patch(new AnyValueToken(), json_encode([ 71 | 'sales_invoice_sending' => [ 72 | 'delivery_method' => SalesInvoice\SendInvoiceOptions::METHOD_EMAIL, 73 | ], 74 | ]))->shouldBeCalled(); 75 | 76 | $this->salesInvoice->sendInvoice(SalesInvoice\SendInvoiceOptions::METHOD_EMAIL); 77 | } 78 | 79 | public function testSendWithOptionsObject() 80 | { 81 | $this->connection->patch(new AnyValueToken(), json_encode([ 82 | 'sales_invoice_sending' => $this->optionsJson, 83 | ]))->shouldBeCalled(); 84 | 85 | $this->salesInvoice->sendInvoice($this->options->reveal()); 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /tests/PicqerTest/Financials/Moneybird/ModelTest.php: -------------------------------------------------------------------------------- 1 | connection = $this->prophesize(Connection::class); 26 | } 27 | 28 | public function testMakeFromResponse() 29 | { 30 | $note = new Note($this->connection->reveal()); 31 | 32 | $id = 1; 33 | $noteText = __METHOD__; 34 | $isToDo = true; 35 | $dummyResponse = [ 36 | 'id' => $id, 37 | 'note' => $noteText, 38 | 'todo' => $isToDo, 39 | 'fakePropertyThatShouldNotBePopulated' => ' ignoredValue', 40 | ]; 41 | 42 | $note = $note->makeFromResponse($dummyResponse); 43 | 44 | $this->assertEquals($id, $note->id); 45 | $this->assertEquals($noteText, $note->note); 46 | $this->assertEquals($isToDo, $note->todo); 47 | $this->assertNull($note->fakePropertyThatShouldNotBePopulated); 48 | } 49 | 50 | public function testMakeFromResponseWithSingleNestedEntities() 51 | { 52 | $salesInvoice = new SalesInvoice($this->connection->reveal()); 53 | 54 | $salesInvoiceId = 1; 55 | $contactId = 10; 56 | 57 | $dummyResponse = [ 58 | 'id' => $salesInvoiceId, 59 | 'contact' => [ 60 | 'id' => $contactId, 61 | ], 62 | ]; 63 | 64 | $salesInvoice = $salesInvoice->makeFromResponse($dummyResponse); 65 | 66 | $this->assertEquals($salesInvoiceId, $salesInvoice->id); 67 | $this->assertInstanceOf(Contact::class, $salesInvoice->contact); 68 | $this->assertEquals($contactId, $salesInvoice->contact->id); 69 | } 70 | 71 | public function testMakeFromResponseWithMultipleNestedEntities() 72 | { 73 | $contact = new Contact($this->connection->reveal()); 74 | 75 | $id = 1; 76 | $dummyCustomFields = [ 77 | [ 78 | 'id' => 1, 79 | 'name' => 'dummyCustomFieldName1', 80 | 'value' => 'dummyCustomFieldName1', 81 | ], 82 | [ 83 | 'id' => 2, 84 | 'name' => 'dummyCustomFieldName2', 85 | 'value' => 'dummyCustomFieldName2', 86 | ], 87 | ]; 88 | $dummyResponse = [ 89 | 'id' => $id, 90 | 'custom_fields' => $dummyCustomFields, 91 | ]; 92 | 93 | $contact = $contact->makeFromResponse($dummyResponse); 94 | 95 | $this->assertEquals($id, $contact->id); 96 | $this->assertCount(count($dummyCustomFields), $contact->custom_fields); 97 | foreach ($contact->custom_fields as $customContactField) { 98 | $this->assertInstanceOf(ContactCustomField::class, $customContactField); 99 | } 100 | } 101 | 102 | public function testRegisterAttributesAsDirty() 103 | { 104 | $invoice = new SalesInvoice($this->connection->reveal()); 105 | 106 | $id = 1; 107 | $invoice_date = '2019-01-01'; 108 | $dummyResponse = [ 109 | 'id' => $id, 110 | 'invoice_date' => $invoice_date, 111 | 'ignoredAttribute' => 'ignoredValue', 112 | ]; 113 | 114 | $invoice = $invoice->makeFromResponse($dummyResponse); 115 | 116 | //check if the correct dirty values are set 117 | $this->assertEquals('id', $invoice->getDirty()[0]); 118 | $this->assertEquals('invoice_date', $invoice->getDirty()[1]); 119 | $this->assertEquals(2, count($invoice->getDirty())); 120 | $this->assertTrue($invoice->isAttributeDirty('id')); 121 | $this->assertTrue($invoice->isAttributeDirty('invoice_date')); 122 | $this->assertFalse($invoice->isAttributeDirty('unknown_key')); 123 | 124 | //check if the getDirtyValues from is null (new object) 125 | $this->assertEquals(null, $invoice->getDirtyValues()['invoice_date']['from']); 126 | $this->assertEquals(null, $invoice->getDirtyValues()['id']['from']); 127 | 128 | //check if the getDirtyValues from is filled with the new value (new object) 129 | $this->assertEquals($id, $invoice->getDirtyValues()['id']['to']); 130 | $this->assertEquals($invoice_date, $invoice->getDirtyValues()['invoice_date']['to']); 131 | } 132 | } 133 | --------------------------------------------------------------------------------