amcintosh / freshbooks
FreshBooks API wrapper
Fund package maintenance!
amcintosh
Installs: 26 600
Dependents: 0
Suggesters: 0
Security: 0
Stars: 8
Watchers: 3
Forks: 9
Open Issues: 5
Requires
- php: >=8.0 <8.4
- php-http/client-common: ^2.5
- php-http/discovery: ^1.14
- php-http/multipart-stream-builder: ^1.3
- psr/http-client: ^1.0
- psr/http-factory: ^1.0
- spatie/data-transfer-object: ^3.8
- spryker/decimal-object: ^1.0
Requires (Dev)
- mockery/mockery: 1.5
- php-http/guzzle7-adapter: 1.x-dev
- php-http/mock-client: 1.x-dev
- phpunit/phpunit: 9.6.x-dev
- quazardous/php-bump-version: 1.0.x-dev
- squizlabs/php_codesniffer: 4.0.x-dev
README
A FreshBooks PHP SDK to allow you to more easily utilize the FreshBooks API. This library is not directly maintained by FreshBooks and community contributions are welcome.
Installation
Install it via Composer.
composer require amcintosh/freshbooks
Requires a PSR-18 implementation client. If you do not already have a compatible client, you can install one with it.
composer require amcintosh/freshbooks php-http/guzzle7-adapter
Usage
See the full documentation or check out some examples.
This SDK makes use of the spryker/decimal-object package.
All monetary amounts are represented as as Spryker\DecimalObject\Decimal
, so it is recommended that you refer to
their documentation.
use Spryker\DecimalObject\Decimal; $this->assertEquals(Decimal::create('41.94'), $invoice->amount->amount);
Configuring the API client
You can create an instance of the API client in one of two ways:
- By providing your application's OAuth2
clientId
andclientSecret
and following through the auth flow, which when complete will return an access token. - Or if you already have a valid access token, you can instantiate the client with that token, however token refresh flows will not function without the application id and secret.
use amcintosh\FreshBooks\FreshBooksClient; use amcintosh\FreshBooks\FreshBooksClientConfig; $conf = new FreshBooksClientConfig( clientSecret: 'your secret', redirectUri: 'https://some-redirect', ); $freshBooksClient = new FreshBooksClient('your application id', $conf);
and then proceed with the auth flow (see below).
Or
use amcintosh\FreshBooks\FreshBooksClient; use amcintosh\FreshBooks\FreshBooksClientConfig; $conf = new FreshBooksClientConfig( accessToken: 'a valid token', ); $freshBooksClient = new FreshBooksClient('your application id', $conf);
Authoization flow
This is a brief summary of the OAuth2 authorization flow and the methods in the FreshBooks API Client around them. See the FreshBooks API - Authentication documentation.
First, instantiate your Client with clientId
, clientSecret
, and redirectUri
as above.
To get an access token, the user must first authorize your application. This can be done by sending the user to
the FreshBooks authorization page. Once the user has clicked accept there, they will be redirected to your
redirectUri
with an access grant code. The authorization URL can be obtained by calling
$freshBooksClient->getAuthRequestUri()
. This method also accepts a list of scopes that you wish the user to
authorize your application for.
$authUrl = $freshBooksClient->getAuthRequestUri(['user:profile:read', 'user:clients:read']);
Once the user has been redirected to your redirectUri
and you have obtained the access grant code, you can exchange
that code for a valid access token.
$authResults = $freshBooksClient->getAccessToken($accessGrantCode);
This call both sets the accessToken
, refreshToken
, and tokenExpiresAt
fields on you Client's
FreshBooksClientConfig instance and returns those values.
echo $authResults->accessToken; // Your token echo $authResults->refreshToken; // Your refresh token echo $authResults->createdAt; // When the token was created (as a DateTime) echo $authResults->expiresIn; // How long the token is valid for (in seconds) echo $authResults->getExpiresAt; // When the token expires (as a DateTime) echo $freshBooksClient->getConfig()->accessToken; // Your token echo $freshBooksClient->getConfig()->refreshToken; // Your refresh token echo $freshBooksClient->getConfig()->tokenExpiresAt; // When the token expires (as a DateTime)
When the token expires, it can be refreshed with the refreshToken
value in the FreshBooksClient:
$authResults = $freshBooksClient->refreshAccessToken(); echo $authResults->accessToken; // Your new token
or you can pass the refresh token yourself:
$authResults = $freshBooksClient->refreshAccessToken($storedRefreshToken); echo $authResults->accessToken; // Your new token
Current User
FreshBooks users are uniquely identified by their email across the entire product. One user may act on several Businesses in different ways, and the Identity model is how to keep track of it. Each unique user has an Identity, and each Identity has Business Memberships which define the permissions they have.
See FreshBooks API - Business, Roles, and Identity and FreshBooks API - The Identity Model.
The current user can be accessed by:
$identity = $freshBooksClient->currentUser() echo $identity.email // prints the current user's email // Print name and role of each business the user is a member of foreach ($identity.businessMemberships as $businessMembership) { echo $businessMembership->business.name echo $businessMembership->role; // eg. owner }
Making API Calls
Each resource in the client has provides calls for get
, list
, create
, update
and delete
calls. Please note
that some API resources are scoped to a FreshBooks accountId
while others are scoped to a businessId
. In general
these fall along the lines of accounting resources vs projects/time tracking resources, but that is not precise.
$client = $freshBooksClient->clients()->get($accountId, $clientId); $project = $freshBooksClient->projects()->get($businessId, $projectId);
Get and List
API calls which return a single resource return a DataTransferObject with the returned data accessible via properties.
$client = $freshBooksClient->clients()->get($accountId, $clientId); echo $client->organization; // 'FreshBooks' $client->only('organization')->toArray(); // ['organization' => 'FreshBooks'];
visState
numbers correspond with various states. See FreshBooks API - Active and Deleted Objects
for details.
use amcintosh\FreshBooks\Model\VisState; echo $client->visState; // '0' echo $client->visState == VisState::ACTIVE ? 'Is Active' : 'Not Active'; // 'Is Active'
API calls which return a list of resources return a DataTransferObject with an array of the resources.
$clients = $freshBooksClient->clients()->list($accountId); echo $clients->clients[0]->organization; // 'FreshBooks' foreach ($clients->clients as $client) { echo $client->organization; }
Create, Update, and Delete
API calls to create and update take either a DataModel
object, or an array of the resource data. A successful call
will return a DataTransferObject
object as if a get
call.
Note: When using the array of data, you need to specify the field as it exists in the FreshBooks API. There
are API fields that are translated to more intuitive names in the data models. For example fname
= firstName
,
or bus_phone
= businessPhone
.
Create:
$clientData = new Client(); $clientData->organization = 'FreshBooks'; $clientData->firstName = 'Gordon'; $clientData->businessPhone = '416-444-4445'; $newClient = $freshBooksClient->clients()->create($accountId, model: $clientData); echo $newClient->organization; // 'FreshBooks' echo $newClient->firstName; // 'Gordon' echo $newClient->businessPhone; // '416-444-4445'
or
$clientData = array( 'organization' => 'FreshBooks', 'fname' => 'Gordon', 'bus_phone' => '416-444-4445' ); $newClient = $freshBooksClient->clients()->create($accountId, data: $clientData); echo $newClient->organization; // 'FreshBooks' echo $newClient->firstName; // 'Gordon' echo $newClient->businessPhone; // '416-444-4445'
Update:
$clientData->organization = 'New Org'; $clientData->firstName = 'Gord'; $newClient = $freshBooksClient->clients()->update($accountId, $clientData->id, model: $clientData); echo $newClient->organization; // 'New Org' echo $newClient->firstName; // 'Gord'
or
$clientData = array( 'organization' => 'Really New Org', 'fname' => 'Gord', ); $newClient = $freshBooksClient->clients()->update($accountId, $clientId, data: $clientData); echo $newClient->organization; // 'Really New Org' echo $newClient->firstName; // 'Gord'
Delete:
$client = $freshBooksClient->clients()->delete($accountId, $clientId); echo $client->visState; // '1' echo $client->visState == VisState::ACTIVE ? 'Is Active' : 'Not Active'; // 'Not Active'
Error Handling
Calls made to the FreshBooks API with a non-2xx response are wrapped in a FreshBooksException
.
This exception class contains the error message, HTTP response code, FreshBooks-specific error number if one exists,
and the HTTP response body.
Example:
use amcintosh\FreshBooks\Exception\FreshBooksException; try { $client = $freshBooksClient->clients()->get($accountId, 134); } catch (FreshBooksException $e) { echo $e->getMessage(); // 'Client not found' echo $e->getCode(); // 404 echo $e->getErrorCode(); // 1012 echo $e->getRawResponse(); // '{"response": {"errors": [{"errno": 1012, // "field": "userid", "message": "Client not found.", // "object": "client", "value": "134"}]}}' }
TODO: this
Not all resources have full CRUD methods available. For example expense categories have list
and get
calls, but are not deletable. If you attempt to call a method that does not exist, the SDK will raise a
FreshBooksNotImplementedError
exception, but this is not something you will likely have to account
for outside of development.
Pagination, Filters, and Includes
list
calls take a list of builder objects that can be used to paginate, filter, and include
optional data in the response. See FreshBooks API - Parameters documentation.
Pagination
Pagination results are included in list
responses:
$clients = $freshBooksClient->clients()->list($accountId); echo $clients->pages()->page // 1 echo $clients->pages()->pages // 1 echo $clients->pages()->perPage // 30 echo $clients->pages()->total // 6
To make a paginated call, first create a PaginateBuilder
that can be passed into the list
method.
use amcintosh\FreshBooks\Builder\PaginateBuilder; $paginator = new PaginateBuilder(2, 4); $clients = $freshBooksClient->clients()->list($accountId, builders: [$paginator]); echo $clients->pages()->page // 2 echo $clients->pages()->pages // 2 echo $clients->pages()->perPage // 4 echo $clients->pages()->total // 6
PaginateBuilder
has chainable methods page
and perPage
to set the values.
$paginator = new PaginateBuilder(1, 3); echo $paginator->page; // 1 echo $paginator->perPage; // 3 $paginator->page(2)->perPage(4); echo $paginator->page; // 2 echo $paginator->perPage; // 4
Filters
To filter which results are return by list
method calls, construct a FilterBuilder
and pass that
in the list of builders to the list
method.
use amcintosh\FreshBooks\Builder\FilterBuilder; $filters = new FilterBuilder(); $filters->equals('userid', 123); $clients = $freshBooksClient->clients()->list($accountId, builders: [$filters]);
Filters can be built with the methods: equals
, inList
, like
, between
, boolean
, and datetime
which can be chained together.
Please see FreshBooks API - Active and Deleted Objects for details on filtering active, archived, and deleted resources.
$filters = new FilterBuilder(); $filters->inList('clientids', [123, 456]); // Creates `&search[clientids][]=123&search[clientids][]=456` $filters = new FilterBuilder(); $filters->like('email_like', '@freshbooks.com'); // Creates `&search[email_like]=@freshbooks.com` $filters = new FilterBuilder(); $filters->between('amount', 1, 10); // Creates `&search[amount_min]=1&search[amount_max]=10` $filters = new FilterBuilder(); $filters->between('amount', min=15); // For just minimum // Creates `&search[amount_min]=15` $filters = new FilterBuilder(); $filters->between('amount_min', 15); // Alternatively // Creates `&search[amount_min]=15` $filters = new FilterBuilder(); $filters->between("start_date", min: new DateTime('2020-10-17')) // Creates `&search[start_date]=2020-10-17` $filters = new FilterBuilder(); $filters->boolean('complete', false); // Boolean filters are mostly used on Project-like resources // Creates `&complete=false` $filters = new FilterBuilder(); $filters->equals('vis_state', VisState::ACTIVE)->between('updated', new DateTime('2020-10-17'), new DateTime('2020-11-21')); // Chaining filters // Creates `&search[vis_state]=0&search[updated_min]=2020-10-17&search[updated_max]=2020-11-21`
Includes
To include additional relationships, sub-resources, or data in a response an IncludesBuilder
can be constructed.
use amcintosh\FreshBooks\Builder\IncludesBuilder; $includes = new IncludesBuilder(); $includes->include("outstanding_balance");
Which can then be passed into list
or get
calls:
$clients = $freshBooksClient->clients()->list($accountId, builders: [$includes]); echo $clients->clients[0]->outstanding_balance->amount; // '100.00' echo $clients->clients[0]->outstanding_balance->code; // 'USD' $client = $freshBooksClient->clients()->get($accountId, $clientId, $includes); echo $client->outstanding_balance->amount; // '100.00' echo $client->outstanding_balance->code; // 'USD'
Includes can also be passed into create
and update
calls to include the data in the response of the updated
resource:
$clientData = array( 'email' => 'john.doe@abcorp.com' ); $newClient = $freshBooksClient->clients()->create($accountId, data: $clientData); echo $client->outstanding_balance->amount; // null, new client has no balance
Sorting
To sort the results of a list call by supported fields (see the documentation for that resource) a
SortBuilder
can be used.
use amcintosh\FreshBooks\Builder\SortBuilder; $sort = new SortBuilder(); $sort->ascending("invoice_date"); $invoices = $freshBooksClient->invoices()->list($accountId, builders: [$sort]);
to sort by the invoice date in ascending order, or:
use amcintosh\FreshBooks\Builder\SortBuilder; $sort = new SortBuilder(); $sort->descending("invoice_date"); $invoices = $freshBooksClient->invoices()->list($accountId, builders: [$sort]);
for descending order.
Development
Testing
To run all tests:
make test
Run a specific test:
./vendor/bin/phpunit --filter testCreateValidationError
Documentations
You can generate the documentation via:
make generate-docs