Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement billing charges API #437

Merged
merged 5 commits into from
Nov 3, 2023
Merged
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
2 changes: 1 addition & 1 deletion .tool-versions
Original file line number Diff line number Diff line change
@@ -1 +1 @@
python 3.8.0
python 3.8.10
3 changes: 2 additions & 1 deletion dnsimple/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions dnsimple/service/__init__.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down
18 changes: 18 additions & 0 deletions dnsimple/service/billing.py
Original file line number Diff line number Diff line change
@@ -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)
1 change: 1 addition & 0 deletions dnsimple/struct/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
35 changes: 35 additions & 0 deletions dnsimple/struct/charge.py
Original file line number Diff line number Diff line change
@@ -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)
14 changes: 14 additions & 0 deletions tests/fixtures/v2/api/listCharges/fail-400-bad-filter.http
Original file line number Diff line number Diff line change
@@ -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)"}
14 changes: 14 additions & 0 deletions tests/fixtures/v2/api/listCharges/fail-403.http
Original file line number Diff line number Diff line change
@@ -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"}
14 changes: 14 additions & 0 deletions tests/fixtures/v2/api/listCharges/success.http
Original file line number Diff line number Diff line change
@@ -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}}
1 change: 1 addition & 0 deletions tests/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
67 changes: 67 additions & 0 deletions tests/service/billing_test.py
Original file line number Diff line number Diff line change
@@ -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)