Skip to content

Commit

Permalink
✨ Add invoice payment options
Browse files Browse the repository at this point in the history
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
  • Loading branch information
amcintosh committed Mar 27, 2024
1 parent 58c7c18 commit fab7d0d
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 fab7d0d

Please sign in to comment.