diff --git a/apps/api/config/dev.exs b/apps/api/config/dev.exs index 7db51eb1e..fb624cf35 100644 --- a/apps/api/config/dev.exs +++ b/apps/api/config/dev.exs @@ -100,3 +100,5 @@ config :buildel, System.get_env("REGISTRATION_DISABLED", "false") == "true" config :buildel, :skip_flame, System.get_env("SKIP_FLAME", "false") == "true" + +config :buildel, :stripe_api_key, System.get_env("STRIPE_API_KEY") diff --git a/apps/api/config/runtime.exs b/apps/api/config/runtime.exs index 32c19f7f2..9b316f967 100644 --- a/apps/api/config/runtime.exs +++ b/apps/api/config/runtime.exs @@ -82,6 +82,8 @@ if config_env() == :prod do config :flame, :backend, FLAME.FlyBackend + config :buildel, :stripe_api_key, System.get_env("STRIPE_API_KEY") + config :flame, FLAME.FlyBackend, token: System.get_env("FLY_API_TOKEN"), env: %{ diff --git a/apps/api/lib/buildel/clients/stripe.ex b/apps/api/lib/buildel/clients/stripe.ex new file mode 100644 index 000000000..9659c213b --- /dev/null +++ b/apps/api/lib/buildel/clients/stripe.ex @@ -0,0 +1,117 @@ +defmodule Buildel.Clients.StripeBehaviour do + @type list_products_params :: %{ + active: boolean() + } + + @callback list_products(list_products_params()) :: + {:ok, [Buildel.Clients.Stripe.Product.t()]} | {:error, term} +end + +defmodule Buildel.Clients.Stripe do + @behaviour Buildel.Clients.StripeBehaviour + + defmodule Price do + @type t :: %__MODULE__{ + id: binary(), + amount: integer(), + currency: binary() + } + + defstruct [:id, :currency, :amount] + end + + defmodule Product do + @type t :: %__MODULE__{ + id: binary(), + name: binary(), + description: binary(), + active: boolean(), + price: Price.t() | nil + } + + defstruct [:id, :name, :description, :active, :price] + end + + @impl Buildel.Clients.StripeBehaviour + def list_products(attrs \\ %{}) do + url = "/products?expand[]=data.default_price" + + url = + Enum.reduce(Map.to_list(attrs), url, fn + {:active, active}, url when is_boolean(active) -> + "#{url}&active=#{active}" + + _, url -> + url + end) + + with {:ok, %Req.Response{body: body, status: 200}} <- + request(url) do + {:ok, body["data"] |> map_products()} + end + end + + def get_price(price_id) do + request(price_id) + end + + def new(options \\ []) when is_list(options) do + Req.new( + base_url: "https://api.stripe.com/v1", + auth: {:bearer, Application.fetch_env!(:buildel, :stripe_api_key)} + ) + |> Req.Request.append_request_steps( + post: fn req -> + with %{method: :get, body: <<_::binary>>} <- req do + %{req | method: :post} + end + end + ) + |> Req.merge(options) + end + + def request(url, options \\ []), do: Req.request(new(url: parse_url(url)), options) + + def request!(url, options \\ []), do: Req.request!(new(url: parse_url(url)), options) + + defp parse_url("prod_" <> _ = id), do: "/products/#{id}" + defp parse_url("price_" <> _ = id), do: "/prices/#{id}" + defp parse_url("sub_" <> _ = id), do: "/subscriptions/#{id}" + defp parse_url("cus_" <> _ = id), do: "/customers/#{id}" + defp parse_url("cs_" <> _ = id), do: "/checkout/sessions/#{id}" + defp parse_url("inv_" <> _ = id), do: "/invoices/#{id}" + defp parse_url("evt_" <> _ = id), do: "/events/#{id}" + defp parse_url(url) when is_binary(url), do: url + + defp map_products(products) do + Enum.map(products, fn %{ + "id" => id, + "name" => name, + "description" => description, + "active" => active, + "default_price" => price + } -> + %Product{ + id: id, + name: name, + description: description, + active: active, + price: map_price(price) + } + end) + end + + defp map_price(nil), do: nil + + defp map_price(%{ + "id" => id, + "currency" => currency, + "unit_amount" => amount + }) do + %Price{ + id: id, + currency: currency, + amount: amount + } + end +end diff --git a/apps/api/lib/buildel_web/controllers/organizations/subscriptions/subscriptions_controller.ex b/apps/api/lib/buildel_web/controllers/organizations/subscriptions/subscriptions_controller.ex new file mode 100644 index 000000000..335c7f164 --- /dev/null +++ b/apps/api/lib/buildel_web/controllers/organizations/subscriptions/subscriptions_controller.ex @@ -0,0 +1,58 @@ +defmodule BuildelWeb.OrganizationSubscriptionController do + use BuildelWeb, :controller + use OpenApiSpex.ControllerSpecs + + import BuildelWeb.UserAuth + + alias Buildel.Clients.Stripe + + alias Buildel.Organizations + + action_fallback(BuildelWeb.FallbackController) + + plug(:fetch_current_user) + plug(:require_authenticated_user) + + plug OpenApiSpex.Plug.CastAndValidate, + json_render_error_v2: true, + render_error: BuildelWeb.ErrorRendererPlug, + replace_params: false + + tags ["subscriptions"] + + operation :list_products, + summary: "List products", + parameters: [ + organization_id: [ + in: :path, + description: "Organization ID", + type: :integer, + required: true + ] + ], + request_body: nil, + responses: [ + ok: {"success", "application/json", BuildelWeb.Schemas.Subscriptions.ListProductsResponse}, + unprocessable_entity: + {"unprocessable entity", "application/json", + BuildelWeb.Schemas.Errors.UnprocessableEntity}, + unauthorized: + {"unauthorized", "application/json", BuildelWeb.Schemas.Errors.UnauthorizedResponse}, + forbidden: {"forbidden", "application/json", BuildelWeb.Schemas.Errors.ForbiddenResponse} + ], + security: [%{"authorization" => []}] + + def list_products(conn, _params) do + %{"organization_id" => organization_id} = conn.params + + user = conn.assigns.current_user + + with {:ok, _organization} <- Organizations.get_user_organization(user, organization_id), + {:ok, products} <- + Stripe.list_products(%{ + active: true + }) do + render(conn, :list_products, products: products) + end + end +end diff --git a/apps/api/lib/buildel_web/controllers/organizations/subscriptions/subscriptions_json.ex b/apps/api/lib/buildel_web/controllers/organizations/subscriptions/subscriptions_json.ex new file mode 100644 index 000000000..9140023d0 --- /dev/null +++ b/apps/api/lib/buildel_web/controllers/organizations/subscriptions/subscriptions_json.ex @@ -0,0 +1,31 @@ +defmodule BuildelWeb.OrganizationSubscriptionJSON do + alias Buildel.Clients.Stripe + + def list_products(%{ + products: products + }) do + %{ + data: for(product <- products, do: product(product)) + } + end + + defp product(%Stripe.Product{} = product) do + %{ + id: product.id, + name: product.name, + description: product.description, + active: product.active, + price: price(product.price) + } + end + + defp price(nil), do: nil + + defp price(%Stripe.Price{} = price) do + %{ + id: price.id, + amount: price.amount, + currency: price.currency + } + end +end diff --git a/apps/api/lib/buildel_web/router.ex b/apps/api/lib/buildel_web/router.ex index dff14c9cf..394825f80 100644 --- a/apps/api/lib/buildel_web/router.ex +++ b/apps/api/lib/buildel_web/router.ex @@ -378,6 +378,12 @@ defmodule BuildelWeb.Router do resources("/organizations", OrganizationController, only: [:index, :create, :show]) put("/organizations/:id", OrganizationController, :update) + get( + "/organizations/:organization_id/subscriptions/products", + OrganizationSubscriptionController, + :list_products + ) + resources("/organizations/:organization_id/costs", OrganizationCostsController, only: [:index] ) diff --git a/apps/api/lib/buildel_web/schemas/organizations/subscriptions/subscriptions.ex b/apps/api/lib/buildel_web/schemas/organizations/subscriptions/subscriptions.ex new file mode 100644 index 000000000..19c030729 --- /dev/null +++ b/apps/api/lib/buildel_web/schemas/organizations/subscriptions/subscriptions.ex @@ -0,0 +1,51 @@ +defmodule BuildelWeb.Schemas.Subscriptions do + alias OpenApiSpex.Schema + + defmodule Price do + require OpenApiSpex + + OpenApiSpex.schema(%{ + title: "SubscriptionPrice", + type: :object, + properties: %{ + id: %Schema{type: :string, description: "Price ID"}, + amount: %Schema{type: :number, description: "Price amount"}, + currency: %Schema{type: :string, description: "Price currency"} + }, + required: [:id, :amount, :currency] + }) + end + + defmodule Product do + require OpenApiSpex + + OpenApiSpex.schema(%{ + title: "SubscriptionProduct", + type: :object, + properties: %{ + id: %Schema{type: :string, description: "Product ID"}, + name: %Schema{type: :string, description: "Product name"}, + description: %Schema{type: :string, description: "Product description"}, + active: %Schema{type: :boolean, description: "Product active status"}, + price: Price + }, + required: [:id, :name, :description, :active] + }) + end + + defmodule ListProductsResponse do + require OpenApiSpex + + OpenApiSpex.schema(%{ + title: "SubscriptionListProductsResponse", + type: :object, + properties: %{ + data: %Schema{ + type: :array, + items: Product + } + }, + required: [:data] + }) + end +end diff --git a/apps/api/test/support/client_mocks/stripe.ex b/apps/api/test/support/client_mocks/stripe.ex new file mode 100644 index 000000000..a8194557a --- /dev/null +++ b/apps/api/test/support/client_mocks/stripe.ex @@ -0,0 +1,32 @@ +defmodule Buildel.ClientMocks.Stripe do + @behaviour Buildel.Clients.StripeBehaviour + + @impl Buildel.Clients.StripeBehaviour + def list_products(attrs \\ %{}) do + {:ok, + [ + %Buildel.Clients.Stripe.Product{ + id: "prod_1", + name: "Product 1", + description: "Description 1", + active: true, + price: %Buildel.Clients.Stripe.Price{ + id: "price_1", + currency: "usd", + amount: 1000 + } + }, + %Buildel.Clients.Stripe.Product{ + id: "prod_2", + name: "Product 2", + description: "Description 2", + active: true, + price: %Buildel.Clients.Stripe.Price{ + id: "price_2", + currency: "usd", + amount: 2000 + } + } + ]} + end +end