Skip to content

Commit

Permalink
Implement billing charges API (#156)
Browse files Browse the repository at this point in the history
This adds support for the `listCharges` API.

Belongs to https://github.com/dnsimple/dnsimple-business/issues/1763
  • Loading branch information
Wilson Lin authored Nov 3, 2023
1 parent e0ce476 commit ccfeab5
Show file tree
Hide file tree
Showing 9 changed files with 317 additions and 6 deletions.
74 changes: 74 additions & 0 deletions dnsimple/billing.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
package dnsimple

import (
"context"
"fmt"

"github.com/shopspring/decimal"
)

type ListChargesOptions struct {
// Only include results after the given date.
StartDate string `url:"start_date,omitempty"`

// Only include results before the given date.
EndDate string `url:"end_date,omitempty"`

// Sort results. Default sorting is by invoiced ascending.
Sort string `url:"sort,omitempty"`
}

type ListChargesResponse struct {
Response
Data []Charge `json:"data"`
}

type Charge struct {
InvoicedAt string `json:"invoiced_at,omitempty"`
TotalAmount decimal.Decimal `json:"total_amount,omitempty"`
BalanceAmount decimal.Decimal `json:"balance_amount,omitempty"`
Reference string `json:"reference,omitempty"`
State string `json:"state,omitempty"`
Items []ChargeItem `json:"items,omitempty"`
}

type ChargeItem struct {
Description string `json:"description,omitempty"`
Amount decimal.Decimal `json:"amount,omitempty"`
ProductId int64 `json:"product_id,omitempty"`
ProductType string `json:"product_type,omitempty"`
ProductReference string `json:"product_reference,omitempty"`
}

type BillingService struct {
client *Client
}

// Lists the billing charges for the account.
//
// See https://developer.dnsimple.com/v2/billing/#listCharges
func (s *BillingService) ListCharges(
ctx context.Context,
account string,
options ListChargesOptions,
) (*ListChargesResponse, error) {
res := &ListChargesResponse{}
path := fmt.Sprintf("/v2/%v/billing/charges", account)

path, err := addURLQueryOptions(path, options)
if err != nil {
return nil, err
}

httpRes, err := s.client.get(
ctx,
path,
res,
)
if err != nil {
return nil, err
}

res.HTTPResponse = httpRes
return res, nil
}
190 changes: 190 additions & 0 deletions dnsimple/billing_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
package dnsimple

import (
"context"
"encoding/json"
"io"
"net/http"
"net/url"
"testing"

"github.com/shopspring/decimal"
"github.com/stretchr/testify/assert"
)

func toDecimal(t *testing.T, s string) decimal.Decimal {
d, err := decimal.NewFromString(s)
if err != nil {
assert.Nilf(t, err, "toDecimal() error = %v", err)
}

return d
}

func TestBillingService_ListCharges_Success(t *testing.T) {
setupMockServer()
defer teardownMockServer()

mux.HandleFunc("/v2/1010/billing/charges", func(w http.ResponseWriter, r *http.Request) {
httpResponse := httpResponseFixture(t, "/api/listCharges/success.http")

testMethod(t, r, "GET")
testHeaders(t, r)
testQuery(t, r, url.Values{})

w.WriteHeader(httpResponse.StatusCode)
_, _ = io.Copy(w, httpResponse.Body)
})

response, err := client.Billing.ListCharges(context.Background(), "1010", ListChargesOptions{})

assert.NoError(t, err)
assert.Equal(t, response.Pagination, &Pagination{CurrentPage: 1, PerPage: 30, TotalPages: 1, TotalEntries: 3})
assert.Equal(t, response.Data, []Charge{
{
InvoicedAt: "2023-08-17T05:53:36Z",
TotalAmount: toDecimal(t, "14.50"),
BalanceAmount: toDecimal(t, "0.00"),
Reference: "1-2",
State: "collected",
Items: []ChargeItem{
{
Description: "Register bubble-registered.com",
Amount: toDecimal(t, "14.50"),
ProductId: 1,
ProductType: "domain-registration",
ProductReference: "bubble-registered.com",
},
},
},
{
InvoicedAt: "2023-08-17T05:57:53Z",
TotalAmount: toDecimal(t, "14.50"),
BalanceAmount: toDecimal(t, "0.00"),
Reference: "2-2",
State: "refunded",
Items: []ChargeItem{
{
Description: "Register example.com",
Amount: toDecimal(t, "14.50"),
ProductId: 2,
ProductType: "domain-registration",
ProductReference: "example.com",
},
},
},
{
InvoicedAt: "2023-10-24T07:49:05Z",
TotalAmount: toDecimal(t, "1099999.99"),
BalanceAmount: toDecimal(t, "0.00"),
Reference: "4-2",
State: "collected",
Items: []ChargeItem{
{
Description: "Test Line Item 1",
Amount: toDecimal(t, "99999.99"),
ProductId: 0,
ProductType: "manual",
ProductReference: "",
},
{
Description: "Test Line Item 2",
Amount: toDecimal(t, "1000000.00"),
ProductId: 0,
ProductType: "manual",
ProductReference: "",
},
},
},
})
}

func TestBillingService_ListCharges_Fail400BadFilter(t *testing.T) {
setupMockServer()
defer teardownMockServer()

mux.HandleFunc("/v2/1010/billing/charges", func(w http.ResponseWriter, r *http.Request) {
httpResponse := httpResponseFixture(t, "/api/listCharges/fail-400-bad-filter.http")

testMethod(t, r, "GET")
testHeaders(t, r)
testQuery(t, r, url.Values{})

w.WriteHeader(httpResponse.StatusCode)
_, _ = io.Copy(w, httpResponse.Body)
})

_, err := client.Billing.ListCharges(context.Background(), "1010", ListChargesOptions{})

assert.Equal(t, err.(*ErrorResponse).Message, "Invalid date format must be ISO8601 (YYYY-MM-DD)")
}

func TestBillingService_ListCharges_Fail403(t *testing.T) {
setupMockServer()
defer teardownMockServer()

mux.HandleFunc("/v2/1010/billing/charges", func(w http.ResponseWriter, r *http.Request) {
httpResponse := httpResponseFixture(t, "/api/listCharges/fail-403.http")

testMethod(t, r, "GET")
testHeaders(t, r)
testQuery(t, r, url.Values{})

w.WriteHeader(httpResponse.StatusCode)
_, _ = io.Copy(w, httpResponse.Body)
})

_, err := client.Billing.ListCharges(context.Background(), "1010", ListChargesOptions{})

assert.Equal(t, err.(*ErrorResponse).Message, "Permission Denied. Required Scope: billing:*:read")
}

func TestUnmarshalCharge(t *testing.T) {
tests := []struct {
name string
jsonStr string
want Charge
wantErr bool
}{
{
name: "valid json",
jsonStr: `{"total_amount": "123.45", "balance_amount": "67.89"}`,
want: Charge{
TotalAmount: decimal.NewFromFloat(123.45),
BalanceAmount: decimal.NewFromFloat(67.89),
},
wantErr: false,
},
{
name: "zero values",
jsonStr: `{"total_amount": "0.00", "balance_amount": "0.00"}`,
want: Charge{
TotalAmount: decimal.NewFromFloat(0.00),
BalanceAmount: decimal.NewFromFloat(0.00),
},
wantErr: false,
},
{
name: "invalid amount value",
jsonStr: `{"total_amount": "123.45", "balance_amount": "abc"}`,
want: Charge{
TotalAmount: decimal.NewFromFloat(123.45),
BalanceAmount: decimal.Decimal{},
},
wantErr: true,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var got Charge
err := json.Unmarshal([]byte(tt.jsonStr), &got)
if (err != nil) != tt.wantErr {
t.Errorf("Unmarshal() error = %v, wantErr %v", err, tt.wantErr)
return
}
assert.Truef(t, got.TotalAmount.Equals(tt.want.TotalAmount), "TotalAmount: got %v, want %v\nTesting: %s\n%s", got.TotalAmount, tt.want.TotalAmount, tt.name, tt.jsonStr)
assert.Truef(t, got.BalanceAmount.Equals(tt.want.BalanceAmount), "BalanceAmount: got %v, want %v\nTesting: %s\n%s", got.BalanceAmount, tt.want.BalanceAmount, tt.name, tt.jsonStr)
})
}
}
2 changes: 2 additions & 0 deletions dnsimple/dnsimple.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ type Client struct {
// Services used for talking to different parts of the DNSimple API.
Identity *IdentityService
Accounts *AccountsService
Billing *BillingService
Certificates *CertificatesService
Contacts *ContactsService
Domains *DomainsService
Expand Down Expand Up @@ -92,6 +93,7 @@ func NewClient(httpClient *http.Client) *Client {
c := &Client{httpClient: httpClient, BaseURL: defaultBaseURL}
c.Identity = &IdentityService{client: c}
c.Accounts = &AccountsService{client: c}
c.Billing = &BillingService{client: c}
c.Certificates = &CertificatesService{client: c}
c.Contacts = &ContactsService{client: c}
c.Domains = &DomainsService{client: c}
Expand Down
12 changes: 6 additions & 6 deletions dnsimple/registrar_domain_transfer_lock_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ func TestRegistrarService_GetDomainTransferLock(t *testing.T) {
setupMockServer()
defer teardownMockServer()

mux.HandleFunc("/v2/1010/registrar/domains/101/transfer_lock", func (w http.ResponseWriter, r *http.Request) {
mux.HandleFunc("/v2/1010/registrar/domains/101/transfer_lock", func(w http.ResponseWriter, r *http.Request) {
httpResponse := httpResponseFixture(t, "/api/getDomainTransferLock/success.http")

testMethod(t, r, "GET")
Expand All @@ -26,7 +26,7 @@ func TestRegistrarService_GetDomainTransferLock(t *testing.T) {
res, err := client.Registrar.GetDomainTransferLock(context.Background(), "1010", "101")

assert.NoError(t, err)
assert.Equal(t, res.Data, &DomainTransferLock {
assert.Equal(t, res.Data, &DomainTransferLock{
Enabled: true,
})
}
Expand All @@ -35,7 +35,7 @@ func TestRegistrarService_EnableDomainTransferLock(t *testing.T) {
setupMockServer()
defer teardownMockServer()

mux.HandleFunc("/v2/1010/registrar/domains/101/transfer_lock", func (w http.ResponseWriter, r *http.Request) {
mux.HandleFunc("/v2/1010/registrar/domains/101/transfer_lock", func(w http.ResponseWriter, r *http.Request) {
httpResponse := httpResponseFixture(t, "/api/enableDomainTransferLock/success.http")

testMethod(t, r, "POST")
Expand All @@ -48,7 +48,7 @@ func TestRegistrarService_EnableDomainTransferLock(t *testing.T) {
res, err := client.Registrar.EnableDomainTransferLock(context.Background(), "1010", "101")

assert.NoError(t, err)
assert.Equal(t, res.Data, &DomainTransferLock {
assert.Equal(t, res.Data, &DomainTransferLock{
Enabled: true,
})
}
Expand All @@ -57,7 +57,7 @@ func TestRegistrarService_DisableDomainTransferLock(t *testing.T) {
setupMockServer()
defer teardownMockServer()

mux.HandleFunc("/v2/1010/registrar/domains/101/transfer_lock", func (w http.ResponseWriter, r *http.Request) {
mux.HandleFunc("/v2/1010/registrar/domains/101/transfer_lock", func(w http.ResponseWriter, r *http.Request) {
httpResponse := httpResponseFixture(t, "/api/disableDomainTransferLock/success.http")

testMethod(t, r, "DELETE")
Expand All @@ -70,7 +70,7 @@ func TestRegistrarService_DisableDomainTransferLock(t *testing.T) {
res, err := client.Registrar.DisableDomainTransferLock(context.Background(), "1010", "101")

assert.NoError(t, err)
assert.Equal(t, res.Data, &DomainTransferLock {
assert.Equal(t, res.Data, &DomainTransferLock{
Enabled: false,
})
}
14 changes: 14 additions & 0 deletions fixtures.http/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 fixtures.http/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 fixtures.http/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 go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/golang/protobuf v1.5.3 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/shopspring/decimal v1.3.1 // indirect
golang.org/x/net v0.17.0 // indirect
google.golang.org/appengine v1.6.7 // indirect
google.golang.org/protobuf v1.31.0 // indirect
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD
github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/shopspring/decimal v1.3.1 h1:2Usl1nmF/WZucqkFZhnfFYxxxu8LG21F6nPQBE5gKV8=
github.com/shopspring/decimal v1.3.1/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
Expand Down

0 comments on commit ccfeab5

Please sign in to comment.