Skip to content

Commit

Permalink
Merge pull request #61 from amcintosh/issue-60-invoice-payment-options
Browse files Browse the repository at this point in the history
✨ Add invoice payment options
  • Loading branch information
amcintosh committed Mar 27, 2024
2 parents 58c7c18 + fab7d0d commit b948292
Show file tree
Hide file tree
Showing 6 changed files with 504 additions and 1 deletion.
19 changes: 19 additions & 0 deletions src/FreshBooksClient.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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
Expand Down Expand Up @@ -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',
);
}
}
107 changes: 107 additions & 0 deletions src/Model/InvoicePaymentOptions.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
<?php

declare(strict_types=1);

namespace amcintosh\FreshBooks\Model;

use Spatie\DataTransferObject\Attributes\MapFrom;
use Spatie\DataTransferObject\Attributes\MapTo;
use Spatie\DataTransferObject\Caster;
use Spatie\DataTransferObject\DataTransferObject;
use amcintosh\FreshBooks\Model\DataModel;

/**
* In FreshBooks, invoices can be paid online via a variety of payment gateways
* setup on the sender’s account. In order for this to be available on an invoice,
* the online payments must be set up through a separate call after the invoice has
* been created.
*
* While default payment options exist, they are not automatically applied to new
* invoices and must be retrieved and added manually.
*
* @package amcintosh\FreshBooks\Model
* @link https://www.freshbooks.com/api/online-payments
*/
class InvoicePaymentOptions extends DataTransferObject implements DataModel
{
public const RESPONSE_FIELD = 'payment_options';

/**
* @var string invoice_id of the connected invoice.
*
* _Note_: The API returns this as `entity_id`.
*/
#[MapFrom('entity_id')]
#[MapTo('entity_id')]
public ?string $entityId;

/**
* @var string Eg. “invoices”.
*/
#[MapFrom('entity_type')]
#[MapTo('entity_type')]
public ?string $entityType;

/**
* @var string Payment gateway name.
*/
#[MapFrom('gateway_name')]
#[MapTo('gateway_name')]
public ?string $gatewayName;

/**
* @var bool If the invoice can accept credit cards.
*/
#[MapTo('has_credit_card')]
#[MapFrom('has_credit_card')]
public ?bool $hasCreditCard;

/**
* @var bool If the invoice can accept ACH bank transfers.
*/
#[MapTo('has_ach_transfer')]
#[MapFrom('has_ach_transfer')]
public ?bool $hasAchTransfer;

#[MapFrom('has_acss_debit')]
public ?bool $hasAcssDebit;

#[MapFrom('has_bacs_debit')]
public ?bool $hasBacsDebit;

#[MapFrom('has_sepa_debit')]
public ?bool $hasSepaDebit;

#[MapFrom('has_paypal_smart_checkout')]
public ?bool $hasPaypalSmartCheckout;

/**
* @var bool If the client can use the gateway to pay part
* of the invoice or only the full amount.
*/
#[MapTo('allow_partial_payments')]
#[MapFrom('allow_partial_payments')]
public ?bool $allowPartialPayments;

/**
* Get the data as an array to POST or PUT to FreshBooks, removing any read-only fields.
*
* @return array
*/
public function getContent(): array
{
$data = $this
->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;
}
}
167 changes: 167 additions & 0 deletions src/Resource/PaymentResource.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
<?php

declare(strict_types=1);

namespace amcintosh\FreshBooks\Resource;

use Http\Client\HttpClient;
use Spatie\DataTransferObject\DataTransferObject;
use amcintosh\FreshBooks\Exception\FreshBooksException;
use amcintosh\FreshBooks\Model\DataModel;

class PaymentResource extends BaseResource
{
private HttpClient $httpClient;
private string $resourcePath;
private string $subResourcePath;
private string $defaultsPath;
private string $staticPathParams;
private string $model;

public function __construct(
HttpClient $httpClient,
string $resourcePath,
string $model,
string $subResourcePath = null,
string $defaultsPath = null,
string $staticPathParams = null,
) {
$this->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]);
}
}
2 changes: 1 addition & 1 deletion src/Resource/ProjectResource.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
68 changes: 68 additions & 0 deletions tests/Model/InvoicePaymentOptionsTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
<?php

declare(strict_types=1);

namespace amcintosh\FreshBooks\Tests\Model;

use PHPUnit\Framework\TestCase;
use amcintosh\FreshBooks\Model\InvoicePaymentOptions;

final class InvoicePaymentOptionsTest extends TestCase
{
private $samplePaymentOptionsData = '{"payment_options":{
"gateway_name": "Stripe",
"has_credit_card": true,
"has_ach_transfer": true,
"has_bacs_debit": false,
"has_sepa_debit": false,
"has_acss_debit": false,
"stripe_acss_payment_options": null,
"has_paypal_smart_checkout": false,
"allow_partial_payments": false,
"entity_type": "invoice",
"entity_id": "12345",
"gateway_info": {
"id": "abcdef",
"account_id": "210000012",
"country": "CA",
"user_publishable_key": null,
"currencies": [
"CAD"
],
"bank_transfer_enabled": false,
"gateway_name": "fbpay",
"can_process_payments": true
}
}}';

public function testInvoicePaymentOptionsFromResponse(): void
{
$paymentOptionsData = json_decode($this->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());
}
}
Loading

0 comments on commit b948292

Please sign in to comment.