├── .gitignore ├── README.md ├── composer.json ├── examples └── example1.php └── src ├── Contracts └── Request.php ├── Exceptions ├── JSONParseException.php ├── NewDeleteException.php ├── RequestErrorException.php ├── SignatureInvalid.php └── TimingInvalid.php ├── HTTP └── Request.php └── TokenRequest.php /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by .ignore support plugin (hsz.mobi) 2 | ### Composer template 3 | composer.phar 4 | vendor/ 5 | example/ 6 | 7 | # Commit your application's lock file http://getcomposer.org/doc/01-basic-usage.md#composer-lock-the-lock-file 8 | # You may choose to ignore a library lock file http://getcomposer.org/doc/02-libraries.md#lock-file 9 | composer.lock 10 | ### JetBrains template 11 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio 12 | 13 | *.iml 14 | 15 | ## Directory-based project format: 16 | .idea/ 17 | # if you remove the above rule, at least ignore the following: 18 | 19 | # User-specific stuff: 20 | # .idea/workspace.xml 21 | # .idea/tasks.xml 22 | # .idea/dictionaries 23 | 24 | # Sensitive or high-churn files: 25 | # .idea/dataSources.ids 26 | # .idea/dataSources.xml 27 | # .idea/sqlDataSources.xml 28 | # .idea/dynamic.xml 29 | # .idea/uiDesigner.xml 30 | 31 | # Gradle: 32 | # .idea/gradle.xml 33 | # .idea/libraries 34 | 35 | # Mongo Explorer plugin: 36 | # .idea/mongoSettings.xml 37 | 38 | ## File-based project format: 39 | *.ipr 40 | *.iws 41 | 42 | ## Plugin-specific files: 43 | 44 | # IntelliJ 45 | /out/ 46 | 47 | # mpeltonen/sbt-idea plugin 48 | .idea_modules/ 49 | 50 | # JIRA plugin 51 | atlassian-ide-plugin.xml 52 | 53 | # Crashlytics plugin (for Android Studio and IntelliJ) 54 | com_crashlytics_export_strings.xml 55 | crashlytics.properties 56 | crashlytics-build.properties 57 | 58 | /.php_cs.cache 59 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Billingo API Connector 2 | 3 | This package is the PHP connector for the Billingo API 2.0. 4 | The full API documentation is available [here](http://billingo.readthedocs.org/en/latest/). 5 | 6 | ## Installing 7 | 8 | The easiest way to install the Connector is using Composer: 9 | 10 | ``` 11 | composer require voov/billingo-api-connector 12 | ``` 13 | 14 | Then use your framework's autoload, or simply add: 15 | 16 | ```php 17 | 'YOUR_PUBLIC_KEY', 70 | 'private_key' => 'YOUR_PRIVATE_KEY' 71 | ]); 72 | ``` 73 | 74 | The `Request` class takes care of the communication between your app and the Billingo API server with JWT authorization handled in the background. 75 | 76 | #### JWT Time leeway 77 | 78 | To adjust for some time skew between the client and the API server, you can set the `leeway` parameter when creating the new instance. The leeway is measured in seconds and the default value is 60. This modifies the `nbf`, `iat` and `exp` claims of the JWT, so in the case of the default leeway, the token is valid one minute before and after the issue time. 79 | 80 | ## General usage 81 | 82 | ### Get resource 83 | 84 | ```php 85 | get('clients'); 88 | // Return the next page 89 | $clients = $billingo->get('clients', ['page' => 2]); 90 | 91 | // Return one client 92 | $client = $billingo->get('clients/123456789'); 93 | ``` 94 | 95 | ### Save resource 96 | 97 | ```php 98 | "Gigazoom LLC.", 102 | "email" => "rbrooks5@amazon.com", 103 | "billing_address" => [ 104 | "street_name" => "Moulton", 105 | "street_type" => "Terrace", 106 | "house_nr" => "2797", 107 | "city" => "Preston", 108 | "postcode" => "PR1", 109 | "country" => "United Kingdom" 110 | ] 111 | ] 112 | $billingo->post('clients', $clientData); 113 | 114 | ``` 115 | 116 | ### Update resource 117 | 118 | ```php 119 | put('clients/123456789', $newData); 122 | ``` 123 | 124 | ### Delete resource 125 | 126 | ```php 127 | delete('clients/123456789'); 130 | ``` 131 | 132 | ### Download invoice 133 | 134 | You can download the generated invoice in a PDF format 135 | 136 | When passing the second parameter, you can specify a filename or a resource opened with `fopen` where the PDF will be saved. Otherwise a `GuzzleHttp\Psr7\Stream` is returned which you can read from. 137 | 138 | ```php 139 | downloadInvoice('123456789', 'filename.pdf'); 141 | ``` 142 | 143 | #### Using the stream interface 144 | 145 | ```php 146 | downloadInvoice('123456789'); 148 | if($invoice->isReadable()) { 149 | while(!$invoice->eof()) { 150 | echo $invoice->read(1); 151 | } 152 | } 153 | ``` 154 | 155 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "voov/billingo-api-connector", 3 | "description": "Billingo API Connector", 4 | "minimum-stability": "dev", 5 | "license": "MIT", 6 | "authors": [ 7 | { 8 | "name": "Daniel Fekete", 9 | "email": "daniel.fekete@voov.hu" 10 | } 11 | ], 12 | "require": { 13 | "php": ">=7.1", 14 | "guzzlehttp/guzzle": "^6.0", 15 | "firebase/php-jwt": "^3.0", 16 | "symfony/options-resolver": "4.*", 17 | "ext-json": "*" 18 | }, 19 | "autoload": { 20 | "psr-4": { 21 | "Billingo\\API\\Connector\\": "src" 22 | } 23 | } 24 | } -------------------------------------------------------------------------------- /examples/example1.php: -------------------------------------------------------------------------------- 1 | API_PRIVATE_KEY, 15 | 'public_key' => API_PUBLIC_KEY, 16 | ]); 17 | 18 | try { 19 | 20 | // Get list of clients 21 | $clients = $request->get("clients"); 22 | var_dump($clients); 23 | 24 | } catch (JSONParseExceptionAlias $e) { 25 | echo "Error parsing response"; 26 | } catch (RequestErrorExceptionAlias $e) { 27 | echo "Error in request"; 28 | } catch (GuzzleException $e) { 29 | var_dump($e->getMessage()); 30 | } -------------------------------------------------------------------------------- /src/Contracts/Request.php: -------------------------------------------------------------------------------- 1 | config = $this->resolveOptions($options); 41 | $this->client = new Client([ 42 | 'verify' => false, 43 | 'base_uri' => $this->config['host'], 44 | 'debug' => false, 45 | ]); 46 | } 47 | 48 | /** 49 | * Get required options for the Billingo API to work. 50 | * 51 | * @param $opts 52 | * 53 | * @return mixed 54 | */ 55 | protected function resolveOptions($opts) 56 | { 57 | $this->resolver = new OptionsResolver(); 58 | $this->resolver->setDefault('version', '2'); 59 | $this->resolver->setDefault('host', 'https://www.billingo.hu/api/'); // might be overridden in the future 60 | $this->resolver->setDefault('leeway', 60); 61 | $this->resolver->setDefault('headers', []); 62 | $this->resolver->setRequired(['host', 'version', 'leeway']); 63 | 64 | if (array_key_exists('token', $opts)) { 65 | $this->resolver->setRequired('token'); 66 | } else { 67 | $this->resolver->setRequired(['private_key', 'public_key']); 68 | } 69 | 70 | return $this->resolver->resolve($opts); 71 | } 72 | 73 | /** 74 | * Make a request to the Billingo API. 75 | * 76 | * @param $method 77 | * @param $uri 78 | * @param array $data 79 | * 80 | * @return mixed|array 81 | * 82 | * @throws JSONParseException 83 | * @throws RequestErrorException 84 | * @throws \GuzzleHttp\Exception\GuzzleException 85 | */ 86 | public function request($method, $uri, $data = []) 87 | { 88 | // get the key to use for the query 89 | if ($method == strtoupper('GET') || $method == strtoupper('DELETE')) { 90 | $queryKey = 'query'; 91 | } else { 92 | $queryKey = 'json'; 93 | } 94 | 95 | $headers = array_merge_recursive($this->config['headers'], $this->generateAuthHeader()); 96 | $response = $this->client->request($method, $uri, [$queryKey => $data, 'headers' => $headers]); 97 | 98 | $jsonData = json_decode($response->getBody(), true); 99 | 100 | if (null == $jsonData) { 101 | throw new JSONParseException('Cannot decode: '.$response->getBody()); 102 | } 103 | 104 | if (200 != $response->getStatusCode() || 0 == $jsonData['success']) { 105 | throw new RequestErrorException('Error: '.$jsonData['error'], $response->getStatusCode()); 106 | } 107 | 108 | if (array_key_exists('data', $jsonData)) { 109 | return $jsonData['data']; 110 | } 111 | 112 | return []; 113 | } 114 | 115 | /** 116 | * GET. 117 | * 118 | * @param $uri 119 | * @param array $data 120 | * 121 | * @return mixed|ResponseInterface 122 | * 123 | * @throws JSONParseException 124 | * @throws RequestErrorException 125 | * @throws \GuzzleHttp\Exception\GuzzleException 126 | */ 127 | public function get($uri, $data = []) 128 | { 129 | return $this->request('GET', $uri, $data); 130 | } 131 | 132 | /** 133 | * POST. 134 | * 135 | * @param $uri 136 | * @param array $data 137 | * 138 | * @return mixed|ResponseInterface 139 | * 140 | * @throws JSONParseException 141 | * @throws RequestErrorException 142 | * @throws \GuzzleHttp\Exception\GuzzleException 143 | */ 144 | public function post($uri, $data = []) 145 | { 146 | return $this->request('POST', $uri, $data); 147 | } 148 | 149 | /** 150 | * PUT. 151 | * 152 | * @param $uri 153 | * @param array $data 154 | * 155 | * @return mixed|ResponseInterface 156 | * 157 | * @throws JSONParseException 158 | * @throws RequestErrorException 159 | * @throws \GuzzleHttp\Exception\GuzzleException 160 | */ 161 | public function put($uri, $data = []) 162 | { 163 | return $this->request('PUT', $uri, $data); 164 | } 165 | 166 | /** 167 | * DELETE. 168 | * 169 | * @param $uri 170 | * @param array $data 171 | * 172 | * @return mixed|ResponseInterface 173 | * 174 | * @throws JSONParseException 175 | * @throws RequestErrorException 176 | * @throws \GuzzleHttp\Exception\GuzzleException 177 | */ 178 | public function delete($uri, $data = []) 179 | { 180 | return $this->request('DELETE', $uri, $data); 181 | } 182 | 183 | /** 184 | * Downloads the given invoice. 185 | * 186 | * @param $id 187 | * @param resource|string|null $file 188 | * 189 | * @return \Psr\Http\Message\StreamInterface|string|null 190 | * 191 | * @throws \GuzzleHttp\Exception\GuzzleException 192 | */ 193 | public function downloadInvoice($id, $file = null) 194 | { 195 | $uri = "invoices/{$id}/download"; 196 | $options = ['headers' => $this->generateAuthHeader()]; 197 | if (!is_null($file)) { 198 | $options['sink'] = $file; 199 | } 200 | $response = $this->client->request('GET', $uri, $options); 201 | 202 | return $response instanceof ResponseInterface ? $response->getBody() : null; 203 | } 204 | 205 | /** 206 | * Get billingo token for user. 207 | * 208 | * @param $pubKey 209 | * @param $privateKey 210 | * 211 | * @return string Billingo token 212 | * 213 | * @throws JSONParseException 214 | * @throws RequestErrorException 215 | * @throws \GuzzleHttp\Exception\GuzzleException 216 | */ 217 | public function getBillingoToken($pubKey, $privateKey) 218 | { 219 | $tr = new TokenRequest($pubKey, $privateKey); 220 | $response = $this->get('token', ['tokenrequest' => $tr->generateWithSignatureAndTiming()]); 221 | 222 | return $response['token']; 223 | } 224 | 225 | /** 226 | * Generate JWT authorization header. 227 | * 228 | * @return string 229 | */ 230 | public function generateJWTArray() 231 | { 232 | $time = time(); 233 | $iss = isset($_SERVER['REQUEST_URI']) ? $_SERVER['REQUEST_URI'] : 'cli'; 234 | $signatureData = [ 235 | 'sub' => $this->config['public_key'], 236 | 'iat' => $time - $this->config['leeway'], 237 | 'exp' => $time + $this->config['leeway'], 238 | 'iss' => $iss, 239 | 'nbf' => $time - $this->config['leeway'], 240 | 'jti' => md5($this->config['public_key'].$time), 241 | ]; 242 | 243 | return JWT::encode($signatureData, $this->config['private_key']); 244 | } 245 | 246 | /** 247 | * Generate authentication header based on JWT. 248 | * 249 | * @return array 250 | */ 251 | protected function generateJWTHeader() 252 | { 253 | return [ 254 | 'Authorization' => 'Bearer '.$this->generateJWTArray(), 255 | ]; 256 | } 257 | 258 | /** 259 | * When using BillingoToken for authentication 260 | * use this function to generate the correct header. 261 | * 262 | * @return array 263 | */ 264 | protected function generateBillingoTokenHeader() 265 | { 266 | return [ 267 | 'X-Billingo-Token' => $this->config['token'], 268 | ]; 269 | } 270 | 271 | /** 272 | * Generate the correct authentication header(s) 273 | * either JWT or BillingoToken. 274 | * 275 | * @return array 276 | */ 277 | protected function generateAuthHeader() 278 | { 279 | if ($this->resolver->isDefined('token')) { 280 | return $this->generateBillingoTokenHeader(); 281 | } else { 282 | return $this->generateJWTHeader(); 283 | } 284 | } 285 | } 286 | -------------------------------------------------------------------------------- /src/TokenRequest.php: -------------------------------------------------------------------------------- 1 | pubKey = $pubKey; 29 | $this->privateKey = $privateKey; 30 | } 31 | 32 | /** 33 | * Generate token request data. 34 | * 35 | * @param $timing 36 | * 37 | * @return string 38 | */ 39 | public function generate($timing): string 40 | { 41 | return implode('|', [$this->pubKey, $timing]); 42 | } 43 | 44 | /** 45 | * Return timing information (ie. unix epoch). 46 | * 47 | * @return int 48 | */ 49 | public function generateTiming() 50 | { 51 | return time(); 52 | } 53 | 54 | /** 55 | * Generate data string with signature. 56 | * 57 | * @param $timing 58 | * 59 | * @return string 60 | */ 61 | public function generateWithSignature($timing) 62 | { 63 | $data = $this->generate($timing); 64 | 65 | return $data.'|'.$this->sign($data); 66 | } 67 | 68 | /** 69 | * Generate a data string with signature and timing 70 | * 71 | * @return string 72 | */ 73 | public function generateWithSignatureAndTiming() 74 | { 75 | return $this->generateWithSignature($this->generateTiming()); 76 | } 77 | 78 | /** 79 | * Return TRUE if timing is valid. 80 | * 81 | * @param $userTiming 82 | * 83 | * @return bool 84 | */ 85 | public function validateTiming($userTiming): bool 86 | { 87 | return abs($this->generateTiming() - $userTiming) <= static::MAX_TIMING_DELTA; 88 | } 89 | 90 | /** 91 | * Validate user string to be valid. 92 | * 93 | * @param $userString 94 | * @param $timing 95 | * 96 | * @return bool 97 | */ 98 | public function validateSignature($userString, $timing): bool 99 | { 100 | $data = $this->generate($timing); 101 | 102 | return hash_equals($this->sign($data), $userString); 103 | } 104 | 105 | /** 106 | * Return the data from the token request string. 107 | * 108 | * @param string $requestString 109 | * 110 | * @return array 111 | */ 112 | public static function requestStringData(string $requestString): array 113 | { 114 | list($pubKey, $timing, $signature) = explode('|', $requestString); 115 | 116 | return compact('pubKey', 'timing', 'signature'); 117 | } 118 | 119 | /** 120 | * Validate a full token request string. 121 | * 122 | * @param string $requestString 123 | * @param string $privateKey 124 | * 125 | * @return bool 126 | * 127 | * @throws SignatureInvalid 128 | * @throws TimingInvalid 129 | */ 130 | public static function validateRequestString(string $requestString, string $privateKey): bool 131 | { 132 | $data = static::requestStringData($requestString); 133 | $self = new static($data['pubKey'], $privateKey); 134 | if (!$self->validateTiming($data['timing'])) { 135 | throw new TimingInvalid(); 136 | } 137 | if (!$self->validateSignature($data['signature'], $data['timing'])) { 138 | throw new SignatureInvalid(); 139 | } 140 | 141 | return true; 142 | } 143 | 144 | /** 145 | * Generate hash signature. 146 | * 147 | * @param string $data 148 | * 149 | * @return string 150 | */ 151 | public function sign(string $data): string 152 | { 153 | return hash_hmac('sha256', $data, $this->privateKey); 154 | } 155 | } 156 | --------------------------------------------------------------------------------