diff --git a/dnsimple/billing.go b/dnsimple/billing.go new file mode 100644 index 0000000..eaf0879 --- /dev/null +++ b/dnsimple/billing.go @@ -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 +} diff --git a/dnsimple/billing_test.go b/dnsimple/billing_test.go new file mode 100644 index 0000000..d48617e --- /dev/null +++ b/dnsimple/billing_test.go @@ -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) + }) + } +} diff --git a/dnsimple/dnsimple.go b/dnsimple/dnsimple.go index 929633e..6f27efe 100644 --- a/dnsimple/dnsimple.go +++ b/dnsimple/dnsimple.go @@ -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 @@ -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} diff --git a/dnsimple/registrar_domain_transfer_lock_test.go b/dnsimple/registrar_domain_transfer_lock_test.go index ae99f6d..2b6bf79 100644 --- a/dnsimple/registrar_domain_transfer_lock_test.go +++ b/dnsimple/registrar_domain_transfer_lock_test.go @@ -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") @@ -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, }) } @@ -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") @@ -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, }) } @@ -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") @@ -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, }) } diff --git a/fixtures.http/api/listCharges/fail-400-bad-filter.http b/fixtures.http/api/listCharges/fail-400-bad-filter.http new file mode 100644 index 0000000..5e36743 --- /dev/null +++ b/fixtures.http/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/fixtures.http/api/listCharges/fail-403.http b/fixtures.http/api/listCharges/fail-403.http new file mode 100644 index 0000000..ddf9f64 --- /dev/null +++ b/fixtures.http/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/fixtures.http/api/listCharges/success.http b/fixtures.http/api/listCharges/success.http new file mode 100644 index 0000000..ae726a0 --- /dev/null +++ b/fixtures.http/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/go.mod b/go.mod index 5f2199d..cbdfedb 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index 2440ccc..251c487 100644 --- a/go.sum +++ b/go.sum @@ -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=