From 12753f7442545a5683940a64e8cba5de55c2fa2c Mon Sep 17 00:00:00 2001 From: Stephen Chapman Date: Tue, 25 Apr 2023 11:34:20 -0400 Subject: [PATCH 1/2] Adding ledger accounts and journal entries --- freshbooks/api/business.py | 181 +++++++++++++++++++++++++++++++++++++ freshbooks/client.py | 20 ++++ 2 files changed, 201 insertions(+) create mode 100644 freshbooks/api/business.py diff --git a/freshbooks/api/business.py b/freshbooks/api/business.py new file mode 100644 index 0000000..400319f --- /dev/null +++ b/freshbooks/api/business.py @@ -0,0 +1,181 @@ +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 BusinessResource(Resource): + """Handles resources under the `/accounting` endpoints.""" + + def __init__(self, client_config: SimpleNamespace, accounting_path: str, + single_name: str, list_name: str, + delete_via_update: bool = True, missing_endpoints: Optional[List[str]] = None): + super().__init__(client_config) + self.accounting_path = accounting_path + self.single_name = single_name + self.list_name = list_name + self.delete_via_update = delete_via_update + self.missing_endpoints = missing_endpoints or [] + + def _get_url(self, account_id: str, resource_id: Optional[int] = 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 + + if isinstance(errors, list): + return errors[0]["message"], int(errors[0]["errno"]) + + return errors["message"], int(errors["errno"]) # 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 "data" not in content: + raise FreshBooksError(status, "Returned an unexpected response", raw_response=response.text) + + response_data = content + if status >= 400: + message, code = self._extract_error(response_data.get("errors")) if response_data.get("errors") else ("unknown error", status) + raise FreshBooksError(status, message, error_code=code, 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(self.list_name, name) + + def get(self, account_id: str, resource_id: int, 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(account_id, 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(self.single_name, 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(self.list_name, self.single_name, 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={self.single_name: data}) + return Result(self.single_name, response) + + def update( + self, account_id: str, resource_id: int, 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={self.single_name: data}) + return Result(self.single_name, response) + + def delete(self, account_id: str, resource_id: int) -> 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={self.single_name: {"vis_state": VisState.DELETED}} + ) + else: + response = self._request(self._get_url(account_id, resource_id), HttpVerbs.DELETE) + return Result(self.single_name, response) diff --git a/freshbooks/client.py b/freshbooks/client.py index 8104cc9..a4be82b 100644 --- a/freshbooks/client.py +++ b/freshbooks/client.py @@ -9,6 +9,7 @@ from freshbooks.api.accounting import AccountingResource from freshbooks.api.auth import AuthResource +from freshbooks.api.business import BusinessResource from freshbooks.api.comments import CommentsResource, CommentsSubResource from freshbooks.api.events import EventsResource from freshbooks.api.payments import PaymentsResource @@ -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") + + @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: @@ -353,6 +364,15 @@ def taxes(self) -> AccountingResource: return AccountingResource( self._client_resource_config(), "taxes/taxes", "tax", "taxes", delete_via_update=False ) + + # Business Resources + + @property + def ledger_accounts(self) -> BusinessResource: + """FreshBooks accounts resource with calls to get, list, create, update, delete""" + return BusinessResource( + self._client_resource_config(), "ledger_accounts/accounts", "ledger_accounts", "accounts" + ) # Events Resources From 1e478773c4a5a66797eee2fa2a27e43a17ee362a Mon Sep 17 00:00:00 2001 From: Stephen Chapman Date: Mon, 1 May 2023 00:16:28 -0400 Subject: [PATCH 2/2] testing changes and adjustments --- .../{business.py => accounting_business.py} | 44 ++++----- freshbooks/client.py | 13 +-- .../fixtures/get_ledger_account_response.json | 18 ++++ ...et_ledger_account_response__not_found.json | 15 +++ .../list_ledger_account_response.json | 48 ++++++++++ tests/test_accounting_business_resource.py | 95 +++++++++++++++++++ 6 files changed, 202 insertions(+), 31 deletions(-) rename freshbooks/api/{business.py => accounting_business.py} (79%) create mode 100644 tests/fixtures/get_ledger_account_response.json create mode 100644 tests/fixtures/get_ledger_account_response__not_found.json create mode 100644 tests/fixtures/list_ledger_account_response.json create mode 100644 tests/test_accounting_business_resource.py diff --git a/freshbooks/api/business.py b/freshbooks/api/accounting_business.py similarity index 79% rename from freshbooks/api/business.py rename to freshbooks/api/accounting_business.py index 400319f..c199949 100644 --- a/freshbooks/api/business.py +++ b/freshbooks/api/accounting_business.py @@ -9,20 +9,17 @@ from freshbooks.models import ListResult, Result, VisState -class BusinessResource(Resource): +class AccountingBusinessResource(Resource): """Handles resources under the `/accounting` endpoints.""" def __init__(self, client_config: SimpleNamespace, accounting_path: str, - single_name: str, list_name: str, delete_via_update: bool = True, missing_endpoints: Optional[List[str]] = None): super().__init__(client_config) self.accounting_path = accounting_path - self.single_name = single_name - self.list_name = list_name self.delete_via_update = delete_via_update self.missing_endpoints = missing_endpoints or [] - def _get_url(self, account_id: str, resource_id: Optional[int] = None) -> str: + 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) @@ -32,10 +29,7 @@ def _extract_error(self, errors: Union[list, dict]) -> Tuple[str, Optional[int]] if not errors: # pragma: no cover return "Unknown error", None - if isinstance(errors, list): - return errors[0]["message"], int(errors[0]["errno"]) - - return errors["message"], int(errors["errno"]) # pragma: no cover + 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) @@ -50,13 +44,13 @@ def _request(self, url: str, method: str, data: Optional[dict] = None) -> Any: except ValueError: raise FreshBooksError(status, "Failed to parse response", raw_response=response.text) - if "data" not in content: - raise FreshBooksError(status, "Returned an unexpected 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, code = self._extract_error(response_data.get("errors")) if response_data.get("errors") else ("unknown error", status) - raise FreshBooksError(status, message, error_code=code, raw_response=content) + 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: @@ -64,9 +58,9 @@ def _request(self, url: str, method: str, data: Optional[dict] = None) -> Any: def _reject_missing(self, name: str) -> None: if name in self.missing_endpoints: - raise FreshBooksNotImplementedError(self.list_name, name) + raise FreshBooksNotImplementedError("data", name) - def get(self, account_id: str, resource_id: int, includes: Optional[IncludesBuilder] = None) -> Result: + def get(self, business_uuid: str, resource_id: str, includes: Optional[IncludesBuilder] = None) -> Result: """Get a single resource with the corresponding id. Args: @@ -80,12 +74,12 @@ def get(self, account_id: str, resource_id: int, includes: Optional[IncludesBuil FreshBooksError: If the call is not successful. """ self._reject_missing("get") - resource_url = self._get_url(account_id, resource_id) + 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(self.single_name, data) + return Result("data", data) def list(self, account_id: str, builders: Optional[List[Builder]] = None) -> ListResult: """Get a list of resources. @@ -104,7 +98,7 @@ def list(self, account_id: str, builders: Optional[List[Builder]] = None) -> Lis 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(self.list_name, self.single_name, data) + return ListResult("data", "data", data) def create(self, account_id: str, data: dict, includes: Optional[IncludesBuilder] = None) -> Result: """Create a resource. @@ -125,11 +119,11 @@ def create(self, account_id: str, data: dict, includes: Optional[IncludesBuilder query_string = "" if includes: query_string = self._build_query_string([includes]) - response = self._request(f"{resource_url}{query_string}", HttpVerbs.POST, data={self.single_name: data}) + 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: int, data: dict, includes: Optional[IncludesBuilder] = None + self, account_id: str, resource_id: str, data: dict, includes: Optional[IncludesBuilder] = None ) -> Result: """Update a resource. @@ -150,10 +144,10 @@ def update( query_string = "" if includes: query_string = self._build_query_string([includes]) - response = self._request(f"{resource_url}{query_string}", HttpVerbs.PUT, data={self.single_name: data}) - return Result(self.single_name, response) + 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: int) -> Result: + def delete(self, account_id: str, resource_id: str) -> Result: """Delete a resource. Note: Most FreshBooks resources are soft-deleted, @@ -174,8 +168,8 @@ def delete(self, account_id: str, resource_id: int) -> Result: response = self._request( self._get_url(account_id, resource_id), HttpVerbs.PUT, - data={self.single_name: {"vis_state": VisState.DELETED}} + data={"data": {"vis_state": VisState.DELETED}} ) else: response = self._request(self._get_url(account_id, resource_id), HttpVerbs.DELETE) - return Result(self.single_name, response) + return Result("data", response) diff --git a/freshbooks/client.py b/freshbooks/client.py index a4be82b..811c16e 100644 --- a/freshbooks/client.py +++ b/freshbooks/client.py @@ -9,7 +9,7 @@ from freshbooks.api.accounting import AccountingResource from freshbooks.api.auth import AuthResource -from freshbooks.api.business import BusinessResource +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 @@ -365,13 +365,14 @@ def taxes(self) -> AccountingResource: self._client_resource_config(), "taxes/taxes", "tax", "taxes", delete_via_update=False ) - # Business Resources + # Accounting Business Resources @property - def ledger_accounts(self) -> BusinessResource: - """FreshBooks accounts resource with calls to get, list, create, update, delete""" - return BusinessResource( - self._client_resource_config(), "ledger_accounts/accounts", "ledger_accounts", "accounts" + 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 diff --git a/tests/fixtures/get_ledger_account_response.json b/tests/fixtures/get_ledger_account_response.json new file mode 100644 index 0000000..c7e3dfa --- /dev/null +++ b/tests/fixtures/get_ledger_account_response.json @@ -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" + } +} \ No newline at end of file diff --git a/tests/fixtures/get_ledger_account_response__not_found.json b/tests/fixtures/get_ledger_account_response__not_found.json new file mode 100644 index 0000000..a6bac72 --- /dev/null +++ b/tests/fixtures/get_ledger_account_response__not_found.json @@ -0,0 +1,15 @@ +{ + "errors": { + "message": "Request failed with status code 404", + "status": 404, + "details": [ + { + "reason": "Not found.", + "metadata": { + "message": "Not found.", + "field": null + } + } + ] + } +} \ No newline at end of file diff --git a/tests/fixtures/list_ledger_account_response.json b/tests/fixtures/list_ledger_account_response.json new file mode 100644 index 0000000..c14a68f --- /dev/null +++ b/tests/fixtures/list_ledger_account_response.json @@ -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" + } + ] +} \ No newline at end of file diff --git a/tests/test_accounting_business_resource.py b/tests/test_accounting_business_resource.py new file mode 100644 index 0000000..3cc75da --- /dev/null +++ b/tests/test_accounting_business_resource.py @@ -0,0 +1,95 @@ +from datetime import datetime, timezone +import json +import httpretty +import pytest +import uuid + +from freshbooks import Client as FreshBooksClient +from freshbooks import PaginateBuilder, FilterBuilder, IncludesBuilder, FreshBooksError, VisState +from freshbooks.client import API_BASE_URL, VERSION +from tests import get_fixture + + +class TestAccountingBusinessResources: + def setup_method(self, method): + self.business_uuid = str(uuid.uuid4()) + self.freshBooksClient = FreshBooksClient(client_id="some_client", access_token="some_token") + + @httpretty.activate + def test_get_ledger_account(self): + account_uuid = "e17f9556-c99f-4fdb-ad8b-089ac4798bae" + url = "{}/accounting/businesses/{}/ledger_accounts/accounts/{}".format(API_BASE_URL, self.business_uuid, account_uuid) + httpretty.register_uri( + httpretty.GET, + url, + body=json.dumps(get_fixture("get_ledger_account_response")), + status=200 + ) + + ledger_account = self.freshBooksClient.ledger_accounts.get(self.business_uuid, account_uuid) + + assert str(ledger_account) == "Result(data)" + assert ledger_account.uuid == account_uuid + assert ledger_account.name == "Cash" + assert ledger_account.data["name"] == "Cash" + assert ledger_account.sub_type == "Cash & Bank" + assert ledger_account.data["sub_type"] == "Cash & Bank" + assert ledger_account.data["updated_at"] == "2022-09-22T08:47:04.668685Z" + assert ledger_account.updated_at == datetime(2022, 9, 22, 8, 47, 4, 668685, tzinfo=timezone.utc) + + assert httpretty.last_request().headers["Authorization"] == "Bearer some_token" + assert httpretty.last_request().headers["Content-Type"] is None + assert (httpretty.last_request().headers["user-agent"] + == f"FreshBooks python sdk/{VERSION} client_id some_client") + + @httpretty.activate + def test_get_ledger_account__not_found_error(self): + account_uuid = "e17f9556-c99f-4fdb-ad8b-089ac4798bae" + url = "{}/accounting/businesses/{}/ledger_accounts/accounts/{}".format(API_BASE_URL, self.business_uuid, account_uuid) + httpretty.register_uri( + httpretty.GET, + url, + body=json.dumps(get_fixture("get_ledger_account_response__not_found")), + status=404 + ) + try: + self.freshBooksClient.ledger_accounts.get(self.business_uuid, account_uuid) + except FreshBooksError as e: + assert str(e) == "Request failed with status code 404" + assert e.status_code == 404 + assert e.error_details == [{ + "reason": "Not found.", + "metadata": { + "message": "Not found.", + "field": None + } + }] + + @httpretty.activate + def test_list_ledger_accounts(self): + account_uuid = "e17f9556-c99f-4fdb-ad8b-089ac4798bae" + url = "{}/accounting/businesses/{}/ledger_accounts/accounts".format(API_BASE_URL, self.business_uuid) + httpretty.register_uri( + httpretty.GET, + url, + body=json.dumps(get_fixture("list_ledger_account_response")), + status=200 + ) + + ledger_accounts = self.freshBooksClient.ledger_accounts.list(self.business_uuid) + + assert str(ledger_accounts) == "ListResult(data)" + assert len(ledger_accounts) == 3 + ledger_account = ledger_accounts[0] + assert ledger_account.uuid == account_uuid + assert ledger_account.name == "Cash" + assert ledger_account.data["name"] == "Cash" + assert ledger_account.sub_type == "Cash & Bank" + assert ledger_account.data["sub_type"] == "Cash & Bank" + assert ledger_account.data["updated_at"] == "2022-09-22T08:47:04.668685Z" + assert ledger_account.updated_at == datetime(2022, 9, 22, 8, 47, 4, 668685, tzinfo=timezone.utc) + + assert httpretty.last_request().headers["Authorization"] == "Bearer some_token" + assert httpretty.last_request().headers["Content-Type"] is None + assert (httpretty.last_request().headers["user-agent"] + == f"FreshBooks python sdk/{VERSION} client_id some_client") \ No newline at end of file