diff --git a/.tool-versions b/.tool-versions index 52d1c3e..8b869bd 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1 +1 @@ -python 3.8.0 +python 3.8.10 diff --git a/dnsimple/client.py b/dnsimple/client.py index 06661e0..507378f 100644 --- a/dnsimple/client.py +++ b/dnsimple/client.py @@ -4,7 +4,7 @@ from requests.auth import HTTPBasicAuth from dnsimple.extra import prepare_params -from dnsimple.service import Accounts, Domains, Identity, Oauth, Zones, Registrar, Certificates, Tlds, Contacts, \ +from dnsimple.service import Accounts, Billing, Domains, Identity, Oauth, Zones, Registrar, Certificates, Tlds, Contacts, \ Services, Templates, VanityNameServers, Webhooks from dnsimple.token_authentication import TokenAuthentication from dnsimple.version import version @@ -211,6 +211,7 @@ def __add_authentication_method(self, access_token, email, password): def __attach_services(self): self.accounts = Accounts(self) + self.billing = Billing(self) self.certificates = Certificates(self) self.contacts = Contacts(self) self.domains = Domains(self) diff --git a/dnsimple/service/__init__.py b/dnsimple/service/__init__.py index 4252b39..32b4956 100644 --- a/dnsimple/service/__init__.py +++ b/dnsimple/service/__init__.py @@ -1,4 +1,5 @@ from dnsimple.service.accounts import Accounts +from dnsimple.service.billing import Billing from dnsimple.service.certificates import Certificates from dnsimple.service.contacts import Contacts from dnsimple.service.domains import Domains diff --git a/dnsimple/service/billing.py b/dnsimple/service/billing.py new file mode 100644 index 0000000..0bfef54 --- /dev/null +++ b/dnsimple/service/billing.py @@ -0,0 +1,18 @@ +from dnsimple.response import Response +from dnsimple.struct import Charge + +class Billing(object): + def __init__(self, client): + self.client = client + + def list_charges(self, account: int, *, start_date=None, end_date=None, sort=None): + """ + Lists the billing charges for the account. + + See https://developer.dnsimple.com/v2/billing/#listCharges + + :param account: + The account id + """ + response = self.client.get(f'/{account}/billing/charges', params={"start_date": start_date, "end_date": end_date, "sort": sort}) + return Response(response, Charge) diff --git a/dnsimple/struct/__init__.py b/dnsimple/struct/__init__.py index d9c0956..0643355 100644 --- a/dnsimple/struct/__init__.py +++ b/dnsimple/struct/__init__.py @@ -2,6 +2,7 @@ from dnsimple.struct.access_token import AccessToken from dnsimple.struct.account import Account from dnsimple.struct.certificate import Certificate, CertificateBundle, LetsencryptCertificateInput, LetsencryptCertificateRenewalInput, CertificatePurchase, CertificateRenewal +from dnsimple.struct.charge import Charge, ChargeItem from dnsimple.struct.collaborator import Collaborator from dnsimple.struct.contact import Contact from dnsimple.struct.dnssec import Dnssec diff --git a/dnsimple/struct/charge.py b/dnsimple/struct/charge.py new file mode 100644 index 0000000..73aae65 --- /dev/null +++ b/dnsimple/struct/charge.py @@ -0,0 +1,35 @@ +from dataclasses import dataclass +from decimal import Decimal + +from dnsimple.struct import Struct + + +@dataclass +class Charge(Struct): + invoiced_at = None + total_amount = None + balance_amount = None + reference = None + state = None + items = None + + def __init__(self, data): + super().__init__(data) + if self.total_amount is not None: + self.total_amount = Decimal(self.total_amount) + if self.balance_amount is not None: + self.balance_amount = Decimal(self.balance_amount) + + +@dataclass +class ChargeItem(Struct): + description = None + amount = None + product_id = None + product_type = None + product_reference = None + + def __init__(self, data): + super().__init__(data) + if self.amount is not None: + self.amount = Decimal(self.amount) diff --git a/tests/fixtures/v2/api/listCharges/fail-400-bad-filter.http b/tests/fixtures/v2/api/listCharges/fail-400-bad-filter.http new file mode 100644 index 0000000..5e36743 --- /dev/null +++ b/tests/fixtures/v2/api/listCharges/fail-400-bad-filter.http @@ -0,0 +1,14 @@ +HTTP/1.1 400 Bad Request +Date: Tue, 24 Oct 2023 08:13:01 GMT +Connection: close +X-RateLimit-Limit: 2400 +X-RateLimit-Remaining: 2392 +X-RateLimit-Reset: 1698136677 +Content-Type: application/json; charset=utf-8 +X-WORK-WITH-US: Love automation? So do we! https://dnsimple.com/jobs +Cache-Control: no-cache +X-Request-Id: bdfbf3a7-d9dc-4018-9732-61502be989a3 +X-Runtime: 0.455303 +Transfer-Encoding: chunked + +{"message":"Invalid date format must be ISO8601 (YYYY-MM-DD)"} diff --git a/tests/fixtures/v2/api/listCharges/fail-403.http b/tests/fixtures/v2/api/listCharges/fail-403.http new file mode 100644 index 0000000..ddf9f64 --- /dev/null +++ b/tests/fixtures/v2/api/listCharges/fail-403.http @@ -0,0 +1,14 @@ +HTTP/1.1 403 Forbidden +Date: Tue, 24 Oct 2023 09:49:29 GMT +Connection: close +X-RateLimit-Limit: 2400 +X-RateLimit-Remaining: 2398 +X-RateLimit-Reset: 1698143967 +Content-Type: application/json; charset=utf-8 +X-WORK-WITH-US: Love automation? So do we! https://dnsimple.com/jobs +Cache-Control: no-cache +X-Request-Id: 5554e2d3-2652-4ca7-8c5e-92b4c35f28d6 +X-Runtime: 0.035309 +Transfer-Encoding: chunked + +{"message":"Permission Denied. Required Scope: billing:*:read"} diff --git a/tests/fixtures/v2/api/listCharges/success.http b/tests/fixtures/v2/api/listCharges/success.http new file mode 100644 index 0000000..ae726a0 --- /dev/null +++ b/tests/fixtures/v2/api/listCharges/success.http @@ -0,0 +1,14 @@ +HTTP/1.1 200 OK +Date: Tue, 24 Oct 2023 09:52:55 GMT +Connection: close +X-RateLimit-Limit: 2400 +X-RateLimit-Remaining: 2397 +X-RateLimit-Reset: 1698143967 +Content-Type: application/json; charset=utf-8 +X-WORK-WITH-US: Love automation? So do we! https://dnsimple.com/jobs +Cache-Control: no-store, must-revalidate, private, max-age=0 +X-Request-Id: a57a87c8-626a-4361-9fb8-b55ca9be8e5d +X-Runtime: 0.060526 +Transfer-Encoding: chunked + +{"data":[{"invoiced_at":"2023-08-17T05:53:36Z","total_amount":"14.50","balance_amount":"0.00","reference":"1-2","state":"collected","items":[{"description":"Register bubble-registered.com","amount":"14.50","product_id":1,"product_type":"domain-registration","product_reference":"bubble-registered.com"}]},{"invoiced_at":"2023-08-17T05:57:53Z","total_amount":"14.50","balance_amount":"0.00","reference":"2-2","state":"refunded","items":[{"description":"Register example.com","amount":"14.50","product_id":2,"product_type":"domain-registration","product_reference":"example.com"}]},{"invoiced_at":"2023-10-24T07:49:05Z","total_amount":"1099999.99","balance_amount":"0.00","reference":"4-2","state":"collected","items":[{"description":"Test Line Item 1","amount":"99999.99","product_id":null,"product_type":"manual","product_reference":null},{"description":"Test Line Item 2","amount":"1000000.00","product_id":null,"product_type":"manual","product_reference":null}]}],"pagination":{"current_page":1,"per_page":30,"total_entries":3,"total_pages":1}} diff --git a/tests/helpers.py b/tests/helpers.py index 3a06038..1fe2b0e 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -34,6 +34,7 @@ class DNSimpleTest(unittest.TestCase): def setUp(self) -> None: self.client = Client(access_token='SomeMagicToken', sandbox=True) self.accounts = self.client.accounts + self.billing = self.client.billing self.certificates = self.client.certificates self.contacts = self.client.contacts self.domains = self.client.domains diff --git a/tests/service/billing_test.py b/tests/service/billing_test.py new file mode 100644 index 0000000..ef56dc3 --- /dev/null +++ b/tests/service/billing_test.py @@ -0,0 +1,67 @@ +import unittest + +import responses + +from dnsimple import DNSimpleException +from dnsimple.struct import Charge, ChargeItem +from tests.helpers import DNSimpleMockResponse, DNSimpleTest + + +class BillingTest(DNSimpleTest): + @responses.activate + def test_list_charges_success(self): + responses.add(DNSimpleMockResponse(method=responses.GET, + path='/1010/billing/charges', + fixture_name='listCharges/success')) + charges = self.billing.list_charges(1010).data + + self.assertEqual(charges, [ + Charge({"invoiced_at": "2023-08-17T05:53:36Z","total_amount": "14.50","balance_amount": "0.00","reference": "1-2","state": "collected","items":[{"description": "Register bubble-registered.com","amount": "14.50","product_id": 1,"product_type": "domain-registration","product_reference": "bubble-registered.com"}]}), + Charge({"invoiced_at": "2023-08-17T05:57:53Z","total_amount": "14.50","balance_amount": "0.00","reference": "2-2","state": "refunded","items":[{"description": "Register example.com","amount": "14.50","product_id": 2,"product_type": "domain-registration","product_reference": "example.com"}]}), + Charge({"invoiced_at": "2023-10-24T07:49:05Z","total_amount": "1099999.99","balance_amount": "0.00","reference": "4-2","state": "collected","items":[{"description": "Test Line Item 1","amount": "99999.99","product_id": None,"product_type": "manual","product_reference":None},{"description": "Test Line Item 2","amount": "1000000.00","product_id": None,"product_type": "manual","product_reference":None}]}), + ]) + + @responses.activate + def test_list_charges_fail_400_bad_filter(self): + responses.add(DNSimpleMockResponse(method=responses.GET, + path='/1010/billing/charges', + fixture_name='listCharges/fail-400-bad-filter')) + try: + self.billing.list_charges(1010) + assert False + except DNSimpleException as e: + self.assertEqual(e.message, "Invalid date format must be ISO8601 (YYYY-MM-DD)") + + @responses.activate + def test_list_charges_fail_403(self): + responses.add(DNSimpleMockResponse(method=responses.GET, + path='/1010/billing/charges', + fixture_name='listCharges/fail-403')) + try: + self.billing.list_charges(1010) + assert False + except DNSimpleException as e: + self.assertEqual(e.message, "Permission Denied. Required Scope: billing:*:read") + +class TestCharge(unittest.TestCase): + def test_total_amount_parsing(self): + charge = Charge({'total_amount': '100.50', 'balance_amount': None}) + self.assertEqual(charge.total_amount, 100.5) + + def test_balance_amount_parsing(self): + charge = Charge({'total_amount': None, 'balance_amount': '50.25'}) + self.assertEqual(charge.balance_amount, 50.25) + + def test_none_values(self): + charge = Charge({'total_amount': None, 'balance_amount': None}) + self.assertIsNone(charge.total_amount) + self.assertIsNone(charge.balance_amount) + +class TestChargeItem(unittest.TestCase): + def test_amount_parsing(self): + charge = ChargeItem({'amount': '100.50'}) + self.assertEqual(charge.amount, 100.5) + + def test_none_values(self): + charge = ChargeItem({'amount': None}) + self.assertIsNone(charge.amount)