├── .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 | 
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 |
--------------------------------------------------------------------------------