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 #239

Merged
merged 5 commits into from
Dec 7, 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 lib/dnsimple.ex
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@
end

defmodule Client do
@default_base_url Application.get_env(:dnsimple, :base_url, "https://api.dnsimple.com")

Check warning on line 70 in lib/dnsimple.ex

View workflow job for this annotation

GitHub Actions / Elixir 1.14 / OTP 24.0

Application.get_env/3 is discouraged in the module body, use Application.compile_env/3 instead

Check warning on line 70 in lib/dnsimple.ex

View workflow job for this annotation

GitHub Actions / Elixir 1.14 / OTP 25.0

Application.get_env/3 is discouraged in the module body, use Application.compile_env/3 instead

Check warning on line 70 in lib/dnsimple.ex

View workflow job for this annotation

GitHub Actions / Elixir 1.14 / OTP 23.0

Application.get_env/3 is discouraged in the module body, use Application.compile_env/3 instead
@default_user_agent "dnsimple-elixir/#{Dnsimple.Mixfile.project[:version]}"

@api_version "v2"
Expand All @@ -76,7 +76,7 @@
@type t :: %__MODULE__{access_token: String.t, base_url: String.t, user_agent: String.t}

@type headers :: [{binary, binary}] | %{binary => binary}
@type body :: binary | {:form, [{atom, any}]} | {:file, binary}
@type body :: binary | {:form, [{atom, any}]} | {:file, binary} | Keyword.t

