Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
175 changes: 175 additions & 0 deletions freshbooks/api/accounting_business.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
from decimal import Decimal
from types import SimpleNamespace
from typing import Any, List, Optional, Tuple, Union

from freshbooks.api.resource import HttpVerbs, Resource
from freshbooks.builders import Builder
from freshbooks.builders.includes import IncludesBuilder
from freshbooks.errors import FreshBooksError, FreshBooksNotImplementedError
from freshbooks.models import ListResult, Result, VisState


class AccountingBusinessResource(Resource):
"""Handles resources under the `/accounting` endpoints."""

def __init__(self, client_config: SimpleNamespace, accounting_path: str,
delete_via_update: bool = True, missing_endpoints: Optional[List[str]] = None):
super().__init__(client_config)
self.accounting_path = accounting_path
self.delete_via_update = delete_via_update
self.missing_endpoints = missing_endpoints or []

def _get_url(self, account_id: str, resource_id: Optional[str] = None) -> str:
if resource_id:
return "{}/accounting/businesses/{}/{}/{}".format(
self.base_url, account_id, self.accounting_path, resource_id)
return "{}/accounting/businesses/{}/{}".format(self.base_url, account_id, self.accounting_path)

def _extract_error(self, errors: Union[list, dict]) -> Tuple[str, Optional[int]]:
if not errors: # pragma: no cover
return "Unknown error", None

return (errors["message"], errors["details"]) # pragma: no cover

def _request(self, url: str, method: str, data: Optional[dict] = None) -> Any:
response = self._send_request(url, method, data)

status = response.status_code
if status == 200 and method == HttpVerbs.HEAD: # pragma: no cover
# no content returned from a HEAD
return

try:
content = response.json(parse_float=Decimal)
except ValueError:
raise FreshBooksError(status, "Failed to parse response", raw_response=response.text)

if not content:
raise FreshBooksError(status, "Returned an empty response", raw_response=response.text)

response_data = content
if status >= 400:
message, details = self._extract_error(response_data.get("errors")) if response_data.get("errors") else ("unknown error", status)
raise FreshBooksError(status, message, error_details=details, raw_response=content)
try:
return response_data["result"]
except KeyError:
return response_data

def _reject_missing(self, name: str) -> None:
if name in self.missing_endpoints:
raise FreshBooksNotImplementedError("data", name)

def get(self, business_uuid: str, resource_id: str, includes: Optional[IncludesBuilder] = None) -> Result:
"""Get a single resource with the corresponding id.

Args:
account_id: The alpha-numeric account id
resource_id: Id of the resource to return
builders: (Optional) IncludesBuilder object for including additional data, sub-resources, etc.
Returns:
Result: Result object with the resource's response data.

Raises:
FreshBooksError: If the call is not successful.
"""
self._reject_missing("get")
resource_url = self._get_url(business_uuid, resource_id)
query_string = ""
if includes:
query_string = self._build_query_string([includes])
data = self._request(f"{resource_url}{query_string}", HttpVerbs.GET)
return Result("data", data)

def list(self, account_id: str, builders: Optional[List[Builder]] = None) -> ListResult:
"""Get a list of resources.

Args:
account_id: The alpha-numeric account id
builders: (Optional) List of builder objects for filters, pagination, etc.

Returns:
ListResult: ListResult object with the resources response data.

Raises:
FreshBooksError: If the call is not successful.
"""
self._reject_missing("list")
resource_url = self._get_url(account_id)
query_string = self._build_query_string(builders)
data = self._request(f"{resource_url}{query_string}", HttpVerbs.GET)
return ListResult("data", "data", data)

def create(self, account_id: str, data: dict, includes: Optional[IncludesBuilder] = None) -> Result:
"""Create a resource.

Args:
account_id: The alpha-numeric account id
data: Dictionary of data to populate the resource
builders: (Optional) IncludesBuilder object for including additional data, sub-resources, etc.

Returns:
Result: Result object with the new resource's response data.

Raises:
FreshBooksError: If the call is not successful.
"""
self._reject_missing("create")
resource_url = self._get_url(account_id)
query_string = ""
if includes:
query_string = self._build_query_string([includes])
response = self._request(f"{resource_url}{query_string}", HttpVerbs.POST, data={"data": data})
return Result(self.single_name, response)

def update(
self, account_id: str, resource_id: str, data: dict, includes: Optional[IncludesBuilder] = None
) -> Result:
"""Update a resource.

Args:
account_id: The alpha-numeric account id
resource_id: Id of the resource to update
data: Dictionary of data to update the resource to
builders: (Optional) IncludesBuilder object for including additional data, sub-resources, etc.

Returns:
Result: Result object with the updated resource's response data.

Raises:
FreshBooksError: If the call is not successful.
"""
self._reject_missing("update")
resource_url = self._get_url(account_id, resource_id)
query_string = ""
if includes:
query_string = self._build_query_string([includes])
response = self._request(f"{resource_url}{query_string}", HttpVerbs.PUT, data={"data": data})
return Result("data", response)

