diff --git a/freshbooks/api/accounting_business.py b/freshbooks/api/accounting_business.py new file mode 100644 index 0000000..c199949 --- /dev/null +++ b/freshbooks/api/accounting_business.py @@ -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) diff --git a/freshbooks/client.py b/freshbooks/client.py index 8104cc9..811c16e 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.accounting_business import AccountingBusinessResource 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,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 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