From ad0601dfa49e01af97b9457a8752da6b78d249e3 Mon Sep 17 00:00:00 2001 From: Stephan Groen Date: Wed, 23 Sep 2026 14:49:28 +0200 Subject: [PATCH 1/2] feat: Add InvoiceSalesOrderResult::findByProcessId() Keeps the ProcessID parameter format in one place in the library, so users don't have to build "guid'...'" themselves. Co-Authored-By: Claude Opus 5.5 --- .../Exact/InvoiceSalesOrderResult.php | 10 +++++++ tests/InvoiceSalesOrderTest.php | 30 +++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/src/Picqer/Financials/Exact/InvoiceSalesOrderResult.php b/src/Picqer/Financials/Exact/InvoiceSalesOrderResult.php index 310dd477..6a333885 100644 --- a/src/Picqer/Financials/Exact/InvoiceSalesOrderResult.php +++ b/src/Picqer/Financials/Exact/InvoiceSalesOrderResult.php @@ -30,4 +30,14 @@ class InvoiceSalesOrderResult extends Model ]; protected $url = 'read/salesinvoice/InvoiceSalesOrderResult'; + + /** + * Get the result of the process started by InvoiceSalesOrder, using the ProcessID it returned. + */ + public function findByProcessId(string $processId): ?self + { + $results = $this->get(['ProcessID' => "guid'{$processId}'"]); + + return $results[0] ?? null; + } } diff --git a/tests/InvoiceSalesOrderTest.php b/tests/InvoiceSalesOrderTest.php index 9d43ae0e..e83d2a5b 100644 --- a/tests/InvoiceSalesOrderTest.php +++ b/tests/InvoiceSalesOrderTest.php @@ -50,6 +50,36 @@ public function testInvoiceSalesOrdersAsync(): void $this->assertSame(self::PROCESS_ID, $invoiceSalesOrder->ProcessID); } + public function testFindResultByProcessId(): void + { + $connection = $this->createMockConnection($this->createMockHandler([ + new Response(200, [], json_encode(['d' => ['results' => [[ + 'ProcessID' => self::PROCESS_ID, + 'NumberOfCreatedInvoices' => 2, + 'Status' => 'Processed', + ]]]])), + ])); + + $result = (new InvoiceSalesOrderResult($connection))->findByProcessId(self::PROCESS_ID); + + $this->assertSame( + "https://start.exactonline.nl/api/v1/1234567890/read/salesinvoice/InvoiceSalesOrderResult?ProcessID=guid'" . self::PROCESS_ID . "'", + urldecode((string) $this->sentRequests()[0]->getUri()) + ); + $this->assertNotNull($result); + $this->assertSame('Processed', $result->Status); + $this->assertSame(2, $result->NumberOfCreatedInvoices); + } + + public function testFindResultByProcessIdReturnsNullWhenNotFound(): void + { + $connection = $this->createMockConnection($this->createMockHandler([ + new Response(200, [], json_encode(['d' => ['results' => []]])), + ])); + + $this->assertNull((new InvoiceSalesOrderResult($connection))->findByProcessId(self::PROCESS_ID)); + } + public function testGetResultOfAsyncProcess(): void { $connection = $this->createMockConnection($this->createMockHandler([ From a53b6a3864ca34e3099b8adaeaae3bc71726bfc4 Mon Sep 17 00:00:00 2001 From: Stephan Groen Date: Wed, 23 Sep 2026 14:52:27 +0200 Subject: [PATCH 2/2] docs: Rewrite README Restructures the README around the tasks users have (authenticate, read, write, related data, rate limits, errors, webhooks) and brings it up to date: - Fix examples that led to broken setups: tokens are now stored through the token update callback (refresh tokens rotate), lock callbacks are set before the first refresh, find()/get() results are used instead of discarded, download() returns a stream, tokens are no longer serialized - Document what was missing: error handling and status codes, filter options, first()/findId(), manual paging, sync and bulk endpoints, lazy loading and $expand, setting the division, base urls per country, state for CSRF, logging via middleware, webhook code examples - Update troubleshooting, add a versions table and upgrade notes, add a contributing section with the entity generator - Remove the job openings link and leftover text copied from a PR Co-Authored-By: Claude Opus 5.5 --- README.md | 619 ++++++++++++++++++++++++++++++++++++++---------------- 1 file changed, 439 insertions(+), 180 deletions(-) diff --git a/README.md b/README.md index 5d58f79e..4f8f230b 100644 --- a/README.md +++ b/README.md @@ -1,290 +1,549 @@ -# Exact PHP Client -![Run phpunit](https://github.com/picqer/exact-php-client/workflows/Run%20phpunit/badge.svg) +# Exact Online PHP client -PHP client library for the Exact Online API. This client lets you integrate with Exact Online, for example by: -- creating and sending invoices, -- add journal entries, -- or upload received invoices. +[![Tests](https://github.com/picqer/exact-php-client/actions/workflows/phpunit.yml/badge.svg)](https://github.com/picqer/exact-php-client/actions/workflows/phpunit.yml) +[![Latest version](https://img.shields.io/packagist/v/picqer/exact-php-client)](https://packagist.org/packages/picqer/exact-php-client) +[![Downloads](https://img.shields.io/packagist/dt/picqer/exact-php-client)](https://packagist.org/packages/picqer/exact-php-client) -This client uses the same naming and conventions as the Exact API itself, so best way to find out how this client works is by [looking at the Exact Online documentation](https://support.exactonline.com/community/s/knowledge-base#All-All-DNO-Content-restrefdocs) and [API reference](https://start.exactonline.nl/docs/HlpRestAPIResources.aspx?SourceAction=10). +A PHP client for the [Exact Online REST API](https://start.exactonline.nl/docs/HlpRestAPIResources.aspx?SourceAction=10). Use it to create invoices, book journal entries, sync items, stock and relations, and anything else the API offers. -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)! +The client follows the naming of the Exact Online API. Every endpoint is a class with the same name, and every field has the same name as in the API. The [Exact API reference](https://start.exactonline.nl/docs/HlpRestAPIResources.aspx?SourceAction=10) is therefore also the reference for this library. -## Composer install -Installing this Exact client for PHP can be done through Composer. +Created and maintained by [Picqer](https://picqer.com). -``` +## Contents + +- [Installation](#installation) +- [Quick start](#quick-start) +- [Authentication](#authentication) +- [Divisions (administrations)](#divisions-administrations) +- [Other countries](#other-countries) +- [Reading data](#reading-data) +- [Creating, updating and deleting](#creating-updating-and-deleting) +- [Related data and files](#related-data-and-files) +- [Examples](#examples) +- [Rate limits](#rate-limits) +- [Error handling](#error-handling) +- [Webhooks](#webhooks) +- [Troubleshooting](#troubleshooting) +- [Versions and upgrading](#versions-and-upgrading) +- [Contributing](#contributing) + +## Installation + +```bash composer require picqer/exact-php-client ``` -## Usage +Requires PHP 7.4 or higher and Guzzle 7.15.2 or higher. + +## Quick start + +1. Create an app in the [Exact Online App Center](https://apps.exactonline.com) to get a **client ID** and **client secret**, and set the **redirect URL** to a page of your application. +2. Send the user to Exact to authorize your app. Exact redirects back to your redirect URL with an authorization code. +3. Exchange the code for tokens, store them, and start calling the API. + +Once you have tokens, using the API looks like this: + +```php +use Picqer\Financials\Exact\Connection; +use Picqer\Financials\Exact\Item; + +$connection = new Connection(); +$connection->setRedirectUrl('https://example.com/exact/callback'); +$connection->setExactClientId('CLIENT_ID'); +$connection->setExactClientSecret('CLIENT_SECRET'); +$connection->setAccessToken($storedAccessToken); +$connection->setRefreshToken($storedRefreshToken); +$connection->setTokenExpires($storedTokenExpires); +$connection->setTokenUpdateCallback(function (Connection $connection) { + // Store the new tokens, see "Authentication" below +}); +$connection->setDivision(123456); -1. Set up app at Exact App Center to retrieve credentials -2. Authorize the integration from your app -3. Parse callback and finish connection set up -4. Use the library to do stuff +$items = (new Item($connection))->filter('IsSalesItem eq 1'); -Steps 1 - 3 are only required once on set up. +foreach ($items as $item) { + echo $item->Code . ': ' . $item->Description . PHP_EOL; +} +``` -### Set up app at Exact App Center to retrieve credentials +A complete, runnable version of the authorization flow is in [example/example.php](example/example.php). -Set up an App at the Exact App Center to retrieve your `Client ID` and `Client Secret`. -You will also need to set the correct `Callback URL` for the oAuth dance to work. +## Authentication -### Authorize the integration from your app +Exact Online uses OAuth 2.0. A user only needs to authorize your app once. After that, the library keeps the connection alive with the refresh token. -The code below is an example `authorize()` function. +### 1. Redirect the user to Exact ```php -$connection = new \Picqer\Financials\Exact\Connection(); -$connection->setRedirectUrl('CALLBACK_URL'); // Same as entered online in the App Center +$connection = new Connection(); +$connection->setRedirectUrl('https://example.com/exact/callback'); // Same as in the App Center $connection->setExactClientId('CLIENT_ID'); $connection->setExactClientSecret('CLIENT_SECRET'); -$connection->redirectForAuthorization(); +$connection->setState($state); // Random value, verify it in your callback to prevent CSRF +$connection->redirectForAuthorization(); // Sends a redirect header and exits ``` -This will redirect the user to Exact to login and authorize your integration with their account. +Use `$connection->getAuthUrl()` instead if you want to do the redirect yourself. Use `setForceLogin(true)` to always show Exact's login screen. -### Parse callback and finish connection set up +### 2. Handle the callback + +Exact redirects back to your redirect URL with a `code` query parameter. The code can only be used **once**, so exchange it for tokens right away: + +```php +$connection = new Connection(); +$connection->setRedirectUrl('https://example.com/exact/callback'); +$connection->setExactClientId('CLIENT_ID'); +$connection->setExactClientSecret('CLIENT_SECRET'); +$connection->setAuthorizationCode($_GET['code']); +$connection->connect(); // Exchanges the code for tokens + +storeTokens( + $connection->getAccessToken(), + $connection->getRefreshToken(), + $connection->getTokenExpires() +); +``` -Exact will redirect back to the `callback url` you provided. The callback will receive a `code` param. -This is the authorization code for oAuth. Store this code. +### 3. Connect with stored tokens -Make a new connection to Exact so the library can exchange codes and fetch the `accesstoken` and `refreshtoken`. -The `accesstoken` is a temporary token which allows for communication between your app and Exact. -The `refreshtoken` is a token which is used to get a new `accesstoken` which also refreshes the `refreshtoken`. -The library will settle all of this for you. The code below could be an general `connect()` function, which returns -the api connection. +For every later request, create the connection from your stored tokens: ```php -$connection = new \Picqer\Financials\Exact\Connection(); -$connection->setRedirectUrl('CALLBACK_URL'); +$connection = new Connection(); +$connection->setRedirectUrl('https://example.com/exact/callback'); $connection->setExactClientId('CLIENT_ID'); $connection->setExactClientSecret('CLIENT_SECRET'); +$connection->setAccessToken($tokens['access_token']); +$connection->setRefreshToken($tokens['refresh_token']); +$connection->setTokenExpires($tokens['expires']); // Unix timestamp + +// Required: store new tokens whenever the library refreshes them +$connection->setTokenUpdateCallback(function (Connection $connection) { + storeTokens( + $connection->getAccessToken(), + $connection->getRefreshToken(), + $connection->getTokenExpires() + ); +}); +``` -if (getValue('authorizationcode')) { - // Retrieves authorizationcode from database - $connection->setAuthorizationCode(getValue('authorizationcode')); -} +The access token is valid for about 10 minutes. The library refreshes it automatically just before it expires, using the refresh token. -if (getValue('accesstoken')) { - // Retrieves accesstoken from database - $connection->setAccessToken(unserialize(getValue('accesstoken'))); -} +> [!IMPORTANT] +> **Always set the token update callback.** Every refresh gives you a new refresh token and invalidates the old one. If you don't store the new refresh token, your next connection uses an invalid token, and the user has to authorize your app again. -if (getValue('refreshtoken')) { - // Retrieves refreshtoken from database - $connection->setRefreshToken(getValue('refreshtoken')); -} +### Refreshing tokens from multiple processes -if (getValue('expires_in')) { - // Retrieves expires timestamp from database - $connection->setTokenExpires(getValue('expires_in')); -} +If several processes (queue workers, cron jobs, web requests) share the same tokens, two of them can refresh at the same time. One of them then ends up with an invalidated refresh token. Prevent this with a lock around the refresh: -// Make the client connect and exchange tokens -try { - $connection->connect(); -} catch (\Exception $e) -{ - throw new Exception('Could not connect to Exact: ' . $e->getMessage()); -} +```php +$connection->setAcquireAccessTokenLockCallback(function (Connection $connection) { + $lock->acquire(); // e.g. a Redis or database lock +}); + +$connection->setRefreshAccessTokenCallback(function (Connection $connection) { + // Called after acquiring the lock: another process may have refreshed the tokens in the meantime. + // Load them from storage. If they are still valid, the library uses them instead of refreshing again. + $tokens = loadTokens(); + $connection->setAccessToken($tokens['access_token']); + $connection->setRefreshToken($tokens['refresh_token']); + $connection->setTokenExpires($tokens['expires']); +}); + +$connection->setAcquireAccessTokenUnlockCallback(function (Connection $connection) { + $lock->release(); // Also called when refreshing fails +}); +``` -// Save the new tokens for next connections -setValue('accesstoken', serialize($connection->getAccessToken())); -setValue('refreshtoken', $connection->getRefreshToken()); +## Divisions (administrations) -// Optionally, save the expiry-timestamp. This prevents exchanging valid tokens (ie. saves you some requests) -setValue('expires_in', $connection->getTokenExpires()); +Almost every endpoint works within one division (administration). Set it explicitly: -// Optionally, set the lock and unlock callbacks to prevent multiple request for acquiring a new refresh token with the same refresh token. -$connection->setAcquireAccessTokenLockCallback('CALLBACK_FUNCTION'); -$connection->setAcquireAccessTokenUnlockCallback('CALLBACK_FUNCTION'); +```php +$connection->setDivision(123456); ``` -### About divisions (administrations) +If you don't, the library asks Exact for the user's current division on every new connection. That costs an extra API call, and the result changes when the user switches administrations in Exact Online. + +List the divisions the user has access to with the `Division` entity (`system/Divisions`), or all divisions of the license with `AllDivision` (`system/AllDivisions`). + +## Other countries -By default the library will use the default administration of the user. This means that when the user switches administrations in Exact Online. The library will also start working with this administration. +The library connects to the Dutch Exact Online site by default. For other countries, set the base URL **before** authorizing: -### Rate limits -Exact uses a minutely and daily rate limit. There are a maximum number of calls per day you can do per company, and to prevent bursting they have also implemented a limit per minute. This PR stores this information in the `Connection` and adds methods to read the rate limits so you can handle these as appropriate for your app. -Exact documentation on rate limits is found here: https://support.exactonline.com/community/s/knowledge-base#All-All-DNO-Simulation-gen-apilimits +```php +$connection->setBaseUrl('https://start.exactonline.be'); +``` + +| Country | Base URL | +| --- | --- | +| Netherlands | `https://start.exactonline.nl` | +| Belgium | `https://start.exactonline.be` | +| Germany | `https://start.exactonline.de` | +| United Kingdom | `https://start.exactonline.co.uk` | +| United States | `https://start.exactonline.com` | +| Spain | `https://start.exactonline.es` | +| France | `https://start.exactonline.fr` | -If you hit a rate limit, an `ApiException` will be thrown with code 429. At that point you can determine whether you've hit the minutely or the daily limit. If you've hit the minutely limit, try again after 60 seconds. If you've hit the daily limit, try again after the daily reset. +## Reading data -You can use the following methods on the `Connection`, which will return the limits after your first API call (based on the headers from Exact). +Every endpoint is a class in `Picqer\Financials\Exact`. See [src/Picqer/Financials/Exact](src/Picqer/Financials/Exact) for the full list. ```php -$connection->getDailyLimit(); // Retrieve your daily limit -$connection->getDailyLimitRemaining(); // Retrieve the remaining amount of API calls for this day -$connection->getDailyLimitReset(); // Retrieve the timestamp for when the limit will reset -$connection->getMinutelyLimit(); // Retrieve your limit per minute -$connection->getMinutelyLimitRemaining(); // Retrieve the amount of API calls remaining for this minute -$connection->getMinutelyLimitReset(); // Retrieve the timestamp for when the minutely limit will reset +use Picqer\Financials\Exact\Item; + +// One record by its primary key +$item = (new Item($connection))->find('097a82a9-6ef7-4edc-8036-3f7559d9ef82'); + +// The first record that matches a filter, or null +$item = (new Item($connection))->first("Code eq 'ITEM-001'"); + +// All records (the library follows the pages for you) +$items = (new Item($connection))->get(); + +// Records that match an OData filter +$items = (new Item($connection))->filter("IsSalesItem eq 1 and Code eq 'ITEM-001'"); + +// The ID of the record with a given code, or null +$id = (new Item($connection))->findId('ITEM-001'); ``` -_Do note when you have no more minutely calls available, Exact only sends the Minutely Limit headers. So in that case, the Daily Limit headers will remain 0 until the minutely reset rolls over._ -There is basic support to `sleep` upon hitting the minutely rate limits. If you enable "Wait on minutely rate limit hit", the client will sleep until the limit is reset. Daily limits are not considered. +`find()`, `first()`, `get()` and `filter()` return new objects. They don't fill the object you call them on. + +### Filter options ```php -$connection->setWaitOnMinutelyRateLimitHit(true); +$items = (new Item($connection))->filter( + 'IsSalesItem eq 1', // $filter + '', // $expand: related collections to include + 'ID,Code,Description', // $select: only fetch these fields + ['$orderby' => 'Code'], // Other OData options, like $orderby or $top + ['CustomDescriptionLanguage' => 'NL'] // Extra headers, e.g. the language of descriptions +); ``` -### Use the library to do stuff (examples) +Filters use the [OData syntax](https://www.odata.org/documentation/odata-version-2-0/uri-conventions/) that Exact Online supports. Write GUIDs as `guid'…'` and strings as `'…'`. The Filter column in the API reference shows which fields you can filter on. + +### Large data sets + +A regular endpoint returns 60 records per page. `get()` and `filter()` fetch all pages, which can take a lot of memory. Use the generator variants to handle records one at a time: ```php -// Optionally set administration, otherwise use the current administration of the user -$connection->setDivision(123456); +foreach ((new Item($connection))->filterAsGenerator('IsSalesItem eq 1') as $item) { + // Only the current page is kept in memory +} +``` -// Create a new account -$account = new \Picqer\Financials\Exact\Account($connection); -$account->AddressLine1 = 'Customers address line'; -$account->AddressLine2 = 'Customer address line 2'; -$account->City = 'Customer city'; -$account->Code = 'Customer code'; -$account->Country = 'Customer country'; -$account->IsSales = 'true'; +`getAsGenerator()` works the same way. To control the paging yourself, for example to spread requests over time: + +```php +$resultSet = (new Item($connection))->getResultSet(['$select' => 'ID,Code']); + +while ($resultSet->hasMore()) { + $items = $resultSet->next(); // One page +} +``` + +### Sync and bulk endpoints + +For large data sets, Exact recommends the sync and bulk endpoints. They return 1000 records per page. + +**Sync endpoints** (classes named `Sync…`) return records that are new or changed since a given `Timestamp`. Store the highest `Timestamp` you've seen and continue from there next time. The timestamp is a row version, not a date. + +```php +use Picqer\Financials\Exact\SyncItem; + +$lastTimestamp = 1; // Start with 1 for the first sync + +foreach ((new SyncItem($connection))->filterAsGenerator("Timestamp gt {$lastTimestamp}") as $item) { + // Process $item + $lastTimestamp = max($lastTimestamp, $item->Timestamp); +} +``` + +Deleted records are available through `SyncDeleted`. + +**Bulk endpoints** (classes named `Bulk…`) require you to select the fields you need: + +```php +use Picqer\Financials\Exact\BulkItem; + +$items = (new BulkItem($connection))->filter('', '', 'ID,Code,Description'); +``` + +## Creating, updating and deleting + +Set fields as properties and call `save()`. A record without a primary key is created (`POST`), a record with one is updated (`PUT`). After saving, the object contains the record as Exact returned it, including the new ID. + +```php +use Picqer\Financials\Exact\Account; + +$account = new Account($connection); +$account->Code = 'CUST-001'; $account->Name = 'Customer name'; -$account->Postcode = 'Customer postcode'; -$account->Status = 'C'; +$account->City = 'Amsterdam'; +$account->Country = 'NL'; +$account->IsSales = true; +$account->Status = 'C'; // Customer $account->save(); -// Add a product in Exact -$item = new \Picqer\Financials\Exact\Item($connection); -$item->Code = 'product code'; -$item->CostPriceStandard = 2.50; -$item->Description = 'product description'; -$item->IsSalesItem = true; -$item->SalesVatCode = 'VH'; -$item->save(); - -// Retrieve an item by id -$item = new \Picqer\Financials\Exact\Item($connection); -$id = '097A82A9-6EF7-4EDC-8036-3F7559D9EF82'; -$item->find($id); - -// List items -$item = new \Picqer\Financials\Exact\Item($connection); -$item->get(); - -// List items with filter (using a filter always returns a collection) and loop through the result -$item = new \Picqer\Financials\Exact\Item($connection); -$items = $item->filter("Code eq '$item->Code'"); // Uses filters as described in Exact API docs (odata filters) -foreach($items as $itemObject){ - $attrs = (array) $itemObject->attributes(); // Turns the endpoint properties into an array - $picture = $itemObject->download(); // Fetches an image string instead of the url - // Do something with $attrs and or $picture, e.g. imagecreatefromstring($picture) -} +echo $account->ID; -// Create new invoice with invoice lines -$invoiceLines[] = [ - 'Item' => $item->ID, - 'Quantity' => 1, - 'UnitPrice' => $item->CostPriceStandard -]; +// Update +$account->City = 'Rotterdam'; +$account->save(); + +// Delete +$account->delete(); +``` + +Only fields that exist in the API are sent. Unknown properties are ignored. + +### Records with lines + +Some endpoints, like sales invoices and sales orders, need their lines in the same request when you create them: + +```php +use Picqer\Financials\Exact\SalesInvoice; -$salesInvoice = new \Picqer\Financials\Exact\SalesInvoice($connection); +$salesInvoice = new SalesInvoice($connection); $salesInvoice->InvoiceTo = $account->ID; $salesInvoice->OrderedBy = $account->ID; -$salesInvoice->YourRef = 'Invoice reference'; -$salesInvoice->SalesInvoiceLines = $invoiceLines; +$salesInvoice->YourRef = 'Order 1001'; +$salesInvoice->SalesInvoiceLines = [ + ['Item' => $itemId, 'Quantity' => 2, 'UnitPrice' => 12.50], + ['Item' => $otherItemId, 'Quantity' => 1], +]; +$salesInvoice->save(); +``` + +Lines are only sent when creating a record. To change the lines of an existing record, use the line endpoint, such as `SalesInvoiceLine`. + +## Related data and files + +### Related collections + +Related collections such as `SalesInvoiceLines` are loaded the first time you use them: + +```php +$salesInvoice = (new SalesInvoice($connection))->find($invoiceId); + +foreach ($salesInvoice->SalesInvoiceLines as $line) { // Fetched now + echo $line->Description; +} +``` + +That is an extra API call per record. When you need the lines of many records, include them in the same request with `$expand`: + +```php +$invoices = (new SalesInvoice($connection))->filter("InvoiceDate ge datetime'2026-01-01'", 'SalesInvoiceLines'); +``` + +If loading fails, for example because of a rate limit, an `ApiException` is thrown. + +### Downloading files + +Items and document attachments can be downloaded: + +```php +$item = (new Item($connection))->find($itemId); +$picture = $item->download(); // Psr\Http\Message\StreamInterface + +file_put_contents('picture.jpg', (string) $picture); +``` + +## Examples + +### Create, print and email a sales invoice + +```php +use Picqer\Financials\Exact\PrintedSalesInvoice; +use Picqer\Financials\Exact\SalesInvoice; + +$salesInvoice = new SalesInvoice($connection); +$salesInvoice->InvoiceTo = $customerId; +$salesInvoice->OrderedBy = $customerId; +$salesInvoice->SalesInvoiceLines = [ + ['Item' => $itemId, 'Quantity' => 1], +]; $salesInvoice->save(); -// Print and email the invoice -$printedInvoice = new \Picqer\Financials\Exact\PrintedSalesInvoice($connection); +$printedInvoice = new PrintedSalesInvoice($connection); $printedInvoice->InvoiceID = $salesInvoice->InvoiceID; +$printedInvoice->DocumentLayout = $documentLayoutId; $printedInvoice->SendEmailToCustomer = true; -$printedInvoice->SenderEmailAddress = "from@example.com"; -$printedInvoice->DocumentLayout = "401f3020-35cd-49a2-843a-d904df0c09ff"; -$printedInvoice->ExtraText = "Some additional text"; +$printedInvoice->SenderEmailAddress = 'invoices@example.com'; +$printedInvoice->ExtraText = 'Thank you for your order.'; $printedInvoice->save(); ``` ### Invoice sales orders (async) + As of October 2026 Exact only supports the async mode of the InvoiceSalesOrders endpoint. Starting the process returns a `ProcessID`, which you use to fetch the result later. ```php -$invoiceSalesOrder = new \Picqer\Financials\Exact\InvoiceSalesOrder($connection); +use Picqer\Financials\Exact\InvoiceSalesOrder; +use Picqer\Financials\Exact\InvoiceSalesOrderResult; +use Picqer\Financials\Exact\SalesOrderID; + +$invoiceSalesOrder = new InvoiceSalesOrder($connection); $invoiceSalesOrder->CreateMode = 1; // 0: Per customer, 1: Per sales order $invoiceSalesOrder->InvoiceMode = 0; // 0: By quantity delivered, 1: By quantity ordered $invoiceSalesOrder->JournalCode = '70'; $invoiceSalesOrder->Mode = 1; // Async $invoiceSalesOrder->SalesOrderIDs = [ - new \Picqer\Financials\Exact\SalesOrderID($connection, ['ID' => $salesOrderId]), + new SalesOrderID($connection, ['ID' => $salesOrderId]), ]; $invoiceSalesOrder->save(); -// Later: fetch the result of the background process -$results = (new \Picqer\Financials\Exact\InvoiceSalesOrderResult($connection)) - ->get(['ProcessID' => "guid'{$invoiceSalesOrder->ProcessID}'"]); -$result = $results[0] ?? null; -// $result->Status, $result->NumberOfCreatedInvoices, $result->NumberOfFailedInvoices, $result->Errors +$processId = $invoiceSalesOrder->ProcessID; // Store this + +// Later, e.g. in a queued job: +$result = (new InvoiceSalesOrderResult($connection))->findByProcessId($processId); + +if ($result !== null) { + echo $result->Status; + echo $result->NumberOfCreatedInvoices; + echo $result->NumberOfFailedInvoices; + echo $result->Errors; +} ``` -### Use generators to prevent memory overflow -This package allows you to interact with the Exact API using PHP [generators](https://www.php.net/manual/en/language.generators.overview.php). -This may be useful when you're retrieving large sets of data that are too big to load into memory all at once. +## Rate limits + +Exact Online limits the number of API calls per division, per minute and per day. The limits depend on your agreement with Exact. See [API limits](https://support.exactonline.com/community/s/knowledge-base#All-All-DNO-Simulation-gen-apilimits) in Exact's documentation. + +After each request, the connection holds the current limits from the response headers: ```php -$item = new \Picqer\Financials\Exact\Item($connection); -$item->getAsGenerator(); -$item->filterAsGenerator('IsWebshopItem eq 1'); +$connection->getDailyLimit(); +$connection->getDailyLimitRemaining(); +$connection->getDailyLimitReset(); // Unix timestamp in milliseconds +$connection->getMinutelyLimit(); +$connection->getMinutelyLimitRemaining(); +$connection->getMinutelyLimitReset(); // Unix timestamp in milliseconds ``` -## Connect to other Exact country than NL +When you exceed the minutely limit, Exact only sends the minutely headers. The daily values are then `0` until the next minute. -Choose the right base URL according to [Exact developers guide](https://developers.exactonline.com/#Exact%20Online%20sites.html) +The library can wait automatically when the minutely limit is reached: ```php -$connection = new \Picqer\Financials\Exact\Connection(); -$connection->setRedirectUrl('CALLBACK_URL'); -$connection->setExactClientId('CLIENT_ID'); -$connection->setExactClientSecret('CLIENT_SECRET'); -$connection->setBaseUrl('https://start.exactonline.de'); +$connection->setWaitOnMinutelyRateLimitHit(true); ``` -Check [src/Picqer/Financials/Exact](src/Picqer/Financials/Exact) for all available entities. +The daily limit is not handled automatically. When you reach it, an `ApiException` with code `429` is thrown. Wait until `getDailyLimitReset()` before trying again. -## Webhooks +## Error handling -Managaging webhook subscriptions is possible through the [WebhookSubscription](src/Picqer/Financials/Exact/WebhookSubscription.php) entitiy. +Every error is thrown as a `Picqer\Financials\Exact\ApiException`. For HTTP errors, the exception code is the HTTP status code, and the message contains Exact's error message. -For authenticating incoming webhook calls you can use the [Authenticatable](src/Picqer/Financials/Exact/Webhook/Authenticatable.php) trait. -Supply the authenticate method with the full JSON request and your Webhook secret supplied by Exact, it will return true or false. +```php +use Picqer\Financials\Exact\ApiException; -## Troubleshooting +try { + $account->save(); +} catch (ApiException $e) { + switch ($e->getCode()) { + case 400: // Invalid request, e.g. a missing mandatory field. See $e->getMessage() + case 401: // The access token was rejected + case 403: // No access to this endpoint or division + case 429: // Rate limit reached, try again later + default: + throw $e; + } +} +``` -> 'Picqer\Financials\Exact\ApiException' with message 'Error 400: Please add a $select or a $top=1 statement to the query string.' +If refreshing the tokens fails because the refresh token is no longer valid, you get an `ApiException` with code `400` and `invalid_grant` in the message. The user then has to authorize your app again. -In specific instances, sadly not documented in the API documentation of Exact this is a requirement. Probably to prevent overflooding requests. What you have to do when encountering this error is adding a select or top. The select is used to provide a list of fields you want to extract, the $top=1 limits the results to one item. +Connection problems, like DNS errors and timeouts, are also thrown as `ApiException`, with code `0`. The original exception is available through `$e->getPrevious()`. -Examples: +To log every request and response, add a Guzzle middleware: -Return only the EntryID and FinancialYear. ```php -$test = new \Picqer\Financials\Exact\GeneralJournalEntry($connection); -var_dump($test->filter('', '', 'EntryID, FinancialYear')); +$connection->insertMiddleWare(\GuzzleHttp\Middleware::log($logger, new \GuzzleHttp\MessageFormatter())); ``` -The $top=1 is added like this: +## Webhooks + +Subscribe to changes with the `WebhookSubscription` entity: + ```php -$test = new \Picqer\Financials\Exact\GeneralJournalEntry($connection); -var_dump($test->filter('', '', '', ['$top'=> 1])); +use Picqer\Financials\Exact\WebhookSubscription; + +$subscription = new WebhookSubscription($connection); +$subscription->CallbackURL = 'https://example.com/exact/webhook'; +$subscription->Topic = 'SalesOrders'; +$subscription->save(); ``` -### Authentication error +Check that incoming webhook calls come from Exact with the `Authenticatable` trait and your webhook secret from the App Center: + +```php +use Picqer\Financials\Exact\Webhook\Authenticatable; -> 'Fatal error: Uncaught Exception: Could not connect to Exact: Client error:POST https://start.exactonline.nl/api/oauth2/token resulted in a 400 Bad Request response: Bad Request in /var/www/html/oauth_call_connect.php:61 Stack trace: #0 {main} thrown in /var/www/html/oauth_call_connect.php on line 61`' +class ExactWebhookHandler +{ + use Authenticatable; +} -This error occurs because the code you get in your redirect URL is only valid for one call. When you call the authentication-process again with a "used" code. You get this error. Make sure you use the provided code by Exact Online only once to get your access token. +$isValid = (new ExactWebhookHandler())->authenticate(file_get_contents('php://input'), 'WEBHOOK_SECRET'); +``` -## Code example +## Troubleshooting -See for example: [example/example.php](example/example.php) +### Error 400: Please add a $select or a $top=1 statement to the query string + +Some endpoints require you to limit the fields or the number of records. Select the fields you need, or request one record: + +```php +$entries = (new GeneralJournalEntry($connection))->filter('', '', 'EntryID,FinancialYear'); +$entries = (new GeneralJournalEntry($connection))->filter('', '', '', ['$top' => 1]); +``` + +### Error 400 when exchanging the authorization code + +An authorization code can only be used once. If you exchange the same code again, Exact responds with `400 Bad Request`. Exchange the code once, store the tokens, and connect with the stored tokens from then on. + +### Error 400 with invalid_grant after some time + +The refresh token is no longer valid. This usually happens when a new refresh token wasn't stored after a refresh (see [Connect with stored tokens](#3-connect-with-stored-tokens)), or when two processes refreshed at the same time (see [Refreshing tokens from multiple processes](#refreshing-tokens-from-multiple-processes)). The user has to authorize your app again. + +### A field isn't saved + +Check that the field exists in the API reference of the endpoint. The library only sends fields it knows about. If the field is missing from the entity class, please open a pull request. + +### Server maintenance + +Exact Online is unavailable for maintenance every day between 04:00 and 04:30 CET. Avoid scheduling API calls in that window. + +## Versions and upgrading + +| Version | PHP | Guzzle | +| --- | --- | --- | +| 4.8 and higher | 7.4 or higher | 7.15.2 or higher | +| 4.0 – 4.7 | 7.4 or higher | 6 or 7 | +| 3.x | 7.2 or higher | 6 or 7 | +| 1.x | | 3 | + +Changes that can affect your code are listed under "Behaviour changes" in the [release notes](https://github.com/picqer/exact-php-client/releases). For example: +- **4.7:** lazy loading and `download()` throw an `ApiException` instead of failing silently or throwing Guzzle exceptions. +- **4.8:** Guzzle 7.15.2 or higher is required. + +## Contributing + +Pull requests are welcome, especially for missing endpoints and fields. Please add tests for your changes and make sure these pass: + +```bash +composer install +vendor/bin/phpunit +vendor/bin/phpstan +``` -## Guzzle versions -Guzzle 7.15.2 or higher is required starting from v4.8, because earlier Guzzle versions have known security vulnerabilities. For Guzzle 6 use [v4.7](https://github.com/picqer/exact-php-client/releases/tag/v4.7.0) or lower. For Guzzle 3 use [v1](https://github.com/picqer/exact-php-client/releases?q=v1&expanded=true). +Entities are generated from the Exact Online API reference, using [DannyvdSluijs/PicqerExactPhpClientGenerator](https://github.com/DannyvdSluijs/PicqerExactPhpClientGenerator). -## TODO +## License -- Current entities do not contain all available properties. Feel free to submit a PR with added or extended entities if you require them. +MIT. See [LICENSE](LICENSE).