diff --git a/elixir/apps/domain/lib/domain/accounts.ex b/elixir/apps/domain/lib/domain/accounts.ex index 2943a1ee3..e449f5697 100644 --- a/elixir/apps/domain/lib/domain/accounts.ex +++ b/elixir/apps/domain/lib/domain/accounts.ex @@ -1,6 +1,6 @@ defmodule Domain.Accounts do alias Domain.{Repo, Config, PubSub} - alias Domain.Auth + alias Domain.{Auth, Billing} alias Domain.Accounts.{Account, Features, Authorizer} def all_active_accounts! do @@ -52,7 +52,7 @@ defmodule Domain.Accounts do |> Repo.insert() end - def change_account(%Account{} = account, attrs) do + def change_account(%Account{} = account, attrs \\ %{}) do Account.Changeset.update(account, attrs) end @@ -93,6 +93,8 @@ defmodule Domain.Accounts do end defp on_account_update(account, changeset) do + :ok = Billing.on_account_update(account, changeset) + if Ecto.Changeset.changed?(changeset, :config) do broadcast_config_update_to_account(account) else diff --git a/elixir/apps/domain/lib/domain/accounts/account.ex b/elixir/apps/domain/lib/domain/accounts/account.ex index 024920f32..2970d19d3 100644 --- a/elixir/apps/domain/lib/domain/accounts/account.ex +++ b/elixir/apps/domain/lib/domain/accounts/account.ex @@ -16,6 +16,7 @@ defmodule Domain.Accounts.Account do field :customer_id, :string field :subscription_id, :string field :product_name, :string + field :billing_email, :string end end diff --git a/elixir/apps/domain/lib/domain/accounts/account/changeset.ex b/elixir/apps/domain/lib/domain/accounts/account/changeset.ex index 53fbfa44e..e41d8d142 100644 --- a/elixir/apps/domain/lib/domain/accounts/account/changeset.ex +++ b/elixir/apps/domain/lib/domain/accounts/account/changeset.ex @@ -30,6 +30,7 @@ defmodule Domain.Accounts.Account.Changeset do def update(%Account{} = account, attrs) do account |> cast(attrs, [ + :slug, :name, :disabled_reason, :disabled_at, @@ -88,7 +89,7 @@ defmodule Domain.Accounts.Account.Changeset do def stripe_metadata_changeset(stripe \\ %Account.Metadata.Stripe{}, attrs) do stripe - |> cast(attrs, [:customer_id, :subscription_id, :product_name]) + |> cast(attrs, [:customer_id, :subscription_id, :product_name, :billing_email]) end def validate_account_id_or_slug(account_id_or_slug) do diff --git a/elixir/apps/domain/lib/domain/actors.ex b/elixir/apps/domain/lib/domain/actors.ex index d9b4d8eb6..bd632bf99 100644 --- a/elixir/apps/domain/lib/domain/actors.ex +++ b/elixir/apps/domain/lib/domain/actors.ex @@ -55,16 +55,6 @@ defmodule Domain.Actors do |> Repo.preload(preload) end - # TODO: this should be a filter - def list_editable_groups(%Auth.Subject{} = subject, opts \\ []) do - with :ok <- Auth.ensure_has_permissions(subject, Authorizer.manage_actors_permission()) do - Group.Query.not_deleted() - |> Group.Query.editable() - |> Authorizer.for_subject(subject) - |> Repo.list(Group.Query, opts) - end - end - def peek_group_actors(groups, limit, %Auth.Subject{} = subject) do with :ok <- Auth.ensure_has_permissions(subject, Authorizer.manage_actors_permission()) do ids = groups |> Enum.map(& &1.id) |> Enum.uniq() diff --git a/elixir/apps/domain/lib/domain/billing.ex b/elixir/apps/domain/lib/domain/billing.ex index 057ef87c0..bf34bc4a7 100644 --- a/elixir/apps/domain/lib/domain/billing.ex +++ b/elixir/apps/domain/lib/domain/billing.ex @@ -1,10 +1,12 @@ defmodule Domain.Billing do use Supervisor alias Domain.{Auth, Accounts, Actors, Clients, Gateways} - alias Domain.Billing.{Authorizer, Jobs} + alias Domain.Billing.{Authorizer, Jobs, EventHandler} alias Domain.Billing.Stripe.APIClient require Logger + # Supervisor + def start_link(opts) do Supervisor.start_link(__MODULE__, opts, name: __MODULE__) end @@ -22,6 +24,8 @@ defmodule Domain.Billing do end end + # Configuration helpers + def enabled? do fetch_config!(:enabled) end @@ -30,14 +34,7 @@ defmodule Domain.Billing do fetch_config!(:webhook_signing_secret) end - def account_provisioned?(%Accounts.Account{metadata: %{stripe: %{customer_id: customer_id}}}) - when not is_nil(customer_id) do - enabled?() - end - - def account_provisioned?(%Accounts.Account{}) do - false - end + # Limits and Features def seats_limit_exceeded?(%Accounts.Account{} = account, active_users_count) do not is_nil(account.limits.monthly_active_users_count) and @@ -91,47 +88,210 @@ defmodule Domain.Billing do account_admins_count < account.limits.account_admin_users_count) end - def provision_account(%Accounts.Account{} = account) do + # API wrappers + + def create_customer(%Accounts.Account{} = account) do + secret_key = fetch_config!(:secret_key) + + with {:ok, %{"id" => customer_id, "email" => customer_email}} <- + APIClient.create_customer(secret_key, account.id, account.name, account.slug) do + Accounts.update_account(account, %{ + metadata: %{stripe: %{customer_id: customer_id, billing_email: customer_email}} + }) + else + {:ok, {status, body}} -> + :ok = + Logger.error("Cannot create Stripe customer", + status: status, + body: inspect(body) + ) + + {:error, :retry_later} + + {:error, reason} -> + :ok = + Logger.error("Cannot create Stripe customer", + reason: inspect(reason) + ) + + {:error, :retry_later} + end + end + + def update_customer(%Accounts.Account{} = account) do + secret_key = fetch_config!(:secret_key) + customer_id = account.metadata.stripe.customer_id + + with {:ok, _customer} <- + APIClient.update_customer( + secret_key, + customer_id, + account.id, + account.name, + account.slug + ) do + {:ok, account} + else + {:ok, {status, body}} -> + :ok = + Logger.error("Cannot update Stripe customer", + status: status, + body: inspect(body) + ) + + {:error, :retry_later} + + {:error, reason} -> + :ok = + Logger.error("Cannot update Stripe customer", + reason: inspect(reason) + ) + + {:error, :retry_later} + end + end + + def fetch_customer_account_id(customer_id) do + secret_key = fetch_config!(:secret_key) + + with {:ok, %{"metadata" => %{"account_id" => account_id}}} <- + APIClient.fetch_customer(secret_key, customer_id) do + {:ok, account_id} + else + {:ok, params} -> + :ok = + Logger.info("Stripe customer does not have account_id in metadata", + customer_id: customer_id, + metadata: inspect(params["metadata"]) + ) + + {:error, :customer_not_provisioned} + + {:ok, {status, body}} -> + :ok = + Logger.error("Cannot fetch Stripe customer", + status: status, + body: inspect(body) + ) + + {:error, :retry_later} + + {:error, reason} -> + :ok = + Logger.error("Cannot fetch Stripe customer", + reason: inspect(reason) + ) + + {:error, :retry_later} + end + end + + def create_subscription(%Accounts.Account{} = account) do secret_key = fetch_config!(:secret_key) default_price_id = fetch_config!(:default_price_id) + customer_id = account.metadata.stripe.customer_id - with true <- enabled?(), - true <- not account_provisioned?(account), - {:ok, %{"id" => customer_id}} <- - APIClient.create_customer(secret_key, account.id, account.name), - {:ok, %{"id" => subscription_id}} <- + with {:ok, %{"id" => subscription_id}} <- APIClient.create_subscription(secret_key, customer_id, default_price_id) do Accounts.update_account(account, %{ - metadata: %{ - stripe: %{ - customer_id: customer_id, - subscription_id: subscription_id - } - } + metadata: %{stripe: %{subscription_id: subscription_id}} }) + else + {:ok, {status, body}} -> + :ok = + Logger.error("Cannot create Stripe subscription", + status: status, + body: inspect(body) + ) + + {:error, :retry_later} + + {:error, reason} -> + :ok = + Logger.error("Cannot create Stripe subscription", + reason: inspect(reason) + ) + + {:error, :retry_later} + end + end + + def fetch_product(product_id) do + secret_key = fetch_config!(:secret_key) + + with {:ok, product} <- APIClient.fetch_product(secret_key, product_id) do + {:ok, product} + else + {:ok, {status, body}} -> + :ok = + Logger.error("Cannot fetch Stripe product", + status: status, + body: inspect(body) + ) + + {:error, :retry_later} + + {:error, reason} -> + :ok = + Logger.error("Cannot fetch Stripe product", + reason: inspect(reason) + ) + + {:error, :retry_later} + end + end + + # Account management, sync and provisioning + + def account_provisioned?(%Accounts.Account{metadata: %{stripe: %{customer_id: customer_id}}}) + when not is_nil(customer_id) do + enabled?() + end + + def account_provisioned?(%Accounts.Account{}) do + false + end + + def provision_account(%Accounts.Account{} = account) do + with true <- enabled?(), + true <- not account_provisioned?(account), + {:ok, account} <- create_customer(account), + {:ok, account} <- create_subscription(account) do + {:ok, account} else false -> {:ok, account} - {:ok, {status, body}} -> - :ok = Logger.error("Stripe API call failed", status: status, body: inspect(body)) - {:error, :retry_later} - {:error, reason} -> - :ok = Logger.error("Stripe API call failed", reason: inspect(reason)) - {:error, :retry_later} + {:error, reason} + end + end + + def on_account_update(%Accounts.Account{} = account, %Ecto.Changeset{} = changeset) do + name_changed? = Ecto.Changeset.changed?(changeset, :name) + slug_changed? = Ecto.Changeset.changed?(changeset, :slug) + + cond do + not account_provisioned?(account) -> + :ok + + not enabled?() -> + :ok + + not name_changed? and not slug_changed? -> + :ok + + true -> + {:ok, _customer} = update_customer(account) + :ok end end def billing_portal_url(%Accounts.Account{} = account, return_url, %Auth.Subject{} = subject) do secret_key = fetch_config!(:secret_key) + required_permissions = [Authorizer.manage_own_account_billing_permission()] - with :ok <- - Auth.ensure_has_permissions( - subject, - Authorizer.manage_own_account_billing_permission() - ), - true <- account_provisioned?(account), + with :ok <- Auth.ensure_has_permissions(subject, required_permissions), {:ok, %{"url" => url}} <- APIClient.create_billing_portal_session( secret_key, @@ -139,226 +299,13 @@ defmodule Domain.Billing do return_url ) do {:ok, url} - else - false -> {:error, :account_not_provisioned} - {:error, reason} -> {:error, reason} end end def handle_events(events) when is_list(events) do - Enum.each(events, &handle_event/1) + Enum.each(events, &EventHandler.handle_event/1) end - # subscription is ended or deleted - defp handle_event(%{ - "object" => "event", - "data" => %{ - "object" => %{ - "customer" => customer_id - } - }, - "type" => "customer.subscription.deleted" - }) do - update_account_by_stripe_customer_id(customer_id, %{ - disabled_at: DateTime.utc_now(), - disabled_reason: "Stripe subscription deleted" - }) - |> case do - {:ok, _account} -> - :ok - - {:error, reason} -> - :ok = - Logger.error("Failed to update account on Stripe subscription event", - customer_id: customer_id, - reason: inspect(reason) - ) - - :error - end - end - - # subscription is paused - defp handle_event(%{ - "object" => "event", - "data" => %{ - "object" => %{ - "customer" => customer_id, - "pause_collection" => %{ - "behavior" => "void" - } - } - }, - "type" => "customer.subscription.updated" - }) do - update_account_by_stripe_customer_id(customer_id, %{ - disabled_at: DateTime.utc_now(), - disabled_reason: "Stripe subscription paused" - }) - |> case do - {:ok, _account} -> - :ok - - {:error, reason} -> - :ok = - Logger.error("Failed to update account on Stripe subscription event", - customer_id: customer_id, - reason: inspect(reason) - ) - - :error - end - end - - defp handle_event(%{ - "object" => "event", - "data" => %{ - "object" => %{ - "customer" => customer_id - } - }, - "type" => "customer.subscription.paused" - }) do - update_account_by_stripe_customer_id(customer_id, %{ - disabled_at: DateTime.utc_now(), - disabled_reason: "Stripe subscription paused" - }) - |> case do - {:ok, _account} -> - :ok - - {:error, reason} -> - :ok = - Logger.error("Failed to update account on Stripe subscription event", - customer_id: customer_id, - reason: inspect(reason) - ) - - :error - end - end - - # subscription is resumed, created or updated - defp handle_event(%{ - "object" => "event", - "data" => %{ - "object" => %{ - "id" => subscription_id, - "customer" => customer_id, - "metadata" => subscription_metadata, - "items" => %{ - "data" => [ - %{ - "plan" => %{ - "product" => product_id - }, - "quantity" => quantity - } - ] - } - } - }, - "type" => "customer.subscription." <> _ - }) do - secret_key = fetch_config!(:secret_key) - - {:ok, %{"name" => product_name, "metadata" => product_metadata}} = - APIClient.fetch_product(secret_key, product_id) - - attrs = - account_update_attrs(quantity, product_metadata, subscription_metadata) - |> Map.put(:metadata, %{ - stripe: %{ - subscription_id: subscription_id, - product_name: product_name - } - }) - |> Map.put(:disabled_at, nil) - |> Map.put(:disabled_reason, nil) - - update_account_by_stripe_customer_id(customer_id, attrs) - |> case do - {:ok, _account} -> - :ok - - {:error, reason} -> - :ok = - Logger.error("Failed to update account on Stripe subscription event", - customer_id: customer_id, - reason: inspect(reason) - ) - - :error - end - end - - defp handle_event(%{"object" => "event", "data" => %{}}) do - :ok - end - - defp update_account_by_stripe_customer_id(customer_id, attrs) do - secret_key = fetch_config!(:secret_key) - - with {:ok, %{"metadata" => %{"account_id" => account_id}}} <- - APIClient.fetch_customer(secret_key, customer_id) do - Accounts.update_account_by_id(account_id, attrs) - else - {:ok, params} -> - :ok = - Logger.error("Stripe customer does not have account_id in metadata", - metadata: inspect(params["metadata"]) - ) - - {:error, :retry_later} - - {:ok, {status, body}} -> - :ok = Logger.error("Cannot fetch Stripe customer", status: status, body: inspect(body)) - {:error, :retry_later} - - {:error, reason} -> - :ok = Logger.error("Cannot fetch Stripe customer", reason: inspect(reason)) - {:error, :retry_later} - end - end - - defp account_update_attrs(quantity, product_metadata, subscription_metadata) do - limit_fields = Accounts.Limits.__schema__(:fields) |> Enum.map(&to_string/1) - - features_and_limits = - Map.merge(product_metadata, subscription_metadata) - |> Enum.flat_map(fn - {feature, "true"} -> - [{feature, true}] - - {feature, "false"} -> - [{feature, false}] - - {key, value} -> - if key in limit_fields do - [{key, cast_limit(value)}] - else - [] - end - end) - |> Enum.into(%{}) - - {monthly_active_users_count, features_and_limits} = - Map.pop(features_and_limits, "monthly_active_users_count", quantity) - - {limits, features} = Map.split(features_and_limits, limit_fields) - - limits = Map.merge(limits, %{"monthly_active_users_count" => monthly_active_users_count}) - - %{ - features: features, - limits: limits - } - end - - defp cast_limit(number) when is_number(number), do: number - defp cast_limit("unlimited"), do: nil - defp cast_limit(binary) when is_binary(binary), do: String.to_integer(binary) - defp fetch_config!(key) do Domain.Config.fetch_env!(:domain, __MODULE__) |> Keyword.fetch!(key) diff --git a/elixir/apps/domain/lib/domain/billing/event_handler.ex b/elixir/apps/domain/lib/domain/billing/event_handler.ex new file mode 100644 index 000000000..98ac2906d --- /dev/null +++ b/elixir/apps/domain/lib/domain/billing/event_handler.ex @@ -0,0 +1,373 @@ +defmodule Domain.Billing.EventHandler do + alias Domain.Repo + alias Domain.Accounts + alias Domain.Billing + require Logger + + # customer is created + def handle_event(%{ + "object" => "event", + "data" => %{ + "object" => customer + }, + "type" => "customer.created" + }) do + create_account_from_stripe_customer(customer) + end + + # customer is updated + def handle_event(%{ + "object" => "event", + "data" => %{ + "object" => + %{ + "id" => customer_id, + "name" => customer_name, + "email" => customer_email, + "metadata" => customer_metadata + } = customer + }, + "type" => "customer.updated" + }) do + attrs = + %{ + name: customer_name, + metadata: %{stripe: %{billing_email: customer_email}} + } + |> put_if_not_nil(:slug, customer_metadata["account_slug"]) + + case update_account_by_stripe_customer_id(customer_id, attrs) do + {:ok, _account} -> + :ok + + {:error, :customer_not_provisioned} -> + _ = create_account_from_stripe_customer(customer) + :ok + + {:error, :not_found} -> + :ok + + {:error, reason} -> + :ok = + Logger.error("Failed to sync account from Stripe", + customer_id: customer_id, + reason: inspect(reason) + ) + + :error + end + end + + # subscription is ended or deleted + def handle_event(%{ + "object" => "event", + "data" => %{ + "object" => %{ + "customer" => customer_id + } + }, + "type" => "customer.subscription.deleted" + }) do + update_account_by_stripe_customer_id(customer_id, %{ + disabled_at: DateTime.utc_now(), + disabled_reason: "Stripe subscription deleted" + }) + |> case do + {:ok, _account} -> + :ok + + {:error, reason} -> + :ok = + Logger.error("Failed to update account on Stripe subscription event", + customer_id: customer_id, + reason: inspect(reason) + ) + + :error + end + end + + # subscription is paused + def handle_event(%{ + "object" => "event", + "data" => %{ + "object" => %{ + "customer" => customer_id, + "pause_collection" => %{ + "behavior" => "void" + } + } + }, + "type" => "customer.subscription.updated" + }) do + update_account_by_stripe_customer_id(customer_id, %{ + disabled_at: DateTime.utc_now(), + disabled_reason: "Stripe subscription paused" + }) + |> case do + {:ok, _account} -> + :ok + + {:error, reason} -> + :ok = + Logger.error("Failed to update account on Stripe subscription event", + customer_id: customer_id, + reason: inspect(reason) + ) + + :error + end + end + + def handle_event(%{ + "object" => "event", + "data" => %{ + "object" => %{ + "customer" => customer_id + } + }, + "type" => "customer.subscription.paused" + }) do + update_account_by_stripe_customer_id(customer_id, %{ + disabled_at: DateTime.utc_now(), + disabled_reason: "Stripe subscription paused" + }) + |> case do + {:ok, _account} -> + :ok + + {:error, reason} -> + :ok = + Logger.error("Failed to update account on Stripe subscription event", + customer_id: customer_id, + reason: inspect(reason) + ) + + :error + end + end + + # subscription is resumed, created or updated + def handle_event(%{ + "object" => "event", + "data" => %{ + "object" => %{ + "id" => subscription_id, + "customer" => customer_id, + "metadata" => subscription_metadata, + "items" => %{ + "data" => [ + %{ + "plan" => %{ + "product" => product_id + }, + "quantity" => quantity + } + ] + } + } + }, + "type" => "customer.subscription." <> _ + }) do + {:ok, + %{ + "name" => product_name, + "metadata" => product_metadata + }} = Billing.fetch_product(product_id) + + attrs = + account_update_attrs(quantity, product_metadata, subscription_metadata) + |> Map.put(:metadata, %{ + stripe: %{ + subscription_id: subscription_id, + product_name: product_name + } + }) + |> Map.put(:disabled_at, nil) + |> Map.put(:disabled_reason, nil) + + update_account_by_stripe_customer_id(customer_id, attrs) + |> case do + {:ok, _account} -> + :ok + + {:error, reason} -> + :ok = + Logger.error("Failed to update account on Stripe subscription event", + customer_id: customer_id, + reason: inspect(reason) + ) + + :error + end + end + + def handle_event(%{"object" => "event", "data" => %{}}) do + :ok + end + + defp create_account_from_stripe_customer(%{ + "id" => customer_id, + "name" => customer_name, + "email" => account_email, + "metadata" => + %{ + "company_website" => company_website, + "account_owner_first_name" => account_owner_first_name, + "account_owner_last_name" => account_owner_last_name + } = metadata + }) do + uri = URI.parse(company_website) + + account_slug = + cond do + uri.host -> + uri.host + |> String.split(".") + |> List.delete_at(-1) + |> Enum.join("_") + + uri.path -> + uri.path + + true -> + Accounts.generate_unique_slug() + end + + attrs = %{ + name: customer_name, + slug: account_slug, + metadata: %{ + stripe: %{ + customer_id: customer_id, + billing_email: account_email + } + } + } + + Repo.transaction(fn -> + :ok = + with {:ok, account} <- Domain.Accounts.create_account(attrs), + {:ok, account} <- Billing.update_customer(account), + {:ok, account} <- Domain.Billing.create_subscription(account) do + {:ok, _everyone_group} = + Domain.Actors.create_managed_group(account, %{ + name: "Everyone", + membership_rules: [%{operator: true}] + }) + + {:ok, magic_link_provider} = + Domain.Auth.create_provider(account, %{ + name: "Email", + adapter: :email, + adapter_config: %{} + }) + + {:ok, actor} = + Domain.Actors.create_actor(account, %{ + type: :account_admin_user, + name: account_owner_first_name <> " " <> account_owner_last_name + }) + + {:ok, _identity} = + Domain.Auth.upsert_identity(actor, magic_link_provider, %{ + provider_identifier: metadata["account_admin_email"] || account_email, + provider_identifier_confirmation: metadata["account_admin_email"] || account_email + }) + + :ok + else + {:error, %Ecto.Changeset{errors: [{:slug, {"has already been taken", _}} | _]}} -> + :ok + + {:error, reason} -> + {:error, reason} + end + end) + |> case do + {:ok, _} -> + :ok + + {:error, %Ecto.Changeset{} = changeset} -> + :ok = + Logger.error("Failed to create account from Stripe", + customer_id: customer_id, + reason: inspect(changeset) + ) + + :ok + + {:error, reason} -> + :ok = + Logger.error("Failed to create account from Stripe", + customer_id: customer_id, + reason: inspect(reason) + ) + + :error + end + end + + defp create_account_from_stripe_customer(%{ + "id" => customer_id, + "name" => customer_name, + "metadata" => customer_metadata + }) do + :ok = + Logger.warning("Failed to create account from Stripe", + customer_id: customer_id, + customer_metadata: inspect(customer_metadata), + customer_name: customer_name, + reason: "missing custom metadata keys" + ) + + :ok + end + + defp update_account_by_stripe_customer_id(customer_id, attrs) do + with {:ok, account_id} <- Billing.fetch_customer_account_id(customer_id) do + Accounts.update_account_by_id(account_id, attrs) + end + end + + defp account_update_attrs(quantity, product_metadata, subscription_metadata) do + limit_fields = Accounts.Limits.__schema__(:fields) |> Enum.map(&to_string/1) + + features_and_limits = + Map.merge(product_metadata, subscription_metadata) + |> Enum.flat_map(fn + {feature, "true"} -> + [{feature, true}] + + {feature, "false"} -> + [{feature, false}] + + {key, value} -> + if key in limit_fields do + [{key, cast_limit(value)}] + else + [] + end + end) + |> Enum.into(%{}) + + {monthly_active_users_count, features_and_limits} = + Map.pop(features_and_limits, "monthly_active_users_count", quantity) + + {limits, features} = Map.split(features_and_limits, limit_fields) + + limits = Map.merge(limits, %{"monthly_active_users_count" => monthly_active_users_count}) + + %{ + features: features, + limits: limits + } + end + + defp cast_limit(number) when is_number(number), do: number + defp cast_limit("unlimited"), do: nil + defp cast_limit(binary) when is_binary(binary), do: String.to_integer(binary) + + defp put_if_not_nil(map, _key, nil), do: map + defp put_if_not_nil(map, key, value), do: Map.put(map, key, value) +end diff --git a/elixir/apps/domain/lib/domain/billing/stripe/api_client.ex b/elixir/apps/domain/lib/domain/billing/stripe/api_client.ex index 094fc06ea..2a0e4d6ce 100644 --- a/elixir/apps/domain/lib/domain/billing/stripe/api_client.ex +++ b/elixir/apps/domain/lib/domain/billing/stripe/api_client.ex @@ -25,12 +25,13 @@ defmodule Domain.Billing.Stripe.APIClient do [conn_opts: [transport_opts: transport_opts]] end - def create_customer(api_token, id, name) do + def create_customer(api_token, id, name, slug) do body = URI.encode_query( %{ "name" => name, - "metadata[account_id]" => id + "metadata[account_id]" => id, + "metadata[account_slug]" => slug }, :www_form ) @@ -38,6 +39,20 @@ defmodule Domain.Billing.Stripe.APIClient do request(api_token, :post, "customers", body) end + def update_customer(api_token, customer_id, id, name, slug) do + body = + URI.encode_query( + %{ + "name" => name, + "metadata[account_id]" => id, + "metadata[account_slug]" => slug + }, + :www_form + ) + + request(api_token, :post, "customers/#{customer_id}", body) + end + def fetch_customer(api_token, customer_id) do request(api_token, :get, "customers/#{customer_id}", "") end diff --git a/elixir/apps/domain/lib/domain/ops.ex b/elixir/apps/domain/lib/domain/ops.ex index 4a0153bb1..f69a703c5 100644 --- a/elixir/apps/domain/lib/domain/ops.ex +++ b/elixir/apps/domain/lib/domain/ops.ex @@ -11,7 +11,12 @@ defmodule Domain.Ops do {:ok, account} = Domain.Accounts.create_account(%{ name: account_name, - slug: account_slug + slug: account_slug, + metadata: %{ + stripe: %{ + billing_email: account_admin_email + } + } }) {:ok, account} = Domain.Billing.provision_account(account) diff --git a/elixir/apps/domain/priv/repo/seeds.exs b/elixir/apps/domain/priv/repo/seeds.exs index d530161d0..ecd5fd54d 100644 --- a/elixir/apps/domain/priv/repo/seeds.exs +++ b/elixir/apps/domain/priv/repo/seeds.exs @@ -40,7 +40,8 @@ account = stripe: %{ customer_id: "cus_PZKIfcHB6SSBA4", subscription_id: "sub_1OkGm2ADeNU9NGxvbrCCw6m3", - product_name: "Enterprise" + product_name: "Enterprise", + billing_email: "fin@firez.one" } }, limits: %{ diff --git a/elixir/apps/domain/test/domain/accounts_test.exs b/elixir/apps/domain/test/domain/accounts_test.exs index 8f49ddff1..a5864e372 100644 --- a/elixir/apps/domain/test/domain/accounts_test.exs +++ b/elixir/apps/domain/test/domain/accounts_test.exs @@ -223,7 +223,8 @@ defmodule Domain.AccountsTest do metadata: %{ stripe: %{ customer_id: "cus_1234567890", - subscription_id: "sub_1234567890" + subscription_id: "sub_1234567890", + billing_email: "foo@example.com" } }, config: %{ @@ -293,7 +294,12 @@ defmodule Domain.AccountsTest do describe "update_account_by_id/2.id" do setup do - account = Fixtures.Accounts.create_account(config: %{}) + account = + Fixtures.Accounts.create_account( + config: %{}, + metadata: %{stripe: %{customer_id: "cus_1234567890"}} + ) + %{account: account} end @@ -373,6 +379,9 @@ defmodule Domain.AccountsTest do end test "updates account and broadcasts a message", %{account: account} do + Bypass.open() + |> Domain.Mocks.Stripe.mock_update_customer_endpoint(account) + attrs = %{ name: Ecto.UUID.generate(), features: %{ @@ -385,7 +394,8 @@ defmodule Domain.AccountsTest do metadata: %{ stripe: %{ customer_id: "cus_1234567890", - subscription_id: "sub_1234567890" + subscription_id: "sub_1234567890", + billing_email: Fixtures.Auth.email() } }, config: %{ @@ -414,6 +424,9 @@ defmodule Domain.AccountsTest do assert account.metadata.stripe.subscription_id == attrs.metadata.stripe.subscription_id + assert account.metadata.stripe.billing_email == + attrs.metadata.stripe.billing_email + assert account.config.clients_upstream_dns == [ %Domain.Accounts.Config.ClientsUpstreamDNS{ protocol: :ip_port, diff --git a/elixir/apps/domain/test/domain/billing_test.exs b/elixir/apps/domain/test/domain/billing_test.exs index bb3d6f084..8fe9ae474 100644 --- a/elixir/apps/domain/test/domain/billing_test.exs +++ b/elixir/apps/domain/test/domain/billing_test.exs @@ -334,9 +334,17 @@ defmodule Domain.BillingTest do assert {:ok, account} = provision_account(account) assert account.metadata.stripe.customer_id == "cus_NffrFeUfNV2Hib" assert account.metadata.stripe.subscription_id == "sub_1MowQVLkdIwHu7ixeRlqHVzs" + assert account.metadata.stripe.billing_email == "foo@example.com" assert_receive {:bypass_request, %{request_path: "/v1/customers"} = conn} - assert conn.params == %{"name" => account.name, "metadata" => %{"account_id" => account.id}} + + assert conn.params == %{ + "name" => account.name, + "metadata" => %{ + "account_id" => account.id, + "account_slug" => account.slug + } + } end test "does nothing when account is already provisioned", %{account: account} do @@ -414,6 +422,247 @@ defmodule Domain.BillingTest do %{account: account, customer_id: customer_id} end + test "does nothing on customer.created event when metadata is incomplete" do + customer_id = "cus_" <> Ecto.UUID.generate() + + event = + Stripe.build_event( + "customer.created", + Stripe.customer_object( + customer_id, + "New Account Name", + "iown@bigcompany.com", + %{} + ) + ) + + assert handle_events([event]) == :ok + + assert Repo.aggregate(Domain.Accounts.Account, :count) == 1 + end + + test "syncs an account from stripe on customer.created event" do + Domain.Config.put_env_override(:outbound_email_adapter_configured?, true) + + customer_id = "cus_" <> Ecto.UUID.generate() + + Bypass.open() + |> Stripe.mock_update_customer_endpoint(%{ + id: Ecto.UUID.generate(), + name: "New Account Name", + metadata: %{stripe: %{customer_id: customer_id}} + }) + |> Stripe.mock_create_subscription_endpoint() + + event = + Stripe.build_event( + "customer.created", + Stripe.customer_object( + customer_id, + "New Account Name", + "iown@bigcompany.com", + %{ + "company_website" => "https://bigcompany.com", + "account_owner_first_name" => "John", + "account_owner_last_name" => "Smith" + } + ) + ) + + assert handle_events([event]) == :ok + + assert account = Repo.get_by(Domain.Accounts.Account, slug: "bigcompany") + assert account.metadata.stripe.customer_id == customer_id + assert account.metadata.stripe.billing_email == "iown@bigcompany.com" + assert account.metadata.stripe.subscription_id + + # Product name and subscription attributes will be synced from a separate webhook + # from Stripe that is sent when subscription is created or updated + refute account.metadata.stripe.product_name + + assert actor = Repo.get_by(Domain.Actors.Actor, account_id: account.id) + assert actor.name == "John Smith" + + assert identity = Repo.get_by(Domain.Auth.Identity, actor_id: actor.id) + assert identity.provider_identifier == "iown@bigcompany.com" + + assert_receive {:bypass_request, + %{request_path: "/v1/customers/" <> ^customer_id, params: params}} + + assert params == %{ + "metadata" => %{"account_id" => account.id, "account_slug" => account.slug}, + "name" => "New Account Name" + } + + assert_receive {:bypass_request, %{request_path: "/v1/subscriptions", params: params}} + + assert params == %{ + "customer" => customer_id, + "items" => %{"0" => %{"price" => "price_1OkUIcADeNU9NGxvTNA4PPq6"}} + } + end + + test "does nothing on customer.created event when an account already exists", %{ + account: account + } do + event = + Stripe.build_event( + "customer.created", + Stripe.customer_object( + account.metadata.stripe.customer_id, + "New Account Name", + "iown@bigcompany.com", + %{ + "company_website" => account.slug, + "account_owner_first_name" => "John", + "account_owner_last_name" => "Smith" + } + ) + ) + + assert handle_events([event]) == :ok + assert Repo.one(Domain.Accounts.Account) + end + + test "does nothing on customer.updated event when metadata is incomplete" do + customer_id = "cus_" <> Ecto.UUID.generate() + + customer_mock_attrs = %{ + id: Ecto.UUID.generate(), + name: "New Account Name", + metadata: %{stripe: %{customer_id: customer_id}} + } + + Bypass.open() + |> Stripe.mock_fetch_customer_endpoint(customer_mock_attrs, %{ + "metadata" => %{} + }) + + event = + Stripe.build_event( + "customer.updated", + Stripe.customer_object( + customer_id, + "New Account Name", + "iown@bigcompany.com", + %{} + ) + ) + + assert handle_events([event]) == :ok + + assert Repo.aggregate(Domain.Accounts.Account, :count) == 1 + end + + test "creates an account from stripe on customer.updated event for new accounts" do + Domain.Config.put_env_override(:outbound_email_adapter_configured?, true) + + customer_id = "cus_" <> Ecto.UUID.generate() + + customer_mock_attrs = %{ + id: Ecto.UUID.generate(), + name: "New Account Name", + metadata: %{stripe: %{customer_id: customer_id}} + } + + customer_metadata = %{ + "company_website" => "https://bigcompany.com", + "account_owner_first_name" => "John", + "account_owner_last_name" => "Smith" + } + + Bypass.open() + |> Stripe.mock_fetch_customer_endpoint(customer_mock_attrs, %{ + "metadata" => customer_metadata + }) + |> Stripe.mock_update_customer_endpoint(customer_mock_attrs) + |> Stripe.mock_create_subscription_endpoint() + + event = + Stripe.build_event( + "customer.updated", + Stripe.customer_object( + customer_id, + "New Account Name", + "iown@bigcompany.com", + customer_metadata + ) + ) + + assert handle_events([event]) == :ok + + assert account = Repo.get_by(Domain.Accounts.Account, slug: "bigcompany") + assert account.metadata.stripe.customer_id == customer_id + assert account.metadata.stripe.billing_email == "iown@bigcompany.com" + assert account.metadata.stripe.subscription_id + + # Product name and subscription attributes will be synced from a separate webhook + # from Stripe that is sent when subscription is created or updated + refute account.metadata.stripe.product_name + + assert actor = Repo.get_by(Domain.Actors.Actor, account_id: account.id) + assert actor.name == "John Smith" + + assert identity = Repo.get_by(Domain.Auth.Identity, actor_id: actor.id) + assert identity.provider_identifier == "iown@bigcompany.com" + + assert_receive {:bypass_request, + %{ + method: "POST", + request_path: "/v1/customers/" <> ^customer_id, + params: params + }} + + assert params == %{ + "metadata" => %{"account_id" => account.id, "account_slug" => account.slug}, + "name" => "New Account Name" + } + + assert_receive {:bypass_request, + %{ + method: "POST", + request_path: "/v1/subscriptions", + params: params + }} + + assert params == %{ + "customer" => customer_id, + "items" => %{"0" => %{"price" => "price_1OkUIcADeNU9NGxvTNA4PPq6"}} + } + end + + test "updates an account from stripe on customer.updated event", %{account: account} do + customer_metadata = %{ + "account_id" => account.id, + "account_slug" => "this_is_a_new_slug" + } + + Bypass.open() + |> Stripe.mock_fetch_customer_endpoint(account, %{ + "metadata" => customer_metadata + }) + |> Stripe.mock_update_customer_endpoint(account) + + event = + Stripe.build_event( + "customer.updated", + Stripe.customer_object( + account.metadata.stripe.customer_id, + "Updated Account Name", + "iown@bigcompany.com", + customer_metadata + ) + ) + + assert handle_events([event]) == :ok + + assert account = Repo.one(Domain.Accounts.Account) + assert account.name == "Updated Account Name" + assert account.slug == "this_is_a_new_slug" + + assert account.metadata.stripe.billing_email == "iown@bigcompany.com" + end + test "disables the account on when subscription is deleted", %{ account: account, customer_id: customer_id diff --git a/elixir/apps/domain/test/support/mocks/stripe.ex b/elixir/apps/domain/test/support/mocks/stripe.ex index 93caeb293..8ce91e7a1 100644 --- a/elixir/apps/domain/test/support/mocks/stripe.ex +++ b/elixir/apps/domain/test/support/mocks/stripe.ex @@ -12,37 +12,9 @@ defmodule Domain.Mocks.Stripe do resp = Map.merge( - %{ - "id" => "cus_NffrFeUfNV2Hib", - "object" => "customer", - "address" => nil, - "balance" => 0, - "created" => 1_680_893_993, - "currency" => nil, - "default_source" => nil, - "delinquent" => false, - "description" => nil, - "discount" => nil, - "email" => nil, - "invoice_prefix" => "0759376C", - "invoice_settings" => %{ - "custom_fields" => nil, - "default_payment_method" => nil, - "footer" => nil, - "rendering_options" => nil - }, - "livemode" => false, - "metadata" => %{ - "account_id" => account.id - }, - "name" => account.name, - "next_invoice_sequence" => 1, - "phone" => nil, - "preferred_locales" => [], - "shipping" => nil, - "tax_exempt" => "none", - "test_clock" => nil - }, + customer_object("cus_NffrFeUfNV2Hib", account.name, "foo@example.com", %{ + "account_id" => account.id + }), resp ) @@ -60,42 +32,39 @@ defmodule Domain.Mocks.Stripe do bypass end + def mock_update_customer_endpoint(bypass, account, resp \\ %{}) do + customer_endpoint_path = "v1/customers/#{account.metadata.stripe.customer_id}" + + resp = + Map.merge( + customer_object(account.metadata.stripe.customer_id, account.name, "foo@example.com", %{ + "account_id" => account.id + }), + resp + ) + + test_pid = self() + + Bypass.expect(bypass, "POST", customer_endpoint_path, fn conn -> + conn = Plug.Conn.fetch_query_params(conn) + conn = fetch_request_params(conn) + send(test_pid, {:bypass_request, conn}) + Plug.Conn.send_resp(conn, 200, Jason.encode!(resp)) + end) + + override_endpoint_url("http://localhost:#{bypass.port}") + + bypass + end + def mock_fetch_customer_endpoint(bypass, account, resp \\ %{}) do customer_endpoint_path = "v1/customers/#{account.metadata.stripe.customer_id}" resp = Map.merge( - %{ - "id" => "cus_NffrFeUfNV2Hib", - "object" => "customer", - "address" => nil, - "balance" => 0, - "created" => 1_680_893_993, - "currency" => nil, - "default_source" => nil, - "delinquent" => false, - "description" => nil, - "discount" => nil, - "email" => nil, - "invoice_prefix" => "0759376C", - "invoice_settings" => %{ - "custom_fields" => nil, - "default_payment_method" => nil, - "footer" => nil, - "rendering_options" => nil - }, - "livemode" => false, - "metadata" => %{ - "account_id" => account.id - }, - "name" => account.name, - "next_invoice_sequence" => 1, - "phone" => nil, - "preferred_locales" => [], - "shipping" => nil, - "tax_exempt" => "none", - "test_clock" => nil - }, + customer_object(account.metadata.stripe.customer_id, account.name, "foo@example.com", %{ + "account_id" => account.id + }), resp ) @@ -212,6 +181,38 @@ defmodule Domain.Mocks.Stripe do bypass end + def customer_object(id, name, email \\ nil, metadata \\ %{}) do + %{ + "id" => id, + "object" => "customer", + "address" => nil, + "balance" => 0, + "created" => 1_680_893_993, + "currency" => nil, + "default_source" => nil, + "delinquent" => false, + "description" => nil, + "discount" => nil, + "email" => email, + "invoice_prefix" => "0759376C", + "invoice_settings" => %{ + "custom_fields" => nil, + "default_payment_method" => nil, + "footer" => nil, + "rendering_options" => nil + }, + "livemode" => false, + "metadata" => metadata, + "name" => name, + "next_invoice_sequence" => 1, + "phone" => nil, + "preferred_locales" => [], + "shipping" => nil, + "tax_exempt" => "none", + "test_clock" => nil + } + end + def subscription_object(customer_id, subscription_metadata, plan_metadata, quantity) do %{ "id" => "sub_1MowQVLkdIwHu7ixeRlqHVzs", diff --git a/elixir/apps/web/lib/web/live/groups/edit.ex b/elixir/apps/web/lib/web/live/groups/edit.ex index 5887bd5f3..59221db7c 100644 --- a/elixir/apps/web/lib/web/live/groups/edit.ex +++ b/elixir/apps/web/lib/web/live/groups/edit.ex @@ -47,7 +47,7 @@ defmodule Web.Groups.Edit do <.form for={@form} phx-change={:change} phx-submit={:submit}>
Seats
diff --git a/elixir/apps/web/lib/web/live/settings/identity_providers/google_workspace/edit.ex b/elixir/apps/web/lib/web/live/settings/identity_providers/google_workspace/edit.ex index 57f5e6fbf..f6c30023f 100644 --- a/elixir/apps/web/lib/web/live/settings/identity_providers/google_workspace/edit.ex +++ b/elixir/apps/web/lib/web/live/settings/identity_providers/google_workspace/edit.ex @@ -29,7 +29,7 @@ defmodule Web.Settings.IdentityProviders.GoogleWorkspace.Edit do <.breadcrumb path={ ~p"/#{@account}/settings/identity_providers/google_workspace/#{@form.data}/edit" }> - Edit <%= # {@form.data.name} %> + Edit <.section> diff --git a/elixir/apps/web/lib/web/live/settings/identity_providers/microsoft_entra/edit.ex b/elixir/apps/web/lib/web/live/settings/identity_providers/microsoft_entra/edit.ex index 9d9a05d79..90bcded88 100644 --- a/elixir/apps/web/lib/web/live/settings/identity_providers/microsoft_entra/edit.ex +++ b/elixir/apps/web/lib/web/live/settings/identity_providers/microsoft_entra/edit.ex @@ -29,7 +29,7 @@ defmodule Web.Settings.IdentityProviders.MicrosoftEntra.Edit do <.breadcrumb path={ ~p"/#{@account}/settings/identity_providers/microsoft_entra/#{@form.data}/edit" }> - Edit <%= # {@form.data.name} %> + Edit <.section> diff --git a/elixir/apps/web/lib/web/live/settings/identity_providers/okta/edit.ex b/elixir/apps/web/lib/web/live/settings/identity_providers/okta/edit.ex index 5027c0f8c..52edba20f 100644 --- a/elixir/apps/web/lib/web/live/settings/identity_providers/okta/edit.ex +++ b/elixir/apps/web/lib/web/live/settings/identity_providers/okta/edit.ex @@ -27,7 +27,7 @@ defmodule Web.Settings.IdentityProviders.Okta.Edit do Identity Providers Settings <.breadcrumb path={~p"/#{@account}/settings/identity_providers/okta/#{@form.data}/edit"}> - Edit <%= # {@form.data.name} %> + Edit <.section> diff --git a/elixir/apps/web/lib/web/live/settings/identity_providers/openid_connect/edit.ex b/elixir/apps/web/lib/web/live/settings/identity_providers/openid_connect/edit.ex index 8ebf4a150..4f09f36ad 100644 --- a/elixir/apps/web/lib/web/live/settings/identity_providers/openid_connect/edit.ex +++ b/elixir/apps/web/lib/web/live/settings/identity_providers/openid_connect/edit.ex @@ -32,7 +32,7 @@ defmodule Web.Settings.IdentityProviders.OpenIDConnect.Edit do <.breadcrumb path={ ~p"/#{@account}/settings/identity_providers/openid_connect/#{@form.data}/edit" }> - Edit <%= # {@form.data.name} %> + Edit <.section> diff --git a/elixir/apps/web/lib/web/router.ex b/elixir/apps/web/lib/web/router.ex index daf5e8076..a245e3763 100644 --- a/elixir/apps/web/lib/web/router.ex +++ b/elixir/apps/web/lib/web/router.ex @@ -199,7 +199,11 @@ defmodule Web.Router do end scope "/settings", Settings do - live "/account", Account + scope "/account" do + live "/", Account + live "/edit", Account.Edit + end + live "/billing", Billing scope "/identity_providers", IdentityProviders do diff --git a/elixir/apps/web/mix.exs b/elixir/apps/web/mix.exs index 5c7c4cb66..364974908 100644 --- a/elixir/apps/web/mix.exs +++ b/elixir/apps/web/mix.exs @@ -62,6 +62,7 @@ defmodule Web.MixProject do {:observer_cli, "~> 1.7"}, # Mailer deps + {:multipart, "~> 0.4.0"}, {:phoenix_swoosh, "~> 1.0"}, {:gen_smtp, "~> 1.0"}, diff --git a/elixir/apps/web/test/web/live/settings/billing_test.exs b/elixir/apps/web/test/web/live/settings/billing_test.exs index 9b70bda9d..7647d202f 100644 --- a/elixir/apps/web/test/web/live/settings/billing_test.exs +++ b/elixir/apps/web/test/web/live/settings/billing_test.exs @@ -10,7 +10,8 @@ defmodule Web.Live.Settings.BillingTest do stripe: %{ customer_id: "cus_NffrFeUfNV2Hib", subscription_id: "sub_NffrFeUfNV2Hib", - product_name: "Enterprise" + product_name: "Enterprise", + billing_email: "foo@example.com" } }, limits: %{ @@ -72,6 +73,7 @@ defmodule Web.Live.Settings.BillingTest do |> render() |> vertical_table_to_map() + assert rows["billing email"] =~ account.metadata.stripe.billing_email assert rows["current plan"] =~ account.metadata.stripe.product_name assert rows["seats"] =~ "0 used / 100 allowed" assert rows["sites"] =~ "0 used / 10 allowed" diff --git a/elixir/mix.lock b/elixir/mix.lock index fc29a5286..30b8666bb 100644 --- a/elixir/mix.lock +++ b/elixir/mix.lock @@ -55,6 +55,7 @@ "mimerl": {:hex, :mimerl, "1.2.0", "67e2d3f571088d5cfd3e550c383094b47159f3eee8ffa08e64106cdf5e981be3", [:rebar3], [], "hexpm", "f278585650aa581986264638ebf698f8bb19df297f66ad91b18910dfc6e19323"}, "mint": {:hex, :mint, "1.5.2", "4805e059f96028948870d23d7783613b7e6b0e2fb4e98d720383852a760067fd", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "d77d9e9ce4eb35941907f1d3df38d8f750c357865353e21d335bdcdf6d892a02"}, "mix_audit": {:hex, :mix_audit, "2.1.2", "6cd5c5e2edbc9298629c85347b39fb3210656e541153826efd0b2a63767f3395", [:make, :mix], [{:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: false]}, {:yaml_elixir, "~> 2.9", [hex: :yaml_elixir, repo: "hexpm", optional: false]}], "hexpm", "68d2f06f96b9c445a23434c9d5f09682866a5b4e90f631829db1c64f140e795b"}, + "multipart": {:hex, :multipart, "0.4.0", "634880a2148d4555d050963373d0e3bbb44a55b2badd87fa8623166172e9cda0", [:mix], [{:mime, "~> 1.2 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}], "hexpm", "3c5604bc2fb17b3137e5d2abdf5dacc2647e60c5cc6634b102cf1aef75a06f0a"}, "nimble_csv": {:hex, :nimble_csv, "1.2.0", "4e26385d260c61eba9d4412c71cea34421f296d5353f914afe3f2e71cce97722", [:mix], [], "hexpm", "d0628117fcc2148178b034044c55359b26966c6eaa8e2ce15777be3bbc91b12a"}, "nimble_options": {:hex, :nimble_options, "1.1.0", "3b31a57ede9cb1502071fade751ab0c7b8dbe75a9a4c2b5bbb0943a690b63172", [:mix], [], "hexpm", "8bbbb3941af3ca9acc7835f5655ea062111c9c27bcac53e004460dfd19008a99"}, "nimble_pool": {:hex, :nimble_pool, "1.0.0", "5eb82705d138f4dd4423f69ceb19ac667b3b492ae570c9f5c900bb3d2f50a847", [:mix], [], "hexpm", "80be3b882d2d351882256087078e1b1952a28bf98d0a287be87e4a24a710b67a"},