Skip to content

Commit 8397552

Browse files
committed
Add webhook support
1 parent 134545e commit 8397552

File tree

5 files changed

+254
-26
lines changed

5 files changed

+254
-26
lines changed

README.md

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,76 @@ class XeroController extends Controller
115115
Route::get('/manage/xero', [\App\Http\Controllers\XeroController::class, 'index'])->name('xero.auth.success');
116116
```
117117

118+
## Using Webhooks
119+
On your application in the Xero developer portal create a webhook to get your webhook key.
120+
121+
You can then add this to your .env file as
122+
123+
```
124+
XERO_WEBHOOK_KEY=...
125+
```
126+
127+
You can then setup a controller to handle your webhook and inject `\Webfox\Xero\Webhook` e.g.
128+
129+
```php
130+
<?php
131+
132+
namespace App\Http\Controllers;
133+
134+
use Webfox\Xero\Webhook;
135+
use Illuminate\Http\Request;
136+
use Illuminate\Http\Response;
137+
use Webfox\Xero\WebhookEvent;
138+
use XeroApi\XeroPHP\Models\Accounting\Contact;
139+
use XeroApi\XeroPHP\Models\Accounting\Invoice;
140+
141+
class XeroWebhookController extends Controller
142+
{
143+
public function __invoke(Request $request, Webhook $webhook)
144+
{
145+
146+
// The following lines are required for Xero's 'itent to receive' validation
147+
if (!$webhook->validate($request->header('x-xero-signature'))) {
148+
// We can't use abort here, since Xero expects no response body
149+
return response('', Response::HTTP_UNAUTHORIZED);
150+
}
151+
152+
// A single webhook trigger can contain multiple events, so we must loop over them
153+
foreach ($webhook->getEvents() as $event) {
154+
if ($event->getEventType() === 'CREATE' && $event->getEventCategory() === 'INVOICE') {
155+
$this->invoiceCreated($request, $event->getResource());
156+
} elseif ($event->getEventType() === 'CREATE' && $event->getEventCategory() === 'CONTACT') {
157+
$this->contactCreated($request, $event->getResource());
158+
} elseif ($event->getEventType() === 'UPDATE' && $event->getEventCategory() === 'INVOICE') {
159+
$this->invoiceUpdated($request, $event->getResource());
160+
} elseif ($event->getEventType() === 'UPDATE' && $event->getEventCategory() === 'CONTACT') {
161+
$this->contactUpdated($request, $event->getResource());
162+
}
163+
}
164+
165+
return response('', Response::HTTP_OK);
166+
}
167+
168+
protected function invoiceCreated(Request $request, Invoice $invoice)
169+
{
170+
}
171+
172+
protected function contactCreated(Request $request, Contact $contact)
173+
{
174+
}
175+
176+
protected function invoiceUpdated(Request $request, Invoice $invoice)
177+
{
178+
}
179+
180+
protected function contactUpdated(Request $request, Contact $contact)
181+
{
182+
}
183+
184+
}
185+
```
186+
187+
118188
## License
119189

120190
The MIT License (MIT). Please see [License File](LICENSE.md) for more information.

src/Webhook.php

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
<?php
2+
3+
namespace Webfox\Xero;
4+
5+
use Illuminate\Support\Collection;
6+
use XeroAPI\XeroPHP\Api\AccountingApi;
7+
8+
class Webhook
9+
{
10+
11+
protected $signingKey;
12+
13+
protected $payload;
14+
15+
protected $properties;
16+
17+
protected $events;
18+
19+
protected $accountingApi;
20+
21+
protected OauthCredentialManager $credentialManager;
22+
23+
public function __construct(OauthCredentialManager $credentialManager, AccountingApi $accountingApi, string $payload, string $signingKey)
24+
{
25+
$this->accountingApi = $accountingApi;
26+
$this->payload = $payload;
27+
$this->signingKey = $signingKey;
28+
$this->properties = new Collection(json_decode($payload, true));
29+
30+
// bail if json_decode fails
31+
if ($this->properties->isEmpty()) {
32+
throw new \Exception('The webhook payload could not be decoded: ' . json_last_error_msg());
33+
}
34+
35+
// bail if we don't have all the fields we are expecting
36+
if (!$this->properties->has(['events', 'firstEventSequence', 'lastEventSequence'])) {
37+
throw new \Exception('The webhook payload was malformed');
38+
}
39+
40+
$this->events = new Collection(array_map(function($event) {
41+
return new WebhookEvent($this->credentialManager, $this->accountingApi, $event);
42+
}, $this->properties->get('events')));
43+
$this->credentialManager = $credentialManager;
44+
}
45+
46+
public function getSignature()
47+
{
48+
return base64_encode(hash_hmac('sha256', $this->payload, $this->signingKey, true));
49+
}
50+
51+
public function validate($signature)
52+
{
53+
return hash_equals($this->getSignature(), $signature);
54+
}
55+
56+
/**
57+
* @return int
58+
*/
59+
public function getFirstEventSequence()
60+
{
61+
return $this->properties->get('firstEventSequence');
62+
}
63+
64+
/**
65+
* @return int
66+
*/
67+
public function getLastEventSequence()
68+
{
69+
return $this->properties->get('lastEventSequence');
70+
}
71+
72+
/**
73+
* @return \Webfox\Xero\WebhookEvent[]|\Illuminate\Support\Collection
74+
*/
75+
public function getEvents()
76+
{
77+
return $this->events;
78+
}
79+
}

src/WebhookEvent.php

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
<?php
2+
3+
4+
namespace Webfox\Xero;
5+
6+
7+
use Illuminate\Support\Collection;
8+
use XeroAPI\XeroPHP\Api\AccountingApi;
9+
10+
class WebhookEvent
11+
{
12+
13+
protected $properties;
14+
15+
protected AccountingApi $accountingApi;
16+
17+
protected OauthCredentialManager $credentialManager;
18+
19+
public function __construct(OauthCredentialManager $credentialManager, AccountingApi $accountingApi, $event)
20+
{
21+
$this->accountingApi = $accountingApi;
22+
$this->properties = new Collection($event);
23+
24+
if (!$this->properties->has(['resourceUrl', 'resourceId', 'eventDateUtc', 'eventType', 'eventCategory', 'tenantId', 'tenantType',])) {
25+
throw new \Exception("The event payload was malformed; missing required field");
26+
}
27+
$this->credentialManager = $credentialManager;
28+
}
29+
30+
public function getResourceUrl()
31+
{
32+
return $this->properties->get('resourceUrl');
33+
}
34+
35+
public function getResourceId()
36+
{
37+
return $this->properties->get('resourceId');
38+
}
39+
40+
public function getEventDateUtc()
41+
{
42+
return $this->properties->get('eventDateUtc');
43+
}
44+
45+
public function getEventDate()
46+
{
47+
return new \DateTime($this->getEventDateUtc());
48+
}
49+
50+
public function getEventType()
51+
{
52+
return $this->properties->get('eventType');
53+
}
54+
55+
public function getEventCategory()
56+
{
57+
return $this->properties->get('eventCategory');
58+
}
59+
60+
public function getEventClass()
61+
{
62+
if ($this->getEventCategory() === 'INVOICE') {
63+
return \XeroApi\XeroPHP\Models\Accounting\Invoice::class;
64+
}
65+
if ($this->getEventCategory() === 'CONTACT') {
66+
return \XeroApi\XeroPHP\Models\Accounting\Contact::class;
67+
}
68+
69+
}
70+
71+
public function getTenantId()
72+
{
73+
return $this->properties->get('tenantId');
74+
}
75+
76+
public function getTenantType()
77+
{
78+
return $this->properties->get('tenantType');
79+
}
80+
81+
public function getResource()
82+
{
83+
if ($this->getEventCategory() === 'INVOICE') {
84+
return $this->accountingApi
85+
->getInvoice($this->credentialManager->getTenantId(), $this->getResourceId())
86+
->getInvoices()[0];
87+
}
88+
if ($this->getEventCategory() === 'CONTACT') {
89+
return $this->accountingApi
90+
->getContact($this->credentialManager->getTenantId(), $this->getResourceId())
91+
->getContacts()[0];
92+
}
93+
}
94+
}

src/XeroClass.php

Lines changed: 0 additions & 25 deletions
This file was deleted.

src/XeroServiceProvider.php

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
namespace Webfox\Xero;
44

5+
use Illuminate\Http\Request;
56
use XeroAPI\XeroPHP\Configuration;
67
use XeroAPI\XeroPHP\Api\IdentityApi;
78
use GuzzleHttp\Client as GuzzleClient;
@@ -16,7 +17,7 @@ class XeroServiceProvider extends ServiceProvider
1617
*/
1718
public function boot()
1819
{
19-
$this->loadRoutesFrom(__DIR__.'/../routes/routes.php');
20+
$this->loadRoutesFrom(__DIR__ . '/../routes/routes.php');
2021

2122
if ($this->app->runningInConsole()) {
2223
$this->publishes([
@@ -73,5 +74,14 @@ public function register()
7374
$this->app->singleton(AccountingApi::class, function (Application $app) {
7475
return new AccountingApi(new GuzzleClient(), $app->make(Configuration::class));
7576
});
77+
78+
$this->app->bind(Webhook::class, function(Application $app) {
79+
return new Webhook(
80+
$app->make(OauthCredentialManager::class),
81+
$app->make(AccountingApi::class),
82+
$this->app->make(Request::class)->getContent(),
83+
config('xero.oauth.webhook_signing_key')
84+
);
85+
});
7686
}
7787
}

0 commit comments

Comments
 (0)