From fab7d0d4f3efa9d0ea4334a1ff43e70e0eaeb150 Mon Sep 17 00:00:00 2001 From: Andrew McIntosh Date: Tue, 26 Mar 2024 20:43:59 -0400 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20Add=20invoice=20payment=20options?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add payments resource and allow getting default payment options as well as payment options on an invoice and creating payment options for an invoice. Closes #60 --- src/FreshBooksClient.php | 19 +++ src/Model/InvoicePaymentOptions.php | 107 ++++++++++++++ src/Resource/PaymentResource.php | 167 ++++++++++++++++++++++ src/Resource/ProjectResource.php | 2 +- tests/Model/InvoicePaymentOptionsTest.php | 68 +++++++++ tests/Resource/PaymentResourceTest.php | 142 ++++++++++++++++++ 6 files changed, 504 insertions(+), 1 deletion(-) create mode 100644 src/Model/InvoicePaymentOptions.php create mode 100644 src/Resource/PaymentResource.php create mode 100644 tests/Model/InvoicePaymentOptionsTest.php create mode 100644 tests/Resource/PaymentResourceTest.php diff --git a/src/FreshBooksClient.php b/src/FreshBooksClient.php index c7ec428..4f8724f 100644 --- a/src/FreshBooksClient.php +++ b/src/FreshBooksClient.php @@ -27,6 +27,7 @@ use amcintosh\FreshBooks\Model\Identity; use amcintosh\FreshBooks\Model\Invoice; use amcintosh\FreshBooks\Model\InvoiceList; +use amcintosh\FreshBooks\Model\invoicePaymentOptions; use amcintosh\FreshBooks\Model\Item; use amcintosh\FreshBooks\Model\ItemList; use amcintosh\FreshBooks\Model\Payment; @@ -40,6 +41,7 @@ use amcintosh\FreshBooks\Resource\AccountingResource; use amcintosh\FreshBooks\Resource\AuthResource; use amcintosh\FreshBooks\Resource\EventsResource; +use amcintosh\FreshBooks\Resource\PaymentResource; use amcintosh\FreshBooks\Resource\ProjectResource; class FreshBooksClient @@ -325,4 +327,21 @@ public function projects(): ProjectResource { return new ProjectResource($this->httpClient, 'projects', 'projects', Project::class, ProjectList::class); } + + /** + * FreshBooks invoice payment options resource with calls to default, get, create. + * + * @return PaymentResource + */ + public function invoicePaymentOptions(): PaymentResource + { + return new PaymentResource( + $this->httpClient, + 'invoice', + InvoicePaymentOptions::class, + subResourcePath: 'payment_options', + defaultsPath: 'payment_options', + staticPathParams: 'entity_type=invoice', + ); + } } diff --git a/src/Model/InvoicePaymentOptions.php b/src/Model/InvoicePaymentOptions.php new file mode 100644 index 0000000..c8357da --- /dev/null +++ b/src/Model/InvoicePaymentOptions.php @@ -0,0 +1,107 @@ +except('id') + ->except('hasAcssDebit') + ->except('hasBacsDebit') + ->except('hasSepaDebit') + ->except('hasPaypalSmartCheckout') + ->toArray(); + foreach ($data as $key => $value) { + if (is_null($value)) { + unset($data[$key]); + } + } + return $data; + } +} diff --git a/src/Resource/PaymentResource.php b/src/Resource/PaymentResource.php new file mode 100644 index 0000000..eaae6af --- /dev/null +++ b/src/Resource/PaymentResource.php @@ -0,0 +1,167 @@ +httpClient = $httpClient; + $this->resourcePath = $resourcePath; + $this->subResourcePath = $subResourcePath; + $this->defaultsPath = $defaultsPath; + $this->staticPathParams = $staticPathParams; + $this->model = $model; + } + + /** + * The the url to the payment resource. + * + * @param int $accountId + * @param int $resourceId + * @param bool $isList + * @return string + */ + private function getUrl(string $accountId, int $resourceId = null): string + { + if (!is_null($resourceId) && !is_null($this->subResourcePath)) { + return "/payments/account/{$accountId}/{$this->resourcePath}/{$resourceId}/{$this->subResourcePath}"; + } else { + $url = "/payments/account/{$accountId}/{$this->defaultsPath}"; + if (!is_null($this->staticPathParams) && !is_null($this->subResourcePath)) { + $url .= "?{$this->staticPathParams}"; + } + return $url; + } + } + + /** + * Parse the json response for payments endpoint errors and create a FreshBooksException from it. + * + * @param int $statusCode HTTP status code + * @param array $responseData The json-parsed response + * @param string $rawRespone The raw response body + * @return void + */ + private function createResponseError(int $statusCode, array $responseData, string $rawRespone): void + { + $message = $responseData['message'] ?? 'Unknown error'; + $errorDetails = null; + + if (array_key_exists('errors', $responseData) && is_array($responseData['errors'])) { + $errorDetails = []; + foreach ($responseData['errors'] as $errorKey => $errorDetail) { + $errorDetails[] = [$errorKey => $errorDetail]; + $message = 'Error: ' . $errorKey . ' ' . $errorDetail; + } + } elseif (array_key_exists('error', $responseData)) { + $message = $responseData['error']; + } + throw new FreshBooksException($message, $statusCode, null, $rawRespone, null, $errorDetails); + } + + /** + * Make a request against the payments resource and return an array of the json response. + * Throws a FreshBooksException if the response is not a 200 or if the response cannot be parsed. + * + * @param string $method + * @param string $url + * @param array $data + * @return array + */ + private function makeRequest(string $method, string $url, array $data = null): array + { + if (!is_null($data)) { + $data = json_encode($data); + } + $response = $this->httpClient->send($method, $url, [], $data); + + $statusCode = $response->getStatusCode(); + if ($statusCode == 204 && $method == self::DELETE) { + return []; + } + try { + $contents = $response->getBody()->getContents(); + $responseData = json_decode($contents, true); + } catch (JSONDecodeError $e) { + throw new FreshBooksException('Failed to parse response', $statusCode, $e, $contents); + } + + if ($statusCode >= 400) { + $this->createResponseError($statusCode, $responseData, $contents); + } + if (!array_key_exists($this->model::RESPONSE_FIELD, $responseData)) { + throw new FreshBooksException('Returned an unexpected response', $statusCode, null, $contents); + } + return $responseData; + } + + /** + * Get the default settings for an account resource. + * + * @param string $accountId The alpha-numeric account id + * @return DataTransferObject The result model with the default data + */ + public function defaults(string $accountId): DataTransferObject + { + $url = $this->getUrl($accountId); + $result = $this->makeRequest(self::GET, $url); + return new $this->model($result[$this->model::RESPONSE_FIELD]); + } + + /** + * Get a single resource with the corresponding id. + * + * @param string $accountId The alpha-numeric account id + * @param int $resourceId Id of the resource to return + * @return DataTransferObject The result model + */ + public function get(string $accountId, int $resourceId): DataTransferObject + { + $url = $this->getUrl($accountId, $resourceId); + $result = $this->makeRequest(self::GET, $url); + return new $this->model($result[$this->model::RESPONSE_FIELD]); + } + + /** + * Create a resource from either an array or a DataModel object. + * + * @param string $accountId The alpha-numeric account id + * @param DataModel $model (Optional) The model to create + * @param array $data (Optional) The data to create the model with + * @return DataTransferObject Model of the new resource's response data. + */ + public function create( + string $accountId, + int $resourceId, + DataModel $model = null, + array $data = null + ): DataTransferObject { + if (!is_null($model)) { + $data = $model->getContent(); + } + $url = $this->getUrl($accountId, $resourceId); + $result = $this->makeRequest(self::POST, $url, $data); + return new $this->model($result[$this->model::RESPONSE_FIELD]); + } +} diff --git a/src/Resource/ProjectResource.php b/src/Resource/ProjectResource.php index b033247..e8212ba 100644 --- a/src/Resource/ProjectResource.php +++ b/src/Resource/ProjectResource.php @@ -80,7 +80,7 @@ private function createResponseError(int $statusCode, array $responseData, strin } /** - * Make a request against the accounting resource and return an array of the json response. + * Make a request against the project resource and return an array of the json response. * Throws a FreshBooksException if the response is not a 200 or if the response cannot be parsed. * * @param string $method diff --git a/tests/Model/InvoicePaymentOptionsTest.php b/tests/Model/InvoicePaymentOptionsTest.php new file mode 100644 index 0000000..d06e279 --- /dev/null +++ b/tests/Model/InvoicePaymentOptionsTest.php @@ -0,0 +1,68 @@ +samplePaymentOptionsData, true); + $paymentOptions = new InvoicePaymentOptions($paymentOptionsData[InvoicePaymentOptions::RESPONSE_FIELD]); + + $this->assertSame('12345', $paymentOptions->entityId); + $this->assertSame('invoice', $paymentOptions->entityType); + $this->assertSame('Stripe', $paymentOptions->gatewayName); + $this->assertTrue($paymentOptions->hasCreditCard); + $this->assertTrue($paymentOptions->hasAchTransfer); + $this->assertFalse($paymentOptions->hasAcssDebit); + $this->assertFalse($paymentOptions->hasBacsDebit); + $this->assertFalse($paymentOptions->hasSepaDebit); + $this->assertFalse($paymentOptions->hasPaypalSmartCheckout); + $this->assertFalse($paymentOptions->allowPartialPayments); + } + + public function testInvoicePaymentOptionsGetContent(): void + { + $paymentOptionsData = json_decode($this->samplePaymentOptionsData, true); + $paymentOptions = new InvoicePaymentOptions($paymentOptionsData['payment_options']); + $this->assertSame([ + 'entity_id' => '12345', + 'entity_type' => 'invoice', + 'gateway_name' => 'Stripe', + 'has_credit_card' => true, + 'has_ach_transfer' => true, + 'allow_partial_payments' => false + ], $paymentOptions->getContent()); + } +} diff --git a/tests/Resource/PaymentResourceTest.php b/tests/Resource/PaymentResourceTest.php new file mode 100644 index 0000000..c60b542 --- /dev/null +++ b/tests/Resource/PaymentResourceTest.php @@ -0,0 +1,142 @@ +accountId = 'ACM123'; + } + + public function testDefaults(): void + { + $mockHttpClient = $this->getMockHttpClient( + 200, + ['payment_options' => [ + 'entity_id' => null, + 'gateway_name' => 'Stripe' + ]] + ); + + $resource = new PaymentResource( + $mockHttpClient, + 'invoice', + InvoicePaymentOptions::class, + subResourcePath: 'payment_options', + defaultsPath: 'payment_options', + staticPathParams: 'entity_type=invoice', + ); + $paymentOption = $resource->defaults($this->accountId); + + $this->assertSame('Stripe', $paymentOption->gatewayName); + $this->assertNull($paymentOption->entityId); + + $request = $mockHttpClient->getLastRequest(); + $this->assertSame('GET', $request->getMethod()); + $this->assertSame('/payments/account/ACM123/payment_options?entity_type=invoice', $request->getRequestTarget()); + } + + public function testGet(): void + { + $invoiceId = 12345; + $mockHttpClient = $this->getMockHttpClient( + 200, + ['payment_options' => [ + 'entity_id' => $invoiceId, + 'gateway_name' => 'Stripe' + ]] + ); + + $resource = new PaymentResource( + $mockHttpClient, + 'invoice', + InvoicePaymentOptions::class, + subResourcePath: 'payment_options', + defaultsPath: 'payment_options', + staticPathParams: 'entity_type=invoice', + ); + $paymentOption = $resource->get($this->accountId, $invoiceId); + + $this->assertSame('Stripe', $paymentOption->gatewayName); + $this->assertSame("{$invoiceId}", $paymentOption->entityId); + + $request = $mockHttpClient->getLastRequest(); + $this->assertSame('GET', $request->getMethod()); + $this->assertSame('/payments/account/ACM123/invoice/12345/payment_options', $request->getRequestTarget()); + } + + public function testGetNotFoundError(): void + { + $invoiceId = 12345; + $mockHttpClient = $this->getMockHttpClient( + 404, + [ + 'error_type' => 'not_found', + 'message' => 'Resource not found' + ] + ); + + $resource = new PaymentResource( + $mockHttpClient, + 'invoice', + InvoicePaymentOptions::class, + subResourcePath: 'payment_options', + defaultsPath: 'payment_options', + staticPathParams: 'entity_type=invoice', + ); + + try { + $resource->get($this->accountId, $invoiceId); + $this->fail('FreshBooksException was not thrown'); + } catch (FreshBooksException $e) { + $this->assertSame('Resource not found', $e->getMessage()); + $this->assertSame(404, $e->getCode()); + $this->assertNull($e->getErrorCode()); + $this->assertNull($e->getErrorDetails()); + } + } + + public function testCreateValidationError(): void + { + $invoiceId = 12345; + $mockHttpClient = $this->getMockHttpClient( + 500, + [ + 'error_type' => 'internal', + 'message' => 'An error has occurred' + ] + ); + + $resource = new PaymentResource( + $mockHttpClient, + 'invoice', + InvoicePaymentOptions::class, + subResourcePath: 'payment_options', + defaultsPath: 'payment_options', + staticPathParams: 'entity_type=invoice', + ); + + try { + $resource->create($this->accountId, $invoiceId, data: []); + $this->fail('FreshBooksException was not thrown'); + } catch (FreshBooksException $e) { + $this->assertSame('An error has occurred', $e->getMessage()); + $this->assertSame(500, $e->getCode()); + $this->assertNull($e->getErrorCode()); + $this->assertNull($e->getErrorDetails()); + } + } +}