feat(portal): Sync accounts between stripe and portal (#4173)

Closes #3888
This commit is contained in:
Andrew Dryga
2024-03-20 16:32:07 -06:00
committed by GitHub
parent ada9d896cf
commit 8195ac1893
24 changed files with 1022 additions and 337 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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()

View File

@@ -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)

View 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

View File

@@ -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

View File

@@ -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)

View File

@@ -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: %{

View File

@@ -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,

View File

@@ -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

View File

@@ -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",

View File

@@ -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>

View File

@@ -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>

View 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

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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

View File

@@ -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"},

View File

@@ -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"

View File

@@ -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"},