def delete(self, account_id: str, resource_id: str) -> Result:
"""Delete a resource.

Note: Most FreshBooks resources are soft-deleted,
See [FreshBooks API - Active and Deleted Objects](https://www.freshbooks.com/api/active_deleted)

Args:
account_id: The alpha-numeric account id
resource_id: Id of the resource to delete

Returns:
Result: An empty Result object.

Raises:
FreshBooksError: If the call is not successful.
"""
self._reject_missing("delete")
if self.delete_via_update:
response = self._request(
self._get_url(account_id, resource_id),
HttpVerbs.PUT,
data={"data": {"vis_state": VisState.DELETED}}
)
else:
response = self._request(self._get_url(account_id, resource_id), HttpVerbs.DELETE)
return Result("data", response)
21 changes: 21 additions & 0 deletions freshbooks/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

from freshbooks.api.accounting import AccountingResource
from freshbooks.api.auth import AuthResource
from freshbooks.api.accounting_business import AccountingBusinessResource
from freshbooks.api.comments import CommentsResource, CommentsSubResource
from freshbooks.api.events import EventsResource
from freshbooks.api.payments import PaymentsResource
Expand Down Expand Up @@ -307,6 +308,16 @@ def invoice_profiles(self) -> AccountingResource:
def items(self) -> AccountingResource:
"""FreshBooks items resource with calls to get, list, create, update, delete"""
return AccountingResource(self._client_resource_config(), "items/items", "item", "items")

@property
def journal_entries(self) -> AccountingResource:
"""FreshBooks items resource with calls to get, list, create, update, delete"""
return AccountingResource(self._client_resource_config(), "journal_entries/journal_entries", "journal_entry", "journal_entries")

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks like you can only create journal entries, so missing_endpoints=["list", "get", "update", "delete"] should be added.


@property
def journal_entries_accounts(self) -> AccountingResource:
"""FreshBooks items resource with calls to get, list, create, update, delete"""
return AccountingResource(self._client_resource_config(), "journal_entry_accounts/journal_entry_accounts", "journal_entry_account", "journal_entries_accounts")

@property
def other_income(self) -> AccountingResource:
Expand Down Expand Up @@ -353,6 +364,16 @@ def taxes(self) -> AccountingResource:
return AccountingResource(
self._client_resource_config(), "taxes/taxes", "tax", "taxes", delete_via_update=False
)

# Accounting Business Resources

@property
def ledger_accounts(self) -> AccountingBusinessResource:
"""FreshBooks accounts resource with calls to get, list, create, update"""
return AccountingBusinessResource(
self._client_resource_config(), "ledger_accounts/accounts",
missing_endpoints=["delete"]
)

# Events Resources

Expand Down
18 changes: 18 additions & 0 deletions tests/fixtures/get_ledger_account_response.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
{
"data": {
"uuid": "e17f9556-c99f-4fdb-ad8b-089ac4798bae",
"name": "Cash",
"number": "1000",
"description": "",
"type": "asset",
"sub_type": "Cash & Bank",
"system_account_name": "Cash",
"parent_account": null,
"sub_accounts": [
"34bb2ff8-8c1b-4142-a66b-b6c35b2e143a"
],
"auto_created": true,
"state": "active",
"updated_at": "2022-09-22T08:47:04.668685Z"
}
}
15 changes: 15 additions & 0 deletions tests/fixtures/get_ledger_account_response__not_found.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{
"errors": {
"message": "Request failed with status code 404",
"status": 404,
"details": [
{
"reason": "Not found.",
"metadata": {
"message": "Not found.",
"field": null
}
}
]
}
}
48 changes: 48 additions & 0 deletions tests/fixtures/list_ledger_account_response.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
{
"data": [
{
"uuid": "e17f9556-c99f-4fdb-ad8b-089ac4798bae",
"name": "Cash",
"number": "1000",
"description": "",
"type": "asset",
"sub_type": "Cash & Bank",
"system_account_name": "Cash",
"parent_account": null,
"sub_accounts": [
"34bb2ff8-8c1b-4142-a66b-b6c35b2e143a"
],
"auto_created": true,
"state": "active",
"updated_at": "2022-09-22T08:47:04.668685Z"
},
{
"uuid": "02ff051b-f059-4650-b819-bccdea4e6b45",
"name": "Petty Cash",
"number": "1000-1",
"description": "",
"type": "asset",
"sub_type": "Cash & Bank",
"system_account_name": "Petty Cash",
"parent_account": "5a3f16b2-d521-414a-a404-575ee2dc6eee",
"sub_accounts": [],
"auto_created": false,
"state": "active",
"updated_at": "2023-04-23T19:51:33.817861Z"
},
{
"uuid": "e6addd0b-9d36-4860-a89d-07ea2fa33117",
"name": "Accounts Receivable",
"number": "1200",
"description": "",
"type": "asset",
"sub_type": "Current Asset",
"system_account_name": "Accounts Receivable",
"parent_account": null,
"sub_accounts": [],
"auto_created": false,
"state": "active",
"updated_at": "2023-04-23T19:51:33.800781Z"
}
]
}
Loading