mirror of
https://github.com/outbackdingo/firezone.git
synced 2026-01-27 10:18:54 +00:00
feat(portal): Sync accounts between stripe and portal (#4173)
Closes #3888
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
373
elixir/apps/domain/lib/domain/billing/event_handler.ex
Normal file
373
elixir/apps/domain/lib/domain/billing/event_handler.ex
Normal file
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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: %{
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -47,7 +47,7 @@ defmodule Web.Groups.Edit do
|
||||
<.form for={@form} phx-change={:change} phx-submit={:submit}>
|
||||
<div class="grid gap-4 mb-4 sm:grid-cols-1 sm:gap-6 sm:mb-6">
|
||||
<div>
|
||||
<.input label="Name" field={@form[:name]} placeholder="Full Name" required />
|
||||
<.input label="Name" field={@form[:name]} placeholder="Group Name" required />
|
||||
</div>
|
||||
</div>
|
||||
<.submit_button>
|
||||
|
||||
@@ -21,6 +21,11 @@ defmodule Web.Settings.Account do
|
||||
<:title>
|
||||
Account Settings
|
||||
</:title>
|
||||
<:action>
|
||||
<.edit_button navigate={~p"/#{@account}/settings/account/edit"}>
|
||||
Edit Account
|
||||
</.edit_button>
|
||||
</:action>
|
||||
<:content>
|
||||
<.vertical_table id="account">
|
||||
<.vertical_table_row>
|
||||
|
||||
67
elixir/apps/web/lib/web/live/settings/account/edit.ex
Normal file
67
elixir/apps/web/lib/web/live/settings/account/edit.ex
Normal file
@@ -0,0 +1,67 @@
|
||||
defmodule Web.Settings.Account.Edit do
|
||||
use Web, :live_view
|
||||
alias Domain.Accounts
|
||||
|
||||
def mount(_params, _session, socket) do
|
||||
changeset = Accounts.change_account(socket.assigns.account)
|
||||
|
||||
socket =
|
||||
assign(socket,
|
||||
page_title: "Edit Account",
|
||||
form: to_form(changeset)
|
||||
)
|
||||
|
||||
{:ok, socket, temporary_assigns: [form: %Phoenix.HTML.Form{}]}
|
||||
end
|
||||
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<.breadcrumbs account={@account}>
|
||||
<.breadcrumb path={~p"/#{@account}/settings/account"}>
|
||||
Account Settings
|
||||
</.breadcrumb>
|
||||
<.breadcrumb path={~p"/#{@account}/settings/account/edit"}>
|
||||
Edit
|
||||
</.breadcrumb>
|
||||
</.breadcrumbs>
|
||||
<.section>
|
||||
<:title>
|
||||
Edit Identity Provider <%= @form.data.name %>
|
||||
</:title>
|
||||
<:content>
|
||||
<div class="max-w-2xl px-4 py-8 mx-auto lg:py-16">
|
||||
<h2 class="mb-4 text-xl text-neutral-900">Edit group details</h2>
|
||||
<.form for={@form} phx-change={:change} phx-submit={:submit}>
|
||||
<div class="grid gap-4 mb-4 sm:grid-cols-1 sm:gap-6 sm:mb-6">
|
||||
<div>
|
||||
<.input label="Name" field={@form[:name]} placeholder="Account Name" required />
|
||||
</div>
|
||||
</div>
|
||||
<.submit_button>
|
||||
Save
|
||||
</.submit_button>
|
||||
</.form>
|
||||
</div>
|
||||
</:content>
|
||||
</.section>
|
||||
"""
|
||||
end
|
||||
|
||||
def handle_event("change", %{"account" => attrs}, socket) do
|
||||
changeset =
|
||||
Accounts.change_account(socket.assigns.account, attrs)
|
||||
|> Map.put(:action, :insert)
|
||||
|
||||
{:noreply, assign(socket, form: to_form(changeset))}
|
||||
end
|
||||
|
||||
def handle_event("submit", %{"account" => attrs}, socket) do
|
||||
with {:ok, _account} <-
|
||||
Accounts.update_account(socket.assigns.account, attrs, socket.assigns.subject) do
|
||||
{:noreply, push_navigate(socket, to: ~p"/#{socket.assigns.account}/settings/account")}
|
||||
else
|
||||
{:error, changeset} ->
|
||||
{:noreply, assign(socket, form: to_form(changeset))}
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -76,6 +76,13 @@ defmodule Web.Settings.Billing do
|
||||
</:value>
|
||||
</.vertical_table_row>
|
||||
|
||||
<.vertical_table_row>
|
||||
<:label>Billing Email</:label>
|
||||
<:value>
|
||||
<%= @account.metadata.stripe.billing_email %>
|
||||
</:value>
|
||||
</.vertical_table_row>
|
||||
|
||||
<.vertical_table_row :if={not is_nil(@account.limits.monthly_active_users_count)}>
|
||||
<:label>
|
||||
<p>Seats</p>
|
||||
|
||||
@@ -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
|
||||
</.breadcrumb>
|
||||
</.breadcrumbs>
|
||||
<.section>
|
||||
|
||||
@@ -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
|
||||
</.breadcrumb>
|
||||
</.breadcrumbs>
|
||||
<.section>
|
||||
|
||||
@@ -27,7 +27,7 @@ defmodule Web.Settings.IdentityProviders.Okta.Edit do
|
||||
Identity Providers Settings
|
||||
</.breadcrumb>
|
||||
<.breadcrumb path={~p"/#{@account}/settings/identity_providers/okta/#{@form.data}/edit"}>
|
||||
Edit <%= # {@form.data.name} %>
|
||||
Edit
|
||||
</.breadcrumb>
|
||||
</.breadcrumbs>
|
||||
<.section>
|
||||
|
||||
@@ -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
|
||||
</.breadcrumb>
|
||||
</.breadcrumbs>
|
||||
<.section>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"},
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"},
|
||||
|
||||
Reference in New Issue
Block a user