@doc """
Initializes a new client from the application environment.
Expand Down
35 changes: 35 additions & 0 deletions lib/dnsimple/billing.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
defmodule Dnsimple.Billing do
@moduledoc """
Provides functions to interact with the
[billing endpoints](https://developer.dnsimple.com/v2/billing/).
"""
@moduledoc section: :api

alias Dnsimple.Client
alias Dnsimple.Listing
alias Dnsimple.Charge
alias Dnsimple.Response

@doc """
Lists the billing charges the current authenticated entity has access to.

See:
- https://developer.dnsimple.com/v2/billing/#listCharges

Examples:

client = %Dnsimple.Client{access_token: "a1b2c3d4"}
{:ok, response} = Dnsimple.Billing.list_charges(client, account_id = "1010")
{:ok, response} = Dnsimple.Billing.list_charges(client, account_id = "1010", filter: [start_date: "2016-01-01", end_date: "2016-01-31"])
{:ok, response} = Dnsimple.Billing.list_charges(client, account_id = "1010", page: 2, per_page: 10)
{:ok, response} = Dnsimple.Billing.list_charges(client, account_id = "1010", sort: "invoiced:asc")

"""
@spec list_charges(Client.t, String.t | integer, Keyword.t) :: {:ok|:error, Response.t}
def list_charges(client, account_id, options \\ []) do
url = Client.versioned("/#{account_id}/billing/charges")

Listing.get(client, url, options)
|> Response.parse(%{"data" => [%Charge{}], "pagination" => %Response.Pagination{}})
end
end
62 changes: 62 additions & 0 deletions lib/dnsimple/charge.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
defmodule Dnsimple.Charge do
require Decimal
@moduledoc """
Represents a billing charge.

See:
- https://developer.dnsimple.com/v2/billing/
"""
@moduledoc section: :data_types

@type t :: %__MODULE__{
invoiced_at: DateTime.t,
total_amount: Decimal.t,
balance_amount: Decimal.t,
reference: String.t,
state: String.t,
items: [ChargeItem.t],
}

defstruct ~w(invoiced_at total_amount balance_amount reference state items)a

def new(attrs) do
attrs = Map.put(attrs, :total_amount, Decimal.new(attrs.total_amount))
attrs = Map.put(attrs, :balance_amount, Decimal.new(attrs.balance_amount))
attrs = Map.put(attrs, :items, Enum.map(attrs.items, &Dnsimple.Charge.ChargeItem.new/1))
struct(__MODULE__, attrs)
end

defimpl Poison.Decoder, for: __MODULE__ do
@spec decode(Dnsimple.Charge.t(), any()) :: struct()
def decode(value, _opts) do
Map.from_struct(value)
|> Dnsimple.Charge.new()
end
end

defmodule ChargeItem do
@moduledoc """
Represents a billing charge item.

See:
- https://developer.dnsimple.com/v2/billing
"""
@moduledoc section: :data_types

@type t :: %__MODULE__{
description: String.t,
amount: Decimal.t,
product_id: integer | nil,
product_type: String.t,
product_reference: String.t | nil,
}

defstruct ~w(description amount product_id product_type product_reference)a

def new(attrs) do
attrs = Map.new(attrs, fn({k, v}) -> {String.to_atom(k), v} end)
attrs = Map.put(attrs, :amount, Decimal.new(attrs.amount))
struct(__MODULE__, attrs)
end
end
end
2 changes: 1 addition & 1 deletion lib/dnsimple/response.ex
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ defmodule Dnsimple.Response do

defp decode(%HTTPoison.Response{body: ""}, _format), do: nil
defp decode(%HTTPoison.Response{body: body}, nil), do: Poison.decode!(body)
defp decode(%HTTPoison.Response{body: body}, format), do: Poison.decode!(body, as: format)
defp decode(%HTTPoison.Response{body: body}, format), do: Poison.decode!(body, %{as: format})

defp extract_data(%{"data" => data}), do: data
defp extract_data(data), do: data
Expand Down
1 change: 1 addition & 0 deletions mix.exs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ defmodule Dnsimple.Mixfile do

defp deps do
[
{:decimal, "~> 2.0"},
{:httpoison, "~> 2.1"},
{:poison, ">= 2.0.0"},
{:exvcr, "~> 0.14.2", only: :test},
Expand Down
1 change: 1 addition & 0 deletions mix.lock
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
%{
"certifi": {:hex, :certifi, "2.12.0", "2d1cca2ec95f59643862af91f001478c9863c2ac9cb6e2f89780bfd8de987329", [:rebar3], [], "hexpm", "ee68d85df22e554040cdb4be100f33873ac6051387baf6a8f6ce82272340ff1c"},
"decimal": {:hex, :decimal, "2.1.1", "5611dca5d4b2c3dd497dec8f68751f1f1a54755e8ed2a966c2633cf885973ad6", [:mix], [], "hexpm", "53cfe5f497ed0e7771ae1a475575603d77425099ba5faef9394932b35020ffcc"},
"dialyxir": {:hex, :dialyxir, "1.4.2", "764a6e8e7a354f0ba95d58418178d486065ead1f69ad89782817c296d0d746a5", [:mix], [{:erlex, ">= 0.2.6", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "516603d8067b2fd585319e4b13d3674ad4f314a5902ba8130cd97dc902ce6bbd"},
"earmark": {:hex, :earmark, "1.4.4", "4821b8d05cda507189d51f2caeef370cf1e18ca5d7dfb7d31e9cafe6688106a4", [:mix], [], "hexpm", "1f93aba7340574847c0f609da787f0d79efcab51b044bb6e242cae5aca9d264d"},
"earmark_parser": {:hex, :earmark_parser, "1.4.37", "2ad73550e27c8946648b06905a57e4d454e4d7229c2dafa72a0348c99d8be5f7", [:mix], [], "hexpm", "6b19783f2802f039806f375610faa22da130b8edc21209d0bff47918bb48360e"},
Expand Down
72 changes: 72 additions & 0 deletions test/dnsimple/billing_test.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
defmodule Dnsimple.BillingTest do
require Decimal
use TestCase, async: false
use ExVCR.Mock, adapter: ExVCR.Adapter.Hackney

@module Dnsimple.Billing
@client %Dnsimple.Client{access_token: "i-am-a-token", base_url: "https://api.dnsimple.test"}
@account_id 1010

describe ".list_charges" do
setup do
url = "#{@client.base_url}/v2/#{@account_id}/billing/charges"
{:ok, fixture: "listCharges/success.http", method: "get", url: url}
end

test "returns the charges in a Dnsimple.Response", %{method: method, url: url} do
fixture = "listCharges/success.http"

use_cassette :stub, ExvcrUtils.response_fixture(fixture, method: method, url: url) do
{:ok, response} = @module.list_charges(@client, @account_id)
assert response.__struct__ == Dnsimple.Response

data = response.data
assert is_list(data)
assert length(data) == 3
assert Enum.all?(data, fn(charge) -> charge.__struct__ == Dnsimple.Charge end)

charge = List.first(data)
assert charge.total_amount == Decimal.new("14.50")
assert is_list(charge.items)

charge_item = List.first(charge.items)
charge_item.__struct__ == Dnsimple.Charge.ChargeItem

Check warning on line 33 in test/dnsimple/billing_test.exs

View workflow job for this annotation

GitHub Actions / Elixir 1.14 / OTP 24.0

use of operator == has no effect

Check warning on line 33 in test/dnsimple/billing_test.exs

View workflow job for this annotation

GitHub Actions / Elixir 1.13 / OTP 24.0

use of operator == has no effect

Check warning on line 33 in test/dnsimple/billing_test.exs

View workflow job for this annotation

GitHub Actions / Elixir 1.12 / OTP 24.0

use of operator == has no effect

Check warning on line 33 in test/dnsimple/billing_test.exs

View workflow job for this annotation

GitHub Actions / Elixir 1.11 / OTP 24.0

use of operator '==' has no effect

Check warning on line 33 in test/dnsimple/billing_test.exs

View workflow job for this annotation

GitHub Actions / Elixir 1.13 / OTP 25.0

use of operator == has no effect

Check warning on line 33 in test/dnsimple/billing_test.exs

View workflow job for this annotation

GitHub Actions / Elixir 1.14 / OTP 25.0

use of operator == has no effect

Check warning on line 33 in test/dnsimple/billing_test.exs

View workflow job for this annotation

GitHub Actions / Elixir 1.13 / OTP 23.0

use of operator == has no effect

Check warning on line 33 in test/dnsimple/billing_test.exs

View workflow job for this annotation

GitHub Actions / Elixir 1.12 / OTP 23.0

use of operator == has no effect

Check warning on line 33 in test/dnsimple/billing_test.exs

View workflow job for this annotation

GitHub Actions / Elixir 1.11 / OTP 23.0

use of operator == has no effect

Check warning on line 33 in test/dnsimple/billing_test.exs

View workflow job for this annotation

GitHub Actions / Elixir 1.14 / OTP 23.0

use of operator == has no effect

Check warning on line 33 in test/dnsimple/billing_test.exs

View workflow job for this annotation

GitHub Actions / Elixir 1.11 / OTP 21.0

use of operator == has no effect

Check warning on line 33 in test/dnsimple/billing_test.exs

View workflow job for this annotation

GitHub Actions / Elixir 1.13 / OTP 22.0

use of operator == has no effect

Check warning on line 33 in test/dnsimple/billing_test.exs

View workflow job for this annotation

GitHub Actions / Elixir 1.11 / OTP 22.0

use of operator == has no effect

Check warning on line 33 in test/dnsimple/billing_test.exs

View workflow job for this annotation

GitHub Actions / Elixir 1.12 / OTP 22.0

use of operator == has no effect
assert charge_item.amount == Decimal.new("14.50")
assert charge_item.product_type == "domain-registration"
end
end

test "sends custom headers", %{fixture: fixture, method: method, url: url} do
use_cassette :stub, ExvcrUtils.response_fixture(fixture, method: method, url: url) do
@module.list_charges(@client, @account_id, headers: %{"X-Header" => "X-Value"})
end
end

test "supports filtering", %{fixture: fixture, method: method} do
url = "#{@client.base_url}/v2/#{@account_id}/billing/charges?start_date=2023-01-01"

use_cassette :stub, ExvcrUtils.response_fixture(fixture, method: method, url: url) do
@module.list_charges(@client, @account_id, filter: [start_date: "2023-01-01"])
end
end

test "supports sorting", %{fixture: fixture, method: method} do
url = "#{@client.base_url}/v2/#{@account_id}/billing/charges?sort=invoiced%3Adesc"

use_cassette :stub, ExvcrUtils.response_fixture(fixture, method: method, url: url) do
@module.list_charges(@client, @account_id, sort: "invoiced:desc")
end
end

test "returns an error if the provided filter is bad", %{method: method, url: url} do
fixture = "listCharges/fail-400-bad-filter.http"

use_cassette :stub, ExvcrUtils.response_fixture(fixture, method: method, url: url) do
{:error, response} = @module.list_charges(@client, @account_id)
assert response.__struct__ == Dnsimple.RequestError
assert response.message == "HTTP 400: Invalid date format must be ISO8601 (YYYY-MM-DD)"
end
end
end

end
14 changes: 14 additions & 0 deletions test/fixtures.http/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 test/fixtures.http/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 test/fixtures.http/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}}
Loading